From 1f8d5b658a9fcae15be192af8c82ab557f88a046 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 19 Sep 2026 13:11:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=80=9F=E6=AC=BE=E3=80=81?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=E3=80=81=E4=BB=98=E6=AC=BE=E4=B8=8E=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E5=B9=B6=E5=8F=91=E5=8F=8A=E9=AA=8C=E6=94=B6=EF=BC=88?= =?UTF-8?q?09-06-release-gate-integrity=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。 --- .trellis/spec/hyb-oa/approval-workflow.md | 19 + .../spec/hyb-oa/backend-fastify-prisma.md | 171 ++- .trellis/spec/hyb-oa/deployment-trialrun.md | 150 +- .../spec/hyb-oa/frontend-react-vite-design.md | 33 +- .../design.md | 105 ++ .../implement.md | 302 ++++ .../prd.md | 53 +- .../task.json | 22 +- .../implement.md | 64 + .../design.md | 10 + .../implement.md | 62 + .../prd.md | 6 + .../task.json | 2 +- .../design.md | 143 ++ .../implement.md | 657 ++++++++ .../prd.md | 83 + .../task.json | 2 +- .../09-06-release-gate-integrity/design.md | 199 +++ .../09-06-release-gate-integrity/implement.md | 608 ++++++++ .../tasks/09-06-release-gate-integrity/prd.md | 58 +- .../09-06-release-gate-integrity/task.json | 157 +- .../implement.md | 8 + .../09-07-manager-ap-access-scope/task.json | 2 +- .../design.md | 10 + .../implement.md | 663 +++++++- .../prd.md | 72 + .../task.json | 32 +- .../implement.md | 8 + .../task.json | 2 +- .../design.md | 111 ++ .../implement.md | 95 ++ .../prd.md | 76 + .../task.json | 26 + .../09-17-erp-exclusive-retirement/design.md | 7 + .../implement.md | 19 + .../09-17-erp-exclusive-retirement/prd.md | 36 + .../09-17-erp-exclusive-retirement/task.json | 26 + .../check.jsonl | 1 + .../design.md | 91 ++ .../implement.jsonl | 2 + .../implement.md | 106 ++ .../prd.md | 43 + .../task.json | 58 + .../design.md | 112 ++ .../implement.md | 128 ++ .../prd.md | 46 + .../task.json | 39 + .../09-19-budget-pool-concurrency/design.md | 56 + .../implement.md | 56 + .../09-19-budget-pool-concurrency/prd.md | 48 + .../09-19-budget-pool-concurrency/task.json | 80 + client/src/api/assets.ts | 93 -- client/src/api/companyLoans.ts | 51 + client/src/api/errorReasons.ts | 622 ++++++++ client/src/api/integrations.ts | 107 -- client/src/api/paymentBatches.ts | 4 +- client/src/api/purchaseRequests.ts | 103 -- client/src/api/travelPayments.ts | 34 - client/src/api/travelReconciliation.ts | 91 -- client/src/auth/permissions.ts | 2 + .../components/common/LoanPaymentDetails.tsx | 64 + client/src/components/navigation/NavLinks.tsx | 3 +- .../travel/TravelPaymentTrackCard.tsx | 12 - .../travel/TravelReconciliationTab.tsx | 66 +- client/src/features/navigation/schema.ts | 4 +- client/src/lib/runtimeContext.ts | 4 + client/src/lib/testIds.ts | 1 - client/src/pages/AnnouncementsPage.tsx | 5 +- client/src/pages/AuditLogsPage.tsx | 20 +- client/src/pages/CompanyLoansPage.tsx | 187 +++ client/src/pages/InvoicePoolPage.tsx | 38 +- client/src/pages/MasterDataPage.tsx | 3 + client/src/pages/MobileMePage.tsx | 1 + client/src/pages/PaymentBatchesPage.tsx | 9 +- client/src/pages/PortalAnnouncementsPage.tsx | 7 +- client/src/pages/WorkflowDefinitionsPage.tsx | 8 +- client/src/pages/WorkspaceSetupPage.tsx | 1 + .../ReimbursementApprovalAdapter.tsx | 15 +- client/src/pwa.ts | 109 +- client/src/router/index.tsx | 4 + client/src/router/lazy/CompanyLoansPage.ts | 1 + client/src/router/lazy/index.ts | 1 + .../e2e/announcements-policy-ack.spec.ts | 25 +- .../tests/e2e/announcements-presence.spec.ts | 2 +- .../tests/e2e/biz8-ap-ledger-project.spec.ts | 1 + .../e2e/dg7-mobile-information-dedupe.spec.ts | 153 +- client/tests/e2e/helpers/auth.ts | 7 +- .../pageObjects/ReimbursementDetailPage.ts | 60 +- .../e2e/helpers/reimbursementPaymentFlow.ts | 254 ++++ client/tests/e2e/helpers/visual-coverage.ts | 28 + .../interaction-pattern-unification.spec.ts | 14 +- client/tests/e2e/invoice-ocr-flow.spec.ts | 47 +- client/tests/e2e/leave-flow.spec.ts | 4 +- .../e2e/local-candidate-witnesses.spec.ts | 5 + client/tests/e2e/main-flow.spec.ts | 180 --- .../e2e/mobile-approvals-offline.spec.ts | 138 +- .../e2e/payment-reviewer-permissions.spec.ts | 152 +- .../e2e/pc-detail-mobile-visual-wave3.spec.ts | 6 +- .../e2e/pc-finance-admin-visual-wave4.spec.ts | 11 +- .../e2e/permission-direct-access.spec.ts | 251 +-- client/tests/e2e/procurement-flow.spec.ts | 21 + client/tests/e2e/project-wide-visual.spec.ts | 369 ++++- client/tests/e2e/project-workbench.spec.ts | 17 +- .../e2e/pwa-cross-version-upgrade.spec.ts | 113 +- client/tests/e2e/r100-design-baseline.spec.ts | 2 +- .../e2e/r101-mobile-wip-closeout.spec.ts | 6 +- .../e2e/r102-reimbursement-main-flow.spec.ts | 8 +- .../r103-cross-page-ui-consistency.spec.ts | 14 +- client/tests/e2e/r106-visual-audit.spec.ts | 8 +- .../e2e/r108-mobile-wecom-visual.spec.ts | 132 +- ...r109-mobile-reimbursement-feedback.spec.ts | 25 +- .../tests/e2e/r110-mobile-pc-routing.spec.ts | 3 +- .../e2e/r124-mobile-entry-matrix.spec.ts | 2 +- ...r125-invoice-reupload-after-delete.spec.ts | 49 +- .../r6-mobile-unified-design-rollout.spec.ts | 2 +- .../tests/e2e/r75-automation-gap-fill.spec.ts | 31 +- .../e2e/r89-mobile-login-navigation.spec.ts | 2 +- .../e2e/r92-mobile-invoice-usability.spec.ts | 17 +- .../reimbursement-payment-batch-flow.spec.ts | 275 +--- .../reimbursement-workflow-progress.spec.ts | 20 +- .../e2e/travel-freeze-protection.spec.ts | 45 + client/tests/e2e/user-onboarding.spec.ts | 3 + client/tests/e2e/visual-shell.spec.ts | 6 +- client/tests/e2e/weekly-todo-flow.spec.ts | 1 + .../e2e/workflow-action-confirm-ui.spec.ts | 82 + client/vite.config.ts | 1 + docs/trialrun/项目级流程覆盖矩阵.csv | 4 +- docs/报销发票附件规则.md | 8 + docs/试运行与自动化验证手册.md | 31 + docs/需求-统一审批中心规约.md | 4 +- scripts/qa/browser-environment-probe.mjs | 21 +- scripts/qa/budget-pool-db-fixture.cjs | 151 ++ scripts/qa/budget-pool-http-fixture.cjs | 201 +++ scripts/qa/business-numbering-db-fixture.cjs | 209 +++ .../qa/check-sensitive-recovery-contract.cjs | 91 ++ scripts/qa/company-loan-browser.cjs | 102 ++ scripts/qa/company-loan-http-fixture.cjs | 331 ++++ scripts/qa/error-reason-legacy-baseline.json | 594 +------- scripts/qa/full-local-coverage-contract.json | 232 +++ scripts/qa/full-local-coverage.mjs | 130 ++ scripts/qa/full-local-diagnostic.mjs | 9 + scripts/qa/full-local-environment-inner.sh | 5 + scripts/qa/full-local-environment.mjs | 24 +- scripts/qa/prepare-local-e2e-accounts.mjs | 82 + scripts/qa/pwa-cross-version-fixture.sh | 2 +- scripts/qa/qa-stage-record.mjs | 68 + scripts/qa/release-gate-result.mjs | 166 ++ scripts/qa/route-manifest.json | 6 +- scripts/qa/run-release-gate.sh | 48 +- scripts/qa/run-web-gates.sh | 241 ++- scripts/qa/test-budget-pool-db.cjs | 77 + scripts/qa/test-business-numbering-db.cjs | 77 + scripts/qa/test-company-loan-db.cjs | 178 +++ scripts/qa/tests/full-local-coverage.test.mjs | 68 + .../qa/tests/full-local-environment.test.mjs | 22 +- .../tests/prepare-local-e2e-accounts.test.mjs | 42 + .../qa/tests/pwa-full-local-inputs.test.mjs | 157 +- scripts/qa/tests/release-gate-result.test.mjs | 139 ++ scripts/qa/tests/release-gate-shell.test.mjs | 135 ++ .../tests/workflow-payment-profile.test.mjs | 47 + .../migration.sql | 339 +++++ .../migration.sql | 51 + server/prisma/schema.prisma | 170 +++ server/scripts/regression-assets.sh | 2 +- server/scripts/regression-im-notifier.sh | 6 +- server/scripts/regression-workflow-actions.sh | 194 +-- server/src/app.js | 2 + server/src/config.js | 21 - server/src/constants/advisoryLocks.js | 2 + server/src/constants/errorReasons.js | 622 ++++++++ server/src/constants/permissions.js | 2 + server/src/jobs/erpMasterDataSyncJob.js | 91 -- server/src/jobs/jobSystem.js | 2 - server/src/jobs/paymentReceiptOcrJob.js | 12 +- server/src/jobs/pgBossJobs.js | 48 - server/src/plugins/auth.js | 8 +- server/src/routes/ai.js | 17 +- server/src/routes/announcements.js | 33 +- server/src/routes/apOperations.js | 20 +- server/src/routes/approvalRules.js | 50 +- server/src/routes/assetMovements.js | 39 +- server/src/routes/assets.js | 291 +--- server/src/routes/attendance.js | 60 +- server/src/routes/attendanceCorrections.js | 3 +- server/src/routes/audit.js | 16 +- server/src/routes/budgetControlRules.js | 27 +- server/src/routes/budgets.js | 41 +- server/src/routes/companyLoans.js | 301 ++++ server/src/routes/confidentialDocRefs.js | 18 +- server/src/routes/configRead.js | 17 +- server/src/routes/contractsRegistry.js | 40 +- server/src/routes/cutover.js | 13 +- server/src/routes/dashboard.js | 21 +- server/src/routes/delegations.js | 142 +- server/src/routes/departments.js | 99 +- server/src/routes/employees.js | 124 +- server/src/routes/expenseStandards.js | 57 +- server/src/routes/expenseTypes.js | 50 +- server/src/routes/exports.js | 104 +- server/src/routes/featureFlags.js | 61 +- server/src/routes/feeControl.js | 12 +- server/src/routes/integrations.js | 1352 +---------------- server/src/routes/inventoryChecks.js | 62 +- server/src/routes/leave-types.js | 27 +- server/src/routes/leave.js | 3 +- server/src/routes/masterData.js | 97 +- .../src/routes/masterDataHolidayCalendars.js | 46 +- server/src/routes/masterDataSuppliers.js | 30 +- server/src/routes/migrationRuns.js | 10 +- server/src/routes/notifications.js | 22 +- server/src/routes/paymentBatches.js | 270 +++- server/src/routes/payroll.js | 67 +- server/src/routes/performance.js | 41 +- server/src/routes/portal.js | 12 +- server/src/routes/presence.js | 31 +- server/src/routes/projects.js | 211 +-- server/src/routes/purchaseRequests.js | 376 ++--- server/src/routes/reimbursementTemplates.js | 32 +- server/src/routes/reimbursements.js | 105 +- server/src/routes/riskFlags.js | 18 +- server/src/routes/search.js | 9 +- server/src/routes/systemWorkspace.js | 8 +- server/src/routes/todos.js | 18 +- server/src/routes/travelAuthorizations.js | 18 +- server/src/routes/travelOrders.js | 94 +- server/src/routes/travelReconciliation.js | 133 +- server/src/routes/travelRequests.js | 81 +- server/src/routes/travelTransactions.js | 32 +- server/src/routes/users.js | 293 ++-- server/src/routes/wecomAuth.js | 32 +- server/src/routes/weeklyReports.js | 28 +- server/src/routes/workflow.js | 105 -- server/src/routes/workflowDefinitions.js | 27 +- server/src/scripts/import-project-ledgers.js | 27 +- .../src/scripts/manage-business-numbering.js | 36 + server/src/services/ai/provider.js | 2 +- server/src/services/ai/suggestions.js | 72 +- server/src/services/attendance.js | 28 +- server/src/services/budgetPool.js | 57 +- server/src/services/businessNumbering.js | 153 ++ server/src/services/companyLoanPayments.js | 232 +++ server/src/services/companyLoans.js | 149 ++ .../src/services/contractRegistryFilters.js | 22 +- server/src/services/cutover.js | 11 +- server/src/services/documentSequence.js | 44 +- .../src/services/employeeDirectoryFilters.js | 19 +- server/src/services/featureFlags/index.js | 5 - server/src/services/feeControl.js | 91 +- .../src/services/integrations/dispatcher.js | 231 +-- server/src/services/integrations/erp.js | 420 ----- server/src/services/integrations/erpAsset.js | 552 ------- .../src/services/integrations/erpJournal.js | 348 ----- .../services/integrations/erpMasterData.js | 534 ------- .../services/integrations/erpProcurement.js | 704 --------- server/src/services/integrations/erpTravel.js | 333 ---- server/src/services/integrations/outbox.js | 2 + .../services/integrations/wecomHrMaster.js | 29 +- server/src/services/paymentOrderXls.js | 8 +- .../paymentReceiptManualPersistence.js | 40 +- .../services/paymentReceiptManualReview.js | 3 +- .../services/paymentReceiptVerification.js | 5 +- server/src/services/paymentResponsibility.js | 61 + server/src/services/presenceHistory.js | 16 +- server/src/services/purchaseBudget.js | 27 +- server/src/services/travelPayments.js | 42 +- server/src/services/travelReconciliation.js | 22 +- server/src/services/workspaceSettings.js | 6 +- server/src/utils/workflow.js | 114 +- .../unit/businessNumbering.service.test.js | 164 ++ .../unit/documentSequence.service.test.js | 4 +- .../tests/unit/featureFlags.evaluate.test.js | 54 +- .../fixtures/deployment-command-client.py | 36 + .../unit/fixtures/deployment-command-fake.cjs | 117 +- .../unit/fixtures/error-reason-messages.json | 624 +++++++- server/tests/unit/helpers/activeNumbering.js | 21 + .../unit/integrations.dispatcher.test.js | 243 --- server/tests/unit/routes/ai.test.js | 4 + .../tests/unit/routes/announcements.test.js | 104 +- .../routes/apOperations.permissions.test.js | 32 +- .../routes/approval-config.errors.test.js | 59 + .../routes/asset-operations.errors.test.js | 121 ++ server/tests/unit/routes/assets.erp.test.js | 148 -- .../tests/unit/routes/assets.errors.test.js | 339 +++++ .../unit/routes/attendance.errors.test.js | 64 + server/tests/unit/routes/attendance.test.js | 42 + .../unit/routes/auth-entity.errors.test.js | 21 + .../unit/routes/budget-control.errors.test.js | 137 ++ .../routes/budget-performance.errors.test.js | 262 ++++ .../unit/routes/contractsRegistry.test.js | 168 ++ .../unit/routes/dashboard.calendar.test.js | 61 + .../routes/date-consumers.calendar.test.js | 92 ++ .../unit/routes/delegations.errors.test.js | 79 + .../unit/routes/departments.errors.test.js | 95 ++ .../unit/routes/employees.attachments.test.js | 52 +- .../unit/routes/employees.calendar.test.js | 202 +++ .../unit/routes/expenseStandards.test.js | 6 +- .../unit/routes/exports.apOperations.test.js | 34 + .../routes/exports.contractsRegistry.test.js | 47 + .../routes/exports.dashboard.calendar.test.js | 70 + .../unit/routes/exports.employees.test.js | 56 + .../unit/routes/exports.presence.test.js | 41 +- .../unit/routes/integrations.bindings.test.js | 2 + .../routes/integrations.erp.asset.test.js | 139 -- .../integrations.erp.procurement.test.js | 59 - .../unit/routes/integrations.erp.test.js | 22 - .../routes/integrations.erp.travel.test.js | 331 ---- .../unit/routes/integrations.outbox.test.js | 27 +- .../unit/routes/master-data.errors.test.js | 153 ++ ...asterDataHolidayCalendars.calendar.test.js | 156 ++ .../tests/unit/routes/notifications.test.js | 53 + .../paymentBatches.reimbursement.test.js | 107 +- .../tests/unit/routes/payroll.exports.test.js | 15 + .../tests/unit/routes/payroll.items.test.js | 29 + .../unit/routes/payroll.payslips.test.js | 104 ++ .../routes/payroll.salary-profile.test.js | 44 + .../unit/routes/performance.cycles.test.js | 14 + server/tests/unit/routes/portal.test.js | 13 + .../tests/unit/routes/presence.admin.test.js | 51 + .../unit/routes/presence.history.test.js | 34 + server/tests/unit/routes/projects.test.js | 107 ++ .../routes/purchase-request.errors.test.js | 302 ++++ .../unit/routes/purchase.archive-erp.test.js | 130 -- .../unit/routes/purchase.attachments.test.js | 53 +- .../unit/routes/purchase.project-link.test.js | 25 +- .../purchase.submit-budget-precheck.test.js | 40 +- .../unit/routes/read-export.errors.test.js | 35 + .../routes/reference-settings.errors.test.js | 106 ++ .../unit/routes/reimbursements.create.test.js | 16 +- .../reimbursements.expense-standards.test.js | 43 +- .../routes/system-contract.errors.test.js | 77 + .../tests/unit/routes/todos.calendar.test.js | 75 + server/tests/unit/routes/todos.errors.test.js | 70 + .../routes/travel-execution.errors.test.js | 75 + .../unit/routes/travel-request.errors.test.js | 168 ++ .../unit/routes/travelAuthorizations.test.js | 1 + server/tests/unit/routes/travelOrders.test.js | 65 +- .../unit/routes/travelReconciliation.test.js | 92 +- .../unit/routes/travelTransactions.test.js | 7 + server/tests/unit/routes/users.import.test.js | 321 +++- server/tests/unit/routes/users.list.test.js | 61 + .../users.sensitive-permissions.test.js | 22 +- server/tests/unit/routes/users.write.test.js | 212 ++- server/tests/unit/routes/wecomAuth.test.js | 32 +- .../routes/weeklyReports.calendar.test.js | 72 + .../unit/routes/workflow.purchase-erp.test.js | 114 -- .../scripts/deployment-db-lifecycle.test.js | 34 +- .../unit/scripts/error-reason-client.test.js | 9 +- .../unit/services/ai.suggestions.test.js | 1 + .../unit/services/budgetPool.locking.test.js | 27 + server/tests/unit/services/erpAsset.test.js | 138 -- server/tests/unit/services/erpJournal.test.js | 80 - .../unit/services/erpJournal.travel.test.js | 121 -- .../tests/unit/services/erpMasterData.test.js | 145 -- .../unit/services/erpProcurement.test.js | 144 -- server/tests/unit/services/erpTravel.test.js | 170 --- .../services/integrations/dispatcher.test.js | 19 +- .../unit/services/integrations/erp.test.js | 237 --- .../unit/services/integrations/outbox.test.js | 7 + .../unit/services/paymentOrderXls.test.js | 27 + .../services/paymentResponsibility.test.js | 43 + .../tests/unit/services/payroll.month.test.js | 17 + .../unit/services/purchaseBudget.test.js | 66 +- .../reimbursementPrecheck.snapshot.test.js | 109 ++ .../sensitiveRecoveryContract.test.js | 145 ++ .../unit/utils/client-pwa-lifecycle.test.js | 111 ++ server/tests/unit/utils/workflow.test.js | 22 + .../tests/unit/wecomHrMaster.calendar.test.js | 129 ++ 367 files changed, 21079 insertions(+), 12287 deletions(-) create mode 100644 .trellis/tasks/09-17-company-loan-principal-repayment/design.md create mode 100644 .trellis/tasks/09-17-company-loan-principal-repayment/implement.md create mode 100644 .trellis/tasks/09-17-company-loan-principal-repayment/prd.md create mode 100644 .trellis/tasks/09-17-company-loan-principal-repayment/task.json create mode 100644 .trellis/tasks/09-17-erp-exclusive-retirement/design.md create mode 100644 .trellis/tasks/09-17-erp-exclusive-retirement/implement.md create mode 100644 .trellis/tasks/09-17-erp-exclusive-retirement/prd.md create mode 100644 .trellis/tasks/09-17-erp-exclusive-retirement/task.json create mode 100644 .trellis/tasks/09-18-local-concurrency-product-fixes/check.jsonl create mode 100644 .trellis/tasks/09-18-local-concurrency-product-fixes/design.md create mode 100644 .trellis/tasks/09-18-local-concurrency-product-fixes/implement.jsonl create mode 100644 .trellis/tasks/09-18-local-concurrency-product-fixes/implement.md create mode 100644 .trellis/tasks/09-18-local-concurrency-product-fixes/prd.md create mode 100644 .trellis/tasks/09-18-local-concurrency-product-fixes/task.json create mode 100644 .trellis/tasks/09-18-reimbursement-periodic-numbering/design.md create mode 100644 .trellis/tasks/09-18-reimbursement-periodic-numbering/implement.md create mode 100644 .trellis/tasks/09-18-reimbursement-periodic-numbering/prd.md create mode 100644 .trellis/tasks/09-18-reimbursement-periodic-numbering/task.json create mode 100644 .trellis/tasks/09-19-budget-pool-concurrency/design.md create mode 100644 .trellis/tasks/09-19-budget-pool-concurrency/implement.md create mode 100644 .trellis/tasks/09-19-budget-pool-concurrency/prd.md create mode 100644 .trellis/tasks/09-19-budget-pool-concurrency/task.json create mode 100644 client/src/api/companyLoans.ts create mode 100644 client/src/components/common/LoanPaymentDetails.tsx create mode 100644 client/src/pages/CompanyLoansPage.tsx create mode 100644 client/src/router/lazy/CompanyLoansPage.ts create mode 100644 client/tests/e2e/helpers/reimbursementPaymentFlow.ts create mode 100644 client/tests/e2e/helpers/visual-coverage.ts delete mode 100644 client/tests/e2e/main-flow.spec.ts create mode 100644 client/tests/e2e/travel-freeze-protection.spec.ts create mode 100644 scripts/qa/budget-pool-db-fixture.cjs create mode 100644 scripts/qa/budget-pool-http-fixture.cjs create mode 100644 scripts/qa/business-numbering-db-fixture.cjs create mode 100644 scripts/qa/check-sensitive-recovery-contract.cjs create mode 100644 scripts/qa/company-loan-browser.cjs create mode 100644 scripts/qa/company-loan-http-fixture.cjs create mode 100644 scripts/qa/full-local-coverage-contract.json create mode 100644 scripts/qa/full-local-coverage.mjs create mode 100644 scripts/qa/prepare-local-e2e-accounts.mjs create mode 100644 scripts/qa/qa-stage-record.mjs create mode 100644 scripts/qa/release-gate-result.mjs create mode 100644 scripts/qa/test-budget-pool-db.cjs create mode 100644 scripts/qa/test-business-numbering-db.cjs create mode 100644 scripts/qa/test-company-loan-db.cjs create mode 100644 scripts/qa/tests/full-local-coverage.test.mjs create mode 100644 scripts/qa/tests/prepare-local-e2e-accounts.test.mjs create mode 100644 scripts/qa/tests/release-gate-result.test.mjs create mode 100644 scripts/qa/tests/release-gate-shell.test.mjs create mode 100644 scripts/qa/tests/workflow-payment-profile.test.mjs create mode 100644 server/prisma/migrations/20260917010000_company_loan_principal/migration.sql create mode 100644 server/prisma/migrations/20260918160000_business_numbering/migration.sql delete mode 100644 server/src/jobs/erpMasterDataSyncJob.js create mode 100644 server/src/routes/companyLoans.js create mode 100644 server/src/scripts/manage-business-numbering.js create mode 100644 server/src/services/businessNumbering.js create mode 100644 server/src/services/companyLoanPayments.js create mode 100644 server/src/services/companyLoans.js delete mode 100644 server/src/services/integrations/erp.js delete mode 100644 server/src/services/integrations/erpAsset.js delete mode 100644 server/src/services/integrations/erpJournal.js delete mode 100644 server/src/services/integrations/erpMasterData.js delete mode 100644 server/src/services/integrations/erpProcurement.js delete mode 100644 server/src/services/integrations/erpTravel.js create mode 100644 server/src/services/paymentResponsibility.js create mode 100644 server/tests/unit/businessNumbering.service.test.js create mode 100644 server/tests/unit/fixtures/deployment-command-client.py create mode 100644 server/tests/unit/helpers/activeNumbering.js create mode 100644 server/tests/unit/routes/approval-config.errors.test.js create mode 100644 server/tests/unit/routes/asset-operations.errors.test.js delete mode 100644 server/tests/unit/routes/assets.erp.test.js create mode 100644 server/tests/unit/routes/assets.errors.test.js create mode 100644 server/tests/unit/routes/attendance.errors.test.js create mode 100644 server/tests/unit/routes/auth-entity.errors.test.js create mode 100644 server/tests/unit/routes/budget-control.errors.test.js create mode 100644 server/tests/unit/routes/budget-performance.errors.test.js create mode 100644 server/tests/unit/routes/dashboard.calendar.test.js create mode 100644 server/tests/unit/routes/date-consumers.calendar.test.js create mode 100644 server/tests/unit/routes/delegations.errors.test.js create mode 100644 server/tests/unit/routes/departments.errors.test.js create mode 100644 server/tests/unit/routes/employees.calendar.test.js create mode 100644 server/tests/unit/routes/exports.dashboard.calendar.test.js delete mode 100644 server/tests/unit/routes/integrations.erp.asset.test.js delete mode 100644 server/tests/unit/routes/integrations.erp.procurement.test.js delete mode 100644 server/tests/unit/routes/integrations.erp.test.js delete mode 100644 server/tests/unit/routes/integrations.erp.travel.test.js create mode 100644 server/tests/unit/routes/master-data.errors.test.js create mode 100644 server/tests/unit/routes/masterDataHolidayCalendars.calendar.test.js create mode 100644 server/tests/unit/routes/purchase-request.errors.test.js delete mode 100644 server/tests/unit/routes/purchase.archive-erp.test.js create mode 100644 server/tests/unit/routes/read-export.errors.test.js create mode 100644 server/tests/unit/routes/reference-settings.errors.test.js create mode 100644 server/tests/unit/routes/system-contract.errors.test.js create mode 100644 server/tests/unit/routes/todos.calendar.test.js create mode 100644 server/tests/unit/routes/todos.errors.test.js create mode 100644 server/tests/unit/routes/travel-execution.errors.test.js create mode 100644 server/tests/unit/routes/travel-request.errors.test.js create mode 100644 server/tests/unit/routes/weeklyReports.calendar.test.js delete mode 100644 server/tests/unit/routes/workflow.purchase-erp.test.js create mode 100644 server/tests/unit/services/budgetPool.locking.test.js delete mode 100644 server/tests/unit/services/erpAsset.test.js delete mode 100644 server/tests/unit/services/erpJournal.test.js delete mode 100644 server/tests/unit/services/erpJournal.travel.test.js delete mode 100644 server/tests/unit/services/erpMasterData.test.js delete mode 100644 server/tests/unit/services/erpProcurement.test.js delete mode 100644 server/tests/unit/services/erpTravel.test.js delete mode 100644 server/tests/unit/services/integrations/erp.test.js create mode 100644 server/tests/unit/services/paymentResponsibility.test.js create mode 100644 server/tests/unit/services/payroll.month.test.js create mode 100644 server/tests/unit/services/reimbursementPrecheck.snapshot.test.js create mode 100644 server/tests/unit/services/sensitiveRecoveryContract.test.js create mode 100644 server/tests/unit/utils/client-pwa-lifecycle.test.js create mode 100644 server/tests/unit/wecomHrMaster.calendar.test.js diff --git a/.trellis/spec/hyb-oa/approval-workflow.md b/.trellis/spec/hyb-oa/approval-workflow.md index ead17e38..e5d2142d 100644 --- a/.trellis/spec/hyb-oa/approval-workflow.md +++ b/.trellis/spec/hyb-oa/approval-workflow.md @@ -17,6 +17,16 @@ task before modifying approval behavior. - Reviewer review is required after changes touching workflow, status machine, approval rules, permissions or migrations. +## Verification: Required Manager Missing + +`server/scripts/regression-workflow-actions.sh` scenario 8 follows the current +required-manager contract: submission returns 409 `VALID_001`, details contain +`workflow=no_actionable_approver`, `reason=no_manager_found` and +`reasonCode=workflow.manager_unavailable`; the reimbursement stays draft with +no workflow instance. Do not restore the obsolete automatic skip-to-finance +expectation. Optional-step skipping remains independently covered in workflow +unit tests. Synthetic relationship changes do not resolve real employee setup. + ## Verification: Direct Manager And CEO Are The Same Employee `scripts/qa/test-invoice-first-http.cjs` supports opt-in @@ -786,3 +796,12 @@ const semantics = buildPaymentProcessingSemantics({ // Correct: the UI renders only the server candidate contract. const candidates = await fetchPaymentBatchCandidates({ q, page, pageSize }); ``` + + +## Scenario: Pending Payment Names And Delivery Evidence + +- Batch pending stages may project current active eligible reviewers/finance names, excluding the maker/reviewer as appropriate. Keep completed stages' actual actor, and label the candidate list as 可复核/可办理 rather than claiming a named exclusive assignment. +- Separately show recipients of the persisted responsibility notification. Creation selects one CEO-first reviewer; current eligibility and historical notification recipient can differ after role changes. Loan review additionally excludes submitter and verified lender OA identity. +- Personal WeCom sending requires a successful delivery with result code `wecom_app_sent`; generic success, mock, webhook group success and no-bound-recipient skip are not personal sending evidence. Success does not establish reading. +- Shared dispatch stores a batch attempt on one anchor notification. Multiple intended recipients plus one success cannot prove every person received the message; use the bounded “存在发送成功记录,接收范围需核对” projection. Do not read raw delivery payloads or channel secrets for the progress display. +- Verify eligible-vs-notified separation, inactive/self/admin exclusions, completed actor preservation, no attempt/group/mock/unbound/failed and multiple-recipient cases; desktop/mobile use the same server progress projection. diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index 73e64d82..624c6c94 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -1,5 +1,20 @@ # Backend Fastify / Prisma +## Shared Budget Pool Write Transactions + +- Entity row locks do not serialize different reimbursements, purchases or card entries sharing a budget pool. Before an authoritative budget decision or usage mutation, call `lockBudgetPoolsForUpdate(tx, poolIds)` on the interactive transaction client. It parameterizes UUIDs, deduplicates/sorts all required IDs and uses `ORDER BY id FOR NO KEY UPDATE`; do not substitute process-local locks or absolute writes based on unlocked reads. Prisma 7 supports nested `$transaction`; use the root-only `$connect` capability to reject root clients, not presence of `$transaction`. +- Maintain caller entity/workflow → budget pool lock order; the helper must never acquire entity locks. Card refunds that later synchronize an authorization must lock that authorization before taking a pool lock, matching authorization imports. For multi-pool releases, lock the complete pool set in stable order, then reread the ledger; reject a newly appearing pool instead of acquiring it out of order. +- `resolveApplicableBudgetPoolByContext(tx, context, { forUpdate: true, additionalPoolIds })` selects, locks and rereads the candidate. A different/missing candidate after waiting returns 409 with `budget.pool_changed`; previews omit `forUpdate`. Submission passes the locked record (or explicit null) as `budgetPoolForWrite` to `resolveEntityPrecheck`, preserving the single reimbursement entity read and keeping the checked/charged pool identical. Undefined means ordinary preview resolution; never accept this internal argument from a public request or cache it across transactions. +- Pool usage, applicable blocking decisions and ledger changes remain in the same business transaction. Purchase submission must recompute and recheck inside that transaction, not reuse a pre-transaction preview. Preserve control-mode/feature gates, Decimal comparisons at exact capacity, duplicate behavior, release floor and existing audit atomicity. Atomic increments alone do not repair stale over-budget decisions. +- Verify with `RUN_ISOLATED_BUDGET_DB_TEST=1 node scripts/qa/test-budget-pool-db.cjs`: real services/routes, synthetic authorized identities and isolated PostgreSQL, observed lock waits, mixed writers, multi-pool order, metadata changes while waiting and fault rollback. These fixtures do not prove production balances, authentication, frozen travel availability or publication eligibility; ordinary full-gate and independent-review obligations remain. + +## Composing Visibility And Activity Filters + +- `GET /delegation-grants` keeps an ordinary caller within records where they are the principal or delegate. The existing `system.users` check alone allows manager-selected principal/delegate filters; do not broaden this to `system.settings` or change the separate legacy revoke API. +- Visibility and activity each contain `OR`. Compose them as `AND: [baseWhere, activeWhere]`; object spread would overwrite the first `OR` and expose unrelated active grants. `includeInactive=true` removes only activity constraints, retaining `baseWhere`. +- Preserve `revoked_at=null`, `valid_from<=now`, and `(valid_to=null OR valid_to>now)`. A grant ending exactly now is inactive; one starting exactly now is eligible. Keep user-supplied personnel filters from widening an ordinary caller's scope. +- Test actual route query consumption with synthetic own/other, active/future/expired/revoked grants, both includeInactive modes, and administrator explicit filters. An unconditional mocked `findMany` result cannot detect a dropped visibility predicate. In-memory evaluation is not evidence of production data, PostgreSQL behavior or authentication. + ## Scope Server work lives under `server/`. Prefer existing route, service, utility and @@ -20,13 +35,84 @@ QA script patterns over new abstractions. ### Calendar Date Inputs -- For reimbursement, payment-batch, travel-request and asset date-only input schemas, +- For reimbursement, payment-batch, travel-request, asset, contract-registry, + purchase delivery, travel payment/reconciliation, weekly-report and presence date-only input schemas, combine the YYYY-MM-DD shape with isValidDateOnlyString. Reject calendar overflow before persistence; a regex alone accepts February 30 and JavaScript normalizes it. - Keep optional and nullable wrappers outside the string validator. Validate leap-day, month-end, invalid month and empty/null boundaries without changing stored historical dates. - parseDateOnlyString retains its legacy conversion contract for unaudited consumers; it is not validation. Do not claim the shared parser or other domains are all hardened. +- Validate weekly-report weekStart before Monday normalization, including the GET + that may create a draft. Invalid dates must not create/update a report. +- Contract expiry filters share contractRegistryFilters with exports. Presence + history exports validate their own dateFrom/dateTo schema; do not silently + broaden unrelated export filters. Keep the original 409 for a reversed travel + reconciliation period after both endpoints pass calendar validation. + +- Todo create/patch dueDate, employee-directory hireDateFrom/To (users and + employees routes), holiday-calendar day replacement and the audited employee, + dashboard and AP-operation export date-only fields also use the strict predicate. + Preserve todo null clearing versus omitted-date retention and original permissions. +- Validate every holiday item before entering the replacement transaction: one invalid + date must not delete existing days or write the successful audit. Duplicate dates + retain 409; an empty item list still intentionally clears the calendar. +- Audit-export timestamps and payroll months are separate contracts. Adding calendar + checks does not change AP ledger reversed-range behavior or authorize historical + data edits. Exercise both CSV and XLSX rejection before reads/audit and valid UTC bounds. + +- User create/patch and CSV/XLSX hireDate, employee profile/lifecycle/archive + dates, AP ledger queries and dashboard queries also use the strict predicate. + Validate before business reads/writes; preserve feature/permission ordering and + original range responses. Import apply rejects the whole invalid batch (409), + while dry-run still records its original preview audit. +- Read user CSV cells as text before normalization. ExcelJS automatic date coercion + uses local time; converting that Date to UTC can move a valid hireDate to the + preceding day. CSV and XLSX tests must assert exact date values, blank defaults, + preserved textual identifiers and mixed-batch rejection, not only HTTP success. + +- Payroll profile effectiveDate, budget creation periods, performance cycle + create/patch dates, attendance correction workDate, attendance sync request + ranges and leave create/edit/preview/list dates follow the same input contract. + Keep payroll YYYY-MM and timestamp/session semantics separate. Legacy + attendance_v1 is permanently disabled: its 409 guard remains authoritative; + never enable the gate just to exercise a schema in tests. +### HR Import Calendar Contract + +1. Scope / trigger: `integrations/wecomHrMaster` normalizes external hire dates before department/account mutations. Do not let malformed supplied dates use the missing-date default. +2. Signatures: `normalizeWecomHrProfile({contact, staff, departmentMap})` returns canonical `hireDate` plus boolean `hireDateInvalid`; `runWecomHrMasterSync({prisma, config, mode, sourceProfiles, fetchImpl, ...})` rechecks normalized offline input. +3. Contract: separated/compact calendar dates and positive seconds/milliseconds remain supported. Invalid canonical or expanded-year dates are rejected. Missing dates retain the run's `now`; existing employee updates still omit `hire_date`. No new env, DB or public request field is introduced. +4. Validation/error matrix: invalid date → item `action=skip`, `status=skipped`, `errorCode=invalid_hire_date`, fixed safe message; no department, employee, role, identity or manager access. Run/item audit is retained. Missing required fields and inactive-source guards keep their existing priority. +5. Good/base/bad: `2024/2/29` → `2024-02-29`; blank → original create default; `2026-02-29`/`20260230` → skip, never February overflow or default today. Valid items in a mixed batch continue normally. +6. Tests: `wecomHrMaster.calendar.test.js` exercises the actual run service with mock DB and rejecting fetch; assert exact UTC dates, preserved existing hire date, audit-only skips, dry-run/apply and mixed-batch behavior. This is not real WeCom/PG evidence. +7. Wrong/correct: `hireDate = parse(raw) || null` alone loses invalidity. Preserve `hireDateInvalid` and guard it before `resolveDepartment`; independently validate any directly supplied normalized `hireDate`. + +### Shared Read Filter Error Contract + +1. Scope / trigger: employee directory, contract-registry filters and presence-history ranges, including their export consumers. +2. Signatures: `parseEmployeeDirectoryFilters`, `parseContractRegistryFilters`, `parseContractRegistryExpiringQuery`, `resolvePresenceHistoryRange`; export `parseQuery(schema, query, reasonCode?)` / `parseBody(schema, body, reasonCode?)` accept an explicit optional registered reason. +3. Contract: retain HTTP 400, `VALID_001`, original flatten/field details and guard order; add only `details.reasonCode`. Shared legacy helpers used by other write/conflict branches keep their contract. +4. Validation/error matrix: employee schema/reversed hire range → `employee_directory.query_invalid` / `hire_date_range_invalid`; contract schema/reversed expiry/days → `contract_registry.query_invalid` / `expiry_date_range_invalid` / `expiring_query_invalid`; history reversed/>31 days → `presence_history.date_range_invalid` / `date_range_too_large`. +5. Good/base/bad: 31 inclusive history days valid; absent history endpoints preserve 7-day default; contract expiring days 1–365 valid, 0/366 invalid. Employee/contract export outer schemas use their domain query reason, including invalid CSV/XLSX format. +6. Tests: actual Fastify negative requests assert exact reasons and legacy details before business queries/success audit; CSV/XLSX and unaffected export paths regress together. Registry/client/fixed message fixture must agree. +7. Wrong/correct: migrating only the shared filter misses outer export validation. Pass its domain reason explicitly; never infer it from ambiguous detail keys. Dynamic helper reasons must use registry validation (`getErrorReason(reasonCode).code`); only remove disappeared AST baseline entries, not still-used legacy helpers. + +### Import Files, Announcement Times And Payroll Errors + +1. Scope / trigger: `users` CSV/XLSX import; announcement create/patch/acknowledge; payroll maintenance and month/export queries. These errors retain status/details and business guard order. +2. Signatures: `readUserImportPayload(request)` must catch `FST_REQ_FILE_TOO_LARGE` both at `request.file()` and `filePart.toBuffer()`. Announcement/payroll `parseBody(schema, body, reasonCode)` take the operation's explicit reason; generic exports/attendance parsers retain optional reason parameters for unmigrated callers. +3. Contracts: import oversize remains production HTTP 400/VALID_001 with `file=file_too_large`, now adding `user.import_file_too_large`; leave other read exceptions unchanged. CSV/XLSX parsing follows the narrow boundary below. Preserve the 500-row cap and original preview audit. Announcement DATE_TIME_SCHEMA retains its old ISO/date-T-prefix alternatives, then requires a real calendar date prefix and finite parsed Date; nullable/optional, local/offset interpretation and valid 24:00 rollover remain compatible. +4. Validation/error matrix: seven file failures → `user.import_*`; announcement input/id/query/disabled/statement/ack guards → `announcement.*`; payroll month/item/profile/payslip conflicts → `payroll.*`; unified attendance month errors → `attendance.month_*`. Keep original 400/409 and nested amount-index/missing-profile details; fixed client text must never interpolate raw file content, staff lists, or amounts. +5. Good/base/bad: 500 import rows may preview; 501 rejects before business access. Announcement `2024-02-29T23:30:00-12:00` is valid even though UTC is March 1; February 30 or an unparseable T suffix must reject before reads/writes. Payroll/attendance month 00/13 already fail in downstream parsers: preserve that behavior instead of adding contradictory schema rules. Low years 0000–0099 still have legacy Date.UTC mapping and require a separate accepted-year/DB compatibility decision. +6. Tests required: actual multipart dry-run/apply and limit rejection; announcement create/patch across all three dates and signature guards; payroll salary item/profile/generate/edit/issue and P2002 simulation with no successful audit or subsequent mutation. Preserve originals/permission checks and assert exact reason plus legacy fields. P2002 mocks do not prove PostgreSQL uniqueness/rollback. Run bcrypt-heavy import regressions without concurrent frontend builds; do not remove hashing or business assertions to meet a timeout. +7. Wrong/correct: catching only `request.file()` misses stream-size errors from `toBuffer()`. Route-local catch both using the same registered reason. Replacing a domain builder globally can mislabel input vs state conflicts: pass explicit reasons per operation, validate dynamic codes through the registry, and delete a legacy helper only after all its callers migrate. Announcement mutation toasts consume `resolveErrorMessage(error, fallback)` so page callbacks do not erase the registered explanation; no new visual design is needed. + +### User Import Parser Failures + +- Catch only the in-memory ExcelJS `csv.read` and `xlsx.load` awaits. Known format failures use `user.import_file_invalid`, HTTP 400 / VALID_001, with only fixed `file=invalid_format` plus reasonCode; no filename, cell, sheet, parser message or cause. +- The installed fast-csv/JSZip/saxes versions expose plain Error messages rather than codes. Keep format-specific, anchored message allowlists supported by real malformed-file tests. Different error types, error codes/statuses and unknown messages remain 500; replace parser exceptions with a fresh fixed internal Error so the global logger receives no original message, stack, cause or custom properties. Do not map every exception to invalid input. +- Keep `request.file`, `toBuffer`, worksheet normalization, permission, DB, transaction and hashing outside that catch. Apply's preview reads already happen inside its transaction; do not claim all internal failures occur before transaction entry. Format rejection itself must precede all preview/transaction/business access. +- Test both multipart endpoints with actual malformed CSV, ZIP and XML and the production error-handler callback; verify zero business access and no raw exception logging. Regress valid quoted multiline CSV, XLSX, exact date text/defaults, 500/501 rows, original file guards and real hashing. Unknown failures and parser-like messages thrown by reads/DB/hash must retain their separate classifications. Use only synthetic input; mock transactions do not prove PostgreSQL rollback. ### Payment Account And Invoice Read Errors @@ -216,6 +302,8 @@ Wrong: treat a notice count or pending-batch aggregate as the complete finance w Fixed expected text lives in `server/tests/unit/fixtures/error-reason-messages.json` and is asserted against both registry and client rendering; do not replace these assertions with broad message regexes or expectations derived at runtime. + Also check registry → fixture completeness for every `audience=user` reason; + iterating fixture entries alone cannot detect a newly registered code omitted from the fixture. - Authentication explanations must retain the same `auth.code_invalid_or_expired` reason for missing, inactive, locked and wrong-code accounts; do not introduce account-enumerating recovery messages or include submitted secrets in errors. @@ -225,6 +313,12 @@ Wrong: treat a notice count or pending-batch aggregate as the complete finance w - Workflow input-error migration preserves parsers, permission order and transaction boundaries. Invalid comments, task identifiers and batch requests must fail before business transactions; changing error metadata does not authorize state-machine edits. +- Notification list/delivery/channel validation uses notification.* reasons; todo + input/id/query/gate errors use todo.* and the portal gate uses portal.disabled. + Preserve legacy fieldErrors/id/featureFlag/reason and 400/409 statuses. A missing + channel encryption key must direct original-key repair without exposing input + secrets or suggesting a replacement key. Error migration must not send messages, + change channel encryption rules, enable flags or loosen assignment permissions. - Invoice date-only input must survive UTC date round-trip unchanged. Reject invalid timestamps and calendar overflow (including non-leap February 29) before updates or audits; JavaScript Date construction alone is not calendar validation. @@ -1016,3 +1110,78 @@ if (hasPermission(employee, "expense.view_all")) return reimbursement; const where = buildReadableReimbursementWhere({ employee, scope: "all", filters }); await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id); ``` + + +## Company Loan Principal And Retired ERP Consumers + +- `09-17-company-loan-principal-repayment` adds the explicit `loan_repayment` payment source: one retained repayment/batch/item, multiple allocations to confirmed same-company/lender/CNY loans. Preserve original principal and confirmed opening evidence; Decimal reservations and settlements must not exceed opening outstanding principal. SQL checks/deferred triggers complement locked application validation. +- Loan management requires an explicit finance/finance_mgr role plus loan.manage. Independent review requires active manager/CEO with review permission and excludes maker, submitter and lender OA identity; admin wildcard cannot replace these identities. External lenders require explicit verified no-OA identity. Account changes revoke identity confirmation and invalidate old unpaid snapshots. +- Creation locks lender, sorted loans, then day sequence; it never waits for a previously existing batch. Existing settlement locks batch, global receipt identity, attachments, lender/reviewer, sorted loans. Source maintenance never locks an existing batch. Claims, allocation settlement, batch/item payment facts and audit commit atomically. +- Export/upload marks bank outcome unknown without reducing principal. Unknown/in-transit cannot release reservations. A never-sent cancellation or independently reviewed, current and exactly matching failed-bank evidence may release; preserve the old request. Paid and terminal allocation/history facts are immutable. Accounting handoff is a separate audited reference, not a second payment state. +- Repayment BIFF8 uses encrypted account-name snapshot and purpose 还款/归还借款本金; receipt matching/manual review explicitly supports loan source and shares the reimbursement claim/alias namespace. Real bank/OCR acceptance is not established by synthetic recognition tests. +- Loan evidence uses governed PDF/image bytes, random object keys, bounded content/hash checks and audit-before-download. Finance reads the ledger; reviewers can download only materials in an eligible payment's allocations, not the full ledger. +- ERP-specific API/adapters/jobs/enqueues are retired by `09-17-erp-exclusive-retirement`. Shared outbox/notification/WeCom and historical records remain. New ERP enqueue and direct dispatch reject; pending selection excludes `erp.*`; historical retry returns `integration.erp_retired` without mutating records. Never restore these retired consumers solely to satisfy old success-path tests. + + +## Purchase draft transaction and response hydration + +- Keep purchase draft creation and its audit atomic. Inside the create transaction, select only identifiers needed for the audit; hydrate the original detail relation projection after commit. +- Draft PATCH likewise hydrates the same response projection after commit. Keep FOR UPDATE, ownership/draft guards, master-data and project validation, item replacement, amount calculation, both existing parent updates and audit in their original transactional order. This response is current committed detail, not a relationship snapshot protected by the earlier write lock; never move a read used for a business decision outside the transaction. +- Cover omitted, empty and populated item lists; fail writes/items/audit/commit before any response read. A failed response read after commit is an error response, not a rollback, and must never trigger automatic patch retry. Review each write entry separately: a creation fix does not establish update/submit/withdraw/archive coverage. +- Do not increase transaction deadlines, connection pool size or retries to conceal a measured P2028. Preserve permission/cutover/default fields and audit ordering. +- Creation or audit failure must prevent post-commit hydration. A hydration failure after commit is not a rollback and must not trigger automatic draft creation retry. +- Test ordering and failures with stubs, and separately exercise the actual create/approve/archive flow in the managed synthetic database. Stub rollback assertions do not prove PostgreSQL fault-injection rollback. + +## Asset and system-user creation under local load + +- Asset creation keeps reference validation, number allocation, asset and optional assignment writes in the original transaction. Return the scalar audit/assignment fields, then hydrate the unchanged detail projection after commit. Preserve the existing post-commit, nonblocking asset audit and its independent failure logger; do not copy purchase's in-transaction audit semantics into this route. +- Failed asset/assignment/commit must not hydrate or emit a successful creation audit. Post-commit detail failure does not roll back the asset and must not retry creation; missing detail follows the existing asset.not_found contract. The detail represents current committed state, not an atomic relationship snapshot from the write. +- Single-user creation performs the unchanged cost12 password hash after existing permission/input/protected-role/reference guards but before opening the account/role/audit transaction. Only server code supplies the internal passwordHash argument; never accept a client-provided hash. Preserve generated credentials, forced password change, unique-conflict handling and post-commit directory sync. Batch imports retain their existing transaction-local hash position. +- Keep original transaction deadlines, password cost, retries and worker count. Test with real hashing for the pre-transaction boundary, plus hash/write/role/audit/commit/read failures and original import/permission flows. Mock transaction ordering is not PostgreSQL rollback or whole-load reliability evidence; retain a fresh complete managed run. + +## Reimbursement submit precheck reads + +- Resolve the reimbursement entity and ordered item/type projection once within each precheck call, and share only that server-read record between private budget, expense-standard and operational checks. Public helper calls still load their own records. Keep rule/assignee/evidence checks, missing-record failures and per-call freshness; do not accept client snapshots or introduce a cross-request cache. +- Submit retains its FOR UPDATE, aggregate total recomputation, budget/approval/history/audit transaction and original deadline. Reuse the already-read item category for travel-summary validation; a linked travel request still requires that summary. Only the legacy invoice gate needs a whole-form count when required-invoice items exist; the enabled per-item gate remains authoritative. +- Test both budget flag states with actual enabled flag fixtures, preserved financial/timeliness/evidence results, missing/read-failure boundaries, non-reimbursement branches and subsequent-call freshness. Travel allowance fixtures need real travel-summary fields even when testing invoice exemptions. Query reduction is bounded optimization, not proof that an intermittent P2028 or complete-load reliability is resolved. + + +## Scenario: Versioned Business Numbering + +### 1. Scope / Trigger + +New in-scope internal numbers use businessNumbering v2 only after an explicitly activated policy. Reimbursement is monthly; PB/JK/HK/CG/PD/automatic PRJ annual; FA and existing EA prefixes permanent. Contracts, employees, external source identifiers and frozen travel stay outside this mechanism. + +### 2. Signatures + +- `resolveNumberingContext(tx, { version?, now?, readOnly? })` returns the pinned rule version and instant. Only internal callers supply version/now. Config reads use readOnly; mutations require the caller's interactive transaction. +- `issueBusinessNumber(tx, { family, now, timeZone, prefix? })` returns persisted documentNumber, businessDate and serial; `issueDocumentNumber` selects legacy/v2, accepting internal numberingVersion and assetType. +- Tables: business_number_policies (version 2, immutable effective_at), business_number_counters (key + bigint high-water); purchase_requests.numbering_version defaults to immutable 1 for old/unversioned drafts. +- `node server/src/scripts/manage-business-numbering.js --help` documents status/activate/pause/resume. It does not load .env, accepts only explicit timestamp/actor/change reference and requires the operator's compatible-writers assertion; this flag is not automated proof of fleet readiness. + +### 3. Contracts + +- Migration adds structures, never activates or rewrites business numbers. New purchases pin eligibility at creation but obtain date/number on first submission; historical missing reimbursement numbers explicitly use version 1. Existing numbers, UUIDs, source snapshots and exported bank row identity remain unchanged. +- Policy shared lock `(1003, 0)` precedes policy read; management takes the exclusive lock and waits for in-flight writes. Counters lock namespace1004, family keys 1..8 from BUSINESS_NUMBER_FAMILIES; asset prefixes share key8 but retain separate counters. Keep lender→sorted loans→shared policy→HK counter→repayment→PB counter order. HK and its PB share the same context. +- Scan matching occupied numbers and persistent high-water under the counter lock, then save in the same transaction as the object. Four digits are minimum padding, never a truncation limit. No reuse after object cancellation/deletion; failed uncommitted transactions roll back. Matching manual project numbers reserve high-water in routes and importer without renaming formal codes. +- Policy effective time, old draft version and counter high-water are protected against changes/decreases/deletion by migration triggers. Pause/resume preserves all issued numbers and counters; pinned v2 drafts still issue v2 while policy is paused. Roll back only to a compatible application; do not remove these structures. + +### 4. Validation / Error Matrix + +- Missing/inactive/future policy → legacy; failed policy access or missing migration/model → error, never a silent legacy fallback. +- Unsupported version/family or invalid prefix → fixed error before writes. Capacity beyond PostgreSQL bigint → fixed exhaustion error, no truncation. +- Default activation time is read after waiting for the exclusive policy lock and compatibility reads; do not capture it in a parameter default before waiting. Existing legacy reimbursement YYYYMM / purchase or inventory YYYY configurations conservatively block activation/resume for a specific coexistence review; status lists only affected types, never silently edits the old rules. Existing same-format source rows are separately handled by the locked maximum. +- Old effective date or repeated activation → rejected; missing compatibility assertion/actor/reference → rejected. Policy update and audit share one transaction. +- Counter decrease/deletion, effective-time rewrite or draft version rewrite → PostgreSQL 23514. Unique violation → complete transaction rollback, never catch-and-retry within the aborted transaction. + +### 5. Good / Base / Bad + +Good: old PB-20260917-001 remains; new PB-2026-0001 can contain both BX date formats. Base: no activation means legacy behavior. Bad: reformat existing PB because its derived bank customer serial would change, reset a counter, or select a draft's version on submission day. + +### 6. Tests Required + +`businessNumbering.service.test.js` plus affected create/submit/payment/file routes cover formats, fixed time, timezone, boundaries, overflow, legacy and active policy. `RUN_ISOLATED_NUMBERING_DB_TEST=1 node scripts/qa/test-business-numbering-db.cjs` uses an owned network-none PG, all migrations, real concurrent writes/unique rollback, occupied numbers, import and CLI, immutable history and shared/exclusive locks, then authenticated loan/repayment/payment HTTP regression. Preserve its failures; unit mocks do not prove DB locking. G20 browser release acceptance and production activation remain separate. + +### 7. Wrong / Correct + +Wrong: `SUBSTRING(code FROM $1)` with an untyped bound offset can resolve PostgreSQL's text-regex overload and miss occupied numbers. Correct: `SUBSTRING(code FROM $1::int)::bigint`, with static allowlisted identifiers and bound regex/prefix values. Keep a real occupied-record integration assertion, not just a stub returning a maximum. Do not use count+1, no-lock MAX+1, or reset old DocumentSequence rows to implement periods. diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 3160ffa6..7f6007d6 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -1,5 +1,23 @@ # Deployment / Trial Run +## Convention: Local Release Candidate Receipt + +- `bash scripts/qa/run-release-gate.sh verify-candidate` reuses existing static and managed full-local stages and never calls deployment. R1 enforcement in existing deployment/bootstrap entries remains a separately approved change. +- `release-gate-result.mjs` owns an exclusive empty directory under `artifacts/release-gate/`, records INCOMPLETE before execution, and derives final results from fixed ordered phases, source/index/tracked and untracked input digests, current policy/toolchain, owned environment/cleanup, G19 full discovery and every local result plus witnesses/acceptance specs. Missing, truncated, linked, changed, interrupted or partial evidence is not PASS. +- `qa-stage-record.mjs` records full-local phases through failure cleanup. Keep plain shell calls under errexit: wrapping compound helpers in `if`/`||` can suppress their internal failures. Preserve the original failing exit code if evidence cleanup also fails. +- Persist actual browser selection separately from discovery: exact derived spec arguments, supplementary arguments, mode, retries, forbid-only and worker count. Ordinary release verification rejects supplementary filters, shards, missing projects, changed budgets, skips, retries, expected failures and inconsistent statistics/time ranges. +- A dirty development tree may have `localChecksPassed=true`, but returns PARTIAL/exit 2 and `deploymentEligible=false`. Offline `verify` re-reads and hashes evidence and recomputes against the current checkout. Local proof does not discharge the 12 same-candidate public obligations, independent reproduction, or operator approval. +- Existing project-wide skip flags accept only exact 0/1. Each or both require nonempty reason/operator/approval reference, persist affected stages, and end PARTIAL/exit 2. Other release modes reject them before stage side effects. These metadata do not grant authorization; no approval time is invented. +- Validate with pure receipt counterexamples, actual shell/fake-tool invocation, source mutations, and existing deployment lifecycle/safety regressions. Fake tools must intercept every external action. Evidence is auditable, not a cryptographic signature. + +## Convention: Managed OCR And Concrete Visual Coverage + +- After a successfully owned local server start, `run-web-gates.sh` recalculates `PLAYWRIGHT_OCR_MOCK` from that startup's `OCR_PROVIDER=mock` and `JOBS_ENABLED=true`; a caller's flag cannot enable unsupported capability. Each startup appends non-secret provider/job facts to `server-capabilities.jsonl` in the run report. Actual missing-field correction still has to pass; configuration alone is not behavior evidence. +- R108 uses an active synthetic reimbursement submitted through formal APIs, with a valid PDF and explicit item binding. Initialization fails the test rather than skipping it. Preserve the employee approval refusal, current approve/return and no-reject contract, offline upload queue, 390/414 layouts, and independent output paths. An incomplete visual report is not PASS just because findings is empty. +- The existing project visual matrix explicitly lists all 14 current system routes. Unknown wildcards/parameters and unowned system routes fail route-plan preparation. AP uses finance, system/audit uses admin, approval uses manager, and login uses an anonymous context. Require exact target URL plus a page-specific title or workspace anchor; 403/404/login fallback and same-origin API 403/404 are failures, not target page coverage. Mock response controls must declare UTF-8 so Chinese error headings exercise the intended guard. +- Per-route arrival and full audit completion are separate: record completion only after the screenshot and all DOM checks. The approved matrix is 25 PC + 8 mobile pages, grouped into seven role/viewport tests with 180 seconds each and one timed synthetic preparation. Preserve all assertions, screenshots, configured workers and zero retries; only this file's groups remain serial as before. Compare exact planned/completed route-role-viewport identities, reject missing/duplicate/unexpected pages and wrong arrivals, and require all seven actual test statuses passed. Failed or skipped serial successors must leave the report incomplete; unrelated guard tests cannot overwrite it. Matrix coverage does not mean every product page. Live/frozen obligations remain in full discovery; focused passes do not change ordinary eligibility. + + ## Public OA Boundary Public OA is quasi-production. Deployments, remote config changes and live data @@ -169,6 +187,12 @@ rollback cannot reverse migrations or initialization data. source copy, including a hung inspect; never shorten the production constant. - Fake binaries and whitelisted PATH/env exist only in test subprocesses. Unknown commands fail, real Docker/SSH/production credentials remain inaccessible. +- Avoid a cold Node runtime per fake command: a per-test private Unix socket + reuses the same command parser and trace semantics. The isolated Python stdlib + client preserves argv, requested stdin and status; transport failure exits 97 + without a real-command fallback. Record timeout before directly executing GNU + timeout, and close all owned connections before removing the temporary fixture. + Keep the original workers, deadlines and full-chain/mutation assertions. - Run `server/tests/unit/scripts/deployment-db-lifecycle.test.js` in static gates and retain existing `deployment-safety-contract.test.js` coverage. @@ -778,6 +802,55 @@ BUILD_SHA= VITE_BUILD_SHA= \ docker compose -f deploy/trialrun/docker-compose.yml build server ``` +## Scenario: Managed Local Gates With Synthetic Password Preparation + +### 1. Scope / Trigger + +- Prepare ordinary full-local or revalidate explicitly selected browser/server contracts in a fresh, run-owned local database. Focused diagnosis has no normal release eligibility; none of these entries activates R1 or authorizes production access. + +### 2. Signatures + +- Entries: ordinary `bash scripts/qa/run-web-gates.sh full-local`; focused `retained-local` with explicit `QA_RETAINED_SPECS` and optional `QA_RETAINED_GREP`; `retained-server-local` with required `QA_RETAINED_SERVER_SUITE=workflow-actions|server-regressions`. The shared shell helpers are `qa_seed_synthetic_credentials` and `qa_prepare_synthetic_accounts`. +- Account owner: `scripts/qa/prepare-local-e2e-accounts.mjs`; validators `validateAccountPreparation` and `prepareAccounts`. +- Receipt: `synthetic-account-preparation.json`, selection, complete Playwright JSON, managed environment and before/after source/repository snapshots. + +### 3. Contracts + +- The existing managed namespace, native-online/no-public-egress topology, exclusive PostgreSQL and owner-verified cleanup apply. Accept only existing local spec basenames; no traversal or implicit full-suite selection. +- Seed all eight synthetic roles once with a per-run random initial password and the real must-change flag. Prove the initial browser change-password gate, then serially use official login/change-password endpoints for each role; the old credential must fail and the new public test fixture credential must log in without another change requirement. +- The preparation CLI verifies the managed context, exact loopback HTTP origin and synthetic database prefix before writes. Do not read operator credentials, print auth payloads, clear flags with SQL, alter production seed behavior or reset shared accounts from concurrent login helpers. +- All three entries seed per-run initial credentials and prepare once before business cases. Ordinary full-local qualifies the guest entry after server startup, then prepares before workflow-actions/server regressions; the later job-enabled restart does not reset accounts. Remove initial-password and seed-password environment variables after preparation. Keep full discovery/PWA inputs, fail-fast, zero retries and configured file concurrency. The diagnostic inventory mode remains a separate contract and is not silently migrated. +- Build Vite clients with `NODE_ENV=production` while the isolated application server remains in test mode. No real notifications, bank calls or external OCR. Ordinary full-local and the focused server-regressions profile retain IM_NOTIFIER_ENABLED=true for the existing mock/loopback-failure notifier contract; focused browser/workflow-only profiles keep it false. The fresh seeded database contains no real channels; managed no-egress and disabled real WeCom/email remain required. The managed inner runner fixes `SAMPLE_FILE` to a nonexistent run-owned scratch path so the smoke script generates its synthetic fallback instead of reading a shared temporary sample. + +### 4. Validation & Error Matrix + +- Missing/invalid spec selection or managed environment, remote origin or invalid database identity -> fail before account preparation. +- Initial change gate missing, change rejected, old credential accepted or fresh login still requiring change -> fail before business cases, including ordinary server regressions. Cleanup still owns this run’s database. Removing preparation must make the command-capture failure test fail. +- Missing final browser JSON, interrupted run, changed source/repository or failed cleanup -> no complete pass. Owner-verified container removal is recorded independently from an interrupted inner database-drop failure. +- Focused pass -> selected contracts only; `normalReleaseEligible=false` remains mandatory. A focused server selection cannot replace full-local; only the two fixed script mappings are accepted, with no shell command interpolation. Ordinary discovery/browser calls use `--forbid-only` and `--retries=0`. + +### 5. Good/Base/Bad Cases + +- Good: one owned empty database, serial official password lifecycle, then unchanged shared login helpers and complete selected results. +- Base: browser runtime absent -> environment limitation; preserve the original observation and record NOT_RUN. +- Bad: disable must-change, mutate the same user's password from parallel tests, or promote a green filtered run into a full release receipt. + +### 6. Tests Required + +- `scripts/qa/tests/prepare-local-e2e-accounts.test.mjs`: accepted lifecycle, old-password rejection and invalid origin/database/initial-password behavior; no credential values in receipts. +- Managed browser exercise: first-login change page, eight successful account lifecycle records, at least one full real-auth business case, zero retries and verified cleanup/source/repository invariants. +- Keep PWA production-build command regression and the real A/B upgrade witness separate from mocked page checks. +- Mock API specs must explicitly block service workers in their actual contexts; production workers can bypass page.route. Keep real PWA A/B witnesses enabled and retain their original budgets. A focused pass after a concurrent failure is not proof of the performance root cause. +- Regression fixtures use current contracts: explicit non-travel expense type for a notification-only reimbursement, explicit invoice item association, management scope for employee lifecycle fields, and save/precheck/submit responses observed separately. Keep blocked prechecks rejected and avoid logging raw payloads. +- Synthetic notifier-secret checks use wecom_webhook with a fixed loopback failure address: encrypted storage, redacted return and provider failure are distinct from mock delivery. Never use real channel configuration. +- Retire a duplicate entry only after proving assertion containment and retaining its canonical manifest/runner entry. Preserve original discovery/results. Visual wildcard route tokens must not be reported as coverage of every matching page; record actual routes and unresolved ownership. +- Copy or hash temporary fixture inputs before the owner cleanup can remove them. A successful list/coverage stage followed by a receipt-collection failure must retain both outcomes; discovery alone never confers release eligibility. + +### 7. Wrong vs Correct + +- Wrong: turn off the seed change-password flag so the shared login helper reaches the portal. +- Correct: retain the flag, change the password once through the official API in the owned pre-test stage, verify both rejection and re-login, then leave authentication unchanged during concurrent tests. + ## Scenario: Isolated Authentication Security Gate ### 1. Scope / Trigger @@ -874,7 +947,8 @@ The independent all-spec owner is `bash scripts/qa/run-web-gates.sh full-local`, not this route-manifest subset. Full-local prepares temporary PWA A/B production builds before Playwright collection using the shared PWA fixture helper; all non-manual spec/test files under the current test directory remain assigned to -full-local, including dedicated-input specs and filenames containing `live`. +full-local discovery, including dedicated-input specs and filenames containing `live`. +The approved G19 contract below partitions execution; public obligations stay mandatory after deployment. The existing local/live runtime mode restrictions remain intact; ownership is not authorization to use live accounts or contact production. @@ -896,13 +970,20 @@ to diagnostics. No boolean environment marker grants release eligibility. Both full-local entries own the Linux `isolated-online-v1` environment through `full-local-environment.mjs`: a fresh network namespace, an up internal veth -pair without an external/default route, and an owned offline PostgreSQL container +pair with no external path, and an owned offline PostgreSQL container connected only through its Unix socket and loopback proxy. No host fallback, image pull, arbitrary outer wrapper or boolean online assertion is accepted. Unsupported capabilities are environment failures, not business test failures. Reports separate inherited, runner-created and browser namespace fingerprints; live ownership/topology and current policy digests are revalidated between stages -and at completion. The native blank-page probe must observe actual online=true. +and at completion. The only default route is `default dev qaonline scope link`: +no gateway/via/multipath, no IPv6 default, and no other interfaces. Verify both +veth endpoints are in the owned namespace by reciprocal peer names/indexes, +`info_kind=veth`, and no external namespace/master link. This closed link lets +Linux WebKit detect availability without an external path. The native blank-page +probe must observe actual online=true in both Chromium and WebKit; each browser +process must match the owned namespace. Never patch navigator or count browser +network emulation as a kernel observation. Before business browser tests, a fresh unmocked context opens the real candidate login page: listeners precede navigation, cookies/storage are empty, the form appears, no refresh or login request occurs, and native online samples are true. @@ -1341,6 +1422,32 @@ STORAGE_HOST_DIR=/srv/oa-data/storage DEPLOY_DATA_MODE=existing \ `storage.tar.gz` and a final `manifest.json`. The manifest contains only safe relative names, byte sizes, SHA-256 hashes, batch time, actual build SHA when known and actual source image digest when supplied. +- Bind the canonical batch directory to the verified manifest `runId`, never + derive it from `createdAt`: run start and manifest creation can differ by + seconds. A cross-host manifest hash match does not replace checking the + selected directory identity on the actual restore host. +- An observation used to prepare a restore plan must return the observed + `runId` and relative `batchName`, not just a timestamp/hash. Pin the intended + manifest and reject a changed newest canonical candidate; do not skip a + newer symlink or silently fall back to an older matching batch. A successful + directory observation is not execution-time preflight or restore approval. +- A separately approved cross-platform image rebuild pins the source commit + and tree, native target platform and verified local base digest. Export only + the approved tracked paths, reject secrets/unsafe archive entries, and record + source/lock/context hashes plus output `BUILD_SHA` and OCI revision. Build + cache reuse is not a clean dependency install; same source is not the original + image's byte identity. Building alone authorizes no application startup. +- Use an exclusively created attempt receipt before a bounded remote build; + an existing receipt or uncertain timeout stops without automatic retry. + Keep prior images/caches and immutable review bundles. Attach new image/source + observations outside the frozen bundle; they do not clear pending content + review, source-directory binding or execution-time freshness gates. +- Rebinding reviewed recovery code uses distinct `reviewManifestSha256` and + `executionManifestSha256` identities. Verify embedded code/helpers against + the original review manifest and restrict bundle changes to the approved + source/image binding fields before importing the runner. Preserve the full + migration map and historical exception, not merely their counts. Review of + the old code does not imply independent review of a new binding envelope. - `BUILD_SHA` supplied by the caller takes precedence over local Git fallback. On a non-Git runtime host, at least `SOURCE_IMAGE_DIGEST=sha256:<64-hex>` is mandatory. Never label a legacy backup with the commit planned for deployment. @@ -1379,6 +1486,15 @@ STORAGE_HOST_DIR=/srv/oa-data/storage DEPLOY_DATA_MODE=existing \ Require the expected unique key count, then normalize both `table=count` collections with `LC_ALL=C sort` (or an equivalent map comparison) before equality checking; database collation order is not an evidence contract. +- A documented historical missing attachment is not an implicit restore + exception. A separately approved exception must pin record/path identities + and disposition evidence, recheck uniqueness, archive time and absence of + business associations inside the restored database, and prove actual file + absence. Keep every reference in the original strict validator input. +- Report `totalReferences`, `rawMismatches`, `acceptedKnownMissing` and + `unexplainedMismatches` separately. An accepted missing file stays incomplete; + it may allow only the explicitly approved later read-only stage and a + `PARTIAL` outcome, never storage-integrity `PASS`. Preserve earlier `NO_GO`. ### 4. Validation & Error Matrix @@ -1407,6 +1523,15 @@ STORAGE_HOST_DIR=/srv/oa-data/storage DEPLOY_DATA_MODE=existing \ - Empty, duplicate or missing table-count output -> classify as a verification execution failure first; do not report production-data drift until stdin was delivered and every expected key was returned exactly once. +- Historical target identity/archive/association drift, duplicate target, + file substitution or any additional unexplained mismatch -> stop before the + application stage. Never use `archived_at IS NOT NULL` as a blanket exclusion. +- Expired or not-yet-rebound source -> do not render an executable restore + entry; code review and synthetic tests may continue with an explicit binding + gate and a non-executable review bundle. +- Build source/base/platform/resource mismatch -> stop before `docker build`; + a successful build with wrong output revision/platform remains unusable for + restore. A rebuild never extends the source backup's freshness window. ### 5. Good/Base/Bad Cases @@ -1429,6 +1554,10 @@ STORAGE_HOST_DIR=/srv/oa-data/storage DEPLOY_DATA_MODE=existing \ - Shell syntax checks for all backup/restore scripts. - Synthetic Docker/PostgreSQL and temporary-storage round trip. +- For a narrowly approved missing-file exception, assert exact identity, + archive time, every association and actual absence against synthetic SQL; + assert new missing files, wrong size/hash, duplicate targets and symlinks + still block, and that raw mismatch counts and `PARTIAL` are preserved. - Negative assertions for wrong DB confirmation, corrupt DB/tar, non-empty target and dangerous tar path. - Validate manifest relative paths, absence of connection secrets and matching @@ -1440,6 +1569,10 @@ STORAGE_HOST_DIR=/srv/oa-data/storage DEPLOY_DATA_MODE=existing \ the pass result while a changed count still fails. - Real-instance restore and `/ready` remain separately approved evidence and must never be fabricated from the local drill. +- For a cross-platform build wrapper, assert exact base/revision/platform, + minimal Dockerfile substitution, secret/path/link rejection, resource + thresholds, failed-preflight zero builds, exclusive receipt/no retry, and + successful image inspection without `docker run` or deletion of old images. ### 7. Wrong vs Correct @@ -3355,3 +3488,14 @@ derive unit identity from installer -> timer trigger + service Result/exit -> bind retained diagnostics to the safe failure phase; unavailable clocks stay unavailable -> partial difference <= min(5% limit, 64 MiB) before calling it similar ``` + + +## G19 layered browser acceptance (approved 2026-09-18) + +- `verify-full-local-discovery.mjs` still discovers every non-manual source spec. `full-local-coverage-contract.json` fixes 12 post-deployment obligations and two retained frozen travel chains by file/case identity and source digest; do not edit the old sources to hide failure. +- `full-local-coverage.mjs plan` derives local files/cases from actual full discovery. Missing sources/protections, duplicate identities, changed exception source or an unregistered case in an exception file fail before business execution. Other discovered cases are locally mandatory. +- The ordinary runner uses exact derived file regexes and zero retries/default concurrency; callers cannot narrow it. `verify-local` checks source identity and every actual case, rejecting missing/unknown/duplicate/skipped/retried/expected-failure results even when Playwright returns zero. Failure receipts remain on disk. +- Local browser PASS always carries post-deploy `NOT_RUN_LOCAL`, delivery INCOMPLETE and no release eligibility. Frozen protection PASS means the new boundary assertions passed, never that the old travel chain ran. +- The offline post-deploy validator needs live profile, the same source candidate/deployed candidate, and complete original Playwright results for all public obligations. It does not establish remote identity independently, grant release authority or run network calls. R1 stage/source/environment enforcement remains a separate open task. +- Focused diagnosis stays focused. Keep original failed ordinary runs; later focused passes do not rewrite them. Actual owners and public evidence remain pending until supplied through authorized work. +- Travel is retained/frozen, not retired: current employee/finance mutation permissions and shared payment blocker are protected without restoring access. The managed synthetic runner enables travel sync for existing low-level regressions; do not claim all integration APIs are disabled or that finance managers lack `integration.manage`. diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 6d169cfb..9c15cd29 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -1,5 +1,13 @@ # Frontend React / Vite / Design +## Convention: Invoice Detail Selection Has One URL State Source + +- `InvoicePoolPage` reads its selected invoice from `useSearchParams().get("id")`. Upload success, list detail selection and return-to-list update that parameter through the router's functional setter, preserving other parameters and the existing replace semantics. +- Do not mirror the ID into `useState` and synchronize both directions with effects depending on both values. An upload changes one side first; opposing effects then exchange old values, repeatedly unmounting the detail and making manual correction unusable. A visible OCR banner alone does not prove a usable detail. +- Keep OCR polling, form edits and invoice permissions unchanged. The management correction case uses a finance identity with `invoice.manage`; ordinary employee reimbursement uploads remain separate coverage. +- The retained `invoice-ocr-flow.spec.ts` correction case must execute in the managed mock/jobs profile: missing fields reject premature availability, correcting them makes the invoice available without reload or forced click, and returning to the list then reopening preserves the result and selected URL. A conditional skip cannot serve as this regression's evidence. + + ## Receipt Verification Dialog - When readiness is unmet and an active receipt has a system/key/snapshot failure, @@ -318,6 +326,7 @@ test.beforeEach(async ({ page }) => { - Standalone runner: `bash scripts/qa/run-pwa-cross-version-upgrade.sh`. - Full-suite owner: `bash scripts/qa/run-web-gates.sh full-local`; both runners use `scripts/qa/pwa-cross-version-fixture.sh` to prepare the same temporary A/B inputs. Missing inputs remain a collection error, never a skip or implicit exclusion. - Build inputs: two distinct 12-character synthetic `VITE_BUILD_SHA` values, written to temporary outDirs rather than `client/dist`. +- Every Vite production-build subprocess sets `NODE_ENV=production` explicitly, including A/B fixtures and the managed client build. The application server may remain in `NODE_ENV=test`; inheriting it into Vite can emit `import.meta.env.DEV=true` and suppress real Service Worker registration. Keep the application registration guard and upgrade assertions intact. - Screenshot artifacts must not overwrite registered historical evidence. The internal-readiness and finance-wave4 specs use `QA_VISUAL_OUTPUT_DIR` as a root and create unique per-spec directories with `mkdtempSync`; standalone defaults also use fresh ignored directories, never the historical 07-18 directory. These are output-path changes, not authentication or assertion changes. Both discovery and full-browser runners supply the current report's visual root. - Full-local's global environment is runner-owned and natively online without public egress. Its separate unmocked fresh-context login probe requires a visible form, zero refresh requests and independently recorded native online samples. A visible form after refresh 401 is not equivalent evidence. Do not instrument AuthContext or override navigator.onLine for this check; explicit offline business contexts remain legitimate. Environment probes are not additional business cases, and invalid environment observations cannot be promoted into a product failure inventory. - Browser contract: `serviceWorkers: "allow"`, one dedicated random-port loopback HTTP origin and one BrowserContext; the spec declares serial execution. The standalone runner keeps one worker; full-local retains its configured file-level concurrency and explicitly passes zero retries even under CI. Do not serialize the whole suite without evidence of interference and separate approval. @@ -334,6 +343,7 @@ test.beforeEach(async ({ page }) => { ### 4. Validation & Error Matrix +- Build-command capture tests must prove both A/B subprocesses receive `NODE_ENV=production`, even when the parent is a test server. A static command check does not replace the real same-profile browser upgrade witness. - A build artifacts are not distinct -> `PAUSED`; the harness cannot prove an upgrade. - A controller or precache is absent -> `PAUSED`; B would be a fresh-install observation. - A is established and B does not converge without a prohibited action -> `PRODUCT_FINDING`; open a separate PWA product task before changing runtime code. @@ -2416,11 +2426,11 @@ button text squeeze. - Event name: `hyb-oa:pwa-update-ready`. - Event detail: `{ applyUpdate?: () => void | Promise }`. - Route predicate: auth input pages must be checked by pathname before calling - `updateServiceWorker(true)`. + the captured controller-update refresh handler. ### 3. Contracts -- Auth input pages must not auto-reload when `onNeedRefresh()` fires. +- Auth input pages must not auto-reload when a replacement native controller activates. - Auth input pages must show an explicit update prompt with a user-triggered refresh action. - Non-auth pages may keep automatic service-worker update behavior when the @@ -2469,15 +2479,16 @@ button text squeeze. #### Wrong ```ts -onNeedRefresh() { - void updateServiceWorker?.(true); -} +serviceWorker.register("/sw.js").then(() => { + // Too late: navigation may have installed and activated the worker already. + serviceWorker.addEventListener("controllerchange", reload); +}); ``` #### Correct ```ts -onNeedRefresh() { +function onActivatedReplacementController(applyUpdate: () => void) { if (isAuthInputPath(window.location.pathname)) { window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, { detail: { applyUpdate } })); return; @@ -2983,3 +2994,13 @@ authorization and independent workbook parsing tests separate from browser mocks Wrong: a visible action or a generated file proves money was paid. Correct: file generation, bank execution and OA payment registration are distinct events. Synthetic browser/backend/container checks do not prove real bank acceptance. + + +## Native PWA controller ownership (09-18 local concurrency fix) + +- Capture the current controller and subscribe to native `controllerchange` before asynchronous `register()`. Use one registration owner (`injectRegister: false`); do not combine generated registration with application reload ownership. +- Wait for the observed controller to activate and verify it is still current. Ignore redundant/superseded/duplicate events. Initial installation dispatches offline-ready without reload; an existing controlled page applies a replacement once. +- On auth input routes, dispatch the existing update-ready event and retain typed input until explicit user refresh. Background-to-foreground build freshness keeps its existing user prompt. +- Controller update clears only app API/static runtime caches. Workbox owns precache revisions. Explicit logout/manual refresh can retain the broader existing cache-clear helper. +- Failure witnesses record observations or null, not invented default false/zero. Build B evidence requires completed performance entries for B-only resources plus B-only cache entries; SPA navigation events are not a document reload count. +- Validate native lifecycle unit cases, full auth UX and the same-profile A/B browser witness. Old running client code cannot be retroactively fixed; first rollout still needs freshness/refresh verification. diff --git a/.trellis/tasks/08-23-isolated-backup-restore-drill/design.md b/.trellis/tasks/08-23-isolated-backup-restore-drill/design.md index ca813f69..6766c6b5 100644 --- a/.trellis/tasks/08-23-isolated-backup-restore-drill/design.md +++ b/.trellis/tasks/08-23-isolated-backup-restore-drill/design.md @@ -1,5 +1,110 @@ # 隔离备份恢复演练设计 +## 2026-09-19 关联消费者边界(文档澄清,无执行) + +K0 的7类输出仅验证加解密合同,不验证脱敏显示,也不等于所有 crypto 消费者的异常/null 分支均安全。Claude 的静态 PASS 按此范围接收;原合成运行和冻结包保持。 + +通知迁移脚本 `server/src/scripts/migrate-notification-channel-secrets.js` 顶层加载 dotenv/config,创建 Prisma 并连接库;--encrypt/--decrypt 都会逐条 update,后者还会将解密值写回旧明文字段。因此仅可只读源码盘点,禁止在 K0 或默认只读 K2 中 import/require/运行,也不能把其执行当作密钥验证。K1只证明保管可用性,不涉及该工具。 + +未来 K2 的字段绑定若包含通知渠道,需按来源 schema 明确 config_json 中的适用加密字段和只读样本上限,验证出口仍仅类别/数量/通过-失败-不适用;旧明文、空字段或不适用字段不能冒充解密通过。不修改通知渠道记录、不启动发送服务;真实迁移/轮换另立经审阅和批准的操作方案,不与只读恢复验收捆绑。 + +## 2026-09-17 K0/K1/K2:原密钥恢复最小设计(K0已批准,K1/K2另批) + +源码依据:`server/src/config.js:143`字段密钥缺省空;`server/src/services/crypto/index.js:8`解析base64后要求32字节,原格式为AES-256-GCM的`v1:`载荷。此格式不含key id,不能由数据库推导原密钥;32字节只证明长度,不证明生成方式或随机性。沿用`deploy/trialrun/env-recovery-classification.json`的preserve_original及现有runbook,不改分类、生产密钥或产品加密协议。 + +K0:只加载生产加密函数与本地模板/分类,不加载真实.env。用临时合成密钥/密文覆盖正确、缺失、错误、篡改、空字段和不可解析输入;验证脚本捕获异常后只报固定类别,不调用可能带原始异常的日志分支。保管合同只记录双角色可用状态;本地模拟保管成功不能写成K1通过。不改已冻R3/app-check的“非空密钥拒绝”,新增验证只在新独立补件中实现并复核,禁止覆盖旧包使旧批准扩大。 + +本地落点:`scripts/qa/check-sensitive-recovery-contract.cjs`与`server/tests/unit/services/sensitiveRecoveryContract.test.js`。入口仅在内存生成合成材料,拒绝命令行参数,不读取环境密钥/配置/备份;直接复用生产`resolveFieldEncryptionKey`、`encryptStringOrThrow`与`decryptStringOrThrow`。生产解密返回null不等于成功:空字段是NOT_TESTED,畸形非空值是STOP;错误密钥或认证篡改异常只转固定DECRYPTION_FAILED,不使用带原始Error日志的safe分支。PASS要求合成明文精确匹配,报告保留真实来源UNKNOWN、custody/realDataDecryption为NOT_TESTED。此补件不是K2真实输入接口;不增加产品加密协议、严格编码或业务字段格式的覆盖声明。临时Buffer清零不代表JavaScript字符串或进程内存已获安全擦除保证,真实秘密载体仍须另审。 + +K1:实际保管渠道优先复用既有仓库外共享保管或加密封存,由管理员与公司责任人取用;若无渠道,只提交最小建立方案,不能先创建或导出Secret。原值如需在受控环境内比对,只输出match/mismatch/unavailable,不输出独立密钥摘要、密文示例、账户或保管路径。未批准前只整理空白检查清单,不搜索秘密值或历史命令。记录保管能力,不要求用户证明未知生成历史。 + +K2准入:K0通过、K1具备可用原密钥、安全注入方式经过独立只读审查,并得到具体一次操作批准后才执行。重新核最新合格源/镜像/平台/时效/容量;上次影子库已删除,不为省事复用已消费入口。若需要重建影子库,明确计入新批准范围,不称为只补一个无需恢复的按钮。 + +拟定隔离边界:不发布宿主端口,非root/只读根/无外网、禁后台任务和持久日志;不把Secret放进命令参数、Docker环境元数据、源码、输出或普通备份。经批准由管理员通过短时只读秘密挂载或等价受保护内存输入提供原密钥,最终选择及跨主机传输方式必须在K2绑定审查中明确,不能将这两种机制当作都已获批。关闭xtrace/core dump,失败也精确清理本轮秘密载体和影子资源,不删除原保管副本。 + +从恢复库只读选取批准字段类别/数量的密文;验证认证解密及既有格式约束,只输出类别级数量、通过/失败/不适用,不回显明文、尾号或原始异常。只读DB身份、禁止通知/OCR/外联与停止条件沿原合同;未纳入字段类别保持NOT_TESTED。历史缺件、真实密码登录及完整RTO边界与解密结果分开,不因K2成功把全部灾备改为PASS。 + +回退不是换密钥:本计划不改生产配置/密文,失败处置为停止、销毁本轮受控临时载体并保留脱敏失败原因;旧备份、旧钥和旧制品不动。若必须修改产品注入接口或密文协议才能安全执行,则暂停K2并另审该变更,不在演练现场临时补代码。 + +## 2026-09-17 K1操作提案与K2绑定清单(准备,未执行) + +K1先由系统管理员和公司责任人在既有受控保管界面分别核验该保管项是否可取用;代理不接触密钥值,不打开真实配置、不搜索shell历史、不建立新传输通道。进入前列明两类操作者、既有渠道类别、核验时间窗口、仅可用性检查范围和暴露风险;取得该具体范围批准后才实际访问。每个角色只回传AVAILABLE/UNAVAILABLE及时间,双角色同一原值的匹配仍为NOT_TESTED,K1可用不等于备份可解密。若仅有一人/原主机保管或无法取得,则停止并提出仓库外共享保管或加密封存的另审建立方案,不能自行复制或换钥。 + +K2先填下列绑定项;目前均未取得新现场事实,不能将旧R3参数或过期源填入当作现成方案。 + +| 绑定项 | 必需事实与进入条件 | 当前状态 | +| --- | --- | --- | +| 备份与制品 | 最新合格成对源、manifest/来源版本/实际目录、同源镜像及平台、新鲜度与容量 | NOT_BOUND | +| 适用字段 | 按来源schema列出实际存在的加密字段类别及只读样本上限;银行/身份/通知/核对材料分别说明,零适用样本为NOT_TESTED | NOT_BOUND | +| 读取身份 | 专属只读DB权限;不启动任务、OCR、通知或业务写入,无外网、无宿主端口 | NOT_BOUND | +| 秘密输入 | 管理员提供短时只读挂载或受保护内存输入,选择一种并审查传输;禁argv、Docker环境元数据、持久日志、core dump | NOT_BOUND | +| 验证出口 | 仅类别/数量/PASS-FAIL-NOT_TESTED;错钥/损坏停止且无原异常/密文/明文/密钥摘要 | NOT_BOUND | +| 资源与清理 | 一次性资源精确身份、异常清理、秘密载体销毁与原保管副本保留;清理失败保留未完成状态 | NOT_BOUND | +| 审阅与授权 | 候选摘要绑定独立只读意见、具体一次新恢复范围与批准;重建影子库计入范围 | PENDING | + +本地源码显示存在银行账户、身份、通知与付款核对相关加密用途;具体源schema和实际非空字段尚未核实,不把当前HEAD字段集合当作未来备份覆盖证明。完整附件、历史缺件、原密码登录与完整RTO仍分别验收。 + +## 2026-09-17 b1新源匹配ARM64镜像准备(已获构建批准) + +用户已明确批准本节固定b1源码的ARM64镜像构建。批准仅涵盖构建及输入/产物元数据验证,不启动应用、不部署、不恢复、不读生产密钥、不删除旧制品;不再重复索取同范围批准。08:45 CST构建及元数据回读已完成,详情见implement最新回执。 + +最新已核ARM自然源manifest d9a23d59…、来源提交b1d25d3c9939c0020e7b4f75868cd97c35c16ba8/树a9f1c8abd16b260d23ee38508b3ff3c51710371e;生产原image9a9d2dfd…为amd64,ARM没有对应可证明制品,不能以旧b7的arm64镜像替代。两源码的实际部署Dockerfile、依赖锁、BIFF8 requirements与71项迁移集合/校验和相同,应用源码仍不同。 + +本轮批准一次b1原生ARM64镜像构建:仅导出该固定提交的client/server/scripts/部署Dockerfile/.dockerignore,不含工作树差异、.env、密钥、真实备份或业务资料;沿前次已验方式,将唯一FROM固定到已在ARM的node@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32,其余Dockerfile不改。执行前再核base身份/平台和可用资源,不擅自拉另一base或启用仿真;构建所需依赖网络与后续恢复禁外联网络分开。 + +只创建本轮专属b1镜像/构建缓存及必要临时源码上下文,固定BUILD_SHA/VITE_BUILD_SHA与OCI revision;登记源码逐件/锁文件/构建上下文/镜像摘要,构建上限30分钟,失败保留脱敏回执、不自动重试。保留旧b7镜像/正式归档/共享缓存,不prune。产物只代表同源码跨平台重建,不是原amd64字节复原;不启动OA服务、不恢复数据库、不部署、不读生产字段密钥或修改业务状态。 + +成功后按新制品身份完成源/目录/应用配置重绑及补充验证;R3独立内容复核和执行时fresh preflight仍不可跳过。本节固定新版本构建已经另获批准;不把构建批准延伸为任意版本构建、应用启动或立即恢复,原一次受限恢复仍受复核/绑定/现场门禁约束。 + +## 2026-09-17 已批准:已确认历史缺件单列验收 + +当前R2实际恢复在附件引用检查停止,数据库/清理/后验PASS。现已找到09-11原处置证据和当前只读回读:精确一条manual归档发票,无七类业务关联;旧决定仅“文档标记并保留”。09-17用户已明确批准以下最小验收增量;不把当前生产扫描冒充冻结副本的最终归因,独立复核仍在执行前。 + +1. R2包与本次NO_GO回执永久保留。新候选需重新冻结并做增量复核,不直接修改已审包或复用已消费入口;源/镜像/生产版本及24小时门禁仍执行时核验,源变化或过期停止重新绑定。 +2. 历史例外只绑定recordIdentity 363834359af7b655648a2562b80559fa132093ffefaabcddb8cc827a7505a3aa和pathIdentity b22a116257e8b2e3922ee32f366935260c16bebc7fd93dcd672febb7d1fdbc5f,以及原处置证据SHA256;仅承认原件不存在,不接受替换成不同大小/哈希的文件、外部存储、非法路径或重复目标。 +3. 在本轮恢复副本重新验证恰好一条目标、manual归档与归档时间、报销/明细/差旅关联为空,以及其他四类附件表均未引用同路径;任何条件不成立仍NO_GO。其他所有引用继续安全路径/存在/适用大小/SHA检查;不按archived泛化过滤、不剔除检查行、不修补数据库或伪造文件。 +4. 输出总引用数、原始不一致数、明确已接受缺件数与未知不一致数,并保留原校验结果;只有已接受缺件恰好为这一条且未知不一致为0才允许继续既有影子应用只读验收。已有哈希的其余文件必须实际核完,不以当前生产61条元数据正常替代。 +5. 附件结果记为“存在一条已接受历史缺件”,不得写完整性PASS;即使DB/其余附件/app均成功,整体只给包含历史缺件与解密未验的PARTIAL。不新增生产写入、真实密码登录、云调用或敏感解密范围;无hash字段及请假附件覆盖限制保留。 +6. 本地定向验证覆盖精确例外通过、身份/归档/关联漂移拒绝、新缺件/大小/摘要不一致拒绝、未知路径或多个目标拒绝、保留原始计数及失败停止、清理/后验;独立增量复核后才生成新的单次管理员入口。此提案批准可合并本地实施与上述一次受限恢复,不为相同范围反复索取批准;实际sudo仍需管理员终端交互。 + +## 2026-09-16 单次隔离恢复审批清单(待批准,非执行回执) + +本节将P2收敛为有限范围的一次演练,不包含生产回滚/部署。来源为已核ARM自然批次manifest 0913782f84bf7253a8ea1a24e727f8c20a6a637c58737b5df767331809dd2f4e,build b7d2285d、树0ef8f7d5597b1fc425888596241d041585aaccab;应用固定ARM64 image 1d4d70f843976e327106d53d13d45118e22372ac6e71cbe7db96953315b4fed3,PostgreSQL固定已核PG_MAJOR=16的ARM64 image c05eced0bdb41ea9b95a656472a6aa4d50cad0d8a2e33d14eb1c53fd6204f2ae。两者启动前再核身份,不用tag代替digest。源创建于09-15T18:30:05Z,执行时超过24小时则停止并重新绑定最新源/镜像,不自动回退或套用旧批准。 + +### 一次批准的允许动作 + +1. 由具备ARM归档读取权限的执行方式复核固定根身份、三文件集合/摘要、最新性、生产ready及ARM监控;读取归档头计算解压体积,不向开发机传输真实备份。允许本轮私有副本、影子DB/storage、两个隔离运行容器及必要的一次性验证工具;禁止访问其他业务目录或挂载Docker socket到容器。 +2. 创建带唯一runId标签的内部网络和专属空卷,均不发布宿主端口、不加现有网络。DB恢复使用原restore-db.sh的shadow/数据库精确名称/容器ID门禁;附件使用原restore-storage.sh和引用校验器,恢复失败或引用不一致即停,不排除“测试件”。来源迁移名称/校验和从固定提交生成,核固定12类表及附件聚合,不输出业务字段。 +3. 恢复完成后,仅在影子库创建临时只读LOGIN角色:非superuser/createdb/createrole/replication,授CONNECT、必要schema USAGE和SELECT,不授序列使用或DML;影子库撤销PUBLIC的CREATE/TEMP权限并设置角色默认只读事务。此处权限DDL属于明确允许的影子环境准备,不声称“恢复后全部零DDL”;不改任何员工、角色业务表、付款状态或原备份。 +4. 应用环境从空白allowlist组装,使用本轮随机且互不相同的JWT_SECRET、AUTH_CODE_PEPPER和影子DB口令;不读取生产.env/密钥。NODE_ENV=production、TRUST_PROXY_HOPS=1、JOBS_ENABLED=false、OCR_PROVIDER=off、STORAGE_DRIVER=local;EMAIL/WECOM/ERP_SYNC/TRAVEL_SYNC/IM_NOTIFIER/OIDC/OTEL_TRACES/AI_ASSIST等启用项均关闭,未列出的外部Secret/URL不传入。FIELD_ENCRYPTION_KEY保持缺省空值,不用随机密钥伪装可解历史数据。internal网络为主要禁外联防线,不改宿主防火墙/DNS。 +5. 原镜像默认入口启动应用;根文件系统只读、附件卷只读并预建storage目录,专属/tmp可写;不运行migrate/seed/worker。应用使用非root、cap-drop=ALL、no-new-privileges,DB按官方入口所需权限独立配置。日志不持久化真实请求/响应,docker log-driver=none,探针只输出状态/计数;若需诊断,仅脱敏错误类别,不回传原件、账户或完整payload。 +6. 在隔离容器内访问/ready和实际GET /api/reimbursements?scope=own&page=1&pageSize=1:用影子JWT签名秘密生成不超过120秒的测试token,subject/tokenVersion取恢复库中确有报销及expense.view_own资格的active身份,不改其密码/角色,不授管理员通配。只读比较响应数量/选中记录与同身份DB期望,不打印记录内容;匿名请求应401。此项明确是“影子签发凭证+真实认证中间件/权限+恢复库读取”,不是员工真实密码登录、SSO、浏览器验收或生产JWT链路。没有合格身份则APP_READ_BLOCKED,不造账号或扩权。 +7. 完成或失败均删除本轮精确ID/标签匹配的运行容器、临时凭证、私有副本/卷和网络,保留正式归档、构建镜像及共享缓存;不prune、不改sudoers、不长期安装runner。复核源摘要/元数据、生产ready和ARMtimer,清理失败记录cleanupPending,不出整体PASS。清理为逻辑移除,不宣称物理安全擦除。 + +### 资源和停止门禁 + +目标新测可用内存10.24GiB、磁盘87.91GiB、2 CPU,仅为规划参考。启动前要求MemAvailable至少6GiB、磁盘至少20GiB。采用专属容量受限tmpfs卷:DB数据2GiB、附件512MiB、归档副本128MiB;先核tar普通文件展开总量不超过附件限额,DB超容直接失败不自动扩容。DB容器memory=2560MiB、memory-swap同值、cpu=1、pids=256;应用memory=1024MiB、memory-swap同值、cpu=1、pids=256;临时工具顺序运行,memory=512MiB、cpu=1、pids=128。不能实现容量上限则前置停止,不把容量监测误写成硬限制。实际宿主swap及tmpfs支持须预检,既有宿主配置不改。 + +总时限30分钟(超时停止本轮任务并清理),DB恢复最多10分钟、应用就绪最多120秒、单个只读SQL最多20秒/锁等待5秒。资源达到上限、OOM、镜像/源身份变化、归档不安全、出现外部存储引用/缺件或任何未计划写入时停止;不为了通过而扩大范围。RTO分别记录复制/DB/附件/应用/清理耗时,RPO取恢复开始减备份createdAt,不以镜像构建耗时冒充恢复耗时。 + +### 结果边界与批准前本地验证 + +输出DB_RESTORE、STORAGE_REFERENCES、APP_HEALTH、APP_AUTHENTICATED_READ、SENSITIVE_DECRYPTION、cleanup分别状态。因本清单明确不使用生产字段密钥,SENSITIVE_DECRYPTION固定NOT_TESTED,整项完整应用灾备验收保留PARTIAL,不把其余阶段PASS合成为全量恢复通过;后续敏感字段恢复要独立设计受控密钥使用,不再次向用户索要密钥值。 + +原版本源码静态核实:认证只读取employee/roles并检查token_version,报销列表及委托读取为SELECT链;index→buildApp入口有storage mkdir、startJobs,JOBS_ENABLED=false才提前退出,tracing关闭才不启动SDK。已用原config源码和全新合成秘密执行纯配置验证,9项后台/外部开关关闭、OCR off、字段密钥空值PASS;此验证未初始化应用或数据库。实际网络隔离、资源/DB权限、匿名拒绝、带凭证读取与异常清理仍须先做本地合成合同验证,再冻结执行器摘要供已批准范围执行,不能将当前文档称为已可直接运行的恢复脚本。 + +## 2026-09-16 设计接续与前置决策 + +本节优先于旧单纯DB/storage阶段“不启动OA server”的约束。方案仍以ARM隔离为首选,不迁往个人开发机或生产宿主;应用容器是单次恢复申请中的第三阶段,不是当前已获授权的操作。 + +1. P1先核源build/image/platform和ARM平台。镜像与源提交不匹配、缺少匹配平台镜像或ARM不能执行时停止应用阶段;不自动启用跨架构仿真。需要同提交匹配平台构建或其他隔离目标时另列来源/依赖/构建证据及批准范围。 +2. 用原backup-all/restore-db/restore-storage工具及现有校验器,不新建通用runner。固定私有临时副本、专属空卷/网络/容器名,无宿主端口、无生产数据挂载、不接现有业务网络;应用仅连本轮影子库与只读附件副本。采用专属internal网络及已核应用配置同时阻止OCR/邮件/企微/ERP/支付/队列/cron等出站或后台写入,不改宿主共享防火墙、DNS或生产服务。 +3. 隔离库恢复阶段可写,完成后应用验证使用最小权限只读连接;禁migrate/seed/任务启动。逐一确认启动入口没有隐式写入前再启动。需要身份/解密的读取检查先设计受控方式、纳入审批;缺条件时只报已验证阶段,不伪造APP_PASS。 +4. 资源预算基线为已有“批次总字节数5倍+2GiB”,再计镜像归档/解包空间、实际storage解压体积及DB膨胀估计;取保守较大值,不能把压缩包5倍当容量保证。申请时给出CPU/内存/磁盘/超时硬上限、停止水位,精确数值由目标只读测量后冻结,不套用旧89GiB。达到上限先停止本轮写入,再按精确资源清单处置,不清旧备份腾空间。 +5. 清理仅指本轮创建、标签/ID均匹配的容器、网络、卷和临时副本;禁止prune/通配删除、删除正式源归档、既有镜像或共享卷。失败也走清理,若清理未完成记录cleanupPending并停止,不把资源残留当演练通过。 + +无副作用只读核验与恢复写操作分开申请,最终源/目标身份仅进受控ignored操作材料;任务文档只存脱敏标识、摘要和结论。 + ## 设计结论 采用“ARM 最新正式日归档 + 本轮私有临时副本 + 无宿主端口的一次性 PostgreSQL 16 + 空附件目录”的数据恢复方案。腾讯云生产只做演练前后公网 `/ready` 读取,不执行任何远端生产写操作;开发机不接收真实备份。 diff --git a/.trellis/tasks/08-23-isolated-backup-restore-drill/implement.md b/.trellis/tasks/08-23-isolated-backup-restore-drill/implement.md index 506f15da..bdf198b4 100644 --- a/.trellis/tasks/08-23-isolated-backup-restore-drill/implement.md +++ b/.trellis/tasks/08-23-isolated-backup-restore-drill/implement.md @@ -1,5 +1,298 @@ # 隔离备份恢复演练实施计划 +## 2026-09-19 K0 独立补审接收(最新) + +- [x] 接回 b1rubx7qf 原报告 `artifacts/qa-governance/business-followup-claude-review-20260919T023537Z.txt`:K0 **PASS_STATIC_CONTENT_REVIEW**。reviewer 只读源码与既有合成测试,未运行测试、未核候选 SHA、未访问真实资料。 +- [x] 主会话另核原候选 manifest 自摘要、6件冻结文件和当前3个代码/测试输入一致;这不是 reviewer 运行证据。原候选的 PENDING 保留为交审时点状态,本段和新增接收回执记录最新状态,不重签冻结包。 +- [x] 在 PRD/design 明确脱敏不在K0合同;通知密钥迁移入口会读配置、连库并批量写入,排除在K0/K1/K2默认执行之外。未来K2通知字段只按绑定方案只读验密,真实迁移另批;本轮未 import 或执行迁移脚本。 +- [ ] K1双角色原密钥可取用的非秘密事实、K2新绑定与具体操作批准仍待取得;K0 PASS不替代它们,旧恢复不重跑。 + +两项审查观察已通过明确范围完成文档处置,不将未测脱敏、其他crypto消费者或真实通知解密改写为PASS。本轮仅任务文档/元数据,无产品修改或新增测试计数;完整implement历史、已消费恢复入口与原PARTIAL均保留。证据:artifacts/qa-governance/business-followup-reception-20260919。 + +## 2026-09-18 后续清单接回 + +09-06本轮仅本地在用门禁重验,并将K1非秘密保管事实、K2新源/制品/字段/资源/注入清理绑定列入retained-gates-closeout-20260917/independent-and-external-readiness.json。K0独立复核仍PENDING,K1/K2真实事实未查;没有连接生产/ARM、读取真实配置密钥备份或重跑旧恢复。借款及付款本地通过不抵扣保管/真实解密条件,原PARTIAL和历史缺件事实保持。 + +## 2026-09-17 后续推进:K1/K2准备与独立复核交接 + +用户要求按已梳理顺序继续,并补齐缺失规划。本轮授权落实为本地准备与已规划治理;具体保管核验、原密钥取用和新恢复绑定尚无事实,不据此开放生产/ARM或重跑旧入口。K0主会话通过保持,独立审查在inline模式下仍PENDING,不能通过另派检查代理代替真实独立身份。 + +- [x] 固定K0源码、生产crypto依赖和本task合同形成新只读审阅候选,保存文件摘要与审阅清单;不覆盖K0原证据或旧恢复四包。 +- [ ] 接回独立审阅意见:核生产函数复用、错误/空值分流、禁输出秘密及原异常、无外部输入/环境读取、合成与真实结果分离;审阅方重跑须单列环境/命令/结果与候选摘要。 +- [ ] 接回双角色保管渠道类别/可用状态的非秘密陈述;用户陈述只记REPORTED,实际取用仍NOT_TESTED。 +- [ ] 按下述设计审阅具体K1操作;渠道不可用时仅提出补建方案,不先导出/转移/生成Secret。 +- [ ] 具备K1事实后填写K2源/制品/字段与资源绑定清单,独立只读审查后取得一次新操作批准;缺任一项不生成可运行远端命令。 + +K0审阅候选位于`artifacts/qa-layout/followup-governance-20260917/k0-review-candidate/`。本轮后续本地B1/B2移至09-05,K1/K2与独立复核保持明确待办,不把等待条件写成任务全完成。 + +## 2026-09-17 K0本地合同已实施,K1/K2另批 + +规划来自09-08顶部优先级表;用户本轮明确批准仅K0本地合同检查与合成验证,旧一次恢复完成事实不变。官方current已从09-08通过task.py start切到本task并回读为当前会话来源;task仍in_progress,不代表K1/K2敏感操作获批。 + +- [x] 审阅本task顶部PRD/design,取得K0本地实施批准后切官方current到本task并核执行树差异。 +- [x] 运行现有`node deploy/trialrun/validate-env-contract.js`,核preserve_original、双角色保管要求和来源/保管/可恢复三个不同结论;只读模板/代码。 +- [x] 新增最小合成恢复合同测试`server/tests/unit/services/sensitiveRecoveryContract.test.js`及独立本地补件`scripts/qa/check-sensitive-recovery-contract.cjs`,覆盖缺钥/错钥/篡改/空值/成功与输出脱敏;复用生产crypto函数,不改旧冻结runner。 +- [x] 执行`cd server && npm test -- tests/unit/services/sensitiveRecoveryContract.test.js --maxWorkers=2 --minWorkers=1`及涉及脚本的语法检查、环境合同/Trellis/process/敏感扫描;具体新增文件及结果见下方回执。 +- [x] 输出K0本地回执和K1无秘密值的保管检查清单。K1访问或建立保管渠道前单独列操作者、范围与风险申请,不要求用户回忆密钥。 +- [ ] K1具备条件后准备K2新源/制品/字段类别数量/资源/秘密注入及清理绑定包,独立只读复核后集中请求一次具体操作批准;K0通过不预先批准K2。 +- [ ] 真正执行后只登记实际分项和清理结果;错误/不可用/无样本分别停止或NOT_TESTED,未完成条件不勾选。 + +本地阶段不用sudo,不连接生产/ARM,不读取真实密钥、密文或备份。若K1/K2等待管理员操作或具体绑定,回到09-08协调并推进09-05治理;不反复让用户运行旧恢复入口。提交/推送/部署及所有真实数据/秘密操作仍另批。 + +### K0本地回执 + +- 工作树沿用已指定的20260911-detail-layout;进入时HEAD为b1d25d3c、detached,官方current为09-08的session-fallback。初始18份脏文档/元数据及借款planning任务四文件已在ignored本地证据保存原字节/差异基线,不接管借款实施。 +- 现有环境合同PASS:config键150、模板及分类键均166;FIELD_ENCRYPTION_KEY仍为preserve_original,双角色仓库外保管要求与来源/保管/真实恢复三种结论保持独立。没有加载生产配置。 +- 新增30项定向测试PASS(1文件);合成CLI的7类矩阵PASS。覆盖缺失/无效密钥、有效长度错钥、IV/tag/密文三个位置篡改、空/null/缺字段、畸形/未知版本/类型、明文不匹配、原始异常抑制、无日志和CLI无输入/无回显边界。7类CLI检查也是这30项中的覆盖,不累加为37个不同测试。两新增JS文件node --check通过。 +- 执行使用精简子进程环境;仅做元数据检查确认工作树根和server无Vite会自动加载的.env系列及项目.npmrc,npm用户/全局配置使用本轮新建空文件。首次把二者都指向同一空设备被npm以double-loading拒绝,测试尚未开始;拆为两个空文件后30项通过,未读取真实配置。保留启动失败日志,不将其计为产品失败或测试通过。 +- 四份旧冻结包逐项核对manifest、登记字节数/SHA及精确文件集合均PASS:R1为17+2=19、R2为19+2=21、R3为28+2=30、绑定执行包14+2=16,合计86。09-08上轮文字写96,与本次现场计数不同;本轮未发现任何manifest登记件缺失,不推断历史多计原因,也不改旧记录或冻结字节。 +- 新证据归`artifacts/qa-layout/sensitive-recovery-k0-20260917/`,只存本地合同摘要、验证日志及既有非秘密差异基线;没有真实数据/密钥材料。原恢复的PARTIAL、历史缺件1、密钥来源UNKNOWN、真实解密NOT_TESTED继续保留。 +- 收口检查:task validate、Trellis治理(23项task合同、1242份Trellis文件敏感扫描)、process baseline、CURRENT事实源敏感扫描(30份、finding=0)及git diff --check通过。docs registry为0错误、1项既有告警(22份未登记文档),未修理无关文档。未改schema/产品代码,不运行Prisma、前端构建、浏览器或旧恢复测试。当前规约已覆盖保管/恢复边界,本轮细化合同留在本task,不扩写原有脏spec。 +- 范围对账:本轮仅新增上述2份本地QA/测试源码,增量更新本task的PRD/design/implement/task notes及09-08入口implement;其余17份初始文件(含借款四文件)原字节不变,五份更新文件的既有非K0内容保留。四包86件集合/摘要、HEAD及真实index不变;当前指针留08-23供接续,不自动切入下一实施段。 +- 主会话inline实施/检查,未派子代理;独立只读复核仍PENDING,尤其K2真实执行绑定方案尚不存在。本地通过不代表公网验收或K1/K2放行;未连接生产/ARM、未重跑旧恢复、未提交/推送/部署/归档。 + +### K1保管检查清单(空白,未访问) + +只填角色、渠道类别、验证时间和可用状态;不填姓名/账号、Secret及摘要、保管地址/目录或原始凭据。来源历史仍UNKNOWN,可用性必须经另批实际核验。 + +| 角色 | 渠道类别 | 验证时间 | 可用状态 | +| --- | --- | --- | --- | +| 系统管理员 | 待批准核验(未访问) | 未验证 | NOT_TESTED | +| 公司责任人 | 待批准核验(未访问) | 未验证 | NOT_TESTED | + +- [ ] 在访问或建立保管渠道前,明确操作者角色、仅可用性核验的范围、暴露风险与批准;不由代理查历史命令或让用户发送明文。 +- [ ] 另批后确认两个角色各自有仓库外受控取用渠道,且不只依赖原主机或单人记忆;只记录渠道类别(共享保管/加密封存/不可用),不导出Secret作为证据。 +- [ ] 无渠道或原密钥不可用则STOP并提出最小后续方案,不创建新钥替代,不将K0合成成功记为保管成功。 +- [ ] K2另行绑定新鲜源/制品/字段类别数量/秘密注入/清理及独立只读复核,再取得具体一次操作批准;旧入口不再使用。 + +## 2026-09-17 09:33 CST实际恢复完成:按批准范围结束,整体PARTIAL + +用户通过新单次入口完成真实ARM影子恢复。本地manual-bound-restore-result.txt唯一JSON与用户回传的观测时间、review/execution/source三摘要及分项结果核对一致,已脱离终端地址/密码提示保存real-bound-restore-result.json,executor=USER_MANUAL_SUDO。最终PARTIAL_KNOWN_MISSING_AND_SENSITIVE_DECRYPTION_NOT_TESTED,failedStage=null、restoreStarted=true;这不是完整灾备PASS,也不是新的NO_GO。 + +| 分项 | 本轮实测 | 边界 | +| --- | --- | --- | +| 现场预检 | PASS,1.167秒;RPO25372秒(7小时2分52秒) | 新源d9a23d59…、b1d25d3c、匹配ARM镜像;24小时/最新性/资源门实际通过 | +| 准备/数据库 | PASS,1.455/2.677秒 | 真实备份恢复到一次性PG16;71项完整迁移映射核对通过,12类关键表可读 | +| 附件 | PARTIAL,0.873秒;62条引用/1原始不一致/1已接受历史缺件/0未知不一致/0外部引用 | 原严格校验器未过滤归档件;恢复副本重核精确身份/归档时间/七类零关联/实际缺失;其余引用在适用大小/SHA范围内无新异常,非所有文件均有SHA字段 | +| 应用 | PASS_SHADOW_AUTHENTICATED_READ,8.542秒 | 实际镜像健康、匿名拒绝、影子凭据通过真实认证/权限后只读列表与恢复库行身份一致,DB只读权限成立;非真实密码/浏览器验收 | +| 清理/后验 | PASS/PASS,cleanupPending=false,productionWrites=false | 正式归档元数据与摘要、生产ready、监控timer后验通过;只删除本轮影子资源,不改生产或历史缺件 | + +attachmentCount=62是归档/解包普通文件数,不等于62条引用全存在,不能抵消引用中的历史缺件。缺件处置SHA仍5ed1e910…,完整值见实际回执;原R2的09-16 NO_GO及R3冻结包、绑定执行包原字节保留,不改历史结果。两类包的“未执行”是冻结时刻事实,当前执行状态以本节及绑定三摘要的实际回执为准。 + +时间口径:准备+数据库+附件阶段小计5.005秒,加入应用为13.547秒,含预检的已报阶段合计14.714秒;未独立记录清理/后验耗时或精确启动时刻,不将小计称完整RTO/切换RTO,不宣称满足未约定SLA。RPO为现场测值,不是备份频率或业务允许丢失窗口的承诺。 + +01:36Z主会话补做只读元数据后验PASS:单次入口暂存目录已移除,restore-run标签容器0/网络0、临时恢复目录0;备份dead-man与公网健康timer均active/enabled;公网ready仍b1d25d3c。仅元数据与公开健康读取,无新容器、恢复、备份内容读取、生产Secret/业务写入;证据real-bound-restore-postcheck.json。新入口已消费,不重新暂存、不重跑、不再索取本次授权。 + +当前结论:本次批准的受限演练执行/分项验收/清理已完成。完整附件恢复仍有一条已接受且未找回的历史缺件;SENSITIVE_DECRYPTION=NOT_TESTED、生产密钥来源UNKNOWN、保管/恢复能力没有被此次验证证明。后续如验证敏感字段恢复,应另做受控密钥保管与灾备方案,不索要聊天明文、不自行换新密钥;不为清单全绿删除引用或重复实际恢复。AC4完整附件及AC5完整计时/同源滞后指标保留未全满足,task维持in_progress,不自动归档/提交/推送/部署;09-08其他自然业务和到期治理不随本轮结案。 + +本轮只核实际回执、执行只读元数据后验并更新任务/操作手册;任务校验、Trellis(含敏感扫描)/process/docs及diff通过,两级冻结包文件集/逐件摘要未变。未重跑原104项或新增产品测试计数;原54项独立单测与R3静态内容审查继续按原范围记录。本次运行出口已关闭,不再要求用户执行命令。 + +## 2026-09-17 09:28 CST:R3新源绑定包已冻结并暂存,待管理员单次恢复 + +用户交互sudo回执PASS_SOURCE_DIRECTORY_BINDING_OBSERVATION_NOT_EXECUTION,01:18:40Z实测:固定源d9a23d59…、b1d25d3c,年龄6.81小时,DB/storage摘要/字节与归档元数据/timer前后均通过。实际runId为auto-20260916T183002Z、相对目录hyb-oa-backup-auto-20260916T183002Z,完整目录身份3cd4b78f…;createdAt18:30:04不能替代runId的18:30:02。rootIdentity1201d50a…与批准值一致;87.84GiB可用磁盘,memoryGiB11.68是物理总量,不冒称MemAvailable,执行前另核可用内存。 + +本轮继续既有批准内的执行输入定稿: +- [x] 从本地原始终端回执提取唯一JSON并与用户回传关键身份核对,保存脱敏USER_MANUAL_SUDO证据。 +- [x] 重新只读核生产ready/固定ARM镜像版本/资源;从b1提交生成完整71项migration映射并与R3核对,不凭数量假设相同。 +- [x] 新建绑定执行包,原R3代码/helper/历史例外/migrations保持字节或完整映射不变,仅新bundle的源build、manifest、实际batchName及appImage更新;review/execution两级摘要绑定,不改原R3/R2。 +- [x] 沿原一次性入口与暂存方式准备新封装,定向测摘要篡改拒绝、白名单重绑、PARTIAL/NO_GO与清理后验出口;不重用09-16已消费入口,不在生成/传输阶段调用Session.execute。 +- [x] 冻结/校验通过后普通用户暂存并回读一致,交付交互sudo单次恢复命令。实际root现场preflight通过后才写影子资源;源过期/变化仍停止,绝不自动回退或扩例外。 +- [x] 管理员执行新入口,接回实际DB/附件/应用/清理/后验结果并分项判定;详见09:33 CST最新记录,整体保留PARTIAL。 + +此回执本身未创建容器、未恢复/解包、未修改生产;不把它升级为真实恢复PASS。既有一次受限恢复批准不重复询问,最终敏感解密仍NOT_TESTED,有历史缺件时最高PARTIAL_KNOWN_MISSING_AND_SENSITIVE_DECRYPTION_NOT_TESTED。 + +01:22Z新增只读前提PASS:生产ready仍b1d25d3c,ARM app5d279fef…/db c05eced0…分别核实linux/arm64及BUILD_SHA/PG_MAJOR16;可用内存10.25GiB、磁盘87.84GiB、swap0。b1来源71项迁移名及逐件SHA组成映射与R3完全相等,规范化映射SHA8adf634b…,不是只对数量。该观察不替代实际执行时preflight,未新建容器或读生产Secret。 + +新绑定包restore-once-execution-20260917-r3-b1:14登记文件+manifest两份,执行manifest SHA f7cf4d62f58f540f950570518568f70aa1f44db328f58a769566f7e5a5e4acce;review-manifest仍33001f58…。原R3执行器及四helper逐字相同,原review-bundle保留;新bundle只改plan的appImage/batchName/build/manifestSha256四项和executionBinding,完整迁移映射/历史处置/其他计划不变。新包独立状态为PASSED_STATIC_R3_CODE_ONLY,封装/绑定增量只有主会话检查,不伪称已独立审新入口。 + +新入口approved-bound-restore-entry.py为170741字节、SHA011b42bd282b7fccc8ee0953c034da66973e28e222cf1c0db3948a326437db2e;装载先核执行manifest及全部payload,再核R3原manifest/code/helpers/旧bundle、白名单变更及源/镜像/配置/迁移证据,之后才取得Session类。12项入口合同通过,含每份payload篡改拒绝、装载零外部调用、非白名单改动/目录和摘要漂移/配置或迁移变动拒绝、两类成功仍PARTIAL、清理/后验失败非零。 + +01:28Z仅普通用户暂存新入口,上传摘要及另次回读一致;暂存目录身份8e34a582…,无真实数据,rootExecuted/restoreStarted均false。新shell run-approved-bound-restore.sh SHA55589a0424b445a5632bb5a0906da58b8691a01793a17171bcf1ae4cf2cc33fa,输出manual-bound-restore-result.txt,TTY/sudo/严格host-key/noclobber单次门;root loader再核入口摘要和实际暂存目录身份,先消耗入口并移除专属空目录再执行。4项传输合同通过:正确摘要只消费本入口、篡改/错目录不执行、意外文件保留且不执行;旧暂存/旧入口未触碰。暂存文件有意保留等待管理员,不称已清理完毕;真实执行前会移除。 + +管理员应在项目根运行artifacts/isolated-worktrees/20260911-detail-layout/artifacts/qa-layout/postrelease-recovery-20260917/run-approved-bound-restore.sh。这是已授权的真实影子恢复,不是只读选源;先现场预检,任何新增不一致/源变化或超龄都停止,不自动重跑。即使成功仍保留历史缺件/解密未验PARTIAL;正式归档、生产数据/配置/权限、旧镜像零修改,未提交/推送/部署。 + +最终主会话整批104/104通过(4.757秒,原88+新封装12+传输4),不新增独立通过计数;源R3/新执行包文件集、逐件摘要、入口/shell摘要和语法、Trellis/process/docs及diff门禁均通过。bound-final-check.json状态READY_FOR_MANUAL_SUDO_FRESH_PREFLIGHT,实际rootExecuted/restoreStarted仍false;HEAD/index不变,暂存无真实数据并等待一次消费,未把暂存当成恢复成功。 + +## 2026-09-17 09:11 CST配置专项通过,目录绑定只读入口已备 + +用户要求继续推进。本会话找到并读取restore-once-r3-claude-review-20260917T005959Z.txt完整外部报告:六项静态内容审查通过、无阻断项;与先前用户转回的独立完整性及54项重跑分开登记。报告明确未运行测试/未重算哈希/未做真实恢复,不能把三类不同证据混成一个独立执行结论。冻结R3不改,外部报告摘要另附补件记录。 + +三条信息项按已知接受登记,不为清理卫生改动重写已审候选:F1先核历史目标后核总失配数,额外失配仍NO_GO;F2只读探针存在不可达production分支,硬编码arm使之不可达;F3测试跨目录fallback,本包必需文件齐全/摘要已核,实际不触发。后续收敛时再处理,不扩大历史例外。 + +本轮清单(已有受限恢复准备范围,不是新恢复启动授权): +- [x] 用固定b1镜像的一次性network=none、只读根、非root、禁日志/挂载的工具容器运行原配置检查器和冻结app-check非空密钥拒绝分支;只用新生成合成秘密,不启动HTTP服务、不连DB、不读备份/生产Secret,精确清理。 +- [x] 原R3只读选源探针保持不变,另备最小增量的目录绑定探针,输出实际runId/相对batchName并绑定已核源manifest/版本;超龄/漂移/完整性失败仍停,不从createdAt推导目录。 +- [x] 验证并生成交互sudo只读入口;root读取不能由代理绕权限或索要密码,无管理员回执前保持SOURCE_DIRECTORY_BINDING_PENDING。 +- [ ] 接回目录身份后再冻结全量新bundle/执行输入,现场preflight后才恢复;不重用旧一次入口,不修改R2/R3字节或提前宣称恢复PASS。 + +外部完整内容报告SHA256 fd58e49199f4172cb3e3544c35f909b7424dae6bc422feb597c5a560631fa07b,已读取原件;静态内容PASS与用户转回的54项独立重跑分别有据,后者原日志仍未取得。本轮不改包内PENDING字节,以本节及binding-preparation.json附录标注PASSED_STATIC。不是全恢复验收,新增准备器未包含在该外部内容审查范围。 + +01:10:49–01:11:08Z实际ARM固定image5d279fef…纯配置专项exit0:原check-image-config.cjs SHA41d213ec…、config SHA a3b62630…、冻结app-check SHA7e56f081…一致,真实镜像依赖、无替身;字段密钥缺省空、合成非空值被app-check拒绝,未启动HTTP应用/DB、未读取真实备份/生产Secret。工具容器512MiB内存/同值swap上限、1CPU/pids128、非root/只读根/cap-drop ALL/no-new-privileges、network none、无挂载/宿主端口、log-driver none,启动前及结束后实际inspect核验;精确ID/label清理PASS、cleanupPending=false,回执arm-image-b1-config-check.json。 + +目录绑定补件source_binding_probe.py在原R3探针外另存,source-binding.delta.diff可重放:增加实际runId、相对batchName与完整路径摘要;只观察最新规范批次,不向旧源回退,规范名下最新symlink/非目录拒绝;不重复镜像全盘盘点。最终要求固定manifest d9a23d59…、b1版本/原amd64 sourceImage、时效≤24h、DB/storage完整性与归档/timer未变,成功仅为PASS_SOURCE_DIRECTORY_BINDING_OBSERVATION_NOT_EXECUTION,绝不写成已恢复或可跳过preflight。原R3不可达production分支保留,不借此清理已审代码。 + +只读入口read-arm-source-binding.sh绑定probe SHA e6f9cd8517c7860ad19ee00d86b2b21b4ef3704e17c3abf434fb2dd913c4ccb6;自身SHA b9c3f4724e460eb1560d69a5369a952cbce660e030425fa701907d47830063a8。前后摘要闸、StrictHostKeyChecking、交互TTY、输出noclobber;仅内存传入sudo python3 -I,无远端安装/容器/解包/恢复。与已消费refresh-arm-source.sh为不同输出,不改旧入口。尚未执行root探针、无实际runId证据;用户在项目根运行该入口并回传JSON后才可定稿bundle,不猜目录秒数。 + +新增5项配置隔离单测、10项目录绑定单测、4项入口合同全部通过,合并此前69项为88/88(4.571秒,主会话);命令为PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest discover -s artifacts/qa-layout/postrelease-recovery-20260917 -p 'test_*.py' -v。测试均合成,只有上述配置专项启动过无网工具容器;新源未解包、无真实恢复/敏感解密/业务写入/提交/推送/部署。R3的30件字节和manifest33001f58…再次核对不变,原19项PG不重跑、不冒增独立计数。 + +## 2026-09-17 R3独立完整性/54项重跑回执已接回,内容审查仍进行中 + +用户转回独立执行方报告:R3 manifest33001f58…自摘要、28份登记文件摘要/字节数/文件集、前驱R2 076078be…、R2执行器/manifest及五份helper/原测试字节保留、两份delta重放均PASS。独立方在包外以PYTHONDONTWRITEBYTECODE=1/python3 -B先导入unittest/unittest.mock,再discover全部test_*.py;EXIT0、54项、4.557秒,23+28+3与交付日志逐名一致。包内30件跑前后摘要不变、无pycache;日志仍在独立方scratchpad,摘要短标afa5c7d9…,本会话未取得日志原件,不冒称亲自独立执行或已验日志全摘要。 + +本会话仅重新核本地R3完整manifest及28份文件摘要/字节,均一致,未重跑这54项、未新增测试通过计数。转述中历史处置摘要简写尾部与实算值不同,完整值以包内5ed1e910408b32d336235729850cb35238bb3ee15b587618c0a56417f2bee8fd为准;原manifest校验及处置文件均未变,不按简写修改冻结身份。 + +当前分项:包完整性独立PASS;54项单测独立重跑PASS(用户转述回执);内容审查IN_PROGRESS,外部会话b1ferktxd尚未给结论。19项最小合成PostgreSQL合同仍仅主会话执行,独立方只读元数据未重跑;b1构建新增15项同样不是本次独立54项范围。冻结包内independentContentReview=PENDING保持,审查进度在包外本节登记,不提前写成整体放行。 + +继续等待内容审查结论,同时保留新源runId/实际目录绑定、新镜像实际配置专项、重冻执行输入及现场fresh preflight门禁;这份中间回执不授权跳过任一项,不生成或运行第二次恢复入口。未改R3/R2、源码、生产或ARM资源,未提交/推送/部署;用户无需重复批准既有范围。 + +## 2026-09-17 08:45 CST:b1原生ARM64构建完成(未启动/未恢复) + +本轮用户批准固定b1d25d3c/树a9f1c8ab与既有node digest的同源码ARM64镜像构建,不包含部署、应用启动、真实恢复或Secret读取。主会话直接实施和检查,不派实现/检查子代理、不冒充独立复核。复用本task清单,不改原R2/R3冻结包或已消费恢复入口。 + +- [x] 准备独立的一次性b1构建器,验证源码/归档/固定base与资源停止门禁;不依赖b7旧失败回执。 +- [x] ARM现场核base/平台及至少6GiB可用内存、20GiB磁盘,固定30分钟构建时限;失败记账、不自动重试、不prune。 +- [x] 产物平台、BUILD_SHA、OCI revision及未启动应用/旧制品保留元数据核验,保存源码/锁/上下文/日志/镜像摘要。 +- [x] 更新本task与09-08协调记录,保留R3独立复核及新源最终绑定待办,不把构建称为恢复PASS。 + +实际一次构建exit0,00:44:10–00:45:16Z全程66.691秒(含源码准备/SSH预检/后验,不是恢复RTO)。产物sha256:5d279fefadee0e6d8d0e9f13c80a521165d495d2a141679ca276aeba51489d79、1075296688字节,linux/arm64,BUILD_SHA/OCI revision均为固定b1提交;镜像无FIELD_ENCRYPTION_KEY/DATABASE_URL/JWT_SECRET/AUTH_CODE_PEPPER非空配置,容器数0,旧b7镜像保留。后验内存可用10.23GiB、磁盘87.84GiB、swap0,未清共享缓存;构建日志9处CACHED,因此不声称全新依赖安装或原amd64制品字节复原。 + +输入1103份固定Git文件,只有部署Dockerfile首个node FROM改为批准digest;原Dockerfile SHA1699c6aa…、修改后26b5a2b2…,三份依赖锁摘要与原批准记录一致。gzip上下文2371581字节、SHA8cfab7a816ced0fe330d9b3202cb2a8165439885f019efa7a36de9b0d6ba7767,逐源列表SHA6b0d5f62…,构建日志SHAbec77d0a…;完整值留在postrelease-recovery-20260917/arm-build-b1-result.json及source-hashes文件。仅源码上下文通过stdin传往ARM,无真实备份或生产配置传输。 + +本地新增15项保护单测通过,合并原54项为69/69;最初两例mock把docker inspect数组写成对象导致测试失败,已仅修测试夹具并保留初次失败日志,未因此发生ARM构建重试。覆盖归档范围/路径/链接/秘密文件、FROM最小替换、固定base/资源/产物身份、失败预检不build、已有回执拒重跑、构建失败不重试及成功不启动应用。主会话执行,不算独立复核;未新增DB集成、浏览器或应用运行计数。task/Trellis/process/diff通过,R3 manifest33001f58…及28登记文件共30件原字节保持。 + +剩余门禁:R3外部内容复核仍PENDING;取得新源同manifest的runId/实际目录身份后重冻执行输入,补新镜像实际配置专项,再现场重核源/最新性/资源。当前源有效期至09-17T18:30:04Z;超龄则重新核源,不回用旧b7、不改R3冻结字节。没有生成新恢复入口,没有第二次真实恢复、生产访问/写入、Secret读取、部署、提交或推送。 + +## 2026-09-17 08:15 CST新源只读回执已接回:数据源完整,b1匹配镜像待批准准备 + +用户手工sudo只读回执与本地脚本输出JSON核对一致,已脱离终端地址/密码提示保存arm-fresh-source-readback.json(executor=USER_MANUAL_SUDO)。ARM固定根身份1201d50a…一致,最新自然源createdAt=2026-09-16T18:30:04Z,manifest SHA256 d9a23d59fb31e3807ed720efb89a4219c466763551e6aa897a5532c40d496b65,来源b1d25d3c,实测5.75小时;DB3186135字节、storage17880399字节,均通过manifest摘要/字节检查,源元数据与timer前后未变。旧09-15源29.75小时超龄,不回退。此结果是配对归档完整性,不是新源已恢复或应用PASS。 + +新源sourceImage=9a9d2dfd…与当前生产image、BUILD_SHA只读核验一致,原制品linux/amd64,ARM清单不含此image。ARM97个image ID与上一轮版本盘点完全相同,只有b7对应1d4d70f8…,没有可证明对应b1的ARM64应用镜像。普通账号资源再读MemAvailable10.25GiB、Docker余87.88GiB、2CPU、swap关闭;只读,无build/pull/load/恢复或配置更改。 + +本地来源树a9f1c8abd16b260d23ee38508b3ff3c51710371e;b1与b7的71项migration名称及校验和全相同。实际部署Dockerfile deploy/trialrun/Dockerfile、.dockerignore、前后端package-lock及BIFF8 requirements逐字相同。最初兼容性探针误以server/Dockerfile作检查路径,不能证明实际Dockerfile;已以fresh-source-build-inputs.json对真实五个路径逐个Git blob比对补正,原回执不冒称该项充分。schema/构建入口一致不等于应用镜像版本可混用。 + +下一项单独受保护动作是b1同源码ARM64镜像构建,范围见design新节;未获此构建批准,不执行Docker build或仿真、不读取Secret。R3内容复核仍PENDING,manifest33001f58…保持原字节;新源绑定/制品身份另行附包,不把R3的PENDING_NEW_SOURCE改一字段当放行。当前只读回执未输出runId/实际目录名,不能由createdAt推导;后续绑定须从已核manifest.runId或同摘要目录现场取证,未取得前不猜测可执行batchName。 + +新源24小时有效期到2026-09-17T18:30:04Z(北京时间09-18 02:30:04),实际执行仍重新核最新性/完整性/资源/生产版本;过期或新源变化则停止重新绑定。已批准的精确历史例外和一次受限恢复不重复索取,匹配新源码制品的构建须单列明确批准。证据均在postrelease-recovery-20260917,未提交/推送/部署。 + +## 2026-09-17 精确历史例外实施(已批准,未重跑恢复) + +用户批准本地修订、测试并独立复核后一次受限恢复,不重复索取同范围批准。执行顺序:保存R2/原NO_GO→工作副本实现精确归档身份/七类关联/真实缺失校验→沿用原严格校验器,保留全部引用和原不一致计数→单测与真实合成PG查询验证→冻结增量供独立复核。例外成功也只给PARTIAL,新增不一致仍阻断应用;不改产品代码或生产记录。 + +本轮北京时间09-17晨间接续,旧固定源已过24小时,原入口已消费且不可重用。只读刷新最新自然源事实,不把旧ARM镜像当成新备份匹配镜像,不启动实际恢复或擅自构建新制品。R3若绑定前提未齐,仅作为不可执行的内容交审包,清楚标出source binding待补,不伪装READY。 + +实施完成:原严格附件helper保持原字节和全量输入,执行器仅修改preflight/verify_storage/execute三个既有方法,新增处置证据/精确查询/实际缺失三方法;其余14方法AST相同。先记录rawMismatches,再在恢复库验证唯一记录/路径、manual归档且归档早于备份、七类关联全部false,检查实际文件不存在且路径无符号链接/非法父节点。只有一条明确历史缺失且无其他不一致才继续app,storage及整体保留PARTIAL;若原严格检查零不一致则不虚构例外。未绑定源的bundle在preflight以SOURCE_BINDING_PENDING拒绝,不生成恢复启动器。 + +验证:主会话54/54(原23源码未改+例外28+只读选源3)通过,冻结副本再跑同54项亦通过,不计108个不同测试;真实本地PostgreSQL16最小合成schema验证19项,含实际新SQL/原helper、长路径base64、七类关联、身份/时间/重复、额外缺件与大小/SHA、空SHA、只读拒写,临时容器及文件清理PASS。未运行R3完整app/JWT/真实恢复,不把最小schema验证称生产全库复验。syntax、task validate、Trellis/process、敏感扫描及diff门禁通过,docs仍0 errors/22项未登记警告,不处理无关文档。 + +只读刷新:普通生产账号读取归档ACCESS_UNAVAILABLE,生产build仍b1d25d3c,timer本自然周期success/0;这不证明新的成对备份完整。ARM镜像元数据盘点集合未变,仅已知b7的1d4d70f8…匹配,没有可证明对应b1的镜像。新自然备份build/manifest仍待ARM管理员只读回执,禁止推测后直接重建或套旧镜像;本轮没有新增ARM镜像/容器或真实恢复。 + +R3交审包artifacts/qa-layout/restore-once-review-20260917-r3已冻结28份登记文件,manifest SHA256 33001f5894ab48c99cac92c388837fcb1b5c549536f0ee29ccedb010d1017100,指回R2 076078be…;两份差分重放与快照一致、逐文件摘要/字节检查PASS,权限644。状态CONTENT_REVIEW_ONLY_SOURCE_REBINDING_PENDING,independentContentReview=PENDING;本会话主会话检查不冒充独立复核,不派检查子代理。bundle仍保存过期R2计划作审计参考,并有PENDING_NEW_SOURCE执行闸,不可直接修改字段后运行;重绑须重新冻结执行输入。 + +管理员只读选源入口postrelease-recovery-20260917/refresh-arm-source.sh已准备,probe SHA e835bc0155aba978447822cab781f488fe0cde85dbdadf8166caa7088295218b,shell SHA 7d7202b8198d16950e1fcbb411b00652cc574fff27e445c70644efabb5886000。复用前次只读探针,强制ARM/root和固定归档根身份,以hash校验的内存代码运行;不在远端落文件、不解包/建容器/恢复,不读字段密钥,不改sudoers。需要用户终端交互sudo取得新源事实,与独立内容复核可并行;本地真实恢复入口仍未生成,原NO_GO和冻结R2未改。下一步接回这两项外部证据后处理匹配镜像/重新绑定,不再要求重复批准已明确的历史例外。 + +## 2026-09-16 实际受限恢复:NO_GO / ATTACHMENT_REFERENCE_MISMATCH + +用户通过交互sudo执行一次固定入口,14:54:53Z返回:preflight 4.169秒、prepare 1.453秒、database 2.607秒均PASS;恢复源0913782f…、复核manifest076078be…,RPO73482秒(20.41小时)。来源迁移集合/校验和及12表读取通过,数据库已实际恢复,不再标NOT_RUN;在storage阶段附件引用校验返回不一致,application未运行,敏感解密NOT_TESTED,整体NO_GO。storage解包/普通文件计数已过才会到该失败码,但不能据此把引用或逐件内容判PASS。该码聚合了空/不安全路径、缺件、大小或SHA不一致、外部存储等情况,回执没有数量/类别,不能直接断言“缺了一件”或“就是旧测试附件”。 + +本次cleanup=PASS、cleanupPending=false、postcheck=PASS、productionWrites=false;即原源摘要/元数据、生产ready和timer后验通过。影子库及附件副本已清理,未执行应用验收;失败阶段自身时长未记录,不能推算完整RTO。原入口已消费,不盲重试,不为了推进删除引用/修改原件/过滤归档项或重写R2。 + +下一步仅先保存原始脱敏回执、核代码判定与历史已批准治理证据,并在原只读授权内核当前生产附件路径/存在/大小及治理关联摘要;不读取原件正文、不额外调用云服务、不开第二轮恢复。当前生产状态只能用于归因线索,不能冒充本次冻结备份DB的复现。若需要重建影子库才能定因,先列出一次诊断所需的最小新增范围和证据,不复用已消费的单次入口。 + +14:59Z当前生产只读扫描62条引用(发票44、报销附件16、付款回单2、采购/请假0),存在/大小检查仅发现一条invoice_pool原件缺失,pathIdentity=b22a1162…与09-11旧缺件完全相同,其余61条元数据检查通过;未读文件正文/重算内容SHA,因此不能排除本次冻结备份另有内容不一致。15:01Z单条只读复核:recordIdentity=36383435…同旧记录,manual归档发生在源备份之前、该行备份后未更新,报销/明细/差旅/报销附件/采购附件/付款回单/请假七类关联均false。两次运行生产容器身份/启动时间不变,未写业务或重新恢复。 + +已找回此前未在本副本定位到的原始处置证据:另一20260910-reimbursement-payment-integration副本的qa-flow/20260911-invoice-reference-readonly/provenance/historical-missing-disposition.json(SHA256 5ed1e910408b32d336235729850cb35238bb3ee15b587618c0a56417f2bee8fd)及disposition-precheck-result.json(9a0b99c051d423b254e67ba5c17f5e40579ec4176c2d5697504fcda8c38c6414)。09-11原决定是DOCUMENTED_MARK_KEEP_ARCHIVED_RECORD,仅在task/证据标记,未修改生产记录/UI、未删文件或更改校验器,并明确原恢复overall仍NO_GO。当前发现与这条历史处置一致,但它不等于“任何归档件均可忽略”,也没有批准把原完整性验收改成PASS。 + +下一步建议见design拟议补充:仅对此精确记录/路径登记一个有条件的历史缺件例外,保留原始不一致计数和PARTIAL结论;在恢复副本确认七类关联及无其他不一致后才继续应用只读验收。此项改变当前遇任何引用不一致即停止的门禁,另需一次明确批准,包含本地修订/定向验证/重新冻结并复核后一次受限恢复;未批准前不修改R2或执行第二轮。当前PAUSED_PENDING_NARROW_ACCEPTANCE_DECISION,不重复索要旧单次批准。 + +## 2026-09-16 独立23项重跑接回,接续管理员现场入口 + +用户转回独立执行方回执:Python3.9.2,包外先导入unittest/unittest.mock,再discover冻结test_restore_once.py;23项、exit0、0.088秒。21份文件(19登记及manifest两件)运行前后摘要相同,无新增pyc;用例名与原日志一致。日志由独立方保存在其scratchpad,本会话仅接收用户转述,没有取得原始日志全文,不将本地自检冒充这次独立执行。 + +独立单测门禁已满足;内容结论仍为有条件通过,现场源/最新性/manifest、资源及生产前后检查只能由实际执行回执解除。执行前条件2已有包外源config与非空fail-closed补件,本轮再用固定ARM镜像实际config和冻结app-check补无真实资料的隔离验证;不读生产字段密钥。附件Finding1/2的范围限制沿上一节,纳入最终验收纪要,不改冻结R2字节。 + +接续清单:核R2全部摘要→实际镜像配置专项→将原执行器和bundle按manifest原字节封装为摘要固定入口→本地检查入口只调用已审Session→普通用户暂存与回读→用户交互sudo运行。现场预检在任何真实恢复前执行;固定源超过2026-09-16T18:30:05Z或最新性/版本/资源变化立即停止,不自动换源、不放宽门禁。受限恢复授权不重复索取;本轮不提交/推送/归档/部署。 + +14:46:33–14:46:57Z实际ARM配置专项通过:固定image 1d4d70f8…中的config字节SHA与来源相同,镜像未设置字段密钥、getConfig缺省为空;以合成非空值运行冻结app-check原字节和实际依赖(无边界替身),返回APP_READ_FAILED/exit1,外层断言exit0。专属配置工具容器network=none、非root、只读根、512MiB/1CPU/pids128、无宿主挂载/持久日志,精确清理PASS。未启动OA服务、读取真实备份或生产密钥;这是主会话现场补证,不新增独立测试计数。回执arm-image-config-check.json。 + +14:50:28Z入口普通用户暂存完成,独立一次传输后回读摘要相同;未sudo、未执行真实源preflight或恢复。入口SHA256 e2f57c3e0429fa581acb9f31f5baf29193d94c6a9e61c6c57823beb98c669653,将R2 manifest、restore_once.py和bundle原字节嵌入;再次校验内嵌摘要/四helper后,仅调用原Session.execute,保留信号/30分钟边界,NO_GO映射非零退出,不重写原门禁。root加载器先核入口摘要,再消费唯一暂存脚本并删除空目录,然后从已校验内存执行;失败不自动重试。手工入口为postrelease-recovery-20260916/run-approved-restore.sh,回执entry-staging.json,暂存仅代码、无真实资料。审查状态通过包外本节接续,冻结包仍21份、076078be…不改。 + +主会话新增入口专项6项与传输/摘要先于执行及精确自清理2项通过,使用unittest/unittest.mock预导入后discover两个test文件;不合并为原23项独立通过数。脚本bash -n、两个task validate、Trellis治理/process及git diff --check通过;文档审计errors=0,既有22份未登记文档警告不在本轮修复。当前READY_FOR_MANUAL_SUDO_PREFLIGHT:现场预检本身只读,通过后才准备容器/网络并恢复;受权限边界限制必须由用户在终端输入sudo密码,不在聊天获取密码或改sudoers。真实DB/storage/app验收仍NOT_RUN,配置专项不是APP_READONLY通过;完整解密仍NOT_TESTED。稳定边界由现有配对恢复规约覆盖,本轮不另改spec。 + +## 2026-09-16 R2外部内容结论接回:有条件通过,证据门禁未解除 + +已收到外部报告restore-once-r2-claude-review-20260916T141328Z.txt及用户独立完整性核验:19/19摘要/字节、manifest 076078be…、R1链和bundle四个可执行件一致。内容审查有条件通过,无阻断性代码缺陷;Finding4仍要求独立执行证据,不能将其隐去写成无条件放行。当前CONDITIONAL_PASS_PENDING_EXECUTION_EVIDENCE:需原独立会话重跑23项单测,管理员真实执行前在ARM当场重验源/最新性/manifest和环境,不能沿用13:28–14:03旧快照。 + +补齐Finding2依赖(报告执行前条件2,不是附件Finding2):包外source-config-b7d2285d.js来自git show b7d2285d,SHA256 a3b62630cb05935194e0297dedbd82e38f6fd4fa4f7042a2ca4ca2e2baeff1c4,与镜像构建登记的server/src/config.js摘要一致,FIELD_ENCRYPTION_KEY缺省为空。ARM目标镜像只读inspect确认镜像环境没有该变量;不代表尚未启动的真实影子容器已经验收。check-key-fail-closed.mjs使用原config和冻结app-check断言,以全新合成非空值验证APP_READ_FAILED、exit1,DB与网络调用均0;边界依赖替身,不是实际容器重跑。证据key-fail-closed.json明确MAIN_SESSION/independentExecution=false,没有读取生产字段密钥。 + +附件覆盖限制明确接受并写入后续验收纪要:file_sha256为空时只能验证安全路径、存在性及可用大小,不能证明该条业务引用的内容哈希一致;leave_requests无size/sha字段,只核路径与存在。归档整体SHA一致不补出原本不存在的逐件业务哈希。tmpfs未加noexec列为已知低风险加固项,保留冻结字节,不为非阻断项未经复核修改R2。 + +两处事实澄清:该备份采集时来源是b7d2285d,b1d25d3c是后来演练时要求保持的生产版本,并非用b1备份冒充b7;两提交migration目录再次静态diff为空。publicOrigin仅由ARM宿主控制器做已批准的生产/ready前后只读探测,不传给隔离应用或影子DB,不能把宿主健康观察误读为允许影子应用访问公网。 + +本次R2 19份文件重核仍全匹配,manifest保持076078be…,补件全在包外,没有恢复真实备份、没有新增应用启动、配置变更或生产业务写入。原受限演练授权不重复索取;补证完成后再给管理员执行命令,源过24小时/版本变化仍停,不绕门禁。 + +## 2026-09-16 受限演练已授权:适配/合成验证完成,真实执行待独立复核 + +用户已明确批准design所列受限演练,不再重复索取同范围批准。本轮完成ignored的一次性restore_once.py编排与app-check.cjs,复用与b7d2285d逐字相同的restore-db/storage/附件校验器;未改产品代码或安装长期runner/sudoers。源时效、根/manifest/文件集、链接/容量、镜像digest、内部网络、无端口、只读挂载/权限、超时和精确清理均有门禁;生产只读ready前后核验。 + +验证:本地主会话23项安全单测PASS,既有offhost runner合同32条PASS输出(含汇总,非32项独立产品用例);ARM上使用已批准镜像创建纯合成库,执行实际来源迁移SQL并登记合成迁移摘要、生成部门/员工/角色/报销/长路径附件,经pg_dump→原DB恢复脚本→迁移/12表聚合→原storage恢复/引用/摘要检查;缺失引用负例拒绝;实际app/匿名401/短期影子JWT/只读权限/行身份比对通过,清理PASS。最终synthetic-full-final-result.json的restoreStarted=true仅指合成库,realBackupRead=false。全程未接触生产字段密钥、真实银行资料、真实备份解包或生产业务写入。 + +首轮单测加载失败是本目录inspect.py遮蔽Python标准库inspect;修正测试入口为先导入unittest/unittest.mock再discover,旧探针未改。旧日志保留不作通过证据。首次ARM合成验证为应用/权限链,最终补足实际dump/restore/附件长路径和缺失反例;各阶段回执区分,不把主会话自检当独立验收。合成使用DB容器内tmpfs,root宿主tmpfs挂载路径未运行;SIGKILL/断电/断网、真实副本恢复和敏感字段解密仍未验证。失败阶段自身时长未完整捕获,不声称完整失败RTO/SLA。 + +交审入口artifacts/qa-layout/restore-once-review-20260916-r2,manifest SHA256 076078be37dcd3c03c4be9b3413904b01fe1ac86f1bf34b34db88cc240334b05,19份登记文件逐项摘要/字节数复核一致,文件权限644。独立只读复核PENDING;受inline工具边界限制未派实现/检查子代理,不虚报外部结论。包内README列出5项审查重点和全部未覆盖,包内不含真实备份/密码/JWT/字段密钥。 + +R1先冻结后交叉核对发现配置误将createdAt的18:30:05当runId末尾;生产只读按精确manifest查到唯一runId=auto-20260915T183002Z。R2只修正bundle的batchName并补来源回执,指回R1哈希,执行器/测试源码未变、R1未重写、不新增测试计数。R1禁止用于执行;真实运行仍会在ARM验证最新目录/同manifest,不能以生产回读代替ARM检验。 + +当前没有执行真实入口。下一步接回对R2的独立只读结论,解决实质阻断后提供摘要固定的ARM管理员命令;普通账号仍无法读归档/sudo非交互不可用,不能偷偷扩权。真实开始前固定源过24小时或生产/最新批次变化即停止,重新绑定来源/镜像;现有批准不授权绕过门禁。敏感字段解密保持NOT_TESTED,最终受限成功也只记PARTIAL。 + +## 2026-09-16 P2单次操作范围准备完成,待授权及合成合同验证 + +本轮按用户批准准备清单,没有继续创建远端资源或启动镜像。已在design最新节固定manifest/来源树/两个ARM64镜像、30分钟总时限、只读DB身份、内部网络/禁外联、容量与内存/CPU上限、失败停止及精确清理规则。ARM补充只读资源观察为MemAvailable10.24GiB、磁盘87.91GiB/2CPU,执行前需重取。 + +认证采取明确披露的影子短期签发token+恢复库现有合格身份,仅调用已静态审查的报销列表own范围及匿名拒绝,不写员工密码/角色,也不声称真实密码登录。为此允许的影子DB权限DDL与恢复数据核验分阶段,应用无DML/CREATE/TEMP权限;缺合格身份不造数据。不读取生产字段密钥,因此敏感字段解密NOT_TESTED、完整应用灾备结论仍PARTIAL;这条需要随单次批准清单明确告知,不隐藏为已完成。 + +配置专项check-shadow-config.mjs执行原b7d2285d config源码、空白环境及随机合成JWT/pepper,结果shadow-config-check.json为PASS_SOURCE_CONFIG_ONLY,未连DB/未启动app。源码检查涉及index/app/auth/reimbursements/delegation/tracing/crypto,原restore-db/storage及附件校验器继续复用。未执行现有真实数据runner或冒用旧安装/sudoers授权。 + +下一门禁:用户批准design所列受限演练范围后,完成相应执行器适配及本地合成安全合同检查、只读复核/冻结,再提供需ARM管理员权限的精确命令;若源已过24小时或模型前提变化停止重新绑定。当前是方案可审,不是脚本已验收,不将未满足的权限/网络/资源测试写成PASS。实际恢复、运行应用与源文件读取仍未开始。 + +## 2026-09-16 已授权ARM64镜像准备完成(非恢复验收) + +用户明确批准本次ARM镜像构建。13:26–13:29Z执行:从固定b7d2285d8d1a2a912222917f7c74067f8bd9f810/树0ef8f7d5597b1fc425888596241d041585aaccab导出1102个构建所需文件,不含工作树差异、生产.env/业务密钥/真实备份。拉取ARM64 node:22-alpine后,将原Dockerfile唯一FROM替换为已解析的node@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32;原/派生Dockerfile、三个lockfile、源码逐文件和上下文摘要均登记。该Dockerfile派生只为固定基础镜像,不改Git源码。 + +首轮构建器在输入识别阶段报ambiguous Dockerfile source,exit1;只读确认拟产物不存在,没有盲目重试。保留首轮回执后,将相同源码tar改为有明确签名的gzip tar并做本地解包文件计数校验,再进行一次修正构建;未重复拉取基础镜像。R2 exit0,npm ci及Vite构建完成。产物image sha256:1d4d70f843976e327106d53d13d45118e22372ac6e71cbe7db96953315b4fed3,linux/arm64,大小1075264677字节;BUILD_SHA及OCI revision均为固定来源提交。 + +独立一次元数据回读(仍主会话执行,不冒充外部审查)确认相同image/平台/版本,引用该镜像的容器数量0,目标盘余87.91GiB。无应用启动、备份读取/恢复、生产连接、业务写入、仿真或部署;未清共享缓存/旧镜像。保留已批准新镜像及本轮基础镜像/构建缓存,不将其当临时残留自动prune。 + +结果ARM64_IMAGE_BUILT_IDENTITY_VERIFIED_NOT_RUNTIME_TESTED。这是同源码跨平台重建,不是原amd64制品字节复原;npm锁及Python哈希约束已保留,但基础系统依赖、平台包和构建时下载不宣称与历史生产完全相同。未运行产品单测、应用健康/业务读取/解密或DB/storage恢复,不能升级为APP_PASS或恢复任务完成。 + +证据位于artifacts/qa-layout/postrelease-recovery-20260916:build-arm.py及首轮arm-build-result.json/log;build-arm-r2.py、arm-build-r2-result.json、arm-build-r2.log、arm-build-r2-source-hashes.json、arm-build-readback.json。下一步按已批准恢复设计准备具体清单,重核源时效、原件引用治理、只读身份/解密、禁外联和资源门禁;真实恢复及应用运行仍待专项授权,不沿用此次镜像构建批准。 + +## 2026-09-16 ARM人工回执接回及镜像版本只读核验 + +13:16:49Z用户在ARM手工sudo执行已登记摘要的只读探针,回执已脱离密码提示/SSH尾部单独保存为postrelease-recovery-20260916/arm-user-readback.json,executor=USER_MANUAL_SUDO。最近两个自然批次manifest与生产逐一相同,DB/storage字节及SHA256通过且归档元数据未变。最新b7d2285d源18.78小时,目标剩余89.38GiB、内存11.68GiB、2 CPU;这些是该时点值,不自动授权恢复或修改访问权限。 + +随后按用户批准以普通账号只读检查95个本地镜像的BUILD_SHA及OCI revision:90个arm64、5个amd64,均没有符合提交格式的上述标记;源image 48dfa8e4…不在集合内。结论NO_PROVEN_MATCHING_APP_IMAGE,不等同于证明95个镜像均不含对应源码。ARM64 PostgreSQL标记PG_MAJOR=16的镜像c05eced0…存在,但尚未启动/运行验证。证据image-versions.py/json,镜像集合前后不变,无构建、pull、load或容器创建。 + +本地确认b7d2285d来源树0ef8f7d5597b1fc425888596241d041585aaccab,是当前b1d25d3c祖先,二者migration目录无差异;不据此把当前应用替换为备份匹配应用。恢复源最新时效仍须在实际执行前重验。 + +下一受保护操作建议单列为ARM64应用镜像准备:经批准后传输该来源提交的脱敏构建上下文,按来源Dockerfile在ARM原生构建,固定BUILD_SHA/VITE_BUILD_SHA并登记基础镜像digest、lockfile、构建输入/产物摘要;无需读取或传入生产.env、业务密钥、真实DB/附件。新产物属于同源码跨平台重建,不是原amd64制品复原;基础镜像与依赖差异必须披露。只创建本轮专属构建目录/镜像,不启动OA、不恢复真实数据、不启用仿真、不清理共享缓存或其他镜像。构建网络用于获批的镜像/依赖下载,与后续禁外联恢复网络分开。尚未取得该构建写入批准,未开始构建。 + +镜像准备后才可冻结完整恢复清单;只读应用身份/必要解密、所有后台与外联关闭、资源硬限制仍待核定。恢复数据写入继续另批,不能把本轮95个镜像元数据检查当应用恢复PASS。 + +## 2026-09-16 待执行清单(最新) + +当前PLANNED_NOT_AUTHORIZED_FOR_RESTORE;不因b1d25d3c已上线就关闭恢复缺口。09-15历史8d源方案不再默认执行,按本task最新PRD/设计和09-08的P1→P3接续。 + +- [ ] 接收P1新时点只读结果:源manifest/字节/SHA、自然或手工、所在节点、年龄、源build/image/platform、ARM容量与权限。归档损坏/传输缺失/源码未知分别归因。 +- [ ] 选定一个源和匹配镜像;核应用启动入口、后台任务与出站关闭、只读身份/解密条件,列未满足前提而不读取生产Secret。 +- [ ] 冻结一次操作清单并集中请求批准:必要的源复制/镜像加载、专属临时副本/卷/内部网络/容器、资源及超时上限、DB/附件恢复、应用只读检查、仅本轮资源清理;不包含生产业务写入、旧件治理、监控修改或测试通知。 +- [ ] 获批后再核源与目标无漂移、生产ready基线;开始计时并执行单次DB恢复,非零停止后续阶段,核迁移集合/校验和与关键表聚合,不硬编码旧66/68数量。 +- [ ] 恢复storage临时副本并核安全路径、引用存在性、适用大小/SHA;缺件细分源缺失/传输不全/校验器误判,无批准治理证据不排除记录。无存量摘要时明确仅存在性验证。 +- [ ] 以匹配平台/来源应用执行已批准的只读验收,分别记录health和业务读取/必要解密;不可用则APP_BLOCKED/PARTIAL,禁止DB PASS顶替。 +- [ ] 核原归档未变、生产前后健康、精确资源清理回执;分段记录DB/storage/app耗时与总RTO、源时点相对演练起点RPO,未计时字段不猜测。 +- [ ] 汇总一次结论:各阶段PASS/FAIL/NOT_TESTED、异常类别、清理及生产影响;仅全部合同通过才标本次演练PASS,08-28两个自然周期另验,不混结案。 + +等待真实恢复批准期间只做本地方案/校验器审查或已授权只读核验,不重复OCR、发布或创建新备份来制造完成证据。 + ## 2026-09-15 简单配对恢复的集中批准边界(未执行) 不新增runner。已知可识别的历史输入为receipt-followup-release回滚备份:manifest `56a9addf57556580c46205ae8e68b01a8e23a45ae7757d4cfce9ec9c92660c86`、来源8d08d46d;对应镜像归档SHA256 `d17d7efaef073b5cc6b78f1ce2a73e938835e4910946c83e855b48e61780541b`,历史回读通过。它不是已核最新自然备份,不能报24小时内,也不能换成b7或当前本地代码假装同源恢复。 @@ -237,3 +530,12 @@ - 本任务涉及真实备份数据和远端运维边界。执行完成后做一次只读复核,重点确认:生产零变更、归档未修改、RTO/RPO 口径正确、附件验证没有漏项、敏感内容未进入证据、临时对象已清理。 - 若现有脚本需要修改,回到规划阶段补充设计和新的批准;不得在远端临时放宽 shadow/路径/确认门禁。 - 门禁通过后用中文 commit 引用 `08-23-isolated-backup-restore-drill`,是否推送仍按用户当时授权执行。 +## 2026-09-16 恢复前置实施:HOLD_ACCESS_AND_PLATFORM + +已按批准执行只读预检,不是恢复执行授权。生产最新自然源b7d2285d(09-15T18:30:05Z,manifest 0913782f84bf7253a8ea1a24e727f8c20a6a637c58737b5df767331809dd2f4e)实测18.34小时、DB/storage完整;源image 48dfa8e4…实测linux/amd64,生产x86_64,目标ARM实测aarch64。ARM归档不可读、sudo非交互不可用,容量/完整性/镜像集合NOT_VERIFIED。不能以生产容量27.39GiB替代ARM容量;不能承诺原生产镜像能在ARM运行。当前DB_RESTORE/STORAGE_REFERENCES/APP_READONLY均NOT_RUN,cleanupPending=false(未创建资源)。 + +本地启动合同检查:Dockerfile默认npm start→node src/index.js,未在该入口发现migrate/seed;app会mkdir(storageDir)且无条件调用startJobs,后者仅JOBS_ENABLED=false提前退出,默认是开启。恢复应用必须显式关闭jobs并预建专用存储目录;镜像启动命令、全部插件/外联配置、只读身份和必要解密还需按最终源提交复核,不能用当前HEAD片段代替源镜像的完整启动审查。不加载生产.env,不把OCR_PROVIDER=off当禁止所有后台任务的替代。 + +集中申请的前置清单:①以有权限方式只读核ARM两个自然批次及容量/镜像;②匹配源与平台,若只存在amd64原镜像则另提同提交arm64构建或已批准的其他隔离目标,禁止隐式仿真/改用生产宿主;③据实测冻结资源预算、只读应用身份、禁外联配置、清理ID范围及停止条件;④取得该具体范围的一次恢复写入批准。源若超过24小时重新选最新合格批次,不固守本次历史摘要。前两项未解决前不生成看似可直接执行的恢复命令。 + +证据归artifacts/qa-layout/postrelease-recovery-20260916;此次公网末次/ready为b1d25d3c且PASS。尚无DB恢复、附件引用、解密或应用恢复通过证据,恢复任务保持进行中。 diff --git a/.trellis/tasks/08-23-isolated-backup-restore-drill/prd.md b/.trellis/tasks/08-23-isolated-backup-restore-drill/prd.md index 5decbed0..a4221a94 100644 --- a/.trellis/tasks/08-23-isolated-backup-restore-drill/prd.md +++ b/.trellis/tasks/08-23-isolated-backup-restore-drill/prd.md @@ -1,5 +1,46 @@ # 隔离备份恢复演练 +## 2026-09-19 K0 独立静态复核与范围澄清 + +K0 加解密合成合同经 Claude 定向静态复核 PASS,未新增运行时或真实恢复结果。它不覆盖 `maskBankAccount`、`maskIdNumber`、`maskKeepEnds` 的脱敏正确性,不能作为“敏感字段完整闸门”引用;本轮不扩建脱敏测试或修改产品函数。 + +`server/src/scripts/migrate-notification-channel-secrets.js` 会读取配置、连接目标数据库并批量改写通知渠道密钥字段;它属于数据迁移工具,明确排除在 K0 以及 K1/K2 的默认执行范围之外,不得为恢复验证运行或导入该入口。未来 K2 若选择通知类加密字段,只按另批绑定方案只读验证适用密文;无适用样本记 NOT_TESTED。迁移、轮换、明文回写或真实通知均需独立方案与明确授权,不由恢复批准附带取得。 + +## 2026-09-17 原密钥保管与敏感恢复后续规划 + +本增量的K0本地合同检查与合成验证已于本日另获批准并完成;K1/K2仍仅规划,原受限演练已经结束且不可重跑。目标从“追查密钥何时生成”改为“原密钥是否有受控保管及能否恢复对应敏感字段”;来源UNKNOWN可以继续如实记录,不能用生成新密钥替代原密钥或把格式合法等同于高熵来源已证实。 + +范围分三段:K0仅本地源码/环境分类合同与合成密文验证;K1另批后只核实际保管可用性;K2再绑定具体新鲜源、制品、隔离目标及受限原密钥注入方案并另批实际恢复。已有环境恢复分类与双角色保管规约复用,不新建密钥平台、不将.env装进普通数据备份。 + +新增验收(不替代原AC4/AC5): +- [x] K0:本地缺密钥、错误密钥、篡改密文均停止;正确合成密钥成功,输出不含密钥/密钥摘要/密文/明文或原始异常,生产未访问。30项定向测试及7类合成检查通过;只证明本地合同,不证明真实密钥或数据可恢复。 +- [ ] K1:管理员与公司责任人均有受控取用原密钥的渠道,非仅依赖原主机或单人记忆;记录角色/渠道类别/验证时间/可用状态,不保存值或保管位置细节。 +- [ ] K2:在另批的隔离范围验证选定备份中实际存在的加密字段,原密钥匹配、适用字段解密检查和清理分列有据;没有适用密文时记NOT_TESTED,不拿合成通过替代。 + +任何保管、原密钥取用/转移、真实备份读取和恢复均非本次K0授权。无法安全取得原密钥时停止真实验证,报告影响与待批方案,不读取shell历史、不索要聊天明文、不擅自轮换。实际执行方案必须重新独立只读复核;本次主会话检查不计为独立复核。 + +## 2026-09-17 本次受限演练实际结果 + +已获批准的一次R3绑定恢复于01:33Z完成:DB及影子认证只读应用PASS;附件62引用中仅精确已接受历史缺件1、未知不一致0;清理及源/生产/监控后验PASS。整体PARTIAL_KNOWN_MISSING_AND_SENSITIVE_DECRYPTION_NOT_TESTED,RPO7小时2分52秒。原始全量附件标准及完整计时指标未因此豁免,敏感字段解密未验;本次无需再执行,task保留未覆盖范围,不自动归档。详情与三摘要绑定证据见implement最新节。 + +## 2026-09-17 单条历史缺件例外(已批准) + +本日后续另获固定b1d25d3c源码的ARM64镜像构建批准,仅构建及元数据验证,不部署、不启动应用、不恢复、不读生产密钥。匹配制品已构建不代表R3独立复核或最终恢复源绑定完成,详见design/implement最新节;其他版本/范围不自动授权。 + +用户批准按design的精确身份/七类零关联条件修订验收、测试并独立复核后再执行一次受限恢复。本增量仅允许将指定历史缺件作为已接受但仍未恢复的事实,逐项报告原始不一致数、已接受数、未知数;其他缺件/大小/摘要/路径异常仍阻断,不能把任意归档件排除。原R2与NO_GO不改,最终最高结论保留历史缺件及解密未验的PARTIAL。生产零写入、资源/网络/清理边界不变。 + +09-15旧源现已超过24小时,禁止重用旧入口或放宽新鲜度;先完成本地修订和只读选源事实,实际重跑必须重新绑定新鲜源与同来源镜像并经复核。源/平台/风险实质变化时说明差异,未解决之前不生成可执行入口。当前批准不授权任意镜像构建、生产密钥读取或新备份写入。 + +## 2026-09-16 配对恢复规划增量(当前优先合同) + +关联09-08总任务P1/P2/P3。先读取08-28的新自然备份/异机完整性事实,再冻结单次演练输入;本轮只更新规划,没有恢复授权。优先ARM最新合格正式日归档,不把09-15固定8d历史方案或09-16生产同机手工备份当默认源;若需使用后者,必须另外明确向ARM复制的范围并重新核源副本。未知/超龄/损坏不得静默换源。 + +恢复覆盖分为DB_RESTORE、STORAGE_REFERENCES、APP_READONLY三个独立结果;下文旧“不启动OA server”仅属于最初数据恢复阶段,本增量允许将匹配应用的一次性启动纳入拟申请范围,但未批准之前不启动。应用镜像必须绑定源build并核平台;生产镜像能归档不代表在ARM可运行,不默认仿真或随意换最新镜像。 + +应用只读验收的访问身份、字段解密能力、禁后台任务/外联配置必须先可证明安全。临时登录/数据库凭据只用于隔离环境且不得复用生产.env;若恢复数据的密钥不可安全提供或身份条件不满足,则记录APP_BLOCKED或APP_PARTIAL及原因,不用随机替换业务加密密钥、仅/ready成功或DB PASS代替全应用可恢复。密钥来源未知与是否获批安全使用为不同问题,不要求用户把值发给代理。 + +本task只有三项实际验收与清理/生产无影响证据齐全才可宣布本次配对演练完成;是否符合业务RTO另按预先确认的目标判断,不能仅因记录了耗时就认定满足SLA。历史缺失附件保留原引用,不在恢复演练中代做生产治理或删除。 + ## Goal 在不覆盖、不暂停腾讯云生产 OA 的前提下,使用 ARM 备份机上最新的完整异机批次完成一次数据库与附件的隔离影子恢复,证明“备份存在”进一步达到“最新成对数据备份可恢复”,并记录实测数据恢复 RTO、RPO 和脱敏证据。 @@ -61,13 +102,13 @@ ## Acceptance Criteria -- [ ] AC1:ARM 最新正式日批次通过文件集、manifest、字节数和 SHA-256 校验,来源构建是当前构建的祖先或相同,且实测 RPO 不超过 24 小时;是否已形成当前构建同源恢复点单独记录。 -- [ ] AC2:腾讯云生产在演练前后 `/ready` 均正常,生产容器、数据库、附件、配置、timer 和业务数据零写入、零重启、零切换。 -- [ ] AC3:一次性 PostgreSQL 16 影子库无宿主端口,数据库恢复成功;恢复 migration 集合与 manifest 来源提交一致且无失败/未知项,来源与当前 migration 差异已明确;固定 12 类关键表均可读并形成仅含数量的脱敏摘要。 +- [x] AC1:ARM 最新正式日批次通过文件集、manifest、字节数和 SHA-256 校验,来源构建是当前构建的祖先或相同,且实测 RPO 不超过 24 小时;是否已形成当前构建同源恢复点单独记录。 +- [x] AC2:腾讯云生产在演练前后 `/ready` 均正常,本次操作对生产容器、数据库、附件、配置、timer 和业务数据零写入、零重启、零切换。 +- [x] AC3:一次性 PostgreSQL 16 影子库无宿主端口,数据库恢复成功;恢复 migration 集合与 manifest 来源提交一致且无失败/未知项,来源与当前 migration 差异已明确;固定 12 类关键表均可读并形成仅含数量的脱敏摘要。 - [ ] AC4:附件安全恢复成功,归档与解包普通文件数一致;所有纳入范围的本地附件引用均安全且存在,适用的哈希/大小核对无不一致。 - [ ] AC5:记录本轮数据恢复 RTO、RPO、同源恢复点可用滞后、开始/结束时间、失败点或 finding、Go/Pause/No-Go 结论;即使恢复未开始也保留已观测空窗,不把任何指标表述为完整应用灾备 RTO 或 SLA。 -- [ ] AC6:演练结束后本轮容器、端口、网络和临时数据全部清理,ARM 正式归档与现有 dead-man/健康监控不受影响。 -- [ ] AC7:任务校验、Trellis 治理、流程合同、文档审计、敏感扫描和 `git diff --check` 通过;证据不含敏感信息。 +- [x] AC6:演练结束后本轮容器、端口、网络和临时数据全部清理,ARM 正式归档与现有 dead-man/健康监控不受影响。 +- [x] AC7:任务校验、Trellis 治理、流程合同、文档审计、敏感扫描和 `git diff --check` 通过;证据不含敏感信息。 ## Out of Scope @@ -79,7 +120,7 @@ ## Blocking Open Questions -- 无。构建门禁已裁决为“来源提交是当前构建祖先或相同 + 批次年龄不超过 24 小时”;实施仍需用户审阅本次修订后的最终规划摘要并再次明确批准。 +- 本次受限演练已批准并完成,入口已消费,不再申请同范围重跑。完整密钥保管/敏感解密恢复、历史缺件及完整计时覆盖仍按顶部结果保留;未来新增实质恢复范围需另行规划,不复用本次单次授权。 ## Test Plan diff --git a/.trellis/tasks/08-23-isolated-backup-restore-drill/task.json b/.trellis/tasks/08-23-isolated-backup-restore-drill/task.json index 97a96b23..e70702d3 100644 --- a/.trellis/tasks/08-23-isolated-backup-restore-drill/task.json +++ b/.trellis/tasks/08-23-isolated-backup-restore-drill/task.json @@ -21,6 +21,24 @@ "children": [], "parent": null, "relatedFiles": [], - "notes": "2026-08-28:恢复演练保持 in_progress/P2,但被 P1 operations 事件 08-28-production-arm-backup-transfer-incident 阻塞。该事件负责生产到 ARM 传输链修复、生产容量处置、告警响应计时和连续两个自然调度周期验证;在新原子批次形成并重新通过 24 小时身份门禁前,本任务不得继续实际恢复。既有恢复候选 SHA 仍有效,无需因事件任务重新渲染。", - "meta": {} + "notes": "2026-09-17:已获批准的R3新源受限ARM影子恢复完成,源d9a23d59/b1d25d3c、执行manifest f7cf4d62。DB/影子认证只读应用PASS;62引用仅1条已接受历史缺件、未知不一致0;cleanup/postcheck PASS,补只读后验无临时资源残留,生产未改。整体PARTIAL_KNOWN_MISSING_AND_SENSITIVE_DECRYPTION_NOT_TESTED,RPO25372秒;旧NO_GO保留,新入口已消费、不重跑。本次操作清单完成,完整附件、敏感解密/密钥保管及完整RTO指标未全验,保持in_progress,不自动归档/提交/推送/部署。 本日后续K0另获本地实施批准:官方current已切本task,新增独立合成补件与30项定向测试通过,环境分类166键通过;未读取真实密钥/配置/备份或连接生产/ARM。K1双角色保管与K2真实解密仍NOT_TESTED、独立复核PENDING,原PARTIAL及历史缺件不变;旧冻结包现场86件完整保留,96件旧文字计数差异单列。无提交/推送/部署或借款实施。", + "meta": { + "k0IndependentReview20260919": { + "status": "PASS_STATIC_CONTENT_REVIEW", + "report": "artifacts/qa-governance/business-followup-claude-review-20260919T023537Z.txt", + "reportSha256": "b48e5f53340e139e5a97b5fdcb6d7003881a80a8a08d4004d01aaa6a759d2d0b", + "reviewerTestsRun": false, + "reviewerShaRecomputed": false, + "mainSessionFrozenManifestVerified": true, + "mainSessionFrozenFilesVerified": 6, + "mainSessionCurrentCodeInputsMatch": 3, + "maskingCovered": false, + "notificationMigrationExecution": "EXCLUDED_FROM_K0_K1_K2_DEFAULT_SCOPE", + "notificationK2ReadOnlyValidation": "ONLY_IF_EXPLICITLY_BOUND_AND_AUTHORIZED", + "k1": "NOT_TESTED", + "k2": "NOT_TESTED", + "evidence": "artifacts/qa-governance/business-followup-reception-20260919", + "oldReceiptsPreserved": true + } + } } diff --git a/.trellis/tasks/08-28-production-arm-backup-transfer-incident/implement.md b/.trellis/tasks/08-28-production-arm-backup-transfer-incident/implement.md index c651f19b..f3fd2b21 100644 --- a/.trellis/tasks/08-28-production-arm-backup-transfer-incident/implement.md +++ b/.trellis/tasks/08-28-production-arm-backup-transfer-incident/implement.md @@ -1,5 +1,62 @@ # 生产到 ARM 备份传输中断实施计划 + +## 2026-09-17 结案缺口与最小补证方案(本地整理,未执行远端观察) + +据09-08后期W4推进,复用本task既有调查器和历史脱敏结果,不重跑旧调查/恢复。两自然日期09-14/09-15副本完整性已由既有记录证实,09-16源又有同源绑定;不再把“等待两份副本”当剩余工作。自然恢复不证明根因修复;本次只把AC的剩余证据和停止线列清。 + +| AC | 已有支持/缺口 | 下一步最小取证及判定 | +| --- | --- | --- | +| AC1 | 原生产完整批次/单点分类、成功prune/失败保留、partial不同停止位置有据;真实rsync码和可靠传输时长NOT_AVAILABLE,不能推定单一根因 | 可保留“历史不可测、根因未收敛”的明确裁决;以后只有同次自然失败才提取绑定运行的退出码/阶段专属时钟。没有新异常不建议为追根因重传 | +| AC2 | 本地分类器修复已验证;未实施传输/生产容量修复 | 需审阅结案口径:若无需传输修改,明确接受观察性结案条件;若容量不足,先给具体最小处置/回滚清单再批准,不能勾为修复完成 | +| AC3 | 两自然日期完整原子副本已有manifest/集合/字节/SHA证明 | 复用原证据,未来不重复消耗同一等待条件;本轮未刷新时效 | +| AC4 | 既有daily/watchdog/dead-man部分状态与后验记录;跨两周期service结果配对未齐 | 下次获批只读观察同时取timer active、最近运行开始/结束、Result、exit及最新归档身份摘要;active本身不是success | +| AC5 | 两日期完整性已有;两个自然周期各自partial/容量趋势未齐 | 优先找同窗口既有脱敏聚合;不足时只补下一两个自然周期相同字段,不手工补跑。每周期记录新增完成批次数、partial件数/总字节、空闲字节,缺历史基点标NOT_MEASURED | +| AC6 | 原10GiB可用不是经核的峰值预算,也不是当前容量事实 | 同批准只读观察取可用字节、受管保留批次总量、自然构建/备份峰值;预算=一次完整新批次实测额外峰值+经审阅安全余量。峰值或余量未定记UNKNOWN,不用最新压缩包大小替代双份DB/附件/镜像的峰值 | +| AC7 | 发送调用与人工确认分开合同已有;缺自然事件响应样本 | 责任人shunli协调系统管理员与公司责任人;自然告警仅记录发送时间、首个确认时间与延迟、是否升级;无事件=NOT_TRIGGERED;无确认=NOT_ACKNOWLEDGED,不能把sent当阅读,不发测试通知 | +| AC8 | 本地工具/治理与交叉引用已有 | 本轮更新后再跑task/Trellis/process/docs/敏感/diff;不替代上面远端项 | + +合并只读观察的输入门禁:固定两个自然周期、既有allowlist调查器版本、双方角色与授权范围;输出仅时间、布尔/分类、计数、字节和脱敏运行标识,不含Secret、地址、真实目录或原始日志。本轮不生成可直接执行的远端命令,也不读真实配置。观察发现无合格新鲜批次、空间不足下一次预算、partial增长或监控失败时记录对应风险并停止扩读;修复、清理、改timer、补传、告警发送分别另批。原恢复PARTIAL和旧入口已消费不变。 + +当前仍有AC1/2/4/5/6/7共6项缺口或裁决,AC3/AC8有范围内证据;不按表格存在性批量勾原PRD。下一可执行动作是接回非秘密观察事实/具体授权,当前主会话继续其他本地工作。 + + +## 2026-09-17 原AC本地证据映射(未新增远端操作) + +按09-08协调规划仅复用已存PRD/实施回执,不连接生产/ARM、不读真实配置/备份、不重传/清理/改timer,不重新跑恢复。此表是原验收对账,不修改PRD复选框,也不把已有局部证据升级为全部结案。 + +| 原验收 | 现有本地记录支持的事实 | 尚缺与后续计划 | +| --- | --- | --- | +| AC1 | 08-28记录有完整本地恢复点、清理保留语义、partial聚合与different修订、空间及wait_natural建议 | rsync原始码/传输时长NOT_AVAILABLE,未收敛单一根因;保留未测原因,不从旧mtime/通用exit补造。新异常再按原调查范围审阅 | +| AC2 | 链路曾自然恢复,有后续完整副本 | 无可绑定的最小修复/回滚/容量处置闭环证据,不能称已实施修复;出现可证缺陷或预算不足后再提交具体变更方案 | +| AC3 | 09-16人工只读回执证明09-14/09-15自然批次manifest、DB/storage字节/SHA及原归档元数据一致 | 该子项已有证据,不再无理由等待两个日期;本轮仅引用arm-user-readback.json,不新读备份 | +| AC4 | 09-16生产及ARM回执有timer/service success/0与新鲜度观察 | 不代表每个自然周期三组监控全部逐项验收;整理原同窗观察可覆盖部分,缺项待有授权的限定观察 | +| AC5 | 两个自然日期的新完整副本已证 | 每周期partial数量/聚合字节、监控与双方容量趋势未形成完整成对记录;不得以两个归档摘要替代整个AC5 | +| AC6 | 已有不同时间点容量观察 | 一个完整新批次实际峰值+安全余量的审阅预算及两个周期趋势仍不齐;先按现有聚合数据拟预算,必要取数另列范围,不主动扩容/清理 | +| AC7 | 既有告警响应计时合同要求,历史首次知晓时间不可补造 | 启用状态和自然告警/恢复样本、人工确认锚点尚无完整闭环;未触发或无证据如实区分,不发送测试消息 | +| AC8 | 与08-23交叉引用保留,本轮按更新文档执行本地task/治理/流程/文档/敏感/diff门禁 | 本地门禁通过不替代AC1–AC7;实际结果归09-05本轮收口回执 | + +原事件保持开放,责任人shunli。下一结案入口是逐项补齐可用旧证据,再针对剩余字段固定最小观察/预算方案;根因仍不可得时保留限制或另审验收处置,不为全绿重传、改历史记录或批量归档。 + +## 2026-09-16 人工只读回执:两个自然批次异机完整性通过 + +用户13:16:49Z在ARM以sudo运行已固定摘要探针,回执来源USER_MANUAL_SUDO。09-15与09-14两份自然批次manifest与12:50Z生产回执逐一相同,数据库/附件字节和SHA256全部匹配,归档元数据未变;最新年龄18.78小时,前一批42.78小时为正常历史批次,不以旧批超过24小时判最新副本陈旧。ARMtimer active、service success/0且检查前后不变,目标盘余89.38GiB。 + +两个连续自然日期的异机完整性证据已补齐;这证明这两份副本已到达且完整,不重建当时传输过程/失败持续时间,不替代PRD其他根因、修复和验收要求,不自动归档事件。普通账号访问限制仍存在,不再将其误写成此次人工核验也未完成。规范化回执位于postrelease-recovery-20260916/arm-user-readback.json;没有生产/ARM配置变更、重传、归档修改或恢复。 + +## 2026-09-16 最新自然备份只读核验计划 + +归属09-08总任务P1,为08-23配对恢复选源提供事实。本轮仅规划,未连接远端;历史2026-08-28调查结论不等于本日链路仍健康或仍故障。现生产发布b1d25d3c;09-16手工842c61e6备份仅已知同机存在,不能抵消异机传输/自然周期验收。 + +- [ ] 确认只读访问范围与权限,记录生产/ARM同一观察窗口;读取既有受控合同确定目标和状态来源,不靠历史日志猜地址/路径。 +- [ ] 分别选生产与ARM正式目录的最新自然complete批次,核manifest规范、文件集合/类型、大小/SHA、来源build/image及年龄;核同批对应关系。手工/周备份/partial分开,不拿目录mtime替代createdAt。 +- [ ] 分列“最新自然批次时效≤24h”“异机完整性”“当前发布同源恢复点是否已形成”;祖先构建但同源镜像可恢复不等于坏备份,当前发布滞后另记finding。 +- [ ] 核daily/watchdog/dead-man的现有timer、最近退出状态与脱敏告警状态;失败退出码只在能绑定同次传输时归因,不凭service通用exit推断rsync故障。不发消息、不restart或修配置。 +- [ ] 记录双方容量、既有partial数量/聚合体积及变化;没有前一可靠观察就记趋势NOT_MEASURED。向08-23提供ARM平台/资源能力事实,不读取原件/数据库业务内容。 +- [ ] 有一个合格最新批次可支持08-23准备单次恢复;事件结案仍须两次连续自然周期各自新批次及完整性/监控/容量证据。历史档案可追溯则复核利用,不要求无理由多等两个周期;缺证据才等下一自然周期。 + +若失败,先提交一份最小故障分类和拟操作范围;手工重传、源复制、镜像加载、清理、参数修改与告警发送都另批,不将只读计划默认为修复许可。产出附回09-08及08-23,避免另开事件任务或重复建工具。 + ## Phase 0:规划与授权 - [x] 创建 P1、`operations` Trellis task,记录来源事件和与恢复演练的阻塞关系。 @@ -88,3 +145,10 @@ - `node scripts/qa/audit-docs.mjs --check-registry` - `node scripts/qa/check-sensitive-content.mjs` - `git diff --check` +## 2026-09-16 新时点只读实施 + +12:50Z核生产最近两个自然批次:09-15T18:30:05Z(b7d2285d,manifest 0913782f84bf7253a8ea1a24e727f8c20a6a637c58737b5df767331809dd2f4e)和09-14T18:30:05Z(229867f5),两者database/storage字节与SHA256均匹配、归档元数据未变。最新年龄18.34小时;生产盘余27.39GiB、备份timer active、service success/0、容器运行身份前后不变。不是当前b1d25d3c的同源自然备份,手工备份未混入自然批次。 + +ARM可SSH且普通账号可读systemd状态:deadman timer active、service success/0;正式归档目录访问被拒,非交互sudo不可用。结论ACCESS_UNAVAILABLE,不是PATH_NOT_FOUND,不是异机副本损坏,也不是完整性PASS。没有安装探针、改权限/定时器、重传、清理或触发告警。两个连续自然周期的异机独立完整性证据仍未取得,事件不结案。 + +证据:artifacts/qa-layout/postrelease-recovery-20260916中的production.json、arm.json、arm-unprivileged.json;下一步需具备归档只读权限的执行方式,再重取同一时点双方批次摘要。生产端通过不能替代异机端,通过一次也不能覆盖两个自然周期要求。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md index 1fb1c919..24e2bd39 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md @@ -1,7 +1,17 @@ # 报销状态空隙与财务处理视角:设计 +## 2026-09-17 当前页进度与报销编号排序修复 + +源码定位:ReimbursementApprovalAdapter的actionMutation成功仅失效workflow查询并refetchTask;ProcessingProgress却取独立的[reimbursements,id]查询,且pending时关闭focus重取。因此补同一动作结束后的报销详情失效;已处理冲突也重取任务与源详情,通用错误不伪造状态。保留服务器结果、不新增轮询、不改全局缓存或审批状态机。 + +付款loadBatchWithItems以created_at/id装载,组批同一时间的随机UUID不等于单号。仅在mapBatch响应投影阶段复制报销items后按entity_code自然升序、id稳定兜底;未知/旧差旅保持原顺序。不能改查询装载/锁定/execute内部顺序,否则可能改变voucher_prefix的序号含义。创建、列表、详情、动作响应复用同一投影,明细关联与金额不变。测试用乱序编号并验证输入items未变;浏览器验证PC/手机实际审批后节点更新。 + ## 2026-09-12 财务本人报销CEO一次审批方案 +发布现状:229867f5已于本轮授权后部署,公网及运行镜像合成验收通过,详见implement顶部及finance-owner-release最新回执。下段“候选未部署”等为发布准备时点记录;安全回退限制继续有效,在途快照不自动修改。 + +发布准备补充:用户已授权并完成31ab9fbb同源配对备份及独立镜像,新连接尺寸/SHA回读通过;候选仍229867f5,未部署。具体回退选择为保守的“先停应用写入并排空事务,重查新步骤快照/任务全部为0才可在另批后退旧镜像;否则保持应用暂停、数据库与附件原样保留,前向修复或另审兼容候选”。不得先查询0后继续放流量再回退,也不得以删单/恢复旧库让计数归零。当前仅server/db运行、新步骤计数0是观察而非故障时保证。停服/旧版切换未执行且须另批;这不是无停机回退承诺。操作步骤及新鲜证据见artifacts/qa-layout/finance-owner-release/README.md。 + 状态:已批准本地实施,取代上一版无人可批才替代且保留三步的草案。边界仅报销;不全局改变请假、采购等流程。 1. 在报销快照构建前形成完整申请人集合(owner_id、submitter_id),与审批执行端的申请人认定一致。候选人须active且未归档,并排除所有申请人;委托的实际操作者与principal仍执行既有自审守卫,不以委托创造绕过路径。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md index 2a329c24..4d81b873 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md @@ -1,5 +1,67 @@ +## 2026-09-17 本批最终本地验证 + +付款待办已显示当前可复核/办理姓名、站内提醒对象及企微个人发送事实。`paymentResponsibility`9/9,最终借款桌面/手机正式登录浏览器同样验证姓名;多通知接收人的单anchor成功不冒称全部送达。真实批次未查询,不能给出其真实待办姓名或宣称个人消息送达。财务原地进度15/15与排序回归保留;借款及ERP改动后的付款128/128、最终前端typecheck/build通过。F4接续已执行;独立复核PENDING、未部署。保护回执与本批候选见借款implement当前入口。 + +## 2026-09-17 付款待办人员与通知可见性追加 + +用户截图指出付款复核未显示待办人,询问企微是否通知到个人。沿既有付款职责补只读投影:当前可复核/办理人员、原站内提醒对象及脱敏企微发送结果。待办资格与提醒接收人分开,不把优先通知对象误作唯一审批人;不改变现有独立复核权限和CEO优先单人通知规则。 + +`wecom_app_sent`才显示个人消息已发送(不代表已读);mock、群通知、无绑定接收人及无记录均不能显示个人送达。只读取本地源码和合成验证,不查询生产账号、配置或通知日志,不发送真实消息。截图该批次的具体待办姓名及真实发送结果尚未核验。 + +## 2026-09-17 两处真人反馈本地回执 + +财务审批成功、退回、转审及已被处理冲突均重读独立报销详情查询,普通失败不伪造进度;付款响应仅对报销按单号自然升序投影,同号按ID兜底,不改变锁定、执行或凭证序号。 + +本批基线及证据:`artifacts/qa-layout/payment-feedback-loan-slim-20260917`。付款定向回归 `payment-route-green-r2` 3文件157/157;浏览器 `progress-green-r2` 15/15,涵盖桌面/手机五类原地处理结果与5个原有场景;typecheck/build通过。首轮2个BIFF8环境失败经选用已有固定依赖Python后通过,转审两例测试身份不符合既有权限已修正合成身份,未放宽产品权限。红绿轮次不累计。当前后续借款正在修改付款共享消费者,其新增回归另记借款task,不能引用早期157作为最终借款验收。 + +无提交、部署、公网验证。F4的本轮最终保护核验和独立复核仍待完成。 + # 报销状态空隙与财务处理视角:实施计划 +## 2026-09-17 当前优先批次:即时进度与报销单号排序 + +官方current已切本task并回读;用户明确要求修复的两处反馈按既有详情一致性范围接续。保护基线177旧差异/6447旧材料,证据payment-feedback-loan-slim-20260917;不改旧冻结包,不访问生产/ARM、真实配置密钥资料。 + +- [x] F1:用PC/手机合成审批证明无刷新仍旧的进度,覆盖通过、退回/转审和已处理冲突;补本页源详情重新查询,不伪造通过。 +- [x] F2:补多报销乱序路由反例;响应层按单号自然升序及稳定ID兜底,验证读取不改原内部顺序/金额/映射,保留差旅顺序。 +- [x] F3:定向路由/浏览器、前端类型构建、语法/治理/差异与保留检查;主会话检查与独立PENDING分列,不把本地修复当已上线。 +- [x] F4:将借款优先级接回09-17既有方案,首批精简先清点ERP专属/共享依赖;不让历史39观测重验挤占本次真人问题。 + +## 2026-09-12 待付款交接只读核对 + +用户批准执行下一步。14:01:56Z生产229867f5只读结果:唯一approved且未付报销已关联reviewed批次,明细pending,已复核、未记录执行,制单导出计数5,未归档回单0。创建人持finance,复核人持ceo且不同于创建人;无需重复组批或复核。只查询单号和状态元数据,不读金额/快照/银行资料/回单内容,不调用导出、解密或业务写接口。RepeatableRead只读事务与10秒语句超时,显式ROLLBACK;check-payment-handoff.py语法及远端执行退出0,证据payment-handoff-result.json。 + +下一位处理人为财务:先核实银行实际交易状态,不能把系统pending或无回单当作未转账;已到账则上传真实回单并完成付款登记,未付款才在确认无重复交易后进行真实网银付款。导出5次不证明银行接受或付款成功;本轮不操作网银、不补造回单、不代改paid。首次银行导入接受性仍需授权操作人员核验,这属于真实资金操作边界而非让员工代做软件回归。 + +## 2026-09-12 发布后旧在途只读核对 + +用户同意继续旧在途只读核对。13:54:06Z读取生产229867f5,使用RepeatableRead只读事务、10秒语句超时及显式ROLLBACK,仅回传聚合状态;未读金额、票据、银行资料或凭据,未登录真人账号、修改业务数据或历史快照。证据为finance-owner-release/check-inflight.py及inflight-result.json,脚本语法检查和远端执行均退出0。 + +全部workflow pending实例0、pending任务0,财务归属人submitted/reviewing单据0;当前不存在需要迁移的待审批流程,不据此保证未来所有单据无异常。未删除报销状态为approved且未付1、draft1、paid1、rejected3。未读取付款批次状态,不将approved未付等同于尚未组批,也不宣称待付款已完成。 + +本项闭合,无需改旧流程。下一步优先只读核对这1张待付款单与付款批次的当前关联/复核/回单状态,明确下一位处理人;3张退回单由归属人按退回意见修改重提,软件回归仍由执行端承担。真实银行导入接受性、真实付款及完整恢复等既有独立缺口仍未被本次聚合查询覆盖。 + +## 2026-09-12 财务本人报销发布闭合 + +用户明确批准229867f5部署并由执行端验证,已执行existing正式路径且退出0。执行前新连接重新核同源备份/独立镜像、时效、生产身份与空间;发布前后迁移pending=none,备份合同刷新通过。公网ready/build及首页通过,106资源扫描无finding,数据库未重启、环境摘要未变。 + +运行镜像审批相关8文件摘要匹配,6项纯合成mock事务快照检查通过;付款相关6文件摘要及合成BIFF8生成/读回通过。没有生产数据库测试连接、真人登录、业务写入或真实付款;部署前519单测与41组隔离HTTP/桌面手机验证按同源输入复用,不冒充本轮公网真实业务闭环。 + +现生产229867f5,发布clone/远端候选一致。本轮源代码无新增变更,仅在本地更新发布事实文档,尚未另行提交这些记录;不重建已部署候选。证据以finance-owner-release的execution-result.json、acceptance-result.json、approval-acceptance.json、generator-acceptance.json和README最新章节为准。以下准备阶段未部署表述均为历史时点。 + +本增量完成,主任务不归档;新提交/合法重提应用新规则,在途不自动重写。后续按已定准入主线收口真实账号与旧在途流程的只读核对、银行导入及完整恢复等独立缺口,不让员工代做软件回归,不擅自扩大生产写入范围。 + +## 2026-09-12 发布退路补齐(已获独立授权并完成,尚未部署) + +- 当前候选229867f5已提交推送;本轮仅新增授权的备份/独立镜像文件与本地回退方案记录,运行时及已推送候选未改变。 +- 生产31ab9fbb同源数据库/附件备份已生成,新连接回读尺寸/SHA及24小时内时效通过;独立镜像1119412224字节,配置与14层、权限及新连接SHA回读通过。旧退路全部保留,未更新自动last-success、未异机传输、未docker load。 +- 镜像保存后约32.68GiB可用;应用/数据库身份与启动时间、环境摘要未变,公网ready=200。无业务数据写入、无停服、无部署;不把配对备份的哈希通过写成完整恢复演练通过。 +- 安全回退采用design所述写入隔离后的条件分流;出现任何新步骤快照即不得简单退旧版,保留数据并前向修复/另审兼容版本,可能需要停服。当前只读计数0不作为未来切换凭证。 +- 执行器仅复用已核脚本、重新绑定BUILD/IMAGE和随机制品身份,Python compile检查通过;未重跑或新增一次519项单测/41组场景计数。本轮task方案/进度为本地未提交文档,不替换已推送发布候选。 +- 下一步须用户对229867f5及上述回退限制另批部署;实际执行前重新核生产变化、备份时效、磁盘与制品完整性,部署后由执行端验收,不让员工代测。当前主任务不归档。 + +本轮证据:finance-owner-release下backup-result.json、backup-readback.json、image-result.json、image-readback.json、rollback-boundary.json、runtime-after-preparation.json及ready-after-preparation.json;README记录现状、授权及安全处置步骤,不覆盖原只读预检回执。 + ## 2026-09-12 本人报销修复执行清单(已授权提交/推送及只读预检,部署另批) 用户在本地验收后明确批准提交、推送与只读发布预检。本轮不部署、不补写生产备份/镜像、不清理服务器、不改真实数据;执行回执保存于既有artifacts/qa-layout/finance-owner-release,提交/推送实际状态以Git及该回执为准。下文“未提交”等表述保留为本地验收时点记录,不代表后续发布准备尚未执行。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md index 3c327d3a..102fd467 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md @@ -1,5 +1,11 @@ # 报销状态空隙与财务处理视角 +## 2026-09-17 真人反馈:审批后进度刷新与付款明细顺序 + +用户要求修复财务复核通过后当前页进度仍旧、同批多报销明细未按单号排序。沿本task既有详情一致性范围本地实施,先于借款与精简;不读取真人单据、不重放真实审批。 + +验收:PC/手机同文档审批成功后,报销处理进度消费新的服务端详情,不需刷新/切页/重新聚焦;退回、转审及已被处理冲突同样重取权威状态,不能把写入失败乐观画成通过。付款列表/详情DTO的报销items按报销编号自然升序、同号按稳定ID兜底,原数组/写入/锁序/凭证编号/导出顺序保持;既有批次无需数据迁移。补三个单据的乱序反例和当前页刷新正反例,保留原权限/副作用断言。仅本地合成验证,提交推送部署仍另批。 + ## 2026-09-12 财务本人报销闭环(已批准本地实施) 用户已批准本地实施和验证:财务本人报销由CEO一次审批,财务创建付款批次、CEO独立复核;提交/推送/部署及生产写入另批。复用当前任务。隔离验证已复现本人报销死结,不据此宣称真人单据已阻断。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json index c8d84156..2f8cc6ec 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json @@ -21,6 +21,6 @@ "children": [], "parent": null, "relatedFiles": [], - "notes": "09-12上轮确认生产及本地基线31ab9fbb,付款创建删除过滤已发布。财务本人报销CEO一次审批、付款独立复核及转交/委托资格约束本地完成,519项不同单测、最终41组隔离HTTP/桌面手机场景通过,独立只读复核两项发现闭合。用户已授权本候选提交/推送及只读发布预检,实际执行状态见Git及artifacts/qa-layout/finance-owner-release回执,部署/生产退路补写/清理另批。旧镜像缺新CEO守卫,已有新快照后不能直接退镜像继续处理。命令、覆盖限制和中间失败见implement;银行导入、完整恢复等其他准入缺口仍未闭合,不自动归档。", + "notes": "2026-09-17状态摘要核对:财务本人报销CEO一次审批及转交/委托资格修复已随229867f5发布,finance-owner-release已有公网只读验收;Git确认包含于最近已核发布b1d25d3c,不再待部署。519项单测、41组隔离HTTP/桌面手机场景及独立只读复核沿原证据,不计本轮重跑;付款独立复核仍保留。旧在途只读核对已完成,历史未提交事实记录不等于代码未上线;出现新快照后仍不能直接退到缺资格守卫的旧镜像。原全量验收未据此补勾,状态保持in_progress,阶段/遗留归属见09-08-reimbursement-payment-closeout/implement.md顶部;本轮仅纠正摘要,不提交/推送/归档/部署。", "meta": {} } diff --git a/.trellis/tasks/09-05-error-explainability-payment-readiness/design.md b/.trellis/tasks/09-05-error-explainability-payment-readiness/design.md index dcb09cb1..eca47c80 100644 --- a/.trellis/tasks/09-05-error-explainability-payment-readiness/design.md +++ b/.trellis/tasks/09-05-error-explainability-payment-readiness/design.md @@ -1,5 +1,148 @@ # Design:错误可解释性与付款就绪治理 +## B34 实施前解析器分类补充 + +实际依赖合成观测见 import-parser-fix-20260917/library-observations.json。仅普通 Error 的确切格式进入白名单:fast-csv 默认引号未闭合/引号后缺分隔符;JSZip 非 ZIP 的完整固定提示/缺少中央目录结尾;saxes 带行列前缀的未匹配闭合标签、未定义实体、无引号属性值。白名单按 CSV/XLSX 分开,未知消息、非 Error、TypeError、带其他内部故障形态均保留 500,并替换为无原异常引用/cause/自定义属性的固定 Error。白名单不声称覆盖依赖的所有损坏输入,新增格式须有真实库复现。 + +catch 只包两个解析 await,不包表格归一化、toBuffer、preview 或 apply。合法 CSV 的 map 原样保留文本,正常 XLSX 仍使用现库。测试抽取 app.js 原 error-handler 回调作响应/日志核验,不 import app/config 或启动 DB/jobs;合法导入继续跑原真实哈希测试,不用 mock 哈希换绿。 + +## 2026-09-17 考勤职责与 D15 优先级更正 + +依据用户“考勤功能在企微”的澄清,以及 docs/产品设计规则.md 的主办边界与考勤页规则,日常考勤由企微承办。client/src/pages/AttendancePage.tsx 已明确 OA 只展示摘要、解释、同步状态和审计;/attendance/monthly 是 OA 侧汇总查询,不是日常打卡入口。源码保留入口不能证明生产启用或真实使用。 + +下方 D15 的低年份观测继续保留,但退出当前必修/待决策清单,作为非阻塞技术记录。2000–2100 方案未获采纳,不实施新年份限制、不恢复旧 OA 打卡、不扩修待精简工资。今后只有摘要查询出现明确业务需求或实际故障证据时再评估;不能把此项处置扩大为删除企微同步、摘要或审计能力。B34 与 D16 原处置保持。 + +## 2026-09-17 三类余项的在用性与限定方案(已完成确认,产品未改) + +本次批准为范围确认及先行规划,采用acceptance-boundaries-20260917/boundary-observations.json的合成观测,不把问题复现算修复通过。未新读真实配置/使用量,所称在用入口表示当前产品/源码保留入口,不证明今天的生产开关或使用频次。 + +### B34 损坏账号导入:下一本地优先修复方案 + +账号管理CSV/XLSX导入仍为保留能力,不属于ERP或工资待精简分支。routes/users的dry-run/apply共用readUserImportPayload:文件尺寸/扩展名/MIME已验证,但ExcelJS的csv.read与xlsx.load异常未映射。原错误handler对非AppError返回500/SYS_001并记录原err;合成未闭合引号CSV、非ZIP XLSX、截断XLSX在两入口共6例均500,CSV两例原err.message含合成单元格标记。9个路由探针(另含两空表头文件及权限拒绝)全部数据库调用0,接口无标记泄露。此为可解释性和日志边界缺口,不声称真实内容已经泄露。 + +限定修改范围:users解析边界、新原因的server/client/固定夹具三方,以及users.import定向回归;不修改全局error handler、所有导入器、上传限额、员工权限或apply事务。拟新增user.import_file_invalid,400/VALID_001、details仅固定file=invalid_format,用户提示重新导出CSV/XLSX;不包含文件名、sheet/cell、原解析异常/cause或数据行。现有missing_headers/empty/row_limit_exceeded/大小错误及其优先级保持。 + +实施步骤:先以实际依赖可识别的CSV语法和ZIP/XML格式错误建立白名单及反例,只在内存buffer解析两个await处翻译;未知解析器/程序异常继续500,但换成无原message/cause的固定内部错误后交日志,不能把任意故障伪装成输入错。toBuffer读取故障、权限、事务/数据库/哈希异常不得被这个catch吞掉。可识别异常策略须在写产品前用现有版本补齐,不能仅按任意message包含csv/xlsx猜测,也不全局catch所有Error返回400。 + +验收矩阵:dry-run/apply × 未闭合引号CSV/非ZIP/截断XLSX/坏XML;合法带引号换行CSV和真实生成XLSX/日期文本/空值/500与501行保持;原7个文件原因/413来源映射保持;权限拒绝先于解析;未知异常仍500且日志/响应均无合成单元格或原exception,读取失败不改分类;格式失败不开始preview/transaction/员工部门角色/成功审计。合法apply仍跑原真实哈希用例,串行避免旧超时。验证users.import、errors-client固定映射、strict/self-test、node --check、前端typecheck/build与治理。没有新数据库结构、外联或真实导入。 + +### D15 极低年份:分开旧代码与保留入口 + +parsePayMonth及统一考勤parseMonthRange对0000/0001/0099分别映射1900/1901/1999,0100/1900/2024正常;共享date-only字符串解析保留字面低年份。旧attendance服务buildMonthRange已有2000–2100限制且attendance_v1硬关闭,不存在该低年份入口,不能为验证打开它;未使用shiftMonth也不扩修。工资属于既有retirement-review候选,按用户取舍本轮不补修工资/工资导出。 + +统一考勤/attendance/monthly仍有页面/route入口,不能整文件以旧考勤名义排除;此为只读查询年月与边界不一致,保留低优先级决策。推荐后续仅该外部年月采用与旧业务月查询一致的2000–2100明确范围;这是新接受年份规则,须单独确认后才写代码。备选为保留四位公历年份并正确构造UTC界限,需另核0000/真实PG支持与跨世纪闰年,不宜未经决策全局替换Date.UTC。均不改历史年月/日期,不推断生产存在低年份数据。 + +### D16 AP反向区间:范围确认后维持现合同 + +ApOperationsPage将ledgerDateFrom/To分别传列表与导出,共用financeLedger构造gte/lte。两端为有效日期但from>to时继续200,谓词同时成立无匹配;不交换日期、不忽略其中一端,deleted_at过滤仍保留。73项现有AP列表/导出回归通过;探针只实测共享where构造,不冒称真实PG空集。日期本身非法仍400,pendingCreatedAt反向范围的原400与ledger不是同一合同。 + +当前不列为必须修复项。若以后需要输入体验提示,必须同批覆盖页面、列表、CSV/XLSX导出及原因三方,把200改400作为显式需求;不可只在共享where偷偷改变规则。不新建视觉方案、不改权限/金额/写入,也不需真实数据验证本次保持结论。 + +## G8按在用路径收口 + +按implement顶部B31–B33合同处理57处,17处ERP旧分支按用户最新指示跳过并保留源码/基线。其依据为产品设计规则61/89及企微路线图37;通用outbox有notification.im消费者不整文件跳过。当前权限/状态/编号/开关/认证业务规则保持,只本地合成。所有旧材料保护、独立PENDING,不读真实配置密钥/外联/提交部署或实施借款。 + + +## G7本地错误合同接续 + +查询考勤周报八源42处,按implement顶部B29/B30逐操作合同实施;三方固定提示、精确减基线、实际路由/服务负例和合法回归、页面安全消费及保护新候选。legacy考勤硬关闭保持,内部路由用合成门禁桩分层验证;周报项目开关失败前原update尝试保持。独立PENDING,不扩权限/状态规则,原真实数据/外联/提交部署边界不变。 + +## G6实现边界 + +共享审批原步骤/角色/兜底/人员上下文守卫分别注册原因,动态主管失败沿固定安全提示,不拼角色ID或链内容。流程定义与审批规则schema由调用方显式传原因;原快照、权限、锁、状态、发布/审计和金额规则保持。不可达防御仅静态核对,不通过改规则制造测试覆盖。B28a仅把委托本人范围和有效期筛选从覆盖式object spread改为AND组合,保留每一原谓词及管理权限来源;结构核验显式单列此处,路由内存过滤负例证明普通用户不会得到他人有效委托。 + +## Bug Analysis: 委托列表同名OR覆盖 + +### 1. Root Cause Category +- **Category**: E/D - 隐含对象合并假设与测试缺口。 +- **Specific Cause**: 期望本人范围OR与有效期OR同时成立,但后置spread按键覆盖前者;原默认includeInactive=false触发。 + +### 2. Why Fixes Failed +无历史修复证据,不编造失败史。本轮先保留4个真实Fastify/合成内存过滤红例,唯一AND组合修复后8个范围用例及全34项委托用例通过。 + +### 3. Prevention Mechanisms +| Priority | Mechanism | Specific Action | Status | +| --- | --- | --- | --- | +| P0 | 结构 | 两个已有谓词放入AND,保留管理权及原日期边界 | DONE | +| P0 | 回归 | 默认/false/0与true/1、本人/他人、管理员过滤、开始/结束等时边界 | DONE | +| P1 | 规约 | backend-fastify-prisma记录同名OR合并与消费where的测试要求 | DONE | + +### 4. Systematic Expansion +本轮只对baseWhere/scopeWhere/permissionWhere传播作源码定位,未做全仓授权审计,不声称不存在其他组合问题。无通用查询框架改造;后续可见性条件变更应保留权限对象并组合筛选。模拟findMany不能无条件返回固定列表来证明数据隔离。 + +### 5. Knowledge Capture +- [x] 更新现有后端spec、当前design与implement;无新计划系统。 +- [x] 本仓无src/templates/markdown/spec目录,不生成Trellis工具模板。 +- [ ] 独立只读内容复核PENDING;遵照用户不提交要求,不执行技能默认commit。 + +## G5接续设计 + +合同schema由四操作显式传原因,版本/终止/归档/纠错/续订守卫保留status和事务位置。采购预算先写流水后查池、release缺池继续的原语义不改,默认409显式传给新builder;仅无剩余消费者时删除旧导出。测试区分已尝试写入、无后续额度/成功审计和mock事务恢复,不能冒称真实PG回滚。 + +资产分类/卡片/索引schema和人员角色原因由调用方明确给出;流转与盘点原状态和循环写入位置保持。采购上传策略仅用既有PurchaseAttachmentPolicyError.reasonCode白名单映射(未知用固定安全兜底),下载构造保留旧message/status/details.reason与必要审计。AST批量迁移需识别shorthand statusCode属性,不能因缺initializer误用默认400;本批通过下载409及原文案路由断言证明。采购金额规范化前原主表update/明细delete和预算前原重算写入不改变。 + +## G4差旅实现设计 + +申请list/id、create/patch/link-project输入及既有状态/财务维度/预检/流程分支分别注册原因。link-project原parseUuid无定义,三个真实Fastify合成用例先复现非法/绑定/解绑均500,再用parseTravelRequestId修复并回归原404/403/开关守卫;不新增项目关系或状态限制。CJS语法检查不能发现运行时未定义函数,保留这组路由回归作为防复发证据。 + +订单/授权/交易导入共享禁用原因,各类schema和空归一化分别解释;标记付款保持精确结算交易匹配,冻结建议仍只记录建议。travelPayments缺池保留409,授权/冲正先ledger再查池,结算原仅ledger不查池/改额度。对账路由与服务旧helper默认409,迁移均显式保持;只有原schema/id错误400,原存在性校验不额外限制启停。原helper全部消费者迁移后删export/import。 + +七源结构检查仅排除错误构造/原因参数及已单测的ID解析器修复,原权限/状态/副作用顺序/事务保持。主会话本地证据,独立PENDING。通知、预算、流程参与者只用桩,ERP仅mock DB outbox,不声称真实发送、PG回滚或开关开放。 + +## G3基础资料实现设计 + +共享schema由调用方显式给操作原因;动态参数经getErrorReason校验,所有原因在服务端、客户端固定映射与断言夹具三方登记。四主数据实体各有id/list/create/patch/空修改原因,跨模块相同公司引用拒绝复用原因;原编码规范化、层级/归档和P2002映射范围保持。日历日期替换保持500项、空数组清空、默认日历绑定与先验后删顺序。 + +费用标准保留合并旧范围后校验的PATCH语义,假别保留原空PATCH和嵌套policy更新,模板保留50项、禁用类型原索引详情及软删除幂等。项目14种schema、7类id、5类开关、列表和豁免备注分别明确;收口项原先初始化再验id/body顺序保持并单独合成实证。项目日期仍只有原格式校验,本轮不扩日历规则或真实功能开放。八源AST去除错误构造/原因参数后业务结构一致,只作主会话辅助证据。页面沿既有安全resolver;假别写API无现有页面调用、费用类型仅有读取客户端,不能声称完成这些管理页面。 + +## B11–B14实现边界 + +预算范围和选池沿用原优先级,控制规则PATCH仍按提交body判断;账号唯一冲突详情继续兼容但客户端不拼数据库字段,密码策略与角色/会话操作不改。部门层级/归档只替换错误构造;员工各schema由调用方显式传入注册原因,分别标识合同、培训、证书和生命周期操作。 + +withUploadSizeReason仅包住员工上传request.file与toBuffer,精确捕获FST_REQ_FILE_TOO_LARGE→原400/VALID_001/file=file_too_large,其他异常原样。合同下载构造器使用注册原因后恢复原安全message“合同附件未通过安全校验”,409及阻断审计保留,不泄露文件字节。六源AST结构对照证实原守卫、数据访问、权限和事务未变;这些为主会话本地证据,独立仍PENDING。 + +## B10路由原因与全量分组 + +预算13分支使用显式budget原因;绩效12原因中parseBody由创建/修改/评分操作显式传入,并通过getErrorReason校验,已有活动周期预检和P2002共用同一原因。移除两个无消费者的legacy builder,不改预算共享服务、schema、状态判断或守卫顺序。页面复用既有resolver;P2002测试只证明异常映射,不证明真实PG并发约束。全部546处按implement顶部互斥文件组持续收敛,详细操作合同在每批实施前补齐。 + +## B6/D13/D14/B7–B9连续本地设计 + +B6保持上传原生产400合同,分别捕获取得filePart与消费toBuffer时的文件过大异常;7原因不涉及文件名/内容拼接,其他异常原样。D13证实月份00/13已有后置拒绝,只补路由/UTC月界证据;低年份Date.UTC映射另列,并未改可用年份范围。D14在公告原schema候选之后检查本地日期部分与finite Date,避免仅验ISO前缀;不拿UTC日与输入日相等作时区校验,因此跨UTC日期偏移仍合法,原无偏移/24:00滚动兼容保留。 + +B7–B9把公告和工资现有拒绝分支按操作注册原因;parseBody所需原因由每个调用方显式传入,删除这两个文件已无消费者的legacy builder;attendance/exports则保留可选原因,防止给其他域贴错提示。原权限、状态判断、数据库查询/锁/事务/金额规范化代码不变。公告创建/更新/归档/签收复用既有安全resolveErrorMessage及toast;工资页面本来已消费resolver,无额外UI改造。P2002冲突只是数据库桩测试,不虚报真实并发/回滚。累计新增36原因,审阅与恢复/生产交付门禁继续分开。 + + +## 2026-09-17 D12/B5实现设计 + +D12将HR原多格式转换与日历有效性分开:canonical日期用既有isValidDateOnlyString判定,normalizer同时保留hireDateInvalid,不能将非法null解释为缺失。processProfile在原缺必填/非active守卫之后、resolveDepartment之前整条skipped/invalid_hire_date;对直接注入sourceProfiles的hireDate再校验。合法来源、新建缺省now及现有员工不改hire_date保持;运行/item审计允许,业务实体和主管同步不得消费非法项。 + +B5按明确操作选择7原因。合同和在岗共享legacy错误helper还被写入/冲突使用,故只迁移筛选服务具体分支;员工/合同导出外层schema另显式传域原因,经getErrorReason校验后调用business builder。其余导出不传参数,保持旧合同;不按details动态推断、不改变导出/权限/31天规则。原因注册/client/fixture及只减的baseline作为同批审阅输入。 + + +## 2026-09-17 D1–D4执行边界 + +沿implement中已落盘的四批日期规划继续,统一复用日历谓词但仅在各已核外部schema串接refine,保留原regex、nullable/optional、权限守卫及业务写入顺序。todos两处dueDate、员工共享筛选及导出、节假日整批items、仪表板与AP导出日期分别验证;导出共用DATE_ONLY_SCHEMA保持旧定义,其他域不能被无意扩修。 + +非法节假日必须在解析整批时拒绝,事务/清空/新增/审计均未调用;合法批次仍按原事务写入,不以mock声称真实数据库回滚。AP pending反向区间保留400;ledger反向区间原无拒绝守卫,另待范围规则审阅,不在本批改变。员工/users与/employees共享服务,权限判断原顺序及最小目录DTO保持;日期输入修复不授权读取或修改真实员工资料。 + +验收为本地合成Fastify路由与已有回归、语法/合同及治理检查;不会启动真实DB、读取环境配置或触发消息。新结果/候选按新目录保存;上一轮候选与未提交修改继续保留,不把主会话自检计为独立复核。 + +## 2026-09-17 B1/B2小批设计 + +B1沿既有isValidDateOnlyString窄谓词:在四个高风险外部路由的原字符串regex之后refine,再保留原optional/nullable外层;区间先保证两端都是实际日历日期,再走原顺序约束。周报必须在周一归一化之前验原始输入,在岗按写入/查询各入口追踪。服务层二级消费者只有查明外部来源才补闸,不统一改转换器或批改存量。 + +B1每组锁定原HTTP状态、权限、workflow及事务调用次数;无效日期不能进入写入、通知或成功审计。日期函数已有测试复用,并补各真实路由负例,避免只测正则/复制schema。合同/采购等冻结业务只修输入契约,不开入口或改开关。 + +B2按现有检查器的实际指纹/上下文分组,不把612次历史计数当作待修bug总数。新迁移采用buildBusinessValidationError,保留status/code/details;动态OCR字段、密码规则和催办时间沿已审窄投影及服务端负载合同,不以注册静态句覆盖动态指导。异常正文、银行数据和未白名单字段不能进入提示。 + +回退按单一小批补丁进行,不新增迁移、不改历史数据;原因码、客户端映射、固定文案夹具、对应测试和只减不增的基线处置作为同一交付单元。新发现需要修改审批资格、状态机或付款语义时停止并另审,不藏在提示治理中。到期延期只允许逐项理由/负责人/复验条件经批准后变更,禁止集中改日期。 + +### 本轮落点与审查边界 + +B1a四路由加合同查询共享服务`contractRegistryFilters`;该服务也接合同导出,属于已证实外部输入,复用同一守卫。B1b两路由加`exports`的PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA,限定在岗导出日期,不改所有导出共用日期schema。`presenceHistory`仍只处理由已校验入口或内部当前日期传入的值;其他导出/待办等日期另列后续消费者,不在本批临时扩修。 + +B2当前扫描229注册/316已迁移/612遗留、主链例外0。选取通知列表/渠道管理、员工待办及门户开关提示这一小批,逐固定原因码迁移原调用,不更改通知投递、权限、状态或业务开关;门户未使用的通用validation helper可删除。三方文案与真实路由负例整批校验,仅移除实际消失的AST指纹,原owner/expiresOn逐条保留。通知测试使用现有合成渠道,不调用真实企业微信。待办日期溢出属于另一个输入批次,先登记,不借提示迁移扩大行为。 + ## 1. 总体边界 本任务不把全部 `VALID_001` 重构成新异常体系。保留 HTTP status、顶层 code 和既有 details,在已迁移错误中增加稳定 `details.reasonCode`,用注册表、遗留指纹基线和 focused 测试形成只增不退的 ratchet。 diff --git a/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.md b/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.md index b8ce4807..c12332da 100644 --- a/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.md +++ b/.trellis/tasks/09-05-error-explainability-payment-readiness/implement.md @@ -1,5 +1,646 @@ # Implementation Plan:错误可解释性与付款就绪治理 +## B34 本地实现与回归回执 + +收尾交接入口为 `artifacts/qa-layout/import-parser-fix-20260917/review-candidate-r2`:仅打包本轮19文件增量、前后SHA、补丁及关键回执;旧G8/K0和历史验收包继续保留并作为引用输入。主会话检查见同证据目录 `main-session-review.md` / `final-verification-r2.json`,候选完整性见包外 `candidate-verification-r2.json`;三task上下文、Trellis/process/docs/敏感/diff与旧材料保护分别登记,不把包完整性或主会话检查冒充独立复核。独立内容复核PENDING、独立重跑NOT_RUN。 + +损坏账号导入已按限定方案实现:两个内存解析 await 映射真实库白名单异常为 user.import_file_invalid(400/VALID_001/file=invalid_format);未知异常固定为不带原内容/cause/自定义属性的内部 Error,生产回调继续 500/SYS_001。原文件读取、大小/格式/表头/行数、权限、preview、apply事务与真实哈希顺序保持;全局 error handler 未改。新增一个原因及客户端/固定文案映射。 + +最终 product-final-r3:2文件922/922通过,其中68项导入路由、854项原因/客户端合同(不是922个业务场景)。14个真实坏格式路由请求(两入口×7格式)均400、无业务访问或原错误日志;未知异常保持500且日志无合成单元格。合法CSV引号/换行、真实生成XLSX、日期文本/默认、CSV/XLSX 500与501行、原7文件原因及权限先拒通过。读取/DB/事务/哈希故障保留原分类,合法apply仍跑真实bcrypt。首轮23红例已留存;product-final初轮1失败为新增测试误认为apply预览在事务外,按原实现改为事务已进入1次,未改产品事务或削弱业务零写入断言。最终精确匹配再补非语法标点反例,原过程日志保留、不累加统计。 + +frontend-build(含typecheck、Vite/PWA)通过;strict为832注册/823迁移/17遗留、主链例外0/findings空,20组self-test通过;4 JS语法通过。旧Browserslist/punycode告警未扩治;17 ERP基线/到期信息未变。证据归 import-parser-fix-20260917;仅主会话本地验证,独立复核PENDING、未部署,后续门禁分类归09-06。 + +精确命令:`cd server && npm test -- tests/unit/routes/users.import.test.js tests/unit/scripts/error-reason-client.test.js --maxWorkers=1 --minWorkers=1`;`cd client && npm run build`;`node scripts/qa/check-error-reason-contract.mjs --strict` / `--self-test`。实际由本证据目录 run-local-check.py 在精简环境运行,未读取真实env/npm配置,完整命令与退出值见各command.json。 + +## 2026-09-17 B34 实施计划(用户已批准接续) + +本轮先修复损坏账号导入,再切 09-06 对 58 条历史发布门禁观测作在用性分类。沿已批准本地边界执行,不启动 R1、生产/ARM、真实配置密钥备份、旧恢复或提交部署。考勤 D15 仍为非阻塞记录,旧/待精简代码不扩修。 + +- [x] B34-1:保护旧差异/冻结材料;核实际 ExcelJS/fast-csv/JSZip/saxes 的可识别格式错误,用真实 multipart 两入口补红例。 +- [x] B34-2:只在 CSV/XLSX 内存解析 await 处映射白名单格式异常为 400/VALID_001/user.import_file_invalid;未知异常改固定安全内部错误,仍 500。读取/权限/DB/事务/哈希不进入此 catch。 +- [x] B34-3:原因注册、客户端提示与固定文案夹具同批;坏格式无业务访问、未知异常不带原内容、正常 CSV/XLSX/日期/500行及原大小/表头/权限合同回归。 +- [x] B34-4:串行跑导入哈希回归及原因用例,再类型/构建、strict/self-test、语法与治理;记录新候选、旧材料保留和独立 PENDING,更新 09-08 协调入口。 + +证据目录 import-parser-fix-20260917;入口 169 份旧差异、5797 份旧材料及 HEAD/index 已建立保护基线。实际库版本 ExcelJS 4.4.0、fast-csv parse 4.3.6、JSZip 3.10.1、saxes 5.0.1。7 个真实库合成探针均拒绝;其错误均为无 code 的普通 Error,CSV 未闭合引号含合成单元格标记。按确切消息格式建立局部白名单,不能以包含 csv/xlsx 或任意 Error 作为 400 依据。 + +## 2026-09-17 考勤澄清后的当前执行口径 + +用户明确“考勤功能在企微”;已核现行产品规则和 AttendancePage 文案,日常考勤由企微承办,OA 保留摘要、解释、同步状态与审计。此前“统一考勤入口待年份规则决策”表述容易误认为还要开发 OA 考勤,在此更正。 + +D15 仅保留 OA 摘要查询极低年份的已知观测,不列本轮必修、不等待新的年份规则决策、不阻塞其余工作;既有工资待精简/旧打卡排除继续有效,不删除保留的企微汇总代码,也不标记缺陷已修复。B34 损坏账号导入仍是下一本地优先修复,尚未实施;D16 维持现合同。此次只追加任务说明,旧探针、冻结包、历史记录和产品源码保持。 + +## 2026-09-17 三类余项已确认,B34与D15分别规划 + +本轮按用户批准完成范围确认,不新增产品改动。新证据acceptance-boundaries-20260917,详见design顶部。9个真实Fastify/合成multipart探针使用AST提取的生产error handler回调及只记布尔的日志桩,不启动app/config/jobs;6个年月探针为真实helper,AP真实where构造保留deleted过滤。 + +- [x] B34损坏导入:两入口×三损坏格式共6例500、CSV两例logger收到合成单元格标记;响应无标记且9个探针全部DB调用0。已落安全错误映射/未知异常分流/合法兼容方案,尚未实施修复。 +- [x] D15低年份:0000/0001/0099偏移,0100/1900/2024正常。旧考勤已有年界/硬关闭、未用shiftMonth及工资待精简路径不扩修;保留统一考勤入口待年份规则决策。 +- [x] D16 AP:原合法反向ledger范围保留200/有界where,非法日期和pending反向仍拒绝;现有2文件73/73通过,不额外增加范围400。 +- [x] G8最新候选与K0候选作为两个独立主题交接,运行时输入保持;本轮只改任务/证据,独立结论仍PENDING。 + +探针初轮启动失败为本地未安装acorn,改用已有TypeScript AST;次轮把parseMonthRange误从route顶层取出,改为既有__testables导出后通过。只修本轮探针,原产品未变,失败与最终日志分开保存。当前结论是有缺口的行为确认,不是6个500已经修复;G8的503已处理/17排除口径保持。 + +## 2026-09-17 G8在用系统与集成合同(实施前) + +入口785注册/778迁移/74遗留、73指纹。按用户最新取舍,ERP专用17处跳过,依据产品设计规则“OA不接ERP,未来也不规划ERP接入”和路线图37行,源码和指纹保留在skipped-obsolete.json。通用outbox支持notification.im,不能与ERP整体丢弃;其4调用及旧builder5处仍处理。其余52处位于实际注册、页面/API或核心服务消费者,普通开关不视为废弃。目标完成57在用/保留路径,不以0为目标;不擅自删旧代码或延基线期限。 + +新证据error-debt-g8-20260917,4884旧材料/139旧脏/HEAD/index保护。G7文档误写16 JS语法,实际syntax.log为17,包外在此更正,冻结包不回写。inline独立PENDING,不外联、不读真实配置密钥备份/附件,不提交部署或实施借款。 + +### B31保留的系统维护与共享守卫 + +配置读取2处与编号5处:enabled筛选、类型输入/不支持类型、指定分类不存在/不匹配/禁用归档;保留expense别名、报销旧序号优先、缺省分类回退/编号递增/原409。工作区2处只改PATCH schema;保留模块可见性规范化及原audit失败容忍。涉密索引4处按create/patch/query/开关解释,原直接UUID Zod保持,不新增权限/索引内容校验或读取真实资料。 + +切换route4+service1与迁移dry-run5:id/create/patch/开关、未纳入既有范围;保留关闭gate时放行、启用时按domain/department/entity任一匹配、startsAt逻辑、原创建/更新无新增audit。dry-run只合成multipart CSV,缺文件/类型,原无行或缺表头仍201带failed状态,不写业务主表。不是旧恢复重跑。 + +功能开关8处:key/page、update输入及global不允许departmentIds、batch输入/重复key、单/批次版本冲突;保留事务/乐观锁/逐项审计和env硬上限,不扩开关能力。费用预检5处:输入、服务不支持实体;保留权限及各域检查/风险规则;服务后置类型不可由已验路由触发,实际导出函数单测或静态标注。风险5处:query/id/action body、非open,原权限/resolve-dismiss状态及审计保持。 + +### B32鉴权与企业微信入口 + +auth插件1处不支持实体动作409;保持JWT、角色、资格、403和认证路径,只合成签名与mock人员可测,不代表生产认证。企微8命中:签名URL非法/带凭据/片段、输入、登录query/PC模式、缺入口配置与缺应用凭据、bind输入;保留同源403、原404/401与cookie/state/nonce/returnTo合同,不回显URL或凭据。所有provider一律mock fetch,无真实配置读取;配置入口仅要求corp/agent/redirect,缺应用凭据仍可产生登录跳转;回调取得token时才409。两守卫各自路由负例验证,无外联;URL构造catch因前置z.url不可达仅静态。 + +### B33通用消息队列与AI入口 + +仅outbox/list/detail/retry及bindings query,保留分页、权限、failed/dead_letter重试资格及审计;retry原只重置pending、不会直接执行投递任务;17 ERP原节点逐字保留。AI两处按三类对象id显式原因、未开放范围409固定提示,保留权限/缓存/provider/mock与脱敏,不接真实模型。页面现有安全resolver或明确错误态保持,不新视觉。 + +- [x] B31三方原因及实际路由/服务负例、合法与已有消费者回归。 +- [x] B32/B33合成鉴权/provider/队列/AI验证,不读取真实配置或外联。 +- [x] 精确减基线(保留17 ERP)、最终构建/治理/保护新候选,独立PENDING。 + + +### G8验证事实与保留事项 + +46新原因,56指纹/57命中精确退出,strict为831注册/822静态迁移/17遗留、17指纹/1源文件,主链例外0、findings空。余17全部为用户排除的ERP专用分支,verify-skipped逐字比对17构造体且最终基线恰等于排除清单。保留原owner/2026-10-05,不把旧分支当新故障、删除代码或默默延期。G1–G7无旧命中;G8本轮57处限定范围完成,扫描非全系统运行故障计数。 + +16源behavior-shape PASS,仅错误构造/import/显式原因参数。系统维护59用例通过;企业微信41用例通过(网络全mock);auth新增5例用真实JWT库与本地合成key/mock员工验证,不是生产JWT/真实人员。外部队列重试只改pending并审计,不即时发消息,原ERP_OUTBOX_RETRIED审计名保持不夹带改名。AI已有固定安全错误态/范围提示,不改UI或外联模型。 + +发现并记录双层校验:feature PUT Fastify先要求departmentIds,Zod默认不会越过该前置层;schema拒绝路径保留,不伪装内部原因覆盖。key通过无前置params schema的changelog验证;update输入用Unicode码点/UTF-16长度差异触发真实Zod后置限额,原限制不改。缺企微secret不会阻止登录跳转,回调token阶段才409;新增独立路由负例,配置入口缺corp另测。URL构造catch被前置z.url覆盖,只静态。 + +首轮系统测试误写URL/method、缺标准envelope、未满足前置JSON Schema,修正夹具后59全绿;企微旧exact详情断言和错误的secret阻断时点假设已纠正,未改产品门禁。最初迁移工具的21 calls日志实际含17显式skip,实际4迁移;后续脚本分开打印,ERP保留由逐字证据证明。阶段数不累加。 + +G8最终product-final为19文件1093/1093,含853固定中文映射断言;client-build-final的typecheck/Vite/PWA退出0,原Browserslist告警保留。阶段回归不与1093相加。命令精确参数/退出码及日志保存在本批product-final.command.json/log、client-build-final.command.json/log;本地19文件覆盖不代表整仓、生产、浏览器或独立验收。 + +G8收口:24增量JS语法、Trellis/process/docs与两task上下文、敏感/diff均通过;原22文档未登记告警保留。4884旧材料集合/SHA、HEAD/index保持;139旧脏131不变/8接续、31增量。候选error-debt-g8-20260917/review-candidate一次生成,承接G7及所有旧产品/规划输入;独立PENDING。完成上限为本轮限定错误合同本地候选,不提交推送部署归档、不改变官方current状态来虚报整个task完成。 + +## 用户最新取舍:在用路径优先(2026-09-17) + +用户明确“旧代码或要精简的代码,不用解决”。从此不以AST清零为验收:已完成改动保留并完成必要验证;后续先核源码注册、页面消费与现行规约,确认FROZEN_CLOSED/弃用/计划移除者记录依据并跳过,普通feature-off不自动等同废弃。旧告警不为绿灯扩大治理。待删除代码不擅自删除、也不把未修计为完成;原基线期限不得静默延期。legacy网页考勤已在此前完成迁移,本次指示后不再扩补其测试或重开功能。G8按在用/保留/弃用重新分类后实施。 + +## 2026-09-17 G7查询考勤周报接续(当前入口) + +G6完成并冻结;入口736注册/745迁移/116命中/114指纹,G7八源42处。新证据error-debt-g7-20260917;保护全部旧材料/差异/HEAD/index,inline本地合成,独立PENDING,原外联/配置/真实数据/提交部署/借款边界保持。 + +### B29考勤与在岗限定合同(实施前) + +考勤路由9处+服务9处:各clock/month/team/policy/backfill/sync/sync-runs输入与两开关、免打卡日/未签到/补录空值与倒序、策略时间/容差/工时午休上下界及月年范围。保留原400/409、打卡幂等/当前时区、管理员补录允许仅退签/相等时间、31日同步与50员工限额。服务后置year/month、容差、datetime防御在路由已验输入下不可达,只实际函数验证或静态核对,不冒充路由覆盖。策略superRefine不能把新增reasonCode当业务字段错误;沿原字段集合转Zod。企业微信只桩provider,不外联或读配置。 + +在岗路由3处+旧共享builder1处:各日期/历史/管理员id与body输入、功能开关、已批准请假覆盖不可自行更改;原历史范围原因保留。迁移全部消费者后移除旧共享builder;管理员原覆盖规则、在岗优先级/可见范围与审计不变。 + +补充可达性:featureFlags中legacy attendance环境上限硬编码false,真实门禁测试必须继续409;后续legacy路由守卫仅在明确合成门禁桩下覆盖,不声称功能已开放。新增reasonCode从策略superRefine转字段错误时排除,避免引入伪业务字段。 + +- [x] B29三方原因/实际路由和服务负例、权限/合法/幂等回归及精确减基线。 +- [x] B30周报与只读查询/导出合同、实施和主会话产品回归完成;最终构建/治理与新候选见本批回执。 + + +### B30周报、审计、搜索与导出限定合同(实施前) + +周报7处按保存/提交/query、开关、已提交只读、项目关联开关解释;保留默认周一归一、GET创建草稿及重复提交、20项目/文本限额,项目开关失败前原update已尝试,测试明确无后续项目删除创建,不声称PG回滚。审计2处与搜索3处保留日期倒序/分页、最少2字符/功能开关、权限和原可见性/敏感字段边界。 + +导出原8处(其中presence超限已在B29迁移)按各类query/body、仪表板时间倒序、AP待付款创建期间倒序、付款/项目开关、审计时间倒序解释;AP台账ledger反向期间原无拒绝不夹带新规则。默认格式/限额、权限/流式导出/成功审计与已有查询顺序保持。只用合成行生成CSV/XLSX,不读真实资料。所有泛化fallback改为调用点显式原因;不从details推断。 + +页面Attendance/TeamPresence/WeeklyReports/GlobalSearch已接resolver;AuditLogsPage三处仍拼原code/message,本批沿既有错误区改用安全resolver,保持交互和样式。 + +### G7产品回执与范围调整 + +49新原因、41指纹/42命中精确退出;strict为785注册/778静态迁移/74遗留、73指纹/16源文件,主链例外0、findings空,owner/2026-10-05不变。product-final 15文件1078/1078(含807固定文案),不累加阶段数。八源behavior-shape保持原schema/权限/状态/事务/读写,仅去错误构造/import/显式原因参数/已无消费者的可选原因分支;策略Zod收集排除新增reasonCode元数据。 + +legacy硬关闭真实门禁保持,39内部考勤用例含显式合成gate和真实共享函数,不代表正式功能开放。周报项目开关失败前原草稿create/update尝试仍在,测试断言无后续关联写入;mock事务不证明PG回滚。审计页面3处改安全resolver,其他页面已消费。 + +初轮B29一红为导出旧exact详情缺新增reason;合并首轮14红为在岗测试原无AppError envelope handler、发票导出测试路径写错,修正测试后1078全绿,产品门禁未削弱。用户最新取舍已纳入:后续停用/明确精简路径不再补修。已确认现行产品规则明确不接ERP,G8将保留ERP旧分支并单列,不以扫描清零为目标。 + +G7最终收口:client typecheck/Vite/PWA、16增量JS语法、治理与两task上下文/敏感/diff通过,旧22文档/Browserslist告警保持;4368旧材料与HEAD/index保持,133旧脏114不变/19接续,25增量。error-debt-g7-20260917新候选独立PENDING。 + +## 2026-09-17 G6审批与委托接续(当前入口) + +G5完成冻结;入口693注册/699迁移/165命中/163指纹,G6四源49处。新证据error-debt-g6-20260917,3862旧保护材料与125旧脏、HEAD/index保持。现有inline边界下只做主会话检查,独立只读复核PENDING;除下方B28a恢复委托既有人物范围的条件组合缺陷外,不改变审批业务规则/权限模型/状态机、不外联或读取真实配置数据,不提交部署或实施借款。 + +### B26共享审批合同(实施前) + +utils/workflow15处:流程步骤、业务角色/规则/空链、本人兜底角色及自角色/无可用角色、兜底空链、审批人规则/角色/指定员工/主管上下文/未知类型、主管链不可解析、最终解析防御。原400/409/details及审批快照优先级、财务本人CEO专链、主管解析/10层防御上限与跳过或阻断开关、自审批限制均不改,原已注册财务本人原因不重复迁移。兜底空链和invalid_resolved_assignee在现支持规则/分支下不可达,静态核对,不伪造路由负例;其他守卫由真实共享函数/消费路由回归验证。 + +### B27流程定义与审批规则合同(实施前) + +workflowDefinitions11处(含builder)按id/entity、create/patch schema、功能开关、报销投影只读、已发布或归档只读、归档不可发布解释;不改变20步/版本/投影/发布和归档事务。approvalRules6处按create/patch schema、id、金额非有限/倒序、兜底角色、空修改解释;保留合并旧上下界校验、max=null原提前返回、角色白名单、优先级和锁/审计。兜底角色helper只接enum已验body,其错误仅静态覆盖。只换错误合同不修改审批规则,主会话验证不能代替独立审查。 + +- [x] B26共享审批三方原因、守卫与快照/执行消费者回归。9文件868/868通过,含B27已登记原因;阶段数不累加。 +- [x] B27定义/规则三方原因、真实Fastify负例和原合法/权限/冻结回归。3文件790/790通过。 +- [x] B28委托具体合同及实施完成,保持两组API原权限差异。 +- [x] G6最终合成回归/前端构建/strict/治理/保护和新候选,独立PENDING。typecheck/Vite/PWA、8 JS语法和治理/task/敏感/diff通过;旧22文档/Browserslist告警保留。3862旧材料与HEAD/index保持,125旧脏116不变/9接续,17增量。 + + +### B28委托合同与B28a范围组合缺陷(实施前) + +delegations17处按legacy create/grant create、id、mine/grants query、有效期倒序、委托人与受托人自身冲突、grant委托人不可用、受托人不可用/缺代提交权限/缺审批权限迁移;validFrom/To后置NaN被严格datetime拦截,保留原因但仅静态覆盖,不收紧或扩展日期规则。保持默认scope=all/validFrom=now、两组API原system.users与system.settings撤销差异、已撤销幂等、角色权限解析及审计。 + +B28a发现明确组合缺陷:GET /delegation-grants普通用户baseWhere的本人OR被后展开的有效期OR覆盖,默认includeInactive=false时丢失人员范围。限定把既有baseWhere和原有效期对象放到AND中,includeInactive=true仍只保留baseWhere,管理员筛选不变;这是恢复原本存在的本人范围,不新增角色资格或扩大管理权。先用包含本人/他人、有效/将生效/过期/撤销的内存数据集经真实Fastify复现,再修复;同时测试默认/false/0、true/1、管理筛选及日期边界。没有访问真实委托或人员。此项从错误构造结构不变的结论中单列,独立只读内容复核仍PENDING。 + +- [x] B28错误合同和B28a红绿路由验证,保留两组API权限与撤销幂等。4个范围红例经AND组合修复转绿;34个委托用例通过。 + +B27页面消费补口:WorkflowDefinitionsPage原四种mutation无onError、列表失败显示空态。复用既有resolveErrorMessage和toast添加创建/保存/发布/归档错误反馈,列表沿现有红色文本样式显示安全提示,失败时不同时显示“暂无流程”;不新增视觉方案。ApprovalRulesPage/DelegationsPage已有resolver,保持。 + +### G6产品验证回执 + +43新原因,49指纹/49命中精确退出基线;strict为736注册/745静态迁移/116遗留、114指纹/24源文件,主链例外0、findings空,owner/2026-10-05保持。product-final为16文件1014/1014,含758固定文案断言,不累加阶段数。四源behavior-shape PASS,B28a查询组合修复单列例外;其他只改错误构造/import/显式原因参数。 + +原无审计的流程定义路由没有新增审计;两个审批后置防御、审批规则enum后置守卫及委托datetime后的NaN守卫不可达,只静态核对。主管解析保留10层防御,不声称遍历全链实测。B28a内存where matcher证明真实Fastify输入的范围组合,不能代替PG、真实认证或独立审查。页面沿既有样式显示安全失败反馈,不新增视觉方案。余G7 42/G8 74;日期低年份/AP反向期间/损坏导入解析、人工节点、真实业务/恢复及独立出口保持开放。 + +## 2026-09-17 G5采购资产合同接续(当前入口) + +G4完成并冻结,入口562注册/599迁移/285命中/277指纹;G5七源120处。新证据error-debt-g5-20260917;3365旧材料(含G4新包)与116旧脏保护,官方current和所有本地/独立/真实数据/提交部署边界保持。为先验证共享builder和乐观锁错误合同,本组先合同与预算,再资产、流转/盘点、采购请求;不新增业务规则,不逐批请求授权。 + +### B22合同台账与采购预算先行合同 + +合同route10处+filter helper1处:create/patch/terminate/correction schema各明确原因;项目关联开关、通用版本冲突、已终止不可改、归档字段锁定、终止条件/并发、纠错状态/并发、续订条件保留旧400/409/details。原UUID直接Zod校验未纳入本批迁移,不改变其响应;终止字段不变量、状态守卫、必须事务审计和恢复归档规则保持。续订原先创建新草稿再抢原版本,冲突可有尝试create,内存事务不证明PG回滚。 + +采购预算3处含helper:重复占用和预算池缺失均保持409。前者写前拒绝,后者原先ledger.create后查池,保持失败无额度更新但有流水尝试。confirm只写流水,release预算池缺失原跳过额度修改并继续,均不新增拒绝。全src/tests追调用后移除旧helper/export;原只测helper构造的单测改为真实服务守卫及合法/幂等/缺池行为。 + +- [x] B22三方原因、真实Fastify/服务合成负例与既有合同导出/预算消费者回归。7文件696/696通过;15新原因,14处旧命中(含2个旧helper)待同批精确减基线。 +- [x] B23资产、B24流转/盘点、B25采购请求逐操作补合同后连续处理。 +- [x] G5最终定向回归、类型构建、strict、治理、保护和新候选,独立PENDING。 + + +### B23资产限定合同(实施前) + +assets29处旧命中:分类/卡片id与list、领用人query、分类/卡片create/patch及脱敏索引导入schema、空修改;文本/哈希/metadata安全边界及逐导入项同类边界;单据分类/资产分类/公司/成本中心/领用人责任人保管人/已批准采购引用;写导入维度必填与目标不存在。按操作显式传原因,不回显敏感字段或索引内容;保留400/404/409及VALID_001(原找不到是VALID_001,不改SYS_404)。 + +索引导入原CONFIG_001写开关保持,只有测试进程合成env控制、无真实配置;先安全校验再开关/维度、dryRun优先于write、20项上限/哈希幂等/原审计和只导索引不导文件保持。编号/领用关系/ERP只经mock DB,失败无后续业务写入和成功审计。人员原因由角色调用方显式传入;未新增引用/启停或字段规则。 + +- [x] B23原因三方、路由负例/安全拒绝/原权限与正常分类卡片、脱敏索引dry-run/write/幂等回归。6文件698/698;33新原因。 + +### B24资产流转和盘点限定合同(实施前) + +assetMovements11处:资产id、领用/归还/调拨schema、已报废归档禁流转、资产/人员不存在或不在职、已有领用/尚未领用、应走归还、无实际调拨变更。人员共用“接收人”原因,原字段employeeId/toEmployeeId保留;不修改先权限后输入、领用记录补建/关闭、状态和通知规则。 + +inventoryChecks14处:id/范围id、query、创建/记录schema、范围必填/公司成本中心不存在、盘点不存在、开始/记录/完成状态、资产范围不符、重复资产和缺失资产。保留404 VALID_001与409、location自由文本/all忽略scopeRefId、只查存在不新增启停。记录是逐项先验后upsert,后项重复/缺失/范围错误前可能已写前项,保留原事务位置;测试明确此尝试,不能统称失败零写入或PG回滚。 + +- [x] B24逐路由拒绝/原细节、无后续成功审计通知、合法领用归还调拨与盘点创建开始记录完成回归;通知/邮件/dispatcher仅直接协作者桩。2文件720/720,61操作用例与659固定文案断言;27新原因。 + +### B25采购申请与附件限定合同(实施前) + +purchaseRequests52处含builder:id/附件id/list、PATCH/归档/附件下载删除schema、预检、数量/单价有限性与下限、公司成本中心供应商单据分类预算池引用/可用/归属、项目关联开关与归档、草稿修改提交/审批中撤回/已批准归档、流程缺失/可审批人、提交维度与明细必需、预算上下文/缺池、上传multipart/文件数量字段大小限额/缺文件/策略错误、下载理由和安全阻断。创建原不接受业务schema、空PATCH原允许、200明细/20归档文件保持;不添加“空修改”或创建必填规则。 + +金额原先更新主表、删除明细,再规范化数量/单价;非法有限数/数量低于0.01时已有尝试写删,保留原事务位置并断言无后续createMany/成功审计。单价负值被schema提前拦截,后置min=0防御仅静态覆盖;数量0字符串/0.001数字和巨大数字文本可实测。提交预算冲突前原重算总额写入和风险helper继续发生,不虚报零副作用。并发申请人变化仍映射原无审批人,不更改权限模型。 + +附件保留自定义安全message/status、旧details.reason和阻断审计;新增download_blocked不拼原文件内容。上传策略依据PurchaseAttachmentPolicyError的明确reasonCode白名单映射,不从任意details猜测;未知策略固定安全兜底,原unsupported_type原因保持。仅合成Buffer/stream与mock storage;流大小/数量/字段限额先后、历史内容复验、headers和成功审计要求保持,绝不读真实附件或配置。 + +- [x] B25原因三方、引用/状态/金额/流程/预算/附件逐分支负例与既有合法/权限/存储安全回归。附件/真实策略75/75;主流程修正测试夹具/旧exact详情断言后75/75。最终同输入合并回归另列。 + +### G5当前回执与余额 + +最终product-final为19文件1095/1095(含715固定文案断言),client-build-final含typecheck/Vite/PWA、15增量JS语法、治理与两task上下文通过。原Browserslist和22文档告警保留;3365份旧保护材料及HEAD/index保持,116旧脏103不变/13接续、本轮22增量。新候选error-debt-g5-20260917/review-candidate只生成一次,独立PENDING。 + +131新原因,精确删除114指纹/120命中;strict为693注册/699静态迁移/165遗留、163指纹/28源文件,主链例外0、findings空,owner/2026-10-05保持。G1–G5扫描范围均无旧调用,余G6审批委托49、G7查询考勤周报42、G8系统集成74。 + +七源behavior-shape PASS:仅排除错误builder/import、显式原因参数、采购附件白名单映射及保留message的阻断构造体(另有路由断言),原schema、权限、状态/副作用/事务结构保持。合同UUID仍直接Zod原行为;资产索引仍20条上限、采购200明细/20归档条目及空PATCH保持。 + +副作用例外已实测:预算缺池前流水尝试、合同续订冲突前新草稿尝试、盘点后项失败前首项upsert、采购金额失败前主表update及明细delete、采购预算拒绝前总额update和风险helper。测试按无后续操作/成功审计断言,不虚报全失败零写入或PG回滚。通知/审批/预检直接协作者桩和纯合成env不代表真实功能开放。 + +页面AssetRegistryPage/InventoryChecksPage/PurchaseRequestsPage及移动采购页面、ContractsRegistryPage/ContractRegistryWorkspacePage均已有安全resolver,无UI改动。初轮迁移工具遇自定义AppError省略属性名statusCode在源码写入前停止,补AST shorthand解析及保留安全message后迁移;原日志记账。B25两红例为新夹具缺合成projectLinkingEnabled、旧exact详情需追加reasonCode,修正测试后通过,未改变产品门禁。 + +## 2026-09-17 G4差旅接续(当前入口) + +G3候选与全部证据已冻,官方current不变,入口508注册/544迁移/347命中/339指纹,G4七源62处。新证据error-debt-g4-20260917,旧包/差异/HEAD/index保护;inline本地合成,不外联、不读真实配置备份,不提交推送部署归档或实施借款。 + +### B19差旅申请先行合同 + +21处AST命中按list/id、create/patch/link-project输入、行程日期时间/倒序、金额、公司/成本中心可用性及归属、空修改、编辑/提交/撤回状态、必需行程、费用预检、工作流缺定义/可审批人、项目关联开关分别明确。保留原400/409/details,审批/预算/事务/锁/切换门禁和审计不改;未提交成功路径不发送通知。decimal helper两个消费者均min=0,负值已由schema拒绝,其后置负值防御只静态覆盖,不虚构路由负例;超大数字文本造成Infinity可在路由实证。 + +发现同入口真实缺陷:link-project调用未定义parseUuid,开关通过后必然500。限定替换为本文件现有parseTravelRequestId,补非法id400/无写入、合法id后原输入/404/权限守卫、绑定/解绑成功的合成测试。保留现有canReadTravelRequest范围,不新增项目关系或状态规则,不修改真实开关。 + +提交拒绝前可能调用原createRiskFlagsFromPrecheck;测试区分风险标记与后续编号/预算占用/审批/通知副作用,不把mock事务称真实回滚。现有日期/时区规范保持,只补原因不收紧兼容。 + +- [x] B19申请原因三方、路由负例和状态/预检/工作流/权限/合法回归,修复未定义id解析器。 +- [x] 后续B20执行/授权/交易与预算共享、B21对账先补具体合同后连续处理。 +- [x] G4最终合成回归/类型构建/严格基线/治理/保护及新候选,独立PENDING。 + + +### B20差旅订单、授权、交易与共享预算限定合同(实施前) + +三路由21处加travelPayments2处:导入禁用共用原因,三类各自导入schema/规范化后空列表;授权/交易query;订单付款id/body、非可付状态、已付资料冲突、缺匹配结算交易;交易冻结建议id/body。保留100项上限、按条skipped与幂等、金额/币种精确匹配、付款锁/ERP排队门禁、冻结建议只是记录而非真正冻结,不外联ERP或支付。 + +共享预算只替换缺预算池的409,原helper全src/tests无外部消费者,迁移唯一内部调用后删除helper及导出。原顺序先ledger.create再查池再更新额度保持:缺池失败允许已尝试写ledger,测试确认无额度更新,不虚报零写入或真实事务回滚。已有路由消费者用mockDB回归,保留原调用事务边界。 + +- [x] B20原因三方、路由拒绝/无后续写入/ERP入队和已有导入/付款/冻结建议回归。 +- [x] 共享预算授权/冲正缺池及合法/重复/零额路径;结算原仅写ledger、不查池/改额度保持,精确扣基线。 + + +### B21差旅对账限定合同(实施前) + +路由12处和共享服务6处:query/create输入、期间倒序、公司/成本中心缺失/归属、空范围、批次id/条目id、确认备注输入;服务为非草稿批次、条目非待确认/非已确认、自动匹配项不可重开、批次有未确认项。特别保留共享旧builder默认409,schema/id显式400,不能因换builder误降400;引用可用性原只检查存在,不新增启停/归档规则。确认/重开/汇总/结案及ERP outbox幂等和runNow语义保持;仅本地已有桩,不外联ERP。最后消费者迁移后移除共享旧helper及导出/import。 + +- [x] B21路由/服务原因和逐守卫负例、原快照/分类/确认重开/结案/ERP入队回归,保持拒绝后无后续修改/成功审计/outbox。 + + +### G4当前回执与余额 + +最终client-build-final含typecheck/Vite/PWA、12增量JS语法、Trellis/process/docs与两task上下文通过,原Browserslist和22文档告警保留。2915份旧保护材料精确集合/SHA及HEAD/index保持;109旧脏中97不变/12接续,本轮19增量文件。新候选error-debt-g4-20260917/review-candidate绑定本批输入,独立PENDING。 + +B19 5文件634、B20 7文件630、B21 4文件624通过;最终product-final为13文件764/764(含584固定映射断言),阶段数不累加。B19-link-before保留3个500红例;同一路由修复后非法id/绑定/解绑及后续权限、404、输入/开关验证通过。七源结构核对PASS,除该解析器修复外只有错误合同变更。 + +新增54原因,精确删62指纹/62命中;strict为562注册/599静态迁移/285遗留、277指纹、35源文件,主链例外0、findings空,owner与2026-10-05保持。G4七源已无本扫描器旧调用。TravelRequestsPage/TravelPaymentTrackCard/TravelReconciliationTab均已消费安全resolver,未增加视觉。 + +风险/副作用例外:decimal负值后置防御因schema与两个min=0消费者不可达,仅静态覆盖;超大数字文本Infinity有路由负例。预检/预算/审批/通知用直接协作者桩,提交成功不是完整流程实证;工作流缺失/无审批人前原风险helper仍被调用。授权/冲正缺池前原ledger.create已尝试,结算仍只记ledger;mock DB不证明事务回滚。 + +剩余G5采购资产合同120、G6审批委托49、G7查询考勤周报42、G8系统集成74,合计285。原日期低年份/AP反向区间/损坏导入解析及独立/真实业务/恢复/人工节点继续开放。 + + +## 2026-09-17 G3基础资料接续(当前入口) + +入口405注册/477迁移/433命中/425指纹,G3余86。B11–B14已冻,新证据error-debt-g3-20260917;保护旧材料/差异/HEAD/index,inline本地合成,独立PENDING,原外联/配置/真实数据/提交部署/借款边界不变。 + +### B15公司、成本中心、单据分类与供应商先行合同 + +masterData的25处与suppliers的11处按实际操作迁移:四实体id/list/create/patch/空修改分别明确原因,不用泛化提示清零;重名/当前归档按实体明确。成本中心与供应商引用公司缺失/归档保持400/409;负责人缺失或非在职保留原not_found;父级自引用/缺失/归档/跨公司/循环及公司有成本中心、成本中心有子级归档守卫保持。schema/helper由调用点显式传reason,不从details反推。单据分类不是银行机构;编号规则/默认workflow字段含义不变,不改权限、冻结开关、默认值、大小写/null转换、查询/事务/审计、404和already_archived幂等。 + +- [x] B15服务端注册/客户端固定安全文案/夹具三方一致;精确减消失baseline。 +- [x] 新真实Fastify+mock DB测试覆盖四实体输入/id/重复/归档、所有引用/层级守卫、无后续业务写入/成功审计及合法create/patch/list/archive和幂等;P2002是桩,非真实PG约束/回滚。 +- [x] 核MasterDataPage错误消费;针对性回归/strict、结构检查通过后,接日历和费用/假别/项目模板;后续子批先补具体操作合同。 +- [x] G3最终回归/前端类型构建/治理/敏感/diff/保护对账及新冻结候选。 + +### B16节假日日历限定合同(实施前) + +日历15处旧命中按id/list/create/patch/空修改、批量日期输入/重复日期、公司引用、编码重复、当前归档与默认日历绑定分别明确原因。复用公司引用原因;归档错误原details包含legalEntityCode保留,但UI只展示固定安全提示,不拼公司代码。保持原500项上限、合法日历日期校验、空数组清空、先验证再删除、事务及默认日历首次赋值;原timezone只检查文本格式,不借原因迁移收紧时区。公司/日历归档、404及归档幂等规则不变。 + +- [x] B16原因与真实路由负例/合法日期及默认绑定/归档回归;原403/400/409、无后续写入/成功审计断言、仅mock DB。 + + + +### B17费用标准、费用类型、假别和报销模板限定合同(实施前) + +四源29处遗留:id/query及create/patch schema分操作原因;费用标准另正金额、费用类型缺失、部门范围缺部门/部门不可用、同类型范围冲突、空修改;费用类型另编码重复与空修改;假别另编码重复;模板另费用类型缺失/停用明细、空修改。保留原details/400/409,标准PATCH原合并范围语义、别名优先级和warn/block规则不变;类型requiresInvoice审计保持;假别半天额度/默认值/嵌套policy更新/普通用户只读启用项以及原允许空PATCH不变,不新增审计;模板50项上限、默认金额和禁用类型整批拒绝、软删除幂等保持。既有主链leave服务不改。 + +- [x] B17四源明确原因、三方文案、输入/分支负例无写入及既有合法/权限回归。 +- [x] 费用标准/模板和假别/费用类型页面核消费,严格基线精确扣减。 + + +### B18项目操作限定合同(实施前) + +projects6处AST命中含共享helper,实际覆盖14个schema操作(项目/成员/里程碑/任务/风险/沟通各create/patch、收口项patch、交付create)、项目与6种子项id、5类开关、列表和豁免备注。为每个操作/开关显式传原因,保留权限/成员可见范围/沟通24小时窗口、状态与审计。日期schema原仅格式校验保持,不借本轮扩日历规则。 + +特别边界:收口项PATCH原先在校验itemId/body之前惰性初始化6项清单;本轮保持该顺序。负例断言无后续修改和成功审计,不能笼统称所有失败零写入;另专例证明原初始化仍可发生。已有清单时的失败无初始化写入。各开关仅合成配置/数据库桩验证,不改变真实配置或冻结规则,不冒称功能已开放。 + +- [x] B18按操作传入原因和三方文案,开关拒绝先于业务访问、所有schema/id与豁免备注负例及原项目回归。 +- [x] 项目原守卫/副作用结构核对,明确清单初始化例外、日期旧边界和UI安全resolver。 + + +### G3当前验证及余额 + +B15 2文件566、B16 3文件615、B17 5文件579、B18 2文件594均通过;属于不同阶段输入不累加。八源结构对照PASS,新增103注册原因;strict为508注册/544静态迁移/347遗留、339指纹、42源文件,主链例外0、findings空。精确删除86指纹/86命中,原owner/2026-10-05不变。G3所列八源无本扫描器遗留,仍不是全仓故障清零。 + +MasterDataPage/SupplierTab/HolidayCalendarTab/ExpenseStandardsPage/ReimbursementTemplatesPage/ProjectsPage/ProjectDetailPage使用既有安全resolver;假别写API没有现有页面消费者,费用类型客户端仅读取,不新增管理UI。项目测试保留先行bootstrap,普通失败无后续目标写入/成功审计;已有清单失败无初始化写入,空清单部分失败仍可初始化6项。不得将mock开关、P2002或内存事务说成真实配置/PG/开放验证。 + +最终product-final为8文件811/811(含530安全映射断言);前端typecheck/Vite/PWA、14增量JS语法、Trellis/process/docs、两task上下文通过。原Browserslist与22未登记文档告警保留。2482份旧材料精确集合/SHA、HEAD/index保持,98旧脏中88未变/10接续,本轮21文件增量;新候选error-debt-g3-20260917/review-candidate绑定输入,独立PENDING。 + +剩余G4差旅62、G5采购资产合同120、G6审批委托49、G7查询考勤周报42、G8系统集成74,合计347;继续按既有分组逐操作先落合同。低年份/AP区间/损坏导入解析及原人工/独立/真实业务出口保持开放。 + + +## 2026-09-17 B11与G2连续接续(当前入口) + +用户明确批准继续处理剩余错误;官方current/session已核为本task,不采用注入no_task另建任务。入口317注册/398静态迁移/520命中/501指纹;inline实施/检查,旧证据目录与B10冻结包保护,新证据error-debt-b11-g2-20260917。全部原本地/真实数据/远端/提交部署/借款边界不变;分批只作内部检查点,不逐批索要继续。 + +### B11先行合同 + +- [x] services/budgetPool:6个旧调用归5原因:成本中心缺公司(创建和选池共用)、部门与公司/成本中心维度混用、公司缺成本中心、同成本中心多预算冲突、生效日期无效。保持原400/409/details及成本中心→部门→全局优先级、历史部门/全局多条取首规则和查询顺序。覆盖报销/采购/差旅、feeControl和AI建议调用方;不修改它们的业务守卫。 +- [x] budgetControlRules:id、query、create/patch输入、成本中心缺公司、费用类型仅报销6原因(原6个命中含构造helper)。保留PATCH原本基于本次body判断的行为,不以本轮原因迁移偷偷合并已有记录来改变规则;schema失败/作用域冲突在读写前拒绝。404/权限/默认值/归档幂等保持。 +- [x] 服务原因负例、预算真实路由负例、控制规则create/patch/list/archive、权限/成功审计及各既有消费者回归;三方原因文案与精确减基线同批。 + +### B12账号与B13部门限定合同(接续前落盘) + +B11已通过8文件428项本地检查;接续B12迁移users的28处旧命中:id/分页、创建/重置密码策略、敏感资料输入/空修改/查看理由/银行不可用、部门与主管引用、自身停用/角色变更、P2002重复、三类批量输入/去重后50人上限、生命周期开关及自动密码生成防御失败。保持PERM_001与VALID_001区分(紧急管理员自身角色为原409,普通管理员仍403),原锁/会话撤销/凭据输出/事务和审计完全不变。密码文案固定安全要求,唯一冲突不展示数据库字段原文。自动生成失败用故障注入验证;hireDate后置防御在严格schema下不可达,沿已有update_input_invalid原因并明确仅静态覆盖,不构造虚假路由证据。 + +B13为departments14处旧命中:id/query/create/update/空修改、父级缺失/归档、当前归档、自引用/循环、归档时子部门/在职员工阻断共12原因。保持原400/409、事务内外位置及already_archived无重复写入审计,不改组织层级规则。employeeDirectoryFilters的旧builder导出经全src/tests调用搜索确无消费者,连同无用AppError import移除,1处归为无调用代码清理而非修复业务分支;现有目录/导出回归保留。 + +- [x] B12账号三方原因与逐操作负例,原权限/脱敏/创建修改/导入/会话回归;3个既有管理页面/对话框消费resolver不新增视觉。 +- [x] B13部门路由负例、正例和归档幂等,目录helper调用核对与导出回归。 +- [x] B12/B13精确减基线后接员工资料/档案(B14),不得把已有扫描归零冒称管理员或冻结功能已开放。 + +### B14员工资料、档案与上传限定合同(实施前落盘) + +接续员工路由31个旧命中,按明确操作传入15种schema原因:HR主数据运行/列表、通讯录重试、合同create/patch/delete、培训create/patch、证书create/patch、档案修改和入职/转正/调动/离职;另注册id/功能与同步能力、四种日期区间、合同重复/活动合同不可删除、本人资料字段限制、头像与合同附件各7种文件失败。保持旧status/details、目录权限、日期/null规则、生命周期事件、删除理由、合同审计/存储事务边界;同步测试只用拒绝外联桩或已有mock fetch,不用真实配置。 + +上传补充与B6同类的漏口:生产app已有FST_REQ_FILE_TOO_LARGE→400/VALID_001/file=file_too_large映射,但request.file/toBuffer异常绕过路由原因。仅在员工两类上传的这两次读取处捕获该精确异常并追加所属文件过大原因,其余异常原样;头像2MiB/合同10MiB及原生产multipart上限保持。测试实际multipart限额、路由限额、缺失/字段/MIME/空文件/内容不匹配,在存储/业务写入/成功审计前拒绝,合法附件旧回归不变。不借迁移扩文件格式、替换安全检查或读历史附件。 + +补充核对:31命中还含一个自定义合同下载阻断构造器(并非通用helper调用);迁移为独立download_blocked原因时保留原安全message、409及attachment详情,保留原阻断审计,无附件头和文件字节返回。只用临时合成损坏文件验证,不读真实历史附件。 + +- [x] B14原因三方、显式schema调用、上传精确捕获及逐分支测试。 +- [x] 员工原日期/权限/资料/档案/附件/同步合成回归与无副作用断言;缺省库/开关守卫保持。 +- [x] B11–B14最终定向回归/前端构建/strict/治理,更新真实余额和新候选,独立PENDING。 + +### G2执行安排 + +B11验证后接账号管理(users)、部门(departments)、员工资料/档案/生命周期(employees)和目录内部helper。每个子批先补具体操作/旧status/details/调用方,保持去重后50人上限、批量导入整批拒绝、敏感字段权限与脱敏、锁与会话撤销、角色保护和原必要审计。不能为错误迁移改变审批或角色规则;不可达防御分支单独记录证据,不冒称路由实测覆盖。所有scope以本节逐批落盘合同为准,完整分组沿下方总表。 + +### B11–B14当前结果 + +连续完成预算共享/控制规则、账号、部门、员工档案及上传错误合同,新增88原因;精确删除76条消失指纹(87次命中),余额433命中/425指纹,50个源文件。G1/G2分组入口已无本扫描器遗留;其中employeeDirectoryFilters的1处是无消费者构造器清理,不记作业务故障修复。strict:405注册、477静态迁移、433遗留、主链例外0、findings空;原owner与2026-10-05不变。 + +product-final:22文件775/775,精确参数见同目录command.json;B11 428、B12 534、B13 40、B14 530为阶段输入,不累加。随后附件测试补强活动合同拒删/删除理由/原下载安全message的断言,attachments-final单文件26项为更新后测试输入;产品源码未变。六源behavior-shape检查剔除已审错误构造/原因参数/上传包装后,原守卫、读写参数、权限、事务结构与入口一致;上传精确捕获和下载安全message另由真实Fastify合成测试证明,不能视作独立审查或PG回滚证据。 + +账号自动密码失败通过合成随机源故障注入;hireDate后置防御在严格schema下不可达,只沿update_input_invalid静态检查,未声称其路由负例通过。员工HR/通讯录验证仅拒绝外联桩/已有mock fetch;合同下载阻断用临时合成损坏文件,保留必要阻断审计,未读历史附件。初次员工迁移脚本因发现额外自定义下载构造器数量不符而在写入前停止,补入保留message的合同后实施;没有改门禁来凑扫描数。 + +最终client-build-final含typecheck/Vite/PWA、15增量JS语法、Trellis/process/docs及两个task上下文通过;原Browserslist和22未登记文档告警保留。旧2091材料精确集合/SHA、HEAD/index保持;91旧脏中76逐字不变、15在本范围继续,本轮22文件增量。候选error-debt-b11-g2-20260917/review-candidate及主会话回执保留;独立PENDING。 + +剩余:G3基础资料86、G4差旅62、G5采购资产合同120、G6审批委托49、G7查询考勤周报42、G8系统集成74,合计433。下一按G3现有规划从公司/成本中心等主数据与供应商开始,先落具体操作原因和测试合同。原低年份/AP区间/损坏导入解析决策、09-25等人工节点、真实业务/恢复及独立出口继续开放。 + +## 2026-09-17 遗留546处治理总表与B10执行合同(当前入口) + +针对用户“这500多处遗留错误怎么解决”,将剩余工作从笼统计数拆为以下8组。入口为292注册、375静态迁移、546次AST命中、526指纹、58源文件,主链例外0;54次命中在function:build构造函数内,并不等于54个独立问题。546描述旧式错误合同债务,不是546个运行故障;数量、原因码、操作和测试不得一一换算。官方current/session仍为本task;沿用同类本地治理连续授权,不要求逐批再次批准。 + +### 完整分组与执行顺序 + +以下按源文件归属互斥分组,数目仅作本轮入口基线;每组内先列操作→原status/details→守卫/副作用→原因→客户端消费→测试,再改代码。普通用户入口、管理员入口、冻结/内部入口必须分别标明,不将有源码等同已上线。 + +| 顺序 | 文件归属(routes除注明services/utils/plugins外) | 入口命中 | 执行方式 | +| --- | --- | ---: | --- | +| G1 预算与绩效 | budgets14、performance12、services/budgetPool7、budgetControlRules6 | 39 | B10先完成两个路由26处;剩13处共享预算范围/选池和控制规则另按调用方细化,不顺手改变额度或选池优先级。绩效维持system.settings管理员边界,经理/本人冻结不解开 | +| G2 员工与账号 | employees31、users28、departments14、services/employeeDirectoryFilters1 | 74 | 分账号管理/资料档案/生命周期/部门引用四批;保持批量导入及部分失败、脱敏、权限和审计语义 | +| G3 基础资料 | masterData25、masterDataSuppliers11、masterDataHolidayCalendars15、expenseStandards11、expenseTypes6、leave-types6、projects6、reimbursementTemplates6 | 86 | 按公司/成本中心、供应商、日历、费用/假别/项目模板四批;覆盖重名、引用不可用、归档/删除守卫,保留各开关 | +| G4 差旅 | travelRequests21、travelOrders9、travelReconciliation12、travelAuthorizations5、travelTransactions7、services/travelPayments2、services/travelReconciliation6 | 62 | 申请、执行/授权、交易、对账四批;逐条失败保留合同,不改审批、金额、支付或对账状态 | +| G5 采购资产与合同 | purchaseRequests52、assets29、assetMovements11、inventoryChecks14、contractsRegistry10、services/contractRegistryFilters1、services/purchaseBudget3 | 120 | 采购、资产、流转/盘点、合同四批;冻结功能只本地补错误合同,守卫继续先拒绝;不得为测深层分支开放真实入口 | +| G6 审批与委托 | utils/workflow15、workflowDefinitions11、approvalRules6、delegations17 | 49 | 共享workflow、定义/规则、委托三批;先追调用方,状态/资格/权限有实质变化则另补方案和只读复核,不将未启用配置域视为上线 | +| G7 查询与考勤周报 | attendance9、services/attendance9、presence3、services/presenceHistory1、weeklyReports7、exports8、audit2、search3 | 42 | 考勤/在岗、周报、导出/审计/搜索三批;attendance_v1永久关闭保持;按真实守卫顺序记录可达性,不用mock开关冒充产品开放 | +| G8 系统与集成 | integrations22、plugins/auth1、wecomAuth8、confidentialDocRefs4、configRead2、cutover4、featureFlags8、feeControl2、migrationRuns5、riskFlags5、systemWorkspace2、ai1、services/ai/suggestions1、services/cutover1、services/documentSequence5、services/feeControl3 | 74 | 认证、系统配置、集成/迁移、共享服务四批;使用合成配置和拒绝外联桩,禁止读取真实配置或发送企业微信/AI请求;保留敏感错误降级 | +| 合计 | 58源文件,文件互斥完整覆盖 | 546 | 约28个实施切片(含B10),切片按实际调用关系可拆合,不是工期或独立缺陷数 | + +每批统一退出条件:①逐操作明确原因及恢复动作;②保持HTTP/VALID_001/原details,只增加reasonCode;③服务端注册、客户端安全文案、固定夹具一致;④实际Fastify路由/服务负例与合法回归,验证应拒绝时无后续业务写入/成功审计(原必要审计保留);⑤确认页面不会覆盖安全resolver提示;⑥只删除已消失指纹,新增/变更无原因调用仍严格失败;⑦新证据另存、旧冻结包/HEAD/index保持。构造helper在最后消费者迁移后才删除,不能删helper或基线来假装清零。未达分支逐项记录入口/原因/后续动作,不获永久豁免。 + +完成目标是所有可达操作有可靠错误合同,剩余内部/冻结分支有逐项处置且最终严格门禁无遗留;当前没有全量完成承诺。owner shunli与2026-10-05到期不变;09-25检查实际余额,09-30如必要只提出有理由/范围/新期限的单项延期,10-02查漏,10-05到期失败。未来人工节点未执行、未创建定时任务。低年份Date.UTC、AP反向区间、损坏导入文件解析异常是另外的开放边界,不能被本AST计数代表。 + +### B10 本轮限定方案(先落盘再实施) + +仅修改budgets/performance路由现有VALID_001原因、对应三方文本、针对性测试和精确基线;不修改schema/权限/状态转换/事务/锁/范围和冻结策略。预算路由13个拒绝分支:id、列表、创建、修改、空修改、反向区间、部门不存在/归档、公司不存在/不可用、成本中心不存在/不可用/公司不匹配。绩效按id、创建/修改周期输入、评分输入、日期反向、功能关闭、已有活动周期(预检与P2002)、已结束不可改、活动周期仅可结束、草稿不可直接结束、非在职不可评分、非活动周期不可评分注册原因。parseBody由调用方显式传入已注册原因;删除两个无调用legacy builder及对应仅供测试的废弃导出。预算共享服务7处保持原合同,不能称完整预算域清零。 + +- [x] B10路由及三方原因映射;BudgetsPage、PerformancePage、EmployeePerformancePanel均已消费安全resolver,页面无需改动。 +- [x] 路由合成负例保留status/details并断言无后续写入/成功审计;功能和权限拒绝保持;P2002只为桩测试,不称PG并发实证。 +- [x] 相关日期/预算选池/采购预检/绩效既有回归及客户端固定文案、strict与self-test、前端构建和治理检查。 +- [x] 更新实际余额、协调入口与新证据/审阅候选;独立复核PENDING,生产/ARM/密钥配置备份/旧恢复/提交推送部署归档/借款边界保持。 + +本轮入口保存90旧脏文件、1730旧材料;新证据error-debt-b10-20260917。以下历史回执保持原时点。 + +### B10结果与实际余额 + +本轮新增25个原因(预算13、绩效12);两路由已无本检查器定义的legacy调用,只删除25个已消失指纹/26次命中。strict为317注册、398静态迁移、520遗留、主链例外0、findings空;501基线指纹,owner/2026-10-05全部原样。剩余G1=13、G2=74、G3=86、G4=62、G5=120、G6=49、G7=42、G8=74,合计520次/56源文件;debt-groups.json验证58个入口文件互斥完整覆盖。预算共享服务仍7处、预算控制规则6处,不以budget域显示0冒称整个预算链完成。 + +product-final为7文件414/414,包括新增真实Fastify+mock DB的40项、绩效周期/评分、日期消费者、预算服务、采购预算预检及339项客户端安全映射。初轮413/414唯一失败是新增夹具将既有feature reason误写为force_off,核实后改为flag_force_off,未改生产开关或业务判断。严格self-test20场景通过,包含新增遗留/指纹变化/过期/缺映射/敏感文案等拒绝;5个增量JS语法通过。精确命令和初次失败保留在本轮证据目录,测试数量不与旧575累加。 + +最终client-build-final含typecheck、Vite/PWA均退出0;两个task上下文、Trellis/process/docs/敏感/diff通过。原22未登记文档与Browserslist陈旧告警保留,不改依赖。90旧脏中79份字节不变,11份在本范围继续,新增1份测试,共12文件增量;1730旧材料精确集合/SHA、HEAD/index保持。新候选error-debt-b10-20260917/review-candidate的完整性以manifest及candidate-verification.json为准,独立PENDING。 + +下一切片为G1余13处:先按resolveBudgetScope→创建,selectBudgetPoolCandidate/resolveApplicableBudgetPoolByContext→消费方,以及budgetControlRules的输入/引用/作用域逐项补操作合同;在原范围/金额规则下迁移,不承诺整文件一次替换。再进入G2四批。其他日期/导入解析决策、未来人工复审和09-08其余W线开放状态不受本轮数量下降抵扣。 + + +## 2026-09-17 连续执行B6/D13/D14及公告提示(当前入口) + +用户要求后续连续完成,不再把单批通过作为停点。本轮官方current已核为本task/session;入口保存81旧脏文件、1352旧证据/冻结材料(含上轮201 payload候选),新证据import-time-contracts-20260917。沿inline实施/检查,独立PENDING;原远端/真实配置数据/提交部署/恢复重跑/借款边界不变。 + +- [x] B6:账号导入7原因(文件过大、缺失、扩展名、MIME、缺表头、空文件、500行上限);request.file与toBuffer的FST_REQ_FILE_TOO_LARGE均映射现有生产400/VALID_001/file=file_too_large,保留其他异常。通过实际multipart dry-run/apply两入口验证拒绝早于DB/事务/审计,合法500行与原日期/默认/整批拒绝回归。三方文案不拼文件内容、原始文件名或未白名单字段。 +- [x] D13:核工资list/generate/issue、工资导出、统一考勤月份外部schema→后置解析。00/13已有400且发生在业务读写前,只补合成证据,不能按regex重复当缺陷。0000–0099因Date.UTC特殊映射涉及可用年份/DB兼容,单列下一限定决策;本轮不改变工资发放或存量年份语义,不改未使用的shiftMonth。 +- [x] D14:公告原ISO或日期T前缀schema加局部真实日历与有限Date判定,阻断非法日历和不可解析时间;保留有偏移ISO、UTC、无偏移本地时间、秒/毫秒及JS既有24:00滚动兼容,不更改时区解释/nullable/optional/原起止顺序(无新增区间规则)。3字段×create/patch非法无公告读写/审计,合法日期精确持久化,签收保护和权限回归。 +- [x] B7:顺同一公告入口补create/patch/ack输入、id、list、禁用、缺签收声明、已签后内容保护、不需签收、旧公告声明缺失原因;按旧status/details迁移,不改签收规则。PortalAnnouncementsPage既有创建onError消费安全错误映射,保存/归档失败亦给现有toast反馈,AnnouncementsPage签收失败同用既有安全映射,不新增视觉方案。 +- [x] B8:接续D13已实证的月份拒绝,迁移工资年月输入/后置月份、工资导出查询/月份、统一考勤月份查询/后置月份4原因;只给对应schema显式传reason,不改其他salary item/profile、旧考勤路由、日期和发放语义;补三方与原details断言及相关helper消费者回归。 +- [x] B9:工资维护剩余操作按同类授权连续迁移:薪资项create/patch输入、薪资档案输入、工资条修改输入、id、金额格式、薪资项重名/不存在、当月已生成/无活动员工/无启用薪资项/缺有效档案、已发工资条不可改、未生成不能发放/已全部发放。工资UI已经消费resolveErrorMessage,不需改界面;保留所有旧400/409/details(包括缺档案人员最小列表),只显示固定安全文案。补真实路由拒绝与无成功审计/无后续写入断言,P2002仅mock,不能称真实PG约束/回滚实证。迁移完成后移除无调用的本文件legacy builder,其他模块不动。 +- [x] 原因三方/精确负例/仅减消失baseline,定向服务路由与客户端映射测试;前端typecheck/build、语法/task/治理/敏感/diff,更新既有spec与09-08并保存新候选。阶段内自主修复失败并继续;仍需外部事实的出口真实列明。 + +### 连续执行验证回执与开放边界 + +最终产品命令product-serial-final为12文件575/575,精确参数见同名command.json/log;覆盖用户写入/列表/CSV/XLSX、公告、工资维护/导出、统一考勤、日期消费者、月份服务及客户端原因固定文案。前面的B6 31、公告364、月份53、工资391是阶段输入,不与575累加。product-final初轮540过/2失败均为CSV/XLSX三次密码哈希用例5000ms超时,当时与客户端构建并行;停止资源竞争后同用例串行1787/1880ms通过,未改超时阈值/密码算法/断言/业务守卫。原失败日志保留。 + +strict最终292注册、375静态迁移、546遗留、主链例外0、finding0。新增36原因,删除35条已消失指纹(36次命中),余526指纹;公告/工资路由均无本扫描器定义的legacy调用,其他域仍有债务,不能等同全项目迁移结束。owner/2026-10-05保持。 + +D13审阅结论:工资list/generate/issue/导出和统一考勤的00/13守卫已在业务读写前;保持规则,只补证/原因。month-year-probe用合成年月复现0000→1900、0099→1999、0100/2024正确的Date.UTC行为。该低年份问题仍开放:下一设计需定义可用年份(保留公历低年份还是业务年份区间)、对齐工资/考勤/导出/前端和数据库兼容,未读真实历史数据、未以源码探针宣称真实PG结果。不批量改共享日期转换或存量年月。 + +本轮最终前端client-build-final包含typecheck、Vite及PWA,退出0;原Browserslist告警保留。14增量JS语法、两个task上下文、Trellis/process/docs/敏感/diff通过,docs仍有原22未登记文件告警。1352份旧材料精确集合/SHA、HEAD/index保持;81旧脏有66份逐字不变、15份在本范围继续,另9份新触及/新增,本轮共24文件增量。主会话检查与完整输入归新候选import-time-contracts-20260917/review-candidate,其manifest绑定逐payload SHA;独立仍PENDING,不修改原task完成/归档元数据。 + +后续可连续处理的错误债务优先按已启用页面的下一操作组(预算/绩效、员工档案)继续固定旧status/details和无副作用断言;不以当前批通过要求用户重发“继续”。剩余546是AST命中,非546个独立bug。上传损坏文件/解析器异常尚非B6的7个已迁移分支;需要先确认库异常与安全错误合同再扩入。AP反向区间、低年份规则及原恢复/真实业务出口按各自决策/授权门禁。09-25/09-30/10-02/10-05未来人工节点不提前计完成。 + +本轮先落本节方案再实施。D13允许如实得到“已有守卫、补证完成”,不为凑改动新增工资年月限制;D14仅修不可用时间,原有效格式与业务规则保持。B7从已启用公告具体用户操作进入,动态文案仍只走安全映射。 + + +## 2026-09-17 接续D12/B5(本轮执行入口) + +用户继续实施授权沿用;官方current已核为本task/session。入口保留77份旧脏文件与1022份保护材料、HEAD/index;新证据仅写hr-date-read-errors-20260917。inline实施/检查,独立复核PENDING;不外联、不读真实配置/人员/备份,不提交/推送/部署/归档,不实施借款。 + +- [x] D12:HR多格式入职日期复用日历谓词;保留分隔符、紧凑日期及秒/毫秒时间戳兼容。明确缺失与非法:缺失仍默认本次now;非法标记整条skipped/invalid_hire_date,固定安全提示,不落原日期。守卫在部门解析之前,dry_run/apply及离线sourceProfiles一致,不产生部门/员工/角色/绑定/主管业务副作用,保留运行/条目审计;现有员工仍不更新hire_date。 +- [x] D12合成验收:合法闰日/月末和多格式、非法溢出/年份/文本、缺失默认、已有员工、混合批继续;实际run服务+mock DB和fetch拒绝桩,无真实企业微信/PG验证。 +- [x] B5:限定7原因——员工目录查询/入职日期反向区间;合同查询/到期反向区间/即将到期days;在岗历史反向区间/超过31日。员工与合同导出外层schema使用相同域查询原因,显式传入parseQuery/parseBody,不按details猜原因。保持旧status/details、可见范围、7日默认/31日上限、导出格式;共享legacy写入helper保持。 +- [x] 三方原因合同与真实路由负例(含CSV/XLSX、外层schema、无查询/成功审计)同步;只删消失baseline指纹,不改owner/2026-10-05。 +- [x] 定向服务/路由/原因测试、客户端typecheck/build、语法/task/治理/敏感/diff;更新协调摘要/可复用spec,保护材料对账并另冻当前候选。 + +### 本轮已验证回执与剩余切片 + +- D12:d12-initial命令2文件44/44,其中新增完整同步服务38例;每项仅mock DB/sourceProfiles,fetch拒绝桩从未调用。非法条目仍有运行/item审计,不称“所有写入为零”。 +- B5:b5-initial命令9文件425/425;客户端固定文本/安全投影、员工两路由及导出、合同直接/导出/expiring、在岗直接/导出和旧admin守卫。export-regression另3文件72/72验证AP/仪表板/工资导出不受共享helper新参数影响。两次不冒称全仓/独立/真实PG或公网验收。 +- 前端:client-typecheck与client-build命令均退出0;build包含类型检查/Vite/PWA,保留原Browserslist数据陈旧告警,不升级依赖。 +- 原因strict:256注册、343静态迁移点、582遗留命中、主链例外0、finding0;导出两个动态helper已显式注册校验,不按静态计数虚增覆盖。7个消失baseline已移除,余561指纹,owner/2026-10-05不变。初次strict的两项未注册动态参数及7项stale提示日志保留;随后补注册校验并精确减基线,未放松扫描器。 + +下一批先按以下已定位范围接续,未实施项不标完成: + +| 切片 | 源码事实与限定准备 | 实施/验收边界 | +| --- | --- | --- | +| B6账号导入文件错误 | users读取文件的大小、缺文件、扩展名、MIME、缺表头、空文件、超过行上限7分支;复用users.import.test.js | 先固定原400/details及multipart库实际拒绝顺序,再注册7原因与客户端安全文本/负例;不透传文件名/内容/表头原值,不改原行错误整批拒绝、dry-run审计和密码动态提示 | +| D13月份输入审阅 | payroll PAY_MONTH_SCHEMA、attendance月份、exports payslip月份;payroll已有parseMonth后置校验,不能仅按regex算缺陷 | 先列外部schema→parseMonth/UTC转换→读写,确认00/13、默认月份、年份及导出兼容;未审前不改工资生成/发放/考勤语义 | +| D14公告datetime审阅 | announcements DATE_TIME_SCHEMA存在ISO解析或日期T前缀兼容 | 先核客户端datetime-local转换和原时区/null/default,再定接受格式与真实时间合法性;不是将date-only谓词套到timestamp | +| AP反向区间 | 列表/导出原无400守卫 | 保留现行为,若改变须单独业务验收;不以本批原因迁移新增业务规则 | + +D1–D12所列日期切片的本地实现已补齐,D8是盘点不是产品补丁;这不代表全仓所有时间格式或历史DB异常已解决。错误债务仍582命中/561指纹;09-25/09-30/10-02/10-05人工节点未到且未执行。W1独立、W2交付、W3恢复、W4事件、W7自然业务及W8历史结案仍按09-08保持开放;借款T2–T4未实施。 + +本轮收尾:13个增量JS语法、两个受影响task上下文、原因strict、Trellis/process/docs/敏感/diff通过;docs保留原22文件登记告警,前端保留原Browserslist告警。21文件增量中77旧脏有60份字节不变、17份在本范围继续;另4份为新触及/新建源码测试。1022旧证据/冻结材料的精确集合/SHA、HEAD/index保持。新候选为hr-date-read-errors-20260917/review-candidate,manifest与逐payload SHA在该目录登记;主会话检查回执不是独立复核,独立PENDING。旧包仍可追溯,本轮不修改task完成/归档元数据。 + +本节先于源码修改落盘。D12是已规划外部日期同类补口,B5只改错误解释;不改权限/状态机/审批/金额/业务区间规则。datetime/月份及AP反向区间仍分开规划,不据测试结果扩大边界。 + + + +本轮最终收尾:4个受影响task上下文校验、19个增量JS语法、原因码strict、Trellis/process/docs/敏感和diff通过;docs仍为既有22未登记文档告警。537份旧证据/冻结材料精确集合与SHA保持,59份旧脏文件中44份逐字不变、15份按本轮范围继续增量,HEAD及真实index保持。本轮33文件增量已单独保存,主会话只读检查不计独立复核。当前累积产品审阅候选位于local-roadmap-execution-20260917/review-candidate;原K0/两个产品候选保持,独立结论PENDING。520和61分别绑定其命令输入,未声称整仓、真实PG、生产认证或公网验证。 + + + +## 2026-09-17 连续执行D5–D8与后续本地工作(本地实现/验证通过,待独立复核) + +用户已批准按后期规划连续执行;官方current已回到本task/session。先完成D5–D7日期合同和D8源码盘点,再按可达操作处理错误原因批次;随后回到09-08补事件缺口方案、历史验收映射及借款T1技术定稿。新发现先补本节范围和验收,保持inline实施/检查,独立复核PENDING;不把用户此次授权扩大成生产/ARM、真实密钥/配置/备份、恢复重跑、提交/推送/部署/归档或借款产品实施。 + +- [x] 保存59份旧脏文件及旧证据/冻结材料基线,新增证据目录local-roadmap-execution-20260917,旧包不改。 +补充D5实测决策:CSV解析库会把合法YYYY-MM-DD先转成本地时区Date,再被现有normalizeImportCell转UTC,导致Asia/Shanghai下日期提前一天。导入字段合同均为文本,因此CSV读取显式保留原始单元格文本,后续继续原统一trim/角色/日期/重复校验;不重写XLSX原生日期转换。验收增加CSV/XLSX合法日期精确相等和文本标识前导零。预览原本写SYSTEM_USERS_IMPORT_DRY_RUN审计,保留此审计;apply非法整批拒绝不写业务/成功审计。初轮失败日志保留,不为测试移除原审计。 + +- [x] D5:users创建/修改/CSV与XLSX导入hireDate日历校验;保留缺省日期、修改省略、导入原批次语义;非法无写入。 +- [x] D6a:员工资料、合同、培训、证书逐字段日历校验与负例;保留nullable/optional和原区间冲突。 +- [x] D6b:入职/转正/调动/离职有效日期及附属日期;逐路由合成验证副作用前拒绝、正常日期和省略行为;不改生命周期规则或权限。 +- [x] D7:AP直接列表dateFrom/dateTo校验,与既有导出合同一致;反向区间先盘点调用方,本轮保持原规则并单列决策。 +- [x] D8:按外部输入/内部派生/历史DB值输出剩余消费者矩阵,新缺口先规划再动代码。 +- [x] 错误原因:先限定用户可达操作和旧status/details,服务端/客户端/夹具/路由负例整组迁移,仅删除消失指纹,原期限不变。 +- [x] 定向测试、治理、增量审查、资料保留对账和新候选;更新09-08协调入口及后续状态。 + + +D11细化事实:featureFlags.resolveEnvGate对旧attendance_v1固定返回false;team/admin backfill历史入口始终409 env_hard_off,不能通过测试夹具重新启用。共享schema仍加日历谓词,但实际路由验收记录为“禁用守卫保持、零业务访问”;只有attendanceUnified的WeCom同步请求及correction可测试400日历拒绝。未改开关规则、未恢复历史OA打卡。 + +D12进一步定位:当前HR主数据只在新建员工消费hireDate,已有员工update不改入职日;不是probation/lastWorking同步。本转换器非法或缺失返回null后创建会默认now,故不能只加日历谓词返回null就声称修复:下一方案必须把“未提供”与“提供但非法”分开,后者按单条同步失败/跳过留安全原因,前者保留既有默认。多格式/时间戳、create/dry-run一致性及零外联合成测试需同批;此项尚未实施。 + +### D10/D11继续执行清单(520轮之后) + +按用户连续完成本地同范围工作的授权,先固定6源路由:payroll、budgets、performance、attendance、attendanceCorrections、leave;仅在各外部date-only schema增加真实日历谓词,不改服务转换器、月份/datetime、额度/状态机/权限/审批守卫。D10覆盖salary-profile生效日、budget创建起止、cycle创建/修改;D11覆盖team日期、管理员回填、WeCom同步请求起止(只合成拒绝,不外联)、correction workDate、leave创建/修改/预览/列表起止。新增date-consumers.calendar真实路由负例,原模块路由/服务正例与回归一起跑;预算新增合法闰日/月末和反向区间。所有输入应在业务查询/事务/外联前拒绝,feature/cutover读取按原顺序保留。D12多格式HR同步仍只规划。 + +- [x] 6源最小refine与逐入口无副作用用例。 +- [x] D10/D11及原模块定向回归,9文件61/61通过,不用上一轮520替代。 +- [x] 更新矩阵/协调状态、最终治理及候选;不重新执行已无输入变化的前端构建。 + +D10/D11回执:6源仅schema/refine/import,新date-consumers.calendar与两既有正例测试;最终9文件61/61,精确输入见d10-d11-final.command.json/log。初轮52过/9失败全部为新夹具未设置环境功能开关及对永久禁用旧入口误期望400;已按真实合同修夹具/409断言,未改featureFlags或开关代码。非法启用入口零业务调用/事务,旧入口409及零访问保持;合法薪资/预算/绩效闰日/月末、请假工作日/编辑/范围与考勤修正原审批回归通过。仍mock数据库,未实证真实PG回滚或外部同步。D10/D11实现状态覆盖D8表当时的“待后续”,D12继续待审。 + +### 本轮产品验证回执 + +D5/D6/D7三源及D9 dashboard共4个路由文件;CSV显式文本映射与日期谓词,B3/B4原因合同保持旧status/details和所有守卫顺序。17文件最终520/520(product-final.command.json/log),客户端typecheck/build均退出0。原因严格检查249注册/336迁移/589遗留、主链例外0、finding0;删除9条已消失baseline指纹后剩568条,owner/期限原样。新增employees.calendar与dashboard.calendar两测试,复用users.write/import与AP权限夹具,其余相关回归沿精确命令记录。 + +初轮日期测试109过/5失败:2条预览本来有审计、2条夹具没有输出details、1条CSV合法日期提前一天。修正夹具及实际CSV转换后14文件215通过。后续原因批369过/5失败为既有预期未含reasonCode及夹具新加空details,保留精确旧字段并更新预期后最终17文件520通过;没有改业务守卫使测试变绿。各轮不累加为独立或全仓覆盖。 + +### D8源码盘点及同范围后续切片 + +本轮检查routes/services中的date-only正则、parseDateOnlyString、resolveEffectiveDate、new Date转换及各外部schema。分类用于确定后续合同,不是全库漏洞数量;原始源命中只存本轮脱敏证据,未查数据库历史值。 + +| 分类/切片 | 消费者→转换→后果 | 处理与验收 | +| --- | --- | --- | +| 已守卫外部 | B1/D1–D7所列报销/付款/差旅/合同/采购/待办/用户/员工/节假日/在岗/导出 | 保留原独立与本地证据边界;不改共享转换器 | +| D9 仪表板只读同类补口 | dashboard四GET共用query→UTC上下界→聚合;导出已有守卫而直接读尚缺 | 先日历refine,再保留原反向区间400;新增dashboard.calendar测试覆盖四路由非法零查询、正常UTC边界、省略与403。与下述B3一起执行 | +| D10 薪资/预算/绩效 | payroll salary-profile effectiveDate;budgets新建periodStart/End;performance cycle create/patch start/end→生效记录/预算周期/绩效周期 | 待后续限定批:分别复用salary-profile、cycles测试,预算新增聚焦路由;非法不得事务/审计,旧区间和状态守卫原样;不改金额、YYYY-MM或周期推进 | +| D11 考勤/修正/请假 | attendance DATE_SCHEMA及parseDateOnly、attendanceCorrections workDate、leave create/edit/preview/list日期,services/leave normalizeDateOnly→考勤与额度 | 待后续限定批:列所有外部消费者后按考勤/请假拆分;无打卡/回填/审批/额度副作用负例,保留datetime/session/工作日计算;不以正则替换代替请假额度回归 | +| D12 外部HR同步 | integrations/wecomHrMaster parseDateOnly多格式/紧凑/时间戳→新建员工hireDate(已核现有update不改此字段) | 待本地合成方案,明确非法字段整条/单字段跳过及审计;不访问企业微信或读取真实人员payload,不把normalize等同校验 | +| 待审其他格式 | announcements DATE_TIME_SCHEMA兼容前缀、各YYYY-MM月份输入 | 超出date-only本批;分别确认时区/旧兼容与月份范围,再补限定方案,不改宽泛共享解析 | +| 内部派生 | portal/currentDateString、employeeStats月初、attendance按年月推导、feeControl内部日期差 | 上游生成或派生,不逐个认定外部缺陷;与未校验外部链分开 | +| 历史DB值 | financeLedger输出、employee档案既存日期、invoice/OCR既有日期转换 | 只读源分类;未盘查/修复实际历史值;将来只允许有范围的聚合异常计数 | + +D7区间决策:ApOperationsPage将ledgerDateFrom/To直接发送列表和导出,两个入口原来都没有反向范围400;本轮固定保持旧行为并有回归。若统一拒绝,需要另批同时改列表/导出及界面提示,不能仅改financeLedger共享构造器。 + +### B3/B4限定原因合同(本轮执行) + +B3:dashboard查询/反向区间及AP列表查询/功能未开,共4原因;B4:users创建输入、修改输入、工号不可改、空修改及导入错误行,共5原因。仅用buildBusinessValidationError增加reasonCode;原VALID_001、status、details字段、权限/开关/事务顺序保持。服务端注册、客户端固定中文与fixture同步;真实注入断言精确reason与旧details及无业务副作用。导入错误详情不透传到客户端拼接文案,不引用密码/员工资料。原已有动态密码提示不动。只删除实际消失AST指纹,不统一延长期限;未修改权限模型/审批状态机。 + +## 2026-09-17 后期治理计划D5–D8与错误债务(仅规划,未实施) + +用户本轮要求详细整理后期工作及剩余量,协调current已切回09-08。D1–D4完成事实与冻结证据不变;本节仅把原“其他日期消费者”落实为3组已定位入口加1轮源码盘点,不据此开始新产品改动。后续各批批准后回到本task实施,原全量验收/借款/远端边界不变。 + +### 日期剩余工作:4个计划批次 + +| 批次 | 本轮只读确认的入口 | 限定方案与验收 | 依赖/预计投入类型 | +| --- | --- | --- | --- | +| D5 用户创建/修改/导入 | routes/users.js的create/patch hireDate及CSV每行hireDate仍仅regex;部分末端只拒绝Invalid Date,不能拒绝二月溢出 | 先分别锁定创建缺省日期、修改省略、导入空值/默认与逐行错误合同,再加日历谓词;非法直接输入无用户/角色/审计写入,非法导入按原行错误/批次语义处理,不把已有允许的部分导入改为整批失败 | 待批;复用users.import.test.js,直接写入新增聚焦路由用例;不执行真实员工导入/同步;中等 | +| D6 员工资料/生命周期/档案 | routes/employees.js共享DATE_SCHEMA用于profile的hireDate/probationEndDate/lastWorkingDate,入职/转正/调动/离职effectiveDate,合同start/end/terminatedDate,培训start/end、证书obtained/expiryDate | 先形成逐路由矩阵,再按资料/档案与生命周期分子批;保留nullable/optional、结束/开始语义、历史生效约定、权限/事务/审批顺序。非法日期必须在资料、角色/部门、checklist、审计和企业微信任务副作用前拒绝;合法边界与原冲突回归 | 待批;复用employees.patch/profile/training/certificates及既有生命周期测试,缺失用例实施前登记。只用合成身份,企业微信/外联为零;中等且需多子批,不直接替换共享schema后笼统宣称全过 | +| D7 AP台账查询与反向区间 | routes/apOperations.js直接查询dateFrom/To仍仅regex;services/financeLedger.js只是转换,D4仅硬化exports入口;台账反向区间原未拒绝 | 先统一列表与导出的date-only输入合同;日历溢出无业务查询/审计。反向区间是否统一400必须核现有UI/调用方及验收后单列批准,再选最小共享入口,保留原权限/可见范围及正常查询结果 | 待批;复用apOperations.permissions和exports.apOperations,加直接查询合法/非法边界;不先改财务数据或金额/付款状态;小至中等 | +| D8 其余日期消费者源码盘点 | 上述已定位集合不是全仓穷尽,shared parser及其他转换器仍有未审消费者 | 只读列外部schema→转换→副作用链,分类为已守卫外部、待审外部、内部派生、数据库历史值;每个新发现给路由/字段/风险/定向用例再决定独立批次。历史异常只做以后可申请的聚合盘点方案,本轮不读真实数据 | 待执行的本地调查,范围可先固定;产出写本task,不新建任务/文档系统、不把代码匹配数当漏洞数;实际规模待盘点 | + +以上4批是当前可规划的工作量单位,不等于只剩4个日期字段或4个bug。D6可能按真实消费者继续拆分;D8可能发现新边界,届时增量评估,不修改共享parseDateOnlyString来掩盖未审调用。当前不创建新测试文件或运行产品测试。 + +### 错误合同剩余工作与进入条件 + +引用D1–D4已执行error-contract.log:240注册、327已迁移、598遗留、主链例外0、finding0;本轮只读核baseline为577 entries/count598,owner全shunli,expiresOn全2026-10-05。58个有遗留的扫描分组;前十purchaseRequests52/users40/employees31/assets29/masterData25/integrations22/travelRequests21/attendance19/payroll18/travelReconciliation18,共275,其余48组323。这里按扫描器分组统计,不把服务与路由重复当作独立产品模块。 + +- [ ] 下一原因批先按当前已启用/用户可达操作选路径,优先用户实际可操作且缺安全恢复指引的分支;不能只因某文件命中多就整文件替换。每批固定操作、旧status/details、固定/动态文案与不允许副作用,再审阅实施。 +- [ ] 服务端原因、客户端窄投影/中文夹具、真实Fastify操作负例作为一组交付;修改权限/状态机/审批资格则退出文案范围另审。 +- [ ] 只从baseline移除实际消失指纹,不能删仍命中条目或统一延长2026-10-05。workflow工具15项与workflowDefinitions配置11项分开处理。 +- [ ] 09-25实际重新扫描及复审,09-30必要条目的有理由延期提案,10-02查漏,10-05原门禁;本节统计不抵扣这些未来动作,也没有创建自动任务。 + +现有K0/产品独立复核、提交与发布、密钥/恢复、备份事件、自然业务、历史task映射和借款依赖归09-08最新W1–W9表。本次只有后续计划新增,下方D1–D4和B1/B2验证回执原样保留,不能将本节待办状态倒推为已完成批次又未实施。 + +## 2026-09-17 D1–D4本地通过(最新入口,待独立复核) + +用户在上一轮已完成B1/B2并补齐D1–D4规划后要求“继续推进”;据此承接这四个同类本地日期批次,沿本task实施,不扩到K1/K2真实操作或借款。官方current回读仍为本task/session,忽略注入no_task。09-25等人工复审节点仍未执行,今日提前推进代码不计其已完成。B1/B2及K0审阅候选原字节保持,独立审查仍PENDING。 + +- [x] 复核D1–D4真实消费者/原语义,保存所有旧差异与既有证据目录的文件集合、SHA、HEAD/index基线。 +- [x] D1:todos创建/修改dueDate在原regex后增加isValidDateOnlyString,nullable/optional和指派权限顺序保持;测非法无读写/通知、有效闰日/月末、null清空/省略保留与原403。 +- [x] D2:employeeDirectoryFilters与exports员工schema同步校验hireDateFrom/To;测试/users和/employees真实路由与CSV/XLSX导出,非法无员工查询/审计,合法UTC上下界/省略、原反向区间400和权限保持。 +- [x] D3:masterDataHolidayCalendars整批items日期先验后事务;混入一条非法日期整批拒绝,零delete/create/audit;重复日期409、归档409、无权限403及空数组清空合同保持。仅mock边界,不伪称PG回滚已验。 +- [x] D4:逐字段修改exports仪表板dateFrom/To与AP pendingCreatedAtFrom/To、ledgerDateFrom/To;保留原DATE_ONLY_SCHEMA、在岗局部校验、审计datetime及工资YYYY-MM,不统一替换所有schema。测非法无业务查询/导出审计、有效闰日/月末/省略及原pending反向区间400。 +- [x] 跑本批路由定向Vitest和utils/date,并回归上批同文件相关用例;语法、原因码strict、task/Trellis/process/docs/敏感/diff门禁、差异与保护材料对账;保存新审阅候选。 +- [ ] 独立只读复核、提交/推送/发布另列真实结果,当前不具备授权或结论。 + +源码复核细化:AP台账ledgerDate反向区间原来未设置拒绝守卫,本批只校验两端是否真实日历日期,不借输入修复新增区间业务规则;此项另列后续审阅,不宣称所有范围语义已收口。员工目录共享服务同时被/users和/employees消费,纳入同组回归;不修改用户管理其他资料写入/导入日期。 + +预计产品变动仅4文件:routes/todos.js、services/employeeDirectoryFilters.js、routes/masterDataHolidayCalendars.js、routes/exports.js。测试复用users.list、exports.employees、exports.apOperations,新增todos.calendar、masterDataHolidayCalendars.calendar与exports.dashboard.calendar。最终命令/结果写入`artifacts/qa-layout/calendar-followup-d1-d4-20260917/`,不覆盖上轮followup-governance-20260917。本批不改客户端映射/依赖/schema,不重复前端构建或此前450项整批;必要重跑以实际新改动和失败为据。 + +### D1–D4本地验证回执 + +产品改动仅上述4个文件的日期谓词串接及3个import;共享日期转换器、导出基础DATE_ONLY_SCHEMA、审计datetime、工资月份、权限/业务守卫/事务代码均保持。已完成主会话增量源/test审查;非法日历日期不会开始节假日清空/新增事务,也不会触发待办写入/通知或三类导出业务查询/成功审计。nullable/optional、UTC上下界、原重复日期409、归档409、无权限403、已有反向区间400及空数组清空行为均在定向路由用例验证。 + +最终同轮命令为server目录下`npm test --`逐项传入todos.calendar、users.list、exports.employees、masterDataHolidayCalendars.calendar、exports.apOperations、exports.dashboard.calendar、todos.errors、exports.contractsRegistry、exports.presence、payroll.exports、utils/date共11文件,参数`--maxWorkers=2 --minWorkers=1`;192/192通过(31.90秒)。准确路径/退出码归新证据目录combined-tests.command.json及combined-tests.log。3份新增测试、3份既有测试追加,包含上批同源回归和共享日期用例,不计为192项全新测试,也不与450累加为全仓覆盖。本次首轮通过,无夹具失败需修订。 + +10个本批源码/测试文件node --check退出0。原因码严格检查PASS:registered240/migrated327/legacy598/primaryExceptions0/findings0,基线及所有owner/expiresOn未改。测试仍使用Fastify注入、合成身份和mock DB,不宣称真实数据库事务回滚、生产认证/浏览器或公网已验证;客户端未变,不重复typecheck/build。 + +### D1–D4治理与输入保留 + +09-05/09-08 task上下文、Trellis治理(23项合同/1242份扫描/14份已登记证据摘要)、process baseline、CURRENT敏感扫描30份/finding0、git diff --check均通过。docs registry零错误,保留1项既有告警(22份未登记文档)。仅更新既有日期规约,不扩大无关清理;客户端/依赖/数据库schema未变,因此不重复前端构建、Prisma或真实DB验证。 + +进入时51份旧差异中,仅计划内8份源码/spec/task文件继续增量更新,另43份逐字不变;所有旧任务正文保留,09-05元数据仅notes更新、借款task四文件及planning状态未改。308份既有证据(四恢复包、K0证据及上一轮followup-governance全目录)精确集合/摘要保持,HEAD与真实index保持。本批增量16文件:4源、6测试、1spec、5task文档/摘要;本地对账另证明4源增量仅限定schema refine/import,无业务守卫/事务变化。 + +新目录保存incremental.patch、changed-files.json、preservation-result.json、result.json及review-candidate的manifest/逐文件字节SHA。候选绑定上一轮审阅manifest并只覆盖本轮更新输入,现有依赖不打包;必须在同树/匹配输入下重跑精确命令,不能把候选完整性称为独立复核或独立执行通过。主会话inline执行/检查,独立状态仍PENDING。本轮全程仅本地,无生产/ARM访问、真实资料/密钥/配置/备份读取、恢复重跑或提交/推送/部署/归档。 + +### 后续入口 + +- [ ] 接回K0、B1/B2与D1–D4的真实独立只读结论,分别记录候选摘要/实际重跑,不在inline模式派代理冒充独立身份。D1–D4更改了todos/exports当前输入,新审阅需使用新候选这两文件并联读前批原因码改动;旧候选不回写。 +- [ ] 09-25按实际AST和可达操作复审剩余598次错误豁免与其他日期消费者;员工资料写入/批量导入及AP台账反向区间规则先核原合同、兼容与业务口径,再给限定批次方案。不将此轮四批收口称全部日期安全。 +- [ ] K1双角色受控保管状态待事实,实际核验仍NOT_TESTED;K2新绑定未具备,借款仍planning。原自然业务/备份事件验收缺口保持,当前不进入远端或真实数据操作。 + +## 2026-09-17 B1/B2本地实施回执(当前入口,未提交/部署) + +用户批准按既有梳理继续;官方current已通过task.py start从08-23切到本task并回读。旧差异、四份恢复冻结包及K0证据已保存基线。本节是本轮新结果,旧日期/提示批次与526项独立证据保持原范围,不计为新通过。 + +- [x] 重盘点AST与本批日期消费者:进入时229注册、316已迁移、612遗留、591条基线entries、主链例外0。 +- [x] B1a:合同、采购、差旅付款/对账及合同查询/导出共享入口严格校验日历日期;无效值在写入前拒绝,保留原nullable/optional和区间业务语义。 +- [x] B1b:周报原始周日期在周一归一化之前校验;在岗写入、筛选和在岗历史导出局部schema同步处理。 +- [x] B2:通知、待办、门户11个原因码及客户端固定提示整组迁移;HTTP status、VALID_001、legacy details、权限/开关/投递及事务顺序保持。 +- [x] 最终同输入定向Vitest:14文件450/450;客户端typecheck/build退出0;后端语法及原因码严格合同通过。 +- [x] 完成本轮task/Trellis/process/docs/敏感扫描、增量审查、旧差异与冻结材料对账并保存只读候选;回执见本节收口记录。 +- [ ] 接回真正独立只读复核;主会话inline自检不计独立通过,提交/推送/部署仍需明确授权。 +- [ ] 09-25复审、09-30必要逐项延期提案、10-02查漏、10-05原门禁;均为人工计划节点,未创建自动定时任务、未提前执行。 + +### 本轮改动与验证边界 + +B1共8个源文件:routes/contractsRegistry、purchaseRequests、travelOrders、travelReconciliation、weeklyReports、presence、exports及services/contractRegistryFilters。复用isValidDateOnlyString,不修改共享parseDateOnlyString;exports只改PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA,未扩大到所有导出。差旅对账反向区间仍返回原409;无效日历日期返回原输入错误。周报GET可能创建草稿,因此原始日期必须在读/写入口都先校验。 + +B2新增notification域6项、todo域4项、portal域1项。服务端注册、客户端映射与固定中文夹具一致;仅移除已消失的14条AST指纹(含未用helper),最终240注册、327已迁移、598遗留、577条entries、主链例外0、findings为0。剩余owner与expiresOn仍逐条保留,全部2026-10-05到期;598不是已修复数,也不是598个已证产品故障。 + +最终命令与退出码位于`artifacts/qa-layout/followup-governance-20260917/combined-final.command.json`,日志为`combined-final.log`:contractsRegistry、purchase.project-link、travelOrders、travelReconciliation、presence.admin/history、exports.contractsRegistry/presence、weeklyReports.calendar、utils/date、notifications、todos.errors、portal、scripts/error-reason-client,共14文件450项通过(含262条客户端合同,不累加前序轮次)。新增测试文件为weeklyReports.calendar.test.js与todos.errors.test.js,其余向既有路由测试追加。覆盖溢出/闰日/月末、空值/省略、无写入、精确reason与旧details;missingFields/password/nextAllowedAt正反断言复用已有客户端合同,本轮没有更改动态投影。 + +`client-typecheck.command.json`与`client-build.command.json`记录npm run typecheck/build均退出0;build内部也含typecheck,Vite构建成功。仅保留既有Browserslist过期及测试punycode提示,未升级依赖。测试使用真实Fastify注入、合成身份与mock数据库;不是实际数据库/JWT、浏览器或生产业务验收。未改Prisma schema、UI布局或权限模型/审批状态机,无需为本批启动数据库、浏览器或远端验证。 + +中途新增夹具/预期失败已保留日志:周报flag需force_on;对账反向区间原为409;通知mock渠道不存Secret,缺钥用例改合成webhook渠道且不发送;portal夹具补合成authorization;todo gate精确原因是flag_force_off。修正测试后完成上述同输入整批450项,未通过更改业务规则绕过失败。初跑与中间测试不累加到最终总数。 + +执行采用精简环境、两个本轮空npm配置并仅元数据检查项目无.env系列/.npmrc,不读真实配置。全部证据存入新目录;原K0两份源码、原证据及恢复四包保持。K1/K2具体操作和借款实施没有由本批授权。 + +### 日期消费者矩阵与下一批规划(未实施) + +| 分组 | 消费者与事实 | 下一步与验收 | +| --- | --- | --- | +| 已发布历史批 | 报销、付款批次、差旅申请、资产及发票局部日期 | 沿原发布/独立证据,不重复计本轮通过 | +| 本轮B1a | 合同写入与contractRegistryFilters查询/导出、采购交付、差旅付款/对账 | 本地校验与路由回归通过,待独立复核/发布 | +| 本轮B1b | weeklyReports原始weekStart、presence写入/查询、在岗历史导出 | 本地通过;周一归一化和反向区间原语义保持 | +| 内部派生 | presenceHistory及本批内部当前日期调用 | 按已校验上游/内部生成分类,不称所有parseDateOnlyString调用都安全 | +| 下一日期批D1 | todos的create/patch dueDate仍仅regex | 先核nullable/optional与自建/指派权限,复用日期谓词;合成路由测溢出无写入、有效闰日/月末、null清空/省略不改值及原403 | +| 下一日期批D2 | employeeDirectoryFilters的hireDateFrom/To及exports员工入口仍仅regex | 共同核列表与导出入口,保留区间/权限合同;非法输入不得查询员工数据或生成导出,补合法边界 | +| 下一日期批D3 | masterDataHolidayCalendars的date仍仅regex | 先核管理权限、重复日期与批量原子性;非法日历日期不得进入写入,合法节假日及原冲突语义保持 | +| 下一日期批D4 | exports其他域的dateFrom/To、pendingCreatedAtFrom/To、ledgerDateFrom/To | 按schema与实际消费者分域,区分datetime和date-only;不能统一替换DATE_ONLY_SCHEMA后宣称全部安全 | + +D1–D4为补齐的待审范围,不在本轮偷偷实施。负责人shunli,09-25复审时按可达外部入口/风险排序固定每批路径、用例与验收,再实施/检查;涉及资金或权限行为实质变化另审。历史异常数据只读盘点方案须先明确字段聚合/访问范围,当前没有读真实数据或修历史日期。 + +### 剩余错误豁免治理(未全量实施) + +- [ ] 09-25重新运行既有AST严格检查,按实际指纹/操作分域;优先仍在用的操作,保留未启用配置域事实,不拿598静态计数替代当日扫描。 +- [ ] 每个候选批同时绑定服务端原因、客户端窄投影、固定文案与路由无副作用负例;workflow工具路由推导及workflowDefinitions配置分别评审,不混成文案批。 +- [ ] 09-30只对届时确实未完成且有具体理由的条目提出owner/期限/验收延期方案;10-02查漏,10-05按原门禁执行。不统一延长、不删除仍命中的豁免。 + +### 本轮收口与后续进入条件 + +五个更新任务(08-23、08-28、09-05、09-08、09-17)的上下文校验退出0;借款task为inline,未新增无用jsonl。Trellis治理PASS(23项合同、1242份扫描、14份既有登记证据摘要),process baseline PASS,CURRENT敏感扫描30份/finding0,docs registry零错误、1项既有告警(22份未登记文档),git diff --check通过;最终23个变动后端JS语法通过。主会话已复核本轮源/test/spec/task增量,没有把既有脏差异作为本轮实现;未修改业务状态机/权限模型,无新增独立审查结论。 + +基线24份旧差异中11份task文档/摘要按本轮范围增量更新,其余13份逐字不变;08-23/08-28/09-08旧实施正文及09-05的R1以后历史章节原样保留,09-05 task元数据仅notes变化、借款task.json仍planning。四恢复包86件与K0原证据41件共127份的精确文件集合/摘要、HEAD与真实index前后相同;新增K0审阅候选6件加manifest两份完整,匹配当前K0输入,状态仍PENDING。原有96计数仅保留为历史差异,不改冻结包。 + +本轮增量38文件,证据目录内保存incremental.patch、changed-files.json、preservation-result.json、最终命令/日志及只读review-candidate(带文件集合/字节/SHA清单)。候选只证明本地输入和回执可审阅,不是独立复核已通过;独立方需先核manifest,再将B1的原区间/null/周一起点、B2的status/details/秘密抑制/无通知副作用分别审查,重跑另记环境和真实结果。K0独立审查使用旁侧k0-review-candidate,不混成一项通过。 + +本批代码保持未提交/未发布;下一步为接回独立只读意见并据具体结论修订,再在明确提交/发布授权范围内推进。K1等待双角色非秘密保管状态与具体核验,K2绑定仍NOT_BOUND,不能复用已消费恢复入口。D1–D4与借款T1–T4均保留待审规划,未实施。未来人工复审节点未提前勾选;未连接生产/ARM、未读真实密钥/配置/备份、未重跑恢复、未制造付款/OCR/通知,未提交/推送/部署/归档。 + ## R1内容复核处置(R2修订,未提交/部署) R2结果:`npm test -- tests/unit/scripts/error-reason-client.test.js tests/unit/routes/auth.login.test.js tests/unit/routes/workflow.transfer.test.js tests/unit/routes/invoicePool.patch.test.js`四文件311/311(244条客户端契约);typecheck与vite build退出0。修订后visual-portal.spec.ts实际22/22零重试,含新增财务两段href和BX/PB均消失。日志/tmp/followup-r2-unit.log、typecheck/build/browser同前缀;不是R1的471项整批重跑。 @@ -258,3 +899,19 @@ R1包及其manifest不变;本轮代码已偏离R1,必须重冻R2后交增量 最终同输入验证:`cd server && npm test -- tests/unit/scripts/error-reason-client.test.js tests/unit/routes/auth.login.test.js tests/unit/routes/invoicePool.patch.test.js tests/unit/routes/workflow.batch.test.js`,4文件323/323通过。新增4项(VAT集合、密码策略、非有限日期、催办精确24小时冷却负载);两项已有路由用例加强动态键断言。催办409同时核nextAllowedAt、查询时间窗、无历史/通知/任务写入;只用合成actor/存储替身,不是生产登录链。首轮1项失败是新测试把已有“总金额”误写为“价税合计”,已校正测试预期而未修改产品文案;最终整轮通过。存在Node既有punycode弃用警告,无测试失败。 流程baseline、Trellis治理及git diff --check通过。本轮仅测试/文档,无运行时代码或schema变化,未重跑前端build/浏览器,未独立复核、提交、推送或发布。扩展票种buyerName、密码新规则的输入覆盖、时区与共享日历校验仍不是这组测试能全量保证的事项。 +## 2026-09-16 已发布状态与剩余消费者重新分类 + +最新日期/动态提示增量已随b1d25d3c发布,独立526项与构建、发布验收沿calendar-contract-release原回执,不把下文历史“未发布”当当前状态。本轮仅本地盘点;check-error-reason-contract.mjs --strict新跑PASS,registered229、migrated316、legacy612、finding0,证据postrelease-recovery-20260916/reasons.json。未重跑526项或修改原因码/到期日。原历史构建措辞“大chunk提示”不准确,实际只有Browserslist提示;冻结包保持原字节,以包外更正为准。 + +| 剩余日期消费者 | 输入分类与新静态证据 | 后续验收边界 | +| --- | --- | --- | +| contractsRegistry | DATE_ONLY_SCHEMA仅regex,外部日期经parseDateOnlyString写入 | 按合同字段逐项加真实日历拒绝,保持nullable/optional,不批改历史 | +| purchaseRequests | 外部expectedDeliveryDate,DATE_ONLY_SCHEMA仅regex,后续写expected_delivery_date | 采购日期输入优先;非法日期须在事务/写入前拒绝 | +| travelOrders | 外部paymentDate仅regex,进入payment_date写入 | 单列付款日期守卫,保留渠道/凭证/已付冲突规则 | +| travelReconciliation | 外部periodStart/periodEnd共享DATE_ONLY_SCHEMA仅regex | 区间两端日历及顺序分别验,不扩大到历史账务修复 | +| presence | 外部date/dateFrom/dateTo使用DATE_SCHEMA仅regex,既有查询和写入消费者 | 外部输入优先,不能因某些默认值来自currentDateString就整体标派生安全 | +| weeklyReports | 外部weekStart仅regex,先进入周起点计算再查询/写入 | 原始输入先验日历,防归一化掩盖溢出;周一归一规则不改 | +| portal | 当前检查到today/currentWeekStart来源于currentDateString及resolveWeekStart | 内部派生优先级低;不能仅凭引用旧解析器宣称存在外部写入漏洞 | +| contractRegistryFilters及attendance/employeeStats/feeControl/presenceHistory、观察导出 | 服务/筛选二级消费者 | 必须追上游输入资格,不做全局替换parseDateOnlyString;本轮未完成逐调用点证明 | + +本轮完成分类与新基线盘点,不宣称剩余日期/612次错误均修复。下批优先外部写入(采购/合同/差旅付款/对账)再周报/在岗,服务派生独立追踪;各批需原task方案、入口负例/无写入测试和相应只读复核,不借恢复预检插入未审业务规则改动。09-25复审、09-30必要单项延期方案、10-02查漏、10-05门禁不变,负责人shunli;没有创建定时自动化。 diff --git a/.trellis/tasks/09-05-error-explainability-payment-readiness/prd.md b/.trellis/tasks/09-05-error-explainability-payment-readiness/prd.md index 6f363c62..4ffdb598 100644 --- a/.trellis/tasks/09-05-error-explainability-payment-readiness/prd.md +++ b/.trellis/tasks/09-05-error-explainability-payment-readiness/prd.md @@ -1,5 +1,88 @@ # 错误可解释性与付款就绪治理 +## B34 当前验收状态 + +用户批准接续后,损坏账号导入的限定修复已完成本地合成验证:已复现白名单格式返回固定400提示,未知解析故障继续500且不记录原解析内容;权限、文件限制、合法导入、日期文本与事务保持。最终68项导入加854项原因合同断言通过,前端typecheck/build及原因strict/self-test通过,详见implement最新回执。此结论不等于独立复核、真实导入或上线;下方“尚未实施”保留原时点,D15非阻塞、D16现合同及17 ERP排除不变。 + +## 2026-09-17 用户澄清:考勤由企业微信承办 + +用户明确“考勤功能在企微”。按现行产品规则,打卡、请假、补卡、外出、加班由企微承办;OA 保留月度摘要、异常解释、同步状态及审计,不恢复旧 OA 打卡。此前 D15 是 OA 摘要查询的极低年份技术观测,不是企微考勤故障,也不是新建 OA 考勤的需求。 + +D15 不列为本轮必须修复或阻塞收尾的项目;原 2000–2100 建议仅为未采纳的技术备选,不再作为等待用户决定的必办项。保留观测和现有代码,不把它记为已修复,也不由此认定所有企微摘要/同步代码均已废弃。B34 账号导入仍为下一本地修复优先项。 + +## 2026-09-17 剩余边界确认结果 + +W6三类余项已完成本地范围确认,产品源码未更改。损坏账号CSV/XLSX在两入口仍返回500且CSV异常可将合成单元格交给日志,列为B34优先修复方案;具体错误白名单、固定安全提示与未知内部异常分流见design。低年份D15将工资待精简路径排除,统一考勤保留入口只读偏移另待接受年份决策。AP反向ledger区间D16保持现有200及有界查询,不作为必须修复项;非法日期/其他区间原拒绝保持。 + +本轮验收为合成行为证据、在用/排除依据与限定实施方案,不把characterization通过记成缺陷已修复。旧17处ERP排除与原人工复审日期不变;不修改原AC勾选、不提交部署或访问真实数据。 + +## G8按在用路径收口 + +按implement顶部B31–B33合同处理57处,17处ERP旧分支按用户最新指示跳过并保留源码/基线。其依据为产品设计规则61/89及企微路线图37;通用outbox有notification.im消费者不整文件跳过。当前权限/状态/编号/开关/认证业务规则保持,只本地合成。所有旧材料保护、独立PENDING,不读真实配置密钥/外联/提交部署或实施借款。 + + +## G7本地错误合同接续 + +查询考勤周报八源42处,按implement顶部B29/B30逐操作合同实施;三方固定提示、精确减基线、实际路由/服务负例和合法回归、页面安全消费及保护新候选。legacy考勤硬关闭保持,内部路由用合成门禁桩分层验证;周报项目开关失败前原update尝试保持。独立PENDING,不扩权限/状态规则,原真实数据/外联/提交部署边界不变。 + +## G6验收补充 + +共享审批工具、流程定义、审批规则和委托仅迁移原错误合同,保留原status/details、角色/自审批/快照/主管链规则及事务副作用位置。明确不可达防御与本地mock边界,另限定修复委托列表有效期OR覆盖本人范围OR的组合缺陷:两者AND同时生效,恢复原有范围而不新增管理权;须有合成数据红绿路由证据。具体操作与验收以implement为准;独立只读复核PENDING。 + +## G5验收补充 + +延续120处采购/资产/流转盘点/合同与共享预算错误合同治理,仅新增明确原因和固定安全提示,原状态/权限/冻结/附件安全及业务写入顺序保持。具体子批先行合同与进展以implement顶部为准;仅本地合成,不以扫描数代替分支或真实业务验收。 + +## G4差旅验收补充 + +B19–B21覆盖申请、订单、授权、交易及共享预算和对账错误合同。保留原400/409/details、审批/付款/预算规则、锁/事务、按条导入跳过与幂等、ERP排队门禁及敏感文案固定映射。另修复申请关联项目误调用未定义parseUuid导致500,复用本文件已有id解析器,原可见/绑定规则不改。缺预算池前原ledger尝试、工作流拒绝前原风险处理继续明确记账,不虚报零写入或真实回滚。 + +## G3基础资料验收补充 + +B15–B18覆盖公司/成本中心/单据分类/供应商、日历、费用标准/类型、假别、模板及项目逐操作错误合同。仅补稳定原因与固定安全提示,保持原400/409/details、权限、开关、状态、金额/日期schema、默认值/null和事务审计。项目收口项的原先行惰性初始化例外明确记录:拒绝后不修改目标项、不记成功审计,不承诺所有拒绝零写入。旧详情中的公司代码及无效费用类型列表不用于拼UI文案。仅合成测试,具体合同与验收状态见implement顶部。 + +## B11–B14连续本地验收 + +覆盖预算范围/选池与控制规则、员工账号/部门、员工资料/生命周期/合同培训证书及头像/合同附件错误解释。保留旧HTTP、VALID_001、详情字段与权限/冻结/事务/业务规则,三方原因及可恢复提示同批。员工文件读取只捕获既有生产映射的FST_REQ_FILE_TOO_LARGE并补原因,原文件限额/格式/内容检查和下载阻断安全message/审计保持。原目录无调用helper清理与不可达日期防御分支单独记录,测试只用合成资料和离线桩,详细合同以implement顶部为准。 + +## B10及剩余546处治理 + +按implement顶部8组总表连续处理同类本地错误合同。B10限定预算/绩效路由原拒绝条件,保留管理员权限、经理/本人冻结、预算范围/额度和事务状态;只补25个明确原因及安全恢复提示。覆盖原status/details、无后续写入/成功审计、正常与权限回归,三方文案和严格基线同批验证。共享预算服务仍单列未完成,不能称整个预算域清零。 + +## B6/D13/D14/B7接续验收 + +连续完成账号导入文件7原因、月份后置守卫补证/具体提示、公告三日期字段合法性与公告操作原因/现有页面错误反馈,并收齐工资维护现有校验原因。共36原因,业务拒绝条件不变。原status/details、导入事务/预览审计、工资生成发放规则、公告时区/缺省/签收保护保持;非法上传或公告时间不进入业务写入。月份00/13已有校验不能重复宣称修复,低年份Date.UTC偏移另列决策。精确合同与状态以implement最新入口为准,不扩大到真实业务或借款实施。 + +## 本轮D12/B5验收补充 + +继续执行已批准的本地日期与错误治理:HR入职日期缺失保留默认,提供但非法必须整条跳过并保留安全审计,在部门/账号/角色/身份/主管写入之前拒绝;已有员工不新增入职日更新。员工目录、合同筛选(含对应导出外层输入)及在岗历史范围共7原因,兼容原status/details与默认范围。以本轮implement顶部为当前限定合同;旧“D12尚未实施”为历史时点,完成状态以本轮回执为准。仅合成数据与离线桩;独立/发布不由主会话本地验证抵扣。 + + +## 2026-09-17 连续本地治理范围(当前合同) + +用户已批准按后期规划连续推进。继D1–D4后,实施D5用户创建/修改/CSV/XLSX入职日、D6员工资料/生命周期/档案、D7 AP直接列表、D8余项盘点、D9仪表板直接读、D10薪资/预算/绩效、D11考勤修正/请假和同步请求日期。仅本地输入校验,保留原权限、开关、状态机、额度/区间/默认日期和nullable/optional;旧attendance_v1固定关闭不被解冻。CSV须保留合法日期及文本标识原值,非法apply整批无业务写入,dry-run原审计保留。 + +B3/B4限于dashboard/AP查询/开关及账号创建/修改/工号不可改/空修改/导入错误行9原因;旧VALID_001、status/details保持,客户端具体安全文本、固定夹具和真实路由负例同批。剩余豁免只删消失指纹,原owner/2026-10-05不变。 + +验收按D5–D9/B3/B4最终17文件520项及后续D10/D11的9文件61项分列,两组不冒称全仓/独立/真实DB或公网。前端typecheck/build通过;完整记录/初跑失败/精确命令归implement及新证据目录。D12外部HR多格式日期须先分清缺失默认与非法跳过,尚未实施;datetime/月份、AP反向区间改变、历史数据修订和借款实现不在本批。旧批次/冻结材料保留,提交发布另按门禁。 + + +## 2026-09-17 D1–D4本地接续范围 + +用户在D1–D4规划落盘后要求继续推进,本轮仅落实待办dueDate、员工目录及导出的hireDate筛选、节假日批量日期、仪表板/AP导出date-only字段。无效日历日期须在写入/业务导出查询前拒绝,原nullable/optional、权限、重复/归档冲突及区间语义保持;CSV/XLSX共同验证。完整结果见implement顶部,未来09-25人工复审与剩余错误豁免仍开放。 + +不扩大共享parseDateOnlyString转换、员工资料写入/导入、审计datetime、工资月份或AP台账反向区间规则;不修改客户端/迁移/生产。B1/B2原候选保留,本次新输入另冻供独立审查,不将旧450项验证冒称覆盖新输入。 + +## 2026-09-17 后续治理范围(本地实施已批准,已发布批次不重做) + +按09-08优先级表承接B1/B2。B1先覆盖contractsRegistry、purchaseRequests、travelOrders、travelReconciliation的外部日期,再覆盖weeklyReports/presence;只加输入日历守卫,不开放冻结模块、不改审批/付款规则、历史数据或旧parseDateOnlyString转换约定。内部派生日期逐调用点追上游资格,不因引用旧解析器就整文件替换。 + +B2先用现有AST门禁盘点当前豁免及用户可达操作,再处理确实未迁移的常用路径;已发布paymentAccounts/invoicePool/auth/workflow提示不重复实施。每项必须同时有服务端原因码、兼容details、客户端具体文案及动态字段/无副作用断言;保留逐条批处理失败原合同,不靠宽泛正则或删基线隐藏问题。 + +新增小批验收:非法日历输入在事务/写入前拒绝,闰日/月末/null/省略及原区间/周起点语义保持;新原因码三方文本一致,动态字段投影仍安全,主链路零新增例外;受影响真实路由/客户端定向测试通过并经相应只读审查。09-25复审、09-30必要单项延期提案、10-02查漏、10-05原到期门禁不变,责任人shunli;本轮未创建定时任务、不延期。 + +用户已于本日批准按梳理继续,本节既有B1/B2小批进入本地实施;新增schema/业务规则、整库历史修订、生产写入和发布不在范围。与08-23受保护操作等待解耦,不要求完整灾备全部通过才能做本地治理。 + ## Goal 把“系统知道为什么不能继续、真人只看到参数校验失败”的缺陷从逐页补文案改成可持续的错误合同,并以付款批次为第一个完整样板:财务在点击前就知道付款资料是否齐全、缺什么、由谁补;点击后的服务端二次校验与页面提示必须来自同一份实时判断。 diff --git a/.trellis/tasks/09-05-error-explainability-payment-readiness/task.json b/.trellis/tasks/09-05-error-explainability-payment-readiness/task.json index 79f51a9b..e8fcaa14 100644 --- a/.trellis/tasks/09-05-error-explainability-payment-readiness/task.json +++ b/.trellis/tasks/09-05-error-explainability-payment-readiness/task.json @@ -28,6 +28,6 @@ "client/src/pages/PaymentBatchesPage.tsx", "scripts/qa" ], - "notes": "来源:真实单据 0014 创建付款批次时第三次出现‘参数校验失败’;生产只读诊断确认未创建批次,员工付款资料缺失且错误未被客户端解释。2026-09-05 最终规划已获用户批准实施;交接复核重点:门禁六类 self-test 真失败、主链路零例外且禁止新增例外、付款候选与创建共享同一 readiness resolver、20 处旧断言逐项处置且不得使用宽泛正则。0014 真实资料补录与付款不属于本 task 自动操作范围。", + "notes": "2026-09-17当前:G8保留路径错误合同已收敛,520入口处理503、排除ERP17;1093最终合成与构建证据保持、独立PENDING。后续本轮只确认B34损坏导入、D15低年份和D16 AP反向区间;9导入探针DB零调用,6损坏文件仍500/CSV日志含合成标记,修复方案已落design但未实施;工资待精简不补、统一考勤年份规则待定、AP原语义保持且73回归通过。旧D12待实施/589余额属于历史记录,不再作为当前状态。\nD10/D11接续:6源日期schema,9文件61/61;旧考勤保持409关闭。D12仍待独立输入策略,前批17文件520结果有效。\n2026-09-17连续本地:D5-D9/CSV日期时区修复及9原因,17文件520通过、前端typecheck/build通过;589遗留/568指纹,独立PENDING,未提交发布。新日期D10-D12见implement。\n2026-09-17继续推进已落盘D1–D4:待办dueDate、员工目录/导出hireDate、节假日批量及仪表板/AP导出日历校验共4个源文件本地完成,11文件192项定向测试及10文件语法通过。原B1/B2的450项/typecheck/build为上一输入结果,不计本轮。AST仍240注册/327迁移/598遗留、577条基线entries、主链例外0,2026-10-05到期保持。官方current仍本task/in_progress;新候选独立复核PENDING,未提交/推送/部署,K1/K2及借款实施未执行。最新入口implement顶部,保留历史commit锚点。", "meta": {} } diff --git a/.trellis/tasks/09-06-release-gate-integrity/design.md b/.trellis/tasks/09-06-release-gate-integrity/design.md index 67799b8a..172626b7 100644 --- a/.trellis/tasks/09-06-release-gate-integrity/design.md +++ b/.trellis/tasks/09-06-release-gate-integrity/design.md @@ -1,5 +1,180 @@ +## 2026-09-19 r5-fixed-r1登录复用边界 + +四完整文件31项30通过/1失败,采购整链、R75及视觉25项全部通过;付款在经理复核登录后按已有redirect参数返回付款页,而共享login仍应验portal。最小修正仅允许显式reuseLoginPage且当前为无search/hash的规范/login时省略goto;带redirect的登录页必须走原goto('/login')清除返回目标。保留真实表单和portal/profile断言、全部付款下游及300秒;不修改认证产品或接受任意登录落点。原失败保留,类型检查后相同四完整文件复验,再从static完整候选。 + +## 2026-09-19 G20 r5三项续修(原预算/并发/断言) + +g20-full-r5历时5916.547秒;static/后端4428+1原条件跳过/8组启用服务回归均通过。浏览器372=367通过、采购保存失败1、视觉admin与付款链超时2、视觉手机后继未执行2,零重试。环境/source/repository/owner清理PASS。R75整文件与新旧BX/PB消费者通过,不据局部绿关闭N5/G20。 + +1. 采购PATCH保存P2028归09-18-local-concurrency-product-fixes P10,锁/校验/写入审计/5000ms不变,只移出非判定用途的响应关系读取。 +2. 视觉admin14/15完成,最后audit页耗尽180秒;管理员页累计导航56.768s、到达20.062s、quiet12.177s、截图95.145s、metrics2.838s(含超时附近结束的阶段日志,不当作独占CPU计量)。保留7组、33页、每组180秒、全部PNG/原断言/原workers/零重试;visibleRouteLink用Playwright同语义:visible+first一次查询;目标文档HTTP读取和已确定的真实链接点击并行并全部await;准确URL仍先验,之后独立到达断言Promise.allSettled并按原顺序抛首个失败,确保原5条正反例不弱化。不跳过字体/动画,不降画质,不用DOM伪导航。 +3. 付款链在299556ms才到执行:已完成提交、双审批、组批、独立复核、回单人工核验。原300秒保持;保留所有角色转换的真实表单登录/登出和全部业务断言,只消除财务审批结束后立即以同一财务身份登出再登录。共享login默认行为不改,允许本helper显式复用登出后已到达的/login页,仍逐项填表、提交、验证portal和身份入口。审批进度及最终只读DOM检查可并行全部await;3种审计只读查询并行仍逐项校验原字段。保留财务原地进度不reload、不得制单人复核/无回单执行、付款/核销/AP/归档与ERP拒绝全部断言。 + +先专用类型/原18条视觉守卫与原四完整文件procurement-flow、project-wide-visual、R75、payment-flow同配置定向,再冻结完整输入从static新候选。当前只是最小续修设计,未改预算、分母分类或验收范围,属于已批准在用修复/重复前置优化;不需要再次确认同一授权。 + +浏览器保护例外:旧GStreamer自动生成registry字节变化,旧baseline保留且不得改写为PASS;改写文件已复制留证,其余旧12137文件/HEAD/index不变。后续运行用本轮物理独占浏览器副本(非硬链接),不安装/下载。原字节恢复仅在找到校验匹配源时执行,不能猜造恢复。 + +## 2026-09-18 真实会话前置与冻结入口修正 + +直连正例必须等待目标路径和真实页面标题,排除错误页/登录后才导航下一文档。仅not.toHaveURL(/403/)会在认证bootstrap之前通过,并可能中断refresh;本轮观测旧有REFRESH_REUSE_DETECTED,补就绪后未再出现且付款整链通过。差旅沿已批准冻结合同改验拒绝,不能放行产品来满足原假阳性测试。 + +全页面视觉只审页面矩阵,匿名/login继续UI观测;已有createAuthenticatedBrowserStorageState走正式服务端登录,为每个独立角色+视口单独建立真实session,状态仅在内存传递给该context。目标页仍需浏览器真实bootstrap及全部原到达/HTTP/权限/视觉检查,不在两个context复制同一refresh凭证,不伪造令牌、mock认证、删页面或增预算。这样减少矩阵外重复portal登录装载,非修改认证行为。 + +## 2026-09-18 浏览器四未过最小修正 + +编号本地服务验收完成后回本task承接浏览器。DG7宽度矩阵是同一角色、同一不变合成数据的视口验收,复用一次登录但每个宽度仍从首页执行全部断言和截图;不同角色不共享。R3相同角色的拒绝、允许及退役404可在该角色独立context内顺序验证,保持五角色和所有路径,减少重复登录。目标文档HTTP探测需Accept: text/html,与浏览器文档请求一致;不能删除探测或放宽404。付款链只记录受控code/status及合成审计事件种类以确定AUTH_004原因,token、cookie、原始payload不进入输出;无证据不修改认证产品或补自动重登。 + +## G20 生命周期测试启动开销修正(2026-09-18) + +原36命令完整链交错对照:默认Node为2.295–2.520秒,single-threaded为2.998–3.572秒,trace完全相同;其他启动参数同样无收益,均不采用。每次冷启动约63ms,重复启动是已测量开销,但不声称完整运行的宿主调度波动已消失。 + +最小改为每例一个仅本地Unix socket假命令服务,运行在现有Vitest进程;轻量Python标准库shim传递原argv/按需stdin/退出码,复用原CJS解析器、Compose依赖分析和命令语义。临时目录及socket独占,无TCP监听,private PATH/白名单与未知命令97保留。timeout记录后由shim直接exec原GNU timeout,hung inspect等待随socket断开取消;不得在服务端阻塞后续deadline。每例结束先关闭服务和连接,再删除自己的临时目录。生产部署源码、测试workers/预算/重试和全部断言不变。原完整链、独立四类源码变异、真实deadline、unknown command与部署安全回归必须全过,再验一次完整门禁。 + +## G20 WebKit 原生在线环境修正(2026-09-18) + +实测官方 WebKit 26.0 在原无默认路由命名空间中 navigator.onLine=false;setOffline(false)也不改变原生值。只含本轮同命名空间veth对的 default dev qaonline scope link 则原生true,主动setOffline(true)后false。此为Linux WebKit网络发现差异,不修改navigator、不改业务离线保护,不增加公网/生产连接。 + +最小环境实现:仍只允许lo/qaonline/qapeer;新增恰好一条无gateway/via/multipath的链路默认路由,仅指向本轮qaonline。加强核验veth类型、两个ifindex独立且link或link_index互为对端且无跨namespace对端,拒绝其他接口/外部网关/IPv6默认路由。唯一链路两端都在独占namespace,无法通向宿主或公网。原“禁止全部默认路由”实现细节替换为“只允许已证明封闭内部对端的链路默认路由”;隔离边界和无外联要求保持。 + +原生空白页探针同时检查Chromium/WebKit及浏览器进程namespace;任一离线在昂贵业务验收前失败。增加拓扑反例与独占namespace合成不可达地址检查;不连接真实公网。原生在线证据和测试context中的主动离线仿真分别记录。旧结果绑定旧策略,不改写。 + +## G20 共享回执最小落地设计(本地机制,2026-09-18) + +复用原 stage_static / stage_full_local,新增 run-release-gate.sh verify-candidate 无部署分支,现有 ci-release/all/deploy/生产 bootstrap 不接新强制消费(R1启用另批)。共享模块 release-gate-result.mjs 负责当前源码/策略/工具链、独占记录、阶段开始结束与失败、结果引用及重新推导资格;run-release-gate.sh 的两阶段仍是唯一执行定义。 + +1. 运行目录必须为空且在 artifacts/release-gate 内,先持久 INCOMPLETE;stage记录固定 static/full-local 顺序、开始/结束/退出;full-local内部固定inputs/database/prisma/server-unit/typecheck/client-build/server-start/accounts/workflow-actions/server-regressions/browser-server/browser阶段通过同一记录器写 full-local-stages.json。失败退出/中断保留未完成,不将结束收集错误转为通过。 +2. sourceSnapshot 复用全仓已跟踪/index及产品/测试/规约摘要,补完整未跟踪输入与QA/部署/包锁/config工具链策略摘要;阶段前后、最终验证当前快照一致。dirty允许本地诊断结果,但deploymentEligible=false。来源不接受只比commit而忽略dirty内容。 +3. 成功判定依赖真实 static/full-local阶段结束及原退出,full-local环境result(mode full-local、当前policy、原生/guest namespace事实、cleanups、sourceBeforeAfter),G19完整发现重算与全部本地逐项执行、预先witness/acceptance归属。复用G19验证,不从候选回执自报expected。原live/frozen12+2按G19处理,公网仍NOT_RUN_LOCAL/交付INCOMPLETE。 +4. 记录只保留相对路径、hash、工具版本、筛选参数与逐项安全身份/状态;不复制业务原文或敏感env。读取仅本轮已验证独占文件;symlink/越界/截断/旧schema拒绝,引用绑定实际摘要。报告仍不是防伪签名,首次部署须独立重跑。 +5. 离线verify以同候选clean状态及完整阶段/环境/覆盖重新推导发布前资格;普通dirty本地运行的验证可证明localChecksPassed,但以PARTIAL/非零结束且不发布。不创建绕过开关,不改local-candidate的严格判定。INCOMPLETE/FAIL/PARTIAL/PASS明确区分。 +6. project-wide既有两个skip开关前置验证精确值、非空reason/operator/approvalRef;获批诊断记PARTIAL/退出2/未跑阶段,普通verify-candidate拒绝。批准引用结构不替代真实授权。只在fake工具验证project-wide,不运行其live readiness。 +7. 验证:缺/旧/错profile、阶段缺失/跳过/错误顺序、工具/输入变化、dirty、missing/unknown/duplicate/retry/xfail、原生/guest/清理漂移、伪通过均拒绝;源码变异移除验证/放宽阶段或skip必须使反例变红。执行既有部署生命周期/安全回归,真实部署命令计数0。 + +本地完整运行不据已有定向结果拼接;若为记录器引入源码变化,最终完整验证必须绑定最终代码。WebKit v2105是当前Debian11可用的官方冻结运行时,只证明该版本,不能宣称最新版Safari/真机企微兼容。 + +## G18 验证后归属 + +票据URL修复及当前OCR/R108/33路由矩阵已验;PWA原生控制器生命周期和采购最小写事务详见09-18-local-concurrency-product-fixes的PRD/design。原四文件并发4/4、完整认证/OCR18/18通过,不能推成旧full-r2全绿。12 live/2 frozen分层已批准并按G19实现;WebKit、R1、独立与真实业务/发布保持原边界。证据只归相应输入,诊断预载与无预载验证分列。 + +当前受管合成 runner 为既有底层回归设置 TRAVEL_SYNC_ENABLED=true;因此 G19 不把“所有入站同步关闭”冒称本地实测冻结。新增整例验证现行员工/财务权限拒绝及付款 blocker,不改变该合成配置。关闭开关的 409 属既有路由单测合同,与实际环境分列。 + +### G19 实现细节 + +保留 6 个旧 spec 原字节,新增 travel-freeze-protection.spec.ts 两项。受控 JSON 清单按 file/id/title/project/repeat 标识 14 个保留旧身份,并核源码 SHA;新同文件用例没有登记即失败。其他发现默认为本地必须执行。完整发现的文件集合仍来自源码扫描,执行集合从核验结果导出精确文件正则,不使用调用者 grep/清单;当前例外文件中不能混入本地用例而被整文件漏掉。普通 runner 执行后检查每项结果,失败退出同时保留结果。定向模式保持无资格。 + +本地覆盖计划记录 sourceSnapshot 的 commit/digest 与合同摘要;结束核输入不变。公网回执验证以原始 Playwright 逐项结果和 live profile/同候选绑定为条件,预先不得标 PASS;验收工具无外联能力,不接入部署入口。这里只解决分层覆盖,R1 的完整阶段、环境、干净候选与发布入口强制消费仍单列。 + +## 2026-09-18 G19 分层验收(用户已明确批准,实施中) + +用户批准:12 条公网用例作为同一候选发布后必跑项;2 条冻结差旅保留原源码与历史结果,改验冻结保护。本地通过不代表公网/完整交付通过,不授权部署、恢复差旅或真实操作。此增量替代 R2 的“排除集合为空”,不重写旧 full-r2 失败。 + +- [x] G19a:全体非 manual 用例继续真实发现,固定 12 live+2 frozen 的用例身份、原因、责任角色、必跑入口和原源码摘要;未指派实际负责人明确待定。 +- [x] G19b:由完整发现与受控合同生成本地执行文件集合,登记用例级覆盖;拒绝遗漏、身份漂移、未知/重复、跳过、重试与 expected-failure。禁止调用者筛选普通本地集合。 +- [x] G19c:新增两条冻结保护整例,验证现行合成角色入口/正式 API 拒绝、共享付款差旅 blocker 与入站导入权限拒绝;不改权限,不执行旧差旅链。 +- [x] G19d:公网义务在本地保持 NOT_RUN_LOCAL,绑定候选提交与源码摘要;公网用例结果只能由同候选、live profile、完整逐项通过报告兑现。验收回执不自授部署或发布资格。 +- [x] G19e:增加漏例/新增未知归属/漂移/伪通过/候选不匹配/真实 runner 选择等反例,运行本地冻结保护,更新 spec/协调 task 与新候选;独立仍 PENDING。 + +WebKit 下载问题仍待答复,完整普通门禁未通过;R1 机制后续与真实强制消费启用分开,不启用发布入口。所有旧差异、失败材料与冻结包保持。 + +## G18 OCR 实测暴露的最小产品修复(2026-09-18) + +g18-corrected-visual-r1:R108 整例通过(8 个移动观测、无发现),OCR 缺字段识别及横幅已通过,但人工修正按钮在180秒内反复被卸载;执行记录有56次 DOM detached,终态回到列表。源码两组 effect 分别 local state→URL、URL→local state,并同时依赖双方;上传选中票据时会交叉回写。此为在用票据详情状态同步缺陷,不是缺权限或OCR规则错误。 + +在已批准 G18 现行修正流程验收内,最小修复只涉及 InvoicePoolPage:从 URL 读取 detailInvoiceId,所有选择/返回动作只更新 URL 且保留其他查询参数,删除双向 effect 与重复返回写入。保留 replace 导航语义、全部查询/编辑/权限合同,不调整OCR、状态机或产品视觉。原 E2E 红例保留,修后必须不刷新/force-click 完成缺字段拒绝→补齐可用,增加URL选中与返回列表再进入验证;运行前端typecheck/build及受影响浏览器检查。相同模式只定位记录,不顺手改其他页面。 + +视觉首轮目标到达还需纠正:portal使用既有portal-work-summary锚点,动态报销使用各自workspace;其余已知页面标题不降级。4xx错误页模拟响应显式UTF-8,确保反例精确击中原到达断言;增加同源API403/404观测为失败发现,避免仅页头可见冒充有效页面。 + +## G18 最小落地合同(2026-09-18) + +沿 implement 顶部 G18a–e 执行。OCR 能力必须由启动同一服务的 runner 派生并用行为验证,外部标志不得伪造;R108 使用在用报销审批,初始化失败不转 skip;视觉矩阵明确列出真实系统路由,检查授权角色与目标到达,错误页不算页面覆盖。保留原并发、预算、零重试和完整发现。只更新测试/QA/现有矩阵,不修改业务权限或视觉样式;可靠性先取得可归属观测再修复。外部与独立复核边界不变。 + +## G17 落地与 G18 后续边界(2026-09-18 最新入口) + +G16 前置已实施并执行普通完整门禁;下方“以上未执行”属于当时计划。本轮 G17 按 implement 完成当前测试合同修复和限定实测:合同列表链接反例、当前删除/明确关联、mock API/SW 边界、通知非差旅夹具、采购保存/预检/提交、管理范围查询、重复付款整例归并。原 full-r2 失败和后续定向成功分别绑定来源;普通全量资格未取得。 + +主会话反例不是独立审阅。PWA/资产/采购/视觉定向通过不能证明完整并发失败的唯一根因;原事务、审计、预算、并发和重试合同保持。项目视觉矩阵中的 `/system/*` 当前为字面地址,报告只支持实际 20 路由项;进一步展开/授权到达检查归 G18,不能拿该结果宣称全部系统页覆盖。完整发现的 wrapper 末尾临时 SHA 采集失败按告警原样保留,枚举及覆盖校验有效,但不是完整门禁回执。 + +G18 的 OCR 能力绑定、R108 当前移动夹具/输出隔离、矩阵真路由与角色到达,以及并发时序诊断先以最小合同细化;不恢复冻结业务或制定新产品视觉方案。WebKit 下载申请仍待答复,原“不联网安装”边界继续;12 live、冻结差旅、R1 启用和所有外部事实/操作分别处理。精确退出条件及六组余项见 implement 顶部。 + +## G16 后续完整门禁准备(2026-09-18 已批准执行,进度见 implement) + +在本轮定向结果冻结后,先补WebKit运行环境;将已验证的单所有者合成改密前置接入普通full-local须保持其fail-fast、全部非manual发现、零重试、PWA输入和完整回执,不把retained-local改名作为完整门禁。四条当前排除观测须逐项登记旧流程退役或冻结处置及替代合同;冻结差旅不能当废弃代码擅删。原服务端场景8与经理缺口先查现行合同和可达性再确定最小修复,不默认放宽组织或审批规则。原AC7补同合成状态的管理身份正向对照与独立反例完整门禁。以上未执行,不据定向通过启用R1或取得发布资格。 + +G16 首次完整运行在旧直接付款成功预期处失败。最小补充为共享受管的 retained-server-local 固定套件选择、普通回归前 guest probe、forbid-only,以及直接付款按实际 batch-required 合同验拒绝与零写入;完整资格不接受该定向结果。具体失败和分支边界见 implement 顶部。 + +独立审阅与外部事实按本轮independent-and-external-readiness.json办理:当前QA和上批产品分别审阅;指定真实批次身份/企微结果仅在另批生产只读范围核;真实银行接受性另验;K1只有非秘密角色/渠道陈述时记REPORTED,K2须新的源/制品/字段/资源/注入清理绑定与独立批准。旧恢复不再运行。 + +real-cases-r6 两条付款主链与进度3/3通过,角色入口矩阵1失败:当前前后端hasFinanceRole均保留admin兼容入口。最后只将admin的AP/付款入口改为可见,补正式API批次复核403(显式经理/负责人角色守卫),继续保留admin非业务审批人和管理聚合审计入口验收。非本轮新权限决定。 + +## G13 第五轮与最终验收补件(2026-09-18) + +real-cases-r5 为1通过/4失败,手机交互整例通过。财务审批实际同页已显示“通过”,测试误定位申请详情标识;应定位reimbursement-approval-processing-progress并检查completed/处理人/通过/无处理中。管理员审计入口位于管理聚合页;管理员正向入口与经理/财务负向权限必须分别检查。旧“非审批环节”视觉标签已无,改验正式workflow恰有2步与3个独立付款阶段,不删业务区分。 + +共享付款流程对照原例补回付款后字段只读、回单不可上传/作废、可下载、正式paid状态和付款/上传审计元数据。两条主链受共享辅助文件影响均需重验。r4在浏览器前为纠正文案主动中止:内层dropdb因代理退出失败,外层owner核验后容器/临时目录均移除,环境按INVALID/中断保留,无业务结果。 + +## G13 第三轮实测纠正(2026-09-18) + +real-cases-r3 完整13条为8通过/5失败,零重试/跳过,环境清理和输入完整性PASS;PWA真实A/B同profile升级已通过。继续只补5个实际失败及共享辅助流程触达项。 + +正式小额报销由approval_rules的manager→finance动态投影,不采用seed日志中的通用workflow_definition一步CEO链。测试读取真实task/步骤ID,经理和财务依次UI审批,并检查财务通过后的同页进度,不改产品或seed。以下较早“一步审批”描述是已纠正的测试假设。 + +经理只有组织只读管理项,无主数据/银行账户;财务有组织、主数据、银行账户。两者管理写权限拒绝保留。手机发票添加成功后页面保留成功消息,须点“返回报销单”;不要求自动关闭。预览使用有效合成PDF,验证UI签名请求、弹窗和正式签名文件200/inline/PDF/原字节一致;headless原生PDF渲染不据about:blank判断通过或失败,也不宣称已验原生阅读器。 + +## G13 第二轮停点与定向补验(2026-09-18) + +real-cases-r2 被信号中断,未生成完整 Playwright JSON;环境清理和源码/仓库完整性 PASS,不从过程日志补造用例 PASS。下一轮仍覆盖其全部 13 条选择,不把日志中的局部通过抵扣完整结果。 + +- PC/手机 OCR 完成后仅默认勾选,测试必须选择唯一既有合成明细,再明确点击添加;继续检查票据关联和明细数量,不能为旧“自动添加”标题恢复旧产品行为。 +- 管理聚合入口按当前子权限展示;经理/财务验证组织、主数据等只读入口,并验证账号、审批规则、流程定义、审计不可见,保留后端拒绝检查。 +- 项目 Portal 使用应用 Asia/Shanghai 日历日;合成跟进日期对齐该口径,增加正式保存请求和首页汇总检查,不删除跟进卡验收。 +- 手机请假使用现行完整企微说明,继续断言日常 OA 表单不存在。 + +两条保留报销付款主链路复用同一测试辅助流程,集中维护现行详情、一步审批和回单核销;各自保留原测试身份,主链路额外验证票据预览。合成企业/员工银行信息由正式API写入当前独占库,固定公开虚构账号,仅限受管loopback;不得读取真实资料。回单使用有效合成PDF及正式人工更正→独立复核,不注入paid状态、不关闭核验。 + +# G13/G15 首轮真实认证重验后的测试对齐方案 + +在 real-cases-r1 完成并核 sourceSnapshot 之前不修改输入。仅改已证实漂移的在用测试/共享页面对象,不改产品、种子审批定义或权限。 + +1. 公告:h1+exact 避免与真实创建的公告标题歧义;保持创建、团队状态和角色首页断言。 +2. 权限直连:退役 ERP 前端应显示404,后端各角色均404,不能继续验证ERP管理成功;经理付款读取/复核入口按当前权限保留,AP列表/台账/导出拒绝仍检查。移动审批退回按钮按当前标识断言无权角色不可见。 +3. 报销页面对象:使用当前独占详情workspace、明细/票据证据与操作面板,不恢复旧overview/summary组件。上传后验证明确票据ID出现在证据区及关联明细;草稿明细由测试显式填写,不能依赖已替代的空单自动建明细。 +4. 主链路:沿合成seed现有总经理一步审批、财务制单、非制单人独立复核、回单验证与核销;不更改seed为旧经理/财务链。保留发票预览、关联、付款状态、回单/付款审计、AP与票据归档,移除ERP成功分支与ERP开关。 +5. 其余首轮失败按实际停点区分定位漂移、无效历史合同、环境不足和产品问题;没有证据不提前改预期。原58身份和失败事实不可回写;当前替代验收逐条记录,不能以删断言/skip减少分母。 + +只重跑受影响失败用例和必要依赖;已经通过且输入未变的结果复用。WebKit缺失仍单列未验证;普通full-local、R1与独立review不据此关闭。 + +PWA首轮停在serviceWorker.ready:进一步核实际构建常量,若受管NODE_ENV=test让Vite输出DEV=true,则仅在构建子进程固定production,并保留应用服务test模式。必须以双构建真实SW升级重新验证,不注入手动注册或放宽超时。 + # 部署门禁完整性设计草案 +### G12执行细化:一次性合成账号与限定受管重验 + +复用`full-local-environment`的namespace、无外部路由、独占PG及owner清理,给`run-web-gates.sh`增加明确的`retained-local`诊断模式。必须显式提供本地spec清单;报告标注focused、normalReleaseEligible=false,不改变普通full-local的无筛选分母。限定模式不读取共享真实环境配置、不联网安装,不运行旧恢复。 + +本轮空库seed为8个现有合成角色注入运行内随机初始密码,仍must_change_password=true;由单一预置进程通过正式登录→验证改密要求→change-password→旧密码拒绝→新密码登录完成初始化。新密码沿当前用例已有公开合成fixture值,必须不同于本轮初始值;不是把已修改账号回改种子初始密码,也不通过SQL清标记。运行后销毁独占库,不把会话凭据写入证据。所有业务测试启动前初始化一次,不在并发login helper里写密码。 + +首次浏览器登录/改密页面另用合成用例验证;管理入口/业务前置差异逐项登记。页面mock与真实认证分开出具回执,历史观测身份映射保持。两个旧截图输出改为运行独占目录,旧冻结截图不动。 + +## 2026-09-17 历史观测到当前合同的分类方法 + +### 下一批环境前置与验收方案 + +58条分类后,本轮重验15条原观测;4条旧日常OA请假/冻结差旅不修,39条保留项尚未整例重验。39条按依赖排程:25登录前置、10页面/fixture、1合同详情只读、1已修AP整例、1WebKit报销离线、1PWA升级。是观测处置数,不是独立缺陷或开发工作量。 + +1. 登录合成生命周期:先核受管一次性DB的seed→首次登录→强制改密→再登录合同,保留must_change_password=true与生产禁止seed的守卫。设计运行独占的已改密合成账号集,集中向测试提供凭据;不得并发套件修改同一账号、回改初始密码、直接SQL清除改密标记或给业务测试注入生产会话。只在零外联、独占合成DB/服务来源核验通过后运行,失败时保留/change-password或/login的原因,不放宽/portal断言掩盖前置。 +2. 页面/fixture:当前待重验观测为6、14–16、22–24、38–39、51。先核witness合同与当前页面层级、付款默认视图、审批详情动作和固定安全提示;混合旧视觉样本不得恢复待精简模块或旧布局。每个保留业务断言先对应现合同,再用合成输入恢复;截图必须另根,不覆盖旧归档。涉及新视觉判断时遵照现行设计技能边界,不擅自改产品样式。 +3. 合同只读阶段B:在保留当前权限前提下从列表进入详情,继续证明新增/编辑/续约/归档不可用,并以隔离反例证明断言能发现泄露;不因旧“更多”定位移除后两项断言。原主合同阶段B完整反例门禁仍未完成。 +4. AP整例:已有09-07发布与路由/导出73项证据仅支持特定修复。登录前置就绪后原权限直连整例重验,经理仅付款复核职责仍拒绝AP全量读取,不把200作为新预期。 +5. WebKit与PWA:本地Chromium可用、WebKit执行文件缺失;不联网安装或用Chromium模拟通过代替WebKit结果。PWA保留真实生产构建与旧新版本/同profile/SW升级验收,当前只读静态mock服务器明确block SW,不能用其PASS抵扣PWA。先补环境合同,独立记录升级与清理结果。 + +本节为已批准后续工作的细化计划,尚未实施上述账号生命周期/未运行项;R1、完整发布资格和独立结论不随本地子集通过解禁。真实环境/凭据、生产/ARM、恢复、提交部署及借款保持原边界。 + +只读接回主checkout的managed-diagnostic-20260907-04/results/browser-failure-inventory.json及同次本地合成Playwright报告,核54失败/4超时、58条身份与原SHA。仅输出相对源码位置、失败层次、当前合同依据、在用性、最小下一步;不复制原始DOM/payload或完整机器路径,不读生产日志。逐例检查当前源码,前置失败不能证明后续业务断言有错;源码已有修复不能把旧结果直接改PASS。 + +R12当前断言要求未知VALID_001回显原message,与09-05安全错误合同冲突。保留共享resolver与禁止普通页面直接拼内部码/原异常的检查,将该例改为未知原因的固定安全兜底、合法requestId、已注册导入原因提示及未知原因不回显。无新增UI或权限行为;直接运行Playwright中的纯源码/函数用例,不启动浏览器、服务或DB。先保留当前失败,再验修后结果,输出目录独立、retries=0,不覆盖旧报告。 + +其他分组须先落实施边界,不能因有旧测试就恢复企微主办/待精简的OA业务,不能为变绿删掉权限或付款断言;涉及真实DB流程或环境前提的项先区分必要环境与业务证据,R1和公网发布资格保持未完成。 + ## 11. 归档截图保护与全仓回执增量(已批准) - 先以独立副本和 SHA 保全四张本次运行截图,再仅恢复这四个 worktree 文件;保留全部 14 个已跟踪历史证据及其原登记摘要,不改 index。 @@ -265,3 +440,27 @@ Correct:当前候选策略定义环境 → 受管 runner 创建并实测 → 按 implement.md 的追加阶段,用 status_matches 集中四处状态判断;grep 直接读取 here-string,不使用 printf 生产者或提前退出模式。退出 0/1 分别代表匹配/不匹配,其余退出码立即停止。保留日志、SQL 查询、候选 allowlist、迁移和回滚判定顺序。 回归直接提取现有源码状态解析与未知迁移拒绝分支;注入真实观察到的 producer=141,并恢复旧谓词做负向变异。长输出与四处 matcher 工具错误单列;原多子进程远端 fixture 使用有界 15 秒测试预算,非生产超时修改。产品差异单独保存在 preflight-fix 工作树;与 seed 的组合验证只用一次性本地测试副本,不能称为已集成或新发布候选。 + +## 2026-09-18 G20 第四轮终态与续修 + +g20-full-r4 FAIL,5768.636秒;static239.024秒PASS,内部11阶段PASS;后端4367通过/1原条件跳过,8组启用服务回归通过/7组原关闭不计通过;浏览器351项=342通过/5失败/3超时/1串行后继未执行,零重试。环境、source/repository未变和owner清理PASS。资产及账号创建两处P2028由09-18-local-concurrency-product-fixes接最小修复;当前不是全量通过。 + +QA续修保持原分母、预算、并发和断言:DG7确认停在登录输入框,改为自动等待既定账号入口;R92导航后回登录,user-onboarding提交改密后失去会话,核路由mock与SW/refresh会话边界;PA6确认第7次角色登录耗尽180秒,同角色PC/移动复用一次独立会话,保留全部角色/入口/管理菜单/API403;全页面180秒只到9/33页,利用真实应用导航减少重复装载,保留25PC+8移动/14系统页、目标文档HTTP、实际URL/标题及全部视觉/错误断言。报销付款整链300秒超时但无精确动作栈,先增加安全阶段诊断和查实际停点,不先猜产品根因或放宽预算。R75后继跳过随原整文件重验,不能当通过。 + +顺序:产品边界红/绿与相关回归→各在用QA夹具/冗余前置修复→原完整文件定向验证→冻结输入、从static开始新完整候选。定向通过不拼接旧完整结果,主会话检查不抵扣独立PENDING。原失败、322项旧差异及29目录9762旧文件保护不变。 +## 2026-09-18 两项阻塞接续:阶段诊断 + +诊断原33路由180秒用例已走完PC,进入移动报销列表时耗尽预算;阶段合计导航34.355秒、到达29.535秒、安静窗口26.177秒、固定额外等待8.327秒、截图45.074秒。原expect.poll默认间隔使已满足500ms安静窗口的普通页约858—894ms后才继续,其后还固定再等300ms。最小QA调整:安静条件仍是全部本次HTTP请求settled且连续500ms,poll间隔改50/100ms精确采样;删除该条件之后重复的300ms固定等待,截图仍用原Playwright字体/布局就绪与完整截图,不改动画、画面、HTTP/权限/视觉断言或33页/180秒/并发。保留阶段日志检查实际耗时,正式运行无诊断预载。该优化本身不宣称全部负载都能在预算内完成。 + +用户同意按报销提交→全页面→新完整验收推进。本轮在原全页面auditPage增加安全阶段耗时:导航(含目标文档)、到达、请求安静窗口、原等待、截图、DOM指标,另在navigateForAudit细分链接查找/管理页转场/文档请求/点击。仅记录已脱敏路由、阶段和耗时,不改变任何页面、断言、预算或并发。与P9的原R75整文件在同一受管合成环境诊断一次;产品源码保持原输入。诊断预载仅归本轮ignored artifacts,独占owner/namespace/无外联核验后才启用,之后正式验证不带预载。 +## 已批准:全页面巡检按角色与终端分组(2026-09-18 实施) + +p9-fixed-r3的三个完整文件11项10通过/1视觉超时,607.746秒;环境/输入/owner清理PASS、零重试、服务端1347响应/0未处理异常。R75四项和付款主链281.132秒均通过;视觉180秒仅完成15页,其导航38.002秒、到达34.353秒、安静窗口16.069秒、截图45.634秒。前轮较少并行工作下走完PC进入移动仍超时,说明已移除的重复等待不足以解决这个把33页面串在单一用例中的累计预算问题;不能把它归为某个产品链接错误。 + +建议将现有矩阵按既有角色与终端拆为7组(PC匿名/员工/财务/经理/管理员,移动员工/经理),每组仍180秒及原expect预算/零重试,浏览器配置workers不变。共用本文件的合成动态路由准备并单列耗时;每组独立真实会话、不共享refresh令牌,保持原逐页HTTP/准确到达/无403或404/请求安静500ms/截图/DOM视觉与文案检查。没有删页面、降低图像质量、禁用动画或修改产品样式。 + +全套报告必须校验计划25PC+8移动与实际33条唯一路由/终端/角色完全对应,重复、缺失、未到达、失败或跳过任一组都不能放行;保留现有5条到达正反例。普通门禁重新发现这组用例身份,仍将所有分组作为同一固定候选、同一完整运行的必跑项,不合并历史定向结果。另加本地覆盖漏项/重复/失败反例,防止拆组后局部绿。 + +用户已明确批准本节分层预算:全部33页按7组、每组180秒,同一完整运行全部必过。该批准覆盖旧累计180秒约束,不改变其他测试预算、workers、零重试或页面断言。 + +实现约束:7个原生测试放在同一serial组,仅本文件保持原逐页顺序,不改变全局并发;beforeAll一次准备动态路由并独立记录耗时。每组页数分别1/7/1/1/15/7/1,启动时对照矩阵校验;每页完成截图与全部DOM检查后才登记完成。afterEach记录真实测试终态,afterAll校验计划/完成精确集合及全部组passed;失败的后继组保留pending,不由后续到达反例写回PASS。新增纯覆盖正反例随原5条到达测试一起运行。整体门禁重新发现真实身份/分母,不合并历史轮次。 diff --git a/.trellis/tasks/09-06-release-gate-integrity/implement.md b/.trellis/tasks/09-06-release-gate-integrity/implement.md index fbf619aa..88e07eb0 100644 --- a/.trellis/tasks/09-06-release-gate-integrity/implement.md +++ b/.trellis/tasks/09-06-release-gate-integrity/implement.md @@ -1,5 +1,602 @@ +## 2026-09-19 用户已授权主会话执行两笔本地提交(最新) + +用户明确要求“提交就你来提交吧,不用发给claude了”。主会话接管最终分组及本地提交,之前Claude打包请求不再等待执行;授权限本地提交,不含推送、R1启用、部署、生产编号或真实业务动作。 + +提交前核对当前392个路径与只读盘点清单一致、暂存区空;建立本地分支codex/20260919-reviewed-candidate保存原detached工作树候选。按两组提交:纯Trellis升级/知识精简及其任务记录;其余关联业务、测试/QA、业务规约及任务记录。两份有效JSONL与Python测试夹具纳入,22项原删除保留;artifacts及个人日志不纳入。完整路径/摘要、实际提交SHA和收尾状态记入artifacts/qa-governance/local-commits-20260919,不覆盖旧回执。 + +首笔治理提交已完成:729d9057(25文件);其余367个文件由本次集成提交保存。提交完整SHA和最终干净状态以本轮result.json为准。 + +本轮不重跑测试,只核对提交范围、内容摘要及差异格式。928项独立验证与历史4446/28等结果保持各自输入归属;提交不会把原FAIL/130变成PASS。B1完整新候选门禁、B2 R1接入及B3部署后公网义务仍开放,提交完成不代表发布资格或整个发布任务完成。 + +## 2026-09-19 发布准备盘点已接回:提交分组决策(已确定) + +已核Claude的release-preparation-claude-20260919T050016Z报告与清单。392条为该盘点时点;B1当前候选完整门禁未通过、B2真实发布R1未接入、B3部署后12项公网未验,分别保留对应边界;预算补丁不再是阻塞。当前未授权提交/推送、R1启用或部署。 + +主会话决定:两份JSONL都有真实有效引用(implement2条/check1条),随所属任务纳入;deployment-command-client.py为现有生命周期测试直接依赖的Python3标准库夹具,跟测试纳入,不作为生产/BIFF8依赖;提交计划从10笔改为2笔(纯Trellis治理及记录;其余关联业务/测试/QA/规约及记录),混合文件整体归后者,不拆共享文件hunk。原删除分项应为7源码+14ERP测试+1旧e2e=22,原证据不改写。 + +Claude下一步只生成最终两组精确路径/SHA/中文说明与唯一覆盖核对,不执行提交或测试。交接:artifacts/qa-governance/release-commit-packaging-claude-request-20260919.md。待范围明确且相应授权到位后安排执行;R1源码变更先于最终候选完整验收,避免先全量再改候选。用户要求机械执行/测试交Claude,主会话负责取舍与接收。 + +## 2026-09-19 下一步分工:Claude整理发布准备包(交接记录) + +用户要求机械执行交Claude,主会话负责范围/风险/验收决策。预算本地修复及独立复核已完成;先让Claude限定只读整理拟提交文件与任务归属、当前候选证据关联、R1接入及12公网/编号启用的既有前置项,不重新审已通过的预算补丁,不运行测试。 + +交接:artifacts/qa-governance/release-preparation-claude-request-20260919.md。此时为HANDOFF_READY,尚未收到执行结果。产出具体提交/发布准备清单后再处理决策和已有授权边界;不提前跑另一轮全量,不视本计划为提交/推送/部署/生产编号启用授权。只新增ignored报告与机器文件清单,Claude不改源码、任务或原证据。 + +## 2026-09-19 预算补丁独立复核与定向测试完成(已完成阶段) + +Claude已执行两份限定测试:2文件928项通过、0跳过、exit0;主会话核对报告、原始日志及补丁before/after各10份SHA一致。差旅选择口径与原因码夹具两项必修均关闭,预算补丁状态LOCAL_VERIFIED_INDEPENDENT_REVIEWED,无需重复相同测试或补审。 + +预算任务B7完成,B6发布完整门禁延期不补勾;累计其他业务未读范围与外部义务不因此变成整体PASS。旧预算候选原始FAIL/exit130、旧4446/28及中止浏览器日志保持原输入归属;deploymentEligible=false,无提交推送部署。证据:artifacts/qa-governance/budget-pool-concurrency-20260919/review-fixes-20260919/claude-result-reception.json。本次仅回填任务记录。 + +## 2026-09-19 预算独立补审两项修正已落地,测试交 Claude(交接时点) + +budget-pool-claude-review-20260919T043257Z.txt 已接回:锁/事务/完整性等分项PASS,两项必修。主会话已恢复差旅submit原预算上下文(移除新增departmentId),加强精确上下文及部门/全局并存测试;新增budget.pool_changed固定夹具,并补全部用户原因码的注册→夹具完整性断言。不是预算政策调整,不碰历史数据。 + +按用户明确分工,修复后仅两份定向测试交Claude执行;主会话不继续测试、不重跑全量/浏览器。修正前新增断言红例4失败/923通过,修正后尚未运行,独立通过待确认。证据与精确命令:artifacts/qa-governance/budget-pool-concurrency-20260919/review-fixes-20260919/claude-test-request.md。原4446及原30文件审查对应修正前输入,原包/回执不改写;当前整树无发布资格。 + +## 2026-09-19 预算修复本地验证完成;重复全量浏览器已中止(首批本地验证记录) + +用户明确指出本轮后端预算修复后重复全量浏览器过于浪费,主会话据此停止 budget-full-r1 剩余浏览器并清理受管环境。已完成的后端225文件4446项通过/1原条件跳过、8个启用服务回归组、审批动作、Prisma及前端typecheck/build保留;针对性真实PG28项与单测526项结果保留,不再重复。实际接口/事务覆盖已足够支持本补丁本地结论,没有追加浏览器运行。 + +浏览器29通过/4中断/339未运行,零重试;原始整轮FAIL/exit130,localChecksPassed=false,不能称完整本地通过。环境PASS、source/repository不变、owner容器/scratch清理完成。发布前完整门禁、33页/7组及12公网义务仍保留,待准备发布时集中执行,不以旧r6抵扣。 + +修复状态LOCAL_VERIFIED_REVIEW_PENDING,实际补丁Claude只读复核待回。新增证据及交审请求:artifacts/qa-governance/budget-pool-concurrency-20260919。后续任务记录/证据变化不触发业务全量重跑。无提交推送部署或真实外部动作。 + +## 2026-09-19 预算并发补丁定向验证完成,待新完整候选(运行前记录) + +用户明确批准 09-19-budget-pool-concurrency 方案。已统一报销/采购/冻结差旅的池锁与锁后预算判断,处理授权→预算锁序及 Decimal 额度边界;真实隔离 PG 28/28、受影响单测 33 文件 526 项通过。证据:artifacts/qa-governance/budget-pool-concurrency-20260919。独立发现仍保留原 FAIL,修复状态为 TARGETED_LOCAL_VERIFIED_FULL_GATE_PENDING,实际补丁的独立审查未通过前不关闭 finding。 + +现冻结新候选,从 static 完整重验;旧 g20-full-r6 的 4437/372 仅代表历史输入,不能抵扣当前代码。保留全部分层验收、33页/7组、并发、预算和零重试。浏览器用已有缓存的独占物理副本,无下载。旧 registry 保护例外与 CSV NOT_RUN 不改写;无提交推送部署或公网/生产/真实业务动作。 + +## 2026-09-19 业务定向补审已接回:预算并发缺陷待修(接收时点,历史) + +已读取 `artifacts/qa-governance/business-followup-claude-review-20260919T023537Z.txt` 全文(9566字节)。b1rubx7qf 已完成,不再记为“审阅仍在跑”;本报告是只读源码/既有测试文件核对,没有新运行时测试。 + +- P9 feeControl 合并读取:PASS;报销已审写路径锁序及 submit 事务内重验:PASS;不据此宣称 workflow snapshot 内部及所有撤回/付款/关联分支均已逐行覆盖。 +- 预算池并发:FAIL,主会话源码核对确认。报销提交、purchaseBudget 占用/释放及 travelPayments 增减共四处先无锁读、后写绝对 used_amount;这些计数片段与 HEAD 旧实现相同,不归因为 P9 新增。独立任务 `.trellis/tasks/09-19-budget-pool-concurrency/` 已落 PRD/design/implement,仅 planning,实施待批准;问题尚未修复。采购权威预检当前在事务外,是同一修复必须覆盖的陈旧判断路径。 +- K0 加解密合成合同:独立静态 PASS。主会话另核冻结 manifest、6件文件以及当前3个代码/测试输入一致;不归为 reviewer 重算或真实恢复验证。脱敏不在合同内、通知密钥迁移脚本不在 K0/K1/K2 默认执行范围,澄清见 08-23 最新三份任务文档。 +- 累计业务独立复核为 PARTIAL_WITH_BLOCKING_FINDING;不是整体 PASS。未读的 workflow snapshot、部分路由业务分支及其余 crypto 消费者仍按实际风险列项,不要求无目的重审整个仓库。 + +本轮只完成报告接收、原有任务回填、K0 范围澄清和新修复规划;不改产品/测试,不重跑旧4437/372或恢复,不改旧失败/冻结包。旧 r6 的历史通过记录保留,但当前已知预算缺陷单列为发布前待修;deploymentEligible=false,12项公网、生产编号、真实银行接受性和K1/K2仍保持原边界。新增证据:artifacts/qa-governance/business-followup-reception-20260919。 + +## 2026-09-19 Claude 累计业务审阅接收(部分覆盖,历史) + +用户提供 Claude 独立只读报告,称借款核心资金路径、编号关键路径、付款责任/通知事实投影和 ERP 删除引用检查无阻断发现;PWA/采购仅部分覆盖。借款页面为抽读,费用控制与报销并发段未逐段审完,K0 合同未补读。因此累计业务独立复核记 PARTIAL,不把“有条件通过”当作完整 PASS;原 4437/372 验收不重复运行。 + +主会话核对意见后的处置: + +1. 银行客户序号形态改变属生产编号启用前的外部接受性待验项,保留明确确认义务;本地长度/字符集通过不证明银行接受,不因此改旧编号或材料。 +2. eligibleLoanReviewers 返回单人用于新申请通知接收人,不是唯一审批人绑定。paymentResponsibility 读取时按当前资格列全部可复核人;review 路由重验当前操作者,不要求其在旧通知名单中。故“离职后被指派而无法改由其他人复核”的缺陷未成立;人员变化后的主动补提醒未验证,不擅自增加全员通知或新转派机制。 +3. PURCHASE_REQUEST_SUBMITTED 的事务外 audit 与当前 HEAD 原代码逐字一致;有 catch + fastify.log.error,不能称静默丢失。审计失败不回滚业务是既有风险,本轮不扩大为事务语义变更或旧代码修复。 +4. 1076 个业务输入与 r6 相同表示该快照之后代码未漂移,不代表它们相对 HEAD 干净;整树 dirty 包括已存在于 r6 的未提交业务改动,不能归因于仅任务/文档/artifacts。 + +下一轮 Claude 定向补审:feeControl.js 预算/标准口径;reimbursements.js 并发、锁序、事务内判定/审计;08-23 K0 合同与本地检查覆盖。先补这三项明确缺口,再按实际覆盖确认是否还有未读消费者/页面/迁移。主会话核对不抵扣独立复核;发布 R1、12 项公网、编号启用和真实业务证据继续保持原授权边界。 + +Trellis 0.6.17 的独立 PASS 单列于 09-05 任务,不移用为业务 PASS。反馈核对证据见 artifacts/qa-governance/trellis-0617-review-followup-20260919;未改业务代码或原始交审包。 + +## 2026-09-19 独立核实反馈已确认(限数量与证据归属) + +用户后续独立核实并确认:r6后端4437通过/1原条件跳过,旧4428来自P10之前;UTC/北京时间换算、阶段先后和累计diff基线争议已闭合。用户另报告当前purchase-request.errors.test.js复跑75 passed、EXIT=0、SHA为2b9ec41d…;此项按用户提供的复核结果登记,不冒称主会话新跑或已取得该次原始日志。 + +用户同时确认33页/7组、372/372浏览器零跳过/零重试,以及386=372本地+12公网+2冻结与证据一致。此次独立核实范围为测试数量、时序和证据归属;当前累计业务实现的完整独立只读review仍PENDING。 + +发布边界保持:dirty、运行后task记录变动,deploymentEligible=false、currentTreeCoveredByOldReceipt=false、productionNumberingActivated=false。旧WebKit派生registry baseline FAIL与历史CSV校验工具NOT_RUN两条例外保留,无新增漂移。原始回执及增量包不改写,详细SHA核对仍见review-provenance-20260919.json。本次仅更新任务记录。 + +## 2026-09-19 外部审查反馈:运行归属与差分基线核对 + +用户提供的审查正确指出历史backend-full-result.json记4428、r6记4437及当前整树无发布资格;但“同一候选、r6后又改产品测试”的推断不成立。以下统一北京时间(UTC+08:00),以原始阶段时间和内容SHA为依据,不以dirty/untracked或diff生成mtime推断改动时点。 + +1. 旧独立全量日志Start at为23:52:35,结束文件mtime为09-19 00:01:32,537.01秒,4428通过/1原跳过,采购错误测试66项。旧644输入摘要与r6相比恰好两处变化:purchaseRequests.js和purchase-request.errors.test.js,属于之后的P10修复,不能当作同一候选。 +2. 采购测试最后修改为09-19 01:55:08;r6回执startedAt为09-18T18:25:05.724Z,即09-19 02:25:05。其server-unit阶段为09-19 02:32:17—02:40:55,晚于该修改,4437通过/1原跳过、采购错误测试75项。9项差值是P10新增边界用例。 +3. 争议测试的旧SHA为c681dc09…;r6环境回执sourceBefore、sourceAfter、r6收口输入清单和当前均为2b9ec41d…(完整SHA见机器回执)。环境证据SHA与原release-result引用一致。当前1162条产品/测试/QA/spec快照(含22条原删除项)与r6前后摘要相同,新增/缺失/改变均0;1076个收口代码输入也无变化。untracked已被正式快照的--others覆盖。 +4. final-incremental-review.diff在03:58生成,但其基线由build-final-review.py明确为本轮开始prior-files快照,未在其中的文件使用HEAD/新文件基线;不是r6结束后的差异。25文件中10个产品/测试文件及2个spec的最终SHA全部已包含于r6开始前。当前已跟踪的运行后差异仅5个task记录,原“task-only closeout records”与产品摘要不矛盾;新增task记录与完整工作树发布资格仍须分列。 + +对外准确口径:**r6冻结代码后端4437项通过、浏览器372/372;当前相关代码输入与该运行一致。当前整树含后续task记录且仍dirty,不具有发布资格。** 不用旧4428覆盖较新的4437,也不将dirty等同于受测代码漂移。此前摘要应更明确标注r6身份、时区与差分基线;本次补充说明,不重跑未变化的全套、不改产品/测试或任何原始证据。 + +独占核对回执:artifacts/qa-layout/submit-visual-closeout-20260918/review-provenance-20260919.json,保存UTC/北京时间、输入对照、12项非task增量与r6摘要、原证据SHA。原final-review-candidate、final-incremental-review.diff和两轮后端结果全部保留。已收到用户转述的局部独立审查,完整独立review仍PENDING;本次只更新本task核对记录,无提交推送部署或生产读取。 + +## 2026-09-19 本地完整验收通过(当前入口) + +g20-full-r6从static开始完成同一冻结候选的普通完整运行:总5128.987秒,static226.728秒PASS、full-local4895.264秒PASS,内部12阶段全部PASS。后端224文件4437通过/1原条件跳过;8启用服务组通过,7原关闭组不计通过。发现386=372本地+12公网+2冻结,372浏览器全部通过,零失败/跳过/重试。环境/source/repository/owner容器与scratch清理PASS。 + +33页与33PNG完整,7组全过、覆盖问题/findings为空;管理员组177.291秒,接近原180秒上限,只证明本轮实测。采购主链125.161秒;付款helper业务步骤264.491秒,原始Playwright整例记录373.383秒(含前后开销;未独立拆分该差额),保留原300秒用例设置与全部断言,不将两种计时混写。R75四项、新旧BX/PB消费者、PWA更新、WebKit离线与冻结保护都纳入本轮372项。 + +回执PARTIAL/exit2,localChecksPassed=true、deploymentEligible=false,唯一资格问题dirty_candidate;不是公网或完整发布通过。同环境离线verify仍PARTIAL且372项/10个witness通过;首次误用CI=1的检查wrapper被正确拒绝(候选CI未设置、4 workers),原拒绝保留,未修改验证器或候选来掩盖。 + +旧材料仍有已披露的一处WebKit派生registry字节变化,原保护FAIL保留,原baseline未改;r6没有新增漂移,其余12137旧文件、HEAD/index和352旧差异快照保持。后续WebKit用本轮物理独占副本。原字节尚未找到,不伪造恢复或将保护写为全PASS。 + +本次完整回执绑定运行时冻结输入;结束后只补task的状态、验收与后续规划记录,不改已验产品/测试代码。后续文档摘要与运行时不同,旧回执不冒充更新后整树的发布证明。独立只读review受inline限制保持PENDING,无提交推送部署/归档、生产编号启用或真实操作。证据根:artifacts/qa-layout/submit-visual-closeout-20260918,入口g20-full-r6.summary.json、g20-full-r6-highlights.json及r6-offline-verification-matching-env.json。 + +G20a/b/c/d本地实现和验证完成;G20e的本地记录/交审包完成,独立复核、R1真实强制消费及外部授权仍开放。最终增量入口final-review-candidate.json、final-incremental-review.diff、final-checks.json。 + +### 已完成与剩余 + +- [x] 本地在用修复、7组视觉实现、定向31/31及新完整候选372/372;编号N5的本地兼容验收完成。 +- [x] P6/P7/P9/P10产品边界与各原整链、P8完整负载验证完成;保护异常和独立复核单列,不将二者勾选为通过。 +- [x] 本轮状态/增量只读交审包与治理收口;现行process/docs/Trellis/sensitive及task/diff检查通过,22个原未登记文档提示保留。历史CSV校验脚本缺失,替代路径检索无结果,本轮未改CSV,该项记NOT_RUN而非PASS;不扩大为旧工具修复、提交或归档。 +- [ ] 独立只读复核:覆盖当前累计候选及本轮增量,主会话检查不得抵扣。 +- [ ] R1真实发布入口强制消费、干净候选提交/完整重验及部署:按现有design另获明确授权;部署后同候选12项公网义务全部完成才可宣布公网可测。 +- [ ] 编号生产只读摘要、启用日期及启用:另获批准,旧号/旧草稿/银行引用保持;线上策略仍未开启。 +- [ ] 真实银行接受性、指定范围外的当前处理人/企微个人送达事实、K1保管/K2新绑定及自然业务证据:按各原task分别授权;旧恢复不重跑。 +- [ ] 旧WebKit派生索引保护异常:只可用SHA匹配的原字节恢复,或保留明确例外;不扩读真实备份、不重签原冻结包。 + +下方均为历史阶段记录,未勾选的旧阶段条目不覆盖本段实测状态;任务整体保持in_progress。 + +## 2026-09-19 四文件定向通过,接完整候选r6(当前入口) + +r5-fixed-r2四完整文件31/31通过,浏览器约5.5分钟、受管总571.747秒,零跳过/重试;采购整链、R75四项、付款提交至归档全链及视觉25项通过。付款约244.773秒,保持300秒;视觉7组各180秒、25PC+8手机/33PNG全部完成、覆盖问题与findings为空。环境/source/repository/owner清理PASS。原r5完整FAIL与r5-fixed-r1登录跳转失败均保留,不拼接为完整通过。 + +P10采购提交后读取边界167/167及QA专用类型已通过;登录仅在无query/hash的规范/login复用,保留原表单/portal/profile断言。下一g20-full-r6从static开始普通完整候选,全部源码/测试/task/spec/docs冻结,只写本轮ignored证据;N5/G20及产品完整负载验收仍待终态,独立PENDING、生产编号未启用。 + +旧WebKit派生registry保护异常保留,原baseline不改;后续运行使用本轮独立WebKit副本,不下载或安装。禁止将一处已知漂移描述为全部旧材料零变化;无提交推送部署或真实操作。 + +## 2026-09-19 r5-fixed-r1结果及最小边界修正 + +四完整文件31项30通过/1失败,585.544秒;采购主链、R75四项、视觉25项通过,零重试/跳过,环境/输入/owner清理PASS。付款新复用逻辑保留了/login?redirect的返回目标,与原portal断言冲突;按design仅复用无query/hash的规范登录页,带返回目标仍用原goto清除。边界修正后的专用类型检查及diff检查通过;下一r5-fixed-r2相同四文件复验,全部受管输入冻结;未开始新的完整候选,不拼接定向结果。 + +## 2026-09-19 当前入口:r5终态与三项续修 + +完整候选FAIL:5916.547秒,static247.910秒PASS,内部11前置阶段通过;后端4428通过/1原跳过,8启用服务回归通过。发现386=372本地+12公网+2冻结;浏览器367通过/1失败/2超时/2串行未执行,零重试。环境/source/repository/owner清理PASS。 + +- [x] 09-18产品P10采购PATCH响应读取最小修复与边界回归;五文件167/167通过,原实现4条红例保留。 +- [x] 视觉独立检查/RPC冗余、付款同角色重复登录与独立只读检查优化已实施;专用tsc通过,原7组及所有预算/断言/worker保持,浏览器尚待验证。 +- [ ] r5-fixed-r1执行采购、视觉、R75和付款四个原完整文件定向;通过后从static完整新候选,不拼接r5通过结果。运行期间全部受管输入冻结。 +- [ ] 治理、保护异常、最新候选和独立PENDING交接;生产未启用。 + +R75四项、新旧BX/PB和合同只读实际页面正反例已在本轮完整负载通过。全页面仍INCOMPLETE:24/33、管理员14/15,手机后继未执行;付款执行及下游未在300秒内完成。保护检查只有旧WebKit派生registry自动改写,原FAIL与字节副本留存,原baseline不改;业务冻结材料与HEAD/index保持。详情及不变量见design顶部。 + +## 2026-09-19 当前执行入口:7组定向通过,进入完整候选 + +用户明确批准:33页拆为7组、各180秒,保留原断言/截图/并发/零重试,同一完整运行全部必过,增加缺页/重复/失败组拒绝。后续按本段执行,下面旧180秒累计约束为历史记录。 + +- [x] 实施7组原生测试、一次合成准备、完整审完记录及精确覆盖校验,保留5条到达正反例。 +- [x] 专用类型检查、覆盖13/13及视觉整文件25/25通过,33页/33截图精确齐全。 +- [ ] 冻结输入,从static开始执行普通完整候选;真实发现分母,不拼接定向结果。 +- [ ] 协调P9/N5/G20、治理/旧材料保护与候选交接;独立PENDING,生产未启用。 + +本轮完整后端回归已通过:224文件4428项通过、1原条件跳过,537.01秒;server/src、server/tests、server/prisma及scripts/qa共644输入结束后校验零变化;P9原R75及报销付款整链已在p9-fixed-r3通过,原视觉超时仍保留。 + +grouped-visual-r1完整文件25/25通过(7真实组+13覆盖正反例+5原到达用例),浏览器91.788秒、受管总352.799秒;25PC+8移动全部审完,33PNG、findings为空、覆盖问题为空,环境/输入/owner清理PASS,零跳过/重试。旧34目录12138文件零增删改,HEAD/index未变。接下来g20-full-r5从static执行普通完整候选,源码/测试/task/spec/docs全冻结,只写本轮ignored证据;本地结果不抵扣独立或公网义务。 + +## 2026-09-18 编号消费者最终回执与G20余额 + +numbering-consumer-final已结束:报销新旧号PC/手机列表、搜索、详情1/1通过(17.756秒),受管总耗时268.462秒;source/repository/candidate不变、owner容器及scratch清理PASS,零重试。付款新旧号消费者最终版本保留browser-r3通过(18.517秒)。付款列表/详情自然排序完整单测129/129通过(15.66秒);五个相关浏览器文件专用类型检查通过。最终六个测试源码摘要在final-code-inputs.json,后续文档收口不变更测试输入。 + +本轮已解决DG7/R3重复登录前置、直连正例未等认证页面就绪、视觉文档缺Accept:text/html、旧员工差旅允许预期及新增编号测试定位/枚举问题。DG7七宽度、五角色权限整文件、编号消费者和真实合成付款链分别取得通过证据;这些结果属于各自输入和选择集合,不能相加成普通完整通过。此前AUTH_004及全部失败记录保留,browser-r2五角色观测无重放事件不等于排除全部认证风险。 + +剩余本地工作按以下顺序,方案已有部分沿用,新增部分先补design再改代码: + +1. 全页面巡检:按已落盘阶段采样继续分离实际导航/装载/API/截图耗时;33路由、原180秒及原并发/零重试保持。当前13/33仅是到达进度,findings为空不代表矩阵通过。 +2. P9报销提交:在09-18-local-concurrency-product-fixes核清P2028边界,先定稿最小方案与确定性回归,再优化;不将出现时点当作编号根因,不直接提高事务超时。之后重验R75原整文件及两个后继。 +3. 两项定向通过后冻结完整输入,从static开始执行普通full-local,发现集合须纳入两条新增编号用例;不能沿用旧351分母或拼接本轮绿灯。 +4. 完整结果和独立只读复核分列;生产摘要/启用、真实R1、提交推送部署及同候选12条公网义务按原授权边界办理。 + +本次增量审阅包保持normalReleaseEligible=false、独立PENDING;旧候选、失败和冻结证据不覆盖。不因编号专项通过关闭N5、G20或归档task。 + +## 2026-09-18 browser-r3 收口与编号最终小修 + +四文件51项49通过、1失败、1超时,673.807秒,零重试/零skip;环境、输入、owner清理PASS。R3五角色与员工可用页面/冻结差旅拒绝整文件通过;付款编号混合来源整例再次通过。新BX消费者已通过PC两种号码及移动旧号可见,最后错误为测试把移动标题div当heading;合成失败截图清楚保留原号,产品没有丢编号。按实际组件将移动号定位到既有header内精确文本,PC仍验证精确h1,再仅重验此变动用例。 + +全页面仍180秒超时,13/33到达,停在管理首页转主数据链接;逐截图时间保留(首PC登录页后到门户22.43秒、普通页面约6—11秒、角色首屏约16—24秒)。没有证据将其写成单个链接缺陷或提升预算。后续需逐阶段采样区分真实点击/装载/API/截图成本并保留全部33页;普通完整G20仍待该项及P9报销提交事务修复,不盲重跑。 + +编号服务端列表/详情原自然排序回归保留原3条旧号与同号ID次序,增加月号0002/0010/10000,验证混排精确ID/号码/金额对应及原数组零改写、读取零写入/审计。只扩测试,无产品源码变化;运行完整付款路由单测。此前编号完整后端/PG及browser-r2付款整链按原输入记录,不改写成此时的新完整候选。 + +## 2026-09-18 browser-r2 终态与分组续修 + +64项为58通过、3失败、1超时、2串行后继跳过,1004.412秒;环境/source/repository/owner清理PASS,零重试。报销审批→财务原地进度→付款复核/执行→回单→核销/AP整链通过(254.846秒);付款新旧编号/混合来源浏览器消费者通过(60.191秒)。整个观测窗口五合成角色tokenVersion均保持1,未记录REFRESH_REUSE_DETECTED;不声称已排除所有认证风险。 + +最小QA续修已规划:员工旧允许列表里的差旅与已批准冻结合同冲突,改明确拒绝该原路径,保留票据/合同可用正例;新BX详情断言接实际PC标题“报销单 <编号>”与移动标题原号;付款合成fixture nextStep改为真实first_review枚举。全页面已超越原9页但仍180秒超时;改用已有正式认证storage-state helper为每个角色/视口分别取得独立真实会话,直接到首个矩阵页面,保留匿名登录页UI与全部33路由、文档HTTP/到达/403/404/截图/视觉断言,不共享refresh cookie、不伪造登录或放宽预算。先类型检查,再四受影响整文件定向复验;付款整链源码未变可保留本轮结果,定向仍不冒充新完整候选。 + +另发现R75通知的报销提交500:真实server输出为P2028,原5000ms事务在5412ms后的workflowHistory.create失败(reimbursements提交事务),不是AUTH_004。只读提取安全code/时间/源码位置,不复制原payload。本轮编号客户端验收不扩大为调整审批事务/预算;按既有09-18-local-concurrency-product-fixes接独立最小方案:确认新取号是否参与此已编号提交、锁等待与事务内重复查询/CPU边界,保留失败,用确定性回归证明再优化,并保持原权限/审批/审计/预算。新完整G20必须等其修复及R75整文件通过后,从static重跑,不能盲重跑或用付款整链绿抵扣。 + +## 2026-09-18 本轮 browser-r1 结果及证据续修 + +八文件34项:29通过、1超时、2失败、2串行后继跳过,零重试,698.271秒。DG7七宽度、R3五角色原失败整例通过;全页面文档404已消失但仍超时,9/33已到达且最后在登录页。R75通知和付款主链均AUTH_004,旧失败保留。环境、输入不变、owner清理PASS。 + +已在本轮拥有的无网络合成库只读取证,经理出现REFRESH_REUSE_DETECTED且tokenVersion从1变2;对应直连允许路径仅not.toHaveURL(/403/)立即进入下一文档,未等待认证恢复和页面加载完成。修为目标路径、真实h1、非403/404/登录共同就绪后再走下一项;加强断言,不改产品认证。下一轮验证是否消除该干扰,不能先写已证明唯一根因。付款后置诊断未到达,失败提前到提交后读取;后续API错误继续保留。 + +编号N5补两条浏览器消费者检查:PC/手机旧新BX列表详情/完整搜索,以及付款候选原样query/组批entityId、新旧PB详情、混合明细原顺序与UUID链接。响应为明确合成fixture,不冒充真实PG分配或审批付款;真实链仍由原完整付款用例及隔离PG分别证明。新增用例进入普通完整发现,不替换原分母。运行前先补类型检查,之后原九文件整例定向复验。 + +## 2026-09-18 编号完成后的浏览器验收接续 + +官方current回到本task。已保全352项旧差异、31目录11427份历史证据(含编号本地审阅包),本轮独占根为numbering-browser-closeout-20260918。原g20-affected-r1四未过保留,编号N5未勾选;先最小修复后原整文件定向,随后从static重新完整候选。 + +- [ ] DG7七种宽度共用同一合成员工会话,仍逐宽度完整验证全部原页面、截图及断言;减少七次登录重复前置,不缩宽度或放宽预算。 +- [ ] R3按五个独立角色各一次登录,同角色复用页面完成全部原403/404及经理付款入口断言,不减少角色和路径。 +- [ ] 全页面目标文档请求明确Accept: text/html,保留真实文档状态及当前路由/页面/错误/视觉检查。 +- [ ] 付款失败已提取安全错误码AUTH_004;先以阶段内401诊断和合成审计确认,不将猜测写成logout根因,不自动刷新API令牌掩盖失效。 +- [ ] 编号混合格式浏览器验收;原G20完整重跑及独立复核另列。本轮不改权限/认证产品、生产规则、并发/重试/预算,不连接生产或发布。 + +## 2026-09-18 编号实施接续(本轮最新) + +用户已明确授权复核编号矩阵适合后实施。官方current进入09-18-reimbursement-periodic-numbering;新计数/策略及创建入口本地已实现;完整后端4412通过/1原跳过,之后策略边界补验456/456及隔离PG复验通过;迁移默认不启用、旧编号/未编号草稿归属及银行引用保留。该任务证据单独存business-numbering-20260918,未覆盖本task的任何失败/冻结包。原G20四未过、后续完整门禁、独立PENDING与公网义务保持,不把编号单测/PG通过折算为G20通过。未提交推送部署、未读取生产编号分布或启用线上策略。 + +## G20 八文件定向结果与编号调研续项 + +g20-affected-r1结束FAIL,806.364秒;34项30通过、2超时、2失败,零重试。环境/source/repository/owner清理PASS。资产、R75原整文件、PA6角色矩阵、R92与onboarding通过;DG7首页整例和R3直连整例超时,全页面文档请求缺text/html Accept触发404,付款整链在财务通过后的API会话读取失败。阶段日志已证明发票popup完成,不再猜该处卡住。原失败保留;后续需逐项证据修复,再从static完整候选,当前不能全量结案。 + +用户已选报销按月,随后新增要求调研全部在用编号及国内财务/OA/ERP惯例,结合不足10人科技初创公司。归属09-18-reimbursement-periodic-numbering,当前只读调研/更新规划,尚未改编号代码;其他编号实施范围待定,不将调研视为生产改号授权。 + +## G20 QA前置修正完成,开始原整文件定向验证 + +DG7固定企微优先夹具的账号入口改为自动等待;R92/onboarding路由mock显式阻断SW绕过,R92会话仅在登录后refresh成功。PA6同角色PC/移动复用单次登录,四角色隔离、所有入口数组逐字保留、原管理员复核403保持。全页面优先真实可见链接/管理首页,SPA仍单独验证目标文档HTTP及原URL/标题/403/404/视觉合同;逐导航请求完成并稳定500ms才截图,不用旧document网络状态代表新页面就绪。核心付款整链仅增加固定阶段/耗时日志,断言与300秒预算未改,先确认实际停点。 + +浏览器六文件专用tsc通过,产品267项相关回归通过。下一g20-affected-r1八文件原配置定向验证:资产、R75、DG7、PA6、R92、onboarding、全页面、报销付款整链;默认4workers/零重试/原预算。运行期间所有输入冻结;只是定向,不抵扣新完整候选。 + +## G20 产品续修单测已通过 + +两个接口最小修复已完成,原实现红灯7项/112通过,修复后两文件119/119通过(17.54秒);资产操作/搜索/校验与账号导入/敏感权限五文件148/148通过(23.71秒),受影响源码Node语法通过。合计267项包含13项新增边界,真实cost12未改;原事务预算、角色、审计语义与批量导入位置保持。下一步由09-06修QA前置及定向整例,再从static全量;P6/P7整体及P8仍待真实整例/全量,独立PENDING。 + +## 2026-09-18 G20 第四轮终态与续修 + +g20-full-r4 FAIL,5768.636秒;static239.024秒PASS,内部11阶段PASS;后端4367通过/1原条件跳过,8组启用服务回归通过/7组原关闭不计通过;浏览器351项=342通过/5失败/3超时/1串行后继未执行,零重试。环境、source/repository未变和owner清理PASS。资产及账号创建两处P2028由09-18-local-concurrency-product-fixes接最小修复;当前不是全量通过。 + +QA续修保持原分母、预算、并发和断言:DG7确认停在登录输入框,改为自动等待既定账号入口;R92导航后回登录,user-onboarding提交改密后失去会话,核路由mock与SW/refresh会话边界;PA6确认第7次角色登录耗尽180秒,同角色PC/移动复用一次独立会话,保留全部角色/入口/管理菜单/API403;全页面180秒只到9/33页,利用真实应用导航减少重复装载,保留25PC+8移动/14系统页、目标文档HTTP、实际URL/标题及全部视觉/错误断言。报销付款整链300秒超时但无精确动作栈,先增加安全阶段诊断和查实际停点,不先猜产品根因或放宽预算。R75后继跳过随原整文件重验,不能当通过。 + +顺序:产品边界红/绿与相关回归→各在用QA夹具/冗余前置修复→原完整文件定向验证→冻结输入、从static开始新完整候选。定向通过不拼接旧完整结果,主会话检查不抵扣独立PENDING。原失败、322项旧差异及29目录9762旧文件保护不变。 + +用户另行明确批准仅查看线上指定PB-20260917-001审批人;北京时间12:14只读确认draft/3项pending、无复核/执行,真实业务提醒对象为负责人,个人应用消息已有发送成功记录(不证明已读),另两名称为测试的在职经理账号符合现有资格。仅查询,没有业务/配置/企微写入;核实其用途和待办引用后才可提出权限收回,不自动停用。此授权不扩展到ARM、备份、恢复、生产改动或部署。 + +## 2026-09-18 G20 接续执行(最新用户批准) + +生命周期夹具续修已验证:原36命令链三组交错对照,旧2.271–2.481秒、新1.665–1.748秒,完整trace逐字相同;仅测试中复用CJS假命令服务,Python -IS传输,不改生产部署或测试预算/并发。最终52生命周期+22初始化+39部署安全=113/113通过(63.57秒),含新增参数/输入/传输失效保护;此前两项超时当前1746/1648ms,四种真实源码变异及真实timeout均保留。g20-full-r4接下来从static完整重跑,运行期间输入冻结;定向通过仍不抵扣整套验收。 + +g20-full-r3已结束FAIL:1208.815秒,static PASS 308.880秒;后端4362通过/2超时/1原条件跳过。超时为生命周期真实源码init/server变异,均超过原5000ms;浏览器等后续阶段未运行。输入/仓库不变、归属清理PASS,guest尚未运行故环境INVALID。原回执保留。 + +G20d最小续修计划:先在本轮ignored证据目录交错测量原假命令与仅假命令Node启动优化,复用真实remote脚本/Compose依赖/相同配置,比较完整argv和生命周期trace、退出状态及耗时。若证实可减少重复启动开销,只改测试夹具启动,不改部署脚本、Vitest/浏览器并发、原预算、重试或断言;保留完整到达init/server/worker、真实timeout及未知命令拒绝。修后运行原71项和39项安全回归,再从头普通完整候选,不靠盲目重跑求绿。 + +清理接线最小修正已验证:auth-postgres-concurrency 10/10,原runner成功/失败/清理与账号前置6/6,原预算/并发/零重试保持。原1项跳过为deploy-init.integration未注入专用独占数据库,未计通过。下一g20-full-r3从static重新执行;运行期间输入冻结。 + +g20-full-r2已结束FAIL:1157.785秒,static PASS 310.928秒;完整发现365/351/12+2正确,后端223文件共4365项:4363通过、1失败、1原有条件跳过。唯一失败是auth-postgres-concurrency检查原qa_cleanup退出结构(exit cleanup_status);本轮记录器改写了结构。最小修正为在既有original_status优先/cleanup_status退出前收集阶段终态,不改原合同或测试。环境因尚未到guest阶段记INVALID,原生双引擎探针通过、源码/仓库不变、归属清理通过;后续阶段及浏览器NOT_RUN。修后针对性验证再从头完整重跑,不拼接前轮。 + +生命周期原配置复核71/71通过,82.64秒;首轮五项本次2226/2240/1936/2397/2628ms,无代码/预算变化。仅证明耗时差异,未确定唯一根因;不以定向绿补首轮完整结果,下一g20-full-r2从static重新执行并冻结输入。 + +普通候选g20-full-r1在static失败:生命周期49项中5项超过既有5000ms,合计71项为66通过/5超时;full-local未运行,120.980秒、static109.595秒,回执FAIL/无发布资格。原件保留。先原配置复核当前生命周期夹具,不提高预算或削弱fake命令审计。最终回执62项纯验证通过,shell补充19/19通过(原组合测试假工具把visual误作full的夹具失败已保留并修正),旧168项QA结果按输入适用性保留。 + +G20机制已完成本地实现:新增verify-candidate(static/full-local复用)、固定12个内部阶段与实际筛选记录、来源/策略/工具链/双引擎原生环境/逐项覆盖共验、dirty PARTIAL和离线verify、精确skip及批准元数据。QA整组168/168通过;补充原生浏览器版本绑定与三个skip组合、真实profile/source/coverage源码变异,最终结果以本轮command回执为准。部署安全39/39通过,流程/文档/敏感/task/diff检查通过。普通全量接下来在冻结输入上执行,原失败不改写。 + +G20a已完成:官方WebKit v2105/26.0、第三轮报销离线1/1通过(325.915秒),环境/源码/仓库/归属清理PASS;前两轮原失败保留。旧差异322项快照及29目录/9762文件保护基线已建立。G20c共享回执与逐阶段/实际筛选记录已接本地verify-candidate;首轮13项实际shell/fake工具检查通过,完整QA回归进行中。未接真实R1入口,无部署调用。 + +G20a 第二轮登录已过,停于WebKit原生离线。独占namespace对照证明无默认路由false,只有内部veth对的链路默认路由true,主动离线false;修正设计已补,保持无外联并加强同namespace对端核验。阶段回执58项合成检查通过,runner接线检查进行中。 + +G20a 首轮 WebKit 原单例超时:合成 refresh 在未登录时无条件返回成功,页面已进入移动工作台,登录 helper 等待不存在的输入框。仅修测试会话为登录前401、登录后成功;不改认证产品或离线断言。原失败保留,环境/source/cleanup通过。WebKit复验后先接阶段记录器,再跑绑定最终runner的完整门禁,避免代码变化后重复全量。 + +用户在解释 WebKit 用途及五组余项后明确“按照这个规划开始执行,非必要不暂停,尽量做完”。本轮据此批准官方 Playwright WebKit 运行包下载到本轮独占缓存,及既定本地完整验收和共享候选回执机制实施;不再列下载授权待答复。仅运行包,不隐式安装系统包或修改共享缓存;业务测试继续无外联合成环境。 + +沿用 G19 分层合同。先补 WebKit 真实启动/报销离线敏感证据单例,再普通 full-local。受管运行期间源码/测试/task/spec/docs冻结,只写本轮 ignored artifacts。旧失败/旧候选不覆盖。 + +- [x] G20a:建立旧差异/冻结材料保护基线,官方包独占下载、运行时依赖/启动探针,WebKit原单例重验。 +- [ ] G20b:普通完整 full-local(351本地用例+配套后端阶段),零重试/默认并发/原预算,保留所有失败及真实阶段耗时;只修在用缺陷/已批准合同漂移。 +- [x] G20c:实现来源/策略/环境/阶段/实际覆盖共同绑定的共享候选回执与离线验证入口,复用现有G19/sourceSnapshot/environment,不新增发布平台。设计细节补在design后再编码。 +- [ ] G20d:逐项反例、fake命令捕获、源码变异和既有部署生命周期回归;只验证机制,不连接真实发布入口强制消费。 +- [ ] G20e:本地检查、规约/协调状态、独立复核交接与新候选冻结;外部事实/12公网义务/真实R1启用/提交推送部署保持分别授权。 + +新证据:artifacts/qa-layout/release-receipt-g20-20260918。主会话inline,独立PENDING;无生产ARM、真实配置密钥备份、真实企微付款/恢复或旧恢复重跑。WebKit与后续本地实施连续推进,不以范围内常规决定反复暂停。 + +## 2026-09-18 G19 分层验收已实施并完成本地针对性验证(最新状态) + +用户已明确批准分层方案,已正式更新 R2 并落门禁:全部非 manual 仍发现;12 公网逐项保留为同候选发布后必跑;2 条冻结差旅原源码/历史结果保持,当前改验两条冻结保护。没有恢复权限或旧整链,没有公网/企微/真实付款操作。 + +- 最终发现 **65 文件/365 用例**:**351 本地用例(59 文件)、12 公网义务、2 冻结旧链**,0 遗漏/未知/重复。351 已包含两条新保护,分母不重复加数;发现不等于执行。 +- 受控清单绑定用例身份/原源码摘要/原因/入口/责任角色。普通 runner 从完整发现派生执行集合,并校逐项结果;漏例、漂移、未知、重复、跳过、重试、expected-failure、伪通过和候选不一致拒绝。定向仍无发布资格。 +- 门禁 **74/74** 与诊断结果 **7/7** 合成检查通过,含真实 runner 调用及删除覆盖消费的源码变异。首轮夹具独占文件问题、第二轮五项30秒超时均保留;最终单独运行使用原预算,没有增加超时或降低断言。 +- `g19-freeze-r2` **2/2** 真实本地浏览器通过:合成员工创建403/无差旅导航,普通财务对账和三类导入403/无差旅导航,财务主管共享付款就绪检查包含差旅冻结 blocker。环境/输入/清理 PASS。首轮测试读取不存在的 me.permissions 失败已保留,改验正式 roleCodes 与接口行为。 +- 当前财务主管仍有 integration.manage,受管合成 runner 仍为既有底层回归启用 travel sync;没有声称所有差旅接口关闭。旧整链未通过/未执行,不因保护通过改写历史。 +- 本地回执明确公网 NOT_RUN_LOCAL、完整交付 INCOMPLETE、normalReleaseEligible=false。离线公网结果验证器要求 live profile、同源码/部署候选及原始逐项结果;未接真实部署入口,不能独立证明远端身份。实际公网责任人尚未指派,证据仍缺。 + +本地证据、新候选及余项:`artifacts/qa-layout/local-coverage-g18-20260918`。最终验证/保护/候选摘要分别为 final-check-summary.json、protection-result.json、candidate-integrity.json;旧完整 full-r2 保持失败,未重跑普通全量。主会话检查不是独立审阅,独立 PENDING。 + +### 接续顺序与余额 + +1. WebKit 官方独占缓存下载问题仍待答复;其真实离线用例未验。条件齐备后跑 **普通完整门禁**,不拼接定向绿;不得恢复旧差旅或放宽并发/预算求绿。 +2. G20:基于 G19 最终归属合同继续共享候选回执/逐阶段验证/skip和变异反例;真实 R1 强制消费启用另行批准。先完整阶段与 source/environment 绑定,再 fake 工具验证零发布写入,最后独立复核;当前未启用真实入口。 +3. 独立只读复核现有 K0/报销付款/借款/QA 与本次产品候选;inline 自检不抵扣。 +4. 经另批范围补真实付款复核人及企微个人发送事实、银行接受性、K1 保管/K2 新绑定、事件/自然业务证据;旧恢复不重跑。 +5. 经明确授权后提交推送、干净候选完整门禁/独立重跑、部署,再兑现 **同候选12条公网义务**。未部署前不能告知可公网测试。 + +借款 T1–T4、报销同页进度/付款排序/候选人通知事实展示、ERP 专属精简与 strict 遗留0保持此前本地成果;本轮无提交/推送/部署/归档,不把本阶段完成说成所有任务结束。 + +## 2026-09-18 G18 本地实施与针对性验证完成(最新入口) + +本轮完成OCR能力接线、R108现行报销/离线队列覆盖、14系统页展开及真实路由到达验收;修复票据详情URL双写导致的人工修正失效。另由09-18-local-concurrency-product-fixes拥有PWA更新事件缺口及采购创建事务缩短:不改权限/审批/审计、事务或浏览器预算、并发/重试。 + +- 最终共享入口回归 g18-final-ux-ocr-r4:18/18通过,含完整认证/门户16项与OCR2项;新PWA生命周期13项连同采购回归共131项Vitest通过,QA接线39项按未变输入复用。 +- 不带预载的 g18-concurrency-fixed-r3:原资产/采购/付款/PWA四文件4/4通过,583.240秒;4 workers/零重试/原预算,995服务响应、0未处理异常。PWA文档B/active controller/worker摘要、68新缓存和2实际加载资源、旧独有缓存0,零登录回退/静态404。 +- R108已完整通过:8移动观测/0发现,员工审批403、经理正式通过,当前approve/return/no-reject及离线队列;路由验收25PC+8移动、14系统页、0发现,另5个正反对照通过。只证明矩阵内覆盖。 +- G18时点完整发现64文件/363用例,缺失/未知/重复0;枚举与临时构建摘要采集成功,仅枚举不等于执行。所有完整受管轮均核环境/source/repository/owner清理,失败原件保留。 + +此前两次并发采购P2028和PWA未收敛均保留。诊断区分了事件循环拥堵/池排队与PG等待(729采样未见Lock);这不是全仓CPU根因证明。原full-r2 65/359、326 passed/12 failed/5 timedOut/16 skipped保持失败,最新候选未再跑普通整套,局部结果不能拼接全量通过。 + +证据/新复核包入口:artifacts/qa-layout/local-coverage-g18-20260918。最终治理、敏感/diff、保护与冻结以final-check-summary/protection-result/candidate-integrity为准;主会话检查不代替独立PENDING。保留304项原差异快照、27旧目录/9004文件以及HEAD/index,不覆盖旧冻结包。 + +### 尚需推进 + +1. 12条公网用例和2条冻结差旅的验收归属:入口/责任角色/证据缺口已列g18-gate-ownership.json;G19分层验收已获用户明确批准并完成本地实现/验证,12项公网仍须同候选发布后执行,未运行公网。 +2. WebKit真实运行条件及现有离线用例未验;此前官方独占缓存下载问题仍待答复,不联网安装。 +3. 条件齐备后完整普通门禁及R1共享回执/拒绝反例;独立只读复核与R1真实强制消费启用单列,不能用本轮定向绿抵扣。 +4. 真实付款处理人/企微个人发送、银行/OCR样本、K1保管事实/K2新绑定、事件和自然业务证据仍未执行;旧恢复不重跑。 +5. 提交/推送、干净候选完整验证、部署/公网验收仍需原明确授权。借款T1–T4、ERP退役、strict遗留0保持此前本地完成,未据此上线。 + +后续具体状态与最小步骤见remaining-work.json;当前task及协调task保持in_progress。 + +## G19 首轮验证发现与修正 + +- 全量发现 65/365;351 本地、12 公网义务、2 冻结旧链,0 归属问题。只是发现,不是全量执行。 +- QA r1 71/73:两条源码变异测试被上次独占报告文件提前阻断;已将每次夹具调用的报告/命令日志隔离,不削弱保护。r2 69/74,五条诊断夹具在并行构建负载下30秒超时;下一单独重跑整组,不改预算。 +- g19-freeze-r1 两条失败,环境/输入/清理 PASS。新测试误用旧 helper 类型中的 me.permissions,正式 /api/me 只返回 roleCodes;修正为实际角色确认+原接口403/付款冻结blocker/导航检查。原类型不据此声称产品权限故障,不扩修旧 helper。 +- 六个原 live/frozen spec 字节不变;冻结义务的当前入口明确指向普通 full-local 内的新保护文件,旧业务域入口仅作 historicalEntry 保留。 + +## 2026-09-18 G19 分层验收(用户已明确批准,实施中) + +用户批准:12 条公网用例作为同一候选发布后必跑项;2 条冻结差旅保留原源码与历史结果,改验冻结保护。本地通过不代表公网/完整交付通过,不授权部署、恢复差旅或真实操作。此增量替代 R2 的“排除集合为空”,不重写旧 full-r2 失败。 + +- [x] G19a:全体非 manual 用例继续真实发现,固定 12 live+2 frozen 的用例身份、原因、责任角色、必跑入口和原源码摘要;未指派实际负责人明确待定。 +- [x] G19b:由完整发现与受控合同生成本地执行文件集合,登记用例级覆盖;拒绝遗漏、身份漂移、未知/重复、跳过、重试与 expected-failure。禁止调用者筛选普通本地集合。 +- [x] G19c:新增两条冻结保护整例,验证现行合成角色入口/正式 API 拒绝、共享付款差旅 blocker 与入站导入权限拒绝;不改权限,不执行旧差旅链。 +- [x] G19d:公网义务在本地保持 NOT_RUN_LOCAL,绑定候选提交与源码摘要;公网用例结果只能由同候选、live profile、完整逐项通过报告兑现。验收回执不自授部署或发布资格。 +- [x] G19e:增加漏例/新增未知归属/漂移/伪通过/候选不匹配/真实 runner 选择等反例,运行本地冻结保护,更新 spec/协调 task 与新候选;独立仍 PENDING。 + +WebKit 下载问题仍待答复,完整普通门禁未通过;R1 机制后续与真实强制消费启用分开,不启用发布入口。所有旧差异、失败材料与冻结包保持。 + +## G18 诊断终态与产品修复归属(2026-09-18) + +诊断r2 531.599秒,2通过/2失败;环境/source/repository/cleanup全部PASS。采购仍P2028(6839/5000ms),PWA现已实测B控制器active/activated、B首页缓存和68新资源、worker摘要匹配,却文档仍A。729次PG采样无Lock等待,存在应用事件循环延迟/池排队,不声称全局唯一根因。 + +按用户连续实施及先补规划授权,新增09-18-local-concurrency-product-fixes接最小PWA事件监听和采购创建事务修复,具体PRD/design已落;四文件不带观察器重验后再接回本task收口。R2 live/frozen归属变更仍仅提案,不影响既有边界。 + +## G18 并发失败与诊断细化(2026-09-18) + +g18-concurrency-r1 505.214秒,资产/付款2通过,采购/PWA2失败;环境/源码/仓库/owner清理PASS,零重试。采购POST为P2028(5000ms预算、5952ms后使用过期事务),响应8086ms;同窗口多登录约15秒,仅据此不能区分事件循环、池等待与PG锁。PWA文档仍A、B独有缓存68、A独有缓存0;原catch中controllerIsActive=false、fetched摘要空值和bOnlyLoaded=0是默认占位,不是实测,禁止据此推因。 + +继续已批准本地可靠性诊断:四文件保持4 workers/零重试/原预算,先修PWA安全失败记录为真实快照或null,并采被动worker状态/控制器/导航时序与缓存index摘要;不额外导航/更新/清缓存。仅本轮ignored观察器通过NODE_OPTIONS预载采服务事件循环与池计数,限定受管retained-local合成server、已核namespace及独占输出;外部观察器只查询已验证本轮容器的pg_stat_activity等待类别/数量/时长,不读取SQL正文或业务表。时间统一epoch便于对照。观察器存在的运行单独标诊断,不替代无观察器最终验收。 + +依据证据再落产品最小修复;PWA若需运行时代码变更,先建独立产品task关联本task,遵守原spec。不增加事务/浏览器预算,不改审计、权限或并发,不把一次定向通过当完整可靠性通过。 + +## G18 已通过本地覆盖,接续并发观测(2026-09-18) + +受管 g18-ocr-visual-r2 7/7 通过,332.441秒(浏览器约2.2分钟),环境/source/repository/cleanup均PASS。原页修正→缺字段阻断→补齐可用→返回再进入已经验证;25PC+8移动(其中14系统页)全部准确到达,0发现;另5个合成目标到达正反对照通过。R108在前轮完整通过,8移动观测/0发现,员工403、经理正式批准、离线上传队列保留。观察器试采该次归属服务655个响应、0未处理异常;不以此抵扣旧full-r2的P2028。 + +下一步 g18-concurrency-r1:资产/采购/保留付款整链/PWA,默认4 workers、原预算/零重试,受管本地合成。观察器通过本次runner后代及其唯一PID文件绑定孤儿server,再核cwd/入口/fd1/namespace;不依赖server一定仍为后代,也不读历史共享日志。输出仅请求类别/状态/耗时/P2028数字和CPU/RSS。PWA输入存在时完整枚举并提前缓存摘要,原发现采集告警不覆盖。 + +## G18 OCR 实测暴露的最小产品修复(2026-09-18) + +g18-corrected-visual-r1:R108 整例通过(8 个移动观测、无发现),OCR 缺字段识别及横幅已通过,但人工修正按钮在180秒内反复被卸载;执行记录有56次 DOM detached,终态回到列表。源码两组 effect 分别 local state→URL、URL→local state,并同时依赖双方;上传选中票据时会交叉回写。此为在用票据详情状态同步缺陷,不是缺权限或OCR规则错误。 + +在已批准 G18 现行修正流程验收内,最小修复只涉及 InvoicePoolPage:从 URL 读取 detailInvoiceId,所有选择/返回动作只更新 URL 且保留其他查询参数,删除双向 effect 与重复返回写入。保留 replace 导航语义、全部查询/编辑/权限合同,不调整OCR、状态机或产品视觉。原 E2E 红例保留,修后必须不刷新/force-click 完成缺字段拒绝→补齐可用,增加URL选中与返回列表再进入验证;运行前端typecheck/build及受影响浏览器检查。相同模式只定位记录,不顺手改其他页面。 + +视觉首轮目标到达还需纠正:portal使用既有portal-work-summary锚点,动态报销使用各自workspace;其余已知页面标题不降级。4xx错误页模拟响应显式UTF-8,确保反例精确击中原到达断言;增加同源API403/404观测为失败发现,避免仅页头可见冒充有效页面。 + +## G18 首轮停点与最小修正(2026-09-18) + +受管 g18-ocr-mobile-r1:338.629 秒,1 passed/2 failed,无 skip/重试,环境/输入/清理 PASS。首例报销上传提交通过;OCR 修正到达前停在员工无票据管理导航,R108 已完成 7 个移动观测和离线队列后停在旧通用审批详情标识。 + +当前票据管理要求 invoice.manage,修正用例改用财务合成角色;普通员工仍通过报销详情提交。当前报销审批规约只提供 approve/return(及有权 transfer),没有 reject;R108 接专用 reimbursement-mobile-workspace,验证退回修改可用、驳回不存在、通过确认和正式 completed/approved,保留员工审批403、离线队列与全部布局验收。原计划“批准/拒绝”是选用旧差旅夹具时的动作,不为本次测试恢复已替换的报销驳回。R108 报告新增完整执行状态,异常退出的无视觉发现不得打印整例 PASS。 + +并发诊断落具体方案:维持4个文件的默认4 workers、原预算和零重试,资产/采购/付款/PWA一组受管对照;观察器仅按本次进程后代链、cwd/entry/fd1核归属读取本次合成服务日志,输出请求类别/状态/耗时/P2028数字和CPU/RSS,不复制payload或凭据。无事件循环/锁证据不下唯一根因结论;定向通过仍不代替完整负载。 + +## 2026-09-18 G18 执行细化(用户已批准按余项连续推进) + +官方 current 已回读本 task,inline 实施/检查;独立审阅 PENDING。新证据根 `artifacts/qa-layout/local-coverage-g18-20260918`,已保全 304 项旧差异、27 个旧目录/9004 个文件;新增本轮候选,不覆盖 G17 冻结包。受管运行期间不修改源码、测试或任务输入。 + +- [ ] G18a OCR:在受管 runner 启动真实本地 server 后,根据该次启动的 mock provider 与 jobs=true 派生测试能力标志,覆盖调用者置位;保留非 mock/停 job 的反例。修正用例实际验证缺字段→拒绝提前可用→补齐→可用,不仅置位求绿。先检查接线,再受管执行整个 OCR 文件,旧标题与原结果映射保留。 +- [ ] G18b R108:用当前小额报销(上海当天、office_postal、有效合成 PDF、显式关联明细、正式提交)产生经理待办,替代冻结差旅。初始化异常直接失败;保留 390/414 布局、离线上传两项/移除/恢复、经理批准/拒绝操作及确认,补员工无审批权限和正式任务完成断言。输出改为运行独占目录,移除允许忽略发现的开关;原预算与零重试保持。仅对已证实的现行 UI 漂移纠正定位,不改产品样式。 +- [ ] G18c 路由:在已有矩阵将 `/system/*` 显式展开为当前 router 的 14 个系统页;AP 选财务,管理页选管理员,审批选经理。每次验收须实际 URL 与预期一致、目标页标题/稳定锚点存在、无 403/404/登录回落;匿名登录页用匿名 context。未知路由或未解析参数直接失败。保留原视觉断言与 180 秒预算;不宣称矩阵外全产品覆盖。既有矩阵业务边界文字同时纠正企微考勤与 ERP 退役事实。 +- [ ] G18d 必跑归属与可靠性:只读梳理 12 live、2 冻结差旅的实际入口/责任/证据缺口,保持 full-local 完整集合不增例外。并发诊断先规划可归属服务时序/资源/浏览器事件采集,不改事务超时、并发、重试、审批和审计;按精确停点决定是否需要修复,不盲跑整套。 +- [ ] G18e 类型/接线/受管定向检查,精确结果与余项、主会话 review、治理/敏感/diff/保护校验和新增量冻结;09-08 协调状态同步。完整发现须使用运行仍有效的 PWA 输入并在清理前保存所需摘要。 + +WebKit 未获官方独占缓存下载答复,不联网安装;R1 启用、独立 review、真实批次/企微个人通知、银行样本、K1/K2/事件和自然业务证据、提交/推送/部署/归档继续分别保留边界。不重跑旧恢复。 + +## 2026-09-18 G16/G17 本地执行收口(最新入口) + +普通 full-r2 的原始结果保持:65 文件/359 用例,326 passed、12 failed、5 timedOut、16 skipped,总计 4879.543 秒。72 迁移、Prisma、4347 服务端单测(另 1 原跳过)、前端 typecheck/build、8 角色正式改密、审批及 8 组默认启用服务端回归通过;完整门禁仍失败,无发布资格。 + +本轮将其 17 条失败/超时逐项处理:14 条在用观测已有本地定向通过证据,1 条缺 WebKit,2 条冻结差旅原样保留;另外补验原 R75 串行未执行的生命周期与开关备注恢复 2 条。是跨轮、不同测试修订下的身份映射,不是 14 个产品缺陷,也不是最新候选一次全量通过。证据总入口为 `artifacts/qa-layout/full-gates-followup-20260918`,逐项见 current-disposition.json,所有失败轮次保留。 + +| 工作 | 最终本地证据 | 边界 | +| --- | --- | --- | +| G16 账号/服务回归前置 | 普通 full-local 已接串行强制改密、guest probe、完整发现/forbid-only;聚合 r4 八组通过 | 七个默认关闭服务套件未执行,不启用 R1 | +| G17a 合同验收 | 同状态管理对照、只读原整例;r3 前后 2 次正常通过、4 类动作注入均精确非零 | 新增合同用列表链接;独立复核仍 PENDING | +| G17b/c 模拟页面 | mock-r1 15 passed/2 timedOut;修已定位按钮后 r2 2/2 通过 | 不加预算、不 force-click;模拟 API 局部阻止 SW,真实 PWA 保留 | +| G17d 在用业务 | business-r1 资产/付款/考勤通过;r2 采购/通知/考勤通过;r3 生命周期与备注恢复 2/2 通过 | 本地合成;r1/r2 失败和串行缺失仍保留 | +| G17c PWA | 单独 1/1,通过用时 18.116 秒;A 受控→B 收敛、旧独有缓存 0、新资源加载、无登录回退/静态 404 | 原 full-r2 的 45 秒未收敛事实不回写,不据单独通过认定并发稳定性已修 | +| G17c 视觉 | 原矩阵单独 1/1、60.890 秒,12 个 PC 路由项/8 个移动路由项,0 发现 | 含字面 `/system/*`,未展开全部系统页,不称全产品视觉覆盖 | +| G17e 精简 | 删除严格重复 main-flow 入口,保留被业务组/manifest 引用的付款整例,固定保留 PDF 和全部下游断言 | 冻结差旅不是废弃;旧报销/借款/ERP产品候选和旧冻结包均保留 | +| G17f 当前发现 | 64 文件/358 用例,缺失/未知均 0,覆盖校验与复核一致 | 只枚举;临时构建清理使末尾 SHA 再采集失败,原 wrapper exit 1/采集告警保留,不冒充完整回执 | + +全部完整受管轮均 sourceUnchanged/repositoryUnchanged=true、环境 PASS、owner 清理 PASS。局部通过不证明 full-r2 中资产/采购/leave-balances 的 P2028 根因已解决;本轮未调事务超时、删审计、降低并发、加重试或更改产品权限/状态。主会话完整 diff 自检及最终治理/保护/候选以 final-check-summary.json、protection-result.json、candidate-integrity.json 为准;独立仍 PENDING。 + +### 剩余工作与下一批执行顺序 + +1. **G18 本地覆盖合同补齐(先按具体修改面细化实施,尚未执行)**:OCR 条件明确绑定受管 mock provider 和 job 的真实能力,再验当前修正流程;R108 把冻结差旅初始化替换为当前在用的移动审批夹具,保留离线队列/手机布局/权限断言并隔离旧截图;路由矩阵把通配项展开为经角色授权的真实路由,验证实际到达,404/403 不能充当该页面视觉验收。三项各自小批,不恢复日常 OA 考勤或差旅,不删除缺覆盖证据来求绿。 +2. **运行可靠性与完整集合**:对 full-r2 的事务超时、长链及 PWA 并发收敛分别设计受管时序/资源对照,先取得精确阶段证据再决定修复;原并发、零重试和预算不暗改。12 条 live、2 条冻结差旅及上述条件项须明确必跑入口/例外归属/责任/证据;完整集合未闭合前不反复跑全套求绿。 +3. **WebKit 1 条**:现行设计“不联网安装”;本轮已提仅官方 v2105 到独占缓存的申请,尚无答复,未下载。具备运行时后按原 WebKit 离线敏感证据整例重验,不用 Chromium 抵扣。 +4. **R1 与独立审阅**:入口收敛/回执消费机制的未完成 AC 保持;完整普通门禁绿、实测成本、干净候选及单独启用批准仍必需。当前 QA、上批产品与 K0 的独立只读审查分别 PENDING,不由本地反例或主会话自检代替。 +5. **外部事实**:指定真实付款批次候选处理人与企微个人发送事实、银行样本接受性、K1 保管事实/K2 新绑定、恢复/事件缺口和自然业务验收按既有清单分别补事实与授权。不得重跑已消费旧恢复。 +6. **交付操作**:提交、推送、部署、归档仍未授权;没有公网更新结论。借款 T1–T4、报销进度/排序及付款姓名/通知事实展示保持此前本地完成,真实使用验收不由本轮门禁抵扣。 + +以上是当前余额,不把旧文档中的“场景8尚未处理”“普通门禁尚未接改密”“借款未实施”继续作为现状。整体 task 仍 in_progress;未连接生产/ARM、读取真实密钥配置备份、重跑旧恢复、发送真实通知或付款。 + +## 2026-09-18 G16 完整运行与 G17 本地后续(当前入口) + +普通 `full-r2` 已完整结束:4879.543 秒;浏览器 3554.893 秒。65 文件/359 用例发现与最终身份一一对应,326 passed、12 failed、5 timedOut、16 skipped;零重试/重复/未知/缺失,框架总计 17 unexpected。72 迁移、Prisma、4347 服务端单测(另 1 原跳过)、前端 typecheck/build、8 角色正式改密、审批和 8 个启用服务端套件通过。环境 PASS,sourceUnchanged/repositoryUnchanged=true,owner 容器/scratch 清理通过。该失败运行与先前定向回执分别保留,无普通发布资格。 + +本轮新失败中,2 条冻结差旅原样保留,1 条 WebKit 缺运行时(下载问题仍待答复);14 条在用观测逐项处理,不能称 14 个产品缺陷。16 条跳过为 12 条 live 专属、1 条 OCR 条件、1 条旧初始化不兼容的 R108、2 条 R75 前序失败后的串行未运行;后两条 expectedStatus=passed,不能计为正常条件跳过或通过。 + +用户连续实施与精简授权下,G17 先按下列最小方案执行,仍不改产品权限/状态/超时或视觉方案,不增加浏览器重试与全局超时,不运行真实操作: + +- [ ] G17a:合同原只读整例在已加载列表补共享禁止动作断言;创建反例使用列表链接,编辑/续约/归档用详情按钮。已有独立副本 4 个详情注入非零+前后 2 次正向结果只支持详情场景;补足列表链接缺口后重新核精确失败。主会话反例不抵扣独立 review。 +- [ ] G17b:R102 删除动作文字接当前“删除报销单”;关闭已验证的更多菜单再点添加发票,按当前明确关联方式验证,不 force-click 穿透菜单。R125 保留解绑/归档后同文件重传合同,显式选择既有明细和当前上传反馈,不恢复旧自动绑整单。Wave3 按现有 spec 的 `lg:static` 与侧栏进度次序替代旧 sticky 断言,保留多视口/宽度/溢出和审批内容。R124 先核具体停点;已过时的角色/删除定位按当前合同改,保留移动全链。 +- [ ] G17c:公告及 visual-shell 的模拟 API 与生产 SW 路由边界作限定对照;确认 mock-only 场景需阻止 SW 后局部设置,真实 PWA 仍启用。visual-shell 截图另根以保护历史材料,不改布局产品。全页视觉与 PWA 各保留原失败,定向运行带诊断观测,先核是否预算/环境或具体行为问题,不改超时求绿。 +- [ ] G17d:R75 通知前置按实际提交拒绝核现行日期/金额/指定明细合同,合成发票使用有效 PDF;保持通知未读/已读/跳转以及后续生命周期、功能备注恢复验收。资产与采购先用原断言在独占库定向重验;本轮服务日志已由运行时间+活动子进程 PID 确认,POST assets/POST purchase-requests 为 P2028 事务超时,GET leave-balances 另列,不放宽事务或移除审计。 +- [ ] G17e:按用户精简取舍核两条报销付款整例的重复:两入口目前均调用同一 helper,main-flow 的 preview=true 包含另一入口的全部业务动作及额外 PDF 验证。确认包含关系后收敛为一个完整主链,移除无调用的可选 preview 分支及重复入口;保留审批/付款/回单/审计/AP/票据归档全部断言和原 300 秒预算。原另一入口本轮超时保留,不以退役重复用例改写历史或取得完整门禁资格。 +- [ ] G17f:相关定向验证、当前完整发现、逐失败及跳过处置、主会话检查、治理/敏感/diff、旧差异和冻结目录保护、新增量候选及 09-08 状态同步。若仍有性能/环境或未决合同,落具体下一步;不反复全量重跑至绿,R1 实现/启用、独立审查和外部事实继续分列。 + +证据入口 `full-gates-followup-20260918/full-r2.summary.json`、`full-r2.cases.json` 与原受管完整报告。后续测试源码有变化时,本次全量结果只绑定本次冻结输入,不借用为更新后完整候选全绿。 + +G17e 入口核对:保留被 business-flow 组与 AP/付款 route-manifest 引用的 `reimbursement-payment-batch-flow.spec.ts`,把已有完整 PDF 检查固定为必跑;退役未被执行入口引用的重复 `main-flow.spec.ts`。这样现有专用门禁及旧 P2 排除配置仍指向同一完整链,不产生悬空引用或顺便改变安全分组策略;历史文档引用保留追溯。 + +G17b 接续:mock-r1 为 15 passed/2 timedOut;trace 的动作记录已证 R124 走到末尾旧“删除草稿”确认,R125 PC 停在旧“移除”。分别接当前 dialog 内精确“删除报销单”和行内“移除对应关系”后,mock-r2 仅这两条 2/2 通过(45.3 秒),相关类型检查通过。旧失败保留,不扩预算。 + +G17c/d 验证顺序:资产/采购原整例、R75 全文件和合并后的付款主链先在同一独占库执行;PWA 与项目全页视觉随后各自单独受管执行,避免把并发压力混成已证明的产品根因。全页视觉仅加逐路由 test.step(脱敏路由标签)以保留超时具体停点,不改路由集合、角色、断言或 180 秒预算;PWA 已有 A/B 失败观测,不额外改动。受管运行期间源码与任务输入冻结,只在本轮 ignored 证据根整理。 + +## 2026-09-18 G16 执行中(原本轮计划,结果以上节为准) + +G17d 第一轮受管结果:g17-business-r1 共 7 条,3 passed/1 timedOut/1 failed/2 串行未执行,496.44 秒;资产整链、含 PDF 的保留付款整链和企微考勤边界通过。采购已越过建卡,提交前当前页报服务错误;R75 仍在合成提交处 VALID_001,不能把旧 P2028 或日期/金额推断当已证根因。环境/源码/仓库不变/owner 清理均 PASS。 + +限定接续方案:R75 不再选择费用接口首行(seed 首行为差旅机票,会要求完整差旅摘要),显式选择既有 office_postal 快递费用,与合成通知业务及已过付款主链一致;提交失败仅记录状态/原因码/字段名,不输出 payload。采购原提交动作确实先 PATCH 保存、再 precheck、最后 submit;补三个正式阶段的状态断言并处理现行预检确认,不把保存失败等成最终 submit 超时,不放宽审批权限或预算阻断。下轮仅采购及 R75 全文件。 + +G17d 第二轮 g17-business-r2:365.142 秒,采购完整链/考勤边界/通知完整场景通过;生命周期页面入职及转正已通过,最终 API 断言却因默认 directory scope 不返回 employmentStatus 而失败,治理备注仍串行未运行。对齐 PeoplePage 已有管理查询,加 scope=management,保留 admin 正式权限与 active 断言;下一仅重验生命周期和备注恢复。环境/源码/仓库不变/owner 清理均 PASS。曾在本轮 artifact 调用者指定独占日志路径,但受管 env 白名单未转发,未产生该日志;不扩大白名单、不借此宣称取得服务日志或 P2028 根因,原日志不重写。 + +用户已批准按后续规划连续执行;官方 current 回读为本 task。新证据根 `artifacts/qa-layout/full-gates-followup-20260918`,基线保全 290 项既有差异、25 个保护目录及 7656 个历史文件,旧冻结包不覆盖。仅本地合成;inline 自检不抵扣独立复核,R1 启用、真实环境与发布边界保持。 + +- [x] 核 current、G16 与既有差异,创建增量基线。 +- [x] G16a:核 WebKit 缓存和运行条件;缺失时保留 NOT_RUN,不下载或用 Chromium 替代。 +- [ ] G16b:共享串行合成改密前置接入普通 full-local,在回归/浏览器前完成;完整发现、PWA、fail-fast、零重试和原并发保持。增加初始化失败阻断与顺序反例。 +- [ ] G16c:同一个 active 合同增加管理身份正向对照;在真实加载后的列表/详情注入四类动作逐一证明负向断言失败;保留原只读整例。完整门禁及独立复核结论分别记录。 +- [ ] G16d:场景 8 对齐现行强制经理解析的 409/no_manager_found 合同,证明草稿且无工作流;不修改人员关系规则/产品权限。后续原 9–23 按实际结果检查,不提前计通过。 +- [ ] G16e:旧日常 OA 请假离线重放/冲突两个用例及专属辅助移除,替代合同引用现有企微只读边界测试;报销离线敏感证据用例保留 WebKit。冻结差旅原两条完整保留、继续归 full-local,记录其当前角色不可达与缺少重启验收,不以删/skip消除完整门禁阻塞。 +- [ ] G16f:定向必要验证后执行普通 full-local;前置失败即停止,保留完整发现与真实未运行分母,不据局部通过取得资格。整理增量候选、保护校验、治理及余项。 + +WebKit 当前缓存只有 Chromium/ffmpeg,运行条件缺失,NOT_RUN 不抵扣验收。合同定向 7/7 通过(原只读/静态反例、管理正向、4 个真实页面注入反例),浏览器测试专用类型检查通过;完整反例门禁/独立复核仍未闭合。场景 8 当前源码仍期待缺经理跳过,但 workflow 单测和实现要求必需经理缺失时失败关闭;以现行合同修测试,不恢复旧规则。真实经理缺口、指定真实批次处理人/企微结果、银行样本、K1/K2、独立复核与发布继续分列。 + +### G16 聚合第四轮通过与普通完整门禁接续 + +`server-regressions-r4` 477.318 秒,exit 0;八个默认启用套件全部通过,七个默认关闭可选套件未执行、不计通过。通知 1–10 步完整通过,包括新 webhook 合成密钥的加密存储/脱敏读取/本机失败 provider、旧合成字段 encrypt/decrypt 迁移。环境 PASS,sourceUnchanged/repositoryUnchanged=true,归属核验及容器/scratch 清理通过;限定回执 normalReleaseEligible=false。 + +下一执行普通 `full-r2`:保持完整发现、全部非 manual 浏览器集合、零重试、原并发及内部 fail-fast;WebKit 官方运行包下载问题仍待答复,未下载,缺运行时按实际失败/未覆盖留痕。冻结差旅用例继续保留,不开旧业务权限求绿。运行中源码/测试/task/spec 输入冻结,只读检查与本轮 ignored 证据整理可继续。完整结果产生后按真实用例身份归类,不以本次八组成功或此前定向结果替代整道门禁。 + +### G16 聚合第三轮与通知密钥合成夹具 + +`server-regressions-r3` 457.215 秒,exit 1;环境、源码/仓库不变和 owner 清理 PASS。前七个启用套件通过,通知步骤 1–7 通过,第 8 步失败:旧夹具给 mock 渠道传 secret,却期待 secretConfigured=true。现行 normalizeConfigJson 只为 wecom_webhook 保存加密配置,mock 返回 false 是正确合同,不修改产品使 mock 保存密钥。 + +最小修复方案:第 8 步改为 wecom_webhook 与固定 loopback :9 地址,仍使用本轮合成 secret;保留脱敏和无明文返回断言,增加数据库 hasSecret=false/hasSecretEncrypted=true。渠道测试 API 保持 HTTP 200,provider 必须为 ok=false/wecom_network_error/retryable=true,证明读取加密配置后进入本机失败路径。1–7 的模拟投递和 9–10 的合成旧字段迁移保留,仅操作独占合成库,不读取真实密钥、不发送外部通知、不重跑恢复。语法检查后执行 server-regressions-r4,再按实际终态继续普通 full-local。 + +### G16 聚合第二轮与本地模拟通知前置 + +`server-regressions-r2` 439.002 秒:smoke、去重、审批、采购、资产、审计、OCR 七个启用套件通过,通知套件失败。已核原因是本轮 QA 统一设置 IM_NOTIFIER_ENABLED=false,导致原通知合同的模拟投递为空;不是企微真实发送失败。原脚本只创建 mock 及 loopback :9 失败渠道,空库seed不带真实通知配置,网络namespace零外部路由,WECOM_ENABLED/EMAIL_ENABLED仍false。 + +最小修复:普通 full-local 恢复 qa_export_app_env 原有的 IM_NOTIFIER_ENABLED=true 供聚合回归;retained-server-local 仅 server-regressions 使用 true,workflow-actions 和限定浏览器继续false。保持 mock_sent、本机失败重试、disabled 明确跳过、合成秘密加密迁移等原断言,不连接真实企微/不读真实密钥。fixture 接线测试捕获实际子进程布尔配置,随后重跑同一聚合套件。七个默认关闭可选套件不计通过。环境/源码/仓库/清理 PASS。 + +### G16 聚合回归首轮 + +`server-regressions-r1` 350.969 秒:smoke、去重、严格预设模式的 workflow-actions 和采购整组通过,资产组停在最终断言。根因已核为编号旧前缀 ZC 预期,当前资产规约和实现使用 FA;状态 in_use、责任人与位置符合当前流转。仅把编号断言改为 FA 年份序号合同,其余领用/归还/调拨/盘点/审计检查保留。后续审计/OCR/通知未运行,disabled 的可选组也不能计通过。环境/源码/仓库/清理 PASS。 + +付款模式源码反例 2/2 已验证:预设批次模式只接受精确 409,返回 200 不能切去 legacy 成功;恢复响应驱动分支的实际源码变异会被抓到。登记到稳定 QA 测试后再次执行。WebKit 官方 dry-run 已核 v2105,因原方案“不联网安装”约束已异步询问是否只准官方包到独占目录,尚未得到回答前不下载、不改系统依赖或旧缓存。 + +### G16 定向审批回归 + +`workflow-r1` 整脚本通过,386.224 秒;原场景 8、付款批次拒绝直接付款、不同状态拒绝、非财务拒绝、并发拒绝、票据解绑重绑及经理恢复均执行。环境 PASS、源码/仓库 unchanged、owner 清理 PASS。工具完整回归 45/45,另环境 13/13、先行接线 12/12,不累加成不同业务用例。随后自检把付款分支选择收紧为测试开始时 PAYMENT_BATCH_ENABLED 的预设模式,禁止根据响应状态选择宽松分支;下一次聚合回归会再次覆盖该更严格版本。 + +聚合回归前将 smoke SAMPLE_FILE 固定到本轮 scratch 的不存在路径,使原脚本只生成自己的合成 fallback,不读取共享临时样本。旧成功/失败回执保留。未完成普通 full-local、WebKit、冻结差旅与独立复核。 + +### G16 首次普通运行与补充实施边界 + +`full-r1` 999.419 秒,65 spec/359 用例完整发现;72 迁移、Prisma、服务端 4347 passed/1 原有 deploy-init integration skipped、前端 typecheck/build、8 角色正式改密通过。workflow-actions 越过场景 8,于场景 19 旧直接付款 200 预期失败,实际 409/payment_batch_v1_required。后续 server-regressions 和全部浏览器未运行。环境终态 ENVIRONMENT_INVALID 是 guest probe 尚未到达、bootstrapObservation=null;原生 online/namespace、源码/仓库不变和 owner 清理证据均保留,不改写终态。 + +继续在同一本地范围实施: + +- 普通 full-local 在启动 server 后、业务回归前执行 guest probe(浏览器重启后仍再测);让环境前置失败先阻断回归,保留普通 fail-fast。完整发现和浏览器显式 forbid-only。 +- 给原受管执行器增 `retained-server-local`,仅允许枚举的 workflow-actions/server-regressions,复用一次性库、构建、合成改密、guest probe及清理,单独选择回执且 normalReleaseEligible=false。不是完整门禁的替代,不新增真实写入或跳过开关。 +- 场景 19–22 按正式响应区分现行 batch-required 边界与仍保留的 legacy-disabled 合同。batch-required 分支检查批准/草稿/审批中/驳回/取消状态直接付款均拒绝、非财务 403、重复及并发请求拒绝、支付字段和发票均未变;不关闭付款批次开关求旧成功。原 legacy 分支保留,未执行就不宣称其成功路径通过。修正已改名负金额夹具引用,删除已无调用的旧经理任务/skip 辅助。 +- 定向真实回归先跑,按实际后续停点修当前在用断言;本轮不能把新定向通过拼接为普通 full-local 全绿。独立、WebKit、冻结差旅、R1 与外部批准仍开放。 + +## 2026-09-18 G12–G15 本地收口回执(当前入口) + +官方current为本task,会话来源有效,状态仍in_progress。证据入口`artifacts/qa-layout/retained-gates-closeout-20260917`,受管运行入口`artifacts/qa-flow/retained-gates-closeout-20260917`;沿开始日期保留目录,不覆盖旧候选。用户连续执行批准限定本地合成/在用验收,未扩到真实操作或发布。 + +### 本轮结果 + +原58身份及54失败/4超时历史不回写。现53条已有当前合同重验证据(52浏览器、1静态函数),4条明确旧/冻结流程排除,1条WebKit报销离线证据因本机运行时缺失仍NOT_RUN,当前定向重验失败余额0。这里是跨轮逐项覆盖,不是一次53项全量运行,更不是53个已修产品bug;原排除项未批量skip或删除。 + +- G12:新增受管`retained-local`显式选择模式及一次性8角色合成改密前置,保留真实must-change约束。先验证管理员初始浏览器进入改密页,再由正式API串行验证8角色改密、旧密码401及新密码登录;业务并发阶段不改密码。普通full-local尚未接入该前置,`normalReleaseEligible=false`。 +- G13:页面定位、角色入口、明确绑定明细、企微只读边界与上海日历日合成输入已对齐。两条主链复用辅助流程;正式小额manager→finance审批、财务审批当前页completed、制单人403、无回单409、合成有效回单的worker失败留痕→人工更正→独立复核→付款登记、付款后只读/回单不可作废、审计/AP/票据归档/登出均在r6整例通过。预览只验UI签名/弹窗/正式PDF字节,不冒充headless原生阅读器渲染。 +- G14:r3的真实PWA A受控→同profile切B自动收敛通过,旧独有缓存0、新资源实际加载、worker摘要对应、无登录回退/静态404;根因是构建继承NODE_ENV=test,已仅固定Vite构建子进程production,服务仍test。WebKit保持未验。 +- G15:原58逐项结果映射、断言替代合同、当前QA和上批产品独立复核问题、4类外部事实清单已整理。独立review仍PENDING;最终治理/保护与候选摘要以同目录final-check-summary.json和candidate-integrity.json为准。 + +### 执行与失败过程 + +| 运行 | 完整结果 | 边界 | +| --- | --- | --- | +| auth-r2 | 1/1管理整例与8角色前置通过 | 非全量认证套件 | +| mock-green-r1 | 11/11通过 | 10条历史映射+1合同DOM反例;DG7、leave另在先前轮有通过 | +| real-cases-r1 | 11通过/13失败 | 初轮24条,不改旧报告 | +| real-cases-r2 | 中断,无最终JSON | 环境/输入/清理有回执,过程日志不补造PASS | +| real-cases-r3 | 8通过/5失败 | 包含真实PWA通过,零重试/跳过 | +| real-cases-r4 | 浏览器前主动中断 | 内层dropdb因代理退出失败;外层owner校验删除容器/临时目录,ENVIRONMENT_INVALID,不计业务通过 | +| real-cases-r5 | 1通过/4失败 | 手机报销整例通过 | +| real-cases-r6 | 3通过/1失败 | 两条付款主链、进度通过;管理员旧入口预期失败 | +| real-cases-r7 | 1/1通过 | 完整PC/移动角色矩阵及admin批次复核403 | + +所有完整受管轮均核sourceUnchanged/repositoryUnchanged与归属清理。类型检查r3/r4/r6/r7通过;较早browser-types-r2因Node类型入口选择不对退出2,后续使用既有server类型目录,无安装。QA账号9、环境回归46、PWA工具26各自通过且分列,不累加为业务覆盖。r6/r7的72迁移与前端typecheck/production构建通过。未改产品逻辑/UI/schema;旧文档登记与Browserslist告警保留。 + +治理/process、四task validate、CURRENT敏感扫描、shell语法和diff检查通过;docs为0错误/既有22未登记文件告警。23个旧目录6946文件的精确集合/SHA、旧差异快照、HEAD/index均保持;本轮只在8个原脏文档/spec路径上追加计划与回执,其余旧差异内容不变。新增复核包另根冻结,独立仍PENDING。 + +### 后续余额与顺序 + +1. G14剩1条WebKit:补具备该运行时的受管本地环境后重验,不用Chromium冒充。 +2. 普通发布门禁另行接入串行合成账号初始化;明确4项旧/冻结观测在完整发现集合中的归属,旧日常OA流程与冻结差旅分别处置,不暗中skip/删冻结测试。现有服务端场景8/经理缺口仍按原记录核验,未被本轮浏览器结果抵扣。 +3. 原AC7的同状态管理权限正向对照和完整独立反例门禁尚未闭合;本轮合同只读+DOM泄露反例只支持其部分条件。R1仍需普通full-local全绿、完整耗时/覆盖和单独启用批准。 +4. 当前QA、上批报销/借款/ERP及K0等候选接独立只读复核;真实批次处理人/企微个人发送、银行样本、K1保管事实及K2新绑定按清单分别取得事实与授权。K0合成、历史恢复及新借款本地通过互不替代。 + +不连接生产/ARM,不读取真实配置密钥备份,不重跑旧恢复;未提交、推送、部署、归档、发送真实通知或付款。公网未据此更新,task原R1/完整验收未补勾。 + +## 2026-09-17 G12–G15接续执行(当前入口) + +用户批准继续推进,官方current已切回本task。只做本地合成环境及在用验收;基线与独立输出为`artifacts/qa-layout/retained-gates-closeout-20260917`,包含上一批借款/ERP候选的保护。原58观测及此前15重验/4排除不回写。 + +- [x] G12:真实隔离DB的首次登录→强制改密→新凭据重登录,预置仅由运行所有者串行完成;测试并发阶段不改密码。运行随机初始密码经正式改密成为既有公开合成fixture值;不改共享helper或产品强制改密。 +- [x] G13:逐例重验10个页面/fixture观测与合同详情/AP权限;先复现,沿当前合同修测试,不降低权限或恢复旧布局。 +- [ ] G14:PWA双生产构建/同profile/真实SW已通过;本机WebKit缺失,1条仍NOT_RUN,不下载、不以Chromium或mock抵扣。 +- [x] G15:完成在用观测结果映射、独立复核问题清单、真实批次/银行样本/恢复事实核查清单及最终保护/治理回执。 + +复用现有测试入口和隔离机制,不启用R1或宣称全量发布门禁通过。inline独立复核PENDING;不连接生产/ARM、不读真实配置密钥备份、不重跑旧恢复、不提交推送部署或发送真实通知。 + # 执行计划与阶段 0 基线记录 +## 2026-09-17 G9–G11 本地执行回执(当前入口) + +本轮最终交接合并到 `import-parser-fix-20260917/review-candidate-r2`,门禁分类/运行日志与浏览器材料的相对路径SHA另列;原报告和旧包不覆盖。重跑移动10项时显式选择 `r101-mobile-wip-closeout.spec.ts r6-mobile-unified-design-rollout.spec.ts` 并使用新运行名:当前临时config已加入G11文件,不能把无筛选重跑范围当作原10项范围。候选检查和主会话结论分别有记录,独立复核仍PENDING。 + +58条原观测身份/原报告SHA已逐一核对并分类:26登录前置、24页面/fixture、2已替代断言、3缺浏览器、1后续AP修复待整例、1PWA待定、1冻结差旅权限。按当前范围为40保留合同、14企微摘要/只读边界、4旧或冻结流程排除;企微主办不等于删除摘要/审计验收。原199/54/4/18和58分母不改。 + +本轮15条历史观测对应的当前用例已取得本地重验,4条明确排除,另39条保留项未整例重验;不是39个产品bug。历史原状态仍保留,新结果单独列于release-gate-triage-20260917/historical-failure-triage.json/md。无新增全量skip、无删用例、无产品UI/权限变化。 + +- R12:旧断言要求未知VALID_001原message回显,与09-05安全合同冲突。先红后修,固定安全兜底、合法/非法requestId、注册导入原因断言通过;同文件7项纯源码/函数回归通过,无浏览器/DB。 +- G10:R101三项+R6七视口10/10通过,修复旧首页标题、模糊报销heading和企微文案重复匹配。原导航、卡片、横向溢出、触控、无OA日常表单及截图检查保留。首轮mobile-red因本轮代理误拒loopback而3失败/1中断,未到业务,保留为环境失败;修正仅转发本轮静态源后mobile-red-r2四项分别到达目标红例,再修测试定位。未修改业务页面。 +- G11:R100首页、AP台账切tab、公告截止时钟漂移三项先红后修;公告固定浏览器与合成API共同场景时钟,并增加逾期补签分支。高敏附件删除旧例当前已通过,只补重验、不重复修改。最终retained-green 5/5通过;它与G10 10项覆盖不同用例,7项静态回归另列。 +- 本地仅client/dist只读静态源、合成API和Chromium。新runner前置证明静态200、API404、写405、外部代理403、仅当前loopback转发200;无后端/DB或外部上游,结束后两本地监听关闭。早期network摘要的upstreamConnections=0指外部上游,后续明确分为externalUpstreamConnections与loopbackProxyForwards。WebKit当前缺失,无下载;此环境block SW,不冒充PWA或完整受管诊断。 + +精确命令见同目录command.json;主要命令为`node artifacts/qa-layout/release-gate-triage-20260917/run-mock-browser.cjs mobile-green`(当时限定R101/R6)与`... retained-green --grep '手机首页和我的页可生成视觉诊断|BIZ8 AP|制度签收公告|高敏附件删除要求'`。纯函数回归为`cd client && node node_modules/@playwright/test/cli.js test tests/e2e/interaction-pattern-unification.spec.ts --workers=1 --retries=0 --reporter=line --output=../artifacts/qa-layout/release-gate-triage-20260917/static-contract-output`。直接筛选重验不是普通发布门禁。 + +后续39条细化设计已写design顶部:25登录前置、10页面/fixture、合同只读1、AP整例1、WebKit报销离线1、PWA1。保护初始密码必须修改的合同,先规划一次性合成账号生命周期;不改seed为免改密、不并发回写同一账号、不恢复旧日常OA请假/差旅。独立仍PENDING,R1/完整发布门禁未完成,未提交/推送/部署或真实业务/恢复操作。 + +## 2026-09-17 58条历史观测分类与在用测试修复 + +### G10 在用移动页定位修复(分类后的限定批次) + +后续 G11 限定四个现行合成用例:R100移动首页同类旧标题、BIZ8财务台账需显式切当前tab、公告签收固定截止日期过期引起的时间漂移,以及高敏附件删除原例的当前重验。前三项只修已复现的测试入口/时钟,不更改产品标题、默认tab、签收规则或付款/附件权限;高敏附件例先重跑,若当前已通过则只登记后续修复证据,不重复开发。截图仍新目录、外部网络拒绝、零重试。未知产品行为差异继续保留,不以宽匹配/去断言替代修复。 + +R101三例及R6七视口仍验证移动首页、报销和企微只读请假说明,不是恢复OA日常考勤。历史停点为“今天”旧标题、模糊“报销”标题和两处企微文案的strict匹配。先在当前构建+原合成API下重现,只更新已证实漂移的定位,采用唯一一级标题/完整固定说明;保留无OA表单、布局/横向溢出/导航/截图等原行为断言,不加first/skip、不改产品样式。 + +使用现有Playwright测试及本轮只读静态服务器:绑定随机loopback端口,仅服务client/dist,拒绝API和写方法,无上游代理;浏览器其他网络走本地拒绝代理。每轮新输出根,R6截图显式指向本轮新目录,原output/归档截图不覆盖。已有构建可复用,若缺浏览器不下载、不把未运行记PASS。26条登录前置中20条明确落/change-password、2条/login、4条原waitForURL未提供最终URL;当前seed-dev强制改初始密码是安全合同,不修改为false或在共享helper里绕过。其修复需先设计独立合成账号改密生命周期,避免并发套件互改密码。 + +按用户最新批准接续,官方current已切本task并回读;09-05 B34产品定向回归922/922与前端构建已通过,本task接管门禁分类及限定测试修复。旧“58条不修”是原阶段授权时点,当前允许按在用性先规划后修;不解禁R1,不实施新权限规则、真实数据/外联/提交发布或借款。 + +- [x] G9-1:核原58条身份、失败层次、当前源码/合同与是否旧或待精简,形成逐项分类及后续最小验证,不把登录/环境错误混算产品缺陷。 +- [x] G9-2:先修R12当前安全提示断言漂移;保留红例和核心保护断言,补固定兜底/注册原因/排障编号的函数行为,纯本地测试。 +- [x] G9-3:分类后为仍在用组写限定实施顺序、环境前提和验收,不以已过定向测试扣减原58或称全量门禁已绿。 +- [x] G9-4:完成受影响检查、来源SHA和旧差异/冻结材料保护,更新09-08协调入口及独立复核交接。 + +本轮证据 release-gate-triage-20260917;产品输入与B34增量分开记录。历史原报告来自限定主checkout本地合成诊断,只读,不访问其真实环境配置或运行服务。 + +## 2026-09-17 历史发布回执接回(仅本地文档对账) + +09-08本轮W8逐条件对账从主checkout同名task implement的“已按本候选例外完成部署(2026-09-09)”接回原文档引用,并核现有脱敏postcheck-result.json;不复制整份旧文档覆盖本副本,也不读取原始发布日志/环境或连接远端。准确来源/行号/摘要与布尔事实见acceptance-boundaries-20260917的historical-acceptance-review.json及historical-report-facts.json。 + +四处迁移状态谓词修复已随b87cc55发布;该事实仅闭合追加预检阶段,不代表R1普通完整门禁、阶段B合同用例或58条诊断观测已通过。R1/阶段B继续原冻结,先按保留在用场景分类再另定限定批次,不盲修待精简旧测试。 + +本次未执行新的发布、真实查询或业务写入;旧“未部署”段落是当时记录,不再代表追加阶段当前余额。原PRD未满足项不补勾,status/completedAt/历史commit不改,不归档。当前G8未提交产品增量仍待独立复核和新的交付授权。 + ## 2026-09-08 独立复核与提交授权 - 用户已独立复核通过归档恢复、输出隔离、全仓回执与摘要校验的 33 文件包;本轮只补记复核结论,不更改获批实现。 @@ -469,3 +1066,14 @@ seed 交审前补正 Compose init 参数数组并新增执行测试后,再次 - 预检缺陷定性是“该拦时可能拦不住”,未证明生产曾误放行。独立审查在其环境较大输出触发 141,但约 64KB 不是通用安全阈值;此前已有短输出捕获 producer=141/consumer=0 的记录,调度和管道实现都会影响触发,不采纳“日常绝不会触发”的推断。 两笔提交准备顺序:先 preflight(门禁 task 三文档、预检脚本、测试及 spec 对应增量),再 seed(原 17 文件范围,包含 F1/F2 手册补充和 seed task 最新状态;spec 只取 seed 增量)。共享 spec 第二笔以第一笔结果为基线。提交、推送、完整候选门禁与部署均未因此授权;当前只做隔离集成和定向验证。 +## 2026-09-18 用户批准后续六组工作的本地接续 + +新证据根submit-visual-closeout-20260918已保全352项旧差异、34目录12138份旧材料。先给全页面巡检补精确阶段计时,再由09-18-local-concurrency-product-fixes承接P9原提交事务诊断、定稿和修复;两项原整文件通过后,回本task从static执行新完整候选。保持原预算/并发/零重试,不复用旧完整绿灯;受管运行期间源码/测试/task/spec/docs冻结。 +## 2026-09-18 P9/全页面最小修复后验证 + +P9由09-18-local-concurrency-product-fixes完成单次预检实体读取与条件查询,原aggregate/事务/校验保持,相关unit已过;本task接续。全页面诊断9过/1超时,已按design去除500ms安静窗口之后的300ms重复等待,改精确poll间隔而非缩短500ms门槛。先专用类型与语法,再无预载R75原四项、完整报销付款链、视觉原六项,共三个完整文件重验;之后才从static跑新完整候选。预算/并发/零重试、独立及生产边界不变。 +## p9-fixed-r3终态及下一验收决策 + +三个完整文件11项10通过/1视觉超时,607.746秒,零skip/重试。R75四项与付款主链281.132秒通过;环境/输入/owner清理PASS、1347服务响应/0未处理异常。预检最小修复的原合成链验证已完成;全量可靠性仍未证明。视觉在同轮负载下只完成15页,未据局部通过关闭P9/P8/G20。 + +design已给出按既有角色+终端拆7组、保留33页面及强制完整覆盖的具体方案,改变累计预算边界,待用户批准;未实施拆分。其余已授权的本地静态/治理检查继续;没有直接盲重跑已知失败的完整浏览器候选,也不新增生产操作。 diff --git a/.trellis/tasks/09-06-release-gate-integrity/prd.md b/.trellis/tasks/09-06-release-gate-integrity/prd.md index 3c3250cd..00a8f8a9 100644 --- a/.trellis/tasks/09-06-release-gate-integrity/prd.md +++ b/.trellis/tasks/09-06-release-gate-integrity/prd.md @@ -1,5 +1,57 @@ +## 2026-09-19 最新本地验收 + +g20-full-r6同一冻结候选完成static及12个full-local阶段,4437后端通过/1原条件跳过、372本地浏览器全过,零失败/跳过/重试。7组33页完整;PARTIAL仅dirty_candidate,localChecksPassed=true,无部署资格。R1真实入口启用及独立、公网12义务、旧registry保护例外仍开放;完整阶段/计时/来源边界与余额见implement顶部。历史FAIL原样保留,不能把本地通过写为所有交付完成。 + +## 2026-09-18 已批准视觉分组验收 + +33页矩阵改为7组、各180秒,共用一次计时合成准备;全部页面/断言/截图、全局并发和零重试保留。同一完整运行必须7组全过并精确覆盖33页,缺页/重复/未审完/失败或跳过组拒绝。设计与implement当前入口已同步;此批准仅替代全页面原累计180秒,不放宽其他门禁,不授权部署。 + +## 2026-09-18 G20 接续执行(最新用户批准) + +用户在解释 WebKit 用途及五组余项后明确“按照这个规划开始执行,非必要不暂停,尽量做完”。本轮据此批准官方 Playwright WebKit 运行包下载到本轮独占缓存,及既定本地完整验收和共享候选回执机制实施;不再列下载授权待答复。仅运行包,不隐式安装系统包或修改共享缓存;业务测试继续无外联合成环境。 + +沿用 G19 分层合同。先补 WebKit 真实启动/报销离线敏感证据单例,再普通 full-local。受管运行期间源码/测试/task/spec/docs冻结,只写本轮 ignored artifacts。旧失败/旧候选不覆盖。 + +## 2026-09-18 G19 最新验收状态 + +分层验收已获明确批准并本地实施验证:完整发现65/365,351本地、12同候选公网义务、2冻结旧链。74项覆盖/runner、7项诊断检查和2条本地冻结保护通过;原完整full-r2仍失败,公网NOT_RUN_LOCAL、独立PENDING,R1未启用。具体合同、证据、失败保留及后续顺序见implement顶部;未提交部署或执行真实操作。 + +## 2026-09-18 G18 最新状态 + +OCR/R108/33路由项覆盖及三处限定产品修复已完成本地验证;最终18项共享入口、原四文件并发4/4及131定向unit通过。G18时点完整发现64/363,仅枚举;原full-r2仍失败,全部AC/R1不因局部绿自动补勾。产品PWA/采购归09-18-local-concurrency-product-fixes,当前结果/失败保留/分组余项详见implement顶部与local-coverage-g18-20260918。独立PENDING,无提交发布或真实操作。 + +## 2026-09-18 G19 分层验收(用户已明确批准,实施中) + +用户批准:12 条公网用例作为同一候选发布后必跑项;2 条冻结差旅保留原源码与历史结果,改验冻结保护。本地通过不代表公网/完整交付通过,不授权部署、恢复差旅或真实操作。此增量替代 R2 的“排除集合为空”,不重写旧 full-r2 失败。 + +- [x] G19a:全体非 manual 用例继续真实发现,固定 12 live+2 frozen 的用例身份、原因、责任角色、必跑入口和原源码摘要;未指派实际负责人明确待定。 +- [x] G19b:由完整发现与受控合同生成本地执行文件集合,登记用例级覆盖;拒绝遗漏、身份漂移、未知/重复、跳过、重试与 expected-failure。禁止调用者筛选普通本地集合。 +- [x] G19c:新增两条冻结保护整例,验证现行合成角色入口/正式 API 拒绝、共享付款差旅 blocker 与入站导入权限拒绝;不改权限,不执行旧差旅链。 +- [x] G19d:公网义务在本地保持 NOT_RUN_LOCAL,绑定候选提交与源码摘要;公网用例结果只能由同候选、live profile、完整逐项通过报告兑现。验收回执不自授部署或发布资格。 +- [x] G19e:增加漏例/新增未知归属/漂移/伪通过/候选不匹配/真实 runner 选择等反例,运行本地冻结保护,更新 spec/协调 task 与新候选;独立仍 PENDING。 + +WebKit 下载问题仍待答复,完整普通门禁未通过;R1 机制后续与真实强制消费启用分开,不启用发布入口。所有旧差异、失败材料与冻结包保持。 + # 部署门禁完整性与候选验收回执 +## 2026-09-18 G18 在用OCR修正缺陷 + +恢复本地OCR条件覆盖后,实测暴露票据详情URL/本地状态双向回写导致无法点击人工修正。依当前范围只消除重复状态源,维持既有URL导航/权限/OCR业务合同;具体红例、修复与验收见implement顶部。G18其余覆盖和普通全量/R1/独立/外部余额继续分列。 + +## 2026-09-18 G16/G17 最新验收状态 + +普通 full-r2 已完整执行但失败:65/359,326 通过/12 失败/5 超时/16 跳过。后续 14 条原失败观测及 2 条串行未执行项已有定向通过;WebKit 1 条未验、冻结差旅 2 条保留。当前发现 64/358,无缺失/未知;发现包装器末尾临时 SHA 采集告警保留。原结果不改写、不拼接普通全绿,完整集合/运行可靠性、条件用例归属、R1 和独立审阅仍开放。最新执行与六组余额以 implement 顶部及 full-gates-followup-20260918 证据为准。原 AC 未以局部结果自动补勾。 + +## 2026-09-18 本地限定验收状态 + +G12/G13在本地受管合成环境完成;原58逐项为53已重验(52浏览器+1静态)、4旧/冻结排除、1WebKit未验。真实PWA双构建升级及报销两步审批/独立付款/回单/下游整例通过,所有结果只归对应定向候选,不修改原54失败/4超时的历史分母。G15复核与外部事实清单已备;独立PENDING,普通full-local账号前置/完整发现处置、原AC7及R1未完成。具体证据、失败轮次和后续顺序见implement顶部;未部署或执行真实操作。 + +## 2026-09-17 在用门禁分类与限定修复(用户已批准) + +用户批准按最新余项建议实施。本轮先把原54失败/4超时的58条观测逐项接回当前源码与产品边界,区分现行合同、已被替代断言、旧/待精简路径、环境/登录前置和已由后续任务处理但尚缺整例重验的项。旧结果保持,不改分母、不批量skip,不把分类或定向通过冒充全量发布资格。 + +对已明确属于现行合同且能用本地合成输入验证的测试漂移,按implement先落最小批次再修复;新权限/业务/视觉规则不在本轮制定。当前首批为R12共享错误提示断言接回09-05已批准的安全兜底/原因码合同。生产/ARM、真实配置密钥备份、旧恢复、提交部署与借款不执行;R1启用仍未批准,独立复核不由inline自检代替。 + 2026-09-07 后续最小增量已批准:保全本轮四张截图、恢复已归档证据;两个 spec 仅调整输出路径,默认和受管调用均不得覆盖归档目录;回执增加完整 Git 状态、全仓已跟踪内容与 index 语义状态,限定源码不变与全仓不变分开判断。并入可定位的 14 张归档截图摘要校验,不读取恢复演练私有候选或生产文件,不宣称任意历史 SHA 均可本地重验。58 条环境有效观测保留,不重跑全套诊断,不启动合同阶段 B、认证迁移或 R1。 ## Goal @@ -40,7 +92,7 @@ ### R2. 回执绑定来源和实际覆盖,不能由定向结果冒充 - 标准发布的 Playwright 执行必须显式传 --retries=0,覆盖 CI 环境下配置默认重试一次的行为;这是 R2 独立要求,不依附 PWA 是否存在。PWA 不要求全局 --workers=1,保留既有本地 4 / CI 2 workers 默认及文件内 serial。 -- 预期集合必须解释当前候选下每个非 manual spec 的归属:要么进入完整验收集合,要么进入显式登记且带原因、必跑入口、责任归属和验收证据的例外集合;不得两边都没有,也不能从回执自报 expected 或既有 acceptanceSpecs 子集反推全量。遗漏、未知归属或有登记却没有兑现执行义务均不能成为完整通过。本轮已批准采用 (a),排除集合为空,具体实现见设计第 8 节。 +- 预期集合必须解释当前候选下每个非 manual spec 的归属:要么进入完整验收集合,要么进入显式登记且带原因、必跑入口、责任归属和验收证据的例外集合;不得两边都没有,也不能从回执自报 expected 或既有 acceptanceSpecs 子集反推全量。遗漏、未知归属或有登记却没有兑现执行义务均不能成为完整通过。2026-09-18 已批准 G19:12 条公网义务在同候选发布后兑现,2 条冻结整链保留原源码/历史结果并由冻结保护替代当前执行;其余本地用例必须完整执行。完整交付须另核公网义务,不能把 NOT_RUN_LOCAL 计为通过。PWA 仍无例外,具体实现见 G19 及设计第 8 节。 - 持久回执至少记录:完整候选提交、来源及校验策略摘要、执行 profile/模式、阶段状态与退出码、真实起止时间、工具链、实际筛选参数、预期和实际 spec 集合、用例集合及逐项结果、异常/跳过记录。 - 环境事实属于同级必需输入:受管环境策略/runner/观测器摘要、运行平台、命名空间创建归属及可核验关系、原生在线探针和候选登录入口黑盒观测、观测作用域、环境检查/清理状态。缺失、未知或与当前受管 profile 不一致时不得获得完整验收或发布资格;不能只信调用者的 ONLINE=1、GATE_PASSED=1 或回执自报 environmentPassed=true。具体字段与判据见已批准的设计 §10;本轮实施验证状态见 implement.md。 - spec 使用仓库相对路径;集合来源是当前候选下的真实发现与执行报告,不是人工声明“跑了全套”。同一 spec 内的 --grep 仍可能漏测,所以还必须核对用例级覆盖,并记录 grep、project、shard、only 等影响范围的条件。 @@ -90,8 +142,8 @@ - [ ] AC4:旧提交、脏树、输入变化、缺失/截断/中断回执、同文件漏用例、受管用例跳过/重试/expected-failure、伪布尔“已通过”均使发布验证非零退出。(R2) - [ ] AC5:两个 skip 开关各自及组合都验证:缺理由/批准引用失败;获批诊断为有留痕的 PARTIAL/非零;均不能进入普通发布。(R3) - [ ] AC6:真实源码变异移除入口验证、放宽 profile/来源/覆盖判定、恢复无记录 skip 时,对应隔离回归必须变红;断言按执行事件和集合语义,不 grep 某个 flag 字符串。(R1/R2/R3) -- [ ] AC7:修复后的合同只读用例实际进入详情,续约/归档负向断言与管理身份对照均执行;人为暴露任一禁止动作时测试失败,不增加 timeout、不删断言。(R4) -- [ ] AC8:既有部署安全、来源和 49 项 db 生命周期回归保持有效;夹具升级不得引入测试专用发布绕过口或削弱 fake 工具防泄漏检查。(R5) +- [x] AC7:修复后的合同只读用例实际进入详情,续约/归档负向断言与管理身份对照均执行;人为暴露任一禁止动作时测试失败,不增加 timeout、不删断言。(R4) +- [x] AC8:既有部署安全、来源和 49 项 db 生命周期回归保持有效;夹具升级不得引入测试专用发布绕过口或削弱 fake 工具防泄漏检查。(R5) - [ ] AC9:本 task 的覆盖核对、回执反例及已批准用例修复通过;原样完整本地运行及全部未通过、未覆盖部分如实留痕,dirty 诊断不具备发布资格。额外红项阻断完整候选资格与 R1 生效,不用聚焦绿抵扣;本地机制 diff 可单独交只读独立复核,但不得宣称完整门禁已绿。相关 spec/docs 及治理检查完成,不要求先偷偷提交来取得发布回执。(R1–R5) ## Post-task Authorization Gates diff --git a/.trellis/tasks/09-06-release-gate-integrity/task.json b/.trellis/tasks/09-06-release-gate-integrity/task.json index 5818be92..529b9d71 100644 --- a/.trellis/tasks/09-06-release-gate-integrity/task.json +++ b/.trellis/tasks/09-06-release-gate-integrity/task.json @@ -2,7 +2,7 @@ "id": "release-gate-integrity", "name": "release-gate-integrity", "title": "部署门禁完整性与候选验收回执", - "description": "本轮§11归档截图保护与全仓回执增量:保全并恢复四图,两个spec仅改截图输出路径,增加完整Git/tracked/index快照及14张登记证据校验;64项QA与typecheck通过,待独立复核。不重跑全套,不扩认证/业务断言,场景8/经理缺口/R1挂起。", + "description": "G18在用覆盖和三处产品修复已本地验证;原四文件并发4/4、认证/OCR18/18通过。全量合同、WebKit、R1、独立及真实操作仍开放。", "status": "in_progress", "dev_type": null, "scope": "release-gate", @@ -12,7 +12,7 @@ "assignee": "shunli", "createdAt": "2026-09-06", "completedAt": null, - "branch": "main", + "branch": "codex/20260919-reviewed-candidate", "base_branch": "main", "worktree_path": null, "commit": null, @@ -21,6 +21,155 @@ "children": [], "parent": null, "relatedFiles": [], - "notes": "2026-09-07 §10已实施并完成一次环境有效的全套诊断,增量待用户独立复核。原21文件加6个QA环境/测试文件,共27文件;本轮产品源码和业务spec零改动,51项针对性验证通过。前三次启动在环境前置拒绝,资源已核对清理;修复跨user namespace读取父namespace的EACCES,改由父runner经本轮Unix IPC即时实测,不降级。第四次环境PASS、源码前后不变:65文件275项,199 passed/54 failed/4 timedOut/18 skipped,58条观测涉及36文件,不是独立产品缺陷计数;诊断仍PARTIAL/2。服务端193文件1692项通过,workflow场景8两个来源仍失败。R102 6/6通过;合同仍卡旧更多,续约/归档未执行;PWA在serviceWorker.ready超时且该失败无trace附件。诊断67分03秒不是普通全绿成本。旧207撤销决策资格但保留原件;阶段1、阶段B、场景8、经理缺口和R1均未扩大执行。无提交/push/归档/部署/生产操作,整体任务未完成。", - "meta": {"archiveOutputProtection": "implemented_pending_independent_review", "registeredLocalEvidenceVerified": 14, "runtimeScreenshotsPreserved": 4, "targetedQaTestsPassed": 64, "fullDiagnosticRerun": false} + "notes": "2026-09-19 根据用户对重复全浏览器成本的反馈,中止budget-full-r1;后端4446与PG28通过保留,浏览器29通过/4中断/339未运行,回执FAIL/130,环境清理PASS。待Claude实际补丁复核,发布完整门禁延期,不再重跑无关测试。\n2026-09-19 预算并发修复已定向通过 PG28/单测526;待新完整候选与独立审查,旧r6为历史输入。\n2026-09-19 用户独立核实确认4437/r6时序及累计diff基线,并报告当前采购测试75 passed/EXIT=0、SHA一致;数量与证据归属争议闭合。完整独立业务review、发布边界及两条例外保持。\n2026-09-19 用户转述局部审查已核对:UTC/北京时间混用及累计diff误作运行后diff;r6实际在P10之后,4437准确,1162源码快照条目与当前一致。证据review-provenance-20260919.json,完整独立review仍PENDING,原证据不改。\n2026-09-19 g20-full-r6:4437后端通过/1原跳过,372本地浏览器全过;PARTIAL仅dirty_candidate,独立PENDING/无部署,旧registry保护例外保留且无新增漂移。当前余额见implement顶部。\n2026-09-18 G19分层验收已批准并本地实施验证,74+7合成检查和2条冻结保护通过;公网12义务/完整门禁/WebKit/R1/独立及真实事实保持开放。\n2026-09-18 G18:QA39、产品131、并发4/4、认证/OCR18/18,33路由项与R108通过;G18时点发现64/363;G19最新发现见g19-discovery/summary.json,旧完整full-r2仍失败。候选与余项见local-coverage-g18-20260918。无提交部署,独立PENDING。\n2026-09-18 G16/G17:普通 full-r2 65/359仍失败;14原失败及2串行缺失已有定向通过,1WebKit/2冻结差旅保留。当前发现64/358,有临时SHA采集告警;QA/治理/保护与增量候选见 full-gates-followup-20260918。整体in_progress、独立PENDING,六组余项见implement顶部,无提交部署/外部操作。\n2026-09-18 G12/G13本地收口,G14真实PWA通过但WebKit1条未验;原58现53重验/4排除/1未验,原历史不改。G15复核及外部事实清单与新候选在retained-gates-closeout-20260917;普通full-local/AC7完整/R1和独立PENDING,不提交部署或真实操作。\n2026-09-17 W8本地对账:四处迁移状态谓词修复已随b87cc55发布;该事实仅闭合追加预检阶段,不代表R1普通完整门禁、阶段B合同用例或58条诊断观测已通过。R1/阶段B继续原冻结,先按保留在用场景分类再另定限定批次,不盲修待精简旧测试。 原回执从主副本接回,见implement顶部;本轮无新的远端/提交发布,原验收未补勾。\n2026-09-07 §10已实施并完成一次环境有效的全套诊断,增量待用户独立复核。原21文件加6个QA环境/测试文件,共27文件;本轮产品源码和业务spec零改动,51项针对性验证通过。前三次启动在环境前置拒绝,资源已核对清理;修复跨user namespace读取父namespace的EACCES,改由父runner经本轮Unix IPC即时实测,不降级。第四次环境PASS、源码前后不变:65文件275项,199 passed/54 failed/4 timedOut/18 skipped,58条观测涉及36文件,不是独立产品缺陷计数;诊断仍PARTIAL/2。服务端193文件1692项通过,workflow场景8两个来源仍失败。R102 6/6通过;合同仍卡旧更多,续约/归档未执行;PWA在serviceWorker.ready超时且该失败无trace附件。诊断67分03秒不是普通全绿成本。旧207撤销决策资格但保留原件;阶段1、阶段B、场景8、经理缺口和R1均未扩大执行。无提交/push/归档/部署/生产操作,整体任务未完成。", + "meta": { + "archiveOutputProtection": "implemented_pending_independent_review", + "registeredLocalEvidenceVerified": 14, + "runtimeScreenshotsPreserved": 4, + "targetedQaTestsPassed": 64, + "fullDiagnosticRerun": false, + "g16g17": { + "ordinaryFullResult": "FAIL", + "originalBrowserCases": 359, + "focusedRevalidatedOriginalFailures": 14, + "revalidatedSerialOmissions": 2, + "currentDiscoveredCases": 358, + "independentReview": "PENDING", + "normalReleaseEligible": false + }, + "g18": { + "currentDiscoveredCases": 363, + "currentDiscoveredSpecs": 64, + "concurrentGroupPassed": 4, + "authOcrPassed": 18, + "ordinaryFullResult": "FAIL", + "independentReview": "PENDING", + "normalReleaseEligible": false + }, + "g19": { + "state": "LOCAL_VERIFIED_PUBLIC_PENDING", + "discovered": 365, + "localMandatory": 351, + "publicMandatory": 12, + "frozenRetained": 2, + "coverageTests": 74, + "freezeProtectionPassed": 2, + "independentReview": "PENDING", + "normalReleaseEligible": false + }, + "latestLocalCandidate": { + "run": "g20-full-r6", + "localChecksPassed": true, + "receiptStatus": "PARTIAL", + "browserPassed": 372, + "publicPending": 12, + "independentReview": "PARTIAL_WITH_BLOCKING_FINDING", + "deploymentEligible": false, + "legacyProtectionStatus": "FAIL_ONE_KNOWN_DERIVED_REGISTRY", + "newProtectedDrift": false, + "productionNumberingActivated": false + }, + "reviewProvenanceReconciliation": { + "status": "CONFIRMED_BY_USER_INDEPENDENT_CHECK", + "evidence": "artifacts/qa-layout/submit-visual-closeout-20260918/review-provenance-20260919.json", + "r6BackendPassed": 4437, + "earlierBackendPassed": 4428, + "currentSourceDigestMatchesR6": true, + "independentReview": "PROVENANCE_CONFIRMED_FULL_BUSINESS_REVIEW_PENDING", + "deploymentEligible": false, + "confirmedScope": [ + "test counts", + "UTC and Asia/Shanghai chronology", + "incremental diff baseline", + "release eligibility boundary", + "retained exceptions" + ], + "reportedFocusedRetest": { + "source": "user-provided independent verification", + "file": "server/tests/unit/routes/purchase-request.errors.test.js", + "passed": 75, + "exitCode": 0, + "sha256Prefix": "2b9ec41d", + "rawLogInspectedByMainSession": false + }, + "countAndProvenanceDispute": "CLOSED" + }, + "claudeBusinessReview20260919": { + "status": "PARTIAL", + "source": "user_provided_claude_report", + "rawReviewerLogsObtained": false, + "blockingFindingsReported": 0, + "pending": [ + "feeControl budget/expense-standard semantics", + "reimbursements concurrency/transaction paths", + "K0 contract and local coverage", + "remaining consumers/pages/migrations after targeted coverage check" + ], + "evidence": "artifacts/qa-governance/trellis-0617-review-followup-20260919", + "deploymentEligible": false + }, + "claudeBusinessFollowup20260919": { + "status": "PARTIAL_WITH_BLOCKING_FINDING", + "reviewerTask": "b1rubx7qf", + "report": "artifacts/qa-governance/business-followup-claude-review-20260919T023537Z.txt", + "reportSha256": "b48e5f53340e139e5a97b5fdcb6d7003881a80a8a08d4004d01aaa6a759d2d0b", + "reviewerExecution": "STATIC_SOURCE_AND_EXISTING_TEST_REVIEW_ONLY", + "feeControlP9": "PASS_SCOPED", + "reimbursementLocksAndRecheck": "PASS_SCOPED", + "budgetPoolConcurrency": "FAIL_CONFIRMED_STATIC", + "findingId": "BUDGET-LOST-UPDATE-01", + "remediationStatus": "LOCAL_VERIFIED_INDEPENDENT_REVIEWED", + "remediationTask": ".trellis/tasks/09-19-budget-pool-concurrency", + "k0Contract": "PASS_STATIC_CONTENT_REVIEW", + "k0Observations": "BOUNDARIES_CLARIFIED_NOT_RUNTIME_VERIFIED", + "remainingCoverage": [ + "workflow snapshot internals", + "withdraw/pay/link-project business branches beyond lock presence", + "remaining crypto consumers as applicable" + ], + "evidence": "artifacts/qa-governance/business-followup-reception-20260919", + "deploymentEligible": false, + "remediationReviewResult": "artifacts/qa-governance/budget-pool-concurrency-20260919/review-fixes-20260919/claude-result-20260919T044304Z.md" + }, + "budgetPoolPatchCandidate": { + "task": ".trellis/tasks/09-19-budget-pool-concurrency", + "run": "budget-full-r1", + "status": "INTERRUPTED_NOT_PASS", + "targetedPostgresPassed": 28, + "targetedUnitPassed": 526, + "independentReview": "PASS_SCOPED_BUDGET_PATCH", + "deploymentEligible": false, + "backendTestsPassed": 4446, + "backendFilesPassed": 225, + "localChecksPassed": false, + "releaseFullGate": "DEFERRED_UNTIL_RELEASE_PREPARATION", + "interruption": "User objected to repeated full browser testing; remaining run stopped and owned environment cleaned.", + "followupEvidence": "artifacts/qa-governance/budget-pool-concurrency-20260919/review-fixes-20260919", + "reviewFixTestsPassed": 928, + "requiredReviewFindingsOpen": 0, + "remediationStatus": "LOCAL_VERIFIED_INDEPENDENT_REVIEWED" + }, + "releasePreparation": { + "status": "LOCAL_COMMIT_AUTHORIZED_RELEASE_PENDING", + "executor": "main Codex session", + "request": "artifacts/qa-governance/release-preparation-claude-request-20260919.md", + "scope": "read-only candidate inventory and actionable release preparation", + "testsAuthorizedInThisStep": false, + "commitPushDeploymentAuthorized": false, + "report": "artifacts/qa-governance/release-preparation-claude-20260919T050016Z.md", + "reportSha256": "42fffaf6bc7f81e45bf959eed7a24acb0a773f05fd97cb473a0c5e49273b49e5", + "nextRequest": "artifacts/qa-governance/release-commit-packaging-claude-request-20260919.md", + "plannedCommitCount": 2, + "jsonlDecision": "INCLUDE_REAL_CONTEXT_ENTRIES", + "pythonFixtureDecision": "INCLUDE_TEST_ONLY_STDLIB_DEPENDENCY", + "localCommitAuthorized": true, + "pushAuthorized": false, + "deploymentAuthorized": false, + "r1ActivationAuthorized": false, + "priorClaudePackagingRequest": "SUPERSEDED_BY_USER_REQUEST", + "commitEvidence": "artifacts/qa-governance/local-commits-20260919", + "firstLocalCommit": "729d90576f25ba658656508af1af32a179aa7057", + "commitResultReceipt": "artifacts/qa-governance/local-commits-20260919/result.json" + } + } } diff --git a/.trellis/tasks/09-07-manager-ap-access-scope/implement.md b/.trellis/tasks/09-07-manager-ap-access-scope/implement.md index 9b0d954f..794fb130 100644 --- a/.trellis/tasks/09-07-manager-ap-access-scope/implement.md +++ b/.trellis/tasks/09-07-manager-ap-access-scope/implement.md @@ -1,5 +1,13 @@ # 执行计划 +## 2026-09-17 历史发布回执接回(仅本地文档对账) + +09-08本轮W8逐条件对账从主checkout同名task implement的“已按本候选例外完成部署(2026-09-09)”接回原文档引用,并核现有脱敏postcheck-result.json;不复制整份旧文档覆盖本副本,也不读取原始发布日志/环境或连接远端。准确来源/行号/摘要与布尔事实见acceptance-boundaries-20260917的historical-acceptance-review.json及historical-report-facts.json。 + +AP实现已随b87cc55发布。运行制品及权限策略核验有据,真实账号AP接口/页面当时NOT_RUN;六个view_all未收窄,不重新部署或扩大权限修复。 + +本次未执行新的发布、真实查询或业务写入;旧“未部署”段落是当时记录,不再代表追加阶段当前余额。原PRD未满足项不补勾,status/completedAt/历史commit不改,不归档。当前G8未提交产品增量仍待独立复核和新的交付授权。 + ## 2026-09-08 付款账户配置只读核对与候选纠偏 - 用户附件要求综合判断后续部署与业务恢复,并提出核对此前配置的建行付款账户。本轮只读核对配置,不将附件中的提交/推送建议、批量主体回填建议或业务付款建议转换为执行授权。 diff --git a/.trellis/tasks/09-07-manager-ap-access-scope/task.json b/.trellis/tasks/09-07-manager-ap-access-scope/task.json index eb47ecc0..2e401353 100644 --- a/.trellis/tasks/09-07-manager-ap-access-scope/task.json +++ b/.trellis/tasks/09-07-manager-ap-access-scope/task.json @@ -34,6 +34,6 @@ "server/tests/unit/routes/exports.apOperations.test.js", ".trellis/spec/hyb-oa/security-privacy.md" ], - "notes": "", + "notes": "2026-09-17 W8本地对账:AP实现已随b87cc55发布。运行制品及权限策略核验有据,真实账号AP接口/页面当时NOT_RUN;六个view_all未收窄,不重新部署或扩大权限修复。 原回执从主副本接回,见implement顶部;本轮无新的远端/提交发布,原验收未补勾。\n", "meta": {} } diff --git a/.trellis/tasks/09-08-reimbursement-payment-closeout/design.md b/.trellis/tasks/09-08-reimbursement-payment-closeout/design.md index c1d7f4a3..c816959b 100644 --- a/.trellis/tasks/09-08-reimbursement-payment-closeout/design.md +++ b/.trellis/tasks/09-08-reimbursement-payment-closeout/design.md @@ -1,5 +1,15 @@ # 业务闭环方案(分阶段实施,发布状态见执行记录) +## 2026-09-17 历史验收证据分层与余项处置 + +复用原22task/231条件索引,逐项保留原文、原勾选、原行号与当前行号;每项给出人工判读、来源文档/摘要、已有支持与下一步。状态仅表示历史支持/部分证据/外部待事实/后续合同替代/原冻结/条件未触发,不自动授予AC或归档。原106勾选不改;文件存在、Git包含与历史文档陈述均不能单独证明当前运行验收。 + +来源分execution和primary两个本地副本,引用仅保存仓库相对路径与SHA,不写完整本地位置;只接回允许的安全摘要,不读原始生产日志/配置/备份。09-07 AP、09-09 seed和09-06追加预检的b87cc55发布回执在primary副本,接回后仅更正旧状态,不覆盖旧原文或扩大本次授权。 + +三类技术余项归09-05 design:B34损坏导入优先提出安全错误合同,D15低年份按待精简工资/保留统一考勤分开,D16 AP反向ledger区间保持现合同。B34尚未修复、D15新年份规则尚未决定;范围确认完成不等于可直接发布。 + +独立交审仍两个主题:K0原6payload包、G8最终368payload累积产品包。逐件SHA/精确集合和当前运行时身份可核;本轮纯文档增量另列,不能因文档变更重跑全部产品/恢复或复制一串新产品包。当前inline只做主会话核查,独立内容审查与重跑各自PENDING/NOT_RUN;不将manifest完整性当作独立PASS。 + ## 2026-09-15 统一收口编排 以本task implement作为唯一协调清单,各模块原task仍为实施合同。执行顺序:事实对账→待办/通知缺口及错误原因并行准备、串行代码实施→配对恢复工具与受控操作包→合成联合验收→按范围分笔提交/整批发布→自然业务观察。一个模块的外部等待不暂停其他模块;不能为追求“一次完成”跳过独立审查或混合权限状态机改动。 diff --git a/.trellis/tasks/09-08-reimbursement-payment-closeout/implement.md b/.trellis/tasks/09-08-reimbursement-payment-closeout/implement.md index f36a3d41..98278111 100644 --- a/.trellis/tasks/09-08-reimbursement-payment-closeout/implement.md +++ b/.trellis/tasks/09-08-reimbursement-payment-closeout/implement.md @@ -1,5 +1,666 @@ +## 2026-09-19 当前进度:本地完整验收通过 + +g20-full-r6从static完成同一冻结候选:后端4437通过/1原条件跳过、372本地浏览器全过,零失败/跳过/重试;33页与33PNG、采购/报销付款整链、新旧编号及财务同页进度全部通过。回执PARTIAL仅dirty_candidate,localChecksPassed=true,无部署资格;环境/输入/归属清理PASS。 + +编号N5和P6–P10本地验收完成。余项为独立只读复核、R1真实入口/干净候选/提交部署及12项公网义务、生产编号摘要与启用、原task外部业务/K1/K2证据,以及旧WebKit派生registry保护异常。原保护FAIL保持,r6没有新增漂移,其余12137旧文件与HEAD/index不变;旧恢复不重跑。无提交推送部署/归档或线上编号启用。 + +详尽结果、真实计时口径、证据入口及授权边界统一见09-06-release-gate-integrity/implement.md顶部。本次运行结束后的变更仅为task收口记录,原回执绑定运行时冻结输入,不能代表记录更新后整树。独立PENDING,整体in_progress。 + +## 2026-09-19 四文件定向通过,接完整候选r6(当前入口) + +r5-fixed-r2四完整文件31/31通过,浏览器约5.5分钟、受管总571.747秒,零跳过/重试;采购整链、R75四项、付款提交至归档全链及视觉25项通过。付款约244.773秒,保持300秒;视觉7组各180秒、25PC+8手机/33PNG全部完成、覆盖问题与findings为空。环境/source/repository/owner清理PASS。原r5完整FAIL与r5-fixed-r1登录跳转失败均保留,不拼接为完整通过。 + +P10采购提交后读取边界167/167及QA专用类型已通过;登录仅在无query/hash的规范/login复用,保留原表单/portal/profile断言。下一g20-full-r6从static开始普通完整候选,全部源码/测试/task/spec/docs冻结,只写本轮ignored证据;N5/G20及产品完整负载验收仍待终态,独立PENDING、生产编号未启用。 + +旧WebKit派生registry保护异常保留,原baseline不改;后续运行使用本轮独立WebKit副本,不下载或安装。禁止将一处已知漂移描述为全部旧材料零变化;无提交推送部署或真实操作。 + +## 2026-09-19 r5结果与本地续修(当前入口) + +完整r5未通过:static及11浏览器前置阶段通过,后端4428通过/1原跳过;372本地浏览器为367通过、1失败、2超时、2串行未执行,零重试。采购PATCH保存P2028、视觉admin最后一页及付款下游耗尽预算,均保留原失败。R75、新旧BX/PB和财务同页进度已在本轮完整负载通过,但N5/G20仍开放。 + +采购P10将仅供响应的多级详情读取移到写事务提交后,保留锁/校验/写入/审计与5000ms;原实现4红、修后167/167通过。视觉/付款仅减少重复RPC和同角色重复登录,7组180秒及付款300秒、全部断言/并发/零重试保持,专用类型通过。官方current 09-06继续四个原完整文件定向,之后冻结输入从static跑新完整候选。 + +旧WebKit自动生成registry出现一处字节变化,原保护FAIL与改写副本保留,未重签baseline;其余12137旧文件及HEAD/index不变。后续WebKit使用本轮物理独立副本;Chromium等既有只读二进制链接保留。独立PENDING、生产编号未启用,无提交推送部署或真实操作。 + +## 2026-09-19 完整候选接续 + +grouped-visual-r1完整文件25/25通过(7真实组+13覆盖正反例+5原到达用例),浏览器91.788秒、受管总352.799秒;25PC+8移动全部审完,33PNG、findings为空、覆盖问题为空,环境/输入/owner清理PASS,零跳过/重试。P9/编号N5/G20仍待新完整负载验收;由官方current 09-06从static开始g20-full-r5,运行时全部受管输入冻结,独立PENDING。 + +## 2026-09-18 P9与全页面分组接续(当前入口) + +P9最小优化已在服务器请求内共享权威报销预检读取,并删除同事务已有数据的重复查询;5000ms/锁/权限/预算/审批原子性保持。无预载p9-fixed-r3的R75四项与付款完整链通过,只有视觉累计180秒超时,不能写作新完整通过。 + +用户已明确批准33页按角色/电脑手机拆为7组、每组180秒;全部页面/断言/截图/全局并发/零重试保留,新增精确覆盖与失败组拒绝。09-06正在实施与定向验证,随后冻结输入从static开始普通完整候选;后端完整单测4428通过/1原条件跳过,644输入核验零变化。下文原33页累计180秒及P9未实施为历史状态。 + +N5/G20/P9完整验收仍开放,独立PENDING、生产编号未启用。旧差异/34目录12138旧证据保护,本轮仅本地合成,不提交推送部署或读取真实资料。 + +## 2026-09-18 编号本地实现与浏览器专项收口(当前入口) + +编号矩阵经小团队可读性、财务核对、旧业务/银行引用、并发容量、切换回退与维护成本复核,已按批准范围完成本地实现:BX按月;PB/JK/HK/CG/PD/自动PRJ按年;FA/EA永久递增。旧号、已有草稿规则归属、来源快照和银行行身份保留;迁移默认不激活,线上仍未启用。 + +官方current已回09-06-release-gate-integrity。本轮numbering-browser-closeout-20260918取得:新旧BX的PC/手机列表搜索详情最终1/1通过;新旧PB与混合来源消费者通过;付款路由自然排序完整129/129通过;浏览器专用类型通过;browser-r2真实合成报销付款链通过(含财务同页进度与回单核销归档)。编号后端/PG原证据及冻结包保持,各轮结果不拼作新完整通过。 + +原DG7/R3整例已通过,视觉文档404和认证前置问题已按证据修复;普通G20仍受两项阻塞:33路由全页面180秒超时(最新13页到达),R75报销提交P2028(5412/5000ms)。后者已在09-18-local-concurrency-product-fixes登记P9待分析/设计/实施,未凭猜测改业务事务。后续先分别完成耗时分析与P9最小方案/回归,再原整文件定向及从static重新完整门禁,纳入两条新增编号用例,不改预算/并发/重试求绿。 + +编号N5和G20保持未完成,独立只读复核PENDING。受管环境/输入/清理PASS,352项旧差异快照、31目录11427份旧证据按本轮保护检查核验;最终治理/保护/增量候选回执在新证据根单列。未读真实配置/密钥/备份,未连接ARM或重跑恢复,未提交推送部署、生产写入或真实企微付款。当前阶段不请求重复实施许可;未来生产摘要及启用仍需明确授权。 + +## 2026-09-18 编号实施接续(历史阶段) + +用户已明确授权复核编号矩阵适合后实施。官方current进入09-18-reimbursement-periodic-numbering;新计数/策略及创建入口本地已实现;完整后端4412通过/1原跳过,之后策略边界补验456/456及隔离PG复验通过;迁移默认不启用、旧编号/未编号草稿归属及银行引用保留。该任务证据单独存business-numbering-20260918,未覆盖本task的任何失败/冻结包。原G20四未过、后续完整门禁、独立PENDING与公网义务保持,不把编号单测/PG通过折算为G20通过。未提交推送部署、未读取生产编号分布或启用线上策略。 + +## G20 八文件定向结果与编号调研续项 + +g20-affected-r1结束FAIL,806.364秒;34项30通过、2超时、2失败,零重试。环境/source/repository/owner清理PASS。资产、R75原整文件、PA6角色矩阵、R92与onboarding通过;DG7首页整例和R3直连整例超时,全页面文档请求缺text/html Accept触发404,付款整链在财务通过后的API会话读取失败。阶段日志已证明发票popup完成,不再猜该处卡住。原失败保留;后续需逐项证据修复,再从static完整候选,当前不能全量结案。 + +用户已选报销按月,随后新增要求调研全部在用编号及国内财务/OA/ERP惯例,结合不足10人科技初创公司。归属09-18-reimbursement-periodic-numbering,当前只读调研/更新规划,尚未改编号代码;其他编号实施范围待定,不将调研视为生产改号授权。 + +## 2026-09-18 新增编号规划(待周期决定) + +用户认为报销序号长期累计不合适,已建09-18-reimbursement-periodic-numbering规划。建议按月BX-YYYYMM-0001,旧单/已有草稿不改号、付款规则不动;偏好问题待答,未实施编号新规则。原G20本地缺陷修复继续,不因新规划暂停或隐式授权生产变更。 + +## 2026-09-18 G20 第四轮终态与续修 + +g20-full-r4 FAIL,5768.636秒;static239.024秒PASS,内部11阶段PASS;后端4367通过/1原条件跳过,8组启用服务回归通过/7组原关闭不计通过;浏览器351项=342通过/5失败/3超时/1串行后继未执行,零重试。环境、source/repository未变和owner清理PASS。资产及账号创建两处P2028由09-18-local-concurrency-product-fixes接最小修复;当前不是全量通过。 + +QA续修保持原分母、预算、并发和断言:DG7确认停在登录输入框,改为自动等待既定账号入口;R92导航后回登录,user-onboarding提交改密后失去会话,核路由mock与SW/refresh会话边界;PA6确认第7次角色登录耗尽180秒,同角色PC/移动复用一次独立会话,保留全部角色/入口/管理菜单/API403;全页面180秒只到9/33页,利用真实应用导航减少重复装载,保留25PC+8移动/14系统页、目标文档HTTP、实际URL/标题及全部视觉/错误断言。报销付款整链300秒超时但无精确动作栈,先增加安全阶段诊断和查实际停点,不先猜产品根因或放宽预算。R75后继跳过随原整文件重验,不能当通过。 + +顺序:产品边界红/绿与相关回归→各在用QA夹具/冗余前置修复→原完整文件定向验证→冻结输入、从static开始新完整候选。定向通过不拼接旧完整结果,主会话检查不抵扣独立PENDING。原失败、322项旧差异及29目录9762旧文件保护不变。 + +用户另行明确批准仅查看线上指定PB-20260917-001审批人;北京时间12:14只读确认draft/3项pending、无复核/执行,真实业务提醒对象为负责人,个人应用消息已有发送成功记录(不证明已读),另两名称为测试的在职经理账号符合现有资格。仅查询,没有业务/配置/企微写入;核实其用途和待办引用后才可提出权限收回,不自动停用。此授权不扩展到ARM、备份、恢复、生产改动或部署。 + +## 2026-09-18 G20 接续执行(最新用户批准) + +完整候选前三轮失败原样保留:r1生命周期超时,r2清理退出结构漂移已修,r3两项生命周期超时。当前已减少假命令重复启动开销并保持完整命令trace;52生命周期+22初始化+39安全共113/113通过。下一g20-full-r4从static重新全量执行,351本地浏览器集合未变,不以定向结果替代完整门禁。 + +G20a WebKit报销离线1/1已验,当前仅证明官方Debian11冻结运行时v2105/26.0。G20c本地共享回执机制已实现,168项QA及39项部署安全通过;最终补充反例和普通完整运行继续,结果见09-06顶部及本轮证据。旧差异322项和29目录/9762文件保护通过;未提交部署。 + +用户在解释 WebKit 用途及五组余项后明确“按照这个规划开始执行,非必要不暂停,尽量做完”。本轮据此批准官方 Playwright WebKit 运行包下载到本轮独占缓存,及既定本地完整验收和共享候选回执机制实施;不再列下载授权待答复。仅运行包,不隐式安装系统包或修改共享缓存;业务测试继续无外联合成环境。 + +沿用 G19 分层合同。先补 WebKit 真实启动/报销离线敏感证据单例,再普通 full-local。受管运行期间源码/测试/task/spec/docs冻结,只写本轮 ignored artifacts。旧失败/旧候选不覆盖。 + +- [x] G20a:建立旧差异/冻结材料保护基线,官方包独占下载、运行时依赖/启动探针,WebKit原单例重验。 +- [ ] G20b:普通完整 full-local(351本地用例+配套后端阶段),零重试/默认并发/原预算,保留所有失败及真实阶段耗时;只修在用缺陷/已批准合同漂移。 +- [x] G20c:实现来源/策略/环境/阶段/实际覆盖共同绑定的共享候选回执与离线验证入口,复用现有G19/sourceSnapshot/environment,不新增发布平台。设计细节补在design后再编码。 +- [ ] G20d:逐项反例、fake命令捕获、源码变异和既有部署生命周期回归;只验证机制,不连接真实发布入口强制消费。 +- [ ] G20e:本地检查、规约/协调状态、独立复核交接与新候选冻结;外部事实/12公网义务/真实R1启用/提交推送部署保持分别授权。 + +新证据:artifacts/qa-layout/release-receipt-g20-20260918。主会话inline,独立PENDING;无生产ARM、真实配置密钥备份、真实企微付款/恢复或旧恢复重跑。WebKit与后续本地实施连续推进,不以范围内常规决定反复暂停。 + +## 2026-09-18 G19 分层验收已实施并完成本地针对性验证(最新状态) + +用户已明确批准分层方案,已正式更新 R2 并落门禁:全部非 manual 仍发现;12 公网逐项保留为同候选发布后必跑;2 条冻结差旅原源码/历史结果保持,当前改验两条冻结保护。没有恢复权限或旧整链,没有公网/企微/真实付款操作。 + +- 最终发现 **65 文件/365 用例**:**351 本地用例(59 文件)、12 公网义务、2 冻结旧链**,0 遗漏/未知/重复。351 已包含两条新保护,分母不重复加数;发现不等于执行。 +- 受控清单绑定用例身份/原源码摘要/原因/入口/责任角色。普通 runner 从完整发现派生执行集合,并校逐项结果;漏例、漂移、未知、重复、跳过、重试、expected-failure、伪通过和候选不一致拒绝。定向仍无发布资格。 +- 门禁 **74/74** 与诊断结果 **7/7** 合成检查通过,含真实 runner 调用及删除覆盖消费的源码变异。首轮夹具独占文件问题、第二轮五项30秒超时均保留;最终单独运行使用原预算,没有增加超时或降低断言。 +- `g19-freeze-r2` **2/2** 真实本地浏览器通过:合成员工创建403/无差旅导航,普通财务对账和三类导入403/无差旅导航,财务主管共享付款就绪检查包含差旅冻结 blocker。环境/输入/清理 PASS。首轮测试读取不存在的 me.permissions 失败已保留,改验正式 roleCodes 与接口行为。 +- 当前财务主管仍有 integration.manage,受管合成 runner 仍为既有底层回归启用 travel sync;没有声称所有差旅接口关闭。旧整链未通过/未执行,不因保护通过改写历史。 +- 本地回执明确公网 NOT_RUN_LOCAL、完整交付 INCOMPLETE、normalReleaseEligible=false。离线公网结果验证器要求 live profile、同源码/部署候选及原始逐项结果;未接真实部署入口,不能独立证明远端身份。实际公网责任人尚未指派,证据仍缺。 + +本地证据、新候选及余项:`artifacts/qa-layout/local-coverage-g18-20260918`。最终验证/保护/候选摘要分别为 final-check-summary.json、protection-result.json、candidate-integrity.json;旧完整 full-r2 保持失败,未重跑普通全量。主会话检查不是独立审阅,独立 PENDING。 + +### 接续顺序与余额 + +1. WebKit 官方独占缓存下载问题仍待答复;其真实离线用例未验。条件齐备后跑 **普通完整门禁**,不拼接定向绿;不得恢复旧差旅或放宽并发/预算求绿。 +2. G20:基于 G19 最终归属合同继续共享候选回执/逐阶段验证/skip和变异反例;真实 R1 强制消费启用另行批准。先完整阶段与 source/environment 绑定,再 fake 工具验证零发布写入,最后独立复核;当前未启用真实入口。 +3. 独立只读复核现有 K0/报销付款/借款/QA 与本次产品候选;inline 自检不抵扣。 +4. 经另批范围补真实付款复核人及企微个人发送事实、银行接受性、K1 保管/K2 新绑定、事件/自然业务证据;旧恢复不重跑。 +5. 经明确授权后提交推送、干净候选完整门禁/独立重跑、部署,再兑现 **同候选12条公网义务**。未部署前不能告知可公网测试。 + +借款 T1–T4、报销同页进度/付款排序/候选人通知事实展示、ERP 专属精简与 strict 遗留0保持此前本地成果;本轮无提交/推送/部署/归档,不把本阶段完成说成所有任务结束。 + +## 2026-09-18 G18 接续结果(最新协调入口) + +本轮完成OCR能力接线、R108现行报销/离线队列覆盖、14系统页展开及真实路由到达验收;修复票据详情URL双写导致的人工修正失效。另由09-18-local-concurrency-product-fixes拥有PWA更新事件缺口及采购创建事务缩短:不改权限/审批/审计、事务或浏览器预算、并发/重试。 + +- 最终共享入口回归 g18-final-ux-ocr-r4:18/18通过,含完整认证/门户16项与OCR2项;新PWA生命周期13项连同采购回归共131项Vitest通过,QA接线39项按未变输入复用。 +- 不带预载的 g18-concurrency-fixed-r3:原资产/采购/付款/PWA四文件4/4通过,583.240秒;4 workers/零重试/原预算,995服务响应、0未处理异常。PWA文档B/active controller/worker摘要、68新缓存和2实际加载资源、旧独有缓存0,零登录回退/静态404。 +- R108已完整通过:8移动观测/0发现,员工审批403、经理正式通过,当前approve/return/no-reject及离线队列;路由验收25PC+8移动、14系统页、0发现,另5个正反对照通过。只证明矩阵内覆盖。 +- G18时点完整发现64文件/363用例,缺失/未知/重复0;枚举与临时构建摘要采集成功,仅枚举不等于执行。所有完整受管轮均核环境/source/repository/owner清理,失败原件保留。 + +此前两次并发采购P2028和PWA未收敛均保留。诊断区分了事件循环拥堵/池排队与PG等待(729采样未见Lock);这不是全仓CPU根因证明。原full-r2 65/359、326 passed/12 failed/5 timedOut/16 skipped保持失败,最新候选未再跑普通整套,局部结果不能拼接全量通过。 + +证据/新复核包入口:artifacts/qa-layout/local-coverage-g18-20260918。最终治理、敏感/diff、保护与冻结以final-check-summary/protection-result/candidate-integrity为准;主会话检查不代替独立PENDING。保留304项原差异快照、27旧目录/9004文件以及HEAD/index,不覆盖旧冻结包。 + +### 尚需推进 + +1. 12条公网用例和2条冻结差旅的验收归属:入口/责任角色/证据缺口已列g18-gate-ownership.json;G19分层验收已获用户明确批准并完成本地实现/验证,12项公网仍须同候选发布后执行,未运行公网。 +2. WebKit真实运行条件及现有离线用例未验;此前官方独占缓存下载问题仍待答复,不联网安装。 +3. 条件齐备后完整普通门禁及R1共享回执/拒绝反例;独立只读复核与R1真实强制消费启用单列,不能用本轮定向绿抵扣。 +4. 真实付款处理人/企微个人发送、银行/OCR样本、K1保管事实/K2新绑定、事件和自然业务证据仍未执行;旧恢复不重跑。 +5. 提交/推送、干净候选完整验证、部署/公网验收仍需原明确授权。借款T1–T4、ERP退役、strict遗留0保持此前本地完成,未据此上线。 + +后续具体状态与最小步骤见remaining-work.json;当前task及协调task保持in_progress。 + +## 2026-09-18 G16/G17 门禁接续结果(最新协调入口) + +官方 current 仍为 09-06-release-gate-integrity。普通完整 full-r2 已运行:65 文件/359 用例,326 passed/12 failed/5 timedOut/16 skipped;后续 14 条原失败观测已有当前合同定向通过,另补原 R75 串行未执行 2 条;WebKit 1 条缺运行时、冻结差旅 2 条保留。当前发现 64/358 对应严格重复付款整例退役,无缺失/未知;枚举包装器临时摘要采集告警保留。不是一次全量通过,原结果不回写。 + +账号强制改密/场景8及批次付款拒绝/资产编号/合成通知配置已接现行合同;合同管理对照和原只读整例 4 类实际页面动作非零反例完成本地验证。审批进度、付款、回单、核销/PDF/审计/AP 整链及真实 PWA 单独通过。PWA/事务/长链的完整并发稳定性不据单独通过关闭。20 路由项视觉矩阵通过,但 `/system/*` 未展开,不能称全部页面覆盖。 + +本轮证据与增量候选在 full-gates-followup-20260918;旧 25 目录/7656 文件及 290 项原差异快照按 protection-result 核验保留。最终检查/候选摘要见同目录回执,独立 PENDING。借款 T1–T4、ERP 退役与 strict 遗留 0 维持此前本地完成,旧“借款尚未实施/ERP17余额”是历史状态。 + +余项按 09-06 implement 顶部六组接续:G18 本地 OCR/R108/真实路由覆盖;完整集合归属与并发可靠性;WebKit;R1/独立审阅;真实批次/个人通知/银行/K1/K2/恢复事件及自然业务事实;最后才是另批提交发布。真实处理人/企微个人发送尚未查询,借款未据此上线。未连接生产/ARM、读取真实秘密配置备份、重跑旧恢复、提交推送部署归档或发送真实通知/付款。 + +## 2026-09-18 在用门禁本地接续收口(最新协调入口) + +官方current已切回09-06-release-gate-integrity并完成G12/G13及G15本地交接:原58观测现53重验通过(52浏览器+1静态)、4旧/冻结排除、1WebKit缺运行时未验,原54失败/4超时不回写。真实PWA同profile升级、报销主管→财务审批且财务当前页进度更新、独立付款/回单/核销/审计/AP/票据归档整例通过。只修保留测试/QA前置,未追加产品权限/UI;证据及新复核包在retained-gates-closeout-20260917。 + +本轮消化此前39条保留未验中的38条,1条WebKit仍开放;不是清空全部发布条件。普通full-local尚未接新合成改密、4排除项完整发现归属/冻结流程、原AC7管理对照与独立反例、场景8及R1保持余额,具体顺序已写09-06 design/implement。借款T1–T4和ERP退役维持上批本地完成,独立PENDING、真实批次姓名/企微个人结果未查、银行样本/K1/K2/自然业务及发布条件仍开放。没有提交推送部署、生产/ARM/秘密备份访问、真实通知付款或重跑旧恢复。 + +## 2026-09-17 本批本地完成回执(最新协调入口) + +本次三项已执行到本地验证:09-04财务审批原地进度与付款单号自然排序修复,追加付款待办姓名/站内提醒对象/企微个人发送事实;09-17借款本金台账到还本、回单、核销与会计交接T1–T4;09-17 ERP专属退役(6个service、1个job、14个专属测试)。共享企微/通知/outbox、历史迁移和冻结材料保留,工资/绩效/预算不退役。strict遗留基线已随最后17条ERP源码移除到0。 + +借款最终72迁移、9组PG约束/并发、16组正式认证HTTP、3组PC/手机/员工403浏览器通过;报销原地进度15/15、ERP后付款128/128与各受影响回归、最终typecheck/build及Prisma通过。官方current保持09-17-company-loan-principal-repayment,借款和ERP task仍in_progress;精确回执/本批冻结候选见对应implement顶部。 + +未部署、公网不可据此认定更新;真实付款批次姓名与企微送达未查。独立只读复核PENDING、真实银行/OCR样本、实际密钥保管/恢复事实与发布前条件仍开放。下面39条历史门禁和恢复/真实业务缺口保留,不能被本轮合成通过自动关闭。未提交推送、不连接生产/ARM、不读真实配置/密钥/备份、不重跑旧恢复。 + # 执行顺序(本地技术审查/修复已批准,真实业务动作另批) +## 2026-09-17 新指示:真人问题、借款实施、精简 + +用户新增三项执行指示。优先顺序调整为:09-04修财务审批后进度原地更新及付款详情报销单号排序;09-17沿既有公司借入人民币本金台账与归还方案进入本地实施;精简从已明确不接ERP的专属消费者和确定废弃测试开始审查依赖并另立具体删除合同。下文借款暂缓属于历史安排,不再代表当前开发优先级。39条旧门禁重验保持记录,按本次触达范围执行必要回归。 + +借款本地仅合成身份/资料/隔离库,不生成真实余额或付款;恢复/密钥保管、独立复核及迁移发布前条件仍开放,不由开发优先级调整抵扣。精简不删历史表/迁移/冻结包/审计,不把企微同步摘要或通知outbox随ERP删除;工资绩效等仅候选模块先核明确范围。提交推送部署、真实业务写入与恢复仍未授权。 + +## 2026-09-17 门禁接续结果与剩余(最新协调入口) + +本轮交接入口:`import-parser-fix-20260917/review-candidate-r2`,19文件增量及关键回执与旧G8/K0/历史验收包分开保留;包外完整性回执与主会话检查不抵扣独立PENDING。三个相关task上下文及本地治理检查通过,官方current回读仍为本09-08协调task;原任务状态不改成全完成。39条后续重验、独立复核和外部事实缺口仍按下述规划推进。 + +B34账号导入已完成本地修复,09-05的922项定向回归与前端typecheck/build通过。09-06随后完成58历史观测逐项分类、R12安全合同及移动/公告/AP在用测试漂移修复:10项移动浏览器+5项公告/AP/附件浏览器+7项纯源码/函数回归分别通过,未改UI/权限规则,也不相加为一次全量门禁。 + +58条中15条对应当前用例已获本地重验、4条旧日常OA请假/冻结差旅按用户取舍排除、39条保留项尚未整例重验。原历史54失败/4超时不回写、不直接扣数;剩39是观测,不是39个产品缺陷。下一按09-06 design完成合成账号强制改密生命周期与登录前置、10条页面/fixture及合同/AP整例、WebKit和PWA环境合同。不得通过关闭强制改密、放宽权限、批量skip或恢复旧布局求绿。 + +新材料分为import-parser-fix-20260917与release-gate-triage-20260917;旧审计/G8/K0包保留,新产品增量需与G8原候选一起独立复核,不能再称当前运行时代码与G8逐字相同。独立仍PENDING。W3恢复、W4事件、W7自然业务、W8未满足验收、原人工复审节点及不提交部署/借款边界保持。考勤日常在企微,D15非阻塞技术记录不扩修。 + +## 2026-09-17 接续实施:B34 已完成本地修复 + +09-05已修损坏账号导入的解析错误与日志边界:固定400原因、未知解析故障安全500,两入口14个坏格式场景无业务访问/原异常日志。最终2文件922项(68导入+854原因断言)及前端typecheck/build、strict/self-test通过;不是922个真实业务场景,独立仍PENDING、未部署。新材料 import-parser-fix-20260917,旧冻结包保持。下方“B34尚未实施”不再代表当前状态。 + +下一连续推进09-06的58条历史观测分类:保留当前产品合同、排除明确旧/待精简路径、辨别前置环境/登录失败与真实断言漂移,再按限定方案修仍有意义的用例;不把旧观测直接当58个产品缺陷,也不启用R1/恢复旧考勤或开展借款。恢复/事件/自然业务、独立复核与提交部署仍各守原边界。 + +## 2026-09-17 用户考勤澄清后的余项更正(当前入口) + +考勤日常操作在企业微信完成,OA 保留摘要、解释、同步状态和审计;依据用户本次澄清、docs/产品设计规则.md 与现有 AttendancePage 文案。此前 D15 是 OA 汇总查询对极低年份的技术观测,不是企微考勤故障;不能据此安排恢复旧 OA 打卡或开发新的考勤规则。 + +D15 从本轮必修/待用户决策清单移出,保留为非阻塞技术记录;原年份限制建议未获采纳,不称已修复,不把仍保留的企微同步/摘要代码整体当作废弃。下方旧表的“统一考勤待年份规则决策”以本节及 09-05 最新合同为准。下一本地修复优先项仍为 B34 损坏账号导入;独立复核、完整发布门禁、恢复/事件证据及自然业务验收等原缺口不受此澄清抵扣。此次仅追加四份任务说明,旧记录和冻结包保持,不修改产品源码。 + +## 2026-09-17 本轮对账与范围确认回执(当前入口) + +W8已从“只有索引”推进为22任务/231原条件逐项证据处置:154有历史支持、56部分证据、11外部事实待补、4被后续合同替代、4原范围冻结、2条件未证明触发。这里没有231个新测试、没有整体完成率;原106勾选和所有未满足条件保持。82个原显式引用均可在已限定本地副本定位,旧行号只作hint;本轮按当前正文重新定位,不能把文件存在当内容验收。每项的具体支持、限制、下一步及相对路径/文档SHA见acceptance-boundaries-20260917/historical-acceptance-review.json与同名md。 + +### 新接回事实与避免重复工作 + +- 09-07 AP、09-09 seed分离、09-06追加迁移预检已随b87cc55发布;接回主checkout原task记载与安全postcheck摘要,未复制整文件覆盖或读原始生产日志。三原task顶部/notes仅补本时点对账,旧记录保留。 +- 普通完整发布门禁仍有实质缺口:R1/阶段1及阶段B保持原冻结,历史有效环境诊断199通过/54失败/4超时/18跳过、58条观测不是全绿。该项归09-06,不被G8的17ERP余额代表;下一先分在用/被替代/待精简用例,不盲修旧分支或继承例外发布授权。 +- assets强制事前批准、旧发票灰度/自动建明细规则、PC主列历史布局等已被后续确认替代的4项,不再按旧PRD重新开发;保留历史对照,不勾成原要求通过。 + +### 三类边界的最终处置 + +| 余项 | 本次证据与结论 | 下一步 | +| --- | --- | --- | +| B34损坏账号导入 | 9个合成路由探针中,两入口×3损坏格式共6例500,CSV两例日志收到合成单元格标记;接口未回显、DB调用全0 | 仍在用的账号导入需修,固定安全解析错误/日志方案已落09-05 design;本轮是确认,不称已修复 | +| D15低年份 | 两个真实helper的6组年月证实0000/0001/0099偏移;旧考勤服务已有年份守卫 | 工资待精简/未用helper/硬关闭旧考勤不补;统一考勤保留入口低优先级,接受年份策略单列待确认,不改存量数据 | +| D16 AP反向ledger区间 | 现有列表/导出2文件73/73通过,共享where维持上下界和deleted过滤 | 保持原200与查询语义,不列必须修复项;实际PG未在本轮执行,非法日历及pending反向原400保持 | + +### 复核交接及本轮边界 + +K0原6payload与G8最终368payload两主题的manifest/SHA/精确集合均核;运行时输入保持。本轮任务文档更新单列差异,不覆盖旧冻结包、不重新生成累计产品包。独立内容review仍PENDING,独立重跑NOT_RUN,主会话不派子代理或冒称独立通过。现有inline限制使本轮只能完成交接,非再次索取同一操作授权。 + +仅本地文档、源码读取和合成探针;未改server/client运行时、未读取真实配置/密钥/备份或连接生产/ARM,未重跑旧恢复,未提交/推送/部署/归档/实施借款。最终5个受影响task validate、Trellis/process/docs/敏感/diff及逐项锚点/探针语法检查通过,docs保留既有22未登记文档告警;5495份旧材料集合/SHA、HEAD/index保持,162份旧差异155逐字不变/7仅追加,14份增量全部为任务文档/notes;原9/25、9/30、10/2、10/5人工节点未提前执行,无定时任务。 + +## 2026-09-17 历史验收实质对账与三类边界确认(本轮已批准) + +用户已批准按上一轮建议实施。本轮复用09-08协调W8历史证据及W1独立交接,09-05拥有W6三类技术边界决策;先方案后检查,不把“确认范围”解释为更改业务年份/AP规则或借款实施。官方current已用task.py切回09-08并回读,状态in_progress;原G8及更早材料5495份、162份旧差异与HEAD/index建立保护基线,新证据单独保存acceptance-boundaries-20260917。 + +- [x] A1:以既有22任务/231条件索引为起点,逐条件核原PRD、现存实施回执及可用本地脱敏证据;当前PRD变化另记增量,不覆盖原索引。每项写明支持、局限、可复核锚点、剩余和最小下一步;源码包含/文档记载/本地验证/独立/发布/真实业务分别判断,不凭复选框或Git祖先关系宣称全验收。 +- [x] A2:检查旧证据的失效引用与不同副本路径,只接回已存在的本地安全报告/源码/测试。找不到时明确缺哪份,不读取真实配置/原件/备份或原始生产日志,不补造过去的运行/人工确认时间。 +- [x] B1:低年份Date.UTC:确认调用链、当前在用/冻结模块及可观察偏移。仅合成探针;提出保留/限制年份的兼容决策,待精简模块按用户取舍排除,不改变历史数据。 +- [x] B2:AP反向区间:核列表、导出、页面与已有回归,区分合法但无匹配的范围与日期本身非法;记录是否存在写入/越权影响及最小处理选项。未批准新业务规则前保持原200/空结果语义。 +- [x] B3:损坏CSV/XLSX:用内存合成数据核实际解析库、路由错误封装和副作用边界;对仍在用的入口固定安全错误合同方案,解析错误与IO/内部故障不混为一类。先确认,不改产品源码或放宽输入限制。 +- [x] C1:只读核K0与G8最终候选manifest/输入身份,整理2主题独立复核交接及发现问题的处理门禁;inline主会话不得充当独立结论,不重复打产品累积包。 +- [x] C2:更新现有PRD/design/implement及必要notes,完成22任务结构/引用/数量一致性、限定合成探针、task validate、Trellis/process/docs/敏感/diff与保留对账。文档为主,不重跑无关全量产品、前端构建或旧恢复。 + +完成判据为本轮对账与范围确认可供复核,不能等同22任务全结案。旧17处ERP明确排除、原22文档/Browserslist告警不扩治;未来人工复审日期不提前执行或自动延期;W2提交/推送/部署、W3真实保管/恢复、W4远端观察、W7自然业务及W9借款保持原边界。无生产/ARM、真实密钥配置备份访问,不重跑旧恢复,不提交推送归档。 + +## 2026-09-17 在用错误合同收敛与旧代码排除(最新协调入口) + +按用户连续处理及“旧/待精简代码不必解决”的最新取舍,09-05 G8完成57处保留路径,46新原因;strict 831注册/822迁移/17遗留、17指纹/1源文件,findings空。余17均为ERP专用旧分支,产品规则明确当前/未来不接ERP,按要求保留而不修;逐字构造体及精确基线清单已核。G1–G7扫描范围无旧命中,G8需修余额0/明确排除17,不称全系统无错。以520入口计已处理503、跳过17;546分组总入口则处理529,数字不可混用。 + +本轮G6、G7、G8候选均另包保护;G6 1014、G7 1078分别对应自身最终输入,不相加称一次验收。G8最终19文件1093/1093,typecheck/Vite/PWA通过;24 JS语法、治理/task/敏感/diff通过;4884旧材料与HEAD/index保持,31增量另包,独立PENDING。详情以09-05 implement及error-debt-g8-20260917为准。auth只有合成JWT/员工;企微和AI只mock;队列retry只重置pending,不发真实消息。旧22文档/Browserslist告警不扩治。 + +W5本轮限定“保留在用路径错误合同”实施收敛,原人工复审节点仍未执行、17条原到期信息保留,用户排除取舍不是自动延期。W1独立PENDING;W2未提交推送部署;W3恢复/W4事件/W7真实自然业务/W8历史验收/W9借款及日期低年份/AP区间/损坏解析各自边界仍开放,不把本轮进展说成只剩部署。 + +## 2026-09-17 G7与用户取舍更新(最新协调增量) + +09-05完成G7查询/考勤/在岗/周报/审计/搜索/导出错误合同,49新原因、42旧命中退出,strict 785注册/778迁移/74遗留、73指纹/16源文件;15文件1078/1078本地合成回归。AuditLogsPage沿原错误区接安全resolver;legacy网页考勤仍硬关闭。详细可达性、初轮夹具修正、构建治理与新候选归09-05及error-debt-g7-20260917。 + +用户新指示:旧代码或待精简代码不必解决。改为在用路径优先,明确弃用/计划移除的命中记录依据后跳过,不为清零追加工作,也不擅自删除代码或延期基线。已完成改动保留;G8先分类,产品规则和路线图明确ERP当前与未来均不接入,因此ERP旧分支退出本轮修复范围。通用outbox仍处理通知,不能整文件当ERP删除。其他W线、旧冻结包/差异、独立PENDING及本地/真实数据/外联/提交部署/借款边界不变。 + +## 2026-09-17 G6本地接续回执(最新协调增量) + +09-05完成共享审批、流程定义、审批规则和两组委托API错误合同:43新原因、49旧命中退出,strict为736注册/745静态迁移/116遗留、114指纹/24源文件,主链例外0。16文件1014/1014本地合成回归通过,阶段计数不累加。流程定义页面复用resolver显示安全错误。 + +B28a单列修复普通用户委托查询的OR覆盖缺陷:既有本人范围与有效期改用AND,原管理员权限不改;4个范围红例后修复、34委托用例通过,内存where matcher非真实PG或JWT。不可达后置防御只静态覆盖。四源结构核对除该明确例外外保持业务结构;独立PENDING。新材料error-debt-g6-20260917,旧冻结材料保持,最终类型/构建/治理及保护结果见09-05本轮回执。 + +接下来连续处理G7查询考勤周报42、G8系统集成74,先按具体分支补合同,不因分组完成停工。其他W线与真实配置密钥/备份/生产/ARM/提交推送部署归档/借款边界不变,不把扫描清零等同全系统验收。 + +## 2026-09-17 G5采购资产合同连续处理(最新协调增量) + +09-05连续完成合同/采购预算、资产、流转盘点、采购申请附件七源错误合同:131新原因,120旧命中精确收敛,余额165命中/163指纹/28源文件(693注册/699迁移,主链例外0)。各原状态/权限/冻结、附件安全及副作用顺序保持。最终19文件1095/1095、前端typecheck/Vite/PWA、15 JS语法、strict与治理通过,原告警保留;新候选与回执见09-05 implement和error-debt-g5-20260917,阶段数不累加。 + +G1–G5本扫描器收敛,G6审批委托49/G7查询考勤周报42/G8系统集成74继续。3365份旧材料含G4冻结包保护;新候选独立PENDING。真实业务/恢复/人工节点/日期剩余边界和W2不提交发布、W9不实施借款保持,不能把扫描收敛说成全系统验收。 + + +## 2026-09-17 G4差旅连续处理(最新协调增量) + +09-05完成差旅申请、订单/授权/交易、共享预算与对账54原因及62旧命中收敛,余额285命中/277指纹/35源文件(562注册/599静态迁移,主链例外0)。修复关联项目未定义parseUuid导致500,3个红例后成功回归;原权限/业务规则保持。最终13文件764/764含584固定文案断言,各阶段数不累加。证据error-debt-g4-20260917。 + +共享预算原先尝试ledger、对账默认409、申请风险helper先行调用都如实保留,不冒称零写入/真实回滚。G1–G4扫描器范围收敛,G5–G8仍285。2915旧材料与G3冻结包保护,独立PENDING;W2/W3/W4/W7/W8/W9和原真实配置/数据/远端/提交部署边界不变。 + +## 2026-09-17 G3基础资料连续处理(最新协调增量) + +09-05沿授权连续完成主数据/供应商、日历、费用标准/类型、假别/模板及项目错误合同。新增103原因、精确删除86命中/86指纹;strict为508注册/544迁移/347遗留(339指纹、42源文件),主链例外0、findings空。八源业务结构对照保持,项目收口项先行初始化清单例外已明确,不虚报全失败零写入。各阶段本地合成结果以09-05 implement和error-debt-g3-20260917为准。 + +最终8文件811/811、前端类型检查/Vite/PWA及语法/task/治理通过,原告警保留;本轮21文件增量与新候选已留证,独立PENDING。G1–G3本扫描器已收敛;G4–G8仍347,不等于运行故障数。原2482份保护材料与B11–B14冻结包只读;独立PENDING。W2提交发布、W3恢复、W4事件、W7自然业务、W8历史验收、W9借款及低年份/AP区间等边界不变。 + +## 2026-09-17 B11–B14连续处理(最新协调增量) + +按用户继续授权,09-05连续完成预算共享/控制规则与员工账号/部门/档案/附件操作原因。新增88原因,87次遗留命中收敛,剩433命中/425指纹(405注册/477静态迁移、主链例外0);G1/G2分组无本扫描器遗留,G3–G8继续开放。product-final为22文件775项本地通过,附件增强断言另按attachments-final;各阶段数不累加,详细回执以09-05 implement和error-debt-b11-g2-20260917为准。 + +最终前端typecheck/Vite/PWA、语法/task/治理通过,原告警保留;2091旧材料与HEAD/index保持,本轮22文件增量,冻结候选与主会话检查另存。六源原权限/状态/查询写入/事务结构保持;新增上传精确尺寸异常映射仍为生产原400,下载阻断安全message/必要审计保留。仅合成DB/文件/随机源和mock fetch,未触及真实配置、人员、附件、备份或远端。保护旧包/差异,新候选独立PENDING。W2提交发布、W3恢复、W4事件、W7自然业务、W8历史验收、W9借款边界不变;不能把G1/G2扫描收敛视作全部工作完成。 + +## 2026-09-17 546处债务分组与B10(最新协调增量) + +针对用户“这500多处遗留错误怎么解决”,09-05已把546次AST命中/526指纹/58源文件逐文件互斥归为8组、约28个实施切片,并规定逐操作合同、三方文案、负例/无副作用、页面消费、精确减基线和到期门禁。数字是错误合同债务,不能等同独立功能故障;未来复审/期限不变,同类本地实施继续自主进行。 + +本轮先完成预算/绩效两路由B10,25个原因替代26次遗留命中,余额520次/501指纹/56源文件;317注册、398静态迁移、主链例外0、findings空。预算/绩效状态、权限、事务和冻结规则保持,3个页面已有resolver;预算共享服务/控制规则余13处列为下一切片。product-final 7文件414/414,strict/self-test20场景及语法通过,属于本地合成验证;P2002只是异常桩,未实证真实PG。全部细节及其余分组以09-05 implement顶部与error-debt-b10-20260917为准。 + +最终客户端typecheck/Vite/PWA、task/治理/敏感/diff通过;原22文档与Browserslist告警保留。旧1730份材料与旧脏改动保留,本轮新候选独立PENDING;W2仍不提交/推送/部署,W3恢复/W4事件/W7真实自然业务/W8历史验收/W9借款未因本轮迁移被结案。W6低年份、AP反向区间与损坏导入解析异常保持各自边界。 + +## 2026-09-17 连续执行B6–B9与D13/D14(最新协调增量) + +按用户明确“后续连续做完”的要求,本轮在09-05主任务内连续处理导入文件、公告时间/操作提示、工资/考勤月份提示及工资维护校验,没有逐批请求继续确认。新增36原因,原业务权限/状态/金额规则不变;公告页面接回安全错误映射。D13现有月份守卫补证完成,低年份Date.UTC偏移单独开放;D14非法日历/不可解析时间已阻断,原时区/null/省略/24:00兼容保持。 + +最终product-serial-final为12文件575/575;初轮2项密码哈希超时在无构建竞争的串行复验通过,原日志保留、没有调宽阈值或削弱断言。错误债务由582降至546次命中,指纹561降至526;292注册/375静态迁移,主链例外0。新增36原因与删35指纹并非一一相等,不能按数量代替具体分支验收。owner/2026-10-05保持。最终客户端typecheck/build与14个JS语法、task/治理/敏感/diff通过,原docs/Browserslist告警保留。候选与证据另存import-time-contracts-20260917,原1352份材料保留,HEAD/index不变,独立PENDING。 + +W5同类本地治理继续自主推进,下一操作组先从预算/绩效、员工档案确定限定合同;不以单批结束作为人为停点。W6剩低年份/月格式兼容决策、AP反向区间,不能称全仓时间治理完成。W1独立仍PENDING,W2不提交/推送/部署,W3恢复/W4事件/W7自然业务保持原缺口,W8原22task/231条件索引未变成全量验收,W9借款产品未实施。细节和最终门禁以09-05本轮implement与新证据为准,下方记录保留原时点。 + + +## 2026-09-17 D12/B5接续回执(最新协调增量) + +本轮current保持09-05,按连续实施授权完成HR多格式入职日期D12及共享只读筛选B5。D12非法日期整条跳过,缺失仍默认本次now,提前阻断部门/账号/角色/绑定/主管操作,保留同步审计;员工/合同含导出外层、在岗历史共7安全原因保持旧status/details与业务范围。 + +验证分为D12 2文件44/44、B5 9文件425/425、共享导出回归3文件72/72;客户端typecheck/build通过,未称全仓/独立/PG/JWT/公网验收。证据另存hr-date-read-errors-20260917,原local-roadmap及更早包保持。当前错误债务更新为582次AST命中、561指纹(本轮仅减7),注册256/静态迁移343,主链例外0;原owner/2026-10-05不变。D1–D12已限定的本地日期工作补齐,不等于全仓时间输入审阅结束。 + +| 协调线 | 本轮变化 / 剩余 | +| --- | --- | +| W1/W2 | 新产品候选待独立复核;仍不提交/推送/部署。K0主题与产品主题分开,不累加多个包为重复审查任务 | +| W5 | 余582命中/561指纹;下一B6限定账号导入文件7分支,先核multipart错误优先级,再同批原因/三方/负例。人工复审日期不被今日扫描抵扣 | +| W6 | D12完成;D13月份消费者与D14公告datetime的兼容审阅、AP反向区间规则单列,详见09-05最新限定准备表;不改历史数据 | +| W3/W4/W7 | 恢复四出口、事件6项缺口和2条自然业务验收链仍开放;无新真实配置/密钥/备份/生产样本,不执行旧恢复或制造业务 | +| W8/W9 | 22历史task/231条件索引仍只是定位,未整体结案;借款技术规划保留,T2–T4产品未实施 | + +后续同类本地治理沿已授权范围继续;新业务时间规则先固定合同,受保护操作和独立结论继续按原门禁。完整本轮实现、验证命令、保留对账及候选以09-05 implement顶部及该证据目录为准;以下旧表保留当时口径。 + + + +本轮最终收尾:4个受影响task上下文校验、19个增量JS语法、原因码strict、Trellis/process/docs/敏感和diff通过;docs仍为既有22未登记文档告警。537份旧证据/冻结材料精确集合与SHA保持,59份旧脏文件中44份逐字不变、15份按本轮范围继续增量,HEAD及真实index保持。本轮33文件增量已单独保存,主会话只读检查不计独立复核。当前累积产品审阅候选位于local-roadmap-execution-20260917/review-candidate;原K0/两个产品候选保持,独立结论PENDING。520和61分别绑定其命令输入,未声称整仓、真实PG、生产认证或公网验证。 + + + +## 2026-09-17 后期规划连续执行回执(最新协调入口) + +用户批准按后期规划尽量连续完成;本轮在指定执行树沿09-05先做本地治理,再补W4/W8/W9文档准备。以下是新增事实,下方“仅规划/未实施”保留原时点;没有提交/推送/部署/归档、生产/ARM/真实配置密钥备份读取、旧恢复重跑或借款产品开发。独立复核仍PENDING。 + +| 工作线 | 本轮进展 | 仍需完成 | +| --- | --- | --- | +| W1/W2 | 保留旧候选,准备当前日期/提示累积输入新候选;本地验证通过 | K0和产品2主题真实独立审查、明确授权后的提交/推送/部署及公网验收;不由520项主会话测试抵扣 | +| W3 | K0/冻结恢复包保持,K1问题不重复索要;无新秘密事实 | K1、K2、附件AC4、完整计时AC5四出口;仍PARTIAL/缺件1/来源UNKNOWN/解密NOT_TESTED | +| W4 | 原事件implement新增AC1–AC8逐项缺口、容量公式、两自然周期合并观察与停止条件 | AC1/2/4/5/6/7六项补证或裁决;AC3/8有据不重复等待;远端观察未执行 | +| W5 | B3/B4增加9个原因码,移除实际消失的9条指纹;注册249、迁移336、遗留589、finding0 | 剩568条豁免/589次命中;按可达操作继续分组,原2026-10-05期限不变,人工复审节点未抵扣 | +| W6 | D5用户直接写入/CSV/XLSX、D6员工资料/生命周期/档案、D7 AP列表完成;D8盘点完成并补D9仪表板直接读,继续完成D10薪资/预算/绩效及D11启用考勤/修正/请假日期 | D12 HR同步待实施(需区分缺失默认与非法输入);其他datetime/月份另审。AP反向区间保持原规则,未来改400需单列方案 | +| W7 | 保留自然事件条件,本轮未访问真实业务 | 付款/回单与退回通知2条链,缺样本不制造业务 | +| W8 | 22历史task的231项PRD复选条件建立逐条文档定位/候选证据引用索引,见下表及JSON | 逐项实质证据比对、原副本引用接回及结案判断仍未完成;不把106个历史勾选当新验收 | +| W9 | 借款T1a表/字段/FK/唯一/金额/历史保留、锁图/并发/权限及接口草案落到原design | T1b数据库原型、T1c独立审阅;T2/T3/T4尚未开始,相关恢复风险及实施准入保持,原8AC未验收 | + +日期D5发现并修复CSV解析库的本地时区转UTC提前一天问题:CSV原始文本进入原统一校验,XLSX原生日期处理不改;保留创建/导入缺省日期、nullable/optional、原导入整批失败及预览审计。D6非法输入在员工/角色/部门/checklist/审计/企业微信业务副作用前拒绝;没有修改权限模型/生命周期推进/付款状态机。D7和D9与对应导出输入合同一致,原区间状态码保留。 + +验证:product-final命令17文件520/520,后续d10-d11-final命令9文件61/61;客户端typecheck/build退出0;14文件215是前一阶段输入,不与520累加。合成Fastify注入和mock DB不等于真实PG事务/JWT/浏览器/公网验证。新回执在artifacts/qa-layout/local-roadmap-execution-20260917;首次CSV实际缺陷和两次测试夹具/预期纠正日志保留。前端保留既有Browserslist告警,没有升级依赖。 + +### W8逐项映射索引与结案顺序 + +historical-acceptance-map.json逐条保存PRD原文/所在节/行号、原勾选状态、原implement入口和显式证据引用的存在性。22项共231个复选条件(含补充条件),106项原文已勾、125项未勾;这些计数都不能换算未完成功能或完成比例。只核当前文档,不读取真实附件、配置或备份。显式路径支持行号及范围,均存在;未自动捕获的裸路径/其他副本仍待接回,绝不把空引用数组解释为无证据。 + +| 原task | PRD条件数/原已勾 | 后续结案处理 | +| --- | --- | --- | +| 08-23-isolated-backup-restore-drill | 10/6 | K0本地/原一次恢复已分层;K1/K2/AC4/AC5继续开放 | +| 08-28-production-arm-backup-transfer-incident | 8/0 | 按本轮W4六项缺口补证/裁决 | +| 08-28-reimbursement-detail-material-preview-fixes | 9/9 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 08-28-reimbursement-detail-material-preview-deploy | 9/1 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 08-28-reimbursement-nonparticipant-readonly-projection | 9/0 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 08-29-reimbursement-expense-policy-invoice-guard | 12/10 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 08-30-reimbursement-expense-policy-invoice-guard-deploy | 12/0 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 08-30-reimbursement-assets-prior-approval-correction | 9/8 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 09-01-reimbursement-one-page-approval-workbench | 12/0 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 09-01-reimbursement-approval-content-workbench | 11/0 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 09-01-reimbursement-form-shared-component-migration | 15/0 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 09-02-reimbursement-readonly-content-consistency | 11/10 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 09-04-approval-return-notification-gap | 11/8 | 保留本地/既有发布事实;自然消费链及独立边界单核 | +| 09-04-reimbursement-state-gap-finance-view | 11/0 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 09-05-error-explainability-payment-readiness | 12/12 | 历史勾选仅对应原批;本轮520与剩589分开审阅 | +| 09-05-existing-deploy-db-lifecycle-isolation | 8/8 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 09-05-trellis-upgrade-rule-cleanup | 18/18 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 09-06-release-gate-integrity | 16/3 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 09-07-manager-ap-access-scope | 7/0 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 09-08-reimbursement-payment-closeout | 7/0 | 实际付款/退回闭环与协调交付分开验收 | +| 09-09-existing-deploy-seed-separation | 6/6 | 按原implement逐条件接回源码/测试/独立/发布证据;暂不改元数据 | +| 09-11-reimbursement-payment-handoffs | 8/7 | 保留本地/既有发布事实;自然消费链及独立边界单核 | + +本轮完成的是可检索映射索引和优先队列,非22项实质验收/归档。优先本轮再次触及的09-05、09-07(AP权限保持的本地回归)、09-08及08-23/08-28;其余在相关代码再次修改或拟结案时接回原副本,不自动重跑所有浏览器/生产验证。借款为第23个task,不在22项历史分母中。 + +后续执行授权保留:日期与原因码同范围可继续小批,不需反复确认同一授权;新业务规则、AP反向区间、具体生产/恢复/发布动作和借款产品准入按相应门禁处理。旧attendance_v1仍固定关闭,相关历史路由拒绝是409而非400;未解冻OA打卡。原9条协调线并未全部结束,不能宣称“剩余只有部署”。 + + +## 2026-09-17 后期工作总表与剩余量(最新协调入口,仅整理规划) + +用户要求详细梳理后期工作及剩余量。本轮复用本task作为协调合同,官方current由09-05切回本task并回读;不是新产品、远端或发布实施授权。数据来自已落盘本地回执与源码,不重新访问生产/ARM;下方历史“当前/下一步”保留各自时点,以本节为最新入口。 + +### 已经完成到哪里 + +| 阶段 | 已完成事实 | 仍保留的边界 | +| --- | --- | --- | +| 既有报销/回单/门户及早期日期错误修订 | 已有b1d25d3c及相关发布/隔离验证记录;原526项独立及公网只读记录可追溯 | 本轮没有新公网观察;已发布不代表自然付款/通知业务全部验收 | +| K0本地恢复合同 | 30项合成测试、166键环境合同通过;候选已保存 | 独立复核待接回,原密钥保管和真实解密没有被证明 | +| B1/B2本地批次 | 8处日期源文件边界及11个通知/待办/门户原因;14文件450项与前端typecheck/build通过 | 新批次未发布;450为该轮输入的合并回归,非450项新增 | +| D1–D4本地批次 | 待办、员工目录筛选、节假日、相关导出4源文件;11文件192项与语法/治理通过 | 只修所列外部日期;192与450含重叠,不相加成全量独立覆盖 | +| 受限恢复及资料保护 | 既有一次DB/影子只读应用恢复与清理已结束;两自然日期异机完整性有据;旧包和后续证据保留 | 整体PARTIAL、历史缺件1、密钥来源UNKNOWN、真实解密NOT_TESTED,旧入口不复用 | + +### 剩余量的口径 + +按独立交付/验收出口归为**9条工作线:8条既有收口/治理 + 1条借款新功能**。这是协调分组,不是9个等量开发任务,也不是剩余9个bug。各线内部数量按事实列明,避免把交叉引用和同一缺口重复累加。无全量验收分母,暂不报总体完成百分比或保证结束日期。 + +| 编号/优先级 | 工作线与归属 | 还要完成什么、退出条件 | 当前剩余量/依赖 | +| --- | --- | --- | --- | +| W1 / P0 | 当前候选独立复核;08-23、09-05 | K0与当前产品变更两类审查;核候选摘要、内容/安全边界、实际重跑及finding;修订后重冻并取得明确结论 | **2个审查主题、3份已备候选**;当前独立结论均PENDING,不能由主会话自检抵扣 | +| W2 / P1,W1之后 | 已实现批次正式交付;09-05/09-08 | 按QA/产品/文档范围拆分可审提交,绑定最终输入;明确授权后提交/推送,需发布的产品另做执行时预检、部署与公网只读验收 | **1条交付流程**,可按批拆交;当前阶段均未提交/发布,不要求先清空全部历史债务 | +| W3 / P0 | 密钥与完整恢复能力;08-23 | K1核双角色仓库外受控可用性;K2绑定新源/制品/字段/秘密注入/资源并经审查授权验证;历史缺件找回或另审最终处置;补完整计时/同源滞后证据 | **4个开放出口:K1、K2、原AC4、原AC5**;K1事实未到、K2未绑定,实际操作另批;原历史来源可继续UNKNOWN,不追索秘密历史 | +| W4 / P1 | 异机传输事件正式结案;08-28 | 对根因分类/未测理由、修复与容量方案、监控、自然周期partial/容量趋势、告警响应逐项补证或裁决 | **1个事件,AC1/2/4/5/6/7仍有缺口**;AC3两日期副本完整及AC8本地治理有据,不再因这些重复等待;自然恢复不冒充已修复 | +| W5 / P1,有期限 | 错误可解释性剩余债务;09-05 | 按已启用用户操作逐组迁移原因码/安全提示/精确负例,保留守卫与旧details;只删除消失指纹 | **577条豁免指纹、598次AST命中、58个扫描分组**;owner全为shunli、expiresOn全为2026-10-05;到期前迁移或逐项另审有依据的豁免,不统一延期 | +| W6 / P1 | 其余日期与区间规则;09-05 | 新发现直接写入/导入及AP查询入口先定限定方案,再验证无效日期无写入/查询;区间规则先核旧合同 | **3组已定位入口 + 1轮余项盘点,共4个计划批次D5–D8**;详见该task新顶部,目前未实施,非全库余项总数已知 | +| W7 / 按自然事件触发 | 真实付款/回单与退回通知;09-08、09-04通知、09-11交接 | 付款链核真实银行接受性、独立复核资格、自然回单消费与实际登记;退回链核原待改单重提/阻断、站内事项与真实投递/深链/处理后消失 | **2条业务验收链**;无自然样本继续等待,实际渠道是否需调整须先核;不得为验收制造付款、重传旧件或发送测试通知 | +| W8 / 按结案或相关变更触发 | 原task验收映射与元数据治理;09-08协调 | 将原PRD逐项关联代码/发布/独立/业务证据,核失效引用、过期摘要;真正满足后再获批归档 | **22项历史task索引待逐项结案判断**;当前23个task合同含借款,不能换算为23个未实现功能;不把全22项映射作为新规划的硬前置 | +| W9 / 新功能,规划先行 | 公司借款本金台账与归还;09-17借款task | T1模型/约束/锁序定稿;T2出借人/借入登记与余额确认;T3还款额度/批次/回单/核销及界面;T4真实本地合成全链、并发/权限/旧业务回归与独立审查 | **4个阶段、原8项AC均未验收**;只有规划和消费者地图,产品实现未开始,另需实施批准与相关恢复风险处置 | + +责任分工:shunli为协调及各原task负责人;执行端负责本地实现/自动验证/脱敏证据;独立审查由真实独立方承担;K1由系统管理员和公司责任人在受控渠道内核可用性;真实付款/通知按正常业务责任与授权进行,不转嫁软件回归给员工。 + +### 当前独立复核输入(冻结包只读) + +- K0:`followup-governance-20260917/k0-review-candidate`,只覆盖本地合同/合成验证。 +- B1/B2:`followup-governance-20260917/review-candidate`,manifest前缀3a22adfb;保留上轮完整输入与回执。 +- D1–D4:`calendar-followup-d1-d4-20260917/review-candidate`,manifest前缀7b1c61d0;绑定前包,当前todos/exports须以此包版本覆盖前包,同源相关回归以192项实际输入为准。 + +以上相对目录均位于artifacts/qa-layout。W1按两个主题审查,不强迫同一产品重复做两次互相矛盾的“全量通过”。K0允许独立交审;产品审阅须联读两个版本的增量,不能拿旧450项证明新输入已经独立通过。包完整性、本地测试、独立审查、独立重跑和发布是不同结论。 + +### 错误债务规模与优先次序 + +数量引用D1–D4的error-contract.log及当前baseline只读统计;本轮不重跑产品测试/AST,不把静态调用量解释为用户遇到故障次数。 + +| 扫描分组 | 剩余命中 | +| --- | ---: | +| purchaseRequests | 52 | +| users | 40 | +| employees | 31 | +| assets | 29 | +| masterData | 25 | +| integrations | 22 | +| travelRequests | 21 | +| attendance | 19 | +| payroll | 18 | +| travelReconciliation | 18 | +| 其余48分组 | 323 | +| 合计58分组 | 598 | + +数量靠前不自动成为第一修复批。先核当前已启用、用户真实可达的操作,再按风险/频度定一组完整原因合同;员工/日期相关路径可先形成审阅批。workflow工具15项、workflowDefinitions配置11项涉及不同边界,需分别审阅,不能混入文案替换。B1/B2已经消除14次历史命中,D1–D4纯日期修复没有继续减少此计数。 + +### 推荐执行顺序与可并行事项 + +1. **先交审当前成果,同时补K1非秘密事实。** W1可立即准备交接;K1只需角色/渠道类别/可用状态,不接收Secret、账号或位置。没有独立方时保留PENDING,不重复跑主会话测试凑结果。 +2. **待审期间做本地计划准备。** W6的D5–D8及W5下一操作组先限定文件/语义/用例;本次仅规划,具体新批获批准后实施。W4先复用现有证据做最小缺口/容量观察方案,不连接远端。 +3. **已实现代码按自身门禁交付。** W1 finding处理且候选固定后,W2在明确提交/推送/部署授权内推进;未经批准不操作。不把全部598遗留、22项历史映射或自然事件等待塞进当前小批发布范围。 +4. **K1事实具备后定K2具体操作。** 新源/平台/秘密输入/字段上限/资源/清理各项绑定,独立只读方案审查、专项批准后才执行;可合并采集完整计时,不能重用旧入口或过期源。缺件处置与解密通过分开。 +5. **业务与备份结案按证据触发。** W7随自然业务、有范围的只读核验推进;W4按缺项补限定观察。未发生的自然通知记未触发,不能补造人工确认时间或历史rsync时长。 +6. **借款在方案批准后独立进入。** 可以继续T1技术定稿的文档准备;实现另获批准。未知密钥/真实恢复风险须有明确处置,不能以旧历史缺件例外默认接受;不要求22项无关任务全部归档才做新规划。 + +### 已有时间节点与估量边界 + +| 时间 | 既有安排 | 当前状态 | +| --- | --- | --- | +| 09-25 | 重扫错误债务、日期消费者及责任/优先级复审 | 待执行;先前450/192不抵扣该日期复审 | +| 09-30 | 仅对必要条目提出具体延期理由、owner、新验收与期限 | 待必要性判断,当前未延期 | +| 10-02 | 对迁移/豁免/测试/发布范围查漏 | 待执行 | +| 10-05 | 按原到期门禁判定,不统一续期 | 原日期保持 | + +这是人工节点,未创建自动定时任务。按代码投入看:W1/W2主要是审查与交付收口(finding未知);W6为中等规模分域修复;W5为现有工作中范围最大、需分批的长期治理;W9是完整新功能,投入显著大于一次日期批次。W3/W4/W7耗时取决于事实、具体授权与自然事件,不宜折算为连续编码天数。W8为按需证据治理。当前不能给可信的统一“还需几天/完成百分比”;每个批准小批完成后用实际通过/未验数量滚动更新。 + +本轮整理验收:不改产品源码、不修改原AC勾选、豁免到期或借款状态;只更新本task PRD/implement/notes与09-05后续批次规划,保存新对账证据并运行对应本地治理门禁。旧冻结包/证据、旧差异及HEAD/index保持;规划落盘不代表W1–W9已经执行。 + +本轮整理回执:两个task上下文、Trellis治理(23项合同/1242份扫描/14份登记证据摘要)、process baseline、CURRENT敏感扫描30份/finding0及diff检查通过;文档注册零错误、保留原22份未登记文档告警。仅4份计划/摘要文件变化,其他55份旧差异原字节保持,452份已有证据/冻结材料的集合与摘要保持,HEAD/index保持;原任务正文及状态/历史commit不改。证据归followup-roadmap-20260917,未新跑产品测试、未新实施D5–D8或借款,不把规划结果当任务全部完成。 + +## 2026-09-17 D1–D4继续推进回执(最新协调入口) + +用户要求继续推进后,官方current保持09-05,按已落盘四批完成待办截止日、员工目录/导出入职日期筛选、节假日整批日期、仪表板与AP导出日历校验。产品增量4源文件,本地11文件192项测试和10文件语法通过,AST240注册/327迁移/598遗留仍零finding、期限未变。新回执与候选归calendar-followup-d1-d4-20260917,上一轮450项和旧候选保留为原输入结果;当前todos/exports已含两轮增量,独立复核须以新候选覆盖这两文件。 + +D1–D4从待审规划进入已批准本地执行,不将09-25等未来复审提前勾选。独立复核仍PENDING;K1实际保管、K2源/制品/秘密注入绑定、自然业务与原备份事件缺证据仍开放,借款planning且未实施。未连接生产/ARM、未读真实密钥/配置/备份、未重跑恢复、未提交/推送/部署/归档。下方旧“D1–D4未实施”为前轮时点事实,最新代码及治理回执以09-05顶部为准。 + +## 2026-09-17 后续本地推进回执(最新协调入口) + +用户批准按既有梳理继续并补规划。官方current已切09-05-error-explainability-payment-readiness;本轮B1/B2本地实施完成:8个日期输入源文件、通知/待办/门户11个错误原因,最终14文件450项测试及客户端typecheck/build通过。AST从612降至598次遗留(577条entries),240注册/327已迁移、主链例外0;原到期日2026-10-05保持。独立只读复核和发布尚未完成,具体回执、剩余日期D1–D4及人工到期节点归09-05顶部。 + +08-23的K0主会话30项及166键合同保持原结果,本轮另备只读审阅候选;独立复核PENDING。K1补双角色受控可用性核验方案,事实陈述待接回、实际取用NOT_TESTED;K2补NOT_BOUND源/制品/字段/资源/清理清单,尚不可执行。没有真实访问、秘密读取或旧恢复重跑,PARTIAL/历史缺件1/密钥来源UNKNOWN/真实解密NOT_TESTED不变。 + +08-28按原AC1–AC8追加本地证据映射,明确“两自然日期异机完整性已证”与根因、容量预算、监控趋势、自然告警响应缺口;不改原验收勾选、不新增远端观察。自然回单/通知、历史缺件及完整恢复计时继续沿原task开放,不制造业务事件。 + +借款09-17-company-loan-principal-repayment继续planning,仅补消费者地图及T1数据/锁序→T2登记确认→T3付款来源与额度/回单闭环→T4合成全链/独立审查依赖;没有schema/migration/API/UI实施,不解冻travel_order付款。未处置的密钥恢复风险不能因新规划被接受。 + +旧差异和四恢复包86件及K0原证据41件均纳入本轮基线;此前96件记录原样保留,以K0现场manifest对账为准。最终治理、差异/摘要及候选回执归09-05顶部和followup-governance-20260917。本轮未提交、推送、部署或归档;下方各节保留当时状态,不能用旧“仅K0/本轮不进入B1/B2”覆盖本节新授权与实际结果。 + +## 2026-09-17 关键遗留优先与新任务规划(当前入口) + +后续执行回填:用户已在指定工作树批准A1/K0,仅本地合同与合成验证;官方current已切08-23,30项定向测试及166键环境合同通过,K1空白保管清单已落盘。K1/K2仍未批准或执行,真实解密NOT_TESTED、来源UNKNOWN、原PARTIAL不变;最终回执归08-23顶部。四包按manifest现场合计86件且全部完整,上轮96件计数与现场不符,保留旧记录并单列差异。本轮不进入B1/B2或借款C。以下保留本节规划时刻事实。 + +本轮用户批准开始规划,非新增实施授权。上一节阶段收尾不撤销;不继续扩大报销任务范围。责任人仍为shunli,主会话直接实施/检查,真实外部只读审查另列;新会话不继承无限生产或密钥权限。 + +规划清单: +- [x] 复用08-23和09-05合同,核源码及现有规约能回答的事实,不索要生产密钥。 +- [x] 分列本地准备、受保护操作、债务治理和新业务开发的进入/退出条件。 +- [x] 通过官方task工具新建独立09-17-company-loan-principal-repayment,使用--no-start保留当前任务,补PRD/design/implement。 +- [x] 对更新合同和新任务执行Trellis、流程、文档、差异及范围校验;本轮不运行产品测试或受控恢复。 + +| 顺序 | 原task / 工作 | 产出与退出条件 | 等待时可做什么 | +| --- | --- | --- | --- | +| A1 第一实施段 | 08-23:K0本地原密钥恢复合同 | 现有环境分类、加密格式、禁泄露输入/输出、错误密钥/缺密钥合成反例及新验证方案通过;不读生产配置/密文 | A1方案获批后可本地实施;不是实际密钥已保管或解密通过 | +| A2 独立授权段 | 08-23:K1保管事实,之后K2受控恢复 | 原密钥可由既有管理员与公司责任人取得,批准范围内验证与选定备份密文匹配并完成清理;失配/不可用停止,缺件与其他未验指标继续单列 | 等待权限/保管安排/具体源绑定时推进B1/B2;不重用上次入口或要求用户在聊天给密钥 | +| B1 第二实施段 | 09-05:高风险外部日期输入 | 合同、采购、差旅付款/对账先行,周报/在岗下一小批;输入溢出在写入前拒绝,兼容null/optional及原业务规则 | 不开放冻结模块、不修改历史日期;各小批独立验证,已发布四域不重做 | +| B2 到期治理段 | 09-05:错误豁免 | 重盘点后逐操作迁移真正未覆盖的常用路径;09-25复审、09-30必要逐项延期提案、10-02查漏、10-05原门禁 | 日期是人工节点,不是已创建的自动任务;不能用旧612次计数代替当前扫描或统一延期 | +| C 新需求 | 09-17-company-loan-principal-repayment | 本轮仅规划;开发前复核A/B遗留结论、方案及资金规则;先借入本金台账和还款闭环,不做通用财务系统 | 规划不等于实施批准;原密钥不可用/恢复失败未经明确处置时,不直接扩充敏感付款数据或发布 | +| 按事件/结案触发 | 自然回单/通知、历史缺件与原task验收映射 | 沿下方阶段收尾表的责任与退出条件 | 不为等待自然事件制造业务,不为切换新任务强行归档旧task | + +执行选择:优先A1;A2受保护前提不具备时切B1/B2,不陷入反复恢复/追问。新任务C的规划与技术准备不要求22项旧task全绿;其业务开发/上线前仍需单独判断相关安全风险,不把“接受历史缺件”扩大为接受未知密钥恢复风险。A2实际源、环境与密钥注入方式尚未绑定,因此本轮没有可执行的远端命令或第二次恢复授权。 + +### 新会话接续 + +本对话可以继续,但历史较长,建议规划落盘后在同一执行工作树另开一个主会话做A1;不另开实现/检查子代理,不同时在多个会话写同一工作树。执行工作树为项目根下`artifacts/isolated-worktrees/20260911-detail-layout`。接续先读本节、08-23的PRD/design/implement顶部、deployment-trialrun的Environment Recovery Classification And Secret Custody及security-privacy;核官方current和Git差异,不用注入的09-04旧提示选任务。08-23新增A1方案获实施批准后,用官方task.py start切到08-23并回读current;未批准只读规划,不自动开始。 + +交接必须保留:已核产品基线b1d25d3c;上次受限恢复已结束/入口已消费;PARTIAL、历史缺件1、密钥来源UNKNOWN、解密NOT_TESTED不被新会话改为PASS;已有脏文档和冻结包原样保留。新会话先做本地合同/合成验证,不连接生产或ARM、不读生产.env/密钥/真实备份、不提交推送部署。B1实施时再切09-05,C仍为planning,不混入同一资金改动批次。 + +规划验证:四个task上下文校验、Trellis(23项合同、1241份敏感扫描)、process baseline及差异检查通过;文档注册0错误,仍有既有22份未登记文档的一项告警。新task四文件齐全、8项可观察验收均未勾选、无TBD;本轮只改9份既有任务文件并新建该task四文件,源码和四份冻结包96文件未变,HEAD/index未变,官方current仍09-08。未跑任何产品测试、环境合同执行或真实恢复;本地准备和新功能均待其方案实施批准,K1/K2另按具体受保护操作审阅。 + +## 2026-09-17 阶段收尾与后续归属(当前入口) + +用户采纳“先整理旧任务完成项、遗留项和归属,再单独规划借款还本”的建议,要求收尾之前的工作。本轮只整理本任务及直接关联任务的状态摘要,不改变原验收标准,不重跑恢复、不改产品/生产、不提交/推送/归档,也不实施股东借款新功能。当前执行树的官方任务仍为09-08;09-04旧会话提示不作为重新实施入口。 + +本轮执行清单: +- [x] 核对最新PRD/设计/实施记录、已保存发布与恢复回执及Git基线,区分历史文字与最新事实。 +- [x] 在本节形成完成项、开放项、原任务归属和重新进入条件;不把22项历史索引称为全量验收。 +- [x] 更新本任务PRD及09-04/09-05/09-08的过期task摘要;保留原status、completedAt、历史commit及未满足验收。 +- [x] 运行任务、文档、流程、敏感信息与差异检查,确认本轮只有列明文档/元数据改动,HEAD/index及原有非本轮差异未变。 + +本轮验证计划:对三个更新摘要的task执行`task.py validate`;运行`node scripts/qa/check-trellis-governance.mjs`、`node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/audit-docs.mjs --check-registry`、`git diff --check`。源码未改,不重跑产品单测/构建/浏览器,不新增独立复核或公网验收计数。 + +### 已完成阶段与证据边界 + +以下证据均位于既有`artifacts/qa-layout/`,本轮只读核对,不覆盖原件、不把历史执行记为今天新跑。 + +| 交付 | 已完成事实 | 证据与限制 | +| --- | --- | --- | +| 报销状态、财务本人审批与付款交接修复 | 229867f5等修订已有发布及隔离验证记录,Git确认包含于b1d25d3c;不重复开发/部署 | `finance-owner-release/acceptance-result.json`及09-04实施记录;代码包含不是原task所有体验/业务条件验收完成 | +| 回单核对、动态错误指引与日期合同 | b7d2285d、3516d7ec及b1d25d3c增量已发布;日期合同已有独立内容审查、526项独立重跑及typecheck/build通过记录 | `calendar-contract-review-followup.md`、`calendar-contract-release/push-result.json`、`calendar-contract-release/acceptance-result.json`;独立重跑复用依赖,非全仓/全新安装;发布验收为公网只读,不证明真实银行导入或生产认证业务全链 | +| 本次异机备份取证 | 09-14/09-15两个自然日期副本的异机摘要已核,09-16自然源又完成当前b1同源新鲜度/实际目录绑定 | `postrelease-recovery-20260916/arm-user-readback.json`及08-28实施记录、`postrelease-recovery-20260917/real-bound-restore-result.json`;不据此补造历史传输耗时、根因修复或事件全部验收 | +| 一次受限ARM恢复 | DB和影子认证只读应用PASS;62引用中1条已接受历史缺件、未知不一致0;清理及后验PASS;RPO7小时2分52秒 | `postrelease-recovery-20260917/real-bound-restore-acceptance.json`与`real-bound-restore-postcheck.json`;整体PARTIAL,非完整附件/解密/完整RTO验收,入口已消费,不再执行 | +| 本轮阶段状态整理 | 直接关联的09-04/09-05/09-08摘要纠正,完成/开放项分开;其他历史task不批量改状态 | 本节及三个task.json;22项旧索引只证明记录和关联,未进行22项全量验收 | + +### 保留的开放项与重新进入条件 + +协调责任人为shunli;以下归属均指已有`.trellis/tasks/`任务。本次登记不是新审批、定时任务、统一延期或已具备完整恢复能力的承诺。 + +| 开放项 | 归属 | 当前状态与影响 | 重新进入及退出条件 | +| --- | --- | --- | --- | +| 自然回单、实际登记、退回通知与交接 | `09-08-reimbursement-payment-closeout`协调,通知/入口分别归`09-04-approval-return-notification-gap`、`09-11-reimbursement-payment-handoffs` | WAITING_NATURAL_EVENT;最近一次已存09-16观察窗口无新增回单/通知,本轮未再查生产;受控样本/隔离链通过不替代自然消费 | 自然事件出现且有相应只读授权时,执行端核队列→结果→登记及待办/投递链,核实际独立复核人资格;银行导入接受性另据真实回执确认,不制造付款、重传历史件或要求员工代做软件回归 | +| 完整附件、敏感解密/密钥保管及完整恢复指标 | `08-23-isolated-backup-restore-drill` | 本次受限操作完成;原AC4/AC5未全满足,1条历史缺件仍未找回,密钥来源UNKNOWN、解密NOT_TESTED | 后续需另行明确缺件处置与受控密钥保管/恢复验证方案及批准;分项补证,不能用新随机密钥、删除引用、阶段耗时小计或重复旧入口实现“全绿”;不向用户索要聊天明文密钥 | +| 异机传输事件正式结案 | `08-28-production-arm-backup-transfer-incident` | 两自然日期异机完整性已证实,不再因该项重复等待;原事件的根因/修复和其他验收尚未全量对账 | 先核原PRD及现有证据是否足够;缺证据才补限定观察,出现新异常再按事件方案处理;不自动重传、改timer或清理partial | +| 剩余日期消费者与错误豁免 | `09-05-error-explainability-payment-readiness` | 四域输入及动态提示批次已发布;其他消费者分类、上轮591条/612次遗留与229项注册记录不是全量修复 | 09-25复审,09-30必要单项延期提案,10-02查漏,10-05原到期门禁;按外部输入风险分批并重盘点,各批补负例/无副作用测试和对应审查,不统一延期,不改历史日期 | +| 历史task全量验收映射 | `09-08-reimbursement-payment-closeout`协调,各原task拥有验收 | `postrelease-recovery-20260916/task-inventory.json`包含22项索引,不代表22项都完成;部分引用仍待跨原副本/证据核对 | 当某原task拟结案或相关代码再次修改时,逐项对照PRD与原证据;未核项保持开放,不能按Git祖先或某轮治理PASS批量勾完 | +| 本地记录提交与任务归档 | 本task及各变更所属原task | 仍有本地未提交文档;本轮只新增本PRD/implement及三个task摘要,不接管其他旧差异 | 明确提交授权后按路径拆分并复核差异;各task确实满足全部验收且另获归档授权才归档,收尾不等于提交/推送/部署许可 | + +阶段交接结论:已发布功能和本次受限演练按实际完成范围结束;开放项继续留在原task,不阻塞独立新需求的规划,也不免除未来发布门禁。下一业务需求为公司借款台账与本金归还,须另建任务并完成方案确认,本轮未创建或实施。主会话完成记录核对,不冒称新增独立复核。 + +复盘:task.json摘要落后于implement和发布回执,造成“仍待部署/全部未做”的误读。本轮只纠正三个直接关联摘要,以本节指向最新证据;历史commit仍为追溯锚点,旧记录和未满足验收保持。既有project-governance及docs-and-evidence已覆盖阶段/任务、实现/发布/外部验证分离规则,本次无新代码合同,不重复新增spec或文档。 + +本轮收尾验证:三个task上下文校验、Trellis治理、process baseline及`git diff --check`均退出0;Trellis敏感扫描1237份文件通过,14份已登记历史证据摘要通过。文档注册检查0错误,保留既有22份未登记文档的一项告警,不借收尾扩修。9个证据入口及6个原任务归属存在性检查通过,不等于重新执行其测试或验收。 + +范围核对以本轮开始时2988份跟踪文件摘要为基线,只有列明5份任务文档/元数据发生本轮变化;三个task.json仅notes改变,status/completedAt/commit保持。四份既有冻结包共96个文件的集合与内容摘要前后相同,HEAD及真实index摘要不变,本轮范围外原有差异未变。无产品测试/构建/浏览器重跑、无新独立复核/生产访问/恢复/业务写入,无提交/推送/归档/部署。结论为“阶段收尾记录完成,原任务开放项保留”,不是全task完成或新业务实施开始。 + +## 2026-09-17 P2/P3本次受限恢复已完成,结论保留PARTIAL + +用户01:33Z实际执行回执与本地唯一JSON核对一致,三摘要绑定R3 33001f58…/新执行包f7cf4d62…/源d9a23d59…。真实DB及影子认证只读应用PASS;62引用仅1条已接受历史缺件、未知不一致0;清理/后验PASS、生产写入false。RPO25372秒,整体PARTIAL_KNOWN_MISSING_AND_SENSITIVE_DECRYPTION_NOT_TESTED,不是完整灾备PASS。01:36Z主会话另读确认临时恢复容器/网络/目录0、暂存入口已移除、两监控timer正常、生产ready仍b1。 + +本次批准的P2准备/P3一次执行及分项验收已完成,不再要求管理员跑命令,不重新暂存或重跑。08-23保留完整附件/计时与敏感解密未覆盖,密钥来源UNKNOWN不改变;自然新回单/退回通知等待与日期/错误治理原到期任务不因此全结案,不制造业务事件或改密钥/生产数据。原R2 NO_GO与两级冻结包不改,实际新结果与清理证据归08-23最新节;未提交/推送/部署/归档。 + +## 2026-09-17 R3新源绑定与入口已就绪,待管理员单次执行 + +接回用户01:18:40Z sudo回执:d9a23d59…新源完整、6.81小时、实际runId/相对目录已证明,归档/timer未改,未恢复。01:22Z生产ready、ARM两镜像及71项完整迁移映射再核PASS;08-23已据此冻结新bundle,保持R3原执行器/helper/精确例外不变。执行manifest f7cf4d62…、入口011b42bd…;12项封装+4项传输合同通过,01:28Z仅普通用户暂存并回读一致,没有启动恢复或改生产。 + +新的交互sudo单次入口run-approved-bound-restore.sh已备,旧入口不复用、既有范围不重复申请批准。新包是R3已审代码加主会话验证的绑定封装,不称整个新包已独立复核;真实执行仍受现场最新性/24小时/资源/隔离门禁,最高保留PARTIAL、任何新增不一致停止。当前待管理员运行并回传实际结果,细节及完整相对命令归08-23最新节。 + +最终本地104/104、两级包摘要/文件集、入口语法及Trellis/process/diff门禁通过,源R3仍原字节;状态READY_FOR_MANUAL_SUDO_FRESH_PREFLIGHT。尚未执行root入口或第二次真实恢复,不提前结案。 + +## 2026-09-17 R3内容通过、新镜像配置已验,待目录只读回执 + +继续推进时发现外部完整报告已落盘,六项内容静态审查通过、无阻断项,报告SHA256以08-23登记的fd58e491…完整值为准;三条信息级卫生项在08-23登记已知接受,不改冻结候选。结合已转回的独立包完整性与54项重跑,内容门禁已满足;19项PG和构建15项仍不是独立重跑。 + +固定b1镜像无网纯配置专项已实际PASS:密钥缺省空/非空fail-closed、真实依赖/无替身、隔离metadata通过,工具容器精确清理PASS;未启动OA/DB、不恢复、不读生产Secret、不部署。新增目录绑定探针及交互sudo只读入口已准备,主会话合并88/88通过,R3冻结30件未改;尚无真实runId/目录回执,新bundle仍待绑定。用户只需在项目根执行postrelease-recovery-20260917/read-arm-source-binding.sh对应入口补受保护目录身份,不用重复批准既有恢复范围;回执后再定稿执行输入与现场预检,详见08-23最新清单。 + +## 2026-09-17 R3外部完整性与独立54项重跑通过,内容结论待接回 + +接回用户转述的独立结果:包完整性/两份delta/R2字节保留PASS,包外54项重跑exit0、4.557秒,30件跑前后不变;原始日志由独立方保管,本会话未取得原件。19项PG合同未独立重跑、构建15项不在此次范围,不能把本地69项全部称为独立通过。外部内容审查b1ferktxd仍在进行,R3包内PENDING与冻结字节不改;本会话仅复算包摘要并在08-23包外记录进度。仍须内容结论、新源目录/制品配置绑定及现场预检后才可恢复,不因中间回执启动应用或生产操作。 + +## 2026-09-17 新源匹配b1镜像已构建,恢复仍待门禁 + +用户批准08-23/design固定b1源码的ARM64原生构建,已于00:45Z完成一次构建exit0及产物元数据核验;image5d279fef…平台/BUILD_SHA/OCI revision正确、容器0、旧镜像保留,内存/磁盘后验10.23/87.84GiB。新增15项保护测试及合并69/69通过,均主会话执行;缓存9处,不声称全新安装或原amd64字节复原。仅构建不部署、不启动OA、不恢复、不读生产密钥、不删旧镜像;R3独立内容复核仍待接回,既有恢复授权不重复申请,实际runId/目录及新制品配置证据未补齐前不生成恢复入口。冻结包30件/manifest33001f58…保持;详情及完整摘要归08-23最新回执。 + +## 2026-09-17 新鲜ARM备份已核实,匹配镜像待准备 + +接回用户sudo只读结果:09-16T18:30:04Z自然源d9a23d59…、b1d25d3c,5.75小时、DB/storage摘要及字节通过、归档元数据/timer未改;旧09-15源超龄不再选用。已按原件回执保存,尚无新源真实恢复通过。 + +新源原image与当前生产一致但为amd64;ARM97个image清单无b1匹配镜像,仅旧b7对应制品。静态71项migration及实际Dockerfile/锁文件一致不能替代同源码镜像。08-23/design已准备一次b1原生ARM64构建申请,不部署/不恢复/不读Secret/不清旧制品;此新构建未授权,先不执行。R3独立内容复核仍待回执,冻结包不修改;既有精确例外与一次受限恢复批准不重复询问。 + +## 2026-09-17 历史缺件精确例外已批准实施 + +用户批准08-23/design的窄验收修订,推进本地实施/定向测试/重新冻结及独立复核后一次恢复;不重复确认同一范围。旧09-15源已超过24小时,先只读刷新源/镜像事实,未绑定新鲜源前无新执行入口。其他业务资料不删除,生产不写入,最终只允许带明确历史缺件和解密未验限制的PARTIAL。详见08-23最新实施记录。 + +本地实施及54项单测/19项真实合成PG查询合同通过,容器/临时文件清理PASS;R3冻结28份文件,manifest33001f5894ab48c99cac92c388837fcb1b5c549536f0ee29ccedb010d1017100,仍待独立增量复核。source binding明确PENDING,未生成新恢复入口。普通生产归档访问不可用,ARM盘点无可证明的b1镜像,不能猜新源版本或复用过期b7备份;已备交互sudo只读选源脚本,可与外部复核并行,不增加恢复/生产写入范围。明细与证据归08-23最新节。 + +## 2026-09-16 受限恢复执行回执:数据库PASS,附件引用NO_GO + +用户手工sudo一次恢复已完成并清理:固定源RPO20.41小时,preflight/prepare/database通过;storage因ATTACHMENT_REFERENCE_MISMATCH停止,app未运行、解密NOT_TESTED。cleanup与源/生产/timer后验PASS、productionWrites=false;完整恢复不得判PASS。本次聚合错误没有给出数量或细分原因,不直接称旧测试件缺失。原入口已消费,按08-23最新记录先只读归因,不自动重跑、不删除或豁免引用;其他自然业务等待和历史技术债不因此结案。 + +只读归因新增事实:当前62条引用中仅一条存在/大小异常,为b22a1162…历史原件缺失;原09-11文档标记证据已从另一隔离副本找回。精确记录manual归档、备份后未更新且七类关联均空;未验证当前61件内容SHA,仍需恢复副本确认是否另有不一致。建议只对该精确记录制定历史缺件例外,保留原NO_GO与PARTIAL,不把所有归档票据排除;涉及验收门禁改变及第二次执行,按08-23/design拟议补充集中确认一次,当前不实施/不重跑。 + +## 2026-09-16 独立单测已通过,准备受限恢复现场入口 + +用户转回外部23项单测exit0回执,Python3.9.2、0.088秒,21份包文件前后摘要一致且未产生pyc;原始日志仍在独立方scratchpad,本会话按转述登记,不冒称已取得原件或亲自独立执行。R2完整性及独立单测两项已满足,包仍冻结为076078be…;内容结论保留有条件通过。 + +按08-23最新清单,继续固定ARM镜像配置专项、摘要绑定的原执行器封装和交互sudo入口准备;不要求重复批准已授权的受限恢复。真正源/最新性/manifest及环境门禁必须在ARM当场执行,未恢复前不得写成已通过。附件覆盖限制已登记,密钥解密仍NOT_TESTED;不提交/推送/部署,不制造业务事件。 + +14:46–14:50Z已补实际ARM镜像config+冻结app-check非空fail-closed验证(实际依赖、无替身),工具容器精确清理PASS;原R2字节封装入口SHA e2f57c3e…已普通用户暂存并回读一致,未执行真实恢复。主会话入口6项+传输2项通过,独立23项不重复计数。当前READY_FOR_MANUAL_SUDO_PREFLIGHT:交互入口run-approved-restore.sh先现场只读预检,通过后执行既已批准的影子恢复;仍需要用户终端sudo密码,不能代理取密钥或扩权。详细证据/限制及02:30北京时间源过期门禁归08-23最新节。 + +## 2026-09-16 恢复R2有条件通过:等待独立23项重跑与当场ARM预检 + +外部包完整性PASS、内容无阻断代码缺陷;证据Finding4仍是执行门禁,不能称无条件放行。已在包外补b7源码config及非空字段密钥fail-closed验证,目标镜像环境无FIELD_ENCRYPTION_KEY;主会话测试明确不计独立执行。附件无摘要/请假附件仅存在性覆盖、tmpfs noexec建议均已写入08-23验收边界,R2冻结内容和076078be…摘要不变。 + +下一步由原独立复核会话执行其已提出的23项单测重跑;真正管理员执行时现场preflight重核ARM源/时效/manifest及生产健康,旧观察不沿用。受限恢复授权已有,不重复确认,也不将当前条件结论直接升级成真实恢复通过。 + +## 2026-09-16 恢复执行准备已实施(授权已接回,不重复申请) + +已获受限ARM恢复授权,完成一次性适配器、23项本地安全单测、既有32条runner合同输出及ARM纯合成DB备份/恢复/附件负例/应用认证只读链,临时资源清理PASS。注意合成回执restoreStarted=true不代表真实恢复;realBackupRead=false,root宿主tmpfs及真实源/解密仍未测。 + +R2交审包restore-once-review-20260916-r2已冻结,manifest 076078be37dcd3c03c4be9b3413904b01fe1ac86f1bf34b34db88cc240334b05、19份文件复核一致;独立只读review仍PENDING,不能由主会话自检冒充。R1把createdAt误当目录runId的输入问题已只读纠正并在R2单独登记,旧包未重写。详情归08-23最新记录。 + +下一必要依赖为外部只读结论与ARM管理员执行入口;不重复问用户是否批准同范围、不自动扩sudo权限或绕过源24小时门禁。未恢复真实备份、未改生产数据、未新增付款/OCR/消息、未提交或部署。 + +## 2026-09-16 P2恢复审批清单已补齐(尚未演练) + +08-23/design已固定一份受限清单:已核源manifest与同源码ARM64镜像、30分钟时限、DB/附件/应用隔离与资源上限、影子DB只读角色、短期影子JWT身份和精确清理。纯配置专项PASS,不启动应用或数据库;远端仅补读可用资源。特别边界:不接触生产字段密钥,故敏感字段解密NOT_TESTED,整项完整应用灾备保留PARTIAL。源过24小时须重新绑定,不拿完成构建作无限期源授权。 + +清单待用户对其中影子恢复/权限DDL/短期认证读取/应用运行范围集中批准,再完成合成安全合同测试与执行器冻结。未实施真实恢复、未新增生产写入/云调用或提交/部署,具体剩余以08-23最新节为准。 + +## 2026-09-16 13:29 UTC:匹配源码ARM64镜像已构建 + +本次镜像构建已获明确批准并执行完成。来源固定备份对应b7d2285d,产物1d4d70f84397…为linux/arm64,BUILD_SHA与OCI revision一致;元数据回读确认无该镜像容器、盘余87.91GiB。首轮因tar输入识别失败停止,查明无产物后以gzip修正传输,第二轮构建exit0;两份回执均保留。详细输入/基础镜像/lockfile/产物摘要及边界见08-23最新记录与postrelease-recovery-20260916。 + +本阶段仅完成原生跨平台重建,不是原生产制品复原,更不是应用运行或配对恢复PASS。没有读取真实备份/Secret、启动OA、调用云业务、修改生产或提交/推送。P2的应用镜像缺口已补;只读身份/解密及最终恢复资源和安全清单尚待核定,P3真实恢复仍单独授权。 + +## 2026-09-16 13:20 UTC接续:异机完整性已证,应用镜像待准备 + +P1人工权限缺口已由用户sudo只读回执补证:ARM最近两份自然批次与生产摘要相同且DB/storage完整,最新18.78小时、盘余89.38GiB;两个自然日期的异机完整性通过,但08-28原task其他验收不自动结案。原先ACCESS_UNAVAILABLE保留为普通账号历史事实,不覆盖新的人工结果。 + +P2按新批准完成95个ARM本地镜像版本元数据只读检查,未找到可证明对应备份源码的应用镜像:BUILD_SHA/OCI revision均无有效提交标记,原amd64源image也不在集合内;有ARM64 PostgreSQL16标记镜像。下一步建议单独批准同源码b7d2285d的原生ARM64应用镜像准备,构建仅用源码及依赖,不带生产.env/密钥/备份、不启动应用;细节归08-23执行记录。备份恢复和应用只读验收仍未执行,无新增云调用/业务操作/部署,不沿用元数据检查授权开展构建。 + +## 2026-09-16 后续实施回执(12:50–12:54 UTC) + +用户批准按规划连续实施。本轮实际执行本地对账及生产/ARM只读观察;未提交、推送、部署、备份、恢复、发送消息或调用OCR。下节PLANNED描述为实施前历史状态,以本节实测为准;整体状态PARTIAL,不能宣称全部完成。 + +- P0:实际生产仍b1d25d3c,server运行身份前后不变;公网/ready末次检查PASS。已发布的日期/动态错误批次不重复实施。 +- P1:生产最近两个自然批次(09-15、09-14各18:30:05Z)数据库/附件字节及SHA256均匹配,归档元数据未变;最新批次年龄18.34小时,build b7d2285d,不是当前b1同源备份。生产备份timer active、上次service success/0,盘余27.39GiB。ARM架构aarch64,deadman timer active、上次service success/0,但普通账号对正式归档ACCESS_UNAVAILABLE,非交互sudo不可用;不能据timer成功推断异机完整或两个周期验收通过。 +- P2/P3:源镜像实测linux/amd64,目标ARM为aarch64;ARM镜像集合、容量及归档仍待有权限只读核验。状态HOLD_ACCESS_AND_PLATFORM,尚不能固定有效恢复来源和应用镜像。没有创建容器/网络/卷、下载真实资料、启用仿真或改sudoers;不切到生产宿主恢复。具体下一门禁归08-23执行记录。 +- P4:12:31:00Z至12:52:18Z限定窗口内新增回单0、通知0,WAITING_NATURAL_EVENT。实际pgboss回单队列及5秒调度存在,近30分钟completed任务29,jobsEnabled=true、provider=tencent;当前未归档回单2件均legacy。空队列tick完成不证明原件消费成功,不重传历史件。三条回单相关迁移成功/未回滚;这不是全库迁移复验。只读事务查询,无日志正文/原件读取,无人工入队或云调用。 +- P5:22项原task全部重新索引PRD/设计/执行记录摘要、引用提交与当前发布祖先关系、77条证据路径引用在执行副本/原仓库的存在性;不修改原验收复选框或批量归档。3个task含未直接解析到的引用,可能为缩写/其他隔离副本入口,不能判证据丢失或验收通过。共享日期外部输入/派生分类及错误基线新盘点写回09-05;AST registered229、migrated316、legacy612、finding0,不延期。 + +证据统一位于ignored的artifacts/qa-layout/postrelease-recovery-20260916:production.json、arm.json(非交互权限不可用)、arm-unprivileged.json、business.json、public-postcheck.json、task-inventory.json、reasons.json。task-inventory只证明索引和关联,不证明77条证据内容全部重新验收;原始冻结包未改。主会话执行/自检,不新增独立复核结论。 + +必要外部前提只集中为:取得ARM正式归档的可用只读执行方式,不需要用户提供业务密钥;据新事实冻结源/目标及架构方案后,再申请一次具体恢复写入授权。自然事件等待不安排虚假业务,历史全量验收缺口保留,不能用本轮索引或部署祖先关系结案。 + +本轮收尾验证:Trellis治理PASS、process baseline PASS、git diff --check通过,两个只读Python探针编译与任务索引脚本node --check通过;无产品改动,不新增产品测试/构建计数。稳定边界已由部署spec的配对恢复/只读备份调查及当前恢复设计覆盖,本轮实测权限与架构写入task,不另建重复spec。三处旧差异保留未改,未暂存、提交或归档。 + +## 2026-09-16 后续执行总清单(替代旧阶段下一步) + +状态:PLANNED_NOT_EXECUTED。本次用户批准更新规划,不新增远端操作、提交或部署。主会话执行/自检,不派实现或检查子代理;需独立内容复核时单列真实外部结论。技术执行与证据由执行端负责,责任人沿原task的shunli;只在范围、风险、验收实质变化或受保护操作需要时集中确认。 + +| 顺序 | 归属与工作 | 可验收产出/退出条件 | 依赖与边界 | +| --- | --- | --- | --- | +| P0 状态对齐 | 本task | 以b1d25d3c发布回执更新代码/发布事实;旧22行表按PRD证据逐项映射,不按祖先关系全部打勾 | 已上线内容不重做;三处09-04/审批中心旧差异不混入本轮 | +| P1 备份只读核验 | 08-28传输task,供08-23选源 | 同一观察时点核生产/ARM最新自然批次、manifest/字节/SHA、年龄、来源build/image、timer/最近退出码、容量及镜像平台;形成PASS/NO_GO/NOT_VERIFIED字段 | 先确认只读授权与访问范围;不以12:25手工备份冒充自然周期或异机副本,不重传/清理/修监控 | +| P2 集中恢复申请 | 08-23恢复task | 固定一个源manifest与匹配镜像、一个ARM隔离目标、资源预算、禁外联开关、安全只读应用验证方式、精确清理对象及停止条件,提交一份操作清单 | 以P1最新事实为前提;缺架构/权限/密钥访问条件先查清,不请求用户提供密钥值;不默认采用09-15旧8d历史源 | +| P3 一次隔离恢复 | 08-23恢复task | 获批后DB、附件引用/适用摘要、匹配应用分别出结果;记录RPO/RTO、原备份未变、生产前后健康、清理回执;失败按阶段停止并保留脱敏证据 | 未获源/目标专项批准不执行;缺失附件不按“可能测试”剔除;/ready不能替代应用业务读取和解密能力检查 | +| P4 自然业务观察 | 本task、09-04退回通知、09-11交接 | 出现新回单时只读核队列→结果→实际登记;自然退回事件核站内待办/通知与企微投递记录,分别披露 | 无对象则等待,不重传已付回单、不发测试消息、不制造审批/付款;不阻塞P1/P2及技术债准备 | +| P5 对账与债务 | 本task协调,09-05等原task实施 | 22项按代码/发布/验收/剩余对账;其他日期消费者先区分外部输入/内部派生,再按风险分批;错误遗留612次/591条为上轮基线,后续须新盘点 | 09-25复审、09-30必要延期提案、10-02查漏、10-05到期门禁沿原计划,不统一续期;这些是人工节点,不声称定时任务已创建 | + +执行顺序P0→P1→P2→获批P3;P4等待自然对象,P5可在等待窗口并行。P1若已有一个合格最新异机批次,可进入恢复准备;08-28事件正式结案仍需两个连续自然周期的独立证据。资源不足、源损坏、身份/平台不符时停止,不自动更换成旧源或改用生产宿主。当前应交付的是P1只读操作范围与P2方案,不再为刚发布代码重复测试/打包/部署。 + +已知参考(非新核验):2026-09-16T12:25:04Z手工备份build842c61e6,manifest 3e8c9d900d2b5290fa73931aede61f5f48e421590292a120d14fd33222ede678;镜像归档b1407e44715d55a4aef07965ddeb34362683155df7b36cb95b637cd5376bd222;同机、未传ARM、未load。当时生产磁盘约27.41 GiB可用不是ARM今日容量。新自然备份与新平台事实都待P1核验,不拿旧86%/8.93GB或历史89GiB作门禁。 + ## R1外部内容结论接回与R2修订 已收到新Claude会话的只读内容审查:有条件通过,F1动态文案退化阻断,F3/F4需登记。本轮按原范围修复F1/F2并以311项定向回归、真实登录JWT+隔离DB+浏览器缺字段修正专项证明;F4转审文案写明10至500字;F5补财务双端href及BX/PB均消失断言,22项浏览器重跑通过。F3共享日期解析及其消费者留待独立批次,无历史数据回填;F6批量错误透传为后续债务,F7防枚举声明仅限邮箱码链,详情见09-05记录。 @@ -70,7 +731,7 @@ E:真实单页隔离队列PASS保留,不重复消耗腾讯调用;自然生 | 09-02-reimbursement-readonly-content-consistency | e48514d包含 | 当前候选包含 | 不能用付款页面验收覆盖只读页面 | 补只读角色及内容证据 | | 09-04-approval-return-notification-gap | 2e69fe4e包含 | 当前候选包含 | 本轮退回通知4项单测通过,不是生产投递 | 核routing及自然/受控投递,生产写入另批 | | 09-04-reimbursement-state-gap-finance-view | 后续229867f5等包含 | finance-owner-release已有发布/公网回执 | 当前代码已上线,不再“待部署” | 分开历史附件治理及未提交事实记录 | -| 09-05-error-explainability-payment-readiness | a837912及后续原因码包含;本轮新增代码未提交 | 旧模块已包含,新一批提示未部署 | 本轮AST基线PASS,D阶段仍在实施 | 执行端按操作收口,不统一延期 | +| 09-05-error-explainability-payment-readiness | a837912及后续842c61e6/b1d25d3c包含 | 最新日期/动态提示批次已发布,calendar-contract-release可追 | 独立526项及构建通过;本轮AST registered229/legacy612/finding0 | 其他日期消费者分类完成,历史错误仍按原期限治理,不统一延期 | | 09-05-existing-deploy-db-lifecycle-isolation | f223bfb包含 | 本次部署证明DB未重启 | 本次运行证据不是首次初始化全路径测试 | 合并已存合同测试与发布回执 | | 09-05-trellis-upgrade-rule-cleanup | review状态,无登记commit | 本地工作流无需公网发布 | 本轮治理检查PASS不代替原改动审查 | 核既有规则清理差异,不重装Trellis | | 09-06-release-gate-integrity | 81102c26有提交 | 当前发布实际执行前后预检PASS | 当前pending=none,不伪造故障生产演练 | 故障断言沿原合成证据对齐 | diff --git a/.trellis/tasks/09-08-reimbursement-payment-closeout/prd.md b/.trellis/tasks/09-08-reimbursement-payment-closeout/prd.md index 0b0aa621..c9a169be 100644 --- a/.trellis/tasks/09-08-reimbursement-payment-closeout/prd.md +++ b/.trellis/tasks/09-08-reimbursement-payment-closeout/prd.md @@ -1,5 +1,77 @@ +## 2026-09-18 G19 最新验收状态 + +分层验收已获明确批准并本地实施验证:完整发现65/365,351本地、12同候选公网义务、2冻结旧链。74项覆盖/runner、7项诊断检查和2条本地冻结保护通过;原完整full-r2仍失败,公网NOT_RUN_LOCAL、独立PENDING,R1未启用。具体合同、证据、失败保留及后续顺序见implement顶部;未提交部署或执行真实操作。 + +## 2026-09-18 最新协调状态 + +G18已补OCR/R108/33路由覆盖并修三处本地缺陷:票据详情状态、PWA更新事件、采购创建事务。原四文件并发4/4和最终认证/OCR18/18通过,产品131/QA39各按输入分列。官方执行接回09-06;完整门禁/12公网与2冻结归属/WebKit/R1/独立及真实业务发布仍开放,见implement顶部。借款T1–T4、ERP退役、strict遗留0保持先前本地完成,未部署。 + # 报销付款与银行制单业务闭环 +## 2026-09-18 G16/G17 门禁接续结果(最新协调入口) + +官方 current 仍为 09-06-release-gate-integrity。普通完整 full-r2 已运行:65 文件/359 用例,326 passed/12 failed/5 timedOut/16 skipped;后续 14 条原失败观测已有当前合同定向通过,另补原 R75 串行未执行 2 条;WebKit 1 条缺运行时、冻结差旅 2 条保留。当前发现 64/358 对应严格重复付款整例退役,无缺失/未知;枚举包装器临时摘要采集告警保留。不是一次全量通过,原结果不回写。 + +账号强制改密/场景8及批次付款拒绝/资产编号/合成通知配置已接现行合同;合同管理对照和原只读整例 4 类实际页面动作非零反例完成本地验证。审批进度、付款、回单、核销/PDF/审计/AP 整链及真实 PWA 单独通过。PWA/事务/长链的完整并发稳定性不据单独通过关闭。20 路由项视觉矩阵通过,但 `/system/*` 未展开,不能称全部页面覆盖。 + +本轮证据与增量候选在 full-gates-followup-20260918;旧 25 目录/7656 文件及 290 项原差异快照按 protection-result 核验保留。最终检查/候选摘要见同目录回执,独立 PENDING。借款 T1–T4、ERP 退役与 strict 遗留 0 维持此前本地完成,旧“借款尚未实施/ERP17余额”是历史状态。 + +余项按 09-06 implement 顶部六组接续:G18 本地 OCR/R108/真实路由覆盖;完整集合归属与并发可靠性;WebKit;R1/独立审阅;真实批次/个人通知/银行/K1/K2/恢复事件及自然业务事实;最后才是另批提交发布。真实处理人/企微个人发送尚未查询,借款未据此上线。未连接生产/ARM、读取真实秘密配置备份、重跑旧恢复、提交推送部署归档或发送真实通知/付款。 + + +## 2026-09-17 历史验收与技术余项范围确认(当前合同) + +用户批准继续本地历史验收证据对账、三类技术边界确认和独立复核交接。交付为22历史任务逐条件支持/缺口/后续动作、低年份/AP反向区间/损坏导入文件的在用性与兼容决策,以及K0/当前产品2主题的准确候选入口。原231项索引为历史输入,不把勾选或提交包含作为验收;当前新增条件另列,不改原未满足复选框、不归档。 + +当前错误合同限定修复已收敛:520入口已处理503,剩17为用户明确排除的ERP旧分支。日期D1–D14已完成各自限定批次,三类余项本轮先核范围和规划,不擅改产品规则。具体清单/证据入口见implement顶部;下方589命中/HR待实施等描述保留原时点,不再作为当前余额。 + +验收需有逐项来源/证据层次/具体剩余;找不到证据如实标明,不为旧/待精简模块追加修复。独立复核在inline限制下保持PENDING,本地报告不冒充独立结论。本轮不访问生产/ARM或真实密钥配置备份,不重跑恢复,不提交推送部署归档,不实施借款。 + +## 2026-09-17 按后期规划连续执行(当前合同) + +用户已批准连续完成可执行的本地工作。本轮沿09-05落实D5–D11日期治理(D8为盘点)、CSV日期时区缺陷及9原因,分别520项和61项定向测试通过;前端类型/构建通过。事件缺口方案、22历史task的231复选条件索引和借款T1a字段/约束/锁图草案同步落盘,未把索引/草案当作结案或产品实现。最新完成/剩余量见implement顶部,旧“仅整理/未实施”段落保留原时点。 + +原边界持续:仅本地合同/合成验证,不读真实配置密钥备份、不访问生产/ARM、不重跑旧恢复、不提交推送部署归档。W1独立复核、W3四恢复出口、W4六证据缺口、W7两自然业务链仍开放;W5剩568指纹/589命中,W6剩HR多格式及其他日期格式待审;W8逐项实质验收、W9借款后续原型/产品阶段仍未完成。当前不提供总体完成百分比。 + + +## 2026-09-17 后期工作详细整理合同(最新入口) + +本轮交付为后期工作总表及剩余量,不新增产品、真实数据或发布执行。复用本task协调8条既有工作线和1条借款新功能线;每条写明归属、已完成/待实施/待事实/待授权、可观察退出条件和依赖,详见implement顶部。官方current已切回本task,09-05继续拥有日期/错误治理实现。 + +已完成K0、B1/B2和D1–D4本地结果分开登记;原受限恢复仍PARTIAL。剩余598次AST命中、577条指纹、58个扫描分组及借款4阶段/8项AC按各自口径统计,不相加成任务总数或推导整体完成百分比。历史22任务索引不等于22个未实现功能;当前23合同包含借款。未来到期节点不提前勾选,既有验收保持。 + +本轮仅更新现有规划和对应notes、执行文档治理及摘要对账;不访问生产/ARM,不读取真实配置/密钥/备份,不重跑旧恢复,不提交/推送/部署/归档,不开始借款或新日期批实现。 + +## 2026-09-17 后续优先级规划(仅规划批准) + +用户要求按“先关键遗留、后借款新功能”的建议规划。沿用本任务协调,密钥保管/敏感恢复归08-23,日期/错误治理归09-05;仅借款业务新建09-17-company-loan-principal-repayment,保持planning。本轮不重新开启已结束的受限恢复,也不改变上一节阶段收尾事实。 + +验收顺序:先完成08-23的本地恢复合同和合成验证准备,按实测条件另批密钥保管核验及受控恢复;等待受保护操作时推进09-05外部输入和到期治理,不因外部等待停掉可做的本地工作。新借款任务先完成规划,关键遗留的证据或风险处置明确后再决定开发准入;上线不得默默豁免密钥/恢复风险。自然事件观察和历史task归档不作为所有新规划的硬前置。 + +具体阶段、责任、停止条件和新会话接续入口见implement顶部优先级表;每段实际实施仍须其最新方案已获批准,生产/ARM访问、密钥使用、恢复、部署及真实业务写入分别受保护。当前只授权规划落盘,不启动任何新增技术或运维执行。 + +## 2026-09-17 阶段收尾边界(当前入口) + +用户采纳先收尾旧工作、再独立规划公司借款台账与本金归还的建议。本轮交付为已发布阶段的事实整理和后续责任交接,不是所有历史task的全量验收或归档。已核发布基线为b1d25d3c;已批准的一次受限ARM恢复已完成且入口已消费,整体仍为PARTIAL。完成项及证据、未完成项归属/触发条件以本task implement顶部“阶段收尾与后续归属”为唯一协调入口,旧节保留历史时点。 + +本轮验收:完成项分列代码/发布/独立验证/真实业务边界;每个开放项均关联已有task、责任人和退出条件;纠正直接关联task摘要中的过期发布描述,不修改原未满足验收、状态或历史提交锚点;只做本地文档/流程验证,产品源码、冻结包、HEAD/index及本轮范围外差异保持。保留自然回单/通知观察、完整恢复与密钥保管、传输事件其他验收、日期/错误治理及历史证据映射待办,不自动延期或制造业务事件。 + +阶段完成不等于本task完成,status继续in_progress。新借款需求不加入本任务的付款类型、审批或回单范围;后续独立建task并审阅方案后才实施,新功能上线仍须评估已有资金与恢复风险。本次“收尾”不包含提交、推送、归档、部署、真实付款、重新恢复或新任务实施授权。 + +## 2026-09-17 恢复阶段执行结果 + +P2/P3本次已批准的受限ARM影子恢复完成:真实DB和影子认证只读应用PASS,附件仅1条精确已接受历史缺件、未知不一致0,清理及生产/源/监控后验PASS。整体保留PARTIAL,敏感字段解密未验、密钥来源UNKNOWN;这不等于全量灾备或其他自然业务/到期治理任务完成。新入口已消费,不重跑、不要求重复授权,详见08-23及本task实施记录最新节。 + +## 2026-09-16 上线后剩余工作合同(当前入口) + +用户要求按建议更新规划。复用本任务协调,恢复和异机备份继续归08-23-isolated-backup-restore-drill、08-28-production-arm-backup-transfer-incident;不新建重复总任务,不将本轮规划批准解释为远端执行或恢复授权。 + +已完成基线:b1d25d3c已按批准上线,日期/北京时间提示增量独立内容审查和独立526项/typecheck-build通过,部署后只读验收通过;证据归calendar-contract-release及calendar-contract-review-followup。下面的历史“未提交/未部署”保留时间语义,不驱动重复实施。现有842c61e6同批备份与独立镜像仅同机完整性通过,未证明异机到达、配对恢复或当前b1d25d3c同源自然备份已形成。 + +本轮目标依次为:先只读选定可验证的源/目标并核异机新鲜度,继而集中申请一次隔离恢复授权,完成DB+附件+匹配应用分项验收;自然业务旁路观察和历史任务/技术债对账可并行,不要求员工代做软件回归。详细依赖和清单见本任务implement最新节及两个恢复任务。 + +完成判据:恢复分项有实际执行证据、生产未受影响且本轮临时资源清理完成;异机状态区分一次恢复可启动与连续两自然周期事件结案;业务只认自然发生的回单/通知结果,没对象记WAITING_NATURAL_EVENT而不是PASS;任务对账区分代码/上线/验收/等待,未覆盖日期消费者与错误豁免按原期限跟进。密钥来源UNKNOWN保留,不追问值、读历史命令、轮换或扩权。提交、推送、归档、真实恢复、生产/ARM写入仍分别明确授权;本轮只更新规划和本地文档验证。 + ## 2026-09-15 六项统一收口合同(本轮最新) 用户采纳后续六项并要求一次规划、连续推进,非必要不提问。复用本任务承接报销付款闭环的统一协调,具体实施仍归已有模块task,不创建新总控系统或每个修复一个task。此增量是后续批准范围,历史阶段的未部署描述不代表当前状态。 diff --git a/.trellis/tasks/09-08-reimbursement-payment-closeout/task.json b/.trellis/tasks/09-08-reimbursement-payment-closeout/task.json index 4d90b8fc..4135ca18 100644 --- a/.trellis/tasks/09-08-reimbursement-payment-closeout/task.json +++ b/.trellis/tasks/09-08-reimbursement-payment-closeout/task.json @@ -2,7 +2,7 @@ "id": "reimbursement-payment-closeout", "name": "reimbursement-payment-closeout", "title": "报销付款与银行制单业务闭环", - "description": "围绕0014待付款、三张退回报销及建行xls制单推进业务闭环规划和限定技术债修复;复用已有付款能力,生产数据、真实业务操作和发布分别批准。", + "description": "报销/付款与借款本地成果保留;G18覆盖及PWA/采购等三处限定修复已验,余项集中于完整门禁、独立、真实业务与发布。", "status": "in_progress", "dev_type": null, "scope": "business-payment-closeout", @@ -21,6 +21,32 @@ "children": [], "parent": null, "relatedFiles": [], - "notes": "2026-09-10:代码已实现付款中文文件名、下载免手填理由、右侧处理进度及共享UTF-8文件名解析;与09-04退回留痕删除按独立补丁完成本地联合验证(296+7单测、19页面回归、真实隔离DB/HTTP/双端),不混提交。发布未执行;业务仅确认用户已成功下载旧版制单,银行实付/回单未代办。当前等待独立只读复核及明确提交发布授权;不让员工代测。详见implement.md本轮证据。", - "meta": {} + "notes": "2026-09-19 g20-full-r6:4437后端通过/1原跳过,372本地浏览器全过;PARTIAL仅dirty_candidate,独立PENDING/无部署,旧registry保护例外保留且无新增漂移。当前余额见implement顶部。\n2026-09-18 G19分层验收已批准并本地实施验证,74+7合成检查和2条冻结保护通过;公网12义务/完整门禁/WebKit/R1/独立及真实事实保持开放。\n2026-09-18 G18本地接续完成:33路由项、R108、OCR;并发4/4、认证/OCR18/18、unit131。原full-r2不回写;12live/2冻结验收G19本地实施验证完成,公网义务待兑现、WebKit/R1/独立/外部事实仍开放。新包local-coverage-g18-20260918,未提交推送部署。\n2026-09-18协调更新:09-06完成G16/G17本地批次,普通全量仍失败;14原失败观测及2串行缺失已定向重验,WebKit/冻结差旅/完整覆盖与可靠性、R1/独立及外部事实仍开放。借款T1–T4、报销反馈与ERP退役维持此前本地完成,非公网事实。最新入口见implement顶部。\n2026-09-17本轮W8/W6:22任务231条件逐项处置,154历史支持/56部分/11外部/4替代/4冻结/2条件未触发;82引用定位,接回AP/seed/预检b87cc55发布事实。B34损坏导入500与合成日志内容已复现并规划、未修;低年份分支分类完成,AP反向保持原语义,73项通过。K0/G8两主题完整性与运行时保持、独立PENDING;无新产品、远端或提交发布。\n连续推进至D10/D11,另9文件61通过;后续剩HR多格式策略、589错误遗留及各外部门禁,未宣称全部结束。\n2026-09-17后期连续执行:日期D5-D9、9原因及520本地验证完成;事件六缺口方案、22历史task231条件索引、借款T1a技术草案落盘;独立/恢复/自然业务/产品准入仍开放,无生产或提交发布。\n2026-09-17后期总表:官方current已切回本task,仅整理规划;K0本地30项/166键、B1/B2的450项与前端构建、D1–D4的192项/治理均有各自回执,未提交/发布,独立复核PENDING。按8条既有收口/治理+1条借款新功能共9条工作线编排;错误债务577指纹/598命中/58扫描分组,2026-10-05到期不变;日期新D5–D8只规划。K1/K2、历史缺件/完整计时、备份事件、自然付款/通知、22项历史任务验收映射均保留。借款planning,4阶段/8项AC待实现验收。原受限恢复PARTIAL与入口已消费不变,未连接生产/ARM、读真实秘密/备份或重跑恢复,未提交推送部署归档。最新范围、依赖、量级和人工节点见implement顶部,不按测试数量宣称全量完成。", + "meta": { + "g18Local": "verified_scoped_not_full_gate", + "independentReview": "PENDING", + "g19": { + "state": "LOCAL_VERIFIED_PUBLIC_PENDING", + "discovered": 365, + "localMandatory": 351, + "publicMandatory": 12, + "frozenRetained": 2, + "coverageTests": 74, + "freezeProtectionPassed": 2, + "independentReview": "PENDING", + "normalReleaseEligible": false + }, + "latestLocalCandidate": { + "run": "g20-full-r6", + "localChecksPassed": true, + "receiptStatus": "PARTIAL", + "browserPassed": 372, + "publicPending": 12, + "independentReview": "PENDING", + "deploymentEligible": false, + "legacyProtectionStatus": "FAIL_ONE_KNOWN_DERIVED_REGISTRY", + "newProtectedDrift": false, + "productionNumberingActivated": false + } + } } diff --git a/.trellis/tasks/09-09-existing-deploy-seed-separation/implement.md b/.trellis/tasks/09-09-existing-deploy-seed-separation/implement.md index c346ca5e..6b0ff8a3 100644 --- a/.trellis/tasks/09-09-existing-deploy-seed-separation/implement.md +++ b/.trellis/tasks/09-09-existing-deploy-seed-separation/implement.md @@ -1,5 +1,13 @@ # 规划与执行清单 +## 2026-09-17 历史发布回执接回(仅本地文档对账) + +09-08本轮W8逐条件对账从主checkout同名task implement的“已按本候选例外完成部署(2026-09-09)”接回原文档引用,并核现有脱敏postcheck-result.json;不复制整份旧文档覆盖本副本,也不读取原始发布日志/环境或连接远端。准确来源/行号/摘要与布尔事实见acceptance-boundaries-20260917的historical-acceptance-review.json及historical-report-facts.json。 + +seed分离已随b87cc55发布,init阶段validate_config→migrate→check_required_configuration且无seed;已有合成PG/组合110回归及有限独立review保留,不重做已实现分离。 + +本次未执行新的发布、真实查询或业务写入;旧“未部署”段落是当时记录,不再代表追加阶段当前余额。原PRD未满足项不补勾,status/completedAt/历史commit不改,不归档。当前G8未提交产品增量仍待独立复核和新的交付授权。 + - [x] 由用户采纳的队列进入本 task,官方 create 激活 planning;未运行 start。 - [x] 确认两个无条件 seed 入口,建立需求/草案;关联既有数据库生命周期隔离合同。 - [x] 核对仓库支持的部署/init 调用路径、最低配置与现有测试,形成待审方案和最小写入文件清单;不声称核对了仓库外手工命令。 diff --git a/.trellis/tasks/09-09-existing-deploy-seed-separation/task.json b/.trellis/tasks/09-09-existing-deploy-seed-separation/task.json index 6a088f85..129a959a 100644 --- a/.trellis/tasks/09-09-existing-deploy-seed-separation/task.json +++ b/.trellis/tasks/09-09-existing-deploy-seed-separation/task.json @@ -21,6 +21,6 @@ "children": [], "parent": null, "relatedFiles": [], - "notes": "", + "notes": "2026-09-17 W8本地对账:seed分离已随b87cc55发布,init阶段validate_config→migrate→check_required_configuration且无seed;已有合成PG/组合110回归及有限独立review保留,不重做已实现分离。 原回执从主副本接回,见implement顶部;本轮无新的远端/提交发布,原验收未补勾。\n", "meta": {} } diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md new file mode 100644 index 00000000..3145c7fd --- /dev/null +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md @@ -0,0 +1,111 @@ +## 2026-09-17 实施细化 + +`origin_reference`作为主体+出借人范围的原借入凭据唯一引用,防止更换幂等键重复登记。期初已还差额必须有adjustment证据。身份与账户分别版本控制,改账户同时撤销身份确认,旧未付快照失效。 + +新申请持lender→排序loan→日序号锁,只创建本事务尚未可见的新batch;所有既有batch路径先锁batch,核销沿receipt advisory→排序附件→lender/审批身份→排序loan,台账/身份维护不反向锁batch。取消保留旧申请和批次,不原位重提;一申请恰好一个保留批次。下载制单或上传回单转unknown,均不扣减本金。首期不能仅凭理由解除unknown。 + +借款材料沿现有PDF/图片字节检验,按种类归属借款、随机存储键、内容hash去重、授权下载理由和发送前审计;已确认材料保留,未新增物理删除接口。代账确认引用和四类材料承担本地业务证据门禁,不等同银行真伪核验。 + +下方“规划/尚未写schema/未运行”的文字为原方案形成时状态,当前状态以implement顶部实测回执为准。 + +# 本金归还最小方案(规划,未实施) + +## 1. 复用边界 + +复用付款账户、批次制单/独立复核、回单存储/核对/交易去重及审计,不复制付款引擎。首期一张申请绑定一个活动批次、批次只有一笔付款;原借入分配保留在申请明细,不拆成两笔银行转账。付款批次复核即本次还款审批,不另接万能工作流;已有报销流程不改。 + +当前付款来源存在reimbursement/否则按travel_order处理的分支,新增loan_repayment必须逐消费者改为明确支持/拒绝,不能只加枚举。新来源覆盖候选/快照/权限/预检/创建/复核/导出/回单上传、job认领、匹配/人工复核/登记、列表统计和通知深链;未审消费者必须拒绝,不默认套差旅或报销。 + +## 2. 最小数据合同 + +- 个人出借人记录:公司关系、可选OA员工关联、身份核对状态、加密收款资料及审计。非员工不虚建员工;已分配后的身份/账户变化使未付申请失去就绪资格,不能只改姓名避开自审。 +- 借款记录:公司/出借人/币种、原始本金、已确认未还本金、核对截止日、历史登记标识、原入账/协议证据及确认审计。待确认不能支付,原登记与后续变动均可追溯。 +- 本金还款申请与分配:一对多关联原借款、精确分配金额、事由和提交人;同公司/出借人/币种,合计与唯一付款明细一致。提交时冻结来源版本/收款信息,复核和登记时重验。 +- 付款/审批状态优先由关联批次权威事实投影,不维护第二套可以独自变成已付的流程;会计交接确认单独存储,不等同银行已付。 + +最终表/字段名、索引与外键在实施前按Prisma现有约定确定。金额用Decimal或最小货币单位,不用浮点累加;活跃分配唯一约束、金额正数/上限及交易指纹占用必须有服务端/数据库保证。数据清理走留痕,不新增批次级联删除来源/交易证据路径。 + +## 3. 并发与状态不变量 + +提交申请在事务中按稳定顺序锁定借款记录,重算已确认未还本金减活动占用;只有余额足够才创建分配及批次。并发同一借款只能占用可用额度,不以页面预检授权。编辑已提交申请须退回/取消原活动付款关系后重新提交,不能改已审快照。 + +登记路径保留既有批次→交易advisory→附件锁序;涉及多笔借款锁时按稳定ID顺序追加,核各路径不存在反向依赖后才落实现。交易claim、分配核销、付款事实和审计同事务提交;中途异常整体回滚。已付请求重试不重复减款,重复银行交易在新来源及报销来源间共用去重空间。 + +待复核/已复核未付仅占用本金;银行在途/未知保持占用并显示查证责任,不能为解锁重付而标失败。安全取消或有依据的确定失败才释放额度;已付纠错另审,不回写历史余额掩盖错误。 + +## 4. 权限与回单 + +新台账沿财务全账读取与付款审查分离:财务管理台账,审批人只读其有权审批的申请及必要证据,普通员工无全账入口。制单人、提交人、出借收款人身份集合统一传给提交预检/批次复核/人工回单复核/登记重验;在职状态和业务权限不吃历史批准,admin通配不能替代独立身份。 + +银行备注采用短本金语义,年份不一致时不生成误导性单一年份;长事由留OA。对现有建行制单模板使用显式业务用途映射,原报销用途不变。回单识别范围只有经过新来源快照和权限校验才可扩展,不能无条件对新业务调用腾讯;真实样本/云调用另批,未验证前不宣称自动支持。 + +自动匹配需金额/币种/完整双方账号/成功状态/唯一交易身份满足;无OCR证据时沿既有双人手工录入复核边界,不把手工值称银行验真。人工复核不覆盖原失败或处理中状态,原件、指纹及版本证据保留;读取账号和原件继续权限/审计/脱敏。 + +## 5. 页面与会计边界 + +财务模块新增借款台账和本金还款入口,复用列表/详情壳、金额/日期格式、附件及下一步投影,保持既有PC/手机样式;不设计新视觉体系。详情显示已确认未还、占用、可还、历史借入分配和会计交接状态,不靠颜色单独表示风险;缺资料/无人可批/银行在途给具体责任提示。 + +只登记代账核对结果与凭证引用,不选择或生成会计科目、不作税务判断;历史确认和实际银行付款资料由有权人员提供,测试使用合成样例。 + +## 6. 验证与发布/回退 + +隔离DB/HTTP测完整申请→复核→回单→登记→分配核销;覆盖无独立审批人、收款人兼CEO、跨公司、余额并发、失败释放、在途不释放、重复登记、跨业务同交易、来源/账户变化及审计失败。浏览器用真实本地认证和合成数据,不把数据库直接置paid当付款流程通过。 + +新表和来源枚举属于迁移/权限/状态机变更,必须独立只读审查。新增枚举不保证旧镜像兼容;有新业务数据后不得直接退到仅识别两类来源的旧版,应保留兼容版本或写入隔离后另审前向修复,不删新账/claim“回退”。发布前重新核候选、备份、原密钥保管和相关敏感恢复风险,不沿用旧例外或已有一次演练授权。 + + +## 7. 2026-09-17 源码消费者地图与技术定稿要求 + +| 消费者 | 已核事实 | 新来源实施前必须证明 | +| --- | --- | --- | +| Prisma `PaymentBatchEntityType` / `PaymentBatch` | 现有两类来源;原有批次/回单/claim关系存在 | 增量枚举、新来源关系与索引显式约束,旧数据无回写;新增来源出现后旧镜像不作为无条件回退 | +| `paymentBatchReadiness.js`、`paymentBatches.js` | readiness非报销直接冻结;路由多处报销/否则差旅分支 | loan_repayment单独资格与快照适配;未知来源一律拒绝,travel_order继续沿原冻结规则 | +| `paymentReceiptVerification.js`、`paymentReceiptManualPersistence.js` | 真实核对及人工持久化明确仅报销 | 新来源独立参与人集合、同一transaction claim去重、版本与成功状态规则;不能只删除reimbursement检查 | +| `paymentReceiptOcrJob.js`、`paymentReceiptMatching.js` | worker认领与匹配受业务来源限制 | 新来源eligibility与只读storage边界明确后才允许进入队列;真实云端样本未验另记 | +| `client/src/api/paymentBatches.ts`、`PaymentBatchesPage.tsx` | 创建按报销,详情部分二选一标签默认差旅 | 类型/入口/详情/深链/统计显式处理本金还款,保持原报销操作与普通员工权限 | + +拟以`LoanLender`、`CompanyLoan`、`LoanRepayment`、`LoanRepaymentAllocation`四类核心关系承载出借人、原借入、还款申请和分配;借款材料使用专属证据关系,付款回单继续归原批次。命名和字段尚待T1按Prisma约定定稿,不创建通用财务台账/文件中心。约束至少包括来源版本、单公司/出借人/币种、申请与借款分配唯一、金额精确且为正、证据不级联删除及历史确认可追溯。 + +锁序定稿必须覆盖创建、重新提交、复核、取消、失败处置、回单登记和余额确认全部写入路径:既有批次先锁batch,再沿原交易advisory/附件顺序,之后按稳定ID锁借款并更新分配;不得存在先锁借款再等待同一既有batch的反向路径。新批次创建在同一事务中的新记录不会成为绕过额度锁的理由;若当前创建器要求反向锁序,应先调整技术方案并审阅,不能边编码边隐藏差异。并发与数据库回滚以真实隔离PG测试证明,mock只覆盖路由输入与调用边界。 + + +## 8. T1字段与约束定稿草案(2026-09-17,仅本地设计) + +本节把前述概念模型具体化为实施合同草案,尚未写Prisma/迁移或启动数据库。金额沿PaymentBatchItem的Decimal(14,2),首期currency_code固定CNY;日期@db.Date,标识UUID,snake_case列名、复数表名、created/updated及操作者沿项目约定。绝不以Number累加核额度。 + +| 模型/表 | 核心字段与不可变事实 | 约束/索引及删除 | +| --- | --- | --- | +| LoanLender / loan_lenders | id、legal_entity_id、name、employee_id可空、identity_status(unverified/verified)、identity_version、account_version、bank_account_encrypted、bank_name、account_name_encrypted、archived_at | FK主体/关联员工Restrict;(legal_entity_id,id)唯一用于复合归属;身份和账户变更递增对应版本及留审计;姓名不作唯一身份;不保存明文账号或密钥摘要 | +| CompanyLoan / company_loans | id、loan_no、legal_entity_id、lender_id、currency_code、original_principal、opening_outstanding_principal、received_date、balance_as_of、historical_entry、confirmation_status、version、confirmed_by/at、accounting_confirmation_ref | loan_no唯一;主体/出借人复合FK;CHECK 0≤opening_outstanding≤original且original>0、CNY;索引(lender_id,confirmation_status,received_date);确认前不能分配;确认后本金/期初余额不可覆盖改写,纠错另审留存 | +| LoanRepayment / loan_repayments | id、repayment_no、legal_entity_id、lender_id、currency_code、reason、amount、version、submitted_by/at、lender_identity_version、lender_account_version、payee_snapshot_json(仅允许加密/脱敏值)、bank_result_state、accounting_handoff_by/at/ref | repayment_no唯一;amount>0;CNY;主体/出借人复合FK;bank_result_state仅作为银行结果证据分类,不替代批次审批/付款状态;申请提交后快照不可原位改;会计交接不是银行成功 | +| LoanRepaymentAllocation / loan_repayment_allocations | id、repayment_id、loan_id、amount、loan_version、reservation_state(reserved/settled/released)、settled_at、released_at、release_reason | (repayment_id,loan_id)唯一,amount>0;FK均Restrict;索引(loan_id,reservation_state);跨行总额和同主体/出借人/币种在锁内检查,不能谎称单行CHECK能保证聚合不超付 | +| LoanEvidence / loan_evidences | id、loan_id、kind(agreement/incoming/accounting_confirmation/adjustment)、storage_path、mime_type、file_size、file_sha256、governance_status、uploaded_by/at、archived_at/reason | 专属材料关系;loan/uploader FK Restrict;大小/类型沿受控附件合同;同一借款同一hash/kind防重复引用;访问需权限/审计。银行付款回单继续放PaymentBatchReceiptAttachment,不复制OCR/claim体系 | +| 既有PaymentBatch增量 | entity_type增加loan_repayment;loan_repayment_id可空且FK Restrict,其他来源必须为空 | SQL CHECK来源与专属FK一致;对loan_repayment_id建status≠cancelled的部分唯一索引,executed仍占唯一,failed不自动解占;每批恰好一item、entity_id=repayment.id、amount=申请与分配之和由事务及延迟约束触发器验证,迁移原型需证明;旧两来源原数据不改 | + +可用本金=已确认期初未还本金−本系统settled分配−reserved分配;已确认外部历史偿还只体现在期初未还及原证据,不能再次减账。每次提交/执行在CompanyLoan行锁下重算聚合,失败回滚不能留下孤儿claim/分配/审计。发布后修正期初余额需另有可追溯调整流程,不提供直接编辑confirmed字段的API。 + +活动批次取消后允许同一申请重提时,新建批次并重建新版本分配;原released分配不能覆盖掉历史金额,故首期更简单规则为取消后复制成新申请编号,旧申请/分配/批次保留。禁止对executed申请重新创建批次。批次草稿删除对loan_repayment明确拒绝,使用有理由取消;不能沿原Cascade删除回单/claim链。 + +## 9. T1锁图与并发验收(已对照当前源码,待数据库原型) + +源码事实:paymentBatches.lockBatchWithItems锁batch;lockPaymentEntities目前reimbursement/否则travel_order;现execute先锁来源实体,再claimVerifiedReceipts;paymentReceiptVerification claim路径先交易advisory再按id锁附件,manualPersistence先batch→交易advisory→attachment。**不能直接把新借款塞入原lockPaymentEntities分支并声称满足前节锁序。** 新来源execute须显式分派,把借款锁放在现有receipt锁之后;旧两类路径保持原回归边界。 + +| 操作 | 固定锁序/验证 | 并发/失败退出 | +| --- | --- | --- | +| 创建申请和新批次 | lender→按id排序的loan→申请/分配→编号daily sequence advisory→插入本事务新batch;同请求幂等键唯一 | 在锁内重查确认/余额/版本;创建后不再等待任何已存在batch;两事务竞争同loan只能成功可用额度内的一笔 | +| 复核、取消、失败处置 | 既有batch→lender→排序loan→申请/分配;不取receipt锁的路径不可调用需要它的子流程 | 重验独立身份/账户版本;只有未送银行或有确认失败证据才释放;failed状态本身不构成释放证据 | +| 回单登记 | batch→全局receipt transaction advisory→按id附件→lender→排序loan→申请/分配/claim/付款事实/审计 | 所有写同事务;精确claim去重;审计失败回滚;重复成功登记返回原事实、不再减本金 | +| 人工回单复核/OCR完成 | 继承原batch→advisory→attachment或原已核最小顺序;涉及额度时只能向后取lender/loan | 不得由持loan的路径回调此操作;失败/在途回单不可被人工改成成功 | +| 出借人修改、余额确认 | lender→排序loan;只改来源版本/证据,不锁/更新已有batch | 未付资格在下一次预检/复核/登记重验时失效;禁止反向“来源锁→批次锁”广播更新 | + +2026-09-17实施定稿:新申请先取得lender和排序loan锁,再分配编号并插入本事务的新batch;整个创建路径不等待既有batch。既有batch处理/重试不分配新编号,来源维护不反向锁batch。此顺序取代初稿序列锁优先的建议。数据库原型必须覆盖相反loan选择顺序、并行申请总额超过余额、取消与登记竞争、账户变更与复核竞争、同交易跨报销/借款竞争、最后审计抛错回滚、同幂等键重复请求。使用两条真实PG连接和barrier,不能由mock顺序调用替代。 + +bank_result_state分为not_sent/unknown/confirmed_failed/confirmed_success;新申请未制单可为not_sent,下载后不等于付款成功,也不允许仅凭OA记录仍未付就释放额度。无法证明是否已送银行时保持unknown并阻止重付;解除unknown需有范围的银行结果/未发送确认事实和审计。该状态仅控制风险守卫,已付仍以受核验的批次执行事实为准。 + +## 10. T1权限、接口和交付门禁 + +沿finance.ledger.view管全账读取,新增loan.manage管登记/确认/提交,不默认授予system_admin/普通员工;实际角色种子修改列独立权限审阅。payment.batch.review既有资格加“制单/提交/出借身份集合排除”,admin通配也不免除独立性。employee_id为空且identity_status未verified不得付款,已确认外部非员工不得伪建账号;同一人的多OA身份须先完成真实身份映射,否则不能假定独立。敏感查看沿专用权限与最小投影、审计和原字段密钥。 + +拟接口/lenders、/company-loans、/loan-repayments分开;创建与确认分离,提交返回关联批次,只通过既有/payment-batches复核/回单/登记完成付款。GET只读无隐式确认。每个写入采用版本检查及request key,409可重读不自动重复付款。界面沿现有样式,新增来源显式标签,不采用“非报销=差旅”默认。 + +T1文档切片完成;T1数据库约束原型、真实并发验证及独立审阅尚未完成,不勾原8项AC。T2–T4产品实现准入仍取决于本方案审阅和相关敏感恢复风险处置。没有运行Prisma迁移、建本地库、真实OCR、秘密/资料读取或付款。 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md new file mode 100644 index 00000000..0b4a9937 --- /dev/null +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md @@ -0,0 +1,95 @@ +## 2026-09-18 共享付款回归与交接补充 + +09-06本地受管重验已覆盖报销主管/财务审批、财务同页进度、批次独立复核、回单人工更正/独立复核、付款核销及AP/票据归档两条主链;完整r6为3通过/1入口矩阵失败,矩阵另在r7通过。此证据支持共享付款旧来源回归,不代替本task真实银行样本、独立资金链审阅或公网验收。当前官方current在09-06,本task仍in_progress;上批review-candidate-r1保护保持。独立问题及真实批次/银行/K1/K2清单见retained-gates-closeout-20260917的independent-and-external-readiness.json,均未进行真实查询或操作。 + +## 2026-09-17 最终本地回执(当前入口) + +T1–T4本地实现与合成闭环完成,官方current保持本task,状态in_progress。证据根为`artifacts/qa-layout/payment-feedback-loan-slim-20260917`;最终`final-loan-browser`退出0:空库72迁移、9组真实PostgreSQL约束/两连接并发、16组正式登录Fastify/Prisma/PG HTTP场景、3组桌面/手机/员工拒绝浏览器检查全部通过;仅本轮容器/浏览器/服务/临时目录清理PASS。最终桌面、手机截图已查看,沿原有样式且银行字段脱敏;手机详情仍在移动入口。 + +报销原地进度`progress-green-r2`15/15;`loan-unit-r3`5文件212/212,ERP退出后的付款路由另在`erp-regression-r2`128/128通过。`final-client-build`typecheck/Vite/PWA、`final-prisma`、`final-module-load`通过。ERP共享回归按r1/r2已通过文件及r3修正后的63/63分别引用,不将分轮结果相加当全量。strict原因码850注册/886使用/legacy0/findings=[]。旧Browserslist/punycode提示和22个历史未登记文档不扩治。 + +### 验收支持与开放项 + +| 验收 | 当前证据与结论 | +| --- | --- | +| AC1 | 正式财务登录、历史两笔借入、凭据唯一、四类材料、余额确认及系统外已还证据通过。 | +| AC2 | 两借款一付款、部分还本、精确金额、跨来源限制、超额拒绝、两连接占用和同requestKey并发幂等通过。 | +| AC3 | 显式财务权限、经理/CEO独立审批、排制单/提交/收款人、无人可批与身份/账户版本变动拒绝通过。 | +| AC4 | 审批/BIFF8导出不减本金,在途不释放;安全取消、独立确认失败、成功原子claim/核销及真实数据库审计失败回滚通过。 | +| AC5 | 合成自动worker/双人手工回单、双方账户/币种/金额/pending负例、跨借款/报销交易去重通过;真实银行/OCR样本接受性仍待验,完整AC保留开放。 | +| AC6 | 原借款分配、余额截止状态、独立会计交接与材料权限通过;不把无OA记录当无银行还款。 | +| AC7 | 付款/制单/回单及ERP触达业务回归通过,PC/手机正式登录同链与普通员工403通过,默认银行信息脱敏。 | +| AC8 | 迁移/锁图/回退影响已落盘;独立只读复核PENDING,08-23实际保管/恢复事实与银行样本/迁移发布条件开放。 | + +主会话只读自审核对了显式角色、账户/身份版本、创建lender→loan锁序、既有batch→receipt→lender→loan核销锁序、loan错误整体抛出回滚、unknown不释放及ERP共享边界;没有把此自审称作独立复核。待办姓名区分可处理人与站内提醒对象,仅`wecom_app_sent`成功记录可描述个人发送,多接收人anchor不推断全部送达;真实批次姓名/企微发送事实未查。 + +最终`closeout-governance/process/docs`与两个task validate通过;文档仅原22项登记warning。保护核验`protection-final.json`:HEAD/index未动,22个目录6447个旧材料SHA与精确文件集合全部原样;177个旧差异151个逐字不变,26个在本批授权路径叠加修改。ERP退出留下的两处尾空格已修,最终diff和敏感回执另存。本批独立审阅入口为`review-candidate-r1`,仅按baseline快照计算本轮增量,当前源码快照、删除清单与定向证据分开;manifest外部完整性回执不自引用。独立状态PENDING。未读取真实配置/密钥/备份,未连接生产/ARM,未重跑旧恢复,未提交/推送/部署/真实通知或付款。下方阶段过程保持历史原文,不再代表当前未实现状态。 + +## 2026-09-17 T3/T4接续回执 + +T3及前端台账/付款分配/材料/会计交接已落地。`loan-payment-http-r3`:72迁移、9组约束、12组真实认证HTTP通过;`loan-unit-r3`:付款/制单/回单/通知5文件212项通过。`loan-browser-r2`桌面和手机走到还款详情并留截图,末项员工拒绝页断言错用地址导致整轮FAIL;已改为实际`/403`,不计该轮完整通过。新增跨借款/报销交易去重、金额/币种/双方账户/在途负例与同键并发,重跑中。 + +审批姓名区分当前可处理人和站内通知对象;企微发送只认`wecom_app_sent`成功,多接收人anchor记录不推出全部送达。不读真实delivery payload或配置。旧日期/恢复状态为历史规划记录,以本顶部实际回执为准。独立只读review PENDING;真实银行接受性、录入/付款/发布未执行。 + +## 2026-09-17 当前实施回执(取代下方旧 planning/未实施描述) + +官方current已start并回读本task。T1增量schema、迁移、延迟聚合约束已落地;独占PostgreSQL无网络/无宿主端口/只用合成库。初始化socket路径已修正,失败轮次原样保留。`loan-db-prototype-r5` 全部72迁移和9组约束通过,包括实际双连接锁等待与超额拒绝;容器清理通过。 + +T2台账/加密出借人/身份确认/原凭据唯一/材料上传下载/期初余额确认已实现。`loan-ledger-http` 真实密码登录、正式auth插件及Fastify/Prisma/PG验证5组接口场景通过,同时重跑9组DB检查。普通员工、经理、system_admin及仅admin不能管理借款;期初余额少于原本金须补系统外已还证据;审计失败由数据库触发,业务写整体回滚。Prisma validate/generate通过,generate使用本树既有链接依赖目录;不安装新依赖。 + +T3正在接入现有付款消费者:专属还款申请、多借款一付款、占用/独立复核、回单worker/人工例外、原子claim和本金核销;尚未完成本切片验证。T4前端和完整验收待执行。AC不提前勾选,独立只读复核PENDING,真实银行样本、发布、实际录入/付款仍未授权。 + +# 实施顺序(仅规划,尚未开始) + +## 2026-09-17 执行优先级更新 + +用户要求尽快完成既有借款方案。先完成09-04两处真人反馈修复,随后官方start本task,本地T1约束/并发原型→T2台账与证据→T3本金分配及既有付款来源全链适配→T4合成闭环和旧报销回归连续推进。范围按原8项AC,不把文档齐全当产品完成。原“仅规划/不实施”的优先级由本节替代;真实操作与独立/发布门禁仍有效。 + +只使用运行独占的本地PostgreSQL和合成加密材料,禁外部通知/OCR/银行调用,不连接生产/ARM或读取真实配置密钥备份。无现成真实身份/付款事实时保持未确认,不用admin兜底。inline下独立只读复核PENDING,主会话不得冒称独立通过;未独立审查的新资金链不发布。 + +## 2026-09-17 T1本地技术准备回执(最新入口) + +已按09-08后期规划细化design第8–10节:四核心表+专属LoanEvidence、既有PaymentBatch增量、金额/唯一/FK/删除保护、余额公式、权限/接口/并发矩阵。当前execute的来源锁早于receipt advisory,不能直接复用为借款锁序;已明确新来源分派和所有写路径的顺序。文档提出取消后新申请保留历史、unknown银行结果不释放/重付,原8项AC未勾选。 + +- [x] T1a字段/约束/锁图/消费者与测试合同草案,源码引用已核。 +- [ ] T1b获得进入条件后,以最小合成schema证明CHECK/部分唯一/延迟触发器/FK和两连接并发;尚未创建schema、迁移或数据库。 +- [ ] T1c独立只读审阅具体方案与原型,不把主会话对照源码计独立通过。 +- [ ] T2/T3/T4仍未开始;原恢复风险和产品实施准入保持,当前planning不变。 + + +## 准入 + +本task为09-08协调表的C阶段,不是其子任务。当前仍planning;本轮官方current在09-05执行本地治理,未切入本task。创建与规划补充不授权借款业务实现。先审阅本PRD/design,核08-23关键恢复状态和09-05到期治理结果;受保护操作等待不要求22项历史task全绿,但实际密钥不可用/敏感恢复失败没有处置时不直接扩大付款数据范围。 + +## 顺序与验证 + +- [ ] 取得本task方案实施批准,用官方task.py start切换并回读;检查执行树、AGENTS、相关backend/approval/receipt/security/frontend spec及未提交差异,记录只允许本批改动的路径。 +- [ ] 清点所有PaymentBatchEntityType消费者,设计最小Prisma模型/增量迁移、唯一约束、FK和权限矩阵;确认制单/复核/登记锁序与旧报销兼容,不引入通用工作流引擎。 +- [ ] 实现出借人/借款登记、证据与余额确认,先做无权限、身份不明、重复/历史登记和可用额度负例,再做正向。 +- [ ] 实现一申请一笔付款及多借款分配,复用批次独立复核;补同笔借款并发、取消/失败/未知在途和幂等断言,不能只用顺序mock证明防超付。 +- [ ] 显式接入用途/备注、快照/来源重验、回单队列范围/匹配/人工复核/全局交易去重;未支持分支拒绝,错金额/账号/失败交易没有人工旁路。 +- [ ] 接入既有风格的台账/还款列表与详情及会计交接记录,保持财务/审批人/普通员工可见范围和PC/手机同功能。 +- [ ] 在合成独立库运行真实路由、登录浏览器和DB同链;新业务反例与原报销/差旅、动态错误合同回归分别记录,不使用真实公司借款样本或真实付款。 +- [ ] 执行`cd server && npm run prisma:validate`、相关`node --check`及新增Vitest;执行`cd client && npm run typecheck && npm run build`及定向Playwright;新增测试文件及精确命令在实施时登记,当前不声称存在或已跑。 +- [ ] 执行原因码、Trellis/process/docs/敏感扫描及`git diff --check`;冻结候选并取得真实外部只读内容复核,主会话检查不冒充独立验收。 +- [ ] 新迁移/真实样本OCR、提交/推送、发布、真实资料导入及真实付款分别按具体范围取得批准;部署后只读验收通过才能告知公网可用。 + +## 停止与交接 + +收款人自审、超额占用、在途可重复付款、旧来源回归失败、秘密/真实资料进入日志或不可回退的迁移方案均阻断候选。找不到合格审批人时明确业务阻断,不通过改角色或admin兜底完成样例。业务少/无自然付款不制造交易测试;需要真实样本时先单独列授权范围。 + +08-23和09-05若新增重大风险,先回09-08协调;本task不顺手改生产密钥/恢复工具或替其他task清理。原始PRD验收保持未勾选,正式实施结果、测试、独立审查和发布分节记录。K0已完成本地验证,K1/K2仍无实际保管/真实解密证据;新会话先核09-08协调入口,不因文件齐全或K0通过就先开发借款模块。 + + +## 2026-09-17 规划补充与开发前审阅包 + +用户要求对未完成工作先补规划;本轮只更新本task计划,不新增枚举/表/接口/UI,不读取真实借款资料。K0的30项合成结果不能替代保管事实;09-05本地日期/错误治理候选也未部署。恢复风险处置、方案实施审阅及独立复核依然是进入条件。 + +- [x] 复核当前枚举只有reimbursement/travel_order,以及readiness、回单验证、人工复核和OCR worker对reimbursement的明确限制;记录下方消费者分组,不借新增来源解冻travel_order。 +- [x] 补迁移/锁序的待审技术切片和验收出口,前端沿既有布局,不新设计视觉体系。 +- [ ] 技术定稿T1:提出四类核心表及专属借款证据关系的字段/索引/FK/版本设计;按当前PaymentBatch/ReceiptClaim实际锁图审查,先以最小合成schema验证约束及并发,再实现接口。 +- [ ] T2:出借人/原借入登记及财务确认,只用合成资料;不自动产生付款或新增真实收款。 +- [ ] T3:新来源显式支持/拒绝矩阵、额度占用/幂等/状态与回单闭环;逐消费者负例后再接页面,不凭新增enum编译成功宣称支持。 +- [ ] T4:真实认证的本地路由/数据库/浏览器全链及旧报销/差旅回归,形成独立审阅候选;新来源OCR真实样本和生产录入仍另批。 + +这四切片复用原8项AC,不降低审批人独立性、并发不超付、在途保持占用、全局交易去重和审计原子性。日期/错误债务后续批次、恢复事实与借款开发分别交付,不要求22个历史task全部结案,但也不默认接受未知密钥风险。 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md new file mode 100644 index 00000000..78926c13 --- /dev/null +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -0,0 +1,76 @@ +# 公司借款台账与本金归还 + +## 2026-09-17 实施接续 + +用户明确要求借款尽快完成,沿本PRD与design既有个人出借人/CNY本金范围推进本地T1–T4,取代此前暂缓实施的优先级;不扩大为利息、备用金或通用付款。真实资料、余额和账户仍须正式录入核实,本地只用合成样例。风险与独立复核在交付/上线前保持未完成,不通过读取生产密钥或重跑旧恢复为本地开发解阻。 + +## Goal + +让财务能按已核实凭据查清公司向个人借入的未还本金,并完成申请、独立审批、网银付款、回单留存和业务本金核销。OA管理业务台账及付款证据,代账系统负责原负债科目记账,不把借款还本变成费用报销。 + +## Background + +- 用户提出历史两笔借入合并一次归还的真实需求;聊天提供的历史日期、金额和“本年无借还”尚未由本任务核验原始凭据,不据此创建真实余额或付款。 +- 当前代码仅有reimbursement/travel_order付款来源(`server/prisma/schema.prisma:273`);创建依赖已有来源单,不支持自由填收款人金额的通用付款申请。 +- 建行制单用途/备注按报销生成(`server/src/services/paymentOrderXls.js:158`);通用审批配置也只有既有五类业务(`server/src/routes/workflowDefinitions.js:9`)。回单OCR及人工核对范围须显式适配,不能只改备注。 +- 本task于2026-09-17获准规划,独立于报销收尾任务,status=planning。先处理08-23恢复关键遗留及09-05到期治理;本次不是实施、真实资料录入或付款授权。 + +## Scope + +首期仅个人出借人(可为股东、非员工)的公司借入本金台账和人民币本金归还,沿现有建行付款支持范围;一张还款申请关联同一公司、同一出借人、同币种的一条或多条已确认借款,对应一笔银行付款。支持部分偿还,不建设任意用途的通用付款平台。 + +## Requirements + +### R1 历史及后续借入登记 + +财务登记出借人、公司主体、借入本金、实际到账日期、协议/确认资料、原入账凭证及核对截止时点。历史登记与当前收款分开,不产生虚假的新增现金流。余额未经财务与代账确认时只能标待核实,不能用于还款;已在系统外偿还的本金须有依据并单列确认,不能重复冲减。出借人不必是员工,银行资料沿现有加密/授权查看/审计要求。 + +### R2 余额与还款申请 + +列表区分未还本金、已占用待付款额、可申请额及核对截止日期,资料覆盖不全时不得回答“已结清”。财务选择借款及本次分配金额,正数、两位小数且合计等于付款额;不得跨公司/出借人/币种合并。并发申请不能超额,已付、取消及有银行在途的状态必须分别处理。 + +### R3 有权且独立的审批 + +财务创建并提交,复用付款批次的经理/CEO独立复核作为首期还款审批,不再加一套重复通用审批链。审批人须在职、有明确业务角色及权限,排除制单/提交人和出借收款人对应的OA身份;身份不清或无人可批则阻止提交,不用admin通配兜底、不靠临时虚建员工绕过。对出借人的OA身份关联必须确认并留痕,执行时重验,不按姓名猜测。 + +### R4 付款与回单证据 + +审批只占用额度,不减少未还本金。财务在网银实际付款,用途为“还款”,短备注按借款年份/本金语义生成,受现有银行长度校验;详细原借款对应关系留在OA,不硬塞进银行备注。原件可先留存,但金额、币种、付款/收款账户、交易成功状态及唯一身份不满足时不得登记已付。 + +复用现有回单核对的自动匹配/人工例外边界,显式支持新付款来源;真实样本适配未验证前不能宣称新来源自动识别可用。OCR不清或不可用时,原件留存、经办录入、独立人员复核不能伪称机器核实或银行验真;明确失败、受理中、账户/金额不符及重复交易不得靠普通理由放行。独立人工复核继续排除收款人和提案人。 + +### R5 核销、异常与会计交接 + +只有符合既有付款登记证据门禁并由财务明确登记成功时,才在同一事务内占用交易身份、核销分配本金并更新付款事实;同一请求重试幂等,跨批次/跨报销复用交易拒绝。确认失败或未发起银行付款的取消可释放额度;银行状态未知/在途时不能直接释放后重付。付款已登记后的错误处置保留原事实,另审纠错,不删除或覆盖记录。 + +付款成功与代账记账分开,记录凭证编号/记账确认及材料交接,不由OA自动生成会计分录。材料包括原借入凭证、协议/确认书、余额核对、审批及还款回单;本金不要求费用发票,不进入费用报销统计或费用预算。关键材料按适用访问权限留存,历史余额/分配不得无审计改写。 + +## Acceptance Criteria + +- [x] AC1:合成历史两笔借入可登记并确认余额;未确认/重复登记/无凭据不进入还款,历史录入不被统计成当天新增收款。 +- [x] AC2:同一出借人两笔借款合并一笔付款、部分偿还正确;超额、跨主体/出借人/币种、重复提交和并发占用均拒绝或幂等,金额用精确小数。 +- [x] AC3:财务不能自审,收款人即使持CEO/manager或多角色也不能批准自己的还款;无合格审批人、身份关联缺失/变动拒绝,无管理通配旁路。 +- [x] AC4:审批/导出/上传回单均不会单独减少本金;处理中保持占用,确定失败/安全取消释放,明确成功登记与分配核销原子完成,数据库异常整体回滚。 +- [ ] AC5:正确回单可匹配新来源,错金额/账户/币种、失败/受理、同额歧义、重复交易、晚到版本及人工自审分别受阻;真实样本未覆盖的自动识别仍明确待验。 +- [x] AC6:付款完成可追到原借款分配和会计交接状态;余额查询标明截止/核对状态,不把OA无记录说成银行无还款,不把本金计入费用。 +- [x] AC7:报销/差旅既有付款、独立复核、制单、回单和权限回归通过;PC/手机沿既有交互,机密字段默认脱敏,普通员工不能读全台账。 +- [ ] AC8:迁移与回退影响、独立只读复核、恢复风险处置和发布前条件完整;本地合成通过不替代真实银行接受性或部署,真实录入/付款另批。 + +## Out of Scope + +- 不处理利息、工资/分红、员工向公司借备用金、资本金、债转股、机构借款、外币、退款或债务冲销。 +- 不做银行直连、流水自动导入、AI自然语言财务问答、自动会计凭证/税务、跨系统重复审批或全量ERP。 +- 不伪造报销单承载本金,不改现有报销一单一笔付款规则,不解冻其他低频模块,不引入新视觉设计体系。 +- 不索取聊天中的完整账号或真实凭证,不读取/轮换生产密钥,不沿用旧恢复/部署/真实付款授权。 + +## Test Plan + +真实Fastify路由与隔离PostgreSQL覆盖AC1–AC6的正反例、并发和事务回滚;前端沿现有Vitest/Playwright以合成财务、审批人、收款人和普通员工验证PC/手机权限/动作/余额状态。跨业务重复交易与报销回归单列;仅云端响应可在合成测试中替换,不用预签身份替代正式认证全链。执行端承担软件验证,不让用户制造真实还款来代测。 + +## Current Status + +T1–T4本地合成实施已完成,AC1–AC4、AC6–AC7有本地证据;AC5真实样本及AC8独立/恢复/发布条件保留开放。精确回执见implement顶部。未部署,不声称真实银行接受或真实批次已通知。 + +## Planning Status(历史时点,已由顶部实施接续替代) + +本task已形成PRD/design/implement待审方案,本日追加源码消费者地图与T1–T4技术切片,无产品实现。实际借款余额、收款账户、审批人资格及银行样本属于录入/上线前的证据门禁,不把聊天转述视作已核实。08-23关键恢复及09-05治理的准入判断见09-08顶部优先级表;未取得本task方案实施批准前不得start。无必须现在向用户索取的秘密或真实资料。 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/task.json b/.trellis/tasks/09-17-company-loan-principal-repayment/task.json new file mode 100644 index 00000000..d6ae116b --- /dev/null +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/task.json @@ -0,0 +1,26 @@ +{ + "id": "company-loan-principal-repayment", + "name": "company-loan-principal-repayment", + "title": "公司借款台账与本金归还", + "description": "本地实施公司借入本金台账、还款申请、独立复核及回单核销;只用合成数据,真实操作与发布另批。", + "status": "in_progress", + "dev_type": null, + "scope": "company-loan-principal-ledger-and-repayment", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-17", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "2026-09-17 T1–T4本地实施完成:72迁移、9组PG约束/并发、16组正式认证HTTP和3组桌面手机权限浏览器通过;212定向unit及ERP退出后付款128回归通过,最终前端typecheck/build与Prisma通过。AC5真实样本/AC8独立复核、恢复事实与发布门禁仍开放;未提交推送部署。", + "meta": {} +} diff --git a/.trellis/tasks/09-17-erp-exclusive-retirement/design.md b/.trellis/tasks/09-17-erp-exclusive-retirement/design.md new file mode 100644 index 00000000..727c95f5 --- /dev/null +++ b/.trellis/tasks/09-17-erp-exclusive-retirement/design.md @@ -0,0 +1,7 @@ +# 删除与保留边界 + +删除候选:server/src/services/integrations/erp*.js、jobs/erpMasterDataSyncJob.js,routes/integrations.js中的ERP专属接口和DTO/helpers、各业务ERP enqueue触发器;config/featureFlags/jobSystem/pgBossJobs的专属项;client ERP专属卡片、按钮与API;对应unit ERP专属成功路径文件。 + +共享保留:integrations/outbox.js、integrationOutboxJob.js中非ERP投递、notifications及wecom同步。历史ERP记录查询保留但不触发投递,retry明确拒绝退役事件。schema与历史迁移不动。 + +本task只清除已确认ERP范围,历史低频或0使用本身不足以删除工资/绩效/预算。历史测试/证据包保持原内容;旧17个ERP错误观测只有源码删除后才同步基线。 diff --git a/.trellis/tasks/09-17-erp-exclusive-retirement/implement.md b/.trellis/tasks/09-17-erp-exclusive-retirement/implement.md new file mode 100644 index 00000000..8bfb099a --- /dev/null +++ b/.trellis/tasks/09-17-erp-exclusive-retirement/implement.md @@ -0,0 +1,19 @@ +## 2026-09-17 本地实施回执 + +已删除6个ERP专属service、1个后台job和14个专属测试文件;各业务enqueue、ERP接口及前端API/摘要操作同步退出。AI资产建议使用的最小资产查询移入其实际消费者,未删除AI业务。共享outbox/notification/企微、历史数据库关系与迁移保留;历史ERP事件不被自动认领、手动retry拒绝、新enqueue及直接dispatch拒绝。 + +strict错误合同`erp-error-contract`通过:850注册、886静态使用、legacy=0、findings=[]。原17条均先删除对应ERP代码再退出基线,不通过延期或扩白求绿。9个共享回归文件在r1已通过;r2新增保留业务回归通过并暴露3个测试遗留断言,r3对相应文件及差旅付款轨道63/63通过。初轮失败和修正原样留存;各轮统计不相加成一次全量。 + +最终`final-client-build`的typecheck/Vite/PWA及`final-module-load`真实应用模块图加载通过。治理发现本task缺少Out of Scope/Test Plan/scope,已补齐合同,`closeout-governance`最终重验通过。尚未独立复核/部署,无实际队列或生产数据变更。 + +# 执行清单 + +- [x] 建task与删除/保留边界,源码首次检索已发现6个ERP services、主数据job、多业务enqueue及14个ERP专属测试候选。 +- [x] 逐消费者核完整清单;以TypeScript AST定位删除块,保留共享集成入口与历史查询。 +- [x] 删除专属后端/前端实现及无效成功路径测试;补退役事件拒绝重试验证。 +- [x] 跑受影响业务/通知/企微/outbox回归及前端构建;核错误基线变化。 +- [x] 更新当前事实源和本task实测回执,与借款/报销候选一并保护核验。 + +原规划已执行,当前状态以顶部回执为准。官方current已回借款完成联合本地验收。 + +最终保护核验:HEAD/index未变,6447个旧材料内容及22个目录精确集合未变;当前增量入口`payment-feedback-loan-slim-20260917/review-candidate-r1`,删除清单与本轮patch单列。后端全批语法通过;diff末两处ERP删除遗留空格已修,最终敏感/diff回执随候选留存。独立只读复核保持PENDING。 diff --git a/.trellis/tasks/09-17-erp-exclusive-retirement/prd.md b/.trellis/tasks/09-17-erp-exclusive-retirement/prd.md new file mode 100644 index 00000000..9aaed375 --- /dev/null +++ b/.trellis/tasks/09-17-erp-exclusive-retirement/prd.md @@ -0,0 +1,36 @@ +# ERP 专属遗留实现精简 + +## Goal + +落实用户“用不到的功能、代码、测试要精简”的要求,沿当前产品不接 ERP 的决定移除专属实现,减少隐藏入口和无效回归。 + +## Scope + +移除 ERP 主数据定时同步、ERP 适配/制单导出及业务触发器、专属 HTTP/API/UI、专属成功路径测试;保留已归档迁移和业务历史、通知/企微、通用 outbox/bindings、当前报销/付款/采购/资产/差旅业务。工资、绩效、预算等退役候选不在本次删除清单。 + +## Requirements + +- 先建立源码消费者清单,删除调用与实现保持同批,不能留 require 断链。 +- ERP 历史 outbox 不重放;保留脱敏只读追溯、对退役事件拒绝重试。通用事件投递不受影响。 +- 不改历史数据库结构/迁移,不删除旧差异与证据冻结包。 +- 只做本地源码/合成验证,不访问真实配置、生产、ARM或外部ERP/企微,不提交推送部署。 + +## Acceptance Criteria + +- [x] 无可调用的 ERP 专属写入口和后台同步任务。 +- [x] 无残留已删除模块的产品依赖;前端不再显示 ERP 专属操作。 +- [x] 共享通知/企微/outbox,以及既有业务针对性回归通过。 +- [x] 专属旧成功路径测试随实现移除;保留拒绝边界测试,历史债务基线按实际移除精确更新。 +- [x] 前端typecheck/build、后端syntax、strict原因码/治理/敏感/diff与旧材料保护核验通过。 + +## Authorization + +用户已明确要求实施精简;这是已确认不使用 ERP 范围的具体执行清单。新权限/支付规则不通过精简顺带改变;独立复核inline下PENDING,未部署。 + +## Out of Scope + +不删除历史表/迁移/审计/冻结包,不退役共享outbox、通知、企微、工资、绩效或预算;不进行远端操作、提交推送部署或外部消息投递。 + +## Test Plan + +真实模块语法及前端typecheck/build;ERP入口404、历史outbox只读/拒重试、新enqueue与dispatcher拒绝、自动队列排除ERP;共享IM/企微、付款、采购、资产、差旅、feature flag与job回归。strict原因码仅按实际删除退出17条旧指纹;流程、文档、Trellis和敏感/diff检查,以及旧材料/HEAD/index逐项完整性核验。 diff --git a/.trellis/tasks/09-17-erp-exclusive-retirement/task.json b/.trellis/tasks/09-17-erp-exclusive-retirement/task.json new file mode 100644 index 00000000..bb7cdb71 --- /dev/null +++ b/.trellis/tasks/09-17-erp-exclusive-retirement/task.json @@ -0,0 +1,26 @@ +{ + "id": "erp-exclusive-retirement", + "name": "erp-exclusive-retirement", + "title": "ERP 专属遗留实现精简", + "description": "按现行不接 ERP 的产品决定移除专属客户端、同步入口、后台作业与失效测试,保留通知、企微、通用 outbox 和历史数据;仅本地验证。", + "status": "in_progress", + "dev_type": null, + "scope": "erp-exclusive-code-retirement", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-17", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "2026-09-17 本地退役实现完成:6个ERP service、1个job、14个专属测试删除,共享通知/企微/outbox与历史数据保留;新enqueue/dispatch及历史retry拒绝,旧pending不重放。受影响回归、typecheck/build、模块图与strict原因码通过,17条ERP旧债退出后legacy=0。独立复核PENDING,未部署。", + "meta": {} +} diff --git a/.trellis/tasks/09-18-local-concurrency-product-fixes/check.jsonl b/.trellis/tasks/09-18-local-concurrency-product-fixes/check.jsonl new file mode 100644 index 00000000..1cecf3a3 --- /dev/null +++ b/.trellis/tasks/09-18-local-concurrency-product-fixes/check.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/spec/hyb-oa/deployment-trialrun.md", "reason": "受管本地验证与真实发布区分"} diff --git a/.trellis/tasks/09-18-local-concurrency-product-fixes/design.md b/.trellis/tasks/09-18-local-concurrency-product-fixes/design.md new file mode 100644 index 00000000..97cef3a4 --- /dev/null +++ b/.trellis/tasks/09-18-local-concurrency-product-fixes/design.md @@ -0,0 +1,91 @@ +## 2026-09-19 P10采购保存事务最小续修(沿用G20在用缺陷授权) + +g20-full-r5完整372项为367通过/1失败/2超时/2串行未执行;环境和source/repository/owner清理PASS。采购唯一失败在PATCH保存草稿,原5000ms事务耗至6529ms、HTTP500,尚未进入precheck/submit或取号。已核运行日志归属(runner PID、namespace、入口、cwd、fd),只保留安全状态与耗时。日志未足以唯一归因数据库或调度耗时。 + +PATCH目前在FOR UPDATE后的写事务末尾读取PURCHASE_DETAIL_SELECT,包含多级关系,仅用于响应,不参与业务判定。复用已批准采购创建与资产响应读取边界:事务内原校验、两次父更新顺序、明细替换/金额计算、审计完全保留;await事务提交后,由同一服务器Prisma读取原详情select并按原mapper响应。保持原5000ms、无重试、权限和数据原子性;不合并更新、不重算编号、不移动一致性校验。 + +新增确定性边界回归:有明细/无明细PATCH完整响应和字段、只在提交后读取;父写入/明细/审计/commit失败均不读详情不成功;提交后详情失败返回错误但不宣称回滚、不自动再写。mock只证明调用边界,实际采购提单→审批→归档由原整例证明。先原实现红例,再最小源码绿例与采购相关文件,最后交09-06做四文件原并发定向及新完整候选。 + +此为在用缺陷最小优化,不新增业务规则或改变验收。独立review继续PENDING,不提交、部署或访问真实数据。 + +# 最小修复设计 + +## 2026-09-18 P9诊断与实施约束 + +### 诊断结果与最小方案定稿 + +p9-diagnostic-r2原R75四项全过,整个选择10项9过/1视觉超时,513.621秒,环境/输入/owner清理PASS。两次已编号提交26个Prisma操作,事务646.077/244.180ms;原5秒失败未在本轮复现。PG采样未见Lock,存在idle-in-transaction/Client等待;事件循环/池等待峰值属于整轮观察,不宣称都是提交原因。首轮仅因选择器用逗号在测试前拒绝,清理PASS且不计业务用例。 + +实施范围限定为已证明的重复开销:resolveEntityPrecheck在报销分支一次读取所需实体/排序明细/费用分类字段,内部私有计算函数共享这次服务器读取;费用标准、预算/审批规则、财务本人审批、票据/附件、时效等校验全部保留。原公开helper仍自己读取,不接受请求传入的快照,不引入跨请求缓存,也不把本次对象读取声称为整库RepeatableRead。采购/差旅分支不变。普通预算开关关闭路径的3次实体读取变为1次,开启路径4次变为1次。 + +提交路由保留原aggregate权威重算及写入顺序,不为微小收益改金额来源;旅行分类直接使用已在同一FOR UPDATE事务读取的明细分类,已关联差旅仍强制结构字段。绑定票据count仅在旧门禁关闭且存在需票明细时查询;开启逐项门禁仍执行原完整校验。状态、权限、锁、预算及审批历史/审计同事务、原5000ms均保持。 + +先添加确定性回归证明重复读取及无条件查询开销;覆盖两种预算开关、费用标准阻断、财务本人无CEO、真实实体缺失、跨调用重新读取和非报销旧分支,以及逐项票据/旧缺票/旅行字段边界。修复后跑相关unit与无预载原R75和付款链,最后仍须新完整负载通过;专项优化通过不声称已彻底排除原P2028。 + +用户已同意按六组后续清单推进。原失败为已编号报销提交的5000ms事务在5412ms后workflowHistory.create报P2028;旧号存在时不调用issueDocumentNumber,因此不能仅因时点关联归因新编号。先在本轮独占无网络合成环境运行原R75整文件,并与全页面阶段计时同轮观察。诊断仅从ignored预载记录Prisma模型/方法耗时与序号、事务耗时、事件循环和池等待;不输出参数、SQL正文、结果、凭据或原始异常。PG只读取聚合state/wait类别与事务年龄;校验本轮容器owner/namespace/目录绑定。预载不改变业务参数/返回值/错误或预算,正式验收移除预载。 + +已识别待核实的重复读取包括提交明细后再次aggregate、旅行分类查询和无条件绑定票据count,以及预检再次读取实体/审批信息。它们只是候选,不能把代码重复当作唯一耗时根因。最小方案须在诊断后具体定稿,涉及一致性的读不能移到事务外;确定性回归先证明旧实现的问题,修复后再原合成链验证。不得通过禁用预检、缩审批、增加事务超时或回退编号求绿。 + +## 证据 + +两轮分别采购P2028:5000ms预算、5952/6839ms后过期。诊断轮729次PG采样无Lock等待;失败窗口持续idle in transaction/Client,事件循环满载且池曾排队。此证据支持减少无关事务存续时间,不足以声称已定位全仓CPU根因;不改bcrypt/连接池大小或全局预算。 + +PWA诊断:B文档仍A,B预缓存首页与68个独有资源存在,controller=active且activated、no-store worker摘要匹配B,零页面/分块异常。native updatefound早于后一次sw.js请求记录(未直接采集register Promise完成时点);源码确认现workbox-window在await register后才订阅updatefound,且不补订阅已installing worker;autoUpdate只靠activated刷新,故可能漏事件。framenavigated含SPA同文档跳转,导航计数不等于重载次数。旧失败日志占位已纠正为实际快照/null,不能用占位推因。 + +## PWA + +client/src/pwa.ts改为原生注册生命周期,先捕获controller并同步订阅controllerchange,再调用register。控制器切换后等待该worker activated,重复事件合并;首次接管只发原offline-ready,不刷新。已受控→新worker的更新:普通页清运行时缓存并刷新一次,认证输入页只发既有事件并交用户触发。同页失效worker不刷新。 + +vite.config.ts明确injectRegister:false,避免插件自动插入第二个注册/重载所有者;保留generateSW、skipWaiting、clientsClaim、缓存与manifest。不引入新UI/依赖,不改前台版本检测合同。显式登出/人工刷新保留既有全缓存清理;自动更新只清app-api-runtime/app-static-runtime。 + +本修复覆盖装载本次生命周期代码的浏览器;已运行旧代码的部署客户端无法被新页面代码追溯改写,首次发布仍按既有手册人工刷新并验证,不宣称旧版本历史代码全覆盖。 + +## 采购 + +创建事务select只含id/applicant_id/department_id,沿原extra写审计,返回id;提交后findUnique使用原PURCHASE_DETAIL_SELECT,再按原mapper响应。保留全部前置与原子性;响应读取失败仍按现有错误处理,不将已提交写入虚报回滚,也不新增自动重试。详情响应是提交后的当前状态,符合列表/详情同一读取合同。 + +## 验证与边界 + +产品测试先红后绿;生命周期用可控合成ServiceWorker事件验证顺序,真实A/B整例证明浏览器实际行为;采购路由模拟测试检查提交顺序/审计失败,真实PG采购整链证明成功下游。不带观察器的四文件并发作最终本地证据;全量、WebKit、独立/真实业务仍由协调task保留。主会话可检查事务/生命周期,但不能冒充独立review。 + +## 2026-09-18 G20 完整负载续修设计(已收齐并核清理) + +来源:g20-full-r4 的资产创建与账号创建两个在用接口均出现原5000ms事务预算过期;资产5983ms,账号6004ms。观察器仅消费归属已核的本轮合成服务日志,无SQL或锁归因,不能声称唯一原因或全仓性能问题已解决。 + +### 资产创建 + +所有分类/主体/成本中心/人员/采购可用性校验、单号分配、资产写入、可选领用写入仍在原事务中。assetCard.create只返回原领用和审计所需标量;事务提交后按原ASSET_CARD_SELECT读取完整响应。原ASSET_CARD_CREATED审计仍在提交后发起并单独捕获失败,不挪入事务、不等待成功、不删除字段。详情读取失败不得重试创建或称写入已回滚;缺失沿现有asset.not_found合同。提交后详情是当前状态,不承诺和写入瞬间的关系快照一致。 + +### 单个账号创建 + +在现有权限、输入、受保护角色、部门/主管校验之后,使用相同hashPassword/真实cost12完成哈希,再进入事务。账号、角色与SYSTEM_USER_CREATED审计仍一起提交;P2002、一次性口令/强制改密、角色回读和提交后企微目录同步语义保持。createSystemUser内部只消费由服务器生成的passwordHash,不能从请求接收哈希。批量导入在既有事务/循环位置计算哈希,不扩改导入事务;不调整加密成本、连接池、事务预算或重试。 + +### 验证 + +先加入8项资产边界和5项账号边界回归,原实现运行取得真实红灯,再实施并运行相同测试及相关资产操作/搜索/校验、账号写入/角色保护/导入回归。Mock事务不冒充PG故障回滚;真实整链在受管合成PG上按原并发、预算、零重试验证,最后新完整候选从static开始。原失败、旧差异与冻结包保留,独立review仍PENDING。 + +不实施考勤重建,不改业务权限/审批状态机,不作线上写入。用户已另行授权的指定付款批次只读查询是单独证据,不影响本地环境归属或取得部署资格。 + +## Bug Analysis: 采购保存事务与完整负载 + +### 1. Root Cause Category +- E 隐含假设:把多级响应关系读取放入5秒业务写事务,假定负载下仍有充足时间;PATCH已实测P2028,具体数据库/调度比例未知。 +- D 覆盖缺口:创建路径的定向通过不能证明保存路径和完整负载。此次原实现确定性边界红4项,修改后新增9项与原采购回归共167项通过。 + +### 2. Why Fixes Failed +此前创建、资产、账号与报销修复各自只覆盖已批准入口;采购PATCH仍保留独立的响应关系读取。7组视觉独立运行通过也未证明15页管理员组在全负载可于180秒完成。不得据这些局部结果关闭完整门禁。 + +### 3. Prevention Mechanisms +| 优先级 | 机制 | 状态 | +| --- | --- | --- | +| P0 | 非判定用途的响应详情提交后读取,锁/一致性校验/写入审计保留事务 | PATCH已实施;167项通过 | +| P0 | 漏项/失败组拒绝及完整同候选运行 | 已捕获视觉缺页并拒绝;新完整待验 | +| P1 | omitted/empty/items及write/items/audit/commit/read边界 | 新9项通过;mock不冒充PG故障注入 | + +### 4. Systematic Expansion +已只读检查采购邻近入口:submit/withdraw沿用各自业务事务与返回边界;archive小投影含审计所需字段,不能按关键词机械外移。当前仅修改已观察失败的PATCH,不把推测扩成全仓重构。当前未建立全负载耗时的唯一根因;修复仍须原整例和完整候选。 + +### 5. Knowledge Capture +- [x] backend-fastify-prisma的采购事务节补PATCH、不变量、失败与当前已提交状态语义。 +- [x] 当前task design/implement记录边界与分轮证据。 +- 模板同步不适用:业务仓库无对应打包模板树。用户明确不提交,未执行skill中默认commit步骤。 diff --git a/.trellis/tasks/09-18-local-concurrency-product-fixes/implement.jsonl b/.trellis/tasks/09-18-local-concurrency-product-fixes/implement.jsonl new file mode 100644 index 00000000..37ae8eee --- /dev/null +++ b/.trellis/tasks/09-18-local-concurrency-product-fixes/implement.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "PWA升级与认证输入保护"} +{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "事务审计和现有采购合同"} diff --git a/.trellis/tasks/09-18-local-concurrency-product-fixes/implement.md b/.trellis/tasks/09-18-local-concurrency-product-fixes/implement.md new file mode 100644 index 00000000..a72ad144 --- /dev/null +++ b/.trellis/tasks/09-18-local-concurrency-product-fixes/implement.md @@ -0,0 +1,106 @@ +## 2026-09-19 P9 定向补审接收与旧预算缺陷分流 + +Claude 补审 b1rubx7qf 已完成,原报告 `artifacts/qa-governance/business-followup-claude-review-20260919T023537Z.txt`。P9 三个纯函数共享一次实体读取且无跨请求缓存的结论 PASS;已审报销锁序、金额重算后同事务 precheck 为 PASS。reviewer 未逐行审全部路由分支/工作流 snapshot 内部,不扩为本任务全量独立 PASS。 + +预算池 used_amount 的无锁读改写确认为遗留并发缺陷,三模块共四处计数片段与 HEAD 一致;两 service 的周边错误码有既存差异,不声称整文件一致。按用户要求单列 `.trellis/tasks/09-19-budget-pool-concurrency/`,统一处理计数与锁后超额判定;目前只规划,未实施。P9 性能目标已通过与这个遗留缺陷待修分别记录,旧 r6/测试结果保留,发布资格仍 false。总体接回状态与未覆盖范围由 09-06 当前入口协调。 + +## 2026-09-19 P6–P10本地验收完成 + +g20-full-r6从static完成同一冻结候选:后端224文件4437通过/1原条件跳过、372本地浏览器全部通过,零失败/跳过/重试。资产、采购、PWA更新、R75四项和报销付款整链通过,P6/P7/P9/P10边界与原整链、P8完整负载验收完成。P10的原实现4红、修后167/167仍保留;写事务及响应读取语义不变更。 + +采购整例125.161秒;付款helper步骤264.491秒、Playwright总记录373.383秒,原300秒设置未改,前后开销差额没有独立拆分。完整来源、计时与证据统一见09-06 implement顶部。PARTIAL仅dirty_candidate,独立PENDING、无部署。 + +P5包含旧材料保护合取条件,因旧WebKit派生registry一处已披露改写而保持开放;原baseline不改,r6无新增漂移,其余12137旧文件与HEAD/index不变。不得将代码验证通过写成保护或独立复核通过。本次结束后仅补task记录,整体in_progress,不提交推送部署/归档。 + +## 2026-09-19 四文件定向通过,接完整候选r6(当前入口) + +r5-fixed-r2四完整文件31/31通过,浏览器约5.5分钟、受管总571.747秒,零跳过/重试;采购整链、R75四项、付款提交至归档全链及视觉25项通过。付款约244.773秒,保持300秒;视觉7组各180秒、25PC+8手机/33PNG全部完成、覆盖问题与findings为空。环境/source/repository/owner清理PASS。原r5完整FAIL与r5-fixed-r1登录跳转失败均保留,不拼接为完整通过。 + +P10采购提交后读取边界167/167及QA专用类型已通过;登录仅在无query/hash的规范/login复用,保留原表单/portal/profile断言。下一g20-full-r6从static开始普通完整候选,全部源码/测试/task/spec/docs冻结,只写本轮ignored证据;N5/G20及产品完整负载验收仍待终态,独立PENDING、生产编号未启用。 + +旧WebKit派生registry保护异常保留,原baseline不改;后续运行使用本轮独立WebKit副本,不下载或安装。禁止将一处已知漂移描述为全部旧材料零变化;无提交推送部署或真实操作。 + +## 2026-09-19 当前入口:P10采购PATCH保存续修 + +P9的R75四项已在g20-full-r5完整负载通过,整体仍失败。新增采购PATCH P2028见design;原付款300秒/管理员视觉180秒累计超时由09-06接QA冗余前置优化。 + +- [x] P10a:证据/不变量定稿;原实现75项71通过、4边界红灯,失败记录保留。 +- [x] P10b:仅移出原响应select,新增9项边界;采购5文件167/167通过(11.68秒),源码语法/diff通过。 +- [ ] P10c:采购相关回归与原主链原预算验证,再接完整候选。 + +旧材料检查发现仅WebKit自动改写旧浏览器包GStreamer派生索引,原baseline不改,改写副本和FAIL留存;另建本轮独占缓存避免继续写旧目录,不能声称旧12138文件全不变。业务冻结材料、HEAD/index不变。独立PENDING。 + +## 2026-09-19 完整候选接续 + +grouped-visual-r1完整文件25/25通过(7真实组+13覆盖正反例+5原到达用例),浏览器91.788秒、受管总352.799秒;25PC+8移动全部审完,33PNG、findings为空、覆盖问题为空,环境/输入/owner清理PASS,零跳过/重试。P9/编号N5/G20仍待新完整负载验收;由官方current 09-06从static开始g20-full-r5,运行时全部受管输入冻结,独立PENDING。 + +## 2026-09-19 当前P9验证状态 + +完整后端224文件4428项通过、1原条件跳过,537.01秒,644输入文件/集合结束后SHA核验零变化。P9产品实现、确定性边界与无预载R75/付款链已通过,完整负载仍交09-06新候选验证,不能据此关闭P9/P8。7组视觉验收已获用户批准并实施,13覆盖正反例/专用类型通过,下一真实矩阵整文件定向后从static完整门禁;独立PENDING。 + +## 2026-09-18 新观察:报销提交事务(待定稿实施) + +09-06的numbering-browser-closeout-20260918/browser-r2中,R75通知场景报销提交返回SYS_001;同一受管server日志定位P2028:原5000ms事务在5412ms后执行workflowHistory.create失败(reimbursements提交事务)。该轮64项58通过/4未过/2串行未执行,报销付款原整链另已通过。安全错误摘要在browser-r2-server-errors.json;未复制原payload,旧失败保留。 + +- [ ] P9a:重现并区分锁等待、事务内重复查询和同步计算;核对已编号创建提交不会进入缺号补号分支,不能只凭出现时点认定新编号导致,也不能据一个样本归因为机器慢。 +- [ ] P9b:在PRD/design定稿最小优化与不变量,再实施。保持FOR UPDATE、权限/所有权/状态、金额票据/预算、审批规则及workflow历史/审计的原子性;原5000ms、并发、零重试不调宽。事务内需要一致性的读不得无条件移到外部。 +- [ ] P9c:先有确定性边界回归,再做受影响unit与合成DB/HTTP;原R75通知及两个串行后继整文件验证,最后09-06从static完整新候选。定向通过不能代替完整门禁;独立review仍PENDING。 + +本轮先由09-06完成编号消费者/QA前置收口,没有改报销提交产品源码;P9为新增待办,不与之前资产/账号已通过结果混为完成。 + +## G20 产品续修单测已通过 + +两个接口最小修复已完成,原实现红灯7项/112通过,修复后两文件119/119通过(17.54秒);资产操作/搜索/校验与账号导入/敏感权限五文件148/148通过(23.71秒),受影响源码Node语法通过。合计267项包含13项新增边界,真实cost12未改;原事务预算、角色、审计语义与批量导入位置保持。下一步由09-06修QA前置及定向整例,再从static全量;P6/P7整体及P8仍待真实整例/全量,独立PENDING。 + +## 2026-09-18 G20 续修(已批准本地缺陷修复范围) + +g20-full-r4已结束FAIL,5768.636秒:静态及完整内部前11阶段PASS,浏览器342通过/5失败/3超时/1串行后继未执行,零重试。环境PASS,source/repository未变,归属容器和scratch清理PASS。两个在用产品错误分别为资产创建P2028(5983/5000ms)、账号创建P2028(6004/5000ms),不归因于考勤/退役ERP。 + +先按更新PRD/design加入8项资产与5项账号边界回归并运行原实现取得红灯;再最小实现、相关回归及原资产/R75整文件验证。所有旧失败与冻结包保留,原超时/并发/零重试不变。证据release-receipt-g20-20260918,独立PENDING。 + +- [ ] P6:资产标量写入/提交后详情及原异步审计;先红后绿与资产相关回归。 +- [ ] P7:单个账号事务前cost12,原子写入/导入/权限合同回归。 +- [ ] P8:原整例和最新完整候选验收,协调09-06/09-08;不据定向绿结案。 + +治理收口:补齐本task标准Goal/Scope/Out of Scope/Acceptance/Test Plan章节与scope字段后,Trellis治理通过。3条大规约的自动注入截断告警保留;本轮inline按相关章节读取,独立审阅须按新增PWA/采购章节和原合同完整阅读,不依赖被截断上下文。 + +## 本地修复验证完成 + +131项Vitest、前端typecheck/production build、Prisma、浏览器专用类型通过;原四文件并发4/4,最终完整认证/门户与OCR18/18通过,环境/输入/清理PASS。PWA新资源实际加载与旧缓存清理共同成立;采购走完审批归档。原两轮失败与诊断不覆盖;不声明全仓CPU或完整负载已解决。 + +源码自检确认单一PWA注册所有者、先原生监听后异步register、首次/重复/失效/认证输入边界、更新保留precache、显式登出保留旧清理合同;采购权限/切换/默认字段/草稿审计原子性保持,详情只在提交后读取。模拟事务测试不冒充PG故障注入回滚证据。首次部署旧客户端仍需既有刷新验收。 + +最终治理与冻结见local-coverage-g18-20260918;独立只读review在inline限制下PENDING。产品本地实施完成,task仍in_progress,交由09-06/09-08协调后续完整门禁及发布边界;本轮无提交推送部署。 + +## 原并发整组通过,接最终共享入口回归 + +g18-concurrency-fixed-r3 583.240秒,原四文件4/4通过(浏览器约4.5分钟),4 workers/零重试/预算未变。观察器仅被动采本轮结构化日志,未加载r2预载诊断;995响应/0未处理异常。PWA B文档/active controller/worker摘要/68新缓存与2实际加载新资源,旧独有缓存0,零登录回落/静态404;采购已实际走完创建至审批/归档,而非只验证POST200。环境/source/repository/owner清理PASS。完整发现64文件363用例,无遗漏/未知/重复。 + +最后运行g18-final-ux-ocr-r4:完整login-portal-ux(16项)与invoice-ocr-flow(2项),验证共享PWA入口下认证输入/前台提示与本轮OCR产品修复保持;原预算/配置并发/零重试,不再改代码。此轮仍是本地定向,不能拼接旧full-r2为完整全绿。之后更新规约/09-06/09-08与最终治理/保护/增量冻结,独立PENDING。 + +## 第一轮定向结果 + +6文件131项Vitest通过,包括13项原生PWA生命周期及4项新增采购事务边界回归;采购提交/预算/附件/项目关联现有回归保持。浏览器专用类型与Prisma validate通过。产品前端typecheck正在完成;随后执行不带预载诊断的g18-concurrency-fixed-r3,原四文件4 workers/零重试/原预算。PWA加强新资源实测,只计performance已完成资源,并同时要求B缓存与实际B资源均存在,不用DOM标签或缓存替代真实加载。 + +# 执行记录 + +2026-09-18:依用户“按规划连续做、缺方案先更新”授权,先建立本task再编码。承接G18两条重复失败,证据在local-coverage-g18-20260918,旧包与两次失败不覆盖。范围与验收详见PRD/design,不扩权或加预算。 + +- [x] 实施PWA原生监听与单一注册所有者,回归首次/更新/认证页/失效/重复/显式清理。 +- [x] 最小采购写事务与提交后详情,路由审计原子性/顺序/响应回归。 +- [x] 不带观察器的四文件4-worker受管重验及受影响类型/build/Prisma。 +- [x] 只读自检、规约/协调状态、最终治理/保护;独立PENDING。新候选终态以本轮candidate-integrity.json为准。 +## 2026-09-18 P9已获批进入诊断 + +官方current已切入本task;用户同意后续清单。旧差异/冻结材料保护基线位于submit-visual-closeout-20260918:352项差异、34目录12138文件。先运行原实现诊断,只有全页面测试新增安全阶段耗时;原产品输入不变。收到终态、确认owner清理后再据证据定稿P9设计与确定性回归。原5000ms/并发/零重试保持,独立PENDING,生产/ARM/真实资料/提交推送部署边界不扩展。 +## P9诊断终态与最小实施 + +p9-diagnostic-r2:10项9通过/1视觉超时,R75四项全通过,0未处理服务异常;两次提交646.077/244.180ms、各26操作,未复现原P2028。确认预检重复实体/明细读取,按design的共享单次实体读取及提交旅行/票据条件查询最小方案先红后绿,不改变aggregate、事务原子性或预算。随后原R75/付款链与视觉同配置验证,再回09-06全量。旧失败与首轮选择器拒绝均保留,独立PENDING。 +## P9最小实现与单元验证 + +已完成feeControl内部单次报销实体读取,原公开helper/其他实体入口保持;submit仅复用旅行分类、按需执行旧绑定票据count,保留aggregate/写入顺序/原事务。原服务反例分别观察预算关闭3次、开启4次读取及跨调用6次,修复后每调用1次且下次重新读;路由三项重复查询反例先失败后通过。 + +七文件第一轮115通过/3失败,失败均为既有补助fixture有travel明细却未带出差结构、旧findFirst桩返回null隐藏了矛盾;按实际业务补齐三项合成出差字段,不改产品旅行校验。最终路由36/36通过(3.89秒),其他六文件82项保持前次通过(包括新服务8项),分轮不虚报为一次全量。首轮新增服务测试缺括号及预算开关fixture缺记录已修,失败日志保留,不计产品缺陷。下一按原配置无预载验证R75、完整付款链及全页面,再完整候选。 +## P9无预载原合成链已通过 + +p9-fixed-r3环境/输入/清理PASS、零重试,R75四项全部通过(通知95.039秒),真实报销付款主链281.132秒通过;同轮只有全页面视觉180秒超时,10/11通过,服务端1347响应无未处理异常。此结果支持本次提交优化与下游兼容,不代表已排除全部负载下P2028;P9/原P8完整候选门槛仍保持。09-06已提出不删页面的分组巡检具体方案,因改变累计预算已获用户明确批准7组验收;当前产品源码冻结供静态与治理检查。 diff --git a/.trellis/tasks/09-18-local-concurrency-product-fixes/prd.md b/.trellis/tasks/09-18-local-concurrency-product-fixes/prd.md new file mode 100644 index 00000000..bc73a337 --- /dev/null +++ b/.trellis/tasks/09-18-local-concurrency-product-fixes/prd.md @@ -0,0 +1,43 @@ +# PWA 更新事件与本地并发产品缺陷最小修复 + +## 来源与批准范围 + +用户已批准按余项连续推进,未规划内容先落方案。09-06 G18两次同样的四文件4-worker并发验证复现采购500及PWA45秒不收敛;环境与输入/清理PASS。此task拥有两个在用产品缺陷的最小本地修复,09-06仍拥有完整覆盖合同。不能改变R2/R1或自动取得真实操作与发布授权。 + +## Goal + +修复已复现的 PWA 更新后页面不收敛和采购创建事务过期,在原并发、预算与业务规则下完成本地验证。 + +## Scope + +- PWA:页面已经受A控制,B worker安装/激活并接管后,当前普通业务页自动收敛到B。新版本监听须在异步注册前建立,不能只依赖Workbox注册完成后的updatefound/activated。首次安装不重复刷新;登录/改密/找回/重置密码页保留输入并使用既有更新提示,用户选择后才刷新;前台版本提示原合同不变。 +- 不在更新过程中删除刚安装的Workbox precache;旧独有预缓存由worker激活清理。运行时缓存清理与显式登出/人工刷新清理区分,保留后者原行为。 +- 采购创建:写事务只创建草稿及其审计,最小字段返回后提交,再按相同详情select读响应。权限、切换控制、默认字段、成功响应、审计内容和二者原子性不变;不提高5000ms事务预算或绕过任何审计。 +- G20续修:资产创建的关系详情读取移至原业务写事务提交后,保留事务内校验、编号、资产与领用写入及原异步审计语义。 +- G20续修:单个账号创建的原cost12哈希移至事务开始前,保留权限/引用校验、账号/角色/审计原子性及原提交后同步;不改批量导入哈希位置。 +- P9:修复本地已复现的报销提交事务超时。先核事务步骤耗时/数据库等待及已编号提交路径,再定稿最小优化;保持FOR UPDATE、权限、票据金额、预算、审批历史和审计的同事务一致性,不提高5000ms预算或增加重试。 + +- P10:g20-full-r5暴露采购PATCH保存草稿P2028(6529/5000ms),沿用已批准的在用缺陷最小修复范围:保留锁、所有权/状态、主数据、项目关联、明细替换、金额/两次父记录更新顺序及审计同事务,只把响应详情投影移至提交后,不增加重试或超时。 + +## Out of Scope + +保留现有差异及旧冻结包。仅本地合成、禁止外联/真实配置密钥备份/生产ARM/恢复/通知付款/提交推送部署归档。 + +## Acceptance + +- [x] P1:本地 production 构建 A→B同profile,原45秒窗口、无测试刷新/清缓存,B文档/active控制器/worker摘要/实际新资源及旧缓存清理共同成立,零页面/静态404/登录回退/重载循环。 +- [x] P2:可执行生命周期回归覆盖注册Promise未完成时控制器已切换、首次安装、重复事件、worker未激活/失效、认证页延后刷新;已有前台提示与登出缓存清理合同保持。 +- [x] P3:正式采购路由回归证明草稿/审计同事务、失败不返回成功,详情仅提交后读取、完整响应保持;原采购提交/附件/审批及预算回归不退化。 +- [x] P4:不带预载观察器的原四文件4-worker/零重试/原预算运行通过;仅证明该组,不能关闭旧full-r2或全负载可靠性。 +- [ ] P5:前端typecheck/build、Prisma/受影响语法、任务治理和保护检查通过;2026-09-19代码/治理验证保持通过,但新增一处旧WebKit派生registry保护异常未恢复,故此合取项保持开放;只读独立review仍按inline限制单列PENDING,不虚报。 + +- [x] P6:资产原响应/可选领用/异步审计语义与提交前后失败边界通过;原资产整链原配置重验。 +- [x] P7:单个账号原cost12在事务前完成,账号/角色/审计保持同事务;原写入/敏感角色/导入回归及R75完整文件通过。 +- [x] P8:续修后的完整候选从static重新执行并如实记录,不用旧定向证据抵扣完整验收。 +- [x] P9:报销提交的重复读取/无效事务开销按证据优化,确定性边界回归和真实合成R75通知原整文件通过;已编号提交不重新取号,失败事务不遗留部分审批/预算写入。 + +- [x] P10:保存草稿写入/审计仍原子,完整响应投影只在提交后读取;写入/明细/审计/commit失败不读取、不假成功,提交后详情失败不得宣称回滚或自动重试。原采购主链原预算重验。 + +## Test Plan + +使用现有server Vitest与浏览器专用tsc;复用受管retained-local四文件命令。精确命令/结果落implement与本轮独占证据,不读取真实输入、不联网安装依赖。 diff --git a/.trellis/tasks/09-18-local-concurrency-product-fixes/task.json b/.trellis/tasks/09-18-local-concurrency-product-fixes/task.json new file mode 100644 index 00000000..6e0a85df --- /dev/null +++ b/.trellis/tasks/09-18-local-concurrency-product-fixes/task.json @@ -0,0 +1,58 @@ +{ + "id": "local-concurrency-product-fixes", + "name": "local-concurrency-product-fixes", + "title": "PWA 更新事件与采购创建事务最小修复", + "description": "PWA原生控制器更新与采购最小写事务已本地实施;131单测、原四文件并发4/4、认证/OCR18/18通过。独立及完整门禁仍待,不提交发布。", + "status": "in_progress", + "dev_type": null, + "scope": "client PWA lifecycle and server purchase draft transaction", + "package": null, + "priority": "P1", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-18", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "client/src/pwa.ts", + "client/vite.config.ts", + "server/src/routes/purchaseRequests.js", + "server/tests/unit/utils/client-pwa-lifecycle.test.js", + "server/tests/unit/routes/purchase-request.errors.test.js", + "client/tests/e2e/pwa-cross-version-upgrade.spec.ts" + ], + "notes": "2026-09-19 g20-full-r6:4437后端通过/1原跳过,372本地浏览器全过;PARTIAL仅dirty_candidate,独立PENDING/无部署,旧registry保护例外保留且无新增漂移。当前余额见implement顶部。\n2026-09-18本地验证完成,失败/诊断/修后回执与候选在local-coverage-g18-20260918。主会话自检不代替独立PENDING;无预算/并发/权限/审计弱化,未发布。\n", + "meta": { + "localImplementation": "VERIFIED", + "independentReview": "PARTIAL_WITH_LEGACY_BUDGET_FINDING", + "normalReleaseEligible": false, + "latestLocalCandidate": { + "run": "g20-full-r6", + "localChecksPassed": true, + "receiptStatus": "PARTIAL", + "browserPassed": 372, + "publicPending": 12, + "independentReview": "PARTIAL_WITH_LEGACY_BUDGET_FINDING", + "deploymentEligible": false, + "legacyProtectionStatus": "FAIL_ONE_KNOWN_DERIVED_REGISTRY", + "newProtectedDrift": false, + "productionNumberingActivated": false + }, + "p9TargetedReview20260919": { + "status": "PASS_SCOPED", + "report": "artifacts/qa-governance/business-followup-claude-review-20260919T023537Z.txt", + "reportSha256": "b48e5f53340e139e5a97b5fdcb6d7003881a80a8a08d4004d01aaa6a759d2d0b", + "runtimeTestsRerun": false, + "budgetPoolFinding": "LEGACY_CONFIRMED_STATIC_UNFIXED", + "remediationTask": ".trellis/tasks/09-19-budget-pool-concurrency", + "evidence": "artifacts/qa-governance/business-followup-reception-20260919" + } + } +} diff --git a/.trellis/tasks/09-18-reimbursement-periodic-numbering/design.md b/.trellis/tasks/09-18-reimbursement-periodic-numbering/design.md new file mode 100644 index 00000000..fb7f3d6d --- /dev/null +++ b/.trellis/tasks/09-18-reimbursement-periodic-numbering/design.md @@ -0,0 +1,112 @@ +# 业务编号与历史兼容设计 + +## 2026-09-18 多角度复核与批准 + +用户明确“再次从多个角度审查……合适的话……实施改进”。主会话核过小团队可读性、财务对账、历史及银行材料兼容、并发/锁序、容量、切换回退与维护成本,保留矩阵全部建议;付款量低,年度号比每日号更便于识别,业务日期继续单独展示。当前范围已批准,官方current切至本task。本地实现默认不启用,独立复核保持PENDING(inline限制)。 + +## 定稿数据与接口 + +- 新增BusinessNumberPolicy单行v2策略,effective_at首次激活后固定、enabled可暂停/恢复;记录更新人及操作引用,并写既有审计。同版本不得回填或修改生效时间。迁移不插入激活记录。受控CLI只准备不执行生产。 +- 新增BusinessNumberCounter,以规则版本+类型/资产前缀+周期唯一键存储BigInt高水位。独立于旧DocumentSequence,避免周期记录混进配置枚举。固定资产统一FA,电子资产沿用既有EA分类前缀(含合法自定义字符,SQL按字面转义匹配并用Unicode字符数截后缀);同前缀共享计数,消除跨分类重号。 +- 新取号器在调用者交互事务内工作,固定一次now并按appTimezone(默认Asia/Shanghai)生成日期。报销monthly,PB/JK/HK/CG/PD/PRJ yearly,FA/EA permanent。最小四位,BigInt扩位,不截断或归零。已有号码与UUID均不改。 +- 现有单据分类页补简短说明:分类字段不会覆盖已启用的统一编号,仍服务于旧规则和电子资产前缀;仅沿用既有文字样式,不新增设计或配置开关。 +- 配置读取返回当前新建对象采用的策略,不以seed切换;旧补号明确version=1。PurchaseRequest新增numbering_version,存量与直接旧写入默认1;新建草稿在事务内固定1/2,首次提交按保存版本取号,日期段按实际首次取号时间,跨月/年不改已编号对象。 +- 新版即时创建取号与对象写入同事务;legacy路径保留。项目自动号进入事务,新号替代count+1;正式手工号继续支持,不替换导入编号。 + +## 锁序、切换与冲突 + +- 策略锁namespace1003/key0:业务事务共享锁,管理事务独占锁;锁后读取策略,不缓存进程状态。启停等候已持共享锁的事务完成,固定生效时刻采用取号时固定的now;切换前已建未编号草稿靠版本字段稳定归属。 +- 新计数锁namespace1004,对象键固定登记为业务类型1..8,资产全类共用8;周期/前缀间保守串行,适合小团队。在锁内查同格式已有最大数字与持久高水位,接续更大值;唯一约束最后防线。无锁max+1、进程锁、事务内捕获唯一异常重试均禁止。 +- 借款创建保留幂等完整事务;本金还款保持lender→排序loan→共享策略→HK计数→申请/分配→PB计数顺序。管理只锁策略,不取业务/计数锁;不存在反向依赖。普通PB只锁自身来源后取策略/PB计数。 +- 同格式外部手工/导入占号通过每次锁内最大值接续;项目在支持的新格式下手工写号需同锁保留高水位。未接入的外部直接SQL写入仍由唯一约束阻止重号,不自动重试或回写。 +- 全部写入实例先升级兼容再激活,不从环境变量或seed自动启用。CLI在锁等待和配置检查完成后拒绝已过期生效点;若旧共享取号配置已使用同一月/年日期段,保守拒绝首次激活/恢复并留待专门兼容处理,防止两套独立计数使旧草稿补号撞号。该检查不修改旧配置或旧号,status仅返回相关类型;可预约未来切换,恢复只复用原时点。暂停只影响以后新建对象,已归属v2的采购草稿仍用v2。回退只能使用兼容版本,不删除策略/计数/版本字段或已发号。 +- 迁移的触发器禁止改写策略生效点、采购既定版本、删除或降低新计数高水位。策略管理明确记录操作者与引用,兼容实例标记属于操作人确认,不是自动化集群证明。 +- 迁移仅加表及默认legacy字段、约束,不批量编号或变更业务号。回退保留新增结构,禁止down migration清空历史。 + +## 验证分层 + +单元覆盖全部格式/业务时区/跨月年/扩位/配置/版本固定/路由与幂等;隔离PG执行全部迁移,并发、回滚、删除占号、同格式接续、策略启停及旧草稿归属。混合BX/HK来源、旧PB及银行客户流水/行身份沿原用例验证。不能把单测stub当PG并发或把定向验证当G20完整门禁。原G20四未过另接续,零重试和原预算不变。 + +## 2026-09-18 官方资料调研 + +用户补充公司不足10人、科技初创公司,并要求先调研国内财务、OA、ERP做法。以下是公开原始资料,不是市场使用比例调查,也不代表所有版本的默认值。 + +| 来源 | 已核实事实 | 对本项目的含义 | +| --- | --- | --- | +| [金蝶云产品手册:编码规则](https://help.open.kingdee.com/dokuwiki/doku.php?id=%E7%BC%96%E7%A0%81%E8%A7%84%E5%88%99) | 可组合常量、日期、基础资料及流水;编码依据单独控制流水;保存时生成并做唯一性校验 | 日期格式与计数周期必须同时设计,不能只换展示格式 | +| [金蝶云产品手册:总账系统参数](https://help.open.kingdee.com/dokuwiki/doku.php?id=%E6%80%BB%E8%B4%A6%E7%B3%BB%E7%BB%9F%E5%8F%82%E6%95%B0) | 凭证号排列规则可按期间或年度 | 总账凭证号与OA业务编号独立,不为迎合记账期间重编报销/付款号 | +| [畅捷通官方:T6小编号,有大学问](https://service.chanjet.com/edu/9164.html) | 已实际阅读文章内教程图片:按业务类型配置前缀、日期、流水依据和起始值;示例日期格式为年月 | 支持按业务配置,而非所有业务固定按日;该资料发表于2017年,不能外推为当前T+默认规则 | +| [钉钉宜搭帮助中心:流水号](https://docs.aliwork.com/docs/yida_support/wtwabe/zk6dhy/vanx1ycppgpz5rsk) | 默认计数4位;支持不重置、按日/月/年;日期格式和时区可配;文档说明手动重置或计数溢出可能产生重复 | 作为OA/流程表单代表,证明周期属于可选配置;本项目不能照搬其允许重号的行为 | + +检索受阻、结果无关、只有标题且未读正文的资料未纳入结论;没有声称已核实泛微/致远的具体默认配置。公开资料、实际读取摘要及校验值保存在本轮ignored证据 `numbering-public-research/sources-and-findings.json`。 + +## 不足10人公司的建议矩阵(2026-09-18复核后获批) + +依据是单据用途和用户提供的小团队/低付款量背景,未额外读取生产业务量。不同类型统一取号原则,不强求同一重置周期。 + +| 类型 | 实施前工作树事实 | 推荐的新号 | 决策状态 | +| --- | --- | --- | --- | +| 报销 | BX-日期-四位下限;长期累计 | `BX-202609-0001`,每月重置 | 用户已选择 | +| 付款批次 | PB-日期-三位;按业务日,日上限999 | `PB-2026-0001`,每年重置 | 本轮批准;报销付款与本金还款继续共用PB序列 | +| 公司借款 | JK-UUID,属于本地待发布实现 | `JK-2026-0001`,每年重置 | 本轮批准 | +| 本金还款申请 | HK-UUID,属于本地待发布实现 | `HK-2026-0001`,每年重置 | 本轮批准;HK是申请、PB是执行批次,分别保留 | +| 采购申请 | 默认CG-日期-流水;yearly元数据未执行重置 | `CG-2026-0001`,每年重置 | 本轮批准;保留首次提交取号时机 | +| 盘点单 | PD-日期-流水;yearly元数据未执行重置 | `PD-2026-0001`,每年重置 | 本轮批准 | +| 项目 | 手工项目号或PRJ-UTC日期-当日数量加一 | 已有/正式项目号保留;新自动号`PRJ-2026-0001`,每年重置 | 本轮批准;修复count+1并发及删除碰号风险 | +| 固定资产 | FA-年份-六位;yearly元数据未执行重置 | 新资产可用`FA-0001`,永久递增、终身不改 | 本轮批准;比前稿年度资产号更适合少量设备;已有标签不换 | +| 电子资产 | EA分类前缀和独立序列 | 保留已有分类边界,新号可去掉年份并永久递增 | 本轮批准,不新增分类体系 | +| 合同 | 正式合同号录入,续约关联使用-Rn | 保留正式编号及既有续约引用 | 不自动改号 | +| 员工与主数据 | 系统生成/人工/来源系统的稳定编码 | 保留稳定编码,不按年月重置 | 不自动改号 | +| 发票、银行流水/回单、企微审批 | 外部系统来源编号 | 保留外部原号 | 不自动改号 | +| 冻结差旅 | CL/TRB等旧编号和冻结链 | 保留,恢复前再评估 | 不恢复或为统一编号改旧链 | + +这里的实施前事实来自指定执行树,不将未部署的借款、还款等本地实现说成线上现状。短号方案是本项目建议,不是厂商强制标准。用户先前报销按月选择保留;若后来希望连报销也统一按年,属于新决策,不能自行替换。 + +## 共同合同与实施顺序草案 + +1. 日期段与重置周期一致;按现有业务时区取号,具体创建/提交/付款/业务发生日期在独立字段展示。跨月审批、跨年结清不换号;补录历史借款区分登记日期和原借入日期。 +2. 不加入部门、人员、金额、付款状态或可变分类。各业务类型独立全局计数,不按经办人/部门再分段;现有资产分类边界和所有权约束保持。 +3. 普通单据四位作为最小补零宽度,超过9999扩位、不归零、不截断;固定资产也从四位起。内部UUID继续用于关联,不承担普通用户展示号。 +4. 已经创建对象获得的号码不回收,取消/作废留下空号;内部业务序号不是业务数量或会计凭证连续性的替代。失败事务未发布号码的回滚与已发号后删除须分开验证。 +5. 旧号、已有草稿及原审批/付款/审计/银行材料引用保持不变。新旧格式并存,搜索、自然排序、通知跳转、PDF/导出都需验;不进行历史批量改号。 +6. 按批准矩阵先完成报销周期计数与历史缺号边界;随后付款/借款/还款共用机制及锁序;最后纳入批准的采购/项目/资产等。分步实施不改变原门禁未完成事实。 +7. 复用周期唯一键和事务原子递增,不能按数量加一、无锁最大号加一或在共享计数器上并发清零;保持本金/审批/审计/银行回单合同,不通过编号调整改变业务状态机。 +8. 验证覆盖业务时区跨月/年、四位溢出、并发取号、回滚、取消不复用、旧草稿稳定、混合格式消费者及报销/借款付款整链。独立只读复核、生产切换、发布授权分别留项。此前调研未改产品代码,本轮进入实施。 + +## 已有实际业务的增量切换方案 + +### 原则与本地证据 + +用户明确已有多个旧号业务。本方案补全历史兼容,最新用户授权复核适合后实施,已确认本节范围。既有业务继续按原审批/付款流程办理,不为编号变更做删除重建、重新提交或重复付款。 + +本地代码显示:报销创建草稿即取号;采购在首次提交时才取号;报销提交只在form_number缺失时补号。PaymentBatchItem同时保存entity_id与entity_code,关联可以保持而展示号应取持久化原值。paymentOrderXls.js:24以batch_no和行号生成客户流水号,:134用于银行制单。因此仅给旧批次换一个“更好看的号”也会影响后续导出身份,不能当作无风险展示调整。 + +### 各类旧业务的处理 + +| 对象 | 切换后处理 | +| --- | --- | +| 已编号报销/采购草稿 | 原号及原规则归属保留,编辑和提交不换号 | +| 切换前创建但尚未取号的采购草稿、历史缺号报销 | 到原业务取号时机时按旧规则补号;不提前批量编号 | +| 审批中、待付款或已付款单据 | 原号及所有历史引用保持,跨月/年完成也不换号 | +| 旧付款批次(含draft、reviewed、failed) | 保留batch_no、已有名称及来源快照;继续原流程,不因切换而取消重建 | +| 已导出付款材料 | 客户流水号、原批次行绑定和证据原样保留;显示排序不能改变生成流水的业务行顺序 | +| 新付款批次关联旧报销/旧还款申请 | PB按新批次自身规则发号,逐项展示原BX/HK,不重写来源号;允许新旧格式在同批次并存 | +| 旧资产/项目/合同 | 编号、标签和正式材料保留;新资产永久短号等仍纳入本轮批准范围 | + +### 切换边界与首号 + +- 采用一个明确、持久化的规则生效点T;优先在完成兼容验证后的月初启用,便于解释。2026-10-01只是候选窗口示例,未排期、未执行发布。付款若按年度编号可以在年中启动新格式,无须专门等到2027年。 +- 规则选择属于单据创建事实,不能按页面展示时间、最后更新时间或提交当天重新判断。实施时明确可信创建时间与规则版本的持久化方式;已有未编号草稿、补录/导入及跨T的在途事务必须纳入同一判据,不仅靠服务进程的一次启动时间。 +- 先具备新旧格式兼容能力、核实全部写入实例都支持切换机制,再受控激活T;不让滚动发布中新旧写入端各自选规则。未准备好就继续旧规则,不倒填过去的生效点。 +- 新格式使用独立计数。比如旧PB-20260917-001保留;年中新格式的PB-2026-0001表示新序列第一批,不宣称是2026年公司实际第一笔付款。 +- 0001只在确认目标格式尚未被已有/导入/自定义编号占用时成立;有同格式号码须在锁和数据库唯一约束下接续,不能无锁MAX+1或清零旧计数。旧格式计数继续保留给兼容补号路径,已发给已创建对象的号码永不复用。 +- 采用存量原号与新号并存,无需为所有旧单额外生成第二套“规范号”或新旧号映射表;这是拒绝历史改号的自然结果。内部关联继续使用原ID,实际编号使用原存储值,兼容未知历史格式而不猜测改写。 +- 混合格式的自然编号顺序不等于时间先后;核验现有列表排序、付款明细编号自然排序及筛选/搜索,不擅自把日期排序宣称为原编号排序。显示调整不得影响付款执行或银行制单行身份。 + +### 切换前验证与回退 + +先在本地合成库构造上表全部状态,验证旧对象字段与引用不变、新旧来源可组批、跨月/年和并发取号、旧草稿切换后提交、客户流水号及行绑定稳定、取消不复用、旧格式查询和导出。针对同格式冲突、在途事务和回退后重新启用补反例。 + +生产前置只需经授权取得格式分布/冲突数/缺号数/各状态数量及范围内关联完整性摘要,不需要票据、银行资料、秘密配置或备份。本次没有查询这些生产事实;此前指定付款批次的只读授权不扩大为全库调查。无证据不声称线上零冲突。 + +回退须保持所有已发新号与计数,旧兼容路径不得删除新号或回收流水;只能回到仍支持新旧格式的版本。数据库切换、真实配置写入、提交推送部署与独立review均保留原授权边界。 diff --git a/.trellis/tasks/09-18-reimbursement-periodic-numbering/implement.md b/.trellis/tasks/09-18-reimbursement-periodic-numbering/implement.md new file mode 100644 index 00000000..a8168cf2 --- /dev/null +++ b/.trellis/tasks/09-18-reimbursement-periodic-numbering/implement.md @@ -0,0 +1,128 @@ +## 2026-09-19 N5本地验收完成(未上线) + +g20-full-r6从static完成同一冻结候选,4437后端通过/1原条件跳过、372本地浏览器全部通过。新旧BX的PC/手机列表搜索详情、新旧PB及混合报销实体对应、自然排序和真实合成报销付款整链均有证据;N5已勾选,本地编号N1–N7完成。不得把消费者mock当作真实PG取号证据,原并发/兼容/银行引用证据分别保留。 + +回执PARTIAL仅dirty_candidate,无部署资格;独立PENDING、生产编号未激活。剩余生产只读摘要、启用日期/启用、干净候选/提交部署及同候选公网验证须另获授权,旧号/草稿/银行引用不变。旧WebKit派生索引保护异常由09-06统一记录,无新增漂移,不修改原baseline。详细完整结果、来源/计时及证据入口见09-06 implement顶部;本次结束后仅补task记录,整体in_progress,不归档。 + +## 2026-09-19 四文件定向通过,接完整候选r6(当前入口) + +r5-fixed-r2四完整文件31/31通过,浏览器约5.5分钟、受管总571.747秒,零跳过/重试;采购整链、R75四项、付款提交至归档全链及视觉25项通过。付款约244.773秒,保持300秒;视觉7组各180秒、25PC+8手机/33PNG全部完成、覆盖问题与findings为空。环境/source/repository/owner清理PASS。原r5完整FAIL与r5-fixed-r1登录跳转失败均保留,不拼接为完整通过。 + +P10采购提交后读取边界167/167及QA专用类型已通过;登录仅在无query/hash的规范/login复用,保留原表单/portal/profile断言。下一g20-full-r6从static开始普通完整候选,全部源码/测试/task/spec/docs冻结,只写本轮ignored证据;N5/G20及产品完整负载验收仍待终态,独立PENDING、生产编号未启用。 + +旧WebKit派生registry保护异常保留,原baseline不改;后续运行使用本轮独立WebKit副本,不下载或安装。禁止将一处已知漂移描述为全部旧材料零变化;无提交推送部署或真实操作。 + +## 2026-09-18 编号页面专项验收收口(本地,未上线) + +- [x] 新旧BX的PC/手机列表与详情、手机完整号搜索:最终用例1/1通过,17.756秒;受管环境总耗时268.462秒,输入不变及owner清理PASS,零重试。移动标题按现有header内精确文本断言,PC保持精确h1;没有修改产品以迎合测试。 +- [x] 新旧PB、完整查询、组批entityId、混合新旧BX来源、原顺序及UUID链接:browser-r3最终版本通过(18.517秒)。合成页面fixture只证明消费者,不冒充真实取号或审批;HK兼容另归原编号PG/银行文件证据,不计入此浏览器用例。 +- [x] 付款路由自然排序补含旧号、月号0002/0010/10000及同号ID顺序,完整本文件129/129通过;验证编号、ID、金额对应、原数组与读取零写入/审计。五个相关浏览器文件专用类型检查通过。 +- [x] browser-r2真实合成报销付款整链通过(254.846秒),覆盖财务同页进度、组批、复核执行、回单、核销/AP与归档。此结果保留原输入,不与多轮定向结果拼为完整发布门禁。 +- [ ] N5继续等待09-06 G20普通完整门禁:全页面33路由巡检仍180秒超时(browser-r3到达13页);R75提交遇P2028(5412/5000ms),由09-18-local-concurrency-product-fixes的P9先分析、定稿再修复。不得直接加预算、缩覆盖或无变化反复重跑。 + +官方current仍为09-06-release-gate-integrity。本次仅增补六个测试文件及任务记录,编号产品实现/原冻结包不变;证据独占保存于numbering-browser-closeout-20260918。最终治理、保护及增量审阅摘要在该根单列,不倒签旧候选。主会话inline自检不替代独立审阅,独立PENDING、normalReleaseEligible=false;未读取生产编号分布、未激活线上编号、未提交推送部署。 + +## 2026-09-18 N5浏览器接续(未关闭) + +官方current为09-06门禁task;编号本地冻结候选保持。新增两条浏览器消费者计划核对PC/手机旧新BX完整显示和搜索、旧新PB及混合来源实体对应;合成mock只证明页面消费者,真实取号/并发仍归原PG证据,审批付款仍须原整链。09-06 browser-r1已证明DG7/R3旧超时项通过,但全页面超时及认证失效仍开放;N5不提前勾选。 + +# 业务编号实施与验证 + +## 本轮执行状态 + +- [x] 多角度复核、范围批准与最终技术合同。 +- [x] 迁移/策略管理、共享取号器与全部批准创建入口(含电子资料索引和项目导入),默认不启用。 +- [x] 单元与隔离PG并发/兼容/回退验证,银行文件与来源快照回归;完整浏览器门禁见后续项。 +- [x] 配置事实、文档/spec与保护检查,冻结本轮本地审阅候选(非发布回执);独立只读复核PENDING。 +- [ ] 衔接原G20四未过及新完整门禁;生产摘要/启用、提交推送部署均未授权。 + +## 调研与实施清单 + +## 当前结果 + +2026-09-18:用户先确认报销按月,随后要求统一梳理并调研国内财务/OA/ERP做法,补充公司不足10人。已核实四份官方原始资料,结合当前执行树整理13类编号现状与建议,见design.md后半部。来源抓取/摘要/校验值在当前ignored证据 `numbering-public-research/sources-and-findings.json`。 + +- [x] 核对现有报销、付款、借款/还款、采购、资产/盘点、项目及外部标识路径。 +- [x] 读取金蝶编码规则和总账参数、畅捷通T6官方图文、钉钉宜搭流水号说明;区分可配置能力与具体默认值,保留资料版本限制。 +- [x] 形成小团队建议:报销按月、低频业务按年、资产永久短号,外部与主数据编号保留;已选报销按月不自行替换。 +- [x] 针对已有实际业务补齐增量切换方案:全状态旧号保留、未编号旧草稿资格、银行客户流水号/行绑定、混合来源批次、持久化生效点及冲突/回退检查,见design.md末节。方案落盘不等于兼容实现已验证。 +- [x] 最新用户授权复核适合后实施,矩阵全部在用内部编号范围已确认。 +- [x] 补定稿技术设计与独占保护基线:338份原差异、10989份旧证据原文件,未覆盖旧结果。 + +本轮官方current切至本task并进入实施,09-06原门禁未完成事实保留。此前只访问公开厂商资料,不扩大为线上数据/配置查询。旧差异与冻结包保留,不提交、推送、部署或历史批量改号。 + +## 后续实施顺序(按批准范围执行) + +1. 报销:选定周期计数实现,固定一次业务时间和规则归属;新草稿按月,已有号保留,未编号旧草稿与历史缺号补号延续旧规则及原取号时机。持久化切换点/资格,覆盖在途事务和同格式已有号冲突。检查配置查询与实际取号口径,不能只改reset元数据。 +2. 财务低频单据:接入付款年度号、借款年度号、还款申请年度号;报销/本金还款共用PB序列,保持借款主体→借款→申请/分配→序列锁的既有锁序及幂等边界。 +3. 其他内部编号:处理采购/项目/盘点年度号及新资产永久短号;项目count+1替换为原子计数。员工、正式合同、发票/银行/企微来源编号与冻结差旅不改。 +4. 混合格式:核对列表/详情/搜索/自然排序/通知/打印和制单文件;既有审批与付款记录无改号写入。普通四位为最小宽度,超过上限扩位而不归零。 +5. 合成验收:新增周期/时区/跨年/扩位/回滚单测、隔离PG并发与占号不复用、旧草稿/旧待付款批次/已导出银行客户流水号及行绑定、新批次混合新旧来源、回退再启用等回归;独立只读review与生产前置摘要检查/切换另记,不据本地结果宣称上线。 + +## 验证入口与风险文件 + +按实际改动选择 `server/tests/unit/documentSequence.service.test.js`、报销创建/提交测试、付款编号与借款幂等/PG并发测试、项目及资产在用回归;不为了统一编号恢复冻结或退役旧代码。共享服务改动才扩展检查到其他调用者。 + +后端沿用 `npm run prisma:validate`、Vitest、相关源文件 `node --check`;本轮已实施共享机制,扩展完整后端单测。单据分类页仅补既有样式说明,运行前端 `npm run typecheck` 与 `npm run build`。文档另验task validate、Trellis治理、process baseline、docs registry与 `git diff --check`。 + +风险入口为 `documentSequence.js`、报销两个取号点、付款批次分配器、借款/还款创建、项目自动取号以及资产编号消费者。新旧规则可能曾存在自定义前缀,首次新规则计数需在锁与唯一约束下校验已有同格式记录,不假定从1一定无冲突。回退不能删除已经发出的新号或清零计数。 + + +## 主会话多角度复核记录(2026-09-18) + +| 角度 | 裁决与依据 | +| --- | --- | +| 小团队日常识别 | 保留BX月号;低频PB/JK/HK/CG/PD/PRJ年号,业务日期另列,不将每日从1误作付款数量 | +| 财务与外部材料 | UUID和原号均持久化;旧PB及entity_code不写回。BIFF8实际生成验证新PB与混合BX格式、原客户流水号及行身份 | +| 旧草稿/在途切换 | 采购numbering_version默认1且数据库禁止改写;新草稿创建时固定。历史缺号报销强制1;HK与对应PB复用一次上下文 | +| 并发和容量 | 锁内同格式占号与BigInt高水位接续,四位下限扩位。12路真实PG事务唯一;失败回滚与已创建后删除占号分验 | +| 切换与回退 | 迁移不激活;受控CLI未来时点、操作人和引用,全部写入实例兼容须操作人核实。策略启停等待共享锁;生效点和计数受数据库保护 | +| 自定义及导入 | 电子前缀原样保留,SQL字面转义与Unicode字符截取;真实PG特殊前缀占号验证通过。项目手工/CSV原号保留并登记高水位,导入dry-run零写入 | +| 权限/审批/本金 | 不改变原权限、审核或资金规则;启用新号的原借款HTTP16组检查通过,包含幂等、审批、真实合成文件转换、回单核验与付款回滚 | +| 维护和页面口径 | 一份v2共享服务,旧取号兼容保留,不扩建配置平台。现有配置读返回有效规则,分类页解释旧配置作用;沿用既有文字样式 | +| 交付边界 | 主会话inline检查不冒充独立复核;未读取生产编号分布、未启用线上。原G20完整门禁与公网义务另续 | + +### 已发现并修复的本轮问题 + +- 原测试夹具无新增策略模型/共享锁,补足实际模型接口,产品不添加“模型缺失则legacy”的掩盖分支。报销创建夹具原来丢弃form_number,修成保存真实data以验证新号。 +- PG占号查询的未定型SUBSTRING参数触发文本正则重载,漏掉已占用流水;修为显式int字符偏移,实际同格式0042→0043通过。电子前缀另验正则元字符与Unicode字符偏移。 +- 第一次全量单测4410通过/1失败/1原条件跳过,失败是清洁包装器缺少供旧运维mock使用的DATABASE_URL;补不可连接的合成回环地址,不改旧业务或取真实配置。原失败日志保留,最终完整重跑单独记结果,不拼接定向绿。 +- 银行文件测试沿用已有受控Python环境;未安装依赖或下载浏览器。首轮缺解释器环境的失败保留。前端已有Browserslist陈旧提示、docs registry既有22份未登记提示均保留,不扩展无关治理。 + +### 证据与复核边界 + +独占新根 `artifacts/qa-layout/business-numbering-20260918`;输入保护为338份旧差异与30目录10989份历史证据。旧文件快照、HEAD及index校验保持。`verification-source.json`固定1002份本地源码/测试/相关脚本输入;这是本次本地验证的选择集合,不是G20正式发布回执,也不证明公网状态。 + +`numbering-pg-final.log`:全部73迁移、9组编号检查与原借款HTTP16组通过,精确owner容器/临时目录清理PASS;两个列表有重叠,不能简单相加成25个独立用例。编号检查含真实项目CSV导入/管理CLI与幂等不占号;无真实凭据、企微发送或付款。旧规则未启用策略的借款DB/HTTP回归另列 `legacy-loan-pg-final.log`。 + +前端 `client-typecheck`、`client-build` 和 `prisma-final` 已通过;源码语法、Trellis/process/docs检查及差异检查单列回执。最终完整后端结果与冻结候选将在全部完成后回填。 + +### 必须保留的后续项 + +1. 原G20四个浏览器未过继续按09-06/09-08合同修复,之后从static开始重新完整验收;此次编号验证不能抵扣。 +2. 只读独立复核编号schema/迁移、锁序、历史兼容与候选摘要,当前PENDING(inline限制)。 +3. 经单独授权取得生产格式/占号/缺号等脱敏摘要,确定未来切换点并核全部写入实例兼容;目前未查、未启用,不预排上线日期。 +4. 提交/推送/部署另需明确授权;部署后履行同候选12条公网检查。旧恢复、ARM、真实配置密钥备份和冻结差旅不在本轮操作范围。 + + +### 最后复核补项:策略锁等待后的时间检查 + +发现setNumberingPolicy默认now在等待独占锁前取值;若等待跨过计划生效点,应该在锁与策略读取完成后取实际时刻再校验,不能按等待前时刻放行。为保持当前全量运行输入固定,先完成该轮,随后只修改此管理入口的默认取时位置,追加确定性时钟推进反例、相关单测及隔离PG复核;完整回归的原输入结果与后续小修输入分开记录,不拼为G20正式候选。 + + +### 固定输入的完整后端回归已完成 + +`backend-unit-full-final`:223文件通过、1原条件跳过;4412用例通过、1原条件跳过,0失败,478.80秒。跳过的是原有deploy-init.integration,要求单独空合成库,未为编号扩大初始化门禁。1002份选定输入全程不变,回读 `full-unit-source-verification.json` 为PASS。 + +随后补策略管理两项边界,不能把这次局部变化后的输入声称为上述完整运行的原输入:锁等待后重新取时(先有 `policy-clock-red` 反例确认旧行为错误,再修复);对本身已使用相同月/年日期段的旧共享取号配置,启用/恢复保守拒绝并报告需专门兼容的类型,不在本轮擅自迁移自定义旧规则。源表内已有新格式号码仍支持锁内接续,两者含义分开。最终补验为 `policy-final-unit` 与 `policy-final-pg`;原G20新完整候选仍待执行。 + + +## 本轮收口回执(本地,非发布放行) + +- 完整后端:`cd server && npm test`,4412 passed / 1原条件skipped,223文件通过 / 1跳过,0失败。其1002输入摘要单列,后续策略小修没有倒签到该结果。 +- 最终策略补验:`policy-final-unit` 的11文件456/456通过;覆盖共享服务、所有相关创建/首次提交入口、电子索引导入、银行文件与项目导入既有合同。新增等锁跨生效点、旧规则周期重叠拒绝激活/恢复均通过。原red测试先证明缺陷,后修复通过。 +- `RUN_ISOLATED_NUMBERING_DB_TEST=1 node scripts/qa/test-business-numbering-db.cjs` 最终 `policy-final-pg` PASS:73迁移、10组编号断言和原借款HTTP16组(含重叠,非简单累加)。实际PG验证高水位/旧草稿不可改写、同格式已有号、Unicode/特殊前缀、12路并发、事务失败回滚、删除不复用、共享/独占锁、暂停恢复、导入dry-run/apply/update、管理CLI、原号快照、幂等不占号、真实合成BIFF8与借款付款整链。 +- 关闭策略的原借款回归:`RUN_ISOLATED_LOAN_DB_TEST=1 node scripts/qa/test-company-loan-db.cjs` PASS,确认新增迁移默认无激活记录时旧链可用。两种策略状态均只用独占无网络合成PG;精确owner清理PASS。 +- `npm run prisma:validate`、前端`npm run typecheck` / `npm run build`通过。前端只有分类页说明,无新视觉方案;未安装依赖、未下载浏览器。 +- 文档/语法/保护终检与候选摘要见本轮ignored `final-checks.json`、`protection-result.json`、`local-review-candidate/manifest.json`。它们不是部署候选/公网通过证明;原G20 FAIL和所有旧失败仍保留。 +- 产品实现与本轮主会话验证完成;任务保持in_progress,N5浏览器完整门禁、独立只读review、生产摘要/启用及提交推送部署继续待办,不将本阶段完成写成整个项目完成。 diff --git a/.trellis/tasks/09-18-reimbursement-periodic-numbering/prd.md b/.trellis/tasks/09-18-reimbursement-periodic-numbering/prd.md new file mode 100644 index 00000000..a1f23109 --- /dev/null +++ b/.trellis/tasks/09-18-reimbursement-periodic-numbering/prd.md @@ -0,0 +1,46 @@ +# 报销单周期编号与历史兼容 + +## Goal + +面向不足10人的科技初创公司,核对国内财务、OA、ERP的公开编号规则,形成简短、稳定、易核对的本项目编号方案;保留历史编号、审批、付款及审计引用。报销按月已由用户选择,其他类型按已复核矩阵实施。 + +## Background + +用户指出现行报销序号长期累计不合适。实施前documentSequence默认BX/YYYYMMDD/4位/none,issueDocumentNumber仅原子递增,不执行周期重置;seed-config保留none。报销创建草稿时取号,提交仅为历史缺号单补号。不能只改reset_period元数据就声称已支持周期重置。 + +用户进一步明确已经按旧规则生成多个实际业务。历史兼容是切换前提,不能要求重建单据、重走审批、重新付款或重贴旧资产标签。付款导出的客户流水号由持久化batch_no派生(paymentOrderXls.js:24),批次明细保存entity_code快照(paymentBatches.js:1726),禁止仅改显示号而让银行材料、通知与原记录失配。 + +## Scope + +- 报销周期已由用户明确选择按月,形式BX-202609-0001,下一月BX-202610-0001。后续用户要求先调研国内产品惯例与不足10人公司的适用规则;2026-09-18用户授权复核适合后实施,矩阵已通过主会话多角度复核。 +- 生效后新创建的报销草稿使用新规则;已创建草稿和所有已有编号保留,不重排历史审批/付款引用。 +- 使用既有业务时区,规则归属在创建时固定;即时取号使用该次业务时刻,采购仍在首次提交时确定日期段;序号允许因取消或删除留下空档,已用号码不回收。 +- 同一周期并发创建必须唯一;数据库失败不能产生重复编号。历史缺号补号的归属规则随最终设计明确。 +- 采用增量切换:旧已编号业务全状态保留原号;切换前未编号的既有草稿保留旧规则资格,延续原取号时机;新建对象才采用批准的新规则。新付款批次可以包含旧号报销,逐项保留来源编号。 +- 固定、可追溯的切换点与规则选择须持久化,不能依靠每次打开/审批时的当前日期重算编号。方案建议在准备就绪后的月初统一启用,具体日期待上线条件满足,不自动预约或部署。 +- 新增只读调研:付款批次、借款/还款、采购、资产/盘点、项目、合同、人员/主数据与外部来源标识;核对实际在用取号路径与官方公开文档,区分厂商可配能力、产品默认值和本项目建议。 +- 除已选择的报销按月外,其他内部编号按design矩阵纳入本轮本地实施;冻结差旅、真实外部来源标识不自动纳入改号。 + +## Out of Scope + +历史全量改号、线上数据回填、真实配置/密钥/备份读取、提交推送部署。本轮已获批的特定付款批次线上只读查询不是编号发布授权。 + +## Acceptance + +- [x] N1:完成有来源的跨系统调研及本项目编号矩阵,报销按月选择保留;明确其余建议、执行起点和历史缺号处理。 +- [x] N2:未占用的新周期首张为0001,否则接续同格式历史占号;同周期递增,日期段与周期一致,午夜/年界按既有业务时区判定。 +- [x] N3:真实隔离PG并发唯一性/事务失败边界通过;不靠先读最大号再无锁加一。 +- [x] N4:历史单号、已有草稿及其审批/付款引用原样保留,范围外单据编号不变。 +- [x] N5:列表/详情/搜索/自然排序及报销审批付款链兼容两种格式,本地通过不代表上线。 +- [x] N6:旧待复核批次、已导出制单的客户流水号与行绑定、entity_code快照、审计/通知引用均保持;新批次关联旧报销及新旧混合来源完整通过。 +- [x] N7:已编号与未编号旧草稿、切换边界并发/在途请求、回退再启用、同格式已有号码冲突分别验证;旧计数与新计数隔离且已用号码不回收。 + +## Test Plan + +先检查documentSequence.service.test.js、草稿创建/补号路由及现有搜索排序消费者;复用合成独占PG合同验证并发、周期边界和回滚。依照design定稿与implement执行,不再重复索取同范围批准。 + +## Open Questions + +- 范围已批准。生产启用日期、生产只读摘要与独立复核另行落实,不妨碍本地实施。 + +本地验收依据见implement末尾回执。N5的API/消费者及构建兼容已检查,浏览器完整验收已随09-06 g20-full-r6的372/372通过;独立复核PENDING。本task不据本地代码完成而归档或声明已上线。 diff --git a/.trellis/tasks/09-18-reimbursement-periodic-numbering/task.json b/.trellis/tasks/09-18-reimbursement-periodic-numbering/task.json new file mode 100644 index 00000000..722d15aa --- /dev/null +++ b/.trellis/tasks/09-18-reimbursement-periodic-numbering/task.json @@ -0,0 +1,39 @@ +{ + "id": "reimbursement-periodic-numbering", + "name": "reimbursement-periodic-numbering", + "title": "业务编号周期化与历史兼容", + "description": "按已复核矩阵本地实施报销月号、低频业务年号及资产永久号;旧号不动,迁移默认不启用", + "status": "in_progress", + "dev_type": null, + "scope": "business-numbering", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-18", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "2026-09-19 g20-full-r6:4437后端通过/1原跳过,372本地浏览器全过;PARTIAL仅dirty_candidate,独立PENDING/无部署,旧registry保护例外保留且无新增漂移。当前余额见implement顶部。\n本地编号实现与验证完成:完整后端4412通过/1原跳过;之后策略边界补验456/456及隔离PG通过。默认不启用,旧号保留;N5/G20浏览器、独立review及生产切换待办。未提交部署。", + "meta": { + "latestLocalCandidate": { + "run": "g20-full-r6", + "localChecksPassed": true, + "receiptStatus": "PARTIAL", + "browserPassed": 372, + "publicPending": 12, + "independentReview": "PENDING", + "deploymentEligible": false, + "legacyProtectionStatus": "FAIL_ONE_KNOWN_DERIVED_REGISTRY", + "newProtectedDrift": false, + "productionNumberingActivated": false + } + } +} diff --git a/.trellis/tasks/09-19-budget-pool-concurrency/design.md b/.trellis/tasks/09-19-budget-pool-concurrency/design.md new file mode 100644 index 00000000..54c1067c --- /dev/null +++ b/.trellis/tasks/09-19-budget-pool-concurrency/design.md @@ -0,0 +1,56 @@ +# 预算池并发修复设计(2026-09-19 用户已批准) + +## 已核事实 + +三个模块共有四处用量写入:报销 submit;purchaseBudget reserve/release;travelPayments delta。它们先普通读取 used_amount 再写绝对值,没有针对 budget_pools 的显式行锁。四处计算/更新与 HEAD 旧实现相同;两个 service 周边错误码已有其他差异,不能宣称整文件相同。 + +报销虽在事务内重新 precheck,但预算池未锁;采购名为 authoritativePrecheck 的结果在写事务外获取,并在事务内继续使用。因此最终更新原子化不足以证明 block 决策可靠。示例仅为合成推演:额度100、用量0,两张60的申请同时读0,都通过并写60;数据库最终可能少记60。规划时尚未复现;现已获得原实现红例及修后绿例(见 implement.md),没有观察生产是否发生过。 + +## 技术方案 + +1. 在既有 budgetPool.js 增加仅接受事务客户端的池级写保护入口。优先使用参数化 `SELECT ... FROM budget_pools ... ORDER BY id FOR NO KEY UPDATE`:该锁与其他用量/额度更新冲突,又与外键 KEY SHARE 兼容;不修改主键,不用全局锁或进程内 mutex。具体返回字段以现有 Decimal 和映射为准,不输出业务值。 +2. 固定顺序:沿入口已有实体/工作流锁,确定所需池 ID;多池去重、按同一稳定顺序一次获取;锁后重读金额、额度、状态和适用范围,再进行现行预算决定、用量/流水及后续业务写入。新 helper 不反向获取实体或工作流锁;锁序盘点发现反向依赖时先调整调用者,不能只改 helper。不要先拿一个池再发现另一个较小 ID 后逆序加锁。 +3. 普通预览保留非锁定读取;提交的最终判定与实际占用使用同一池和锁后状态。自动候选的初次选择只用于定位,等待锁后重新验证适用性;候选失效/变化按现行错误合同停止,不能拿旧结果继续写或静默占另一个未锁的池。 +4. 报销保留 P9 的一次实体读取及同事务金额重算/审批/审计;预算保护必须先于权威预算读取。采购增加事务内最新预算/输入判定,事务外结果只可作预览,不能抵扣提交判定;差旅 submit 同步该约定。显式指定池与自动池的实际占用对象必须与校验对象一致。 +5. reserve/release、authorize/reverse 同样先按协议取池,再处理用量与流水;保持零金额、池不存在、释放归零、金额两位、重复调用的原语义。全部写入仍在调用者原事务内,失败回滚;不提高超时、增设重试或移出审计来掩盖锁等待。 +6. 保留管理端正常额度/状态更新;其 PostgreSQL 行更新与上述池锁自然互斥。开发 seed 为排除项,不运行它修改真实或既有库。没有 schema 变更,不顺手重构预算模型、历史流水或报销撤回的既有计量口径。 + +## 预计影响范围 + +| 归属 | 拟改/检查位置 | 边界 | +| --- | --- | --- | +| 共享保护 | server/src/services/budgetPool.js | 增加事务级锁入口,普通预览仍无写锁 | +| 核心写入 | server/src/routes/reimbursements.js;server/src/services/purchaseBudget.js;server/src/services/travelPayments.js | 四处计数写入口一并处理 | +| 最终预算判定 | server/src/services/feeControl.js;server/src/routes/purchaseRequests.js;server/src/routes/travelRequests.js | 必需的锁后重验与同池保证,保留 P9 合并读取 | +| 调用者锁序 | server/src/routes/workflow.js、travelAuthorizations.js、travelTransactions.js 及采购/差旅撤回 | 先检查,只有遵守统一保护协议所必需时调整;冻结功能不启用 | +| 验证 | 已有 budgetPool/purchaseBudget/travelPayments、报销/采购预检及审批测试;最小合成 PG 并发补件 | 不改原断言和验收预算,不把 mock 当真实 PG | + +## 验证矩阵 + +- 红绿:原代码在同池两笔增量/两笔 block 提交下复现失败;保留旧失败,修后同一输入通过。SQL 微实验只能说明底层竞争,最终必须测试真实 service/route 的调用路径。 +- 合成 PG:同池并发占用;报销与采购共享池竞争;占用/释放交错;同实体重复占用/释放;跨多个池反向输入但统一锁序;不同池不无谓互斥。释放有归零时按合法串行顺序判定,不能把所有增减简单相加当唯一预期。 +- 预算决策:额度边界、刚好用尽、block/warn/off、旧/新开关、显式/自动池、等待期间池停用/额度修改;锁后重验应阻止陈旧放行。 +- 原子性:在流水、用量、工作流及原事务审计步骤注入失败,核无部分提交;同实体幂等和不存在/失效池保持既有返回。 +- 隔离:独占合成 PG、无外联、无真实环境配置/密钥/备份、可证明 owner 与精确清理;使用现有 runner/本地依赖,不安装或启动远端资源。无法满足时报告缺项,不借线上证明。 +- 门禁:针对性通过后,将新代码候选交 09-06 从 static 开始普通完整重验,并独立审查新增锁序/事务补丁。完整本地通过、独立审阅、干净候选、部署和12项公网结果分别记账。 + +## 保留与回退 + +每轮从当前 dirty 树取增量快照,不以 HEAD reset/stash 覆盖旧修复;本轮证据单独保存,旧 r6、冻结差旅和恢复材料不改。回退只恢复本任务精确变更;不读取生产用量、不重放流水、不轮换密钥、不提交推送部署。K0 文档澄清由 08-23 任务持有,与预算代码修复分开。 + +## 实施落点与锁序核验 + +- 新增锁函数仅接受事务客户端;Prisma 7.8.0 支持嵌套事务,不能用存在 $transaction 判断根客户端,使用根客户端 $connect 边界。 +- 公务卡退款原本先改预算再更新授权快照,与授权导入实体→预算的顺序相反;现于 upsert/预算前先锁已有 card_authorizations 记录,其余同步语义保留。真实 PG 并发验证该顺序。 +- legacy block 金额判断改用 Prisma.Decimal,两位金额边界避免二进制加法误拒绝;未改变精度或额度策略。 +- 提交锁后同一预算快照经 budgetPoolForWrite 传给纯预检,null 表示已判无池,undefined 才进行普通读取;避免锁外陈旧结果和重复预算查询。 + +## 验证实施收敛 + +本补丁客户端只有错误原因镜像,未改页面布局/交互;实际接口与事务验证承担新增并发行为的证明。用户对重复完整浏览器的成本提出异议后,中止本轮未完成浏览器阶段,保留全部原始回执;不追加无明确缺口的浏览器检查。发布候选完整门禁留待准备发布时统一执行,不降低或删除原发布验收要求。 + +## 独立复核修正:恢复既有选择口径 + +差旅 submit 原池解析仅传 legalEntityId、costCenterId、effectiveDate;本轮误加 departmentId 会让 departmentLegacy 池优先于 global,超出“保持原选择”的合同。删除新增 departmentId,保持原成本中心→全局匹配;不借并发修复改预算归属,不迁移或回填历史记录。上下文精确断言及部门/全局并存用例防止再次漂移。 + +原因码固定夹具除验证夹具→服务端/客户端文本外,还须反向要求所有 audience=user 注册码在夹具中有自有条目;期望中文仍为独立固定值。 diff --git a/.trellis/tasks/09-19-budget-pool-concurrency/implement.md b/.trellis/tasks/09-19-budget-pool-concurrency/implement.md new file mode 100644 index 00000000..ac1a2660 --- /dev/null +++ b/.trellis/tasks/09-19-budget-pool-concurrency/implement.md @@ -0,0 +1,56 @@ +## 2026-09-19 定向测试及独立补审通过(最新) + +已读取 Claude 的 claude-result-20260919T044304Z.md、unit.log 与 exit-code.txt,并在回填记录前实算核对补丁manifest的before/after各10份全部一致,原审查报告SHA一致。Claude执行两份指定测试:2文件928项通过、0跳过、退出0、8.47秒(差旅54、原因码874);主会话未重跑测试。红例927→绿例928的差值1来自新增固定夹具的正向用例,其他用例没有增减。 + +两项必修均关闭:差旅预算池原口径恢复且锁定保留;budget.pool_changed固定夹具与反向完整性检查补齐。结合首批锁/事务/证据审查,预算补丁本地修复及独立复核完成;B7勾选,B6发布完整门禁保留延期,因此任务状态仍in_progress,不归档、不提交、不推送、不部署,不借本轮928抵扣旧4446/28所属输入。 + +无需再安排同范围Claude复核或测试。接收回执:review-fixes-20260919/claude-result-reception.json。回填仅改变任务记录,受测产品/测试文件未变;原manifest/报告/日志均未覆盖。真实PG差旅提交并发、真实认证与外部业务范围仍未验证,冻结和已披露例外保持。 + +## 2026-09-19 Claude 复核接回及最小修复计划(修正前记录) + +报告 budget-pool-claude-review-20260919T043257Z.txt 已实读。独立静态/哈希复核确认锁、事务与证据归属,但有两项必修;不登记整体独立 PASS。 + +本轮在已批准“保持原预算池选择”的范围内修复:删除差旅提交预算上下文新增的 departmentId,保持公司/成本中心/业务日及 forUpdate 锁定;收紧上下文断言,并用部门池与全局池并存的合成路由用例验证原归属。新增 budget.pool_changed 的固定夹具文字,并反向核验全部 audience=user 注册码都有夹具,不从实现动态生成期望文本。 + +仅这两份测试需要验证:travel-request.errors.test.js、error-reason-client.test.js。主会话在用户明确分工前已运行红例:4失败/923通过(2文件),证明新增断言能抓到当前差旅上下文变化与夹具遗漏。用户随后明确要求测试交给Claude;修复后的测试由Claude执行,主会话不再运行。不重跑数据库全套、浏览器或全量门禁。保留差旅提交真实PG并发未测的范围说明。此前30文件交审包与运行日志原样保留;新证据独占 review-fixes-20260919 子目录。代码和测试已修改;绿例尚未执行,不记验证通过。交 Claude 执行两份定向测试并复核这两项增量,无需全量或浏览器。交接入口:review-fixes-20260919/claude-test-request.md。 + +# 预算池并发修复执行清单 + +## 首批修复验证状态(两项补审修正前,历史) + +2026-09-19:用户已批准并完成本轮代码修复;真实隔离 PG 最终 28/28、受影响 Vitest 33 文件 526 项通过。后端全量225文件4446项及8个启用服务回归组也已通过;依用户对重复全量的反馈停止剩余浏览器验收。实际补丁独立只读复核待完成,发布前完整门禁仍待执行,不记整体任务完成或具备发布资格。证据根:artifacts/qa-governance/budget-pool-concurrency-20260919。 + +- [x] 核对独立报告、四处读改写及 HEAD 归因,确认采购事务外权威预检也须修复。 +- [x] 写明并批准 PRD/design;官方 task 启动,3074 路径及旧 dirty 内容保存在 baseline.json 与 before/。 +- [x] 完成调用者锁图:已有实体/工作流锁 → 稳定顺序预算池锁 → 最新判定与用量/流水;公务卡退款先锁授权记录再更新预算,消除反向依赖。 +- [x] 保留原实现真实 PG 红例及单测红例,不覆盖中间失败。 +- [x] 实施事务级 FOR NO KEY UPDATE、锁后重读/候选变化拒绝、四处写入保护和提交事务内权威判断;保留 P9 单次实体读取、原开关/金额/幂等/审计回滚。 +- [x] 最终真实 PG 28/28、相关 Vitest 33 文件 526 项通过;错误注册表/锁序定向 4 文件 885 项通过(有重叠,不能相加)。 +- [x] 在已有 backend-fastify-prisma.md 沉淀共享预算池锁序、锁后判定与事务客户端约定。 +- [x] task/process/docs/governance/diff、22份改动脚本语法及 Prisma/frontend 静态检查完成;原有 docs22未登记提示保留。旧材料保护结果见 final-protection.json。 +- [x] 新候选从 static 开始运行,输入冻结;已完成阶段如实保留,按用户反馈中止剩余浏览器;不重跑已完成阶段。 +- [ ] 准备正式发布候选时统一完成剩余完整门禁;本补丁阶段延期,原分层/预算/并发/零重试合同不降低。 +- [x] 制作本轮 before/ → 最终输入增量及 claude-review-request.md;inline 不派 implement/check 代理、不自称独立通过。 +- [x] 接回独立意见、修复两项必修并由Claude完成928项定向验证与只读补审;提交、发布、公网、编号与真实业务仍按原授权边界。 + +## 已完成验证与限制 + +- pg-red.log:原实现 4 个真实并发失败、1 个回滚通过;unit-red.log 保留 helper 缺失时的预期红例。 +- pg-original-final-fixture.log:22 场景版 fixture 对原源码快照运行,3 通过/19 失败;其中包含新增 helper 缺失,不能把 19 全称为业务缺陷复现,也不是最终 28 场景版红跑。 +- pg-final-r2.log:最终 28/28;真实 Prisma service/实际 Fastify handler、73 个已有迁移、受管隔离 PG 和精确 owner 清理通过。并发靠观察真实锁等待,不用任意 sleep 推测。 +- unit-final.log:33 文件、526 项通过;unit-lock-order.log:4 文件、885 项通过。两者范围不同且重叠。 +- 覆盖新旧预检开关、显式/自动池、报销/采购交叉 block/warn、额度等待后降低、候选停用、重复调用、多池锁序、独立池、归零和流水/工作流历史/代理审计失败回滚。 +- 金额判断使用 Decimal,补验 0.10 + 0.20 = 0.30;Prisma 7.8.0 事务客户端允许嵌套 $transaction,以根客户端 $connect 防误用。公务卡授权/冲正实体→池顺序已用真实 PG 验证。 +- HTTP fixture 使用合成授权身份;不是生产认证证明。冻结差旅只作受管服务/合成 handler 验证,未恢复应用入口或冻结浏览器用例。 +- 浏览器使用物理独占缓存副本,710 个文件复制 SHA 一致,没有下载浏览器。旧 WebKit 派生 registry baseline FAIL 和历史 CSV 校验工具 NOT_RUN 继续保留。 +- 当前无提交、推送、部署、生产/ARM/真实配置密钥备份读取、真实通知付款或旧恢复重跑。 + +## 本轮完整门禁中止与测试成本控制 + +budget-full-r1:static PASS;full-local 前11阶段 PASS,包括后端225文件4446项通过/1原条件跳过、审批动作、8个启用服务回归组(7个原关闭组不计通过)、Prisma及前端typecheck/build。后端全量用时512.855秒。用户指出重复完整浏览器测试浪费后,主会话向本轮受管环境发SIGINT,停止浏览器;29通过、4中断、339未运行,零重试,无产品断言失败,但整轮不是通过。 + +原始回执保留 FAIL/exit130,localChecksPassed=false;不将中断解释为产品修复失败,也不伪造372/372。受管环境PASS,sourceUnchanged/repositoryUnchanged=true,owner容器及scratch均已清理。发现386=372本地+12公网+2冻结不变;33页/7组本轮未完成,不借旧结果冒充。 + +本次将后端共享事务修复直接扩展为所有页面重跑,成本控制不足。今后修复过程中按改动风险选择定向验证,批次稳定且准备发布时集中完整验收;任务记录、证据和未触及代码的说明更新不触发业务重跑。本轮已完成PG实际接口/事务检查、受影响单测及全量后端/服务回归,暂未发现追加浏览器检查的必要性,因此不再新开一轮。 + +停止后仅回填任务记录及ignored交审材料;最终输入与运行冻结输入的对应关系见 final-summary.json / final-protection.json。Claude需复核本轮实际预算补丁,不需要重新审核知识精简;完整请求位于同证据根 claude-review-request.md。独立发现BUDGET-LOST-UPDATE-01保持待复核关闭,生产历史用量未检查或回填。 diff --git a/.trellis/tasks/09-19-budget-pool-concurrency/prd.md b/.trellis/tasks/09-19-budget-pool-concurrency/prd.md new file mode 100644 index 00000000..a415efff --- /dev/null +++ b/.trellis/tasks/09-19-budget-pool-concurrency/prd.md @@ -0,0 +1,48 @@ +# 预算池占用与释放并发一致性修复 + +## Goal + +修复业务独立补审发现的预算池丢失更新与陈旧超额判定;统一保证报销、采购及冻结差旅共享服务的并发一致性,以本地合成数据验证。 + +## Requirements + +本任务来自业务独立补审 `artifacts/qa-governance/business-followup-claude-review-20260919T023537Z.txt`。用户要求将三组同类问题单独规划、一并修复,并于2026-09-19明确批准本方案实施。09-18 的 P9 性能优化通过,不代表旧预算并发缺陷不存在。 + +## Scope + +- 报销提交、采购/差旅共享占用与释放、公务卡授权/冲正中的预算池计数必须在并发下保持一致;相关调用者必须遵循统一事务协议。 +- 超预算判断必须基于获得预算池写保护后的最新状态;只把计数改成原子加减、保留陈旧预算判定不算完成。 +- 保持现行预算池选择、控制模式/开关、错误语义、金额精度、幂等、释放不低于零及审批/审计回滚合同。 +- 差旅仍冻结;仅允许共享服务的安全修复及本地合成验证,不恢复入口、不重启冻结 E2E、不删除其历史代码或结果。 + +R1:不同实体并发命中同一池时,最终用量等于按合法串行顺序应用全部已提交增减后的值,不能覆盖他人的写入。 + +R2:现行 block 策略下,并发竞争剩余额度不能双双通过;warn/off 仍按原规则运行。开关前后、自动选择与显式预算池必须分别验证。 + +R3:流水、用量、单据/工作流状态和原本要求原子的审计共同提交或回滚;同一实体重复占用/释放仍按现行合同拒绝或幂等。 + +R4:锁序统一,涉及多个池时按稳定 ID 顺序获取;并发故障测试使用真正隔离 PostgreSQL,不用 mock 证明数据库行为。 + +## Out of Scope + +不修复其他旧代码或警告;不修改生产用量、不推断历史漏记金额、不增加自动对账回填。无数据库 schema/历史迁移重写、预算策略变更、冻结差旅恢复、生产/ARM/真实配置密钥备份读取、真实通知付款、恢复演练、提交推送部署或编号启用授权。 + +## Acceptance Criteria + +- [x] B1:三组入口全部使用同一预算池并发保护约定;调用者盘点包含报销、采购、差旅及工作流释放、公务卡授权/冲正,没有遗漏写入口。 +- [x] B2:真实隔离 PG 证明同池正向并发无丢失更新、占用与释放交错结果等于合法串行结果、不同池可独立进行;原实现有可复现失败证据。 +- [x] B3:受 block 规则约束的同池竞争只有额度内提交成功;同时覆盖 legacy/precheck 两态以及显式/自动池,warn/off 行为不变。 +- [x] B4:重复调用、释放归零、空/失效池和中途异常均符合原合同;失败无孤立流水、用量、工作流或成功审计。 +- [x] B5:单池和多池锁序、外键关联写入及既有实体锁不形成新增等待环;并发测试通过同步屏障或锁状态观测建立交错,不靠任意 sleep 猜测。 +- [ ] B6:受影响 Vitest、语法/Prisma 与治理检查通过;业务代码变化后新完整候选沿 09-06 现行门禁重验,旧 r6 不能抵扣新代码。保留 12 公网/2 冻结归属、并发、预算和零重试合同。 +- [x] B7:实际补丁经独立只读复核;inline 主会话不冒充 reviewer。本地完成不等于可以发布。 + +## 本轮验收执行调整(2026-09-19) + +用户在本轮运行中明确指出重复完整浏览器验收浪费,主会话据此停止剩余全量并重新评估影响范围。本补丁阶段以已完成的真实 PG 28项、受影响单测526项、后端全量4446项及服务回归证明本地修复;客户端仅新增错误原因镜像,没有页面/交互改动。新增锁后提交、block/warn、新旧开关、交叉业务并发及失败回滚由实际 Fastify handler/Prisma/PG 覆盖,未发现需要另起浏览器定向运行的缺口。 + +B6 中的发布候选完整门禁义务保留为后续准备发布时执行,本轮不补勾、不以旧 r6 或中止的 browser 阶段抵扣。不再因本轮记录/交审材料变化重跑业务测试。B7 已由首批独立审查及2026-09-19两项修正补审完成;Claude实跑两份文件928项全通过,结果见 implement.md 最新记录。 + +## Test Plan + +按 design.md 的场景矩阵及 implement.md 的红绿/保护清单执行;现有预算、费用预检、报销/采购审批测试优先复用。真实 PG 仅使用原受管合成环境及已在本地的依赖,不读取真实配置来寻找数据库。 diff --git a/.trellis/tasks/09-19-budget-pool-concurrency/task.json b/.trellis/tasks/09-19-budget-pool-concurrency/task.json new file mode 100644 index 00000000..1d78a2b7 --- /dev/null +++ b/.trellis/tasks/09-19-budget-pool-concurrency/task.json @@ -0,0 +1,80 @@ +{ + "id": "budget-pool-concurrency", + "name": "budget-pool-concurrency", + "title": "预算池占用与释放并发一致性修复", + "description": "预算并发本地修复及独立审查完成;两项必修经Claude定向928项验证通过并关闭。发布完整门禁延期,任务保留in_progress,未提交部署。", + "status": "in_progress", + "dev_type": "backend", + "scope": "budget-control", + "package": null, + "priority": "P1", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-19", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "server/src/services/budgetPool.js", + "server/src/services/purchaseBudget.js", + "server/src/services/travelPayments.js", + "server/src/services/feeControl.js", + "server/src/routes/reimbursements.js", + "server/src/routes/purchaseRequests.js", + "server/src/routes/travelRequests.js", + "server/src/routes/travelTransactions.js", + "scripts/qa/test-budget-pool-db.cjs" + ], + "notes": "", + "meta": { + "planningSource": "user-provided independent review and request to address three related paths together", + "implementationApproval": "APPROVED_BY_USER_20260919", + "findingId": "BUDGET-LOST-UPDATE-01", + "discoveryStatus": "CONFIRMED_STATIC", + "discoverySeverity": "P1", + "discoveryCommit": "b1d25d3c9939c0020e7b4f75868cd97c35c16ba8", + "remediationStatus": "LOCAL_VERIFIED_INDEPENDENT_REVIEWED", + "deploymentStatus": "NOT_DEPLOYED", + "externalVerificationStatus": "NOT_TESTED", + "report": "artifacts/qa-governance/business-followup-claude-review-20260919T023537Z.txt", + "reportSha256": "b48e5f53340e139e5a97b5fdcb6d7003881a80a8a08d4004d01aaa6a759d2d0b", + "evidence": "artifacts/qa-governance/budget-pool-concurrency-20260919", + "coordinatorTask": ".trellis/tasks/09-06-release-gate-integrity", + "independentReview": "PASS_SCOPED_BUDGET_PATCH", + "deploymentAuthorized": false, + "travelReactivationAuthorized": false, + "targetedPostgresPassed": 28, + "targetedUnitPassed": 526, + "fullCandidate": "INTERRUPTED_NOT_PASS", + "deploymentEligible": false, + "backendFilesPassed": 225, + "backendTestsPassed": 4446, + "backendOriginalConditionalSkip": 1, + "browser": { + "passed": 29, + "interrupted": 4, + "notRun": 339, + "retriedCases": 0 + }, + "releaseFullGate": "DEFERRED_UNTIL_RELEASE_PREPARATION", + "reviewReport": "artifacts/qa-governance/budget-pool-claude-review-20260919T043257Z.txt", + "reviewFixEvidence": "artifacts/qa-governance/budget-pool-concurrency-20260919/review-fixes-20260919", + "reviewFixTests": "CLAUDE_PASSED_2_FILES_928_TESTS_NO_SKIPS", + "reviewFixFiles": [ + "server/src/routes/travelRequests.js", + "server/tests/unit/routes/travel-request.errors.test.js", + "server/tests/unit/fixtures/error-reason-messages.json", + "server/tests/unit/scripts/error-reason-client.test.js" + ], + "findingResolution": "RESOLVED_LOCAL_NOT_DEPLOYED", + "reviewFixResult": "artifacts/qa-governance/budget-pool-concurrency-20260919/review-fixes-20260919/claude-result-20260919T044304Z.md", + "reviewFixResultSha256": "cb7996f6dcacc3ffd44a398b705ed840bf51ed6a0e312336933a25f93813a8f9", + "requiredReviewFindingsOpen": 0 + } +} diff --git a/client/src/api/assets.ts b/client/src/api/assets.ts index bc167f64..81df1765 100644 --- a/client/src/api/assets.ts +++ b/client/src/api/assets.ts @@ -168,81 +168,6 @@ export type AssetMovementResponse = { transferId?: string | null; }; -export type AssetErpTrackStatus = - | "not_triggered" - | "pending" - | "processing" - | "succeeded" - | "failed" - | "dead_letter" - | "skipped"; - -export type AssetErpBinding = { - id: string; - provider: string; - entityType: string; - localId: string; - externalId: string | null; - externalCode: string | null; - lastSyncedAt: string | null; -}; - -export type AssetErpOutbox = { - id: string; - eventType: string; - status: string; - attempts: number; - lastError: string | null; - processedAt: string | null; - updatedAt: string | null; -}; - -export type AssetErpStage = { - eligible: boolean; - reason: string | null; - deliveryStatus: AssetErpTrackStatus | string; - actionHint: "none" | "backfill" | "retry" | "resync"; - preview: { - changeType: string | null; - sourceEventName: string | null; - categoryCode: string | null; - legalEntityCode: string | null; - costCenterCode: string | null; - status: string | null; - }; - outbox: AssetErpOutbox | null; - binding: AssetErpBinding | null; -}; - -export type AssetErpTrack = { - asset: { - id: string; - assetNo: string | null; - name: string | null; - status: string | null; - legalEntity: { - id: string; - code: string | null; - name: string | null; - } | null; - costCenter: { - id: string; - code: string | null; - name: string | null; - } | null; - purchaseRequest: { - id: string; - requestNo: string | null; - title: string | null; - } | null; - }; - stage: AssetErpStage; - permissions: { - canRetry: boolean; - canSeeExternalRefs: boolean; - }; -}; - function toQueryString(query: Record) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { @@ -341,21 +266,3 @@ export async function transferAsset( body: JSON.stringify(payload), }); } - -export async function fetchAssetErpTrack(assetId: string) { - return apiRequestData(`/api/integrations/erp/assets/${assetId}/track`); -} - -export async function requeueAssetErpSync(assetId: string, runNow = true) { - const query = runNow ? '?runNow=1' : '?runNow=0'; - return apiRequestData<{ - outboxId: string; - status: string; - duplicated: boolean; - requeued: boolean; - eventType: string; - }>(`/api/integrations/erp/assets/${assetId}/requeue${query}`, { - method: 'POST', - body: JSON.stringify({}), - }); -} diff --git a/client/src/api/companyLoans.ts b/client/src/api/companyLoans.ts new file mode 100644 index 00000000..fd720163 --- /dev/null +++ b/client/src/api/companyLoans.ts @@ -0,0 +1,51 @@ +import { apiRequestBlob, apiRequestData } from "./http"; +import type { PaymentBatchDetail } from "./paymentBatches"; + +export type LoanLender = { + id: string; name: string; legalEntityId: string; legalEntityName: string; + employeeId: string | null; identityStatus: string; identityVersion: number; accountVersion: number; + bankInstitutionId: string; bankName: string; bankAccountMasked: string | null; accountUnavailable: boolean; +}; +export type LoanEvidence = { + id: string; kind: string; originalName: string; fileSize: number; mimeType: string; + governanceStatus: string; archivedAt: string | null; +}; +export type CompanyLoan = { + id: string; loanNo: string; originReference: string; lender: LoanLender; + originalPrincipal: string; openingOutstandingPrincipal: string; receivedDate: string; + balanceAsOf: string; historicalEntry: boolean; confirmationStatus: string; version: number; + outstanding: string; available: string; reserved: string; settled: string; + evidences: LoanEvidence[]; accountingConfirmationRef: string | null; +}; +export type LoanRepayment = { + id: string; repaymentNo: string; lenderId: string; lenderName: string; amount: string; reason: string; + bankResultState: string; paymentBatchId: string; status: string; + accountingHandoffRef: string | null; accountingHandoffAt: string | null; + allocations: Array<{ loanId: string; loanNo: string; amount: string; state: string; receivedDate: string; originReference: string; balanceAsOf: string; evidences: LoanEvidence[] }>; +}; +export function fetchCompanyLoans(page: number, lenderId?: string, signal?: AbortSignal) { + return apiRequestData<{ items: CompanyLoan[]; total: number }>(`/api/company-loans?page=${page}&pageSize=30${lenderId ? `&lenderId=${encodeURIComponent(lenderId)}` : ""}`, { signal }); +} +export function fetchLoanLenders(page: number, signal?: AbortSignal) { + return apiRequestData<{ items: LoanLender[]; total: number }>(`/api/loan-lenders?page=${page}&pageSize=30`, { signal }); +} +export function fetchLoanRepayments(page: number, signal?: AbortSignal) { + return apiRequestData<{ items: LoanRepayment[]; total: number }>(`/api/loan-repayments?page=${page}&pageSize=30`, { signal }); +} +export function fetchLoanIdentities(q: string, signal?: AbortSignal) { + return apiRequestData<{ items: Array<{ id: string; name: string; employeeNo: string }> }>(`/api/loan-lenders/identity-options?q=${encodeURIComponent(q)}`, { signal }); +} +export function saveLoanAction(path: string, body: unknown, method = "POST") { + return apiRequestData(`/api${path}`, { method, body: JSON.stringify(body) }); +} +export function createLoanRepayment(body: { requestKey: string; lenderId: string; reason: string; + allocations: Array<{ loanId: string; amount: string }> }) { + return saveLoanAction<{ item: PaymentBatchDetail }>("/loan-repayments", body); +} +export function uploadLoanEvidence(id: string, kind: string, file: File) { + const body = new FormData(); body.append("file", file); + return apiRequestData(`/api/company-loans/${id}/evidences/${kind}`, { method: "POST", body }); +} +export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string) { + return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", body: JSON.stringify({ reason, paymentBatchId }) }); +} diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts index 02278871..5176e798 100644 --- a/client/src/api/errorReasons.ts +++ b/client/src/api/errorReasons.ts @@ -1,4 +1,626 @@ export const ERROR_REASON_MESSAGES: Record = { + "integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。", + "loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。", + "loan.not_found": "借款记录不存在或已不可用,请刷新后重新选择。", + "loan.source_unavailable": "公司、银行或关联人员已停用,请核对后重新选择。", + "loan.identity_required": "请先核实出借人与 OA 账号的关联;非员工须明确确认未关联账号。", + "loan.version_changed": "借款身份、账户或余额已变更,请刷新后重新核对。", + "loan.account_unavailable": "借款收款资料暂不可用,请联系财务核对账户加密配置。", + "loan.duplicate_registration": "该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。", + "loan.confirmed_immutable": "借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。", + "loan.evidence_required": "请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。", + "loan.evidence_invalid": "借款材料不符合要求,请使用有效的 PDF 或图片文件。", + "loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。", + "loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。", + "loan.reviewer_required": "没有符合独立复核要求的经理或总经理;制单人、提交人和收款人不能复核。", + "loan.independent_review_required": "本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。", + "loan.bank_state_unsafe": "银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。", + "loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。", + "loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。", + "config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。", + "config.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。", + "document_sequence.type_unsupported": "当前单据类型不支持编号,请联系管理员核对。", + "document_sequence.category_missing": "指定的单据分类不存在,请重新选择分类。", + "document_sequence.category_type_mismatch": "单据分类与当前单据类型不匹配,请重新选择。", + "document_sequence.category_unavailable": "指定的单据分类已停用或归档,请选择可用分类。", + "workspace.update_input_invalid": "工作区设置不符合要求,请检查模块显示方式后保存。", + "confidential_ref.create_input_invalid": "资料索引信息不符合要求,请检查必填项和字段长度。", + "confidential_ref.update_input_invalid": "资料索引修改不符合要求,请检查要更新的字段。", + "confidential_ref.query_invalid": "资料索引查询条件不符合要求,请检查关联对象。", + "confidential_ref.feature_disabled": "当前未启用资料索引,请联系管理员确认。", + "cutover.id_invalid": "切换范围标识无效,请刷新列表后重新选择。", + "cutover.create_input_invalid": "切换范围信息不符合要求,请检查范围类型、标识和时间。", + "cutover.update_input_invalid": "切换范围修改不符合要求,请检查要更新的字段。", + "cutover.feature_disabled": "当前未启用切换控制台,请联系管理员确认。", + "cutover.scope_not_enabled": "当前业务尚未纳入允许操作的切换范围,请联系管理员确认。", + "migration_run.id_invalid": "迁移检查记录标识无效,请刷新列表后重新选择。", + "migration_run.file_required": "请选择用于迁移预检查的文件。", + "migration_run.type_invalid": "迁移检查类型无效,请重新选择检查类型。", + "feature_flag.version_conflict": "功能设置已被其他操作更新,请刷新后核对再保存。", + "feature_flag.key_invalid": "功能标识无效,请刷新功能设置列表后重新选择。", + "feature_flag.page_invalid": "功能设置历史查询条件不符合要求,请检查分页。", + "feature_flag.update_input_invalid": "功能设置不符合要求,请检查覆盖模式、范围和版本。", + "feature_flag.global_departments_invalid": "全局范围不能同时指定部门,请清除部门或选择部门范围。", + "feature_flag.batch_input_invalid": "批量功能设置不符合要求,请检查操作说明、版本和重复项目。", + "fee_control.input_invalid": "费用预检信息不符合要求,请检查单据类型和标识。", + "fee_control.entity_unsupported": "当前单据类型不支持费用预检,请联系管理员确认。", + "risk_flag.query_invalid": "风险记录查询条件不符合要求,请检查单据、状态和来源。", + "risk_flag.id_invalid": "风险记录标识无效,请刷新列表后重新选择。", + "risk_flag.action_input_invalid": "风险处理信息不符合要求,请检查备注长度。", + "risk_flag.not_open": "风险记录已处理,不能重复处理,请刷新后核对。", + "auth.entity_action_unsupported": "当前单据不支持此操作,请返回页面选择有效操作。", + "wecom.signature_url_invalid": "企业微信页面签名地址无效,请刷新当前页面后重试。", + "wecom.entry_config_incomplete": "企业微信登录入口尚未配置完整,请联系管理员确认。", + "wecom.auth_config_incomplete": "企业微信认证配置不完整,请联系管理员确认。", + "wecom.login_query_invalid": "企业微信登录参数无效,请从正式入口重新进入。", + "wecom.pc_mode_unsupported": "电脑扫码入口不支持此操作,请使用对应的绑定或入职入口。", + "wecom.bind_input_invalid": "企业微信绑定参数无效,请刷新绑定页面后重试。", + "integration.outbox_query_invalid": "消息队列查询条件不符合要求,请检查状态、类型和分页。", + "integration.bindings_query_invalid": "外部绑定查询条件不符合要求,请检查类型和分页。", + "integration.outbox_id_invalid": "消息队列记录标识无效,请刷新列表后重新选择。", + "integration.retry_state_invalid": "当前记录不处于失败或待人工处理状态,无法重试,请刷新后核对。", + "ai.reimbursement_id_invalid": "报销单标识无效,请刷新后重新打开报销详情。", + "ai.purchase_id_invalid": "采购申请标识无效,请刷新后重新打开采购详情。", + "ai.asset_id_invalid": "资产标识无效,请刷新后重新打开资产详情。", + "ai.feature_disabled": "当前单据未开放智能建议,请联系管理员确认。", + "weekly_report.query_invalid": "周报查询日期不符合要求,请选择有效日期。", + "weekly_report.write_input_invalid": "周报内容不符合要求,请检查文字长度和关联项目。", + "weekly_report.submit_input_invalid": "周报提交信息不符合要求,请检查周报日期。", + "weekly_report.feature_disabled": "当前未启用周报,请联系管理员确认。", + "weekly_report.submitted_readonly": "周报已提交,当前无法修改,请刷新后核对。", + "weekly_report.project_linking_disabled": "当前未启用项目关联,请移除关联项目或联系管理员确认。", + "audit.query_invalid": "审计日志查询条件不符合要求,请检查分页、日期和筛选条件。", + "audit.date_range_invalid": "审计日志结束时间早于开始时间,请调整查询范围。", + "search.feature_disabled": "当前未启用全局搜索,请联系管理员确认。", + "search.query_invalid": "搜索条件不符合要求,请输入至少2个字符并检查结果数量。", + "export.presence_query_invalid": "在岗历史导出条件不符合要求,请检查日期、人员和文件格式。", + "export.reimbursement_query_invalid": "报销导出条件不符合要求,请检查范围、状态、条数和文件格式。", + "export.invoice_query_invalid": "发票导出条件不符合要求,请检查归属、状态、条数和文件格式。", + "export.audit_query_invalid": "审计日志导出条件不符合要求,请检查日期、筛选条件和文件格式。", + "export.dashboard_query_invalid": "看板导出条件不符合要求,请检查日期、部门和文件格式。", + "export.ap_input_invalid": "应付工作台导出条件不符合要求,请检查筛选条件和文件格式。", + "export.dashboard_date_range_invalid": "看板导出结束日期早于开始日期,请调整日期范围。", + "export.payment_batch_disabled": "当前未启用付款批次功能,无法导出应付工作台数据,请联系管理员确认。", + "export.project_linking_disabled": "当前未启用项目关联,无法导出合同台账,请联系管理员确认。", + "export.ap_pending_date_range_invalid": "待付款批次导出结束日期早于开始日期,请调整创建日期范围。", + "attendance.clock_input_invalid": "打卡信息不符合要求,请检查备注后重试。", + "attendance.legacy_month_query_invalid": "考勤月份查询不符合要求,请选择2000至2100年内的有效月份。", + "attendance.team_query_invalid": "团队考勤查询不符合要求,请检查日期和部门。", + "attendance.policy_input_invalid": "考勤策略信息不符合要求,请检查时间格式和容差分钟数。", + "attendance.backfill_input_invalid": "补录信息不符合要求,请检查员工、日期和打卡时间。", + "attendance.sync_input_invalid": "考勤同步条件不符合要求,请选择不超过31天的日期范围和不超过50名员工。", + "attendance.sync_runs_query_invalid": "考勤同步记录查询不符合要求,请检查分页条件。", + "attendance.feature_disabled": "当前未启用网页打卡,请使用现行考勤渠道。", + "attendance.unified_feature_disabled": "当前未启用统一考勤,请联系管理员确认。", + "attendance.datetime_invalid": "打卡时间无法识别,请填写有效的日期时间。", + "attendance.exempt_day": "当天属于免打卡范围,无需提交打卡。", + "attendance.missing_clock_in": "当天尚未签到,请先签到或联系管理员核查。", + "attendance.missing_backfill_values": "补录缺少打卡时间,请至少填写签到或签退时间。", + "attendance.clock_range_invalid": "签退时间早于签到时间,请核对补录时间。", + "attendance.policy_time_invalid": "考勤策略时间无效,请使用00:00至23:59的小时和分钟格式。", + "attendance.tolerance_invalid": "迟到容差无效,请填写0至600之间的整数分钟数。", + "attendance.work_range_invalid": "上班时间必须早于下班时间,请调整考勤策略。", + "attendance.lunch_range_invalid": "午休开始时间必须早于结束时间,请调整考勤策略。", + "attendance.lunch_outside_work_range": "午休时间超出工作时段,请调整考勤策略。", + "attendance.year_invalid": "考勤年份无效,请选择2000至2100之间的年份。", + "presence.query_invalid": "在岗查询条件不符合要求,请检查日期。", + "presence.write_input_invalid": "在岗信息不符合要求,请检查日期、状态和备注。", + "presence.history_query_invalid": "在岗历史查询条件不符合要求,请检查日期范围。", + "presence.team_history_query_invalid": "团队在岗历史查询条件不符合要求,请检查日期、员工和筛选条件。", + "presence.employee_id_invalid": "员工标识无效,请刷新列表后重新选择。", + "presence.override_input_invalid": "在岗调整信息不符合要求,请检查日期、状态和备注。", + "presence.feature_disabled": "当前未启用在岗看板,请联系管理员确认。", + "presence.approved_leave_readonly": "当天已有获批请假记录,无法自行修改在岗状态,请联系管理员核查。", + "export.presence_too_large": "在岗历史导出条目过多,请缩小日期或人员范围。", + "delegation.id_invalid": "委托记录标识无效,请刷新委托列表后重试。", + "delegation.create_input_invalid": "委托资料格式有误,请检查受托人、范围及起止时间。", + "delegation.grant_input_invalid": "委托授权资料格式有误,请检查委托人、受托人、范围及起止时间。", + "delegation.start_time_invalid": "委托开始时间无效,请重新选择。", + "delegation.end_time_invalid": "委托结束时间无效,请重新选择。", + "delegation.time_range_invalid": "委托结束时间必须晚于开始时间,请调整后重试。", + "delegation.status_query_invalid": "委托状态筛选条件无效,请重新选择状态。", + "delegation.grants_query_invalid": "委托授权筛选条件有误,请检查人员与有效期选项。", + "delegation.delegate_unavailable": "受托人不存在或不在职,请选择在职人员。", + "delegation.delegate_proxy_permission_required": "受托人不具备报销代提交权限,请选择具备该权限的人员。", + "delegation.delegate_approval_permission_required": "受托人不具备报销审批权限,请选择具备审批权限的人员。", + "delegation.self_not_allowed": "委托人和受托人不能是同一人,请重新选择受托人。", + "delegation.principal_unavailable": "委托人不存在或不在职,请选择在职人员。", + "workflow_definition.id_invalid": "审批流程标识无效,请刷新流程列表后重试。", + "workflow_definition.create_input_invalid": "审批流程新增资料有误,请检查名称、业务类型及1至20个审批步骤。", + "workflow_definition.update_input_invalid": "审批流程修改内容为空或格式有误,请检查步骤和人员配置后保存。", + "workflow_definition.entity_type_invalid": "审批流程业务类型无效,请重新选择。", + "workflow_definition.feature_disabled": "审批流程管理功能尚未启用,请联系管理员确认。", + "workflow_definition.reimbursement_readonly": "报销审批流程由审批规则生成,请在审批规则中维护。", + "workflow_definition.not_editable": "已发布生效或已归档的审批流程不能直接修改,请先确认流程版本。", + "workflow_definition.archived_not_publishable": "已归档的审批流程不能发布,请选择可用草稿版本。", + "approval_rule.create_input_invalid": "审批规则新增资料有误,请检查金额范围、审批角色和优先级。", + "approval_rule.update_input_invalid": "审批规则修改资料有误,请检查金额范围、审批角色和优先级。", + "approval_rule.id_invalid": "审批规则标识无效,请刷新规则列表后重试。", + "approval_rule.amount_invalid": "审批金额范围超出可处理范围,请填写有效的上下限金额。", + "approval_rule.amount_range_invalid": "审批金额上限必须大于下限,请调整金额范围。", + "approval_rule.fallback_role_invalid": "负责人本人申请的兜底角色仅支持财务或财务经理,请重新选择。", + "approval_rule.empty_update": "尚未填写需要修改的审批规则内容,请修改后保存。", + "workflow.steps_invalid": "审批流程缺少有效步骤,请联系管理员检查流程配置。", + "workflow.business_role_unsupported": "审批流程包含不支持的业务审批角色,请联系管理员修正。", + "workflow.approval_rule_invalid": "审批规则配置无效,请联系管理员核对后重试。", + "workflow.approval_chain_empty": "审批规则未配置审批层级,请联系管理员补充。", + "workflow.fallback_role_unsupported": "负责人本人申请的兜底审批角色无效,请联系管理员核对。", + "workflow.fallback_role_is_self": "当前兜底审批角色由申请人本人持有,无法继续,请联系管理员安排符合要求的审批人。", + "workflow.fallback_holder_unavailable": "当前没有可处理的非本人兜底审批人,请联系管理员核对角色人员。", + "workflow.fallback_chain_empty": "本人申请调整后没有可用审批步骤,请联系管理员检查审批规则。", + "workflow.assignee_rule_invalid": "审批人分配规则无效,请联系管理员检查流程配置。", + "workflow.role_assignee_invalid": "审批步骤未配置有效的角色,请联系管理员修正。", + "workflow.user_assignee_invalid": "审批步骤指定的人员标识无效,请联系管理员重新配置。", + "workflow.manager_context_invalid": "无法确定申请人的主管关系,请联系管理员核对人员资料。", + "workflow.assignee_type_unsupported": "审批人分配方式不受支持,请联系管理员修正流程。", + "workflow.manager_unavailable": "无法找到可处理的主管审批人,请联系管理员检查汇报关系后重试。", + "workflow.assignee_resolution_invalid": "审批步骤未能解析出有效审批人,请联系管理员检查配置。", + "purchase_request.id_invalid": "采购申请标识无效,请刷新采购列表后重试。", + "purchase_attachment.id_invalid": "采购附件标识无效,请刷新附件列表后重试。", + "purchase_request.query_invalid": "采购筛选条件有误,请检查状态、页码和归属信息。", + "purchase_request.update_input_invalid": "采购修改资料格式有误,请检查字段、明细和交付日期。", + "purchase_request.archive_input_invalid": "采购归档资料有误,请填写采购单号并检查合同号和附件清单。", + "purchase_request.precheck_blocked": "采购预检未通过,请查看预检结果并处理阻断项后提交。", + "purchase_request.quantity_invalid": "采购明细数量超出可处理范围,请填写有效数量。", + "purchase_request.quantity_too_small": "采购明细数量不得小于0.01,请修正数量后保存。", + "purchase_request.unit_price_invalid": "采购明细单价超出可处理范围,请填写有效单价。", + "purchase_request.unit_price_negative": "采购明细单价不能为负数,请修正后保存。", + "purchase_request.company_not_found": "采购所属公司不存在,请刷新后重新选择。", + "purchase_request.company_inactive": "采购所属公司已停用或归档,请选择可用公司。", + "purchase_request.cost_center_not_found": "采购成本中心不存在,请刷新后重新选择。", + "purchase_request.cost_center_inactive": "采购成本中心已停用或归档,请选择可用成本中心。", + "purchase_request.cost_center_company_mismatch": "采购成本中心不属于所选公司,请核对归属信息。", + "purchase_request.supplier_not_found": "采购供应商不存在,请刷新后重新选择。", + "purchase_request.supplier_inactive": "采购供应商已停用或归档,请选择可用供应商。", + "purchase_request.supplier_company_mismatch": "采购供应商不适用于所选公司,请核对供应商归属。", + "purchase_request.document_category_not_found": "采购单据分类不存在,请刷新后重新选择。", + "purchase_request.document_category_type_invalid": "所选单据分类不适用于采购申请,请重新选择。", + "purchase_request.document_category_inactive": "采购单据分类已停用或归档,请选择可用分类。", + "purchase_request.budget_pool_not_found": "所选采购预算池不存在,请刷新后重新选择。", + "purchase_request.budget_pool_inactive": "所选采购预算池不可用,请联系财务核对预算。", + "purchase_request.project_link_disabled": "采购项目关联功能尚未启用,请联系管理员确认。", + "purchase_request.project_archived": "已归档项目不能新增采购关联,请选择可用项目。", + "purchase_attachment.download_input_invalid": "采购附件下载参数有误,请检查下载理由后重试。", + "purchase_attachment.delete_input_invalid": "删除采购附件需要2至200字的理由,请填写后重试。", + "purchase_attachment.download_blocked": "采购附件已被隔离或未通过安全校验,无法下载,请联系管理员核查。", + "purchase_request.not_editable": "只有草稿采购申请可以修改,请刷新确认申请状态。", + "purchase_request.workflow_missing": "采购审批流程尚未配置,请联系管理员配置后提交。", + "purchase_request.approver_unavailable": "采购申请当前没有可处理的审批人,请联系管理员核对审批配置后重试。", + "purchase_request.not_submittable": "只有草稿采购申请可以提交,请刷新确认申请状态。", + "purchase_request.company_required": "提交采购申请前,请选择所属公司。", + "purchase_request.cost_center_required": "提交采购申请前,请选择成本中心。", + "purchase_request.supplier_required": "提交采购申请前,请选择供应商。", + "purchase_request.document_category_required": "提交采购申请前,请选择单据分类。", + "purchase_request.items_required": "提交采购申请前,请至少添加一条采购明细。", + "purchase_request.budget_context_mismatch": "所选预算池与当前采购归属或期间不匹配,请重新选择预算池。", + "purchase_request.budget_required": "采购申请缺少适用预算池,请联系财务配置预算后提交。", + "purchase_request.not_withdrawable": "只有审批中的采购申请可以撤回,请刷新确认申请状态。", + "purchase_request.not_archivable": "只有已批准的采购申请可以登记订单归档,请先完成审批。", + "purchase_attachment.not_editable": "只有草稿采购申请可以上传或删除附件,请刷新确认申请状态。", + "purchase_attachment.multipart_required": "请通过文件上传入口选择采购附件后重试。", + "purchase_attachment.single_file_required": "每次只能上传一个采购附件,请逐个上传。", + "purchase_attachment.field_invalid": "采购附件上传字段有误,请重新选择文件后上传。", + "purchase_attachment.too_large": "采购附件超过允许大小,请缩小文件后重新上传。", + "purchase_attachment.multipart_limit": "采购附件上传字段或分段过多,请重新选择文件后上传。", + "purchase_attachment.validation_failed": "采购附件未通过文件校验,请更换有效文件后重试。", + "purchase_attachment.file_required": "请选择采购附件后上传。", + "purchase_attachment.download_reason_required": "下载此采购附件需要填写理由,请补充后重试。", + "purchase_attachment.dangerous_filename": "采购附件文件名不符合安全要求,请使用不含路径或可执行扩展名的普通文件名。", + "purchase_attachment.corrupt_content": "采购附件内容损坏或格式无效,请重新导出文件后上传。", + "purchase_attachment.multiple_pages": "采购附件图片包含多帧或多页,请转换为单张图片后上传。", + "purchase_attachment.pixel_limit": "采购附件图片像素过大,请降低分辨率后上传。", + "purchase_attachment.type_mismatch": "采购附件实际内容与文件类型不一致,请重新导出正确格式后上传。", + "purchase_attachment.empty_file": "采购附件内容为空,请选择有内容的文件。", + "asset_movement.id_invalid": "资产标识无效,请刷新资产列表后重试。", + "asset_movement.assign_input_invalid": "资产领用资料有误,请选择领用人并检查备注。", + "asset_movement.return_input_invalid": "资产归还资料有误,请检查备注后重试。", + "asset_movement.transfer_input_invalid": "资产调拨资料有误,请检查接收人、位置和备注。", + "asset_movement.status_blocked": "已报废或归档的资产不能领用、归还或调拨,请确认资产状态。", + "asset_movement.not_found": "待流转的资产不存在,请刷新资产列表后重试。", + "asset_movement.employee_not_found": "资产接收人不存在,请刷新人员列表后重新选择。", + "asset_movement.employee_inactive": "资产接收人已不在职,请选择在职人员。", + "asset_movement.already_assigned": "资产已有领用人或未关闭的领用记录,请先归还或使用调拨。", + "asset_movement.not_assigned": "资产尚未领用,无需归还,请刷新确认资产记录。", + "asset_movement.use_return": "清空资产领用人请使用归还操作,不能通过调拨清空。", + "asset_movement.no_change": "本次调拨没有改变接收人或位置,请填写实际变更。", + "inventory_check.id_invalid": "盘点单标识无效,请刷新盘点列表后重试。", + "inventory_check.scope_id_invalid": "盘点范围标识无效,请重新选择公司或成本中心。", + "inventory_check.query_invalid": "盘点筛选条件有误,请检查状态和范围类型。", + "inventory_check.create_input_invalid": "盘点创建资料有误,请检查范围类型和范围值。", + "inventory_check.record_input_invalid": "盘点结果有误,请至少填写一项并检查资产、结果和备注。", + "inventory_check.scope_required": "请填写所选盘点范围对应的公司、成本中心或位置。", + "inventory_check.company_not_found": "盘点范围的公司不存在,请刷新后重新选择。", + "inventory_check.cost_center_not_found": "盘点范围的成本中心不存在,请刷新后重新选择。", + "inventory_check.not_found": "盘点单不存在,请刷新盘点列表后重试。", + "inventory_check.start_state_invalid": "只有草稿盘点单可以开始,请刷新确认盘点状态。", + "inventory_check.record_state_invalid": "只有进行中的盘点单可以记录结果,请先开始盘点或刷新状态。", + "inventory_check.complete_state_invalid": "只有进行中的盘点单可以完成,请刷新确认盘点状态。", + "inventory_check.asset_scope_mismatch": "资产不属于本次盘点范围,请核对公司、成本中心或位置。", + "inventory_check.duplicate_asset": "同批盘点结果包含重复资产,请合并后重新提交。", + "inventory_check.asset_not_found": "盘点结果中的资产不存在,请刷新资产列表后重新选择。", + "asset.category_id_invalid": "资产分类标识无效,请刷新分类列表后重试。", + "asset.id_invalid": "资产标识无效,请刷新资产列表后重试。", + "asset.category_query_invalid": "资产分类筛选条件有误,请检查启用和归档选项。", + "asset.query_invalid": "资产筛选条件有误,请检查分类、人员、归属和状态后重试。", + "asset.assignee_query_invalid": "领用人查询条件有误,请缩短关键词后重试。", + "asset.category_create_input_invalid": "资产分类资料不完整或格式有误,请检查编码、名称和单据分类。", + "asset.category_update_input_invalid": "资产分类修改内容为空或格式有误,请检查后保存。", + "asset.create_input_invalid": "资产资料不完整或格式有误,请检查必填项、人员和日期。", + "asset.update_input_invalid": "资产修改内容为空或格式有误,请检查字段和日期后保存。", + "asset.index_import_input_invalid": "脱敏资产索引格式有误,请检查条目、字段和数量后重新导入。", + "asset.unsafe_content": "资产资料包含不允许保存的文本、哈希或元数据,请脱敏并检查格式和长度。", + "asset.index_import_unsafe_content": "脱敏资产索引含不允许保存的文本或哈希,请检查并脱敏后重试。", + "asset.document_category_not_found": "所选单据分类不存在,请刷新后重新选择。", + "asset.document_category_type_invalid": "所选单据分类不适用于资产卡片,请选择资产卡片分类。", + "asset.document_category_inactive": "所选单据分类已停用或归档,请选择可用分类。", + "asset.category_not_found": "资产分类不存在,请刷新后重新选择。", + "asset.category_inactive": "资产分类已停用或归档,请选择可用分类。", + "asset.category_document_invalid": "资产分类关联的单据分类不可用,请联系管理员修正分类设置。", + "asset.company_not_found": "资产所属公司不存在,请刷新后重新选择。", + "asset.company_inactive": "资产所属公司已停用或归档,请选择可用公司。", + "asset.cost_center_not_found": "资产成本中心不存在,请刷新后重新选择。", + "asset.cost_center_inactive": "资产成本中心已停用或归档,请选择可用成本中心。", + "asset.cost_center_company_mismatch": "资产成本中心不属于所选公司,请核对公司与成本中心。", + "asset.purchase_not_found": "关联采购申请不存在,请刷新后重新选择。", + "asset.purchase_not_approved": "关联采购申请尚未批准,请在采购审批完成后登记资产。", + "asset.index_import_dimensions_required": "写入资产索引前,请填写资产分类、所属公司和成本中心。", + "asset.not_found": "资产不存在,请刷新资产列表后重试。", + "asset.assignee_not_found": "所选资产领用人不存在,请刷新人员列表后重新选择。", + "asset.assignee_inactive": "所选资产领用人已不在职,请选择在职人员。", + "asset.owner_not_found": "所选资产责任人不存在,请刷新人员列表后重新选择。", + "asset.owner_inactive": "所选资产责任人已不在职,请选择在职人员。", + "asset.keeper_not_found": "所选资产保管人不存在,请刷新人员列表后重新选择。", + "asset.keeper_inactive": "所选资产保管人已不在职,请选择在职人员。", + "contract_registry.create_input_invalid": "合同台账新增资料不完整或格式有误,请检查必填项、金额和日期。", + "contract_registry.update_input_invalid": "合同台账修改内容为空或格式有误,请检查后重新保存。", + "contract_registry.terminate_input_invalid": "请填写10至1000字的合同终止原因后重试。", + "contract_registry.correction_input_invalid": "请填写10至1000字的合同纠错说明后重试。", + "contract_registry.feature_disabled": "合同台账功能尚未启用,请联系管理员确认。", + "contract_registry.state_conflict": "合同状态或资料已被更新,请刷新后重试。", + "contract_registry.terminated_is_final": "已终止的合同不能修改,请查看原合同记录。", + "contract_registry.archived_fields_locked": "已归档合同不能直接修改资料,请通过纠错记录补充说明。", + "contract_registry.terminate_not_allowed": "仅生效或已到期的合同可以终止,请刷新确认合同状态。", + "contract_registry.terminate_conflict": "合同已发生变更,本次未完成终止,请刷新后确认。", + "contract_registry.correction_requires_archived": "仅已归档合同可以追加纠错记录,请先确认合同状态。", + "contract_registry.correction_state_conflict": "合同归档状态已变化,本次未保存纠错记录,请刷新后重试。", + "contract_registry.renew_not_allowed": "仅生效或已到期的合同可以续订,请刷新确认合同状态。", + "purchase_budget.reserve_already_exists": "该采购申请已有未释放的预算占用,请刷新查看,避免重复提交。", + "purchase_budget.pool_not_found": "采购预算池已不存在,请联系财务核对预算后重试。", + "travel_reconciliation.query_invalid": "差旅对账筛选条件无效,请检查日期、状态和分页后重试。", + "travel_reconciliation.create_input_invalid": "对账批次资料不完整或格式有误,请检查公司、成本中心和期间后重试。", + "travel_reconciliation.period_reversed": "对账开始日期不能晚于结束日期,请调整对账期间。", + "travel_reconciliation.company_not_found": "对账公司不存在,请刷新公司列表后重新选择。", + "travel_reconciliation.cost_center_not_found": "对账成本中心不存在,请刷新列表后重新选择。", + "travel_reconciliation.cost_center_company_mismatch": "对账成本中心与所选公司不匹配,请重新选择。", + "travel_reconciliation.empty_scope": "所选范围没有可对账的订单或交易,请检查期间和公司范围后重试。", + "travel_reconciliation.batch_id_invalid": "对账批次标识无效,请刷新批次列表后重新选择。", + "travel_reconciliation.item_id_invalid": "对账条目标识无效,请刷新批次详情后重新选择。", + "travel_reconciliation.confirm_input_invalid": "对账确认备注格式有误,请检查填写内容后重新确认。", + "travel_reconciliation.batch_not_editable": "对账批次已不可修改,请刷新并确认批次状态。", + "travel_reconciliation.item_not_pending": "该对账条目当前不是待确认状态,请刷新后核对处理结果。", + "travel_reconciliation.item_not_confirmed": "只有已确认的对账条目可以重开,请刷新并确认条目状态。", + "travel_reconciliation.matched_item_not_reopenable": "自动匹配成功的对账条目不能重开,请检查关联订单和交易记录。", + "travel_reconciliation.batch_has_pending_items": "对账批次仍有未确认条目,请先处理全部条目后再结案。", + "travel_import.sync_disabled": "差旅同步尚未启用,请联系管理员确认后再导入。", + "travel_payment.budget_pool_not_found": "关联预算池不存在,请联系财务检查预算配置后重试。", + "travel_order.import_input_invalid": "差旅订单导入列表格式有误,请检查数据并确保每批为1至100项。", + "travel_order.import_empty": "未识别到有效的差旅订单记录,请检查必填字段和格式后重新导入。", + "travel_order.id_invalid": "差旅订单标识无效,请刷新列表后重新选择。", + "travel_authorization.import_input_invalid": "差旅授权导入列表格式有误,请检查数据并确保每批为1至100项。", + "travel_authorization.import_empty": "未识别到有效的差旅授权记录,请检查必填字段和格式后重新导入。", + "travel_authorization.query_invalid": "差旅授权筛选条件无效,请检查筛选项和分页后重试。", + "travel_transaction.import_input_invalid": "差旅交易导入列表格式有误,请检查数据并确保每批为1至100项。", + "travel_transaction.import_empty": "未识别到有效的差旅交易记录,请检查必填字段和格式后重新导入。", + "travel_transaction.query_invalid": "差旅交易筛选条件无效,请检查筛选项和分页后重试。", + "travel_transaction.id_invalid": "差旅交易标识无效,请刷新列表后重新选择。", + "travel_order.payment_input_invalid": "差旅付款记录格式有误,请检查付款日期、渠道和凭证号后重试。", + "travel_order.invalid_pay_status": "该差旅订单当前不能标记付款,请刷新并确认订单状态。", + "travel_order.already_paid_conflict": "该差旅订单已记录付款,本次付款资料与原记录不同,请核对原付款记录。", + "travel_order.settled_transaction_required": "该订单缺少金额和币种一致的已结算扣款交易,请先核对并补齐交易记录。", + "travel_transaction.freeze_input_invalid": "冻结建议的原因或备注格式有误,请选择有效原因后重新提交。", + "travel_request.query_invalid": "差旅申请筛选条件无效,请检查状态、分页和筛选项后重试。", + "travel_request.id_invalid": "差旅申请标识无效,请刷新列表后重新选择。", + "travel_request.create_input_invalid": "差旅申请资料不完整或格式有误,请检查事由、日期、费用及行程后重新创建。", + "travel_request.update_input_invalid": "差旅申请修改内容格式有误,请检查填写内容后重试。", + "travel_request.project_link_input_invalid": "项目关联参数无效,请重新选择项目或清除关联后重试。", + "travel_request.segment_time_invalid": "行程出发或到达时间无法识别,请填写有效时间。", + "travel_request.amount_invalid": "差旅预估金额无法识别或超出支持范围,请填写有效金额。", + "travel_request.amount_negative": "差旅预估金额不能为负数,请检查金额后重试。", + "travel_request.segment_time_reversed": "行程到达时间不能早于出发时间,请调整行程时间。", + "travel_request.date_range_reversed": "差旅结束日期不能早于开始日期,请调整出差日期。", + "travel_request.precheck_blocked": "差旅申请未通过费用预检,请检查预算与费用标准提示,处理后重新提交。", + "travel_request.company_unavailable": "所选公司不存在、已停用或已归档,请选择有效公司。", + "travel_request.cost_center_unavailable": "所选成本中心不存在、已停用或已归档,请选择有效成本中心。", + "travel_request.cost_center_company_mismatch": "成本中心与所选公司不匹配,请重新选择同一公司的成本中心。", + "travel_request.update_empty": "尚未填写差旅修改内容,请修改至少一项后保存。", + "travel_request.only_draft_editable": "只有草稿差旅申请可以修改,请刷新并确认申请状态。", + "travel_request.only_draft_submittable": "只有草稿差旅申请可以提交,请刷新并确认申请状态,避免重复提交。", + "travel_request.segments_required": "差旅申请缺少行程,请补充至少一段行程后提交。", + "travel_request.workflow_missing": "差旅审批流程尚未配置,请联系管理员配置后重新提交。", + "travel_request.approver_unavailable": "差旅审批流程没有可处理的审批人,请联系管理员检查审批人配置后重试。", + "travel_request.project_linking_disabled": "差旅项目关联功能暂不可用,请联系管理员确认开放范围后重试。", + "travel_request.only_reviewing_withdrawable": "只有审批中的差旅申请可以撤回,请刷新并确认申请状态。", + "project.create_input_invalid": "项目新增内容不完整或格式有误,请检查填写内容后重试。", + "project.update_input_invalid": "项目修改内容不完整或格式有误,请检查填写内容后重试。", + "project.member_create_input_invalid": "项目成员新增内容不完整或格式有误,请检查填写内容后重试。", + "project.member_update_input_invalid": "项目成员修改内容不完整或格式有误,请检查填写内容后重试。", + "project.milestone_create_input_invalid": "里程碑新增内容不完整或格式有误,请检查填写内容后重试。", + "project.milestone_update_input_invalid": "里程碑修改内容不完整或格式有误,请检查填写内容后重试。", + "project.task_create_input_invalid": "项目任务新增内容不完整或格式有误,请检查填写内容后重试。", + "project.task_update_input_invalid": "项目任务修改内容不完整或格式有误,请检查填写内容后重试。", + "project.risk_create_input_invalid": "项目风险新增内容不完整或格式有误,请检查填写内容后重试。", + "project.risk_update_input_invalid": "项目风险修改内容不完整或格式有误,请检查填写内容后重试。", + "project.communication_log_create_input_invalid": "项目沟通记录新增内容不完整或格式有误,请检查填写内容后重试。", + "project.communication_log_update_input_invalid": "项目沟通记录修改内容不完整或格式有误,请检查填写内容后重试。", + "project.closure_checklist_update_input_invalid": "项目收口项修改内容不完整或格式有误,请检查填写内容后重试。", + "project.delivery_create_input_invalid": "项目交付记录新增内容不完整或格式有误,请检查填写内容后重试。", + "project.id_invalid": "项目标识无效,请刷新项目列表后重新选择。", + "project.member_id_invalid": "项目成员标识无效,请刷新项目详情后重新选择。", + "project.milestone_id_invalid": "里程碑标识无效,请刷新项目详情后重新选择。", + "project.task_id_invalid": "项目任务标识无效,请刷新项目详情后重新选择。", + "project.risk_id_invalid": "项目风险标识无效,请刷新项目详情后重新选择。", + "project.communication_log_id_invalid": "项目沟通记录标识无效,请刷新项目详情后重新选择。", + "project.closure_checklist_id_invalid": "项目收口项标识无效,请刷新项目详情后重新选择。", + "project.feature_disabled": "项目功能暂不可用,请联系管理员确认开放范围后重试。", + "project.linking_disabled": "项目关联功能暂不可用,请联系管理员确认开放范围后重试。", + "project.communication_disabled": "项目沟通记录功能暂不可用,请联系管理员确认开放范围后重试。", + "project.closure_disabled": "项目收口清单功能暂不可用,请联系管理员确认开放范围后重试。", + "project.document_refs_disabled": "项目资料引用功能暂不可用,请联系管理员确认开放范围后重试。", + "project.query_invalid": "项目筛选条件无效,请检查状态和筛选项后重试。", + "project.waiver_note_required": "豁免项目收口项需要填写理由,请补充说明后保存。", + "expense_standard.id_invalid": "费用标准标识无效,请刷新列表后重新选择。", + "expense_standard.query_invalid": "费用标准筛选条件无效,请检查筛选项后重试。", + "expense_standard.create_input_invalid": "费用标准资料不完整或格式有误,请检查必填项后重新创建。", + "expense_standard.update_input_invalid": "费用标准修改内容格式有误,请检查填写内容后重试。", + "expense_standard.update_empty": "尚未填写费用标准修改内容,请修改至少一项后保存。", + "expense_type.id_invalid": "费用类型标识无效,请刷新列表后重新选择。", + "expense_type.query_invalid": "费用类型筛选条件无效,请检查筛选项后重试。", + "expense_type.create_input_invalid": "费用类型资料不完整或格式有误,请检查必填项后重新创建。", + "expense_type.update_input_invalid": "费用类型修改内容格式有误,请检查填写内容后重试。", + "expense_type.update_empty": "尚未填写费用类型修改内容,请修改至少一项后保存。", + "expense_type.code_duplicate": "费用类型编码已存在,请使用其他编码。", + "leave_type.id_invalid": "假别标识无效,请刷新列表后重新选择。", + "leave_type.query_invalid": "假别筛选条件无效,请检查筛选项后重试。", + "leave_type.create_input_invalid": "假别资料不完整或格式有误,请检查必填项后重新创建。", + "leave_type.update_input_invalid": "假别修改内容格式有误,请检查填写内容后重试。", + "leave_type.code_duplicate": "假别编码已存在,请使用其他编码。", + "reimbursement_template.id_invalid": "报销模板标识无效,请刷新列表后重新选择。", + "reimbursement_template.query_invalid": "报销模板筛选条件无效,请检查筛选项后重试。", + "reimbursement_template.create_input_invalid": "报销模板资料不完整或格式有误,请检查必填项后重新创建。", + "reimbursement_template.update_input_invalid": "报销模板修改内容格式有误,请检查填写内容后重试。", + "reimbursement_template.update_empty": "尚未填写报销模板修改内容,请修改至少一项后保存。", + "expense_standard.amount_not_positive": "费用标准金额须大于零,请填写有效的金额。", + "expense_standard.expense_type_not_found": "所选费用类型不存在,请刷新费用类型列表后重新选择。", + "expense_standard.department_required": "部门费用标准需要指定部门,请选择部门后保存。", + "expense_standard.department_unavailable": "所选部门不存在或已归档,请选择有效部门。", + "expense_standard.scope_conflict": "该费用类型在相同范围已有标准,请修改已有标准或选择其他范围。", + "reimbursement_template.expense_types_unavailable": "模板中有不存在或已停用的费用类型,请重新选择有效类型后保存。", + "holiday_calendar.id_invalid": "节假日日历标识无效,请刷新列表后重新选择。", + "holiday_calendar.query_invalid": "节假日日历筛选条件无效,请检查筛选项后重试。", + "holiday_calendar.create_input_invalid": "日历资料不完整或格式有误,请检查编码和名称后重新创建。", + "holiday_calendar.update_input_invalid": "日历修改内容格式有误,请检查填写内容后重试。", + "holiday_calendar.update_empty": "尚未填写日历修改内容,请修改至少一项后保存。", + "holiday_calendar.days_input_invalid": "日历日期列表无效,请检查真实日期、日期类型和名称,且一次不超过500项。", + "holiday_calendar.date_duplicate": "日历日期列表有重复日期,请每个日期只保留一项后重试。", + "holiday_calendar.code_duplicate": "日历编码已存在,请使用其他编码。", + "holiday_calendar.archived_locked": "该日历已归档,无法修改资料或日期;请选择有效日历。", + "holiday_calendar.bound_as_default": "该日历仍是公司的默认日历,请先调整默认日历后再归档。", + "legal_entity.id_invalid": "公司标识无效,请刷新列表后重新选择。", + "legal_entity.query_invalid": "公司筛选条件无效,请检查筛选项后重试。", + "legal_entity.create_input_invalid": "公司资料不完整或格式有误,请检查必填项后重新创建。", + "legal_entity.update_input_invalid": "公司修改内容格式有误,请检查填写内容后重试。", + "legal_entity.update_empty": "尚未填写公司修改内容,请修改至少一项后保存。", + "legal_entity.code_duplicate": "公司编码已存在,请使用其他编码。", + "legal_entity.archived_locked": "该公司已归档,无法修改;请返回列表选择有效记录。", + "cost_center.id_invalid": "成本中心标识无效,请刷新列表后重新选择。", + "cost_center.query_invalid": "成本中心筛选条件无效,请检查筛选项后重试。", + "cost_center.create_input_invalid": "成本中心资料不完整或格式有误,请检查必填项后重新创建。", + "cost_center.update_input_invalid": "成本中心修改内容格式有误,请检查填写内容后重试。", + "cost_center.update_empty": "尚未填写成本中心修改内容,请修改至少一项后保存。", + "cost_center.code_duplicate": "成本中心编码已存在,请使用其他编码。", + "cost_center.archived_locked": "该成本中心已归档,无法修改;请返回列表选择有效记录。", + "document_category.id_invalid": "单据分类标识无效,请刷新列表后重新选择。", + "document_category.query_invalid": "单据分类筛选条件无效,请检查筛选项后重试。", + "document_category.create_input_invalid": "单据分类资料不完整或格式有误,请检查必填项后重新创建。", + "document_category.update_input_invalid": "单据分类修改内容格式有误,请检查填写内容后重试。", + "document_category.update_empty": "尚未填写单据分类修改内容,请修改至少一项后保存。", + "document_category.code_duplicate": "单据分类编码已存在,请使用其他编码。", + "document_category.archived_locked": "该单据分类已归档,无法修改;请返回列表选择有效记录。", + "supplier.id_invalid": "供应商标识无效,请刷新列表后重新选择。", + "supplier.query_invalid": "供应商筛选条件无效,请检查筛选项后重试。", + "supplier.create_input_invalid": "供应商资料不完整或格式有误,请检查必填项后重新创建。", + "supplier.update_input_invalid": "供应商修改内容格式有误,请检查填写内容后重试。", + "supplier.update_empty": "尚未填写供应商修改内容,请修改至少一项后保存。", + "supplier.code_duplicate": "供应商编码已存在,请使用其他编码。", + "supplier.archived_locked": "该供应商已归档,无法修改;请返回列表选择有效记录。", + "legal_entity.reference_not_found": "所选公司不存在,请刷新公司列表后重新选择。", + "legal_entity.reference_archived": "所选公司已归档,请选择有效公司。", + "legal_entity.has_cost_centers": "该公司仍有未归档的成本中心,请先处理成本中心后再归档公司。", + "cost_center.owner_unavailable": "所选负责人不存在或已非在职状态,请选择在职员工。", + "cost_center.parent_self_forbidden": "成本中心不能作为自身的上级,请重新选择上级成本中心。", + "cost_center.parent_not_found": "所选上级成本中心不存在,请刷新列表后重新选择。", + "cost_center.parent_archived": "所选上级成本中心已归档,请选择有效的上级成本中心。", + "cost_center.parent_company_mismatch": "上级成本中心与当前成本中心须属于同一公司,请重新选择。", + "cost_center.parent_cycle_forbidden": "所选上级成本中心会形成循环层级,请选择其他上级。", + "cost_center.has_children": "该成本中心仍有未归档的子级,请先处理子级后再归档。", + "employee.contract_attachment_download_blocked": "此合同附件未通过文件安全校验,暂不能下载,请联系有权限的管理员核查并重新上传有效附件。", + "employee.id_invalid": "员工或档案记录标识无效,请刷新列表后重新选择。", + "employee.lifecycle_disabled": "员工资料与生命周期管理暂不可用,请联系管理员确认功能开放范围。", + "employee.hr_sync_disabled": "企业微信人事主数据同步暂未启用,请联系管理员确认同步安排。", + "employee.hr_sync_unavailable": "企业微信人事主数据同步所需组件不可用,请联系管理员检查系统组件后重试。", + "employee.directory_sync_unavailable": "企业微信通讯录同步所需组件不可用,请联系管理员检查系统组件后重试。", + "employee.contract_date_range_invalid": "员工合同结束日期不能早于开始日期,请调整合同日期后保存。", + "employee.contract_termination_date_invalid": "员工合同终止日期不能早于开始日期,请核对终止日期后保存。", + "employee.training_date_range_invalid": "培训结束日期不能早于开始日期,请调整培训日期后保存。", + "employee.certificate_date_range_invalid": "证书到期日期不能早于取得日期,请核对证书日期后保存。", + "employee.contract_number_duplicate": "已有相同编号的员工合同,请核对合同编号并避免重复登记。", + "employee.active_contract_delete_forbidden": "有效中的员工合同不能直接删除,请先按实际情况登记终止,再填写删除理由。", + "employee.self_profile_fields_forbidden": "本人资料中包含无权修改的字段,请仅修改允许的个人资料,其他变更联系管理员办理。", + "employee.hr_sync_input_invalid": "人事同步参数无效,请检查预览或执行模式、人员范围和数量限制后重试。", + "employee.hr_sync_query_invalid": "人事同步记录筛选条件无效,请重新选择有效的每页条数。", + "employee.directory_retry_input_invalid": "通讯录同步重试参数无效,请重新选择有效的同步操作后重试。", + "employee.contract_create_input_invalid": "员工合同资料无效,请检查编号、合同类型和日期后登记。", + "employee.contract_update_input_invalid": "员工合同修改内容无效,请检查合同类型、日期和需要修改的字段。", + "employee.contract_delete_input_invalid": "请填写有效的合同删除理由后重试。", + "employee.training_create_input_invalid": "培训资料无效,请检查名称、类型、状态和日期后登记。", + "employee.training_update_input_invalid": "培训修改内容无效,请检查类型、状态、日期和需要修改的字段。", + "employee.certificate_create_input_invalid": "证书资料无效,请检查名称、颁发机构和日期后登记。", + "employee.certificate_update_input_invalid": "证书修改内容无效,请检查名称、机构、日期和需要修改的字段。", + "employee.profile_input_invalid": "员工资料修改内容无效,请检查日期、联系方式和需要修改的字段后保存。", + "employee.onboarding_input_invalid": "入职办理资料无效,请检查生效日期、试用期结束日期和备注后重试。", + "employee.regularize_input_invalid": "转正办理资料无效,请检查生效日期和备注后重试。", + "employee.transfer_input_invalid": "调动办理资料无效,请检查生效日期、目标部门、岗位或直属主管后重试。", + "employee.offboarding_input_invalid": "离职办理资料无效,请检查生效日期、最后工作日期和备注后重试。", + "employee.avatar_multipart_required": "头像上传方式无效,请通过头像上传入口重新选择图片。", + "employee.avatar_file_required": "请选择头像图片后上传。", + "employee.avatar_field_invalid": "头像上传字段无效,请刷新页面并重新选择头像图片。", + "employee.avatar_type_invalid": "头像仅支持JPEG、PNG或WebP图片,请选择支持的图片格式。", + "employee.avatar_file_empty": "头像文件为空,请选择有内容的图片后上传。", + "employee.avatar_file_too_large": "头像文件不能超过2MiB,请压缩图片后重新上传。", + "employee.avatar_image_invalid": "头像文件无法解析为图片,请重新导出JPEG、PNG或WebP图片后上传。", + "employee.contract_attachment_multipart_required": "合同附件上传方式无效,请通过附件上传入口重新选择文件。", + "employee.contract_attachment_file_required": "请选择员工合同附件后上传。", + "employee.contract_attachment_field_invalid": "合同附件上传字段无效,请刷新页面并重新选择附件。", + "employee.contract_attachment_type_invalid": "员工合同附件仅支持PDF、JPEG或PNG,请选择支持的文件格式。", + "employee.contract_attachment_file_empty": "合同附件为空,请选择有内容的文件后上传。", + "employee.contract_attachment_file_too_large": "员工合同附件不能超过10MiB,请缩小文件后重新上传。", + "employee.contract_attachment_content_invalid": "合同附件内容与声明格式不符或未通过文件检查,请重新导出有效PDF、JPEG或PNG后上传。", + "user.id_invalid": "员工账号标识无效,请刷新列表后重新选择。", + "user.list_query_invalid": "员工列表筛选条件无效,请检查页码、每页条数和查看范围后重试。", + "user.password_policy_failed": "密码需至少12位,包含大小写字母和数字,并避免常见弱密码,请调整后重试。", + "user.sensitive_input_invalid": "敏感资料填写无效,请核对所选银行、账号格式或证件资料后保存。", + "user.sensitive_update_empty": "没有可保存的敏感资料修改,请填写需要修改的字段后保存。", + "user.department_not_found": "所选员工部门已不存在,请刷新后重新选择部门。", + "user.department_archived": "所选员工部门已归档,请选择可用部门或联系管理员核查。", + "user.manager_self_forbidden": "员工不能担任自己的直属主管,请选择其他在职人员;批量设置时主管不能在所选员工中。", + "user.manager_not_found": "所选直属主管已不存在,请刷新后重新选择。", + "user.manager_inactive": "所选直属主管当前不在职,请选择其他在职人员。", + "user.unique_conflict": "员工资料与已有记录重复,本次操作未完成,请核对工号、邮箱等唯一资料后重试。", + "user.bank_reveal_input_invalid": "请填写2至500字的查看银行账号理由后重试。", + "user.bank_institution_unavailable": "所选银行已停用、归档或不存在,请刷新后重新选择可用银行。", + "user.self_disable_forbidden": "不能停用或归档当前登录账号,请从本次操作中移除自己。", + "user.reset_password_input_invalid": "重置密码内容无效,请填写有效的新密码,或留空使用系统生成的临时密码。", + "user.self_role_change_forbidden": "不能修改当前登录账号自己的角色,请由其他有权限的管理员处理。", + "user.roles_input_invalid": "角色设置无效,请至少选择一个可用角色后保存。", + "user.batch_transfer_input_invalid": "批量调部门资料无效,请选择员工和目标部门后重试。", + "user.batch_manager_input_invalid": "批量设置主管资料无效,请选择员工和直属主管后重试。", + "user.batch_toggle_input_invalid": "批量启用或停用的员工列表无效,请重新选择员工后重试。", + "user.batch_limit_exceeded": "每次最多处理50名不同员工,请缩小选择范围后分批操作。", + "user.lifecycle_disabled": "员工生命周期管理暂不可用,请联系管理员确认功能开放范围。", + "user.generated_password_unavailable": "系统未能生成符合安全要求的临时密码,本次操作未完成,请联系管理员核查。", + "department.id_invalid": "部门标识无效,请刷新列表后重新选择。", + "department.query_invalid": "部门筛选条件无效,请重新选择是否包含已归档部门。", + "department.create_input_invalid": "部门资料无效,请检查部门名称和上级部门后创建。", + "department.update_input_invalid": "部门修改内容无效,请检查部门名称和上级部门后保存。", + "department.update_empty": "没有可保存的部门修改,请填写名称或调整上级部门后保存。", + "department.parent_not_found": "所选上级部门已不存在,请刷新后重新选择上级部门。", + "department.parent_archived": "所选上级部门已归档,请选择可用部门。", + "department.archived_locked": "该部门已归档,无法修改,请刷新确认当前部门状态。", + "department.parent_self_forbidden": "部门不能把自己设为上级,请选择其他部门或清空上级。", + "department.parent_cycle_forbidden": "所选上级部门会形成层级循环,请选择当前部门下级以外的部门。", + "department.children_exist": "该部门仍有下级部门,暂不能归档,请先调整下级部门的归属。", + "department.active_employees_exist": "该部门仍有在职员工,暂不能归档,请先办理员工部门调整。", + "budget.company_required": "选择成本中心时必须同时指定所属公司,请补齐公司后重试。", + "budget.scope_mixed": "预算范围不能同时使用部门和公司或成本中心,请选择一种范围后重试。", + "budget.cost_center_required": "公司范围预算需要指定成本中心,请补齐该公司的成本中心后重试。", + "budget.pool_changed": "预算池状态或适用范围已变化,请刷新后重新提交。", + "budget.pool_ambiguous": "同一成本中心匹配到多个有效预算,无法确定使用哪一个,请联系预算管理员核查重叠的预算期间。", + "budget.effective_date_invalid": "用于匹配预算的业务日期无效,请核对单据日期;仍无法处理时联系管理员。", + "budget_rule.id_invalid": "预算控制规则标识无效,请刷新列表后重新选择。", + "budget_rule.query_invalid": "预算控制规则筛选条件无效,请重新选择业务类型和启用状态。", + "budget_rule.create_input_invalid": "预算控制规则资料无效,请检查名称、业务类型、控制方式、优先级和适用范围后创建。", + "budget_rule.update_input_invalid": "预算控制规则修改内容无效,请检查要修改的字段并填写有效内容后保存。", + "budget_rule.company_required": "本次预算规则填写了成本中心,请同时填写所属公司后保存。", + "budget_rule.expense_type_unsupported": "指定费用类型时,本次提交的业务类型必须为报销,请同时选择报销类型或移除费用类型后保存。", + "budget.id_invalid": "预算标识无效,请刷新预算列表后重新选择。", + "budget.query_invalid": "预算筛选条件无效,请检查状态、所属范围和分页后重试。", + "budget.create_input_invalid": "预算资料不完整或格式有误,请检查编号、名称、期间、金额和预警比例后创建。", + "budget.update_input_invalid": "预算修改内容无效,请检查名称、金额、预警比例和状态后保存。", + "budget.update_empty": "没有可保存的预算修改,请修改资料后再保存。", + "budget.period_invalid": "预算结束日期不能早于开始日期,请调整预算期间后重试。", + "budget.department_not_found": "所选预算部门已不存在,请刷新后重新选择部门。", + "budget.department_archived": "所选预算部门已归档,请选择可用部门或联系管理员核查。", + "budget.legal_entity_not_found": "所选预算公司已不存在,请刷新后重新选择公司。", + "budget.legal_entity_unavailable": "所选预算公司已停用或归档,请选择可用公司或联系管理员核查。", + "budget.cost_center_not_found": "所选预算成本中心已不存在,请刷新后重新选择成本中心。", + "budget.cost_center_unavailable": "所选预算成本中心已停用或归档,请选择可用成本中心或联系管理员核查。", + "budget.cost_center_company_mismatch": "所选成本中心不属于当前预算公司,请重新选择匹配的公司和成本中心。", + "performance.id_invalid": "绩效周期或员工标识无效,请刷新列表后重新选择。", + "performance.cycle_create_input_invalid": "绩效周期资料无效,请检查名称、周期类型和起止日期后创建。", + "performance.cycle_update_input_invalid": "绩效周期修改内容无效,请检查名称、类型、日期和状态,并填写需要修改的内容。", + "performance.review_input_invalid": "绩效评价内容无效,请检查总分和各维度评分(1至5分,以0.5分递增)、重复维度及评语长度。", + "performance.cycle_date_range_invalid": "绩效周期结束日期不能早于开始日期,请调整起止日期后重试。", + "performance.disabled": "绩效管理功能暂不可用,请联系管理员确认功能开放范围。", + "performance.active_cycle_exists": "已有进行中的绩效周期,请先核对并结束该周期,再激活其他周期。", + "performance.completed_cycle_locked": "该绩效周期已结束,无法再修改,请刷新查看当前周期状态。", + "performance.active_cycle_only_complete": "进行中的绩效周期只能结束,不能修改资料,请保留周期资料后执行结束操作。", + "performance.draft_cannot_complete": "草稿绩效周期不能直接结束,请先激活周期并完成评价,再结束周期。", + "performance.employee_inactive": "该员工当前不在职,不能录入绩效评价,请刷新并核对员工状态。", + "performance.active_cycle_required": "只有进行中的绩效周期可以录入评价,请刷新并选择进行中的周期。", + "payroll.id_invalid": "薪资记录或员工标识无效,请刷新列表后重新选择。", + "payroll.item_create_input_invalid": "薪资项资料不完整或格式有误,请检查名称、收入或扣除类型及排序后创建。", + "payroll.item_update_input_invalid": "薪资项修改内容无效,请检查名称、类型和排序后保存。", + "payroll.profile_input_invalid": "薪资档案资料无效,请检查生效日期、薪资项和金额,并移除重复薪资项。", + "payroll.payslip_update_input_invalid": "工资条修改内容无效,请检查薪资项和金额,并移除重复薪资项。", + "payroll.amount_invalid": "薪资金额格式无效,请填写不小于零且最多两位小数的金额。", + "payroll.item_name_duplicate": "已有同名薪资项,请更换名称后保存。", + "payroll.item_not_found": "部分薪资项已不存在,请刷新后重新选择薪资项。", + "payroll.month_already_generated": "该月工资条已生成,请查看现有工资条,避免重复生成。", + "payroll.active_employees_missing": "没有可生成工资条的在职员工,请先核对员工状态。", + "payroll.enabled_items_missing": "没有启用的薪资项,请先设置并启用薪资项,再生成工资条。", + "payroll.profiles_missing": "部分员工缺少该月有效的薪资档案,请补齐档案和生效日期后再生成工资条。", + "payroll.payslip_not_editable": "此工资条已发放,无法继续修改,请刷新查看当前状态。", + "payroll.month_not_generated": "该月尚未生成工资条,请先生成并核对,再发放。", + "payroll.month_already_issued": "该月工资条已全部发放,无需重复操作,请刷新查看。", + "payroll.month_invalid": "工资月份参数无效,请重新选择有效的年和月后重试。", + "payroll.export_query_invalid": "工资导出条件无效,请检查月份和导出格式后重试。", + "attendance.month_query_invalid": "考勤月报筛选条件无效,请检查月份和所选员工后重试。", + "attendance.month_invalid": "考勤月份无效,请重新选择有效的年和月后重试。", + "user.import_file_too_large": "导入文件超过上传大小限制,请拆分或缩小文件后重试。", + "user.import_file_missing": "请选择员工导入文件后重试。", + "user.import_file_invalid": "导入文件格式损坏或无法解析,请重新导出为CSV或XLSX后再上传。", + "user.import_extension_unsupported": "员工导入仅支持CSV或XLSX文件,请使用导入模板重新保存。", + "user.import_mime_unsupported": "导入文件类型无法识别,请将表格另存为CSV或XLSX后重新上传。", + "user.import_headers_missing": "导入表格缺少必填列,请按模板补齐工号、姓名、邮箱和部门列。", + "user.import_file_empty": "导入表格没有员工数据,请在表头下填写员工资料后重试。", + "user.import_row_limit_exceeded": "每次最多导入500行员工数据,请拆分表格后分别预览和导入。", + "announcement.id_invalid": "公告标识无效,请刷新列表后重新选择。", + "announcement.create_input_invalid": "公告资料不完整或格式有误,请检查标题、内容和时间后重新创建。", + "announcement.update_input_invalid": "公告修改内容无效,请检查修改项及时间后重新保存。", + "announcement.acknowledgement_input_invalid": "请先勾选签收确认,再提交签收。", + "announcement.query_invalid": "公告筛选条件无效,请刷新列表后重试。", + "announcement.disabled": "公告功能暂未启用,请联系管理员确认。", + "announcement.statement_required": "制度签收公告必须填写签收声明,请补充后保存。", + "announcement.acknowledged_content_locked": "此公告已有员工签收,内容变更请发布新版本。", + "announcement.acknowledgement_not_required": "此公告无需签收,请返回公告列表查看。", + "announcement.statement_missing": "此公告缺少签收声明,暂时无法签收,请联系管理员补充。", + "employee_directory.query_invalid": "员工筛选条件无效,请检查入职日期、人员状态及导出格式后重试。", + "employee_directory.hire_date_range_invalid": "入职结束日期不能早于开始日期,请重新选择筛选范围。", + "contract_registry.query_invalid": "合同筛选条件无效,请检查到期日期、合同状态及导出格式后重试。", + "contract_registry.expiry_date_range_invalid": "合同到期结束日期不能早于开始日期,请重新选择筛选范围。", + "contract_registry.expiring_query_invalid": "即将到期的筛选天数无效,请输入1至365之间的整数。", + "presence_history.date_range_invalid": "在岗历史结束日期不能早于开始日期,请重新选择日期范围。", + "presence_history.date_range_too_large": "在岗历史每次最多查询31天,请缩小日期范围后重试。", + "dashboard.query_invalid": "统计筛选条件无效,请检查日期、部门和显示数量后重试。", + "dashboard.date_range_invalid": "统计结束日期不能早于开始日期,请重新选择日期范围。", + "ap_operations.disabled": "付款工作台暂未启用,请联系管理员确认。", + "ap_operations.ledger_query_invalid": "财务台账筛选条件无效,请检查日期、状态和分页后重试。", + "user.create_input_invalid": "账号资料不完整或格式有误,请检查姓名、邮箱、入职日期和角色后重新创建。", + "user.update_input_invalid": "账号修改内容格式有误,请检查联系方式、入职日期和角色后重新保存。", + "user.employee_no_immutable": "工号创建后不能修改,请保留原工号;如登记有误,请联系管理员核查。", + "user.update_empty": "没有可保存的账号修改,请修改资料后再保存。", + "user.import_rows_invalid": "导入表格中仍有错误行,本次未导入任何员工,请先预览并修正标出的行后重试。", + "notification.list_query_invalid": "通知筛选条件无效,请检查页码、每页条数和未读筛选后重试。", + "notification.delivery_query_invalid": "通知投递筛选条件无效,请重新选择通知、渠道或投递状态后重试。", + "notification.id_invalid": "通知或渠道标识无效,请刷新列表后重新选择。", + "notification.channel_input_invalid": "通知渠道资料不完整或格式有误,请检查名称、发送方式、渠道配置和消息模板。", + "notification.channel_name_duplicate": "已有同名通知渠道,请更换名称后保存。", + "notification.encryption_key_unavailable": "通知渠道加密配置不可用,请联系管理员检查原字段加密密钥后再保存;不要生成新密钥替代。", + "todo.input_invalid": "待办资料不完整或格式有误,请检查标题、负责人、截止日期和优先级后重试。", + "todo.id_invalid": "待办标识无效,请刷新列表后重新选择。", + "todo.disabled": "待办功能暂未启用,请联系管理员确认。", + "todo.query_invalid": "待办筛选条件无效,请检查状态、范围和负责人后重试。", + "portal.disabled": "工作台暂未启用,请联系管理员确认。", "workflow.task_already_processed": "此审批任务已处理,请刷新待办查看最新进度。", "workflow.entity_unavailable": "审批对应的单据不存在或当前不可处理,请联系管理员核查。", "workflow.self_approval_forbidden": "不能审批本人申请或代提交的单据,请由符合资格的其他审批人处理。", diff --git a/client/src/api/integrations.ts b/client/src/api/integrations.ts index ea29d420..601afb93 100644 --- a/client/src/api/integrations.ts +++ b/client/src/api/integrations.ts @@ -1,71 +1,5 @@ import { apiRequestData } from "./http"; -export type ErpMasterDataResourceType = "legal_entity" | "cost_center" | "supplier"; - -export type ErpGateStatus = { - envEnabled: boolean; - effectiveEnabled: boolean; - effectiveReason: string; - provider: string; - masterDataEndpointConfigured: boolean; - journalEndpointConfigured: boolean; - masterDataSyncCron: string | null; -}; - -export type ErpMasterDataSyncSummary = { - requested: number; - created: number; - updated: number; - archived: number; - skipped: number; - sampleSkipped: Array<{ key: string; reason: string }>; -}; - -export type ErpMasterDataSyncItem = { - resourceType: ErpMasterDataResourceType; - status: string; - lastStartedAt: string | null; - lastFinishedAt: string | null; - lastSuccessAt: string | null; - lastError: string | null; - lastResult: ErpMasterDataSyncSummary | null; -}; - -export type ErpMasterDataStatusResponse = { - gate: ErpGateStatus; - items: ErpMasterDataSyncItem[]; - triggerMode?: string; -}; - -export type ReimbursementJournalPreview = { - journal: { - journalType: string; - sourceEventName: string; - entityType: string; - entityId: string; - documentNo: string | null; - currencyCode: string; - totalAmount: string | null; - occurredAt: string | null; - paymentVoucherNo: string | null; - paymentDate: string | null; - dimensions: { - departmentId: string | null; - ownerEmployeeId: string | null; - submitterEmployeeId: string | null; - legalEntityCode: string | null; - costCenterCode: string | null; - }; - }; - exportState: { - externalId: string | null; - lastOutboxId: string | null; - lastOutboxStatus: string | null; - lastError: string | null; - exportedAt: string | null; - }; -}; - export type IntegrationOutboxStatus = | "pending" | "processing" @@ -159,47 +93,6 @@ function toQueryString(query: Record(`/api/integrations/erp/sync/${reimbursementId}?runNow=${runNow ? "1" : "0"}`, { - method: "POST", - body: JSON.stringify({}), - }); -} - -export async function fetchErpMasterDataStatus() { - return apiRequestData("/api/integrations/erp/master-data/status"); -} - -export async function syncErpMasterData(payload: { resources?: ErpMasterDataResourceType[] }) { - return apiRequestData("/api/integrations/erp/master-data/sync", { - method: "POST", - body: JSON.stringify(payload), - }); -} - -export async function previewReimbursementJournal(reimbursementId: string) { - return apiRequestData( - `/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`, - ); -} - -export async function exportReimbursementJournal(reimbursementId: string, runNow = true) { - return apiRequestData<{ - outboxId: string; - status: string; - duplicated: boolean; - requeued: boolean; - eventType: string; - }>(`/api/integrations/erp/journals/reimbursements/${reimbursementId}/export?runNow=${runNow ? "1" : "0"}`, { - method: "POST", - body: JSON.stringify({}), - }); -} - export async function fetchIntegrationOutbox(query: IntegrationOutboxQuery = {}) { return apiRequestData( `/api/integrations/outbox${toQueryString({ diff --git a/client/src/api/paymentBatches.ts b/client/src/api/paymentBatches.ts index d129fa7a..47c19940 100644 --- a/client/src/api/paymentBatches.ts +++ b/client/src/api/paymentBatches.ts @@ -3,7 +3,7 @@ import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/ import type { RiskFlag } from "./feeControl"; import type { ProcessingProgress } from "../types/processingProgress"; -export type PaymentBatchEntityType = "reimbursement" | "travel_order"; +export type PaymentBatchEntityType = "reimbursement" | "travel_order" | "loan_repayment"; export type PaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled"; export type PaymentChannel = "bank" | "alipay" | "wechat" | "cash" | "other"; @@ -87,6 +87,8 @@ export type PaymentBatch = { export type PaymentBatchDetail = PaymentBatch & { processingProgress: ProcessingProgress; + loanRepayment?: import("./companyLoans").LoanRepayment; + loanRepaymentId?: string | null; }; export type PaymentReadiness = { diff --git a/client/src/api/purchaseRequests.ts b/client/src/api/purchaseRequests.ts index 1b08cf94..94ca8fe4 100644 --- a/client/src/api/purchaseRequests.ts +++ b/client/src/api/purchaseRequests.ts @@ -122,78 +122,6 @@ export type PurchaseOrderArchive = { archivedFiles: PurchaseArchivedFile[]; }; -export type PurchaseErpTrackStatus = - | "not_triggered" - | "pending" - | "processing" - | "succeeded" - | "failed" - | "dead_letter" - | "skipped"; - -export type PurchaseErpBinding = { - id: string; - provider: string; - entityType: string; - localId: string; - externalId: string | null; - externalCode: string | null; - lastSyncedAt: string | null; -}; - -export type PurchaseErpOutbox = { - id: string; - eventType: string; - status: string; - attempts: number; - lastError: string | null; - processedAt: string | null; - updatedAt: string | null; -}; - -export type PurchaseErpTrackStage = { - eligible: boolean; - reason: string | null; - status: PurchaseErpTrackStatus | string; - preview: Record; - outbox: PurchaseErpOutbox | null; - binding?: PurchaseErpBinding | null; - bindings?: { - po: PurchaseErpBinding | null; - contract: PurchaseErpBinding | null; - }; -}; - -export type PurchaseRequestErpTrack = { - purchaseRequest: { - id: string; - requestNo: string | null; - status: string; - totalAmount: string; - supplier: { - id: string; - code: string | null; - name: string | null; - } | null; - archiveOrder: { - id: string; - poNo: string; - contractNo: string | null; - archivedAt: string | null; - } | null; - }; - stages: { - commitmentJournal: PurchaseErpTrackStage; - accrualJournal: PurchaseErpTrackStage; - archiveSummary: PurchaseErpTrackStage; - }; - permissions: { - canRetry: boolean; - }; -}; - -export type PurchaseRequestErpRequeueTarget = "commitment_journal" | "accrual_journal" | "archive_summary"; - export type PurchaseRequestDetail = PurchaseRequestSummary & { items: PurchaseRequestItem[]; attachments: PurchaseAttachment[]; @@ -397,34 +325,3 @@ export async function downloadPurchaseAttachment(purchaseRequestId: string, atta }, ); } - -export async function fetchPurchaseRequestErpTrack(purchaseRequestId: string) { - return apiRequestData( - `/api/integrations/erp/procurement/purchase-requests/${purchaseRequestId}/track`, - ); -} - -export async function requeuePurchaseRequestErpSync( - purchaseRequestId: string, - payload: { - targets: PurchaseRequestErpRequeueTarget[]; - }, - runNow = true, -) { - return apiRequestData<{ - items: Array<{ - target: PurchaseRequestErpRequeueTarget; - outboxId: string; - status: string; - duplicated: boolean; - requeued: boolean; - eventType: string; - }>; - }>( - `/api/integrations/erp/procurement/purchase-requests/${purchaseRequestId}/requeue${toQueryString({ runNow })}`, - { - method: 'POST', - body: JSON.stringify(payload), - }, - ); -} diff --git a/client/src/api/travelPayments.ts b/client/src/api/travelPayments.ts index a68da81d..c416bad6 100644 --- a/client/src/api/travelPayments.ts +++ b/client/src/api/travelPayments.ts @@ -87,33 +87,6 @@ export type TravelPaymentBoundInvoice = { linkedTravelOrderId: string | null; }; -export type TravelPaymentErpStage = { - binding: { - id: string; - provider: string; - entityType: string; - localId: string; - externalId: string | null; - externalCode: string | null; - lastSyncedAt: string | null; - } | null; - outbox: { - id: string; - eventType: string; - status: string; - lastError: string | null; - processedAt: string | null; - updatedAt: string | null; - } | null; - exportState: { - externalId: string | null; - lastOutboxId: string | null; - lastOutboxStatus: string | null; - lastError: string | null; - exportedAt: string | null; - }; -}; - export type TravelPaymentOrder = { id: string; externalOrderNo: string; @@ -128,7 +101,6 @@ export type TravelPaymentOrder = { paidBy: string | null; boundInvoices: TravelPaymentBoundInvoice[]; matchedTransactions: TravelPaymentTransaction[]; - erpStage: TravelPaymentErpStage; actions: { canMarkPaid: boolean; canBindInvoice: boolean; @@ -242,12 +214,6 @@ export async function markTravelOrderPaid( paymentVoucherNo: string | null; paymentDate: string | null; paymentChannel: TravelPaymentChannel | null; - erpJournal: { - eventType: string; - outboxId: string | null; - dedupeKey: string; - enqueued: boolean; - }; }>(`/api/travel-orders/${travelOrderId}/mark-paid`, { method: "POST", body: JSON.stringify(payload), diff --git a/client/src/api/travelReconciliation.ts b/client/src/api/travelReconciliation.ts index 6ea55e0b..76f41a07 100644 --- a/client/src/api/travelReconciliation.ts +++ b/client/src/api/travelReconciliation.ts @@ -107,7 +107,6 @@ export type TravelReconciliationBatchDetail = TravelReconciliationBatchSummary & items: TravelReconciliationItem[]; permissions: { canManagePayments: boolean; - canRetryErp: boolean; }; }; @@ -135,84 +134,10 @@ export type TravelReconciliationCreatePayload = { periodEnd: string; }; -export type TravelReconciliationErpTrack = { - batch: { - id: string; - batchNo: string; - status: string; - periodStart: string | null; - periodEnd: string | null; - legalEntity: { - id: string; - code: string; - name: string; - } | null; - costCenter: { - id: string; - code: string; - name: string; - } | null; - }; - stage: { - summaryExport: { - eligible: boolean; - reason: string | null; - status: string; - preview: { - exportType: string; - entityType: string; - entityId: string | null; - batchNo: string | null; - occurredAt: string | null; - }; - outbox: { - id: string; - eventType: string; - status: string; - attempts: number; - lastError: string | null; - processedAt: string | null; - updatedAt: string | null; - } | null; - bindings: { - payableSummary: { - id: string; - provider: string; - entityType: string; - localId: string; - externalId: string | null; - externalCode: string | null; - lastSyncedAt: string | null; - } | null; - settlementBatch: { - id: string; - provider: string; - entityType: string; - localId: string; - externalId: string | null; - externalCode: string | null; - lastSyncedAt: string | null; - } | null; - }; - }; - }; - permissions: { - canRetry: boolean; - canSeeExternalRefs: boolean; - }; -}; - export type TravelReconciliationFinalizeResponse = { batchId: string; batchNo: string; status: TravelReconciliationBatchStatus; - erpExport: { - eventType: string; - outboxId: string | null; - duplicated: boolean; - requeued: boolean; - enqueued: boolean; - }; }; export type TravelReconciliationRequeueResponse = { @@ -281,19 +206,3 @@ export async function finalizeTravelReconciliationBatch(batchId: string) { }, ); } - -export async function fetchTravelReconciliationErpTrack(batchId: string) { - return apiRequestData( - `/api/integrations/erp/travel/reconciliation-batches/${batchId}/track`, - ); -} - -export async function requeueTravelReconciliationBatch(batchId: string) { - return apiRequestData( - `/api/integrations/erp/travel/reconciliation-batches/${batchId}/requeue`, - { - method: 'POST', - body: JSON.stringify({}), - }, - ); -} diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 9bb9c904..8b4abad7 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -86,6 +86,7 @@ const ROLE_PERMISSIONS: Record = { "payment.batch.review", ], finance: [ + "loan.manage", "finance.ledger.view", ...EMPLOYEE_BASE_PERMISSIONS, ...MASTERDATA_VIEW_PERMISSIONS, @@ -107,6 +108,7 @@ const ROLE_PERMISSIONS: Record = { "payroll.manage", ], finance_mgr: [ + "loan.manage", "finance.ledger.view", ...EMPLOYEE_BASE_PERMISSIONS, ...MASTERDATA_MANAGE_PERMISSIONS, diff --git a/client/src/components/common/LoanPaymentDetails.tsx b/client/src/components/common/LoanPaymentDetails.tsx new file mode 100644 index 00000000..16f32bea --- /dev/null +++ b/client/src/components/common/LoanPaymentDetails.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "../../auth/AuthContext"; +import { downloadLoanEvidence, saveLoanAction, type LoanRepayment } from "../../api/companyLoans"; +import type { PaymentBatchReceiptAttachment } from "../../api/paymentBatches"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; +import { downloadBlob } from "../../lib/download"; +import { resolveErrorMessage } from "../../lib/resolveErrorMessage"; +import { formatDisplayDate } from "../../lib/displayFormat"; + +export function LoanPaymentDetails({ repayment, attachments }: { repayment: LoanRepayment; attachments: PaymentBatchReceiptAttachment[] }) { + const { me } = useAuth(); const cache = useQueryClient(); + const active = useRef(true); + useEffect(() => { active.current = true; return () => { active.current = false; }; }, []); + const [action, setAction] = useState<"handoff" | "failure" | "download" | null>(null); + const [text, setText] = useState(""); const [attachmentId, setAttachmentId] = useState(""); + const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null); + const finance = me?.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)); + const reviewer = me?.roleCodes.some((r) => ["manager", "ceo"].includes(r)); + const mutation = useMutation({ mutationFn: async () => { + if (action === "handoff") await saveLoanAction(`/loan-repayments/${repayment.id}/accounting-handoff`, { reference: text }); + if (action === "failure") await saveLoanAction(`/payment-batches/${repayment.paymentBatchId}/bank-failure-review`, { attachmentId }); + if (action === "download" && evidence) { + const result = await downloadLoanEvidence(evidence.loanId, evidence.id, text, repayment.paymentBatchId); + if (active.current) downloadBlob(result.blob, result.filename ?? evidence.name); + } + }, onSuccess: async () => { + if (!active.current) return; + setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); + } }); + function open(next: typeof action) { setText(""); setAttachmentId(""); mutation.reset(); setAction(next); } + return
+

原借款分配与会计交接

+

{repayment.reason}

+ {repayment.bankResultState === "unknown" ?

银行结果待查证;本金保持占用,不能取消释放或重复付款。

: null} + {repayment.bankResultState === "confirmed_failed" ?

银行失败证据已由独立人员确认。请财务取消本申请释放额度,保留原记录。

: null} + {repayment.allocations.map((allocation) =>
+

{allocation.loanNo} · 本次归还 ¥{allocation.amount}

+

借入 {formatDisplayDate(allocation.receivedDate)} · 核对截至 {formatDisplayDate(allocation.balanceAsOf)}

+

原凭据:{allocation.originReference}

+
{allocation.evidences.map((item) => )}
+
)} +

会计交接:{repayment.accountingHandoffRef ?? "待交接(与银行付款登记分别确认)"}

+
+ {finance && repayment.status === "executed" && !repayment.accountingHandoffAt ? : null} + {reviewer && ["reviewed", "failed"].includes(repayment.status) && repayment.bankResultState === "unknown" ? : null} +
+ { if (!value && !mutation.isPending) setAction(null); }}> + {action === "handoff" ? "确认会计交接" : action === "failure" ? "独立复核银行失败证据" : "下载原借款材料"} + {action === "failure" ? "仅限金额、双方账号和币种一致且明确失败的银行回单;受理中、未知、未识别的材料不能释放额度。上传人和收款人不得自审。" : "核对业务材料后填写真实引用或用途,操作将留存审计。"} +
{ event.preventDefault(); mutation.mutate(); }}> + {mutation.error ?

{resolveErrorMessage(mutation.error)}

: null} + {action === "failure" ? : } + +
+
+
+
; +} diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 28df3888..a00259dc 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -38,6 +38,7 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] }, { to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] }, { to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] }, + { to: "/company-loans", label: "公司借款", icon: "account_balance", anyOf: ["loan.manage"] }, { to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["finance.ledger.view"] }, { to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] }, { to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] }, @@ -138,7 +139,7 @@ function renderBackupSection(items: ResolvedNavItem[], pathname: string, permiss } function buildFallbackItems(permissions: string[], roleCodes: string[]) { - const paymentPaths = new Set(["/payment-batches", "/ap-operations"]); + const paymentPaths = new Set(["/payment-batches", "/ap-operations", "/company-loans"]); const financeOnlyPaths = new Set(["/invoice-pool"]); const backupOnlyPaths = new Set(BACKUP_NAV_ORDER); const mapItems = (paths: string[]) => diff --git a/client/src/components/travel/TravelPaymentTrackCard.tsx b/client/src/components/travel/TravelPaymentTrackCard.tsx index 00c9621e..9da1fa41 100644 --- a/client/src/components/travel/TravelPaymentTrackCard.tsx +++ b/client/src/components/travel/TravelPaymentTrackCard.tsx @@ -51,17 +51,6 @@ function paymentChannelLabel(value: TravelPaymentChannel | string | null) { return "-"; } -function erpStageLabel(order: TravelPaymentOrder) { - const status = order.erpStage.exportState.lastOutboxStatus; - if (status) { - return status; - } - if (order.erpStage.binding?.externalId) { - return "已同步"; - } - return "摘要导出未开启"; -} - type TravelPaymentTrackCardProps = { travelRequestId: string; }; @@ -250,7 +239,6 @@ export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCa
金额:{order.amount} {order.currency}
付款凭证:{order.paymentVoucherNo ?? "-"}
付款日期:{order.paymentDate ?? "-"} · 渠道:{paymentChannelLabel(order.paymentChannel)}
-
导出状态:{erpStageLabel(order)}
{canManagePayments ? ( diff --git a/client/src/components/travel/TravelReconciliationTab.tsx b/client/src/components/travel/TravelReconciliationTab.tsx index 5f2b56e0..f590897d 100644 --- a/client/src/components/travel/TravelReconciliationTab.tsx +++ b/client/src/components/travel/TravelReconciliationTab.tsx @@ -10,10 +10,8 @@ import { createTravelReconciliationBatch, fetchTravelReconciliationBatchDetail, fetchTravelReconciliationBatches, - fetchTravelReconciliationErpTrack, finalizeTravelReconciliationBatch, reopenTravelReconciliationItem, - requeueTravelReconciliationBatch, type TravelReconciliationBatchStatus, type TravelReconciliationItem, } from '../../api/travelReconciliation'; @@ -113,12 +111,6 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav queryFn: () => fetchTravelReconciliationBatchDetail(selectedBatchId), }); - const trackQuery = useQuery({ - enabled: Boolean(selectedBatchId), - queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId], - queryFn: () => fetchTravelReconciliationErpTrack(selectedBatchId), - }); - useEffect(() => { if (selectedBatchId || !(listQuery.data?.items?.length)) { return; @@ -179,7 +171,6 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav await Promise.all([ queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'detail', selectedBatchId] }), queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'list'] }), - queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] }), ]); toast.success(`已确认差异:${variables.item.snapshot.externalOrderNo ?? variables.item.snapshot.externalTxnId ?? variables.item.id}`); }, @@ -199,7 +190,6 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav await Promise.all([ queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'detail', selectedBatchId] }), queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'list'] }), - queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] }), ]); toast.success('差异项已重新打开'); }, @@ -228,29 +218,12 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav }, }); - const requeueMutation = useMutation({ - mutationFn: async () => { - if (!selectedBatchId) { - throw new Error('请选择待重试的对账批次'); - } - return requeueTravelReconciliationBatch(selectedBatchId); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] }); - toast.success('交接摘要已重新入队'); - }, - onError: (error) => { - toast.error(resolveError(error)); - }, - }); - function handleConfirm(item: TravelReconciliationItem) { setConfirmItemTarget(item); setConfirmItemNote(item.confirmNote ?? ''); } const detail = detailQuery.data ?? null; - const track = trackQuery.data ?? null; const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); return ( @@ -436,11 +409,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav {finalizeMutation.isPending ? '收口中...' : detail.status === 'reconciled' ? '已收口' : 'Finalize'} ) : null} - {track?.permissions.canRetry ? ( - - ) : null} + @@ -464,40 +433,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav -
-
-

交接摘要

-

批次级摘要导出轨道,不会回改订单、申请或预算原值。

-
- {trackQuery.isLoading ? : null} - {trackQuery.isError ? ( -

- 摘要轨道加载失败:{resolveError(trackQuery.error)} -

- ) : null} - - {track ? ( -
-
-

阶段状态

-
-
-
-

Outbox

-

{track.stage.summaryExport.outbox?.id ?? '未入队'}

-
-
-

应付摘要

-

{track.stage.summaryExport.bindings.payableSummary?.externalCode ?? '未生成'}

-
-
-

结算批次

-

{track.stage.summaryExport.bindings.settlementBatch?.externalCode ?? '未生成'}

-
-
- ) : null} -
diff --git a/client/src/features/navigation/schema.ts b/client/src/features/navigation/schema.ts index 04a4739a..9013c39e 100644 --- a/client/src/features/navigation/schema.ts +++ b/client/src/features/navigation/schema.ts @@ -3,7 +3,7 @@ import { resolveManagementRoute, } from "./managementRoutes"; -export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/purchase-requests", "/contracts-registry", "/projects", "/assets", "/payment-batches", "/ap-operations", "/invoice-pool", "/team", "/leave-requests"]; +export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/purchase-requests", "/contracts-registry", "/projects", "/assets", "/payment-batches", "/company-loans", "/ap-operations", "/invoice-pool", "/team", "/leave-requests"]; export const ADMIN_NAV_ORDER = ["/system/settings"]; export const BACKUP_NAV_ORDER = ["/weekly-reports", "/announcements", "/budgets", "/attendance", "/wecom-approval-archive", "/travel-requests", "/inventory-checks", "/dashboard"]; @@ -19,6 +19,8 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [ { path: "/purchase-requests", title: "采购", group: "业务" }, { path: "/travel-requests", title: "差旅申请", group: "更多" }, { path: "/budgets", title: "预算管理", group: "财务" }, + { path: "/company-loans", title: "公司借款", group: "财务" }, + { path: "/mobile/company-loans", title: "移动公司借款", group: "财务" }, { path: "/payment-batches", title: "付款", group: "财务" }, { path: "/ap-operations", title: "应付账款", group: "财务" }, { path: "/invoice-pool", title: "票据", group: "财务" }, diff --git a/client/src/lib/runtimeContext.ts b/client/src/lib/runtimeContext.ts index 92443b40..6c2da514 100644 --- a/client/src/lib/runtimeContext.ts +++ b/client/src/lib/runtimeContext.ts @@ -129,6 +129,10 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") { return "/mobile"; } + if (normalizedPath === "/company-loans") { + return `/mobile/company-loans${search}`; + } + if (normalizedPath === "/payment-batches") { return `/mobile/payment-batches${search}`; } diff --git a/client/src/lib/testIds.ts b/client/src/lib/testIds.ts index 2b23de22..b119f1ff 100644 --- a/client/src/lib/testIds.ts +++ b/client/src/lib/testIds.ts @@ -38,7 +38,6 @@ export const TEST_IDS = { withdraw: "reimbursement-withdraw", pay: "reimbursement-pay", budgetHint: "reimbursement-budget-hint", - erpExport: "reimbursement-erp-export", overview: "reimbursement-overview", workflowProgress: "reimbursement-workflow-progress", invoices: "reimbursement-invoices", diff --git a/client/src/pages/AnnouncementsPage.tsx b/client/src/pages/AnnouncementsPage.tsx index a7aaca24..0dbfaae7 100644 --- a/client/src/pages/AnnouncementsPage.tsx +++ b/client/src/pages/AnnouncementsPage.tsx @@ -1,3 +1,4 @@ +import { resolveErrorMessage } from "../lib/resolveErrorMessage"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -107,9 +108,7 @@ export function AnnouncementsPage() { await queryClient.invalidateQueries({ queryKey: ["announcements"] }); toast.success("签收已记录"); }, - onError: () => { - toast.error("签收失败,请刷新后重试"); - }, + onError: (error) => toast.error(resolveErrorMessage(error, "签收失败,请刷新后重试")), }); return ( diff --git a/client/src/pages/AuditLogsPage.tsx b/client/src/pages/AuditLogsPage.tsx index 622ad304..51348e6c 100644 --- a/client/src/pages/AuditLogsPage.tsx +++ b/client/src/pages/AuditLogsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { isApiError } from "../api/types"; +import { resolveErrorMessage } from "../lib/resolveErrorMessage"; import { exportAuditLogs } from "../api/exports"; import { fetchAuditLogs, type AuditLogListResponse } from "../api/audit"; import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; @@ -47,11 +47,7 @@ export function AuditLogsPage() { } } catch (err) { if (!cancelled) { - if (isApiError(err)) { - setError(`${err.code}: ${err.message}`); - } else { - setError("加载审计日志失败,请稍后重试"); - } + setError(resolveErrorMessage(err, "加载审计日志失败,请稍后重试")); } } finally { if (!cancelled) { @@ -93,11 +89,7 @@ export function AuditLogsPage() { downloadBlob(response.blob, response.filename ?? "audit-logs.csv"); setError(""); } catch (err) { - if (isApiError(err)) { - setError(`${err.code}: ${err.message}`); - } else { - setError("导出审计日志失败,请稍后重试"); - } + setError(resolveErrorMessage(err, "导出审计日志失败,请稍后重试")); } finally { setExporting(false); } @@ -118,11 +110,7 @@ export function AuditLogsPage() { downloadBlob(response.blob, response.filename ?? "audit-logs.xlsx"); setError(""); } catch (err) { - if (isApiError(err)) { - setError(`${err.code}: ${err.message}`); - } else { - setError("导出审计日志失败,请稍后重试"); - } + setError(resolveErrorMessage(err, "导出审计日志失败,请稍后重试")); } finally { setExporting(false); } diff --git a/client/src/pages/CompanyLoansPage.tsx b/client/src/pages/CompanyLoansPage.tsx new file mode 100644 index 00000000..e9d7e511 --- /dev/null +++ b/client/src/pages/CompanyLoansPage.tsx @@ -0,0 +1,187 @@ +import { cloneElement, isValidElement, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "../auth/AuthContext"; +import { hasPermission } from "../auth/permissions"; +import { fetchLegalEntities } from "../api/masterData"; +import { fetchBankInstitutions } from "../api/paymentAccounts"; +import { createLoanRepayment, downloadLoanEvidence, fetchCompanyLoans, fetchLoanIdentities, fetchLoanLenders, + fetchLoanRepayments, saveLoanAction, uploadLoanEvidence, type CompanyLoan, type LoanLender } from "../api/companyLoans"; +import { ListWorkspace, WorkspaceHeader, HeaderActionGroup } from "../components/patterns/ListWorkspace"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { Label } from "../components/ui/label"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog"; +import { resolveErrorMessage } from "../lib/resolveErrorMessage"; +import { formatDisplayDate } from "../lib/displayFormat"; +import { downloadBlob } from "../lib/download"; + +const evidenceLabels: Record = { agreement: "协议或借款确认书", incoming: "原借入凭据", + accounting_confirmation: "余额核对材料", adjustment: "系统外已还本金依据" }; +const stateLabels: Record = { draft: "待付款复核", reviewed: "待付款", executed: "已付款", cancelled: "已取消", failed: "登记未完成" }; +type Mode = "lender" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null; +const selectClass = "min-h-11 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm"; +function Field({ label, children }: { label: string; children: ReactNode }) { + const id = useId(); + return
+ {isValidElement<{ id?: string }>(children) ? cloneElement(children, { id }) : children}
; +} + +export function CompanyLoansPage() { + const { me, permissions } = useAuth(); const navigate = useNavigate(); const cache = useQueryClient(); + const paymentPath = useLocation().pathname.startsWith("/mobile/") ? "/mobile/payment-batches" : "/payment-batches"; + const mounted = useRef(true); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); + const allowed = Boolean(me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role))) && hasPermission(permissions, "loan.manage"); + const [page, setPage] = useState(1); const [lenderPage, setLenderPage] = useState(1); const [repayPage, setRepayPage] = useState(1); + const [tab, setTab] = useState<"loans" | "lenders" | "repayments">("loans"); + const [mode, setMode] = useState(null); const [selectedLender, setSelectedLender] = useState(null); + const [selectedLoan, setSelectedLoan] = useState(null); + const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null); + const [lenderId, setLenderId] = useState(""); const [identitySearch, setIdentitySearch] = useState(""); + const [amounts, setAmounts] = useState>({}); + const [candidatePage, setCandidatePage] = useState(1); + const [requestKey, setRequestKey] = useState(() => crypto.randomUUID()); + const identity = useRef(me?.id); identity.current = me?.id; + const loans = useQuery({ queryKey: ["company-loans", me?.id, page], queryFn: ({ signal }) => fetchCompanyLoans(page, undefined, signal), enabled: allowed }); + const lenders = useQuery({ queryKey: ["loan-lenders", me?.id, lenderPage], queryFn: ({ signal }) => fetchLoanLenders(lenderPage, signal), enabled: allowed }); + const repayments = useQuery({ queryKey: ["loan-repayments", me?.id, repayPage], queryFn: ({ signal }) => fetchLoanRepayments(repayPage, signal), enabled: allowed }); + const candidates = useQuery({ queryKey: ["company-loans", me?.id, "candidates", lenderId, candidatePage], + queryFn: ({ signal }) => fetchCompanyLoans(candidatePage, lenderId, signal), enabled: allowed && mode === "repay" && Boolean(lenderId) }); + const companies = useQuery({ queryKey: ["loan-companies", me?.id], queryFn: () => fetchLegalEntities({ enabled: true }), enabled: allowed && mode === "lender" }); + const banks = useQuery({ queryKey: ["loan-banks", me?.id], queryFn: fetchBankInstitutions, enabled: allowed && ["lender", "account"].includes(mode ?? "") }); + const people = useQuery({ queryKey: ["loan-identities", me?.id, identitySearch], queryFn: ({ signal }) => fetchLoanIdentities(identitySearch, signal), enabled: allowed && mode === "identity" }); + const mutation = useMutation({ mutationFn: async (action: () => Promise) => action(), onSuccess: async () => { + await Promise.all(["company-loans", "loan-lenders", "loan-repayments", "payment-batches"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); + } }); + useEffect(() => { setMode(null); setSelectedLender(null); setSelectedLoan(null); setAmounts({}); }, [me?.id]); + function open(next: Mode, lender: LoanLender | null = null, loan: CompanyLoan | null = null) { + mutation.reset(); setRequestKey(crypto.randomUUID()); setMode(next); setSelectedLender(lender); setSelectedLoan(loan); + setLenderId(lender?.id ?? ""); setAmounts({}); setIdentitySearch(""); setCandidatePage(1); + } + async function submit(event: FormEvent) { + event.preventDefault(); const values = new FormData(event.currentTarget); + const text = (key: string) => String(values.get(key) ?? "").trim(); + const actorId = me?.id; + mutation.mutate(async () => { + if (mode === "lender") await saveLoanAction("/loan-lenders", { requestKey, legalEntityId: text("company"), name: text("name"), + bankInstitutionId: text("bank"), bankName: text("bankName"), bankAccount: text("account"), accountName: text("accountName") }); + if (mode === "account" && selectedLender) await saveLoanAction(`/loan-lenders/${selectedLender.id}/account`, { + expectedVersion: selectedLender.accountVersion, bankInstitutionId: text("bank"), bankName: text("bankName"), + bankAccount: text("account"), accountName: text("accountName"), + }, "PATCH"); + if (mode === "identity" && selectedLender) await saveLoanAction(`/loan-lenders/${selectedLender.id}/identity-confirmation`, { + expectedVersion: selectedLender.identityVersion, employeeId: text("employee") === "external" ? null : text("employee"), + externalPersonConfirmed: text("employee") === "external", reason: text("reason"), + }); + if (mode === "loan") await saveLoanAction("/company-loans", { requestKey, lenderId: text("lender"), originReference: text("origin"), + originalPrincipal: text("principal"), openingOutstandingPrincipal: text("opening"), receivedDate: text("received"), balanceAsOf: text("asOf"), + historicalEntry: text("entry") === "historical" }); + if (mode === "confirm" && selectedLoan) await saveLoanAction(`/company-loans/${selectedLoan.id}/confirm`, { + expectedVersion: selectedLoan.version, accountingConfirmationRef: text("reference"), + }); + if (mode === "download" && evidence) { + const response = await downloadLoanEvidence(evidence.loanId, evidence.id, text("reason")); + if (mounted.current && identity.current === actorId) downloadBlob(response.blob, response.filename ?? evidence.name); + } + if (mode === "repay") { + const response = await createLoanRepayment({ requestKey, lenderId, reason: text("reason"), + allocations: Object.entries(amounts).filter(([, amount]) => amount.trim()).map(([loanId, amount]) => ({ loanId, amount: amount.trim() })) }); + if (mounted.current && identity.current === actorId) navigate(`${paymentPath}?id=${encodeURIComponent(response.item.id)}`); + } + if (mounted.current && identity.current === actorId) setMode(null); + }); + } + if (!allowed) return

当前账号无借款台账权限。

; + const error = mutation.error ?? loans.error ?? lenders.error ?? repayments.error ?? companies.error ?? banks.error ?? people.error; + const current = tab === "loans" ? loans : tab === "lenders" ? lenders : repayments; + const modalQueryBlocked = mode === "lender" ? !companies.isSuccess || !banks.isSuccess : mode === "account" ? !banks.isSuccess : mode === "identity" ? !people.isSuccess : mode === "loan" ? !lenders.isSuccess : mode === "repay" ? !lenders.isSuccess || !candidates.isSuccess : false; + const currentPage = tab === "loans" ? page : tab === "lenders" ? lenderPage : repayPage; + const setCurrentPage = tab === "loans" ? setPage : tab === "lenders" ? setLenderPage : setRepayPage; + return + + } /> +
+ {([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"]] as const).map(([key, label]) => + )} +
+ {error && !mode ?

{resolveErrorMessage(error)}

: null} + {current.isPending ?

加载中…

: null} + {current.isSuccess && !current.data.items.length ?

暂无记录;不能据此判断银行实际借还款情况。

: null} + {tab === "loans" && loans.isSuccess ? loans.data.items.map((loan) =>
+

{loan.lender.name} · {loan.loanNo}

+ {loan.confirmationStatus === "confirmed" ? "余额已确认" : "余额待核实"}
+

{loan.lender.legalEntityName} · 借入 {formatDisplayDate(loan.receivedDate)} · 核对截至 {formatDisplayDate(loan.balanceAsOf)} · {loan.historicalEntry ? "历史登记" : "当期借入"}

+
+ {[["原本金", loan.originalPrincipal], ["未还本金", loan.outstanding], ["待付款占用", loan.reserved], ["可申请还本", loan.available]].map(([label, value]) => +
{label}
¥{value}
)} +
+

原借入凭据:{loan.originReference}

+
借款材料与余额确认 +
{Object.entries(evidenceLabels).map(([kind, label]) =>
+ + { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; + if (file) mutation.mutate(() => uploadLoanEvidence(loan.id, kind, file)); }} /> + {loan.evidences.filter((e) => e.kind === kind && !e.archivedAt).map((e) => )} +
)}
+ {loan.confirmationStatus !== "confirmed" ? : null} +
+
) : null} + {tab === "lenders" && lenders.isSuccess ? lenders.data.items.map((lender) =>
+

{lender.name} · {lender.legalEntityName}

+

{lender.bankName} · {lender.bankAccountMasked ?? "账户不可用"} · {lender.identityStatus === "verified" ? "身份已核实" : "身份待核实"}

+
+
+
) : null} + {tab === "repayments" && repayments.isSuccess ? repayments.data.items.map((repayment) =>
+

{repayment.lenderName} · {repayment.repaymentNo}

+

¥{repayment.amount} · {stateLabels[repayment.status] ?? "待核对"}

+ {repayment.bankResultState === "unknown" ?

银行结果待查证,额度保持占用,请勿重复付款。

: null} +

会计交接:{repayment.accountingHandoffRef ?? "待交接"}

+ 查看还款分配、付款与回单 +
) : null} +
+ 第 {currentPage} 页
+ { if (!opened && !mutation.isPending) setMode(null); }}> + + {({ lender: "登记出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record)[mode ?? ""]} + {mode === "repay" ? "同一公司和出借人可合并多笔借款,提交后进入独立付款复核。" : + mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" : + mode === "account" ? "更新后须重新确认身份,原未付申请失效,请安全取消后重新申请。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"} +
+ {error ?

{resolveErrorMessage(error)}

: null} + {mode === "lender" ? <> + : null} + {mode === "lender" || mode === "account" ? <> + + + : null} + {mode === "identity" ? <> setIdentitySearch(e.target.value)} placeholder="姓名或员工编号" /> + : null} + {mode === "loan" ? <> + + + + + : null} + {mode === "confirm" ? <>

原本金 ¥{selectedLoan?.originalPrincipal},核对日未还 ¥{selectedLoan?.openingOutstandingPrincipal}

+ : null} + {mode === "repay" ? <> +

填写已确认借款的分配金额,留空表示本次不归还。翻页保留已填写金额。

+ {candidates.error ?

{resolveErrorMessage(candidates.error)}

: null} + {candidates.isSuccess ? candidates.data.items.filter((l) => l.lender.id === lenderId && l.confirmationStatus === "confirmed").map((l) => + setAmounts({ ...amounts, [l.id]: e.target.value })} />) : null} : null} + {mode === "repay" && lenderId ?
+ 第 {candidatePage} 页 · 已填 {Object.values(amounts).filter((v) => v.trim()).length} 笔
: null} + {["identity", "repay", "download"].includes(mode ?? "") ? : null} + +
+
+
+
; +} diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx index 3058b03a..62a20958 100644 --- a/client/src/pages/InvoicePoolPage.tsx +++ b/client/src/pages/InvoicePoolPage.tsx @@ -73,7 +73,15 @@ export function InvoicePoolPage() { const [selectedReimbursementId, setSelectedReimbursementId] = useState(""); const [selectedTravelRequestId, setSelectedTravelRequestId] = useState(""); const [selectedTravelOrderId, setSelectedTravelOrderId] = useState(""); - const [detailInvoiceId, setDetailInvoiceId] = useState(searchParams.get("id") ?? ""); + const detailInvoiceId = searchParams.get("id") ?? ""; + const setDetailInvoiceId = (id: string) => { + setSearchParams((current) => { + const next = new URLSearchParams(current); + if (id) next.set("id", id); + else next.delete("id"); + return next; + }, { replace: true }); + }; const [editOpen, setEditOpen] = useState(false); const [editSetAvailable, setEditSetAvailable] = useState(false); const [editRemarks, setEditRemarks] = useState(""); @@ -204,27 +212,6 @@ export function InvoicePoolPage() { setSelectedTravelOrderId(""); }, [canManageTravelPayments]); - useEffect(() => { - const searchInvoiceId = searchParams.get("id") ?? ""; - if (searchInvoiceId !== detailInvoiceId) { - setDetailInvoiceId(searchInvoiceId); - } - }, [detailInvoiceId, searchParams]); - - useEffect(() => { - const currentId = searchParams.get("id") ?? ""; - if (detailInvoiceId === currentId) { - return; - } - const next = new URLSearchParams(searchParams); - if (detailInvoiceId) { - next.set("id", detailInvoiceId); - } else { - next.delete("id"); - } - setSearchParams(next, { replace: true }); - }, [detailInvoiceId, searchParams, setSearchParams]); - // --- Helper for error handling --- const toUserError = (err: unknown, fallback: string) => { @@ -459,12 +446,7 @@ export function InvoicePoolPage() { detail.uploadedByEmployeeId === me?.id), ); const isInvoiceDetailMode = Boolean(detailInvoiceId); - const returnToInvoiceList = () => { - setDetailInvoiceId(""); - const next = new URLSearchParams(searchParams); - next.delete("id"); - setSearchParams(next, { replace: true }); - }; + const returnToInvoiceList = () => setDetailInvoiceId(""); // --- Render --- diff --git a/client/src/pages/MasterDataPage.tsx b/client/src/pages/MasterDataPage.tsx index 3cab4174..f1eb8c0b 100644 --- a/client/src/pages/MasterDataPage.tsx +++ b/client/src/pages/MasterDataPage.tsx @@ -639,6 +639,9 @@ export function MasterDataPage() { +

+ 统一编号启用后,报销按月、采购和盘点按年、资产永久编号。以下编号设置仅继续用于旧规则及电子资产前缀,不会覆盖统一编号;已有单号保持不变。 +

登录

'], +]) { + test(`视觉到达门禁拒绝 ${label}`, async ({ page }) => { + await page.route("**/*", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: html })); + await page.goto(route); + // The synthetic page is final; a short assertion budget avoids waiting for + // a state that this counterexample intentionally never produces. + await expect(assertRouteArrival(page, "/system/users", 100)).rejects.toThrow(label === "login-fallback" ? /toHaveURL/ : label === "wrong-page" ? /toBeVisible/ : /toHaveCount/); + }); +} + +test("视觉到达门禁接受真实目标结构和匿名登录入口", async ({ page }) => { + await page.route("**/*", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: new URL(route.request().url()).pathname === "/login" ? '' : "

系统用户管理

" })); + await page.goto("/system/users"); + await assertRouteArrival(page, "/system/users", 100); + await page.goto("/login"); + await assertRouteArrival(page, "/login", 100); }); diff --git a/client/tests/e2e/project-workbench.spec.ts b/client/tests/e2e/project-workbench.spec.ts index 10f0cc71..8141cbff 100644 --- a/client/tests/e2e/project-workbench.spec.ts +++ b/client/tests/e2e/project-workbench.spec.ts @@ -53,7 +53,7 @@ async function ensureFeatureFlags(api: APIRequestContext, keys: string[]) { test("E2E 项目工作台:沟通回填 + 收口面板 + Portal 摘要", async ({ page }) => { const adminClient = await createAuthenticatedApiClient(ADMIN); const suffix = Date.now(); - const today = new Date().toISOString().slice(0, 10); + const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()); await ensureFeatureFlags(adminClient.api, [ "portal_home_v1", @@ -90,19 +90,24 @@ test("E2E 项目工作台:沟通回填 + 收口面板 + Portal 摘要", async const communicationPanel = page.getByTestId("project-communication-panel"); await communicationPanel.getByTestId("project-communication-summary-input").fill(communicationSummary); - await communicationPanel.locator('input[type="date"]').nth(1).fill(today); + await communicationPanel.getByPlaceholder('例如 2026-05-25').nth(1).fill(today); + const savedCommunication = page.waitForResponse(response => + response.request().method() === "POST" && new URL(response.url()).pathname === `/api/projects/${projectId}/communication-logs`); await communicationPanel.getByTestId("project-communication-submit").click(); + const saved = await savedCommunication; + expect(saved.ok()).toBe(true); + expect(saved.request().postDataJSON()).toMatchObject({ nextFollowUpDate: today }); await expect(communicationPanel.getByText(communicationSummary)).toBeVisible(); await expect(page.getByText(deliveryTitle)).toBeVisible(); await expect(page.getByTestId("project-closure-panel")).toContainText("确认交付/验收记录齐全"); + const portal = await apiGetData<{ projectSummary: { followUpDueTodayCount: number } }>(adminClient.api, "/api/portal/home"); + expect(portal.projectSummary.followUpDueTodayCount).toBeGreaterThan(0); await page.goto("/portal"); - await expect(page.getByRole("heading", { name: "你好,Admin" })).toBeVisible(); - await page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" }).click(); - await expect(page.getByRole("heading", { name: "项目跟进与组织协同" })).toBeVisible(); - await expect(page.getByRole("link", { name: /项目风险/ })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: /今日项目跟进/ })).toBeVisible(); await adminClient.dispose(); }); diff --git a/client/tests/e2e/pwa-cross-version-upgrade.spec.ts b/client/tests/e2e/pwa-cross-version-upgrade.spec.ts index 6f07adca..b0a9f5f6 100644 --- a/client/tests/e2e/pwa-cross-version-upgrade.spec.ts +++ b/client/tests/e2e/pwa-cross-version-upgrade.spec.ts @@ -19,9 +19,13 @@ type RequestLedgerEntry = { method: string; path: string; status: number; + time: number; }; +type WorkerEvent = { time: number; event: string; state: string | null; documentSha: string | null }; + type BrowserWitnessState = { + events: WorkerEvent[]; controllerChanges: number; updateReadyEvents: number; }; @@ -29,6 +33,7 @@ type BrowserWitnessState = { type CacheSnapshot = { names: string[]; paths: string[]; + indexSha256: string[]; }; type ServiceWorkerSnapshot = { @@ -122,6 +127,7 @@ function recordResponse(request: IncomingMessage, pathname: string, status: numb method: request.method ?? "GET", path: pathname, status, + time: Date.now(), }); } @@ -377,19 +383,42 @@ async function installWitnessObservers(context: BrowserContext) { const readState = (): BrowserWitnessState => { try { const current = window.sessionStorage.getItem(storageKey); - return current ? JSON.parse(current) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0 }; + return current ? JSON.parse(current) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0, events: [] }; } catch { - return { controllerChanges: 0, updateReadyEvents: 0 }; + return { controllerChanges: 0, updateReadyEvents: 0, events: [] }; } }; const writeState = (state: BrowserWitnessState) => { window.sessionStorage.setItem(storageKey, JSON.stringify(state)); }; writeState(readState()); + const record = (event: string, worker?: ServiceWorker | null) => { + const state = readState(); + state.events.push({ time: Date.now(), event, state: worker?.state ?? null, + documentSha: document.documentElement?.dataset.buildSha ?? null }); + state.events = state.events.slice(-100); + writeState(state); + }; + record("document-start", window.navigator.serviceWorker.controller); + document.addEventListener("DOMContentLoaded", () => record("document-ready", window.navigator.serviceWorker.controller)); + const watched = new WeakSet(); + const watch = (worker: ServiceWorker | null, role: string) => { + if (!worker || watched.has(worker)) return; + watched.add(worker); + record(role, worker); + worker.addEventListener("statechange", () => record("worker-state", worker)); + }; + void window.navigator.serviceWorker.getRegistration().then((registration) => { + if (!registration) return; + watch(registration.active, "active-observed"); + watch(registration.installing, "installing-observed"); + registration.addEventListener("updatefound", () => watch(registration.installing, "update-found")); + }); window.navigator.serviceWorker.addEventListener("controllerchange", () => { const state = readState(); state.controllerChanges += 1; writeState(state); + record("controller-change", window.navigator.serviceWorker.controller); }); window.addEventListener("hyb-oa:pwa-update-ready", () => { const state = readState(); @@ -402,7 +431,7 @@ async function installWitnessObservers(context: BrowserContext) { async function readWitnessState(page: Page): Promise { return page.evaluate((storageKey) => { const value = window.sessionStorage.getItem(storageKey); - return value ? JSON.parse(value) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0 }; + return value ? JSON.parse(value) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0, events: [] }; }, WITNESS_STATE_KEY); } @@ -410,13 +439,22 @@ async function readCacheSnapshot(page: Page): Promise { return page.evaluate(async () => { const names = await window.caches.keys(); const paths = new Set(); + const indexSha256: string[] = []; for (const name of names) { const cache = await window.caches.open(name); for (const request of await cache.keys()) { - paths.add(new URL(request.url).pathname); + const pathname = new URL(request.url).pathname; + paths.add(pathname); + if (pathname === "/index.html") { + const response = await cache.match(request); + if (response) { + const digest = await window.crypto.subtle.digest("SHA-256", await response.arrayBuffer()); + indexSha256.push(Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("")); + } + } } } - return { names, paths: [...paths].sort() }; + return { names, paths: [...paths].sort(), indexSha256 }; }); } @@ -433,7 +471,7 @@ async function controllerPath(page: Page) { async function serviceWorkerSnapshot(page: Page): Promise { return page.evaluate(async () => { - const registration = await window.navigator.serviceWorker.ready; + const registration = await window.navigator.serviceWorker.getRegistration(); const controller = window.navigator.serviceWorker.controller; const response = await window.fetch("/sw.js", { cache: "no-store" }); if (!response.ok) { @@ -441,13 +479,19 @@ async function serviceWorkerSnapshot(page: Page): Promise } const digest = await window.crypto.subtle.digest("SHA-256", await response.arrayBuffer()); return { - controllerIsActive: controller !== null && registration.active === controller, + controllerIsActive: controller !== null && registration?.active === controller, controllerState: controller?.state ?? null, fetchedSha256: Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""), }; }); } +async function loadedAssets(page: Page) { + return page.evaluate(() => performance.getEntriesByType("resource") + .filter((entry) => (entry as PerformanceResourceTiming).responseEnd > 0) + .map((entry) => new URL(entry.name).pathname)); +} + function requestCount(build: BuildLabel, pathname: string, status = 200) { return requestLedger.filter((entry) => entry.build === build && entry.path === pathname && entry.status === status).length; } @@ -457,15 +501,16 @@ function safeLedger(input: { documentSha: string | null; controller: string | null; cacheNames: string[]; - aOnlyCached: number; - bOnlyCached: number; - bOnlyLoaded: number; + aOnlyCached: number | null; + bOnlyCached: number | null; + bOnlyLoaded: number | null; navigationCount: number; - witnessState: BrowserWitnessState; - serviceWorker: ServiceWorkerSnapshot; + witnessState: BrowserWitnessState | null; + serviceWorker: ServiceWorkerSnapshot | null; }) { return { phase: input.phase, + workerEvents: input.witnessState?.events ?? null, documentSha: input.documentSha, controller: input.controller, cacheNames: input.cacheNames.sort(), @@ -473,16 +518,16 @@ function safeLedger(input: { bOnlyCached: input.bOnlyCached, bOnlyLoaded: input.bOnlyLoaded, navigationCount: input.navigationCount, - controllerChanges: input.witnessState.controllerChanges, - controllerIsActive: input.serviceWorker.controllerIsActive, - controllerState: input.serviceWorker.controllerState, - updateReadyEvents: input.witnessState.updateReadyEvents, + controllerChanges: input.witnessState?.controllerChanges ?? null, + controllerIsActive: input.serviceWorker?.controllerIsActive ?? null, + controllerState: input.serviceWorker?.controllerState ?? null, + updateReadyEvents: input.witnessState?.updateReadyEvents ?? null, refresh200: requestCount(activeBuild.label, "/api/auth/refresh"), me200: requestCount(activeBuild.label, "/api/me"), loginNavigations: requestLedger.filter((entry) => entry.path === "/login").length, static404: requestLedger.filter((entry) => entry.status === 404 && /\.(?:css|js)$/u.test(entry.path)).length, swSha256Prefix: activeBuild.swSha256.slice(0, 12), - fetchedSwSha256Matches: input.serviceWorker.fetchedSha256 === activeBuild.swSha256, + fetchedSwSha256Matches: input.serviceWorker ? input.serviceWorker.fetchedSha256 === activeBuild.swSha256 : null, }; } @@ -576,11 +621,7 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a const cacheB = await readCacheSnapshot(pageB); const finalAOnly = [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)); const finalBOnly = [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)); - const loadedAssetPaths = await pageB.evaluate(() => [ - ...Array.from(document.querySelectorAll("script[src]"), (element) => new URL(element.src).pathname), - ...Array.from(document.querySelectorAll("link[href]"), (element) => new URL(element.href).pathname), - ...performance.getEntriesByType("resource").map((entry) => new URL(entry.name).pathname), - ]); + const loadedAssetPaths = await loadedAssets(pageB); const loadedBOnly = [...bOnlyAssets].filter((assetPath) => loadedAssetPaths.includes(assetPath)); const stateB = await readWitnessState(pageB); workerB = await serviceWorkerSnapshot(pageB); @@ -593,7 +634,8 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a fetchedSha256: buildB.swSha256, }); expect(finalAOnly).toEqual([]); - expect(finalBOnly.length + loadedBOnly.length).toBeGreaterThan(0); + expect(finalBOnly.length).toBeGreaterThan(0); + expect(loadedBOnly.length).toBeGreaterThan(0); expect(pageB.url()).not.toContain("/login"); expect(requestLedger.filter((entry) => entry.status === 404 && /\.(?:css|js)$/u.test(entry.path))).toEqual([]); expect(pageErrors).toEqual([]); @@ -614,21 +656,24 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a serviceWorker: workerB, }))); } catch (error) { - const cacheB = await readCacheSnapshot(pageB).catch(() => ({ names: [], paths: [] })); - const stateB = await readWitnessState(pageB).catch(() => ({ controllerChanges: 0, updateReadyEvents: 0 })); - const frozenWorkerB = workerB ?? { - controllerIsActive: false, - controllerState: null, - fetchedSha256: "", - }; + const cacheB = await readCacheSnapshot(pageB).catch(() => null); + const stateB = await readWitnessState(pageB).catch(() => null); + const frozenWorkerB = await serviceWorkerSnapshot(pageB).catch(() => null); + const loadedPaths = await loadedAssets(pageB).catch(() => null); + console.log("[pwa-cross-version-diagnostic]", JSON.stringify({ + time: Date.now(), pageErrors, chunkErrors, + cacheObserved: cacheB !== null, + cachedIndexBuilds: cacheB?.indexSha256.map((digest) => digest === sha256(readFileSync(path.join(buildA.root, "index.html"))) ? "A" : digest === sha256(readFileSync(path.join(buildB.root, "index.html"))) ? "B" : "unknown") ?? null, + documentResponses: requestLedger.filter((entry) => ["/portal", "/index.html", "/sw.js"].includes(entry.path)), + })); console.log("[pwa-cross-version]", JSON.stringify(safeLedger({ phase: "B_FIRST_FAILURE_FROZEN", documentSha: await documentBuildSha(pageB).catch(() => null), controller: await controllerPath(pageB).catch(() => null), - cacheNames: cacheB.names, - aOnlyCached: [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length, - bOnlyCached: [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length, - bOnlyLoaded: 0, + cacheNames: cacheB?.names ?? [], + aOnlyCached: cacheB ? [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length : null, + bOnlyCached: cacheB ? [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length : null, + bOnlyLoaded: loadedPaths ? [...bOnlyAssets].filter((assetPath) => loadedPaths.includes(assetPath)).length : null, navigationCount: bNavigations, witnessState: stateB, serviceWorker: frozenWorkerB, diff --git a/client/tests/e2e/r100-design-baseline.spec.ts b/client/tests/e2e/r100-design-baseline.spec.ts index 262787bf..dd533744 100644 --- a/client/tests/e2e/r100-design-baseline.spec.ts +++ b/client/tests/e2e/r100-design-baseline.spec.ts @@ -252,7 +252,7 @@ test.describe("R100 design baseline diagnostics", () => { await login(page); await page.goto("/mobile"); - await expect(page.getByRole("heading", { name: "今天" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: "今日待处理", exact: true })).toBeVisible(); await expect(page.getByTestId("mobile-today-focus")).toBeVisible(); await attachDiagnostics(page, testInfo, "r100-mobile-home"); diff --git a/client/tests/e2e/r101-mobile-wip-closeout.spec.ts b/client/tests/e2e/r101-mobile-wip-closeout.spec.ts index 25c8a90e..9395608f 100644 --- a/client/tests/e2e/r101-mobile-wip-closeout.spec.ts +++ b/client/tests/e2e/r101-mobile-wip-closeout.spec.ts @@ -370,7 +370,7 @@ test.describe("R101 mobile WIP closeout", () => { test("移动首页和我的页收口", async ({ page }, testInfo) => { await page.goto("/mobile"); - await expect(page.getByRole("heading", { name: "今天" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: "今日待处理", exact: true })).toBeVisible(); await expect(page.getByTestId("mobile-today-focus")).toBeVisible(); await expect(page.locator("aside")).toHaveCount(0); await assertNoMobileDesignRegression(page); @@ -386,7 +386,7 @@ test.describe("R101 mobile WIP closeout", () => { test("移动报销页使用卡片入口", async ({ page }, testInfo) => { await page.goto("/mobile/reimbursements"); - await expect(page.getByRole("heading", { name: "报销" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: "移动报销", exact: true })).toBeVisible(); await expect(page.getByTestId("mobile-reimbursements-create")).toBeVisible(); await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible(); const card = page.getByTestId("mobile-reimbursement-card").first(); @@ -400,7 +400,7 @@ test.describe("R101 mobile WIP closeout", () => { test("移动请假页默认只看记录并提示企微主办", async ({ page }, testInfo) => { await page.goto("/mobile/leave"); await expect(page.locator("h1", { hasText: "请假记录" })).toBeVisible(); - await expect(page.getByText("企业微信主办")).toBeVisible(); + await expect(page.getByText("企业微信主办", { exact: true })).toBeVisible(); await expect(page.getByTestId("mobile-leave-records")).toBeVisible(); await expect(page.getByTestId("mobile-leave-record-card")).toBeVisible(); diff --git a/client/tests/e2e/r102-reimbursement-main-flow.spec.ts b/client/tests/e2e/r102-reimbursement-main-flow.spec.ts index 9de66269..c5e17a96 100644 --- a/client/tests/e2e/r102-reimbursement-main-flow.spec.ts +++ b/client/tests/e2e/r102-reimbursement-main-flow.spec.ts @@ -366,7 +366,7 @@ test.describe("R102 reimbursement main flow", () => { await expect(page.getByText(reimbursementId)).toHaveCount(0); await page.getByRole("button", { name: "更多" }).first().click(); - await page.getByRole("menuitem", { name: "删除草稿" }).click(); + await page.getByRole("menuitem", { name: "删除报销单", exact: true }).click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber); await page.getByTestId("action-confirm-submit").click(); await expect(page.getByTestId("reimbursement-row")).toHaveCount(0); @@ -387,10 +387,12 @@ test.describe("R102 reimbursement main flow", () => { await page.getByTestId("reimbursement-more-toggle").click(); await expect(page.getByTestId("reimbursement-delete-draft")).toBeVisible(); await expect(page.getByTestId("reimbursement-secondary-payment-status")).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0); await page.getByTestId("reimbursement-invoice-add").click(); await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible(); - await expect(page.getByTestId("reimbursement-invoice-dialog")).toContainText("添加后自动生成或匹配报销明细"); + await expect(page.getByTestId("reimbursement-invoice-dialog")).toContainText("选择可添加发票,或上传新发票"); await expect(page.getByText("绑定整单")).toHaveCount(0); await expect(page.getByText("不绑定明细")).toHaveCount(0); await expect(page.getByTestId(`reimbursement-invoice-linked-item-${invoiceId}`)).toHaveCount(0); @@ -405,7 +407,7 @@ test.describe("R102 reimbursement main flow", () => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === `/api/invoice-pool/${invoiceId}/bind` && response.ok(); }); - await page.getByRole("button", { name: "添加选中发票" }).click(); + await page.getByRole("button", { name: "添加到报销单", exact: true }).click(); await bindResponsePromise; await expect(page.getByTestId("reimbursement-invoice-dialog")).toHaveCount(0); }); diff --git a/client/tests/e2e/r103-cross-page-ui-consistency.spec.ts b/client/tests/e2e/r103-cross-page-ui-consistency.spec.ts index 3b961c3f..c543e033 100644 --- a/client/tests/e2e/r103-cross-page-ui-consistency.spec.ts +++ b/client/tests/e2e/r103-cross-page-ui-consistency.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from "node:fs"; +import { mkdirSync, mkdtempSync } from "node:fs"; import path from "node:path"; import { expect, test, type Page, type Route } from "@playwright/test"; @@ -16,7 +16,9 @@ async function fulfillJson(route: Route, data: unknown, status = 200) { }); } -const screenshotDir = path.resolve(process.cwd(), "../output/playwright/r103"); +const visualRoot = path.resolve(process.env.QA_VISUAL_OUTPUT_DIR?.trim() || path.join(process.cwd(), "../output/playwright")); +mkdirSync(visualRoot, { recursive: true }); +const screenshotDir = mkdtempSync(path.join(visualRoot, "r103-")); function screenshotPath(fileName: string) { mkdirSync(screenshotDir, { recursive: true }); @@ -659,7 +661,8 @@ async function expectLedgerHeaderActions( const actionRegion = header.locator(":scope > .page-header-shell__actions"); if (target.expectedActions.length === 0) { - await expect(actionRegion).toHaveCount(0); + // Some workspaces retain an empty shared action slot; neither may expose a control. + await expect(actionRegion.locator("button, a")).toHaveCount(0); return; } @@ -695,7 +698,7 @@ test.describe("R103 cross page UI consistency", () => { { path: "/contracts-registry", heading: "合同", expectedActions: ["新增合同"] }, { path: "/projects", heading: "项目", expectedActions: ["新建项目"] }, { path: "/assets", heading: "资产", expectedActions: ["新增资产", "更多"] }, - { path: "/payment-batches", heading: "付款", expectedActions: ["新建批次"] }, + { path: "/payment-batches", heading: "付款", expectedActions: [] }, { path: "/ap-operations", heading: "应付账款", expectedActions: [] }, { path: "/invoice-pool", heading: "票据", expectedActions: ["上传票据"] }, ]; @@ -951,7 +954,8 @@ test.describe("R103 cross page UI consistency", () => { expect(failedResponse.status()).toBe(400); await expect(page).toHaveURL(/\/assets\/new$/); await expect(page.getByTestId("asset-name")).toHaveValue("合成失败资产"); - await expect(page.getByRole("main").getByText("资产保存失败,请检查填写内容。", { exact: true })).toBeVisible(); + await expect(page.getByRole("main").getByText("操作未完成,请核对页面提示;如仍无法继续,请联系管理员协助处理。 排障编号:mock-r103-asset-create-failure", { exact: true })).toBeVisible(); + await expect(page.getByText("资产保存失败,请检查填写内容。", { exact: true })).toHaveCount(0); await page.getByRole("button", { name: "返回资产列表", exact: true }).click(); await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("资产表单尚未保存"); diff --git a/client/tests/e2e/r106-visual-audit.spec.ts b/client/tests/e2e/r106-visual-audit.spec.ts index 45261d63..8ece4707 100644 --- a/client/tests/e2e/r106-visual-audit.spec.ts +++ b/client/tests/e2e/r106-visual-audit.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import path from "node:path"; import { expect, test, type BrowserContext, type Locator, type Page } from "@playwright/test"; @@ -42,8 +42,10 @@ type PageMetrics = { }; const ROOT_DIR = path.resolve(process.cwd(), ".."); -const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r106"); -const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/r106-visual-audit"); +const visualRoot = path.resolve(process.env.QA_VISUAL_OUTPUT_DIR?.trim() || path.join(ROOT_DIR, "output/playwright")); +mkdirSync(visualRoot, { recursive: true }); +const SCREENSHOT_DIR = mkdtempSync(path.join(visualRoot, "r106-")); +const ARTIFACT_DIR = path.join(SCREENSHOT_DIR, "report"); const WECOM_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.0 MicroMessenger/8.0.37 Language/zh_CN"; const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi; diff --git a/client/tests/e2e/r108-mobile-wecom-visual.spec.ts b/client/tests/e2e/r108-mobile-wecom-visual.spec.ts index 993d157a..acf84702 100644 --- a/client/tests/e2e/r108-mobile-wecom-visual.spec.ts +++ b/client/tests/e2e/r108-mobile-wecom-visual.spec.ts @@ -1,15 +1,17 @@ import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { createRequire } from "node:module"; import { expect, test, type Browser, type Page } from "@playwright/test"; import { EMPLOYEE, MANAGER, createAuthenticatedApiClient, + apiGetData, + apiPostData, type AuthenticatedApiClient, type E2EUser, } from "./helpers/auth"; -import { createSubmittedTravelRequestViaApi } from "./helpers/travel"; import { waitForWorkflowTask } from "./helpers/workflow"; type Finding = { @@ -44,8 +46,12 @@ type MobileMetrics = { }; const ROOT_DIR = path.resolve(process.cwd(), ".."); -const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r108"); -const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/r108-mobile-wecom-visual"); +const RUN_DIR = process.env.QA_VISUAL_OUTPUT_DIR + ? path.resolve(process.env.QA_VISUAL_OUTPUT_DIR, "r108") + : path.join(ROOT_DIR, "artifacts/qa-layout/r108-runs", `${Date.now()}-${process.pid}`); +const SCREENSHOT_DIR = path.join(RUN_DIR, "screenshots"); +const ARTIFACT_DIR = path.join(RUN_DIR, "reports"); +const requireServer = createRequire(new URL("../../../server/package.json", import.meta.url)); const WECOM_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN"; const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi; @@ -64,7 +70,7 @@ function artifactPath(fileName: string) { } function redact(value: string) { - return value.replace(UUID_RE, "[uuid]"); + return value.replaceAll(ROOT_DIR, "").replace(UUID_RE, "[uuid]"); } function addFinding(finding: Finding) { @@ -96,43 +102,36 @@ async function newWecomPage(browser: Browser, viewport: { width: number; height: return { context, page: await context.newPage() }; } -async function firstEnabledExpenseTypeId(client: AuthenticatedApiClient) { - const response = await client.api.get("/api/expense-types?enabled=true"); - const payload = await response.json() as { - data?: { items?: Array<{ id: string; enabled?: boolean }> }; - }; - const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0]; - if (!response.ok() || !item?.id) { - throw new Error(`无法读取可用费用类型:HTTP ${response.status()}`); - } - return item.id; +async function syntheticPdf(label: string) { + const document = await requireServer("pdf-lib").PDFDocument.create(); + document.addPage().drawText(`Synthetic R108 ${label} ${Date.now()}`, { x: 40, y: 700 }); + return Buffer.from(await document.save()); } async function seedEmployeeMobileData() { const client = await createAuthenticatedApiClient(EMPLOYEE); try { - const expenseTypeId = await firstEnabledExpenseTypeId(client); - const reimbursementResponse = await client.api.post("/api/reimbursements", { - data: { description: "R108 移动端巡检报销草稿" }, + const expenseDate = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date()); + const types = await apiGetData<{ items: Array<{ id: string; code: string; enabled: boolean }> }>(client.api, "/api/expense-types?enabled=true"); + const expenseType = types.items.find(item => item.enabled && item.code === "office_postal"); + expect(expenseType, "R108 requires the active synthetic postal expense type").toBeDefined(); + const reimbursement = await apiPostData<{ id: string }>(client.api, "/api/reimbursements", { + description: "R108 移动审批合成报销", reimbursementDate: expenseDate, }); - const reimbursementPayload = await reimbursementResponse.json() as { data?: { id?: string } }; - if (!reimbursementResponse.ok() || !reimbursementPayload.data?.id) { - throw new Error(`创建移动端报销草稿失败:HTTP ${reimbursementResponse.status()}`); - } - - const itemResponse = await client.api.post(`/api/reimbursements/${reimbursementPayload.data.id}/items`, { - data: { - expenseTypeId, - expenseDate: "2026-05-07", - amount: "88.80", - description: "R108 移动巡检明细", - }, + const item = await apiPostData<{ id: string }>(client.api, `/api/reimbursements/${reimbursement.id}/items`, { + expenseTypeId: expenseType!.id, expenseDate, amount: "10.00", description: "R108 合成邮寄费", }); - if (!itemResponse.ok()) { - throw new Error(`创建移动端报销明细失败:HTTP ${itemResponse.status()}`); - } - - return { reimbursementId: reimbursementPayload.data.id }; + const uploaded = await client.api.post("/api/invoice-pool/upload", { + multipart: { file: { name: "r108-approval.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("approval") }, ocr_type: "general" }, + }); + expect(uploaded.ok(), `R108 upload HTTP ${uploaded.status()}`).toBe(true); + const invoice = await uploaded.json() as { data: { invoicePoolId: string } }; + await expect.poll(async () => (await apiGetData<{ status: string }>(client.api, `/api/invoice-pool/${invoice.data.invoicePoolId}`)).status, { timeout: 90_000 }).toBe("available"); + await apiPostData(client.api, `/api/invoice-pool/${invoice.data.invoicePoolId}/bind`, { reimbursementId: reimbursement.id, linkedItemId: item.id }); + const submitted = await apiPostData<{ reimbursementId: string; formNumber: string; currentTaskId: string }>(client.api, `/api/reimbursements/${reimbursement.id}/submit`, {}); + const denied = await client.api.post(`/api/workflow/tasks/${submitted.currentTaskId}/approve`, { data: {} }); + expect(denied.status(), "员工不可审批分配给经理的任务").toBe(403); + return submitted; } finally { await client.dispose(); } @@ -370,9 +369,9 @@ async function auditMobilePage(page: Page, options: MobileAuditOptions): Promise return metrics; } -function writeReport() { +function writeReport(completed: boolean) { const generatedAt = new Date().toISOString(); - const report = { generatedAt, checks, findings }; + const report = { generatedAt, status: completed && findings.length === 0 ? "PASS" : "INCOMPLETE_OR_FAILED", checks, findings }; writeFileSync(artifactPath("report.json"), redact(JSON.stringify(report, null, 2))); const lines = [ @@ -382,9 +381,10 @@ function writeReport() { "", "## 结论", "", + `- 完整执行:${completed ? "是" : "否(以浏览器失败为准)"}`, `- 巡检记录数:${checks.length}`, `- 发现问题数:${findings.length}`, - `- 截图目录:${SCREENSHOT_DIR}`, + `- 截图目录:${path.relative(ROOT_DIR, SCREENSHOT_DIR)}`, "", "## 发现的问题", "", @@ -412,33 +412,21 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => { test.skip(process.env.E2E_MODE === "live", "R108 本地移动专项只在临时本地环境运行"); test("员工移动页面、票据上传队列与经理移动审批可巡检", async ({ browser }) => { - let travel: Awaited> | null = null; + let completed = false; + const contexts: Array>["context"]> = []; let managerClient: AuthenticatedApiClient | null = null; try { - try { - await seedEmployeeMobileData(); - travel = await createSubmittedTravelRequestViaApi({ - user: EMPLOYEE, - purposePrefix: "R108移动审批视觉", - amount: "1098.00", - }); - managerClient = await createAuthenticatedApiClient(MANAGER); - } catch (error) { - test.skip(true, `R108 初始化 API 不可用,跳过本地视觉巡检:${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!travel || !managerClient) { - test.skip(true, "R108 初始化未产生必要测试数据,跳过本地视觉巡检。"); - return; - } - + const reimbursement = await seedEmployeeMobileData(); + managerClient = await createAuthenticatedApiClient(MANAGER); const task = await waitForWorkflowTask(managerClient.api, { - entityId: travel.travelRequest.requestId, - entityType: "travel_request", + entityId: reimbursement.reimbursementId, + entityType: "reimbursement", }); + expect(task.id).toBe(reimbursement.currentTaskId); const employee390 = await newWecomPage(browser, { width: 390, height: 844 }); + contexts.push(employee390.context); await loginForMobile(employee390.page, EMPLOYEE); await auditMobilePage(employee390.page, { label: "企微 390-首页", route: "/mobile", screenshotName: "mobile-390-home" }); await auditMobilePage(employee390.page, { label: "企微 390-报销", route: "/mobile/reimbursements", screenshotName: "mobile-390-reimbursements" }); @@ -451,8 +439,8 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => { await employee390.page.evaluate(() => window.dispatchEvent(new Event("offline"))); await expect(employee390.page.getByText("离线", { exact: true })).toBeVisible(); await employee390.page.getByTestId("mobile-capture-pdf-file").setInputFiles([ - { name: "r108-mobile-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") }, - { name: "r108-mobile-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") }, + { name: "r108-mobile-a.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("queue-a") }, + { name: "r108-mobile-b.pdf", mimeType: "application/pdf", buffer: await syntheticPdf("queue-b") }, ]); await expect(employee390.page.getByText("已选择 2 个")).toBeVisible(); await employee390.page.getByTestId("mobile-capture-upload").click(); @@ -466,33 +454,43 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => { await employee390.context.close(); const employee414 = await newWecomPage(browser, { width: 414, height: 896 }); + contexts.push(employee414.context); await loginForMobile(employee414.page, EMPLOYEE); await auditMobilePage(employee414.page, { label: "企微 414-首页", route: "/mobile", screenshotName: "mobile-414-home" }); await auditMobilePage(employee414.page, { label: "企微 414-票据上传", route: "/mobile/capture", screenshotName: "mobile-414-capture" }); await employee414.context.close(); const manager390 = await newWecomPage(browser, { width: 390, height: 844 }); + contexts.push(manager390.context); await loginForMobile(manager390.page, MANAGER); await manager390.page.goto(`/mobile/approvals?taskId=${encodeURIComponent(task.id)}`); - await expect(manager390.page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 }); - await expect(manager390.page.getByTestId("mobile-approvals-detail")).toContainText(travel.travelRequest.requestNo); + await expect(manager390.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible({ timeout: 30_000 }); + await expect(manager390.page.getByTestId("reimbursement-mobile-workspace")).toContainText(reimbursement.formNumber); await expect(manager390.page.getByTestId("mobile-approvals-approve")).toBeEnabled(); - await expect(manager390.page.getByTestId("mobile-approvals-reject")).toBeEnabled(); + await expect(manager390.page.getByTestId("mobile-approvals-reject")).toHaveCount(0); + await expect(manager390.page.getByTestId("mobile-approvals-return")).toBeEnabled(); + await manager390.page.getByTestId("mobile-approvals-return").click(); await manager390.page.getByTestId("mobile-approvals-comment").fill("R108 移动审批操作区验证"); - await auditMobilePage(manager390.page, { label: "企微 390-审批详情", route: `/mobile/approvals?taskId=${encodeURIComponent(task.id)}`, screenshotName: "mobile-390-approvals-detail" }); + await auditMobilePage(manager390.page, { label: "企微 390-审批详情", route: `/mobile/approvals?taskId=${encodeURIComponent(task.id)}`, screenshotName: "mobile-390-approvals-detail", skipNavigation: true }); await manager390.page.getByTestId("mobile-approvals-approve").click(); await expect(manager390.page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点"); + const approval = manager390.page.waitForResponse(response => new URL(response.url()).pathname === `/api/workflow/tasks/${task.id}/approve` && response.request().method() === "POST"); await manager390.page.getByTestId("workflow-action-confirm-submit").click(); - await expect(manager390.page.getByText("操作成功")).toBeVisible({ timeout: 30_000 }); + expect((await approval).ok()).toBe(true); + const processed = await apiGetData<{ task: { status: string; outcome: string } }>(managerClient.api, `/api/workflow/tasks/${task.id}`); + expect(processed.task.status).toBe("completed"); + expect(processed.task.outcome).toBe("approved"); + await expect(manager390.page.getByText("已通过", { exact: true })).toBeVisible({ timeout: 30_000 }); await manager390.context.close(); - writeReport(); - if (process.env.R108_MOBILE_ALLOW_FINDINGS !== "1") { + { const summary = findings.map((finding) => `${finding.severity} ${finding.page} / ${finding.category}: ${finding.title}`).join("\n"); expect(findings, summary || "R108 mobile visual findings should be empty").toEqual([]); } + completed = true; } finally { - await Promise.all([travel?.client.dispose(), managerClient?.dispose()]); + writeReport(completed); + await Promise.all([...contexts.map(context => context.close()), managerClient?.dispose()]); } }); }); diff --git a/client/tests/e2e/r109-mobile-reimbursement-feedback.spec.ts b/client/tests/e2e/r109-mobile-reimbursement-feedback.spec.ts index 389f7e86..94e622b8 100644 --- a/client/tests/e2e/r109-mobile-reimbursement-feedback.spec.ts +++ b/client/tests/e2e/r109-mobile-reimbursement-feedback.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from "node:fs"; +import { mkdirSync, mkdtempSync } from "node:fs"; import path from "node:path"; import { expect, test, type Browser, type Page } from "@playwright/test"; @@ -10,7 +10,9 @@ import { } from "./helpers/auth"; const ROOT_DIR = path.resolve(process.cwd(), ".."); -const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r109-mobile-feedback"); +const visualRoot = path.resolve(process.env.QA_VISUAL_OUTPUT_DIR?.trim() || path.join(ROOT_DIR, "output/playwright")); +mkdirSync(visualRoot, { recursive: true }); +const SCREENSHOT_DIR = mkdtempSync(path.join(visualRoot, "r109-")); const WECOM_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN"; @@ -178,16 +180,25 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => { }, ]); await employee.page.getByTestId("reimbursement-invoice-upload-submit").click(); - await expect(employee.page.getByTestId("reimbursement-invoice-upload-submit")).toHaveText("上传发票", { - timeout: 20_000, - }); - + const invoiceDialog = employee.page.getByTestId("reimbursement-invoice-dialog"); + await expect(invoiceDialog.getByRole("checkbox", { checked: true })).toHaveCount(1, { timeout: 90_000 }); + const target = invoiceDialog.getByRole("combobox", { name: "添加到哪条明细" }); + const existingItems = target.locator('option:not([value=""])'); + await expect(existingItems).toHaveCount(1); + await target.selectOption((await existingItems.getAttribute("value"))!); + await invoiceDialog.getByRole("button", { name: "添加到报销单 1", exact: true }).click(); + await expect(invoiceDialog.getByText("已添加 1 张发票", { exact: true })).toBeVisible(); + await invoiceDialog.getByRole("button", { name: "返回报销单", exact: true }).click(); + await expect(invoiceDialog).toHaveCount(0); + await expect(employee.page.getByTestId("approval-invoice-evidence")).toHaveCount(1); + await employee.page.getByTestId("reimbursement-invoice-add").click(); await employee.page.getByRole("button", { name: "已有发票" }).click(); await expect(employee.page.getByTestId("mobile-reimbursement-invoice-search")).toBeVisible(); await employee.page.screenshot({ path: screenshotPath("mobile-invoice-dialog") }); await employee.page.goto("/mobile/leave?view=form"); - await expect(employee.page.getByTestId("mobile-leave-form")).toBeVisible(); + await expect(employee.page.getByTestId("mobile-leave-form")).toHaveCount(0); + await expect(employee.page.getByText("日常请假、补卡、外出和加班在企业微信完成;OA 这里只查看记录、额度和历史状态。", { exact: true })).toBeVisible(); await employee.page.screenshot({ path: screenshotPath("mobile-leave") }); const leaveMetrics = await collectMobileMetrics(employee.page); expect(leaveMetrics.navHeight).toBeLessThanOrEqual(68); diff --git a/client/tests/e2e/r110-mobile-pc-routing.spec.ts b/client/tests/e2e/r110-mobile-pc-routing.spec.ts index 85fe443b..9bafa91e 100644 --- a/client/tests/e2e/r110-mobile-pc-routing.spec.ts +++ b/client/tests/e2e/r110-mobile-pc-routing.spec.ts @@ -145,7 +145,8 @@ test.describe.serial("R110 手机和 PC 路由分流", () => { await mobile.page.goto("/leave-requests?action=create"); await expect(mobile.page).toHaveURL(/\/mobile\/leave\?view=form$/); - await expect(mobile.page.getByTestId("mobile-leave-form")).toBeVisible(); + await expect(mobile.page.getByTestId("mobile-leave-form")).toHaveCount(0); + await expect(mobile.page.getByText("日常请假、补卡、外出和加班在企业微信完成;OA 这里只查看记录、额度和历史状态。", { exact: true })).toBeVisible(); await expectNoPcShell(mobile.page); await mobile.page.goto("/invoice-pool"); diff --git a/client/tests/e2e/r124-mobile-entry-matrix.spec.ts b/client/tests/e2e/r124-mobile-entry-matrix.spec.ts index ae7addbd..c7f6e210 100644 --- a/client/tests/e2e/r124-mobile-entry-matrix.spec.ts +++ b/client/tests/e2e/r124-mobile-entry-matrix.spec.ts @@ -596,7 +596,7 @@ test.describe("R124 企业微信移动入口矩阵", () => { await draftCard.locator("summary").click(); await draftCard.getByTestId("mobile-reimbursement-delete").click(); await expect(mobile.page.getByRole("dialog")).toBeVisible(); - await mobile.page.getByRole("button", { name: "删除草稿" }).last().click(); + await mobile.page.getByRole("dialog").getByRole("button", { name: "删除报销单", exact: true }).click(); await expect(mobile.page.getByTestId("mobile-reimbursement-card").filter({ hasText: "R124 删除草稿" })).toHaveCount(0); await mobile.page.screenshot({ path: screenshotPath("mobile-reimbursements-after-delete") }); await expectMobilePageContract(mobile.page); diff --git a/client/tests/e2e/r125-invoice-reupload-after-delete.spec.ts b/client/tests/e2e/r125-invoice-reupload-after-delete.spec.ts index ef3fe916..28fdd458 100644 --- a/client/tests/e2e/r125-invoice-reupload-after-delete.spec.ts +++ b/client/tests/e2e/r125-invoice-reupload-after-delete.spec.ts @@ -1,9 +1,12 @@ -import { mkdirSync } from "node:fs"; +import { mkdirSync, mkdtempSync } from "node:fs"; import path from "node:path"; import { expect, test, type Browser, type Page, type Route } from "@playwright/test"; const ROOT_DIR = path.resolve(process.cwd(), ".."); -const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r125-invoice-reupload-after-delete"); +const SCREENSHOT_ROOT = process.env.QA_VISUAL_OUTPUT_DIR || path.join(ROOT_DIR, "output/playwright/r125-invoice-reupload-after-delete"); +mkdirSync(SCREENSHOT_ROOT, { recursive: true }); +const SCREENSHOT_DIR = mkdtempSync(path.join(SCREENSHOT_ROOT, "r125-")); +test.use({ serviceWorkers: "block" }); const WECOM_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN"; @@ -107,6 +110,8 @@ function buildState() { invoices: [] as ReturnType[], availableInvoices: [buildAvailableInvoice()], uploadCount: 0, + archived: false, + bindRequests: [] as Array<{ id: string; reimbursementId?: string; linkedItemId?: string }>, }; } @@ -297,7 +302,8 @@ async function mockApis(page: Page, state: ReturnType) { if (pathName === "/api/invoice-pool/upload") { state.uploadCount += 1; - const invoicePoolId = state.uploadCount === 1 ? reusableInvoiceId : newInvoiceId; + const invoicePoolId = state.archived ? newInvoiceId : reusableInvoiceId; + state.availableInvoices = [...state.availableInvoices.filter((item) => item.id !== invoicePoolId), { ...buildAvailableInvoice(), id: invoicePoolId }]; await fulfillJson(route, { invoicePoolId, status: "available", @@ -311,7 +317,10 @@ async function mockApis(page: Page, state: ReturnType) { const bindMatch = pathName.match(/^\/api\/invoice-pool\/([^/]+)\/bind$/); if (bindMatch) { + const payload = request.postDataJSON() as { reimbursementId?: string; linkedItemId?: string }; + state.bindRequests.push({ id: bindMatch[1], ...payload }); state.invoices = [buildBoundInvoice(bindMatch[1])]; + state.availableInvoices = state.availableInvoices.filter((item) => item.id !== bindMatch[1]); await fulfillJson(route, { id: bindMatch[1], status: "bound", @@ -339,6 +348,7 @@ async function mockApis(page: Page, state: ReturnType) { if (pathName === `/api/invoice-pool/${archivedInvoiceId}/archive`) { state.availableInvoices = []; + state.archived = true; await fulfillJson(route, { id: archivedInvoiceId, status: "archived", @@ -385,29 +395,36 @@ test.describe("R125 发票删除后重传", () => { await page.setViewportSize({ width: 1440, height: 960 }); await page.goto(`/reimbursements/${reimbursementId}`); await page.getByTestId("reimbursement-invoice-add").click(); + await page.getByLabel("新发票添加位置").selectOption("item-r125"); await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "北京悦海堂发票.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF r125 invoice"), }); await page.getByTestId("reimbursement-invoice-upload-submit").click(); - await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 }); - await page.keyboard.press("Escape"); - await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible(); + await expect(page.getByText("发票上传完成:1 张已入我的发票,请选择后添加")).toBeVisible({ timeout: 10_000 }); + expect(state.bindRequests).toHaveLength(0); + await page.getByRole("button", { name: "添加到报销单", exact: true }).click(); + await expect(page.locator(`#reimbursement-invoice-${reusableInvoiceId}`)).toBeVisible(); + expect(state.bindRequests).toEqual([{ id: reusableInvoiceId, reimbursementId, linkedItemId: "item-r125" }]); - await page.getByRole("button", { name: "移除" }).first().click(); + await page.locator(`#reimbursement-invoice-${reusableInvoiceId}`).getByRole("button", { name: "移除对应关系", exact: true }).click(); await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toHaveCount(0); await page.getByTestId("reimbursement-invoice-add").click(); + await page.getByLabel("新发票添加位置").selectOption("item-r125"); await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "北京悦海堂发票.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF r125 invoice"), }); await page.getByTestId("reimbursement-invoice-upload-submit").click(); - await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 }); - await page.keyboard.press("Escape"); - await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible(); + await expect(page.getByText("发票上传完成:1 张已入我的发票,请选择后添加")).toBeVisible({ timeout: 10_000 }); + expect(state.bindRequests).toHaveLength(1); + await page.getByRole("button", { name: "添加到报销单", exact: true }).click(); + await expect(page.locator(`#reimbursement-invoice-${reusableInvoiceId}`)).toBeVisible(); + expect(state.bindRequests).toHaveLength(2); + expect(state.bindRequests[1]).toEqual({ id: reusableInvoiceId, reimbursementId, linkedItemId: "item-r125" }); await expectNoRawReimbursementCodes(page); await page.screenshot({ path: screenshotPath("pc-reupload-after-unbind.png"), fullPage: true }); }); @@ -431,16 +448,20 @@ test.describe("R125 发票删除后重传", () => { await expect(availableInvoiceCard).toHaveCount(0); await invoiceDialog.getByRole("button", { name: "上传" }).click(); + await mobile.page.getByLabel("新发票添加位置").selectOption("item-r125"); await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "北京悦海堂发票.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF r125 invoice"), }); await mobile.page.getByTestId("reimbursement-invoice-upload-submit").click(); - await expect( - mobile.page.getByTestId("reimbursement-invoice-dialog").getByText(/1 张已添加|已添加 1 张发票/), - ).toBeVisible({ timeout: 10_000 }); - await mobile.page.keyboard.press("Escape"); + await expect(invoiceDialog.getByRole("button", { name: "添加到报销单 1", exact: true })).toBeEnabled(); + expect(state.bindRequests).toHaveLength(0); + await invoiceDialog.getByRole("button", { name: "添加到报销单 1", exact: true }).click(); + await expect(invoiceDialog.getByText("已添加 1 张发票", { exact: true })).toBeVisible(); + expect(state.archived).toBe(true); + expect(state.bindRequests).toEqual([{ id: newInvoiceId, reimbursementId, linkedItemId: "item-r125" }]); + await invoiceDialog.getByRole("button", { name: "返回报销单", exact: true }).click(); await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible(); await mobile.page.screenshot({ path: screenshotPath("mobile-reupload-after-archive.png"), fullPage: true }); } finally { diff --git a/client/tests/e2e/r6-mobile-unified-design-rollout.spec.ts b/client/tests/e2e/r6-mobile-unified-design-rollout.spec.ts index e37de2b7..a97f07e9 100644 --- a/client/tests/e2e/r6-mobile-unified-design-rollout.spec.ts +++ b/client/tests/e2e/r6-mobile-unified-design-rollout.spec.ts @@ -627,7 +627,7 @@ test.describe("MUD6 专用移动页面统一设计", () => { await expect(page.getByTestId(mobilePage.marker)).toBeVisible(); if (mobilePage.key === "leave") { await expect(page.getByTestId("mobile-leave-form")).toHaveCount(0); - await expect(page.getByText("企业微信主办")).toBeVisible(); + await expect(page.getByText("企业微信主办", { exact: true })).toBeVisible(); } if (mobilePage.key === "approvals") { await expect(page.getByText("批量")).toHaveCount(0); diff --git a/client/tests/e2e/r75-automation-gap-fill.spec.ts b/client/tests/e2e/r75-automation-gap-fill.spec.ts index ffc1ece2..36cc54d3 100644 --- a/client/tests/e2e/r75-automation-gap-fill.spec.ts +++ b/client/tests/e2e/r75-automation-gap-fill.spec.ts @@ -1,4 +1,5 @@ import { type APIResponse } from "@playwright/test"; +import { createRequire } from "node:module"; import { expect, test } from "./helpers/fixtures"; import { @@ -55,6 +56,7 @@ type EmployeeSummary = { type ExpenseTypeListResponse = { items: Array<{ id: string; + code: string; enabled: boolean; name: string; }>; @@ -79,11 +81,7 @@ type SubmittedReimbursementSeed = { type AuthedApi = Awaited>["api"]; -function formatDate(offsetDays: number) { - const value = new Date(); - value.setUTCDate(value.getUTCDate() + offsetDays); - return value.toISOString().slice(0, 10); -} +const requireServer = createRequire(new URL("../../../server/package.json", import.meta.url)); async function readApiData(response: APIResponse) { const payload = (await response.json()) as { data?: T; error?: { code?: string; message?: string } }; @@ -118,7 +116,7 @@ async function ensureFeatureFlags(api: Awaited { - const reimbursementDate = formatDate(30 + Math.floor(Math.random() * 45)); + const reimbursementDate = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date()); const description = `Playwright 通知补盲 ${label}`; const created = await apiPostData<{ id: string }>(api, "/api/reimbursements", { reimbursementDate, @@ -126,25 +124,27 @@ async function createSubmittedReimbursement(api: AuthedApi, label: string): Prom }); const expenseTypes = await apiGetData(api, "/api/expense-types?enabled=true"); - const expenseTypeId = expenseTypes.items[0]?.id; + const expenseTypeId = expenseTypes.items.find((item) => item.enabled && item.code === "office_postal")?.id; if (!expenseTypeId) { - throw new Error("未找到可用报销费用类型"); + throw new Error("未找到合成通知用例必需的邮寄快递费用类型"); } - await apiPostData(api, `/api/reimbursements/${created.id}/items`, { + const item = await apiPostData<{ id: string }>(api, `/api/reimbursements/${created.id}/items`, { expenseTypeId, expenseDate: reimbursementDate, - amount: "1683.00", + amount: "10.00", description, }); + const document = await requireServer("pdf-lib").PDFDocument.create(); + document.addPage().drawText(`Synthetic R75 notification ${label} ${Date.now()}`, { x: 40, y: 700 }); const upload = await readApiData( await api.post("/api/invoice-pool/upload", { multipart: { file: { name: `notify-${label}.pdf`, mimeType: "application/pdf", - buffer: Buffer.from(`%PDF-1.4\nPW_R75_NOTIFY_${label}_${Date.now()}_${Math.random().toString(16).slice(2)}\n%%EOF\n`, "utf8"), + buffer: Buffer.from(await document.save()), }, ocr_type: "general", }, @@ -166,9 +166,14 @@ async function createSubmittedReimbursement(api: AuthedApi, label: string): Prom await apiPostData(api, `/api/invoice-pool/${upload.invoicePoolId}/bind`, { reimbursementId: created.id, + linkedItemId: item.id, }); - const submitted = await apiPostData(api, `/api/reimbursements/${created.id}/submit`, {}); + const response = await api.post(`/api/reimbursements/${created.id}/submit`, { data: {} }); + const payload = await response.json() as { data?: SubmittedReimbursementSeed; error?: { code?: string; details?: { reasonCode?: string } } }; + expect(response.ok(), JSON.stringify({ status: response.status(), code: payload.error?.code, reasonCode: payload.error?.details?.reasonCode, fields: Object.keys(payload.error?.details ?? {}) })).toBe(true); + expect(payload.data).toBeDefined(); + const submitted = payload.data!; return { reimbursementId: submitted.reimbursementId, formNumber: submitted.formNumber, @@ -198,7 +203,7 @@ async function fetchEmployeeByKeyword( const encoded = encodeURIComponent(keyword); const payload = await apiGetData<{ items: EmployeeSummary[] }>( api, - `/api/employees?page=1&pageSize=20&q=${encoded}`, + `/api/employees?page=1&pageSize=20&scope=management&q=${encoded}`, ); return payload.items.find((item) => item.employeeNo === keyword || item.email === keyword) ?? null; } diff --git a/client/tests/e2e/r89-mobile-login-navigation.spec.ts b/client/tests/e2e/r89-mobile-login-navigation.spec.ts index e5ede292..5f7e3012 100644 --- a/client/tests/e2e/r89-mobile-login-navigation.spec.ts +++ b/client/tests/e2e/r89-mobile-login-navigation.spec.ts @@ -486,7 +486,7 @@ test.describe("R89 移动底部导航与核心页面", () => { await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible(); await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible(); await expect(page.getByTestId("mobile-approvals-approve")).toBeVisible(); - await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible(); + await expect(page.getByTestId("mobile-approvals-return")).toBeVisible(); await page.getByTestId("mobile-approvals-approve").click(); await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点"); await page.getByTestId("workflow-action-confirm-cancel").click(); diff --git a/client/tests/e2e/r92-mobile-invoice-usability.spec.ts b/client/tests/e2e/r92-mobile-invoice-usability.spec.ts index 14cb42cb..9e865db7 100644 --- a/client/tests/e2e/r92-mobile-invoice-usability.spec.ts +++ b/client/tests/e2e/r92-mobile-invoice-usability.spec.ts @@ -3,6 +3,9 @@ import { expect, test, type Page, type Route, type TestInfo } from "@playwright/ import { ADMIN } from "./helpers/auth"; import { TEST_IDS } from "./helpers/testIds"; +// Route mocks must own requests; an installed worker bypasses page.route. +test.use({ serviceWorkers: "block" }); + function envelope(data: unknown) { return { requestId: "r92-mock", data }; } @@ -23,6 +26,7 @@ async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { } async function mockAuthenticatedShell(page: Page) { + let signedIn = false; await page.route((url) => url.pathname === "/api/auth/sso/providers", (route) => fulfillJson(route, [])); await page.route((url) => url.pathname === "/api/auth/wecom/config", (route) => fulfillJson(route, { @@ -39,6 +43,7 @@ async function mockAuthenticatedShell(page: Page) { }), ); await page.route((url) => url.pathname === "/api/auth/login", async (route) => { + signedIn = true; await route.fulfill({ status: 200, contentType: "application/json", @@ -50,13 +55,17 @@ async function mockAuthenticatedShell(page: Page) { })), }); }); - await page.route((url) => url.pathname === "/api/auth/refresh", (route) => - fulfillJson(route, { + await page.route((url) => url.pathname === "/api/auth/refresh", async (route) => { + if (!signedIn) { + await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_001" } }) }); + return; + } + await fulfillJson(route, { accessToken: "r92-access-token", expiresIn: 900, mustChangePassword: false, - }), - ); + }); + }); await page.route((url) => url.pathname === "/api/me", (route) => fulfillJson(route, { id: "employee-mock-1", diff --git a/client/tests/e2e/reimbursement-payment-batch-flow.spec.ts b/client/tests/e2e/reimbursement-payment-batch-flow.spec.ts index 7bfdcb78..a87a4bf0 100644 --- a/client/tests/e2e/reimbursement-payment-batch-flow.spec.ts +++ b/client/tests/e2e/reimbursement-payment-batch-flow.spec.ts @@ -1,274 +1,7 @@ -import { test, expect } from "./helpers/fixtures"; -import { - ADMIN, - apiGetData, - createAuthenticatedApiClient, - EMPLOYEE, - FINANCE, - FINANCE_MGR, - login, - logout, - MANAGER, -} from "./helpers/auth"; -import { ensureFeatureFlagOn } from "./helpers/featureFlags"; -import { openNavLink } from "./helpers/navigation"; -import { TEST_IDS } from "./helpers/testIds"; +import { test } from "@playwright/test"; +import { reimbursementPaymentFlow } from "./helpers/reimbursementPaymentFlow"; -test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({ - page, - reimbursementDetail, - workflowDetail, -}) => { +test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({ page }) => { test.setTimeout(300_000); - - const adminApi = await createAuthenticatedApiClient(ADMIN); - const employeeApi = await createAuthenticatedApiClient(EMPLOYEE); - const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR); - - try { - await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright reimbursement payment batch flow"); - await ensureFeatureFlagOn(adminApi.api, "erp_sync", "playwright reimbursement payment batch flow"); - - await login(page, EMPLOYEE); - - await (await openNavLink(page, "我的报销")).click(); - await expect(page).toHaveURL(/\/reimbursements/); - const createResponsePromise = page.waitForResponse((response) => { - const url = new URL(response.url()); - return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok(); - }); - await page.getByTestId("reimbursements-create").click(); - await page.getByRole("menuitem", { name: "空白报销" }).click(); - const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } }; - const reimbursementId = createPayload.data?.id; - if (!reimbursementId) { - throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`); - } - - await page.goto(`/reimbursements/${reimbursementId}`); - await reimbursementDetail.assertLoaded(); - - const marker = `R78_BATCH_${Date.now()}_${Math.random().toString(16).slice(2)}`; - const invoiceId = await reimbursementDetail.uploadInvoicePdf( - `r78-${Date.now()}.pdf`, - Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"), - ); - - await page.goto(`/reimbursements/${reimbursementId}`); - await reimbursementDetail.assertLoaded(); - await reimbursementDetail.submitReimbursement(); - const reimbursementAfterSubmit = await apiGetData<{ - formNumber: string | null; - status: string; - totalAmount: string | null; - }>( - employeeApi.api, - `/api/reimbursements/${reimbursementId}`, - ); - expect(reimbursementAfterSubmit.status).toBe("reviewing"); - await expect(page.getByText("审批中", { exact: true }).first()).toBeVisible(); - const reimbursementDisplayCode = reimbursementAfterSubmit.formNumber ?? reimbursementId; - const secondApprovalUser = Number(reimbursementAfterSubmit.totalAmount ?? 0) > 500 ? FINANCE_MGR : FINANCE; - - await logout(page, EMPLOYEE.name); - - await login(page, MANAGER); - await (await openNavLink(page, "待我审批")).click(); - await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible(); - const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first(); - await expect(managerTaskRow).toBeVisible(); - await managerTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click(); - await workflowDetail.assertLoaded(); - await workflowDetail.approve(); - await expect(page.getByText("已完成", { exact: true }).first()).toBeVisible(); - await expect(page.getByText(`通过 · ${MANAGER.name}`, { exact: true })).toBeVisible(); - - await logout(page, MANAGER.name); - - await login(page, secondApprovalUser); - await (await openNavLink(page, "待我审批")).click(); - await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible(); - const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first(); - await expect(financeTaskRow).toBeVisible(); - await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click(); - await workflowDetail.assertLoaded(); - await workflowDetail.approve(); - await expect(page.getByText("已完成", { exact: true }).first()).toBeVisible(); - await expect(page.getByText(`通过 · ${secondApprovalUser.name}`, { exact: true })).toBeVisible(); - - await logout(page, secondApprovalUser.name); - await login(page, FINANCE); - - await page.goto("/payment-batches"); - await expect(page).toHaveURL(/\/payment-batches/); - - const createCard = page.getByTestId("payment-batch-create-panel"); - await expect(createCard).toBeVisible(); - const createBatchResponsePromise = page.waitForResponse((response) => { - const url = new URL(response.url()); - return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok(); - }); - await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode); - const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first(); - await expect(candidateRow).toBeVisible(); - await candidateRow.getByRole("checkbox").check(); - await createCard.getByTestId("payment-batch-default-channel").selectOption("bank"); - await createCard.getByRole("button", { name: "创建并提交复核" }).click(); - - const createBatchResponse = await createBatchResponsePromise; - expect(createBatchResponse.request().postDataJSON()).toMatchObject({ - entityType: "reimbursement", - entityIds: [reimbursementId], - defaultPaymentChannel: "bank", - }); - const createBatchPayload = (await createBatchResponse.json()) as { - data?: { item?: { id?: string; name?: string } }; - }; - const batchId = createBatchPayload.data?.item?.id; - const batchName = createBatchPayload.data?.item?.name; - if (!batchId || !batchName) { - throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); - } - await page.goto(`/payment-batches?id=${batchId}`); - const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName }); - await expect(batchDetailCard).toBeVisible(); - await expect(batchDetailCard).toContainText("待付款复核"); - await expect(batchDetailCard).toContainText("已提交付款复核,等待负责人处理"); - await expect(batchDetailCard).toContainText("0/1"); - const draftItemsPanel = page.getByTestId("payment-batch-items-panel"); - await expect(draftItemsPanel).toContainText(EMPLOYEE.name); - await expect(draftItemsPanel).toContainText("开户银行"); - await expect(draftItemsPanel).toContainText("银行账户"); - await expect(draftItemsPanel).toContainText("银行转账"); - await expect(draftItemsPanel.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0); - const draftReceiptPanel = page.getByTestId("payment-batch-receipt-panel"); - await expect(draftReceiptPanel).toContainText("付款复核通过后,由财务在线下完成付款并上传银行回单"); - await expect(draftReceiptPanel.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0); - await expect(draftReceiptPanel.getByRole("button", { name: "上传银行回单" })).toHaveCount(0); - - await logout(page, FINANCE.name); - - await login(page, MANAGER); - await page.goto(`/payment-batches?id=${batchId}`); - const managerBatchDetail = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName }); - await expect(managerBatchDetail).toBeVisible(); - await expect(page.getByTestId("payment-batch-items-panel")).toContainText(EMPLOYEE.name); - await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "复核通过" }).click(); - await expect(page.getByTestId("action-confirm-dialog")).toContainText("独立复核"); - await page.getByTestId("action-confirm-submit").click(); - await expect(managerBatchDetail).toContainText("1/1"); - - await logout(page, MANAGER.name); - - await login(page, FINANCE); - await page.goto(`/payment-batches?id=${batchId}`); - const executedBatchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName }); - await expect(executedBatchDetailCard).toBeVisible(); - await expect(executedBatchDetailCard).toContainText("1/1"); - const receiptCard = page.getByTestId("payment-batch-receipt-panel"); - await expect(receiptCard).toBeVisible(); - await expect(page.getByTestId("payment-batch-item-payment-fields")).toBeVisible(); - await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "上传银行回单" })).toBeVisible(); - await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toHaveCount(0); - const receiptUploadResponsePromise = page.waitForResponse((response) => { - const url = new URL(response.url()); - return response.request().method() === "POST" && - url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` && - response.ok(); - }); - await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({ - name: "r78-bank-receipt.pdf", - mimeType: "application/pdf", - buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"), - }); - await receiptUploadResponsePromise; - await expect(receiptCard).toContainText("r78-bank-receipt.pdf"); - await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible(); - - const executeBatchResponsePromise = page.waitForResponse((response) => { - const url = new URL(response.url()); - return response.request().method() === "POST" && - url.pathname === `/api/payment-batches/${batchId}/execute` && - response.ok(); - }); - await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" }).click(); - await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成"); - await page.getByTestId("action-confirm-submit").click(); - await executeBatchResponsePromise; - await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 }); - await expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0); - await expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible(); - await expect(receiptCard.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0); - await expect(receiptCard.getByRole("button", { name: "上传银行回单" })).toHaveCount(0); - await expect(receiptCard.getByRole("button", { name: "作废" })).toHaveCount(0); - await expect(receiptCard.getByRole("button", { name: "下载" })).toBeVisible(); - - await page.goto(`/reimbursements/${reimbursementId}`); - await reimbursementDetail.assertLoaded(); - const reimbursementAfterPayment = await apiGetData<{ status: string }>( - employeeApi.api, - `/api/reimbursements/${reimbursementId}`, - ); - expect(reimbursementAfterPayment.status).toBe("paid"); - await expect(page.getByText("已支付", { exact: true }).first()).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId("reimbursement-payment-status")).toContainText("已付款"); - await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0); - await expect(page.getByText("执行支付")).toHaveCount(0); - - const paymentAudit = await apiGetData<{ - items: Array<{ action: string; targetId: string | null; extra: Record }>; - }>( - adminApi.api, - "/api/audit-logs?action=REIMBURSEMENT_PAYMENT_MARKED&page=1&pageSize=20", - ); - expect( - paymentAudit.items.some((item) => item.targetId === reimbursementId && item.extra?.idempotent === false), - "付款动作应写入报销付款审计", - ).toBe(true); - const receiptAudit = await apiGetData<{ - items: Array<{ action: string; targetId: string | null; extra: Record }>; - }>( - adminApi.api, - "/api/audit-logs?action=PAYMENT_BATCH_RECEIPT_UPLOADED&page=1&pageSize=20", - ); - expect( - receiptAudit.items.some((item) => item.targetId === batchId && Array.isArray(item.extra?.attachmentIds)), - "银行回单上传应写入付款批次审计", - ).toBe(true); - - await page.goto("/ap-operations"); - await page.getByRole("tab", { name: /财务台账/ }).click(); - await expect(page.getByTestId("finance-ledger-table")).toContainText(reimbursementDisplayCode); - await expect(page.getByTestId("finance-ledger-table")).toContainText("已付款"); - await expect(page.getByRole("tab", { name: /待付款/ })).toBeVisible(); - await expect(page.getByText(batchName)).toHaveCount(0); - - await logout(page, FINANCE.name); - - const invoiceDetail = await apiGetData<{ - status: string; - archivedAt: string | null; - linkedReimbursementId: string | null; - }>( - employeeApi.api, - `/api/invoice-pool/${invoiceId}`, - ); - expect(invoiceDetail.status).toBe("archived"); - expect(invoiceDetail.archivedAt).toBeTruthy(); - expect(invoiceDetail.linkedReimbursementId).toBe(reimbursementId); - - const erpPreview = await apiGetData<{ - exportState: { - lastOutboxId: string | null; - lastOutboxStatus: string | null; - }; - }>( - financeMgrApi.api, - `/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`, - ); - expect(erpPreview.exportState.lastOutboxId).toBeTruthy(); - expect(erpPreview.exportState.lastOutboxStatus).toBe("pending"); - } finally { - await Promise.all([adminApi.dispose(), employeeApi.dispose(), financeMgrApi.dispose()]); - } + await reimbursementPaymentFlow(page); }); diff --git a/client/tests/e2e/reimbursement-workflow-progress.spec.ts b/client/tests/e2e/reimbursement-workflow-progress.spec.ts index e221bd96..be427fc3 100644 --- a/client/tests/e2e/reimbursement-workflow-progress.spec.ts +++ b/client/tests/e2e/reimbursement-workflow-progress.spec.ts @@ -1,13 +1,13 @@ import type { APIRequestContext } from "@playwright/test"; import { test, expect } from "./helpers/fixtures"; -import { EMPLOYEE, createAuthenticatedApiClient, login, logout } from "./helpers/auth"; +import { EMPLOYEE, MANAGER, apiGetData, createAuthenticatedApiClient, login, logout } from "./helpers/auth"; import { openNavLink } from "./helpers/navigation"; async function firstEnabledExpenseTypeId(api: APIRequestContext) { const response = await api.get("/api/expense-types?enabled=true"); const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean; category?: string | null }> } }; - const item = payload.data?.items?.find((candidate) => candidate.enabled !== false && candidate.category !== "travel"); + const item = payload.data?.items?.find((candidate) => candidate.enabled !== false && candidate.category === "office"); if (!response.ok() || !item?.id) { throw new Error(`无法读取费用类型:HTTP ${response.status()}`); } @@ -20,7 +20,7 @@ async function createReimbursementItem(api: APIRequestContext, reimbursementId: data: { expenseTypeId, expenseDate: "2026-05-13", - amount: "168.30", + amount: "10.00", description: "E2E 报销审批进度明细", }, }); @@ -64,16 +64,24 @@ test("E2E 报销处理进度:提交后按真实 workflow 展示职责进度", await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); await reimbursementDetail.submitReimbursement(); + const actual = await apiGetData<{ workflowProgress: { steps: Array<{id: string}> }; processingProgress: {stages: Array<{key: string; state: string}>} }>(apiClient.api, `/api/reimbursements/${reimbursementId}`); + expect(actual.workflowProgress.steps.map(step => step.id)).toEqual(["approval_1_manager", "approval_2_finance"]); await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 }); await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 }); - await expect(reimbursementDetail.workflowProgress.locator(":scope > li")).toHaveCount(4); + await expect(reimbursementDetail.workflowProgress.locator(":scope > li")).toHaveCount(6); await expect(reimbursementDetail.workflowProgress).toContainText("申请提交"); - await expect(reimbursementDetail.workflowProgress).toContainText("审批"); + await expect(reimbursementDetail.workflowProgress.locator(":scope > li").nth(1)).toContainText(MANAGER.name); + await expect(reimbursementDetail.workflowProgress).toContainText("财务复核"); + await expect(reimbursementDetail.workflowProgress).toContainText("组建付款批次"); await expect(reimbursementDetail.workflowProgress).not.toContainText("审批节点1"); await expect(reimbursementDetail.workflowProgress).toContainText("付款复核"); await expect(reimbursementDetail.workflowProgress).toContainText("付款完成"); - await expect(reimbursementDetail.workflowProgress).toContainText("非审批环节"); + // Payment work is separate from the two actual approval steps; no legacy badge is required. + expect(actual.processingProgress.stages.slice(3).map(stage => stage.key)).toEqual([ + "payment_batch_created", "payment_batch_review", "payment_and_receipt", + ]); + for (const stage of actual.processingProgress.stages.slice(3)) expect(stage.state).toBe("pending"); await expect(reimbursementDetail.workflowProgress).not.toContainText(/第 \d+ 步/); await logout(page, EMPLOYEE.name); diff --git a/client/tests/e2e/travel-freeze-protection.spec.ts b/client/tests/e2e/travel-freeze-protection.spec.ts new file mode 100644 index 00000000..c562e4ce --- /dev/null +++ b/client/tests/e2e/travel-freeze-protection.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; +import { createAuthenticatedApiClient, EMPLOYEE, FINANCE, FINANCE_MGR, login } from "./helpers/auth"; + +// Original travel chains remain byte-for-byte in their specs, owned by G19's +// frozen obligations. These assertions exercise the current retained boundary. +test("冻结差旅:员工入口与共享付款边界", async ({ page }) => { + const employee = await createAuthenticatedApiClient(EMPLOYEE); + const finance = await createAuthenticatedApiClient(FINANCE_MGR); + try { + expect(employee.me.roleCodes).toContain("employee"); + const denied = await employee.api.post("/api/travel-requests", { data: {} }); + expect(denied.status()).toBe(403); + await login(page, EMPLOYEE); + await page.goto("/portal"); + await expect(page.getByTestId("portal-work-summary")).toBeVisible(); + await expect(page.getByRole("link", { name: "差旅申请", exact: true })).toHaveCount(0); + const readiness = await finance.api.post("/api/payment-batches/readiness", { + data: { entityType: "travel_order", entityIds: ["ffffffff-ffff-4fff-8fff-ffffffffffff"], defaultPaymentChannel: "bank" }, + }); + expect(readiness.status()).toBe(200); + const payload = await readiness.json(); + expect(payload.data.ready).toBe(false); + expect(payload.data.blockers).toContainEqual(expect.objectContaining({ reasonCode: "payment_batch.travel_payment_frozen" })); + } finally { + await employee.dispose(); + await finance.dispose(); + } +}); + +test("冻结差旅:财务对账权限与入站导入权限边界", async ({ page }) => { + const finance = await createAuthenticatedApiClient(FINANCE); + try { + expect(finance.me.roleCodes).toContain("finance"); + for (const endpoint of ["/api/travel/reconciliation/batches", "/api/travel/orders/import", "/api/travel/transactions/import", "/api/travel/authorizations/import"]) { + const denied = await finance.api.post(endpoint, { data: {} }); + expect(denied.status(), endpoint).toBe(403); + } + await login(page, FINANCE); + await page.goto("/portal"); + await expect(page.getByTestId("portal-work-summary")).toBeVisible(); + await expect(page.getByRole("link", { name: "差旅申请", exact: true })).toHaveCount(0); + } finally { + await finance.dispose(); + } +}); diff --git a/client/tests/e2e/user-onboarding.spec.ts b/client/tests/e2e/user-onboarding.spec.ts index dd1d4aff..b527b7fa 100644 --- a/client/tests/e2e/user-onboarding.spec.ts +++ b/client/tests/e2e/user-onboarding.spec.ts @@ -1,5 +1,8 @@ import { expect, test } from "@playwright/test"; +// These synthetic auth routes must also handle the post-change /api/me request. +test.use({ serviceWorkers: "block" }); + function envelope(data: unknown) { return { requestId: "mock-request", diff --git a/client/tests/e2e/visual-shell.spec.ts b/client/tests/e2e/visual-shell.spec.ts index b20b3f9d..035bfb0c 100644 --- a/client/tests/e2e/visual-shell.spec.ts +++ b/client/tests/e2e/visual-shell.spec.ts @@ -1,8 +1,12 @@ import { mkdir, writeFile } from "node:fs/promises"; +import { mkdirSync, mkdtempSync } from "node:fs"; import path from "node:path"; import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test"; -const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/pc-frontend-visual-consistency-wave1"); +const SCREENSHOT_ROOT = process.env.QA_VISUAL_OUTPUT_DIR || path.resolve(process.cwd(), "../output/playwright/pc-frontend-visual-consistency-wave1"); +mkdirSync(SCREENSHOT_ROOT, { recursive: true }); +const SCREENSHOT_DIR = mkdtempSync(path.join(SCREENSHOT_ROOT, "visual-shell-")); +test.use({ serviceWorkers: "block" }); function envelope(data: unknown) { return { requestId: "mock-r96-ui", data }; diff --git a/client/tests/e2e/weekly-todo-flow.spec.ts b/client/tests/e2e/weekly-todo-flow.spec.ts index d4aec5f8..e363a17b 100644 --- a/client/tests/e2e/weekly-todo-flow.spec.ts +++ b/client/tests/e2e/weekly-todo-flow.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from "./helpers/fixtures"; import { EMPLOYEE, login, logout } from "./helpers/auth"; +import { openNavLink } from "./helpers/navigation"; import { TEST_IDS } from "./helpers/testIds"; function formatWeekStart(offsetDays: number) { diff --git a/client/tests/e2e/workflow-action-confirm-ui.spec.ts b/client/tests/e2e/workflow-action-confirm-ui.spec.ts index 1c8f88e7..ed9ba1a4 100644 --- a/client/tests/e2e/workflow-action-confirm-ui.spec.ts +++ b/client/tests/e2e/workflow-action-confirm-ui.spec.ts @@ -739,3 +739,85 @@ test.describe("R7E 审批动作二次确认", () => { }); } }); + +for (const mobile of [false, true]) { + for (const action of ["approve", "return", "transfer", "already_processed", "failed"] as const) { + test(`财务审批原地进度 ${mobile ? "手机" : "桌面"} ${action}`, async ({ page }) => { + const reimbursementId = "synthetic-finance-progress"; + let changed = false; + let readsAfterAction = 0; + let writes = 0; + const expectedResult = action === "return" ? "已退回修改" : action === "transfer" ? "已转审" : "财务复核通过"; + const source = { + id: reimbursementId, formNumber: "BX-20260917-0003", status: "reviewing", + reimbursementDate: "2026-09-17", totalAmount: "100.00", description: "合成审批进度验证", + owner: { id: "synthetic-owner", employeeNo: "SYN-1", name: "合成申请人" }, + submitter: { id: "synthetic-owner", employeeNo: "SYN-1", name: "合成申请人" }, + items: [{ id: "synthetic-item", expenseTypeId: "synthetic-type", expenseDate: "2026-09-17", + amount: "100.00", description: "合成明细", assetPriorApprovalBasis: null, + expenseType: { id: "synthetic-type", code: "synthetic", name: "合成费用", category: "office", enabled: true, requiresInvoice: false } }], + invoices: [], attachments: [], + }; + await mockWorkflowConfirmApis(page); + await page.route("**/api/me", (route) => fulfillJson(route, { ...mockMe, roleCodes: action === "transfer" ? ["finance", "manager"] : ["finance"], + permissions: ["expense.approve", "expense.transfer", "expense.view_all"] })); + await page.route(`**/api/reimbursements/${reimbursementId}`, async (route) => { + if (changed) readsAfterAction++; + await fulfillJson(route, { ...source, status: changed && action !== "transfer" ? (action === "return" ? "draft" : "approved") : "reviewing", + processingProgress: { stages: [{ key: "synthetic-finance-task", label: "财务复核", + state: changed ? (action === "return" ? "exception" : action === "transfer" ? "current" : "completed") : "current", actorName: "合成财务", occurredAt: null, + result: changed ? expectedResult : "等待财务复核", comment: null, detail: null, targetHref: null }] } }); + }); + await page.route(`**/api/workflow/tasks/${pendingTask.id}`, (route) => fulfillJson(route, { + ...pendingTaskDetail, task: { ...pendingTaskDetail.task, stepName: "财务复核", assigneeRole: "finance", + status: changed ? "completed" : "pending" }, + workflowInstance: { ...pendingTaskDetail.workflowInstance, entityType: "reimbursement", + contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "available", parseStatus: "ready" } }, + reimbursement: source, leaveRequest: null, history: [], allowedActions: changed ? [] : ["approve", "return", "transfer"], + })); + await page.route(`**/api/workflow/tasks/${pendingTask.id}/*`, async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + writes++; + changed = action !== "failed"; + if (action === "already_processed" || action === "failed") { + await route.fulfill({ status: action === "failed" ? 500 : 409, contentType: "application/json", + body: JSON.stringify({ requestId: "synthetic-progress", error: { code: action === "failed" ? "SYS_001" : "VALID_001", + message: "合成失败", details: action === "already_processed" ? { task: "already_processed" } : {} } }) }); + } else await fulfillJson(route, { ok: true }); + }); + await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1280, height: 900 }); + await login(page, MANAGER); + await page.goto(`${mobile ? "/mobile/approvals" : "/workflow/tasks"}/${pendingTask.id}`); + const progress = page.getByTestId("reimbursement-approval-processing-progress"); + await expect(progress).toContainText("等待财务复核"); + const documentMarker = await page.evaluate(() => { + const marker = crypto.randomUUID(); document.documentElement.dataset.syntheticDocument = marker; return marker; + }); + if (action === "transfer") { + await page.getByTestId(mobile ? "mobile-approvals-transfer" : "workflow-task-transfer-menu-item").click(); + await page.getByTestId(mobile ? "mobile-approvals-transfer-to" : "workflow-task-transfer-approver").selectOption("employee-transfer-1"); + await page.getByTestId(mobile ? "mobile-approvals-transfer-reason" : "workflow-task-transfer-reason").fill("合成转审理由用于验证处理进度刷新"); + await page.getByTestId(mobile ? "mobile-approvals-transfer-submit" : "workflow-task-transfer").click(); + await page.getByTestId("workflow-transfer-confirm-dialog").getByRole("button", { name: "确认转审", exact: true }).click(); + } else { + if (action === "return") { + await page.getByTestId(mobile ? "mobile-approvals-return" : "workflow-task-return-toggle").click(); + await page.getByTestId(mobile ? "mobile-approvals-comment" : "workflow-task-return-comment").fill("请补充合成资料"); + await page.getByTestId(mobile ? "mobile-approvals-return-submit" : "workflow-task-return").click(); + } else await page.getByTestId(mobile ? "mobile-approvals-approve" : "workflow-task-approve").click(); + await page.getByTestId("workflow-action-confirm-submit").click(); + } + await expect.poll(() => writes).toBe(1); + if (action === "failed") { + await expect(page.getByTestId(mobile ? "mobile-approvals-approve" : "workflow-task-approve")).toBeEnabled(); + await expect(progress).toContainText("等待财务复核"); + expect(readsAfterAction).toBe(0); + } else { + await expect(progress).toContainText(expectedResult); + await expect(progress.locator("li")).toHaveAttribute("data-state", action === "return" ? "exception" : action === "transfer" ? "current" : "completed"); + expect(readsAfterAction).toBeGreaterThan(0); + } + expect(await page.evaluate(() => document.documentElement.dataset.syntheticDocument)).toBe(documentMarker); + }); + } +} diff --git a/client/vite.config.ts b/client/vite.config.ts index ca4fed42..c094bdce 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -71,6 +71,7 @@ export default defineConfig(({ mode }) => { VitePWA({ disable: false, registerType: "autoUpdate", + injectRegister: false, // pwa.ts owns native registration and update reloads. includeAssets: ["pwa-192x192.png", "pwa-512x512.png", "pwa-maskable-512x512.png"], manifest: { name: "hyb-oa 移动工作台", diff --git a/docs/trialrun/项目级流程覆盖矩阵.csv b/docs/trialrun/项目级流程覆盖矩阵.csv index 15ff0976..7a1058c6 100644 --- a/docs/trialrun/项目级流程覆盖矩阵.csv +++ b/docs/trialrun/项目级流程覆盖矩阵.csv @@ -1,6 +1,6 @@ Domain,Tier,Primary_Roles,PC_Routes,Mobile_WeCom_Routes,Server_Routes,Main_Flow,Exception_Flow,Permission_Flow,Current_Coverage,Known_Gaps,Best_Practice_Delta,Next_Action "认证与企业微信入口","P0","employee|manager|finance|admin","/login|/portal|/settings/profile","/mobile|/mobile/me","server/src/routes/auth.js|server/src/routes/wecomAuth.js|server/src/routes/me.js","账号登录或企业微信进入 OA,按设备进入 PC 或移动工作台。","未绑定、会话过期、OAuth 回跳失败。","员工看个人入口,管理员看系统入口。","wecom-entry|live-smoke|ME6 automation|MIF7TD","ME6-3 已关闭;R7 真实扩大试运行未启动。","企业微信只做入口、身份、消息和 deeplink。","R7E 前跑公网匿名、带凭据 live readiness 和角色只读核验。" "报销与票据","P0","employee|manager|finance","/reimbursements|/reimbursements/:id|/ap-operations","/mobile/reimbursements|/mobile/reimbursements/:id","server/src/routes/reimbursements.js|server/src/routes/invoicePool.js|server/src/routes/apOperations.js","员工建报销、加发票/附件、提交审批、财务复核或付款状态展示。","识别失败、重复票据、已归档、缺附件。","员工看本人,审批人处理分配任务,财务看财务范围。","RI6|FL6|PA6|MIF7|MIF7T","R7 真实扩大试运行未启动。","移动轻任务,PC 高密度复核。","R7E 前复跑核心业务流和公网移动矩阵。" -"请假与采购","P0","employee|manager","/leave-requests|/purchase-requests|/workflow/tasks","/mobile/leave|/mobile/purchase-requests|/mobile/approvals","server/src/routes/leave.js|server/src/routes/purchaseRequests.js|server/src/routes/workflow.js","员工提交,请假/采购进入审批,审批人处理,员工看进度。","退回/驳回、缺预算池 warning、附件缺失。","自审和 admin-only 业务审批由服务端拒绝。","LP6|PS6|PA6|MIF7T","采购复杂预算、供应商准入、ERP 不在当前范围;移动采购创建如需进入 R7 后优化另拆 Issue。","状态和审批历史一致。","R7E 前复跑请假/采购列表、详情和返回语义。" +"请假与采购","P0","employee|manager","/leave-requests|/purchase-requests|/workflow/tasks","/mobile/leave|/mobile/purchase-requests|/mobile/approvals","server/src/routes/leave.js|server/src/routes/purchaseRequests.js|server/src/routes/workflow.js","日常请假在企业微信办理,OA 保留只读记录与已授权应急边界;采购由员工提交并进入审批。","退回/驳回、缺预算池 warning、附件缺失。","自审和 admin-only 业务审批由服务端拒绝。","LP6|PS6|PA6|MIF7T","采购复杂预算与供应商准入按既有范围验收;ERP 专属实现已退役;移动采购创建不在本批范围。","状态和审批历史一致。","R7E 前复跑请假/采购列表、详情和返回语义。" "通知与审批效率","P0","employee|manager","/notifications|/workflow/tasks","/mobile/notifications|/mobile/approvals","server/src/routes/notifications.js|server/src/routes/workflow.js","通知事件直达详情;申请人可催办本人 pending 任务。","通知目标不存在、无权限、重复催办。","催办不改变审批状态,通知失败不回滚业务。","AP6|DG7|MIA7|MIF7","R7 真实扩大试运行未启动。","通知是事件收件箱,不是待办副本。","R7E 前复跑通知直达和审批/进度矩阵。" -"系统治理和财务","P1","finance|admin","/system/*|/audit-logs|/ap-operations","/mobile/me","server/src/routes/audit.js|server/src/routes/featureFlags.js|server/src/routes/apOperations.js","管理员看配置和审计,财务看台账和导出。","权限不足、导出失败、审计缺口。","管理员不默认获得财务业务能力,财务不获得系统管理能力。","PA6|FL6|QG6","R7 真实执行未启动。","最小权限和可追溯。","R7 前确认人员、窗口和回滚联系人。" +"系统治理和财务","P1","finance|admin","/system/reimbursement-templates|/system/users|/system/departments|/system/master-data|/system/payment-accounts|/system/notification-channels|/system/workspace|/system/settings|/system/approval-rules|/system/expense-standards|/system/feature-flags|/system/announcements|/system/workflow-definitions|/system/cutover|/audit-logs|/ap-operations","/mobile/me","server/src/routes/audit.js|server/src/routes/featureFlags.js|server/src/routes/apOperations.js","管理员看配置和审计,财务看台账和导出。","权限不足、导出失败、审计缺口。","管理员不默认获得财务业务能力,财务不获得系统管理能力。","PA6|FL6|QG6","R7 真实执行未启动。","最小权限和可追溯。","R7 前确认人员、窗口和回滚联系人。" diff --git a/docs/报销发票附件规则.md b/docs/报销发票附件规则.md index ce0c38f7..da476d7a 100644 --- a/docs/报销发票附件规则.md +++ b/docs/报销发票附件规则.md @@ -6,6 +6,14 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文件;图片和拍照直传不作为发票入口。附件材料仍可上传图片或 PDF,但必须与发票分离。重复票据、已使用票据和 OCR 失败必须进入可解释终态,不能长期停留在处理中。 +## 业务编号(本地候选,尚未线上启用) + +面向不足10人的团队,新规则采用“类型前缀+必要期间+流水”。报销按月,如 `BX-202609-0001`;付款批次按年,如 `PB-2026-0001`,报销付款与本金还款共用PB。借款、还款申请、采购、盘点及自动项目号分别为JK、HK、CG、PD、PRJ年度号。新固定资产用 `FA-0001` 永久递增,电子资产保留既有EA分类前缀并永久递增。正式合同、员工和外部原始编号不改。 + +已有单号、草稿、审批、付款材料及资产标签全部保留。切换前尚未编号的采购草稿和历史缺号报销沿用旧规则;采购仍在首次提交时取号。新批次可关联旧号报销,逐项显示来源原号。付款批次编号与实际付款日期分别展示,年中新序列的0001不表示公司当年实际第一笔付款。 + +四位是最小宽度,超过9999继续扩位。取消或删除会留下空号,不回收已发号码;号码也不等于业务数量或总账凭证号。跨月审批、跨年付款不换号。只有迁移和兼容代码部署、独立复核及切换授权齐备后才受控启用;本地验证不表示现有线上编号已改变。 + ## 总原则 - PC 与手机功能一致,只允许布局和控件形态不同。 diff --git a/docs/试运行与自动化验证手册.md b/docs/试运行与自动化验证手册.md index 5e1e0bbd..d89370e7 100644 --- a/docs/试运行与自动化验证手册.md +++ b/docs/试运行与自动化验证手册.md @@ -2,6 +2,15 @@ **文档状态**:当前操作手册,用于本地、公网、企业微信和真实 OCR 试运行验证。 +## 备份恢复验证边界 + +2026-09-17,`08-23-isolated-backup-restore-drill`完成一次已批准的ARM受限影子恢复:来源与生产构建同为`b1d25d3c`,数据库恢复和影子认证只读应用通过;62条附件引用中仅1条精确已接受历史缺件,未知不一致0。清理及源/生产/监控后验通过,未修改生产。 + +- 整体结论为`PARTIAL_KNOWN_MISSING_AND_SENSITIVE_DECRYPTION_NOT_TESTED`,不是完整灾备PASS。历史缺件未找回、不删除引用;其余引用按已有大小/摘要字段验证,不把无SHA字段的存在性检查说成全量内容校验。 +- 应用证据是影子凭据经过真实认证/权限后的只读读取与恢复库比对,不是员工真实密码登录、浏览器全流程或敏感字段解密验收;生产密钥来源/保管与灾备可用性仍未证明,不索取聊天明文或擅自换密钥。 +- 实测RPO为7小时2分52秒。分阶段耗时不包含完整清理/后验及切换过程,不将阶段小计称为完整RTO或SLA。 +- 本次入口已消费,无需重跑。历史NO_GO与冻结包不改,当前状态按绑定review/execution/source三摘要的实际回执及task最新记录判断;后续扩大范围或再恢复需重新规划,不复用单次授权。 + ## 银行回单系统失败恢复 - `system_failed`、`key_failed`、`snapshot_failed`不是普通识别失败。管理员先依据安全原因码排查数据库可用性、密钥配置/版本或付款业务与银行快照;不输出账号、密钥或原始识别响应,不随意生成新密钥替换旧密钥。 @@ -364,3 +373,25 @@ PROJECT_CLOSURE_CHECKLIST_ENABLED=true - task validate、Trellis governance 和任务要求的必要门禁通过。 - 历史 Issue CSV 保持审计可追溯,但不要求作为当前状态合同。 - 中文分批 commit。 + + +## 2026-09-17 本地借款与付款责任候选 + +本地源码新增公司向个人借入的人民币本金台账及还本;登记出借人/核实OA身份、上传原借入和核对材料、确认期初余额后,可将同一出借人的多笔借入分配到一笔还款。付款批次独立复核、线下网银制单/回单、登记成功后核销本金,会计交接单独确认。未知或在途银行结果保持占用;不得仅用取消理由解锁重付。此段描述待发布候选,不表示公网已具备该入口。 + +付款进度候选显示当前可处理人员、原站内提醒对象及脱敏发送记录。企微个人消息仅按`wecom_app_sent`成功认定已发送,不等于已读;无成功记录也不能单凭页面断言消息一定没有发出。多接收人anchor记录不能证明人人送达。真实批次接收人/送达仍须在另行授权的范围核实际记录。 + +ERP专属实现与后台同步已从本地候选移除,历史ERP数据保留只读且不能重试;共享企微、通知和outbox保留。历史`erp_sync=force_off`证据不改写;当前候选的运行时不再提供该开关对应的ERP执行能力。工资、绩效、预算和企微考勤摘要不在本次删除范围。 + +验证入口为`09-17-company-loan-principal-repayment`与`09-17-erp-exclusive-retirement`的implement回执;合成专库使用`RUN_ISOLATED_LOAN_DB_TEST=1 node scripts/qa/test-company-loan-db.cjs`,浏览器另设`RUN_ISOLATED_LOAN_BROWSER=1`且先构建client。BIFF8使用已有专用Python并传`PAYMENT_ORDER_PYTHON_BIN`,不自动安装或联网;所有身份、密钥和材料运行内生成,不读生产配置。独立只读复核、真实银行样本/恢复保管事实、迁移发布及真实录入付款仍是独立门禁。 + + +## 业务编号增量切换(准备,未启用生产) + +执行合同为 `09-18-reimbursement-periodic-numbering`。先部署加法迁移和兼容新旧规则的所有写入实例,策略表无记录时仍发旧号,不重跑seed切换。旧单和未编号旧草稿不回填;不得清理编号计数表。切换前经另行批准取得格式分布/缺号及同格式占号摘要,完成独立复核,确认银行材料和来源快照兼容,再确定未来生效时刻;优先准备就绪后的月初,不预定某个日期上线。 + +管理入口 `node server/src/scripts/manage-business-numbering.js --help`;status只输出版本、启用状态、生效时刻及需要专门兼容复核的旧周期类型,activate需要含时区的未来ISO时间、操作人UUID、变更引用与 `--writers-compatible`。该标记只是操作人对全部写入实例兼容的明确确认,不能代替发布核验。首次激活在等待策略锁及检查后重新取时,已过期的生效点拒绝。若旧报销/采购/盘点取号配置本身已使用新月/年日期段,启用/恢复会保守拒绝并要求专门兼容处理,不能擅自清零或改写旧规则;这与源表里已有若干同格式号码的正常接续不同。入口不读取.env;正式数据库连接只能通过届时批准的注入方式提供,本轮不提供或执行生产连接命令。 + +pause/resume保留首次生效时刻、全部已发号码和高水位;暂停仅影响以后创建对象,已归属v2的采购草稿仍按v2首次提交取号。策略变更与既有审计同事务,启停等待已持策略锁的业务事务结束。回退代码必须仍兼容两类号,不删除新增表/版本字段、不减计数,也不重新导出旧批次为新编号。 + +本地验证命令:`cd server && npm run prisma:validate && npm test`;隔离PG为 `RUN_ISOLATED_NUMBERING_DB_TEST=1 node scripts/qa/test-business-numbering-db.cjs`,使用本机已备好的PostgreSQL镜像和合成运行环境,禁外联/宿主端口,包含合成借款HTTP整链及银行文件测试。需沿用已有受控Python运行环境;不得因缺依赖下载或读取真实配置。本次新证据在 `artifacts/qa-layout/business-numbering-20260918`,旧失败与冻结材料保留;仍须另行完成原G20完整门禁、独立复核及授权发布后的公网验收。 diff --git a/docs/需求-统一审批中心规约.md b/docs/需求-统一审批中心规约.md index 2cc7718d..99f1ab37 100644 --- a/docs/需求-统一审批中心规约.md +++ b/docs/需求-统一审批中心规约.md @@ -127,7 +127,7 @@ OAFLOW1 后,OA 主办的报销审批规则增加 `ownerSelfFallbackRole`。当 --- -### 2.5 财务本人报销与独立付款复核(2026-09-12 已批准,待发布) +### 2.5 财务本人报销与独立付款复核(2026-09-12 已部署229867f5) 财务本人报销的新提交及重提,由非申请人的在职总经理一次审批,不再重复经理、总经理、财务三个审批步骤。以报销归属人持有明确的 `finance` 角色识别,不硬编码姓名;财务代填其他员工的单据不属于此分支。存在第二位财务也不改变本规则。 @@ -135,7 +135,7 @@ OAFLOW1 后,OA 主办的报销审批规则增加 `ownerSelfFallbackRole`。当 - 没有合法总经理时,预检和提交明确阻断,预算预检开关关闭也不能绕过。 - 该单步任务只能转交给另一位合法总经理。审批时实际操作者及委托方仍须具备有效总经理资格,不因指定人分配、转交或委托免检。 - 报销通过后仍由财务创建付款批次,包括财务自己的报销;非制单人的合格上级另行复核付款,财务再下载制单、通过网银付款并登记回单。报销审批不等于付款复核,下载成功不等于付款完成。 -- 普通员工原审批链及仅持CEO角色的负责人财务兜底不变;在途与历史快照不自动改写。此规则属于当前09-04任务的新候选,不宣称已部署。 +- 普通员工原审批链及仅持CEO角色的负责人财务兜底不变;在途与历史快照不自动改写。此规则由09-04任务的229867f5发布,公网和运行镜像检查通过;真实业务闭环证据仍来自部署前隔离数据库/浏览器验证,不混称为公网真人单据测试。 ## 3. 入口与任务视图 diff --git a/scripts/qa/browser-environment-probe.mjs b/scripts/qa/browser-environment-probe.mjs index f703a4f0..ab8edbe8 100644 --- a/scripts/qa/browser-environment-probe.mjs +++ b/scripts/qa/browser-environment-probe.mjs @@ -66,11 +66,28 @@ export async function observeGuestEntry(context, baseURL, timeout = 30000) { return observed; } +async function observeNativeEngine(engine, name, owner) { + const server = await engine.launchServer({ headless: true, host: "127.0.0.1" }); + let browser; + try { + const browserNamespace = namespaceFingerprint(server.process().pid); + if (browserNamespace !== owner.ownedNamespace) throw new Error("environment_browser_namespace_mismatch"); + browser = await engine.connect(server.wsEndpoint()); + const context = await browser.newContext({ serviceWorkers: "block" }); + const page = await context.newPage(); + const online = await page.evaluate(() => navigator.onLine); + return { name, version: browser.version(), valid: online === true, online, browserNamespace, browserMatchesOwnedChild: true, noNavigatorOverride: true }; + } finally { + await browser?.close().catch(() => {}); + await server.close(); + } +} + async function main(mode, baseURL) { if (!["native", "guest"].includes(mode)) throw new Error("environment_probe_mode_invalid"); const { context: owner } = verifyManagedEnvironment(); const require = createRequire(path.join(root, "client/package.json")); - const { chromium } = require("@playwright/test"); + const { chromium, webkit } = require("@playwright/test"); const server = await chromium.launchServer({ headless: true, host: "127.0.0.1" }); let browser; let context; @@ -89,6 +106,8 @@ async function main(mode, baseURL) { const page = await context.newPage(); const online = await page.evaluate(() => navigator.onLine); result = { valid: online === true, scope: "blank-page-native-probe", online, errorKind: online === true ? null : online === false ? "native_online_observed_false" : "native_online_observation_missing", browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true }; + result.engines = [{ name: "chromium", version: browser.version(), valid: online === true, online, browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true }, await observeNativeEngine(webkit, "webkit", owner)]; + result.valid = result.engines.every(engine => engine.valid); } else result = { ...await observeGuestEntry(context, baseURL), browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true }; verifyManagedEnvironment(); } catch (error) { diff --git a/scripts/qa/budget-pool-db-fixture.cjs b/scripts/qa/budget-pool-db-fixture.cjs new file mode 100644 index 00000000..1001cf2d --- /dev/null +++ b/scripts/qa/budget-pool-db-fixture.cjs @@ -0,0 +1,151 @@ +const assert = require('node:assert/strict'); +const { randomUUID } = require('node:crypto'); +const { createRequire } = require('node:module'); +const path = require('node:path'); +const { setTimeout: delay } = require('node:timers/promises'); +const req = createRequire(path.resolve(__dirname, '../../server/package.json')); +const { PrismaClient } = req('@prisma/client'); +const { PrismaPg } = req('@prisma/adapter-pg'); +const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = req('./src/services/purchaseBudget'); +const { authorizeBudgetForEntity, reverseBudgetForEntity } = req('./src/services/travelPayments'); +const { lockBudgetPoolsForUpdate, resolveApplicableBudgetPoolByContext } = req('./src/services/budgetPool'); + +module.exports = async function runBudgetFixture(pool) { + const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) }); + const passed = []; const failures = []; + const tx = fn => prisma.$transaction(fn); + const actorId = randomUUID(); + const createPool = (used = '0.00') => prisma.budgetPool.create({ data: { + code: `SYN-${randomUUID()}`, name: '合成预算池', period_start: new Date('2026-01-01'), + period_end: new Date('2026-12-31'), allocated_amount: '1000.00', used_amount: used, + } }); + const input = (id, amount = '60.00') => ({ budgetPoolId: id, entityType: 'purchase_request', + entityId: randomUUID(), amount, actorId, note: 'synthetic concurrent budget check' }); + async function check(name, fn) { + try { await fn(); passed.push(name); console.log(JSON.stringify({ check: name, status: 'PASS' })); } + catch (error) { failures.push(name); console.log(JSON.stringify({ check: name, status: 'FAIL', code: error.code ?? error.name, + fixtureLine: /budget-pool-db-fixture.cjs:(\d+)/.exec(error.stack ?? '')?.[1], + reason: /transaction client/.test(error.message) ? 'transaction_client_required' : undefined, + causeName: error.cause?.name, causeCode: error.cause?.code })); } + } + // Both calls must reach a real database lock wait before release. The old + // implementation waits at UPDATE after reading stale usage; the fixed one + // waits before its authoritative read. No simulated locks or timing guess. + async function contend(id, calls, standalone = false, beforeUnlock) { + const blocker = await pool.connect(); let results; + try { + await blocker.query('BEGIN'); + await blocker.query('SELECT id FROM budget_pools WHERE id=$1 FOR NO KEY UPDATE', [id]); + const jobs = calls.map(fn => standalone ? fn() : tx(fn)); + let completed; + results = Promise.allSettled(jobs).then(value => { completed = value; return value; }); + const deadline = Date.now() + 2500; + let waiting = 0; + while (Date.now() < deadline) { + const r = await pool.query("SELECT count(*)::int n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%budget_pools%'"); + waiting = r.rows[0].n; + if (completed?.some(r => r.status === 'rejected')) throw completed.find(r => r.status === 'rejected').reason; + if (waiting >= calls.length) break; + await delay(10); + } + assert.equal(waiting, calls.length, 'all contenders reached database lock wait'); + if (beforeUnlock) await beforeUnlock(blocker); + await blocker.query('COMMIT'); + const settled = await results; + for (const r of settled) if (r.status === 'rejected') throw r.reason; + return settled.map(r => r.value); + } finally { + await blocker.query('ROLLBACK'); blocker.release(); + if (results) await results; + } + } + const used = async id => (await prisma.budgetPool.findUniqueOrThrow({ where: { id } })).used_amount.toFixed(2); + try { + await check('reserve_same_pool_accumulates_both_commits', async () => { + const p = await createPool(); + await contend(p.id, [t => reserveBudgetForEntity(t, input(p.id)), t => reserveBudgetForEntity(t, input(p.id))]); + assert.equal(await used(p.id), '120.00'); + assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: p.id } }), 2); + }); + await check('purchase_and_card_authorization_share_pool', async () => { + const p = await createPool(); + await contend(p.id, [t => reserveBudgetForEntity(t, input(p.id)), t => authorizeBudgetForEntity(t, input(p.id))]); + assert.equal(await used(p.id), '120.00'); + }); + await check('release_and_reserve_preserve_both_deltas', async () => { + const p = await createPool('100.00'); const reserved = input(p.id, '40.00'); + await prisma.budgetLedgerEntry.create({ data: { budget_pool_id: p.id, entity_type: reserved.entityType, + entity_id: reserved.entityId, kind: 'reserve', amount: reserved.amount } }); + await contend(p.id, [t => releaseReservedBudgetIfAny(t, reserved), t => reserveBudgetForEntity(t, input(p.id, '20.00'))]); + assert.equal(await used(p.id), '80.00'); + }); + await check('card_reverse_and_reserve_preserve_both_deltas', async () => { + const p = await createPool('100.00'); + await contend(p.id, [t => reverseBudgetForEntity(t, input(p.id, '40.00')), t => reserveBudgetForEntity(t, input(p.id, '20.00'))]); + assert.equal(await used(p.id), '80.00'); + }); + await check('ledger_and_usage_rollback_together', async () => { + const p = await createPool(); + await assert.rejects(tx(async t => { await reserveBudgetForEntity(t, input(p.id)); throw new Error('synthetic rollback'); }), /synthetic rollback/); + assert.equal(await used(p.id), '0.00'); + assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: p.id } }), 0); + }); + await check('concurrent_duplicate_reserve_rechecks_ledger_after_wait', async () => { + const p = await createPool(); const args = input(p.id); + const call = async t => { try { return await reserveBudgetForEntity(t, args); } + catch (error) { assert.equal(error.details?.reasonCode, 'purchase_budget.reserve_already_exists'); return 'duplicate'; } }; + const results = await contend(p.id, [call, call]); + assert.equal(results.filter(r => r === 'duplicate').length, 1); + assert.equal(await used(p.id), '60.00'); + assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: p.id } }), 1); + }); + await check('release_multi_pool_reverse_input_order_has_no_deadlock', async () => { + const a = await createPool('80.00'); const b = await createPool('80.00'); + const left = input(a.id, '40.00'); const right = input(b.id, '40.00'); + for (const [entity, pools] of [[left, [a, b]], [right, [b, a]]]) { + for (const p of pools) await prisma.budgetLedgerEntry.create({ data: { + budget_pool_id: p.id, entity_type: entity.entityType, entity_id: entity.entityId, kind: 'reserve', amount: '40.00', + } }); + } + await contend([a.id, b.id].sort()[0], [t => releaseReservedBudgetIfAny(t, left), t => releaseReservedBudgetIfAny(t, right)]); + assert.equal(await used(a.id), '0.00'); assert.equal(await used(b.id), '0.00'); + const second = await tx(t => releaseReservedBudgetIfAny(t, left)); assert.equal(second.applied, false); + }); + await check('release_retains_nonnegative_floor', async () => { + const p = await createPool('20.00'); const args = input(p.id, '30.00'); + await prisma.budgetLedgerEntry.create({ data: { budget_pool_id: p.id, entity_type: args.entityType, + entity_id: args.entityId, kind: 'reserve', amount: args.amount } }); + await tx(t => releaseReservedBudgetIfAny(t, args)); assert.equal(await used(p.id), '0.00'); + }); + await check('different_pools_do_not_share_a_global_lock', async () => { + const a = await createPool(); const b = await createPool(); + await tx(async t => { + await lockBudgetPoolsForUpdate(t, [a.id]); + await tx(other => reserveBudgetForEntity(other, input(b.id))); + }); + assert.equal(await used(b.id), '60.00'); + }); + await check('candidate_archived_while_waiting_is_not_silently_replaced', async () => { + const department = await prisma.department.create({ data: { name: '合成候选变更' } }); + const p = await createPool(); await prisma.budgetPool.update({ where: { id: p.id }, data: { department_id: department.id } }); + const blocker = await pool.connect(); let pending; + try { + await blocker.query('BEGIN'); await blocker.query('SELECT id FROM budget_pools WHERE id=$1 FOR NO KEY UPDATE', [p.id]); + pending = tx(t => resolveApplicableBudgetPoolByContext(t, { departmentId: department.id, effectiveDate: new Date('2026-09-19') }, { forUpdate: true })) + .then(value => ({ value }), error => ({ error })); + const deadline = Date.now() + 2000; let waiting = 0; + while (Date.now() < deadline) { + waiting = (await pool.query("SELECT count(*)::int n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")).rows[0].n; + if (waiting) break; await delay(10); + } + assert.equal(waiting, 1); + await blocker.query("UPDATE budget_pools SET status='archived',archived_at=now() WHERE id=$1", [p.id]); + await blocker.query('COMMIT'); + assert.equal((await pending).error?.details?.reasonCode, 'budget.pool_changed'); + } finally { await blocker.query('ROLLBACK'); blocker.release(); if (pending) await pending; } + }); + await require('./budget-pool-http-fixture.cjs')({ prisma, pool, check, contend, used }); + console.log(JSON.stringify({ scope: 'real-services-real-isolated-postgresql', passed, failures })); + assert.equal(failures.length, 0, 'budget concurrency checks failed'); + } finally { await prisma.$disconnect(); } +}; diff --git a/scripts/qa/budget-pool-http-fixture.cjs b/scripts/qa/budget-pool-http-fixture.cjs new file mode 100644 index 00000000..188f9369 --- /dev/null +++ b/scripts/qa/budget-pool-http-fixture.cjs @@ -0,0 +1,201 @@ +const assert = require('node:assert/strict'); +const { randomUUID } = require('node:crypto'); +const { createRequire } = require('node:module'); +const path = require('node:path'); +const { setTimeout: delay } = require('node:timers/promises'); +const req = createRequire(path.resolve(__dirname, '../../server/package.json')); + +// Real Fastify routes and Prisma transactions; synthetic authorized identity. +// Authentication and browser behavior remain covered by the ordinary full gate. +module.exports = async ({ prisma, pool, check, contend, used }) => { + const app = req('fastify')({ logger: false }); + const department = await prisma.department.create({ data: { name: '合成预算并发部门' } }); + const employee = async role => prisma.employee.create({ data: { + email: `${randomUUID()}@example.invalid`, name: '合成预算用户', department_id: department.id, + title: '合成岗位', hire_date: new Date('2025-01-01'), bank_name: 'synthetic', + bank_account_encrypted: 'synthetic-unused', password_hash: 'synthetic-unused', + roles: { create: { role_code: role } }, + } }); + const owner = await employee('employee'); const manager = await employee('manager'); await employee('finance'); + const company = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: '合成公司' } }); + const costCenter = await prisma.costCenter.create({ data: { code: 'SYN', name: '合成成本中心', legal_entity_id: company.id } }); + const supplier = await prisma.supplier.create({ data: { code: `SYN-${randomUUID()}`, name: '合成供应商', legal_entity_id: company.id } }); + const category = await prisma.documentCategory.create({ data: { code: `SYN-${randomUUID()}`, name: '合成采购类别', + entity_type: 'purchase_request', sequence_prefix: 'SYN', reset_period: 'yearly' } }); + const expense = await prisma.expenseType.create({ data: { code: `SYN-${randomUUID()}`, name: '合成办公费', + category: 'office', sort_order: 999, requires_invoice: false } }); + await prisma.approvalRule.create({ data: { name: '合成财务审批', min_amount: '0', level1_role: 'finance', priority: 1 } }); + for (const entity_type of ['purchase_request', 'reimbursement']) { + await prisma.workflowDefinition.create({ data: { entity_type, name: '合成预算流程', version: 1, + definition_json: { steps: [{ id: 'approve', name: '合成审批', type: 'approval', assignee_rule: { type: 'role', value: 'manager' } }] } } }); + } + app.decorate('prisma', prisma); + const config = { appTimezone: 'Asia/Shanghai', budgetPrecheckEnabled: false, budgetControlMode: 'block', + emailEnabled: false, imNotifierEnabled: false, cutoverConsoleEnabled: false, reimbursementInvoiceGuardEnabled: false }; + app.decorate('config', config); + const actor = { ...owner, roleCodes: ['employee'], permissionCodes: ['*'] }; + app.decorate('hasPermission', () => true); + app.decorate('requirePermission', () => async request => { request.employee = actor; }); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ + code: error.code ?? 'SYS_001', reason: error.details?.reasonCode ?? null, + precheckCodes: error.details?.precheck?.map?.(item => item.code) ?? [], + })); + await app.register(req('./src/routes/purchaseRequests'), { prefix: '/api' }); + await app.register(req('./src/routes/reimbursements'), { prefix: '/api' }); + await app.register(req('./src/routes/travelTransactions'), { prefix: '/api' }); + await app.ready(); + let selected; + const today = new Date(); + async function setup({ enabled, mode = 'block' }) { + config.budgetPrecheckEnabled = enabled; config.budgetControlMode = mode; + await prisma.featureFlag.upsert({ where: { flag_key: 'budget_precheck_v1' }, update: { override_mode: enabled ? 'force_on' : 'force_off' }, + create: { flag_key: 'budget_precheck_v1', override_mode: enabled ? 'force_on' : 'force_off', rollout_type: 'global' } }); + await prisma.budgetPool.updateMany({ data: { status: 'archived', archived_at: today } }); + selected = await prisma.budgetPool.create({ data: { code: `SYN-${randomUUID()}`, name: '合成共享预算', + period_start: new Date(today.getFullYear(), 0, 1), period_end: new Date(today.getFullYear(), 11, 31), + allocated_amount: '100.00', used_amount: '0.00', warning_threshold_percent: 100 } }); + } + async function purchase(explicit = false, amount = '60.00') { + const row = await prisma.purchaseRequest.create({ data: { applicant_id: owner.id, department_id: department.id, + title: '合成并发采购', legal_entity_id: company.id, cost_center_id: costCenter.id, supplier_id: supplier.id, + document_category_id: category.id, request_no: `SYN-${randomUUID()}`, total_amount: amount, + budget_pool_id: explicit ? selected.id : null, + items: { create: { description: '合成用品', quantity: '1.00', unit_price: amount, line_amount: amount } } } }); + return { id: row.id, type: 'purchase_request', url: `/api/purchase-requests/${row.id}/submit` }; + } + async function reimbursement(amount = '60.00') { + const row = await prisma.reimbursementForm.create({ data: { owner_id: owner.id, submitter_id: owner.id, + department_id: department.id, reimbursement_date: today, form_number: `SYN-${randomUUID()}`, total_amount: amount, + items: { create: { expense_type_id: expense.id, expense_date: today, amount, description: '合成并发费用' } } }, include: { items: true } }); + await prisma.invoicePool.create({ data: { owner_employee_id: owner.id, uploaded_by_employee_id: owner.id, + storage_path: 'synthetic/not-a-real-file', original_name: 'synthetic.txt', mime_type: 'text/plain', file_size: 1, + file_sha256: randomUUID(), status: 'bound', linked_reimbursement_id: row.id, linked_item_id: row.items[0].id, + total_amount: amount, ocr_completed_at: today } }); + return { id: row.id, type: 'reimbursement', url: `/api/reimbursements/${row.id}/submit` }; + } + const submit = entity => () => app.inject({ method: 'POST', url: entity.url }); + async function assertRace(entities, expectedStatuses, expectedUsage) { + const responses = await contend(selected.id, entities.map(submit), true); + // Only safe codes are logged for unexpected route responses. + if (responses.some(r => !expectedStatuses.includes(r.statusCode))) { + console.log(JSON.stringify({ routeDiagnostics: responses.map(r => ({ status: r.statusCode, ...(r.statusCode >= 400 ? r.json() : {}) })) })); + } + assert.deepEqual(responses.map(r => r.statusCode).sort(), expectedStatuses.slice().sort()); + assert.equal(await used(selected.id), expectedUsage); + const winners = responses.filter(r => r.statusCode === 200).length; + assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: selected.id } }), winners); + assert.equal(await prisma.workflowInstance.count({ where: { entity_id: { in: entities.map(e => e.id) } } }), winners); + } + try { + for (const enabled of [false, true]) { + for (const explicit of [false, true]) { + await check(`purchase_block_concurrency_gate_${enabled}_explicit_${explicit}`, async () => { + await setup({ enabled }); + await assertRace([await purchase(explicit), await purchase(explicit)], [200, 409], '60.00'); + }); + } + await check(`reimbursement_block_concurrency_gate_${enabled}`, async () => { + await setup({ enabled }); + await assertRace([await reimbursement(), await reimbursement()], [200, 409], '60.00'); + }); + await check(`mixed_purchase_reimbursement_block_gate_${enabled}`, async () => { + await setup({ enabled }); + await assertRace([await purchase(), await reimbursement()], [200, 409], '60.00'); + }); + await check(`mixed_warn_keeps_both_writes_gate_${enabled}`, async () => { + await setup({ enabled, mode: 'warn' }); + await assertRace([await purchase(), await reimbursement()], [200, 200], '120.00'); + }); + } + await check('exact_capacity_and_duplicate_submit', async () => { + await setup({ enabled: true }); + const a = await purchase(false, '40.00'); const b = await reimbursement(); + // Precheck block also blocks warning threshold hits under its existing rule. + await prisma.budgetPool.update({ where: { id: selected.id }, data: { warning_threshold_percent: 101 } }); + await assertRace([a, b], [200, 200], '100.00'); + assert.equal((await submit(a)()).statusCode, 409); + assert.equal(await used(selected.id), '100.00'); + }); + await check('submit_ledger_failure_rolls_back_usage_and_workflow', async () => { + await setup({ enabled: false }); const form = await reimbursement(); + await pool.query(`CREATE FUNCTION synthetic_budget_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic failure'; END $$; + CREATE TRIGGER synthetic_budget_failure BEFORE INSERT ON budget_ledger_entries FOR EACH ROW EXECUTE FUNCTION synthetic_budget_failure()`); + try { + assert.equal((await submit(form)()).statusCode, 500); + assert.equal(await used(selected.id), '0.00'); + assert.equal(await prisma.workflowInstance.count({ where: { entity_id: form.id } }), 0); + assert.equal((await prisma.reimbursementForm.findUniqueOrThrow({ where: { id: form.id } })).status, 'draft'); + } finally { await pool.query('DROP TRIGGER synthetic_budget_failure ON budget_ledger_entries; DROP FUNCTION synthetic_budget_failure()'); } + }); + for (const kind of ['purchase', 'reimbursement']) { + await check(`legacy_block_exact_decimal_capacity_${kind}`, async () => { + await setup({ enabled: false }); + await prisma.budgetPool.update({ where: { id: selected.id }, data: { allocated_amount: '0.30', used_amount: '0.10' } }); + const entity = kind === 'purchase' ? await purchase(false, '0.20') : await reimbursement('0.20'); + assert.equal((await submit(entity)()).statusCode, 200); + assert.equal(await used(selected.id), '0.30'); + }); + } + await check('allocation_reduced_during_wait_is_used_by_both_submitters', async () => { + await setup({ enabled: true }); + const entities = [await purchase(), await reimbursement()]; + const responses = await contend(selected.id, entities.map(submit), true, + blocker => blocker.query('UPDATE budget_pools SET allocated_amount=50 WHERE id=$1', [selected.id])); + assert.deepEqual(responses.map(r => r.statusCode), [409, 409]); + assert.equal(await used(selected.id), '0.00'); + assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: selected.id } }), 0); + }); + for (const table of ['workflow_history', 'audit_logs']) { + await check(`submit_${table}_failure_rolls_back_all_writes`, async () => { + await setup({ enabled: false }); const form = await reimbursement(); + if (table === 'audit_logs') await prisma.reimbursementForm.update({ where: { id: form.id }, data: { submitter_id: manager.id } }); + // Table is from the fixed two-element allowlist above, not user input. + await pool.query(`CREATE FUNCTION synthetic_budget_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic failure'; END $$; + CREATE TRIGGER synthetic_budget_failure BEFORE INSERT ON ${table} FOR EACH ROW EXECUTE FUNCTION synthetic_budget_failure()`); + try { + assert.equal((await submit(form)()).statusCode, 500); + assert.equal(await used(selected.id), '0.00'); + assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: selected.id } }), 0); + assert.equal(await prisma.workflowInstance.count({ where: { entity_id: form.id } }), 0); + assert.equal((await prisma.reimbursementForm.findUniqueOrThrow({ where: { id: form.id } })).status, 'draft'); + } finally { await pool.query(`DROP TRIGGER synthetic_budget_failure ON ${table}; DROP FUNCTION synthetic_budget_failure()`); } + }); + } + await check('authorization_and_refund_share_entity_before_pool_lock_order', async () => { + await setup({ enabled: false }); + // Exercise the frozen import handler only in this synthetic Fastify app; + // no deployment flag, external integration or browser scenario is enabled. + config.travelSyncEnabled = true; + const travel = await prisma.travelRequest.create({ data: { applicant_employee_id: owner.id, department_id: department.id, + legal_entity_id: company.id, cost_center_id: costCenter.id, budget_pool_id: selected.id, + purpose: '合成锁序验证', trip_type: 'domestic', start_date: today, end_date: today, estimated_amount: '60.00' } }); + const authorization = await prisma.cardAuthorization.create({ data: { source: 'synthetic', external_auth_no: randomUUID(), + employee_id: owner.id, department_id: department.id, travel_request_id: travel.id, budget_pool_id: selected.id, + legal_entity_id: company.id, cost_center_id: costCenter.id, authorized_amount: '60.00' } }); + let refund; + try { + await prisma.$transaction(async t => { + await t.$queryRaw`SELECT id FROM card_authorizations WHERE id=${authorization.id}::uuid FOR NO KEY UPDATE`; + refund = app.inject({ method: 'POST', url: '/api/travel/transactions/import', payload: { transactions: [{ + source: 'synthetic', externalTxnId: randomUUID(), employeeId: owner.id, travelRequestId: travel.id, + cardAuthorizationId: authorization.id, amount: '20.00', currency: 'CNY', txnType: 'refund', status: 'settled', + }] } }); + // Start the injection and observe its wait on the authorization. + refund = Promise.resolve(refund); + const deadline = Date.now() + 2000; let waiting = 0; + while (Date.now() < deadline) { + waiting = (await pool.query("SELECT count(*)::int n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")).rows[0].n; + if (waiting) break; await delay(10); + } + assert.equal(waiting, 1); + await req('./src/services/travelPayments').authorizeBudgetForEntity(t, { budgetPoolId: selected.id, + entityId: authorization.id, amount: '60.00', actorId: owner.id, note: 'synthetic authorization' }); + }); + const response = await refund; + assert.equal(response.statusCode, 200); + assert.equal(response.json().data.reversedCount, 1); + assert.equal(await used(selected.id), '40.00'); + } finally { if (refund) await refund; config.travelSyncEnabled = false; } + }); + } finally { await app.close(); } +}; diff --git a/scripts/qa/business-numbering-db-fixture.cjs b/scripts/qa/business-numbering-db-fixture.cjs new file mode 100644 index 00000000..b49e7281 --- /dev/null +++ b/scripts/qa/business-numbering-db-fixture.cjs @@ -0,0 +1,209 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); +const { writeFileSync } = require('node:fs'); +const { spawnSync } = require('node:child_process'); +const { createRequire } = require('node:module'); +const { setTimeout: delay } = require('node:timers/promises'); +const req = createRequire(path.resolve(__dirname, '../../server/package.json')); +const { PrismaClient } = req('@prisma/client'); +const { PrismaPg } = req('@prisma/adapter-pg'); +const { resolveNumberingContext, issueBusinessNumber, reserveProjectNumber, setNumberingPolicy } = req('./src/services/businessNumbering'); +const { issueDocumentNumber, resolveDocumentSequenceConfig } = req('./src/services/documentSequence'); + +module.exports = async function runNumberingFixture(pool, scratch) { + const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) }); + const checks = []; + const instant = new Date('2026-10-01T00:00:00Z'); + try { + const department = await prisma.department.create({ data: { name: '合成编号部门' } }); + const actor = await prisma.employee.create({ data: { + email: `${randomUUID()}@example.invalid`, name: '合成编号用户', department_id: department.id, + title: '合成岗位', hire_date: new Date('2025-01-01'), bank_name: 'synthetic', + bank_account_encrypted: 'synthetic-unused', password_hash: 'synthetic-unused', + } }); + const baseDraft = { applicant_id: actor.id, department_id: department.id, title: '合成采购' }; + const oldDraft = await prisma.purchaseRequest.create({ data: baseDraft }); + assert.equal(oldDraft.numbering_version, 1); + assert.equal(await prisma.businessNumberPolicy.count(), 0); + const oldContext = await prisma.$transaction(tx => resolveNumberingContext(tx, { now: instant })); + assert.equal(oldContext.version, 1); + const oldForm = await prisma.reimbursementForm.create({ data: { form_number: 'BX-20260918-0500', + submitter_id: actor.id, owner_id: actor.id, department_id: department.id, reimbursement_date: instant } }); + const oldBatch = await prisma.paymentBatch.create({ data: { batch_no: 'PB-20260917-001', name: '合成旧批次', + entity_type: 'reimbursement', created_by: actor.id, + items: { create: { entity_id: oldForm.id, entity_code: oldForm.form_number, entity_title: '合成报销', amount: '1.00', snapshot_json: {} } } }, include: { items: true } }); + checks.push('migration_inactive_existing_draft_legacy'); + const effectiveAt = new Date('2026-09-01T00:00:00Z'); + const change = (action, more = {}) => prisma.$transaction(tx => setNumberingPolicy(tx, { + action, actorId: actor.id, reference: 'synthetic-numbering', writersCompatible: true, + now: new Date('2026-08-31T00:00:00Z'), ...more, + })); + await prisma.documentSequence.create({ data: { document_type: 'reimbursement', prefix: 'BX', date_format: 'YYYYMM', + separator: '-', number_length: 4, reset_period: 'none', current_number: 12 } }); + await assert.rejects(change('activate', { effectiveAt }), /compatibility review/); + assert.equal(await prisma.businessNumberPolicy.count(), 0); + assert.equal((await prisma.documentSequence.findUnique({ where: { document_type: 'reimbursement' } })).current_number, 12); + // Synthetic legacy-rule setup only; the activation operation itself never edits it. + await prisma.documentSequence.update({ where: { document_type: 'reimbursement' }, data: { date_format: 'YYYYMMDD' } }); + checks.push('overlapping_active_legacy_rule_blocks_activation_without_rewriting_it'); + await change('activate', { effectiveAt }); + const activeConfig = await resolveDocumentSequenceConfig(prisma, { documentType: 'reimbursement' }); + assert.equal(activeConfig.dateFormat, 'YYYYMM'); assert.equal(activeConfig.resetPeriod, 'monthly'); + const newDraft = await prisma.$transaction(async tx => { + const ctx = await resolveNumberingContext(tx, { now: instant }); + return tx.purchaseRequest.create({ data: { ...baseDraft, numbering_version: ctx.version } }); + }); + assert.equal(newDraft.numbering_version, 2); + async function submitDraft(draft) { + return prisma.$transaction(async tx => { + const row = await tx.purchaseRequest.findUniqueOrThrow({ where: { id: draft.id } }); + const issued = await issueDocumentNumber(tx, { documentType: 'purchase_request', numberingVersion: row.numbering_version, now: instant }); + return tx.purchaseRequest.update({ where: { id: row.id }, data: { request_no: issued.documentNumber } }); + }); + } + assert.match((await submitDraft(oldDraft)).request_no, /^CG-\d{8}-0001$/); + await change('pause'); + assert.equal((await submitDraft(newDraft)).request_no, 'CG-2026-0001'); + assert.equal((await prisma.$transaction(tx => resolveNumberingContext(tx, { now: instant }))).version, 1); + await change('resume'); + assert.equal((await prisma.businessNumberPolicy.findUnique({ where: { version: 2 } })).effective_at.getTime(), effectiveAt.getTime()); + await assert.rejects(pool.query('UPDATE purchase_requests SET numbering_version=2 WHERE id=$1', [oldDraft.id]), error => error.code === '23514'); + await assert.rejects(pool.query("UPDATE business_number_policies SET effective_at='2020-01-01' WHERE version=2"), error => error.code === '23514'); + checks.push('stored_draft_version_cutover_pause_resume_config'); + + const concurrent = await Promise.all(Array.from({ length: 12 }, () => prisma.$transaction(async tx => { + const context = await resolveNumberingContext(tx, { now: instant }); + const issued = await issueBusinessNumber(tx, { family: 'reimbursement', ...context }); + return tx.reimbursementForm.create({ data: { form_number: issued.documentNumber, + submitter_id: actor.id, owner_id: actor.id, department_id: department.id, reimbursement_date: instant } }); + }))); + assert.equal(new Set(concurrent.map(row => row.form_number)).size, 12); + assert.deepEqual(concurrent.map(row => row.form_number).sort(), Array.from({ length: 12 }, (_, i) => `BX-202610-${String(i + 1).padStart(4, '0')}`)); + const deleted = concurrent.find(row => row.form_number.endsWith('0012')); + await prisma.reimbursementForm.delete({ where: { id: deleted.id } }); + const next = await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'reimbursement', now: instant })); + assert.equal(next.documentNumber, 'BX-202610-0013'); + const key = 'v2:reimbursement:BX:202610'; + await assert.rejects(prisma.$transaction(async tx => { + await issueBusinessNumber(tx, { family: 'reimbursement', now: instant }); + // Actual unique violation aborts the complete transaction including its counter. + await tx.reimbursementForm.create({ data: { form_number: oldForm.form_number, + submitter_id: actor.id, owner_id: actor.id, department_id: department.id, reimbursement_date: instant } }); + }), error => error.code === 'P2002'); + assert.equal((await prisma.businessNumberCounter.findUnique({ where: { key } })).current_number, 13n); + await assert.rejects(pool.query('UPDATE business_number_counters SET current_number=0 WHERE key=$1', [key]), error => error.code === '23514'); + await assert.rejects(pool.query('DELETE FROM business_number_counters WHERE key=$1', [key]), error => error.code === '23514'); + checks.push('twelve_concurrent_commits_unique_deletion_no_reuse_real_rollback'); + + // Existing same-format occupancy is scanned under the family lock; permanent + // high-water survives both imported-object deletion and process/client restart. + const projectData = { project_no: 'PRJ-2026-9999', name: '合成项目', client_name: '合成客户', owner_employee_id: actor.id }; + await prisma.$transaction(async tx => { await reserveProjectNumber(tx, projectData.project_no); await tx.project.create({ data: projectData }); }); + const projectNo = await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'project', now: instant })); + assert.equal(projectNo.documentNumber, 'PRJ-2026-10000'); + await prisma.project.delete({ where: { project_no: projectData.project_no } }); + const otherClient = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) }); + try { + assert.equal((await otherClient.$transaction(tx => issueBusinessNumber(tx, { family: 'project', now: instant }))).documentNumber, 'PRJ-2026-10001'); + } finally { await otherClient.$disconnect(); } + const imported = await prisma.project.create({ data: { ...projectData, project_no: 'PRJ-2027-0042' } }); + assert.equal((await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'project', now: new Date('2026-12-31T16:00:00Z') }))).documentNumber, 'PRJ-2027-0043'); + await prisma.project.delete({ where: { id: imported.id } }); + checks.push('occupied_format_expansion_year_boundary_manual_reservation_restart'); + const csvPath = path.join(scratch, 'synthetic-project.csv'); + writeFileSync(csvPath, `project_no,name,client_name,owner_employee_no +PRJ-2030-0042,synthetic,synthetic,${actor.employee_no} +`); + const dbUrl = `postgresql://synthetic@localhost/numbering_synthetic?host=${encodeURIComponent(pool.options.host)}`; + const cliEnv = { PATH: process.env.PATH, LANG: 'C.UTF-8', DATABASE_URL: dbUrl }; + const runCli = (file, args) => { + const result = spawnSync(process.execPath, [path.resolve(__dirname, '../../server/src/scripts', file), ...args], { + cwd: path.resolve(__dirname, '../..'), env: cliEnv, encoding: 'utf8', timeout: 30000, + }); + assert.equal(result.status, 0, 'synthetic CLI must succeed'); + return JSON.parse(result.stdout); + }; + for (const apply of [false, true, true]) { + const result = runCli('import-project-ledgers.js', ['--type', 'projects', '--file', csvPath, '--json', ...(apply ? ['--apply'] : [])]); + assert.equal(result.errorCount, 0); + if (!apply) assert.equal(await prisma.project.count({ where: { project_no: 'PRJ-2030-0042' } }), 0); + } + assert.equal((await prisma.project.findUnique({ where: { project_no: 'PRJ-2030-0042' } })).name, 'synthetic'); + await prisma.projectMember.deleteMany({ where: { project: { project_no: 'PRJ-2030-0042' } } }); + await prisma.project.delete({ where: { project_no: 'PRJ-2030-0042' } }); + assert.equal((await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'project', now: new Date('2030-07-01T00:00:00Z') }))).documentNumber, 'PRJ-2030-0043'); + assert.equal(runCli('manage-business-numbering.js', ['status']).enabled, true); + assert.equal(runCli('manage-business-numbering.js', ['pause', actor.id, 'synthetic-cli-pause', '--writers-compatible']).enabled, false); + assert.equal(runCli('manage-business-numbering.js', ['resume', actor.id, 'synthetic-cli-resume', '--writers-compatible']).enabled, true); + checks.push('real_project_import_dry_run_apply_update_reservation_and_policy_cli'); + + + const assetCompany = await prisma.legalEntity.create({ data: { code: 'SYNTHETIC-NUMBERING', name: '合成资产公司' } }); + const assetCenter = await prisma.costCenter.create({ data: { code: 'SYNTHETIC-NUMBERING', name: '合成资产成本', legal_entity_id: assetCompany.id } }); + const assetCategory = await prisma.assetCategory.create({ data: { code: 'SYNTHETIC-NUMBERING', name: '合成资产分类' } }); + for (const prefix of ['EA.DOC+', '电子🧾']) { + await prisma.assetCard.create({ data: { asset_no: `${prefix}-4321`, name: '合成已有资产', + category_id: assetCategory.id, legal_entity_id: assetCompany.id, cost_center_id: assetCenter.id } }); + const issued = await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'asset_card', prefix, now: instant })); + assert.equal(issued.documentNumber, `${prefix}-4322`); + } + checks.push('existing_custom_asset_prefix_literal_regex_and_unicode_offset'); + + // A real held shared lock must block the admin's exclusive transition. + let release, acquired; + const held = new Promise(resolve => { release = resolve; }); + const ready = new Promise(resolve => { acquired = resolve; }); + const holder = prisma.$transaction(async tx => { await resolveNumberingContext(tx, { now: instant }); acquired(); await held; }); + await ready; + const pausing = change('pause'); + let blocked = false; + try { + for (let i = 0; i < 30; i++) { + const locks = await pool.query("SELECT count(*)::int AS count FROM pg_locks WHERE locktype='advisory' AND classid=1003 AND objid=0 AND NOT granted"); + if (locks.rows[0].count > 0) { blocked = true; break; } + await delay(10); + } + assert.ok(blocked, 'policy pause must wait for an in-flight writer'); + } finally { release(); await holder; await pausing; } + await change('resume'); + checks.push('real_policy_shared_exclusive_lock_boundary'); + + const mixed = await prisma.$transaction(async tx => { + const ctx = await resolveNumberingContext(tx, { now: instant }); + const issued = await issueBusinessNumber(tx, { family: 'payment_batch', ...ctx }); + return tx.paymentBatch.create({ data: { batch_no: issued.documentNumber, name: '合成新旧混合批次', + entity_type: 'reimbursement', created_by: actor.id, items: { create: [oldForm, concurrent[0]].map(form => ({ + entity_id: form.id, entity_code: form.form_number, entity_title: '合成来源', amount: '1.00', snapshot_json: {}, + })) } }, include: { items: true } }); + }); + assert.equal(mixed.batch_no, 'PB-2026-0001'); + assert.deepEqual(mixed.items.map(i => i.entity_code).sort(), [oldForm.form_number, concurrent[0].form_number].sort()); + assert.deepEqual(await prisma.paymentBatch.findUnique({ where: { id: oldBatch.id }, include: { items: true } }), oldBatch); + assert.deepEqual(await prisma.reimbursementForm.findUnique({ where: { id: oldForm.id } }), oldForm); + const freshPB = await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'payment_batch', now: new Date('2026-12-31T16:00:00Z') })); + assert.equal(freshPB.documentNumber, 'PB-2027-0001'); + checks.push('old_identifiers_and_item_snapshots_unchanged_mixed_sources'); + await require('./company-loan-http-fixture.cjs')(pool, scratch); + const loans = await prisma.companyLoan.findMany({ select: { loan_no: true } }); + const repayments = await prisma.loanRepayment.findMany({ select: { repayment_no: true } }); + const batches = await prisma.paymentBatch.findMany({ where: { entity_type: 'loan_repayment' }, select: { batch_no: true } }); + assert.ok(loans.length > 0 && loans.every(row => /^JK-\d{4}-\d{4,}$/.test(row.loan_no))); + assert.ok(repayments.length > 0 && repayments.every(row => /^HK-\d{4}-\d{4,}$/.test(row.repayment_no))); + assert.ok(batches.length > 0 && batches.every(row => /^PB-\d{4}-\d{4,}$/.test(row.batch_no))); + for (const [rows, column, family, prefix] of [[loans, 'loan_no', 'company_loan', 'JK'], [repayments, 'repayment_no', 'loan_repayment', 'HK']]) { + const groups = new Map(); + for (const row of rows) { + const [, year, serial] = row[column].split('-'); + if (!groups.has(year)) groups.set(year, []); + groups.get(year).push(Number(serial)); + } + for (const [year, serials] of groups) { + assert.deepEqual(serials.sort((a, b) => a - b), Array.from({ length: serials.length }, (_, i) => i + 1)); + assert.equal((await prisma.businessNumberCounter.findUnique({ where: { key: `v2:${family}:${prefix}:${year}` } })).current_number, BigInt(serials.length)); + } + } + checks.push('active_numbering_real_authenticated_loan_repayment_payment_idempotency'); + console.log(JSON.stringify({ status: 'PASS', numberingChecks: checks })); + } finally { await prisma.$disconnect(); } +}; diff --git a/scripts/qa/check-sensitive-recovery-contract.cjs b/scripts/qa/check-sensitive-recovery-contract.cjs new file mode 100644 index 00000000..3f36c2d8 --- /dev/null +++ b/scripts/qa/check-sensitive-recovery-contract.cjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// K0 only: synthetic, in-memory inputs. No env/config/backup loader or K2 entry. +const { randomBytes } = require("node:crypto"); +const { + resolveFieldEncryptionKey, + encryptStringOrThrow, + decryptStringOrThrow, +} = require("../../server/src/services/crypto"); + +function result(status, reason) { + return { status, reason }; +} + +// The expected value belongs to a synthetic fixture, never a real-data probe. +// Return fixed categories only, including when crypto throws or returns null. +function checkSyntheticSample(config, encrypted, expectedPlaintext) { + try { + const key = resolveFieldEncryptionKey(config); + if (!key) return result("STOP", "KEY_UNAVAILABLE_OR_INVALID"); + key.fill(0); + if (encrypted === null || encrypted === undefined || encrypted === "") { + return result("NOT_TESTED", "EMPTY_FIELD"); + } + if (typeof encrypted !== "string") return result("STOP", "INVALID_PAYLOAD"); + if (typeof expectedPlaintext !== "string" || !expectedPlaintext) { + return result("STOP", "SYNTHETIC_EXPECTATION_REQUIRED"); + } + const plaintext = decryptStringOrThrow(config, encrypted); + if (plaintext === null) return result("STOP", "INVALID_PAYLOAD"); + if (plaintext !== expectedPlaintext) return result("STOP", "PLAINTEXT_MISMATCH"); + return result("PASS", "SYNTHETIC_MATCH"); + } catch { + // Never call decryptStringSafe: its warning can contain a raw Error object. + return result("STOP", "DECRYPTION_FAILED"); + } +} + +function runSyntheticContract() { + const key = randomBytes(32); + const otherKey = Buffer.from(key); + otherKey[0] ^= 1; + try { + const config = { fieldEncryptionKey: key.toString("base64") }; + const wrongConfig = { fieldEncryptionKey: otherKey.toString("base64") }; + const plaintext = `synthetic-only-${randomBytes(16).toString("hex")}`; + const encrypted = encryptStringOrThrow(config, plaintext); + const parts = encrypted.split(":"); + const tag = Buffer.from(parts[2], "base64url"); + tag[0] ^= 1; + parts[2] = tag.toString("base64url"); + const cases = [ + ["correct", config, encrypted, "PASS", "SYNTHETIC_MATCH"], + ["missing", {}, encrypted, "STOP", "KEY_UNAVAILABLE_OR_INVALID"], + ["invalid_key", { fieldEncryptionKey: "invalid" }, encrypted, "STOP", "KEY_UNAVAILABLE_OR_INVALID"], + ["wrong", wrongConfig, encrypted, "STOP", "DECRYPTION_FAILED"], + ["tampered", config, parts.join(":"), "STOP", "DECRYPTION_FAILED"], + ["empty", config, "", "NOT_TESTED", "EMPTY_FIELD"], + ["malformed", config, "v1:invalid", "STOP", "INVALID_PAYLOAD"], + ]; + const checks = cases.map(([category, inputConfig, payload, status, reason]) => { + const actual = checkSyntheticSample(inputConfig, payload, plaintext); + return { category, ...actual, matched: actual.status === status && actual.reason === reason }; + }); + return { + scope: "K0_SYNTHETIC_ONLY", + status: checks.every((check) => check.matched) ? "PASS" : "FAIL", + checks, + originalKeySource: "UNKNOWN", + custody: "NOT_TESTED", + realDataDecryption: "NOT_TESTED", + }; + } finally { + key.fill(0); + otherKey.fill(0); + } +} + +if (require.main === module) { + let report; + try { + report = process.argv.length === 2 + ? runSyntheticContract() + : { scope: "K0_SYNTHETIC_ONLY", status: "FAIL", reason: "ARGUMENTS_NOT_ALLOWED" }; + } catch { + report = { scope: "K0_SYNTHETIC_ONLY", status: "FAIL", reason: "SYNTHETIC_CHECK_FAILED" }; + } + process.stdout.write(`${JSON.stringify(report)}\n`); + process.exitCode = report.status === "PASS" ? 0 : 1; +} + +module.exports = { checkSyntheticSample, runSyntheticContract }; diff --git a/scripts/qa/company-loan-browser.cjs b/scripts/qa/company-loan-browser.cjs new file mode 100644 index 00000000..93da27a0 --- /dev/null +++ b/scripts/qa/company-loan-browser.cjs @@ -0,0 +1,102 @@ +const assert = require('node:assert/strict'); +const { mkdtempSync } = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const root = path.resolve(__dirname, '../..'); +const req = createRequire(path.join(root, 'client/package.json')); +const { chromium, expect } = req('@playwright/test'); + +module.exports = async function runBrowser({ app, finance, ordinary, password, company, bank, prisma }) { + const output = mkdtempSync(path.join(root, 'artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-')); + const origin = await app.listen({ host: '127.0.0.1', port: 0 }); + const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] }); + const checks = []; + try { + for (const mobile of [false, true]) { + const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); + await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); + const page = await context.newPage(); + const mode = mobile ? 'mobile' : 'desktop'; + const target = mobile ? '/mobile/company-loans' : '/company-loans'; + await page.goto(`${origin}/login?redirect=${encodeURIComponent(target)}`); + await page.getByTestId('login-identifier').fill(finance.email); + await page.getByTestId('login-password').fill(password); + await page.getByTestId('login-submit').click(); + await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); + await page.getByRole('button', { name: '登记出借人', exact: true }).click(); + let dialog = page.getByRole('dialog'); + await dialog.getByLabel('公司主体').selectOption(company.id); + const lenderName = `合成浏览器出借人-${mode}`; + await dialog.getByLabel('出借人姓名').fill(lenderName); + await dialog.getByLabel('收款银行').selectOption(bank.id); + await dialog.getByLabel('开户行名称').fill('合成建行'); + await dialog.getByLabel('账户名', { exact: true }).fill(lenderName); + await dialog.getByLabel('收款账号', { exact: true }).fill('6'.repeat(16)); + await dialog.getByRole('button', { name: '确认', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + await page.getByRole('tab', { name: '出借人', exact: true }).click(); + const lenderCard = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${company.name}`, exact: true }) }); + await lenderCard.getByRole('button', { name: '核实身份关联' }).click(); + dialog = page.getByRole('dialog'); + await dialog.getByLabel('出借人对应身份').selectOption('external'); + await dialog.getByLabel('说明', { exact: true }).fill('合成浏览器非员工身份核实'); + await dialog.getByRole('button', { name: '确认', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + await expect(lenderCard).toContainText('身份已核实'); + await page.getByRole('button', { name: '登记借入', exact: true }).click(); + dialog = page.getByRole('dialog'); + const lender = await prisma.loanLender.findFirstOrThrow({ where: { name: lenderName } }); + await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id); + const reference = `合成浏览器原借入-${mode}`; + await dialog.getByLabel('原借入凭据唯一引用').fill(reference); + await dialog.getByLabel('原始本金').fill('300.05'); + await dialog.getByLabel('核对日未还本金').fill('300.05'); + await dialog.getByLabel('实际到账日期').fill('2025-01-01'); + await dialog.getByLabel('余额核对截止日').fill('2026-01-01'); + await dialog.getByRole('button', { name: '确认', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + await page.getByRole('tab', { name: '借款台账', exact: true }).click(); + const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference } }); + const card = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${loan.loan_no}`, exact: true }) }); + await card.locator('summary').click(); + for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) { + const received = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST'); + await page.locator(`[id="${loan.id}-${kind}"]`).setInputFiles({ name: 'synthetic.pdf', mimeType: 'application/pdf', + buffer: Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n') }); + assert.equal((await received).status(), 200); + } + await card.getByRole('button', { name: '确认期初余额' }).click(); + dialog = page.getByRole('dialog'); + await dialog.getByLabel('代账余额确认引用').fill('合成代账核对'); + await dialog.getByRole('button', { name: '确认', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + await expect(card).toContainText('余额已确认'); + await page.getByRole('button', { name: '申请还本', exact: true }).click(); + dialog = page.getByRole('dialog'); + await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id); + await dialog.getByLabel(`分配 ${reference}`, { exact: true }).fill('100.05'); + await dialog.getByLabel('说明', { exact: true }).fill('合成浏览器部分还本'); + await dialog.getByRole('button', { name: '提交还款申请', exact: true }).click(); + await expect(page.getByText('原借款分配与会计交接', { exact: true })).toBeVisible(); + await expect(page.getByText('可复核:', { exact: false }).first()).toBeVisible(); + await expect(page.getByText('暂无企微个人消息发送成功记录', { exact: false }).first()).toBeVisible(); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1); + assert.equal(overflow, false, `${mode} horizontal overflow`); + await page.screenshot({ path: path.join(output, `${mode}-repayment.png`), fullPage: true }); + checks.push(`${mode}_real_login_register_materials_confirm_submit_and_named_progress`); + await context.close(); + } + const context = await browser.newContext({ serviceWorkers: 'block' }); + await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); + const page = await context.newPage(); + await page.goto(`${origin}/login?redirect=%2Fcompany-loans`); + await page.getByTestId('login-identifier').fill(ordinary.email); + await page.getByTestId('login-password').fill(password); + await page.getByTestId('login-submit').click(); + await expect(page.getByRole('heading', { name: '公司借款', exact: true })).not.toBeVisible(); + await page.waitForURL(/\/403$/); + checks.push('ordinary_employee_cannot_open_loan_ledger'); + await context.close(); + console.log(JSON.stringify({ stage: 'loan_browser', status: 'PASS', checks, output: path.relative(root, output) })); + } finally { await browser.close(); } +}; diff --git a/scripts/qa/company-loan-http-fixture.cjs b/scripts/qa/company-loan-http-fixture.cjs new file mode 100644 index 00000000..fdc0a2dc --- /dev/null +++ b/scripts/qa/company-loan-http-fixture.cjs @@ -0,0 +1,331 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { randomBytes, randomUUID } = require('node:crypto'); +const { createRequire } = require('node:module'); +const root = path.resolve(__dirname, '../..'); +const req = createRequire(path.join(root, 'server/package.json')); +const { PrismaClient } = req('@prisma/client'); +const { PrismaPg } = req('@prisma/adapter-pg'); +const { hashPassword } = req('./src/utils/crypto'); + +module.exports = async function runLoanHttpFixture(pool, scratch) { + const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) }); + const app = req('fastify')({ logger: false }); + const config = { + fieldEncryptionKey: randomBytes(32).toString('base64'), jwtSecret: randomBytes(48).toString('hex'), + accessTokenTtl: '30m', refreshTtlSeconds: 3600, refreshCookieName: 'synthetic_refresh', cookieSecure: false, + rateLimitEnabled: false, emailEnabled: false, appOrigin: 'http://127.0.0.1', + storageDir: path.join(scratch, 'materials'), storageDriver: 'local', appTimezone: 'Asia/Shanghai', + paymentBatchEnabled: true, + }; + app.decorate('config', config); app.decorate('prisma', prisma); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ + code: error.code ?? 'SYS_001', details: error.details ?? {}, + })); + await app.register(req('@fastify/cookie')); + await app.register(req('@fastify/multipart')); + await app.register(req('./src/plugins/auth')); + await app.register(req('./src/routes/auth'), { prefix: '/api/auth' }); + await app.register(req('./src/routes/companyLoans'), { prefix: '/api' }); + await app.register(req('./src/routes/paymentBatches'), { prefix: '/api' }); + for (const route of ['me', 'systemWorkspace', 'notifications', 'masterData', 'paymentAccounts']) { + await app.register(req(`./src/routes/${route}`), { prefix: '/api' }); + } + if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') { + await app.register(req('@fastify/static'), { root: path.join(root, 'client/dist') }); + app.setNotFoundHandler((request, reply) => request.url.startsWith('/api/') ? reply.code(404).send({ code: 'SYS_404' }) : reply.sendFile('index.html')); + } + await app.ready(); + const checks = []; + try { + const company = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: '合成公司' } }); + const bank = await prisma.bankInstitution.upsert({ where: { code: 'CCB' }, update: {}, create: { code: 'CCB', name: '合成建行' } }); + const department = await prisma.department.create({ data: { name: '合成财务部' } }); + await prisma.featureFlag.create({ data: { flag_key: 'payment_batch_v1', override_mode: 'force_on', rollout_type: 'global' } }); + const password = `SyntH!${randomBytes(16).toString('hex')}`; + const passwordHash = await hashPassword(password); + async function actor(roleCodes) { + const id = randomUUID(); + const employee = await prisma.employee.create({ data: { + id, email: `${id}@example.invalid`, name: '合成用户', department_id: department.id, + legal_entity_id: company.id, title: '合成岗位', hire_date: new Date('2025-01-01'), + bank_name: '合成', bank_account_encrypted: req('./src/services/crypto').encryptStringOrThrow(config, '7'.repeat(16)), password_hash: passwordHash, + roles: { create: roleCodes.map(role_code => ({ role_code })) }, + } }); + const login = await app.inject({ method: 'POST', url: '/api/auth/login', + headers: { 'x-device-fingerprint': randomUUID() }, payload: { identifier: employee.email, password } }); + assert.equal(login.statusCode, 200, 'real synthetic password login'); + return { id, email: employee.email, headers: { authorization: `Bearer ${login.json().data.accessToken}` } }; + } + const finance = await actor(['finance']); + const manager = await actor(['manager']); + const system = await actor(['system_admin']); + const admin = await actor(['admin']); + const ordinary = await actor(['employee']); + async function call(who, method, url, payload, status = 200, reason) { + const response = await app.inject({ method, url: `/api${url}`, headers: who?.headers ?? {}, payload }); + assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().details?.reasonCode ?? ''}`); + if (reason) assert.equal(response.json().details?.reasonCode, reason); + return response.json().data; + } + for (const who of [manager, system, admin, ordinary]) { + await call(who, 'GET', '/company-loans', undefined, 403); + await call(who, 'POST', '/loan-lenders', {}, 403); + } + await call(null, 'GET', '/company-loans', undefined, 401); + checks.push('real_login_and_explicit_finance_permissions'); + const bankAccount = '9'.repeat(16); // synthetic account, never log its value + const lenderInput = { requestKey: randomUUID(), legalEntityId: company.id, name: '合成出借人', + bankInstitutionId: bank.id, bankName: '合成银行', bankAccount, accountName: '合成出借人' }; + const lender = await call(finance, 'POST', '/loan-lenders', lenderInput); + assert.equal((await call(finance, 'POST', '/loan-lenders', lenderInput)).id, lender.id); + await call(finance, 'POST', '/loan-lenders', { ...lenderInput, name: 'different' }, 409, 'loan.duplicate_registration'); + assert.equal(lender.identityStatus, 'unverified'); + assert.ok(!JSON.stringify(lender).includes(bankAccount)); + assert.ok(!JSON.stringify(lender).includes('v1:')); + await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, { + expectedVersion: 1, employeeId: null, externalPersonConfirmed: false, reason: '合成核实', + }, 400, 'loan.invalid_input'); + const verified = await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, { + expectedVersion: 1, employeeId: null, externalPersonConfirmed: true, reason: '合成非员工核实', + }); + assert.equal(verified.identityStatus, 'verified'); + checks.push('lender_encryption_identity_confirmation_and_idempotency'); + const input = { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成原借入凭据一', + originalPrincipal: '1000.10', openingOutstandingPrincipal: '800.05', + receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true }; + const loan = await call(finance, 'POST', '/company-loans', input); + assert.equal(loan.available, '0.00'); assert.equal(loan.outstanding, '800.05'); + assert.equal((await call(finance, 'POST', '/company-loans', input)).id, loan.id); + await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID() }, 409, 'loan.duplicate_registration'); + await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), receivedDate: '2025-02-30' }, 400); + await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required'); + const material = Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n'); + async function upload(kind, data = material, filename = 'synthetic.pdf', type = 'application/pdf', status = 200, loanId = loan.id) { + const boundary = `synthetic-${randomUUID()}`; + const payload = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]); + const response = await app.inject({ method: 'POST', url: `/api/company-loans/${loanId}/evidences/${kind}`, + headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, payload }); + assert.equal(response.statusCode, status, `upload ${kind}`); + return response.json().data; + } + await upload('agreement', Buffer.from(''), 'bad.pdf', 'application/pdf', 400); + const evidence = await upload('agreement'); await upload('incoming'); await upload('accounting_confirmation'); + await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required'); + await upload('adjustment'); + const confirmed = await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }); + assert.equal(confirmed.available, '800.05'); assert.equal(confirmed.version, 2); + await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 2, accountingConfirmationRef: '改写' }, 409, 'loan.confirmed_immutable'); + checks.push('historical_balance_evidence_and_outside_repayment_required'); + await call(ordinary, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, { reason: '合成读取' }, 403); + await call(finance, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, {}, 400); + const downloaded = await app.inject({ method: 'POST', url: `/api/company-loans/${loan.id}/evidences/${evidence.id}/download`, + headers: finance.headers, payload: { reason: '合成核对读取' } }); + assert.equal(downloaded.statusCode, 200); + assert.ok(downloaded.headers['content-disposition'].startsWith('attachment;')); + assert.equal(downloaded.headers['x-content-type-options'], 'nosniff'); + assert.ok(downloaded.headers['cache-control'].includes('no-store')); + checks.push('scoped_evidence_download_and_headers'); + // Real database audit failure must roll back the source write and not merely log it. + await pool.query(`CREATE FUNCTION synthetic_fail_loan_audit() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.action='COMPANY_LOAN_REGISTERED' THEN RAISE EXCEPTION 'synthetic audit unavailable'; END IF; RETURN NEW; END $$`); + await pool.query('CREATE TRIGGER synthetic_loan_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_loan_audit()'); + const count = await prisma.companyLoan.count(); + try { + await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成失败' }, 500); + assert.equal(await prisma.companyLoan.count(), count); + } finally { + await pool.query('DROP TRIGGER synthetic_loan_audit ON audit_logs'); + await pool.query('DROP FUNCTION synthetic_fail_loan_audit()'); + } + checks.push('real_audit_failure_atomic_rollback'); + const { encryptStringOrThrow } = req('./src/services/crypto'); + const payerAccount = '8'.repeat(16); + await prisma.paymentAccount.create({ data: { legal_entity_id: company.id, bank_institution_id: bank.id, + account_name: '合成付款公司', account_number_encrypted: encryptStringOrThrow(config, payerAccount) } }); + const second = await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成原借入凭据二', + originalPrincipal: '300.05', openingOutstandingPrincipal: '300.05' }); + for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) await upload(kind, material, 'synthetic.pdf', 'application/pdf', 200, second.id); + await call(finance, 'POST', `/company-loans/${second.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对二' }); + const repaymentInput = { requestKey: randomUUID(), lenderId: lender.id, reason: '合成两笔借入部分还本', + allocations: [{ loanId: loan.id, amount: '100.10' }, { loanId: second.id, amount: '200.05' }] }; + const { item: batch } = await call(finance, 'POST', '/loan-repayments', repaymentInput); + assert.equal(batch.items.length, 1); assert.equal(String(batch.totalAmount), '300.15'); + assert.equal((await call(finance, 'POST', '/loan-repayments', repaymentInput)).item.id, batch.id); + await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, reason: 'changed' }, 409, 'loan.duplicate_registration'); + const balance = await call(finance, 'GET', `/company-loans/${loan.id}`); + assert.equal(balance.outstanding, '800.05'); assert.equal(balance.reserved, '100.10'); assert.equal(balance.available, '699.95'); + await call(finance, 'POST', `/payment-batches/${batch.id}/review`, {}, 403); + await call(admin, 'POST', `/payment-batches/${batch.id}/execute`, {}, 403); + await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); + assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05'); + await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 409, 'payment_batch.bank_receipt_required'); + await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '提前交接' }, 409, 'loan.handoff_not_ready'); + checks.push('two_loans_one_payment_partial_allocation_idempotency_and_review'); + const { runReceiptOcrOnce } = req('./src/jobs/paymentReceiptOcrJob'); + async function receipt(batchId, amount, reference, overrides = {}) { + const boundary = `synthetic-${randomUUID()}`; + const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`, + headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, + payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) }); + assert.equal(response.statusCode, 200, 'receipt upload'); + const recognition = { status: 'recognized', pagesExpected: 1, pagesProcessed: 1, + pages: [{ pageNumber: 1, amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount, + transactionStatus: 'success', reference, issues: [], ...overrides }] }; + // Only the cloud recognizer is replaced. Queue claiming, local storage, + // normalization, verification, transaction claims and HTTP writes are real. + const result = await runReceiptOcrOnce({ prisma, config, recognize: async () => recognition }); + return { attachment: response.json().data.items[0], result }; + } + const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} }); + assert.equal(exportResponse.statusCode, 200, 'loan bank-order export'); + assert.equal(exportResponse.rawPayload.subarray(0, 8).toString('hex'), 'd0cf11e0a1b11ae1'); + assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05'); + await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '制单后银行结果待查' }, 409, 'loan.bank_state_unsafe'); + checks.push('actual_biff8_export_keeps_principal_and_prevents_unsafe_release'); + const receiptResult = await receipt(batch.id, '30015', 'SYNTHETIC-BANK-1'); + assert.equal(receiptResult.result.status, 'matched'); + await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '未知银行结果不能释放' }, 409, 'loan.bank_state_unsafe'); + assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05'); + await pool.query(`CREATE FUNCTION synthetic_fail_settlement_audit() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.action='LOAN_PRINCIPAL_SETTLED' THEN RAISE EXCEPTION 'synthetic settlement audit unavailable'; END IF; RETURN NEW; END $$`); + await pool.query('CREATE TRIGGER synthetic_settlement_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_settlement_audit()'); + try { + await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 500); + assert.equal(await prisma.paymentReceiptClaim.count(), 0); + assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05'); + } finally { + await pool.query('DROP TRIGGER synthetic_settlement_audit ON audit_logs'); + await pool.query('DROP FUNCTION synthetic_fail_settlement_audit()'); + } + const paid = await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}); + assert.equal(paid.item.status, 'executed'); + const after = await call(finance, 'GET', `/company-loans/${loan.id}`); + assert.equal(after.outstanding, '699.95'); assert.equal(after.reserved, '0.00'); + assert.equal(await prisma.paymentReceiptClaim.count(), 1); + await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}); + assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95'); + await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '合成会计凭证' }); + checks.push('real_receipt_worker_atomic_claim_settlement_rollback_retry_and_handoff'); + const nextInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '300.15' }] }; + const { item: duplicate } = await call(finance, 'POST', '/loan-repayments', nextInput); + await call(manager, 'POST', `/payment-batches/${duplicate.id}/review`, {}); + await receipt(duplicate.id, '30015', 'SYNTHETIC-BANK-1'); + await call(finance, 'POST', `/payment-batches/${duplicate.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused'); + assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95'); + const concurrentInput = { ...repaymentInput, allocations: [{ loanId: loan.id, amount: '300.00' }] }; + const concurrent = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments', + headers: finance.headers, payload: { ...concurrentInput, requestKey: randomUUID() } }))); + assert.deepEqual(concurrent.map(r => r.statusCode).sort(), [200, 409]); + const winner = concurrent.find(r => r.statusCode === 200).json().data.item; + await call(finance, 'POST', `/payment-batches/${winner.id}/cancel`, { reason: '合成未发送取消' }); + assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80'); + checks.push('duplicate_transaction_refusal_real_http_concurrency_and_safe_cancel'); + const { item: failedBank } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '10.00' }] }); + await call(manager, 'POST', `/payment-batches/${failedBank.id}/review`, {}); + const failureReceipt = await receipt(failedBank.id, '1000', 'SYNTHETIC-FAILED', { transactionStatus: 'failed', issues: ['transaction_failed'] }); + await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409); + await call(finance, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }, 403); + await call(manager, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }); + await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409); + await call(finance, 'POST', `/payment-batches/${failedBank.id}/cancel`, { reason: '合成银行明确失败' }); + assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80'); + checks.push('confirmed_bank_failure_requires_independent_matching_evidence'); + const { item: manual } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '20.00' }] }); + await call(manager, 'POST', `/payment-batches/${manual.id}/review`, {}); + const unclear = await receipt(manual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }); + assert.equal(unclear.result.status, 'needs_review'); + const currentAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: unclear.attachment.id } }); + const manualBase = `/payment-batches/${manual.id}/receipt-attachments/${unclear.attachment.id}/manual-verification`; + const proposal = await call(finance, 'POST', manualBase, { expectedVersion: currentAttachment.receipt_ocr_version, + reason: '合成原件独立人工核对', pages: [{ pageNumber: 1, itemId: manual.items[0].id, fields: Object.entries({ + 小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: bankAccount, 凭证号: 'SYNTHETIC-MANUAL', 单据状态: '交易成功', + }).map(([Name, Value]) => ({ Name, Value })) }] }); + const saved = proposal.item ?? proposal; + await call(finance, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成不允许自审' }, 403); + await call(manager, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成独立复核' }); + await call(finance, 'POST', `/payment-batches/${manual.id}/execute`, {}); + assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95'); + checks.push('external_lender_two_person_manual_receipt_and_real_settlement'); + // Cross-source claim protection: seed only the prior reimbursement facts, + // then use actual receipt upload/worker/execute HTTP. No reimbursement paid + // result is seeded or asserted by this setup. + const source = await prisma.reimbursementForm.create({ data: { + form_number: `SYN-${randomUUID()}`, submitter_id: ordinary.id, owner_id: ordinary.id, + department_id: department.id, reimbursement_date: new Date('2026-01-01'), total_amount: '300.15', status: 'approved', + } }); + const savedBatch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id }, include: { items: true } }); + const crossBatch = await prisma.paymentBatch.create({ data: { + batch_no: `SYN-${randomUUID()}`, name: '合成跨业务去重', entity_type: 'reimbursement', status: 'reviewed', + created_by: finance.id, reviewed_by: manager.id, reviewed_at: new Date(), legal_entity_id: company.id, + payment_account_id: savedBatch.payment_account_id, payment_account_snapshot_json: savedBatch.payment_account_snapshot_json, + default_payment_channel: 'bank', items: { create: { entity_id: source.id, entity_code: source.form_number, entity_title: '合成报销来源', + amount: '300.15', snapshot_json: savedBatch.items[0].snapshot_json } }, + } }); + await receipt(crossBatch.id, '30015', 'SYNTHETIC-BANK-1'); + await call(finance, 'POST', `/payment-batches/${crossBatch.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused'); + assert.equal((await prisma.reimbursementForm.findUniqueOrThrow({ where: { id: source.id } })).status, 'approved'); + checks.push('same_bank_transaction_rejected_across_loan_and_reimbursement_http'); + + for (const [label, overrides] of [ + ['amount', { amount: '101' }], ['currency', { currency: 'USD' }], + ['payer', { payerAccount: '1'.repeat(16) }], ['payee', { payeeAccount: '2'.repeat(16) }], + ['pending', { transactionStatus: 'pending', issues: ['transaction_pending'] }], + ]) { + const { item: rejected } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, + requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] }); + await call(manager, 'POST', `/payment-batches/${rejected.id}/review`, {}); + const mismatch = await receipt(rejected.id, '100', `SYNTHETIC-NEGATIVE-${label}`, overrides); + assert.equal(mismatch.result.status, 'needs_review'); + await call(finance, 'POST', `/payment-batches/${rejected.id}/execute`, {}, 409); + await call(manager, 'POST', `/payment-batches/${rejected.id}/bank-failure-review`, { attachmentId: mismatch.attachment.id }, 409); + await call(finance, 'POST', `/payment-batches/${rejected.id}/cancel`, { reason: '不明或不符不得释放' }, 409, 'loan.bank_state_unsafe'); + } + assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95'); + checks.push('wrong_amount_currency_both_accounts_and_pending_never_settle_or_release'); + const noReviewerInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] }; + await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } }); + try { await call(finance, 'POST', '/loan-repayments', noReviewerInput, 409, 'loan.reviewer_required'); } + finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); } + const sameKey = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: noReviewerInput }))); + assert.deepEqual(sameKey.map(r => r.statusCode), [200, 200]); + assert.equal(sameKey[0].json().data.item.id, sameKey[1].json().data.item.id); + await call(finance, 'POST', `/payment-batches/${sameKey[0].json().data.item.id}/cancel`, { reason: '合成并发验证结束' }); + checks.push('no_eligible_reviewer_blocks_and_concurrent_same_key_is_idempotent'); + const evidencePath = `/company-loans/${loan.id}/evidences/${evidence.id}/download`; + const reviewerDownload = await app.inject({ method: 'POST', url: `/api${evidencePath}`, headers: manager.headers, + payload: { reason: '合成还款审批材料', paymentBatchId: manual.id } }); + assert.equal(reviewerDownload.statusCode, 200); + await call(manager, 'POST', evidencePath, { reason: '缺少审批范围' }, 403); + const { item: stale } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] }); + await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, { + expectedVersion: verified.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成收款人关联经理', + }); + await call(manager, 'POST', `/payment-batches/${stale.id}/review`, {}, 403); + const independent = await actor(['ceo']); + await call(independent, 'POST', `/payment-batches/${stale.id}/review`, {}, 409, 'loan.version_changed'); + await call(finance, 'POST', `/payment-batches/${stale.id}/cancel`, { reason: '合成未发起身份更正' }); + const { item: ownPayee } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] }); + await call(manager, 'POST', `/payment-batches/${ownPayee.id}/review`, {}, 403); + await call(independent, 'POST', `/payment-batches/${ownPayee.id}/review`, {}); + await call(finance, 'POST', `/payment-batches/${ownPayee.id}/cancel`, { reason: '合成验证结束' }); + checks.push('scoped_reviewer_materials_payee_self_review_and_identity_version_guards'); + const { item: oldAccount } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] }); + const changedAccount = await call(finance, 'PATCH', `/loan-lenders/${lender.id}/account`, { + expectedVersion: lender.accountVersion, bankInstitutionId: bank.id, bankName: '合成变更开户行', + bankAccount: '3'.repeat(16), accountName: '合成出借人', + }); + assert.equal(changedAccount.identityStatus, 'unverified'); + await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.identity_required'); + await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, { + expectedVersion: changedAccount.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成账户变更后重新核实', + }); + await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.version_changed'); + await call(finance, 'POST', `/payment-batches/${oldAccount.id}/cancel`, { reason: '合成旧账户未发起取消' }); + checks.push('account_changes_revoke_identity_and_invalidate_old_unpaid_snapshot'); + if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') { + await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma }); + } + console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks })); + } finally { await app.close(); await prisma.$disconnect(); } +}; diff --git a/scripts/qa/error-reason-legacy-baseline.json b/scripts/qa/error-reason-legacy-baseline.json index 43ccf58f..7f19696e 100644 --- a/scripts/qa/error-reason-legacy-baseline.json +++ b/scripts/qa/error-reason-legacy-baseline.json @@ -1,596 +1,4 @@ { "version": 1, - "entries": [ - {"file":"server/src/plugins/auth.js","fingerprint":"736318b046fb0f03c3c1e1db2630c7d84a63d0bb47a9ba15d56444a75cbc3bba","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:authPlugin"}, - {"file":"server/src/routes/ai.js","fingerprint":"08c44e703511941756749328a4035ccb43674cc82d91dee795211ef829fe01bc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"}, - {"file":"server/src/routes/announcements.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/announcements.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, - {"file":"server/src/routes/announcements.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/announcements.js","fingerprint":"9fcc268e8e61e61364ca617c982d1cff4a1b76290ff19822ed363383be4c3cda","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/announcements.js","fingerprint":"f2b59eab3ecb251e0fc247952125ed33c0c87173c26052b01af7eb6c7fed2595","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeAnnouncementPolicyFields"}, - {"file":"server/src/routes/announcements.js","fingerprint":"b7bbbb68fab3f1f34753f2cd1dd5d3834cc220df4be0dc915c252db56125ddd9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/announcements"}, - {"file":"server/src/routes/announcements.js","fingerprint":"16e3a77398374a9da0ba54d34380f16866000cd809d649d13c68a262437cecb8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/announcements/:id"}, - {"file":"server/src/routes/announcements.js","fingerprint":"b489caa88f9c4b0f9cbdd4ddaa317a550973dc746fe11c44f556d6b0be5c5c58","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/announcements/:id/acknowledge"}, - {"file":"server/src/routes/announcements.js","fingerprint":"6f2ecb599e0390bc141c7c6a8874a4a0b04b55c3f9c9f249432829ef2aa4c21f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/announcements/:id/acknowledge"}, - {"file":"server/src/routes/apOperations.js","fingerprint":"48951ae2ad49593089e10d00afd95a5f18e10c2cbcb87f7956eb47a59d60cfa0","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/apOperations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/ap-operations/reimbursement-ledger"}, - {"file":"server/src/routes/approvalRules.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/approvalRules.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseApprovalRuleId"}, - {"file":"server/src/routes/approvalRules.js","fingerprint":"cd6c77086d44835903ad40dc099ee6640c1ddfb1323f346af4ba86ed8a0ea323","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAmountRangeValid"}, - {"file":"server/src/routes/approvalRules.js","fingerprint":"993d7526060943199799457112b59cf24e63a46341d3dc1080ddb6c8d0af317c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAmountRangeValid"}, - {"file":"server/src/routes/approvalRules.js","fingerprint":"b8698fe0fd39540fb0a1d21bf610947927564bdf7150e35b98ee0077317cc84b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureOwnerSelfFallbackRoleValid"}, - {"file":"server/src/routes/approvalRules.js","fingerprint":"996fd561bfa6933b8a36273a25fecfecc94d0b366da830685336ddc7f1212eca","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/approval-rules/:id"}, - {"file":"server/src/routes/assetMovements.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/assetMovements.js","fingerprint":"6c58fb0c0b2fb699a6a013d4d40a2b87c072068b06e766e74b0e092cc7a335d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"}, - {"file":"server/src/routes/assetMovements.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/assetMovements.js","fingerprint":"62bc45cfdb1f68c9dd134641c77cdfdfb653555f2e498e397e39a40e41e166e8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureMovementAllowed"}, - {"file":"server/src/routes/assetMovements.js","fingerprint":"d5b13762c5f0318690bb04b0e4d006e066524b06b185f9615a335396e6f0c93c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetExists"}, - {"file":"server/src/routes/assetMovements.js","fingerprint":"22b2935f24450ede35fa143304840ecaa3496a12c034f5ad284c2eef01c50cba","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureActiveEmployee"}, - {"file":"server/src/routes/assetMovements.js","fingerprint":"186bfa660e6ea4282e9f63afccc7348f2096c8402e8e901439fb15627837565a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureActiveEmployee"}, - {"file":"server/src/routes/assetMovements.js","fingerprint":"9c55abdac47794fe22e2d5663060d2db6753aa236adba4be735827e00b11cbdb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/:id/assign"}, - {"file":"server/src/routes/assetMovements.js","fingerprint":"6a2623801e3905473f5af5a6aeb45e5ad43aa309026772ccdd536c6ffbad2e8f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/:id/return"}, - {"file":"server/src/routes/assetMovements.js","fingerprint":"d5a1ffc7fafbd0cab7a4ba98d3a9da8817c9166e452d4e1997bb6dbc93ca052d","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/:id/transfer"}, - {"file":"server/src/routes/assetMovements.js","fingerprint":"b239d697b3f7d3d616328076dfc48f7f081e0d74f178031d8760214a2b333137","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/:id/transfer"}, - {"file":"server/src/routes/assets.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/assets.js","fingerprint":"6c58fb0c0b2fb699a6a013d4d40a2b87c072068b06e766e74b0e092cc7a335d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"}, - {"file":"server/src/routes/assets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCategoryListQuery"}, - {"file":"server/src/routes/assets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseAssetListQuery"}, - {"file":"server/src/routes/assets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseAssigneeListQuery"}, - {"file":"server/src/routes/assets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"}, - {"file":"server/src/routes/assets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"}, - {"file":"server/src/routes/assets.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"}, - {"file":"server/src/routes/assets.js","fingerprint":"01a2842a9be803e2beaabaec7fe403c509d868712d5e38b0650eafe19567fb96","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateAssetTextBoundaries"}, - {"file":"server/src/routes/assets.js","fingerprint":"01a2842a9be803e2beaabaec7fe403c509d868712d5e38b0650eafe19567fb96","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateRedactedImportBoundaries"}, - {"file":"server/src/routes/assets.js","fingerprint":"940b4a2172252aa6e6bc4f8dd8865e3361ba2c7fd53c77414f3d9ef73c1fdffa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"}, - {"file":"server/src/routes/assets.js","fingerprint":"ea52a43d4fba3f07b80c3328f4ff96fedf5a51348bee2ef60d5bf721deaef49c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"}, - {"file":"server/src/routes/assets.js","fingerprint":"d701b972a1ed447a07f76be872f47a5988c6c9dd5ae4edeb4badaadec3f4bf85","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"}, - {"file":"server/src/routes/assets.js","fingerprint":"b50f5f62f57a8d2e85808fd7633568a49e91b8a821e2afdead6647a7ea84b159","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetCategoryExists"}, - {"file":"server/src/routes/assets.js","fingerprint":"0ba3108bacfe6c81125153caf72003c1bd3d445e8493290d1e7e30b56e456b84","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetCategoryExists"}, - {"file":"server/src/routes/assets.js","fingerprint":"80914f548ee5899eb71a178c4a13ad10646bdd2c0fed7acd44c5924581dfc2aa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetCategoryExists"}, - {"file":"server/src/routes/assets.js","fingerprint":"6f091706e1ec75d17b76658dd81f7a3f7347110b8c6a5e7a81b7fda6020358f4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/assets.js","fingerprint":"b1fab48ce07d1d7f2308476dd38dfbcce526a64bd50de2ac484d2e5b0e3e8fff","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/assets.js","fingerprint":"d5aa541584c0cf873d161fc6e31f82c8a1ec62b67eebd4444c5d52270123eb13","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"}, - {"file":"server/src/routes/assets.js","fingerprint":"96523ea82ed258eb92ca4990dc8bc6bb84d3e3bbf76d66dca60b15931455cdc8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"}, - {"file":"server/src/routes/assets.js","fingerprint":"57ccb19330174bae06b385dca2cb8c41ce487980099e934fef19f8fc3a15cba7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"}, - {"file":"server/src/routes/assets.js","fingerprint":"22b2935f24450ede35fa143304840ecaa3496a12c034f5ad284c2eef01c50cba","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureActiveEmployee"}, - {"file":"server/src/routes/assets.js","fingerprint":"186bfa660e6ea4282e9f63afccc7348f2096c8402e8e901439fb15627837565a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureActiveEmployee"}, - {"file":"server/src/routes/assets.js","fingerprint":"49c2624e15c3d37dbae668f8c668461f5451fc4bf15fea7cac68c42ec099fdc8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureApprovedPurchaseRequest"}, - {"file":"server/src/routes/assets.js","fingerprint":"4ae96897d6ee2f02a7aa80c4d966629721cb8ea4c47b5713c32dfc6274ad3e5a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureApprovedPurchaseRequest"}, - {"file":"server/src/routes/assets.js","fingerprint":"d5b13762c5f0318690bb04b0e4d006e066524b06b185f9615a335396e6f0c93c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/asset-categories/:id"}, - {"file":"server/src/routes/assets.js","fingerprint":"442e2a0cd3f40bb741d5d4727944806abae5ce494465168d0a45fa8e9c3063ca","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/import/redacted-doc-index"}, - {"file":"server/src/routes/assets.js","fingerprint":"d5b13762c5f0318690bb04b0e4d006e066524b06b185f9615a335396e6f0c93c","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/:id"}, - {"file":"server/src/routes/attendance.js","fingerprint":"6ed67e68f41d5964a43a8a6e8d0ed71207418c81f7ad013aa3cfd9158f1ae29c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseInput"}, - {"file":"server/src/routes/attendance.js","fingerprint":"2a45170a8628f3a739cf666505b0f43784def012f00d0ed87a220b3d70d79589","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/attendance.js","fingerprint":"4ff302aeefae7b9ad6f35e657c3d2abf738531a34d8f7e655f31e09a27dc41f8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureUnifiedFeatureEnabled"}, - {"file":"server/src/routes/attendance.js","fingerprint":"a657a09fd09b5fe31ee45ebfaaeda99ba30d6944bade370d5d0d7ac3c7456a5a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseOptionalDateTime"}, - {"file":"server/src/routes/attendance.js","fingerprint":"99ebe0d43dca95cb33f043545c5302d439e6822f524e9f2c083f1d9f46feb362","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseMonthRange"}, - {"file":"server/src/routes/attendance.js","fingerprint":"02626f66ac5688562048fbc15a189c27d9c88c08778562564396aa8534eed7d1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/clock-in"}, - {"file":"server/src/routes/attendance.js","fingerprint":"02626f66ac5688562048fbc15a189c27d9c88c08778562564396aa8534eed7d1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/clock-out"}, - {"file":"server/src/routes/attendance.js","fingerprint":"287252e0c5d2fdaadef7cfed6b3ba8982e97a10db47a8e422a877eda7aa4d4bb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/clock-out"}, - {"file":"server/src/routes/attendance.js","fingerprint":"8e69dcaef6fa42597a8ca6804bbcc30d18e4641c50a5945a4171e2f3fec1f59f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/admin/backfill"}, - {"file":"server/src/routes/attendance.js","fingerprint":"3bc8430a72bd41e66bb2de2fc5b38cb78b7306a353e0beb21adf56eefce42840","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/admin/backfill"}, - {"file":"server/src/routes/audit.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"}, - {"file":"server/src/routes/audit.js","fingerprint":"510d8ad88dde0075df37838cd0b7ff6241145d700817ea5f203086abbae5b568","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"}, - {"file":"server/src/routes/budgetControlRules.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/budgetControlRules.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseRuleId"}, - {"file":"server/src/routes/budgetControlRules.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"}, - {"file":"server/src/routes/budgetControlRules.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/budgetControlRules.js","fingerprint":"7c065e9daea0385b2bfb3ab1e1948e602a6376bac9e44d5d8c90564cd0f1c1f8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/budgetControlRules.js","fingerprint":"c92ca88007ea087989f14e6555add522f0ab717298fc801fafc942e1187c93cf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/budgets.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/budgets.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBudgetId"}, - {"file":"server/src/routes/budgets.js","fingerprint":"4bcacc7fa10fe5395c9233195e0941962c3f9fa6115492445e654f070fe58075","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDepartmentAvailable"}, - {"file":"server/src/routes/budgets.js","fingerprint":"7f37e2cba05a7d3afeaf7a814afb721bad3a3ef590eb388aeb9149b612ab9cef","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDepartmentAvailable"}, - {"file":"server/src/routes/budgets.js","fingerprint":"8506eaeef8adc8cc277fcffb3ce92086e955ecadb4149df7b5a01363da60d337","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/budgets.js","fingerprint":"c4967a64f972aa0396e182d2d06880ab684c25d2545466aad0854828740ad39e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/budgets.js","fingerprint":"3cc514556b50dae2ebe09ef19b335d02d760c8375bf6d85dae2b9cce523f1870","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"}, - {"file":"server/src/routes/budgets.js","fingerprint":"dc93103cf32df2eb4384550e3ae7ebcc290879e69e11aad7eaea6481f8f414fb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"}, - {"file":"server/src/routes/budgets.js","fingerprint":"0bd9235198a75999811434ce3e70e584019d64555ede15c2202bd5f1030edf89","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"}, - {"file":"server/src/routes/budgets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/budgets"}, - {"file":"server/src/routes/budgets.js","fingerprint":"12506f55725f28e98268fb5ffef82d9e96e5b7738fea52108977bb3a0efd511f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/budgets"}, - {"file":"server/src/routes/budgets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/budgets/:id"}, - {"file":"server/src/routes/budgets.js","fingerprint":"92e4ca56a07acf54433ae06c7af4755bd7aa80956a055e41da4388cb5ac61962","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/budgets/:id"}, - {"file":"server/src/routes/confidentialDocRefs.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/confidentialDocRefs.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/confidentialDocRefs.js","fingerprint":"09dcc86e0b4bc71f7bd78b881d1bb47f727736ba45d232d08ed0bf87c23072de","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/confidentialDocRefs.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/confidential-doc-refs"}, - {"file":"server/src/routes/configRead.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseEnabledQuery"}, - {"file":"server/src/routes/configRead.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseDocumentTypeParams"}, - {"file":"server/src/routes/contractsRegistry.js","fingerprint":"0d7d8d325c8629980a1d6f1c569255c2995059d5b19667be27f57eed2ea837af","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/contractsRegistry.js","fingerprint":"254541548938a93942be3a6f1bd3b33b17c82aa977fd75228871a5842d9c10c1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/contractsRegistry.js","fingerprint":"92558dc9d5ba5ab7224a8267f2b79920feaec4457df73b8f30cacb6aef9f12d3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:updateContractWithStateGuard"}, - {"file":"server/src/routes/contractsRegistry.js","fingerprint":"cba9e7b82b29c4ec1f89720620c1f78ea2d67133338d9957c8baece08306bd2b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id"}, - {"file":"server/src/routes/contractsRegistry.js","fingerprint":"60347bec707618636867c4310250919b6443c10f22f88f68f6c8d2c3de565d13","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id"}, - {"file":"server/src/routes/contractsRegistry.js","fingerprint":"0f95d3875c822fa7d8c016aa7be48ad2d20a658b57bed0cbed14dcfe4196a2b9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id/terminate"}, - {"file":"server/src/routes/contractsRegistry.js","fingerprint":"c5fc8ab4ac98ddff39aa922bbd24744a815c5c423aa5b18687dc59611f1273da","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id/terminate"}, - {"file":"server/src/routes/contractsRegistry.js","fingerprint":"ad732dac27597f2483143c7f845399a79c2a276d4b89e32fcb96f583930b4240","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id/corrections"}, - {"file":"server/src/routes/contractsRegistry.js","fingerprint":"d3204f15378a5bfcdea2de26e96d097a89e142fa3329a2d7f742b917f2f22c39","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id/corrections"}, - {"file":"server/src/routes/contractsRegistry.js","fingerprint":"80c9775ffbd6d8c8f43d0baf4539ffa3649a923f74b3973d8fe78fff4a51ab26","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id/renew"}, - {"file":"server/src/routes/cutover.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/cutover.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, - {"file":"server/src/routes/cutover.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/cutover.js","fingerprint":"43aaacd8708bb5c07aa913145a5dfa84cd402038b1fee3ada860ee249f8ce9c3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/dashboard.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"}, - {"file":"server/src/routes/dashboard.js","fingerprint":"510d8ad88dde0075df37838cd0b7ff6241145d700817ea5f203086abbae5b568","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"}, - {"file":"server/src/routes/delegations.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseDelegationId"}, - {"file":"server/src/routes/delegations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"}, - {"file":"server/src/routes/delegations.js","fingerprint":"3e5bc2343859761fdfae1aedfba4dd9d8d252d16e479b01a5ac477e25b700209","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"}, - {"file":"server/src/routes/delegations.js","fingerprint":"abc5f3ef3dcaeb55ac80eb31853eed3c582d7c7ded7421c3903effdb2b83fabb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"}, - {"file":"server/src/routes/delegations.js","fingerprint":"1f1a149c83f74bc04b86baf4116b9601c8956a4a0bf75a66c3848108e9681fbb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"}, - {"file":"server/src/routes/delegations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateGrantBody"}, - {"file":"server/src/routes/delegations.js","fingerprint":"3e5bc2343859761fdfae1aedfba4dd9d8d252d16e479b01a5ac477e25b700209","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateGrantBody"}, - {"file":"server/src/routes/delegations.js","fingerprint":"abc5f3ef3dcaeb55ac80eb31853eed3c582d7c7ded7421c3903effdb2b83fabb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateGrantBody"}, - {"file":"server/src/routes/delegations.js","fingerprint":"1f1a149c83f74bc04b86baf4116b9601c8956a4a0bf75a66c3848108e9681fbb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateGrantBody"}, - {"file":"server/src/routes/delegations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseStatusQuery"}, - {"file":"server/src/routes/delegations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListDelegationGrantsQuery"}, - {"file":"server/src/routes/delegations.js","fingerprint":"c65eb61b3d8ca109854fc10890d4307723bbd03dc93d1549964d328ae9bc5069","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDelegateEligible"}, - {"file":"server/src/routes/delegations.js","fingerprint":"2d1c39e41522f8863cd6928b50a11bb9958d348b7a9f20c5292608e650667c17","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDelegateEligible"}, - {"file":"server/src/routes/delegations.js","fingerprint":"dd4725b4c2c79ef5e59dbe55b5eb4c177d167186dc7ef8b6d17f6f61bebc43a5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDelegateEligible"}, - {"file":"server/src/routes/delegations.js","fingerprint":"86ec38bd74707cd6917fe2a7ffc79440867b6a37e71efb3b6981aa9a7b560194","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/delegation-grants"}, - {"file":"server/src/routes/delegations.js","fingerprint":"1ba976c328b41f63396aeb96618d8c525df8255f3ce17510fd019421207bd3d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/delegation-grants"}, - {"file":"server/src/routes/delegations.js","fingerprint":"86ec38bd74707cd6917fe2a7ffc79440867b6a37e71efb3b6981aa9a7b560194","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/delegations"}, - {"file":"server/src/routes/departments.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseDepartmentId"}, - {"file":"server/src/routes/departments.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/departments.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"}, - {"file":"server/src/routes/departments.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"}, - {"file":"server/src/routes/departments.js","fingerprint":"dd4c7435c5433e19eb9893f762c76225772c4cf62eb01c6c45d7b8d1b207705a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"}, - {"file":"server/src/routes/departments.js","fingerprint":"5326d4c4b0ac27a96de8b209570f3d8ee687aad427a8cb2b9957484b287e02ed","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments"}, - {"file":"server/src/routes/departments.js","fingerprint":"8d444b923b812b3afe71913b82f132c3955e73935ebd12361bab0edd67214d28","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments"}, - {"file":"server/src/routes/departments.js","fingerprint":"fa47bac9301143e633b50167c8fab4a40a1edb9397734104569905b99f5ad4aa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id"}, - {"file":"server/src/routes/departments.js","fingerprint":"0c6eb41240d31840a48750871c49320b8feecf58984a2a969144276fac1298f7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id"}, - {"file":"server/src/routes/departments.js","fingerprint":"5326d4c4b0ac27a96de8b209570f3d8ee687aad427a8cb2b9957484b287e02ed","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id"}, - {"file":"server/src/routes/departments.js","fingerprint":"8d444b923b812b3afe71913b82f132c3955e73935ebd12361bab0edd67214d28","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id"}, - {"file":"server/src/routes/departments.js","fingerprint":"71cf92240dcb163524d928e3f792ac176896ff89107986f3fd9ad119ad49aa47","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id"}, - {"file":"server/src/routes/departments.js","fingerprint":"17e1fd34460204ab475dc670a1b4ea61a1f369ebbd8dd2efe5eec265255701f7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id/archive"}, - {"file":"server/src/routes/departments.js","fingerprint":"1e15674977106d900a40e34fe64657152da9dfe383e63fa88e6c0de70c4cb8d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id/archive"}, - {"file":"server/src/routes/employees.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/employees.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, - {"file":"server/src/routes/employees.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/employees.js","fingerprint":"64ec8f0736b31d4a76082fa397b9adf9107f0628c97b9547026f55a3629f71cd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/employees.js","fingerprint":"ab1ed75076ba8a91674136da33198f3bd082cf5c9633b45e97f78c456a0471dc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureWecomHrMasterSyncEnabled"}, - {"file":"server/src/routes/employees.js","fingerprint":"cc8894f77bdedd30913ca06dae19111faa1c97bc65078db3821925a514742afa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureWecomHrMasterSyncEnabled"}, - {"file":"server/src/routes/employees.js","fingerprint":"99617ed3d2198ffedead181d3d347c8c6b3be4d886c92bcfa7c58f39389a6d57","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateContractDates"}, - {"file":"server/src/routes/employees.js","fingerprint":"cdf73963d211c5dc959bbd8d24d489f7d33a2024f7613c6033341c15e4d8dc4e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateContractDates"}, - {"file":"server/src/routes/employees.js","fingerprint":"99617ed3d2198ffedead181d3d347c8c6b3be4d886c92bcfa7c58f39389a6d57","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateTrainingDates"}, - {"file":"server/src/routes/employees.js","fingerprint":"606fa5a41e86d696a118edf385c6a6e2f4bf1b2c41288420ebcbe01d46eac084","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateCertificateDates"}, - {"file":"server/src/routes/employees.js","fingerprint":"9d60ee3ecc95ce9a699e29cf140233c55b0d8a3ee95946b4ec5f07f66d9610c9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildContractAttachmentBlockedError"}, - {"file":"server/src/routes/employees.js","fingerprint":"7ccdc0b4a4431f13ce618cd19f4da8fe8167d21ca3bcb55ae0bc7249f23b5607","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"}, - {"file":"server/src/routes/employees.js","fingerprint":"3efb722614f1cdee938215738adeb5b2d4930fdb24c470e560b69244f0bcae7e","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"}, - {"file":"server/src/routes/employees.js","fingerprint":"fd1f07cbb59e6103dfac77bafd01b3ed7a0ce88a5b45ea9f3d7238776249d498","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"}, - {"file":"server/src/routes/employees.js","fingerprint":"71935e997078aefd6df41afdb612ae8b79e5e67356bcaea3c984b414cff95f57","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"}, - {"file":"server/src/routes/employees.js","fingerprint":"0c5c7e91b73ae8e0c05e8c3a34dbbdb250559b2f46385fe5c66ed1aff3a02166","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"}, - {"file":"server/src/routes/employees.js","fingerprint":"561527fb2f4fdce80bdcb3c9b02a1afc9c2664dfd9bcb592f7e554b0b447b8fa","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"}, - {"file":"server/src/routes/employees.js","fingerprint":"cabd6d4585a224ce1759dbcb60d03a9544950051604a6cffaf0ad7b530f92a2c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"}, - {"file":"server/src/routes/employees.js","fingerprint":"37cc3fcf7eb9efd9a9270eb9a0bcf68b4d083d484e70541c6afd5bb67ea1b3ce","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/wecom-directory-sync"}, - {"file":"server/src/routes/employees.js","fingerprint":"37cc3fcf7eb9efd9a9270eb9a0bcf68b4d083d484e70541c6afd5bb67ea1b3ce","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/wecom-directory-sync/retry"}, - {"file":"server/src/routes/employees.js","fingerprint":"bddf88642c81d891ef3da8820dc127dfa2ceffb3b74983791dd7deb00d2b32c5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/contracts"}, - {"file":"server/src/routes/employees.js","fingerprint":"bddf88642c81d891ef3da8820dc127dfa2ceffb3b74983791dd7deb00d2b32c5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/contracts/:contractId"}, - {"file":"server/src/routes/employees.js","fingerprint":"7c5310f7b2163f74cc5120711effbbd1f1bdc2cdfcd49d4e00b99665e2b1580a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"}, - {"file":"server/src/routes/employees.js","fingerprint":"06f4a2e303107e87d1e94009275b39ed2838c7ec0fc45595a64dbbc4331b1106","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/contracts/:contractId"}, - {"file":"server/src/routes/employees.js","fingerprint":"2b6b9e0ebbca3a448aaa94b95a2d6d43e3a14ccc3f5aeca4f956deec94aa3107","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/profile"}, - {"file":"server/src/routes/expenseStandards.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/expenseStandards.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseExpenseStandardId"}, - {"file":"server/src/routes/expenseStandards.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/expenseStandards.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/expenseStandards.js","fingerprint":"b9dc511484348ded712b71280db3e49c789be0666623eed6cb9cf7a4468aedf9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/expenseStandards.js","fingerprint":"30b8f6d233001091ad09d7f6dc365136091288a663f1458bbce7201de1218b2c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureExpenseTypeExists"}, - {"file":"server/src/routes/expenseStandards.js","fingerprint":"a0b01e1f14b7ede96e7cd1e8252c748afdf125feaa51441a37b9aabb363a328b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureScopeKeyValid"}, - {"file":"server/src/routes/expenseStandards.js","fingerprint":"a5ad69546d526d962f12d1c7e1d0e24a153f585fa6d61daa123fc1a2843f580e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureScopeKeyValid"}, - {"file":"server/src/routes/expenseStandards.js","fingerprint":"d67e306450dee53a89c46327da7ca8273417de0f4f74f80a46b9711f513301c9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-standards"}, - {"file":"server/src/routes/expenseStandards.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-standards/:id"}, - {"file":"server/src/routes/expenseStandards.js","fingerprint":"d67e306450dee53a89c46327da7ca8273417de0f4f74f80a46b9711f513301c9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-standards/:id"}, - {"file":"server/src/routes/expenseTypes.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/expenseTypes.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/expenseTypes.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseExpenseTypeId"}, - {"file":"server/src/routes/expenseTypes.js","fingerprint":"6fefcf7886d8aca7c344962a4621880f7da29f0915282c8763cf48d3049279a8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-types"}, - {"file":"server/src/routes/expenseTypes.js","fingerprint":"dd4c7435c5433e19eb9893f762c76225772c4cf62eb01c6c45d7b8d1b207705a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-types/:id"}, - {"file":"server/src/routes/expenseTypes.js","fingerprint":"6fefcf7886d8aca7c344962a4621880f7da29f0915282c8763cf48d3049279a8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-types/:id"}, - {"file":"server/src/routes/exports.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"}, - {"file":"server/src/routes/exports.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/exports.js","fingerprint":"510d8ad88dde0075df37838cd0b7ff6241145d700817ea5f203086abbae5b568","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:toDateRange"}, - {"file":"server/src/routes/exports.js","fingerprint":"48951ae2ad49593089e10d00afd95a5f18e10c2cbcb87f7956eb47a59d60cfa0","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureApOperationsExportEnabled"}, - {"file":"server/src/routes/exports.js","fingerprint":"0160d938808a2f4bbee5beb67caad7ddcbea52af30db293b600c23db638e57f3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureContractsRegistryExportEnabled"}, - {"file":"server/src/routes/exports.js","fingerprint":"a0125087df9691c0e5f6f2989910035efba279b7f0bffdc10579765966fb150c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:toDateOnlyRange"}, - {"file":"server/src/routes/exports.js","fingerprint":"67b9d4a42e614d1bf96832420680b7ca2ea4749f910828802ff3170eaeda6e64","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/exports/payslips"}, - {"file":"server/src/routes/exports.js","fingerprint":"6d38b77a953af94b8bff5a99e5b3ab22f28507853694cd6fd50d38589850f8f1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/exports/presence-history"}, - {"file":"server/src/routes/exports.js","fingerprint":"510d8ad88dde0075df37838cd0b7ff6241145d700817ea5f203086abbae5b568","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/exports/audit-logs"}, - {"file":"server/src/routes/featureFlags.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/featureFlags.js","fingerprint":"5fad3764129696d62548c50a8b78ee8f322b718e877eb8950d98ac0c331173c3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildVersionConflictError"}, - {"file":"server/src/routes/featureFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseKey"}, - {"file":"server/src/routes/featureFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePageQuery"}, - {"file":"server/src/routes/featureFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"}, - {"file":"server/src/routes/featureFlags.js","fingerprint":"6ef26793cac05e83ddd65bf556897f795f41584fe69f21c6b4842554f2e1c087","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"}, - {"file":"server/src/routes/featureFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBatchBody"}, - {"file":"server/src/routes/featureFlags.js","fingerprint":"4d213e5dda91586732d96d078bbff9ee63351c094c47bc8d31b3e7450f8f1aab","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:featureFlagsRoutes"}, - {"file":"server/src/routes/feeControl.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/feeControl.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/integrations.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/integrations.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseOutboxListQuery"}, - {"file":"server/src/routes/integrations.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBindingsListQuery"}, - {"file":"server/src/routes/integrations.js","fingerprint":"6c58fb0c0b2fb699a6a013d4d40a2b87c072068b06e766e74b0e092cc7a335d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuidParam"}, - {"file":"server/src/routes/integrations.js","fingerprint":"48d75541b43988964ecf85d59f32007713a3be1bd381ef2bf4bcb3b38dc839bf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:requireErpManagementGate"}, - {"file":"server/src/routes/integrations.js","fingerprint":"8a83e372ba9211a169ca20935d21336c95349f5db935bb4b4e73fa4c62912a43","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensurePaidReimbursement"}, - {"file":"server/src/routes/integrations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeProcurementTargets"}, - {"file":"server/src/routes/integrations.js","fingerprint":"ac52892b0856c64c0b8918ee2e323d5965f8db02f2026199498e89128c7542fb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureProcurementTargetEligible"}, - {"file":"server/src/routes/integrations.js","fingerprint":"e1eaff595d742e37768ec5e6b5859a29604d1a54fbee9da795bebb9397abf95c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureProcurementTargetEligible"}, - {"file":"server/src/routes/integrations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeResourcesInput"}, - {"file":"server/src/routes/integrations.js","fingerprint":"efc285e1cd886a88a6e57e61ce5da1a202409c21a930621af47f4f8052ba90b9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/outbox/:id/retry"}, - {"file":"server/src/routes/integrations.js","fingerprint":"26c3c1c908fda8c7350351ee2f11769acdbf1bdee51bce704759d7d07f5eadae","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/master-data/sync"}, - {"file":"server/src/routes/integrations.js","fingerprint":"399f9ec607c4403c3c6c8763433114d192a5df162ba7d0fdd2fe03aaeb572ac7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/journals/reimbursements/:reimbursementId/preview"}, - {"file":"server/src/routes/integrations.js","fingerprint":"399f9ec607c4403c3c6c8763433114d192a5df162ba7d0fdd2fe03aaeb572ac7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/journals/reimbursements/:reimbursementId/export"}, - {"file":"server/src/routes/integrations.js","fingerprint":"095bb8a253c5934e8f8629e16c38b388f6d2cc6354343bf8506cf0787917c09e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/procurement/purchase-requests/:purchaseRequestId/track"}, - {"file":"server/src/routes/integrations.js","fingerprint":"095bb8a253c5934e8f8629e16c38b388f6d2cc6354343bf8506cf0787917c09e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/procurement/purchase-requests/:purchaseRequestId/requeue"}, - {"file":"server/src/routes/integrations.js","fingerprint":"6392bfcb435833f01d57d08f451784a96334daa7151cef168939c1160ad93fcd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/assets/:assetId/track"}, - {"file":"server/src/routes/integrations.js","fingerprint":"6392bfcb435833f01d57d08f451784a96334daa7151cef168939c1160ad93fcd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/assets/:assetId/requeue"}, - {"file":"server/src/routes/integrations.js","fingerprint":"1298b6503bead86f2a1f836887a4019b863e86cf40e33200ff554fac72afe4a6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/travel/reconciliation-batches/:batchId/track"}, - {"file":"server/src/routes/integrations.js","fingerprint":"1298b6503bead86f2a1f836887a4019b863e86cf40e33200ff554fac72afe4a6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/travel/reconciliation-batches/:batchId/requeue"}, - {"file":"server/src/routes/integrations.js","fingerprint":"9c6bf2a19d60e08f038020fe4faef2823e46f32d5d5523e4a484c5175840fc1c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/travel/reconciliation-batches/:batchId/requeue"}, - {"file":"server/src/routes/integrations.js","fingerprint":"399f9ec607c4403c3c6c8763433114d192a5df162ba7d0fdd2fe03aaeb572ac7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/sync/:reimbursementId"}, - {"file":"server/src/routes/inventoryChecks.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/inventoryChecks.js","fingerprint":"6c58fb0c0b2fb699a6a013d4d40a2b87c072068b06e766e74b0e092cc7a335d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"}, - {"file":"server/src/routes/inventoryChecks.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/inventoryChecks.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/inventoryChecks.js","fingerprint":"435181ebd3fd0f7b87dc2c1708ae0454eec366f3084a133cf0bf7e292259dd27","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureScopeReference"}, - {"file":"server/src/routes/inventoryChecks.js","fingerprint":"da55de4248c5c6a9283d50da6dd09edba0ce3f0a9e6a91af8e2d7d62b0785a4b","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureScopeReference"}, - {"file":"server/src/routes/inventoryChecks.js","fingerprint":"d5b13762c5f0318690bb04b0e4d006e066524b06b185f9615a335396e6f0c93c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureInventoryCheck"}, - {"file":"server/src/routes/inventoryChecks.js","fingerprint":"1bf348c5073f0a17d62a5ae67b998e93fe35c77c2abe4cb0784c153ca4f8272a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCheckStatus"}, - {"file":"server/src/routes/inventoryChecks.js","fingerprint":"0492f0a2dab5df4df9bdd0504d07e51d03f2c56d6e0e516abb2c5ecac159c545","count":3,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetInScope"}, - {"file":"server/src/routes/inventoryChecks.js","fingerprint":"5a020ee415b339c0a8064f07afa8fd5c6e3b305186e24d2d1653cdc3143f1277","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/inventory-checks/:id/items"}, - {"file":"server/src/routes/inventoryChecks.js","fingerprint":"466c3a313730aa3a9720bb5b37447aa5d1fa274961d8f64838c66b2780e69ef3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/inventory-checks/:id/items"}, - {"file":"server/src/routes/leave-types.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/leave-types.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTypeId"}, - {"file":"server/src/routes/leave-types.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTypeQuery"}, - {"file":"server/src/routes/leave-types.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTypeBody"}, - {"file":"server/src/routes/leave-types.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/leave-types"}, - {"file":"server/src/routes/leave-types.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/leave-types/:id"}, - {"file":"server/src/routes/masterData.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/masterData.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"}, - {"file":"server/src/routes/masterData.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/masterData.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"}, - {"file":"server/src/routes/masterData.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"}, - {"file":"server/src/routes/masterData.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"}, - {"file":"server/src/routes/masterData.js","fingerprint":"8506eaeef8adc8cc277fcffb3ce92086e955ecadb4149df7b5a01363da60d337","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/masterData.js","fingerprint":"fdc96cb05717a00bf966b3db85f546eb54af6008074759298bb2532b1af926fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/masterData.js","fingerprint":"57bed79bc70017e6d9ed3c59bfab4bbea560ee2b688d24e44d3a9e180bbb2d89","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureOwnerEmployeeAvailable"}, - {"file":"server/src/routes/masterData.js","fingerprint":"7d66e43d79549021e2d29e57f896c77cb1a141106745c7fcef0921ef92eac9c6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterParent"}, - {"file":"server/src/routes/masterData.js","fingerprint":"28c3d33c45d69dc5760112592b2e589e13c781f795e0ee212e8fb44b363af293","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterParent"}, - {"file":"server/src/routes/masterData.js","fingerprint":"84943edb161885fdf0358991a25cb76ab086fbd61abc0a918c98260319a2b631","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterParent"}, - {"file":"server/src/routes/masterData.js","fingerprint":"ed6740bc6dd3fe1dcf7c6f61dcf0faaee12827f3d31984c9837dcc3e87a79070","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterParent"}, - {"file":"server/src/routes/masterData.js","fingerprint":"246729bbf97af47de4eb151bba65d11dd270b868a2b928b3fab16ff1c5a230e7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterParent"}, - {"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/legal-entities"}, - {"file":"server/src/routes/masterData.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/legal-entities/:id"}, - {"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/legal-entities/:id"}, - {"file":"server/src/routes/masterData.js","fingerprint":"ca92f646662d11a4962bd5affd2bd7355fdfc5e89c32d3a9ab63a55f83b80f55","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/legal-entities/:id/archive"}, - {"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/cost-centers"}, - {"file":"server/src/routes/masterData.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/cost-centers/:id"}, - {"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/cost-centers/:id"}, - {"file":"server/src/routes/masterData.js","fingerprint":"693964faf025faa1aa110ecdd0ddc2a00538646cd68ab9956d46ef51ad584c9b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/cost-centers/:id/archive"}, - {"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/document-categories"}, - {"file":"server/src/routes/masterData.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/document-categories/:id"}, - {"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/document-categories/:id"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseReplaceDaysBody"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"8837b7f15919ad77abb757977808b0de05190f54a8df757a34637464b2d00d20","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseReplaceDaysBody"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"8506eaeef8adc8cc277fcffb3ce92086e955ecadb4149df7b5a01363da60d337","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"fdc96cb05717a00bf966b3db85f546eb54af6008074759298bb2532b1af926fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/holiday-calendars"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/holiday-calendars/:id"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/holiday-calendars/:id"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:masterDataHolidayCalendarsRoutes"}, - {"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"27733508586c12b8ad87835d0b8eba2a47d1d70c27a7dd86b9424d106b0cc3df","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/holiday-calendars/:id/archive"}, - {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"}, - {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"}, - {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"}, - {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"}, - {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"8506eaeef8adc8cc277fcffb3ce92086e955ecadb4149df7b5a01363da60d337","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"fdc96cb05717a00bf966b3db85f546eb54af6008074759298bb2532b1af926fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers"}, - {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers/:id"}, - {"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers/:id"}, - {"file":"server/src/routes/migrationRuns.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/migrationRuns.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, - {"file":"server/src/routes/migrationRuns.js","fingerprint":"43aaacd8708bb5c07aa913145a5dfa84cd402038b1fee3ada860ee249f8ce9c3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/migrationRuns.js","fingerprint":"3efb722614f1cdee938215738adeb5b2d4930fdb24c470e560b69244f0bcae7e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/migration-runs/dry-run"}, - {"file":"server/src/routes/migrationRuns.js","fingerprint":"f19072dfa882b5edb238bce9545f1adb9ffb544f3571b0bf3f7ba50e28cc5afe","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/migration-runs/dry-run"}, - {"file":"server/src/routes/notifications.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/notifications.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/notifications.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseDeliveryQuery"}, - {"file":"server/src/routes/notifications.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseNotificationId"}, - {"file":"server/src/routes/notifications.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/notifications.js","fingerprint":"57c60265b18cb39c11ef9a4e5ef528ea29ffae46c51f52b11e64a44564993d74","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:handleNotificationChannelWriteError"}, - {"file":"server/src/routes/notifications.js","fingerprint":"8413330dba32e6adedc5c566d53c40d6e596465bf96637bcd76a272a9940c24f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:handleNotificationChannelWriteError"}, - {"file":"server/src/routes/payroll.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/payroll.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, - {"file":"server/src/routes/payroll.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/payroll.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"}, - {"file":"server/src/routes/payroll.js","fingerprint":"8d8b2a6b503e009a90b28a9371fedbaf81f53d1fd0904330ff1a589b18a15868","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseMoneyList"}, - {"file":"server/src/routes/payroll.js","fingerprint":"c2549c5ddc23dc22598ff4d22aa465eedb561a6b130059d44bcf3e279bb18a4b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseMonthOrThrow"}, - {"file":"server/src/routes/payroll.js","fingerprint":"eb4ae752a39b2db5f9a01b4a18a2ef4a033542477df821fde95a073332334a2a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/items"}, - {"file":"server/src/routes/payroll.js","fingerprint":"eb4ae752a39b2db5f9a01b4a18a2ef4a033542477df821fde95a073332334a2a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/items/:itemId"}, - {"file":"server/src/routes/payroll.js","fingerprint":"8f9fcc9a5f96c1d1ddc3792737989e17225d9e62ed663a6323e40c2182878439","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:payrollRoutes"}, - {"file":"server/src/routes/payroll.js","fingerprint":"29feee7aa021e87ec66e539ddd587225b345306848abc928b326193854df5bbb","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/generate"}, - {"file":"server/src/routes/payroll.js","fingerprint":"5cbd8ccfbb4a49806897f695843745f0f6978cf06798722867a1a86f8895ee49","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/generate"}, - {"file":"server/src/routes/payroll.js","fingerprint":"cd1730346f59b6dd1ef61a96541d6b14fae7427014a4df660622d5db1971e7cb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/generate"}, - {"file":"server/src/routes/payroll.js","fingerprint":"d504875855dd432ce8fd8e3712b810057bc498752f4f20d7b1ecdb1f0974b458","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/generate"}, - {"file":"server/src/routes/payroll.js","fingerprint":"2f529b8652d855f222007a9aaf7efda679de466eb009646f68dec1c8af9878d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/:payslipId"}, - {"file":"server/src/routes/payroll.js","fingerprint":"8f9fcc9a5f96c1d1ddc3792737989e17225d9e62ed663a6323e40c2182878439","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/:payslipId"}, - {"file":"server/src/routes/payroll.js","fingerprint":"e5bed6a1ef46e69a472e6a8e0f1637ef20b8a9a7b1b913981c715e6fa0ca53c5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/issue"}, - {"file":"server/src/routes/payroll.js","fingerprint":"3d6f069f1e14a67b816a3f19cf9547f70835b326a63c8e97a730403c189fb0ff","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/issue"}, - {"file":"server/src/routes/performance.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/performance.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, - {"file":"server/src/routes/performance.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/performance.js","fingerprint":"99617ed3d2198ffedead181d3d347c8c6b3be4d886c92bcfa7c58f39389a6d57","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateCycleDates"}, - {"file":"server/src/routes/performance.js","fingerprint":"64ec8f0736b31d4a76082fa397b9adf9107f0628c97b9547026f55a3629f71cd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/performance.js","fingerprint":"5feffb51751e2ea37516e71a87d53134a4dd4c62f8d1365f85071e9e669af061","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureNoOtherActiveCycle"}, - {"file":"server/src/routes/performance.js","fingerprint":"e5be30f4e74036474ea74e5a14b970e139c14c3d245c0db50d115a51aa739287","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/performance/cycles/:cycleId"}, - {"file":"server/src/routes/performance.js","fingerprint":"7ecf4a716d093c397f13de0cf527550ee5338bda3bb148ab77927eae1e278ec4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/performance/cycles/:cycleId"}, - {"file":"server/src/routes/performance.js","fingerprint":"ae0076b9e368da3ee5b33929325622ec7245142706ddc9b1b9ffc654bdb039a9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/performance/cycles/:cycleId"}, - {"file":"server/src/routes/performance.js","fingerprint":"f45ef4d4efbe70b8b8d9986fbfa81ab17ec19b4be5d49a05c50e4113870bf090","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/performance/cycles/:cycleId"}, - {"file":"server/src/routes/performance.js","fingerprint":"b2271cd4b603af5a201ffeacd3f23408030b50cb56e9f7380996a3a90cfe241d","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:performanceRoutes"}, - {"file":"server/src/routes/performance.js","fingerprint":"a36e4a2edab739bdd3595d96a3d8ac1c1e6dd814915bfa5b0a189c17da335abe","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:performanceRoutes"}, - {"file":"server/src/routes/portal.js","fingerprint":"de0048472baa2ade9251177868ca94a4e86e149b88bb49f48d8cc7d10549767b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/portal.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/presence.js","fingerprint":"8082c82f99fcf677784600a080881baa1d83e621985e6c5945f8c90cd05d4297","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseInput"}, - {"file":"server/src/routes/presence.js","fingerprint":"2adc3ff7dd515743a159e4289f5872beaed9785c8fb49532b3c37485d6053cf1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/presence.js","fingerprint":"ab9d9bd7ccc0aad6c6dd652ce7bc50b2c92691ab967336b355c61af3aa1ab0db","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:presenceRoutes"}, - {"file":"server/src/routes/projects.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/projects.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/projects.js","fingerprint":"50daa180b4259d12a5b9bfa3bdf8c1dc8bf65b66fe736780985e0f5e0645bd87","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, - {"file":"server/src/routes/projects.js","fingerprint":"786f7b3c31e737656476b5bf3d9ce110029b4d21a45c73d7bd0a9cd2141b77e4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/projects.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/projects"}, - {"file":"server/src/routes/projects.js","fingerprint":"05cb62a133436678bfe2db9f1af0acaf82a799ff8b253bd29820783f91d6f5ae","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/projects/:id/closure-checklist/:itemId"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePurchaseRequestId"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"74a25a3be91eff6f6378ec4bb7b73f089e32a91c4b72813dd6d8737f53072a12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseAttachmentId"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseArchiveBody"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"32c90a20fefee508b70375039699b0944f7f0e7393868e6f484667d827ec59eb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildPrecheckBlockedError"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"0d724434a86f1930647935a2be2f6d0c3cb0f6f972e32a28568a9db808b300de","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:decimalInputToString"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"abfc2c646e9b5a2faa92673d2e933810ba72e4f21f242139cbcb418f504322bd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:decimalInputToString"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"d74cf60fb2fbfb6c420e156df0c230eccd33b37dd768c62e4cddc729d2b8d36e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"c4967a64f972aa0396e182d2d06880ab684c25d2545466aad0854828740ad39e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"1e04b9db6494f88b9adbdfbc2cafcc8970bccbb039c99e67ce3ccbbfc868ace1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"dc93103cf32df2eb4384550e3ae7ebcc290879e69e11aad7eaea6481f8f414fb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"0bd9235198a75999811434ce3e70e584019d64555ede15c2202bd5f1030edf89","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"36aa7be0c8e53d562f66944b903fb548652b9a5d882b3ca6bbbd15a71a3d70f8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureSupplierAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"31b1543cd9b1084ee05a0a1bd62272225e06a4c2ef43dbe002d5923c73fa46ae","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureSupplierAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"5e473411e4ac8c5e89cc4593424ed01cab03c258c8d7ec3254ee4ed3a57ae9d4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureSupplierAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"1f616caf65363bde2fcebb6b65f22a06972c60903568ff1a1669f3198b66a322","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"9c0bd30f0d8350f57b3e84625952980393265ba93a5faaab83814a12f8b97009","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"1d5e08f2e6e5f0de7f26fb28c9a18afcad9bbca93ee7c289de73a80085d2d726","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"6aeceeea0061576ecdce5bb9ba919e00f69c3ce34c7699223e15a0679d6d646e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureBudgetPoolAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"acc1967cece89a983569e89f0e9a6993c85bcf6b85ab6322b6a8cd603ad29ac3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureBudgetPoolAvailable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"625cfacc8d63ac5a09613bd85f9ce9bb3a6aef3b1c45c631d48e9c9c5effb54a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureProjectLinkingEnabled"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"e3a1f2bab30aec1c86d5589d1dbdc3350bafc1178f33178f1e33ece354de6567","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureProjectLinkable"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePurchaseAttachmentDownloadBody"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePurchaseAttachmentDeleteBody"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"09e6ed8568ec152bc9e258a6af9d3087ca261ab91afb35dacc64b39f0d7f2f77","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildAttachmentBlockedError"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"df339a0b9ae2b0568bbc36f464165a9786d9e7b82d19110b5be663c92adf6307","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"d99112098e6e3ce510653305291db41171fc8ed1e720cadfc66fcb32f6608eb6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"020f59053f8ead4a770124fc5ccae72fdd5e7a75fcde95800f1b6348b64ad0ff","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"426a08b378106f678291428f006adcf189c14e99dc30a36b13ef515aa2022ed5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"af136d0a2f077abe83abf80e534806deca3c33ebd048d5f8bd610662dad2234f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"b045c685e631112457b97aefd603efbe2d2dc7b4ba1f7e40eac3405cefe2adfd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"ac621f7cc93e03e3255a541cbf63faea101f6ae1c017c732634c8d64a2d298f7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"f79cec61c75f3cc4d3a3cd8fa999f2c9c90a3f056541020a4e3ac5ea4507f151","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"c0e7abb9c3ac55801d691f055e8fa5f6f2d94915023a0684d6e9ad1768d85e87","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"4fc911e68f5f8710662d80ffbdc0214968e726f4f25c06969c57cb11643d6147","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"c6f59dbc2caae66192a4f48e0d9b6071d52c7e004e099b0413b68335b725a254","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"f52ae13c892cffc99bb85b3059629d4b449f12f79fe89234a9a4fb4b741428f3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/withdraw"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"08f8022fc7683b6eb3dbd6b92c8ebcb12cc6679b6ca4414842d575a6a7522d36","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/archive-order"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"df339a0b9ae2b0568bbc36f464165a9786d9e7b82d19110b5be663c92adf6307","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"5c5ab17d2c4d8884cbe06e4f4597264afd5afa49353d4727355711193f6fe26b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"ed01eee13031abab1b3ab75739b4e3a06f1ffb3d4d6cda0e91a7dcc56b92383f","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"fd1f07cbb59e6103dfac77bafd01b3ed7a0ce88a5b45ea9f3d7238776249d498","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"561527fb2f4fdce80bdcb3c9b02a1afc9c2664dfd9bcb592f7e554b0b447b8fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"9eca7119004b10837ed03641c72eae0ec83e46d3e5ca20024bb41b95ce1b6f60","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"2b4bec423c5af9d8e18b6ba3196b28911651662dd0294bd835c51d2685146ba2","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"3efb722614f1cdee938215738adeb5b2d4930fdb24c470e560b69244f0bcae7e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"df339a0b9ae2b0568bbc36f464165a9786d9e7b82d19110b5be663c92adf6307","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments/:attachmentId"}, - {"file":"server/src/routes/purchaseRequests.js","fingerprint":"85a20df57650243bd107434cf30f3652f8c466f8040b9446a23bf5999dc24946","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments/:attachmentId/download"}, - {"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTemplateId"}, - {"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"01ede183f16765ade77b433028000084374d4f113f6255e49d41f5c3d7f38646","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureExpenseTypesEnabled"}, - {"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/reimbursement-templates/:id"}, - {"file":"server/src/routes/riskFlags.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/riskFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"}, - {"file":"server/src/routes/riskFlags.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, - {"file":"server/src/routes/riskFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/riskFlags.js","fingerprint":"ccadcec1520fec31ce3906b9792f9f33e3633ce278244816db0e0c53a5f847ad","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:riskFlagsRoutes"}, - {"file":"server/src/routes/search.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/search.js","fingerprint":"ca31c72fdd0b89c6ce11ac23fe5887903daf75f09eeef0e4af5e1330a9f747f7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/search.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/search"}, - {"file":"server/src/routes/systemWorkspace.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/systemWorkspace.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/todos.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/todos.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/todos.js","fingerprint":"6c58fb0c0b2fb699a6a013d4d40a2b87c072068b06e766e74b0e092cc7a335d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"}, - {"file":"server/src/routes/todos.js","fingerprint":"309a572d3996ecefcf2cd76ff4d999f27066193832e45339137025bd0444a326","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/todos.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/todos"}, - {"file":"server/src/routes/travelAuthorizations.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/travelAuthorizations.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/travelAuthorizations.js","fingerprint":"ba1f56c27bbfd403f3f2ec44f1cc63120cd8619bb07758590c204ca433ea6195","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/authorizations/import"}, - {"file":"server/src/routes/travelAuthorizations.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/authorizations/import"}, - {"file":"server/src/routes/travelAuthorizations.js","fingerprint":"c25ad9fd794672ca12ee3f985568d7f60fcd71e6bf4f98397bbf0faa51e57835","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/authorizations/import"}, - {"file":"server/src/routes/travelOrders.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/travelOrders.js","fingerprint":"ba1f56c27bbfd403f3f2ec44f1cc63120cd8619bb07758590c204ca433ea6195","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/orders/import"}, - {"file":"server/src/routes/travelOrders.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/orders/import"}, - {"file":"server/src/routes/travelOrders.js","fingerprint":"401a1e7b894d5bd123a72d89ca48ec5055bf228f8ab41429bc25304b4946b7ef","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/orders/import"}, - {"file":"server/src/routes/travelOrders.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-orders/:id/mark-paid"}, - {"file":"server/src/routes/travelOrders.js","fingerprint":"6ed4360839cd3ae903e4ef9b4e339575c30c0f20a7166c1ea449f17d292ff844","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-orders/:id/mark-paid"}, - {"file":"server/src/routes/travelOrders.js","fingerprint":"f868afb0ae5510d8969c9b43b2f628a8af144ad8ff05768844e9139c7c6a5ecd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-orders/:id/mark-paid"}, - {"file":"server/src/routes/travelOrders.js","fingerprint":"1c393ac35b7be8c743b2904d8587c2e6fd564fa21eb31be689718225cb108291","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-orders/:id/mark-paid"}, - {"file":"server/src/routes/travelOrders.js","fingerprint":"c974531164266ba82a417ecc4fb030096b4abeff568aba3b70b613985301e819","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-orders/:id/mark-paid"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"4ce8a4863c616aaef038fd59c2bd34754e1f4a07a817afc733e8a0bd7dd6398e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"9752938eeee08ad1684e1f901484bf811d47d2ebffbebd74ec34f73be5de7605","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureValidPeriod"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"8506eaeef8adc8cc277fcffb3ce92086e955ecadb4149df7b5a01363da60d337","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateBatchScopeContext"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"3cc514556b50dae2ebe09ef19b335d02d760c8375bf6d85dae2b9cce523f1870","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateBatchScopeContext"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"4b8d375f7b60f2922bcd07343e3ea3a6e3b80c22cacb7f698540885224a1a259","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateBatchScopeContext"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"4ce8a4863c616aaef038fd59c2bd34754e1f4a07a817afc733e8a0bd7dd6398e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/batches"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"41b9715742da06f74902865fe5a1d9cf214f8fe8b47345e3aa827a458e3053b6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/batches"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"7754d00c7af4946d48b4e723e1e6165805cacbff52289e221127d905441f2823","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/batches/:batchId"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"87cb200635db2286f2444af5847774354326bf8162317cdf6012214505a835ec","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/items/:itemId/confirm"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"88ca8fcd24be33d22f8814a459047b1041e99774a9bce0a77991481749ee3fcc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/items/:itemId/confirm"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"87cb200635db2286f2444af5847774354326bf8162317cdf6012214505a835ec","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/items/:itemId/reopen"}, - {"file":"server/src/routes/travelReconciliation.js","fingerprint":"7754d00c7af4946d48b4e723e1e6165805cacbff52289e221127d905441f2823","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/batches/:batchId/finalize"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTravelRequestId"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"e6cf16bb46d3d378312e4d0c034325e4c365748b562a58468eb66fcd1f021db0","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseDateTimeValue"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"0d724434a86f1930647935a2be2f6d0c3cb0f6f972e32a28568a9db808b300de","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:decimalInputToString"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"abfc2c646e9b5a2faa92673d2e933810ba72e4f21f242139cbcb418f504322bd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:decimalInputToString"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"6cb2563bca7c74fed4a6c76d781843eae305be58d2d29908ea589906b6bcef83","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeSegments"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"b1404f502f7dc79f6e49855018d6715052f64db61e4f93376bcde2f8c0934700","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDateRangeValid"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"32c90a20fefee508b70375039699b0944f7f0e7393868e6f484667d827ec59eb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildPrecheckBlockedError"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"646fc9e91a562d0983f9b5e15d73334df2faf1b7760e3c03bae4917cf83292b0","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFinancialDimensions"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"7de438154ec7f3addf2aa384c147760463b5b61a961d3026988b6ff9f71c051d","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFinancialDimensions"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"57ccb19330174bae06b385dca2cb8c41ce487980099e934fef19f8fc3a15cba7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFinancialDimensions"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"92e4ca56a07acf54433ae06c7af4755bd7aa80956a055e41da4388cb5ac61962","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"df339a0b9ae2b0568bbc36f464165a9786d9e7b82d19110b5be663c92adf6307","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"426a08b378106f678291428f006adcf189c14e99dc30a36b13ef515aa2022ed5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/submit"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"5fd193c785794e7bfeca6cba08789d583371b625c54caad5e6ab70750d0381e8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/submit"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"d99112098e6e3ce510653305291db41171fc8ed1e720cadfc66fcb32f6608eb6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/submit"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"020f59053f8ead4a770124fc5ccae72fdd5e7a75fcde95800f1b6348b64ad0ff","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/submit"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"5130090900bde10ab9d5955020cc5c2227dd5680b6036f83e7d14337c8fa5057","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/link-project"}, - {"file":"server/src/routes/travelRequests.js","fingerprint":"f52ae13c892cffc99bb85b3059629d4b449f12f79fe89234a9a4fb4b741428f3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/withdraw"}, - {"file":"server/src/routes/travelTransactions.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/travelTransactions.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/travelTransactions.js","fingerprint":"ba1f56c27bbfd403f3f2ec44f1cc63120cd8619bb07758590c204ca433ea6195","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/transactions/import"}, - {"file":"server/src/routes/travelTransactions.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/transactions/import"}, - {"file":"server/src/routes/travelTransactions.js","fingerprint":"9d03fb23e8d0a0b4abb5044684e8f43268dacf0c6a6fb983034511ee56d598a7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/transactions/import"}, - {"file":"server/src/routes/travelTransactions.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/transactions/:id/freeze-suggestion"}, - {"file":"server/src/routes/travelTransactions.js","fingerprint":"6ed4360839cd3ae903e4ef9b4e339575c30c0f20a7166c1ea449f17d292ff844","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/transactions/:id/freeze-suggestion"}, - {"file":"server/src/routes/users.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUserId"}, - {"file":"server/src/routes/users.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"}, - {"file":"server/src/routes/users.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"}, - {"file":"server/src/routes/users.js","fingerprint":"9629ec80c0f796bc6a00f7dfecb68242c56c3d9ff5fa981740c2afbee0bc5c21","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"}, - {"file":"server/src/routes/users.js","fingerprint":"8aa13b8fbc71173fb12f1a2d9f197b2c993f28bd29015eee8d9a06e58b2e2aa7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"}, - {"file":"server/src/routes/users.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"}, - {"file":"server/src/routes/users.js","fingerprint":"dd4c7435c5433e19eb9893f762c76225772c4cf62eb01c6c45d7b8d1b207705a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"}, - {"file":"server/src/routes/users.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseSensitiveBody"}, - {"file":"server/src/routes/users.js","fingerprint":"dd4c7435c5433e19eb9893f762c76225772c4cf62eb01c6c45d7b8d1b207705a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseSensitiveBody"}, - {"file":"server/src/routes/users.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/users.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBatchBody"}, - {"file":"server/src/routes/users.js","fingerprint":"e60ad7bc69f342e2ea14a2ef0d0debd98d71a4358ad3db7f1273d77e331d0188","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBatchBody"}, - {"file":"server/src/routes/users.js","fingerprint":"64ec8f0736b31d4a76082fa397b9adf9107f0628c97b9547026f55a3629f71cd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLifecycleFeatureEnabled"}, - {"file":"server/src/routes/users.js","fingerprint":"ace2fbe941e2d06d8f7aac2467ce8475df71d7c14253228443c68ef83d294799","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDepartmentUsable"}, - {"file":"server/src/routes/users.js","fingerprint":"0bd10e8819ebb46f6802f2308035459543c5aa995c12e10b3db34b1c0e4bcae4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDepartmentUsable"}, - {"file":"server/src/routes/users.js","fingerprint":"0369c70693eba9ea471e80c6d9c427eb0aa8818467889d3616cef56a3b6b678f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureManagerUsable"}, - {"file":"server/src/routes/users.js","fingerprint":"ace2fbe941e2d06d8f7aac2467ce8475df71d7c14253228443c68ef83d294799","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureManagerUsable"}, - {"file":"server/src/routes/users.js","fingerprint":"8d5fb35f3a4cca58bc32a3966938d9c038cb755122c201e79191f648b3831393","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureManagerUsable"}, - {"file":"server/src/routes/users.js","fingerprint":"facb330f41026823036cb195e4e3d9b76a3af9516e9353a9e4bfd944e9de68fd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:generateOneTimePassword"}, - {"file":"server/src/routes/users.js","fingerprint":"561527fb2f4fdce80bdcb3c9b02a1afc9c2664dfd9bcb592f7e554b0b447b8fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"}, - {"file":"server/src/routes/users.js","fingerprint":"1b27bf379328573c70d6d1995fab5ec7ddb58308a9058235fc86451eb943efdb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"}, - {"file":"server/src/routes/users.js","fingerprint":"0df105fd6c424e2d4cf00942f34c4cd306bbcb55116207f24d365de76fe02d68","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"}, - {"file":"server/src/routes/users.js","fingerprint":"ce0ac252d216b04fd3edb044c4e83b79094808a6950e9443fd55c7231bb53b15","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"}, - {"file":"server/src/routes/users.js","fingerprint":"3714c500736e5dc6b230fb71e2895fc7fd653da4134811192579cb2a8f3403cf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"}, - {"file":"server/src/routes/users.js","fingerprint":"0c5c7e91b73ae8e0c05e8c3a34dbbdb250559b2f46385fe5c66ed1aff3a02166","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"}, - {"file":"server/src/routes/users.js","fingerprint":"6bc44520602f42ec038dcfd6f58b0c2f56dae06e8e26a50870b576066fad1eeb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"}, - {"file":"server/src/routes/users.js","fingerprint":"2255ec084503939a7653b2dc67783bca3f1affb9fa99c046d39b0b2d7fb35f98","count":3,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"}, - {"file":"server/src/routes/users.js","fingerprint":"ba37f131e8e7ec10223c5cbf578abe550f01988bd91f594862771bfae88a223c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"}, - {"file":"server/src/routes/users.js","fingerprint":"94d1f0e015e7774bcc0adaa3550ca8bebabea49c52e7ea9679ce22310f6c888d","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"}, - {"file":"server/src/routes/users.js","fingerprint":"f642f8ed261072a289ce343e133ac9b0954ebac4c531e1993209f7ea9876a7a6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"}, - {"file":"server/src/routes/users.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":3,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"}, - {"file":"server/src/routes/users.js","fingerprint":"c2c42563d5083e1fcffc70061e11f05a5fb622030879e07c9112e128f780f0e4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"}, - {"file":"server/src/routes/users.js","fingerprint":"8863d485ac227a65846c44668d0c84fb88de50f56f4f921f66a02685297720e7","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"}, - {"file":"server/src/routes/users.js","fingerprint":"a4ed2cb425167eb0564f1d3f38574b1caacfd0fbff3544381dd70f1992bade04","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"}, - {"file":"server/src/routes/users.js","fingerprint":"8c73684345b1f5a10d5cb4f2911293eab416bb4ee406354f3a8c4c0d4caee34a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"}, - {"file":"server/src/routes/wecomAuth.js","fingerprint":"a86ce70414af8f63b8b3ffc060802dcc2540bc915bfa8669802ff91f167a728f","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeJsSdkPageUrl"}, - {"file":"server/src/routes/wecomAuth.js","fingerprint":"55c80c9479f485c70513c36176f96c81b8ee4ebdad4d77bcd5cab252f5370808","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:assertWecomOauthReady"}, - {"file":"server/src/routes/wecomAuth.js","fingerprint":"d70cc75afc07320183256873a1bda11d5ec98169057ce9dd8af5ad976c573533","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:getWecomAccessToken"}, - {"file":"server/src/routes/wecomAuth.js","fingerprint":"a86ce70414af8f63b8b3ffc060802dcc2540bc915bfa8669802ff91f167a728f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/wecom/js-sdk-signature"}, - {"file":"server/src/routes/wecomAuth.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/wecom/login"}, - {"file":"server/src/routes/wecomAuth.js","fingerprint":"2b0b99fd226589d42c63361d123e4721928f1243cb62e242ec6563dd3349e58e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/wecom/login"}, - {"file":"server/src/routes/wecomAuth.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/wecom/bind"}, - {"file":"server/src/routes/weeklyReports.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/weeklyReports.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/weeklyReports.js","fingerprint":"503ec8814ed94eae417ed7863bf5f57b8f222346eeb202dd07517c08bacef9fc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/weeklyReports.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/weekly-reports/me"}, - {"file":"server/src/routes/weeklyReports.js","fingerprint":"c467510c074544d5abb39af6d212600918c78bae7e46bb8c1837b82b1eb16ccb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:weeklyReportsRoutes"}, - {"file":"server/src/routes/weeklyReports.js","fingerprint":"d3e6e1b5646c7292abc574c53b3894ca82620c1f2cb70d3c0f968e0bea4764df","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:weeklyReportsRoutes"}, - {"file":"server/src/routes/weeklyReports.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/weekly-reports/team"}, - {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"}, - {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"}, - {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"440871aaf1bf287ce45b8aff11b1c04f2f21151f3898d17f7752253ea9debfed","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseEntityType"}, - {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"39168f5e747cc5770558e5a046456e986f365b93b4072c93a56dc14761b770dd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"}, - {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"2236cc64fcafe74edc1e79d11a6e93efc7379d02257b7d5fc1e454ee5f9bde95","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions"}, - {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"2236cc64fcafe74edc1e79d11a6e93efc7379d02257b7d5fc1e454ee5f9bde95","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions/:id"}, - {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"567632d90a6968fcc24a576a4699526e2b0fbe15ff285494c449014f955cac77","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions/:id"}, - {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"2236cc64fcafe74edc1e79d11a6e93efc7379d02257b7d5fc1e454ee5f9bde95","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions/:id/publish"}, - {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"9e2ed33d6382c6f9d820878049ac1c9d4b11cdecfae6efe6e5a4a4924b7e87b5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions/:id/publish"}, - {"file":"server/src/routes/workflowDefinitions.js","fingerprint":"2236cc64fcafe74edc1e79d11a6e93efc7379d02257b7d5fc1e454ee5f9bde95","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions/:id/archive"}, - {"file":"server/src/services/ai/suggestions.js","fingerprint":"43c8e843f54d9ce819d8d943da46acf1637e682537b116b3d070d3e67523e256","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildAiDisabledError"}, - {"file":"server/src/services/attendance.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildAttendanceValidationError"}, - {"file":"server/src/services/attendance.js","fingerprint":"2ee8bb29f955fb3062828a3dcf1e0e9cdf801091629163c8a5e7f500dd8ec64d","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTimeToMinutes"}, - {"file":"server/src/services/attendance.js","fingerprint":"362096848d0df743442bfb66cdf57a594b74d7ad1d8c49068b4e623fadd51483","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateAttendancePolicyInput"}, - {"file":"server/src/services/attendance.js","fingerprint":"57d8963b1bfd4124fc9dd309a0fe0d6fa2286251aa94ba702cde045a4fba70fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateAttendancePolicyInput"}, - {"file":"server/src/services/attendance.js","fingerprint":"33f1a6a204fc84205fa86787d743080b63ccbe0c1c29700bbb6a6987870fad24","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateAttendancePolicyInput"}, - {"file":"server/src/services/attendance.js","fingerprint":"541024df6a9deeb726d27470531467a9244f66d9196f1658867f494c04757798","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateAttendancePolicyInput"}, - {"file":"server/src/services/attendance.js","fingerprint":"2ce1b8dcfb0321bbef8d1102f6c60cd5dfe1aab6fd4b4ccf7f86e3dedaf4e6ff","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildMonthRange"}, - {"file":"server/src/services/attendance.js","fingerprint":"99ebe0d43dca95cb33f043545c5302d439e6822f524e9f2c083f1d9f46feb362","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildMonthRange"}, - {"file":"server/src/services/budgetPool.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/services/budgetPool.js","fingerprint":"8ea8274148c5fc65a473197d6a2617bf27971d7d8de4b26f9c3ec4e28bc7879e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveBudgetScope"}, - {"file":"server/src/services/budgetPool.js","fingerprint":"15b3efedb6c7af2094e04906a764b073066e16614492a2e8eadb52d4cea4899c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveBudgetScope"}, - {"file":"server/src/services/budgetPool.js","fingerprint":"78bb3b7b0cb97341c844a151c521cb679fc0c4115369dffcadcae5f15b48b594","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveBudgetScope"}, - {"file":"server/src/services/budgetPool.js","fingerprint":"8ea8274148c5fc65a473197d6a2617bf27971d7d8de4b26f9c3ec4e28bc7879e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeBudgetContext"}, - {"file":"server/src/services/budgetPool.js","fingerprint":"c10503d03230e1ac3fb2c83e632f28ff6054d39cb3522e43daa8929b4085fc62","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:selectBudgetPoolCandidate"}, - {"file":"server/src/services/budgetPool.js","fingerprint":"b9800e3b093f9036da6d9eb06e688b99636297e64ae73052c309b2724c24fd22","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveApplicableBudgetPoolByContext"}, - {"file":"server/src/services/contractRegistryFilters.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildContractRegistryValidationError"}, - {"file":"server/src/services/contractRegistryFilters.js","fingerprint":"0d7d8d325c8629980a1d6f1c569255c2995059d5b19667be27f57eed2ea837af","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseContractRegistryFilters"}, - {"file":"server/src/services/contractRegistryFilters.js","fingerprint":"0157cfe6b95bfb9824b92b3187d51e79af6759e2082f6f829c29ec79dc41eed1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseContractRegistryFilters"}, - {"file":"server/src/services/contractRegistryFilters.js","fingerprint":"0d7d8d325c8629980a1d6f1c569255c2995059d5b19667be27f57eed2ea837af","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseContractRegistryExpiringQuery"}, - {"file":"server/src/services/cutover.js","fingerprint":"547aac9dca5ebf4d86d1488f348d7cc03c32103a801898e4c8acc9866dd47da7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCutoverAllowed"}, - {"file":"server/src/services/documentSequence.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/services/documentSequence.js","fingerprint":"b929462215d217481e0021dbc6414f9dd0d13a7ff37a3485e70e251a34649f96","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeDocumentType"}, - {"file":"server/src/services/documentSequence.js","fingerprint":"52251a414cebd986f1a66ada25aa84ad8201ef118fffda7cb25a4b0c5eb7b3c6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:findDocumentCategory"}, - {"file":"server/src/services/documentSequence.js","fingerprint":"17742cab588bf1fa29dcb863cac76aa8e313cc9fbe831e384e6076865c9f4c92","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:findDocumentCategory"}, - {"file":"server/src/services/documentSequence.js","fingerprint":"218b770702cc4205ce09e22bf23938845fbe17e9677d717994df7ef74cf7c4c9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:findDocumentCategory"}, - {"file":"server/src/services/employeeDirectoryFilters.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/services/employeeDirectoryFilters.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseEmployeeDirectoryFilters"}, - {"file":"server/src/services/employeeDirectoryFilters.js","fingerprint":"c7d5cb33dcb5fc3619be1f8aa436be5129922d43e88e5723b1077127d3980e9c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseEmployeeDirectoryFilters"}, - {"file":"server/src/services/feeControl.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/services/feeControl.js","fingerprint":"82e83bd1db5eb446f90f9a600d75e13a0f9d71bd48454bb770ab8a1055fa4a96","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:loadPrecheckEntityContext"}, - {"file":"server/src/services/feeControl.js","fingerprint":"82e83bd1db5eb446f90f9a600d75e13a0f9d71bd48454bb770ab8a1055fa4a96","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildEntityPrecheck"}, - {"file":"server/src/services/presenceHistory.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildPresenceValidationError"}, - {"file":"server/src/services/presenceHistory.js","fingerprint":"819ebeb74216f9c73d4d3677beea18e8919b8d4803a7f52d7d0b01c2dfdbe1cb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolvePresenceHistoryRange"}, - {"file":"server/src/services/presenceHistory.js","fingerprint":"903f7ff892fca342b08ad67d8edd56defbb3981edc8974890b0151a0fe53bee0","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolvePresenceHistoryRange"}, - {"file":"server/src/services/purchaseBudget.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildPurchaseBudgetValidationError"}, - {"file":"server/src/services/purchaseBudget.js","fingerprint":"e4d46dd9d7b30343b07d84605262f5a96f639d93f3362edbc9eb0f4448ff0d0b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:reserveBudgetForEntity"}, - {"file":"server/src/services/purchaseBudget.js","fingerprint":"5d79c097496ad908123fee937c35dbd8ddcb6d0f79bfb3dc697bff0cee946c0c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:reserveBudgetForEntity"}, - {"file":"server/src/services/travelPayments.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildTravelPaymentValidationError"}, - {"file":"server/src/services/travelPayments.js","fingerprint":"70cfb750ebbaa9e398ab6a8ab743ea042422ada7a3ee87be85ab9314c5ce6f09","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:applyBudgetPoolUsageDelta"}, - {"file":"server/src/services/travelReconciliation.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"}, - {"file":"server/src/services/travelReconciliation.js","fingerprint":"387721ed02fa83f31bd471035145206c36af7484532afe5353955e667dfc89d1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDraftBatch"}, - {"file":"server/src/services/travelReconciliation.js","fingerprint":"17733bab2d92da224cf69fdedb3ee146cbf121eef58e881794e407a51b058f68","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensurePendingReconciliationItem"}, - {"file":"server/src/services/travelReconciliation.js","fingerprint":"0443a8ffa4324e1e4e08fa89037c0b28b24213ab09941a8ceff40126a457ea1e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureConfirmedReconciliationItem"}, - {"file":"server/src/services/travelReconciliation.js","fingerprint":"bbe9cd46973ca0c77ae1763e6adba68e26441e9584168373a97324864b6697ea","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureReopenableReconciliationItem"}, - {"file":"server/src/services/travelReconciliation.js","fingerprint":"3ac5e1d7ea04918b94a7e5083b0a6c4ef002a6fe68b1b168d6be7a58125ed0a8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureBatchFinalizable"}, - {"file":"server/src/utils/workflow.js","fingerprint":"04bd03a6f4a2518dcecf7d1702e4695d0104b403a6b929a1147a17e9a32ba6a5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseWorkflowSteps"}, - {"file":"server/src/utils/workflow.js","fingerprint":"0cd1c6b1f3fa1355f63b8def66e42f047c1ac5bacbdea9a4a8258212cd401648","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:assertBusinessApprovalRoleCode"}, - {"file":"server/src/utils/workflow.js","fingerprint":"207961eec0e9985eb51a9108be430c27f2365413a4cefe00cdec2d58df4e228a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildWorkflowStepsFromApprovalRule"}, - {"file":"server/src/utils/workflow.js","fingerprint":"7ba594f9bf352b163a734e664825e1e964ed4491d0120eef78a9fc3aa8b43a07","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildWorkflowStepsFromApprovalRule"}, - {"file":"server/src/utils/workflow.js","fingerprint":"b8698fe0fd39540fb0a1d21bf610947927564bdf7150e35b98ee0077317cc84b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:assertOwnerSelfFallbackRole"}, - {"file":"server/src/utils/workflow.js","fingerprint":"ca50ced3104a846b28238b26edb8fcb7af6d10be5fa07ff92438f33b65b659aa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:assertOwnerSelfFallbackRole"}, - {"file":"server/src/utils/workflow.js","fingerprint":"d08fdd02992c489e39264bf0b214f52d0bd1ce5a1c58b6e5342d5bd4347a7057","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildApprovalWorkflowSnapshot"}, - {"file":"server/src/utils/workflow.js","fingerprint":"d8fafe98ccaff8cefb614917926b0bc1f90244ac555e5a8eb18d0d32ee5a1d72","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildApprovalWorkflowSnapshot"}, - {"file":"server/src/utils/workflow.js","fingerprint":"f5c8f2ab31ac7144fd05d20bdcc8db4cf6d92a7fb6e8405326dc77f9e68e2b84","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveAssigneeFromRule"}, - {"file":"server/src/utils/workflow.js","fingerprint":"feb0686cc3f1b63906adb701af0d69479951f3a81172d7b2c0414e5b1b6b7ce2","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveAssigneeFromRule"}, - {"file":"server/src/utils/workflow.js","fingerprint":"5206020abf57bcd7c7ba728ca164031668424f199e062694d69a57cfdaf5980e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveAssigneeFromRule"}, - {"file":"server/src/utils/workflow.js","fingerprint":"806e44515d6d7eb6dc41a7fa24363b1fb61a6ab6a28dd1168b2d855ca8e0b994","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveAssigneeFromRule"}, - {"file":"server/src/utils/workflow.js","fingerprint":"ce63d28720f7d951355348d5ab600ff5009a35ed855613caf5706f9fb007a2d7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveAssigneeFromRule"}, - {"file":"server/src/utils/workflow.js","fingerprint":"b312a21b28c9dbe1efc4bd7f5fd029b62a2c0cceedf88f4e621a84f948896e8a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveActionableStep"}, - {"file":"server/src/utils/workflow.js","fingerprint":"7e3347da9ac0499d2aa163fe5f743ed01b3abeb75a3267626b76e4a566d246de","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveActionableStep"} - ] + "entries": [] } diff --git a/scripts/qa/full-local-coverage-contract.json b/scripts/qa/full-local-coverage-contract.json new file mode 100644 index 00000000..93113e68 --- /dev/null +++ b/scripts/qa/full-local-coverage-contract.json @@ -0,0 +1,232 @@ +{ + "schemaVersion": 1, + "approvalReference": "09-06-release-gate-integrity G19; 2026-09-18 用户批准分层验收", + "exceptions": [ + { + "file": "client/tests/e2e/live-smoke.spec.ts", + "id": "365489eee4d4394f3125-8b8398ac223b126b928f", + "title": "公网基础探活与企微配置状态", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "5a8dcb90b7a747343120c68743dc3e726b6f56fdc7abbefb9318611cc3dc6f16", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "scripts/qa/run-web-gates.sh smoke-live", + "responsibilityRole": "发布/企微入口验收", + "namedOwner": null, + "requiredEvidence": "当前候选的匿名配置边界和管理员/员工只读页面", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/live-smoke.spec.ts", + "id": "365489eee4d4394f3125-45091a332e611ca4d4b6", + "title": "管理员只读网页 smoke", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "5a8dcb90b7a747343120c68743dc3e726b6f56fdc7abbefb9318611cc3dc6f16", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "scripts/qa/run-web-gates.sh smoke-live", + "responsibilityRole": "发布/企微入口验收", + "namedOwner": null, + "requiredEvidence": "当前候选的匿名配置边界和管理员/员工只读页面", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/live-smoke.spec.ts", + "id": "365489eee4d4394f3125-2a8b3f6ab858d5ed2edf", + "title": "员工只读网页 smoke", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "5a8dcb90b7a747343120c68743dc3e726b6f56fdc7abbefb9318611cc3dc6f16", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "scripts/qa/run-web-gates.sh smoke-live", + "responsibilityRole": "发布/企微入口验收", + "namedOwner": null, + "requiredEvidence": "当前候选的匿名配置边界和管理员/员工只读页面", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/oafront3-pc-readonly-visual.spec.ts", + "id": "e2a48d9364fe7e78ebe4-db965786c46856b925ec", + "title": "角色化访问 PC 关键页面并生成只读视觉证据", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "dfe9e58e53d12deb7e67afb210d62351ac1527bb4ad38d1e4ed17946ee70409d", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "scripts/qa/run-oafront3-pc-readonly-visual.sh", + "responsibilityRole": "角色只读视觉验收", + "namedOwner": null, + "requiredEvidence": "经批准的真实只读角色及页面;不得默认加载真实 env", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/r104-public-visual-acceptance.spec.ts", + "id": "30adf2c642da2c48495e-9f9637e1bdbfe16c765e", + "title": "公网桌面登录页与企业微信配置可见", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "6062d85f1417f7f81823600ef354db1c1110ee4a867151e145e76771f73cde05", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "E2E_MODE=live npx playwright test tests/e2e/r104-public-visual-acceptance.spec.ts --retries=0", + "responsibilityRole": "公网 PC/移动体验验收", + "namedOwner": null, + "requiredEvidence": "真实 OAuth 边界、员工移动及 PC 页面", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/r104-public-visual-acceptance.spec.ts", + "id": "30adf2c642da2c48495e-c96d1dc362b0c90bef45", + "title": "公网企业微信 WebView 入口跳转到 OAuth 边界", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "6062d85f1417f7f81823600ef354db1c1110ee4a867151e145e76771f73cde05", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "E2E_MODE=live npx playwright test tests/e2e/r104-public-visual-acceptance.spec.ts --retries=0", + "responsibilityRole": "公网 PC/移动体验验收", + "namedOwner": null, + "requiredEvidence": "真实 OAuth 边界、员工移动及 PC 页面", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/r104-public-visual-acceptance.spec.ts", + "id": "30adf2c642da2c48495e-89c56782b69f34a7da95", + "title": "公网员工移动工作台与高频页面保持移动体验", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "6062d85f1417f7f81823600ef354db1c1110ee4a867151e145e76771f73cde05", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "E2E_MODE=live npx playwright test tests/e2e/r104-public-visual-acceptance.spec.ts --retries=0", + "responsibilityRole": "公网 PC/移动体验验收", + "namedOwner": null, + "requiredEvidence": "真实 OAuth 边界、员工移动及 PC 页面", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/r104-public-visual-acceptance.spec.ts", + "id": "30adf2c642da2c48495e-5e7cbbdeb955eb29a064", + "title": "公网 PC 报销与票据页面不回退到路径式标题", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "6062d85f1417f7f81823600ef354db1c1110ee4a867151e145e76771f73cde05", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "E2E_MODE=live npx playwright test tests/e2e/r104-public-visual-acceptance.spec.ts --retries=0", + "responsibilityRole": "公网 PC/移动体验验收", + "namedOwner": null, + "requiredEvidence": "真实 OAuth 边界、员工移动及 PC 页面", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/r105-pretrial-visual.spec.ts", + "id": "671b0bc6dbfa8f5c6309-bcbb26db989ad12bb527", + "title": "公网登录与企业微信入口仍默认进入移动工作台", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "643a8f55df63f56b053aea770d1bfc54e4ef8278193991b244b692c7712e16db", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "scripts/qa/run-pretrial-readiness.sh --visual", + "responsibilityRole": "试运行准备/公网体验验收", + "namedOwner": null, + "requiredEvidence": "入口、员工和管理员 PC/移动;历史 admin 审批假设须先核当前权限", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/r105-pretrial-visual.spec.ts", + "id": "671b0bc6dbfa8f5c6309-8f75d16673e2a1fb5141", + "title": "员工移动首页、票据上传和我的页面保持手机体验", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "643a8f55df63f56b053aea770d1bfc54e4ef8278193991b244b692c7712e16db", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "scripts/qa/run-pretrial-readiness.sh --visual", + "responsibilityRole": "试运行准备/公网体验验收", + "namedOwner": null, + "requiredEvidence": "入口、员工和管理员 PC/移动;历史 admin 审批假设须先核当前权限", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/r105-pretrial-visual.spec.ts", + "id": "671b0bc6dbfa8f5c6309-f93f8021b0b1357f1d3d", + "title": "管理员移动审批页不退回桌面表格体验", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "643a8f55df63f56b053aea770d1bfc54e4ef8278193991b244b692c7712e16db", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "scripts/qa/run-pretrial-readiness.sh --visual", + "responsibilityRole": "试运行准备/公网体验验收", + "namedOwner": null, + "requiredEvidence": "入口、员工和管理员 PC/移动;历史 admin 审批假设须先核当前权限", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/r105-pretrial-visual.spec.ts", + "id": "671b0bc6dbfa8f5c6309-f68af4e323d362a886a7", + "title": "PC Portal、报销和票据保持试运行首页一致性", + "projectName": "", + "repeatEachIndex": 0, + "kind": "post-deploy", + "sourceSha256": "643a8f55df63f56b053aea770d1bfc54e4ef8278193991b244b692c7712e16db", + "reason": "需要公网部署及经批准的真实只读身份", + "entry": "scripts/qa/run-pretrial-readiness.sh --visual", + "responsibilityRole": "试运行准备/公网体验验收", + "namedOwner": null, + "requiredEvidence": "入口、员工和管理员 PC/移动;历史 admin 审批假设须先核当前权限", + "replacementFile": null, + "replacementTitle": null + }, + { + "file": "client/tests/e2e/travel-payment-flow.spec.ts", + "id": "dcd93a716d5f1dd3dbad-b611c274a721c4b81f48", + "title": "E2E 差旅支付主链路:申请→审批→支付→绑票", + "projectName": "", + "repeatEachIndex": 0, + "kind": "frozen", + "sourceSha256": "f8a0afba7ddf7a19b5ddb91bfcf42697c2ea582d49715d3c022157d88cf83b33", + "reason": "差旅业务冻结,保留旧整链,不恢复权限", + "entry": "bash scripts/qa/run-web-gates.sh full-local; replacement: client/tests/e2e/travel-freeze-protection.spec.ts", + "responsibilityRole": "冻结差旅支付业务责任", + "namedOwner": null, + "requiredEvidence": "当前合成角色不可达、正式接口拒绝、共享付款冻结及入站导入权限拒绝", + "replacementFile": "client/tests/e2e/travel-freeze-protection.spec.ts", + "replacementTitle": "冻结差旅:员工入口与共享付款边界", + "historicalEntry": "full-local; scripts/qa/run-business-flow-gates.sh" + }, + { + "file": "client/tests/e2e/travel-reconciliation-flow.spec.ts", + "id": "4a81bdc0eb898c93e44d-b5124d64393296358132", + "title": "E2E 差旅对账批次:创建→确认异常→收口", + "projectName": "", + "repeatEachIndex": 0, + "kind": "frozen", + "sourceSha256": "c2c535cccdd5add2d2005037e7c2946e528ccaf481aaa5d2522b2c482d6dff4e", + "reason": "差旅业务冻结,保留旧整链,不恢复权限", + "entry": "bash scripts/qa/run-web-gates.sh full-local; replacement: client/tests/e2e/travel-freeze-protection.spec.ts", + "responsibilityRole": "冻结差旅对账业务责任", + "namedOwner": null, + "requiredEvidence": "当前合成角色不可达、正式接口拒绝、共享付款冻结及入站导入权限拒绝", + "replacementFile": "client/tests/e2e/travel-freeze-protection.spec.ts", + "replacementTitle": "冻结差旅:财务对账权限与入站导入权限边界", + "historicalEntry": "full-local; scripts/qa/run-business-flow-gates.sh" + } + ] +} diff --git a/scripts/qa/full-local-coverage.mjs b/scripts/qa/full-local-coverage.mjs new file mode 100644 index 00000000..bb9ae8a3 --- /dev/null +++ b/scripts/qa/full-local-coverage.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { verifyFullLocalDiscovery } from "./verify-full-local-discovery.mjs"; +import { sourceSnapshot } from "./full-local-diagnostic.mjs"; + +const hash = value => createHash("sha256").update(value).digest("hex"); +export const caseKey = entry => JSON.stringify([entry.file, entry.id, entry.title, entry.projectName ?? "", entry.repeatEachIndex ?? 0]); +const sameCandidate = (a, b) => Boolean(a && b && /^[a-f0-9]{40}$/.test(a.commit ?? "") && /^[a-f0-9]{64}$/.test(a.digest ?? "") && a.commit === b.commit && a.digest === b.digest); + +export const localSpecArgs = plan => plan.localSpecs.map(file => `${file.slice(7).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}$`); + +export function coveragePlan(root, discovery, contract, candidate) { + const full = verifyFullLocalDiscovery(root, discovery); + const issues = [...full.issues]; + const exceptions = contract?.exceptions ?? []; + if (contract?.schemaVersion !== 1 || !Array.isArray(contract?.exceptions) || !contract.approvalReference) issues.push("invalid_coverage_contract"); + if (!sameCandidate(candidate, candidate)) issues.push("invalid_candidate"); + const keys = full.cases.map(caseKey); + if (new Set(keys).size !== keys.length) issues.push("duplicate_discovery_case"); + const registered = new Map(); + for (const entry of exceptions) { + const key = caseKey(entry); + if (registered.has(key)) issues.push("duplicate_exception"); + registered.set(key, entry); + if (!keys.includes(key)) issues.push("exception_identity_drift"); + if (!["post-deploy", "frozen"].includes(entry.kind) || !entry.reason || !entry.entry || !entry.responsibilityRole || !entry.requiredEvidence) issues.push("invalid_exception_obligation"); + if (!full.expectedSpecs.includes(entry.file)) issues.push("exception_source_unknown"); + else if (hash(readFileSync(path.join(root, entry.file))) !== entry.sourceSha256) issues.push("exception_source_changed"); + if (entry.kind === "frozen" && !full.cases.some(row => row.file === entry.replacementFile && row.title === entry.replacementTitle && !exceptions.some(other => caseKey(other) === caseKey(row)))) issues.push("frozen_protection_missing"); + } + const exceptionFiles = new Set(exceptions.map(entry => entry.file)); + const localCases = full.cases.filter(entry => !registered.has(caseKey(entry))); + if (localCases.some(entry => exceptionFiles.has(entry.file))) issues.push("unregistered_case_in_exception_file"); + if (!localCases.length) issues.push("empty_local_coverage"); + const localSpecs = [...new Set(localCases.map(entry => entry.file))].sort(); + return { + schemaVersion: 1, kind: "LAYERED_LOCAL_COVERAGE_PLAN", status: issues.length ? "FAIL" : "PASS", + candidate: { commit: candidate?.commit, digest: candidate?.digest }, contractSha256: hash(JSON.stringify(contract)), + discoverySha256: hash(JSON.stringify(discovery)), localSpecs, localCases, + obligations: exceptions.map(entry => ({ ...entry, status: entry.kind === "post-deploy" ? "NOT_RUN_LOCAL" : "FROZEN_PROTECTION_REQUIRED" })), + totalCases: full.cases.length, issues: [...new Set(issues)], normalReleaseEligible: false, + }; +} + +function reportCases(report) { + const cases = []; + function visit(suites, inherited = "") { + for (const suite of suites ?? []) { + for (const spec of suite.specs ?? []) { + let file = (spec.file ?? suite.file ?? inherited).replaceAll("\\", "/"); + if (!file.startsWith("client/tests/e2e/")) file = `client/tests/e2e/${file.replace(/^tests\/e2e\//u, "")}`; + for (const test of spec.tests ?? []) cases.push({ file, id: spec.id, title: spec.title, projectName: test.projectName ?? "", repeatEachIndex: test.repeatEachIndex ?? 0, ...test }); + } + visit(suite.suites, suite.file ?? inherited); + } + } + visit(report?.suites); + return cases; +} + +export function verifyCaseExecution(expected, report) { + const issues = []; + if (!Array.isArray(report?.suites) || !Array.isArray(report?.errors)) issues.push("invalid_execution_report"); + if (report?.errors?.length) issues.push("execution_errors"); + const actual = reportCases(report); + const byKey = new Map(); + for (const entry of actual) { + const key = caseKey(entry); + if (byKey.has(key)) issues.push("duplicate_execution_case"); + byKey.set(key, entry); + } + const cases = expected.map(entry => { + const observed = byKey.get(caseKey(entry)); + byKey.delete(caseKey(entry)); + const passed = observed?.expectedStatus === "passed" && observed?.status === "expected" && observed?.results?.length === 1 && observed.results[0].status === "passed" && observed.results[0].retry === 0; + if (!passed) issues.push(observed ? "case_not_clean_pass" : "missing_execution_case"); + return { ...entry, status: passed ? "PASS" : "FAIL", observedStatus: observed?.results?.at(-1)?.status ?? "NOT_RUN" }; + }); + if (byKey.size) issues.push("unexpected_execution_case"); + return { status: issues.length ? "FAIL" : "PASS", cases, issues: [...new Set(issues)] }; +} + +export function verifyLocalCoverage(plan, report, candidate) { + const result = verifyCaseExecution(plan.localCases, report); + if (plan.status !== "PASS") result.issues.push("coverage_plan_invalid"); + if (!sameCandidate(plan.candidate, candidate)) result.issues.push("candidate_changed"); + result.status = result.issues.length ? "FAIL" : "PASS"; + return { ...result, kind: "LOCAL_BROWSER_COVERAGE_ONLY", candidate: plan.candidate, + obligations: plan.obligations.map(entry => ({ ...entry, status: entry.kind === "post-deploy" ? "NOT_RUN_LOCAL" : result.cases.some(row => row.file === entry.replacementFile && row.title === entry.replacementTitle && row.status === "PASS") ? "FROZEN_PROTECTION_PASS" : "FROZEN_PROTECTION_FAIL" })), + deliveryStatus: "INCOMPLETE", normalReleaseEligible: false }; +} + +// Pure offline verifier. This does not run public tests or authorize deployment. +export function verifyPostDeployCoverage(plan, evidence) { + const expected = plan.obligations.filter(entry => entry.kind === "post-deploy"); + const result = verifyCaseExecution(expected, evidence?.report); + if (plan.status !== "PASS" || !expected.length) result.issues.push("coverage_plan_invalid"); + if (evidence?.profile !== "post-deploy-live" || evidence?.mode !== "live") result.issues.push("live_profile_required"); + if (!sameCandidate(plan.candidate, evidence?.candidate) || !sameCandidate(plan.candidate, evidence?.deployedCandidate)) result.issues.push("deployed_candidate_mismatch"); + result.status = result.issues.length ? "FAIL" : "PASS"; + return { ...result, normalReleaseEligible: false }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + try { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + const [mode, directory] = process.argv.slice(2); + const read = file => JSON.parse(readFileSync(path.join(directory, file), "utf8")); + const contract = JSON.parse(readFileSync(path.join(root, "scripts/qa/full-local-coverage-contract.json"), "utf8")); + const current = coveragePlan(root, read("discovery.json"), contract, sourceSnapshot(root)); + if (mode === "plan") { + console.log(JSON.stringify(current, null, 2)); + if (current.status === "PASS") writeFileSync(path.join(directory, "local-spec-args.txt"), localSpecArgs(current).join("\n") + "\n", { flag: "wx" }); + process.exitCode = current.status === "PASS" ? 0 : 1; + } else if (mode === "verify-local") { + const saved = read("coverage-plan.json"); + const result = verifyLocalCoverage(saved, read("playwright-results.json"), current.candidate); + if (JSON.stringify(saved) !== JSON.stringify(current)) result.issues.push("coverage_plan_changed"); + result.status = result.issues.length ? "FAIL" : "PASS"; + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.status === "PASS" ? 0 : 1; + } else throw new Error("unknown_mode"); + } catch { + console.log(JSON.stringify({ status: "FAIL", issues: ["coverage_input_invalid"], normalReleaseEligible: false })); + process.exitCode = 1; + } +} diff --git a/scripts/qa/full-local-diagnostic.mjs b/scripts/qa/full-local-diagnostic.mjs index fd71be93..7fedf42c 100644 --- a/scripts/qa/full-local-diagnostic.mjs +++ b/scripts/qa/full-local-diagnostic.mjs @@ -223,6 +223,15 @@ async function main() { let discovery = null; try { discovery = existsSync(discoveryFile) ? readJson(discoveryFile) : null; + const planFile = path.join(reportDir, "full-local-report/coverage-plan.json"); + if (existsSync(planFile)) { + const plan = readJson(planFile); + if (plan.status !== "PASS") throw new Error("invalid_layered_plan"); + discovery = { ...discovery, cases: plan.localCases }; + report.coverageObligations = plan.obligations; + const resultFile = path.join(reportDir, "full-local-report/local-coverage-result.json"); + report.localCoverage = existsSync(resultFile) ? readJson(resultFile) : { status: "NOT_RUN" }; + } report.browser = browserInventory(discovery, existsSync(browserFile) ? readJson(browserFile) : null); } catch { report.browser = browserInventory(discovery, null); diff --git a/scripts/qa/full-local-environment-inner.sh b/scripts/qa/full-local-environment-inner.sh index b6956d86..67b42ffd 100644 --- a/scripts/qa/full-local-environment-inner.sh +++ b/scripts/qa/full-local-environment-inner.sh @@ -22,10 +22,15 @@ ip addr add 192.0.2.1/30 dev qaonline ip addr add 192.0.2.2/30 dev qapeer ip link set qaonline up ip link set qapeer up +# Both veth ends live here; this route has no gateway or path to the host. +# GLib/WebKit needs a link default route to report native network availability. +ip route add default dev qaonline scope link node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" initialize node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" native mkdir "$QA_MANAGED_SCRATCH/tmp" "$QA_MANAGED_SCRATCH/storage" export TMPDIR="$QA_MANAGED_SCRATCH/tmp" STORAGE_DIR="$QA_MANAGED_SCRATCH/storage" +# Force smoke-test to create its own synthetic fallback, never read a shared /tmp sample. +export SAMPLE_FILE="$QA_MANAGED_SCRATCH/tmp/synthetic-smoke-input.pdf" python3 "$ROOT_DIR/scripts/qa/local-postgres-proxy.py" "$QA_MANAGED_SOCKET/.s.PGSQL.5432" & PROXY_PID=$! node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify diff --git a/scripts/qa/full-local-environment.mjs b/scripts/qa/full-local-environment.mjs index b55d51b3..b4915aeb 100644 --- a/scripts/qa/full-local-environment.mjs +++ b/scripts/qa/full-local-environment.mjs @@ -9,7 +9,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { sourceSnapshot, compareSnapshots } from "./full-local-diagnostic.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const policyFiles = ["scripts/qa/full-local-environment.mjs", "scripts/qa/full-local-environment-inner.sh", "scripts/qa/local-postgres-proxy.py", "scripts/qa/browser-environment-probe.mjs", "scripts/qa/run-web-gates.sh", "scripts/qa/full-local-diagnostic.mjs", "client/playwright.config.ts"]; +const policyFiles = ["scripts/qa/full-local-environment.mjs", "scripts/qa/full-local-environment-inner.sh", "scripts/qa/local-postgres-proxy.py", "scripts/qa/browser-environment-probe.mjs", "scripts/qa/run-web-gates.sh", "scripts/qa/prepare-local-e2e-accounts.mjs", "scripts/qa/full-local-diagnostic.mjs", "client/playwright.config.ts"]; const sha = value => createHash("sha256").update(value).digest("hex"); const readJson = file => JSON.parse(readFileSync(file, "utf8")); export function atomicEnvironmentJson(file, value) { @@ -60,17 +60,27 @@ export async function requestSupervisorObservation(context) { }); }); } -export function topologyMatches(addresses, routes, ipv6Routes) { +export function topologyMatches(addresses, routes, ipv6Routes, links) { const names = addresses.map(entry => entry.ifname).sort(); if (JSON.stringify(names) !== JSON.stringify(["lo", "qaonline", "qapeer"])) return false; for (const [name, address] of [["qaonline", "192.0.2.1"], ["qapeer", "192.0.2.2"]]) { const entry = addresses.find(item => item.ifname === name); if (!entry.flags?.includes("UP") || entry.operstate !== "UP" || !entry.addr_info?.some(item => item.local === address && item.prefixlen === 30)) return false; } - return ![...routes, ...ipv6Routes].some(entry => entry.dst === "default" || entry.gateway); + if (!Array.isArray(links) || JSON.stringify(links.map(entry => entry.ifname).sort()) !== JSON.stringify(names)) return false; + const left = links.find(entry => entry.ifname === "qaonline"), right = links.find(entry => entry.ifname === "qapeer"); + if (left.ifindex === right.ifindex) return false; + for (const [entry, peer] of [[left, right], [right, left]]) { + if (!Number.isInteger(entry.ifindex) || entry.ifindex <= 0 || entry.linkinfo?.info_kind !== "veth" || entry.link_netnsid !== undefined || entry.master !== undefined) return false; + if (entry.link !== peer.ifname && entry.link_index !== peer.ifindex) return false; + } + const defaults = routes.filter(entry => entry.dst === "default"); + if (defaults.length !== 1 || defaults[0].dev !== "qaonline" || defaults[0].scope !== "link" || (defaults[0].table !== undefined && defaults[0].table !== "main")) return false; + if (ipv6Routes.some(entry => entry.dst === "default")) return false; + return ![...routes, ...ipv6Routes].some(entry => entry.gateway || entry.via || entry.multipath || entry.nexthops || entry.encap || !names.includes(entry.dev)); } function topology() { - return topologyMatches(JSON.parse(command("ip", ["-j", "address", "show"])), JSON.parse(command("ip", ["-j", "route", "show", "table", "all"])), JSON.parse(command("ip", ["-j", "-6", "route", "show", "table", "all"]))); + return topologyMatches(JSON.parse(command("ip", ["-j", "address", "show"])), JSON.parse(command("ip", ["-j", "route", "show", "table", "all"])), JSON.parse(command("ip", ["-j", "-6", "route", "show", "table", "all"])), JSON.parse(command("ip", ["-j", "-d", "link", "show"]))); } export function validateEnvironmentFacts(context, facts) { const failures = []; @@ -112,6 +122,8 @@ export function validateEnvironmentReceipt(receipt, digest, requireGuest = true) if (!namespace?.inheritedRunner || !namespace.ownedChild || namespace.inheritedRunner === namespace.ownedChild || namespace.runnerCreated !== true || namespace.currentMatchesOwnedChild !== true || namespace.topologyMatched !== true) failures.push("environment_receipt_namespace_invalid"); if (receipt?.nativeOnlineProbe?.browserNamespace !== namespace?.ownedChild || receipt?.nativeOnlineProbe?.noNavigatorOverride !== true) failures.push("native_probe_namespace_invalid"); if (!receipt?.nativeOnlineProbe?.valid || receipt.nativeOnlineProbe.online !== true || !receipt.nativeOnlineProbe.browserMatchesOwnedChild) failures.push("native_probe_invalid"); + const engines = receipt?.nativeOnlineProbe?.engines; + if (!Array.isArray(engines) || JSON.stringify(engines.map(engine => engine.name)) !== JSON.stringify(["chromium", "webkit"]) || engines.some(engine => engine.online !== true || engine.valid !== true || engine.noNavigatorOverride !== true || engine.browserNamespace !== namespace?.ownedChild || engine.browserMatchesOwnedChild !== true || !engine.version)) failures.push("native_engine_probe_invalid"); if (requireGuest) { const observed = receipt?.bootstrapObservation; if (observed?.browserNamespace !== namespace?.ownedChild || observed?.noNavigatorOverride !== true || observed?.loginRequestCount !== 0 || observed?.documentStatus !== 200 || observed?.stayedOnLogin !== true) failures.push("guest_probe_evidence_invalid"); @@ -136,7 +148,7 @@ export function environmentReceipt(contextFile = process.env.QA_ENVIRONMENT_CONT return receipt; } async function launch(mode) { - if (!["full-local", "full-local-diagnostic"].includes(mode)) throw new Error("environment_mode_invalid"); + if (!["full-local", "full-local-diagnostic", "retained-local", "retained-server-local"].includes(mode)) throw new Error("environment_mode_invalid"); if (process.platform !== "linux") throw new Error("environment_platform_unsupported"); if (mode === "full-local-diagnostic") for (const name of ["REASON", "OPERATOR", "APPROVAL_REF"]) { const value = process.env[`QA_DIAGNOSTIC_${name}`]?.trim(); @@ -191,7 +203,7 @@ async function launch(mode) { }); chmodSync(path.join(scratch, "supervisor.sock"), 0o600); const env = {}; - for (const name of ["PATH", "HOME", "LANG", "TERM", "CI", "NODE_OPTIONS", "PAYMENT_ORDER_PYTHON_BIN", "QA_DIAGNOSTIC_REASON", "QA_DIAGNOSTIC_OPERATOR", "QA_DIAGNOSTIC_APPROVAL_REF"]) if (process.env[name] !== undefined) env[name] = process.env[name]; + for (const name of ["PATH", "HOME", "LANG", "TERM", "CI", "NODE_OPTIONS", "PAYMENT_ORDER_PYTHON_BIN", "QA_DIAGNOSTIC_REASON", "QA_DIAGNOSTIC_OPERATOR", "QA_DIAGNOSTIC_APPROVAL_REF", "QA_RETAINED_SPECS", "QA_RETAINED_GREP", "QA_RETAINED_SERVER_SUITE", "DOCKER_CONFIG", "PLAYWRIGHT_BROWSERS_PATH", "npm_config_userconfig", "npm_config_globalconfig"]) if (process.env[name] !== undefined) env[name] = process.env[name]; Object.assign(env, { QA_ENVIRONMENT_CONTEXT: contextFile, QA_FULL_REPORT_DIR: path.join(base, "results"), QA_DIAGNOSTIC_REPORT_DIR: path.join(base, "results"), QA_MANAGED_SCRATCH: scratch, QA_MANAGED_SOCKET: socket, QA_HOST: "127.0.0.1", QA_PORT: "23000", QA_DB_HOST: "127.0.0.1", QA_DB_PORT: "25432", QA_DB_CLIENT: "host", QA_DB_USER: "postgres", QA_DB_PASSWORD: "local_qa_only", QA_KEEP_DB: "0", QA_SKIP_CLIENT_BUILD: "0", E2E_MODE: "local", WECOM_ENABLED: "false", EMAIL_ENABLED: "false", OIDC_ENABLED: "false", STORAGE_DRIVER: "local", NODE_ENV: "test", npm_config_offline: "true", NO_COLOR: "1" }); const outcome = await new Promise(resolve => { child = spawn("unshare", ["-Urn", "bash", path.join(root, "scripts/qa/full-local-environment-inner.sh"), mode], { cwd: root, env, stdio: "inherit", detached: true }); diff --git a/scripts/qa/prepare-local-e2e-accounts.mjs b/scripts/qa/prepare-local-e2e-accounts.mjs new file mode 100644 index 00000000..995c4be5 --- /dev/null +++ b/scripts/qa/prepare-local-e2e-accounts.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { createRequire } from "node:module"; +import { verifyManagedEnvironment } from "./full-local-environment.mjs"; + +// Public synthetic fixtures already used by the local browser suite. They are +// never read from an operator account, environment file or production service. +export const localAccounts = [ + ["CEO", "ceo", "Ceo"], ["ADMIN", "admin", "Admin"], + ["MANAGER", "manager", "Manager"], ["EMPLOYEE", "employee", "Employee"], + ["FINANCE", "finance", "Finance"], ["FINANCE_MGR", "finance_mgr", "FinanceMgr"], + ["HR", "hr", "Hr"], ["ADMINISTRATION", "administration", "Administration"], +].map(([role, identifier, prefix]) => ({ role, identifier: `${identifier}@hyinda.com`, password: `${prefix}Password1234` })); + +export function validateAccountPreparation({ origin, initialPassword, databaseName }) { + const url = new URL(origin); + assert.equal(url.protocol, "http:", "synthetic_origin_required"); + assert.equal(url.hostname, "127.0.0.1", "synthetic_loopback_required"); + assert.ok(url.port && url.pathname === "/" && !url.search && !url.hash && !url.username && !url.password, "synthetic_origin_invalid"); + assert.match(databaseName ?? "", /^hyb_oa_webgate_[a-zA-Z0-9_]+$/, "synthetic_database_required"); + assert.ok(initialPassword?.length >= 24 && localAccounts.every(a => a.password !== initialPassword), "distinct_initial_password_required"); +} + +export async function prepareAccounts({ origin, initialPassword, databaseName, fetchImpl = fetch }) { + validateAccountPreparation({ origin, initialPassword, databaseName }); + const checks = []; + for (const account of localAccounts) { + const request = async (route, payload, token) => { + const response = await fetchImpl(`${origin}/api/auth/${route}`, { + method: "POST", redirect: "error", signal: AbortSignal.timeout(15000), + headers: { "content-type": "application/json", "x-device-fingerprint": `qa-initialize-${account.role}`, + ...(token ? { authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify(payload), + }); + // Do not expose raw auth response/payload in failures or receipts. + const body = await response.json(); + return { status: response.status, data: body.data }; + }; + const login = await request("login", { identifier: account.identifier, password: initialPassword }); + assert.equal(login.status, 200, `initial_login_${account.role}`); + assert.equal(login.data?.mustChangePassword, true, `initial_change_required_${account.role}`); + const changed = await request("change-password", { oldPassword: initialPassword, newPassword: account.password }, login.data.accessToken); + assert.equal(changed.status, 200, `change_password_${account.role}`); + assert.equal(changed.data?.mustChangePassword, false, `changed_password_${account.role}`); + const old = await request("login", { identifier: account.identifier, password: initialPassword }); + assert.equal(old.status, 401, `old_password_rejected_${account.role}`); + const fresh = await request("login", { identifier: account.identifier, password: account.password }); + assert.equal(fresh.status, 200, `fresh_login_${account.role}`); + assert.equal(fresh.data?.mustChangePassword, false, `fresh_password_ready_${account.role}`); + checks.push({ role: account.role, initialChangeRequired: true, changedThroughApi: true, oldPasswordRejected: true, newLoginReady: true }); + } + return { status: "PASS", scope: "run-owned synthetic accounts only", accounts: checks }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + verifyManagedEnvironment(process.env.QA_ENVIRONMENT_CONTEXT); + validateAccountPreparation({ origin: process.env.E2E_BASE_URL, + initialPassword: process.env.QA_SYNTHETIC_INITIAL_PASSWORD, databaseName: process.env.SEED_DEV_DATABASE_CONFIRM }); + const require = createRequire(new URL("../../client/package.json", import.meta.url)); + const { chromium } = require("@playwright/test"); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(`${process.env.E2E_BASE_URL}/login`); + await page.getByTestId("login-identifier").fill(localAccounts[1].identifier); + await page.getByTestId("login-password").fill(process.env.QA_SYNTHETIC_INITIAL_PASSWORD); + await page.getByTestId("login-submit").click(); + await page.waitForURL(url => url.pathname === "/change-password"); + assert.equal(await page.getByTestId("change-password-old").isVisible(), true); + } finally { await browser.close(); } + const result = await prepareAccounts({ origin: process.env.E2E_BASE_URL, + initialPassword: process.env.QA_SYNTHETIC_INITIAL_PASSWORD, databaseName: process.env.SEED_DEV_DATABASE_CONFIRM }); + result.initialBrowserChangePasswordGate = true; + writeFileSync(process.argv[2], `${JSON.stringify(result, null, 2)}\n`, { flag: "wx", mode: 0o600 }); + console.log(`Synthetic account preparation: PASS (${result.accounts.length} roles)`); + } catch { + console.error("Synthetic account preparation failed; business browser tests were not started"); + process.exitCode = 1; + } +} diff --git a/scripts/qa/pwa-cross-version-fixture.sh b/scripts/qa/pwa-cross-version-fixture.sh index 2f2832f4..adcc8dc6 100644 --- a/scripts/qa/pwa-cross-version-fixture.sh +++ b/scripts/qa/pwa-cross-version-fixture.sh @@ -12,7 +12,7 @@ pwa_build_candidate() { ( cd "$client_dir" - VITE_BUILD_SHA="$build_sha" npx vite build --outDir "$output_dir" --emptyOutDir + NODE_ENV=production VITE_BUILD_SHA="$build_sha" npx vite build --outDir "$output_dir" --emptyOutDir ) > "$log_file" 2>&1 || build_status="$?" if [[ "$build_status" -ne 0 ]]; then diff --git a/scripts/qa/qa-stage-record.mjs b/scripts/qa/qa-stage-record.mjs new file mode 100644 index 00000000..4c1e1a98 --- /dev/null +++ b/scripts/qa/qa-stage-record.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync, renameSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const FULL_LOCAL_STAGES = ['inputs', 'database', 'prisma', 'server-unit', 'typecheck', 'client-build', 'server-start', 'accounts', 'workflow-actions', 'server-regressions', 'browser-server', 'browser']; +const now = () => new Date().toISOString(); +export function validTime(value) { return typeof value === 'string' && Number.isFinite(Date.parse(value)); } +export function validateStages(record, expected, profile) { + const issues = []; + if (record?.schemaVersion !== 1 || record?.profile !== profile || record?.status !== 'PASS' || record?.exitCode !== 0 || !validTime(record.startedAt) || !validTime(record.endedAt) || Date.parse(record.endedAt) < Date.parse(record.startedAt)) issues.push('stages_not_complete'); + const stages = Array.isArray(record?.stages) ? record.stages : []; + if (JSON.stringify(stages.map(s => s?.id)) !== JSON.stringify(expected)) issues.push('stage_set_or_order_invalid'); + let previous = Date.parse(record?.startedAt); + for (const stage of stages) { + if (stage?.status !== 'PASS' || stage?.exitCode !== 0 || !validTime(stage?.startedAt) || !validTime(stage?.endedAt) || Date.parse(stage.startedAt) < previous || Date.parse(stage.endedAt) < Date.parse(stage.startedAt)) issues.push('stage_not_clean_pass'); + previous = Date.parse(stage?.endedAt); + } + if (previous > Date.parse(record?.endedAt)) issues.push('stage_after_completion'); + return [...new Set(issues)]; +} +export function updateStageRecord(record, action, id, exitCode) { + const stages = record.stages; + const stage = stages.find(s => s.id === id); + if (record.status !== 'INCOMPLETE') throw Error('stage_record_already_finished'); + if (action === 'start') { + const next = stages.find(s => s.status !== 'PASS'); + if (!stage || next !== stage || stage.status !== 'NOT_RUN') throw Error('stage_start_out_of_order'); + Object.assign(stage, {status:'RUNNING', startedAt:now()}); + } else if (action === 'end') { + if (!stage || stage.status !== 'RUNNING' || !Number.isSafeInteger(exitCode) || exitCode < 0 || exitCode > 255) throw Error('stage_end_invalid'); + Object.assign(stage, {status:exitCode === 0 ? 'PASS':'FAIL', exitCode, endedAt:now()}); + } else if (action === 'finish') { + if (!Number.isSafeInteger(exitCode) || exitCode < 0 || exitCode > 255) throw Error('stage_exit_invalid'); + for (const running of stages.filter(s => s.status === 'RUNNING')) Object.assign(running, {status:'FAIL', exitCode:exitCode || 1, endedAt:now()}); + const complete = stages.every(s => s.status === 'PASS' && s.exitCode === 0); + Object.assign(record, {status:exitCode === 0 && complete ? 'PASS':'FAIL', exitCode:exitCode || (complete ? 0:1), endedAt:now()}); + } else throw Error('stage_action_invalid'); + return record; +} +export function newStageRecord(ids, profile) { + return {schemaVersion:1, profile, status:'INCOMPLETE', startedAt:now(), endedAt:null, exitCode:null, stages:ids.map(id => ({id,status:'NOT_RUN',startedAt:null,endedAt:null,exitCode:null}))}; +} +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + try { + const [action,input,id,rawExit] = process.argv.slice(2); + const file = path.resolve(input); + if (realpathSync(path.dirname(file)) !== path.dirname(file)) throw Error('stage_path_invalid'); + try { if (!lstatSync(file).isFile()) throw Error('stage_path_invalid'); } catch (error) { if (error.code !== 'ENOENT') throw error; } + let record; + if (action === 'selection') { + if (existsSync(file) || !/^\d+$/.test(id ?? '')) throw Error('selection_invalid'); + const args = process.argv.slice(5), count = Number(id); + if (count < 1 || args.length < count) throw Error('selection_invalid'); + record = {schemaVersion:1,profile:'full-local-selection',specArgs:args.slice(0,count),additionalArgs:args.slice(count),retries:0,forbidOnly:true,workers:process.env.CI?2:4,mode:'local'}; + } else if (action === 'init') { + if (existsSync(file)) throw Error('stage_record_exists'); + record = newStageRecord(FULL_LOCAL_STAGES, 'full-local-stages'); + } else { + record = JSON.parse(readFileSync(file,'utf8')); + if (record.schemaVersion !== 1 || record.profile !== 'full-local-stages' || JSON.stringify(record.stages.map(s=>s.id)) !== JSON.stringify(FULL_LOCAL_STAGES)) throw Error('stage_record_invalid'); + const raw = action === 'finish' ? id : rawExit; + if (action !== 'start' && !/^\d{1,3}$/.test(raw ?? '')) throw Error('stage_exit_invalid'); + updateStageRecord(record,action,id,Number(raw)); + } + writeFileSync(file+'.tmp',JSON.stringify(record,null,2)+'\n',{flag:'wx',mode:0o600});renameSync(file+'.tmp',file); + } catch { console.error('[stage-record] invalid_or_unwritable'); process.exitCode=1; } +} diff --git a/scripts/qa/release-gate-result.mjs b/scripts/qa/release-gate-result.mjs new file mode 100644 index 00000000..386331f9 --- /dev/null +++ b/scripts/qa/release-gate-result.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +import { createHash, randomBytes } from 'node:crypto'; +import { existsSync, lstatSync, readlinkSync, readFileSync, readdirSync, realpathSync, mkdirSync, writeFileSync, renameSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { sourceSnapshot, compareSnapshots } from './full-local-diagnostic.mjs'; +import { environmentPolicyDigest, validateEnvironmentReceipt } from './full-local-environment.mjs'; +import { coveragePlan, verifyLocalCoverage, localSpecArgs } from './full-local-coverage.mjs'; +import { verifyLocalCandidateResults } from './verify-local-candidate-results.mjs'; +import { FULL_LOCAL_STAGES, newStageRecord, updateStageRecord, validateStages, validTime } from './qa-stage-record.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const hash = value => createHash('sha256').update(value).digest('hex'); +const read = file => JSON.parse(readFileSync(file,'utf8')); +const PHASES = ['static','full-local']; +const POLICY_FILES = ['scripts/qa/run-release-gate.sh','scripts/qa/release-gate-result.mjs','scripts/qa/qa-stage-record.mjs','scripts/qa/full-local-coverage.mjs','scripts/qa/full-local-coverage-contract.json','scripts/qa/verify-full-local-discovery.mjs','scripts/qa/verify-local-candidate-results.mjs','scripts/qa/route-manifest.json','client/playwright.config.ts','client/package.json','client/package-lock.json','server/package.json','server/package-lock.json']; +const EVIDENCE_FILES = {environment:'environment/result.json',phases:'results/full-local-stages.json',discovery:'results/discovery.json',plan:'results/coverage-plan.json',execution:'results/playwright-results.json',selection:'results/execution-selection.json'}; +function atomic(file, value) { writeFileSync(file+'.tmp',JSON.stringify(value,null,2)+'\n',{flag:'wx',mode:0o600});renameSync(file+'.tmp',file); } +function compactSource(value) { + const {commit,tree,dirty,digest,repository}=value; + return {commit,tree,dirty,digest,repository:{trackedDigest:repository.trackedDigest,indexDigest:repository.indexDigest,porcelainV1Z:repository.porcelainV1Z,trackedCoverageComplete:repository.trackedCoverageComplete}}; +} +export function captureReleaseSource(root=ROOT) { + const source = compactSource(sourceSnapshot(root)); + // Include untracked task/docs/deployment inputs which the shared product + // snapshot does not hash. Ignored private/runtime files remain excluded. + const result = spawnSync('git', ['ls-files','--others','--exclude-standard','-z'], {cwd:root,encoding:'utf8',maxBuffer:16*1024*1024}); + if (result.status !== 0) throw Error('untracked_inputs_unavailable'); + const files = result.stdout.split('\0').filter(Boolean).sort().map(file => { + const target = path.join(root,file); + if (realpathSync(path.dirname(target)) !== path.dirname(target)) throw Error('input_parent_symlink'); + const stat = lstatSync(target); + if (!stat.isFile() && !stat.isSymbolicLink()) throw Error('input_kind_invalid'); + return [file,stat.mode & 0o777,stat.isSymbolicLink() ? 'symlink':'file',hash(stat.isSymbolicLink() ? readlinkSync(target):readFileSync(target))]; + }); + return {...source,untrackedDigest:hash(JSON.stringify(files))}; +} +export function currentPolicy(root=ROOT) { + return {digest:hash(JSON.stringify(POLICY_FILES.map(file=>[file,hash(readFileSync(path.join(root,file)))]))), environmentDigest:environmentPolicyDigest(), runnerDigest:hash(readFileSync(path.join(root,'scripts/qa/full-local-environment.mjs'))), probeDigest:hash(readFileSync(path.join(root,'scripts/qa/browser-environment-probe.mjs'))), toolchain:{node:process.version,playwright:read(path.join(root,'client/node_modules/@playwright/test/package.json')).version,browserRegistry:hash(readFileSync(path.join(root,'client/node_modules/playwright-core/browsers.json'))),browserVersions:Object.fromEntries(read(path.join(root,'client/node_modules/playwright-core/browsers.json')).browsers.filter(browser=>['chromium','webkit'].includes(browser.name)).map(browser=>[browser.name,browser.browserVersion])),platform:process.platform,arch:process.arch,ci:Boolean(process.env.CI),workers:process.env.CI?2:4}}; +} +function saneSource(source) { + return /^[a-f0-9]{40}$/.test(source?.commit ?? '') && /^[a-f0-9]{40}$/.test(source?.tree ?? '') && /^[a-f0-9]{64}$/.test(source?.digest ?? '') && source?.repository?.trackedCoverageComplete === true && typeof source.repository.porcelainV1Z === 'string' && source.dirty === Boolean(source.repository.porcelainV1Z.length); +} +function releaseMatches(a,b) { return matches(a,b) && /^[a-f0-9]{64}$/.test(a?.untrackedDigest ?? '') && a.untrackedDigest === b?.untrackedDigest; } +function matches(a,b) { return saneSource(a) && saneSource(b) && compareSnapshots(a,b).candidateIntegrityStatus === 'UNCHANGED'; } +export function verifyReleaseEvidence(record,bundle,facts) { + const issues=[]; + if (record?.schemaVersion!==1 || record?.profile!=='release-predeploy' || record?.mode!=='verify-candidate' || !/^[a-f0-9]{24}$/.test(record?.runId??'')) issues.push('receipt_profile_invalid'); + if (!validTime(record?.startedAt) || !validTime(record?.finishedAt) || Date.parse(record.finishedAt)Date.parse(record?.finishedAt)) issues.push('release_time_binding_invalid'); + const env=bundle?.environment; + if (env?.schemaVersion!==1 || env?.mode!=='full-local' || env?.profile!=='isolated-online-v1' || env?.policyDigest!==facts.policy.environmentDigest || env?.status!=='PASS' || env?.exitCode!==0 || env?.childExit!==0 || env?.childSignal || env?.interrupted || env?.error || !validTime(env?.startedAt) || !validTime(env?.endedAt) || Date.parse(env.endedAt)!facts.policy.toolchain.browserVersions?.[name] || env?.environment?.nativeOnlineProbe?.engines?.find(engine=>engine.name===name)?.version!==facts.policy.toolchain.browserVersions[name])) issues.push('browser_version_changed'); + if (env?.cleanup?.containerOwnerVerified!==true || env?.cleanup?.containerRemoved!==true || env?.cleanup?.scratchRemoved!==true || env?.cleanup?.error) issues.push('cleanup_incomplete'); + if (!matches(env?.sourceBefore,env?.sourceAfter) || !matches(env?.sourceBefore,facts.source) || env?.sourceUnchanged!==true || env?.repositoryUnchanged!==true || env?.candidateIntegrityStatus!=='UNCHANGED') issues.push('managed_source_mismatch'); + if (bundle?.phases?.schemaVersion!==1 || bundle?.phases?.profile!=='full-local-stages') issues.push('full_stage_profile_invalid'); + issues.push(...validateStages(bundle?.phases,FULL_LOCAL_STAGES,'full-local-stages')); + const outerFull=record?.phases?.stages?.find(s=>s.id==='full-local'); + if (env && outerFull && (Date.parse(env.startedAt)Date.parse(outerFull.endedAt) || Date.parse(bundle?.phases?.startedAt)Date.parse(env.endedAt))) issues.push('stage_time_binding_invalid'); + let coverage=null,witness=null; + try { + const plan=coveragePlan(facts.root,bundle.discovery,facts.contract,facts.source); + if (plan.status!=='PASS' || JSON.stringify(plan)!==JSON.stringify(bundle.plan)) issues.push('discovery_plan_invalid'); + coverage=verifyLocalCoverage(plan,bundle.execution,facts.source); + issues.push(...coverage.issues); + witness=verifyLocalCandidateResults(facts.manifest,bundle.execution); + if (!witness.ok) issues.push(...witness.issues); + const specs=new Set(plan.localSpecs); + if (!facts.manifest.acceptanceSpecs?.length || facts.manifest.acceptanceSpecs.some(file=>!specs.has(file))) issues.push('acceptance_spec_missing'); + const expectedSelection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(plan),additionalArgs:[],retries:0,forbidOnly:true,workers:facts.policy.toolchain.workers,mode:'local'}; + if (JSON.stringify(bundle.selection)!==JSON.stringify(expectedSelection)) issues.push('recorded_selection_invalid'); + const config=bundle.execution?.config, projects=config?.projects; + if (config?.forbidOnly!==true || !Array.isArray(projects) || projects.length!==1 || projects.some(project=>project.retries!==0 || project.repeatEach!==1 || project.name!=='' || project.timeout!==180000 || JSON.stringify(project.testIgnore)!==JSON.stringify(['**/manual/**'])) || config?.shard!==null || config?.maxFailures!==0 || config?.workers!==facts.policy.toolchain.workers || config?.version!==facts.policy.toolchain.playwright) issues.push('execution_selection_invalid'); + const stats=bundle.execution?.stats; + if (!validTime(stats?.startTime) || !Number.isFinite(stats?.duration) || stats.duration<0 || stats.expected!==plan.localCases.length || stats.unexpected!==0 || stats.flaky!==0 || stats.skipped!==0) issues.push('execution_statistics_invalid'); + const browser=bundle?.phases?.stages?.find(s=>s.id==='browser'); + if (Date.parse(stats?.startTime)Date.parse(browser?.endedAt)) issues.push('browser_time_binding_invalid'); + } catch { issues.push('coverage_evidence_invalid'); } + const localChecksPassed=issues.length===0; + const clean=[record?.sourceBefore,record?.sourceAfter,facts.source,env?.sourceBefore,env?.sourceAfter].every(source=>saneSource(source) && source.dirty===false); + if (!clean) issues.push('dirty_candidate'); + return {schemaVersion:1,profile:'release-predeploy',status:!record?.finishedAt?'INCOMPLETE':localChecksPassed ? (clean?'PASS':'PARTIAL'):'FAIL',localChecksPassed,deploymentEligible:localChecksPassed&&clean,deliveryStatus:'INCOMPLETE',issues:[...new Set(issues)],localCases:coverage?.cases??[],obligations:coverage?.obligations??[],witnessSummary:witness?{ok:witness.ok,expected:witness.expectedTagCount,passed:witness.passedTagCount}:null}; +} +export function validateSkipRequest(mode,env) { + const affected=[]; + for (const [name,stage] of [['PROJECT_WIDE_SKIP_BUSINESS_FLOW','project-wide-business-flow'],['PROJECT_WIDE_SKIP_VISUAL','project-wide-visual']]) { + const value=env[name]??'0'; + if (!['0','1'].includes(value)) throw Error('skip_value_invalid'); + if (value==='1') affected.push(stage); + } + if (!affected.length) return null; + if (mode!=='project-wide') throw Error('skip_forbidden_for_release'); + const values=['PROJECT_WIDE_SKIP_REASON','PROJECT_WIDE_SKIP_OPERATOR','PROJECT_WIDE_SKIP_APPROVAL_REF'].map(name=>env[name]?.trim()); + if (values.some(value=>!value || value.length>500 || /[\r\n\0]/u.test(value))) throw Error('skip_approval_metadata_required'); + return {profile:'project-wide',status:'PARTIAL',deploymentEligible:false,affectedStages:affected,reason:values[0],operator:values[1],approvalRef:values[2],approvalTime:null,observedAt:new Date().toISOString()}; +} +export function confinedPath(root,relative) { + if (typeof relative!=='string' || !relative || path.isAbsolute(relative) || relative.split('/').some(part=>part==='..'||part===''||part==='.') || relative.includes('\\')) throw Error('evidence_path_invalid'); + if (realpathSync(root)!==root) throw Error('evidence_root_symlink'); + const resolved=path.join(root,relative); + let cursor=root; + for (const part of relative.split('/')) { cursor=path.join(cursor,part); try { if (lstatSync(cursor).isSymbolicLink()) throw Error('evidence_symlink'); } catch (error) { if (error.code!=='ENOENT') throw error; } } + return resolved; +} +function readBundle(record,seal=false) { + const bundle={}; + if (!/^[a-f0-9]{24}$/.test(record.runId??'') || record.fullLocalDirectory!==`artifacts/qa-flow/release-predeploy/${record.runId}`) throw Error('managed_report_path_invalid'); + const base=confinedPath(ROOT,record.fullLocalDirectory); + for (const [key,relative] of Object.entries(EVIDENCE_FILES)) { + const file=confinedPath(base,relative);if (!lstatSync(file).isFile()) throw Error('evidence_not_file');const raw=readFileSync(file);const digest=hash(raw); + if (seal) record.evidence[key]={relative,sha256:digest}; + else if (record.evidence?.[key]?.relative!==relative || record.evidence[key].sha256!==digest) throw Error('evidence_digest_mismatch'); + bundle[key]=JSON.parse(raw); + } + return bundle; +} +function facts() {return {root:ROOT,source:captureReleaseSource(),policy:currentPolicy(),contract:read(path.join(ROOT,'scripts/qa/full-local-coverage-contract.json')),manifest:read(path.join(ROOT,'scripts/qa/route-manifest.json'))};} +function ownedDirectory(input,create=false) { + const relative=path.relative(ROOT,path.resolve(input)).split(path.sep).join('/'); + if (!relative.startsWith('artifacts/release-gate/')) throw Error('release_report_outside_artifacts'); + const dir=confinedPath(ROOT,relative); + if (create && existsSync(dir) && readdirSync(dir).length) throw Error('release_report_not_empty'); + if (create) mkdirSync(dir,{recursive:true,mode:0o700}); + if (realpathSync(dir)!==dir) throw Error('release_report_symlink'); + return dir; +} +if (process.argv[1] && import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href) { + try { + const [action,input,arg,rawExit]=process.argv.slice(2); + if (action==='skip') { + const result=validateSkipRequest(arg,process.env); + if (result) { const dir=ownedDirectory(input,true);atomic(confinedPath(dir,'skip-receipt.json'),result);console.log('[release-gate] approved diagnostic skips: PARTIAL'); } + } else { + const dir=ownedDirectory(input,action==='init');const file=confinedPath(dir,'release-result.json'); + if (action==='init') { + validateSkipRequest('verify-candidate',process.env); + const runId=randomBytes(12).toString('hex'); + const record={schemaVersion:1,profile:'release-predeploy',mode:'verify-candidate',runId,startedAt:new Date().toISOString(),finishedAt:null,status:'INCOMPLETE',deploymentEligible:false,policy:currentPolicy(),sourceBefore:captureReleaseSource(),sourceAfter:null,phases:newStageRecord(PHASES,'release-predeploy-stages'),fullLocalDirectory:`artifacts/qa-flow/release-predeploy/${runId}`,evidence:{},executionExit:null}; + atomic(file,record); + } else { + const record=read(file); + if (record.profile!=='release-predeploy'||record.mode!=='verify-candidate') throw Error('receipt_profile_invalid'); + if (action==='full-path') console.log(confinedPath(ROOT,record.fullLocalDirectory)); + else if (action==='stage-start'||action==='stage-end') { + if (!releaseMatches(record.sourceBefore,captureReleaseSource())) throw Error('stage_source_changed'); + updateStageRecord(record.phases,action==='stage-start'?'start':'end',arg,Number(rawExit));atomic(file,record); + } else if (action==='finish') { + if (!/^\d{1,3}$/.test(arg??'')) throw Error('exit_invalid'); + const exitCode=Number(arg);updateStageRecord(record.phases,'finish',null,exitCode);record.executionExit=exitCode;record.sourceAfter=captureReleaseSource();record.finishedAt=new Date().toISOString(); + let result; + try {const bundle=readBundle(record,true);result=verifyReleaseEvidence(record,bundle,facts());} + catch {result={status:exitCode?'FAIL':'INCOMPLETE',localChecksPassed:false,deploymentEligible:false,deliveryStatus:'INCOMPLETE',issues:['required_evidence_unavailable']};} + Object.assign(record,{status:result.status,deploymentEligible:result.deploymentEligible,verification:result});atomic(file,record);console.log(JSON.stringify({status:result.status,localChecksPassed:result.localChecksPassed,deploymentEligible:result.deploymentEligible,issues:result.issues}));process.exitCode=result.deploymentEligible?0:result.status==='PARTIAL'?2:1; + } else if (action==='verify') { + const result=verifyReleaseEvidence(record,readBundle(record),facts());console.log(JSON.stringify(result));process.exitCode=result.deploymentEligible?0:result.status==='PARTIAL'?2:1; + } else throw Error('action_invalid'); + } + } + } catch {console.error('[release-receipt] invalid_or_incomplete');process.exitCode=1;} +} diff --git a/scripts/qa/route-manifest.json b/scripts/qa/route-manifest.json index 20f9a140..2dafde9b 100644 --- a/scripts/qa/route-manifest.json +++ b/scripts/qa/route-manifest.json @@ -47,7 +47,7 @@ "bundle": "approval", "witnessRoute": "/workflow/tasks", "witnessTag": "@local-candidate:approval", - "expectedH1": "待我处理", + "expectedH1": "待我审批", "roleFixture": "manager", "primaryAction": { "allowed": "处理", "forbidden": "通过" }, "witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts" @@ -85,7 +85,7 @@ "witnessTag": "@local-candidate:payment-ap", "expectedH1": "付款", "roleFixture": "finance", - "primaryAction": { "allowed": "新建批次", "forbidden": "复核批次" }, + "primaryAction": { "allowed": "搜索待组批报销", "forbidden": "复核批次" }, "witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts" }, { @@ -110,7 +110,7 @@ "bundle": "mobile-workspace", "witnessRoute": "/mobile", "witnessTag": "@local-candidate:mobile-workspace", - "expectedH1": "今天待办", + "expectedH1": "今日待处理", "roleFixture": "employee", "primaryAction": { "allowed": "新建报销", "forbidden": "财务" }, "witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts" diff --git a/scripts/qa/run-release-gate.sh b/scripts/qa/run-release-gate.sh index 9cab4d85..1f8b38b3 100755 --- a/scripts/qa/run-release-gate.sh +++ b/scripts/qa/run-release-gate.sh @@ -6,13 +6,12 @@ ARTIFACT_ROOT="${RELEASE_GATE_ARTIFACT_DIR:-${ROOT_DIR}/artifacts/release-gate/$ MODE="${1:-all}" CURRENT_STAGE="" -mkdir -p "$ARTIFACT_ROOT" - usage() { cat < +Usage: bash scripts/qa/run-release-gate.sh 说明: + verify-candidate 仅 static + 完整受管 full-local,生成共享回执;绝不部署 static 运行部署契约、server Prisma 校验、client typecheck/build smoke-local 运行本地网页 smoke 门禁 full-local 运行本地全量门禁(server regression + client full E2E) @@ -28,6 +27,8 @@ Usage: bash scripts/qa/run-release-gate.sh >> [release-gate] ${stage_name}" + if [[ "$MODE" == "verify-candidate" ]]; then + node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" stage-start "$ARTIFACT_ROOT" "$stage_name" + fi "$@" + if [[ "$MODE" == "verify-candidate" ]]; then + node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" stage-end "$ARTIFACT_ROOT" "$stage_name" 0 + fi } stage_static() { @@ -125,10 +132,40 @@ stage_deploy() { bash "$ROOT_DIR/deploy/trialrun/deploy.sh" } +# This entry does not install enforcement in any existing deployment path. +finish_candidate() { + local original_status=$? receipt_status=0 + trap - EXIT ERR INT TERM + node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" finish "$ARTIFACT_ROOT" "$original_status" || receipt_status=$? + if (( original_status )); then exit "$original_status"; fi + exit "$receipt_status" +} + +if [[ "$MODE" != "help" && "$MODE" != "-h" && "$MODE" != "--help" ]]; then + # Validate before static, remote readiness or deployment side effects. + trap - ERR + node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" skip "$ARTIFACT_ROOT" "$MODE" + if [[ "$MODE" != "verify-candidate" ]]; then trap handle_error ERR; fi +fi + +if [[ "$MODE" != "verify-candidate" ]]; then mkdir -p "$ARTIFACT_ROOT"; fi + case "$MODE" in help|-h|--help) usage ;; + verify-candidate) + node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" init "$ARTIFACT_ROOT" + trap finish_candidate EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + unset QA_ENVIRONMENT_CONTEXT QA_DIAGNOSTIC_REPORT_DIR QA_DIAGNOSTIC_STAGE + QA_FULL_REPORT_DIR="$(node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" full-path "$ARTIFACT_ROOT")" + export QA_FULL_REPORT_DIR + run_stage static stage_static + run_stage full-local stage_full_local + exit 0 + ;; static) run_stage static stage_static ;; @@ -170,5 +207,10 @@ case "$MODE" in ;; esac +if [[ "$MODE" == "project-wide" && ( "${PROJECT_WIDE_SKIP_BUSINESS_FLOW:-0}" == "1" || "${PROJECT_WIDE_SKIP_VISUAL:-0}" == "1" ) ]]; then + echo "[release-gate][PARTIAL] 诊断跳过不具备发布资格" + exit 2 +fi + echo "[release-gate][PASS] 模式 ${MODE} 通过" echo "[release-gate] 产物目录:${ARTIFACT_ROOT}" diff --git a/scripts/qa/run-web-gates.sh b/scripts/qa/run-web-gates.sh index b8aefcc1..a677c53a 100755 --- a/scripts/qa/run-web-gates.sh +++ b/scripts/qa/run-web-gates.sh @@ -7,7 +7,7 @@ CLIENT_DIR="$ROOT_DIR/client" source "$ROOT_DIR/scripts/qa/pwa-cross-version-fixture.sh" MODE="${1:-}" -if [[ "$MODE" == "full-local" || "$MODE" == "full-local-diagnostic" ]]; then +if [[ "$MODE" == "full-local" || "$MODE" == "full-local-diagnostic" || "$MODE" == "retained-local" || "$MODE" == "retained-server-local" ]]; then if [[ -z "${QA_ENVIRONMENT_CONTEXT:-}" ]]; then exec node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" "$MODE" fi @@ -43,6 +43,7 @@ QA_READY_TIMEOUT_SECONDS="${QA_READY_TIMEOUT_SECONDS:-90}" QA_BASE_URL="" QA_DB_NAME="${QA_DB_NAME:-${QA_DB_PREFIX}_$(date +%Y%m%d%H%M%S)_$RANDOM}" QA_DB_CREATED=0 +QA_FULL_STAGE_FILE="" QA_SERVER_STARTED=0 QA_SERVER_OWNED=0 DATABASE_URL="postgresql://${QA_DB_USER}:${QA_DB_PASSWORD}@${QA_DB_HOST}:${QA_DB_PORT}/${QA_DB_NAME}?schema=public" @@ -57,7 +58,7 @@ qa_require_cmd() { qa_usage() { cat < +Usage: bash scripts/qa/run-web-gates.sh 可选环境变量: QA_PORT / QA_HOST @@ -66,6 +67,9 @@ Usage: bash scripts/qa/run-web-gates.sh &2 || true exit 1 } + + # Derive browser capabilities from this successfully started server, never + # from a caller's PLAYWRIGHT_OCR_MOCK assertion. A restart recalculates it. + export PLAYWRIGHT_OCR_MOCK=0 + if [[ "$OCR_PROVIDER" == "mock" && "$JOBS_ENABLED" == "true" ]]; then + export PLAYWRIGHT_OCR_MOCK=1 + fi + if [[ -n "${QA_FULL_REPORT_DIR:-}" ]]; then + mkdir -p "$QA_FULL_REPORT_DIR" + node -e 'require("node:fs").appendFileSync(process.argv[1], JSON.stringify({provider:process.env.OCR_PROVIDER,jobsEnabled:process.env.JOBS_ENABLED === "true",mockCorrectionEnabled:process.env.PLAYWRIGHT_OCR_MOCK === "1",source:"owned-server-start"})+"\n")' "$QA_FULL_REPORT_DIR/server-capabilities.jsonl" + fi } qa_build_client() { @@ -530,7 +552,7 @@ qa_build_client() { ( cd "$CLIENT_DIR" npx tsc --noEmit --pretty false --extendedDiagnostics - npx vite build + NODE_ENV=production npx vite build ) } @@ -563,75 +585,182 @@ qa_prepare_full_inputs() { pwa_with_fixture env E2E_MODE=local \ PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/discovery.json" \ QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" \ - npx playwright test --list --retries=0 --reporter=json \ + npx playwright test --list --retries=0 --forbid-only --reporter=json \ > "$QA_FULL_REPORT_DIR/discovery.stdout.log" 2> "$QA_FULL_REPORT_DIR/discovery.stderr.log" ) node "$ROOT_DIR/scripts/qa/verify-full-local-discovery.mjs" "$QA_FULL_REPORT_DIR/discovery.json" \ > "$QA_FULL_REPORT_DIR/discovery-coverage.json" + node "$ROOT_DIR/scripts/qa/full-local-coverage.mjs" plan "$QA_FULL_REPORT_DIR" \ + > "$QA_FULL_REPORT_DIR/coverage-plan.json" +} + +# One owner prepares synthetic accounts before any business suite runs. +qa_seed_synthetic_credentials() { + export QA_SYNTHETIC_INITIAL_PASSWORD="Init!$(node -e 'process.stdout.write(require("node:crypto").randomBytes(24).toString("hex"))')" + local role + for role in CEO ADMIN MANAGER EMPLOYEE FINANCE FINANCE_MGR HR ADMINISTRATION; do + export "SEED_${role}_PASSWORD=$QA_SYNTHETIC_INITIAL_PASSWORD" + done +} + +qa_prepare_synthetic_accounts() { + mkdir -p "$QA_FULL_REPORT_DIR" + export SEED_DEV_DATABASE_CONFIRM="$QA_DB_NAME" + node "$ROOT_DIR/scripts/qa/prepare-local-e2e-accounts.mjs" "$QA_FULL_REPORT_DIR/synthetic-account-preparation.json" + unset QA_SYNTHETIC_INITIAL_PASSWORD + for role in CEO ADMIN MANAGER EMPLOYEE FINANCE FINANCE_MGR HR ADMINISTRATION; do unset "SEED_${role}_PASSWORD"; done +} + +# Focused historical-case diagnosis; never a normal full-local release receipt. +qa_run_retained_local() { + [[ -n "${QA_RETAINED_SPECS:-}" ]] || { echo "retained-local requires an explicit spec list" >&2; return 1; } + local -a retained_specs + read -r -a retained_specs <<< "$QA_RETAINED_SPECS" + local spec + for spec in "${retained_specs[@]}"; do + [[ "$spec" =~ ^[a-zA-Z0-9_-]+\.spec\.ts$ && -f "$CLIENT_DIR/tests/e2e/$spec" ]] || { echo "invalid local spec selector" >&2; return 1; } + done + mkdir -p "$QA_FULL_REPORT_DIR" + node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify({scope:"focused-historical-retest",normalReleaseEligible:false,specs:process.argv.slice(2),grep:process.env.QA_RETAINED_GREP||null})+"\n",{flag:"wx"})' "$QA_FULL_REPORT_DIR/selection.json" "${retained_specs[@]}" + qa_select_port + export JOBS_ENABLED=true RATE_LIMIT_ENABLED=false IM_NOTIFIER_ENABLED=false + qa_export_app_env + export IM_NOTIFIER_ENABLED=false + qa_seed_synthetic_credentials + qa_prepare_db + qa_build_client + qa_start_server + mkdir -p "$QA_FULL_REPORT_DIR" + qa_prepare_synthetic_accounts + node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL" + node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" report-guest > "$QA_FULL_REPORT_DIR/environment-before-browser.json" + local -a pwa_env=() + if [[ " $QA_RETAINED_SPECS " == *" pwa-cross-version-upgrade.spec.ts "* ]]; then + pwa_prepare_fixture "$CLIENT_DIR" "$QA_FULL_REPORT_DIR" + pwa_env=("PWA_BUILD_A_DIR=$PWA_BUILD_A_DIR" "PWA_BUILD_B_DIR=$PWA_BUILD_B_DIR" "PWA_BUILD_A_SHA=$PWA_BUILD_A_SHA" "PWA_BUILD_B_SHA=$PWA_BUILD_B_SHA") + fi + local -a grep_args=() + if [[ -n "${QA_RETAINED_GREP:-}" ]]; then grep_args=(--grep "$QA_RETAINED_GREP"); fi + ( + cd "$CLIENT_DIR" + env "${pwa_env[@]}" E2E_MODE=local PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/playwright-results.json" \ + QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" QA_VISUAL_ARTIFACT_DIR="$QA_FULL_REPORT_DIR/visual-artifacts" \ + node node_modules/@playwright/test/cli.js test "${retained_specs[@]}" "${grep_args[@]}" \ + --retries=0 --reporter=line,json --output="$QA_FULL_REPORT_DIR/test-results" + ) +} + +# A fixed server suite in the same managed environment; never full-local coverage. +qa_run_retained_server_local() { + case "${QA_RETAINED_SERVER_SUITE:-}" in + workflow-actions|server-regressions) ;; + *) echo "retained-server-local requires an allowed server suite" >&2; return 1 ;; + esac + mkdir -p "$QA_FULL_REPORT_DIR" + node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify({scope:"focused-server-regression",normalReleaseEligible:false,suite:process.argv[2]})+"\n",{flag:"wx"})' "$QA_FULL_REPORT_DIR/selection.json" "$QA_RETAINED_SERVER_SUITE" + qa_select_port + qa_export_app_env + export JOBS_ENABLED=false RATE_LIMIT_ENABLED=false IM_NOTIFIER_ENABLED=false + if [[ "$QA_RETAINED_SERVER_SUITE" == "server-regressions" ]]; then export IM_NOTIFIER_ENABLED=true; fi + qa_seed_synthetic_credentials + qa_prepare_db + qa_build_client + qa_start_server + node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL" + qa_prepare_synthetic_accounts + ( + cd "$SERVER_DIR" + case "$QA_RETAINED_SERVER_SUITE" in + workflow-actions) ./scripts/regression-workflow-actions.sh ;; + server-regressions) ./scripts/regression-all.sh ;; + esac + ) +} + +# Plain calls preserve errexit inside compound helpers. Failure is sealed by +# qa_cleanup; wrapping "$@" in an if/|| would suppress their inner failures. +qa_full_stage() { + local stage="$1" + shift + node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" start "$QA_FULL_STAGE_FILE" "$stage" + echo ">>> [full-local] $stage" + "$@" + node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" end "$QA_FULL_STAGE_FILE" "$stage" 0 +} + +qa_full_in_directory() ( + cd "$1" + shift + "$@" +) + +qa_full_database() { + qa_seed_synthetic_credentials + qa_prepare_db +} + +qa_full_server_start() { + qa_start_server + node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL" +} + +qa_full_browser_server() { + qa_cleanup_server + export JOBS_ENABLED="${JOBS_ENABLED_FOR_E2E:-true}" + qa_start_server } qa_run_full_local() { qa_select_port export JOBS_ENABLED="${JOBS_ENABLED:-false}" qa_export_app_env - qa_prepare_full_inputs - qa_prepare_db - - echo ">>> 执行 server Prisma 校验" - ( - cd "$SERVER_DIR" - npm run prisma:validate - ) - - echo ">>> 执行 server 单元测试" - ( - cd "$SERVER_DIR" - npm test - ) - - echo ">>> 执行 client typecheck" - ( - cd "$CLIENT_DIR" - npx tsc --noEmit --pretty false --extendedDiagnostics - ) - - qa_build_client - qa_start_server - - echo ">>> 执行 workflow-actions 回归" - ( - cd "$SERVER_DIR" - ./scripts/regression-workflow-actions.sh - ) - - echo ">>> 执行 server 全量回归" - ( - cd "$SERVER_DIR" - ./scripts/regression-all.sh - ) - - echo ">>> 切换 server 到 Playwright E2E 模式(启用后台 job)" - qa_cleanup_server - export JOBS_ENABLED="${JOBS_ENABLED_FOR_E2E:-true}" - qa_start_server - - qa_run_full_browser + export RATE_LIMIT_ENABLED=false + QA_FULL_REPORT_DIR="${QA_FULL_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/full-local/$QA_RUN_ID}" + mkdir -p "$QA_FULL_REPORT_DIR" + node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" init "$QA_FULL_REPORT_DIR/full-local-stages.json" + QA_FULL_STAGE_FILE="$QA_FULL_REPORT_DIR/full-local-stages.json" + qa_full_stage inputs qa_prepare_full_inputs + qa_full_stage database qa_full_database + qa_full_stage prisma qa_full_in_directory "$SERVER_DIR" npm run prisma:validate + qa_full_stage server-unit qa_full_in_directory "$SERVER_DIR" npm test + qa_full_stage typecheck qa_full_in_directory "$CLIENT_DIR" npx tsc --noEmit --pretty false --extendedDiagnostics + qa_full_stage client-build qa_build_client + qa_full_stage server-start qa_full_server_start + qa_full_stage accounts qa_prepare_synthetic_accounts + qa_full_stage workflow-actions qa_full_in_directory "$SERVER_DIR" ./scripts/regression-workflow-actions.sh + qa_full_stage server-regressions qa_full_in_directory "$SERVER_DIR" ./scripts/regression-all.sh + qa_full_stage browser-server qa_full_browser_server + qa_full_stage browser qa_run_full_browser } qa_run_full_browser() { + # Only the diagnostic owner may add tracing; ordinary callers cannot narrow coverage. + if (( $# )) && [[ "${QA_DIAGNOSTIC_STAGE:-}" != "browser" || "$*" != "--trace=on" ]]; then + echo "[ERROR] full-local browser scope override rejected" >&2 + return 1 + fi + local -a local_specs + mapfile -t local_specs < "$QA_FULL_REPORT_DIR/local-spec-args.txt" + (( ${#local_specs[@]} > 0 )) || return 1 if [[ -n "${QA_DIAGNOSTIC_STAGE:-}" ]]; then QA_DIAGNOSTIC_PHASE=environment; fi node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL" node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" report-guest > "$QA_FULL_REPORT_DIR/environment-before-browser.json" if [[ -n "${QA_DIAGNOSTIC_STAGE:-}" ]]; then QA_DIAGNOSTIC_PHASE=test; fi echo ">>> 执行 Playwright full E2E" + local browser_exit=0 coverage_exit=0 + node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" selection "$QA_FULL_REPORT_DIR/execution-selection.json" "${#local_specs[@]}" "${local_specs[@]}" "$@" ( cd "$CLIENT_DIR" pwa_with_fixture env E2E_MODE=local \ PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/playwright-results.json" \ QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" \ PLAYWRIGHT_HTML_OUTPUT_DIR="$QA_FULL_REPORT_DIR/playwright-report" PLAYWRIGHT_HTML_OPEN=never \ - npm run test:e2e:full -- "$@" --retries=0 --reporter=list,json,html --output="$QA_FULL_REPORT_DIR/test-results" - ) + npm run test:e2e:full -- "${local_specs[@]}" "$@" --retries=0 --forbid-only --reporter=list,json,html --output="$QA_FULL_REPORT_DIR/test-results" + ) || browser_exit=$? + node "$ROOT_DIR/scripts/qa/full-local-coverage.mjs" verify-local "$QA_FULL_REPORT_DIR" \ + > "$QA_FULL_REPORT_DIR/local-coverage-result.json" || coverage_exit=$? + if (( browser_exit )); then return "$browser_exit"; fi + return "$coverage_exit" } qa_run_r70_assist_local() { @@ -815,6 +944,22 @@ case "$MODE" in source "$ROOT_DIR/scripts/qa/full-local-diagnostic-stage.sh" qa_run_diagnostic_stage "${2:-}" ;; + retained-local) + qa_require_cmd curl + qa_require_db_client + qa_require_cmd node + qa_require_cmd npm + trap qa_cleanup EXIT + qa_run_retained_local + ;; + retained-server-local) + qa_require_cmd curl + qa_require_db_client + qa_require_cmd node + qa_require_cmd npm + trap qa_cleanup EXIT + qa_run_retained_server_local + ;; full-local) qa_require_cmd curl qa_require_db_client diff --git a/scripts/qa/test-budget-pool-db.cjs b/scripts/qa/test-budget-pool-db.cjs new file mode 100644 index 00000000..08a8973c --- /dev/null +++ b/scripts/qa/test-budget-pool-db.cjs @@ -0,0 +1,77 @@ +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { randomUUID } = require('node:crypto'); +const { mkdtempSync, mkdirSync, chmodSync, readFileSync, readdirSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const { setTimeout: delay } = require('node:timers/promises'); +const root = path.resolve(__dirname, '../..'); +const serverRequire = createRequire(path.join(root, 'server/package.json')); +const { Pool } = serverRequire('pg'); + +async function main() { + assert.equal(process.env.RUN_ISOLATED_BUDGET_DB_TEST, '1'); + const scratch = mkdtempSync(path.join(tmpdir(), 'hyb-budget-synthetic-')); + const socket = path.join(scratch, 'socket'); + const dockerConfig = path.join(scratch, 'docker'); + mkdirSync(socket); chmodSync(scratch, 0o755); chmodSync(socket, 0o777); mkdirSync(dockerConfig); + const owner = randomUUID(); + const docker = (args, input) => execFileSync('docker', ['--config', dockerConfig, ...args], { + input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 60000, + env: { PATH: process.env.PATH, LANG: 'C.UTF-8' }, + }).trim(); + let container; let pool; let failure; + const passed = []; + try { + container = docker(['run', '-d', '--pull=never', '--network', 'none', '--read-only', + '--label', `hyb.qa.owner=${owner}`, '--tmpfs', '/var/lib/postgresql/data', '--tmpfs', '/var/run/postgresql', + '--tmpfs', '/tmp', '--mount', `type=bind,src=${socket},dst=/qa-socket`, + '-e', 'POSTGRES_HOST_AUTH_METHOD=trust', '-e', 'POSTGRES_USER=synthetic', '-e', 'POSTGRES_DB=budget_synthetic', + 'postgres:16-alpine', '-c', 'listen_addresses=', '-c', 'unix_socket_directories=/qa-socket,/var/run/postgresql']); + assert.equal(docker(['inspect', '--format', '{{.HostConfig.NetworkMode}}', container]), 'none'); + pool = new Pool({ host: socket, user: 'synthetic', database: 'budget_synthetic', password: '', max: 8, + connectionTimeoutMillis: 1500, statement_timeout: 10000 }); + let ready = false; + for (let i = 0; i < 40; i++) { + try { await pool.query('SELECT 1'); ready = true; break; } catch { await delay(250); } + } + if (!ready) { + const logs = spawnSync('docker', ['--config', dockerConfig, 'logs', container], { + encoding: 'utf8', env: { PATH: process.env.PATH }, timeout: 10000, + }); + console.error(logs.stdout, logs.stderr); + throw new Error('isolated database did not become ready'); + } + const migrations = path.join(root, 'server/prisma/migrations'); + let count = 0; + for (const name of readdirSync(migrations).sort()) { + if (name === 'migration_lock.toml') continue; + // psql autocommit preserves PostgreSQL's new-enum-value transaction boundary. + docker(['exec', '-i', container, 'psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', '-h', '/qa-socket', '-U', 'synthetic', '-d', 'budget_synthetic'], + readFileSync(path.join(migrations, name, 'migration.sql'))); + count++; + } + console.log(JSON.stringify({ stage: 'migrations', count, network: 'none', persistentVolumes: false })); + passed.push('clean_database_all_migrations'); + await require('./budget-pool-db-fixture.cjs')(pool, scratch); + console.log(JSON.stringify({status:'PASS',checks:passed,scope:'isolated real PostgreSQL budget concurrency'})); + } catch (error) { + failure = error; + throw error; + } finally { + try { + if(pool) await pool.end(); + if(container){ + assert.equal(docker(['inspect','--format','{{ index .Config.Labels "hyb.qa.owner" }}',container]),owner); + docker(['rm','-f',container]); + } + rmSync(scratch,{recursive:true,force:true}); + console.log(JSON.stringify({cleanup:'PASS',onlyRunOwnedResources:true})); + } catch (cleanupError) { + console.error(JSON.stringify({cleanup:'FAIL',code:cleanupError.code??null})); + if (!failure) throw cleanupError; + } + } +} +main().catch(error=>{console.error(JSON.stringify({status:'FAIL',name:error.name,code:error.code??null,actual:typeof error.actual==='string'&&/^[A-Z0-9-]{1,60}$/.test(error.actual)?error.actual:undefined,expected:typeof error.expected==='string'&&/^[A-Z0-9-]{1,60}$/.test(error.expected)?error.expected:undefined,fixtureLine:/budget-pool-db-fixture.cjs:(\d+)/.exec(error.stack??'')?.[1]??null,reason:error.name==='AssertionError'?String(error.message).split('\n')[0]:'budget_fixture_failed'}));process.exitCode=1;}); diff --git a/scripts/qa/test-business-numbering-db.cjs b/scripts/qa/test-business-numbering-db.cjs new file mode 100644 index 00000000..9f605583 --- /dev/null +++ b/scripts/qa/test-business-numbering-db.cjs @@ -0,0 +1,77 @@ +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { randomUUID } = require('node:crypto'); +const { mkdtempSync, mkdirSync, chmodSync, readFileSync, readdirSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const { setTimeout: delay } = require('node:timers/promises'); +const root = path.resolve(__dirname, '../..'); +const serverRequire = createRequire(path.join(root, 'server/package.json')); +const { Pool } = serverRequire('pg'); + +async function main() { + assert.equal(process.env.RUN_ISOLATED_NUMBERING_DB_TEST, '1'); + const scratch = mkdtempSync(path.join(tmpdir(), 'hyb-numbering-synthetic-')); + const socket = path.join(scratch, 'socket'); + const dockerConfig = path.join(scratch, 'docker'); + mkdirSync(socket); chmodSync(scratch, 0o755); chmodSync(socket, 0o777); mkdirSync(dockerConfig); + const owner = randomUUID(); + const docker = (args, input) => execFileSync('docker', ['--config', dockerConfig, ...args], { + input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 60000, + env: { PATH: process.env.PATH, LANG: 'C.UTF-8' }, + }).trim(); + let container; let pool; let failure; + const passed = []; + try { + container = docker(['run', '-d', '--pull=never', '--network', 'none', '--read-only', + '--label', `hyb.qa.owner=${owner}`, '--tmpfs', '/var/lib/postgresql/data', '--tmpfs', '/var/run/postgresql', + '--tmpfs', '/tmp', '--mount', `type=bind,src=${socket},dst=/qa-socket`, + '-e', 'POSTGRES_HOST_AUTH_METHOD=trust', '-e', 'POSTGRES_USER=synthetic', '-e', 'POSTGRES_DB=numbering_synthetic', + 'postgres:16-alpine', '-c', 'listen_addresses=', '-c', 'unix_socket_directories=/qa-socket,/var/run/postgresql']); + assert.equal(docker(['inspect', '--format', '{{.HostConfig.NetworkMode}}', container]), 'none'); + pool = new Pool({ host: socket, user: 'synthetic', database: 'numbering_synthetic', password: '', max: 4, + connectionTimeoutMillis: 1500, statement_timeout: 10000 }); + let ready = false; + for (let i = 0; i < 40; i++) { + try { await pool.query('SELECT 1'); ready = true; break; } catch { await delay(250); } + } + if (!ready) { + const logs = spawnSync('docker', ['--config', dockerConfig, 'logs', container], { + encoding: 'utf8', env: { PATH: process.env.PATH }, timeout: 10000, + }); + console.error(logs.stdout, logs.stderr); + throw new Error('isolated database did not become ready'); + } + const migrations = path.join(root, 'server/prisma/migrations'); + let count = 0; + for (const name of readdirSync(migrations).sort()) { + if (name === 'migration_lock.toml') continue; + // psql autocommit preserves PostgreSQL's new-enum-value transaction boundary. + docker(['exec', '-i', container, 'psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', '-h', '/qa-socket', '-U', 'synthetic', '-d', 'numbering_synthetic'], + readFileSync(path.join(migrations, name, 'migration.sql'))); + count++; + } + console.log(JSON.stringify({ stage: 'migrations', count, network: 'none', persistentVolumes: false })); + passed.push('clean_database_all_migrations'); + await require('./business-numbering-db-fixture.cjs')(pool, scratch); + console.log(JSON.stringify({status:'PASS',checks:passed,scope:'isolated real PostgreSQL numbering and synthetic loan HTTP regression'})); + } catch (error) { + failure = error; + throw error; + } finally { + try { + if(pool) await pool.end(); + if(container){ + assert.equal(docker(['inspect','--format','{{ index .Config.Labels "hyb.qa.owner" }}',container]),owner); + docker(['rm','-f',container]); + } + rmSync(scratch,{recursive:true,force:true}); + console.log(JSON.stringify({cleanup:'PASS',onlyRunOwnedResources:true})); + } catch (cleanupError) { + console.error(JSON.stringify({cleanup:'FAIL',code:cleanupError.code??null})); + if (!failure) throw cleanupError; + } + } +} +main().catch(error=>{console.error(JSON.stringify({status:'FAIL',name:error.name,code:error.code??null,actual:typeof error.actual==='string'&&/^[A-Z0-9-]{1,60}$/.test(error.actual)?error.actual:undefined,expected:typeof error.expected==='string'&&/^[A-Z0-9-]{1,60}$/.test(error.expected)?error.expected:undefined,fixtureLine:/business-numbering-db-fixture.cjs:(\d+)/.exec(error.stack??'')?.[1]??null,reason:error.name==='AssertionError'?String(error.message).split('\n')[0]:'numbering_fixture_failed'}));process.exitCode=1;}); diff --git a/scripts/qa/test-company-loan-db.cjs b/scripts/qa/test-company-loan-db.cjs new file mode 100644 index 00000000..afc40fe2 --- /dev/null +++ b/scripts/qa/test-company-loan-db.cjs @@ -0,0 +1,178 @@ +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { randomUUID } = require('node:crypto'); +const { mkdtempSync, mkdirSync, chmodSync, readFileSync, readdirSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const { setTimeout: delay } = require('node:timers/promises'); +const root = path.resolve(__dirname, '../..'); +const serverRequire = createRequire(path.join(root, 'server/package.json')); +const { Pool } = serverRequire('pg'); + +async function main() { + assert.equal(process.env.RUN_ISOLATED_LOAN_DB_TEST, '1'); + const scratch = mkdtempSync(path.join(tmpdir(), 'hyb-loan-synthetic-')); + const socket = path.join(scratch, 'socket'); + const dockerConfig = path.join(scratch, 'docker'); + mkdirSync(socket); chmodSync(scratch, 0o755); chmodSync(socket, 0o777); mkdirSync(dockerConfig); + const owner = randomUUID(); + const docker = (args, input) => execFileSync('docker', ['--config', dockerConfig, ...args], { + input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 60000, + env: { PATH: process.env.PATH, LANG: 'C.UTF-8' }, + }).trim(); + let container; let pool; let failure; + const passed = []; + try { + container = docker(['run', '-d', '--pull=never', '--network', 'none', '--read-only', + '--label', `hyb.qa.owner=${owner}`, '--tmpfs', '/var/lib/postgresql/data', '--tmpfs', '/var/run/postgresql', + '--tmpfs', '/tmp', '--mount', `type=bind,src=${socket},dst=/qa-socket`, + '-e', 'POSTGRES_HOST_AUTH_METHOD=trust', '-e', 'POSTGRES_USER=synthetic', '-e', 'POSTGRES_DB=loan_synthetic', + 'postgres:16-alpine', '-c', 'listen_addresses=', '-c', 'unix_socket_directories=/qa-socket,/var/run/postgresql']); + assert.equal(docker(['inspect', '--format', '{{.HostConfig.NetworkMode}}', container]), 'none'); + pool = new Pool({ host: socket, user: 'synthetic', database: 'loan_synthetic', password: '', max: 4, + connectionTimeoutMillis: 1500, statement_timeout: 10000 }); + let ready = false; + for (let i = 0; i < 40; i++) { + try { await pool.query('SELECT 1'); ready = true; break; } catch { await delay(250); } + } + if (!ready) { + const logs = spawnSync('docker', ['--config', dockerConfig, 'logs', container], { + encoding: 'utf8', env: { PATH: process.env.PATH }, timeout: 10000, + }); + console.error(logs.stdout, logs.stderr); + throw new Error('isolated database did not become ready'); + } + const migrations = path.join(root, 'server/prisma/migrations'); + let count = 0; + for (const name of readdirSync(migrations).sort()) { + if (name === 'migration_lock.toml') continue; + // psql autocommit preserves PostgreSQL's new-enum-value transaction boundary. + docker(['exec', '-i', container, 'psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', '-h', '/qa-socket', '-U', 'synthetic', '-d', 'loan_synthetic'], + readFileSync(path.join(migrations, name, 'migration.sql'))); + count++; + } + console.log(JSON.stringify({ stage: 'migrations', count, network: 'none', persistentVolumes: false })); + passed.push('clean_database_all_migrations'); + const tables = new Set(['legal_entities','bank_institutions','loan_lenders','company_loans','loan_repayments', + 'loan_repayment_allocations','payment_batches','payment_batch_items']); + async function insert(client, table, data) { + assert.ok(tables.has(table)); + const keys = Object.keys(data); + await client.query(`INSERT INTO ${table} (${keys.join(',')}) VALUES (${keys.map((_, i) => '$'+(i+1)).join(',')})`, Object.values(data)); + } + async function transaction(fn) { + const client = await pool.connect(); + try { await client.query('BEGIN'); const result = await fn(client); await client.query('COMMIT'); return result; } + catch (error) { await client.query('ROLLBACK'); throw error; } + finally { client.release(); } + } + const companyId = randomUUID(); const bankId = randomUUID(); const actorId = randomUUID(); + await insert(pool,'legal_entities',{id:companyId,code:'SYNTHETIC-LOAN',name:'synthetic company'}); + await insert(pool,'bank_institutions',{id:bankId,code:'SYNTHETIC-LOAN',name:'synthetic bank'}); + async function fixture(options={}) { + const lenderId = randomUUID(); const loanId = randomUUID(); + await insert(pool,'loan_lenders',{id:lenderId,legal_entity_id:companyId,name:'synthetic lender',identity_status:'verified', + identity_confirmed_by:actorId,identity_confirmed_at:new Date(),identity_confirmation:'synthetic external identity checked', + bank_institution_id:bankId,bank_name:'synthetic',bank_account_encrypted:'synthetic ciphertext',account_name_encrypted:'synthetic ciphertext', + created_by:actorId,updated_by:actorId,updated_at:new Date(),request_key:randomUUID()}); + const loan={id:loanId,loan_no:'SYN-'+loanId,origin_reference:'SYN-'+loanId,legal_entity_id:companyId,lender_id:lenderId, + original_principal:'100.00',opening_outstanding_principal:'100.00',received_date:'2025-01-01',balance_as_of:'2026-09-17', + historical_entry:true,confirmation_status:'confirmed',confirmed_by:actorId,confirmed_at:new Date(),accounting_confirmation_ref:'synthetic check', + created_by:actorId,updated_by:actorId,updated_at:new Date(),request_key:randomUUID(),...options}; + await insert(pool,'company_loans',loan); + return {lenderId,loanId,loan}; + } + async function repayment(client, f, options={}) { + const id=randomUUID(); const batchId=randomUUID(); const itemId=randomUUID(); + const amount=options.amount ?? '60.00'; + await insert(client,'loan_repayments',{id,repayment_no:'SYN-'+id,legal_entity_id:companyId,lender_id:f.lenderId, + reason:'synthetic principal repayment',amount,submitted_by:actorId,lender_identity_version:1,lender_account_version:1, + payee_snapshot_json:'{}',request_key:randomUUID()}); + await insert(client,'payment_batches',{id:batchId,batch_no:'SYN-'+batchId,entity_type:'loan_repayment',loan_repayment_id:id, + legal_entity_id:companyId,name:'synthetic batch',default_payment_channel:'bank',created_by:actorId,updated_at:new Date()}); + await insert(client,'payment_batch_items',{id:itemId,payment_batch_id:batchId,entity_id:id,entity_code:'SYN-'+id, + entity_title:'synthetic principal',amount:options.itemAmount??amount,snapshot_json:'{}'}); + await insert(client,'loan_repayment_allocations',{id:randomUUID(),repayment_id:id,loan_id:f.loanId, + amount:options.allocationAmount??amount,loan_version:1}); + return {id,batchId,itemId}; + } + const rejectsCheck = (fn) => assert.rejects(fn, (error) => error.code === '23514'); + await rejectsCheck(() => fixture({original_principal:'0.00'})); + await rejectsCheck(() => fixture({opening_outstanding_principal:'100.01'})); + await rejectsCheck(() => fixture({accounting_confirmation_ref:null})); + passed.push('money_and_confirmation_checks'); + const f=await fixture(); + await assert.rejects(() => insert(pool,'company_loans',{...f.loan,id:randomUUID(),loan_no:'SYN-DUP',request_key:randomUUID()}), e=>e.code==='23505'); + passed.push('duplicate_incoming_reference'); + await rejectsCheck(()=>transaction(c=>repayment(c,f,{itemAmount:'59.00'}))); + await rejectsCheck(()=>transaction(c=>repayment(c,f,{allocationAmount:'59.00'}))); + const unconfirmed=await fixture({confirmation_status:'unconfirmed'}); + await rejectsCheck(()=>transaction(c=>repayment(c,unconfirmed))); + const other=await fixture(); + await rejectsCheck(()=>transaction(c=>repayment(c,{...f,loanId:other.loanId}))); + passed.push('allocation_total_source_and_batch_identity'); + const first=await transaction(c=>repayment(c,f)); + const balances=await pool.query(`SELECT opening_outstanding_principal::text AS outstanding, + (SELECT sum(amount)::text FROM loan_repayment_allocations WHERE loan_id=$1 AND reservation_state='reserved') AS reserved + FROM company_loans WHERE id=$1`,[f.loanId]); + assert.deepEqual(balances.rows[0],{outstanding:'100.00',reserved:'60.00'}); + await pool.query("UPDATE payment_batches SET status='reviewed' WHERE id=$1",[first.batchId]); + assert.equal((await pool.query('SELECT opening_outstanding_principal::text AS value FROM company_loans WHERE id=$1',[f.loanId])).rows[0].value,'100.00'); + await rejectsCheck(()=>transaction(c=>repayment(c,f))); + passed.push('approval_reserves_without_settlement_and_total_capacity'); + await rejectsCheck(()=>pool.query('UPDATE company_loans SET opening_outstanding_principal=50 WHERE id=$1',[f.loanId])); + passed.push('confirmed_opening_balance_immutable'); + await pool.query("UPDATE loan_repayments SET bank_result_state='unknown' WHERE id=$1",[first.id]); + await rejectsCheck(()=>transaction(async c=>{ + await c.query("UPDATE payment_batches SET status='cancelled' WHERE id=$1",[first.batchId]); + await c.query("UPDATE loan_repayment_allocations SET reservation_state='released',released_at=now(),release_reason='synthetic' WHERE repayment_id=$1",[first.id]); + })); + assert.equal((await pool.query('SELECT status FROM payment_batches WHERE id=$1',[first.batchId])).rows[0].status,'reviewed'); + passed.push('unknown_bank_state_cannot_release_and_transaction_rolls_back'); + await transaction(async c=>{ + await c.query("UPDATE loan_repayments SET bank_result_state='confirmed_failed',bank_result_ref='synthetic confirmed failure' WHERE id=$1",[first.id]); + await c.query("UPDATE payment_batches SET status='cancelled' WHERE id=$1",[first.batchId]); + await c.query("UPDATE loan_repayment_allocations SET reservation_state='released',released_at=now(),release_reason='synthetic confirmed failure' WHERE repayment_id=$1",[first.id]); + }); + passed.push('confirmed_failure_atomic_release'); + const concurrent=await fixture(); + const left=await pool.connect();const right=await pool.connect(); + try { + await left.query('BEGIN');await right.query('BEGIN'); + await left.query('SELECT id FROM company_loans WHERE id=$1 FOR UPDATE',[concurrent.loanId]); + const pid=(await right.query('SELECT pg_backend_pid() AS pid')).rows[0].pid; + const waiting=right.query('SELECT id FROM company_loans WHERE id=$1 FOR UPDATE',[concurrent.loanId]); + let blocked=false; + for(let i=0;i<30;i++){ + const state=(await pool.query('SELECT wait_event_type FROM pg_stat_activity WHERE pid=$1',[pid])).rows[0]; + if(state?.wait_event_type==='Lock'){blocked=true;break;} + await delay(10); + } + assert.ok(blocked,'second connection must actually wait for the same loan lock'); + await repayment(left,concurrent);await left.query('COMMIT');await waiting; + await repayment(right,concurrent);await rejectsCheck(()=>right.query('COMMIT'));await right.query('ROLLBACK'); + assert.equal((await pool.query("SELECT sum(amount)::text AS value FROM loan_repayment_allocations WHERE loan_id=$1",[concurrent.loanId])).rows[0].value,'60.00'); + } finally {await left.query('ROLLBACK');await right.query('ROLLBACK');left.release();right.release();} + passed.push('two_connections_serialize_and_reject_over_reservation'); + await require('./company-loan-http-fixture.cjs')(pool, scratch); + console.log(JSON.stringify({status:'PASS',checks:passed,scope:'schema and real PostgreSQL constraints; not HTTP/auth/payment acceptance'})); + } catch (error) { + failure = error; + throw error; + } finally { + try { + if(pool) await pool.end(); + if(container){ + assert.equal(docker(['inspect','--format','{{ index .Config.Labels "hyb.qa.owner" }}',container]),owner); + docker(['rm','-f',container]); + } + rmSync(scratch,{recursive:true,force:true}); + console.log(JSON.stringify({cleanup:'PASS',onlyRunOwnedResources:true})); + } catch (cleanupError) { + console.error(JSON.stringify({cleanup:'FAIL',code:cleanupError.code??null})); + if (!failure) throw cleanupError; + } + } +} +main().catch(error=>{console.error(JSON.stringify({status:'FAIL',name:error.name,code:error.code??null,message:String(error.message).replace(/\/home\/[^\s]+/g,'')}));process.exitCode=1;}); diff --git a/scripts/qa/tests/full-local-coverage.test.mjs b/scripts/qa/tests/full-local-coverage.test.mjs new file mode 100644 index 00000000..d191b2d2 --- /dev/null +++ b/scripts/qa/tests/full-local-coverage.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { coveragePlan, verifyLocalCoverage, verifyPostDeployCoverage } from "../full-local-coverage.mjs"; + +const candidate = { commit: "a".repeat(40), digest: "b".repeat(64) }; +function report(rows, executed = false) { + return { errors: [], suites: rows.map(row => ({ file: row.file, specs: [{ ...row, tests: [{ projectName: "", repeatEachIndex: 0, ...(executed ? { expectedStatus: "passed", status: "expected", results: [{ status: "passed", retry: 0 }] } : {}) }] }] })) }; +} +function fixture(context) { + const root = mkdtempSync(path.join(os.tmpdir(), "hyb-coverage-")); + context.after(() => rmSync(root, { recursive: true, force: true })); + const rows = ["local", "live", "frozen", "protection"].map(name => ({ file: `client/tests/e2e/${name}.spec.ts`, id: name, title: name, projectName: "", repeatEachIndex: 0 })); + for (const row of rows) { mkdirSync(path.dirname(path.join(root, row.file)), { recursive: true }); writeFileSync(path.join(root, row.file), row.title); } + const contract = { schemaVersion: 1, approvalReference: "synthetic", exceptions: rows.slice(1, 3).map(row => ({ ...row, kind: row.id === "live" ? "post-deploy" : "frozen", reason: "synthetic", entry: "synthetic", responsibilityRole: "synthetic", namedOwner: null, requiredEvidence: "synthetic", sourceSha256: createHash("sha256").update(row.title).digest("hex"), replacementFile: rows[3].file, replacementTitle: rows[3].title })) }; + const discovery = report(rows); + const plan = () => coveragePlan(root, discovery, contract, candidate); + return { root, rows, contract, discovery, plan }; +} + +test("全发现分层,本地通过仍保留公网必跑且不能获得交付资格", context => { + const f = fixture(context), p = f.plan(); + assert.equal(p.status, "PASS"); assert.equal(p.totalCases, 4); assert.equal(p.localCases.length, 2); + const r = verifyLocalCoverage(p, report(p.localCases, true), candidate); + assert.equal(r.status, "PASS"); assert.equal(r.deliveryStatus, "INCOMPLETE"); assert.equal(r.normalReleaseEligible, false); + assert.deepEqual(r.obligations.map(row => row.status), ["NOT_RUN_LOCAL", "FROZEN_PROTECTION_PASS"]); +}); + +for (const mutation of ["missing-file", "new-file", "new-live-case", "identity-drift", "duplicate", "source-drift", "no-protection", "unknown-exception", "missing-reason"]) test(`发现/归属反例:${mutation}`, context => { + const f = fixture(context); + if (mutation === "missing-file") f.discovery.suites.pop(); + if (mutation === "new-file") writeFileSync(path.join(f.root, "client/tests/e2e/new.spec.ts"), "new"); + if (mutation === "new-live-case") f.discovery.suites[1].specs.push({ ...f.discovery.suites[1].specs[0], id: "new-live", title: "new-live" }); + if (mutation === "identity-drift") f.discovery.suites[1].specs[0].id = "drift"; + if (mutation === "duplicate") f.discovery.suites.push(f.discovery.suites[0]); + if (mutation === "source-drift") writeFileSync(path.join(f.root, f.rows[1].file), "changed"); + if (mutation === "no-protection") f.contract.exceptions[1].replacementTitle = "missing"; + if (mutation === "unknown-exception") f.contract.exceptions[1].kind = "ignore"; + if (mutation === "missing-reason") delete f.contract.exceptions[0].reason; + assert.equal(f.plan().status, "FAIL"); +}); + +for (const mutation of ["missing", "unexpected", "duplicate", "skipped", "failed", "timedOut", "interrupted", "retry", "expected-failure", "fake-pass", "candidate", "errors", "title-drift"]) test(`本地结果反例:${mutation}`, context => { + const f = fixture(context), p = f.plan(), r = report(p.localCases, true); + let current = candidate; + const row = r.suites[0].specs[0].tests[0]; + if (mutation === "missing") r.suites.pop(); + if (mutation === "unexpected") r.suites.push(report([f.rows[1]], true).suites[0]); + if (mutation === "duplicate") r.suites.push(r.suites[0]); + if (["skipped", "failed", "timedOut", "interrupted"].includes(mutation)) row.results[0].status = mutation; + if (mutation === "retry") row.results.push({ status: "passed", retry: 1 }); + if (mutation === "expected-failure") row.expectedStatus = "failed"; + if (mutation === "fake-pass") { r.passed = true; delete row.results; } + if (mutation === "candidate") current = { ...candidate, digest: "c".repeat(64) }; + if (mutation === "errors") r.errors.push({ message: "synthetic" }); + if (mutation === "title-drift") r.suites[0].specs[0].title = "changed"; + assert.equal(verifyLocalCoverage(p, r, current).status, "FAIL"); +}); + +test("公网只接受同候选 live 逐项结果,不接受本地或布尔通过", context => { + const f = fixture(context), p = f.plan(); + const good = { profile: "post-deploy-live", mode: "live", candidate, deployedCandidate: candidate, report: report([f.rows[1]], true) }; + assert.equal(verifyPostDeployCoverage(p, good).status, "PASS"); + for (const bad of [{ passed: true }, { ...good, profile: "full-local" }, { ...good, mode: "local" }, { ...good, deployedCandidate: { ...candidate, commit: "c".repeat(40) } }, { ...good, candidate: { ...candidate, digest: "d".repeat(64) } }, { ...good, report: report(p.localCases, true) }]) assert.equal(verifyPostDeployCoverage(p, bad).status, "FAIL"); +}); diff --git a/scripts/qa/tests/full-local-environment.test.mjs b/scripts/qa/tests/full-local-environment.test.mjs index 0885601c..2ad38044 100644 --- a/scripts/qa/tests/full-local-environment.test.mjs +++ b/scripts/qa/tests/full-local-environment.test.mjs @@ -13,7 +13,7 @@ function receipt() { schemaVersion: 1, profile: "isolated-online-v1", policyDigest: "policy", supervisorObservation: { source: "live-supervisor-unix-ipc", sampledAt: "2026-09-07T00:00:00Z", namespace: "parent" }, namespace: { inheritedRunner: "parent", ownedChild: "child", runnerCreated: true, currentMatchesOwnedChild: true, topologyMatched: true }, - nativeOnlineProbe: { valid: true, online: true, browserNamespace: "child", browserMatchesOwnedChild: true, noNavigatorOverride: true }, + nativeOnlineProbe: { valid: true, online: true, browserNamespace: "child", browserMatchesOwnedChild: true, noNavigatorOverride: true, engines: ["chromium", "webkit"].map(name => ({ name, version: "synthetic", online: true, valid: true, noNavigatorOverride: true, browserNamespace: "child", browserMatchesOwnedChild: true })) }, bootstrapObservation: { valid: true, observationPoint: "guest-entry-black-box", freshContext: true, initialSessionHint: false, listenerBeforeNavigation: true, noRouteMocks: true, loginFormVisible: true, refreshRequestCount: 0, loginRequestCount: 0, @@ -66,13 +66,21 @@ test("缺 context 不接受 GATE_PASSED 或已有 true", () => { assert.deepEqual(validateEnvironmentFacts(context, facts), []); for (const key of Object.keys(facts)) assert.ok(validateEnvironmentFacts(context, { ...facts, [key]: null }).length, key); }); -test("拓扑只接纳 owned veth 且禁止默认出口", () => { +test("拓扑只接纳同 namespace 内部 veth 默认路由,拒绝外部对端和网关", () => { const addresses = [{ ifname: "lo" }, ...["qaonline", "qapeer"].map((ifname, index) => ({ ifname, flags: ["UP"], operstate: "UP", addr_info: [{ local: `192.0.2.${index + 1}`, prefixlen: 30 }] }))]; - assert.equal(topologyMatches(addresses, [], []), true); - assert.equal(topologyMatches(addresses, [{ dst: "default" }], []), false); - assert.equal(topologyMatches(addresses, [], [{ gateway: "::1" }]), false); - assert.equal(topologyMatches([...addresses, { ifname: "eth0" }], [], []), false); - assert.equal(topologyMatches(addresses.slice(0, 2), [], []), false); + const links = [{ifname:"lo",ifindex:1},{ifname:"qaonline",ifindex:2,link:"qapeer",linkinfo:{info_kind:"veth"}},{ifname:"qapeer",ifindex:3,link:"qaonline",linkinfo:{info_kind:"veth"}}]; + const routes = [{dst:"default",dev:"qaonline",scope:"link"}]; + assert.equal(topologyMatches(addresses, routes, [], links), true); + for(const mutate of [ + f=>{f.routes=[];}, f=>{f.routes.push(f.routes[0]);}, f=>{f.routes[0].gateway="192.0.2.2";}, + f=>{f.routes[0].dev="eth0";}, f=>{f.routes[0].scope="global";}, f=>{f.routes[0].multipath=[];}, + f=>{f.ipv6=[{dst:"default",dev:"qaonline"}];}, f=>{f.addresses.push({ifname:"eth0"});}, + f=>{f.links[1].link_netnsid=0;}, f=>{f.links[1].link="outside";}, f=>{f.links[1].linkinfo.info_kind="bridge";}, + f=>{f.links[1].master="outside";}, f=>{f.links.pop();}, f=>{f.links=undefined;}, + ]) { + const f=structuredClone({addresses,links,routes,ipv6:[]});mutate(f); + assert.equal(topologyMatches(f.addresses,f.routes,f.ipv6,f.links),false); + } }); for (const mismatch of [false, true]) test(`namespace 创建失败不降级,清理须匹配 owner:mismatch=${mismatch}`, context => { diff --git a/scripts/qa/tests/prepare-local-e2e-accounts.test.mjs b/scripts/qa/tests/prepare-local-e2e-accounts.test.mjs new file mode 100644 index 00000000..a13adc23 --- /dev/null +++ b/scripts/qa/tests/prepare-local-e2e-accounts.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { prepareAccounts, localAccounts } from "../prepare-local-e2e-accounts.mjs"; + +const input = { origin: "http://127.0.0.1:12345", databaseName: "hyb_oa_webgate_synthetic", initialPassword: "Initial!synthetic-only-123456789" }; + +test("preparation refuses remote origins, wrong database and reused initial credentials before any request", async () => { + for (const change of [{ origin: "https://example.invalid" }, { origin: "http://127.0.0.1:1/api" }, + { databaseName: "production" }, { initialPassword: localAccounts[0].password }]) { + let calls = 0; + await assert.rejects(prepareAccounts({ ...input, ...change, fetchImpl: async () => { calls++; } })); + assert.equal(calls, 0); + } +}); + +test("all roles must really require change, reject old password and login again; receipts contain no credentials", async () => { + const calls = []; + const result = await prepareAccounts({ ...input, fetchImpl: async (url, request) => { + const step = calls.length % 4; + calls.push({ url, body: JSON.parse(request.body) }); + assert.equal(request.redirect, "error"); + assert.equal(new URL(url).pathname, step === 1 ? "/api/auth/change-password" : "/api/auth/login"); + if (step === 1) assert.equal(request.headers.authorization, "Bearer synthetic"); + return { status: step === 2 ? 401 : 200, json: async () => ({ data: { accessToken: "synthetic", mustChangePassword: step === 0 } }) }; + } }); + assert.equal(calls.length, localAccounts.length * 4); + assert.equal(result.accounts.length, 8); + assert.ok(!JSON.stringify(result).includes(input.initialPassword)); + assert.ok(localAccounts.every(a => !JSON.stringify(result).includes(a.password))); +}); + +test("one false precondition stops before changing any further account", async () => { + for (const failingStep of [0, 1, 2, 3]) { + let calls = 0; + await assert.rejects(prepareAccounts({ ...input, fetchImpl: async () => { + const step = calls++; + return { status: step === failingStep ? 503 : step === 2 ? 401 : 200, + json: async () => ({ data: { accessToken: "synthetic", mustChangePassword: step === 0 } }) }; + } })); + assert.equal(calls, failingStep + 1); + } +}); diff --git a/scripts/qa/tests/pwa-full-local-inputs.test.mjs b/scripts/qa/tests/pwa-full-local-inputs.test.mjs index 386f5f83..d9effbe0 100644 --- a/scripts/qa/tests/pwa-full-local-inputs.test.mjs +++ b/scripts/qa/tests/pwa-full-local-inputs.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; @@ -31,8 +32,16 @@ function fixture(context, overrides = {}) { context.after(() => rmSync(root, { recursive: true, force: true })); for (const directory of ["bin", "tmp", "scripts/qa", "server/scripts", "client/dist", "client/tests/e2e", "output"]) mkdirSync(path.join(root, directory), { recursive: true }); writeFileSync(path.join(root, "output/archive.png"), "historical evidence"); - for (const file of ["run-web-gates.sh", "run-pwa-cross-version-upgrade.sh", "pwa-cross-version-fixture.sh", "verify-full-local-discovery.mjs", "full-local-diagnostic.mjs", "full-local-diagnostic-stage.sh"]) copyFileSync(path.join(projectRoot, "scripts/qa", file), path.join(root, "scripts/qa", file)); + for (const file of ["run-web-gates.sh", "run-pwa-cross-version-upgrade.sh", "pwa-cross-version-fixture.sh", "verify-full-local-discovery.mjs", "full-local-diagnostic.mjs", "full-local-diagnostic-stage.sh", "full-local-coverage.mjs", "qa-stage-record.mjs"]) copyFileSync(path.join(projectRoot, "scripts/qa", file), path.join(root, "scripts/qa", file)); for (const file of ["pwa-cross-version-upgrade.spec.ts", "ordinary.spec.ts"]) writeFileSync(path.join(root, "client/tests/e2e", file), ""); + const exceptions = []; + if (overrides.LAYERED_FIXTURE) { + for (const [file, kind] of [["live.spec.ts", "post-deploy"], ["frozen.spec.ts", "frozen"]]) { + writeFileSync(path.join(root, "client/tests/e2e", file), "preserved"); + exceptions.push({ file: `client/tests/e2e/${file}`, id: file, title: file, projectName: "", repeatEachIndex: 0, kind, sourceSha256: createHash("sha256").update("preserved").digest("hex"), reason: "fixture", entry: "fixture", responsibilityRole: "fixture", requiredEvidence: "fixture", replacementFile: "client/tests/e2e/ordinary.spec.ts", replacementTitle: "ordinary.spec.ts" }); + } + } + writeFileSync(path.join(root, "scripts/qa/full-local-coverage-contract.json"), JSON.stringify({ schemaVersion: 1, approvalReference: "fixture", exceptions })); writeFileSync(path.join(root, "client/dist/keep.txt"), "original-dist"); for (const [file, stage] of [["regression-workflow-actions.sh", "workflow-actions"], ["regression-all.sh", "server-regressions"]]) { writeFileSync(path.join(root, "server/scripts", file), `#!${process.execPath} @@ -49,20 +58,21 @@ const root = process.env.HYB_QA_FIXTURE_ROOT; if (!root || !path.basename(root).startsWith('hyb-full-local-test-') || path.dirname(process.argv[1]) !== path.join(root, 'bin')) throw Error('fixture_only'); const command = path.basename(process.argv[1]); const args = process.argv.slice(2); -fs.appendFileSync(path.join(root, 'commands.jsonl'), JSON.stringify({command,args,visualOutput:process.env.QA_VISUAL_OUTPUT_DIR,stage:process.env.QA_DIAGNOSTIC_STAGE,database:process.env.QA_DB_NAME,sha:process.env.VITE_BUILD_SHA,inputs:[process.env.PWA_BUILD_A_DIR,process.env.PWA_BUILD_B_DIR,process.env.PWA_BUILD_A_SHA,process.env.PWA_BUILD_B_SHA]})+'\\n'); +fs.appendFileSync(path.join(root, 'commands.jsonl'), JSON.stringify({command,args,nodeEnv:process.env.NODE_ENV,imNotifierEnabled:process.env.IM_NOTIFIER_ENABLED,ocrProvider:process.env.OCR_PROVIDER,jobsEnabled:process.env.JOBS_ENABLED,ocrMock:process.env.PLAYWRIGHT_OCR_MOCK,syntheticInitialPresent:Boolean(process.env.QA_SYNTHETIC_INITIAL_PASSWORD),seedRolesPrepared:['CEO','ADMIN','MANAGER','EMPLOYEE','FINANCE','FINANCE_MGR','HR','ADMINISTRATION'].every(role=>Boolean(process.env['SEED_'+role+'_PASSWORD'])),visualOutput:process.env.QA_VISUAL_OUTPUT_DIR,stage:process.env.QA_DIAGNOSTIC_STAGE,database:process.env.QA_DB_NAME,sha:process.env.VITE_BUILD_SHA,inputs:[process.env.PWA_BUILD_A_DIR,process.env.PWA_BUILD_B_DIR,process.env.PWA_BUILD_A_SHA,process.env.PWA_BUILD_B_SHA]})+'\\n'); if (command === 'node' && args[0]?.endsWith('full-local-environment.mjs')) { if (process.env.FAIL_ENVIRONMENT) process.exit(41); if (process.env.FAIL_ENVIRONMENT_VERIFY && args[1] === 'verify') process.exit(41); if (process.env.FAIL_ENVIRONMENT_CONSUME && args[1] === 'report-guest') process.exit(41); - if (['full-local','full-local-diagnostic'].includes(args[1])) { + if (['full-local','full-local-diagnostic','retained-server-local'].includes(args[1])) { const result = spawnSync('bash',[path.join(root,'scripts/qa/run-web-gates.sh'),args[1]],{env:{...process.env,QA_ENVIRONMENT_CONTEXT:'synthetic-reference'},stdio:'inherit'}); process.exit(result.status ?? 1); } console.log(JSON.stringify({bootstrapObservation:{valid:true}})); process.exit(0); } +if (command === 'node' && args[0]?.endsWith('prepare-local-e2e-accounts.mjs')) process.exit(process.env.FAIL_ACCOUNT_PREPARATION ? 43 : 0); if (command === 'node' && args[0]?.endsWith('browser-environment-probe.mjs')) process.exit(process.env.FAIL_ENVIRONMENT_PROBE ? 42 : 0); -if (command === 'node' && (args[0]?.endsWith('verify-full-local-discovery.mjs') || args[0]?.endsWith('full-local-diagnostic.mjs') || args[0] === '-e')) { +if (command === 'node' && (args[0]?.endsWith('verify-full-local-discovery.mjs') || args[0]?.endsWith('full-local-diagnostic.mjs') || args[0]?.endsWith('full-local-coverage.mjs') || args[0]?.endsWith('qa-stage-record.mjs') || args[0] === '-e')) { const result = spawnSync(process.execPath, args, {env:process.env, stdio:'inherit'}); process.exit(result.status ?? 1); } @@ -91,7 +101,10 @@ if (command === 'npm' && args.includes('test:e2e:full')) { if (process.env.MUTATE_ARCHIVE) fs.writeFileSync(path.join(root,'output/archive.png'),'overwritten'); if (process.env.PLAYWRIGHT_JSON_OUTPUT_FILE) { const discovery = JSON.parse(fs.readFileSync(path.join(root,process.env.QA_DIAGNOSTIC_STAGE?'report/full-local-report/discovery.json':'report/discovery.json'))); + if (process.env.LAYERED_FIXTURE) discovery.suites = discovery.suites.filter(suite => args.some(arg => {try { return new RegExp(arg).test('tests/e2e/'+suite.file); } catch { return false; }})); + if (process.env.OMIT_EXECUTION_CASE) discovery.suites.pop(); for (const suite of discovery.suites) for (const spec of suite.specs) for (const test of spec.tests) Object.assign(test,{status:process.env.FAIL_BROWSER?'unexpected':'expected',expectedStatus:'passed',results:[{status:process.env.FAIL_BROWSER?'failed':'passed',retry:0,duration:1}]}); + if (process.env.SKIP_EXECUTION_CASE) discovery.suites[0].specs[0].tests[0].results[0].status = 'skipped'; fs.writeFileSync(process.env.PLAYWRIGHT_JSON_OUTPUT_FILE,JSON.stringify(discovery)); } if (process.env.FAIL_BROWSER) process.exit(26); @@ -115,6 +128,10 @@ if (!['node','npm','npx','curl','psql','createdb','dropdb'].includes(command)) t return { root, run(script = "run-web-gates.sh", args = ["full-local"]) { + // Each mutation invocation owns a fresh report; do not fail on a prior + // invocation's exclusive output before reaching the mutated guard. + rmSync(path.join(root, "report"), { recursive: true, force: true }); + rmSync(path.join(root, "commands.jsonl"), { force: true }); const result = spawnSync("bash", [path.join(root, "scripts/qa", script), ...args], { env, cwd: root, encoding: "utf8", timeout: 30000 }); return { ...result, commands: existsSync(path.join(root, "commands.jsonl")) ? readFileSync(path.join(root, "commands.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)) : [] }; }, @@ -128,12 +145,15 @@ for (const ci of [undefined, "1"]) { assert.equal(result.status, 0, result.stderr); const builds = result.commands.filter((command) => command.args.includes("--outDir")); assert.deepEqual(builds.map((command) => command.sha), ["111111111111", "222222222222"]); + assert.ok(builds.every(command => command.nodeEnv === "production")); const discovery = result.commands.find((command) => command.args.includes("--list")); const browser = result.commands.find((command) => command.args.includes("test:e2e:full")); + assert.equal(browser.imNotifierEnabled, "true"); assert.ok(!browser.args.some(argument => argument.startsWith("--trace"))); for (const command of [discovery, browser]) { assert.ok(command); assert.ok(command.args.includes("--retries=0")); + assert.ok(command.args.includes("--forbid-only")); assert.ok(!command.args.some((argument) => argument.startsWith("--workers") || argument.startsWith("--grep") || argument.startsWith("--shard"))); assert.ok(command.inputs.every(Boolean)); assert.equal(command.visualOutput, path.join(owned.root, "report/visual")); @@ -330,3 +350,132 @@ test("移除诊断 trace 实参的源码变异会被真实命令捕获拒绝", c assert.ok(!browser.args.includes("--trace=on")); assert.throws(() => assert.deepEqual(browser.args.filter(argument => argument.startsWith("--trace")), ["--trace=on"])); }); + + +test("普通 full-local 在回归前串行准备账号,随后不传播初始密码", context => { + const owned = fixture(context); + const result = owned.run(); + assert.equal(result.status, 0, result.stderr); + const seed = result.commands.find(c => c.args.includes("seed:dev")); + const preparations = result.commands.filter(c => c.args[0]?.endsWith("prepare-local-e2e-accounts.mjs")); + assert.equal(preparations.length, 1); + assert.equal(seed.seedRolesPrepared, true); + assert.equal(preparations[0].syntheticInitialPresent, true); + assert.ok(result.commands.indexOf(seed) < result.commands.indexOf(preparations[0])); + const regression = result.commands.find(c => c.command === "regression"); + assert.ok(result.commands.indexOf(preparations[0]) < result.commands.indexOf(regression)); + const browser = result.commands.find(c => c.args.includes("test:e2e:full")); + assert.equal(browser.syntheticInitialPresent, false); + assert.equal(browser.seedRolesPrepared, false); +}); + +test("普通 full-local 账号准备失败阻断回归及浏览器,删前置反例会被抓到", context => { + const owned = fixture(context, { FAIL_ACCOUNT_PREPARATION: "1" }); + const verify = result => { + assert.equal(result.status, 43); + assert.ok(!result.commands.some(c => c.command === "regression" || c.args.includes("test:e2e:full"))); + assert.ok(result.commands.some(c => c.command === "dropdb")); + }; + verify(owned.run()); + const file = path.join(owned.root, "scripts/qa/run-web-gates.sh"); + const source = readFileSync(file, "utf8"); + writeFileSync(file, source.replace("qa_full_stage accounts qa_prepare_synthetic_accounts", "qa_full_stage accounts : # removed preparation")); + assert.throws(() => verify(owned.run()), assert.AssertionError); +}); + + +for (const suite of ["workflow-actions", "server-regressions"]) { + test(`受管限定服务端只运行已选套件并保持无发布资格 ${suite}`, context => { + const owned = fixture(context, { QA_RETAINED_SERVER_SUITE: suite }); + const result = owned.run("run-web-gates.sh", ["retained-server-local"]); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.commands.filter(c => c.command === "regression").map(c => c.stage), [suite]); + assert.ok(!result.commands.some(c => c.args.includes("--list") || c.args.includes("test:e2e:full"))); + const selection = JSON.parse(readFileSync(path.join(owned.root, "report/selection.json"))); + assert.equal(selection.normalReleaseEligible, false); + assert.equal(selection.scope, "focused-server-regression"); + assert.equal(selection.suite, suite); + const regressionIndex = result.commands.findIndex(c => c.command === "regression"); + const accountPreparation = result.commands.find(c => c.args[0]?.endsWith("prepare-local-e2e-accounts.mjs")); + assert.equal(accountPreparation.imNotifierEnabled, suite === "server-regressions" ? "true" : "false"); + for (const script of ["browser-environment-probe.mjs", "prepare-local-e2e-accounts.mjs"]) { + const index = result.commands.findIndex(c => c.args[0]?.endsWith(script)); + assert.ok(index >= 0 && index < regressionIndex); + } + assert.ok(result.commands.some(c => c.command === "dropdb")); + }); +} + +for (const suite of [undefined, "../workflow-actions", "workflow-actions; true"]) { + test(`限定服务端拒绝缺失或任意命令选择 ${suite}`, context => { + const owned = fixture(context, suite ? { QA_RETAINED_SERVER_SUITE: suite } : {}); + const result = owned.run("run-web-gates.sh", ["retained-server-local"]); + assert.notEqual(result.status, 0); + assert.ok(!result.commands.some(c => c.command === "createdb" || c.command === "regression")); + }); +} + +for (const flag of ["FAIL_ENVIRONMENT_PROBE", "FAIL_ACCOUNT_PREPARATION"]) { + test(`限定服务端前置失败不进入回归 ${flag}`, context => { + const owned = fixture(context, { QA_RETAINED_SERVER_SUITE: "workflow-actions", [flag]: "1" }); + const result = owned.run("run-web-gates.sh", ["retained-server-local"]); + assert.notEqual(result.status, 0); + assert.ok(!result.commands.some(c => c.command === "regression")); + assert.ok(result.commands.some(c => c.command === "dropdb")); + }); +} + +for (const [overrides, enabled] of [ + [{}, true], + [{ PLAYWRIGHT_OCR_MOCK: "0" }, true], + [{ PLAYWRIGHT_OCR_MOCK: "1", JOBS_ENABLED_FOR_E2E: "false" }, false], + [{ PLAYWRIGHT_OCR_MOCK: "1", OCR_PROVIDER: "external-fixture" }, false], +]) { + test(`OCR 修正能力绑定已启动服务而非调用者标志 ${JSON.stringify(overrides)}`, context => { + const owned = fixture(context, overrides); + const result = owned.run(); + assert.equal(result.status, 0, result.stderr); + const browser = result.commands.find(c => c.args.includes("test:e2e:full")); + assert.equal(browser.ocrMock, enabled ? "1" : "0"); + assert.equal(browser.ocrMock === "1", browser.ocrProvider === "mock" && browser.jobsEnabled === "true"); + const capabilities = readFileSync(path.join(owned.root, "report/server-capabilities.jsonl"), "utf8").trim().split("\n").map(JSON.parse); + assert.equal(capabilities.at(-1).mockCorrectionEnabled, enabled); + assert.equal(capabilities.at(-1).source, "owned-server-start"); + }); +} + + +test("分层 full-local 真实入口从总发现派生集合并保留公网与冻结义务", context => { + const owned = fixture(context, { LAYERED_FIXTURE: "1" }); + const result = owned.run(); + assert.equal(result.status, 0, result.stderr); + const plan = JSON.parse(readFileSync(path.join(owned.root, "report/coverage-plan.json"))); + const receipt = JSON.parse(readFileSync(path.join(owned.root, "report/local-coverage-result.json"))); + assert.equal(plan.totalCases, 4); + assert.equal(plan.localCases.length, 2); + assert.deepEqual(receipt.obligations.map(row => row.status), ["NOT_RUN_LOCAL", "FROZEN_PROTECTION_PASS"]); + assert.equal(receipt.deliveryStatus, "INCOMPLETE"); + assert.equal(receipt.normalReleaseEligible, false); + const browser = result.commands.find(command => command.args.includes("test:e2e:full")); + assert.ok(!browser.args.some(arg => arg.includes("live.spec") || arg.includes("frozen.spec"))); +}); + +for (const flag of ["OMIT_EXECUTION_CASE", "SKIP_EXECUTION_CASE"]) test(`即使浏览器退出零也拒绝漏例或跳过:${flag}`, context => { + const owned = fixture(context, { LAYERED_FIXTURE: "1", [flag]: "1" }); + const result = owned.run(); + assert.notEqual(result.status, 0); + assert.equal(JSON.parse(readFileSync(path.join(owned.root, "report/local-coverage-result.json"))).status, "FAIL"); +}); + + +test("删除真实覆盖消费必须使跳过反例失去阻断", context => { + const owned = fixture(context, { LAYERED_FIXTURE: "1", SKIP_EXECUTION_CASE: "1" }); + const assertBlocked = result => assert.notEqual(result.status, 0); + assertBlocked(owned.run()); + const source = path.join(owned.root, "scripts/qa/run-web-gates.sh"); + const original = readFileSync(source, "utf8"); + const protectedCall = 'node "$ROOT_DIR/scripts/qa/full-local-coverage.mjs" verify-local "$QA_FULL_REPORT_DIR"'; + assert.ok(original.includes(protectedCall)); + writeFileSync(source, original.replace(protectedCall, ':')); + assert.throws(() => assertBlocked(owned.run()), assert.AssertionError); +}); diff --git a/scripts/qa/tests/release-gate-result.test.mjs b/scripts/qa/tests/release-gate-result.test.mjs new file mode 100644 index 00000000..5d2347c3 --- /dev/null +++ b/scripts/qa/tests/release-gate-result.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { captureReleaseSource, verifyReleaseEvidence, validateSkipRequest, confinedPath } from '../release-gate-result.mjs'; +import { coveragePlan, localSpecArgs } from '../full-local-coverage.mjs'; +import { FULL_LOCAL_STAGES, newStageRecord, updateStageRecord, validateStages } from '../qa-stage-record.mjs'; + +const time = seconds => new Date(Date.UTC(2026,8,18,0,0,seconds)).toISOString(); +function stages(ids, profile, start=1) { + return {schemaVersion:1,profile,status:'PASS',exitCode:0,startedAt:time(start),endedAt:time(start+ids.length*2+1),stages:ids.map((id,index)=>({id,status:'PASS',exitCode:0,startedAt:time(start+index*2),endedAt:time(start+index*2+1)}))}; +} +function fixture(context) { + const root=mkdtempSync(path.join(os.tmpdir(),'hyb-release-evidence-')); + context.after(()=>rmSync(root,{recursive:true,force:true})); + mkdirSync(path.join(root,'client/tests/e2e'),{recursive:true}); + const rows=Array.from({length:10},(_,i)=>({file:`client/tests/e2e/flow-${i}.spec.ts`,id:`flow-${i}`,title:`flow-${i} @local-candidate:flow-${i}`,line:1})); + for(const row of rows) writeFileSync(path.join(root,row.file),'synthetic'); + const source={commit:'a'.repeat(40),tree:'b'.repeat(40),digest:'c'.repeat(64),untrackedDigest:'d'.repeat(64),dirty:false,repository:{trackedCoverageComplete:true,trackedDigest:'e'.repeat(64),indexDigest:'f'.repeat(64),porcelainV1Z:''}}; + const policy={digest:'policy',environmentDigest:'environment',runnerDigest:'runner',probeDigest:'probe',toolchain:{node:process.version,playwright:'1.58.2',browserRegistry:'browser',browserVersions:{chromium:'synthetic',webkit:'synthetic'},platform:'linux',arch:'x64',ci:false,workers:4}}; + const facts={root,source,policy,contract:{schemaVersion:1,approvalReference:'synthetic',exceptions:[]},manifest:{localCandidateFlows:rows.map((row,i)=>({bundle:`flow-${i}`,witnessTag:`@local-candidate:flow-${i}`,witnessSpec:row.file})),acceptanceSpecs:rows.map(row=>row.file)}}; + const report=executed=>({errors:[],suites:rows.map(row=>({file:row.file,specs:[{...row,tests:[{projectName:'',repeatEachIndex:0,...(executed?{expectedStatus:'passed',status:'expected',results:[{status:'passed',retry:0}]}:{})}]}]}))}); + const discovery=report(false),execution=report(true); + execution.config={forbidOnly:true,projects:[{name:'',retries:0,repeatEach:1,timeout:180000,testIgnore:['**/manual/**']}],shard:null,maxFailures:0,workers:4,version:'1.58.2'}; + execution.stats={startTime:time(26),duration:500,expected:10,unexpected:0,flaky:0,skipped:0}; + const environment={schemaVersion:1,profile:'isolated-online-v1',mode:'full-local',policyDigest:'environment',platform:'linux',status:'PASS',exitCode:0,childExit:0,childSignal:null,interrupted:null,startedAt:time(3),endedAt:time(30),sourceBefore:structuredClone(source),sourceAfter:structuredClone(source),sourceUnchanged:true,repositoryUnchanged:true,candidateIntegrityStatus:'UNCHANGED',cleanup:{containerOwnerVerified:true,containerRemoved:true,scratchRemoved:true},environment:{schemaVersion:1,profile:'isolated-online-v1',policyDigest:'environment',platform:'linux',runnerDigest:'runner',probeDigest:'probe',supervisorObservation:{source:'live-supervisor-unix-ipc',sampledAt:time(3),namespace:'parent'},namespace:{inheritedRunner:'parent',ownedChild:'child',runnerCreated:true,currentMatchesOwnedChild:true,topologyMatched:true},nativeOnlineProbe:{valid:true,online:true,browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,engines:['chromium','webkit'].map(name=>({name,version:'synthetic',online:true,valid:true,noNavigatorOverride:true,browserNamespace:'child',browserMatchesOwnedChild:true}))},bootstrapObservation:{valid:true,observationPoint:'guest-entry-black-box',freshContext:true,initialSessionHint:false,listenerBeforeNavigation:true,noRouteMocks:true,loginFormVisible:true,refreshRequestCount:0,loginRequestCount:0,nativeOnlineSamples:{beforeNavigation:true,documentStart:true,afterForm:true},browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,documentStatus:200,stayedOnLogin:true}}}; + const record={schemaVersion:1,profile:'release-predeploy',mode:'verify-candidate',runId:'a'.repeat(24),startedAt:time(0),finishedAt:time(33),executionExit:0,policy:structuredClone(policy),sourceBefore:structuredClone(source),sourceAfter:structuredClone(source),phases:stages(['static','full-local'],'release-predeploy-stages')}; + record.phases.endedAt=time(32);record.phases.stages[1].endedAt=time(31); + const bundle={environment,phases:stages(FULL_LOCAL_STAGES,'full-local-stages',4),discovery,execution,plan:coveragePlan(root,discovery,facts.contract,source)}; + bundle.selection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(bundle.plan),additionalArgs:[],retries:0,forbidOnly:true,workers:4,mode:'local'}; + return {root,record,bundle,facts,verify:()=>verifyReleaseEvidence(record,bundle,facts)}; +} + +test('完整同源分阶段结果获得发布前资格,但公网交付仍未完成',context=>{ + const f=fixture(context), result=f.verify();assert.equal(result.status,'PASS',JSON.stringify(result.issues));assert.equal(result.deploymentEligible,true);assert.equal(result.deliveryStatus,'INCOMPLETE');assert.equal(result.localCases.length,10);assert.equal(result.witnessSummary.passed,10); +}); + +test('dirty 开发树可完成本地检查,但始终 PARTIAL/无发布资格',context=>{ + const f=fixture(context); + for(const source of [f.facts.source,f.record.sourceBefore,f.record.sourceAfter,f.bundle.environment.sourceBefore,f.bundle.environment.sourceAfter]){source.dirty=true;source.repository.porcelainV1Z=' M client/file\0';} + const result=f.verify();assert.equal(result.localChecksPassed,true,JSON.stringify(result.issues));assert.equal(result.status,'PARTIAL');assert.equal(result.deploymentEligible,false); +}); + +const mutations={ + 'missing-receipt':f=>{f.record={};}, + 'old-schema':f=>{f.record.schemaVersion=0;}, + 'focused-profile':f=>{f.record.profile='local-candidate';}, + 'no-finish':f=>{f.record.finishedAt=null;}, + 'failed-run':f=>{f.record.executionExit=17;}, + 'interrupted':f=>{f.record.interrupted='SIGTERM';}, + 'phase-missing':f=>{f.record.phases.stages.pop();}, + 'phase-skipped':f=>{f.record.phases.stages[0].status='SKIP';}, + 'phase-failed':f=>{f.record.phases.stages[0].exitCode=3;}, + 'phase-reversed':f=>{f.record.phases.stages.reverse();}, + 'phase-after-completion':f=>{f.record.phases.endedAt=time(40);}, + 'inner-phase-missing':f=>{f.bundle.phases.stages.pop();}, + 'inner-phase-failed':f=>{f.bundle.phases.stages[2].status='FAIL';}, + 'inner-profile-invalid':f=>{f.bundle.phases.profile='diagnostic';}, + 'inner-time-outside-run':f=>{f.bundle.phases.startedAt=time(0);}, + 'changed-commit':f=>{f.facts.source.commit='1'.repeat(40);}, + 'changed-source':f=>{f.facts.source.digest='1'.repeat(64);}, + 'changed-index':f=>{f.facts.source.repository.indexDigest='1'.repeat(64);}, + 'changed-untracked-input':f=>{f.facts.source.untrackedDigest='1'.repeat(64);}, + 'source-unknown':f=>{f.record.sourceBefore.repository.trackedCoverageComplete=false;}, + 'policy-drift':f=>{f.record.policy.digest='old';}, + 'toolchain-drift':f=>{f.record.policy.toolchain.playwright='old';}, + 'environment-missing':f=>{delete f.bundle.environment;}, + 'environment-focused':f=>{f.bundle.environment.mode='retained-local';}, + 'environment-child-failed':f=>{f.bundle.environment.childExit=2;}, + 'environment-exit-failed':f=>{f.bundle.environment.exitCode=2;}, + 'environment-source-mismatch':f=>{f.bundle.environment.sourceBefore.digest='1'.repeat(64);}, + 'environment-self-claimed':f=>{delete f.bundle.environment.environment.namespace;}, + 'native-offline':f=>{f.bundle.environment.environment.nativeOnlineProbe.online=false;}, + 'webkit-version-drift':f=>{f.bundle.environment.environment.nativeOnlineProbe.engines[1].version='old';}, + 'webkit-native-offline':f=>{f.bundle.environment.environment.nativeOnlineProbe.engines[1].online=false;}, + 'guest-refresh':f=>{f.bundle.environment.environment.bootstrapObservation.refreshRequestCount=1;}, + 'guest-mock':f=>{f.bundle.environment.environment.bootstrapObservation.noRouteMocks=false;}, + 'runner-digest-drift':f=>{f.bundle.environment.environment.runnerDigest='old';}, + 'cleanup-missing':f=>{f.bundle.environment.cleanup.containerRemoved=false;}, + 'discovery-truncated':f=>{f.bundle.discovery.suites.pop();}, + 'plan-truncated':f=>{f.bundle.plan.localCases.pop();}, + 'execution-missing':f=>{f.bundle.execution.suites.pop();}, + 'execution-duplicate':f=>{f.bundle.execution.suites.push(f.bundle.execution.suites[0]);}, + 'execution-unknown':f=>{f.bundle.execution.suites[0].specs[0].id='unknown';}, + 'execution-skipped':f=>{f.bundle.execution.suites[0].specs[0].tests[0].results[0].status='skipped';}, + 'execution-retried':f=>{f.bundle.execution.suites[0].specs[0].tests[0].results[0].retry=1;}, + 'execution-xfail':f=>{f.bundle.execution.suites[0].specs[0].tests[0].expectedStatus='failed';}, + 'execution-global-error':f=>{f.bundle.execution.errors.push({message:'synthetic'});}, + 'selection-grep':f=>{f.bundle.selection.additionalArgs=['--grep=one'];}, + 'selection-missing':f=>{delete f.bundle.selection;}, + 'config-missing':f=>{delete f.bundle.execution.config;}, + 'projects-missing':f=>{delete f.bundle.execution.config.projects;}, + 'only-allowed':f=>{f.bundle.execution.config.forbidOnly=false;}, + 'sharded':f=>{f.bundle.execution.config.shard={current:1,total:2};}, + 'serialized':f=>{f.bundle.execution.config.workers=1;}, + 'budget-increased':f=>{f.bundle.execution.config.projects[0].timeout=360000;}, + 'stats-false-pass':f=>{f.bundle.execution.stats.expected=11;}, + 'browser-time-mismatch':f=>{f.bundle.execution.stats.startTime=time(0);}, + 'acceptance-missing':f=>{f.facts.manifest.acceptanceSpecs.push('client/tests/e2e/absent.spec.ts');}, + 'witness-missing':f=>{f.bundle.execution.suites[0].specs[0].title='missing';}, +}; +for(const [name,mutate] of Object.entries(mutations))test(`拒绝伪通过:${name}`,context=>{ + const f=fixture(context);mutate(f);const r=verifyReleaseEvidence(f.record,f.bundle,f.facts);assert.equal(r.deploymentEligible,false);assert.ok(r.issues.length); +}); + +test('阶段记录保留真实失败、未跑阶段与清理失败,不吞早期错误',()=>{ + const r=newStageRecord(['a','b'],'fixture');assert.throws(()=>updateStageRecord(r,'start','b')); + updateStageRecord(r,'start','a');updateStageRecord(r,'finish',null,23); + assert.equal(r.status,'FAIL');assert.equal(r.exitCode,23);assert.equal(r.stages[0].exitCode,23);assert.equal(r.stages[1].status,'NOT_RUN');assert.throws(()=>updateStageRecord(r,'start','b')); + const complete=newStageRecord(['a'],'fixture');updateStageRecord(complete,'start','a');updateStageRecord(complete,'end','a',0);updateStageRecord(complete,'finish',null,31);assert.ok(validateStages(complete,['a'],'fixture').length); +}); + +test('skip 精确值和批准记录,仅诊断 PARTIAL,普通发布前入口拒绝',()=>{ + assert.equal(validateSkipRequest('verify-candidate',{}),null); + for(const value of ['true','yes','01','', '2'])assert.throws(()=>validateSkipRequest('project-wide',{PROJECT_WIDE_SKIP_VISUAL:value})); + const env={PROJECT_WIDE_SKIP_VISUAL:'1',PROJECT_WIDE_SKIP_REASON:'synthetic',PROJECT_WIDE_SKIP_OPERATOR:'fixture',PROJECT_WIDE_SKIP_APPROVAL_REF:'fixture-only'}; + assert.throws(()=>validateSkipRequest('verify-candidate',env)); + for(const key of ['PROJECT_WIDE_SKIP_REASON','PROJECT_WIDE_SKIP_OPERATOR','PROJECT_WIDE_SKIP_APPROVAL_REF'])assert.throws(()=>validateSkipRequest('project-wide',{...env,[key]:''})); + const r=validateSkipRequest('project-wide',env);assert.equal(r.status,'PARTIAL');assert.equal(r.deploymentEligible,false);assert.deepEqual(r.affectedStages,['project-wide-visual']); +}); + +test('回执路径拒绝越界、现存或断裂符号链接',context=>{ + const f=fixture(context); + for(const input of ['../outside','/absolute','a/../b','a//b','a\\b'])assert.throws(()=>confinedPath(f.root,input)); + symlinkSync('/nonexistent-synthetic-target',path.join(f.root,'link')); + assert.throws(()=>confinedPath(f.root,'link')); + symlinkSync(f.root,path.join(f.root,'directory')); + assert.throws(()=>confinedPath(f.root,'directory/file')); +}); + +test('原有未跟踪部署文件内容变化,即使 Git status 相同也使来源变化',context=>{ + const f=fixture(context); + for(const args of [['init','-q'],['add','.'],['-c','user.name=QA Fixture','-c','user.email=qa@example.invalid','commit','-qm','synthetic fixture']])assert.equal(spawnSync('git',args,{cwd:f.root}).status,0); + mkdirSync(path.join(f.root,'deploy'));writeFileSync(path.join(f.root,'deploy/new.sh'),'first'); + const before=captureReleaseSource(f.root);writeFileSync(path.join(f.root,'deploy/new.sh'),'second');const after=captureReleaseSource(f.root); + assert.equal(before.repository.porcelainV1Z,after.repository.porcelainV1Z);assert.notEqual(before.untrackedDigest,after.untrackedDigest); +}); diff --git a/scripts/qa/tests/release-gate-shell.test.mjs b/scripts/qa/tests/release-gate-shell.test.mjs new file mode 100644 index 00000000..35b3ee05 --- /dev/null +++ b/scripts/qa/tests/release-gate-shell.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const projectRoot=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../../..'); +const receiptFiles={environment:'environment/result.json',phases:'results/full-local-stages.json',discovery:'results/discovery.json',plan:'results/coverage-plan.json',execution:'results/playwright-results.json',selection:'results/execution-selection.json'}; +function fixture(context,overrides={}) { + const root=mkdtempSync(path.join(os.tmpdir(),'hyb-release-shell-')); + context.after(()=>rmSync(root,{recursive:true,force:true})); + for(const dir of ['scripts/qa','deploy/trialrun','client/tests/e2e','server','bin','client/node_modules/@playwright/test','client/node_modules/playwright-core'])mkdirSync(path.join(root,dir),{recursive:true}); + for(const file of readdirSync(path.join(projectRoot,'scripts/qa')).filter(file=>file.endsWith('.mjs')||file.endsWith('.sh')||file.endsWith('.py')))copyFileSync(path.join(projectRoot,'scripts/qa',file),path.join(root,'scripts/qa',file)); + const rows=Array.from({length:10},(_,i)=>({file:`client/tests/e2e/flow-${i}.spec.ts`,id:`flow-${i}`,title:`flow-${i} @local-candidate:flow-${i}`,line:1})); + rows.push({file:'client/tests/e2e/ordinary.spec.ts',id:'ordinary',title:'ordinary',line:1}); + for(const row of rows)writeFileSync(path.join(root,row.file),'synthetic'); + const manifest={localCandidateFlows:rows.slice(0,10).map((row,i)=>({bundle:`flow-${i}`,witnessTag:`@local-candidate:flow-${i}`,witnessSpec:row.file})),acceptanceSpecs:rows.map(row=>row.file)}; + const contract={schemaVersion:1,approvalReference:'synthetic',exceptions:[]}; + writeFileSync(path.join(root,'scripts/qa/route-manifest.json'),JSON.stringify(manifest)); + writeFileSync(path.join(root,'scripts/qa/full-local-coverage-contract.json'),JSON.stringify(contract)); + for(const file of ['client/package.json','client/package-lock.json','server/package.json','server/package-lock.json','client/node_modules/playwright-core/browsers.json'])writeFileSync(path.join(root,file),'{}'); + writeFileSync(path.join(root,'client/node_modules/playwright-core/browsers.json'),JSON.stringify({browsers:['chromium','webkit'].map(name=>({name,browserVersion:'synthetic'}))})); + writeFileSync(path.join(root,'client/node_modules/@playwright/test/package.json'),JSON.stringify({version:'1.58.2'})); + writeFileSync(path.join(root,'client/playwright.config.ts'),'// synthetic policy'); + writeFileSync(path.join(root,'deploy/trialrun/validate-env-contract.js'),'// fake tooling owns this command'); + writeFileSync(path.join(root,'.gitignore'),'artifacts/\nbin/\nnode_modules/\n'); + const env={PATH:`${path.join(root,'bin')}:${process.env.PATH}`,LANG:'C.UTF-8',HYB_RELEASE_FIXTURE_ROOT:root,RELEASE_GATE_ARTIFACT_DIR:path.join(root,'artifacts/release-gate/run'),...overrides}; + const fake=`#!${process.execPath} +const fs=require('node:fs'),path=require('node:path'),{spawnSync}=require('node:child_process'); +const root=process.env.HYB_RELEASE_FIXTURE_ROOT,args=process.argv.slice(2),name=path.basename(process.argv[1]); +fs.mkdirSync(path.join(root,'artifacts'),{recursive:true});fs.appendFileSync(path.join(root,'artifacts/commands.jsonl'),JSON.stringify({name,args})+'\\n'); +if(name==='node' && args[0]?.endsWith('release-gate-result.mjs')){const result=spawnSync(process.execPath,args,{env:process.env,stdio:'inherit'});process.exit(result.status??1);} +if(name==='node' && args[0]?.endsWith('validate-env-contract.js') && process.env.FAIL_STATIC)process.exit(23); +if(name==='bash' && args[0]?.endsWith('run-web-gates.sh') && args[1]==='full-local'){if(process.env.OMIT_EVIDENCE)process.exit(0);const result=spawnSync(process.execPath,[path.join(root,'bin/evidence.mjs')],{env:process.env,stdio:'inherit'});process.exit(result.status??1);} +if(name==='bash' && args[0]?.includes('deploy/trialrun'))process.exit(91); +if(!['node','npm','bash','python3'].includes(name))throw Error('unexpected command'); +`; + writeFileSync(path.join(root,'bin/fake'),fake,{mode:0o755}); + for(const name of ['node','npm','bash','python3'])symlinkSync('fake',path.join(root,'bin',name)); + writeFileSync(path.join(root,'bin/evidence.mjs'),` +import {mkdirSync,writeFileSync,readFileSync} from 'node:fs'; +import path from 'node:path'; +import {captureReleaseSource,currentPolicy} from '../scripts/qa/release-gate-result.mjs'; +import {FULL_LOCAL_STAGES,newStageRecord,updateStageRecord} from '../scripts/qa/qa-stage-record.mjs'; +import {coveragePlan,localSpecArgs} from '../scripts/qa/full-local-coverage.mjs'; +const root=process.env.HYB_RELEASE_FIXTURE_ROOT,base=process.env.QA_FULL_REPORT_DIR,now=()=>new Date().toISOString(); +const source=captureReleaseSource(root),policy=currentPolicy(root),startedAt=now(); +const phases=newStageRecord(FULL_LOCAL_STAGES,'full-local-stages'); +const rows=${JSON.stringify(rows)},contract=${JSON.stringify(contract)}; +const report=executed=>({errors:[],suites:rows.map(row=>({file:row.file,specs:[{...row,tests:[{projectName:'',repeatEachIndex:0,...(executed?{expectedStatus:'passed',status:'expected',results:[{status:'passed',retry:0}]}:{})}]}]}))}); +const discovery=report(false),execution=report(true); +execution.config={forbidOnly:true,projects:[{name:'',retries:0,repeatEach:1,timeout:180000,testIgnore:['**/manual/**']}],shard:null,maxFailures:0,workers:policy.toolchain.workers,version:policy.toolchain.playwright}; +for(const stage of FULL_LOCAL_STAGES){updateStageRecord(phases,'start',stage);if(stage==='browser')execution.stats={startTime:now(),duration:0,expected:rows.length,unexpected:0,flaky:0,skipped:0};updateStageRecord(phases,'end',stage,0);}updateStageRecord(phases,'finish',null,0); +const environment={schemaVersion:1,profile:'isolated-online-v1',mode:'full-local',policyDigest:policy.environmentDigest,platform:process.platform,status:'PASS',exitCode:0,childExit:0,childSignal:null,interrupted:null,startedAt,endedAt:now(),sourceBefore:source,sourceAfter:source,sourceUnchanged:true,repositoryUnchanged:true,candidateIntegrityStatus:'UNCHANGED',cleanup:{containerOwnerVerified:true,containerRemoved:true,scratchRemoved:true},environment:{schemaVersion:1,profile:'isolated-online-v1',policyDigest:policy.environmentDigest,platform:process.platform,runnerDigest:policy.runnerDigest,probeDigest:policy.probeDigest,supervisorObservation:{source:'live-supervisor-unix-ipc',sampledAt:now(),namespace:'parent'},namespace:{inheritedRunner:'parent',ownedChild:'child',runnerCreated:true,currentMatchesOwnedChild:true,topologyMatched:true},nativeOnlineProbe:{valid:true,online:true,browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,engines:['chromium','webkit'].map(name=>({name,version:'synthetic',online:true,valid:true,noNavigatorOverride:true,browserNamespace:'child',browserMatchesOwnedChild:true}))},bootstrapObservation:{valid:true,observationPoint:'guest-entry-black-box',freshContext:true,initialSessionHint:false,listenerBeforeNavigation:true,noRouteMocks:true,loginFormVisible:true,refreshRequestCount:0,loginRequestCount:0,nativeOnlineSamples:{beforeNavigation:true,documentStart:true,afterForm:true},browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,documentStatus:200,stayedOnLogin:true}}}; +const bundle={environment,phases,discovery,execution,plan:coveragePlan(root,discovery,contract,source)}; +bundle.selection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(bundle.plan),additionalArgs:[],retries:0,forbidOnly:true,workers:policy.toolchain.workers,mode:'local'}; +if(process.env.BAD_PROFILE)environment.mode='retained-local'; +if(process.env.BAD_SOURCE)environment.sourceBefore={...source,digest:'0'.repeat(64)}; +if(process.env.BAD_COVERAGE)execution.suites.pop(); +if(process.env.BAD_CLEANUP)environment.cleanup.containerRemoved=false; +if(process.env.BAD_STAGE)phases.stages.splice(2,1); +for(const [key,relative] of Object.entries(${JSON.stringify(receiptFiles)})){const file=path.join(base,relative);mkdirSync(path.dirname(file),{recursive:true});writeFileSync(file,JSON.stringify(bundle[key]));} +if(process.env.FAIL_FULL)process.exit(17); +`); + for(const args of [['init','-q'],['add','.'],['-c','user.name=QA Fixture','-c','user.email=qa@example.invalid','commit','-qm','synthetic fixture']])assert.equal(spawnSync('git',args,{cwd:root}).status,0); + return {root,env,run(mode='verify-candidate') { + rmSync(path.join(root,'artifacts'),{recursive:true,force:true}); + const result=spawnSync('/bin/bash',[path.join(root,'scripts/qa/run-release-gate.sh'),mode],{cwd:root,env,encoding:'utf8',timeout:30000}); + const file=path.join(env.RELEASE_GATE_ARTIFACT_DIR,'release-result.json'); + return {...result,record:existsSync(file)?JSON.parse(readFileSync(file)):null,commands:existsSync(path.join(root,'artifacts/commands.jsonl'))?readFileSync(path.join(root,'artifacts/commands.jsonl'),'utf8').trim().split('\n').map(JSON.parse):[]}; + },verify() {return spawnSync(process.execPath,[path.join(root,'scripts/qa/release-gate-result.mjs'),'verify',env.RELEASE_GATE_ARTIFACT_DIR],{cwd:root,env,encoding:'utf8',timeout:30000});}}; +} +function noDeploy(result) {assert.ok(!result.commands.some(c=>c.args.some(arg=>arg.includes('deploy/trialrun/deploy'))));} + +test('真实 shell 复用 static/full-local、回执离线重验、部署命令零调用',context=>{ + const f=fixture(context),r=f.run();assert.equal(r.status,0,r.stdout+r.stderr);assert.equal(r.record.status,'PASS');assert.equal(r.record.verification.localChecksPassed,true);assert.deepEqual(r.record.phases.stages.map(s=>s.id),['static','full-local']);noDeploy(r);assert.equal(f.verify().status,0); +}); +for(const flag of ['FAIL_STATIC','FAIL_FULL','OMIT_EVIDENCE','BAD_COVERAGE','BAD_CLEANUP','BAD_STAGE'])test(`shell 假工具退出零也不能绕过:${flag}`,context=>{ + const f=fixture(context,{[flag]:'1'}),r=f.run();assert.notEqual(r.status,0,r.stdout+r.stderr);assert.equal(r.record.deploymentEligible,false);noDeploy(r); + if(flag==='FAIL_STATIC'){assert.equal(r.status,23);assert.equal(r.record.phases.stages[0].exitCode,23);assert.equal(r.record.phases.stages[1].status,'NOT_RUN');assert.ok(!r.commands.some(c=>c.name==='bash'));} + if(flag==='FAIL_FULL')assert.equal(r.status,17); +}); +test('dirty 本地树执行完整阶段但 PARTIAL/退出2',context=>{ + const f=fixture(context);writeFileSync(path.join(f.root,'deploy/untracked.sh'),'synthetic');const r=f.run();assert.equal(r.status,2,r.stdout+r.stderr);assert.equal(r.record.verification.localChecksPassed,true);assert.equal(r.record.status,'PARTIAL');assert.equal(f.verify().status,2);noDeploy(r); +}); +test('实际回执拒绝文件摘要篡改、截断、删除和链接',context=>{ + const f=fixture(context),r=f.run();assert.equal(r.status,0,r.stdout+r.stderr); + const file=path.join(f.root,r.record.fullLocalDirectory,'results/playwright-results.json'),raw=readFileSync(file); + for(const mutate of [()=>writeFileSync(file,'{'),()=>writeFileSync(file,raw.toString()+' '),()=>rmSync(file),()=>{rmSync(file);symlinkSync('/outside-synthetic',file); }]){ + writeFileSync(file,raw);mutate();assert.notEqual(f.verify().status,0);rmSync(file,{force:true}); + } +}); +for(const flags of [{PROJECT_WIDE_SKIP_VISUAL:'1'},{PROJECT_WIDE_SKIP_BUSINESS_FLOW:'1'},{PROJECT_WIDE_SKIP_VISUAL:'1',PROJECT_WIDE_SKIP_BUSINESS_FLOW:'1'}])test(`跳过有批准元数据时只限诊断 PARTIAL/2:${JSON.stringify(flags)}`,context=>{ + const f=fixture(context,{...flags,PROJECT_WIDE_SKIP_REASON:'synthetic',PROJECT_WIDE_SKIP_OPERATOR:'fixture',PROJECT_WIDE_SKIP_APPROVAL_REF:'fixture-only'}); + const rejected=f.run();assert.notEqual(rejected.status,0);assert.ok(!rejected.commands.some(c=>c.name==='npm'||c.name==='bash'));noDeploy(rejected); + const r=f.run('project-wide');assert.equal(r.status,2,r.stdout+r.stderr);assert.doesNotMatch(r.stdout,/\[PASS\]/);noDeploy(r); + const receipt=JSON.parse(readFileSync(path.join(f.env.RELEASE_GATE_ARTIFACT_DIR,'skip-receipt.json')));assert.equal(receipt.status,'PARTIAL');assert.equal(receipt.deploymentEligible,false); +}); +test('删掉真实 skip 前置校验会使反例失败',context=>{ + const f=fixture(context,{PROJECT_WIDE_SKIP_VISUAL:'yes'}); + const blocked=r=>{assert.notEqual(r.status,0);assert.ok(!r.commands.some(c=>c.name==='npm'));};blocked(f.run('project-wide')); + const file=path.join(f.root,'scripts/qa/run-release-gate.sh'),original=readFileSync(file,'utf8'),mutated=original.replace(' node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" skip "$ARTIFACT_ROOT" "$MODE"',' : # removed skip validation');assert.notEqual(original,mutated);writeFileSync(file,mutated); + assert.throws(()=>blocked(f.run('project-wide')),assert.AssertionError); +}); +test('删除最终回执消费会使缺证据反例失败',context=>{ + const f=fixture(context,{OMIT_EVIDENCE:'1'});const blocked=r=>assert.notEqual(r.status,0);blocked(f.run()); + const file=path.join(f.root,'scripts/qa/run-release-gate.sh'),original=readFileSync(file,'utf8'),mutated=original.replace(' node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" finish "$ARTIFACT_ROOT" "$original_status" || receipt_status=$?',' receipt_status=0 # removed verification');assert.notEqual(original,mutated);writeFileSync(file,mutated); + assert.throws(()=>blocked(f.run()),assert.AssertionError); +}); +test('放宽真实阶段验证使缺阶段反例失败',context=>{ + const f=fixture(context,{BAD_STAGE:'1'});const blocked=r=>assert.notEqual(r.status,0);blocked(f.run()); + const file=path.join(f.root,'scripts/qa/qa-stage-record.mjs'),original=readFileSync(file,'utf8'),mutated=original.replace('export function validateStages(record, expected, profile) {','export function validateStages(record, expected, profile) { return [];');assert.notEqual(original,mutated);writeFileSync(file,mutated); + const r=f.run();assert.equal(r.status,2,r.stdout+r.stderr); // mutation makes source dirty, still no release eligibility + assert.equal(r.record.verification.localChecksPassed,true); +}); + +for(const [flag,from,to] of [ + ['BAD_PROFILE', "env?.mode!=='full-local' || ", ''], + ['BAD_SOURCE', "issues.push('managed_source_mismatch');", "; // removed source validation"], + ['BAD_COVERAGE', 'issues.push(...coverage.issues);', '; // removed coverage validation'], +])test(`移除实际 profile/source/coverage 保护使反例失效:${flag}`,context=>{ + const f=fixture(context,{[flag]:'1'}),first=f.run(); + assert.notEqual(first.status,0);assert.equal(first.record.verification.localChecksPassed,false);noDeploy(first); + const file=path.join(f.root,'scripts/qa/release-gate-result.mjs'),original=readFileSync(file,'utf8'),mutated=original.replace(from,to); + assert.notEqual(original,mutated);writeFileSync(file,mutated); + const result=f.run();assert.equal(result.status,2,result.stdout+result.stderr); + assert.equal(result.record.verification.localChecksPassed,true);noDeploy(result); +}); + +test('候选路径越界在建目录和静态执行前拒绝',context=>{ + const f=fixture(context);f.env.RELEASE_GATE_ARTIFACT_DIR=path.join(f.root,'unapproved-directory'); + const r=f.run();assert.notEqual(r.status,0);assert.equal(existsSync(f.env.RELEASE_GATE_ARTIFACT_DIR),false);assert.ok(!r.commands.some(c=>c.name==='npm'));noDeploy(r); +}); diff --git a/scripts/qa/tests/workflow-payment-profile.test.mjs b/scripts/qa/tests/workflow-payment-profile.test.mjs new file mode 100644 index 00000000..82e3945a --- /dev/null +++ b/scripts/qa/tests/workflow-payment-profile.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const source = readFileSync(path.join(root, 'server/scripts/regression-workflow-actions.sh'), 'utf8'); +function checkResponse(context, code, details, candidate = source) { + const directory = mkdtempSync(path.join(os.tmpdir(), 'hyb-payment-profile-')); + context.after(() => rmSync(directory, { recursive: true, force: true })); + writeFileSync(path.join(directory, 'pay-ok-pay.json'), JSON.stringify({ error: { code: 'VALID_001', details } })); + // Execute the candidate's real shell assertions. No app, database, or HTTP tools are started. + const helpers = ['assert_status', 'assert_error_code', 'assert_error_detail'].map(name => { + const start = candidate.indexOf(name + '() {'); + assert.ok(start >= 0); + const end = candidate.indexOf('\n}\n', start); + assert.ok(end > start); + return candidate.slice(start, end + 3); + }).join('\n'); + const start = candidate.indexOf('if [[', candidate.indexOf('\npay_ok_status=')); + const end = candidate.indexOf(' echo "[19/23] batch-required profile:', start); + assert.ok(start >= 0 && end > start); + const script = 'set -euo pipefail\n' + helpers + '\n' + candidate.slice(start, end) + '\nfi\n'; + return spawnSync('bash', ['-c', script], { + env: { PATH: process.env.PATH, TMP_DIR: directory, PAYMENT_BATCH_ENABLED: 'true', pay_ok_status: String(code) }, + encoding: 'utf8', timeout: 10000, + }); +} + +test('configured batch mode accepts only the exact batch-required refusal', context => { + const valid = { payment: 'payment_batch_v1_required', reasonCode: 'reimbursement.payment_payment_batch_v1_required' }; + assert.equal(checkResponse(context, 409, valid).status, 0); + for (const code of [200, 400, 403, 500]) assert.notEqual(checkResponse(context, code, valid).status, 0); + assert.notEqual(checkResponse(context, 409, { payment: 'some_other_reason' }).status, 0); +}); + +test('choosing the branch from the observed response is detected as a false-green mutation', context => { + const before = 'if [[ "${PAYMENT_BATCH_ENABLED:-false}" == "true" ]]; then'; + assert.ok(source.includes(before)); + const mutant = source.replace(before, 'if [[ "$pay_ok_status" == "409" ]]; then'); + const verify = candidate => assert.notEqual(checkResponse(context, 200, {}, candidate).status, 0); + verify(source); + assert.throws(() => verify(mutant), assert.AssertionError); +}); diff --git a/server/prisma/migrations/20260917010000_company_loan_principal/migration.sql b/server/prisma/migrations/20260917010000_company_loan_principal/migration.sql new file mode 100644 index 00000000..af7e0380 --- /dev/null +++ b/server/prisma/migrations/20260917010000_company_loan_principal/migration.sql @@ -0,0 +1,339 @@ +-- AlterEnum +ALTER TYPE "PaymentBatchEntityType" ADD VALUE 'loan_repayment'; + +-- AlterTable +ALTER TABLE "payment_batches" ADD COLUMN "loan_repayment_id" UUID; + +-- CreateTable +CREATE TABLE "loan_lenders" ( + "id" UUID NOT NULL, + "legal_entity_id" UUID NOT NULL, + "name" VARCHAR(120) NOT NULL, + "employee_id" UUID, + "identity_status" VARCHAR(16) NOT NULL DEFAULT 'unverified', + "identity_version" INTEGER NOT NULL DEFAULT 1, + "account_version" INTEGER NOT NULL DEFAULT 1, + "identity_confirmed_by" UUID, + "identity_confirmed_at" TIMESTAMP(3), + "identity_confirmation" TEXT, + "bank_institution_id" UUID NOT NULL, + "bank_name" VARCHAR(200) NOT NULL, + "bank_account_encrypted" TEXT NOT NULL, + "account_name_encrypted" TEXT NOT NULL, + "archived_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL, + "updated_by" UUID NOT NULL, + "request_key" UUID NOT NULL, + + CONSTRAINT "loan_lenders_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "company_loans" ( + "id" UUID NOT NULL, + "loan_no" VARCHAR(64) NOT NULL, + "origin_reference" VARCHAR(200) NOT NULL, + "legal_entity_id" UUID NOT NULL, + "lender_id" UUID NOT NULL, + "currency_code" VARCHAR(8) NOT NULL DEFAULT 'CNY', + "original_principal" DECIMAL(14,2) NOT NULL, + "opening_outstanding_principal" DECIMAL(14,2) NOT NULL, + "received_date" DATE NOT NULL, + "balance_as_of" DATE NOT NULL, + "historical_entry" BOOLEAN NOT NULL, + "confirmation_status" VARCHAR(16) NOT NULL DEFAULT 'unconfirmed', + "version" INTEGER NOT NULL DEFAULT 1, + "confirmed_by" UUID, + "confirmed_at" TIMESTAMP(3), + "accounting_confirmation_ref" VARCHAR(200), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL, + "updated_by" UUID NOT NULL, + "request_key" UUID NOT NULL, + + CONSTRAINT "company_loans_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "loan_repayments" ( + "id" UUID NOT NULL, + "repayment_no" VARCHAR(64) NOT NULL, + "legal_entity_id" UUID NOT NULL, + "lender_id" UUID NOT NULL, + "currency_code" VARCHAR(8) NOT NULL DEFAULT 'CNY', + "reason" VARCHAR(500) NOT NULL, + "amount" DECIMAL(14,2) NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "submitted_by" UUID NOT NULL, + "submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lender_identity_version" INTEGER NOT NULL, + "lender_account_version" INTEGER NOT NULL, + "payee_snapshot_json" JSONB NOT NULL, + "bank_result_state" VARCHAR(24) NOT NULL DEFAULT 'not_sent', + "bank_result_ref" VARCHAR(500), + "accounting_handoff_by" UUID, + "accounting_handoff_at" TIMESTAMP(3), + "accounting_handoff_ref" VARCHAR(200), + "request_key" UUID NOT NULL, + + CONSTRAINT "loan_repayments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "loan_repayment_allocations" ( + "id" UUID NOT NULL, + "repayment_id" UUID NOT NULL, + "loan_id" UUID NOT NULL, + "amount" DECIMAL(14,2) NOT NULL, + "loan_version" INTEGER NOT NULL, + "reservation_state" VARCHAR(16) NOT NULL DEFAULT 'reserved', + "settled_at" TIMESTAMP(3), + "released_at" TIMESTAMP(3), + "release_reason" VARCHAR(500), + + CONSTRAINT "loan_repayment_allocations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "loan_evidences" ( + "id" UUID NOT NULL, + "loan_id" UUID NOT NULL, + "kind" VARCHAR(32) NOT NULL, + "original_name" VARCHAR(255) NOT NULL, + "storage_path" TEXT NOT NULL, + "mime_type" VARCHAR(100) NOT NULL, + "file_size" INTEGER NOT NULL, + "file_sha256" VARCHAR(64) NOT NULL, + "governance_status" VARCHAR(16) NOT NULL DEFAULT 'pending', + "uploaded_by" UUID NOT NULL, + "uploaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "archived_at" TIMESTAMP(3), + "archive_reason" VARCHAR(500), + + CONSTRAINT "loan_evidences_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "loan_lenders_employee_id_idx" ON "loan_lenders"("employee_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "loan_lenders_legal_entity_id_id_key" ON "loan_lenders"("legal_entity_id", "id"); + +-- CreateIndex +CREATE UNIQUE INDEX "loan_lenders_created_by_request_key_key" ON "loan_lenders"("created_by", "request_key"); + +-- CreateIndex +CREATE UNIQUE INDEX "company_loans_loan_no_key" ON "company_loans"("loan_no"); + +-- CreateIndex +CREATE INDEX "company_loans_lender_id_confirmation_status_received_date_idx" ON "company_loans"("lender_id", "confirmation_status", "received_date"); + +-- CreateIndex +CREATE UNIQUE INDEX "company_loans_created_by_request_key_key" ON "company_loans"("created_by", "request_key"); + +-- CreateIndex +CREATE UNIQUE INDEX "loan_repayments_repayment_no_key" ON "loan_repayments"("repayment_no"); + +-- CreateIndex +CREATE INDEX "loan_repayments_lender_id_submitted_at_idx" ON "loan_repayments"("lender_id", "submitted_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "loan_repayments_submitted_by_request_key_key" ON "loan_repayments"("submitted_by", "request_key"); + +-- CreateIndex +CREATE INDEX "loan_repayment_allocations_loan_id_reservation_state_idx" ON "loan_repayment_allocations"("loan_id", "reservation_state"); + +-- CreateIndex +CREATE UNIQUE INDEX "loan_repayment_allocations_repayment_id_loan_id_key" ON "loan_repayment_allocations"("repayment_id", "loan_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "loan_evidences_loan_id_file_sha256_kind_key" ON "loan_evidences"("loan_id", "file_sha256", "kind"); + +-- AddForeignKey +ALTER TABLE "payment_batches" ADD CONSTRAINT "payment_batches_loan_repayment_id_fkey" FOREIGN KEY ("loan_repayment_id") REFERENCES "loan_repayments"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "loan_lenders" ADD CONSTRAINT "loan_lenders_legal_entity_id_fkey" FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "loan_lenders" ADD CONSTRAINT "loan_lenders_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "loan_lenders" ADD CONSTRAINT "loan_lenders_bank_institution_id_fkey" FOREIGN KEY ("bank_institution_id") REFERENCES "bank_institutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "company_loans" ADD CONSTRAINT "company_loans_legal_entity_id_lender_id_fkey" FOREIGN KEY ("legal_entity_id", "lender_id") REFERENCES "loan_lenders"("legal_entity_id", "id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "loan_repayments" ADD CONSTRAINT "loan_repayments_legal_entity_id_lender_id_fkey" FOREIGN KEY ("legal_entity_id", "lender_id") REFERENCES "loan_lenders"("legal_entity_id", "id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "loan_repayment_allocations" ADD CONSTRAINT "loan_repayment_allocations_repayment_id_fkey" FOREIGN KEY ("repayment_id") REFERENCES "loan_repayments"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "loan_repayment_allocations" ADD CONSTRAINT "loan_repayment_allocations_loan_id_fkey" FOREIGN KEY ("loan_id") REFERENCES "company_loans"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "loan_evidences" ADD CONSTRAINT "loan_evidences_loan_id_fkey" FOREIGN KEY ("loan_id") REFERENCES "company_loans"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "loan_evidences" ADD CONSTRAINT "loan_evidences_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + + +-- A historical incoming reference may be registered only once for this lender. +CREATE UNIQUE INDEX "company_loans_legal_entity_id_lender_id_origin_reference_key" + ON company_loans(legal_entity_id, lender_id, origin_reference); + +ALTER TABLE loan_lenders ADD CONSTRAINT loan_lenders_identity_check CHECK ( + identity_status IN ('unverified','verified') AND identity_version > 0 AND account_version > 0 + AND (identity_status <> 'verified' OR (identity_confirmed_by IS NOT NULL + AND identity_confirmed_at IS NOT NULL AND NULLIF(trim(identity_confirmation),'') IS NOT NULL)) +); +ALTER TABLE company_loans ADD CONSTRAINT company_loans_balance_check CHECK ( + original_principal > 0 AND opening_outstanding_principal >= 0 + AND opening_outstanding_principal <= original_principal AND currency_code = 'CNY' + AND received_date <= balance_as_of AND version > 0 + AND confirmation_status IN ('unconfirmed','confirmed') + AND (confirmation_status <> 'confirmed' OR (confirmed_by IS NOT NULL AND confirmed_at IS NOT NULL + AND NULLIF(trim(accounting_confirmation_ref),'') IS NOT NULL)) +); +ALTER TABLE loan_repayments ADD CONSTRAINT loan_repayments_amount_state_check CHECK ( + amount > 0 AND currency_code = 'CNY' AND version > 0 + AND lender_identity_version > 0 AND lender_account_version > 0 + AND bank_result_state IN ('not_sent','unknown','confirmed_failed','confirmed_success') + AND (bank_result_state NOT IN ('confirmed_failed','confirmed_success') OR NULLIF(trim(bank_result_ref),'') IS NOT NULL) + AND ((accounting_handoff_by IS NULL AND accounting_handoff_at IS NULL AND accounting_handoff_ref IS NULL) + OR (accounting_handoff_by IS NOT NULL AND accounting_handoff_at IS NOT NULL AND NULLIF(trim(accounting_handoff_ref),'') IS NOT NULL)) +); +ALTER TABLE loan_repayment_allocations ADD CONSTRAINT loan_allocation_amount_state_check CHECK ( + amount > 0 AND loan_version > 0 AND reservation_state IN ('reserved','settled','released') + AND (reservation_state <> 'settled' OR settled_at IS NOT NULL) + AND (reservation_state <> 'released' OR (released_at IS NOT NULL AND NULLIF(trim(release_reason),'') IS NOT NULL)) +); +ALTER TABLE loan_evidences ADD CONSTRAINT loan_evidence_kind_check CHECK ( + kind IN ('agreement','incoming','accounting_confirmation','adjustment') AND file_size > 0 + AND governance_status IN ('pending','approved','blocked') AND file_sha256 ~ '^[0-9a-f]{64}$' +); +ALTER TABLE payment_batches ADD CONSTRAINT payment_batch_loan_source_check CHECK ( + (entity_type::text = 'loan_repayment') = (loan_repayment_id IS NOT NULL) +); +CREATE UNIQUE INDEX payment_batch_one_active_loan_repayment ON payment_batches(loan_repayment_id) + WHERE loan_repayment_id IS NOT NULL AND status <> 'cancelled'; + +-- Confirmed opening balances are immutable; correcting history needs a separately +-- reviewed adjustment, not an UPDATE that silently erases old loan evidence. +CREATE FUNCTION guard_confirmed_loan_balance() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.confirmation_status = 'confirmed' AND ( + NEW.legal_entity_id, NEW.lender_id, NEW.currency_code, NEW.original_principal, + NEW.opening_outstanding_principal, NEW.received_date, NEW.balance_as_of, + NEW.historical_entry, NEW.origin_reference, NEW.confirmation_status, + NEW.confirmed_by, NEW.confirmed_at, NEW.accounting_confirmation_ref + ) IS DISTINCT FROM ( + OLD.legal_entity_id, OLD.lender_id, OLD.currency_code, OLD.original_principal, + OLD.opening_outstanding_principal, OLD.received_date, OLD.balance_as_of, + OLD.historical_entry, OLD.origin_reference, OLD.confirmation_status, + OLD.confirmed_by, OLD.confirmed_at, OLD.accounting_confirmation_ref + ) THEN RAISE EXCEPTION 'confirmed loan balance is immutable' USING ERRCODE='23514'; END IF; + RETURN NEW; +END $$; +CREATE TRIGGER company_loan_confirmed_immutable BEFORE UPDATE ON company_loans + FOR EACH ROW EXECUTE FUNCTION guard_confirmed_loan_balance(); + +-- Deferred checks validate the complete atomic repayment/batch/allocation write. +-- Writers still follow the application lock order; the loan-row locks below also +-- serialize aggregate validation across concurrent transactions. +CREATE FUNCTION assert_loan_repayment_integrity(repayment_uuid uuid) RETURNS void LANGUAGE plpgsql AS $$ +DECLARE r loan_repayments%ROWTYPE; b payment_batches%ROWTYPE; n integer; loan_uuid uuid; +BEGIN + SELECT * INTO r FROM loan_repayments WHERE id=repayment_uuid; + IF NOT FOUND THEN RETURN; END IF; + SELECT count(*) INTO n FROM payment_batches WHERE loan_repayment_id=r.id; + IF n <> 1 THEN RAISE EXCEPTION 'repayment requires one retained batch' USING ERRCODE='23514'; END IF; + SELECT * INTO b FROM payment_batches WHERE loan_repayment_id=r.id; + IF b.entity_type::text <> 'loan_repayment' OR b.legal_entity_id IS DISTINCT FROM r.legal_entity_id + OR b.default_payment_channel IS DISTINCT FROM 'bank' + OR (SELECT count(*) FROM payment_batch_items WHERE payment_batch_id=b.id) <> 1 + OR NOT EXISTS(SELECT 1 FROM payment_batch_items WHERE payment_batch_id=b.id + AND entity_id=r.id AND amount=r.amount AND currency_code=r.currency_code) + THEN RAISE EXCEPTION 'repayment payment identity mismatch' USING ERRCODE='23514'; END IF; + IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations WHERE repayment_id=r.id) <> r.amount + OR EXISTS(SELECT 1 FROM loan_repayment_allocations a JOIN company_loans l ON l.id=a.loan_id + WHERE a.repayment_id=r.id AND (l.lender_id<>r.lender_id OR l.legal_entity_id<>r.legal_entity_id + OR l.currency_code<>r.currency_code OR l.confirmation_status<>'confirmed' OR l.version<>a.loan_version)) + THEN RAISE EXCEPTION 'repayment allocation mismatch' USING ERRCODE='23514'; END IF; + IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state <> + CASE WHEN b.status='executed' THEN 'settled' WHEN b.status='cancelled' THEN 'released' ELSE 'reserved' END) + OR (b.status='executed' AND r.bank_result_state<>'confirmed_success') + OR (b.status='cancelled' AND r.bank_result_state NOT IN ('not_sent','confirmed_failed')) + THEN RAISE EXCEPTION 'repayment release or settlement state mismatch' USING ERRCODE='23514'; END IF; + FOR loan_uuid IN SELECT loan_id FROM loan_repayment_allocations WHERE repayment_id=r.id ORDER BY loan_id LOOP + PERFORM 1 FROM company_loans WHERE id=loan_uuid FOR UPDATE; + IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations + WHERE loan_id=loan_uuid AND reservation_state IN ('reserved','settled')) > + (SELECT opening_outstanding_principal FROM company_loans WHERE id=loan_uuid) + THEN RAISE EXCEPTION 'loan principal capacity exceeded' USING ERRCODE='23514'; END IF; + END LOOP; +END $$; + +CREATE FUNCTION check_loan_repayment_integrity() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE old_id uuid; new_id uuid; +BEGIN + IF TG_TABLE_NAME='loan_repayments' THEN + IF TG_OP<>'INSERT' THEN old_id=OLD.id; END IF; + IF TG_OP<>'DELETE' THEN new_id=NEW.id; END IF; + ELSIF TG_TABLE_NAME='loan_repayment_allocations' THEN + IF TG_OP<>'INSERT' THEN old_id=OLD.repayment_id; END IF; + IF TG_OP<>'DELETE' THEN new_id=NEW.repayment_id; END IF; + ELSIF TG_TABLE_NAME='payment_batches' THEN + IF TG_OP<>'INSERT' THEN old_id=OLD.loan_repayment_id; END IF; + IF TG_OP<>'DELETE' THEN new_id=NEW.loan_repayment_id; END IF; + ELSE + IF TG_OP<>'INSERT' THEN SELECT loan_repayment_id INTO old_id FROM payment_batches WHERE id=OLD.payment_batch_id; END IF; + IF TG_OP<>'DELETE' THEN SELECT loan_repayment_id INTO new_id FROM payment_batches WHERE id=NEW.payment_batch_id; END IF; + END IF; + IF old_id IS NOT NULL THEN PERFORM assert_loan_repayment_integrity(old_id); END IF; + IF new_id IS NOT NULL AND new_id IS DISTINCT FROM old_id THEN PERFORM assert_loan_repayment_integrity(new_id); END IF; + RETURN NULL; +END $$; +CREATE CONSTRAINT TRIGGER loan_repayment_integrity AFTER INSERT OR UPDATE OR DELETE ON loan_repayments + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity(); +CREATE CONSTRAINT TRIGGER loan_allocation_integrity AFTER INSERT OR UPDATE OR DELETE ON loan_repayment_allocations + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity(); +CREATE CONSTRAINT TRIGGER loan_batch_integrity AFTER INSERT OR UPDATE OR DELETE ON payment_batches + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity(); +CREATE CONSTRAINT TRIGGER loan_payment_item_integrity AFTER INSERT OR UPDATE OR DELETE ON payment_batch_items + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity(); + +CREATE FUNCTION guard_loan_repayment_history() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_TABLE_NAME='loan_repayment_allocations' THEN + IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan allocation history must be retained' USING ERRCODE='23514'; END IF; + IF (NEW.repayment_id,NEW.loan_id,NEW.amount,NEW.loan_version) IS DISTINCT FROM + (OLD.repayment_id,OLD.loan_id,OLD.amount,OLD.loan_version) + OR (OLD.reservation_state IN ('settled','released') AND NEW IS DISTINCT FROM OLD) + THEN RAISE EXCEPTION 'loan allocation history is immutable' USING ERRCODE='23514'; END IF; + ELSE + IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan repayment history must be retained' USING ERRCODE='23514'; END IF; + IF (NEW.repayment_no,NEW.legal_entity_id,NEW.lender_id,NEW.currency_code,NEW.reason,NEW.amount, + NEW.submitted_by,NEW.submitted_at,NEW.lender_identity_version,NEW.lender_account_version,NEW.payee_snapshot_json,NEW.request_key) + IS DISTINCT FROM + (OLD.repayment_no,OLD.legal_entity_id,OLD.lender_id,OLD.currency_code,OLD.reason,OLD.amount, + OLD.submitted_by,OLD.submitted_at,OLD.lender_identity_version,OLD.lender_account_version,OLD.payee_snapshot_json,OLD.request_key) + OR (OLD.bank_result_state IN ('confirmed_failed','confirmed_success') AND + (NEW.bank_result_state,NEW.bank_result_ref) IS DISTINCT FROM (OLD.bank_result_state,OLD.bank_result_ref)) + OR (OLD.bank_result_state='unknown' AND NEW.bank_result_state='not_sent') + OR (OLD.accounting_handoff_at IS NOT NULL AND + (NEW.accounting_handoff_at,NEW.accounting_handoff_by,NEW.accounting_handoff_ref) IS DISTINCT FROM + (OLD.accounting_handoff_at,OLD.accounting_handoff_by,OLD.accounting_handoff_ref)) + THEN RAISE EXCEPTION 'loan repayment history is immutable' USING ERRCODE='23514'; END IF; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER loan_repayment_history BEFORE UPDATE OR DELETE ON loan_repayments + FOR EACH ROW EXECUTE FUNCTION guard_loan_repayment_history(); +CREATE TRIGGER loan_allocation_history BEFORE UPDATE OR DELETE ON loan_repayment_allocations + FOR EACH ROW EXECUTE FUNCTION guard_loan_repayment_history(); diff --git a/server/prisma/migrations/20260918160000_business_numbering/migration.sql b/server/prisma/migrations/20260918160000_business_numbering/migration.sql new file mode 100644 index 00000000..6d88382f --- /dev/null +++ b/server/prisma/migrations/20260918160000_business_numbering/migration.sql @@ -0,0 +1,51 @@ +-- Additive only: existing identifiers and both old counters and drafts stay intact. +CREATE TABLE "business_number_policies" ( + "version" INTEGER PRIMARY KEY CHECK ("version" = 2), + "effective_at" TIMESTAMP(3) NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_by" UUID NOT NULL, + "reference" TEXT NOT NULL +); +CREATE TABLE "business_number_counters" ( + "key" TEXT PRIMARY KEY, + "current_number" BIGINT NOT NULL DEFAULT 0 CHECK ("current_number" >= 0), + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +ALTER TABLE "purchase_requests" ADD COLUMN "numbering_version" INTEGER NOT NULL DEFAULT 1 + CHECK ("numbering_version" IN (1, 2)); +-- No INSERT into policy; activation is a separate, explicitly authorized operation. + +-- Keep the original eligibility and issued high-water durable across rollback or +-- accidental administrative resets. A pause changes only policy.enabled. +CREATE FUNCTION guard_business_numbering_history() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'business numbering history cannot be deleted' USING ERRCODE = '23514'; + END IF; + IF TG_TABLE_NAME = 'business_number_policies' THEN + IF NEW.version <> OLD.version OR NEW.effective_at <> OLD.effective_at THEN + RAISE EXCEPTION 'business numbering activation is immutable' USING ERRCODE = '23514'; + END IF; + ELSIF TG_TABLE_NAME = 'business_number_counters' THEN + IF NEW.key <> OLD.key OR NEW.current_number < OLD.current_number THEN + RAISE EXCEPTION 'business numbering high-water cannot decrease' USING ERRCODE = '23514'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER business_number_policy_history BEFORE UPDATE OR DELETE ON business_number_policies + FOR EACH ROW EXECUTE FUNCTION guard_business_numbering_history(); +CREATE TRIGGER business_number_counter_history BEFORE UPDATE OR DELETE ON business_number_counters + FOR EACH ROW EXECUTE FUNCTION guard_business_numbering_history(); +CREATE FUNCTION guard_purchase_numbering_version() RETURNS trigger AS $$ +BEGIN + IF NEW.numbering_version <> OLD.numbering_version THEN + RAISE EXCEPTION 'purchase numbering eligibility is immutable' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER purchase_numbering_version BEFORE UPDATE OF numbering_version ON purchase_requests + FOR EACH ROW EXECUTE FUNCTION guard_purchase_numbering_version(); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 794fe205..7552b028 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -273,6 +273,7 @@ enum PaymentBatchStatus { enum PaymentBatchEntityType { reimbursement travel_order + loan_repayment } enum BudgetControlRuleEntityType { @@ -731,6 +732,9 @@ model Employee { @@index([legal_entity_id], map: "idx_employees_legal_entity") @@index([bank_institution_id], map: "idx_employees_bank_institution") @@index([direct_manager_id]) + loan_lender_identities LoanLender[] + loan_evidence_uploads LoanEvidence[] + @@map("employees") } @@ -949,6 +953,26 @@ model ReimbursementTemplate { @@map("reimbursement_templates") } +// Absent policy means legacy numbering. Migration never activates v2. +model BusinessNumberPolicy { + version Int @id + effective_at DateTime + enabled Boolean @default(false) + updated_at DateTime @default(now()) @updatedAt + updated_by String @db.Uuid + reference String + + @@map("business_number_policies") +} + +model BusinessNumberCounter { + key String @id + current_number BigInt @default(0) + updated_at DateTime @default(now()) @updatedAt + + @@map("business_number_counters") +} + model DocumentSequence { document_type String @id prefix String @@ -997,6 +1021,8 @@ model LegalEntity { @@index([enabled, archived_at], map: "idx_legal_entities_status") @@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar") + loan_lenders LoanLender[] + @@map("legal_entities") } @@ -1016,6 +1042,8 @@ model BankInstitution { payment_accounts PaymentAccount[] @@index([enabled, archived_at], map: "idx_bank_institutions_status") + loan_lenders LoanLender[] + @@map("bank_institutions") } @@ -1168,6 +1196,7 @@ model Supplier { } model PurchaseRequest { + numbering_version Int @default(1) id String @id @default(uuid()) @db.Uuid request_no String? @unique applicant_id String @db.Uuid @@ -1901,6 +1930,8 @@ model EntityRiskFlag { } model PaymentBatch { + loan_repayment_id String? @db.Uuid + loan_repayment LoanRepayment? @relation(fields: [loan_repayment_id], references: [id], onDelete: Restrict) id String @id @default(uuid()) @db.Uuid batch_no String @unique @db.VarChar(64) entity_type PaymentBatchEntityType @@ -3809,3 +3840,142 @@ model ApprovalTransfer { @@index([to_approver_id]) @@map("approval_transfers") } + + +model LoanLender { + id String @id @default(uuid()) @db.Uuid + legal_entity_id String @db.Uuid + name String @db.VarChar(120) + employee_id String? @db.Uuid + identity_status String @default("unverified") @db.VarChar(16) + identity_version Int @default(1) + account_version Int @default(1) + identity_confirmed_by String? @db.Uuid + identity_confirmed_at DateTime? + identity_confirmation String? + bank_institution_id String @db.Uuid + bank_name String @db.VarChar(200) + bank_account_encrypted String + account_name_encrypted String + archived_at DateTime? + created_at DateTime @default(now()) + created_by String @db.Uuid + updated_at DateTime @updatedAt + updated_by String @db.Uuid + request_key String @db.Uuid + + legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict) + employee Employee? @relation(fields: [employee_id], references: [id], onDelete: Restrict) + bank_institution BankInstitution @relation(fields: [bank_institution_id], references: [id], onDelete: Restrict) + loans CompanyLoan[] + repayments LoanRepayment[] + + @@unique([legal_entity_id, id]) + @@unique([created_by, request_key]) + @@index([employee_id]) + @@map("loan_lenders") +} + +model CompanyLoan { + id String @id @default(uuid()) @db.Uuid + loan_no String @unique @db.VarChar(64) + origin_reference String @db.VarChar(200) + legal_entity_id String @db.Uuid + lender_id String @db.Uuid + currency_code String @default("CNY") @db.VarChar(8) + original_principal Decimal @db.Decimal(14, 2) + opening_outstanding_principal Decimal @db.Decimal(14, 2) + received_date DateTime @db.Date + balance_as_of DateTime @db.Date + historical_entry Boolean + confirmation_status String @default("unconfirmed") @db.VarChar(16) + version Int @default(1) + confirmed_by String? @db.Uuid + confirmed_at DateTime? + accounting_confirmation_ref String? @db.VarChar(200) + created_at DateTime @default(now()) + created_by String @db.Uuid + updated_at DateTime @updatedAt + updated_by String @db.Uuid + request_key String @db.Uuid + + lender LoanLender @relation(fields: [legal_entity_id, lender_id], references: [legal_entity_id, id], onDelete: Restrict) + allocations LoanRepaymentAllocation[] + evidences LoanEvidence[] + + @@unique([created_by, request_key]) + @@unique([legal_entity_id, lender_id, origin_reference]) + @@index([lender_id, confirmation_status, received_date]) + @@map("company_loans") +} + +model LoanRepayment { + id String @id @default(uuid()) @db.Uuid + repayment_no String @unique @db.VarChar(64) + legal_entity_id String @db.Uuid + lender_id String @db.Uuid + currency_code String @default("CNY") @db.VarChar(8) + reason String @db.VarChar(500) + amount Decimal @db.Decimal(14, 2) + version Int @default(1) + submitted_by String @db.Uuid + submitted_at DateTime @default(now()) + lender_identity_version Int + lender_account_version Int + payee_snapshot_json Json + bank_result_state String @default("not_sent") @db.VarChar(24) + bank_result_ref String? @db.VarChar(500) + accounting_handoff_by String? @db.Uuid + accounting_handoff_at DateTime? + accounting_handoff_ref String? @db.VarChar(200) + request_key String @db.Uuid + + lender LoanLender @relation(fields: [legal_entity_id, lender_id], references: [legal_entity_id, id], onDelete: Restrict) + allocations LoanRepaymentAllocation[] + batches PaymentBatch[] + + @@unique([submitted_by, request_key]) + @@index([lender_id, submitted_at]) + @@map("loan_repayments") +} + +model LoanRepaymentAllocation { + id String @id @default(uuid()) @db.Uuid + repayment_id String @db.Uuid + loan_id String @db.Uuid + amount Decimal @db.Decimal(14, 2) + loan_version Int + reservation_state String @default("reserved") @db.VarChar(16) + settled_at DateTime? + released_at DateTime? + release_reason String? @db.VarChar(500) + + repayment LoanRepayment @relation(fields: [repayment_id], references: [id], onDelete: Restrict) + loan CompanyLoan @relation(fields: [loan_id], references: [id], onDelete: Restrict) + + @@unique([repayment_id, loan_id]) + @@index([loan_id, reservation_state]) + @@map("loan_repayment_allocations") +} + +model LoanEvidence { + id String @id @default(uuid()) @db.Uuid + loan_id String @db.Uuid + kind String @db.VarChar(32) + original_name String @db.VarChar(255) + storage_path String + mime_type String @db.VarChar(100) + file_size Int + file_sha256 String @db.VarChar(64) + governance_status String @default("pending") @db.VarChar(16) + uploaded_by String @db.Uuid + uploaded_at DateTime @default(now()) + archived_at DateTime? + archive_reason String? @db.VarChar(500) + + loan CompanyLoan @relation(fields: [loan_id], references: [id], onDelete: Restrict) + uploader Employee @relation(fields: [uploaded_by], references: [id], onDelete: Restrict) + + @@unique([loan_id, file_sha256, kind]) + @@map("loan_evidences") +} diff --git a/server/scripts/regression-assets.sh b/server/scripts/regression-assets.sh index 4d7b2e82..59e84abb 100755 --- a/server/scripts/regression-assets.sh +++ b/server/scripts/regression-assets.sh @@ -222,7 +222,7 @@ const { createPrismaClient } = require('./src/prismaClient'); process.exit(1); } - if (!asset || !asset.asset_no.startsWith('ZC-') || asset.status !== 'in_use' || asset.assignee_employee_id !== financeMgrId || asset.location !== '深圳办公室 B-02') { + if (!asset || !/^FA-\d{4}-\d{6,}$/.test(asset.asset_no) || asset.status !== 'in_use' || asset.assignee_employee_id !== financeMgrId || asset.location !== '深圳办公室 B-02') { console.error('[ERROR] asset card state mismatch', asset); process.exit(1); } diff --git a/server/scripts/regression-im-notifier.sh b/server/scripts/regression-im-notifier.sh index 01c115e8..9b7d2738 100755 --- a/server/scripts/regression-im-notifier.sh +++ b/server/scripts/regression-im-notifier.sh @@ -701,12 +701,14 @@ assert_status "$test_status" "200" "test channel by id" "$TMP_DIR/channel-test.j assert_jq "$TMP_DIR/channel-test.json" '.data.testResult.ok == true and .data.testResult.code == "mock_sent" and .data.testResult.retryable == false' "test channel endpoint mock sent" echo "[8/10] create encrypted secret channel and verify masked read model" -SECRET_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Secret ${RUN_SUFFIX}" "mock" "" "secret-${RUN_SUFFIX}" "$TMP_DIR/create-secret-channel.json")" +SECRET_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Secret ${RUN_SUFFIX}" "wecom_webhook" "http://127.0.0.1:9/wecom" "secret-${RUN_SUFFIX}" "$TMP_DIR/create-secret-channel.json")" track_regression_channel "$SECRET_CHANNEL_ID" assert_jq "$TMP_DIR/create-secret-channel.json" '.data.channel.configJson.secretConfigured == true and (.data.channel.configJson.secretMasked | type == "string") and (.data.channel.configJson | has("secret") | not)' "create channel masks secret on read" +query_channel_secret_state "$SECRET_CHANNEL_ID" > "$TMP_DIR/secret-channel-storage.json" +assert_jq "$TMP_DIR/secret-channel-storage.json" '.hasSecret == false and .hasSecretEncrypted == true' "channel stores only encrypted secret" secret_test_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$SECRET_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/secret-channel-test.json" '{}')" assert_status "$secret_test_status" "200" "test encrypted channel by id" "$TMP_DIR/secret-channel-test.json" -assert_jq "$TMP_DIR/secret-channel-test.json" '.data.testResult.ok == true and .data.testResult.code == "mock_sent" and .data.channel.configJson.secretConfigured == true' "encrypted secret channel test still works" +assert_jq "$TMP_DIR/secret-channel-test.json" '.data.testResult.ok == false and .data.testResult.code == "wecom_network_error" and .data.testResult.retryable == true and .data.channel.configJson.secretConfigured == true' "encrypted secret channel follows loopback provider failure path" echo "[9/10] migrate legacy plain secret channel to encrypted storage and keep test path working" LEGACY_CHANNEL_ID="$(create_legacy_secret_channel "IM Legacy ${RUN_SUFFIX}" "http://127.0.0.1:9/wecom" "legacy-${RUN_SUFFIX}")" diff --git a/server/scripts/regression-workflow-actions.sh b/server/scripts/regression-workflow-actions.sh index 027b511a..b1510d9d 100755 --- a/server/scripts/regression-workflow-actions.sh +++ b/server/scripts/regression-workflow-actions.sh @@ -493,6 +493,28 @@ const { createPrismaClient } = require('./src/prismaClient'); NODE } +assert_direct_payment_unchanged() { + DATABASE_URL="$DATABASE_URL" node - "$1" "$2" <<'NODE' +const assert = require('node:assert/strict'); +const { createPrismaClient } = require('./src/prismaClient'); +(async () => { + const { prisma, pool } = createPrismaClient(); + try { + const form = await prisma.reimbursementForm.findUniqueOrThrow({ where: { id: process.argv[2] } }); + assert.equal(form.status, process.argv[3]); + for (const field of ['payment_voucher_no', 'payment_date', 'payment_channel', 'paid_at', 'paid_by']) { + assert.equal(form[field], null, `blocked direct payment changed ${field}`); + } + const invoices = await prisma.invoicePool.findMany({ where: { linked_reimbursement_id: form.id }, select: { status: true, archived_reason: true } }); + for (const invoice of invoices) { + assert.equal(invoice.status, 'bound'); + assert.equal(invoice.archived_reason, null); + } + } finally { await prisma.$disconnect(); await pool.end(); } +})().catch(() => { console.error('[ERROR] blocked direct payment changed state or invoice'); process.exitCode = 1; }); +NODE +} + assert_reimbursement_status_db() { local reimbursement_id="$1" local expected_status="$2" @@ -533,106 +555,6 @@ const { createPrismaClient } = require('./src/prismaClient'); NODE } -assert_skip_history_record() { - local instance_id="$1" - local expected_step_id="$2" - local expected_reason="$3" - local scenario="$4" - - DATABASE_URL="$DATABASE_URL" node - "$instance_id" "$expected_step_id" "$expected_reason" "$scenario" <<'NODE' -const [instanceId, expectedStepId, expectedReason, scenario] = process.argv.slice(2); -const { createPrismaClient } = require('./src/prismaClient'); - -(async () => { - const { prisma, pool } = createPrismaClient(); - await prisma.$connect(); - try { - const history = await prisma.workflowHistory.findFirst({ - where: { - instance_id: instanceId, - action: 'skip', - }, - orderBy: { created_at: 'asc' }, - select: { payload: true }, - }); - - if (!history) { - console.error(`[ERROR] ${scenario} expected skip history record but none found`); - process.exit(1); - } - - const payload = history.payload ?? {}; - const actualStepId = payload.step_id ?? null; - const actualReason = payload.reason ?? null; - if (actualStepId !== expectedStepId || actualReason !== expectedReason) { - console.error( - `[ERROR] ${scenario} expected skip payload step_id=${expectedStepId}, reason=${expectedReason} but got step_id=${actualStepId}, reason=${actualReason}`, - ); - process.exit(1); - } - } finally { - await prisma.$disconnect(); - await pool.end(); - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -NODE -} - -assert_task_step_and_role() { - local task_id="$1" - local expected_step_id="$2" - local expected_role="$3" - local scenario="$4" - - DATABASE_URL="$DATABASE_URL" node - "$task_id" "$expected_step_id" "$expected_role" "$scenario" <<'NODE' -const [taskId, expectedStepId, expectedRole, scenario] = process.argv.slice(2); -const { createPrismaClient } = require('./src/prismaClient'); - -(async () => { - const { prisma, pool } = createPrismaClient(); - await prisma.$connect(); - try { - const task = await prisma.workflowTask.findUnique({ - where: { id: taskId }, - select: { - id: true, - step_id: true, - assignee_role: true, - assignee_id: true, - }, - }); - - if (!task) { - console.error(`[ERROR] ${scenario} task not found: ${taskId}`); - process.exit(1); - } - - if (task.step_id !== expectedStepId) { - console.error( - `[ERROR] ${scenario} expected step_id ${expectedStepId} but got ${task.step_id}`, - ); - process.exit(1); - } - - if (task.assignee_role !== expectedRole || task.assignee_id !== null) { - console.error( - `[ERROR] ${scenario} expected role assignee ${expectedRole} with null assignee_id but got assignee_role=${task.assignee_role}, assignee_id=${task.assignee_id}`, - ); - process.exit(1); - } - } finally { - await prisma.$disconnect(); - await pool.end(); - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -NODE -} assert_no_workflow_instance() { local reimbursement_id="$1" @@ -1049,19 +971,17 @@ echo "[7/23] Prepare manager-missing context" set_employee_direct_manager "$EMPLOYEE_ID" "__NULL__" EMPLOYEE_MANAGER_PATCHED=1 -echo "[8/23] manager skip: missing manager -> skip with history + finance task" +echo "[8/23] required manager missing: reject before workflow creation" RID_SKIP="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/skip-create.json")" -create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_SKIP" "skip" >/dev/null -submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_SKIP" "$TMP_DIR/skip-submit.json" -TASK_SKIP="$(jq -er '.data.currentTaskId' "$TMP_DIR/skip-submit.json")" -INSTANCE_SKIP="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/skip-submit.json")" - -assert_task_step_and_role "$TASK_SKIP" "approval_2_finance" "finance" "manager skip scenario" -assert_skip_history_record "$INSTANCE_SKIP" "approval_1_manager" "no_manager_found" "manager skip scenario" - -skip_finance_approve_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_SKIP/approve" "$FINANCE_TOKEN" "$TMP_DIR/skip-finance-approve.json" '{}')" -assert_status "$skip_finance_approve_status" "200" "manager skip finance approve" "$TMP_DIR/skip-finance-approve.json" -assert_reimbursement_status_db "$RID_SKIP" "approved" "manager skip scenario" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_SKIP" "missing-manager" >/dev/null +missing_manager_status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$RID_SKIP/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/skip-submit.json" '{}')" +assert_status "$missing_manager_status" "409" "required manager missing" "$TMP_DIR/skip-submit.json" +assert_error_code "$TMP_DIR/skip-submit.json" "VALID_001" "required manager missing" +assert_error_detail "$TMP_DIR/skip-submit.json" '.error.details.workflow' "no_actionable_approver" "required manager missing" +assert_error_detail "$TMP_DIR/skip-submit.json" '.error.details.reason' "no_manager_found" "required manager missing" +assert_error_detail "$TMP_DIR/skip-submit.json" '.error.details.reasonCode' "workflow.manager_unavailable" "required manager missing" +assert_no_workflow_instance "$RID_SKIP" "required manager missing" +assert_reimbursement_status_db "$RID_SKIP" "draft" "required manager missing" echo "[9/23] no_actionable_approver: full chain non-actionable" TMP_APPROVAL_RULE_ID="$(create_temp_manager_only_rule "tmp-manager-only-$RUN_SUFFIX" "9000.00" "9001.00" "1")" @@ -1264,7 +1184,7 @@ fi manager_approve_after_expired_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_EXPIRED/approve" "$MANAGER_TOKEN" "$TMP_DIR/delegation-expired-manager-approve.json" '{}')" assert_status "$manager_approve_after_expired_status" "200" "manager approve after expired delegation" "$TMP_DIR/delegation-expired-manager-approve.json" -echo "[19/23] pay success: approved -> paid and invoices archived" +echo "[19/23] direct payment contract for the configured payment mode" RID_PAY_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-ok-create.json")" IID_PAY_OK="$(create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "pay-ok")" submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-submit.json" @@ -1283,13 +1203,55 @@ assert_reimbursement_status_db "$RID_PAY_OK" "approved" "pay success preconditio pay_ok_body="{\"paymentVoucherNo\":\"VOUCHER-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-07\",\"paymentChannel\":\"bank\"}" pay_ok_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-pay.json" "$pay_ok_body")" +if [[ "${PAYMENT_BATCH_ENABLED:-false}" == "true" ]]; then + assert_status "$pay_ok_status" "409" "batch mode forbids direct payment" "$TMP_DIR/pay-ok-pay.json" + assert_error_code "$TMP_DIR/pay-ok-pay.json" "VALID_001" "direct pay batch boundary" + assert_error_detail "$TMP_DIR/pay-ok-pay.json" '.error.details.payment' "payment_batch_v1_required" "direct pay batch boundary" + echo "[19/23] batch-required profile: direct pay denied; legacy success is not exercised" + assert_error_detail "$TMP_DIR/pay-ok-pay.json" '.error.details.reasonCode' "reimbursement.payment_payment_batch_v1_required" "direct pay batch boundary" + assert_direct_payment_unchanged "$RID_PAY_OK" "approved" + + echo "[20/23] batch-required rejects draft/reviewing/rejected/cancelled without payment writes" + RID_PAY_REVIEWING="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-reviewing-create.json")" + create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "pay-reviewing" >/dev/null + submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "$TMP_DIR/pay-reviewing-submit.json" + RID_PAY_CANCELLED="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-cancelled-create.json")" + set_reimbursement_status "$RID_PAY_CANCELLED" "cancelled" + for fixture in "$RID_NON_POSITIVE_ITEM:draft" "$RID_PAY_REVIEWING:reviewing" "$RID_REJECT:rejected" "$RID_PAY_CANCELLED:cancelled"; do + rid="${fixture%:*}" + expected_state="${fixture##*:}" + blocked_status="$(pay_reimbursement "$FINANCE_TOKEN" "$rid" "$TMP_DIR/pay-blocked-$expected_state.json")" + assert_status "$blocked_status" "409" "batch-required $expected_state" "$TMP_DIR/pay-blocked-$expected_state.json" + assert_error_detail "$TMP_DIR/pay-blocked-$expected_state.json" '.error.details.payment' "payment_batch_v1_required" "batch-required $expected_state" + assert_direct_payment_unchanged "$rid" "$expected_state" + done + withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "cleanup batch-required reviewing" "$TMP_DIR/pay-reviewing-withdraw-cleanup.json" + + echo "[21/23] non-finance remains forbidden" + pay_perm_status="$(pay_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-perm-employee.json")" + assert_status "$pay_perm_status" "403" "pay forbidden for non-finance" "$TMP_DIR/pay-perm-employee.json" + assert_error_code "$TMP_DIR/pay-perm-employee.json" "PERM_001" "pay forbidden for non-finance" + + echo "[22/23] repeated and concurrent direct requests cannot bypass batches" + pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-repeat-a.json" "$pay_ok_body" > "$TMP_DIR/pay-repeat-a.status" & + pid_a=$! + pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-repeat-b.json" "$pay_ok_body" > "$TMP_DIR/pay-repeat-b.status" & + pid_b=$! + wait "$pid_a" + wait "$pid_b" + for suffix in a b; do + assert_status "$(cat "$TMP_DIR/pay-repeat-$suffix.status")" "409" "concurrent batch-required refusal" "$TMP_DIR/pay-repeat-$suffix.json" + assert_error_detail "$TMP_DIR/pay-repeat-$suffix.json" '.error.details.payment' "payment_batch_v1_required" "concurrent batch-required refusal" + done + assert_direct_payment_unchanged "$RID_PAY_OK" "approved" +else assert_status "$pay_ok_status" "200" "pay approved reimbursement" "$TMP_DIR/pay-ok-pay.json" assert_jq "$TMP_DIR/pay-ok-pay.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 1 and .data.payment.idempotent == false and .data.payment.paymentChannel == "bank"' "pay success response" assert_reimbursement_status_db "$RID_PAY_OK" "paid" "pay success" assert_invoices_archived_used_complete "$RID_PAY_OK" "pay success" echo "[20/23] pay invalid status: draft/reviewing/rejected/cancelled" -pay_invalid_draft_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_NEGATIVE" "$TMP_DIR/pay-invalid-draft.json")" +pay_invalid_draft_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_NON_POSITIVE_ITEM" "$TMP_DIR/pay-invalid-draft.json")" assert_status "$pay_invalid_draft_status" "409" "pay invalid status draft" "$TMP_DIR/pay-invalid-draft.json" assert_error_code "$TMP_DIR/pay-invalid-draft.json" "VALID_001" "pay invalid status draft" assert_error_detail "$TMP_DIR/pay-invalid-draft.json" '.error.details.status' "invalid_pay_status" "pay invalid status draft" @@ -1364,6 +1326,8 @@ if [[ "$RUN_PAY_CONCURRENCY_CHECK" == "1" ]]; then assert_invoices_archived_used_complete "$RID_PAY_CONCURRENCY" "pay concurrency" fi +fi + echo "[22.2/23] invoice unbind/rebind" RID_UNBIND_A="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind-a-create.json")" INVOICE_UNBIND="$(create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_UNBIND_A" "unbind")" @@ -1385,7 +1349,7 @@ echo "- return reimbursementId: $RID_RETURN" echo "- withdraw reimbursementId: $RID_WITHDRAW" echo "- perm reimbursementId: $RID_PERM" echo "- non-positive item reimbursementId: $RID_NON_POSITIVE_ITEM" -echo "- manager-skip reimbursementId: $RID_SKIP" +echo "- manager-missing reimbursementId: $RID_SKIP" echo "- no-actionable reimbursementId: $RID_NO_ACTIONABLE" echo "- transfer-ok reimbursementId: $RID_TRANSFER_OK" echo "- transfer-chain reimbursementId: $RID_TRANSFER_CHAIN" diff --git a/server/src/app.js b/server/src/app.js index 39e1b67f..30859a0f 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -49,6 +49,7 @@ const budgetControlRulesRoutes = require("./routes/budgetControlRules"); const feeControlRoutes = require("./routes/feeControl"); const riskFlagsRoutes = require("./routes/riskFlags"); const paymentBatchesRoutes = require("./routes/paymentBatches"); +const companyLoansRoutes = require("./routes/companyLoans"); const paymentAccountsRoutes = require("./routes/paymentAccounts"); const apOperationsRoutes = require("./routes/apOperations"); const portalRoutes = require("./routes/portal"); @@ -374,6 +375,7 @@ async function buildApp(dependencies = {}) { await app.register(feeControlRoutes, { prefix: "/api" }); await app.register(riskFlagsRoutes, { prefix: "/api" }); await app.register(paymentBatchesRoutes, { prefix: "/api" }); + await app.register(companyLoansRoutes, { prefix: "/api" }); await app.register(paymentAccountsRoutes, { prefix: "/api" }); await app.register(apOperationsRoutes, { prefix: "/api" }); await app.register(portalRoutes, { prefix: "/api" }); diff --git a/server/src/config.js b/server/src/config.js index 027cc39e..25c4d5de 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -277,27 +277,6 @@ function getConfig() { aiModel: process.env.AI_MODEL ?? "", aiTimeoutMs: Number(process.env.AI_TIMEOUT_MS ?? 10000), aiAuthToken: process.env.AI_AUTH_TOKEN ?? "", - - erpSyncEnabled: process.env.ERP_SYNC_ENABLED === "true", - erpSyncFlagEnv: process.env.ERP_SYNC_ENABLED ?? "false", - erpSyncProvider: process.env.ERP_SYNC_PROVIDER ?? "mock", - erpSyncEndpoint: process.env.ERP_SYNC_ENDPOINT ?? "", - erpJournalExportEndpoint: process.env.ERP_JOURNAL_EXPORT_ENDPOINT ?? "", - erpProcurementExportEndpoint: process.env.ERP_PROCUREMENT_EXPORT_ENDPOINT ?? "", - erpAssetExportEndpoint: process.env.ERP_ASSET_EXPORT_ENDPOINT ?? "", - erpMasterDataEndpoint: process.env.ERP_MASTER_DATA_ENDPOINT ?? "", - erpSyncTimeoutMs: Number(process.env.ERP_SYNC_TIMEOUT_MS ?? 5000), - erpProcurementTimeoutMs: Number( - process.env.ERP_PROCUREMENT_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, - ), - erpAssetTimeoutMs: Number( - process.env.ERP_ASSET_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, - ), - erpMasterDataTimeoutMs: Number( - process.env.ERP_MASTER_DATA_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, - ), - erpMasterDataSyncCron: process.env.ERP_MASTER_DATA_SYNC_CRON ?? "", - erpSyncAuthToken: process.env.ERP_SYNC_AUTH_TOKEN ?? "", travelSyncEnabled: process.env.TRAVEL_SYNC_ENABLED === "true", imNotifierEnabled: process.env.IM_NOTIFIER_ENABLED === "true", integrationOutboxCron: process.env.INTEGRATION_OUTBOX_CRON ?? "* * * * *", diff --git a/server/src/constants/advisoryLocks.js b/server/src/constants/advisoryLocks.js index 6f84d048..4ac3c00b 100644 --- a/server/src/constants/advisoryLocks.js +++ b/server/src/constants/advisoryLocks.js @@ -1,6 +1,8 @@ const ADVISORY_LOCK_NAMESPACES = Object.freeze({ paymentBatchDailySequence: 1001, paymentReceiptClaims: 1002, + businessNumberPolicy: 1003, // key 0: v2 policy; shared writers / exclusive administration + businessNumberCounter: 1004, // object keys: BUSINESS_NUMBER_FAMILIES lockKey }); module.exports = { diff --git a/server/src/constants/errorReasons.js b/server/src/constants/errorReasons.js index 087f373e..8840aa9f 100644 --- a/server/src/constants/errorReasons.js +++ b/server/src/constants/errorReasons.js @@ -1,4 +1,626 @@ const ERROR_REASONS = [ + {"code":"integration.erp_retired","domain":"integration","audience":"user","defaultMessage":"ERP 集成已停用,历史记录仅供查询,不能重新投递。","recovery":"follow_guidance"}, + {"code":"loan.invalid_input","domain":"loan","audience":"user","defaultMessage":"借款信息不符合要求,请检查必填项、金额和日期。","recovery":"follow_guidance"}, + {"code":"loan.not_found","domain":"loan","audience":"user","defaultMessage":"借款记录不存在或已不可用,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"loan.source_unavailable","domain":"loan","audience":"user","defaultMessage":"公司、银行或关联人员已停用,请核对后重新选择。","recovery":"follow_guidance"}, + {"code":"loan.identity_required","domain":"loan","audience":"user","defaultMessage":"请先核实出借人与 OA 账号的关联;非员工须明确确认未关联账号。","recovery":"follow_guidance"}, + {"code":"loan.version_changed","domain":"loan","audience":"user","defaultMessage":"借款身份、账户或余额已变更,请刷新后重新核对。","recovery":"follow_guidance"}, + {"code":"loan.account_unavailable","domain":"loan","audience":"user","defaultMessage":"借款收款资料暂不可用,请联系财务核对账户加密配置。","recovery":"follow_guidance"}, + {"code":"loan.duplicate_registration","domain":"loan","audience":"user","defaultMessage":"该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。","recovery":"follow_guidance"}, + {"code":"loan.confirmed_immutable","domain":"loan","audience":"user","defaultMessage":"借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。","recovery":"follow_guidance"}, + {"code":"loan.evidence_required","domain":"loan","audience":"user","defaultMessage":"请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。","recovery":"follow_guidance"}, + {"code":"loan.evidence_invalid","domain":"loan","audience":"user","defaultMessage":"借款材料不符合要求,请使用有效的 PDF 或图片文件。","recovery":"follow_guidance"}, + {"code":"loan.balance_unconfirmed","domain":"loan","audience":"user","defaultMessage":"借款余额尚未核实,不能申请归还本金。","recovery":"follow_guidance"}, + {"code":"loan.allocation_invalid","domain":"loan","audience":"user","defaultMessage":"分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。","recovery":"follow_guidance"}, + {"code":"loan.reviewer_required","domain":"loan","audience":"user","defaultMessage":"没有符合独立复核要求的经理或总经理;制单人、提交人和收款人不能复核。","recovery":"follow_guidance"}, + {"code":"loan.independent_review_required","domain":"loan","audience":"user","defaultMessage":"本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。","recovery":"follow_guidance"}, + {"code":"loan.bank_state_unsafe","domain":"loan","audience":"user","defaultMessage":"银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。","recovery":"follow_guidance"}, + {"code":"loan.handoff_not_ready","domain":"loan","audience":"user","defaultMessage":"本金付款尚未成功登记,或已完成会计交接,请核对付款状态。","recovery":"follow_guidance"}, + {"code":"loan.payment_not_ready","domain":"loan","audience":"user","defaultMessage":"还款申请或付款状态已变化,请刷新核对后继续。","recovery":"follow_guidance"}, + {"code":"config.enabled_query_invalid","domain":"config","audience":"user","defaultMessage":"启用状态筛选无效,请重新选择后查询。","recovery":"follow_guidance"}, + {"code":"config.document_type_invalid","domain":"config","audience":"user","defaultMessage":"单据类型标识不符合要求,请重新选择单据类型。","recovery":"follow_guidance"}, + {"code":"document_sequence.type_unsupported","domain":"document_sequence","audience":"user","defaultMessage":"当前单据类型不支持编号,请联系管理员核对。","recovery":"follow_guidance"}, + {"code":"document_sequence.category_missing","domain":"document_sequence","audience":"user","defaultMessage":"指定的单据分类不存在,请重新选择分类。","recovery":"follow_guidance"}, + {"code":"document_sequence.category_type_mismatch","domain":"document_sequence","audience":"user","defaultMessage":"单据分类与当前单据类型不匹配,请重新选择。","recovery":"follow_guidance"}, + {"code":"document_sequence.category_unavailable","domain":"document_sequence","audience":"user","defaultMessage":"指定的单据分类已停用或归档,请选择可用分类。","recovery":"follow_guidance"}, + {"code":"workspace.update_input_invalid","domain":"workspace","audience":"user","defaultMessage":"工作区设置不符合要求,请检查模块显示方式后保存。","recovery":"follow_guidance"}, + {"code":"confidential_ref.create_input_invalid","domain":"confidential_ref","audience":"user","defaultMessage":"资料索引信息不符合要求,请检查必填项和字段长度。","recovery":"follow_guidance"}, + {"code":"confidential_ref.update_input_invalid","domain":"confidential_ref","audience":"user","defaultMessage":"资料索引修改不符合要求,请检查要更新的字段。","recovery":"follow_guidance"}, + {"code":"confidential_ref.query_invalid","domain":"confidential_ref","audience":"user","defaultMessage":"资料索引查询条件不符合要求,请检查关联对象。","recovery":"follow_guidance"}, + {"code":"confidential_ref.feature_disabled","domain":"confidential_ref","audience":"user","defaultMessage":"当前未启用资料索引,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"cutover.id_invalid","domain":"cutover","audience":"user","defaultMessage":"切换范围标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"cutover.create_input_invalid","domain":"cutover","audience":"user","defaultMessage":"切换范围信息不符合要求,请检查范围类型、标识和时间。","recovery":"follow_guidance"}, + {"code":"cutover.update_input_invalid","domain":"cutover","audience":"user","defaultMessage":"切换范围修改不符合要求,请检查要更新的字段。","recovery":"follow_guidance"}, + {"code":"cutover.feature_disabled","domain":"cutover","audience":"user","defaultMessage":"当前未启用切换控制台,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"cutover.scope_not_enabled","domain":"cutover","audience":"user","defaultMessage":"当前业务尚未纳入允许操作的切换范围,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"migration_run.id_invalid","domain":"migration_run","audience":"user","defaultMessage":"迁移检查记录标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"migration_run.file_required","domain":"migration_run","audience":"user","defaultMessage":"请选择用于迁移预检查的文件。","recovery":"follow_guidance"}, + {"code":"migration_run.type_invalid","domain":"migration_run","audience":"user","defaultMessage":"迁移检查类型无效,请重新选择检查类型。","recovery":"follow_guidance"}, + {"code":"feature_flag.version_conflict","domain":"feature_flag","audience":"user","defaultMessage":"功能设置已被其他操作更新,请刷新后核对再保存。","recovery":"follow_guidance"}, + {"code":"feature_flag.key_invalid","domain":"feature_flag","audience":"user","defaultMessage":"功能标识无效,请刷新功能设置列表后重新选择。","recovery":"follow_guidance"}, + {"code":"feature_flag.page_invalid","domain":"feature_flag","audience":"user","defaultMessage":"功能设置历史查询条件不符合要求,请检查分页。","recovery":"follow_guidance"}, + {"code":"feature_flag.update_input_invalid","domain":"feature_flag","audience":"user","defaultMessage":"功能设置不符合要求,请检查覆盖模式、范围和版本。","recovery":"follow_guidance"}, + {"code":"feature_flag.global_departments_invalid","domain":"feature_flag","audience":"user","defaultMessage":"全局范围不能同时指定部门,请清除部门或选择部门范围。","recovery":"follow_guidance"}, + {"code":"feature_flag.batch_input_invalid","domain":"feature_flag","audience":"user","defaultMessage":"批量功能设置不符合要求,请检查操作说明、版本和重复项目。","recovery":"follow_guidance"}, + {"code":"fee_control.input_invalid","domain":"fee_control","audience":"user","defaultMessage":"费用预检信息不符合要求,请检查单据类型和标识。","recovery":"follow_guidance"}, + {"code":"fee_control.entity_unsupported","domain":"fee_control","audience":"user","defaultMessage":"当前单据类型不支持费用预检,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"risk_flag.query_invalid","domain":"risk_flag","audience":"user","defaultMessage":"风险记录查询条件不符合要求,请检查单据、状态和来源。","recovery":"follow_guidance"}, + {"code":"risk_flag.id_invalid","domain":"risk_flag","audience":"user","defaultMessage":"风险记录标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"risk_flag.action_input_invalid","domain":"risk_flag","audience":"user","defaultMessage":"风险处理信息不符合要求,请检查备注长度。","recovery":"follow_guidance"}, + {"code":"risk_flag.not_open","domain":"risk_flag","audience":"user","defaultMessage":"风险记录已处理,不能重复处理,请刷新后核对。","recovery":"follow_guidance"}, + {"code":"auth.entity_action_unsupported","domain":"auth","audience":"user","defaultMessage":"当前单据不支持此操作,请返回页面选择有效操作。","recovery":"follow_guidance"}, + {"code":"wecom.signature_url_invalid","domain":"wecom","audience":"user","defaultMessage":"企业微信页面签名地址无效,请刷新当前页面后重试。","recovery":"follow_guidance"}, + {"code":"wecom.entry_config_incomplete","domain":"wecom","audience":"user","defaultMessage":"企业微信登录入口尚未配置完整,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"wecom.auth_config_incomplete","domain":"wecom","audience":"user","defaultMessage":"企业微信认证配置不完整,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"wecom.login_query_invalid","domain":"wecom","audience":"user","defaultMessage":"企业微信登录参数无效,请从正式入口重新进入。","recovery":"follow_guidance"}, + {"code":"wecom.pc_mode_unsupported","domain":"wecom","audience":"user","defaultMessage":"电脑扫码入口不支持此操作,请使用对应的绑定或入职入口。","recovery":"follow_guidance"}, + {"code":"wecom.bind_input_invalid","domain":"wecom","audience":"user","defaultMessage":"企业微信绑定参数无效,请刷新绑定页面后重试。","recovery":"follow_guidance"}, + {"code":"integration.outbox_query_invalid","domain":"integration","audience":"user","defaultMessage":"消息队列查询条件不符合要求,请检查状态、类型和分页。","recovery":"follow_guidance"}, + {"code":"integration.bindings_query_invalid","domain":"integration","audience":"user","defaultMessage":"外部绑定查询条件不符合要求,请检查类型和分页。","recovery":"follow_guidance"}, + {"code":"integration.outbox_id_invalid","domain":"integration","audience":"user","defaultMessage":"消息队列记录标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"integration.retry_state_invalid","domain":"integration","audience":"user","defaultMessage":"当前记录不处于失败或待人工处理状态,无法重试,请刷新后核对。","recovery":"follow_guidance"}, + {"code":"ai.reimbursement_id_invalid","domain":"ai","audience":"user","defaultMessage":"报销单标识无效,请刷新后重新打开报销详情。","recovery":"follow_guidance"}, + {"code":"ai.purchase_id_invalid","domain":"ai","audience":"user","defaultMessage":"采购申请标识无效,请刷新后重新打开采购详情。","recovery":"follow_guidance"}, + {"code":"ai.asset_id_invalid","domain":"ai","audience":"user","defaultMessage":"资产标识无效,请刷新后重新打开资产详情。","recovery":"follow_guidance"}, + {"code":"ai.feature_disabled","domain":"ai","audience":"user","defaultMessage":"当前单据未开放智能建议,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"weekly_report.query_invalid","domain":"weekly_report","audience":"user","defaultMessage":"周报查询日期不符合要求,请选择有效日期。","recovery":"follow_guidance"}, + {"code":"weekly_report.write_input_invalid","domain":"weekly_report","audience":"user","defaultMessage":"周报内容不符合要求,请检查文字长度和关联项目。","recovery":"follow_guidance"}, + {"code":"weekly_report.submit_input_invalid","domain":"weekly_report","audience":"user","defaultMessage":"周报提交信息不符合要求,请检查周报日期。","recovery":"follow_guidance"}, + {"code":"weekly_report.feature_disabled","domain":"weekly_report","audience":"user","defaultMessage":"当前未启用周报,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"weekly_report.submitted_readonly","domain":"weekly_report","audience":"user","defaultMessage":"周报已提交,当前无法修改,请刷新后核对。","recovery":"follow_guidance"}, + {"code":"weekly_report.project_linking_disabled","domain":"weekly_report","audience":"user","defaultMessage":"当前未启用项目关联,请移除关联项目或联系管理员确认。","recovery":"follow_guidance"}, + {"code":"audit.query_invalid","domain":"audit","audience":"user","defaultMessage":"审计日志查询条件不符合要求,请检查分页、日期和筛选条件。","recovery":"follow_guidance"}, + {"code":"audit.date_range_invalid","domain":"audit","audience":"user","defaultMessage":"审计日志结束时间早于开始时间,请调整查询范围。","recovery":"follow_guidance"}, + {"code":"search.feature_disabled","domain":"search","audience":"user","defaultMessage":"当前未启用全局搜索,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"search.query_invalid","domain":"search","audience":"user","defaultMessage":"搜索条件不符合要求,请输入至少2个字符并检查结果数量。","recovery":"follow_guidance"}, + {"code":"export.presence_query_invalid","domain":"export","audience":"user","defaultMessage":"在岗历史导出条件不符合要求,请检查日期、人员和文件格式。","recovery":"follow_guidance"}, + {"code":"export.reimbursement_query_invalid","domain":"export","audience":"user","defaultMessage":"报销导出条件不符合要求,请检查范围、状态、条数和文件格式。","recovery":"follow_guidance"}, + {"code":"export.invoice_query_invalid","domain":"export","audience":"user","defaultMessage":"发票导出条件不符合要求,请检查归属、状态、条数和文件格式。","recovery":"follow_guidance"}, + {"code":"export.audit_query_invalid","domain":"export","audience":"user","defaultMessage":"审计日志导出条件不符合要求,请检查日期、筛选条件和文件格式。","recovery":"follow_guidance"}, + {"code":"export.dashboard_query_invalid","domain":"export","audience":"user","defaultMessage":"看板导出条件不符合要求,请检查日期、部门和文件格式。","recovery":"follow_guidance"}, + {"code":"export.ap_input_invalid","domain":"export","audience":"user","defaultMessage":"应付工作台导出条件不符合要求,请检查筛选条件和文件格式。","recovery":"follow_guidance"}, + {"code":"export.dashboard_date_range_invalid","domain":"export","audience":"user","defaultMessage":"看板导出结束日期早于开始日期,请调整日期范围。","recovery":"follow_guidance"}, + {"code":"export.payment_batch_disabled","domain":"export","audience":"user","defaultMessage":"当前未启用付款批次功能,无法导出应付工作台数据,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"export.project_linking_disabled","domain":"export","audience":"user","defaultMessage":"当前未启用项目关联,无法导出合同台账,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"export.ap_pending_date_range_invalid","domain":"export","audience":"user","defaultMessage":"待付款批次导出结束日期早于开始日期,请调整创建日期范围。","recovery":"follow_guidance"}, + {"code":"attendance.clock_input_invalid","domain":"attendance","audience":"user","defaultMessage":"打卡信息不符合要求,请检查备注后重试。","recovery":"follow_guidance"}, + {"code":"attendance.legacy_month_query_invalid","domain":"attendance","audience":"user","defaultMessage":"考勤月份查询不符合要求,请选择2000至2100年内的有效月份。","recovery":"follow_guidance"}, + {"code":"attendance.team_query_invalid","domain":"attendance","audience":"user","defaultMessage":"团队考勤查询不符合要求,请检查日期和部门。","recovery":"follow_guidance"}, + {"code":"attendance.policy_input_invalid","domain":"attendance","audience":"user","defaultMessage":"考勤策略信息不符合要求,请检查时间格式和容差分钟数。","recovery":"follow_guidance"}, + {"code":"attendance.backfill_input_invalid","domain":"attendance","audience":"user","defaultMessage":"补录信息不符合要求,请检查员工、日期和打卡时间。","recovery":"follow_guidance"}, + {"code":"attendance.sync_input_invalid","domain":"attendance","audience":"user","defaultMessage":"考勤同步条件不符合要求,请选择不超过31天的日期范围和不超过50名员工。","recovery":"follow_guidance"}, + {"code":"attendance.sync_runs_query_invalid","domain":"attendance","audience":"user","defaultMessage":"考勤同步记录查询不符合要求,请检查分页条件。","recovery":"follow_guidance"}, + {"code":"attendance.feature_disabled","domain":"attendance","audience":"user","defaultMessage":"当前未启用网页打卡,请使用现行考勤渠道。","recovery":"follow_guidance"}, + {"code":"attendance.unified_feature_disabled","domain":"attendance","audience":"user","defaultMessage":"当前未启用统一考勤,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"attendance.datetime_invalid","domain":"attendance","audience":"user","defaultMessage":"打卡时间无法识别,请填写有效的日期时间。","recovery":"follow_guidance"}, + {"code":"attendance.exempt_day","domain":"attendance","audience":"user","defaultMessage":"当天属于免打卡范围,无需提交打卡。","recovery":"follow_guidance"}, + {"code":"attendance.missing_clock_in","domain":"attendance","audience":"user","defaultMessage":"当天尚未签到,请先签到或联系管理员核查。","recovery":"follow_guidance"}, + {"code":"attendance.missing_backfill_values","domain":"attendance","audience":"user","defaultMessage":"补录缺少打卡时间,请至少填写签到或签退时间。","recovery":"follow_guidance"}, + {"code":"attendance.clock_range_invalid","domain":"attendance","audience":"user","defaultMessage":"签退时间早于签到时间,请核对补录时间。","recovery":"follow_guidance"}, + {"code":"attendance.policy_time_invalid","domain":"attendance","audience":"user","defaultMessage":"考勤策略时间无效,请使用00:00至23:59的小时和分钟格式。","recovery":"follow_guidance"}, + {"code":"attendance.tolerance_invalid","domain":"attendance","audience":"user","defaultMessage":"迟到容差无效,请填写0至600之间的整数分钟数。","recovery":"follow_guidance"}, + {"code":"attendance.work_range_invalid","domain":"attendance","audience":"user","defaultMessage":"上班时间必须早于下班时间,请调整考勤策略。","recovery":"follow_guidance"}, + {"code":"attendance.lunch_range_invalid","domain":"attendance","audience":"user","defaultMessage":"午休开始时间必须早于结束时间,请调整考勤策略。","recovery":"follow_guidance"}, + {"code":"attendance.lunch_outside_work_range","domain":"attendance","audience":"user","defaultMessage":"午休时间超出工作时段,请调整考勤策略。","recovery":"follow_guidance"}, + {"code":"attendance.year_invalid","domain":"attendance","audience":"user","defaultMessage":"考勤年份无效,请选择2000至2100之间的年份。","recovery":"follow_guidance"}, + {"code":"presence.query_invalid","domain":"presence","audience":"user","defaultMessage":"在岗查询条件不符合要求,请检查日期。","recovery":"follow_guidance"}, + {"code":"presence.write_input_invalid","domain":"presence","audience":"user","defaultMessage":"在岗信息不符合要求,请检查日期、状态和备注。","recovery":"follow_guidance"}, + {"code":"presence.history_query_invalid","domain":"presence","audience":"user","defaultMessage":"在岗历史查询条件不符合要求,请检查日期范围。","recovery":"follow_guidance"}, + {"code":"presence.team_history_query_invalid","domain":"presence","audience":"user","defaultMessage":"团队在岗历史查询条件不符合要求,请检查日期、员工和筛选条件。","recovery":"follow_guidance"}, + {"code":"presence.employee_id_invalid","domain":"presence","audience":"user","defaultMessage":"员工标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"presence.override_input_invalid","domain":"presence","audience":"user","defaultMessage":"在岗调整信息不符合要求,请检查日期、状态和备注。","recovery":"follow_guidance"}, + {"code":"presence.feature_disabled","domain":"presence","audience":"user","defaultMessage":"当前未启用在岗看板,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"presence.approved_leave_readonly","domain":"presence","audience":"user","defaultMessage":"当天已有获批请假记录,无法自行修改在岗状态,请联系管理员核查。","recovery":"follow_guidance"}, + {"code":"export.presence_too_large","domain":"export","audience":"user","defaultMessage":"在岗历史导出条目过多,请缩小日期或人员范围。","recovery":"follow_guidance"}, + {"code":"delegation.id_invalid","domain":"delegation","audience":"user","defaultMessage":"委托记录标识无效,请刷新委托列表后重试。","recovery":"follow_guidance"}, + {"code":"delegation.create_input_invalid","domain":"delegation","audience":"user","defaultMessage":"委托资料格式有误,请检查受托人、范围及起止时间。","recovery":"follow_guidance"}, + {"code":"delegation.grant_input_invalid","domain":"delegation","audience":"user","defaultMessage":"委托授权资料格式有误,请检查委托人、受托人、范围及起止时间。","recovery":"follow_guidance"}, + {"code":"delegation.start_time_invalid","domain":"delegation","audience":"user","defaultMessage":"委托开始时间无效,请重新选择。","recovery":"follow_guidance"}, + {"code":"delegation.end_time_invalid","domain":"delegation","audience":"user","defaultMessage":"委托结束时间无效,请重新选择。","recovery":"follow_guidance"}, + {"code":"delegation.time_range_invalid","domain":"delegation","audience":"user","defaultMessage":"委托结束时间必须晚于开始时间,请调整后重试。","recovery":"follow_guidance"}, + {"code":"delegation.status_query_invalid","domain":"delegation","audience":"user","defaultMessage":"委托状态筛选条件无效,请重新选择状态。","recovery":"follow_guidance"}, + {"code":"delegation.grants_query_invalid","domain":"delegation","audience":"user","defaultMessage":"委托授权筛选条件有误,请检查人员与有效期选项。","recovery":"follow_guidance"}, + {"code":"delegation.delegate_unavailable","domain":"delegation","audience":"user","defaultMessage":"受托人不存在或不在职,请选择在职人员。","recovery":"follow_guidance"}, + {"code":"delegation.delegate_proxy_permission_required","domain":"delegation","audience":"user","defaultMessage":"受托人不具备报销代提交权限,请选择具备该权限的人员。","recovery":"follow_guidance"}, + {"code":"delegation.delegate_approval_permission_required","domain":"delegation","audience":"user","defaultMessage":"受托人不具备报销审批权限,请选择具备审批权限的人员。","recovery":"follow_guidance"}, + {"code":"delegation.self_not_allowed","domain":"delegation","audience":"user","defaultMessage":"委托人和受托人不能是同一人,请重新选择受托人。","recovery":"follow_guidance"}, + {"code":"delegation.principal_unavailable","domain":"delegation","audience":"user","defaultMessage":"委托人不存在或不在职,请选择在职人员。","recovery":"follow_guidance"}, + {"code":"workflow_definition.id_invalid","domain":"workflow_definition","audience":"user","defaultMessage":"审批流程标识无效,请刷新流程列表后重试。","recovery":"follow_guidance"}, + {"code":"workflow_definition.create_input_invalid","domain":"workflow_definition","audience":"user","defaultMessage":"审批流程新增资料有误,请检查名称、业务类型及1至20个审批步骤。","recovery":"follow_guidance"}, + {"code":"workflow_definition.update_input_invalid","domain":"workflow_definition","audience":"user","defaultMessage":"审批流程修改内容为空或格式有误,请检查步骤和人员配置后保存。","recovery":"follow_guidance"}, + {"code":"workflow_definition.entity_type_invalid","domain":"workflow_definition","audience":"user","defaultMessage":"审批流程业务类型无效,请重新选择。","recovery":"follow_guidance"}, + {"code":"workflow_definition.feature_disabled","domain":"workflow_definition","audience":"user","defaultMessage":"审批流程管理功能尚未启用,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"workflow_definition.reimbursement_readonly","domain":"workflow_definition","audience":"user","defaultMessage":"报销审批流程由审批规则生成,请在审批规则中维护。","recovery":"follow_guidance"}, + {"code":"workflow_definition.not_editable","domain":"workflow_definition","audience":"user","defaultMessage":"已发布生效或已归档的审批流程不能直接修改,请先确认流程版本。","recovery":"follow_guidance"}, + {"code":"workflow_definition.archived_not_publishable","domain":"workflow_definition","audience":"user","defaultMessage":"已归档的审批流程不能发布,请选择可用草稿版本。","recovery":"follow_guidance"}, + {"code":"approval_rule.create_input_invalid","domain":"approval_rule","audience":"user","defaultMessage":"审批规则新增资料有误,请检查金额范围、审批角色和优先级。","recovery":"follow_guidance"}, + {"code":"approval_rule.update_input_invalid","domain":"approval_rule","audience":"user","defaultMessage":"审批规则修改资料有误,请检查金额范围、审批角色和优先级。","recovery":"follow_guidance"}, + {"code":"approval_rule.id_invalid","domain":"approval_rule","audience":"user","defaultMessage":"审批规则标识无效,请刷新规则列表后重试。","recovery":"follow_guidance"}, + {"code":"approval_rule.amount_invalid","domain":"approval_rule","audience":"user","defaultMessage":"审批金额范围超出可处理范围,请填写有效的上下限金额。","recovery":"follow_guidance"}, + {"code":"approval_rule.amount_range_invalid","domain":"approval_rule","audience":"user","defaultMessage":"审批金额上限必须大于下限,请调整金额范围。","recovery":"follow_guidance"}, + {"code":"approval_rule.fallback_role_invalid","domain":"approval_rule","audience":"user","defaultMessage":"负责人本人申请的兜底角色仅支持财务或财务经理,请重新选择。","recovery":"follow_guidance"}, + {"code":"approval_rule.empty_update","domain":"approval_rule","audience":"user","defaultMessage":"尚未填写需要修改的审批规则内容,请修改后保存。","recovery":"follow_guidance"}, + {"code":"workflow.steps_invalid","domain":"workflow","audience":"user","defaultMessage":"审批流程缺少有效步骤,请联系管理员检查流程配置。","recovery":"follow_guidance"}, + {"code":"workflow.business_role_unsupported","domain":"workflow","audience":"user","defaultMessage":"审批流程包含不支持的业务审批角色,请联系管理员修正。","recovery":"follow_guidance"}, + {"code":"workflow.approval_rule_invalid","domain":"workflow","audience":"user","defaultMessage":"审批规则配置无效,请联系管理员核对后重试。","recovery":"follow_guidance"}, + {"code":"workflow.approval_chain_empty","domain":"workflow","audience":"user","defaultMessage":"审批规则未配置审批层级,请联系管理员补充。","recovery":"follow_guidance"}, + {"code":"workflow.fallback_role_unsupported","domain":"workflow","audience":"user","defaultMessage":"负责人本人申请的兜底审批角色无效,请联系管理员核对。","recovery":"follow_guidance"}, + {"code":"workflow.fallback_role_is_self","domain":"workflow","audience":"user","defaultMessage":"当前兜底审批角色由申请人本人持有,无法继续,请联系管理员安排符合要求的审批人。","recovery":"follow_guidance"}, + {"code":"workflow.fallback_holder_unavailable","domain":"workflow","audience":"user","defaultMessage":"当前没有可处理的非本人兜底审批人,请联系管理员核对角色人员。","recovery":"follow_guidance"}, + {"code":"workflow.fallback_chain_empty","domain":"workflow","audience":"user","defaultMessage":"本人申请调整后没有可用审批步骤,请联系管理员检查审批规则。","recovery":"follow_guidance"}, + {"code":"workflow.assignee_rule_invalid","domain":"workflow","audience":"user","defaultMessage":"审批人分配规则无效,请联系管理员检查流程配置。","recovery":"follow_guidance"}, + {"code":"workflow.role_assignee_invalid","domain":"workflow","audience":"user","defaultMessage":"审批步骤未配置有效的角色,请联系管理员修正。","recovery":"follow_guidance"}, + {"code":"workflow.user_assignee_invalid","domain":"workflow","audience":"user","defaultMessage":"审批步骤指定的人员标识无效,请联系管理员重新配置。","recovery":"follow_guidance"}, + {"code":"workflow.manager_context_invalid","domain":"workflow","audience":"user","defaultMessage":"无法确定申请人的主管关系,请联系管理员核对人员资料。","recovery":"follow_guidance"}, + {"code":"workflow.assignee_type_unsupported","domain":"workflow","audience":"user","defaultMessage":"审批人分配方式不受支持,请联系管理员修正流程。","recovery":"follow_guidance"}, + {"code":"workflow.manager_unavailable","domain":"workflow","audience":"user","defaultMessage":"无法找到可处理的主管审批人,请联系管理员检查汇报关系后重试。","recovery":"follow_guidance"}, + {"code":"workflow.assignee_resolution_invalid","domain":"workflow","audience":"user","defaultMessage":"审批步骤未能解析出有效审批人,请联系管理员检查配置。","recovery":"follow_guidance"}, + {"code":"purchase_request.id_invalid","domain":"purchase_request","audience":"user","defaultMessage":"采购申请标识无效,请刷新采购列表后重试。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.id_invalid","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件标识无效,请刷新附件列表后重试。","recovery":"follow_guidance"}, + {"code":"purchase_request.query_invalid","domain":"purchase_request","audience":"user","defaultMessage":"采购筛选条件有误,请检查状态、页码和归属信息。","recovery":"follow_guidance"}, + {"code":"purchase_request.update_input_invalid","domain":"purchase_request","audience":"user","defaultMessage":"采购修改资料格式有误,请检查字段、明细和交付日期。","recovery":"follow_guidance"}, + {"code":"purchase_request.archive_input_invalid","domain":"purchase_request","audience":"user","defaultMessage":"采购归档资料有误,请填写采购单号并检查合同号和附件清单。","recovery":"follow_guidance"}, + {"code":"purchase_request.precheck_blocked","domain":"purchase_request","audience":"user","defaultMessage":"采购预检未通过,请查看预检结果并处理阻断项后提交。","recovery":"follow_guidance"}, + {"code":"purchase_request.quantity_invalid","domain":"purchase_request","audience":"user","defaultMessage":"采购明细数量超出可处理范围,请填写有效数量。","recovery":"follow_guidance"}, + {"code":"purchase_request.quantity_too_small","domain":"purchase_request","audience":"user","defaultMessage":"采购明细数量不得小于0.01,请修正数量后保存。","recovery":"follow_guidance"}, + {"code":"purchase_request.unit_price_invalid","domain":"purchase_request","audience":"user","defaultMessage":"采购明细单价超出可处理范围,请填写有效单价。","recovery":"follow_guidance"}, + {"code":"purchase_request.unit_price_negative","domain":"purchase_request","audience":"user","defaultMessage":"采购明细单价不能为负数,请修正后保存。","recovery":"follow_guidance"}, + {"code":"purchase_request.company_not_found","domain":"purchase_request","audience":"user","defaultMessage":"采购所属公司不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"purchase_request.company_inactive","domain":"purchase_request","audience":"user","defaultMessage":"采购所属公司已停用或归档,请选择可用公司。","recovery":"follow_guidance"}, + {"code":"purchase_request.cost_center_not_found","domain":"purchase_request","audience":"user","defaultMessage":"采购成本中心不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"purchase_request.cost_center_inactive","domain":"purchase_request","audience":"user","defaultMessage":"采购成本中心已停用或归档,请选择可用成本中心。","recovery":"follow_guidance"}, + {"code":"purchase_request.cost_center_company_mismatch","domain":"purchase_request","audience":"user","defaultMessage":"采购成本中心不属于所选公司,请核对归属信息。","recovery":"follow_guidance"}, + {"code":"purchase_request.supplier_not_found","domain":"purchase_request","audience":"user","defaultMessage":"采购供应商不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"purchase_request.supplier_inactive","domain":"purchase_request","audience":"user","defaultMessage":"采购供应商已停用或归档,请选择可用供应商。","recovery":"follow_guidance"}, + {"code":"purchase_request.supplier_company_mismatch","domain":"purchase_request","audience":"user","defaultMessage":"采购供应商不适用于所选公司,请核对供应商归属。","recovery":"follow_guidance"}, + {"code":"purchase_request.document_category_not_found","domain":"purchase_request","audience":"user","defaultMessage":"采购单据分类不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"purchase_request.document_category_type_invalid","domain":"purchase_request","audience":"user","defaultMessage":"所选单据分类不适用于采购申请,请重新选择。","recovery":"follow_guidance"}, + {"code":"purchase_request.document_category_inactive","domain":"purchase_request","audience":"user","defaultMessage":"采购单据分类已停用或归档,请选择可用分类。","recovery":"follow_guidance"}, + {"code":"purchase_request.budget_pool_not_found","domain":"purchase_request","audience":"user","defaultMessage":"所选采购预算池不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"purchase_request.budget_pool_inactive","domain":"purchase_request","audience":"user","defaultMessage":"所选采购预算池不可用,请联系财务核对预算。","recovery":"follow_guidance"}, + {"code":"purchase_request.project_link_disabled","domain":"purchase_request","audience":"user","defaultMessage":"采购项目关联功能尚未启用,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"purchase_request.project_archived","domain":"purchase_request","audience":"user","defaultMessage":"已归档项目不能新增采购关联,请选择可用项目。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.download_input_invalid","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件下载参数有误,请检查下载理由后重试。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.delete_input_invalid","domain":"purchase_attachment","audience":"user","defaultMessage":"删除采购附件需要2至200字的理由,请填写后重试。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.download_blocked","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件已被隔离或未通过安全校验,无法下载,请联系管理员核查。","recovery":"follow_guidance"}, + {"code":"purchase_request.not_editable","domain":"purchase_request","audience":"user","defaultMessage":"只有草稿采购申请可以修改,请刷新确认申请状态。","recovery":"follow_guidance"}, + {"code":"purchase_request.workflow_missing","domain":"purchase_request","audience":"user","defaultMessage":"采购审批流程尚未配置,请联系管理员配置后提交。","recovery":"follow_guidance"}, + {"code":"purchase_request.approver_unavailable","domain":"purchase_request","audience":"user","defaultMessage":"采购申请当前没有可处理的审批人,请联系管理员核对审批配置后重试。","recovery":"follow_guidance"}, + {"code":"purchase_request.not_submittable","domain":"purchase_request","audience":"user","defaultMessage":"只有草稿采购申请可以提交,请刷新确认申请状态。","recovery":"follow_guidance"}, + {"code":"purchase_request.company_required","domain":"purchase_request","audience":"user","defaultMessage":"提交采购申请前,请选择所属公司。","recovery":"follow_guidance"}, + {"code":"purchase_request.cost_center_required","domain":"purchase_request","audience":"user","defaultMessage":"提交采购申请前,请选择成本中心。","recovery":"follow_guidance"}, + {"code":"purchase_request.supplier_required","domain":"purchase_request","audience":"user","defaultMessage":"提交采购申请前,请选择供应商。","recovery":"follow_guidance"}, + {"code":"purchase_request.document_category_required","domain":"purchase_request","audience":"user","defaultMessage":"提交采购申请前,请选择单据分类。","recovery":"follow_guidance"}, + {"code":"purchase_request.items_required","domain":"purchase_request","audience":"user","defaultMessage":"提交采购申请前,请至少添加一条采购明细。","recovery":"follow_guidance"}, + {"code":"purchase_request.budget_context_mismatch","domain":"purchase_request","audience":"user","defaultMessage":"所选预算池与当前采购归属或期间不匹配,请重新选择预算池。","recovery":"follow_guidance"}, + {"code":"purchase_request.budget_required","domain":"purchase_request","audience":"user","defaultMessage":"采购申请缺少适用预算池,请联系财务配置预算后提交。","recovery":"follow_guidance"}, + {"code":"purchase_request.not_withdrawable","domain":"purchase_request","audience":"user","defaultMessage":"只有审批中的采购申请可以撤回,请刷新确认申请状态。","recovery":"follow_guidance"}, + {"code":"purchase_request.not_archivable","domain":"purchase_request","audience":"user","defaultMessage":"只有已批准的采购申请可以登记订单归档,请先完成审批。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.not_editable","domain":"purchase_attachment","audience":"user","defaultMessage":"只有草稿采购申请可以上传或删除附件,请刷新确认申请状态。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.multipart_required","domain":"purchase_attachment","audience":"user","defaultMessage":"请通过文件上传入口选择采购附件后重试。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.single_file_required","domain":"purchase_attachment","audience":"user","defaultMessage":"每次只能上传一个采购附件,请逐个上传。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.field_invalid","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件上传字段有误,请重新选择文件后上传。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.too_large","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件超过允许大小,请缩小文件后重新上传。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.multipart_limit","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件上传字段或分段过多,请重新选择文件后上传。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.validation_failed","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件未通过文件校验,请更换有效文件后重试。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.file_required","domain":"purchase_attachment","audience":"user","defaultMessage":"请选择采购附件后上传。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.download_reason_required","domain":"purchase_attachment","audience":"user","defaultMessage":"下载此采购附件需要填写理由,请补充后重试。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.dangerous_filename","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件文件名不符合安全要求,请使用不含路径或可执行扩展名的普通文件名。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.corrupt_content","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件内容损坏或格式无效,请重新导出文件后上传。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.multiple_pages","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件图片包含多帧或多页,请转换为单张图片后上传。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.pixel_limit","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件图片像素过大,请降低分辨率后上传。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.type_mismatch","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件实际内容与文件类型不一致,请重新导出正确格式后上传。","recovery":"follow_guidance"}, + {"code":"purchase_attachment.empty_file","domain":"purchase_attachment","audience":"user","defaultMessage":"采购附件内容为空,请选择有内容的文件。","recovery":"follow_guidance"}, + {"code":"asset_movement.id_invalid","domain":"asset_movement","audience":"user","defaultMessage":"资产标识无效,请刷新资产列表后重试。","recovery":"follow_guidance"}, + {"code":"asset_movement.assign_input_invalid","domain":"asset_movement","audience":"user","defaultMessage":"资产领用资料有误,请选择领用人并检查备注。","recovery":"follow_guidance"}, + {"code":"asset_movement.return_input_invalid","domain":"asset_movement","audience":"user","defaultMessage":"资产归还资料有误,请检查备注后重试。","recovery":"follow_guidance"}, + {"code":"asset_movement.transfer_input_invalid","domain":"asset_movement","audience":"user","defaultMessage":"资产调拨资料有误,请检查接收人、位置和备注。","recovery":"follow_guidance"}, + {"code":"asset_movement.status_blocked","domain":"asset_movement","audience":"user","defaultMessage":"已报废或归档的资产不能领用、归还或调拨,请确认资产状态。","recovery":"follow_guidance"}, + {"code":"asset_movement.not_found","domain":"asset_movement","audience":"user","defaultMessage":"待流转的资产不存在,请刷新资产列表后重试。","recovery":"follow_guidance"}, + {"code":"asset_movement.employee_not_found","domain":"asset_movement","audience":"user","defaultMessage":"资产接收人不存在,请刷新人员列表后重新选择。","recovery":"follow_guidance"}, + {"code":"asset_movement.employee_inactive","domain":"asset_movement","audience":"user","defaultMessage":"资产接收人已不在职,请选择在职人员。","recovery":"follow_guidance"}, + {"code":"asset_movement.already_assigned","domain":"asset_movement","audience":"user","defaultMessage":"资产已有领用人或未关闭的领用记录,请先归还或使用调拨。","recovery":"follow_guidance"}, + {"code":"asset_movement.not_assigned","domain":"asset_movement","audience":"user","defaultMessage":"资产尚未领用,无需归还,请刷新确认资产记录。","recovery":"follow_guidance"}, + {"code":"asset_movement.use_return","domain":"asset_movement","audience":"user","defaultMessage":"清空资产领用人请使用归还操作,不能通过调拨清空。","recovery":"follow_guidance"}, + {"code":"asset_movement.no_change","domain":"asset_movement","audience":"user","defaultMessage":"本次调拨没有改变接收人或位置,请填写实际变更。","recovery":"follow_guidance"}, + {"code":"inventory_check.id_invalid","domain":"inventory_check","audience":"user","defaultMessage":"盘点单标识无效,请刷新盘点列表后重试。","recovery":"follow_guidance"}, + {"code":"inventory_check.scope_id_invalid","domain":"inventory_check","audience":"user","defaultMessage":"盘点范围标识无效,请重新选择公司或成本中心。","recovery":"follow_guidance"}, + {"code":"inventory_check.query_invalid","domain":"inventory_check","audience":"user","defaultMessage":"盘点筛选条件有误,请检查状态和范围类型。","recovery":"follow_guidance"}, + {"code":"inventory_check.create_input_invalid","domain":"inventory_check","audience":"user","defaultMessage":"盘点创建资料有误,请检查范围类型和范围值。","recovery":"follow_guidance"}, + {"code":"inventory_check.record_input_invalid","domain":"inventory_check","audience":"user","defaultMessage":"盘点结果有误,请至少填写一项并检查资产、结果和备注。","recovery":"follow_guidance"}, + {"code":"inventory_check.scope_required","domain":"inventory_check","audience":"user","defaultMessage":"请填写所选盘点范围对应的公司、成本中心或位置。","recovery":"follow_guidance"}, + {"code":"inventory_check.company_not_found","domain":"inventory_check","audience":"user","defaultMessage":"盘点范围的公司不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"inventory_check.cost_center_not_found","domain":"inventory_check","audience":"user","defaultMessage":"盘点范围的成本中心不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"inventory_check.not_found","domain":"inventory_check","audience":"user","defaultMessage":"盘点单不存在,请刷新盘点列表后重试。","recovery":"follow_guidance"}, + {"code":"inventory_check.start_state_invalid","domain":"inventory_check","audience":"user","defaultMessage":"只有草稿盘点单可以开始,请刷新确认盘点状态。","recovery":"follow_guidance"}, + {"code":"inventory_check.record_state_invalid","domain":"inventory_check","audience":"user","defaultMessage":"只有进行中的盘点单可以记录结果,请先开始盘点或刷新状态。","recovery":"follow_guidance"}, + {"code":"inventory_check.complete_state_invalid","domain":"inventory_check","audience":"user","defaultMessage":"只有进行中的盘点单可以完成,请刷新确认盘点状态。","recovery":"follow_guidance"}, + {"code":"inventory_check.asset_scope_mismatch","domain":"inventory_check","audience":"user","defaultMessage":"资产不属于本次盘点范围,请核对公司、成本中心或位置。","recovery":"follow_guidance"}, + {"code":"inventory_check.duplicate_asset","domain":"inventory_check","audience":"user","defaultMessage":"同批盘点结果包含重复资产,请合并后重新提交。","recovery":"follow_guidance"}, + {"code":"inventory_check.asset_not_found","domain":"inventory_check","audience":"user","defaultMessage":"盘点结果中的资产不存在,请刷新资产列表后重新选择。","recovery":"follow_guidance"}, + {"code":"asset.category_id_invalid","domain":"asset","audience":"user","defaultMessage":"资产分类标识无效,请刷新分类列表后重试。","recovery":"follow_guidance"}, + {"code":"asset.id_invalid","domain":"asset","audience":"user","defaultMessage":"资产标识无效,请刷新资产列表后重试。","recovery":"follow_guidance"}, + {"code":"asset.category_query_invalid","domain":"asset","audience":"user","defaultMessage":"资产分类筛选条件有误,请检查启用和归档选项。","recovery":"follow_guidance"}, + {"code":"asset.query_invalid","domain":"asset","audience":"user","defaultMessage":"资产筛选条件有误,请检查分类、人员、归属和状态后重试。","recovery":"follow_guidance"}, + {"code":"asset.assignee_query_invalid","domain":"asset","audience":"user","defaultMessage":"领用人查询条件有误,请缩短关键词后重试。","recovery":"follow_guidance"}, + {"code":"asset.category_create_input_invalid","domain":"asset","audience":"user","defaultMessage":"资产分类资料不完整或格式有误,请检查编码、名称和单据分类。","recovery":"follow_guidance"}, + {"code":"asset.category_update_input_invalid","domain":"asset","audience":"user","defaultMessage":"资产分类修改内容为空或格式有误,请检查后保存。","recovery":"follow_guidance"}, + {"code":"asset.create_input_invalid","domain":"asset","audience":"user","defaultMessage":"资产资料不完整或格式有误,请检查必填项、人员和日期。","recovery":"follow_guidance"}, + {"code":"asset.update_input_invalid","domain":"asset","audience":"user","defaultMessage":"资产修改内容为空或格式有误,请检查字段和日期后保存。","recovery":"follow_guidance"}, + {"code":"asset.index_import_input_invalid","domain":"asset","audience":"user","defaultMessage":"脱敏资产索引格式有误,请检查条目、字段和数量后重新导入。","recovery":"follow_guidance"}, + {"code":"asset.unsafe_content","domain":"asset","audience":"user","defaultMessage":"资产资料包含不允许保存的文本、哈希或元数据,请脱敏并检查格式和长度。","recovery":"follow_guidance"}, + {"code":"asset.index_import_unsafe_content","domain":"asset","audience":"user","defaultMessage":"脱敏资产索引含不允许保存的文本或哈希,请检查并脱敏后重试。","recovery":"follow_guidance"}, + {"code":"asset.document_category_not_found","domain":"asset","audience":"user","defaultMessage":"所选单据分类不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"asset.document_category_type_invalid","domain":"asset","audience":"user","defaultMessage":"所选单据分类不适用于资产卡片,请选择资产卡片分类。","recovery":"follow_guidance"}, + {"code":"asset.document_category_inactive","domain":"asset","audience":"user","defaultMessage":"所选单据分类已停用或归档,请选择可用分类。","recovery":"follow_guidance"}, + {"code":"asset.category_not_found","domain":"asset","audience":"user","defaultMessage":"资产分类不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"asset.category_inactive","domain":"asset","audience":"user","defaultMessage":"资产分类已停用或归档,请选择可用分类。","recovery":"follow_guidance"}, + {"code":"asset.category_document_invalid","domain":"asset","audience":"user","defaultMessage":"资产分类关联的单据分类不可用,请联系管理员修正分类设置。","recovery":"follow_guidance"}, + {"code":"asset.company_not_found","domain":"asset","audience":"user","defaultMessage":"资产所属公司不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"asset.company_inactive","domain":"asset","audience":"user","defaultMessage":"资产所属公司已停用或归档,请选择可用公司。","recovery":"follow_guidance"}, + {"code":"asset.cost_center_not_found","domain":"asset","audience":"user","defaultMessage":"资产成本中心不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"asset.cost_center_inactive","domain":"asset","audience":"user","defaultMessage":"资产成本中心已停用或归档,请选择可用成本中心。","recovery":"follow_guidance"}, + {"code":"asset.cost_center_company_mismatch","domain":"asset","audience":"user","defaultMessage":"资产成本中心不属于所选公司,请核对公司与成本中心。","recovery":"follow_guidance"}, + {"code":"asset.purchase_not_found","domain":"asset","audience":"user","defaultMessage":"关联采购申请不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"asset.purchase_not_approved","domain":"asset","audience":"user","defaultMessage":"关联采购申请尚未批准,请在采购审批完成后登记资产。","recovery":"follow_guidance"}, + {"code":"asset.index_import_dimensions_required","domain":"asset","audience":"user","defaultMessage":"写入资产索引前,请填写资产分类、所属公司和成本中心。","recovery":"follow_guidance"}, + {"code":"asset.not_found","domain":"asset","audience":"user","defaultMessage":"资产不存在,请刷新资产列表后重试。","recovery":"follow_guidance"}, + {"code":"asset.assignee_not_found","domain":"asset","audience":"user","defaultMessage":"所选资产领用人不存在,请刷新人员列表后重新选择。","recovery":"follow_guidance"}, + {"code":"asset.assignee_inactive","domain":"asset","audience":"user","defaultMessage":"所选资产领用人已不在职,请选择在职人员。","recovery":"follow_guidance"}, + {"code":"asset.owner_not_found","domain":"asset","audience":"user","defaultMessage":"所选资产责任人不存在,请刷新人员列表后重新选择。","recovery":"follow_guidance"}, + {"code":"asset.owner_inactive","domain":"asset","audience":"user","defaultMessage":"所选资产责任人已不在职,请选择在职人员。","recovery":"follow_guidance"}, + {"code":"asset.keeper_not_found","domain":"asset","audience":"user","defaultMessage":"所选资产保管人不存在,请刷新人员列表后重新选择。","recovery":"follow_guidance"}, + {"code":"asset.keeper_inactive","domain":"asset","audience":"user","defaultMessage":"所选资产保管人已不在职,请选择在职人员。","recovery":"follow_guidance"}, + {"code":"contract_registry.create_input_invalid","domain":"contract_registry","audience":"user","defaultMessage":"合同台账新增资料不完整或格式有误,请检查必填项、金额和日期。","recovery":"follow_guidance"}, + {"code":"contract_registry.update_input_invalid","domain":"contract_registry","audience":"user","defaultMessage":"合同台账修改内容为空或格式有误,请检查后重新保存。","recovery":"follow_guidance"}, + {"code":"contract_registry.terminate_input_invalid","domain":"contract_registry","audience":"user","defaultMessage":"请填写10至1000字的合同终止原因后重试。","recovery":"follow_guidance"}, + {"code":"contract_registry.correction_input_invalid","domain":"contract_registry","audience":"user","defaultMessage":"请填写10至1000字的合同纠错说明后重试。","recovery":"follow_guidance"}, + {"code":"contract_registry.feature_disabled","domain":"contract_registry","audience":"user","defaultMessage":"合同台账功能尚未启用,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"contract_registry.state_conflict","domain":"contract_registry","audience":"user","defaultMessage":"合同状态或资料已被更新,请刷新后重试。","recovery":"follow_guidance"}, + {"code":"contract_registry.terminated_is_final","domain":"contract_registry","audience":"user","defaultMessage":"已终止的合同不能修改,请查看原合同记录。","recovery":"follow_guidance"}, + {"code":"contract_registry.archived_fields_locked","domain":"contract_registry","audience":"user","defaultMessage":"已归档合同不能直接修改资料,请通过纠错记录补充说明。","recovery":"follow_guidance"}, + {"code":"contract_registry.terminate_not_allowed","domain":"contract_registry","audience":"user","defaultMessage":"仅生效或已到期的合同可以终止,请刷新确认合同状态。","recovery":"follow_guidance"}, + {"code":"contract_registry.terminate_conflict","domain":"contract_registry","audience":"user","defaultMessage":"合同已发生变更,本次未完成终止,请刷新后确认。","recovery":"follow_guidance"}, + {"code":"contract_registry.correction_requires_archived","domain":"contract_registry","audience":"user","defaultMessage":"仅已归档合同可以追加纠错记录,请先确认合同状态。","recovery":"follow_guidance"}, + {"code":"contract_registry.correction_state_conflict","domain":"contract_registry","audience":"user","defaultMessage":"合同归档状态已变化,本次未保存纠错记录,请刷新后重试。","recovery":"follow_guidance"}, + {"code":"contract_registry.renew_not_allowed","domain":"contract_registry","audience":"user","defaultMessage":"仅生效或已到期的合同可以续订,请刷新确认合同状态。","recovery":"follow_guidance"}, + {"code":"purchase_budget.reserve_already_exists","domain":"purchase_budget","audience":"user","defaultMessage":"该采购申请已有未释放的预算占用,请刷新查看,避免重复提交。","recovery":"follow_guidance"}, + {"code":"purchase_budget.pool_not_found","domain":"purchase_budget","audience":"user","defaultMessage":"采购预算池已不存在,请联系财务核对预算后重试。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.query_invalid","domain":"travel_reconciliation","audience":"user","defaultMessage":"差旅对账筛选条件无效,请检查日期、状态和分页后重试。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.create_input_invalid","domain":"travel_reconciliation","audience":"user","defaultMessage":"对账批次资料不完整或格式有误,请检查公司、成本中心和期间后重试。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.period_reversed","domain":"travel_reconciliation","audience":"user","defaultMessage":"对账开始日期不能晚于结束日期,请调整对账期间。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.company_not_found","domain":"travel_reconciliation","audience":"user","defaultMessage":"对账公司不存在,请刷新公司列表后重新选择。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.cost_center_not_found","domain":"travel_reconciliation","audience":"user","defaultMessage":"对账成本中心不存在,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.cost_center_company_mismatch","domain":"travel_reconciliation","audience":"user","defaultMessage":"对账成本中心与所选公司不匹配,请重新选择。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.empty_scope","domain":"travel_reconciliation","audience":"user","defaultMessage":"所选范围没有可对账的订单或交易,请检查期间和公司范围后重试。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.batch_id_invalid","domain":"travel_reconciliation","audience":"user","defaultMessage":"对账批次标识无效,请刷新批次列表后重新选择。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.item_id_invalid","domain":"travel_reconciliation","audience":"user","defaultMessage":"对账条目标识无效,请刷新批次详情后重新选择。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.confirm_input_invalid","domain":"travel_reconciliation","audience":"user","defaultMessage":"对账确认备注格式有误,请检查填写内容后重新确认。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.batch_not_editable","domain":"travel_reconciliation","audience":"user","defaultMessage":"对账批次已不可修改,请刷新并确认批次状态。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.item_not_pending","domain":"travel_reconciliation","audience":"user","defaultMessage":"该对账条目当前不是待确认状态,请刷新后核对处理结果。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.item_not_confirmed","domain":"travel_reconciliation","audience":"user","defaultMessage":"只有已确认的对账条目可以重开,请刷新并确认条目状态。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.matched_item_not_reopenable","domain":"travel_reconciliation","audience":"user","defaultMessage":"自动匹配成功的对账条目不能重开,请检查关联订单和交易记录。","recovery":"follow_guidance"}, + {"code":"travel_reconciliation.batch_has_pending_items","domain":"travel_reconciliation","audience":"user","defaultMessage":"对账批次仍有未确认条目,请先处理全部条目后再结案。","recovery":"follow_guidance"}, + {"code":"travel_import.sync_disabled","domain":"travel_import","audience":"user","defaultMessage":"差旅同步尚未启用,请联系管理员确认后再导入。","recovery":"follow_guidance"}, + {"code":"travel_payment.budget_pool_not_found","domain":"travel_payment","audience":"user","defaultMessage":"关联预算池不存在,请联系财务检查预算配置后重试。","recovery":"follow_guidance"}, + {"code":"travel_order.import_input_invalid","domain":"travel_order","audience":"user","defaultMessage":"差旅订单导入列表格式有误,请检查数据并确保每批为1至100项。","recovery":"follow_guidance"}, + {"code":"travel_order.import_empty","domain":"travel_order","audience":"user","defaultMessage":"未识别到有效的差旅订单记录,请检查必填字段和格式后重新导入。","recovery":"follow_guidance"}, + {"code":"travel_order.id_invalid","domain":"travel_order","audience":"user","defaultMessage":"差旅订单标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"travel_authorization.import_input_invalid","domain":"travel_authorization","audience":"user","defaultMessage":"差旅授权导入列表格式有误,请检查数据并确保每批为1至100项。","recovery":"follow_guidance"}, + {"code":"travel_authorization.import_empty","domain":"travel_authorization","audience":"user","defaultMessage":"未识别到有效的差旅授权记录,请检查必填字段和格式后重新导入。","recovery":"follow_guidance"}, + {"code":"travel_authorization.query_invalid","domain":"travel_authorization","audience":"user","defaultMessage":"差旅授权筛选条件无效,请检查筛选项和分页后重试。","recovery":"follow_guidance"}, + {"code":"travel_transaction.import_input_invalid","domain":"travel_transaction","audience":"user","defaultMessage":"差旅交易导入列表格式有误,请检查数据并确保每批为1至100项。","recovery":"follow_guidance"}, + {"code":"travel_transaction.import_empty","domain":"travel_transaction","audience":"user","defaultMessage":"未识别到有效的差旅交易记录,请检查必填字段和格式后重新导入。","recovery":"follow_guidance"}, + {"code":"travel_transaction.query_invalid","domain":"travel_transaction","audience":"user","defaultMessage":"差旅交易筛选条件无效,请检查筛选项和分页后重试。","recovery":"follow_guidance"}, + {"code":"travel_transaction.id_invalid","domain":"travel_transaction","audience":"user","defaultMessage":"差旅交易标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"travel_order.payment_input_invalid","domain":"travel_order","audience":"user","defaultMessage":"差旅付款记录格式有误,请检查付款日期、渠道和凭证号后重试。","recovery":"follow_guidance"}, + {"code":"travel_order.invalid_pay_status","domain":"travel_order","audience":"user","defaultMessage":"该差旅订单当前不能标记付款,请刷新并确认订单状态。","recovery":"follow_guidance"}, + {"code":"travel_order.already_paid_conflict","domain":"travel_order","audience":"user","defaultMessage":"该差旅订单已记录付款,本次付款资料与原记录不同,请核对原付款记录。","recovery":"follow_guidance"}, + {"code":"travel_order.settled_transaction_required","domain":"travel_order","audience":"user","defaultMessage":"该订单缺少金额和币种一致的已结算扣款交易,请先核对并补齐交易记录。","recovery":"follow_guidance"}, + {"code":"travel_transaction.freeze_input_invalid","domain":"travel_transaction","audience":"user","defaultMessage":"冻结建议的原因或备注格式有误,请选择有效原因后重新提交。","recovery":"follow_guidance"}, + {"code":"travel_request.query_invalid","domain":"travel_request","audience":"user","defaultMessage":"差旅申请筛选条件无效,请检查状态、分页和筛选项后重试。","recovery":"follow_guidance"}, + {"code":"travel_request.id_invalid","domain":"travel_request","audience":"user","defaultMessage":"差旅申请标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"travel_request.create_input_invalid","domain":"travel_request","audience":"user","defaultMessage":"差旅申请资料不完整或格式有误,请检查事由、日期、费用及行程后重新创建。","recovery":"follow_guidance"}, + {"code":"travel_request.update_input_invalid","domain":"travel_request","audience":"user","defaultMessage":"差旅申请修改内容格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"travel_request.project_link_input_invalid","domain":"travel_request","audience":"user","defaultMessage":"项目关联参数无效,请重新选择项目或清除关联后重试。","recovery":"follow_guidance"}, + {"code":"travel_request.segment_time_invalid","domain":"travel_request","audience":"user","defaultMessage":"行程出发或到达时间无法识别,请填写有效时间。","recovery":"follow_guidance"}, + {"code":"travel_request.amount_invalid","domain":"travel_request","audience":"user","defaultMessage":"差旅预估金额无法识别或超出支持范围,请填写有效金额。","recovery":"follow_guidance"}, + {"code":"travel_request.amount_negative","domain":"travel_request","audience":"user","defaultMessage":"差旅预估金额不能为负数,请检查金额后重试。","recovery":"follow_guidance"}, + {"code":"travel_request.segment_time_reversed","domain":"travel_request","audience":"user","defaultMessage":"行程到达时间不能早于出发时间,请调整行程时间。","recovery":"follow_guidance"}, + {"code":"travel_request.date_range_reversed","domain":"travel_request","audience":"user","defaultMessage":"差旅结束日期不能早于开始日期,请调整出差日期。","recovery":"follow_guidance"}, + {"code":"travel_request.precheck_blocked","domain":"travel_request","audience":"user","defaultMessage":"差旅申请未通过费用预检,请检查预算与费用标准提示,处理后重新提交。","recovery":"follow_guidance"}, + {"code":"travel_request.company_unavailable","domain":"travel_request","audience":"user","defaultMessage":"所选公司不存在、已停用或已归档,请选择有效公司。","recovery":"follow_guidance"}, + {"code":"travel_request.cost_center_unavailable","domain":"travel_request","audience":"user","defaultMessage":"所选成本中心不存在、已停用或已归档,请选择有效成本中心。","recovery":"follow_guidance"}, + {"code":"travel_request.cost_center_company_mismatch","domain":"travel_request","audience":"user","defaultMessage":"成本中心与所选公司不匹配,请重新选择同一公司的成本中心。","recovery":"follow_guidance"}, + {"code":"travel_request.update_empty","domain":"travel_request","audience":"user","defaultMessage":"尚未填写差旅修改内容,请修改至少一项后保存。","recovery":"follow_guidance"}, + {"code":"travel_request.only_draft_editable","domain":"travel_request","audience":"user","defaultMessage":"只有草稿差旅申请可以修改,请刷新并确认申请状态。","recovery":"follow_guidance"}, + {"code":"travel_request.only_draft_submittable","domain":"travel_request","audience":"user","defaultMessage":"只有草稿差旅申请可以提交,请刷新并确认申请状态,避免重复提交。","recovery":"follow_guidance"}, + {"code":"travel_request.segments_required","domain":"travel_request","audience":"user","defaultMessage":"差旅申请缺少行程,请补充至少一段行程后提交。","recovery":"follow_guidance"}, + {"code":"travel_request.workflow_missing","domain":"travel_request","audience":"user","defaultMessage":"差旅审批流程尚未配置,请联系管理员配置后重新提交。","recovery":"follow_guidance"}, + {"code":"travel_request.approver_unavailable","domain":"travel_request","audience":"user","defaultMessage":"差旅审批流程没有可处理的审批人,请联系管理员检查审批人配置后重试。","recovery":"follow_guidance"}, + {"code":"travel_request.project_linking_disabled","domain":"travel_request","audience":"user","defaultMessage":"差旅项目关联功能暂不可用,请联系管理员确认开放范围后重试。","recovery":"follow_guidance"}, + {"code":"travel_request.only_reviewing_withdrawable","domain":"travel_request","audience":"user","defaultMessage":"只有审批中的差旅申请可以撤回,请刷新并确认申请状态。","recovery":"follow_guidance"}, + {"code":"project.create_input_invalid","domain":"project","audience":"user","defaultMessage":"项目新增内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.update_input_invalid","domain":"project","audience":"user","defaultMessage":"项目修改内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.member_create_input_invalid","domain":"project","audience":"user","defaultMessage":"项目成员新增内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.member_update_input_invalid","domain":"project","audience":"user","defaultMessage":"项目成员修改内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.milestone_create_input_invalid","domain":"project","audience":"user","defaultMessage":"里程碑新增内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.milestone_update_input_invalid","domain":"project","audience":"user","defaultMessage":"里程碑修改内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.task_create_input_invalid","domain":"project","audience":"user","defaultMessage":"项目任务新增内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.task_update_input_invalid","domain":"project","audience":"user","defaultMessage":"项目任务修改内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.risk_create_input_invalid","domain":"project","audience":"user","defaultMessage":"项目风险新增内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.risk_update_input_invalid","domain":"project","audience":"user","defaultMessage":"项目风险修改内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.communication_log_create_input_invalid","domain":"project","audience":"user","defaultMessage":"项目沟通记录新增内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.communication_log_update_input_invalid","domain":"project","audience":"user","defaultMessage":"项目沟通记录修改内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.closure_checklist_update_input_invalid","domain":"project","audience":"user","defaultMessage":"项目收口项修改内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.delivery_create_input_invalid","domain":"project","audience":"user","defaultMessage":"项目交付记录新增内容不完整或格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"project.id_invalid","domain":"project","audience":"user","defaultMessage":"项目标识无效,请刷新项目列表后重新选择。","recovery":"follow_guidance"}, + {"code":"project.member_id_invalid","domain":"project","audience":"user","defaultMessage":"项目成员标识无效,请刷新项目详情后重新选择。","recovery":"follow_guidance"}, + {"code":"project.milestone_id_invalid","domain":"project","audience":"user","defaultMessage":"里程碑标识无效,请刷新项目详情后重新选择。","recovery":"follow_guidance"}, + {"code":"project.task_id_invalid","domain":"project","audience":"user","defaultMessage":"项目任务标识无效,请刷新项目详情后重新选择。","recovery":"follow_guidance"}, + {"code":"project.risk_id_invalid","domain":"project","audience":"user","defaultMessage":"项目风险标识无效,请刷新项目详情后重新选择。","recovery":"follow_guidance"}, + {"code":"project.communication_log_id_invalid","domain":"project","audience":"user","defaultMessage":"项目沟通记录标识无效,请刷新项目详情后重新选择。","recovery":"follow_guidance"}, + {"code":"project.closure_checklist_id_invalid","domain":"project","audience":"user","defaultMessage":"项目收口项标识无效,请刷新项目详情后重新选择。","recovery":"follow_guidance"}, + {"code":"project.feature_disabled","domain":"project","audience":"user","defaultMessage":"项目功能暂不可用,请联系管理员确认开放范围后重试。","recovery":"follow_guidance"}, + {"code":"project.linking_disabled","domain":"project","audience":"user","defaultMessage":"项目关联功能暂不可用,请联系管理员确认开放范围后重试。","recovery":"follow_guidance"}, + {"code":"project.communication_disabled","domain":"project","audience":"user","defaultMessage":"项目沟通记录功能暂不可用,请联系管理员确认开放范围后重试。","recovery":"follow_guidance"}, + {"code":"project.closure_disabled","domain":"project","audience":"user","defaultMessage":"项目收口清单功能暂不可用,请联系管理员确认开放范围后重试。","recovery":"follow_guidance"}, + {"code":"project.document_refs_disabled","domain":"project","audience":"user","defaultMessage":"项目资料引用功能暂不可用,请联系管理员确认开放范围后重试。","recovery":"follow_guidance"}, + {"code":"project.query_invalid","domain":"project","audience":"user","defaultMessage":"项目筛选条件无效,请检查状态和筛选项后重试。","recovery":"follow_guidance"}, + {"code":"project.waiver_note_required","domain":"project","audience":"user","defaultMessage":"豁免项目收口项需要填写理由,请补充说明后保存。","recovery":"follow_guidance"}, + {"code":"expense_standard.id_invalid","domain":"expense_standard","audience":"user","defaultMessage":"费用标准标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"expense_standard.query_invalid","domain":"expense_standard","audience":"user","defaultMessage":"费用标准筛选条件无效,请检查筛选项后重试。","recovery":"follow_guidance"}, + {"code":"expense_standard.create_input_invalid","domain":"expense_standard","audience":"user","defaultMessage":"费用标准资料不完整或格式有误,请检查必填项后重新创建。","recovery":"follow_guidance"}, + {"code":"expense_standard.update_input_invalid","domain":"expense_standard","audience":"user","defaultMessage":"费用标准修改内容格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"expense_standard.update_empty","domain":"expense_standard","audience":"user","defaultMessage":"尚未填写费用标准修改内容,请修改至少一项后保存。","recovery":"follow_guidance"}, + {"code":"expense_type.id_invalid","domain":"expense_type","audience":"user","defaultMessage":"费用类型标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"expense_type.query_invalid","domain":"expense_type","audience":"user","defaultMessage":"费用类型筛选条件无效,请检查筛选项后重试。","recovery":"follow_guidance"}, + {"code":"expense_type.create_input_invalid","domain":"expense_type","audience":"user","defaultMessage":"费用类型资料不完整或格式有误,请检查必填项后重新创建。","recovery":"follow_guidance"}, + {"code":"expense_type.update_input_invalid","domain":"expense_type","audience":"user","defaultMessage":"费用类型修改内容格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"expense_type.update_empty","domain":"expense_type","audience":"user","defaultMessage":"尚未填写费用类型修改内容,请修改至少一项后保存。","recovery":"follow_guidance"}, + {"code":"expense_type.code_duplicate","domain":"expense_type","audience":"user","defaultMessage":"费用类型编码已存在,请使用其他编码。","recovery":"follow_guidance"}, + {"code":"leave_type.id_invalid","domain":"leave_type","audience":"user","defaultMessage":"假别标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"leave_type.query_invalid","domain":"leave_type","audience":"user","defaultMessage":"假别筛选条件无效,请检查筛选项后重试。","recovery":"follow_guidance"}, + {"code":"leave_type.create_input_invalid","domain":"leave_type","audience":"user","defaultMessage":"假别资料不完整或格式有误,请检查必填项后重新创建。","recovery":"follow_guidance"}, + {"code":"leave_type.update_input_invalid","domain":"leave_type","audience":"user","defaultMessage":"假别修改内容格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"leave_type.code_duplicate","domain":"leave_type","audience":"user","defaultMessage":"假别编码已存在,请使用其他编码。","recovery":"follow_guidance"}, + {"code":"reimbursement_template.id_invalid","domain":"reimbursement_template","audience":"user","defaultMessage":"报销模板标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"reimbursement_template.query_invalid","domain":"reimbursement_template","audience":"user","defaultMessage":"报销模板筛选条件无效,请检查筛选项后重试。","recovery":"follow_guidance"}, + {"code":"reimbursement_template.create_input_invalid","domain":"reimbursement_template","audience":"user","defaultMessage":"报销模板资料不完整或格式有误,请检查必填项后重新创建。","recovery":"follow_guidance"}, + {"code":"reimbursement_template.update_input_invalid","domain":"reimbursement_template","audience":"user","defaultMessage":"报销模板修改内容格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"reimbursement_template.update_empty","domain":"reimbursement_template","audience":"user","defaultMessage":"尚未填写报销模板修改内容,请修改至少一项后保存。","recovery":"follow_guidance"}, + {"code":"expense_standard.amount_not_positive","domain":"expense_standard","audience":"user","defaultMessage":"费用标准金额须大于零,请填写有效的金额。","recovery":"follow_guidance"}, + {"code":"expense_standard.expense_type_not_found","domain":"expense_standard","audience":"user","defaultMessage":"所选费用类型不存在,请刷新费用类型列表后重新选择。","recovery":"follow_guidance"}, + {"code":"expense_standard.department_required","domain":"expense_standard","audience":"user","defaultMessage":"部门费用标准需要指定部门,请选择部门后保存。","recovery":"follow_guidance"}, + {"code":"expense_standard.department_unavailable","domain":"expense_standard","audience":"user","defaultMessage":"所选部门不存在或已归档,请选择有效部门。","recovery":"follow_guidance"}, + {"code":"expense_standard.scope_conflict","domain":"expense_standard","audience":"user","defaultMessage":"该费用类型在相同范围已有标准,请修改已有标准或选择其他范围。","recovery":"follow_guidance"}, + {"code":"reimbursement_template.expense_types_unavailable","domain":"reimbursement_template","audience":"user","defaultMessage":"模板中有不存在或已停用的费用类型,请重新选择有效类型后保存。","recovery":"follow_guidance"}, + {"code":"holiday_calendar.id_invalid","domain":"holiday_calendar","audience":"user","defaultMessage":"节假日日历标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"holiday_calendar.query_invalid","domain":"holiday_calendar","audience":"user","defaultMessage":"节假日日历筛选条件无效,请检查筛选项后重试。","recovery":"follow_guidance"}, + {"code":"holiday_calendar.create_input_invalid","domain":"holiday_calendar","audience":"user","defaultMessage":"日历资料不完整或格式有误,请检查编码和名称后重新创建。","recovery":"follow_guidance"}, + {"code":"holiday_calendar.update_input_invalid","domain":"holiday_calendar","audience":"user","defaultMessage":"日历修改内容格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"holiday_calendar.update_empty","domain":"holiday_calendar","audience":"user","defaultMessage":"尚未填写日历修改内容,请修改至少一项后保存。","recovery":"follow_guidance"}, + {"code":"holiday_calendar.days_input_invalid","domain":"holiday_calendar","audience":"user","defaultMessage":"日历日期列表无效,请检查真实日期、日期类型和名称,且一次不超过500项。","recovery":"follow_guidance"}, + {"code":"holiday_calendar.date_duplicate","domain":"holiday_calendar","audience":"user","defaultMessage":"日历日期列表有重复日期,请每个日期只保留一项后重试。","recovery":"follow_guidance"}, + {"code":"holiday_calendar.code_duplicate","domain":"holiday_calendar","audience":"user","defaultMessage":"日历编码已存在,请使用其他编码。","recovery":"follow_guidance"}, + {"code":"holiday_calendar.archived_locked","domain":"holiday_calendar","audience":"user","defaultMessage":"该日历已归档,无法修改资料或日期;请选择有效日历。","recovery":"follow_guidance"}, + {"code":"holiday_calendar.bound_as_default","domain":"holiday_calendar","audience":"user","defaultMessage":"该日历仍是公司的默认日历,请先调整默认日历后再归档。","recovery":"follow_guidance"}, + {"code":"legal_entity.id_invalid","domain":"legal_entity","audience":"user","defaultMessage":"公司标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"legal_entity.query_invalid","domain":"legal_entity","audience":"user","defaultMessage":"公司筛选条件无效,请检查筛选项后重试。","recovery":"follow_guidance"}, + {"code":"legal_entity.create_input_invalid","domain":"legal_entity","audience":"user","defaultMessage":"公司资料不完整或格式有误,请检查必填项后重新创建。","recovery":"follow_guidance"}, + {"code":"legal_entity.update_input_invalid","domain":"legal_entity","audience":"user","defaultMessage":"公司修改内容格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"legal_entity.update_empty","domain":"legal_entity","audience":"user","defaultMessage":"尚未填写公司修改内容,请修改至少一项后保存。","recovery":"follow_guidance"}, + {"code":"legal_entity.code_duplicate","domain":"legal_entity","audience":"user","defaultMessage":"公司编码已存在,请使用其他编码。","recovery":"follow_guidance"}, + {"code":"legal_entity.archived_locked","domain":"legal_entity","audience":"user","defaultMessage":"该公司已归档,无法修改;请返回列表选择有效记录。","recovery":"follow_guidance"}, + {"code":"cost_center.id_invalid","domain":"cost_center","audience":"user","defaultMessage":"成本中心标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"cost_center.query_invalid","domain":"cost_center","audience":"user","defaultMessage":"成本中心筛选条件无效,请检查筛选项后重试。","recovery":"follow_guidance"}, + {"code":"cost_center.create_input_invalid","domain":"cost_center","audience":"user","defaultMessage":"成本中心资料不完整或格式有误,请检查必填项后重新创建。","recovery":"follow_guidance"}, + {"code":"cost_center.update_input_invalid","domain":"cost_center","audience":"user","defaultMessage":"成本中心修改内容格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"cost_center.update_empty","domain":"cost_center","audience":"user","defaultMessage":"尚未填写成本中心修改内容,请修改至少一项后保存。","recovery":"follow_guidance"}, + {"code":"cost_center.code_duplicate","domain":"cost_center","audience":"user","defaultMessage":"成本中心编码已存在,请使用其他编码。","recovery":"follow_guidance"}, + {"code":"cost_center.archived_locked","domain":"cost_center","audience":"user","defaultMessage":"该成本中心已归档,无法修改;请返回列表选择有效记录。","recovery":"follow_guidance"}, + {"code":"document_category.id_invalid","domain":"document_category","audience":"user","defaultMessage":"单据分类标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"document_category.query_invalid","domain":"document_category","audience":"user","defaultMessage":"单据分类筛选条件无效,请检查筛选项后重试。","recovery":"follow_guidance"}, + {"code":"document_category.create_input_invalid","domain":"document_category","audience":"user","defaultMessage":"单据分类资料不完整或格式有误,请检查必填项后重新创建。","recovery":"follow_guidance"}, + {"code":"document_category.update_input_invalid","domain":"document_category","audience":"user","defaultMessage":"单据分类修改内容格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"document_category.update_empty","domain":"document_category","audience":"user","defaultMessage":"尚未填写单据分类修改内容,请修改至少一项后保存。","recovery":"follow_guidance"}, + {"code":"document_category.code_duplicate","domain":"document_category","audience":"user","defaultMessage":"单据分类编码已存在,请使用其他编码。","recovery":"follow_guidance"}, + {"code":"document_category.archived_locked","domain":"document_category","audience":"user","defaultMessage":"该单据分类已归档,无法修改;请返回列表选择有效记录。","recovery":"follow_guidance"}, + {"code":"supplier.id_invalid","domain":"supplier","audience":"user","defaultMessage":"供应商标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"supplier.query_invalid","domain":"supplier","audience":"user","defaultMessage":"供应商筛选条件无效,请检查筛选项后重试。","recovery":"follow_guidance"}, + {"code":"supplier.create_input_invalid","domain":"supplier","audience":"user","defaultMessage":"供应商资料不完整或格式有误,请检查必填项后重新创建。","recovery":"follow_guidance"}, + {"code":"supplier.update_input_invalid","domain":"supplier","audience":"user","defaultMessage":"供应商修改内容格式有误,请检查填写内容后重试。","recovery":"follow_guidance"}, + {"code":"supplier.update_empty","domain":"supplier","audience":"user","defaultMessage":"尚未填写供应商修改内容,请修改至少一项后保存。","recovery":"follow_guidance"}, + {"code":"supplier.code_duplicate","domain":"supplier","audience":"user","defaultMessage":"供应商编码已存在,请使用其他编码。","recovery":"follow_guidance"}, + {"code":"supplier.archived_locked","domain":"supplier","audience":"user","defaultMessage":"该供应商已归档,无法修改;请返回列表选择有效记录。","recovery":"follow_guidance"}, + {"code":"legal_entity.reference_not_found","domain":"legal_entity","audience":"user","defaultMessage":"所选公司不存在,请刷新公司列表后重新选择。","recovery":"follow_guidance"}, + {"code":"legal_entity.reference_archived","domain":"legal_entity","audience":"user","defaultMessage":"所选公司已归档,请选择有效公司。","recovery":"follow_guidance"}, + {"code":"legal_entity.has_cost_centers","domain":"legal_entity","audience":"user","defaultMessage":"该公司仍有未归档的成本中心,请先处理成本中心后再归档公司。","recovery":"follow_guidance"}, + {"code":"cost_center.owner_unavailable","domain":"cost_center","audience":"user","defaultMessage":"所选负责人不存在或已非在职状态,请选择在职员工。","recovery":"follow_guidance"}, + {"code":"cost_center.parent_self_forbidden","domain":"cost_center","audience":"user","defaultMessage":"成本中心不能作为自身的上级,请重新选择上级成本中心。","recovery":"follow_guidance"}, + {"code":"cost_center.parent_not_found","domain":"cost_center","audience":"user","defaultMessage":"所选上级成本中心不存在,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"cost_center.parent_archived","domain":"cost_center","audience":"user","defaultMessage":"所选上级成本中心已归档,请选择有效的上级成本中心。","recovery":"follow_guidance"}, + {"code":"cost_center.parent_company_mismatch","domain":"cost_center","audience":"user","defaultMessage":"上级成本中心与当前成本中心须属于同一公司,请重新选择。","recovery":"follow_guidance"}, + {"code":"cost_center.parent_cycle_forbidden","domain":"cost_center","audience":"user","defaultMessage":"所选上级成本中心会形成循环层级,请选择其他上级。","recovery":"follow_guidance"}, + {"code":"cost_center.has_children","domain":"cost_center","audience":"user","defaultMessage":"该成本中心仍有未归档的子级,请先处理子级后再归档。","recovery":"follow_guidance"}, + {"code":"employee.contract_attachment_download_blocked","domain":"employee","audience":"user","defaultMessage":"此合同附件未通过文件安全校验,暂不能下载,请联系有权限的管理员核查并重新上传有效附件。","recovery":"follow_guidance"}, + {"code":"employee.id_invalid","domain":"employee","audience":"user","defaultMessage":"员工或档案记录标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"employee.lifecycle_disabled","domain":"employee","audience":"user","defaultMessage":"员工资料与生命周期管理暂不可用,请联系管理员确认功能开放范围。","recovery":"follow_guidance"}, + {"code":"employee.hr_sync_disabled","domain":"employee","audience":"user","defaultMessage":"企业微信人事主数据同步暂未启用,请联系管理员确认同步安排。","recovery":"follow_guidance"}, + {"code":"employee.hr_sync_unavailable","domain":"employee","audience":"user","defaultMessage":"企业微信人事主数据同步所需组件不可用,请联系管理员检查系统组件后重试。","recovery":"follow_guidance"}, + {"code":"employee.directory_sync_unavailable","domain":"employee","audience":"user","defaultMessage":"企业微信通讯录同步所需组件不可用,请联系管理员检查系统组件后重试。","recovery":"follow_guidance"}, + {"code":"employee.contract_date_range_invalid","domain":"employee","audience":"user","defaultMessage":"员工合同结束日期不能早于开始日期,请调整合同日期后保存。","recovery":"follow_guidance"}, + {"code":"employee.contract_termination_date_invalid","domain":"employee","audience":"user","defaultMessage":"员工合同终止日期不能早于开始日期,请核对终止日期后保存。","recovery":"follow_guidance"}, + {"code":"employee.training_date_range_invalid","domain":"employee","audience":"user","defaultMessage":"培训结束日期不能早于开始日期,请调整培训日期后保存。","recovery":"follow_guidance"}, + {"code":"employee.certificate_date_range_invalid","domain":"employee","audience":"user","defaultMessage":"证书到期日期不能早于取得日期,请核对证书日期后保存。","recovery":"follow_guidance"}, + {"code":"employee.contract_number_duplicate","domain":"employee","audience":"user","defaultMessage":"已有相同编号的员工合同,请核对合同编号并避免重复登记。","recovery":"follow_guidance"}, + {"code":"employee.active_contract_delete_forbidden","domain":"employee","audience":"user","defaultMessage":"有效中的员工合同不能直接删除,请先按实际情况登记终止,再填写删除理由。","recovery":"follow_guidance"}, + {"code":"employee.self_profile_fields_forbidden","domain":"employee","audience":"user","defaultMessage":"本人资料中包含无权修改的字段,请仅修改允许的个人资料,其他变更联系管理员办理。","recovery":"follow_guidance"}, + {"code":"employee.hr_sync_input_invalid","domain":"employee","audience":"user","defaultMessage":"人事同步参数无效,请检查预览或执行模式、人员范围和数量限制后重试。","recovery":"follow_guidance"}, + {"code":"employee.hr_sync_query_invalid","domain":"employee","audience":"user","defaultMessage":"人事同步记录筛选条件无效,请重新选择有效的每页条数。","recovery":"follow_guidance"}, + {"code":"employee.directory_retry_input_invalid","domain":"employee","audience":"user","defaultMessage":"通讯录同步重试参数无效,请重新选择有效的同步操作后重试。","recovery":"follow_guidance"}, + {"code":"employee.contract_create_input_invalid","domain":"employee","audience":"user","defaultMessage":"员工合同资料无效,请检查编号、合同类型和日期后登记。","recovery":"follow_guidance"}, + {"code":"employee.contract_update_input_invalid","domain":"employee","audience":"user","defaultMessage":"员工合同修改内容无效,请检查合同类型、日期和需要修改的字段。","recovery":"follow_guidance"}, + {"code":"employee.contract_delete_input_invalid","domain":"employee","audience":"user","defaultMessage":"请填写有效的合同删除理由后重试。","recovery":"follow_guidance"}, + {"code":"employee.training_create_input_invalid","domain":"employee","audience":"user","defaultMessage":"培训资料无效,请检查名称、类型、状态和日期后登记。","recovery":"follow_guidance"}, + {"code":"employee.training_update_input_invalid","domain":"employee","audience":"user","defaultMessage":"培训修改内容无效,请检查类型、状态、日期和需要修改的字段。","recovery":"follow_guidance"}, + {"code":"employee.certificate_create_input_invalid","domain":"employee","audience":"user","defaultMessage":"证书资料无效,请检查名称、颁发机构和日期后登记。","recovery":"follow_guidance"}, + {"code":"employee.certificate_update_input_invalid","domain":"employee","audience":"user","defaultMessage":"证书修改内容无效,请检查名称、机构、日期和需要修改的字段。","recovery":"follow_guidance"}, + {"code":"employee.profile_input_invalid","domain":"employee","audience":"user","defaultMessage":"员工资料修改内容无效,请检查日期、联系方式和需要修改的字段后保存。","recovery":"follow_guidance"}, + {"code":"employee.onboarding_input_invalid","domain":"employee","audience":"user","defaultMessage":"入职办理资料无效,请检查生效日期、试用期结束日期和备注后重试。","recovery":"follow_guidance"}, + {"code":"employee.regularize_input_invalid","domain":"employee","audience":"user","defaultMessage":"转正办理资料无效,请检查生效日期和备注后重试。","recovery":"follow_guidance"}, + {"code":"employee.transfer_input_invalid","domain":"employee","audience":"user","defaultMessage":"调动办理资料无效,请检查生效日期、目标部门、岗位或直属主管后重试。","recovery":"follow_guidance"}, + {"code":"employee.offboarding_input_invalid","domain":"employee","audience":"user","defaultMessage":"离职办理资料无效,请检查生效日期、最后工作日期和备注后重试。","recovery":"follow_guidance"}, + {"code":"employee.avatar_multipart_required","domain":"employee","audience":"user","defaultMessage":"头像上传方式无效,请通过头像上传入口重新选择图片。","recovery":"follow_guidance"}, + {"code":"employee.avatar_file_required","domain":"employee","audience":"user","defaultMessage":"请选择头像图片后上传。","recovery":"follow_guidance"}, + {"code":"employee.avatar_field_invalid","domain":"employee","audience":"user","defaultMessage":"头像上传字段无效,请刷新页面并重新选择头像图片。","recovery":"follow_guidance"}, + {"code":"employee.avatar_type_invalid","domain":"employee","audience":"user","defaultMessage":"头像仅支持JPEG、PNG或WebP图片,请选择支持的图片格式。","recovery":"follow_guidance"}, + {"code":"employee.avatar_file_empty","domain":"employee","audience":"user","defaultMessage":"头像文件为空,请选择有内容的图片后上传。","recovery":"follow_guidance"}, + {"code":"employee.avatar_file_too_large","domain":"employee","audience":"user","defaultMessage":"头像文件不能超过2MiB,请压缩图片后重新上传。","recovery":"follow_guidance"}, + {"code":"employee.avatar_image_invalid","domain":"employee","audience":"user","defaultMessage":"头像文件无法解析为图片,请重新导出JPEG、PNG或WebP图片后上传。","recovery":"follow_guidance"}, + {"code":"employee.contract_attachment_multipart_required","domain":"employee","audience":"user","defaultMessage":"合同附件上传方式无效,请通过附件上传入口重新选择文件。","recovery":"follow_guidance"}, + {"code":"employee.contract_attachment_file_required","domain":"employee","audience":"user","defaultMessage":"请选择员工合同附件后上传。","recovery":"follow_guidance"}, + {"code":"employee.contract_attachment_field_invalid","domain":"employee","audience":"user","defaultMessage":"合同附件上传字段无效,请刷新页面并重新选择附件。","recovery":"follow_guidance"}, + {"code":"employee.contract_attachment_type_invalid","domain":"employee","audience":"user","defaultMessage":"员工合同附件仅支持PDF、JPEG或PNG,请选择支持的文件格式。","recovery":"follow_guidance"}, + {"code":"employee.contract_attachment_file_empty","domain":"employee","audience":"user","defaultMessage":"合同附件为空,请选择有内容的文件后上传。","recovery":"follow_guidance"}, + {"code":"employee.contract_attachment_file_too_large","domain":"employee","audience":"user","defaultMessage":"员工合同附件不能超过10MiB,请缩小文件后重新上传。","recovery":"follow_guidance"}, + {"code":"employee.contract_attachment_content_invalid","domain":"employee","audience":"user","defaultMessage":"合同附件内容与声明格式不符或未通过文件检查,请重新导出有效PDF、JPEG或PNG后上传。","recovery":"follow_guidance"}, + {"code":"user.id_invalid","domain":"user","audience":"user","defaultMessage":"员工账号标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"user.list_query_invalid","domain":"user","audience":"user","defaultMessage":"员工列表筛选条件无效,请检查页码、每页条数和查看范围后重试。","recovery":"follow_guidance"}, + {"code":"user.password_policy_failed","domain":"user","audience":"user","defaultMessage":"密码需至少12位,包含大小写字母和数字,并避免常见弱密码,请调整后重试。","recovery":"follow_guidance"}, + {"code":"user.sensitive_input_invalid","domain":"user","audience":"user","defaultMessage":"敏感资料填写无效,请核对所选银行、账号格式或证件资料后保存。","recovery":"follow_guidance"}, + {"code":"user.sensitive_update_empty","domain":"user","audience":"user","defaultMessage":"没有可保存的敏感资料修改,请填写需要修改的字段后保存。","recovery":"follow_guidance"}, + {"code":"user.department_not_found","domain":"user","audience":"user","defaultMessage":"所选员工部门已不存在,请刷新后重新选择部门。","recovery":"follow_guidance"}, + {"code":"user.department_archived","domain":"user","audience":"user","defaultMessage":"所选员工部门已归档,请选择可用部门或联系管理员核查。","recovery":"follow_guidance"}, + {"code":"user.manager_self_forbidden","domain":"user","audience":"user","defaultMessage":"员工不能担任自己的直属主管,请选择其他在职人员;批量设置时主管不能在所选员工中。","recovery":"follow_guidance"}, + {"code":"user.manager_not_found","domain":"user","audience":"user","defaultMessage":"所选直属主管已不存在,请刷新后重新选择。","recovery":"follow_guidance"}, + {"code":"user.manager_inactive","domain":"user","audience":"user","defaultMessage":"所选直属主管当前不在职,请选择其他在职人员。","recovery":"follow_guidance"}, + {"code":"user.unique_conflict","domain":"user","audience":"user","defaultMessage":"员工资料与已有记录重复,本次操作未完成,请核对工号、邮箱等唯一资料后重试。","recovery":"follow_guidance"}, + {"code":"user.bank_reveal_input_invalid","domain":"user","audience":"user","defaultMessage":"请填写2至500字的查看银行账号理由后重试。","recovery":"follow_guidance"}, + {"code":"user.bank_institution_unavailable","domain":"user","audience":"user","defaultMessage":"所选银行已停用、归档或不存在,请刷新后重新选择可用银行。","recovery":"follow_guidance"}, + {"code":"user.self_disable_forbidden","domain":"user","audience":"user","defaultMessage":"不能停用或归档当前登录账号,请从本次操作中移除自己。","recovery":"follow_guidance"}, + {"code":"user.reset_password_input_invalid","domain":"user","audience":"user","defaultMessage":"重置密码内容无效,请填写有效的新密码,或留空使用系统生成的临时密码。","recovery":"follow_guidance"}, + {"code":"user.self_role_change_forbidden","domain":"user","audience":"user","defaultMessage":"不能修改当前登录账号自己的角色,请由其他有权限的管理员处理。","recovery":"follow_guidance"}, + {"code":"user.roles_input_invalid","domain":"user","audience":"user","defaultMessage":"角色设置无效,请至少选择一个可用角色后保存。","recovery":"follow_guidance"}, + {"code":"user.batch_transfer_input_invalid","domain":"user","audience":"user","defaultMessage":"批量调部门资料无效,请选择员工和目标部门后重试。","recovery":"follow_guidance"}, + {"code":"user.batch_manager_input_invalid","domain":"user","audience":"user","defaultMessage":"批量设置主管资料无效,请选择员工和直属主管后重试。","recovery":"follow_guidance"}, + {"code":"user.batch_toggle_input_invalid","domain":"user","audience":"user","defaultMessage":"批量启用或停用的员工列表无效,请重新选择员工后重试。","recovery":"follow_guidance"}, + {"code":"user.batch_limit_exceeded","domain":"user","audience":"user","defaultMessage":"每次最多处理50名不同员工,请缩小选择范围后分批操作。","recovery":"follow_guidance"}, + {"code":"user.lifecycle_disabled","domain":"user","audience":"user","defaultMessage":"员工生命周期管理暂不可用,请联系管理员确认功能开放范围。","recovery":"follow_guidance"}, + {"code":"user.generated_password_unavailable","domain":"user","audience":"user","defaultMessage":"系统未能生成符合安全要求的临时密码,本次操作未完成,请联系管理员核查。","recovery":"follow_guidance"}, + {"code":"department.id_invalid","domain":"department","audience":"user","defaultMessage":"部门标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"department.query_invalid","domain":"department","audience":"user","defaultMessage":"部门筛选条件无效,请重新选择是否包含已归档部门。","recovery":"follow_guidance"}, + {"code":"department.create_input_invalid","domain":"department","audience":"user","defaultMessage":"部门资料无效,请检查部门名称和上级部门后创建。","recovery":"follow_guidance"}, + {"code":"department.update_input_invalid","domain":"department","audience":"user","defaultMessage":"部门修改内容无效,请检查部门名称和上级部门后保存。","recovery":"follow_guidance"}, + {"code":"department.update_empty","domain":"department","audience":"user","defaultMessage":"没有可保存的部门修改,请填写名称或调整上级部门后保存。","recovery":"follow_guidance"}, + {"code":"department.parent_not_found","domain":"department","audience":"user","defaultMessage":"所选上级部门已不存在,请刷新后重新选择上级部门。","recovery":"follow_guidance"}, + {"code":"department.parent_archived","domain":"department","audience":"user","defaultMessage":"所选上级部门已归档,请选择可用部门。","recovery":"follow_guidance"}, + {"code":"department.archived_locked","domain":"department","audience":"user","defaultMessage":"该部门已归档,无法修改,请刷新确认当前部门状态。","recovery":"follow_guidance"}, + {"code":"department.parent_self_forbidden","domain":"department","audience":"user","defaultMessage":"部门不能把自己设为上级,请选择其他部门或清空上级。","recovery":"follow_guidance"}, + {"code":"department.parent_cycle_forbidden","domain":"department","audience":"user","defaultMessage":"所选上级部门会形成层级循环,请选择当前部门下级以外的部门。","recovery":"follow_guidance"}, + {"code":"department.children_exist","domain":"department","audience":"user","defaultMessage":"该部门仍有下级部门,暂不能归档,请先调整下级部门的归属。","recovery":"follow_guidance"}, + {"code":"department.active_employees_exist","domain":"department","audience":"user","defaultMessage":"该部门仍有在职员工,暂不能归档,请先办理员工部门调整。","recovery":"follow_guidance"}, + {"code":"budget.company_required","domain":"budget","audience":"user","defaultMessage":"选择成本中心时必须同时指定所属公司,请补齐公司后重试。","recovery":"follow_guidance"}, + {"code":"budget.scope_mixed","domain":"budget","audience":"user","defaultMessage":"预算范围不能同时使用部门和公司或成本中心,请选择一种范围后重试。","recovery":"follow_guidance"}, + {"code":"budget.cost_center_required","domain":"budget","audience":"user","defaultMessage":"公司范围预算需要指定成本中心,请补齐该公司的成本中心后重试。","recovery":"follow_guidance"}, + {"code":"budget.pool_changed","domain":"budget","audience":"user","defaultMessage":"预算池状态或适用范围已变化,请刷新后重新提交。","recovery":"follow_guidance"}, + {"code":"budget.pool_ambiguous","domain":"budget","audience":"user","defaultMessage":"同一成本中心匹配到多个有效预算,无法确定使用哪一个,请联系预算管理员核查重叠的预算期间。","recovery":"follow_guidance"}, + {"code":"budget.effective_date_invalid","domain":"budget","audience":"user","defaultMessage":"用于匹配预算的业务日期无效,请核对单据日期;仍无法处理时联系管理员。","recovery":"follow_guidance"}, + {"code":"budget_rule.id_invalid","domain":"budget_rule","audience":"user","defaultMessage":"预算控制规则标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"budget_rule.query_invalid","domain":"budget_rule","audience":"user","defaultMessage":"预算控制规则筛选条件无效,请重新选择业务类型和启用状态。","recovery":"follow_guidance"}, + {"code":"budget_rule.create_input_invalid","domain":"budget_rule","audience":"user","defaultMessage":"预算控制规则资料无效,请检查名称、业务类型、控制方式、优先级和适用范围后创建。","recovery":"follow_guidance"}, + {"code":"budget_rule.update_input_invalid","domain":"budget_rule","audience":"user","defaultMessage":"预算控制规则修改内容无效,请检查要修改的字段并填写有效内容后保存。","recovery":"follow_guidance"}, + {"code":"budget_rule.company_required","domain":"budget_rule","audience":"user","defaultMessage":"本次预算规则填写了成本中心,请同时填写所属公司后保存。","recovery":"follow_guidance"}, + {"code":"budget_rule.expense_type_unsupported","domain":"budget_rule","audience":"user","defaultMessage":"指定费用类型时,本次提交的业务类型必须为报销,请同时选择报销类型或移除费用类型后保存。","recovery":"follow_guidance"}, + {"code":"budget.id_invalid","domain":"budget","audience":"user","defaultMessage":"预算标识无效,请刷新预算列表后重新选择。","recovery":"follow_guidance"}, + {"code":"budget.query_invalid","domain":"budget","audience":"user","defaultMessage":"预算筛选条件无效,请检查状态、所属范围和分页后重试。","recovery":"follow_guidance"}, + {"code":"budget.create_input_invalid","domain":"budget","audience":"user","defaultMessage":"预算资料不完整或格式有误,请检查编号、名称、期间、金额和预警比例后创建。","recovery":"follow_guidance"}, + {"code":"budget.update_input_invalid","domain":"budget","audience":"user","defaultMessage":"预算修改内容无效,请检查名称、金额、预警比例和状态后保存。","recovery":"follow_guidance"}, + {"code":"budget.update_empty","domain":"budget","audience":"user","defaultMessage":"没有可保存的预算修改,请修改资料后再保存。","recovery":"follow_guidance"}, + {"code":"budget.period_invalid","domain":"budget","audience":"user","defaultMessage":"预算结束日期不能早于开始日期,请调整预算期间后重试。","recovery":"follow_guidance"}, + {"code":"budget.department_not_found","domain":"budget","audience":"user","defaultMessage":"所选预算部门已不存在,请刷新后重新选择部门。","recovery":"follow_guidance"}, + {"code":"budget.department_archived","domain":"budget","audience":"user","defaultMessage":"所选预算部门已归档,请选择可用部门或联系管理员核查。","recovery":"follow_guidance"}, + {"code":"budget.legal_entity_not_found","domain":"budget","audience":"user","defaultMessage":"所选预算公司已不存在,请刷新后重新选择公司。","recovery":"follow_guidance"}, + {"code":"budget.legal_entity_unavailable","domain":"budget","audience":"user","defaultMessage":"所选预算公司已停用或归档,请选择可用公司或联系管理员核查。","recovery":"follow_guidance"}, + {"code":"budget.cost_center_not_found","domain":"budget","audience":"user","defaultMessage":"所选预算成本中心已不存在,请刷新后重新选择成本中心。","recovery":"follow_guidance"}, + {"code":"budget.cost_center_unavailable","domain":"budget","audience":"user","defaultMessage":"所选预算成本中心已停用或归档,请选择可用成本中心或联系管理员核查。","recovery":"follow_guidance"}, + {"code":"budget.cost_center_company_mismatch","domain":"budget","audience":"user","defaultMessage":"所选成本中心不属于当前预算公司,请重新选择匹配的公司和成本中心。","recovery":"follow_guidance"}, + {"code":"performance.id_invalid","domain":"performance","audience":"user","defaultMessage":"绩效周期或员工标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"performance.cycle_create_input_invalid","domain":"performance","audience":"user","defaultMessage":"绩效周期资料无效,请检查名称、周期类型和起止日期后创建。","recovery":"follow_guidance"}, + {"code":"performance.cycle_update_input_invalid","domain":"performance","audience":"user","defaultMessage":"绩效周期修改内容无效,请检查名称、类型、日期和状态,并填写需要修改的内容。","recovery":"follow_guidance"}, + {"code":"performance.review_input_invalid","domain":"performance","audience":"user","defaultMessage":"绩效评价内容无效,请检查总分和各维度评分(1至5分,以0.5分递增)、重复维度及评语长度。","recovery":"follow_guidance"}, + {"code":"performance.cycle_date_range_invalid","domain":"performance","audience":"user","defaultMessage":"绩效周期结束日期不能早于开始日期,请调整起止日期后重试。","recovery":"follow_guidance"}, + {"code":"performance.disabled","domain":"performance","audience":"user","defaultMessage":"绩效管理功能暂不可用,请联系管理员确认功能开放范围。","recovery":"follow_guidance"}, + {"code":"performance.active_cycle_exists","domain":"performance","audience":"user","defaultMessage":"已有进行中的绩效周期,请先核对并结束该周期,再激活其他周期。","recovery":"follow_guidance"}, + {"code":"performance.completed_cycle_locked","domain":"performance","audience":"user","defaultMessage":"该绩效周期已结束,无法再修改,请刷新查看当前周期状态。","recovery":"follow_guidance"}, + {"code":"performance.active_cycle_only_complete","domain":"performance","audience":"user","defaultMessage":"进行中的绩效周期只能结束,不能修改资料,请保留周期资料后执行结束操作。","recovery":"follow_guidance"}, + {"code":"performance.draft_cannot_complete","domain":"performance","audience":"user","defaultMessage":"草稿绩效周期不能直接结束,请先激活周期并完成评价,再结束周期。","recovery":"follow_guidance"}, + {"code":"performance.employee_inactive","domain":"performance","audience":"user","defaultMessage":"该员工当前不在职,不能录入绩效评价,请刷新并核对员工状态。","recovery":"follow_guidance"}, + {"code":"performance.active_cycle_required","domain":"performance","audience":"user","defaultMessage":"只有进行中的绩效周期可以录入评价,请刷新并选择进行中的周期。","recovery":"follow_guidance"}, + {"code":"payroll.id_invalid","domain":"payroll","audience":"user","defaultMessage":"薪资记录或员工标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"payroll.item_create_input_invalid","domain":"payroll","audience":"user","defaultMessage":"薪资项资料不完整或格式有误,请检查名称、收入或扣除类型及排序后创建。","recovery":"follow_guidance"}, + {"code":"payroll.item_update_input_invalid","domain":"payroll","audience":"user","defaultMessage":"薪资项修改内容无效,请检查名称、类型和排序后保存。","recovery":"follow_guidance"}, + {"code":"payroll.profile_input_invalid","domain":"payroll","audience":"user","defaultMessage":"薪资档案资料无效,请检查生效日期、薪资项和金额,并移除重复薪资项。","recovery":"follow_guidance"}, + {"code":"payroll.payslip_update_input_invalid","domain":"payroll","audience":"user","defaultMessage":"工资条修改内容无效,请检查薪资项和金额,并移除重复薪资项。","recovery":"follow_guidance"}, + {"code":"payroll.amount_invalid","domain":"payroll","audience":"user","defaultMessage":"薪资金额格式无效,请填写不小于零且最多两位小数的金额。","recovery":"follow_guidance"}, + {"code":"payroll.item_name_duplicate","domain":"payroll","audience":"user","defaultMessage":"已有同名薪资项,请更换名称后保存。","recovery":"follow_guidance"}, + {"code":"payroll.item_not_found","domain":"payroll","audience":"user","defaultMessage":"部分薪资项已不存在,请刷新后重新选择薪资项。","recovery":"follow_guidance"}, + {"code":"payroll.month_already_generated","domain":"payroll","audience":"user","defaultMessage":"该月工资条已生成,请查看现有工资条,避免重复生成。","recovery":"follow_guidance"}, + {"code":"payroll.active_employees_missing","domain":"payroll","audience":"user","defaultMessage":"没有可生成工资条的在职员工,请先核对员工状态。","recovery":"follow_guidance"}, + {"code":"payroll.enabled_items_missing","domain":"payroll","audience":"user","defaultMessage":"没有启用的薪资项,请先设置并启用薪资项,再生成工资条。","recovery":"follow_guidance"}, + {"code":"payroll.profiles_missing","domain":"payroll","audience":"user","defaultMessage":"部分员工缺少该月有效的薪资档案,请补齐档案和生效日期后再生成工资条。","recovery":"follow_guidance"}, + {"code":"payroll.payslip_not_editable","domain":"payroll","audience":"user","defaultMessage":"此工资条已发放,无法继续修改,请刷新查看当前状态。","recovery":"follow_guidance"}, + {"code":"payroll.month_not_generated","domain":"payroll","audience":"user","defaultMessage":"该月尚未生成工资条,请先生成并核对,再发放。","recovery":"follow_guidance"}, + {"code":"payroll.month_already_issued","domain":"payroll","audience":"user","defaultMessage":"该月工资条已全部发放,无需重复操作,请刷新查看。","recovery":"follow_guidance"}, + {"code":"payroll.month_invalid","domain":"payroll","audience":"user","defaultMessage":"工资月份参数无效,请重新选择有效的年和月后重试。","recovery":"follow_guidance"}, + {"code":"payroll.export_query_invalid","domain":"payroll","audience":"user","defaultMessage":"工资导出条件无效,请检查月份和导出格式后重试。","recovery":"follow_guidance"}, + {"code":"attendance.month_query_invalid","domain":"attendance","audience":"user","defaultMessage":"考勤月报筛选条件无效,请检查月份和所选员工后重试。","recovery":"follow_guidance"}, + {"code":"attendance.month_invalid","domain":"attendance","audience":"user","defaultMessage":"考勤月份无效,请重新选择有效的年和月后重试。","recovery":"follow_guidance"}, + {"code":"user.import_file_too_large","domain":"user","audience":"user","defaultMessage":"导入文件超过上传大小限制,请拆分或缩小文件后重试。","recovery":"follow_guidance"}, + {"code":"user.import_file_missing","domain":"user","audience":"user","defaultMessage":"请选择员工导入文件后重试。","recovery":"follow_guidance"}, + {"code":"user.import_file_invalid","domain":"user","audience":"user","defaultMessage":"导入文件格式损坏或无法解析,请重新导出为CSV或XLSX后再上传。","recovery":"follow_guidance"}, + {"code":"user.import_extension_unsupported","domain":"user","audience":"user","defaultMessage":"员工导入仅支持CSV或XLSX文件,请使用导入模板重新保存。","recovery":"follow_guidance"}, + {"code":"user.import_mime_unsupported","domain":"user","audience":"user","defaultMessage":"导入文件类型无法识别,请将表格另存为CSV或XLSX后重新上传。","recovery":"follow_guidance"}, + {"code":"user.import_headers_missing","domain":"user","audience":"user","defaultMessage":"导入表格缺少必填列,请按模板补齐工号、姓名、邮箱和部门列。","recovery":"follow_guidance"}, + {"code":"user.import_file_empty","domain":"user","audience":"user","defaultMessage":"导入表格没有员工数据,请在表头下填写员工资料后重试。","recovery":"follow_guidance"}, + {"code":"user.import_row_limit_exceeded","domain":"user","audience":"user","defaultMessage":"每次最多导入500行员工数据,请拆分表格后分别预览和导入。","recovery":"follow_guidance"}, + {"code":"announcement.id_invalid","domain":"announcement","audience":"user","defaultMessage":"公告标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"announcement.create_input_invalid","domain":"announcement","audience":"user","defaultMessage":"公告资料不完整或格式有误,请检查标题、内容和时间后重新创建。","recovery":"follow_guidance"}, + {"code":"announcement.update_input_invalid","domain":"announcement","audience":"user","defaultMessage":"公告修改内容无效,请检查修改项及时间后重新保存。","recovery":"follow_guidance"}, + {"code":"announcement.acknowledgement_input_invalid","domain":"announcement","audience":"user","defaultMessage":"请先勾选签收确认,再提交签收。","recovery":"follow_guidance"}, + {"code":"announcement.query_invalid","domain":"announcement","audience":"user","defaultMessage":"公告筛选条件无效,请刷新列表后重试。","recovery":"follow_guidance"}, + {"code":"announcement.disabled","domain":"announcement","audience":"user","defaultMessage":"公告功能暂未启用,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"announcement.statement_required","domain":"announcement","audience":"user","defaultMessage":"制度签收公告必须填写签收声明,请补充后保存。","recovery":"follow_guidance"}, + {"code":"announcement.acknowledged_content_locked","domain":"announcement","audience":"user","defaultMessage":"此公告已有员工签收,内容变更请发布新版本。","recovery":"follow_guidance"}, + {"code":"announcement.acknowledgement_not_required","domain":"announcement","audience":"user","defaultMessage":"此公告无需签收,请返回公告列表查看。","recovery":"follow_guidance"}, + {"code":"announcement.statement_missing","domain":"announcement","audience":"user","defaultMessage":"此公告缺少签收声明,暂时无法签收,请联系管理员补充。","recovery":"follow_guidance"}, + {"code":"employee_directory.query_invalid","domain":"employee_directory","audience":"user","defaultMessage":"员工筛选条件无效,请检查入职日期、人员状态及导出格式后重试。","recovery":"follow_guidance"}, + {"code":"employee_directory.hire_date_range_invalid","domain":"employee_directory","audience":"user","defaultMessage":"入职结束日期不能早于开始日期,请重新选择筛选范围。","recovery":"follow_guidance"}, + {"code":"contract_registry.query_invalid","domain":"contract_registry","audience":"user","defaultMessage":"合同筛选条件无效,请检查到期日期、合同状态及导出格式后重试。","recovery":"follow_guidance"}, + {"code":"contract_registry.expiry_date_range_invalid","domain":"contract_registry","audience":"user","defaultMessage":"合同到期结束日期不能早于开始日期,请重新选择筛选范围。","recovery":"follow_guidance"}, + {"code":"contract_registry.expiring_query_invalid","domain":"contract_registry","audience":"user","defaultMessage":"即将到期的筛选天数无效,请输入1至365之间的整数。","recovery":"follow_guidance"}, + {"code":"presence_history.date_range_invalid","domain":"presence_history","audience":"user","defaultMessage":"在岗历史结束日期不能早于开始日期,请重新选择日期范围。","recovery":"follow_guidance"}, + {"code":"presence_history.date_range_too_large","domain":"presence_history","audience":"user","defaultMessage":"在岗历史每次最多查询31天,请缩小日期范围后重试。","recovery":"follow_guidance"}, + {"code":"dashboard.query_invalid","domain":"dashboard","audience":"user","defaultMessage":"统计筛选条件无效,请检查日期、部门和显示数量后重试。","recovery":"follow_guidance"}, + {"code":"dashboard.date_range_invalid","domain":"dashboard","audience":"user","defaultMessage":"统计结束日期不能早于开始日期,请重新选择日期范围。","recovery":"follow_guidance"}, + {"code":"ap_operations.disabled","domain":"ap_operations","audience":"user","defaultMessage":"付款工作台暂未启用,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"ap_operations.ledger_query_invalid","domain":"ap_operations","audience":"user","defaultMessage":"财务台账筛选条件无效,请检查日期、状态和分页后重试。","recovery":"follow_guidance"}, + {"code":"user.create_input_invalid","domain":"user","audience":"user","defaultMessage":"账号资料不完整或格式有误,请检查姓名、邮箱、入职日期和角色后重新创建。","recovery":"follow_guidance"}, + {"code":"user.update_input_invalid","domain":"user","audience":"user","defaultMessage":"账号修改内容格式有误,请检查联系方式、入职日期和角色后重新保存。","recovery":"follow_guidance"}, + {"code":"user.employee_no_immutable","domain":"user","audience":"user","defaultMessage":"工号创建后不能修改,请保留原工号;如登记有误,请联系管理员核查。","recovery":"follow_guidance"}, + {"code":"user.update_empty","domain":"user","audience":"user","defaultMessage":"没有可保存的账号修改,请修改资料后再保存。","recovery":"follow_guidance"}, + {"code":"user.import_rows_invalid","domain":"user","audience":"user","defaultMessage":"导入表格中仍有错误行,本次未导入任何员工,请先预览并修正标出的行后重试。","recovery":"follow_guidance"}, + {"code":"notification.list_query_invalid","domain":"notification","audience":"user","defaultMessage":"通知筛选条件无效,请检查页码、每页条数和未读筛选后重试。","recovery":"follow_guidance"}, + {"code":"notification.delivery_query_invalid","domain":"notification","audience":"user","defaultMessage":"通知投递筛选条件无效,请重新选择通知、渠道或投递状态后重试。","recovery":"follow_guidance"}, + {"code":"notification.id_invalid","domain":"notification","audience":"user","defaultMessage":"通知或渠道标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"notification.channel_input_invalid","domain":"notification","audience":"user","defaultMessage":"通知渠道资料不完整或格式有误,请检查名称、发送方式、渠道配置和消息模板。","recovery":"follow_guidance"}, + {"code":"notification.channel_name_duplicate","domain":"notification","audience":"user","defaultMessage":"已有同名通知渠道,请更换名称后保存。","recovery":"follow_guidance"}, + {"code":"notification.encryption_key_unavailable","domain":"notification","audience":"user","defaultMessage":"通知渠道加密配置不可用,请联系管理员检查原字段加密密钥后再保存;不要生成新密钥替代。","recovery":"follow_guidance"}, + {"code":"todo.input_invalid","domain":"todo","audience":"user","defaultMessage":"待办资料不完整或格式有误,请检查标题、负责人、截止日期和优先级后重试。","recovery":"follow_guidance"}, + {"code":"todo.id_invalid","domain":"todo","audience":"user","defaultMessage":"待办标识无效,请刷新列表后重新选择。","recovery":"follow_guidance"}, + {"code":"todo.disabled","domain":"todo","audience":"user","defaultMessage":"待办功能暂未启用,请联系管理员确认。","recovery":"follow_guidance"}, + {"code":"todo.query_invalid","domain":"todo","audience":"user","defaultMessage":"待办筛选条件无效,请检查状态、范围和负责人后重试。","recovery":"follow_guidance"}, + {"code":"portal.disabled","domain":"portal","audience":"user","defaultMessage":"工作台暂未启用,请联系管理员确认。","recovery":"follow_guidance"}, {"code":"workflow.task_already_processed","domain":"workflow","audience":"user","defaultMessage":"此审批任务已处理,请刷新待办查看最新进度。","recovery":"follow_guidance"}, {"code":"workflow.entity_unavailable","domain":"workflow","audience":"user","defaultMessage":"审批对应的单据不存在或当前不可处理,请联系管理员核查。","recovery":"follow_guidance"}, {"code":"workflow.self_approval_forbidden","domain":"workflow","audience":"user","defaultMessage":"不能审批本人申请或代提交的单据,请由符合资格的其他审批人处理。","recovery":"follow_guidance"}, diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index 7314c318..61dda471 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -180,6 +180,7 @@ const ROLE_PERMISSIONS = { "payment.batch.review", ], finance: [ + "loan.manage", "finance.ledger.view", ...EMPLOYEE_BASE_PERMISSIONS, ...MASTERDATA_VIEW_PERMISSIONS, @@ -201,6 +202,7 @@ const ROLE_PERMISSIONS = { "payroll.manage", ], finance_mgr: [ + "loan.manage", "finance.ledger.view", ...EMPLOYEE_BASE_PERMISSIONS, ...MASTERDATA_MANAGE_PERMISSIONS, diff --git a/server/src/jobs/erpMasterDataSyncJob.js b/server/src/jobs/erpMasterDataSyncJob.js deleted file mode 100644 index 87d2a29c..00000000 --- a/server/src/jobs/erpMasterDataSyncJob.js +++ /dev/null @@ -1,91 +0,0 @@ -const cron = require('node-cron'); - -const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require('../services/featureFlags'); -const { syncErpMasterData } = require('../services/integrations/erpMasterData'); - -async function runErpMasterDataSyncOnce(prisma, config, logger) { - if (!config.erpMasterDataSyncCron) { - return { - skipped: true, - reason: 'cron_disabled', - items: [], - }; - } - - const gate = await evaluateFeatureFlag({ - prisma, - config, - flagKey: FEATURE_FLAG_KEYS.erpSync, - departmentId: null, - strictDepartment: false, - }); - - if (!gate.enabled) { - logger.info( - { - featureFlag: FEATURE_FLAG_KEYS.erpSync, - reason: gate.reason, - }, - 'ERP master data sync skipped by ERP gate', - ); - return { - skipped: true, - reason: gate.reason, - items: [], - }; - } - - return syncErpMasterData(prisma, config, { logger }); -} - -function startErpMasterDataSyncJob(fastify) { - if (!fastify.config.erpMasterDataSyncCron) { - fastify.log.info('ERP master data sync cron disabled'); - return null; - } - - let running = false; - const task = cron.schedule(fastify.config.erpMasterDataSyncCron, async () => { - if (running) { - return; - } - - running = true; - try { - await runErpMasterDataSyncOnce(fastify.prisma, fastify.config, fastify.log); - fastify.metrics?.jobRunsTotal?.inc?.({ - job: 'erp.master-data.sync', - driver: 'cron', - outcome: 'success', - }); - } catch (error) { - fastify.log.error({ err: error }, 'ERP master data sync job failed'); - fastify.metrics?.jobRunsTotal?.inc?.({ - job: 'erp.master-data.sync', - driver: 'cron', - outcome: 'failure', - }); - } finally { - running = false; - } - }); - - fastify.addHook('onClose', async () => { - task.stop(); - task.destroy(); - }); - - fastify.log.info( - { - cron: fastify.config.erpMasterDataSyncCron, - }, - 'ERP master data sync job scheduled', - ); - - return task; -} - -module.exports = { - runErpMasterDataSyncOnce, - startErpMasterDataSyncJob, -}; diff --git a/server/src/jobs/jobSystem.js b/server/src/jobs/jobSystem.js index 610ef4e6..8eeef6f7 100644 --- a/server/src/jobs/jobSystem.js +++ b/server/src/jobs/jobSystem.js @@ -3,7 +3,6 @@ const { startInvoiceArchiveJob } = require("./invoiceArchiveJob"); const { startOcrJob } = require("./ocrJob"); const { startReceiptOcrJob } = require("./paymentReceiptOcrJob"); const { startIntegrationOutboxWorker } = require("./integrationOutboxWorker"); -const { startErpMasterDataSyncJob } = require("./erpMasterDataSyncJob"); const { startPgBossJobs } = require("./pgBossJobs"); async function startJobs(fastify) { @@ -22,7 +21,6 @@ async function startJobs(fastify) { startReceiptOcrJob(fastify); startInvoiceArchiveJob(fastify); startIntegrationOutboxWorker(fastify); - startErpMasterDataSyncJob(fastify); return { driver: "cron" }; } diff --git a/server/src/jobs/paymentReceiptOcrJob.js b/server/src/jobs/paymentReceiptOcrJob.js index 78e4d668..0af34f90 100644 --- a/server/src/jobs/paymentReceiptOcrJob.js +++ b/server/src/jobs/paymentReceiptOcrJob.js @@ -72,13 +72,23 @@ async function claimReceiptJob(prisma) { `; if (!batches.length) return null; const batch = await tx.paymentBatch.findUnique({ where: { id: batches[0].id }, include: { items: true } }); - if (batch?.entity_type !== "reimbursement" || !requiresBankReceipt(batch)) { + if (!["reimbursement", "loan_repayment"].includes(batch?.entity_type) || !requiresBankReceipt(batch)) { await tx.paymentBatchReceiptAttachment.updateMany({ where: { payment_batch_id: batches[0].id, receipt_ocr_state: "queued" }, data: { receipt_ocr_state: "legacy" }, }); return null; } + if (batch.entity_type === "loan_repayment") { + try { await require("../services/companyLoanPayments").validateLoanPayment(tx, batch); } + catch (error) { + if (!error?.details?.reasonCode?.startsWith("loan.")) throw error; + await tx.paymentBatchReceiptAttachment.updateMany({ + where: { payment_batch_id: batch.id, receipt_ocr_state: "queued" }, data: { receipt_ocr_state: "legacy" }, + }); + return null; + } + } const attachments = await tx.$queryRaw` SELECT id FROM payment_batch_receipt_attachments WHERE payment_batch_id = ${batches[0].id}::uuid AND receipt_ocr_state = 'queued' diff --git a/server/src/jobs/pgBossJobs.js b/server/src/jobs/pgBossJobs.js index 2304e1f2..ef0277c3 100644 --- a/server/src/jobs/pgBossJobs.js +++ b/server/src/jobs/pgBossJobs.js @@ -6,7 +6,6 @@ const { archiveUnusedInvoices } = require("./invoiceArchiveJob"); const { runOcrForInvoiceId, runOcrOnce } = require("./ocrJob"); const { runVerificationForRecord } = require("./verificationJob"); const { runIntegrationOutboxOnce } = require("./integrationOutboxJob"); -const { runErpMasterDataSyncOnce } = require("./erpMasterDataSyncJob"); const { publishInvoiceUpdated } = require("../services/events"); const { FEATURE_FLAG_KEYS, @@ -19,7 +18,6 @@ const JOB_DELEGATION_REVOKE = "delegation.revoke-expired"; const JOB_INVOICE_ARCHIVE = "invoice.archive-unused"; const JOB_INVOICE_VERIFY = "invoice.verify"; const JOB_INTEGRATION_OUTBOX = "integration.outbox.dispatch"; -const JOB_ERP_MASTER_DATA_SYNC = "erp.master-data.sync"; function resolveDatabaseConnectionString() { const connectionString = process.env.DATABASE_URL; @@ -52,9 +50,6 @@ async function startPgBossJobs(fastify) { await boss.createQueue(JOB_INVOICE_ARCHIVE); await boss.createQueue(JOB_INVOICE_VERIFY); await boss.createQueue(JOB_INTEGRATION_OUTBOX); - if (fastify.config.erpMasterDataSyncCron) { - await boss.createQueue(JOB_ERP_MASTER_DATA_SYNC); - } const onOcrInvoiceProcessed = (event) => publishInvoiceUpdated(fastify, { @@ -278,37 +273,6 @@ async function startPgBossJobs(fastify) { } }); - - if (fastify.config.erpMasterDataSyncCron) { - await boss.work(JOB_ERP_MASTER_DATA_SYNC, { batchSize: 1 }, async (jobs) => { - const job = jobs?.[0]; - - try { - const result = await runErpMasterDataSyncOnce( - fastify.prisma, - fastify.config, - fastify.log, - ); - fastify.metrics?.jobRunsTotal?.inc?.({ - job: JOB_ERP_MASTER_DATA_SYNC, - driver: "pgboss", - outcome: "success", - }); - if (!result?.skipped) { - fastify.log.info({ jobId: job?.id, result }, "pg-boss ERP master data sync processed"); - } - } catch (error) { - fastify.metrics?.jobRunsTotal?.inc?.({ - job: JOB_ERP_MASTER_DATA_SYNC, - driver: "pgboss", - outcome: "failure", - }); - fastify.log.error({ err: error, jobId: job?.id }, "pg-boss ERP master data sync failed"); - throw error; - } - }); - } - await boss.work(JOB_INTEGRATION_OUTBOX, { batchSize: 1 }, async (jobs) => { const job = jobs?.[0]; @@ -405,16 +369,6 @@ async function startPgBossJobs(fastify) { singletonSeconds: 55, }); - if (fastify.config.erpMasterDataSyncCron) { - await boss.schedule(JOB_ERP_MASTER_DATA_SYNC, fastify.config.erpMasterDataSyncCron, null, { - tz: fastify.config.appTimezone, - singletonKey: JOB_ERP_MASTER_DATA_SYNC, - singletonSeconds: 55, - }); - } else { - await boss.unschedule(JOB_ERP_MASTER_DATA_SYNC).catch(() => {}); - } - fastify.log.info( { schema: fastify.config.pgBossSchema, @@ -426,7 +380,6 @@ async function startPgBossJobs(fastify) { delegationCron: fastify.config.delegationRevokeCron, invoiceArchiveCron: fastify.config.invoiceArchiveCron, integrationOutboxCron: fastify.config.integrationOutboxCron, - erpMasterDataSyncCron: fastify.config.erpMasterDataSyncCron || null, }, "pg-boss job system started", ); @@ -454,7 +407,6 @@ module.exports = { JOB_INVOICE_ARCHIVE, JOB_INVOICE_VERIFY, JOB_INTEGRATION_OUTBOX, - JOB_ERP_MASTER_DATA_SYNC, enqueueOcrInvoice, startPgBossJobs, }; diff --git a/server/src/plugins/auth.js b/server/src/plugins/auth.js index 722f6ea6..087a95b8 100644 --- a/server/src/plugins/auth.js +++ b/server/src/plugins/auth.js @@ -1,6 +1,7 @@ const fastifyJwt = require("@fastify/jwt"); const fp = require("fastify-plugin"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { resolvePermissionsForRoles, hasPermission, @@ -46,12 +47,7 @@ async function authPlugin(fastify) { fastify.decorate("assertEntityPermission", (employee, entityType, action) => { const permissionCode = resolveEntityPermission(entityType, action); if (!permissionCode) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { entityType, action, permission: "unsupported_entity_action" }, - }); + throw buildBusinessValidationError("auth.entity_action_unsupported", { entityType, action, permission: "unsupported_entity_action" }, 409); } return fastify.assertPermission(employee, permissionCode, { entityType, action }); diff --git a/server/src/routes/ai.js b/server/src/routes/ai.js index 8216d502..be2e9a08 100644 --- a/server/src/routes/ai.js +++ b/server/src/routes/ai.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { sendData } = require("../utils/reply"); const { getAiStatus, @@ -11,15 +13,10 @@ const { const UUID_SCHEMA = z.string().uuid(); -function parseUuid(value, fieldName) { +function parseUuid(value, fieldName, reasonCode) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { [fieldName]: "invalid_uuid" }, - }); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, { [fieldName]: "invalid_uuid" }, 400); } return parsed.data; @@ -53,7 +50,7 @@ async function aiRoutes(fastify) { }, }, async (request, reply) => { - const reimbursementId = parseUuid(request.params?.reimbursementId, "reimbursementId"); + const reimbursementId = parseUuid(request.params?.reimbursementId, "reimbursementId", "ai.reimbursement_id_invalid"); const result = await generateReimbursementSuggestion( fastify.prisma, fastify.config, @@ -75,7 +72,7 @@ async function aiRoutes(fastify) { }, }, async (request, reply) => { - const purchaseRequestId = parseUuid(request.params?.purchaseRequestId, "purchaseRequestId"); + const purchaseRequestId = parseUuid(request.params?.purchaseRequestId, "purchaseRequestId", "ai.purchase_id_invalid"); const result = await generatePurchaseSuggestion( fastify.prisma, fastify.config, @@ -97,7 +94,7 @@ async function aiRoutes(fastify) { }, }, async (request, reply) => { - const assetId = parseUuid(request.params?.assetId, "assetId"); + const assetId = parseUuid(request.params?.assetId, "assetId", "ai.asset_id_invalid"); const result = await generateAssetSuggestion( fastify.prisma, fastify.config, diff --git a/server/src/routes/announcements.js b/server/src/routes/announcements.js index eda39963..637c4d82 100644 --- a/server/src/routes/announcements.js +++ b/server/src/routes/announcements.js @@ -1,3 +1,6 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); +const { isValidDateOnlyString } = require("../utils/date"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); @@ -6,7 +9,8 @@ const { buildAuditLogData } = require("../utils/audit"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const UUID_SCHEMA = z.string().uuid(); -const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}T/)); +const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}T/)) + .refine((value) => isValidDateOnlyString(value.slice(0, 10)) && Number.isFinite(new Date(value).getTime()), { message: "invalid_calendar_datetime" }); const QUERY_SCHEMA = z.object({ manage: z.enum(["0", "1", "true", "false"]).optional() }); const WRITE_SCHEMA = z.object({ title: z.string().trim().min(1).max(120), @@ -25,26 +29,23 @@ const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value) const ACKNOWLEDGE_SCHEMA = z.object({ confirmed: z.literal(true) }).strict(); const ACKNOWLEDGEMENT_PROTECTED_FIELDS = new Set(["title", "summary", "category", "requiresAcknowledgement", "acknowledgementStatement"]); -function buildValidationError(details, statusCode = 400) { - return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); -} function buildNotFoundError(details = {}) { return new AppError({ code: "ANN_404", message: "公告不存在或不可见", statusCode: 404, details }); } function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); - if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); + if (!parsed.success) throw buildBusinessValidationError("announcement.id_invalid", { id: "invalid_uuid" }); return parsed.data; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); - if (!parsed.success) throw buildValidationError(parsed.error.flatten()); + if (!parsed.success) throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); return parsed.data; } async function ensureFeatureEnabled(fastify) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.announcements, strictDepartment: false }); if (!gate.enabled) { - throw buildValidationError({ featureFlag: "announcements_v1_disabled", reason: gate.reason }, 409); + throw buildBusinessValidationError("announcement.disabled", { featureFlag: "announcements_v1_disabled", reason: gate.reason }, 409); } } function normalizeAnnouncementPolicyFields(body, current = null) { @@ -66,7 +67,7 @@ function normalizeAnnouncementPolicyFields(body, current = null) { : current?.acknowledgement_due_at ?? null; if (requiresAcknowledgement && !acknowledgementStatement) { - throw buildValidationError({ acknowledgementStatement: "required_for_policy_acknowledgement" }); + throw buildBusinessValidationError("announcement.statement_required", { acknowledgementStatement: "required_for_policy_acknowledgement" }); } return { @@ -168,7 +169,7 @@ async function announcementsRoutes(fastify) { fastify.get("/announcements", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify); const queryParsed = QUERY_SCHEMA.safeParse(request.query ?? {}); - if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten()); + if (!queryParsed.success) throw buildBusinessValidationError("announcement.query_invalid", queryParsed.error.flatten()); const query = queryParsed.data; const isManage = query.manage === "1" || query.manage === "true"; if (isManage) { @@ -205,7 +206,7 @@ async function announcementsRoutes(fastify) { fastify.post("/announcements", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify); - const body = parseBody(WRITE_SCHEMA, request.body); + const body = parseBody(WRITE_SCHEMA, request.body, "announcement.create_input_invalid"); const policyFields = normalizeAnnouncementPolicyFields(body); const created = await fastify.prisma.portalAnnouncement.create({ data: { @@ -236,7 +237,7 @@ async function announcementsRoutes(fastify) { fastify.patch("/announcements/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify); const announcementId = parseId(request.params?.id); - const body = parseBody(PATCH_SCHEMA, request.body); + const body = parseBody(PATCH_SCHEMA, request.body, "announcement.update_input_invalid"); const current = await fastify.prisma.portalAnnouncement.findUnique({ where: { id: announcementId } }); if (!current) throw buildNotFoundError({ id: "not_found" }); @@ -245,7 +246,7 @@ async function announcementsRoutes(fastify) { where: { announcement_id: announcementId }, }); if (acknowledgementCount > 0) { - throw buildValidationError({ acknowledgement: "create_new_version_after_acknowledgement" }, 409); + throw buildBusinessValidationError("announcement.acknowledged_content_locked", { acknowledgement: "create_new_version_after_acknowledgement" }, 409); } } @@ -280,7 +281,7 @@ async function announcementsRoutes(fastify) { fastify.post("/announcements/:id/acknowledge", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify); const announcementId = parseId(request.params?.id); - parseBody(ACKNOWLEDGE_SCHEMA, request.body); + parseBody(ACKNOWLEDGE_SCHEMA, request.body, "announcement.acknowledgement_input_invalid"); const now = new Date(); const announcement = await fastify.prisma.portalAnnouncement.findUnique({ @@ -296,10 +297,10 @@ async function announcementsRoutes(fastify) { throw buildNotFoundError({ id: "not_found_or_not_visible" }); } if (!announcement.requires_acknowledgement) { - throw buildValidationError({ acknowledgement: "not_required" }, 409); + throw buildBusinessValidationError("announcement.acknowledgement_not_required", { acknowledgement: "not_required" }, 409); } if (!announcement.acknowledgement_statement) { - throw buildValidationError({ acknowledgementStatement: "missing_for_policy_acknowledgement" }, 409); + throw buildBusinessValidationError("announcement.statement_missing", { acknowledgementStatement: "missing_for_policy_acknowledgement" }, 409); } const existing = announcement.acknowledgements?.[0] ?? null; diff --git a/server/src/routes/apOperations.js b/server/src/routes/apOperations.js index 19d34958..1235f2e3 100644 --- a/server/src/routes/apOperations.js +++ b/server/src/routes/apOperations.js @@ -1,3 +1,5 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { isValidDateOnlyString } = require("../utils/date"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); @@ -18,8 +20,8 @@ const FINANCE_LEDGER_QUERY_SCHEMA = z.object({ status: z.enum(REIMBURSEMENT_LEDGER_STATUS_VALUES).optional(), paymentStatus: z.enum(REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES).optional(), invoiceStatus: z.enum(REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES).optional(), - dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), + dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), q: z.string().trim().max(100).optional(), }); @@ -39,12 +41,7 @@ async function ensureFeatureEnabled(fastify) { strictDepartment: false, }); if (!gate.enabled) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, - }); + throw buildBusinessValidationError("ap_operations.disabled", { featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, 409); } } @@ -208,12 +205,7 @@ async function apOperationsRoutes(fastify) { const parsed = FINANCE_LEDGER_QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("ap_operations.ledger_query_invalid", parsed.error.flatten()); } const ledger = await listFinanceLedger(fastify.prisma, parsed.data); diff --git a/server/src/routes/approvalRules.js b/server/src/routes/approvalRules.js index 17ce4105..3ce7db84 100644 --- a/server/src/routes/approvalRules.js +++ b/server/src/routes/approvalRules.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const { ROLE_PERMISSIONS } = require("../constants/permissions"); @@ -28,15 +30,10 @@ const CREATE_BODY_SCHEMA = z.object({ const UPDATE_BODY_SCHEMA = CREATE_BODY_SCHEMA.partial(); -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten(), 400); } return parsed.data; @@ -45,12 +42,7 @@ function parseBody(schema, body) { function parseApprovalRuleId(params) { const parsed = z.string().uuid().safeParse(params?.id); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { id: "invalid_uuid" }, - }); + throw buildBusinessValidationError("approval_rule.id_invalid", { id: "invalid_uuid" }, 400); } return parsed.data; @@ -64,21 +56,11 @@ function ensureAmountRangeValid(minAmount, maxAmount) { const minValue = Number(minAmount); const maxValue = Number(maxAmount); if (!Number.isFinite(minValue) || !Number.isFinite(maxValue)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { amount: "invalid" }, - }); + throw buildBusinessValidationError("approval_rule.amount_invalid", { amount: "invalid" }, 400); } if (maxValue <= minValue) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { maxAmount: "must_be_greater_than_minAmount" }, - }); + throw buildBusinessValidationError("approval_rule.amount_range_invalid", { maxAmount: "must_be_greater_than_minAmount" }, 400); } } @@ -88,12 +70,7 @@ function ensureOwnerSelfFallbackRoleValid(roleCode) { } if (!OWNER_SELF_FALLBACK_ROLE_CODES.includes(roleCode)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { ownerSelfFallbackRole: "unsupported_business_approval_role" }, - }); + throw buildBusinessValidationError("approval_rule.fallback_role_invalid", { ownerSelfFallbackRole: "unsupported_business_approval_role" }, 400); } } @@ -124,7 +101,7 @@ async function approvalRulesRoutes(fastify) { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { - const body = parseBody(CREATE_BODY_SCHEMA, request.body); + const body = parseBody(CREATE_BODY_SCHEMA, request.body, "approval_rule.create_input_invalid"); const maxAmount = Object.prototype.hasOwnProperty.call(body, "maxAmount") ? body.maxAmount : null; ensureAmountRangeValid(body.minAmount, maxAmount); ensureOwnerSelfFallbackRoleValid(body.ownerSelfFallbackRole); @@ -205,15 +182,10 @@ async function approvalRulesRoutes(fastify) { }, async (request, reply) => { const approvalRuleId = parseApprovalRuleId(request.params); - const body = parseBody(UPDATE_BODY_SCHEMA, request.body); + const body = parseBody(UPDATE_BODY_SCHEMA, request.body, "approval_rule.update_input_invalid"); if (Object.keys(body).length === 0) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { body: "empty_patch" }, - }); + throw buildBusinessValidationError("approval_rule.empty_update", { body: "empty_patch" }, 400); } if (Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole")) { ensureOwnerSelfFallbackRoleValid(body.ownerSelfFallbackRole); diff --git a/server/src/routes/assetMovements.js b/server/src/routes/assetMovements.js index dfa8d499..fd40c91d 100644 --- a/server/src/routes/assetMovements.js +++ b/server/src/routes/assetMovements.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const { createNotificationsWithRecords } = require("../services/notifications"); @@ -35,27 +37,18 @@ const TRANSFER_BODY_SCHEMA = z const BLOCKED_MOVEMENT_STATUSES = new Set(["retired", "archived"]); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseUuid(fieldName, value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ [fieldName]: "invalid_uuid" }); + throw buildBusinessValidationError("asset_movement.id_invalid", { [fieldName]: "invalid_uuid" }); } return parsed.data; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } @@ -82,7 +75,7 @@ function uniq(values) { function ensureMovementAllowed(asset) { if (BLOCKED_MOVEMENT_STATUSES.has(asset.status)) { - throw buildValidationError({ status: "movement_not_allowed" }, 409); + throw buildBusinessValidationError("asset_movement.status_blocked", { status: "movement_not_allowed" }, 409); } } @@ -104,7 +97,7 @@ async function loadAsset(tx, assetId) { async function ensureAssetExists(tx, assetId) { const asset = await loadAsset(tx, assetId); if (!asset) { - throw buildValidationError({ id: "not_found" }, 404); + throw buildBusinessValidationError("asset_movement.not_found", { id: "not_found" }, 404); } ensureMovementAllowed(asset); return asset; @@ -121,10 +114,10 @@ async function ensureActiveEmployee(tx, employeeId, fieldName = "employeeId") { }, }); if (!employee) { - throw buildValidationError({ [fieldName]: "not_found" }, 404); + throw buildBusinessValidationError("asset_movement.employee_not_found", { [fieldName]: "not_found" }, 404); } if (employee.status !== "active") { - throw buildValidationError({ [fieldName]: "inactive" }, 409); + throw buildBusinessValidationError("asset_movement.employee_inactive", { [fieldName]: "inactive" }, 409); } return employee; } @@ -201,7 +194,7 @@ async function assetMovementsRoutes(fastify) { }, async (request, reply) => { const assetId = parseUuid("id", request.params?.id); - const body = parseBody(ASSIGN_BODY_SCHEMA, request.body); + const body = parseBody(ASSIGN_BODY_SCHEMA, request.body, "asset_movement.assign_input_invalid"); const note = normalizeNote(body.note); const emailTasks = []; const dispatchTasks = []; @@ -210,7 +203,7 @@ async function assetMovementsRoutes(fastify) { const asset = await ensureAssetExists(tx, assetId); const openAssignment = await findOpenAssignment(tx, asset.id); if (asset.assignee_employee_id || openAssignment) { - throw buildValidationError({ asset: "already_assigned" }, 409); + throw buildBusinessValidationError("asset_movement.already_assigned", { asset: "already_assigned" }, 409); } const employee = await ensureActiveEmployee(tx, body.employeeId); @@ -327,7 +320,7 @@ async function assetMovementsRoutes(fastify) { }, async (request, reply) => { const assetId = parseUuid("id", request.params?.id); - const body = parseBody(RETURN_BODY_SCHEMA, request.body); + const body = parseBody(RETURN_BODY_SCHEMA, request.body, "asset_movement.return_input_invalid"); const note = normalizeNote(body.note); const emailTasks = []; const dispatchTasks = []; @@ -335,7 +328,7 @@ async function assetMovementsRoutes(fastify) { const result = await fastify.prisma.$transaction(async (tx) => { const asset = await ensureAssetExists(tx, assetId); if (!asset.assignee_employee_id) { - throw buildValidationError({ asset: "not_assigned" }, 409); + throw buildBusinessValidationError("asset_movement.not_assigned", { asset: "not_assigned" }, 409); } const returnedAt = new Date(); @@ -438,7 +431,7 @@ async function assetMovementsRoutes(fastify) { }, async (request, reply) => { const assetId = parseUuid("id", request.params?.id); - const body = parseBody(TRANSFER_BODY_SCHEMA, request.body); + const body = parseBody(TRANSFER_BODY_SCHEMA, request.body, "asset_movement.transfer_input_invalid"); const note = normalizeNote(body.note); const hasEmployeeField = Object.prototype.hasOwnProperty.call(body, "toEmployeeId"); const hasLocationField = Object.prototype.hasOwnProperty.call(body, "toLocation"); @@ -453,14 +446,14 @@ async function assetMovementsRoutes(fastify) { const requestedEmployeeId = hasEmployeeField ? body.toEmployeeId ?? null : currentEmployeeId; if (hasEmployeeField && body.toEmployeeId == null && currentEmployeeId) { - throw buildValidationError({ toEmployeeId: "use_return" }, 409); + throw buildBusinessValidationError("asset_movement.use_return", { toEmployeeId: "use_return" }, 409); } const employeeChange = hasEmployeeField && requestedEmployeeId !== currentEmployeeId; const locationChange = hasLocationField && nextLocation !== currentLocation; if (!employeeChange && !locationChange) { - throw buildValidationError({ transfer: "no_change" }, 409); + throw buildBusinessValidationError("asset_movement.no_change", { transfer: "no_change" }, 409); } if (employeeChange) { diff --git a/server/src/routes/assets.js b/server/src/routes/assets.js index 12ef0ce1..831d0bd1 100644 --- a/server/src/routes/assets.js +++ b/server/src/routes/assets.js @@ -1,15 +1,11 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { buildAuditLogData } = require("../utils/audit"); const { parseDateOnlyString, isValidDateOnlyString } = require("../utils/date"); const { issueDocumentNumber } = require("../services/documentSequence"); -const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require('../services/featureFlags'); -const { - ASSET_ERP_TARGET, - enqueueAssetLedgerSummaryExport, - resolveAssetLedgerSummaryChange, -} = require('../services/integrations/erpAsset'); const { sendData } = require("../utils/reply"); const UUID_SCHEMA = z.string().uuid(); @@ -251,15 +247,6 @@ const ASSET_CARD_SELECT = { }, }; -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseBooleanQueryValue(rawValue) { if (rawValue == null) { return null; @@ -267,10 +254,10 @@ function parseBooleanQueryValue(rawValue) { return rawValue === "1" || rawValue === "true"; } -function parseUuid(fieldName, value) { +function parseUuid(fieldName, value, reasonCode) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ [fieldName]: "invalid_uuid" }); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, { [fieldName]: "invalid_uuid" }); } return parsed.data; } @@ -278,7 +265,7 @@ function parseUuid(fieldName, value) { function parseCategoryListQuery(query) { const parsed = CATEGORY_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("asset.category_query_invalid", parsed.error.flatten()); } return { includeArchived: parseBooleanQueryValue(parsed.data.includeArchived) ?? false, @@ -289,7 +276,7 @@ function parseCategoryListQuery(query) { function parseAssetListQuery(query) { const parsed = ASSET_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("asset.query_invalid", parsed.error.flatten()); } return { status: parsed.data.status, @@ -309,26 +296,26 @@ function parseAssetListQuery(query) { function parseAssigneeListQuery(query) { const parsed = ASSIGNEE_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("asset.assignee_query_invalid", parsed.error.flatten()); } return { q: parsed.data.q?.trim() ?? "" }; } -function parseCreateBody(schema, body) { +function parseCreateBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } -function parsePatchBody(schema, body) { +function parsePatchBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { - throw buildValidationError({ body: "empty" }); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, { body: "empty" }); } return parsed.data; } @@ -464,7 +451,7 @@ function validateAssetTextBoundaries(payload) { validateSourceHash("sourceHash", payload.sourceHash, details); validateMetadataField("metadata", payload.metadata, details); if (Object.keys(details).length > 0) { - throw buildValidationError(details); + throw buildBusinessValidationError("asset.unsafe_content", details); } } @@ -479,7 +466,7 @@ function validateRedactedImportBoundaries(payload) { validateSourceHash(`items.${index}.sourceHash`, item.sourceHash, details); } if (Object.keys(details).length > 0) { - throw buildValidationError(details); + throw buildBusinessValidationError("asset.index_import_unsafe_content", details); } } @@ -695,13 +682,13 @@ async function ensureDocumentCategoryAvailable(tx, documentCategoryId) { }, }); if (!record) { - throw buildValidationError({ documentCategoryId: "not_found" }, 404); + throw buildBusinessValidationError("asset.document_category_not_found", { documentCategoryId: "not_found" }, 404); } if (record.entity_type !== "asset_card") { - throw buildValidationError({ documentCategoryId: "unsupported_entity_type" }, 409); + throw buildBusinessValidationError("asset.document_category_type_invalid", { documentCategoryId: "unsupported_entity_type" }, 409); } if (!record.enabled || record.archived_at) { - throw buildValidationError({ documentCategoryId: "inactive" }, 409); + throw buildBusinessValidationError("asset.document_category_inactive", { documentCategoryId: "inactive" }, 409); } return record; } @@ -729,13 +716,13 @@ async function ensureAssetCategoryExists(tx, categoryId, { requireActive = true }, }); if (!record) { - throw buildValidationError({ categoryId: "not_found" }, 404); + throw buildBusinessValidationError("asset.category_not_found", { categoryId: "not_found" }, 404); } if (requireActive && (!record.enabled || record.archived_at)) { - throw buildValidationError({ categoryId: "inactive" }, 409); + throw buildBusinessValidationError("asset.category_inactive", { categoryId: "inactive" }, 409); } if (record.document_category && (record.document_category.entity_type !== "asset_card" || !record.document_category.enabled || record.document_category.archived_at)) { - throw buildValidationError({ categoryId: "invalid_document_category" }, 409); + throw buildBusinessValidationError("asset.category_document_invalid", { categoryId: "invalid_document_category" }, 409); } return record; } @@ -746,10 +733,10 @@ async function ensureLegalEntityAvailable(tx, legalEntityId) { select: { id: true, code: true, name: true, enabled: true, archived_at: true }, }); if (!record) { - throw buildValidationError({ legalEntityId: "not_found" }, 404); + throw buildBusinessValidationError("asset.company_not_found", { legalEntityId: "not_found" }, 404); } if (!record.enabled || record.archived_at) { - throw buildValidationError({ legalEntityId: "inactive" }, 409); + throw buildBusinessValidationError("asset.company_inactive", { legalEntityId: "inactive" }, 409); } return record; } @@ -767,18 +754,18 @@ async function ensureCostCenterAvailable(tx, costCenterId, legalEntityId) { }, }); if (!record) { - throw buildValidationError({ costCenterId: "not_found" }, 404); + throw buildBusinessValidationError("asset.cost_center_not_found", { costCenterId: "not_found" }, 404); } if (!record.enabled || record.archived_at) { - throw buildValidationError({ costCenterId: "inactive" }, 409); + throw buildBusinessValidationError("asset.cost_center_inactive", { costCenterId: "inactive" }, 409); } if (record.legal_entity_id !== legalEntityId) { - throw buildValidationError({ costCenterId: "legal_entity_mismatch" }, 409); + throw buildBusinessValidationError("asset.cost_center_company_mismatch", { costCenterId: "legal_entity_mismatch" }, 409); } return record; } -async function ensureActiveEmployee(tx, fieldName, employeeId) { +async function ensureActiveEmployee(tx, fieldName, employeeId, notFoundReason, inactiveReason) { if (!employeeId) { return null; } @@ -792,16 +779,16 @@ async function ensureActiveEmployee(tx, fieldName, employeeId) { }, }); if (!record) { - throw buildValidationError({ [fieldName]: "not_found" }, 404); + throw buildBusinessValidationError(getErrorReason(notFoundReason).code, { [fieldName]: "not_found" }, 404); } if (record.status !== "active") { - throw buildValidationError({ [fieldName]: "inactive" }, 409); + throw buildBusinessValidationError(getErrorReason(inactiveReason).code, { [fieldName]: "inactive" }, 409); } return record; } async function ensureAssigneeEmployee(tx, assigneeEmployeeId) { - return ensureActiveEmployee(tx, "assigneeEmployeeId", assigneeEmployeeId); + return ensureActiveEmployee(tx, "assigneeEmployeeId", assigneeEmployeeId, "asset.assignee_not_found", "asset.assignee_inactive"); } function assetDocumentCategoryCode(assetType, category = null) { @@ -829,137 +816,14 @@ async function ensureApprovedPurchaseRequest(tx, purchaseRequestId) { }, }); if (!record) { - throw buildValidationError({ purchaseRequestId: "not_found" }, 404); + throw buildBusinessValidationError("asset.purchase_not_found", { purchaseRequestId: "not_found" }, 404); } if (record.status !== "approved") { - throw buildValidationError({ purchaseRequestId: "not_approved" }, 409); + throw buildBusinessValidationError("asset.purchase_not_approved", { purchaseRequestId: "not_approved" }, 409); } return record; } -async function handleAssetLedgerSummaryErpSync({ - tx, - config, - request, - actorId, - asset, - sourceEventName, - changeType, - changedFields, - evaluateGate = evaluateFeatureFlag, - enqueueExport = enqueueAssetLedgerSummaryExport, -}) { - const gate = await evaluateGate({ - prisma: tx, - config, - flagKey: FEATURE_FLAG_KEYS.erpSync, - departmentId: null, - strictDepartment: false, - }); - - if (!gate.enabled) { - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId, - action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG', - targetType: 'asset_card', - targetId: asset.id, - extra: { - erpTarget: ASSET_ERP_TARGET, - eventType: 'erp.asset.export', - assetId: asset.id, - assetNo: asset.asset_no, - changeType, - gateReason: gate.reason, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - }, - }), - }); - - return { - skipped: true, - gate, - asset, - }; - } - - const enqueued = await enqueueExport(tx, config, { - asset, - sourceEventName, - changeType, - changedFields, - occurredAt: new Date(), - actorId, - requeueOnFailure: false, - }); - - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId, - action: 'ERP_SYNC_ENQUEUED', - targetType: 'asset_card', - targetId: asset.id, - extra: { - erpTarget: ASSET_ERP_TARGET, - eventType: enqueued.eventType, - assetId: asset.id, - assetNo: asset.asset_no, - outboxId: enqueued.id, - duplicated: enqueued.duplicated, - requeued: enqueued.requeued, - changeType, - changedFields: Array.isArray(changedFields) ? changedFields : [], - gateReason: gate.reason, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - }, - }), - }); - - return { - skipped: false, - gate, - asset, - enqueued, - }; -} - -async function runAssetLedgerSummaryErpSyncSafely({ - prisma, - config, - request, - actorId, - asset, - sourceEventName, - changeType, - changedFields, -}) { - try { - return await handleAssetLedgerSummaryErpSync({ - tx: prisma, - config, - request, - actorId, - asset, - sourceEventName, - changeType, - changedFields, - }); - } catch (error) { - request.log.error( - { - err: error, - assetId: asset?.id ?? null, - assetNo: asset?.asset_no ?? null, - sourceEventName, - changeType, - changedFields: Array.isArray(changedFields) ? changedFields : [], - }, - "Asset ledger summary ERP sync failed after asset commit", - ); - return null; - } -} - async function assetsRoutes(fastify) { fastify.get( "/asset-categories", @@ -996,7 +860,7 @@ async function assetsRoutes(fastify) { preHandler: fastify.requirePermission("asset.manage"), }, async (request, reply) => { - const payload = normalizeAssetCategoryPayload(parseCreateBody(ASSET_CATEGORY_CREATE_SCHEMA, request.body)); + const payload = normalizeAssetCategoryPayload(parseCreateBody(ASSET_CATEGORY_CREATE_SCHEMA, request.body, "asset.category_create_input_invalid")); const assetCategory = await fastify.prisma.$transaction(async (tx) => { if (payload.documentCategoryId) { await ensureDocumentCategoryAvailable(tx, payload.documentCategoryId); @@ -1041,8 +905,8 @@ async function assetsRoutes(fastify) { preHandler: fastify.requirePermission("asset.manage"), }, async (request, reply) => { - const assetCategoryId = parseUuid("id", request.params?.id); - const payload = normalizeAssetCategoryPayload(parsePatchBody(ASSET_CATEGORY_PATCH_SCHEMA, request.body)); + const assetCategoryId = parseUuid("id", request.params?.id, "asset.category_id_invalid"); + const payload = normalizeAssetCategoryPayload(parsePatchBody(ASSET_CATEGORY_PATCH_SCHEMA, request.body, "asset.category_update_input_invalid")); const assetCategory = await fastify.prisma.$transaction(async (tx) => { const existing = await tx.assetCategory.findUnique({ @@ -1050,7 +914,7 @@ async function assetsRoutes(fastify) { select: { id: true }, }); if (!existing) { - throw buildValidationError({ id: "not_found" }, 404); + throw buildBusinessValidationError("asset.category_not_found", { id: "not_found" }, 404); } if (payload.documentCategoryId) { @@ -1227,21 +1091,23 @@ async function assetsRoutes(fastify) { preHandler: fastify.requirePermission("asset.manage"), }, async (request, reply) => { - const payload = normalizeAssetPayload(parseCreateBody(ASSET_CREATE_SCHEMA, request.body)); + const payload = normalizeAssetPayload(parseCreateBody(ASSET_CREATE_SCHEMA, request.body, "asset.create_input_invalid")); validateAssetTextBoundaries(payload); const asset = await fastify.prisma.$transaction(async (tx) => { const category = await ensureAssetCategoryExists(tx, payload.categoryId, { requireActive: true }); await ensureLegalEntityAvailable(tx, payload.legalEntityId); await ensureCostCenterAvailable(tx, payload.costCenterId, payload.legalEntityId); await ensureAssigneeEmployee(tx, payload.assigneeEmployeeId ?? null); - await ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null); - await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null); + await ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null, "asset.owner_not_found", "asset.owner_inactive"); + await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null, "asset.keeper_not_found", "asset.keeper_inactive"); await ensureApprovedPurchaseRequest(tx, payload.purchaseRequestId ?? null); const assetType = payload.assetType ?? "fixed_asset"; const documentCategoryCode = assetDocumentCategoryCode(assetType, category); const issued = await issueDocumentNumber(tx, { documentType: "asset_card", + assetType, + timeZone: fastify.config.appTimezone, documentCategoryCode, sequenceKey: assetSequenceKey(assetType, documentCategoryCode), actorId: request.employee.id, @@ -1278,7 +1144,19 @@ async function assetsRoutes(fastify) { created_by: request.employee.id, updated_by: request.employee.id, }, - select: ASSET_CARD_SELECT, + // Keep relation hydration outside the atomic asset/assignment write. + select: { + id: true, + asset_no: true, + asset_type: true, + sensitivity_level: true, + purchase_request_id: true, + assignee_employee_id: true, + owner_employee_id: true, + keeper_employee_id: true, + activated_at: true, + created_at: true, + }, }); if (created.assignee_employee_id) { @@ -1315,18 +1193,14 @@ async function assetsRoutes(fastify) { fastify.log.error({ err: error, assetId: asset.id }, "Failed to write asset create audit log"); }); - await runAssetLedgerSummaryErpSyncSafely({ - prisma: fastify.prisma, - config: fastify.config, - request, - actorId: request.employee.id, - asset, - sourceEventName: 'asset.card.created', - changeType: 'created', - changedFields: [], + const detail = await fastify.prisma.assetCard.findUnique({ + where: { id: asset.id }, + select: ASSET_CARD_SELECT, }); - - return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); + if (!detail) { + throw buildBusinessValidationError("asset.not_found", { id: "not_found" }, 404); + } + return sendData(reply, request, { asset: mapAsset(detail, request, fastify) }); }, ); @@ -1337,7 +1211,7 @@ async function assetsRoutes(fastify) { preHandler: fastify.requirePermission("asset.manage"), }, async (request, reply) => { - const payload = parseCreateBody(REDACTED_DOC_IMPORT_SCHEMA, request.body); + const payload = parseCreateBody(REDACTED_DOC_IMPORT_SCHEMA, request.body, "asset.index_import_input_invalid"); validateRedactedImportBoundaries(payload); const shouldWrite = payload.write === true && payload.dryRun !== true; if (shouldWrite && process.env.ASSET_INDEX_IMPORT_WRITE !== "1") { @@ -1349,7 +1223,7 @@ async function assetsRoutes(fastify) { }); } if (shouldWrite && (!payload.categoryId || !payload.legalEntityId || !payload.costCenterId)) { - throw buildValidationError({ + throw buildBusinessValidationError("asset.index_import_dimensions_required", { categoryId: payload.categoryId ? undefined : "required_for_write", legalEntityId: payload.legalEntityId ? undefined : "required_for_write", costCenterId: payload.costCenterId ? undefined : "required_for_write", @@ -1383,8 +1257,8 @@ async function assetsRoutes(fastify) { const category = await ensureAssetCategoryExists(tx, payload.categoryId, { requireActive: true }); await ensureLegalEntityAvailable(tx, payload.legalEntityId); await ensureCostCenterAvailable(tx, payload.costCenterId, payload.legalEntityId); - await ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null); - await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null); + await ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null, "asset.owner_not_found", "asset.owner_inactive"); + await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null, "asset.keeper_not_found", "asset.keeper_inactive"); const items = []; for (const [index, item] of payload.items.entries()) { @@ -1408,6 +1282,8 @@ async function assetsRoutes(fastify) { const issued = await issueDocumentNumber(tx, { documentType: "asset_card", + assetType: "electronic_document", + timeZone: fastify.config.appTimezone, documentCategoryCode: assetDocumentCategoryCode("electronic_document"), sequenceKey: assetSequenceKey("electronic_document", assetDocumentCategoryCode("electronic_document")), actorId: request.employee.id, @@ -1509,13 +1385,13 @@ async function assetsRoutes(fastify) { }, async (request, reply) => { ensureAssetReadPermission(fastify, request); - const assetId = parseUuid("id", request.params?.id); + const assetId = parseUuid("id", request.params?.id, "asset.id_invalid"); const asset = await fastify.prisma.assetCard.findUnique({ where: { id: assetId }, select: ASSET_CARD_SELECT, }); if (!asset) { - throw buildValidationError({ id: "not_found" }, 404); + throw buildBusinessValidationError("asset.not_found", { id: "not_found" }, 404); } ensureAssetViewerOwnsRecord(fastify, request, asset); return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); @@ -1529,11 +1405,11 @@ async function assetsRoutes(fastify) { preHandler: fastify.requirePermission("asset.manage"), }, async (request, reply) => { - const assetId = parseUuid("id", request.params?.id); - const payload = normalizeAssetPayload(parsePatchBody(ASSET_PATCH_SCHEMA, request.body)); + const assetId = parseUuid("id", request.params?.id, "asset.id_invalid"); + const payload = normalizeAssetPayload(parsePatchBody(ASSET_PATCH_SCHEMA, request.body, "asset.update_input_invalid")); validateAssetTextBoundaries(payload); - const { asset, assetLedgerSummaryChange } = await fastify.prisma.$transaction(async (tx) => { + const { asset } = await fastify.prisma.$transaction(async (tx) => { const current = await tx.assetCard.findUnique({ where: { id: assetId }, select: { @@ -1565,7 +1441,7 @@ async function assetsRoutes(fastify) { }, }); if (!current) { - throw buildValidationError({ id: "not_found" }, 404); + throw buildBusinessValidationError("asset.not_found", { id: "not_found" }, 404); } const nextCategoryId = payload.categoryId ?? current.category_id; @@ -1575,8 +1451,8 @@ async function assetsRoutes(fastify) { if (payload.categoryId !== undefined) { await ensureAssetCategoryExists(tx, nextCategoryId, { requireActive: true }); } - await ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null); - await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null); + await ensureActiveEmployee(tx, "ownerEmployeeId", payload.ownerEmployeeId ?? null, "asset.owner_not_found", "asset.owner_inactive"); + await ensureActiveEmployee(tx, "keeperEmployeeId", payload.keeperEmployeeId ?? null, "asset.keeper_not_found", "asset.keeper_inactive"); if (payload.legalEntityId !== undefined) { await ensureLegalEntityAvailable(tx, nextLegalEntityId); } @@ -1638,27 +1514,11 @@ async function assetsRoutes(fastify) { }, }), }); - - const assetLedgerSummaryChange = resolveAssetLedgerSummaryChange(current, updated); return { asset: updated, - assetLedgerSummaryChange, }; }); - if (assetLedgerSummaryChange) { - await runAssetLedgerSummaryErpSyncSafely({ - prisma: fastify.prisma, - config: fastify.config, - request, - actorId: request.employee.id, - asset, - sourceEventName: assetLedgerSummaryChange.sourceEventName, - changeType: assetLedgerSummaryChange.changeType, - changedFields: assetLedgerSummaryChange.changedFields, - }); - } - return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); }, ); @@ -1666,8 +1526,7 @@ async function assetsRoutes(fastify) { module.exports = assetsRoutes; module.exports.__testables = { - handleAssetLedgerSummaryErpSync, parseAssetCreateBodyForTest(body) { - return normalizeAssetPayload(parseCreateBody(ASSET_CREATE_SCHEMA, body)); + return normalizeAssetPayload(parseCreateBody(ASSET_CREATE_SCHEMA, body, "asset.create_input_invalid")); }, }; diff --git a/server/src/routes/attendance.js b/server/src/routes/attendance.js index 568c4a7b..a66a8fa0 100644 --- a/server/src/routes/attendance.js +++ b/server/src/routes/attendance.js @@ -1,8 +1,10 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); -const { currentDateString } = require("../utils/date"); +const { currentDateString, isValidDateOnlyString } = require("../utils/date"); const { sendData } = require("../utils/reply"); const { hasPermission } = require("../constants/permissions"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); @@ -12,7 +14,6 @@ const { } = require("../services/integrations/wecomCheckin"); const { buildAttendanceMetrics, - buildAttendanceValidationError, buildMyMonthAttendance, buildTeamAttendance, detectAttendanceSource, @@ -25,7 +26,7 @@ const { upsertAttendancePolicy, } = require("../services/attendance"); -const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().datetime()); const CLOCK_BODY_SCHEMA = z @@ -63,6 +64,7 @@ const POLICY_BODY_SCHEMA = z } catch (error) { if (error instanceof AppError) { for (const [key, reason] of Object.entries(error.details ?? {})) { + if (key === "reasonCode") continue; ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], @@ -126,10 +128,10 @@ const SYNC_RUN_QUERY_SCHEMA = z.object({ pageSize: z.coerce.number().int().min(1).max(100).optional(), }); -function parseInput(schema, value) { +function parseInput(schema, value, reasonCode) { const parsed = schema.safeParse(value ?? {}); if (!parsed.success) { - throw buildAttendanceValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } @@ -143,10 +145,7 @@ async function ensureFeatureEnabled(fastify, request) { strictDepartment: false, }); if (!gate.enabled) { - throw buildAttendanceValidationError( - { featureFlag: "attendance_v1_disabled", reason: gate.reason }, - 409, - ); + throw buildBusinessValidationError("attendance.feature_disabled", { featureFlag: "attendance_v1_disabled", reason: gate.reason }, 409); } } @@ -159,10 +158,7 @@ async function ensureUnifiedFeatureEnabled(fastify, request) { strictDepartment: false, }); if (!gate.enabled) { - throw buildAttendanceValidationError( - { featureFlag: "attendance_unified_v1_disabled", reason: gate.reason }, - 409, - ); + throw buildBusinessValidationError("attendance.unified_feature_disabled", { featureFlag: "attendance_unified_v1_disabled", reason: gate.reason }, 409); } } @@ -209,7 +205,7 @@ function parseOptionalDateTime(value) { } const date = new Date(value); if (Number.isNaN(date.getTime())) { - throw buildAttendanceValidationError({ datetime: "invalid" }); + throw buildBusinessValidationError("attendance.datetime_invalid", { datetime: "invalid" }); } return date; } @@ -251,7 +247,7 @@ function parseMonthRange(monthValue, timeZone) { const month = monthValue || fallback; const [year, monthNo] = month.split("-").map(Number); if (!Number.isInteger(year) || !Number.isInteger(monthNo) || monthNo < 1 || monthNo > 12) { - throw buildAttendanceValidationError({ month: "invalid" }); + throw buildBusinessValidationError("attendance.month_invalid", { month: "invalid" }); } const startDate = new Date(Date.UTC(year, monthNo - 1, 1)); const endDate = new Date(Date.UTC(year, monthNo, 0)); @@ -466,7 +462,7 @@ async function loadMonthlyEmployees(fastify, request, employeeId) { async function attendanceRoutes(fastify) { fastify.post("/attendance/clock-in", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const body = parseInput(CLOCK_BODY_SCHEMA, request.body); + const body = parseInput(CLOCK_BODY_SCHEMA, request.body, "attendance.clock_input_invalid"); const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id); if (record?.clock_in_at) { @@ -474,10 +470,7 @@ async function attendanceRoutes(fastify) { } if (exemption) { - throw buildAttendanceValidationError( - { attendance: "exempt_day", exemptionReason: exemption.exemptionReason }, - 409, - ); + throw buildBusinessValidationError("attendance.exempt_day", { attendance: "exempt_day", exemptionReason: exemption.exemptionReason }, 409); } const policy = await getAttendancePolicy(fastify.prisma); @@ -531,17 +524,14 @@ async function attendanceRoutes(fastify) { fastify.post("/attendance/clock-out", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const body = parseInput(CLOCK_BODY_SCHEMA, request.body); + const body = parseInput(CLOCK_BODY_SCHEMA, request.body, "attendance.clock_input_invalid"); const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id); if (!record?.clock_in_at) { if (exemption) { - throw buildAttendanceValidationError( - { attendance: "exempt_day", exemptionReason: exemption.exemptionReason }, - 409, - ); + throw buildBusinessValidationError("attendance.exempt_day", { attendance: "exempt_day", exemptionReason: exemption.exemptionReason }, 409); } - throw buildAttendanceValidationError({ attendance: "missing_clock_in" }); + throw buildBusinessValidationError("attendance.missing_clock_in", { attendance: "missing_clock_in" }); } const clockOutAt = new Date(); @@ -586,7 +576,7 @@ async function attendanceRoutes(fastify) { fastify.get("/attendance/me/month", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const query = parseInput(MONTH_QUERY_SCHEMA, request.query); + const query = parseInput(MONTH_QUERY_SCHEMA, request.query, "attendance.legacy_month_query_invalid"); const now = new Date(); const parts = new Intl.DateTimeFormat("en-CA", { timeZone: fastify.config.appTimezone, @@ -614,7 +604,7 @@ async function attendanceRoutes(fastify) { { preHandler: fastify.requirePermission("system.users") }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const query = parseInput(TEAM_QUERY_SCHEMA, request.query); + const query = parseInput(TEAM_QUERY_SCHEMA, request.query, "attendance.team_query_invalid"); const dateString = query.date ?? currentDateString(fastify.config.appTimezone); const items = await buildTeamAttendance(fastify.prisma, { dateString, @@ -639,7 +629,7 @@ async function attendanceRoutes(fastify) { { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const body = parseInput(POLICY_BODY_SCHEMA, request.body); + const body = parseInput(POLICY_BODY_SCHEMA, request.body, "attendance.policy_input_invalid"); const item = await upsertAttendancePolicy(fastify.prisma, request.employee.id, body); return sendData(reply, request, { item }); }, @@ -650,7 +640,7 @@ async function attendanceRoutes(fastify) { { preHandler: fastify.requirePermission("system.users") }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const body = parseInput(ADMIN_BACKFILL_BODY_SCHEMA, request.body); + const body = parseInput(ADMIN_BACKFILL_BODY_SCHEMA, request.body, "attendance.backfill_input_invalid"); const employee = await loadEmployeeForAttendance(fastify.prisma, body.employeeId); if (!employee || employee.status !== "active") { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); @@ -661,11 +651,11 @@ async function attendanceRoutes(fastify) { const nextClockOutAt = body.clockOutAt !== undefined ? parseOptionalDateTime(body.clockOutAt) : before?.clock_out_at ?? null; if (!nextClockInAt && !nextClockOutAt) { - throw buildAttendanceValidationError({ attendance: "missing_backfill_values" }); + throw buildBusinessValidationError("attendance.missing_backfill_values", { attendance: "missing_backfill_values" }); } if (nextClockInAt && nextClockOutAt && nextClockOutAt < nextClockInAt) { - throw buildAttendanceValidationError({ clockOutAt: "must_be_after_or_equal_clockInAt" }); + throw buildBusinessValidationError("attendance.clock_range_invalid", { clockOutAt: "must_be_after_or_equal_clockInAt" }); } const policy = await getAttendancePolicy(fastify.prisma); @@ -737,7 +727,7 @@ async function attendanceRoutes(fastify) { async (request, reply) => { await ensureUnifiedFeatureEnabled(fastify, request); fastify.assertEntityPermission(request.employee, "attendance", "sync"); - const body = parseInput(WECOM_SYNC_BODY_SCHEMA, request.body); + const body = parseInput(WECOM_SYNC_BODY_SCHEMA, request.body, "attendance.sync_input_invalid"); const startDate = parseDateOnly(body.startDate); const endDate = parseDateOnly(body.endDate); const employeeWhere = { @@ -877,7 +867,7 @@ async function attendanceRoutes(fastify) { async (request, reply) => { await ensureUnifiedFeatureEnabled(fastify, request); fastify.assertEntityPermission(request.employee, "attendance", "sync"); - const query = parseInput(SYNC_RUN_QUERY_SCHEMA, request.query); + const query = parseInput(SYNC_RUN_QUERY_SCHEMA, request.query, "attendance.sync_runs_query_invalid"); const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where = {}; @@ -915,7 +905,7 @@ async function attendanceRoutes(fastify) { async (request, reply) => { await ensureUnifiedFeatureEnabled(fastify, request); fastify.assertEntityPermission(request.employee, "attendance", "view_own"); - const query = parseInput(UNIFIED_MONTH_QUERY_SCHEMA, request.query); + const query = parseInput(UNIFIED_MONTH_QUERY_SCHEMA, request.query, "attendance.month_query_invalid"); const { month, startDate, endDate } = parseMonthRange(query.month, fastify.config.appTimezone); const employees = await loadMonthlyEmployees(fastify, request, query.employeeId); const employeeIds = employees.map((employee) => employee.id); diff --git a/server/src/routes/attendanceCorrections.js b/server/src/routes/attendanceCorrections.js index 48526107..ab9b9203 100644 --- a/server/src/routes/attendanceCorrections.js +++ b/server/src/routes/attendanceCorrections.js @@ -1,3 +1,4 @@ +const { isValidDateOnlyString } = require("../utils/date"); const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { z } = require("zod"); @@ -8,7 +9,7 @@ const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow"); -const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().datetime()); const UUID_SCHEMA = z.string().uuid(); diff --git a/server/src/routes/audit.js b/server/src/routes/audit.js index 264fed75..01247bba 100644 --- a/server/src/routes/audit.js +++ b/server/src/routes/audit.js @@ -1,5 +1,5 @@ const { z } = require("zod"); -const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const QUERY_SCHEMA = z.object({ @@ -15,12 +15,7 @@ const QUERY_SCHEMA = z.object({ function parseQuery(query) { const parsed = QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("audit.query_invalid", parsed.error.flatten(), 400); } const page = parsed.data.page ?? 1; @@ -29,12 +24,7 @@ function parseQuery(query) { const dateTo = parsed.data.dateTo ? new Date(parsed.data.dateTo) : null; if (dateFrom && dateTo && dateFrom > dateTo) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { dateTo: "must_be_after_dateFrom" }, - }); + throw buildBusinessValidationError("audit.date_range_invalid", { dateTo: "must_be_after_dateFrom" }, 400); } return { diff --git a/server/src/routes/budgetControlRules.js b/server/src/routes/budgetControlRules.js index 25476c05..51c07a54 100644 --- a/server/src/routes/budgetControlRules.js +++ b/server/src/routes/budgetControlRules.js @@ -1,3 +1,5 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); @@ -30,19 +32,10 @@ const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value) message: "empty_patch", }); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseRuleId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("budget_rule.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } @@ -50,7 +43,7 @@ function parseRuleId(value) { function parseQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("budget_rule.query_invalid", parsed.error.flatten()); } return { entityType: parsed.data.entityType ?? null, @@ -58,17 +51,17 @@ function parseQuery(query) { }; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } if (parsed.data.costCenterId && !parsed.data.legalEntityId) { - throw buildValidationError({ costCenterId: "legalEntityId_required" }, 409); + throw buildBusinessValidationError("budget_rule.company_required", { costCenterId: "legalEntityId_required" }, 409); } if (parsed.data.entityType !== "reimbursement" && parsed.data.expenseTypeId) { - throw buildValidationError({ expenseTypeId: "only_reimbursement_supported" }, 409); + throw buildBusinessValidationError("budget_rule.expense_type_unsupported", { expenseTypeId: "only_reimbursement_supported" }, 409); } return parsed.data; @@ -116,7 +109,7 @@ async function budgetControlRulesRoutes(fastify) { "/budget-control-rules", { preHandler: fastify.requirePermission("budget.manage") }, async (request, reply) => { - const body = parseBody(WRITE_SCHEMA, request.body); + const body = parseBody(WRITE_SCHEMA, request.body, "budget_rule.create_input_invalid"); const created = await fastify.prisma.budgetControlRule.create({ data: { name: body.name, @@ -153,7 +146,7 @@ async function budgetControlRulesRoutes(fastify) { { preHandler: fastify.requirePermission("budget.manage") }, async (request, reply) => { const ruleId = parseRuleId(request.params?.id); - const body = parseBody(PATCH_SCHEMA, request.body); + const body = parseBody(PATCH_SCHEMA, request.body, "budget_rule.update_input_invalid"); const current = await fastify.prisma.budgetControlRule.findUnique({ where: { id: ruleId } }); if (!current || current.archived_at) { diff --git a/server/src/routes/budgets.js b/server/src/routes/budgets.js index 505cba63..59fe8aaa 100644 --- a/server/src/routes/budgets.js +++ b/server/src/routes/budgets.js @@ -1,3 +1,5 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { isValidDateOnlyString } = require("../utils/date"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); @@ -17,8 +19,8 @@ const BUDGET_CREATE_SCHEMA = z departmentId: UUID_SCHEMA.nullable().optional(), legalEntityId: UUID_SCHEMA.nullable().optional(), costCenterId: UUID_SCHEMA.nullable().optional(), - periodStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - periodEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + periodStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }), + periodEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }), allocatedAmount: z.string().regex(/^\d+(?:\.\d{1,2})?$/), warningThresholdPercent: z.coerce.number().int().min(1).max(100).optional(), }) @@ -66,15 +68,6 @@ const BUDGET_RELATION_SELECT = { }, }; -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function toDate(value) { return new Date(`${value}T00:00:00.000Z`); } @@ -138,7 +131,7 @@ function mapBudget(item) { function parseBudgetId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("budget.id_invalid", { id: "invalid_uuid" }); } return parsed.data; @@ -155,11 +148,11 @@ async function ensureDepartmentAvailable(tx, departmentId) { }); if (!department) { - throw buildValidationError({ departmentId: "not_found" }); + throw buildBusinessValidationError("budget.department_not_found", { departmentId: "not_found" }); } if (department.archived_at) { - throw buildValidationError({ departmentId: "archived" }, 409); + throw buildBusinessValidationError("budget.department_archived", { departmentId: "archived" }, 409); } return department; @@ -176,11 +169,11 @@ async function ensureLegalEntityAvailable(tx, legalEntityId) { }); if (!legalEntity) { - throw buildValidationError({ legalEntityId: "not_found" }); + throw buildBusinessValidationError("budget.legal_entity_not_found", { legalEntityId: "not_found" }); } if (legalEntity.archived_at || !legalEntity.enabled) { - throw buildValidationError({ legalEntityId: "disabled_or_archived" }, 409); + throw buildBusinessValidationError("budget.legal_entity_unavailable", { legalEntityId: "disabled_or_archived" }, 409); } return legalEntity; @@ -204,15 +197,15 @@ async function ensureCostCenterAvailable(tx, { costCenterId, legalEntityId }) { }); if (!costCenter) { - throw buildValidationError({ costCenterId: "not_found" }); + throw buildBusinessValidationError("budget.cost_center_not_found", { costCenterId: "not_found" }); } if (costCenter.archived_at || !costCenter.enabled) { - throw buildValidationError({ costCenterId: "disabled_or_archived" }, 409); + throw buildBusinessValidationError("budget.cost_center_unavailable", { costCenterId: "disabled_or_archived" }, 409); } if (legalEntityId && costCenter.legal_entity_id !== legalEntityId) { - throw buildValidationError({ costCenterId: "legalEntityId_mismatch" }, 409); + throw buildBusinessValidationError("budget.cost_center_company_mismatch", { costCenterId: "legalEntityId_mismatch" }, 409); } return costCenter; @@ -244,7 +237,7 @@ async function budgetsRoutes(fastify) { async (request, reply) => { const parsed = BUDGET_LIST_QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("budget.query_invalid", parsed.error.flatten()); } const query = parsed.data; @@ -287,14 +280,14 @@ async function budgetsRoutes(fastify) { async (request, reply) => { const parsed = BUDGET_CREATE_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("budget.create_input_invalid", parsed.error.flatten()); } const body = parsed.data; const start = toDate(body.periodStart); const end = toDate(body.periodEnd); if (start > end) { - throw buildValidationError({ periodEnd: "must_be_after_periodStart" }); + throw buildBusinessValidationError("budget.period_invalid", { periodEnd: "must_be_after_periodStart" }); } const scope = resolveBudgetScope({ @@ -359,11 +352,11 @@ async function budgetsRoutes(fastify) { const budgetId = parseBudgetId(request.params?.id); const parsed = BUDGET_PATCH_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("budget.update_input_invalid", parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { - throw buildValidationError({ body: "empty_patch" }); + throw buildBusinessValidationError("budget.update_empty", { body: "empty_patch" }); } await fetchBudgetPoolOrThrow(fastify.prisma, budgetId); diff --git a/server/src/routes/companyLoans.js b/server/src/routes/companyLoans.js new file mode 100644 index 00000000..3437516b --- /dev/null +++ b/server/src/routes/companyLoans.js @@ -0,0 +1,301 @@ +const { resolveNumberingContext, issueBusinessNumber } = require("../services/businessNumbering"); +const { randomUUID } = require("node:crypto"); +const { Prisma } = require("@prisma/client"); +const { z } = require("zod"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { sendData } = require("../utils/reply"); +const { parseDateOnlyString, isValidDateOnlyString, currentDateString } = require("../utils/date"); +const { + LENDER_INCLUDE, LOAN_INCLUDE, ensureLoanManager, loanNotFound, auditLoan, + readLenderAccount, encryptLenderAccount, mapLender, mapLoan, mapEvidence, lockLoan, lockLender, + assertEvidenceComplete, assertCurrentVersion, validateLenderReferences, createIdempotently, +} = require("../services/companyLoans"); +const { writeBuffer, openReadStream, deleteObject, StorageNotFoundError } = require("../services/storage"); +const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, buildAttachmentContentDisposition } = require("../services/purchaseAttachmentPolicy"); + +const UUID = z.string().uuid(); +const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString); +const MONEY = z.string().regex(/^\d{1,12}(\.\d{1,2})?$/); +const TEXT = z.string().trim().min(2).max(500); +const VERSION = z.number().int().min(1).max(2147483646); +const ACCOUNT = z.object({ + bankInstitutionId: UUID, bankName: z.string().trim().min(1).max(200), + bankAccount: z.string().regex(/^\d{10,30}$/), accountName: z.string().trim().min(1).max(120), +}); +const LENDER = ACCOUNT.extend({ requestKey: UUID, legalEntityId: UUID, name: z.string().trim().min(1).max(120) }).strict(); +const IDENTITY = z.object({ expectedVersion: VERSION, employeeId: UUID.nullable(), externalPersonConfirmed: z.boolean(), reason: TEXT }).strict() + .refine((v) => v.employeeId ? !v.externalPersonConfirmed : v.externalPersonConfirmed); +const LOAN = z.object({ + requestKey: UUID, lenderId: UUID, originReference: z.string().trim().min(1).max(200), + originalPrincipal: MONEY, openingOutstandingPrincipal: MONEY, + receivedDate: DATE, balanceAsOf: DATE, historicalEntry: z.boolean(), +}).strict().refine((v) => new Prisma.Decimal(v.originalPrincipal).gt(0) && + new Prisma.Decimal(v.openingOutstandingPrincipal).lte(v.originalPrincipal) && v.receivedDate <= v.balanceAsOf); +const CONFIRM = z.object({ expectedVersion: VERSION, accountingConfirmationRef: z.string().trim().min(2).max(200) }).strict(); +const EVIDENCE_KIND = z.enum(["agreement", "incoming", "accounting_confirmation", "adjustment"]); +const LIST = z.object({ lenderId: UUID.optional(), page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(30) }).strict(); +const MAX_BYTES = 10 * 1024 * 1024; + +function parse(schema, value) { + const parsed = schema.safeParse(value); + if (!parsed.success) throw buildBusinessValidationError("loan.invalid_input", {}, 400); + return parsed.data; +} + +async function boundedBuffer(stream) { + const chunks = []; let size = 0; + try { + for await (const chunk of stream) { + size += chunk.length; + if (size > MAX_BYTES) throw buildBusinessValidationError("loan.evidence_invalid", {}, 400); + chunks.push(Buffer.from(chunk)); + } + if (stream.truncated) throw buildBusinessValidationError("loan.evidence_invalid", {}, 400); + return Buffer.concat(chunks, size); + } catch (error) { stream.destroy?.(); throw error; } +} + +async function validateEvidenceFile(input) { + try { return await validateAndNormalizePurchaseAttachment({ ...input, maxBytes: MAX_BYTES }); } + catch (error) { + if (error instanceof PurchaseAttachmentPolicyError) throw buildBusinessValidationError("loan.evidence_invalid", {}, 400); + throw error; + } +} + +async function companyLoansRoutes(fastify) { + const auth = { preHandler: fastify.authenticate }; + fastify.get("/loan-lenders/identity-options", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const query = parse(z.object({ q: z.string().trim().max(100).default("") }).strict(), request.query ?? {}); + const items = await fastify.prisma.employee.findMany({ where: { status: "active", archived_at: null, + ...(query.q ? { OR: [{ name: { contains: query.q, mode: "insensitive" } }, { employee_no: { contains: query.q } }] } : {}), + }, select: { id: true, name: true, employee_no: true }, orderBy: [{ employee_no: "asc" }, { id: "asc" }], take: 100 }); + return sendData(reply, request, { items: items.map((item) => ({ id: item.id, name: item.name, employeeNo: item.employee_no })) }); + }); + fastify.get("/loan-lenders", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const query = parse(LIST.omit({ lenderId: true }), request.query ?? {}); + const where = { archived_at: null }; + const [items, total] = await Promise.all([ + fastify.prisma.loanLender.findMany({ where, include: LENDER_INCLUDE, orderBy: [{ name: "asc" }, { id: "asc" }], + skip: (query.page - 1) * query.pageSize, take: query.pageSize }), + fastify.prisma.loanLender.count({ where }), + ]); + return sendData(reply, request, { items: items.map((item) => mapLender(fastify.config, item)), total }); + }); + + fastify.post("/loan-lenders", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const body = parse(LENDER, request.body); + const actor = request.employee.id; + const matches = (item) => { + const account = readLenderAccount(fastify.config, item); + return item.name === body.name && item.legal_entity_id === body.legalEntityId && + item.bank_institution_id === body.bankInstitutionId && item.bank_name === body.bankName && + account.bankAccount === body.bankAccount && account.accountName === body.accountName; + }; + const item = await createIdempotently(fastify.prisma, async (tx) => { + await validateLenderReferences(tx, body); + const result = await tx.loanLender.create({ data: { + legal_entity_id: body.legalEntityId, name: body.name, bank_institution_id: body.bankInstitutionId, + bank_name: body.bankName, ...encryptLenderAccount(fastify.config, body), + created_by: actor, updated_by: actor, request_key: body.requestKey, + }, include: LENDER_INCLUDE }); + await auditLoan(tx, request, "LOAN_LENDER_CREATED", result.id); + return result; + }, (tx) => tx.loanLender.findUnique({ where: { created_by_request_key: { created_by: actor, request_key: body.requestKey } }, include: LENDER_INCLUDE }), matches); + return sendData(reply, request, mapLender(fastify.config, item)); + }); + + fastify.post("/loan-lenders/:id/identity-confirmation", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const id = parse(UUID, request.params.id); const body = parse(IDENTITY, request.body); + const item = await fastify.prisma.$transaction(async (tx) => { + const lender = await lockLender(tx, id); + assertCurrentVersion(lender.identity_version, body.expectedVersion); + if (body.employeeId && !await tx.employee.findFirst({ where: { id: body.employeeId, status: "active", archived_at: null } })) { + throw buildBusinessValidationError("loan.source_unavailable", {}, 409); + } + const result = await tx.loanLender.update({ where: { id }, data: { + employee_id: body.employeeId, identity_status: "verified", identity_version: { increment: 1 }, + identity_confirmed_by: request.employee.id, identity_confirmed_at: new Date(), + identity_confirmation: body.reason, updated_by: request.employee.id, + }, include: LENDER_INCLUDE }); + await auditLoan(tx, request, "LOAN_LENDER_IDENTITY_CONFIRMED", id, { + identityVersion: result.identity_version, employeeId: body.employeeId, externalPersonConfirmed: body.externalPersonConfirmed, + }); + return result; + }); + return sendData(reply, request, mapLender(fastify.config, item)); + }); + + fastify.patch("/loan-lenders/:id/account", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const id = parse(UUID, request.params.id); + const body = parse(ACCOUNT.extend({ expectedVersion: VERSION }).strict(), request.body); + const item = await fastify.prisma.$transaction(async (tx) => { + const lender = await lockLender(tx, id); + assertCurrentVersion(lender.account_version, body.expectedVersion); + await validateLenderReferences(tx, { ...body, legalEntityId: lender.legal_entity_id }); + const result = await tx.loanLender.update({ where: { id }, data: { + bank_institution_id: body.bankInstitutionId, bank_name: body.bankName, ...encryptLenderAccount(fastify.config, body), + account_version: { increment: 1 }, identity_version: { increment: 1 }, identity_status: "unverified", + identity_confirmed_by: null, identity_confirmed_at: null, identity_confirmation: null, updated_by: request.employee.id, + }, include: LENDER_INCLUDE }); + await auditLoan(tx, request, "LOAN_LENDER_ACCOUNT_UPDATED", id, { accountVersion: result.account_version }); + return result; + }); + return sendData(reply, request, mapLender(fastify.config, item)); + }); + + fastify.get("/company-loans", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const query = parse(LIST, request.query ?? {}); + const where = query.lenderId ? { lender_id: query.lenderId } : {}; + const [items, total] = await Promise.all([ + fastify.prisma.companyLoan.findMany({ where, include: LOAN_INCLUDE, orderBy: [{ received_date: "desc" }, { id: "asc" }], + skip: (query.page - 1) * query.pageSize, take: query.pageSize }), + fastify.prisma.companyLoan.count({ where }), + ]); + return sendData(reply, request, { items: items.map((item) => mapLoan(fastify.config, item)), total }); + }); + + fastify.get("/company-loans/:id", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const item = await fastify.prisma.companyLoan.findUnique({ where: { id: parse(UUID, request.params.id) }, include: LOAN_INCLUDE }); + if (!item) throw loanNotFound(); + return sendData(reply, request, mapLoan(fastify.config, item)); + }); + + fastify.post("/company-loans", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const body = parse(LOAN, request.body); + if (body.balanceAsOf > currentDateString(fastify.config.timeZone ?? "Asia/Shanghai")) { + throw buildBusinessValidationError("loan.invalid_input", {}, 400); + } + const actor = request.employee.id; + const matches = (item) => item.lender_id === body.lenderId && item.origin_reference === body.originReference && + new Prisma.Decimal(item.original_principal).eq(body.originalPrincipal) && + new Prisma.Decimal(item.opening_outstanding_principal).eq(body.openingOutstandingPrincipal) && + item.received_date.toISOString().slice(0, 10) === body.receivedDate && + item.balance_as_of.toISOString().slice(0, 10) === body.balanceAsOf && item.historical_entry === body.historicalEntry; + const item = await createIdempotently(fastify.prisma, async (tx) => { + const lender = await tx.loanLender.findUnique({ where: { id: body.lenderId }, include: LENDER_INCLUDE }); + if (!lender || lender.archived_at) throw loanNotFound(); + await validateLenderReferences(tx, { legalEntityId: lender.legal_entity_id, bankInstitutionId: lender.bank_institution_id }); + const id = randomUUID(); + const numbering = await resolveNumberingContext(tx); + const loanNo = numbering.version === 2 + ? (await issueBusinessNumber(tx, { family: "company_loan", ...numbering, timeZone: fastify.config.appTimezone })).documentNumber + : `JK-${id}`; + const result = await tx.companyLoan.create({ data: { + id, loan_no: loanNo, origin_reference: body.originReference, + legal_entity_id: lender.legal_entity_id, lender_id: lender.id, + original_principal: body.originalPrincipal, opening_outstanding_principal: body.openingOutstandingPrincipal, + received_date: parseDateOnlyString(body.receivedDate), balance_as_of: parseDateOnlyString(body.balanceAsOf), + historical_entry: body.historicalEntry, created_by: actor, updated_by: actor, request_key: body.requestKey, + }, include: LOAN_INCLUDE }); + await auditLoan(tx, request, "COMPANY_LOAN_REGISTERED", result.id, { historicalEntry: body.historicalEntry }); + return result; + }, (tx) => tx.companyLoan.findUnique({ where: { created_by_request_key: { created_by: actor, request_key: body.requestKey } }, include: LOAN_INCLUDE }), matches); + return sendData(reply, request, mapLoan(fastify.config, item)); + }); + + fastify.post("/company-loans/:id/confirm", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const id = parse(UUID, request.params.id); const body = parse(CONFIRM, request.body); + const item = await fastify.prisma.$transaction(async (tx) => { + const loan = await lockLoan(tx, id); + assertCurrentVersion(loan.version, body.expectedVersion); + if (loan.confirmation_status === "confirmed") throw buildBusinessValidationError("loan.confirmed_immutable", {}, 409); + assertEvidenceComplete(loan); + const result = await tx.companyLoan.update({ where: { id }, data: { + confirmation_status: "confirmed", confirmed_by: request.employee.id, confirmed_at: new Date(), + accounting_confirmation_ref: body.accountingConfirmationRef, version: { increment: 1 }, updated_by: request.employee.id, + }, include: LOAN_INCLUDE }); + await auditLoan(tx, request, "COMPANY_LOAN_BALANCE_CONFIRMED", id, { version: result.version }); + return result; + }); + return sendData(reply, request, mapLoan(fastify.config, item)); + }); + + fastify.post("/company-loans/:id/evidences/:kind", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const id = parse(UUID, request.params.id); const kind = parse(EVIDENCE_KIND, request.params.kind); + if (!await fastify.prisma.companyLoan.findUnique({ where: { id }, select: { id: true } })) throw loanNotFound(); + const files = []; + try { + for await (const part of request.parts({ preservePath: true, limits: { files: 1, fields: 0, parts: 1, fileSize: MAX_BYTES } })) { + if (part.type !== "file") throw buildBusinessValidationError("loan.evidence_invalid", {}, 400); + const buffer = await boundedBuffer(part.file); + if (part.fieldname !== "file") throw buildBusinessValidationError("loan.evidence_invalid", {}, 400); + files.push(await validateEvidenceFile({ buffer, fileName: part.filename, declaredMimeType: part.mimetype, normalizeImages: true })); + } + } catch (error) { + if (String(error.code ?? "").startsWith("FST_")) throw buildBusinessValidationError("loan.evidence_invalid", {}, 400); + throw error; + } + if (files.length !== 1) throw buildBusinessValidationError("loan.evidence_invalid", {}, 400); + const file = files[0]; + const storagePath = await writeBuffer({ config: fastify.config, key: `company-loan-evidence/${id}/${randomUUID()}${file.extension}`, + buffer: file.buffer, contentType: file.mimeType }); + try { + const item = await fastify.prisma.$transaction(async (tx) => { + await lockLoan(tx, id); + const result = await tx.loanEvidence.create({ data: { + loan_id: id, kind, original_name: file.originalName, storage_path: storagePath, + mime_type: file.mimeType, file_size: file.fileSize, file_sha256: file.fileSha256, + governance_status: "approved", uploaded_by: request.employee.id, + } }); + await auditLoan(tx, request, "COMPANY_LOAN_EVIDENCE_UPLOADED", id, { evidenceId: result.id, kind }); + return result; + }); + return sendData(reply, request, mapEvidence(item)); + } catch (error) { + try { await deleteObject({ config: fastify.config, storagePath }); } catch { + request.log.error({ loanId: id }, "loan evidence cleanup failed"); + } + if (error?.code === "P2002") throw buildBusinessValidationError("loan.duplicate_registration", {}, 409); + throw error; + } + }); + + fastify.post("/company-loans/:id/evidences/:evidenceId/download", auth, async (request, reply) => { + const finance = request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && fastify.hasPermission(request.employee, "loan.manage"); + if (!finance && (!request.employee.roleCodes.some((r) => ["manager", "ceo"].includes(r)) || !fastify.hasPermission(request.employee, "payment.batch.review"))) ensureLoanManager(fastify, request); + const id = parse(UUID, request.params.id); const evidenceId = parse(UUID, request.params.evidenceId); + const { reason, paymentBatchId } = parse(z.object({ reason: TEXT, paymentBatchId: UUID.optional() }).strict(), request.body); + if (!finance) { + if (!paymentBatchId) ensureLoanManager(fastify, request); + const { readLoanRepayment, activeReviewer } = require("../services/companyLoanPayments"); + const batch = await fastify.prisma.paymentBatch.findUnique({ where: { id: paymentBatchId }, include: { items: true } }); + if (!batch || batch.entity_type !== "loan_repayment") throw loanNotFound(); + const repayment = await readLoanRepayment(fastify.prisma, batch); + await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, repayment.lender.employee_id]); + if (!repayment.allocations.some((a) => a.loan_id === id)) throw loanNotFound(); + } + const item = await fastify.prisma.loanEvidence.findFirst({ where: { id: evidenceId, loan_id: id, archived_at: null, governance_status: "approved" } }); + if (!item) throw loanNotFound(); + let validated; + try { + if (item.file_size > MAX_BYTES) throw buildBusinessValidationError("loan.evidence_invalid", {}, 409); + const opened = await openReadStream({ config: fastify.config, storagePath: item.storage_path }); + validated = await validateEvidenceFile({ buffer: await boundedBuffer(opened.stream), fileName: item.original_name, + declaredMimeType: item.mime_type, normalizeImages: false }); + if (validated.fileSha256 !== item.file_sha256) throw buildBusinessValidationError("loan.evidence_invalid", {}, 409); + } catch (error) { + await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOAD_BLOCKED", id, { evidenceId }); + if (error instanceof StorageNotFoundError) throw loanNotFound(); + throw error; + } + await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOADED", id, { evidenceId, reason }); + return reply.header("Cache-Control", "private, no-store").header("X-Content-Type-Options", "nosniff") + .header("Content-Security-Policy", "default-src 'none'; sandbox") + .header("Content-Disposition", buildAttachmentContentDisposition(validated.originalName)) + .type("application/octet-stream").send(validated.buffer); + }); +} + +module.exports = companyLoansRoutes; diff --git a/server/src/routes/confidentialDocRefs.js b/server/src/routes/confidentialDocRefs.js index 9ec43432..ba056dc6 100644 --- a/server/src/routes/confidentialDocRefs.js +++ b/server/src/routes/confidentialDocRefs.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); @@ -23,19 +25,15 @@ const WRITE_SCHEMA = z.object({ }).strict(); const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); -function buildValidationError(details, statusCode = 400) { - return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); -} - -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); - if (!parsed.success) throw buildValidationError(parsed.error.flatten()); + if (!parsed.success) throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); return parsed.data; } async function ensureFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.confidentialDocRefs, departmentId: request.employee.department_id ?? null, strictDepartment: false }); - if (!gate.enabled) throw buildValidationError({ featureFlag: "confidential_doc_refs_v1_disabled", reason: gate.reason }, 409); + if (!gate.enabled) throw buildBusinessValidationError("confidential_ref.feature_disabled", { featureFlag: "confidential_doc_refs_v1_disabled", reason: gate.reason }, 409); } function canManage(fastify, employee) { @@ -75,7 +73,7 @@ async function confidentialDocRefsRoutes(fastify) { fastify.get("/confidential-doc-refs", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); - if (!parsed.success) throw buildValidationError(parsed.error.flatten()); + if (!parsed.success) throw buildBusinessValidationError("confidential_ref.query_invalid", parsed.error.flatten()); const rows = await fastify.prisma.confidentialDocumentRef.findMany({ where: { AND: [ @@ -112,7 +110,7 @@ async function confidentialDocRefsRoutes(fastify) { if (!canManage(fastify, request.employee)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } }); } - const body = parseBody(WRITE_SCHEMA, request.body); + const body = parseBody(WRITE_SCHEMA, request.body, "confidential_ref.create_input_invalid"); const created = await fastify.prisma.confidentialDocumentRef.create({ data: { project_id: body.projectId ?? null, @@ -142,7 +140,7 @@ async function confidentialDocRefsRoutes(fastify) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } }); } const refId = UUID_SCHEMA.parse(request.params?.id); - const body = parseBody(PATCH_SCHEMA, request.body); + const body = parseBody(PATCH_SCHEMA, request.body, "confidential_ref.update_input_invalid"); const updated = await fastify.prisma.confidentialDocumentRef.update({ where: { id: refId }, data: { diff --git a/server/src/routes/configRead.js b/server/src/routes/configRead.js index 1066077c..8f5bf392 100644 --- a/server/src/routes/configRead.js +++ b/server/src/routes/configRead.js @@ -1,6 +1,6 @@ const { z } = require("zod"); -const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { normalizeDocumentType, @@ -18,12 +18,7 @@ const DOCUMENT_TYPE_PARAM_SCHEMA = z.object({ function parseEnabledQuery(query) { const parsed = ENABLED_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("config.enabled_query_invalid", parsed.error.flatten(), 400); } const enabledRaw = parsed.data.enabled; @@ -42,12 +37,7 @@ function parseEnabledQuery(query) { function parseDocumentTypeParams(params) { const parsed = DOCUMENT_TYPE_PARAM_SCHEMA.safeParse(params ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("config.document_type_invalid", parsed.error.flatten(), 400); } return normalizeDocumentType(parsed.data.documentType); @@ -91,6 +81,7 @@ function mapDocumentSequence(config) { async function loadDocumentSequenceConfig(fastify, documentType) { return resolveDocumentSequenceConfig(fastify.prisma, { documentType, + timeZone: fastify.config.appTimezone, }); } diff --git a/server/src/routes/contractsRegistry.js b/server/src/routes/contractsRegistry.js index 58f91ff5..7874a97f 100644 --- a/server/src/routes/contractsRegistry.js +++ b/server/src/routes/contractsRegistry.js @@ -4,7 +4,6 @@ const { CONTRACT_REGISTRY_TYPE_VALUES, buildContractRegistryExpiringWhere, buildContractRegistryScopeWhere, - buildContractRegistryValidationError, buildContractRegistryWhere, canManageContractRegistry, formatContractRegistryAmount, @@ -16,12 +15,14 @@ const { } = require("../services/contractRegistryFilters"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { buildAuditLogData } = require("../utils/audit"); -const { parseDateOnlyString } = require("../utils/date"); +const { parseDateOnlyString, isValidDateOnlyString } = require("../utils/date"); const { sendData } = require("../utils/reply"); const UUID_SCHEMA = z.string().uuid(); -const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const CONFIDENTIALITY_SCHEMA = z.enum(["public", "internal", "confidential", "restricted"]); const CONTRACT_TYPE_SCHEMA = z.enum(CONTRACT_REGISTRY_TYPE_VALUES); const GENERAL_WRITE_STATUS_VALUES = ["draft", "active", "archived"]; @@ -103,10 +104,10 @@ const CONTRACT_HISTORY_ACTIONS = [ "CONTRACT_REGISTRY_CORRECTION_APPENDED", ]; -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildContractRegistryValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } @@ -120,10 +121,7 @@ async function ensureFeatureEnabled(fastify, request) { strictDepartment: false, }); if (!gate.enabled) { - throw buildContractRegistryValidationError( - { featureFlag: "project_linking_v1_disabled", reason: gate.reason }, - 409, - ); + throw buildBusinessValidationError("contract_registry.feature_disabled", { featureFlag: "project_linking_v1_disabled", reason: gate.reason }, 409); } } @@ -345,7 +343,7 @@ async function updateContractWithStateGuard(prisma, existing, data) { data, }); if (changed.count !== 1) { - throw buildContractRegistryValidationError({ status: "contract_state_conflict" }, 409); + throw buildBusinessValidationError("contract_registry.state_conflict", { status: "contract_state_conflict" }, 409); } const updated = await prisma.contractRegistry.findUnique({ @@ -443,7 +441,7 @@ async function contractsRegistryRoutes(fastify) { }); } - const body = parseBody(WRITE_SCHEMA, request.body); + const body = parseBody(WRITE_SCHEMA, request.body, "contract_registry.create_input_invalid"); const today = resolveContractRegistryToday(fastify.config.appTimezone); const created = await fastify.prisma.contractRegistry.create({ data: { @@ -487,7 +485,7 @@ async function contractsRegistryRoutes(fastify) { } const contractId = UUID_SCHEMA.parse(request.params?.id); - const body = parseBody(PATCH_SCHEMA, request.body); + const body = parseBody(PATCH_SCHEMA, request.body, "contract_registry.update_input_invalid"); const existing = await fastify.prisma.contractRegistry.findUnique({ where: { id: contractId }, select: { @@ -506,11 +504,11 @@ async function contractsRegistryRoutes(fastify) { assertTerminationInvariant(existing); if (existing.status === "terminated") { - throw buildContractRegistryValidationError({ status: "terminated_is_final" }, 409); + throw buildBusinessValidationError("contract_registry.terminated_is_final", { status: "terminated_is_final" }, 409); } const patchFields = Object.keys(body); if (existing.status === "archived" && !(patchFields.length === 1 && body.status === "active")) { - throw buildContractRegistryValidationError({ status: "archived_fields_locked" }, 409); + throw buildBusinessValidationError("contract_registry.archived_fields_locked", { status: "archived_fields_locked" }, 409); } const today = resolveContractRegistryToday(fastify.config.appTimezone); @@ -550,7 +548,7 @@ async function contractsRegistryRoutes(fastify) { } const contractId = UUID_SCHEMA.parse(request.params?.id); - const body = parseBody(TERMINATE_SCHEMA, request.body); + const body = parseBody(TERMINATE_SCHEMA, request.body, "contract_registry.terminate_input_invalid"); const today = resolveContractRegistryToday(fastify.config.appTimezone); const result = await fastify.prisma.$transaction(async (tx) => { const existing = await tx.contractRegistry.findUnique({ @@ -564,7 +562,7 @@ async function contractsRegistryRoutes(fastify) { const effectiveStatus = resolveContractRegistryEffectiveStatus(existing, today); if (!["active", "expired"].includes(effectiveStatus)) { - throw buildContractRegistryValidationError({ status: "terminate_not_allowed" }, 409); + throw buildBusinessValidationError("contract_registry.terminate_not_allowed", { status: "terminate_not_allowed" }, 409); } const changed = await tx.contractRegistry.updateMany({ @@ -577,7 +575,7 @@ async function contractsRegistryRoutes(fastify) { }, }); if (changed.count !== 1) { - throw buildContractRegistryValidationError({ status: "terminate_conflict" }, 409); + throw buildBusinessValidationError("contract_registry.terminate_conflict", { status: "terminate_conflict" }, 409); } const updated = await tx.contractRegistry.findUnique({ @@ -609,7 +607,7 @@ async function contractsRegistryRoutes(fastify) { } const contractId = UUID_SCHEMA.parse(request.params?.id); - const body = parseBody(CORRECTION_SCHEMA, request.body); + const body = parseBody(CORRECTION_SCHEMA, request.body, "contract_registry.correction_input_invalid"); await fastify.prisma.$transaction(async (tx) => { const item = await tx.contractRegistry.findUnique({ where: { id: contractId }, @@ -619,7 +617,7 @@ async function contractsRegistryRoutes(fastify) { throw buildNotFoundError(); } if (item.status !== "archived") { - throw buildContractRegistryValidationError({ status: "correction_requires_archived" }, 409); + throw buildBusinessValidationError("contract_registry.correction_requires_archived", { status: "correction_requires_archived" }, 409); } const claimed = await tx.contractRegistry.updateMany({ @@ -627,7 +625,7 @@ async function contractsRegistryRoutes(fastify) { data: { updated_at: new Date() }, }); if (claimed.count !== 1) { - throw buildContractRegistryValidationError({ status: "correction_state_conflict" }, 409); + throw buildBusinessValidationError("contract_registry.correction_state_conflict", { status: "correction_state_conflict" }, 409); } await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_CORRECTION_APPENDED", contractId, { @@ -662,7 +660,7 @@ async function contractsRegistryRoutes(fastify) { const effectiveStatus = resolveContractRegistryEffectiveStatus(original, today); if (!["active", "expired"].includes(effectiveStatus)) { - throw buildContractRegistryValidationError({ status: "renew_not_allowed" }, 409); + throw buildBusinessValidationError("contract_registry.renew_not_allowed", { status: "renew_not_allowed" }, 409); } const baseContractNo = original.contract_no.replace(/-R\d+$/, ""); diff --git a/server/src/routes/cutover.js b/server/src/routes/cutover.js index 119abfb1..8da2416c 100644 --- a/server/src/routes/cutover.js +++ b/server/src/routes/cutover.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { sendData } = require("../utils/reply"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); @@ -15,12 +17,11 @@ const WRITE_SCHEMA = z.object({ }).strict(); const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); -function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } -function parseId(value) { const parsed = ID_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); return parsed.data; } -function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) throw buildValidationError(parsed.error.flatten()); return parsed.data; } +function parseId(value) { const parsed = ID_SCHEMA.safeParse(value); if (!parsed.success) throw buildBusinessValidationError("cutover.id_invalid", { id: "invalid_uuid" }); return parsed.data; } +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); return parsed.data; } async function ensureFeatureEnabled(fastify) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.cutoverConsole, strictDepartment: false }); - if (!gate.enabled) throw buildValidationError({ featureFlag: "cutover_console_v1_disabled", reason: gate.reason }, 409); + if (!gate.enabled) throw buildBusinessValidationError("cutover.feature_disabled", { featureFlag: "cutover_console_v1_disabled", reason: gate.reason }, 409); } function mapScope(item) { return { @@ -44,7 +45,7 @@ async function cutoverRoutes(fastify) { fastify.post("/cutover/scopes", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify); - const body = parseBody(WRITE_SCHEMA, request.body); + const body = parseBody(WRITE_SCHEMA, request.body, "cutover.create_input_invalid"); const created = await fastify.prisma.cutoverScope.create({ data: { scope_type: body.scopeType, @@ -62,7 +63,7 @@ async function cutoverRoutes(fastify) { fastify.patch("/cutover/scopes/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify); const scopeId = parseId(request.params?.id); - const body = parseBody(PATCH_SCHEMA, request.body); + const body = parseBody(PATCH_SCHEMA, request.body, "cutover.update_input_invalid"); const updated = await fastify.prisma.cutoverScope.update({ where: { id: scopeId }, data: { diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js index 5d51e7bd..9410c0d7 100644 --- a/server/src/routes/dashboard.js +++ b/server/src/routes/dashboard.js @@ -1,12 +1,13 @@ const { z } = require("zod"); -const { AppError } = require("../utils/AppError"); +const { isValidDateOnlyString } = require("../utils/date"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { buildReadableReimbursementWhere } = require("../services/reimbursementVisibility"); const QUERY_SCHEMA = z.object({ - dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), + dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), departmentId: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(30).optional(), }); @@ -14,23 +15,13 @@ const QUERY_SCHEMA = z.object({ function parseQuery(query) { const parsed = QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("dashboard.query_invalid", parsed.error.flatten()); } const dateFrom = parsed.data.dateFrom ? new Date(`${parsed.data.dateFrom}T00:00:00.000Z`) : null; const dateTo = parsed.data.dateTo ? new Date(`${parsed.data.dateTo}T23:59:59.999Z`) : null; if (dateFrom && dateTo && dateFrom > dateTo) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { dateTo: "must_be_after_dateFrom" }, - }); + throw buildBusinessValidationError("dashboard.date_range_invalid", { dateTo: "must_be_after_dateFrom" }); } return { diff --git a/server/src/routes/delegations.js b/server/src/routes/delegations.js index dfbcdf51..857b71bc 100644 --- a/server/src/routes/delegations.js +++ b/server/src/routes/delegations.js @@ -1,5 +1,6 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { computeDelegationStatus } = require("../utils/delegation"); @@ -31,12 +32,7 @@ const LIST_DELEGATION_GRANTS_QUERY_SCHEMA = z.object({ function parseDelegationId(id) { const parsed = UUID_SCHEMA.safeParse(id); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { id: "invalid_uuid" }, - }); + throw buildBusinessValidationError("delegation.id_invalid", { id: "invalid_uuid" }, 400); } return parsed.data; @@ -45,42 +41,22 @@ function parseDelegationId(id) { function parseCreateBody(body) { const parsed = CREATE_DELEGATION_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("delegation.create_input_invalid", parsed.error.flatten(), 400); } const validFrom = parsed.data.validFrom ? new Date(parsed.data.validFrom) : new Date(); const validTo = parsed.data.validTo ? new Date(parsed.data.validTo) : null; if (Number.isNaN(validFrom.getTime())) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { validFrom: "invalid_datetime" }, - }); + throw buildBusinessValidationError("delegation.start_time_invalid", { validFrom: "invalid_datetime" }, 400); } if (validTo && Number.isNaN(validTo.getTime())) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { validTo: "invalid_datetime" }, - }); + throw buildBusinessValidationError("delegation.end_time_invalid", { validTo: "invalid_datetime" }, 400); } if (validTo && validTo <= validFrom) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { validTo: "must_be_after_validFrom" }, - }); + throw buildBusinessValidationError("delegation.time_range_invalid", { validTo: "must_be_after_validFrom" }, 400); } return { @@ -94,42 +70,22 @@ function parseCreateBody(body) { function parseCreateGrantBody(body) { const parsed = CREATE_DELEGATION_GRANT_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("delegation.grant_input_invalid", parsed.error.flatten(), 400); } const validFrom = parsed.data.validFrom ? new Date(parsed.data.validFrom) : new Date(); const validTo = parsed.data.validTo ? new Date(parsed.data.validTo) : null; if (Number.isNaN(validFrom.getTime())) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { validFrom: "invalid_datetime" }, - }); + throw buildBusinessValidationError("delegation.start_time_invalid", { validFrom: "invalid_datetime" }, 400); } if (validTo && Number.isNaN(validTo.getTime())) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { validTo: "invalid_datetime" }, - }); + throw buildBusinessValidationError("delegation.end_time_invalid", { validTo: "invalid_datetime" }, 400); } if (validTo && validTo <= validFrom) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { validTo: "must_be_after_validFrom" }, - }); + throw buildBusinessValidationError("delegation.time_range_invalid", { validTo: "must_be_after_validFrom" }, 400); } return { @@ -144,12 +100,7 @@ function parseCreateGrantBody(body) { function parseStatusQuery(query) { const parsed = QUERY_STATUS_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("delegation.status_query_invalid", parsed.error.flatten(), 400); } return parsed.data.status ?? "active"; @@ -158,12 +109,7 @@ function parseStatusQuery(query) { function parseListDelegationGrantsQuery(query) { const parsed = LIST_DELEGATION_GRANTS_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("delegation.grants_query_invalid", parsed.error.flatten(), 400); } const includeInactive = ["true", "1"].includes(parsed.data.includeInactive ?? "false"); @@ -228,12 +174,7 @@ async function delegationRoutes(fastify) { }); if (!delegateEmployee || delegateEmployee.status !== "active") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { delegateEmployeeId: "invalid_delegate" }, - }); + throw buildBusinessValidationError("delegation.delegate_unavailable", { delegateEmployeeId: "invalid_delegate" }, 409); } const roleCodes = delegateEmployee.roles.map((item) => item.role_code); @@ -241,24 +182,14 @@ async function delegationRoutes(fastify) { if (scope === "reimbursement_submit") { if (!hasPermission(permissionCodes, "expense.proxy_submit")) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { delegateEmployeeId: "delegate_must_be_administration" }, - }); + throw buildBusinessValidationError("delegation.delegate_proxy_permission_required", { delegateEmployeeId: "delegate_must_be_administration" }, 409); } return delegateEmployee; } if (!hasPermission(permissionCodes, "expense.approve")) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { delegateEmployeeId: "delegate_must_be_approver" }, - }); + throw buildBusinessValidationError("delegation.delegate_approval_permission_required", { delegateEmployeeId: "delegate_must_be_approver" }, 409); } return delegateEmployee; @@ -315,16 +246,18 @@ async function delegationRoutes(fastify) { ], }; - const where = { - ...baseWhere, - ...(query.includeInactive - ? {} - : { - revoked_at: null, - valid_from: { lte: now }, - OR: [{ valid_to: null }, { valid_to: { gt: now } }], - }), - }; + const where = query.includeInactive + ? baseWhere + : { + AND: [ + baseWhere, + { + revoked_at: null, + valid_from: { lte: now }, + OR: [{ valid_to: null }, { valid_to: { gt: now } }], + }, + ], + }; const items = await fastify.prisma.delegationGrant.findMany({ where, @@ -379,12 +312,7 @@ async function delegationRoutes(fastify) { } if (input.delegateEmployeeId === principalEmployeeId) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { delegateEmployeeId: "self_not_allowed" }, - }); + throw buildBusinessValidationError("delegation.self_not_allowed", { delegateEmployeeId: "self_not_allowed" }, 409); } const delegation = await fastify.prisma.$transaction(async (tx) => { @@ -393,12 +321,7 @@ async function delegationRoutes(fastify) { select: { id: true, status: true }, }); if (!principal || principal.status !== "active") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { principalEmployeeId: "invalid_principal" }, - }); + throw buildBusinessValidationError("delegation.principal_unavailable", { principalEmployeeId: "invalid_principal" }, 409); } await ensureDelegateEligible(tx, input.delegateEmployeeId, input.scope); @@ -513,12 +436,7 @@ async function delegationRoutes(fastify) { const input = parseCreateBody(request.body); if (input.delegateEmployeeId === request.employee.id) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { delegateEmployeeId: "self_not_allowed" }, - }); + throw buildBusinessValidationError("delegation.self_not_allowed", { delegateEmployeeId: "self_not_allowed" }, 409); } const delegation = await fastify.prisma.$transaction(async (tx) => { diff --git a/server/src/routes/departments.js b/server/src/routes/departments.js index adab5796..bb036273 100644 --- a/server/src/routes/departments.js +++ b/server/src/routes/departments.js @@ -1,3 +1,4 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); @@ -23,12 +24,7 @@ const UPDATE_BODY_SCHEMA = z.object({ function parseDepartmentId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { id: "invalid_uuid" }, - }); + throw buildBusinessValidationError("department.id_invalid", { id: "invalid_uuid" }, 400); } return parsed.data; } @@ -36,12 +32,7 @@ function parseDepartmentId(value) { function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("department.query_invalid", parsed.error.flatten(), 400); } const raw = parsed.data.includeArchived ?? "0"; @@ -53,12 +44,7 @@ function parseListQuery(query) { function parseCreateBody(body) { const parsed = CREATE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("department.create_input_invalid", parsed.error.flatten(), 400); } return { @@ -70,22 +56,12 @@ function parseCreateBody(body) { function parseUpdateBody(body) { const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("department.update_input_invalid", parsed.error.flatten(), 400); } const hasUpdate = typeof parsed.data.name === "string" || "parentId" in parsed.data; if (!hasUpdate) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { body: "empty" }, - }); + throw buildBusinessValidationError("department.update_empty", { body: "empty" }, 400); } return { @@ -196,20 +172,10 @@ async function departmentsRoutes(fastify) { select: { id: true, archived_at: true }, }); if (!parent) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { parentId: "not_found" }, - }); + throw buildBusinessValidationError("department.parent_not_found", { parentId: "not_found" }, 400); } if (parent.archived_at) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { parentId: "archived" }, - }); + throw buildBusinessValidationError("department.parent_archived", { parentId: "archived" }, 409); } } @@ -284,24 +250,14 @@ async function departmentsRoutes(fastify) { } if (current.archived_at) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { status: "archived" }, - }); + throw buildBusinessValidationError("department.archived_locked", { status: "archived" }, 409); } const parentId = Object.prototype.hasOwnProperty.call(input, "parentId") ? input.parentId : current.parent_id; if (parentId === departmentId) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { parentId: "self_not_allowed" }, - }); + throw buildBusinessValidationError("department.parent_self_forbidden", { parentId: "self_not_allowed" }, 409); } if (parentId) { @@ -310,20 +266,10 @@ async function departmentsRoutes(fastify) { select: { id: true, archived_at: true }, }); if (!parent) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { parentId: "not_found" }, - }); + throw buildBusinessValidationError("department.parent_not_found", { parentId: "not_found" }, 400); } if (parent.archived_at) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { parentId: "archived" }, - }); + throw buildBusinessValidationError("department.parent_archived", { parentId: "archived" }, 409); } const allDepartments = await tx.department.findMany({ @@ -331,12 +277,7 @@ async function departmentsRoutes(fastify) { }); if (detectParentCycle(allDepartments, departmentId, parentId)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { parentId: "cycle_not_allowed" }, - }); + throw buildBusinessValidationError("department.parent_cycle_forbidden", { parentId: "cycle_not_allowed" }, 409); } } @@ -425,21 +366,11 @@ async function departmentsRoutes(fastify) { ]); if (childCount > 0) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { department: "has_children" }, - }); + throw buildBusinessValidationError("department.children_exist", { department: "has_children" }, 409); } if (activeEmployeeCount > 0) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { department: "has_active_employees" }, - }); + throw buildBusinessValidationError("department.active_employees_exist", { department: "has_active_employees" }, 409); } const department = await tx.department.update({ diff --git a/server/src/routes/employees.js b/server/src/routes/employees.js index e77603dd..5758c9c4 100644 --- a/server/src/routes/employees.js +++ b/server/src/routes/employees.js @@ -1,3 +1,5 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const fs = require("node:fs/promises"); const crypto = require("node:crypto"); const path = require("node:path"); @@ -8,7 +10,7 @@ const { z } = require("zod"); const usersRoutes = require("./users"); const { buildDefaultChecklist, resolveEffectiveDate, applyEmployeeTransfer } = require("../services/employeeLifecycle"); const { buildEmployeeStats } = require("../services/employeeStats"); -const { currentDateString } = require("../utils/date"); +const { currentDateString, isValidDateOnlyString } = require("../utils/date"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); @@ -40,7 +42,7 @@ const { } = require("../services/purchaseAttachmentPolicy"); const UUID_SCHEMA = z.string().uuid(); -const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const REPORTING_CHAIN_MAX_DEPTH = 12; const AVATAR_ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/webp"]); const AVATAR_MAX_BYTES = 2 * 1024 * 1024; @@ -151,22 +153,29 @@ const CERTIFICATE_PATCH_SCHEMA = CERTIFICATE_CREATE_SCHEMA.partial() .strict() .refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); -function buildValidationError(details, statusCode = 400) { - return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); +async function withUploadSizeReason(read, reasonCode) { + try { + return await read(); + } catch (error) { + if (error?.code === "FST_REQ_FILE_TOO_LARGE") { + throw buildBusinessValidationError(getErrorReason(reasonCode).code, { file: "file_too_large" }); + } + throw error; + } } function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("employee.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } @@ -180,7 +189,7 @@ async function ensureFeatureEnabled(fastify, request) { strictDepartment: false, }); if (!gate.enabled) { - throw buildValidationError({ featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409); + throw buildBusinessValidationError("employee.lifecycle_disabled", { featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409); } } @@ -223,10 +232,10 @@ async function ensureWecomHrMasterSyncEnabled(fastify, request) { strictDepartment: false, }); if (!gate.enabled) { - throw buildValidationError({ featureFlag: "wecom_hr_master_sync_v1_disabled", reason: gate.reason }, 409); + throw buildBusinessValidationError("employee.hr_sync_disabled", { featureFlag: "wecom_hr_master_sync_v1_disabled", reason: gate.reason }, 409); } if (!canUseWecomHrMasterSync(fastify)) { - throw buildValidationError({ featureFlag: "wecom_hr_master_sync_v1_unavailable" }, 409); + throw buildBusinessValidationError("employee.hr_sync_unavailable", { featureFlag: "wecom_hr_master_sync_v1_unavailable" }, 409); } } @@ -284,22 +293,22 @@ function parseDateOnly(value) { function validateContractDates(value) { if (value.startDate && value.endDate && value.startDate > value.endDate) { - throw buildValidationError({ endDate: "must_be_after_or_equal_startDate" }); + throw buildBusinessValidationError("employee.contract_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" }); } if (value.startDate && value.terminatedDate && value.terminatedDate < value.startDate) { - throw buildValidationError({ terminatedDate: "must_be_after_or_equal_startDate" }); + throw buildBusinessValidationError("employee.contract_termination_date_invalid", { terminatedDate: "must_be_after_or_equal_startDate" }); } } function validateTrainingDates(value) { if (value.startDate && value.endDate && value.startDate > value.endDate) { - throw buildValidationError({ endDate: "must_be_after_or_equal_startDate" }); + throw buildBusinessValidationError("employee.training_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" }); } } function validateCertificateDates(value) { if (value.obtainedDate && value.expiryDate && value.obtainedDate > value.expiryDate) { - throw buildValidationError({ expiryDate: "must_be_after_or_equal_obtainedDate" }); + throw buildBusinessValidationError("employee.certificate_date_range_invalid", { expiryDate: "must_be_after_or_equal_obtainedDate" }); } } @@ -467,12 +476,9 @@ async function writeEmployeeContractAudit(fastify, request, { } function buildContractAttachmentBlockedError() { - return new AppError({ - code: "VALID_001", - message: "合同附件未通过安全校验", - statusCode: 409, - details: { attachment: "security_validation_failed" }, - }); + const error = buildBusinessValidationError("employee.contract_attachment_download_blocked", { attachment: "security_validation_failed" }, 409); + error.message = "合同附件未通过安全校验"; + return error; } async function auditBlockedContractAttachmentDownload({ @@ -899,26 +905,26 @@ async function employeesRoutes(fastify) { await ensureEmployeeExists(fastify.prisma, employeeId); if (!request.isMultipart()) { - throw buildValidationError({ file: "invalid_multipart" }); + throw buildBusinessValidationError("employee.avatar_multipart_required", { file: "invalid_multipart" }); } - const filePart = await request.file(); + const filePart = await withUploadSizeReason(() => request.file(), "employee.avatar_file_too_large"); if (!filePart) { - throw buildValidationError({ file: "required" }); + throw buildBusinessValidationError("employee.avatar_file_required", { file: "required" }); } if (filePart.fieldname !== "file") { - throw buildValidationError({ file: "invalid_field" }); + throw buildBusinessValidationError("employee.avatar_field_invalid", { file: "invalid_field" }); } if (!AVATAR_ALLOWED_MIME_TYPES.has(filePart.mimetype)) { - throw buildValidationError({ file: "invalid_type" }); + throw buildBusinessValidationError("employee.avatar_type_invalid", { file: "invalid_type" }); } - const rawBuffer = await filePart.toBuffer(); + const rawBuffer = await withUploadSizeReason(() => filePart.toBuffer(), "employee.avatar_file_too_large"); if (!rawBuffer.length) { - throw buildValidationError({ file: "empty" }); + throw buildBusinessValidationError("employee.avatar_file_empty", { file: "empty" }); } if (rawBuffer.length > AVATAR_MAX_BYTES) { - throw buildValidationError({ file: "file_too_large" }); + throw buildBusinessValidationError("employee.avatar_file_too_large", { file: "file_too_large" }); } let normalizedBuffer; @@ -928,7 +934,7 @@ async function employeesRoutes(fastify) { .webp({ quality: 90 }) .toBuffer(); } catch { - throw buildValidationError({ file: "invalid_image" }); + throw buildBusinessValidationError("employee.avatar_image_invalid", { file: "invalid_image" }); } const avatarPath = await writeBuffer({ @@ -1007,7 +1013,7 @@ async function employeesRoutes(fastify) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } await ensureWecomHrMasterSyncEnabled(fastify, request); - const body = parseBody(WECOM_HR_MASTER_SYNC_RUN_SCHEMA, request.body); + const body = parseBody(WECOM_HR_MASTER_SYNC_RUN_SCHEMA, request.body, "employee.hr_sync_input_invalid"); const run = await runWecomHrMasterSync({ prisma: fastify.prisma, @@ -1043,7 +1049,7 @@ async function employeesRoutes(fastify) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } await ensureWecomHrMasterSyncEnabled(fastify, request); - const query = parseBody(WECOM_HR_MASTER_SYNC_LIST_QUERY_SCHEMA, request.query); + const query = parseBody(WECOM_HR_MASTER_SYNC_LIST_QUERY_SCHEMA, request.query, "employee.hr_sync_query_invalid"); const items = await listWecomHrMasterSyncRuns(fastify.prisma, { pageSize: query.pageSize }); return sendData(reply, request, { items }); }); @@ -1068,7 +1074,7 @@ async function employeesRoutes(fastify) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } if (!canUseWecomDirectorySync(fastify)) { - throw buildValidationError({ featureFlag: "hr_wecom_directory_sync_v1_unavailable" }, 409); + throw buildBusinessValidationError("employee.directory_sync_unavailable", { featureFlag: "hr_wecom_directory_sync_v1_unavailable" }, 409); } const employeeId = parseId(request.params?.id); @@ -1104,11 +1110,11 @@ async function employeesRoutes(fastify) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } if (!canUseWecomDirectorySync(fastify)) { - throw buildValidationError({ featureFlag: "hr_wecom_directory_sync_v1_unavailable" }, 409); + throw buildBusinessValidationError("employee.directory_sync_unavailable", { featureFlag: "hr_wecom_directory_sync_v1_unavailable" }, 409); } const employeeId = parseId(request.params?.id); - const body = parseBody(WECOM_DIRECTORY_RETRY_SCHEMA, request.body); + const body = parseBody(WECOM_DIRECTORY_RETRY_SCHEMA, request.body, "employee.directory_retry_input_invalid"); const employee = await fastify.prisma.employee.findUnique({ where: { id: employeeId }, select: { @@ -1296,7 +1302,7 @@ async function employeesRoutes(fastify) { await ensureFeatureEnabled(fastify, request); const employeeId = parseId(request.params?.id); - const body = parseBody(CONTRACT_CREATE_SCHEMA, request.body); + const body = parseBody(CONTRACT_CREATE_SCHEMA, request.body, "employee.contract_create_input_invalid"); validateContractDates(body); await ensureEmployeeExists(fastify.prisma, employeeId); @@ -1326,7 +1332,7 @@ async function employeesRoutes(fastify) { return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone)); } catch (error) { if (error?.code === "P2002") { - throw buildValidationError({ contractNo: "duplicate" }); + throw buildBusinessValidationError("employee.contract_number_duplicate", { contractNo: "duplicate" }); } throw error; } @@ -1337,7 +1343,7 @@ async function employeesRoutes(fastify) { const employeeId = parseId(request.params?.id); const contractId = parseId(request.params?.contractId); - const body = parseBody(CONTRACT_PATCH_SCHEMA, request.body); + const body = parseBody(CONTRACT_PATCH_SCHEMA, request.body, "employee.contract_update_input_invalid"); await ensureEmployeeExists(fastify.prisma, employeeId); try { @@ -1374,7 +1380,7 @@ async function employeesRoutes(fastify) { return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone)); } catch (error) { if (error?.code === "P2002") { - throw buildValidationError({ contractNo: "duplicate" }); + throw buildBusinessValidationError("employee.contract_number_duplicate", { contractNo: "duplicate" }); } throw error; } @@ -1389,28 +1395,28 @@ async function employeesRoutes(fastify) { await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId); if (!request.isMultipart()) { - throw buildValidationError({ file: "invalid_multipart" }); + throw buildBusinessValidationError("employee.contract_attachment_multipart_required", { file: "invalid_multipart" }); } - const filePart = await request.file(); + const filePart = await withUploadSizeReason(() => request.file(), "employee.contract_attachment_file_too_large"); if (!filePart) { - throw buildValidationError({ file: "required" }); + throw buildBusinessValidationError("employee.contract_attachment_file_required", { file: "required" }); } if (filePart.fieldname !== "file") { - throw buildValidationError({ file: "invalid_field" }); + throw buildBusinessValidationError("employee.contract_attachment_field_invalid", { file: "invalid_field" }); } const descriptor = resolveContractAttachmentDescriptor(filePart.mimetype); if (!descriptor) { - throw buildValidationError({ file: "invalid_type" }); + throw buildBusinessValidationError("employee.contract_attachment_type_invalid", { file: "invalid_type" }); } - const rawBuffer = await filePart.toBuffer(); + const rawBuffer = await withUploadSizeReason(() => filePart.toBuffer(), "employee.contract_attachment_file_too_large"); if (!rawBuffer.length) { - throw buildValidationError({ file: "empty" }); + throw buildBusinessValidationError("employee.contract_attachment_file_empty", { file: "empty" }); } if (rawBuffer.length > CONTRACT_ATTACHMENT_MAX_BYTES) { - throw buildValidationError({ file: "file_too_large" }); + throw buildBusinessValidationError("employee.contract_attachment_file_too_large", { file: "file_too_large" }); } let validatedAttachment; @@ -1424,7 +1430,7 @@ async function employeesRoutes(fastify) { }); } catch (error) { if (error instanceof PurchaseAttachmentPolicyError) { - throw buildValidationError({ file: error.reasonCode }); + throw buildBusinessValidationError("employee.contract_attachment_content_invalid", { file: error.reasonCode }); } throw error; } @@ -1484,14 +1490,14 @@ async function employeesRoutes(fastify) { fastify.delete("/employees/:id/contracts/:contractId", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => { const employeeId = parseId(request.params?.id); const contractId = parseId(request.params?.contractId); - const body = parseBody(CONTRACT_DELETE_SCHEMA, request.body); + const body = parseBody(CONTRACT_DELETE_SCHEMA, request.body, "employee.contract_delete_input_invalid"); await ensureFeatureEnabled(fastify, request); const current = await fastify.prisma.$transaction(async (tx) => { const contract = await ensureEmployeeContractExists(tx, employeeId, contractId); const status = deriveEmployeeContractStatus(contract, fastify.config.appTimezone); if (status === "active") { - throw buildValidationError({ contract: "active_contract_must_be_terminated" }, 409); + throw buildBusinessValidationError("employee.active_contract_delete_forbidden", { contract: "active_contract_must_be_terminated" }, 409); } await tx.employeeContract.delete({ where: { id: contractId } }); @@ -1550,7 +1556,7 @@ async function employeesRoutes(fastify) { } const employeeId = parseId(request.params?.id); - const body = parseBody(TRAINING_CREATE_SCHEMA, request.body); + const body = parseBody(TRAINING_CREATE_SCHEMA, request.body, "employee.training_create_input_invalid"); validateTrainingDates(body); await ensureEmployeeExists(fastify.prisma, employeeId); @@ -1579,7 +1585,7 @@ async function employeesRoutes(fastify) { const employeeId = parseId(request.params?.id); const trainingId = parseId(request.params?.trainingId); - const body = parseBody(TRAINING_PATCH_SCHEMA, request.body); + const body = parseBody(TRAINING_PATCH_SCHEMA, request.body, "employee.training_update_input_invalid"); await ensureEmployeeExists(fastify.prisma, employeeId); const current = await ensureEmployeeTrainingExists(fastify.prisma, employeeId, trainingId); validateTrainingDates({ @@ -1629,7 +1635,7 @@ async function employeesRoutes(fastify) { } const employeeId = parseId(request.params?.id); - const body = parseBody(CERTIFICATE_CREATE_SCHEMA, request.body); + const body = parseBody(CERTIFICATE_CREATE_SCHEMA, request.body, "employee.certificate_create_input_invalid"); validateCertificateDates(body); await ensureEmployeeExists(fastify.prisma, employeeId); @@ -1658,7 +1664,7 @@ async function employeesRoutes(fastify) { const employeeId = parseId(request.params?.id); const certificateId = parseId(request.params?.certificateId); - const body = parseBody(CERTIFICATE_PATCH_SCHEMA, request.body); + const body = parseBody(CERTIFICATE_PATCH_SCHEMA, request.body, "employee.certificate_update_input_invalid"); await ensureEmployeeExists(fastify.prisma, employeeId); const current = await ensureEmployeeCertificateExists(fastify.prisma, employeeId, certificateId); validateCertificateDates({ @@ -1688,7 +1694,7 @@ async function employeesRoutes(fastify) { if (!canManageProfile(fastify, request, employeeId)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.profile.write" } }); } - const body = parseBody(PROFILE_PATCH_SCHEMA, request.body); + const body = parseBody(PROFILE_PATCH_SCHEMA, request.body, "employee.profile_input_invalid"); const managerMode = canManageLifecycle(fastify, request); const selfEditableKeys = ["workLocation", "emergencyContactName", "emergencyContactPhone", "notes"]; if (!managerMode) { @@ -1698,7 +1704,7 @@ async function employeesRoutes(fastify) { const invalidKeys = Object.keys(body).filter((key) => !selfEditableKeys.includes(key)); if (invalidKeys.length) { - throw buildValidationError({ body: `self_profile_fields_forbidden:${invalidKeys.join(",")}` }, 403); + throw buildBusinessValidationError("employee.self_profile_fields_forbidden", { body: `self_profile_fields_forbidden:${invalidKeys.join(",")}` }, 403); } } @@ -1739,7 +1745,7 @@ async function employeesRoutes(fastify) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); - const body = parseBody(ONBOARDING_SCHEMA, request.body); + const body = parseBody(ONBOARDING_SCHEMA, request.body, "employee.onboarding_input_invalid"); const effectiveDate = resolveEffectiveDate(body.effectiveDate); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeeExists(tx, employeeId); @@ -1778,7 +1784,7 @@ async function employeesRoutes(fastify) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); - const body = parseBody(REGULARIZE_SCHEMA, request.body); + const body = parseBody(REGULARIZE_SCHEMA, request.body, "employee.regularize_input_invalid"); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeeExists(tx, employeeId); await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "active", updated_by: request.employee.id } }); @@ -1802,7 +1808,7 @@ async function employeesRoutes(fastify) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); - const body = parseBody(TRANSFER_SCHEMA, request.body); + const body = parseBody(TRANSFER_SCHEMA, request.body, "employee.transfer_input_invalid"); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeeExists(tx, employeeId); await applyEmployeeTransfer(tx, { @@ -1829,7 +1835,7 @@ async function employeesRoutes(fastify) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); - const body = parseBody(OFFBOARDING_SCHEMA, request.body); + const body = parseBody(OFFBOARDING_SCHEMA, request.body, "employee.offboarding_input_invalid"); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeeExists(tx, employeeId); await tx.employee.update({ diff --git a/server/src/routes/expenseStandards.js b/server/src/routes/expenseStandards.js index d6887c17..9b449a06 100644 --- a/server/src/routes/expenseStandards.js +++ b/server/src/routes/expenseStandards.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); @@ -47,15 +49,6 @@ const UPDATE_BODY_SCHEMA = z }) .strict(); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function toMoneyString(value) { const numeric = Number(value ?? 0); if (!Number.isFinite(numeric)) { @@ -67,7 +60,7 @@ function toMoneyString(value) { function parseExpenseStandardId(params) { const parsed = UUID_SCHEMA.safeParse(params?.id); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("expense_standard.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } @@ -75,7 +68,7 @@ function parseExpenseStandardId(params) { function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("expense_standard.query_invalid", parsed.error.flatten()); } const enabledRaw = parsed.data.enabled; @@ -93,15 +86,15 @@ function parseListQuery(query) { }; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } const data = parsed.data; if ("maxAmount" in data && data.maxAmount != null && Number(data.maxAmount) <= 0) { - throw buildValidationError({ maxAmount: "must_be_positive" }); + throw buildBusinessValidationError("expense_standard.amount_not_positive", { maxAmount: "must_be_positive" }); } return data; @@ -118,12 +111,7 @@ async function ensureExpenseTypeExists(tx, expenseTypeId) { }); if (!expenseType) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { expenseTypeId: "not_found" }, - }); + throw buildBusinessValidationError("expense_standard.expense_type_not_found", { expenseTypeId: "not_found" }, 409); } return expenseType; @@ -135,7 +123,7 @@ async function ensureScopeKeyValid(tx, scopeType, scopeKey) { } if (!scopeKey) { - throw buildValidationError({ scopeKey: "required_for_department_scope" }); + throw buildBusinessValidationError("expense_standard.department_required", { scopeKey: "required_for_department_scope" }); } const department = await tx.department.findUnique({ @@ -148,12 +136,7 @@ async function ensureScopeKeyValid(tx, scopeType, scopeKey) { }); if (!department || department.archived_at) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { scopeKey: "invalid_department" }, - }); + throw buildBusinessValidationError("expense_standard.department_unavailable", { scopeKey: "invalid_department" }, 409); } return department; @@ -315,7 +298,7 @@ async function expenseStandardsRoutes(fastify) { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { - const body = parseBody(CREATE_BODY_SCHEMA, request.body); + const body = parseBody(CREATE_BODY_SCHEMA, request.body, "expense_standard.create_input_invalid"); const now = new Date(); let created = null; @@ -385,12 +368,7 @@ async function expenseStandardsRoutes(fastify) { }); } catch (error) { if (error?.code === "P2002") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { duplicate: "expense_type_scope_conflict" }, - }); + throw buildBusinessValidationError("expense_standard.scope_conflict", { duplicate: "expense_type_scope_conflict" }, 409); } throw error; } @@ -407,9 +385,9 @@ async function expenseStandardsRoutes(fastify) { }, async (request, reply) => { const standardId = parseExpenseStandardId(request.params); - const body = parseBody(UPDATE_BODY_SCHEMA, request.body); + const body = parseBody(UPDATE_BODY_SCHEMA, request.body, "expense_standard.update_input_invalid"); if (!Object.keys(body).length) { - throw buildValidationError({ body: "empty" }); + throw buildBusinessValidationError("expense_standard.update_empty", { body: "empty" }); } const now = new Date(); @@ -501,12 +479,7 @@ async function expenseStandardsRoutes(fastify) { }); } catch (error) { if (error?.code === "P2002") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { duplicate: "expense_type_scope_conflict" }, - }); + throw buildBusinessValidationError("expense_standard.scope_conflict", { duplicate: "expense_type_scope_conflict" }, 409); } throw error; } diff --git a/server/src/routes/expenseTypes.js b/server/src/routes/expenseTypes.js index e1c7fc16..c3158046 100644 --- a/server/src/routes/expenseTypes.js +++ b/server/src/routes/expenseTypes.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); @@ -24,12 +26,7 @@ const UPDATE_BODY_SCHEMA = CREATE_BODY_SCHEMA.partial(); function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("expense_type.query_invalid", parsed.error.flatten(), 400); } const enabledRaw = parsed.data.enabled; @@ -45,15 +42,10 @@ function parseListQuery(query) { return { enabled }; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten(), 400); } return parsed.data; } @@ -61,12 +53,7 @@ function parseBody(schema, body) { function parseExpenseTypeId(params) { const parsed = z.string().uuid().safeParse(params?.id); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { id: "invalid_uuid" }, - }); + throw buildBusinessValidationError("expense_type.id_invalid", { id: "invalid_uuid" }, 400); } return parsed.data; } @@ -131,7 +118,7 @@ async function expenseTypesRoutes(fastify) { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { - const body = parseBody(CREATE_BODY_SCHEMA, request.body); + const body = parseBody(CREATE_BODY_SCHEMA, request.body, "expense_type.create_input_invalid"); const now = new Date(); let created = null; @@ -173,12 +160,7 @@ async function expenseTypesRoutes(fastify) { }); } catch (error) { if (error?.code === "P2002") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { code: "duplicate" }, - }); + throw buildBusinessValidationError("expense_type.code_duplicate", { code: "duplicate" }, 409); } throw error; } @@ -194,15 +176,10 @@ async function expenseTypesRoutes(fastify) { }, async (request, reply) => { const expenseTypeId = parseExpenseTypeId(request.params); - const body = parseBody(UPDATE_BODY_SCHEMA, request.body); + const body = parseBody(UPDATE_BODY_SCHEMA, request.body, "expense_type.update_input_invalid"); if (!Object.keys(body).length) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { body: "empty" }, - }); + throw buildBusinessValidationError("expense_type.update_empty", { body: "empty" }, 400); } const now = new Date(); @@ -288,12 +265,7 @@ async function expenseTypesRoutes(fastify) { }); } catch (error) { if (error?.code === "P2002") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { code: "duplicate" }, - }); + throw buildBusinessValidationError("expense_type.code_duplicate", { code: "duplicate" }, 409); } throw error; } diff --git a/server/src/routes/exports.js b/server/src/routes/exports.js index 4c63f2b1..c5a60784 100644 --- a/server/src/routes/exports.js +++ b/server/src/routes/exports.js @@ -1,3 +1,6 @@ +const { getErrorReason } = require("../constants/errorReasons"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { isValidDateOnlyString } = require("../utils/date"); const { Readable } = require("node:stream"); const ExcelJS = require("exceljs"); @@ -30,7 +33,6 @@ const { } = require("../services/financeLedger"); const { HISTORY_STATUS_VALUES, - buildPresenceValidationError, buildTeamPresenceHistory, resolvePresenceHistoryRange, } = require("../services/presenceHistory"); @@ -76,8 +78,8 @@ const AUDIT_EXPORT_QUERY_SCHEMA = z.object({ const DASHBOARD_EXPORT_QUERY_SCHEMA = z.object({ format: EXPORT_FORMAT_SCHEMA.optional(), - dateFrom: DATE_ONLY_SCHEMA.optional(), - dateTo: DATE_ONLY_SCHEMA.optional(), + dateFrom: DATE_ONLY_SCHEMA.refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), + dateTo: DATE_ONLY_SCHEMA.refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), departmentId: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(20000).optional(), }); @@ -89,8 +91,8 @@ const EMPLOYEE_EXPORT_QUERY_SCHEMA = z.object({ q: z.string().max(100).optional(), title: z.string().max(100).optional(), managerId: z.string().uuid().optional(), - hireDateFrom: DATE_ONLY_SCHEMA.optional(), - hireDateTo: DATE_ONLY_SCHEMA.optional(), + hireDateFrom: DATE_ONLY_SCHEMA.refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), + hireDateTo: DATE_ONLY_SCHEMA.refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), employmentStatus: z.enum(EMPLOYMENT_STATUS_VALUES).optional(), limit: z.coerce.number().int().min(1).max(20000).optional(), }); @@ -102,8 +104,8 @@ const PAYSLIP_EXPORT_QUERY_SCHEMA = z.object({ const PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA = z.object({ format: EXPORT_FORMAT_SCHEMA.optional(), - dateFrom: DATE_ONLY_SCHEMA.optional(), - dateTo: DATE_ONLY_SCHEMA.optional(), + dateFrom: DATE_ONLY_SCHEMA.refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), + dateTo: DATE_ONLY_SCHEMA.refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), departmentId: z.string().uuid().optional(), employeeId: z.string().uuid().optional(), status: z.enum(HISTORY_STATUS_VALUES).optional(), @@ -115,14 +117,14 @@ const CONTRACT_REGISTRY_EXPORT_BODY_SCHEMA = CONTRACT_REGISTRY_FILTER_INPUT_SCHE const AP_OPERATIONS_EXPORT_BODY_SCHEMA = z.object({ format: EXPORT_FORMAT_SCHEMA.optional(), pendingEntityType: z.enum(["reimbursement", "travel_order"]).optional(), - pendingCreatedAtFrom: DATE_ONLY_SCHEMA.optional(), - pendingCreatedAtTo: DATE_ONLY_SCHEMA.optional(), + pendingCreatedAtFrom: DATE_ONLY_SCHEMA.refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), + pendingCreatedAtTo: DATE_ONLY_SCHEMA.refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), riskBucket: z.enum(["all", "exception", "budget_overspend"]).optional(), ledgerStatus: z.enum(REIMBURSEMENT_LEDGER_STATUS_VALUES).optional(), ledgerPaymentStatus: z.enum(REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES).optional(), ledgerInvoiceStatus: z.enum(REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES).optional(), - ledgerDateFrom: DATE_ONLY_SCHEMA.optional(), - ledgerDateTo: DATE_ONLY_SCHEMA.optional(), + ledgerDateFrom: DATE_ONLY_SCHEMA.refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), + ledgerDateTo: DATE_ONLY_SCHEMA.refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), ledgerSearch: z.string().trim().max(100).optional(), }).strict(); @@ -144,29 +146,19 @@ function csvRow(values) { return `${values.map(csvCell).join(",")}\n`; } -function parseQuery(schema, query) { +function parseQuery(schema, query, reasonCode) { const parsed = schema.safeParse(query ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten(), 400); } return parsed.data; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten(), 400); } return parsed.data; @@ -194,12 +186,7 @@ function toDateRange(query) { const dateFrom = query.dateFrom ? new Date(`${query.dateFrom}T00:00:00.000Z`) : null; const dateTo = query.dateTo ? new Date(`${query.dateTo}T23:59:59.999Z`) : null; if (dateFrom && dateTo && dateFrom > dateTo) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { dateTo: "must_be_after_dateFrom" }, - }); + throw buildBusinessValidationError("export.dashboard_date_range_invalid", { dateTo: "must_be_after_dateFrom" }, 400); } return { dateFrom, dateTo }; } @@ -328,12 +315,7 @@ async function ensureApOperationsExportEnabled(fastify) { }); if (!gate.enabled) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, - }); + throw buildBusinessValidationError("export.payment_batch_disabled", { featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, 409); } } @@ -347,12 +329,7 @@ async function ensureContractsRegistryExportEnabled(fastify, request) { }); if (!gate.enabled) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason }, - }); + throw buildBusinessValidationError("export.project_linking_disabled", { featureFlag: "project_linking_v1_disabled", reason: gate.reason }, 409); } } @@ -439,12 +416,7 @@ function toDateOnlyRange(dateFrom, dateTo, fieldNames) { const from = dateFrom ? new Date(`${dateFrom}T00:00:00.000Z`) : null; const to = dateTo ? new Date(`${dateTo}T23:59:59.999Z`) : null; if (from && to && from > to) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { [fieldNames.to]: "must_be_after_or_equal_to_from" }, - }); + throw buildBusinessValidationError("export.ap_pending_date_range_invalid", { [fieldNames.to]: "must_be_after_or_equal_to_from" }, 400); } return { from, to }; } @@ -1564,7 +1536,7 @@ async function exportsRoutes(fastify) { }, async (request, reply) => { await ensureContractsRegistryExportEnabled(fastify, request); - const parsedBody = parseBody(CONTRACT_REGISTRY_EXPORT_BODY_SCHEMA, request.body); + const parsedBody = parseBody(CONTRACT_REGISTRY_EXPORT_BODY_SCHEMA, request.body, "contract_registry.query_invalid"); const format = parsedBody.format ?? "csv"; const filters = parseContractRegistryFilters({ projectId: parsedBody.projectId, @@ -1623,7 +1595,7 @@ async function exportsRoutes(fastify) { await ensureApOperationsExportEnabled(fastify); ensureHasAnyPermission(fastify, request, AP_OPERATIONS_PERMISSION_CODES); - const parsedBody = parseBody(AP_OPERATIONS_EXPORT_BODY_SCHEMA, request.body); + const parsedBody = parseBody(AP_OPERATIONS_EXPORT_BODY_SCHEMA, request.body, "export.ap_input_invalid"); const format = parsedBody.format ?? "csv"; const filters = { pendingEntityType: parsedBody.pendingEntityType, @@ -1683,7 +1655,7 @@ async function exportsRoutes(fastify) { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { - const query = parseQuery(EMPLOYEE_EXPORT_QUERY_SCHEMA, request.query); + const query = parseQuery(EMPLOYEE_EXPORT_QUERY_SCHEMA, request.query, "employee_directory.query_invalid"); const filters = parseEmployeeDirectoryFilters(query, { allowDepartmentFilter: true }); const where = buildEmployeeDirectoryWhere(filters); const format = resolveExportFormat(query); @@ -1730,15 +1702,10 @@ async function exportsRoutes(fastify) { preHandler: fastify.requirePermission("payroll.manage"), }, async (request, reply) => { - const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query); + const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query, "payroll.export_query_invalid"); const month = parsePayMonth(query.month); if (!month) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { month: "invalid_month" }, - }); + throw buildBusinessValidationError("payroll.month_invalid", { month: "invalid_month" }); } const dataset = await loadMonthlyPayslips(fastify.prisma, month.startDate); @@ -1775,7 +1742,7 @@ async function exportsRoutes(fastify) { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { - const query = parseQuery(PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA, request.query); + const query = parseQuery(PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA, request.query, "export.presence_query_invalid"); const format = resolveExportFormat(query); const range = resolvePresenceHistoryRange(query, fastify.config.appTimezone); const history = await buildTeamPresenceHistory(fastify.prisma, { @@ -1786,7 +1753,7 @@ async function exportsRoutes(fastify) { }); if (history.items.length > PRESENCE_HISTORY_EXPORT_LIMIT) { - throw buildPresenceValidationError({ export: "too_large" }, 409); + throw buildBusinessValidationError("export.presence_too_large", { export: "too_large" }, 409); } await fastify.prisma.auditLog.create({ @@ -1825,7 +1792,7 @@ async function exportsRoutes(fastify) { }, async (request, reply) => { ensureHasAnyPermission(fastify, request, ["expense.view_own", "expense.view_all"]); - const query = parseQuery(REIMBURSEMENT_EXPORT_QUERY_SCHEMA, request.query); + const query = parseQuery(REIMBURSEMENT_EXPORT_QUERY_SCHEMA, request.query, "export.reimbursement_query_invalid"); const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); const scope = query.scope ?? (canViewAll ? "all" : "own"); @@ -1882,7 +1849,7 @@ async function exportsRoutes(fastify) { }, async (request, reply) => { ensureHasAnyPermission(fastify, request, ["invoice.view_own", "invoice.manage"]); - const query = parseQuery(INVOICE_EXPORT_QUERY_SCHEMA, request.query); + const query = parseQuery(INVOICE_EXPORT_QUERY_SCHEMA, request.query, "export.invoice_query_invalid"); const canManage = fastify.hasPermission(request.employee, "invoice.manage"); const ownerScope = query.ownerScope ?? (canManage ? "all" : "own"); @@ -1952,17 +1919,12 @@ async function exportsRoutes(fastify) { preHandler: fastify.requirePermission("system.audit.view"), }, async (request, reply) => { - const query = parseQuery(AUDIT_EXPORT_QUERY_SCHEMA, request.query); + const query = parseQuery(AUDIT_EXPORT_QUERY_SCHEMA, request.query, "export.audit_query_invalid"); const dateFrom = query.dateFrom ? new Date(query.dateFrom) : null; const dateTo = query.dateTo ? new Date(query.dateTo) : null; if (dateFrom && dateTo && dateFrom > dateTo) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { dateTo: "must_be_after_dateFrom" }, - }); + throw buildBusinessValidationError("audit.date_range_invalid", { dateTo: "must_be_after_dateFrom" }, 400); } const where = { @@ -2020,7 +1982,7 @@ async function exportsRoutes(fastify) { preHandler: fastify.requirePermission("report.dashboard.view"), }, async (request, reply) => { - const query = parseQuery(DASHBOARD_EXPORT_QUERY_SCHEMA, request.query); + const query = parseQuery(DASHBOARD_EXPORT_QUERY_SCHEMA, request.query, "export.dashboard_query_invalid"); const where = buildDashboardWhere(request, query); const format = resolveExportFormat(query); diff --git a/server/src/routes/featureFlags.js b/server/src/routes/featureFlags.js index 6a0cf2ba..a2c73ed2 100644 --- a/server/src/routes/featureFlags.js +++ b/server/src/routes/featureFlags.js @@ -1,6 +1,7 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const { @@ -131,7 +132,6 @@ const BACKUP_NAVIGATION_KEYS = new Set([ "invoicePool", "inventoryChecks", "cutover", - "erpIntegrations", "approvalRules", "userManagement", "departments", @@ -148,28 +148,6 @@ const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({ routes: ["/api/ai/suggestions"], }, ], - erp_sync: [ - { - label: "ERP 集成控制台", - routes: [ - "/api/integrations/erp/master-data/status", - "/api/integrations/erp/master-data/sync", - "/api/integrations/outbox", - "/api/integrations/bindings", - ], - }, - { - label: "业务 ERP 同步入口", - routes: [ - "/api/integrations/erp/journals/reimbursements/:reimbursementId/preview", - "/api/integrations/erp/journals/reimbursements/:reimbursementId/export", - "/api/integrations/erp/procurement/purchase-requests/:purchaseRequestId/track", - "/api/integrations/erp/procurement/purchase-requests/:purchaseRequestId/requeue", - "/api/integrations/erp/assets/:assetId/track", - "/api/integrations/erp/assets/:assetId/export", - ], - }, - ], invoice_verification: [ { label: "票据验真模块", @@ -342,33 +320,19 @@ const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({ public_security_hardening_v1: [], }); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function buildVersionConflictError(flagKey, expectedVersion, currentVersion) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { + return buildBusinessValidationError("feature_flag.version_conflict", { featureFlag: "version_conflict", flagKey, expectedVersion, currentVersion, - }, - }); + }, 409); } function parseKey(params) { const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("feature_flag.key_invalid", parsed.error.flatten()); } return parsed.data.key; @@ -377,7 +341,7 @@ function parseKey(params) { function parsePageQuery(query) { const parsed = PAGE_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("feature_flag.page_invalid", parsed.error.flatten()); } return { @@ -389,11 +353,11 @@ function parsePageQuery(query) { function parseUpdateBody(body) { const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("feature_flag.update_input_invalid", parsed.error.flatten()); } if (parsed.data.rolloutType !== "department_allowlist" && parsed.data.departmentIds.length > 0) { - throw buildValidationError({ departmentIds: "rollout_type_global_disallows_department_ids" }); + throw buildBusinessValidationError("feature_flag.global_departments_invalid", { departmentIds: "rollout_type_global_disallows_department_ids" }); } return parsed.data; @@ -402,7 +366,7 @@ function parseUpdateBody(body) { function parseBatchBody(body) { const parsed = BATCH_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("feature_flag.batch_input_invalid", parsed.error.flatten()); } return parsed.data; } @@ -752,16 +716,11 @@ async function featureFlagsRoutes(fastify) { } if (result.status === "version_conflict") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { + throw buildBusinessValidationError("feature_flag.version_conflict", { featureFlag: "version_conflict", expectedVersion: body.expectedVersion, currentVersion: result.current.version, - }, - }); + }, 409); } if (result.status !== "updated" || !result.updated) { diff --git a/server/src/routes/feeControl.js b/server/src/routes/feeControl.js index 617846e8..802b36fe 100644 --- a/server/src/routes/feeControl.js +++ b/server/src/routes/feeControl.js @@ -1,6 +1,7 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { getPrecheckPermissionCode, @@ -12,19 +13,10 @@ const PRECHECK_BODY_SCHEMA = z.object({ entityId: z.string().uuid(), }).strict(); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseBody(body) { const parsed = PRECHECK_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("fee_control.input_invalid", parsed.error.flatten()); } return parsed.data; } diff --git a/server/src/routes/integrations.js b/server/src/routes/integrations.js index 41234639..ca2ea358 100644 --- a/server/src/routes/integrations.js +++ b/server/src/routes/integrations.js @@ -1,63 +1,16 @@ const { z } = require('zod'); const { AppError } = require('../utils/AppError'); +const { buildBusinessValidationError } = require('../utils/businessValidationError'); const { sendData } = require('../utils/reply'); const { buildAuditLogData } = require('../utils/audit'); const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema, } = require('../utils/openapi'); -const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require('../services/featureFlags'); -const { runIntegrationOutboxOnce } = require('../jobs/integrationOutboxJob'); -const { - ERP_MASTER_DATA_RESOURCES, - listMasterDataSyncStates, - normalizeRequestedResources, - syncErpMasterData, -} = require('../services/integrations/erpMasterData'); const { OUTBOX_STATUSES } = require('../services/integrations/outbox'); -const { - buildReimbursementJournalPreview, - enqueueReimbursementJournalExport, -} = require('../services/integrations/erpJournal'); -const { - PROCUREMENT_ERP_TARGETS, - buildPurchaseRequestErpTrack, - enqueuePurchaseAccrualJournalExport, - enqueuePurchaseArchiveSummaryExport, - enqueuePurchaseCommitmentJournalExport, - loadPurchaseRequestErpContext, -} = require('../services/integrations/erpProcurement'); -const { - ASSET_ERP_EVENT_TYPE, - ASSET_ERP_TARGET, - buildAssetErpTrack, - buildAssetLedgerSummaryDedupeKey, - enqueueAssetLedgerSummaryExport, - loadAssetErpContext, - resolveAssetLedgerSummaryDrift, -} = require('../services/integrations/erpAsset'); -const { - TRAVEL_ERP_EVENT_TYPE, - buildTravelReconciliationErpTrack, - buildTravelReconciliationSummaryDedupeKey, - enqueueTravelReconciliationSummaryExport, - loadTravelReconciliationErpContext, -} = require('../services/integrations/erpTravel'); const UUID_SCHEMA = z.string().uuid(); -const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']); -const MASTER_DATA_SYNC_BODY_SCHEMA = z.object({ - resources: z.array(MASTER_DATA_RESOURCE_ENUM).max(3).optional(), -}); -const PROCUREMENT_REQUEUE_TARGET_ENUM = z.enum([ - 'commitment_journal', - 'accrual_journal', - 'archive_summary', -]); -const PROCUREMENT_REQUEUE_BODY_SCHEMA = z.object({ - targets: z.array(PROCUREMENT_REQUEUE_TARGET_ENUM).min(1).max(3), -}); const OUTBOX_STATUS_ENUM = z.enum([ OUTBOX_STATUSES.pending, OUTBOX_STATUSES.processing, @@ -208,86 +161,10 @@ const BINDINGS_LIST_RESPONSE_SCHEMA = { }, }; -const ERP_SYNC_RESPONSE_SCHEMA = { - type: 'object', - required: ['outboxId', 'status', 'duplicated'], - properties: { - outboxId: { type: 'string', format: 'uuid' }, - status: { type: 'string' }, - duplicated: { type: 'boolean' }, - }, -}; - -const ERP_JOURNAL_EXPORT_RESPONSE_SCHEMA = { - type: 'object', - required: ['outboxId', 'status', 'duplicated', 'requeued', 'eventType'], - properties: { - outboxId: { type: 'string', format: 'uuid' }, - status: { type: 'string' }, - duplicated: { type: 'boolean' }, - requeued: { type: 'boolean' }, - eventType: { type: 'string' }, - }, -}; - -const ERP_MASTER_DATA_STATUS_SCHEMA = { - type: 'object', - required: ['items', 'gate'], - properties: { - gate: { - type: 'object', - required: ['envEnabled', 'effectiveEnabled', 'effectiveReason', 'provider'], - properties: { - envEnabled: { type: 'boolean' }, - effectiveEnabled: { type: 'boolean' }, - effectiveReason: { type: 'string' }, - provider: { type: 'string' }, - masterDataEndpointConfigured: { type: 'boolean' }, - journalEndpointConfigured: { type: 'boolean' }, - masterDataSyncCron: { anyOf: [{ type: 'string' }, { type: 'null' }] }, - }, - }, - items: { - type: 'array', - items: { - type: 'object', - required: ['resourceType', 'status'], - properties: { - resourceType: { type: 'string' }, - status: { type: 'string' }, - lastStartedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, - lastFinishedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, - lastSuccessAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, - lastError: { anyOf: [{ type: 'string' }, { type: 'null' }] }, - lastResult: { anyOf: [{ type: 'object' }, { type: 'null' }] }, - }, - }, - }, - }, -}; - -function parseRunNow(query) { - return ( - query?.runNow === '1' || - query?.runNow === 1 || - query?.runNow === true || - query?.runNow === 'true' - ); -} - -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode, - details, - }); -} - function parseOutboxListQuery(query) { const parsed = OUTBOX_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("integration.outbox_query_invalid", parsed.error.flatten()); } return { @@ -301,7 +178,7 @@ function parseOutboxListQuery(query) { function parseBindingsListQuery(query) { const parsed = BINDINGS_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("integration.bindings_query_invalid", parsed.error.flatten()); } return { @@ -315,7 +192,7 @@ function parseBindingsListQuery(query) { function parseUuidParam(value, fieldName) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ [fieldName]: 'invalid_uuid' }); + throw buildBusinessValidationError("integration.outbox_id_invalid", { [fieldName]: 'invalid_uuid' }); } return parsed.data; @@ -464,445 +341,6 @@ async function buildOutboxStats(prisma) { }; } -async function resolveErpGate(prisma, config) { - const evaluation = await evaluateFeatureFlag({ - prisma, - config, - flagKey: FEATURE_FLAG_KEYS.erpSync, - departmentId: null, - strictDepartment: false, - }); - - const envEnabled = Boolean(config.erpSyncEnabled); - const featureFlagEnabled = Boolean(evaluation.enabled); - - return { - envEnabled, - featureFlagEnabled, - effectiveEnabled: envEnabled && featureFlagEnabled, - effectiveReason: envEnabled ? evaluation.reason : 'env_disabled', - provider: config.erpSyncProvider, - masterDataEndpointConfigured: Boolean(String(config.erpMasterDataEndpoint ?? '').trim()), - journalEndpointConfigured: Boolean( - String(config.erpJournalExportEndpoint ?? config.erpSyncEndpoint ?? '').trim(), - ), - masterDataSyncCron: String(config.erpMasterDataSyncCron ?? '').trim() || null, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - }; -} - -async function requireErpManagementGate(prisma, config) { - const gate = await resolveErpGate(prisma, config); - if (gate.effectiveEnabled) { - return gate; - } - - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 409, - details: { - integration: gate.envEnabled ? 'feature_flag_blocked' : 'erp_sync_disabled', - featureFlag: gate.featureFlag, - reason: gate.effectiveReason, - }, - }); -} - -async function fetchReimbursementForErp(prisma, reimbursementId) { - const reimbursement = await prisma.reimbursementForm.findUnique({ - where: { id: reimbursementId }, - select: { - id: true, - form_number: true, - owner_id: true, - submitter_id: true, - department_id: true, - status: true, - total_amount: true, - payment_voucher_no: true, - payment_date: true, - paid_at: true, - owner: { - select: { - legal_entity: { - select: { - code: true, - }, - }, - }, - }, - submitter: { - select: { - legal_entity: { - select: { - code: true, - }, - }, - }, - }, - }, - }); - - if (!reimbursement) { - throw new AppError({ - code: 'SYS_404', - message: 'Not Found', - statusCode: 404, - }); - } - - return reimbursement; -} - -function ensurePaidReimbursement(reimbursement) { - if (reimbursement.status !== 'paid') { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 409, - details: { reimbursement: 'erp_sync_requires_paid' }, - }); - } -} - -function canReadPurchaseRequest(request, purchaseRequest) { - if (!purchaseRequest) { - return false; - } - - const permissionCodes = request?.employee?.permissionCodes ?? []; - if (permissionCodes.includes('*') || permissionCodes.includes('procurement.view_all') || permissionCodes.includes('procurement.manage')) { - return true; - } - - return ( - purchaseRequest.applicant_id === request.employee.id || - purchaseRequest.submitter_id === request.employee.id - ); -} - -function normalizeProcurementTargets(body) { - const parsed = PROCUREMENT_REQUEUE_BODY_SCHEMA.safeParse(body ?? {}); - if (!parsed.success) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 400, - details: parsed.error.flatten(), - }); - } - - const orderedTargets = [ - PROCUREMENT_ERP_TARGETS.commitmentJournal, - PROCUREMENT_ERP_TARGETS.accrualJournal, - PROCUREMENT_ERP_TARGETS.archiveSummary, - ]; - const requested = new Set(parsed.data.targets); - return orderedTargets.filter((target) => requested.has(target)); -} - -async function fetchReadablePurchaseRequestContext(prisma, purchaseRequestId, request) { - const context = await loadPurchaseRequestErpContext(prisma, purchaseRequestId); - if (!context?.purchaseRequest) { - throw new AppError({ - code: 'SYS_404', - message: 'Not Found', - statusCode: 404, - }); - } - - if (!canReadPurchaseRequest(request, context.purchaseRequest)) { - throw new AppError({ - code: 'PERM_001', - message: '无权限', - statusCode: 403, - }); - } - - return context; -} - -function canReadAsset(request, asset) { - if (!asset) { - return false; - } - - const permissionCodes = request?.employee?.permissionCodes ?? []; - if ( - permissionCodes.includes('*') || - permissionCodes.includes('asset.view_all') || - permissionCodes.includes('asset.manage') || - permissionCodes.includes('asset.transfer') || - permissionCodes.includes('asset.inventory') - ) { - return true; - } - - return ( - permissionCodes.includes('asset.view_own') && - asset.assignee_employee_id === request?.employee?.id - ); -} - -async function fetchReadableAssetContext(prisma, assetId, request) { - const context = await loadAssetErpContext(prisma, assetId); - if (!context?.asset) { - throw new AppError({ - code: 'SYS_404', - message: 'Not Found', - statusCode: 404, - }); - } - - if (!canReadAsset(request, context.asset)) { - throw new AppError({ - code: 'PERM_001', - message: '无权限', - statusCode: 403, - }); - } - - return context; -} - -function canReadTravelReconciliationBatch(request) { - const permissionCodes = request?.employee?.permissionCodes ?? []; - return ( - permissionCodes.includes('*') || - permissionCodes.includes('travel.payment.manage') || - permissionCodes.includes('integration.manage') - ); -} - -async function fetchReadableTravelReconciliationContext(prisma, batchId, request) { - const context = await loadTravelReconciliationErpContext(prisma, batchId); - if (!context) { - throw new AppError({ - code: 'SYS_404', - message: 'Not Found', - statusCode: 404, - }); - } - - if (!canReadTravelReconciliationBatch(request)) { - throw new AppError({ - code: 'PERM_001', - message: '无权限', - statusCode: 403, - }); - } - - return context; -} - -async function findTravelReconciliationOutboxByDedupeKey(prisma, batchId) { - return prisma.integrationOutbox.findUnique({ - where: { - event_type_dedupe_key: { - event_type: TRAVEL_ERP_EVENT_TYPE, - dedupe_key: buildTravelReconciliationSummaryDedupeKey(batchId), - }, - }, - select: { - id: true, - status: true, - payload: true, - }, - }); -} - -async function enqueueTravelReconciliationExportAndMaybeRun({ - prisma, - config, - logger, - context, - actorId, - runNow, - findExistingOutbox = findTravelReconciliationOutboxByDedupeKey, - enqueueExport = enqueueTravelReconciliationSummaryExport, - runOutboxOnce = runIntegrationOutboxOnce, -}) { - const existingOutbox = await findExistingOutbox(prisma, context.id); - const enqueued = await enqueueExport(prisma, config, { - batch: context, - payload: existingOutbox?.payload, - actorId, - requeueOnFailure: true, - }); - - if (runNow && (!enqueued.duplicated || enqueued.requeued)) { - await runOutboxOnce(prisma, config, logger); - } - - return enqueued; -} - -async function findAssetOutboxByDedupeKey(prisma, assetId) { - return prisma.integrationOutbox.findUnique({ - where: { - event_type_dedupe_key: { - event_type: ASSET_ERP_EVENT_TYPE, - dedupe_key: buildAssetLedgerSummaryDedupeKey(assetId), - }, - }, - select: { - id: true, - status: true, - payload: true, - }, - }); -} - -async function enqueueAssetExportAndMaybeRun({ - prisma, - config, - logger, - context, - actorId, - runNow, - findExistingOutbox = findAssetOutboxByDedupeKey, - enqueueExport = enqueueAssetLedgerSummaryExport, - runOutboxOnce = runIntegrationOutboxOnce, -}) { - const asset = context?.asset; - const existingOutbox = await findExistingOutbox(prisma, asset.id); - const drift = resolveAssetLedgerSummaryDrift(asset, existingOutbox?.payload ?? null); - - const enqueued = await enqueueExport(prisma, config, { - asset, - payload: existingOutbox?.payload && !drift ? existingOutbox.payload : undefined, - sourceEventName: drift?.sourceEventName, - changeType: drift?.changeType, - changedFields: drift?.changedFields, - occurredAt: new Date(), - actorId, - requeueOnFailure: true, - }); - - if (runNow && (!enqueued.duplicated || enqueued.requeued)) { - await runOutboxOnce(prisma, config, logger); - } - - return enqueued; -} - -function ensureProcurementTargetEligible(target, context) { - const purchaseRequest = context?.purchaseRequest; - const archive = purchaseRequest?.order_archive ?? null; - - if (target === PROCUREMENT_ERP_TARGETS.commitmentJournal) { - if (purchaseRequest?.status !== 'approved') { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 409, - details: { purchaseRequest: 'purchase_request_not_approved', target }, - }); - } - return; - } - - if (!archive) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 409, - details: { purchaseOrderArchive: 'purchase_order_archive_missing', target }, - }); - } -} - -async function enqueueProcurementTargetsAndMaybeRun({ - prisma, - config, - logger, - context, - actorId, - targets, - runNow, -}) { - const purchaseRequest = context.purchaseRequest; - const archive = purchaseRequest.order_archive ?? null; - const items = []; - - for (const target of targets) { - ensureProcurementTargetEligible(target, context); - - if (target === PROCUREMENT_ERP_TARGETS.commitmentJournal) { - const enqueued = await enqueuePurchaseCommitmentJournalExport(prisma, config, { - purchaseRequest, - occurredAt: context.approvalOccurredAt ?? new Date(), - actorId, - requeueOnFailure: true, - }); - items.push({ target, ...enqueued }); - continue; - } - - if (target === PROCUREMENT_ERP_TARGETS.accrualJournal) { - const enqueued = await enqueuePurchaseAccrualJournalExport(prisma, config, { - purchaseRequest, - archive, - occurredAt: archive.archived_at ?? new Date(), - actorId, - requeueOnFailure: true, - }); - items.push({ target, ...enqueued }); - continue; - } - - const enqueued = await enqueuePurchaseArchiveSummaryExport(prisma, config, { - purchaseRequest, - archive, - archivedAt: archive.archived_at ?? new Date(), - actorId, - requeueOnFailure: true, - }); - items.push({ target, ...enqueued }); - } - - if (runNow && items.some((item) => !item.duplicated || item.requeued)) { - await runIntegrationOutboxOnce(prisma, config, logger); - } - - return items; -} - -async function enqueueReimbursementExportAndMaybeRun({ - prisma, - config, - logger, - reimbursement, - actorId, - runNow, -}) { - const enqueued = await enqueueReimbursementJournalExport(prisma, config, { - reimbursement, - actorId, - }); - - if (runNow) { - await runIntegrationOutboxOnce(prisma, config, logger); - } - - return enqueued; -} - -function normalizeResourcesInput(value) { - const parsed = MASTER_DATA_SYNC_BODY_SCHEMA.safeParse(value ?? {}); - if (!parsed.success) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 400, - details: parsed.error.flatten(), - }); - } - - return normalizeRequestedResources(parsed.data.resources ?? []); -} - async function integrationsRoutes(fastify) { fastify.get( '/integrations/outbox/stats', @@ -910,10 +348,10 @@ async function integrationsRoutes(fastify) { preHandler: fastify.requirePermission('integration.manage'), schema: { tags: ['integrations'], - summary: '查询 ERP Outbox 队列状态统计', + summary: '查询 集成 Outbox 队列状态统计', security: [{ bearerAuth: [] }], response: { - 200: buildDataEnvelopeSchema(OUTBOX_STATS_RESPONSE_SCHEMA, 'ERP Outbox 状态统计'), + 200: buildDataEnvelopeSchema(OUTBOX_STATS_RESPONSE_SCHEMA, '集成 Outbox 状态统计'), }, }, }, @@ -929,10 +367,10 @@ async function integrationsRoutes(fastify) { preHandler: fastify.requirePermission('integration.manage'), schema: { tags: ['integrations'], - summary: '分页查询 ERP Outbox 事件', + summary: '分页查询 集成 Outbox 事件', security: [{ bearerAuth: [] }], response: { - 200: buildDataEnvelopeSchema(OUTBOX_LIST_RESPONSE_SCHEMA, 'ERP Outbox 列表'), + 200: buildDataEnvelopeSchema(OUTBOX_LIST_RESPONSE_SCHEMA, '集成 Outbox 列表'), 400: buildErrorEnvelopeSchema({ description: '参数校验失败', codeExample: 'VALID_001', @@ -978,10 +416,10 @@ async function integrationsRoutes(fastify) { preHandler: fastify.requirePermission('integration.manage'), schema: { tags: ['integrations'], - summary: '查询单条 ERP Outbox 事件详情', + summary: '查询单条 集成 Outbox 事件详情', security: [{ bearerAuth: [] }], response: { - 200: buildDataEnvelopeSchema(OUTBOX_DETAIL_SCHEMA, 'ERP Outbox 详情'), + 200: buildDataEnvelopeSchema(OUTBOX_DETAIL_SCHEMA, '集成 Outbox 详情'), 400: buildErrorEnvelopeSchema({ description: '参数校验失败', codeExample: 'VALID_001', @@ -1021,10 +459,10 @@ async function integrationsRoutes(fastify) { preHandler: fastify.requirePermission('integration.manage'), schema: { tags: ['integrations'], - summary: '重试 failed/dead_letter 的 ERP Outbox 事件', + summary: '重试 failed/dead_letter 的 集成 Outbox 事件', security: [{ bearerAuth: [] }], response: { - 200: buildDataEnvelopeSchema(OUTBOX_DETAIL_SCHEMA, 'ERP Outbox 已重置为 pending'), + 200: buildDataEnvelopeSchema(OUTBOX_DETAIL_SCHEMA, '集成 Outbox 已重置为 pending'), 400: buildErrorEnvelopeSchema({ description: '参数校验失败', codeExample: 'VALID_001', @@ -1057,14 +495,15 @@ async function integrationsRoutes(fastify) { }); } + if (String(existed.event_type).startsWith("erp.")) { + throw buildBusinessValidationError("integration.erp_retired", {}, 409); + } + if (![OUTBOX_STATUSES.failed, OUTBOX_STATUSES.dead_letter].includes(existed.status)) { - throw buildValidationError( - { + throw buildBusinessValidationError("integration.retry_state_invalid", { outbox: 'integration_outbox_retry_status_invalid', status: existed.status, - }, - 409, - ); + }, 409); } const retried = await fastify.prisma.integrationOutbox.update({ @@ -1083,7 +522,7 @@ async function integrationsRoutes(fastify) { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, - action: 'ERP_OUTBOX_RETRIED', + action: 'INTEGRATION_OUTBOX_RETRIED', targetType: 'integration_outbox', targetId: retried.id, extra: { @@ -1107,10 +546,10 @@ async function integrationsRoutes(fastify) { preHandler: fastify.requirePermission('integration.manage'), schema: { tags: ['integrations'], - summary: '分页查询 ERP IntegrationBinding', + summary: '分页查询 集成关联', security: [{ bearerAuth: [] }], response: { - 200: buildDataEnvelopeSchema(BINDINGS_LIST_RESPONSE_SCHEMA, 'ERP Binding 列表'), + 200: buildDataEnvelopeSchema(BINDINGS_LIST_RESPONSE_SCHEMA, '集成关联 列表'), 400: buildErrorEnvelopeSchema({ description: '参数校验失败', codeExample: 'VALID_001', @@ -1150,755 +589,6 @@ async function integrationsRoutes(fastify) { }); }, ); - - fastify.get( - '/integrations/erp/master-data/status', - { - preHandler: fastify.requirePermission('integration.manage'), - schema: { - tags: ['integrations'], - summary: '查询 ERP 主数据同步状态', - security: [{ bearerAuth: [] }], - response: { - 200: buildDataEnvelopeSchema(ERP_MASTER_DATA_STATUS_SCHEMA, 'ERP 主数据同步状态'), - }, - }, - }, - async (request, reply) => { - const [items, gate] = await Promise.all([ - listMasterDataSyncStates(fastify.prisma), - resolveErpGate(fastify.prisma, fastify.config), - ]); - return sendData(reply, request, { gate, items }); - }, - ); - - fastify.post( - '/integrations/erp/master-data/sync', - { - preHandler: fastify.requirePermission('integration.manage'), - schema: { - tags: ['integrations'], - summary: '手动触发 ERP 主数据同步', - security: [{ bearerAuth: [] }], - response: { - 200: buildDataEnvelopeSchema(ERP_MASTER_DATA_STATUS_SCHEMA, 'ERP 主数据同步结果'), - 400: buildErrorEnvelopeSchema({ - description: '参数校验失败', - codeExample: 'VALID_001', - messageExample: '参数校验失败', - }), - 409: buildErrorEnvelopeSchema({ - description: 'ERP 总闸关闭或同步失败', - codeExample: 'VALID_001', - messageExample: '参数校验失败', - }), - }, - }, - }, - async (request, reply) => { - const resources = normalizeResourcesInput(request.body); - const gate = await requireErpManagementGate(fastify.prisma, fastify.config); - const result = await syncErpMasterData(fastify.prisma, fastify.config, { - resources, - actorId: request.employee.id, - logger: fastify.log, - }); - - await fastify.prisma.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: 'ERP_MASTER_DATA_SYNC_TRIGGERED', - targetType: 'integration_master_data', - extra: { - resources, - gateReason: gate.effectiveReason, - provider: gate.provider, - ok: result.ok, - errorCode: result.error?.code ?? null, - }, - }), - }); - - if (!result.ok) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 409, - details: { - integration: result.error?.code ?? 'erp_master_data_sync_failed', - reason: result.error?.message ?? 'erp_master_data_sync_failed', - }, - }); - } - - return sendData(reply, request, { - gate, - items: result.items.filter((item) => resources.includes(item.resourceType)), - triggerMode: 'manual', - }); - }, - ); - - fastify.get( - '/integrations/erp/journals/reimbursements/:reimbursementId/preview', - { - preHandler: fastify.requirePermission('integration.manage'), - schema: { - tags: ['integrations'], - summary: '预览报销付款凭证切片', - security: [{ bearerAuth: [] }], - params: { - type: 'object', - required: ['reimbursementId'], - properties: { - reimbursementId: { type: 'string', format: 'uuid' }, - }, - }, - }, - }, - async (request, reply) => { - const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId); - if (!parsedId.success) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 400, - details: { reimbursementId: 'invalid_uuid' }, - }); - } - - const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data); - ensurePaidReimbursement(reimbursement); - const preview = await buildReimbursementJournalPreview(fastify.prisma, reimbursement); - return sendData(reply, request, { - journal: preview.journal, - exportState: preview.exportState, - }); - }, - ); - - fastify.post( - '/integrations/erp/journals/reimbursements/:reimbursementId/export', - { - preHandler: fastify.requirePermission('integration.manage'), - schema: { - tags: ['integrations'], - summary: '导出报销付款凭证到 ERP', - security: [{ bearerAuth: [] }], - params: { - type: 'object', - required: ['reimbursementId'], - properties: { - reimbursementId: { type: 'string', format: 'uuid' }, - }, - }, - querystring: { - type: 'object', - properties: { - runNow: { - anyOf: [ - { type: 'string', enum: ['0', '1', 'false', 'true'] }, - { type: 'boolean' }, - ], - }, - }, - additionalProperties: true, - }, - response: { - 200: buildDataEnvelopeSchema(ERP_JOURNAL_EXPORT_RESPONSE_SCHEMA, 'ERP 导出任务已入队'), - 400: buildErrorEnvelopeSchema({ - description: '参数校验失败', - codeExample: 'VALID_001', - messageExample: '参数校验失败', - }), - 404: buildErrorEnvelopeSchema({ - description: '报销单不存在', - codeExample: 'SYS_404', - messageExample: 'Not Found', - }), - 409: buildErrorEnvelopeSchema({ - description: '状态不满足或开关阻断', - codeExample: 'VALID_001', - messageExample: '参数校验失败', - }), - }, - }, - }, - async (request, reply) => { - const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId); - if (!parsedId.success) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 400, - details: { reimbursementId: 'invalid_uuid' }, - }); - } - - const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data); - ensurePaidReimbursement(reimbursement); - const gate = await requireErpManagementGate(fastify.prisma, fastify.config); - const enqueued = await enqueueReimbursementExportAndMaybeRun({ - prisma: fastify.prisma, - config: fastify.config, - logger: fastify.log, - reimbursement, - actorId: request.employee.id, - runNow: parseRunNow(request.query), - }); - - await fastify.prisma.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: 'ERP_SYNC_ENQUEUED', - targetType: 'reimbursement_form', - targetId: reimbursement.id, - extra: { - outboxId: enqueued.id, - duplicated: enqueued.duplicated, - requeued: enqueued.requeued, - eventType: enqueued.eventType, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - featureFlagReason: gate.effectiveReason, - }, - }), - }); - - return sendData(reply, request, { - outboxId: enqueued.id, - status: enqueued.status, - duplicated: enqueued.duplicated, - requeued: enqueued.requeued, - eventType: enqueued.eventType, - }); - }, - ); - - fastify.get( - '/integrations/erp/procurement/purchase-requests/:purchaseRequestId/track', - { - preHandler: fastify.authenticate, - schema: { - tags: ['integrations'], - summary: '查询采购 ERP 同步轨道', - security: [{ bearerAuth: [] }], - params: { - type: 'object', - required: ['purchaseRequestId'], - properties: { - purchaseRequestId: { type: 'string', format: 'uuid' }, - }, - }, - }, - }, - async (request, reply) => { - const parsedId = UUID_SCHEMA.safeParse(request.params?.purchaseRequestId); - if (!parsedId.success) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 400, - details: { purchaseRequestId: 'invalid_uuid' }, - }); - } - - const [context, gate] = await Promise.all([ - fetchReadablePurchaseRequestContext(fastify.prisma, parsedId.data, request), - resolveErpGate(fastify.prisma, fastify.config), - ]); - const canManage = fastify.hasPermission(request.employee, 'integration.manage'); - const track = await buildPurchaseRequestErpTrack(fastify.prisma, fastify.config, parsedId.data, { - context, - canManage, - gate, - }); - - return sendData(reply, request, track); - }, - ); - - fastify.post( - '/integrations/erp/procurement/purchase-requests/:purchaseRequestId/requeue', - { - preHandler: fastify.requirePermission('integration.manage'), - schema: { - tags: ['integrations'], - summary: '重试或补发采购 ERP 同步', - security: [{ bearerAuth: [] }], - params: { - type: 'object', - required: ['purchaseRequestId'], - properties: { - purchaseRequestId: { type: 'string', format: 'uuid' }, - }, - }, - querystring: { - type: 'object', - properties: { - runNow: { - anyOf: [ - { type: 'string', enum: ['0', '1', 'false', 'true'] }, - { type: 'boolean' }, - ], - }, - }, - additionalProperties: true, - }, - }, - }, - async (request, reply) => { - const parsedId = UUID_SCHEMA.safeParse(request.params?.purchaseRequestId); - if (!parsedId.success) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 400, - details: { purchaseRequestId: 'invalid_uuid' }, - }); - } - - const targets = normalizeProcurementTargets(request.body); - const gate = await requireErpManagementGate(fastify.prisma, fastify.config); - const context = await loadPurchaseRequestErpContext(fastify.prisma, parsedId.data); - if (!context?.purchaseRequest) { - throw new AppError({ - code: 'SYS_404', - message: 'Not Found', - statusCode: 404, - }); - } - - const items = await enqueueProcurementTargetsAndMaybeRun({ - prisma: fastify.prisma, - config: fastify.config, - logger: fastify.log, - context, - actorId: request.employee.id, - targets, - runNow: parseRunNow(request.query), - }); - - for (const item of items) { - await fastify.prisma.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: 'ERP_SYNC_ENQUEUED', - targetType: item.target === PROCUREMENT_ERP_TARGETS.commitmentJournal ? 'purchase_request' : 'purchase_order_archive', - targetId: - item.target === PROCUREMENT_ERP_TARGETS.commitmentJournal - ? context.purchaseRequest.id - : context.purchaseRequest.order_archive?.id ?? null, - extra: { - erpTarget: item.target, - eventType: item.eventType, - purchaseRequestId: context.purchaseRequest.id, - purchaseOrderArchiveId: context.purchaseRequest.order_archive?.id ?? null, - outboxId: item.id, - duplicated: item.duplicated, - requeued: item.requeued, - gateReason: gate.effectiveReason, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - }, - }), - }); - } - - return sendData(reply, request, { - items: items.map((item) => ({ - target: item.target, - outboxId: item.id, - status: item.status, - duplicated: item.duplicated, - requeued: item.requeued, - eventType: item.eventType, - })), - }); - }, - ); - - fastify.get( - '/integrations/erp/assets/:assetId/track', - { - preHandler: fastify.authenticate, - schema: { - tags: ['integrations'], - summary: '查询资产 ERP 同步轨道', - security: [{ bearerAuth: [] }], - params: { - type: 'object', - required: ['assetId'], - properties: { - assetId: { type: 'string', format: 'uuid' }, - }, - }, - }, - }, - async (request, reply) => { - const parsedId = UUID_SCHEMA.safeParse(request.params?.assetId); - if (!parsedId.success) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 400, - details: { assetId: 'invalid_uuid' }, - }); - } - - const [context, gate] = await Promise.all([ - fetchReadableAssetContext(fastify.prisma, parsedId.data, request), - resolveErpGate(fastify.prisma, fastify.config), - ]); - const canManage = fastify.hasPermission(request.employee, 'integration.manage'); - const track = await buildAssetErpTrack(fastify.prisma, fastify.config, parsedId.data, { - context, - canManage, - gate, - }); - - return sendData(reply, request, track); - }, - ); - - fastify.post( - '/integrations/erp/assets/:assetId/requeue', - { - preHandler: fastify.requirePermission('integration.manage'), - schema: { - tags: ['integrations'], - summary: '补发或重试资产 ERP 同步', - security: [{ bearerAuth: [] }], - params: { - type: 'object', - required: ['assetId'], - properties: { - assetId: { type: 'string', format: 'uuid' }, - }, - }, - querystring: { - type: 'object', - properties: { - runNow: { - anyOf: [ - { type: 'string', enum: ['0', '1', 'false', 'true'] }, - { type: 'boolean' }, - ], - }, - }, - additionalProperties: true, - }, - }, - }, - async (request, reply) => { - const parsedId = UUID_SCHEMA.safeParse(request.params?.assetId); - if (!parsedId.success) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 400, - details: { assetId: 'invalid_uuid' }, - }); - } - - const gate = await requireErpManagementGate(fastify.prisma, fastify.config); - const context = await loadAssetErpContext(fastify.prisma, parsedId.data); - if (!context?.asset) { - throw new AppError({ - code: 'SYS_404', - message: 'Not Found', - statusCode: 404, - }); - } - - const enqueued = await enqueueAssetExportAndMaybeRun({ - prisma: fastify.prisma, - config: fastify.config, - logger: fastify.log, - context, - actorId: request.employee.id, - runNow: parseRunNow(request.query), - }); - - await fastify.prisma.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: 'ERP_SYNC_ENQUEUED', - targetType: 'asset_card', - targetId: context.asset.id, - extra: { - erpTarget: ASSET_ERP_TARGET, - eventType: enqueued.eventType, - assetId: context.asset.id, - assetNo: context.asset.asset_no ?? null, - outboxId: enqueued.id, - duplicated: enqueued.duplicated, - requeued: enqueued.requeued, - gateReason: gate.effectiveReason, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - }, - }), - }); - - return sendData(reply, request, { - outboxId: enqueued.id, - status: enqueued.status, - duplicated: enqueued.duplicated, - requeued: enqueued.requeued, - eventType: enqueued.eventType, - }); - }, - ); - - fastify.get( - '/integrations/erp/travel/reconciliation-batches/:batchId/track', - { - preHandler: fastify.authenticate, - schema: { - tags: ['integrations'], - summary: '查询差旅对账批次 ERP 同步轨道', - security: [{ bearerAuth: [] }], - params: { - type: 'object', - required: ['batchId'], - properties: { - batchId: { type: 'string', format: 'uuid' }, - }, - }, - }, - }, - async (request, reply) => { - const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId); - if (!parsedId.success) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 400, - details: { batchId: 'invalid_uuid' }, - }); - } - - const [context, gate] = await Promise.all([ - fetchReadableTravelReconciliationContext(fastify.prisma, parsedId.data, request), - resolveErpGate(fastify.prisma, fastify.config), - ]); - const canManage = fastify.hasPermission(request.employee, 'integration.manage'); - const track = await buildTravelReconciliationErpTrack(fastify.prisma, fastify.config, parsedId.data, { - context, - canManage, - gate, - }); - - return sendData(reply, request, track); - }, - ); - - fastify.post( - '/integrations/erp/travel/reconciliation-batches/:batchId/requeue', - { - preHandler: fastify.requirePermission('integration.manage'), - schema: { - tags: ['integrations'], - summary: '重试差旅对账批次 ERP 摘要桥同步', - security: [{ bearerAuth: [] }], - params: { - type: 'object', - required: ['batchId'], - properties: { - batchId: { type: 'string', format: 'uuid' }, - }, - }, - querystring: { - type: 'object', - properties: { - runNow: { - anyOf: [ - { type: 'string', enum: ['0', '1', 'false', 'true'] }, - { type: 'boolean' }, - ], - }, - }, - additionalProperties: true, - }, - }, - }, - async (request, reply) => { - const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId); - if (!parsedId.success) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 400, - details: { batchId: 'invalid_uuid' }, - }); - } - - const gate = await requireErpManagementGate(fastify.prisma, fastify.config); - const context = await loadTravelReconciliationErpContext(fastify.prisma, parsedId.data); - if (!context) { - throw new AppError({ - code: 'SYS_404', - message: 'Not Found', - statusCode: 404, - }); - } - if (context.status !== 'reconciled') { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 409, - details: { batch: 'travel_reconciliation_batch_not_reconciled' }, - }); - } - - const enqueued = await enqueueTravelReconciliationExportAndMaybeRun({ - prisma: fastify.prisma, - config: fastify.config, - logger: fastify.log, - context, - actorId: request.employee.id, - runNow: parseRunNow(request.query), - }); - - await fastify.prisma.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: 'ERP_SYNC_ENQUEUED', - targetType: 'travel_reconciliation_batch', - targetId: context.id, - extra: { - erpTarget: 'travel_reconciliation_summary', - eventType: enqueued.eventType, - batchId: context.id, - batchNo: context.batch_no, - outboxId: enqueued.id, - duplicated: enqueued.duplicated, - requeued: enqueued.requeued, - gateReason: gate.effectiveReason, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - }, - }), - }); - - return sendData(reply, request, { - outboxId: enqueued.id, - status: enqueued.status, - duplicated: enqueued.duplicated, - requeued: enqueued.requeued, - eventType: enqueued.eventType, - }); - }, - ); - - fastify.post( - '/integrations/erp/sync/:reimbursementId', - { - preHandler: fastify.requirePermission('integration.manage'), - schema: { - tags: ['integrations'], - summary: '手动触发 ERP 同步', - security: [{ bearerAuth: [] }], - params: { - type: 'object', - required: ['reimbursementId'], - properties: { - reimbursementId: { type: 'string', format: 'uuid' }, - }, - }, - querystring: { - type: 'object', - properties: { - runNow: { - anyOf: [ - { type: 'string', enum: ['0', '1', 'false', 'true'] }, - { type: 'boolean' }, - ], - }, - }, - additionalProperties: true, - }, - response: { - 200: buildDataEnvelopeSchema(ERP_SYNC_RESPONSE_SCHEMA, 'ERP 同步任务已入队'), - 400: buildErrorEnvelopeSchema({ - description: '参数校验失败', - codeExample: 'VALID_001', - messageExample: '参数校验失败', - }), - 404: buildErrorEnvelopeSchema({ - description: '报销单不存在', - codeExample: 'SYS_404', - messageExample: 'Not Found', - }), - 409: buildErrorEnvelopeSchema({ - description: '状态不满足或开关阻断', - codeExample: 'VALID_001', - messageExample: '参数校验失败', - detailsExample: { - integration: 'feature_flag_blocked', - featureFlag: 'erp_sync', - reason: 'department_not_allowed', - }, - }), - }, - }, - }, - async (request, reply) => { - const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId); - if (!parsedId.success) { - throw new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode: 400, - details: { reimbursementId: 'invalid_uuid' }, - }); - } - - const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data); - ensurePaidReimbursement(reimbursement); - const gate = await requireErpManagementGate(fastify.prisma, fastify.config); - const enqueued = await enqueueReimbursementExportAndMaybeRun({ - prisma: fastify.prisma, - config: fastify.config, - logger: fastify.log, - reimbursement, - actorId: request.employee.id, - runNow: parseRunNow(request.query), - }); - - await fastify.prisma.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: 'ERP_SYNC_ENQUEUED', - targetType: 'reimbursement_form', - targetId: reimbursement.id, - extra: { - outboxId: enqueued.id, - duplicated: enqueued.duplicated, - requeued: enqueued.requeued, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - featureFlagReason: gate.effectiveReason, - }, - }), - }); - - return sendData(reply, request, { - outboxId: enqueued.id, - status: enqueued.status, - duplicated: enqueued.duplicated, - }); - }, - ); } -integrationsRoutes.__testables = { - canReadAsset, - canReadPurchaseRequest, - canReadTravelReconciliationBatch, - enqueueAssetExportAndMaybeRun, - enqueueTravelReconciliationExportAndMaybeRun, - ensureProcurementTargetEligible, - normalizeProcurementTargets, - normalizeResourcesInput, - parseRunNow, -}; - module.exports = integrationsRoutes; diff --git a/server/src/routes/inventoryChecks.js b/server/src/routes/inventoryChecks.js index 1e60cd27..d5d59c89 100644 --- a/server/src/routes/inventoryChecks.js +++ b/server/src/routes/inventoryChecks.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { buildAuditLogData } = require("../utils/audit"); const { issueDocumentNumber } = require("../services/documentSequence"); const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications"); @@ -42,27 +44,18 @@ const RECORD_ITEMS_BODY_SCHEMA = z }) .strict(); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - -function parseUuid(fieldName, value) { +function parseUuid(fieldName, value, reasonCode) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ [fieldName]: "invalid_uuid" }); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, { [fieldName]: "invalid_uuid" }); } return parsed.data; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } @@ -70,7 +63,7 @@ function parseBody(schema, body) { function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("inventory_check.query_invalid", parsed.error.flatten()); } return parsed.data; } @@ -92,20 +85,20 @@ async function ensureScopeReference(tx, scopeType, scopeRefId) { return null; } if (!scopeRefId) { - throw buildValidationError({ scopeRefId: "required" }); + throw buildBusinessValidationError("inventory_check.scope_required", { scopeRefId: "required" }); } if (scopeType === "location") { return scopeRefId; } - const normalizedId = parseUuid("scopeRefId", scopeRefId); + const normalizedId = parseUuid("scopeRefId", scopeRefId, "inventory_check.scope_id_invalid"); if (scopeType === "legal_entity") { const legalEntity = await tx.legalEntity.findUnique({ where: { id: normalizedId }, select: { id: true }, }); if (!legalEntity) { - throw buildValidationError({ scopeRefId: "not_found" }, 404); + throw buildBusinessValidationError("inventory_check.company_not_found", { scopeRefId: "not_found" }, 404); } return normalizedId; } @@ -115,7 +108,7 @@ async function ensureScopeReference(tx, scopeType, scopeRefId) { select: { id: true }, }); if (!costCenter) { - throw buildValidationError({ scopeRefId: "not_found" }, 404); + throw buildBusinessValidationError("inventory_check.cost_center_not_found", { scopeRefId: "not_found" }, 404); } return normalizedId; } @@ -194,14 +187,14 @@ async function ensureInventoryCheck(tx, inventoryCheckId) { }, }); if (!record) { - throw buildValidationError({ id: "not_found" }, 404); + throw buildBusinessValidationError("inventory_check.not_found", { id: "not_found" }, 404); } return record; } -function ensureCheckStatus(record, expectedStatus) { +function ensureCheckStatus(record, expectedStatus, reasonCode) { if (record.status !== expectedStatus) { - throw buildValidationError({ status: `expected_${expectedStatus}` }, 409); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, { status: `expected_${expectedStatus}` }, 409); } } @@ -210,13 +203,13 @@ function ensureAssetInScope(check, asset) { return; } if (check.scope_type === "legal_entity" && asset.legal_entity_id !== check.scope_ref_id) { - throw buildValidationError({ assetId: "scope_mismatch" }, 409); + throw buildBusinessValidationError("inventory_check.asset_scope_mismatch", { assetId: "scope_mismatch" }, 409); } if (check.scope_type === "cost_center" && asset.cost_center_id !== check.scope_ref_id) { - throw buildValidationError({ assetId: "scope_mismatch" }, 409); + throw buildBusinessValidationError("inventory_check.asset_scope_mismatch", { assetId: "scope_mismatch" }, 409); } if (check.scope_type === "location" && (asset.location ?? null) !== (check.scope_ref_id ?? null)) { - throw buildValidationError({ assetId: "scope_mismatch" }, 409); + throw buildBusinessValidationError("inventory_check.asset_scope_mismatch", { assetId: "scope_mismatch" }, 409); } } @@ -283,13 +276,14 @@ async function inventoryChecksRoutes(fastify) { preHandler: fastify.requirePermission("asset.inventory"), }, async (request, reply) => { - const body = parseBody(CREATE_BODY_SCHEMA, request.body); + const body = parseBody(CREATE_BODY_SCHEMA, request.body, "inventory_check.create_input_invalid"); const scopeRefId = normalizeOptionalText(body.scopeRefId); const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { const normalizedScopeRefId = await ensureScopeReference(tx, body.scopeType, scopeRefId); const issued = await issueDocumentNumber(tx, { documentType: "inventory_check", + timeZone: fastify.config.appTimezone, actorId: request.employee.id, }); @@ -363,10 +357,10 @@ async function inventoryChecksRoutes(fastify) { preHandler: fastify.requirePermission("asset.inventory"), }, async (request, reply) => { - const inventoryCheckId = parseUuid("id", request.params?.id); + const inventoryCheckId = parseUuid("id", request.params?.id, "inventory_check.id_invalid"); const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { const current = await ensureInventoryCheck(tx, inventoryCheckId); - ensureCheckStatus(current, "draft"); + ensureCheckStatus(current, "draft", "inventory_check.start_state_invalid"); const updated = await tx.inventoryCheck.update({ where: { id: inventoryCheckId }, @@ -436,17 +430,17 @@ async function inventoryChecksRoutes(fastify) { preHandler: fastify.requirePermission("asset.inventory"), }, async (request, reply) => { - const inventoryCheckId = parseUuid("id", request.params?.id); - const body = parseBody(RECORD_ITEMS_BODY_SCHEMA, request.body); + const inventoryCheckId = parseUuid("id", request.params?.id, "inventory_check.id_invalid"); + const body = parseBody(RECORD_ITEMS_BODY_SCHEMA, request.body, "inventory_check.record_input_invalid"); const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { const current = await ensureInventoryCheck(tx, inventoryCheckId); - ensureCheckStatus(current, "counting"); + ensureCheckStatus(current, "counting", "inventory_check.record_state_invalid"); const seen = new Set(); for (const item of body.items) { if (seen.has(item.assetId)) { - throw buildValidationError({ items: "duplicate_asset_id" }, 409); + throw buildBusinessValidationError("inventory_check.duplicate_asset", { items: "duplicate_asset_id" }, 409); } seen.add(item.assetId); @@ -464,7 +458,7 @@ async function inventoryChecksRoutes(fastify) { }, }); if (!asset) { - throw buildValidationError({ assetId: "not_found" }, 404); + throw buildBusinessValidationError("inventory_check.asset_not_found", { assetId: "not_found" }, 404); } ensureAssetInScope(current, asset); @@ -515,12 +509,12 @@ async function inventoryChecksRoutes(fastify) { preHandler: fastify.requirePermission("asset.inventory"), }, async (request, reply) => { - const inventoryCheckId = parseUuid("id", request.params?.id); + const inventoryCheckId = parseUuid("id", request.params?.id, "inventory_check.id_invalid"); const emailTasks = []; const dispatchTasks = []; const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { const current = await ensureInventoryCheck(tx, inventoryCheckId); - ensureCheckStatus(current, "counting"); + ensureCheckStatus(current, "counting", "inventory_check.complete_state_invalid"); const updated = await tx.inventoryCheck.update({ where: { id: inventoryCheckId }, diff --git a/server/src/routes/leave-types.js b/server/src/routes/leave-types.js index 262ac37e..844c5248 100644 --- a/server/src/routes/leave-types.js +++ b/server/src/routes/leave-types.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { sendData } = require("../utils/reply"); const { mapLeaveTypeResponse } = require("../services/leave"); @@ -37,19 +39,10 @@ const LEAVE_TYPE_BODY_SCHEMA = z const LEAVE_TYPE_PATCH_SCHEMA = LEAVE_TYPE_BODY_SCHEMA.partial(); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseTypeId(value) { const parsed = LEAVE_TYPE_ID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("leave_type.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } @@ -57,15 +50,15 @@ function parseTypeId(value) { function parseTypeQuery(query) { const parsed = LEAVE_TYPE_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("leave_type.query_invalid", parsed.error.flatten()); } return parsed.data; } -function parseTypeBody(schema, body) { +function parseTypeBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return { @@ -160,7 +153,7 @@ async function leaveTypesRoutes(fastify) { preHandler: fastify.requireEntityPermission("leave", "manage"), }, async (request, reply) => { - const body = parseTypeBody(LEAVE_TYPE_BODY_SCHEMA, request.body); + const body = parseTypeBody(LEAVE_TYPE_BODY_SCHEMA, request.body, "leave_type.create_input_invalid"); try { const leaveType = await fastify.prisma.leaveType.create({ @@ -209,7 +202,7 @@ async function leaveTypesRoutes(fastify) { return sendData(reply, request, normalizePrismaLeaveType(leaveType)); } catch (error) { if (isUniqueConstraint(error, "code")) { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("leave_type.code_duplicate", { code: "duplicate" }, 409); } throw error; } @@ -224,7 +217,7 @@ async function leaveTypesRoutes(fastify) { }, async (request, reply) => { const leaveTypeId = parseTypeId(request.params?.id); - const body = parseTypeBody(LEAVE_TYPE_PATCH_SCHEMA, request.body); + const body = parseTypeBody(LEAVE_TYPE_PATCH_SCHEMA, request.body, "leave_type.update_input_invalid"); const existing = await fastify.prisma.leaveType.findUnique({ where: { id: leaveTypeId }, @@ -303,7 +296,7 @@ async function leaveTypesRoutes(fastify) { return sendData(reply, request, normalizePrismaLeaveType(leaveType)); } catch (error) { if (isUniqueConstraint(error, "code")) { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("leave_type.code_duplicate", { code: "duplicate" }, 409); } throw error; } diff --git a/server/src/routes/leave.js b/server/src/routes/leave.js index da680ab2..f5ab3e77 100644 --- a/server/src/routes/leave.js +++ b/server/src/routes/leave.js @@ -1,3 +1,4 @@ +const { isValidDateOnlyString } = require("../utils/date"); const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { z } = require("zod"); @@ -23,7 +24,7 @@ const { toHalfDayUnits, } = require("../services/leave"); -const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const SESSION_SCHEMA = z.enum(["am", "pm"]); const STATUS_SCHEMA = z.enum(["draft", "reviewing", "approved", "rejected", "cancelled"]); diff --git a/server/src/routes/masterData.js b/server/src/routes/masterData.js index bc534da2..b91900cc 100644 --- a/server/src/routes/masterData.js +++ b/server/src/routes/masterData.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { getErrorReason } = require("../constants/errorReasons"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); @@ -74,19 +76,10 @@ const documentCategoryCreateBodySchema = z const documentCategoryPatchBodySchema = documentCategoryCreateBodySchema.partial(); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - -function parseUuid(value) { +function parseUuid(value, reasonCode) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, { id: "invalid_uuid" }); } return parsed.data; } @@ -98,10 +91,10 @@ function parseBooleanQueryValue(rawValue) { return rawValue === "1" || rawValue === "true"; } -function parseListQuery(schema, query) { +function parseListQuery(schema, query, reasonCode) { const parsed = schema.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return { @@ -111,23 +104,23 @@ function parseListQuery(schema, query) { }; } -function parseCreateBody(schema, body) { +function parseCreateBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } -function parsePatchBody(schema, body) { +function parsePatchBody(schema, body, reasonCode, emptyReasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { - throw buildValidationError({ body: "empty" }); + throw buildBusinessValidationError(getErrorReason(emptyReasonCode).code, { body: "empty" }); } return parsed.data; @@ -289,11 +282,11 @@ async function ensureLegalEntityAvailable(tx, legalEntityId) { }); if (!legalEntity) { - throw buildValidationError({ legalEntityId: "not_found" }); + throw buildBusinessValidationError("legal_entity.reference_not_found", { legalEntityId: "not_found" }); } if (legalEntity.archived_at) { - throw buildValidationError({ legalEntityId: "archived" }, 409); + throw buildBusinessValidationError("legal_entity.reference_archived", { legalEntityId: "archived" }, 409); } return legalEntity; @@ -310,7 +303,7 @@ async function ensureOwnerEmployeeAvailable(tx, ownerEmployeeId) { }); if (!employee || employee.status !== "active") { - throw buildValidationError({ ownerEmployeeId: "not_found" }); + throw buildBusinessValidationError("cost_center.owner_unavailable", { ownerEmployeeId: "not_found" }); } return employee; @@ -322,7 +315,7 @@ async function ensureCostCenterParent(tx, { parentId, legalEntityId, selfId }) { } if (selfId && parentId === selfId) { - throw buildValidationError({ parentId: "self_not_allowed" }, 409); + throw buildBusinessValidationError("cost_center.parent_self_forbidden", { parentId: "self_not_allowed" }, 409); } const parent = await tx.costCenter.findUnique({ @@ -336,15 +329,15 @@ async function ensureCostCenterParent(tx, { parentId, legalEntityId, selfId }) { }); if (!parent) { - throw buildValidationError({ parentId: "not_found" }); + throw buildBusinessValidationError("cost_center.parent_not_found", { parentId: "not_found" }); } if (parent.archived_at) { - throw buildValidationError({ parentId: "archived" }, 409); + throw buildBusinessValidationError("cost_center.parent_archived", { parentId: "archived" }, 409); } if (parent.legal_entity_id !== legalEntityId) { - throw buildValidationError({ parentId: "cross_legal_entity_not_allowed" }, 409); + throw buildBusinessValidationError("cost_center.parent_company_mismatch", { parentId: "cross_legal_entity_not_allowed" }, 409); } if (selfId) { @@ -353,7 +346,7 @@ async function ensureCostCenterParent(tx, { parentId, legalEntityId, selfId }) { }); if (detectParentCycle(items, selfId, parentId)) { - throw buildValidationError({ parentId: "cycle_not_allowed" }, 409); + throw buildBusinessValidationError("cost_center.parent_cycle_forbidden", { parentId: "cycle_not_allowed" }, 409); } } @@ -396,7 +389,7 @@ async function masterDataRoutes(fastify) { }, }, async (request, reply) => { - const query = parseListQuery(legalEntityListQuerySchema, request.query); + const query = parseListQuery(legalEntityListQuerySchema, request.query, "legal_entity.query_invalid"); const items = await fastify.prisma.legalEntity.findMany({ where: { ...(query.includeArchived ? {} : { archived_at: null }), @@ -423,7 +416,7 @@ async function masterDataRoutes(fastify) { }, }, async (request, reply) => { - const body = normalizeLegalEntityPayload(parseCreateBody(legalEntityCreateBodySchema, request.body)); + const body = normalizeLegalEntityPayload(parseCreateBody(legalEntityCreateBodySchema, request.body, "legal_entity.create_input_invalid")); try { const legalEntity = await fastify.prisma.legalEntity.create({ @@ -452,7 +445,7 @@ async function masterDataRoutes(fastify) { return sendData(reply, request, { legalEntity: mapLegalEntity(legalEntity) }); } catch (error) { if (isUniqueConstraint(error, "code")) { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("legal_entity.code_duplicate", { code: "duplicate" }, 409); } throw error; } @@ -470,8 +463,8 @@ async function masterDataRoutes(fastify) { }, }, async (request, reply) => { - const legalEntityId = parseUuid(request.params?.id); - const body = normalizeLegalEntityPayload(parsePatchBody(legalEntityPatchBodySchema, request.body)); + const legalEntityId = parseUuid(request.params?.id, "legal_entity.id_invalid"); + const body = normalizeLegalEntityPayload(parsePatchBody(legalEntityPatchBodySchema, request.body, "legal_entity.update_input_invalid", "legal_entity.update_empty")); const current = await fastify.prisma.legalEntity.findUnique({ where: { id: legalEntityId }, @@ -482,7 +475,7 @@ async function masterDataRoutes(fastify) { } if (current.archived_at) { - throw buildValidationError({ status: "archived" }, 409); + throw buildBusinessValidationError("legal_entity.archived_locked", { status: "archived" }, 409); } try { @@ -512,7 +505,7 @@ async function masterDataRoutes(fastify) { return sendData(reply, request, { legalEntity: mapLegalEntity(legalEntity) }); } catch (error) { if (isUniqueConstraint(error, "code")) { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("legal_entity.code_duplicate", { code: "duplicate" }, 409); } throw error; } @@ -530,7 +523,7 @@ async function masterDataRoutes(fastify) { }, }, async (request, reply) => { - const legalEntityId = parseUuid(request.params?.id); + const legalEntityId = parseUuid(request.params?.id, "legal_entity.id_invalid"); const now = new Date(); const result = await fastify.prisma.$transaction(async (tx) => { @@ -547,7 +540,7 @@ async function masterDataRoutes(fastify) { where: { legal_entity_id: legalEntityId, archived_at: null }, }); if (activeCostCenterCount > 0) { - throw buildValidationError({ legalEntity: "has_cost_centers" }, 409); + throw buildBusinessValidationError("legal_entity.has_cost_centers", { legalEntity: "has_cost_centers" }, 409); } const legalEntity = await tx.legalEntity.update({ @@ -590,7 +583,7 @@ async function masterDataRoutes(fastify) { }, }, async (request, reply) => { - const query = parseListQuery(costCenterListQuerySchema, request.query); + const query = parseListQuery(costCenterListQuerySchema, request.query, "cost_center.query_invalid"); const items = await fastify.prisma.costCenter.findMany({ where: { ...(query.includeArchived ? {} : { archived_at: null }), @@ -633,7 +626,7 @@ async function masterDataRoutes(fastify) { }, }, async (request, reply) => { - const body = normalizeCostCenterPayload(parseCreateBody(costCenterCreateBodySchema, request.body)); + const body = normalizeCostCenterPayload(parseCreateBody(costCenterCreateBodySchema, request.body, "cost_center.create_input_invalid")); try { const costCenter = await fastify.prisma.$transaction(async (tx) => { @@ -689,7 +682,7 @@ async function masterDataRoutes(fastify) { return sendData(reply, request, { costCenter: mapCostCenter(costCenter) }); } catch (error) { if (isUniqueConstraint(error)) { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("cost_center.code_duplicate", { code: "duplicate" }, 409); } throw error; } @@ -707,8 +700,8 @@ async function masterDataRoutes(fastify) { }, }, async (request, reply) => { - const costCenterId = parseUuid(request.params?.id); - const body = normalizeCostCenterPayload(parsePatchBody(costCenterPatchBodySchema, request.body)); + const costCenterId = parseUuid(request.params?.id, "cost_center.id_invalid"); + const body = normalizeCostCenterPayload(parsePatchBody(costCenterPatchBodySchema, request.body, "cost_center.update_input_invalid", "cost_center.update_empty")); try { const costCenter = await fastify.prisma.$transaction(async (tx) => { @@ -731,7 +724,7 @@ async function masterDataRoutes(fastify) { } if (current.archived_at) { - throw buildValidationError({ status: "archived" }, 409); + throw buildBusinessValidationError("cost_center.archived_locked", { status: "archived" }, 409); } const nextLegalEntityId = body.legalEntityId ?? current.legal_entity_id; @@ -791,7 +784,7 @@ async function masterDataRoutes(fastify) { return sendData(reply, request, { costCenter: mapCostCenter(costCenter) }); } catch (error) { if (isUniqueConstraint(error)) { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("cost_center.code_duplicate", { code: "duplicate" }, 409); } throw error; } @@ -809,7 +802,7 @@ async function masterDataRoutes(fastify) { }, }, async (request, reply) => { - const costCenterId = parseUuid(request.params?.id); + const costCenterId = parseUuid(request.params?.id, "cost_center.id_invalid"); const now = new Date(); const result = await fastify.prisma.$transaction(async (tx) => { @@ -844,7 +837,7 @@ async function masterDataRoutes(fastify) { where: { parent_id: costCenterId, archived_at: null }, }); if (childCount > 0) { - throw buildValidationError({ costCenter: "has_children" }, 409); + throw buildBusinessValidationError("cost_center.has_children", { costCenter: "has_children" }, 409); } const costCenter = await tx.costCenter.update({ @@ -902,7 +895,7 @@ async function masterDataRoutes(fastify) { }, }, async (request, reply) => { - const query = parseListQuery(documentCategoryListQuerySchema, request.query); + const query = parseListQuery(documentCategoryListQuerySchema, request.query, "document_category.query_invalid"); const items = await fastify.prisma.documentCategory.findMany({ where: { ...(query.includeArchived ? {} : { archived_at: null }), @@ -931,7 +924,7 @@ async function masterDataRoutes(fastify) { }, async (request, reply) => { const body = normalizeDocumentCategoryPayload( - parseCreateBody(documentCategoryCreateBodySchema, request.body), + parseCreateBody(documentCategoryCreateBodySchema, request.body, "document_category.create_input_invalid"), ); try { @@ -963,7 +956,7 @@ async function masterDataRoutes(fastify) { return sendData(reply, request, { documentCategory: mapDocumentCategory(documentCategory) }); } catch (error) { if (isUniqueConstraint(error, "code")) { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("document_category.code_duplicate", { code: "duplicate" }, 409); } throw error; } @@ -981,9 +974,9 @@ async function masterDataRoutes(fastify) { }, }, async (request, reply) => { - const documentCategoryId = parseUuid(request.params?.id); + const documentCategoryId = parseUuid(request.params?.id, "document_category.id_invalid"); const body = normalizeDocumentCategoryPayload( - parsePatchBody(documentCategoryPatchBodySchema, request.body), + parsePatchBody(documentCategoryPatchBodySchema, request.body, "document_category.update_input_invalid", "document_category.update_empty"), ); const current = await fastify.prisma.documentCategory.findUnique({ @@ -995,7 +988,7 @@ async function masterDataRoutes(fastify) { } if (current.archived_at) { - throw buildValidationError({ status: "archived" }, 409); + throw buildBusinessValidationError("document_category.archived_locked", { status: "archived" }, 409); } try { @@ -1029,7 +1022,7 @@ async function masterDataRoutes(fastify) { return sendData(reply, request, { documentCategory: mapDocumentCategory(documentCategory) }); } catch (error) { if (isUniqueConstraint(error, "code")) { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("document_category.code_duplicate", { code: "duplicate" }, 409); } throw error; } @@ -1047,7 +1040,7 @@ async function masterDataRoutes(fastify) { }, }, async (request, reply) => { - const documentCategoryId = parseUuid(request.params?.id); + const documentCategoryId = parseUuid(request.params?.id, "document_category.id_invalid"); const now = new Date(); const result = await fastify.prisma.$transaction(async (tx) => { diff --git a/server/src/routes/masterDataHolidayCalendars.js b/server/src/routes/masterDataHolidayCalendars.js index 6d124ef3..11c2852b 100644 --- a/server/src/routes/masterDataHolidayCalendars.js +++ b/server/src/routes/masterDataHolidayCalendars.js @@ -1,12 +1,14 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { isValidDateOnlyString } = require("../utils/date"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional(); const UUID_SCHEMA = z.string().uuid(); -const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const HOLIDAY_DAY_TYPE_VALUES = ["holiday", "workday", "adjusted_workday"]; const holidayCalendarListQuerySchema = z.object({ @@ -42,19 +44,10 @@ const holidayCalendarDaysReplaceBodySchema = z }) .strict(); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseUuid(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("holiday_calendar.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } @@ -69,7 +62,7 @@ function parseBooleanQueryValue(rawValue) { function parseListQuery(query) { const parsed = holidayCalendarListQuerySchema.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("holiday_calendar.query_invalid", parsed.error.flatten()); } return { @@ -82,7 +75,7 @@ function parseListQuery(query) { function parseCreateBody(body) { const parsed = holidayCalendarCreateBodySchema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("holiday_calendar.create_input_invalid", parsed.error.flatten()); } return parsed.data; } @@ -90,10 +83,10 @@ function parseCreateBody(body) { function parsePatchBody(body) { const parsed = holidayCalendarPatchBodySchema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("holiday_calendar.update_input_invalid", parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { - throw buildValidationError({ body: "empty" }); + throw buildBusinessValidationError("holiday_calendar.update_empty", { body: "empty" }); } return parsed.data; } @@ -101,13 +94,13 @@ function parsePatchBody(body) { function parseReplaceDaysBody(body) { const parsed = holidayCalendarDaysReplaceBodySchema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("holiday_calendar.days_input_invalid", parsed.error.flatten()); } const seenDates = new Set(); for (const item of parsed.data.items) { if (seenDates.has(item.date)) { - throw buildValidationError({ date: "duplicate" }, 409); + throw buildBusinessValidationError("holiday_calendar.date_duplicate", { date: "duplicate" }, 409); } seenDates.add(item.date); } @@ -199,11 +192,11 @@ async function ensureLegalEntityAvailable(tx, legalEntityId) { }); if (!legalEntity) { - throw buildValidationError({ legalEntityId: "not_found" }); + throw buildBusinessValidationError("legal_entity.reference_not_found", { legalEntityId: "not_found" }); } if (legalEntity.archived_at) { - throw buildValidationError({ legalEntityId: "archived" }, 409); + throw buildBusinessValidationError("legal_entity.reference_archived", { legalEntityId: "archived" }, 409); } return legalEntity; @@ -357,7 +350,7 @@ async function masterDataHolidayCalendarsRoutes(fastify) { return sendData(reply, request, { holidayCalendar: mapHolidayCalendar(result) }); } catch (error) { if (error?.code === "P2002") { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("holiday_calendar.code_duplicate", { code: "duplicate" }, 409); } throw error; } @@ -392,7 +385,7 @@ async function masterDataHolidayCalendarsRoutes(fastify) { } if (current.archived_at) { - throw buildValidationError({ status: "archived" }, 409); + throw buildBusinessValidationError("holiday_calendar.archived_locked", { status: "archived" }, 409); } try { @@ -454,7 +447,7 @@ async function masterDataHolidayCalendarsRoutes(fastify) { return sendData(reply, request, { holidayCalendar: mapHolidayCalendar(result) }); } catch (error) { if (error?.code === "P2002") { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("holiday_calendar.code_duplicate", { code: "duplicate" }, 409); } throw error; } @@ -532,7 +525,7 @@ async function masterDataHolidayCalendarsRoutes(fastify) { } if (current.archived_at) { - throw buildValidationError({ status: "archived" }, 409); + throw buildBusinessValidationError("holiday_calendar.archived_locked", { status: "archived" }, 409); } await tx.holidayCalendarDay.deleteMany({ @@ -636,13 +629,10 @@ async function masterDataHolidayCalendarsRoutes(fastify) { }); if (boundLegalEntity) { - throw buildValidationError( - { + throw buildBusinessValidationError("holiday_calendar.bound_as_default", { holidayCalendar: "bound_as_default", legalEntityCode: boundLegalEntity.code, - }, - 409, - ); + }, 409); } const holidayCalendar = await tx.holidayCalendar.update({ diff --git a/server/src/routes/masterDataSuppliers.js b/server/src/routes/masterDataSuppliers.js index f3b136c0..9f766be4 100644 --- a/server/src/routes/masterDataSuppliers.js +++ b/server/src/routes/masterDataSuppliers.js @@ -1,6 +1,7 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); @@ -30,19 +31,10 @@ const supplierCreateBodySchema = z const supplierPatchBodySchema = supplierCreateBodySchema.partial(); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseUuid(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("supplier.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } @@ -57,7 +49,7 @@ function parseBooleanQueryValue(rawValue) { function parseListQuery(query) { const parsed = supplierListQuerySchema.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("supplier.query_invalid", parsed.error.flatten()); } return { @@ -71,7 +63,7 @@ function parseListQuery(query) { function parseCreateBody(body) { const parsed = supplierCreateBodySchema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("supplier.create_input_invalid", parsed.error.flatten()); } return parsed.data; } @@ -79,10 +71,10 @@ function parseCreateBody(body) { function parsePatchBody(body) { const parsed = supplierPatchBodySchema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("supplier.update_input_invalid", parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { - throw buildValidationError({ body: "empty" }); + throw buildBusinessValidationError("supplier.update_empty", { body: "empty" }); } return parsed.data; } @@ -146,11 +138,11 @@ async function ensureLegalEntityAvailable(tx, legalEntityId) { }); if (!legalEntity) { - throw buildValidationError({ legalEntityId: "not_found" }); + throw buildBusinessValidationError("legal_entity.reference_not_found", { legalEntityId: "not_found" }); } if (legalEntity.archived_at) { - throw buildValidationError({ legalEntityId: "archived" }, 409); + throw buildBusinessValidationError("legal_entity.reference_archived", { legalEntityId: "archived" }, 409); } return legalEntity; @@ -314,7 +306,7 @@ async function masterDataSuppliersRoutes(fastify) { return sendData(reply, request, { supplier: mapSupplier(supplier) }); } catch (error) { if (error?.code === "P2002") { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("supplier.code_duplicate", { code: "duplicate" }, 409); } throw error; } @@ -345,7 +337,7 @@ async function masterDataSuppliersRoutes(fastify) { } if (current.archived_at) { - throw buildValidationError({ status: "archived" }, 409); + throw buildBusinessValidationError("supplier.archived_locked", { status: "archived" }, 409); } try { @@ -411,7 +403,7 @@ async function masterDataSuppliersRoutes(fastify) { return sendData(reply, request, { supplier: mapSupplier(supplier) }); } catch (error) { if (error?.code === "P2002") { - throw buildValidationError({ code: "duplicate" }, 409); + throw buildBusinessValidationError("supplier.code_duplicate", { code: "duplicate" }, 409); } throw error; } diff --git a/server/src/routes/migrationRuns.js b/server/src/routes/migrationRuns.js index 5b2447d0..5fb3d073 100644 --- a/server/src/routes/migrationRuns.js +++ b/server/src/routes/migrationRuns.js @@ -1,17 +1,17 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const ID_SCHEMA = z.string().uuid(); const RUN_TYPE_SCHEMA = z.enum(["employees", "departments", "budgets", "masterdata_validation"]); -function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } -function parseId(value) { const parsed = ID_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); return parsed.data; } +function parseId(value) { const parsed = ID_SCHEMA.safeParse(value); if (!parsed.success) throw buildBusinessValidationError("migration_run.id_invalid", { id: "invalid_uuid" }); return parsed.data; } async function ensureFeatureEnabled(fastify) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.cutoverConsole, strictDepartment: false }); - if (!gate.enabled) throw buildValidationError({ featureFlag: "cutover_console_v1_disabled", reason: gate.reason }, 409); + if (!gate.enabled) throw buildBusinessValidationError("cutover.feature_disabled", { featureFlag: "cutover_console_v1_disabled", reason: gate.reason }, 409); } function parseCsv(content) { const rows = String(content ?? "").trim().split(/\r?\n/).filter(Boolean); @@ -47,9 +47,9 @@ async function migrationRunsRoutes(fastify) { fastify.post("/migration-runs/dry-run", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify); const part = await request.file(); - if (!part) throw buildValidationError({ file: "required" }); + if (!part) throw buildBusinessValidationError("migration_run.file_required", { file: "required" }); const runType = RUN_TYPE_SCHEMA.safeParse(part.fields?.runType?.value ?? request.query?.runType ?? request.body?.runType); - if (!runType.success) throw buildValidationError({ runType: "invalid" }); + if (!runType.success) throw buildBusinessValidationError("migration_run.type_invalid", { runType: "invalid" }); const content = await part.toBuffer(); const parsed = parseCsv(content.toString("utf8")); const required = requiredHeaders(runType.data); diff --git a/server/src/routes/notifications.js b/server/src/routes/notifications.js index 049c2e01..e32bdf30 100644 --- a/server/src/routes/notifications.js +++ b/server/src/routes/notifications.js @@ -1,4 +1,5 @@ const { z } = require('zod'); +const { buildBusinessValidationError } = require('../utils/businessValidationError'); const { AppError } = require('../utils/AppError'); const { sendData } = require('../utils/reply'); @@ -118,15 +119,6 @@ const CHANNEL_PATCH_SCHEMA = z message: 'empty_patch', }); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: 'VALID_001', - message: '参数校验失败', - statusCode, - details, - }); -} - function uniqStrings(values) { return Array.from(new Set((values ?? []).filter(Boolean))); } @@ -134,7 +126,7 @@ function uniqStrings(values) { function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError('notification.list_query_invalid', parsed.error.flatten()); } const page = parsed.data.page ?? 1; @@ -159,7 +151,7 @@ function parseListQuery(query) { function parseDeliveryQuery(query) { const parsed = DELIVERY_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError('notification.delivery_query_invalid', parsed.error.flatten()); } return { @@ -173,7 +165,7 @@ function parseDeliveryQuery(query) { function parseNotificationId(value) { const parsed = CHANNEL_ID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: 'invalid_uuid' }); + throw buildBusinessValidationError('notification.id_invalid', { id: 'invalid_uuid' }); } return parsed.data; } @@ -181,7 +173,7 @@ function parseNotificationId(value) { function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError('notification.channel_input_invalid', parsed.error.flatten()); } return parsed.data; } @@ -345,12 +337,12 @@ function handleNotificationChannelWriteError(error) { } if (error?.code === 'P2002') { - throw buildValidationError({ name: 'duplicate_channel_name' }, 409); + throw buildBusinessValidationError('notification.channel_name_duplicate', { name: 'duplicate_channel_name' }, 409); } const message = String(error?.message ?? error ?? ''); if (message.includes('FIELD_ENCRYPTION_KEY') || message.includes('Invalid encryption key')) { - throw buildValidationError({ secret: 'field_encryption_key_missing' }); + throw buildBusinessValidationError('notification.encryption_key_unavailable', { secret: 'field_encryption_key_missing' }); } throw error; diff --git a/server/src/routes/paymentBatches.js b/server/src/routes/paymentBatches.js index 812cfb3f..6d0dbe51 100644 --- a/server/src/routes/paymentBatches.js +++ b/server/src/routes/paymentBatches.js @@ -1,3 +1,8 @@ +const { resolveNumberingContext, issueBusinessNumber } = require("../services/businessNumbering"); +const { ensureLoanManager, auditLoan, createIdempotently } = require("../services/companyLoans"); +const { loadPendingPaymentResponsibility } = require("../services/paymentResponsibility"); +const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput, + activeReviewer, settleLoanRepayment, cancelLoanRepayment, mapRepayment, confirmLoanBankFailure } = require("../services/companyLoanPayments"); const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { ERROR_REASONS } = require("../constants/errorReasons"); const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification"); @@ -20,7 +25,6 @@ const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks"); const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions"); const { sendNotificationEmailSafe } = require("../services/email"); const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); -const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal"); const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl"); const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto"); const { generatePaymentOrderXls, PaymentOrderXlsError } = require("../services/paymentOrderXls"); @@ -47,7 +51,7 @@ const { } = require("../services/businessAttachmentGovernance"); const UUID_SCHEMA = z.string().uuid(); -const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]); +const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order", "loan_repayment"]); const PAYMENT_CHANNEL_SCHEMA = z.enum(["bank", "alipay", "wechat", "cash", "other"]); const PAYMENT_ACCOUNT_INCLUDE = { legal_entity: { select: { id: true, code: true, name: true } }, @@ -176,7 +180,7 @@ function mapReceiptAttachment(item, batch) { ocrState: item.receipt_ocr_state ?? "legacy", ocrVersion: item.receipt_ocr_version ?? 0, ocrStartedAt: item.receipt_ocr_started_at?.toISOString?.() ?? null, - ocrRetryAvailable: batch?.entity_type === "reimbursement" && requiresBankReceipt(batch) && + ocrRetryAvailable: ["reimbursement", "loan_repayment"].includes(batch?.entity_type) && requiresBankReceipt(batch) && canRetryReceiptRecognition(item), originalName: item.original_name, mimeType: item.mime_type, @@ -259,6 +263,7 @@ async function ensurePaymentBatchDepartmentsEnabled(fastify, prisma, departmentI } async function loadBatchSourceDepartmentIds(tx, batch) { + if (batch.entity_type === "loan_repayment") return [batch.items?.[0]?.snapshot_json?.departmentId ?? null]; if (batch.entity_type !== "reimbursement") { return []; } @@ -278,7 +283,7 @@ async function loadBatchSourceDepartmentIds(tx, batch) { } async function ensureBatchSourceDepartmentsEnabled(fastify, tx, batch) { - if (batch.entity_type !== "reimbursement") { + if (!["reimbursement", "loan_repayment"].includes(batch.entity_type)) { return; } const departmentIds = await loadBatchSourceDepartmentIds(tx, batch); @@ -448,7 +453,12 @@ function parsePaymentBatchDailySequence(batchNo, compactDate) { return match ? Number.parseInt(match[1], 10) : 0; } -async function allocatePaymentBatchNumber(tx, timeZone) { +async function allocatePaymentBatchNumber(tx, timeZone, context = null) { + const numbering = context ?? await resolveNumberingContext(tx); + if (numbering.version === 2) { + const issued = await issueBusinessNumber(tx, { family: "payment_batch", ...numbering, timeZone }); + return { batchNo: issued.documentNumber, businessDate: issued.businessDate, sequence: issued.serial }; + } const businessDate = currentDateString(timeZone ?? "Asia/Shanghai"); const compactDate = businessDate.replaceAll("-", ""); const dateLockKey = Number.parseInt(compactDate, 10); @@ -543,6 +553,9 @@ async function lockBatchWithItems(tx, batchId) { } async function lockPaymentEntities(tx, entityType, entityIds) { + // Loan settlement takes its source locks after receipt claim/attachment locks. + if (entityType === "loan_repayment") return; + if (!["reimbursement", "travel_order"].includes(entityType)) throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); const sortedIds = Array.from(new Set(entityIds)).sort(); if (!sortedIds.length) return; @@ -643,6 +656,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds, config, options = })); } + if (entityType !== "travel_order") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); const items = await tx.travelOrder.findMany({ where: { id: { in: entityIds } }, select: { @@ -816,7 +830,7 @@ function paymentOrderExportBlockers(batch) { const blockers = []; const add = (code, entityCode) => blockers.push({ code, ...(entityCode ? { entityCode } : {}) }); if (batch.status !== "reviewed") add("status_not_reviewed"); - if (batch.entity_type !== "reimbursement") add("reimbursement_only"); + if (!["reimbursement", "loan_repayment"].includes(batch.entity_type)) add("reimbursement_only"); if (!batch.payment_account_snapshot_json) { add("legacy_payment_account_snapshot_missing"); } else { @@ -882,6 +896,12 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = const includeExpenseItems = options.includeExpenseItems === true; const expenseItemsByEntityId = options.expenseItemsByEntityId ?? new Map(); const batchRisk = summarizeBatchRisk(batch); + // Display order must not change execution order or voucher-prefix numbering. + const displayItems = batch.entity_type === "reimbursement" + ? [...(batch.items ?? [])].sort((left, right) => + String(left.entity_code ?? "").localeCompare(String(right.entity_code ?? ""), "en", { numeric: true }) || + String(left.id).localeCompare(String(right.id), "en")) + : batch.items ?? []; const riskMap = new Map(); for (const item of openRiskFlags) { const entityId = item.entityId; @@ -896,6 +916,7 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = id: batch.id, batchNo: batch.batch_no, entityType: batch.entity_type, + loanRepaymentId: batch.loan_repayment_id ?? null, name: batch.name, status: batch.status, defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null, @@ -926,7 +947,7 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = cancelledAt: batch.cancelled_at?.toISOString?.() ?? null, cancelledBy: batch.cancelled_by ?? null, cancelledReason: batch.cancelled_reason ?? null, - items: (batch.items ?? []).map((item) => { + items: displayItems.map((item) => { const payee = includePayee ? readSnapshotPayee(item.snapshot_json) ?? legacyPayees.get(item.entity_id) ?? {} : {}; @@ -969,7 +990,7 @@ function isoDateTimeOrNull(value) { return Number.isNaN(date.getTime()) ? null : date.toISOString(); } -function buildPaymentBatchProcessingProgress(batch, actorNames) { +function buildPaymentBatchProcessingProgress(batch, actorNames, responsibility = null) { const semantics = buildPaymentProcessingSemantics({ status: batch.status, createdAt: batch.created_at, @@ -992,11 +1013,13 @@ function buildPaymentBatchProcessingProgress(batch, actorNames) { key: stage.key, label: stage.label, state: stage.state, - actorName: stage.actorSource ? actorNames.get(actorIds[stage.actorSource]) ?? null : null, + actorName: stage.state === "current" && stage.key === responsibility?.stageKey + ? responsibility.actorName : stage.actorSource ? actorNames.get(actorIds[stage.actorSource]) ?? null : null, occurredAt: isoDateTimeOrNull(stage.occurredAt), result: stage.result, comment: stage.comment, - detail: stage.detail, + detail: stage.key === responsibility?.stageKey + ? [stage.detail, responsibility.detail].filter(Boolean).join(";") : stage.detail, targetHref: null, })), }; @@ -1022,12 +1045,13 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = { : []; const actorNames = new Map(actors.map((actor) => [actor.id, actor.name])); return { + ...(batch.entity_type === "loan_repayment" ? { loanRepayment: mapRepayment(await readLoanRepayment(tx, batch), batch) } : {}), ...mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true, includeExpenseItems, expenseItemsByEntityId, }), - processingProgress: buildPaymentBatchProcessingProgress(batch, actorNames), + processingProgress: buildPaymentBatchProcessingProgress(batch, actorNames, await loadPendingPaymentResponsibility(tx, batch)), }; } @@ -1178,43 +1202,6 @@ async function processPaidReimbursementFollowUps({ fastify, request, reimburseme const body = `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`; const targetPath = `/reimbursements/${reimbursementId}`; - try { - const erpGate = await evaluateFeatureFlag({ - prisma: fastify.prisma, - config: fastify.config, - flagKey: FEATURE_FLAG_KEYS.erpSync, - departmentId, - strictDepartment: true, - }); - - if (erpGate.enabled) { - await enqueueReimbursementJournalExport(fastify.prisma, fastify.config, { - reimbursement, - actorId: request.employee.id, - }); - } else { - await fastify.prisma.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG", - targetType: "reimbursement_form", - targetId: reimbursement.id, - extra: { - featureFlag: FEATURE_FLAG_KEYS.erpSync, - departmentId, - reason: erpGate.reason, - source: "payment_batch", - }, - }), - }); - } - } catch (error) { - fastify.log.error( - { err: error, reimbursementId }, - "Failed to enqueue reimbursement ERP follow-up after payment batch execution", - ); - } - try { const recipients = Array.from(new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean))); const notifications = await createNotificationsWithRecords(fastify.prisma, { @@ -1477,7 +1464,111 @@ async function readReceiptUploadFiles(fastify, request) { } async function paymentBatchesRoutes(fastify) { - fastify.get("/payment-batches/candidates", { preHandler: fastify.authenticate }, async (request, reply) => { + const authenticateBatch = async (request, reply) => { + await fastify.authenticate(request, reply); + if (!request.params?.id) return; + const route = request.routeOptions.url; + const reviewAction = request.method === "POST" && (route.endsWith("/review") || route.endsWith("/bank-failure-review")); + if (request.method === "GET") ensureBatchReadPermission(fastify, request); + else if (reviewAction) ensureBatchReviewPermission(fastify, request); + else if (route.endsWith("/payment-order")) ensurePaymentOrderExportPermission(fastify, request); + else if (route.endsWith("/payee-bank/reveal")) ensurePayeeBankRevealPermission(fastify, request); + else ensureBatchManagePermission(fastify, request); + const id = parseId(request.params.id); + const batch = await fastify.prisma.paymentBatch.findUnique({ where: { id }, include: { items: true } }); + if (batch?.entity_type !== "loan_repayment") return; + const finance = request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && + fastify.hasPermission(request.employee, "loan.manage"); + if (request.method !== "GET" && !reviewAction) ensureLoanManager(fastify, request); + if (!finance || reviewAction) { + const repayment = await readLoanRepayment(fastify.prisma, batch); + await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, repayment.lender.employee_id]); + } + }; + const REPAYMENT_SCHEMA = z.object({ + requestKey: UUID_SCHEMA, lenderId: UUID_SCHEMA, reason: z.string().trim().min(2).max(500), + allocations: z.array(z.object({ loanId: UUID_SCHEMA, amount: z.string().regex(/^\d{1,12}(\.\d{1,2})?$/) }).strict()).min(1).max(100), + }).strict(); + fastify.post("/payment-batches/:id/bank-failure-review", { preHandler: authenticateBatch }, async (request, reply) => { + const id = parseId(request.params.id); + const { attachmentId } = parseBody(z.object({ attachmentId: UUID_SCHEMA }).strict(), request.body); + const result = await fastify.prisma.$transaction(async (tx) => { + const batch = await lockBatchWithItems(tx, id); + if (batch.entity_type !== "loan_repayment") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); + await ensureFeatureEnabled(fastify); + await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch); + await confirmLoanBankFailure(tx, fastify.config, batch, request, attachmentId); + return mapRepayment(await readLoanRepayment(tx, batch), batch); + }); + return sendData(reply, request, result); + }); + fastify.post("/loan-repayments", { preHandler: fastify.authenticate }, async (request, reply) => { + ensureLoanManager(fastify, request); + await ensureFeatureEnabled(fastify); + const body = parseBody(REPAYMENT_SCHEMA, request.body); + await ensurePaymentBatchDepartmentsEnabled(fastify, fastify.prisma, [request.employee.department_id]); + let notificationTask = null; + const include = { allocations: true, batches: { include: { items: true, _count: { select: { receipt_attachments: true } } } } }; + const repayment = await createIdempotently(fastify.prisma, async (tx) => { + const facts = await prepareLoanRepayment(tx, fastify.config, request, body); + const number = await allocatePaymentBatchNumber(tx, fastify.config.appTimezone, facts.numbering); + const batch = await tx.paymentBatch.create({ data: { + batch_no: number.batchNo, name: `${number.businessDate} 本金还款`, entity_type: "loan_repayment", + loan_repayment_id: facts.repayment.id, legal_entity_id: facts.repayment.legal_entity_id, + payment_account_id: facts.account.id, payment_account_snapshot_json: facts.payer, + default_payment_channel: "bank", created_by: request.employee.id, updated_by: request.employee.id, + items: { create: { entity_id: facts.repayment.id, entity_code: facts.repayment.repayment_no, + entity_title: "借款本金归还", amount: facts.repayment.amount, currency_code: "CNY", + snapshot_json: { departmentId: request.employee.department_id, payee: facts.payee, loanYears: facts.years } } }, + } }); + await auditLoan(tx, request, "LOAN_REPAYMENT_SUBMITTED", facts.repayment.id, { paymentBatchId: batch.id, allocationCount: body.allocations.length }); + notificationTask = await createPaymentResponsibilityNotification(tx, { + recipientEmployeeIds: facts.reviewerIds, type: NOTIFICATION_TYPES.paymentBatchReviewRequested, + title: "待还款复核", body: "本金还款申请已提交,请核对原借入分配和付款资料。", batchId: batch.id, actorId: request.employee.id, + }); + return tx.loanRepayment.findUniqueOrThrow({ where: { id: facts.repayment.id }, include }); + }, (tx) => tx.loanRepayment.findUnique({ where: { submitted_by_request_key: { + submitted_by: request.employee.id, request_key: body.requestKey, + } }, include }), (saved) => repaymentMatchesInput(saved, body)); + if (notificationTask) await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask); + return sendData(reply, request, { item: await mapBatchForResponse(fastify.prisma, repayment.batches[0], [], fastify.config) }); + }); + + fastify.get("/loan-repayments", { preHandler: fastify.authenticate }, async (request, reply) => { + ensureLoanManager(fastify, request); + const query = parseBody(z.object({ page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(30) }).strict(), request.query ?? {}); + const [items, total] = await Promise.all([ + fastify.prisma.loanRepayment.findMany({ include: { ...REPAYMENT_INCLUDE, batches: true }, + orderBy: [{ submitted_at: "desc" }, { id: "asc" }], skip: (query.page - 1) * query.pageSize, take: query.pageSize }), + fastify.prisma.loanRepayment.count(), + ]); + return sendData(reply, request, { items: items.map((item) => mapRepayment(item, item.batches[0])), total }); + }); + + fastify.post("/loan-repayments/:repaymentId/accounting-handoff", { preHandler: fastify.authenticate }, async (request, reply) => { + ensureLoanManager(fastify, request); + const id = parseId(request.params.repaymentId); + const { reference } = parseBody(z.object({ reference: z.string().trim().min(2).max(200) }).strict(), request.body); + const batch = await fastify.prisma.paymentBatch.findFirst({ where: { loan_repayment_id: id } }); + if (!batch) throw buildNotFoundError(); + const result = await fastify.prisma.$transaction(async (tx) => { + const locked = await lockBatchWithItems(tx, batch.id); + const repayment = await readLoanRepayment(tx, locked); + if (locked.status !== "executed" || repayment.bank_result_state !== "confirmed_success") throw buildBusinessValidationError("loan.handoff_not_ready", {}, 409); + if (repayment.accounting_handoff_at) { + if (repayment.accounting_handoff_ref === reference) return mapRepayment(repayment, locked); + throw buildBusinessValidationError("loan.handoff_not_ready", {}, 409); + } + const updated = await tx.loanRepayment.update({ where: { id }, data: { + accounting_handoff_ref: reference, accounting_handoff_at: new Date(), accounting_handoff_by: request.employee.id, + }, include: REPAYMENT_INCLUDE }); + await auditLoan(tx, request, "LOAN_ACCOUNTING_HANDOFF_CONFIRMED", id); + return mapRepayment(updated, locked); + }); + return sendData(reply, request, result); + }); + fastify.get("/payment-batches/candidates", { preHandler: authenticateBatch }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchManagePermission(fastify, request); const parsed = CANDIDATES_QUERY_SCHEMA.safeParse(request.query ?? {}); @@ -1542,7 +1633,7 @@ async function paymentBatchesRoutes(fastify) { }); }); - fastify.post("/payment-batches/readiness", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.post("/payment-batches/readiness", { preHandler: authenticateBatch }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchManagePermission(fastify, request); const body = parseBody(CREATE_SCHEMA.pick({ entityType: true, entityIds: true, defaultPaymentChannel: true }), request.body); @@ -1553,7 +1644,7 @@ async function paymentBatchesRoutes(fastify) { return sendData(reply, request, resolvePaymentBatchCreationReadiness(facts)); }); - fastify.get("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.get("/payment-batches", { preHandler: authenticateBatch }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchReadPermission(fastify, request); const queryParsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {}); @@ -1573,7 +1664,15 @@ async function paymentBatchesRoutes(fastify) { const mapped = []; for (const batch of items) { - if (batch.entity_type === "reimbursement") { + if (batch.entity_type === "loan_repayment") { + const finance = request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && fastify.hasPermission(request.employee, "loan.manage"); + if (!finance) { + const repayment = await readLoanRepayment(fastify.prisma, batch); + try { await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, repayment.lender.employee_id]); } + catch (error) { if (error instanceof AppError && error.statusCode === 403) continue; throw error; } + } + } + if (["reimbursement", "loan_repayment"].includes(batch.entity_type)) { const departmentIds = await loadBatchSourceDepartmentIds(fastify.prisma, batch); const rollout = await evaluatePaymentBatchDepartments(fastify, fastify.prisma, departmentIds); if (!rollout.enabled) { @@ -1586,7 +1685,7 @@ async function paymentBatchesRoutes(fastify) { return sendData(reply, request, { items: mapped }); }); - fastify.post("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.post("/payment-batches", { preHandler: authenticateBatch }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchManagePermission(fastify, request); const body = parseBody(CREATE_SCHEMA, request.body); @@ -1676,7 +1775,7 @@ async function paymentBatchesRoutes(fastify) { return reply.code(201).send({ requestId: request.id, data: { item: mappedCreated } }); }); - fastify.get("/payment-batches/:id", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.get("/payment-batches/:id", { preHandler: authenticateBatch }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchReadPermission(fastify, request); const batchId = parseId(request.params?.id); @@ -1692,7 +1791,7 @@ async function paymentBatchesRoutes(fastify) { fastify.post( "/payment-batches/:id/payment-order", - { preHandler: fastify.authenticate }, + { preHandler: authenticateBatch }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensurePaymentOrderExportPermission(fastify, request); @@ -1709,6 +1808,12 @@ async function paymentBatchesRoutes(fastify) { result = await fastify.prisma.$transaction(async (tx) => { const batch = await lockBatchWithItems(tx, batchId); await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch); + if (batch.entity_type === "loan_repayment") { + const repayment = await validateLoanPayment(tx, batch, { lock: true, recheckApproval: true }); + if (repayment.bank_result_state === "confirmed_failed") throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); + downloadContext.purpose = "loan_principal_repayment"; + await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } }); + } const blockers = paymentOrderExportBlockers(batch); if (blockers.length) { throw new PaymentOrderXlsError(blockers[0].code, { blockers }); @@ -1740,7 +1845,7 @@ async function paymentBatchesRoutes(fastify) { }, }), }); - return { ...generated, batchNo: batch.batch_no }; + return { ...generated, batchNo: batch.batch_no, entityType: batch.entity_type }; }); } catch (error) { const resultCode = error instanceof PaymentOrderXlsError @@ -1776,7 +1881,7 @@ async function paymentBatchesRoutes(fastify) { const safeBatchNo = String(result.batchNo).replace(/[^A-Za-z0-9-]/g, "_"); reply.header("content-type", "application/vnd.ms-excel"); - reply.header("content-disposition", buildAttachmentContentDisposition(`报销制单_${safeBatchNo}.xls`)); + reply.header("content-disposition", buildAttachmentContentDisposition(`${result.entityType === "loan_repayment" ? "还款" : "报销"}制单_${safeBatchNo}.xls`)); reply.header("cache-control", "no-store"); reply.header("x-content-type-options", "nosniff"); return reply.send(result.buffer); @@ -1785,7 +1890,7 @@ async function paymentBatchesRoutes(fastify) { fastify.post( "/payment-batches/:id/items/:itemId/payee-bank/reveal", - { preHandler: fastify.authenticate }, + { preHandler: authenticateBatch }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensurePayeeBankRevealPermission(fastify, request); @@ -1822,7 +1927,7 @@ async function paymentBatchesRoutes(fastify) { }, ); - fastify.get("/payment-batches/:id/receipt-readiness", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.get("/payment-batches/:id/receipt-readiness", { preHandler: authenticateBatch }, async (request, reply) => { ensureBatchManagePermission(fastify, request); await ensureFeatureEnabled(fastify); const batchId = parseId(request.params?.id); @@ -1832,7 +1937,7 @@ async function paymentBatchesRoutes(fastify) { await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch); if (!["reviewed", "failed"].includes(batch.status)) return { ready: false }; try { - if (batch.entity_type === "reimbursement") await claimVerifiedReceipts(tx, fastify.config, batch, { readOnly: true }); + if (["reimbursement", "loan_repayment"].includes(batch.entity_type)) await claimVerifiedReceipts(tx, fastify.config, batch, { readOnly: true }); return { ready: true }; } catch (error) { if (!(error instanceof ReceiptVerificationError)) throw error; @@ -1842,7 +1947,7 @@ async function paymentBatchesRoutes(fastify) { return sendData(reply, request, { item: result }); }); - fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: authenticateBatch }, async (request, reply) => { ensureBatchReadPermission(fastify, request); await ensureFeatureEnabled(fastify); const batchId = parseId(request.params?.id); @@ -1885,7 +1990,7 @@ async function paymentBatchesRoutes(fastify) { return sendData(reply, request, { items: items.map((item) => mapReceiptAttachment(item, batch)) }); }); - fastify.post("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.post("/payment-batches/:id/receipt-attachments", { preHandler: authenticateBatch }, async (request, reply) => { ensureBatchManagePermission(fastify, request); await ensureFeatureEnabled(fastify); const batchId = parseId(request.params?.id); @@ -1921,6 +2026,11 @@ async function paymentBatchesRoutes(fastify) { throw buildBusinessValidationError("payment_batch.receipt_not_editable", { status: "receipt_attachment_not_editable" }, 409); } + if (lockedBatch.entity_type === "loan_repayment") { + const repayment = await readLoanRepayment(tx, lockedBatch); + if (repayment.bank_result_state === "confirmed_failed") throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); + await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } }); + } const created = []; for (const file of storedFiles) { created.push(await tx.paymentBatchReceiptAttachment.create({ @@ -1932,7 +2042,7 @@ async function paymentBatchesRoutes(fastify) { file_size: file.fileSize, file_sha256: file.fileSha256, uploaded_by: request.employee.id, - receipt_ocr_state: lockedBatch.entity_type === "reimbursement" && requiresBankReceipt(lockedBatch) ? "queued" : "legacy", + receipt_ocr_state: ["reimbursement", "loan_repayment"].includes(lockedBatch.entity_type) && requiresBankReceipt(lockedBatch) ? "queued" : "legacy", ...governanceCreateData({}, { governanceStatus: "approved", sensitivityLevel: SENSITIVITY_LEVEL.confidential, @@ -2000,7 +2110,7 @@ async function paymentBatchesRoutes(fastify) { return sendData(reply, request, { items: createdItems }); }); - fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/file", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/file", { preHandler: authenticateBatch }, async (request, reply) => { ensureBatchReadPermission(fastify, request); await ensureFeatureEnabled(fastify); const batchId = parseId(request.params?.id); @@ -2177,7 +2287,7 @@ async function paymentBatchesRoutes(fastify) { return reply.send(validated.buffer); }); - fastify.delete("/payment-batches/:id/receipt-attachments/:attachmentId", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.delete("/payment-batches/:id/receipt-attachments/:attachmentId", { preHandler: authenticateBatch }, async (request, reply) => { ensureBatchManagePermission(fastify, request); await ensureFeatureEnabled(fastify); parseId(request.params?.id); @@ -2185,7 +2295,7 @@ async function paymentBatchesRoutes(fastify) { throw buildBusinessValidationError("payment_batch.receipt_void_required", { receiptAttachment: "void_required" }, 409); }); - fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: authenticateBatch }, async (request, reply) => { ensureBatchReadPermission(fastify, request); await ensureFeatureEnabled(fastify); const batchId = parseId(request.params?.id); @@ -2204,7 +2314,7 @@ async function paymentBatchesRoutes(fastify) { } }); - fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: authenticateBatch }, async (request, reply) => { ensureBatchManagePermission(fastify, request); await ensureFeatureEnabled(fastify); const batchId = parseId(request.params?.id); @@ -2222,7 +2332,7 @@ async function paymentBatchesRoutes(fastify) { } }); - fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification/:proposalId/review", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification/:proposalId/review", { preHandler: authenticateBatch }, async (request, reply) => { ensureBatchReviewPermission(fastify, request); await ensureFeatureEnabled(fastify); const batchId = parseId(request.params?.id); @@ -2242,7 +2352,7 @@ async function paymentBatchesRoutes(fastify) { } }); - fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/ocr-retry", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/ocr-retry", { preHandler: authenticateBatch }, async (request, reply) => { ensureBatchManagePermission(fastify, request); await ensureFeatureEnabled(fastify); const batchId = parseId(request.params?.id); @@ -2251,7 +2361,7 @@ async function paymentBatchesRoutes(fastify) { const result = await fastify.prisma.$transaction(async (tx) => { const batch = await lockBatchWithItems(tx, batchId); await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch); - if (batch.entity_type !== "reimbursement" || !requiresBankReceipt(batch)) { + if (!["reimbursement", "loan_repayment"].includes(batch.entity_type) || !requiresBankReceipt(batch)) { throw buildBusinessValidationError("payment_batch.receipt_retry_unavailable", {}, 409); } if (!["reviewed", "failed"].includes(batch.status)) { @@ -2274,7 +2384,7 @@ async function paymentBatchesRoutes(fastify) { return sendData(reply, request, { item: result }); }); - fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/void", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/void", { preHandler: authenticateBatch }, async (request, reply) => { ensureBatchManagePermission(fastify, request); await ensureFeatureEnabled(fastify); const batchId = parseId(request.params?.id); @@ -2326,7 +2436,7 @@ async function paymentBatchesRoutes(fastify) { }); }); - fastify.patch("/payment-batches/:id/items/:itemId", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.patch("/payment-batches/:id/items/:itemId", { preHandler: authenticateBatch }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchManagePermission(fastify, request); const batchId = parseId(request.params?.id); @@ -2388,7 +2498,7 @@ async function paymentBatchesRoutes(fastify) { }); }); - fastify.post("/payment-batches/:id/review", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.post("/payment-batches/:id/review", { preHandler: authenticateBatch }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchReviewPermission(fastify, request); const batchId = parseId(request.params?.id); @@ -2397,6 +2507,7 @@ async function paymentBatchesRoutes(fastify) { const { batch: result, notificationTask } = await fastify.prisma.$transaction(async (tx) => { const batch = await lockBatchWithItems(tx, batchId); await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch); + if (batch.entity_type === "loan_repayment") await validateLoanPayment(tx, batch, { lock: true, reviewerId: request.employee.id }); const batchRisk = summarizeBatchRisk(batch); const isFirstReview = batch.status === "draft" && !batch.reviewed_at; @@ -2492,7 +2603,7 @@ async function paymentBatchesRoutes(fastify) { }); }); - fastify.post("/payment-batches/:id/execute", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.post("/payment-batches/:id/execute", { preHandler: authenticateBatch }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchManagePermission(fastify, request); const batchId = parseId(request.params?.id); @@ -2500,6 +2611,7 @@ async function paymentBatchesRoutes(fastify) { const result = await fastify.prisma.$transaction(async (tx) => { const batch = await lockBatchWithItems(tx, batchId); + if (batch.entity_type === "loan_repayment" && batch.status === "executed") return batch; const batchRisk = summarizeBatchRisk(batch); if (!["reviewed", "failed"].includes(batch.status)) { throw buildBusinessValidationError("payment_batch.batch_not_executable", { status: "only_reviewed_or_failed_executable" }, 409); @@ -2514,7 +2626,7 @@ async function paymentBatchesRoutes(fastify) { ); await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch); await ensureBankReceiptForExecutableBatch(tx, batch); - if (batch.entity_type === "reimbursement" && requiresBankReceipt(batch)) { + if (["reimbursement", "loan_repayment"].includes(batch.entity_type) && requiresBankReceipt(batch)) { try { await claimVerifiedReceipts(tx, fastify.config, batch); } catch (error) { @@ -2543,7 +2655,9 @@ async function paymentBatchesRoutes(fastify) { if (!paymentResult.idempotent) { successfulReimbursementIds.push(item.entity_id); } - } else { + } else if (batch.entity_type === "loan_repayment") { + await settleLoanRepayment(tx, batch, request); + } else if (batch.entity_type === "travel_order") { await executeTravelOrderPayment(tx, { batch, item, @@ -2564,6 +2678,7 @@ async function paymentBatchesRoutes(fastify) { finalItemStatuses.set(item.id, "executed"); attemptSuccessCount += 1; } catch (error) { + if (batch.entity_type === "loan_repayment") throw error; const recoverableBusinessError = error instanceof AppError && error.code === "VALID_001" && @@ -2678,7 +2793,7 @@ async function paymentBatchesRoutes(fastify) { }); }); - fastify.post("/payment-batches/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => { + fastify.post("/payment-batches/:id/cancel", { preHandler: authenticateBatch }, async (request, reply) => { await ensureFeatureEnabled(fastify); ensureBatchManagePermission(fastify, request); const batchId = parseId(request.params?.id); @@ -2692,6 +2807,7 @@ async function paymentBatchesRoutes(fastify) { if (["executed", "cancelled"].includes(batch.status)) { throw buildBusinessValidationError("payment_batch.batch_not_cancellable", { status: "batch_not_cancellable" }, 409); } + if (batch.entity_type === "loan_repayment") await cancelLoanRepayment(tx, batch, request, reason); const openItemIds = batch.items .filter((item) => ["pending", "failed"].includes(item.status)) .map((item) => item.id); diff --git a/server/src/routes/payroll.js b/server/src/routes/payroll.js index 61a51152..5b0fcedd 100644 --- a/server/src/routes/payroll.js +++ b/server/src/routes/payroll.js @@ -1,3 +1,6 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); +const { isValidDateOnlyString } = require("../utils/date"); const { z } = require("zod"); const { @@ -17,7 +20,7 @@ const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const UUID_SCHEMA = z.string().uuid(); -const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const PAY_MONTH_SCHEMA = z.string().regex(/^\d{4}-\d{2}$/); const MONEY_INPUT_SCHEMA = z.union([z.number(), z.string()]); const SALARY_ITEM_TYPE_SCHEMA = z.enum(["earning", "deduction"]); @@ -113,35 +116,26 @@ const PAYSLIP_PATCH_SCHEMA = z }); }); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("payroll.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } -function parseQuery(schema, query) { +function parseQuery(schema, query, reasonCode) { const parsed = schema.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } @@ -150,7 +144,7 @@ function parseMoneyList(items, fieldName) { return items.map((item, index) => { const normalized = normalizeMoneyInput(item[fieldName]); if (!normalized) { - throw buildValidationError({ + throw buildBusinessValidationError("payroll.amount_invalid", { items: { [index]: { [fieldName]: "invalid_money_amount", @@ -169,7 +163,7 @@ function parseMoneyList(items, fieldName) { function parseMonthOrThrow(monthText) { const month = parsePayMonth(monthText); if (!month) { - throw buildValidationError({ month: "invalid_month" }); + throw buildBusinessValidationError("payroll.month_invalid", { month: "invalid_month" }); } return month; } @@ -259,7 +253,7 @@ async function payrollRoutes(fastify) { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { - const body = parseBody(SALARY_ITEM_CREATE_SCHEMA, request.body); + const body = parseBody(SALARY_ITEM_CREATE_SCHEMA, request.body, "payroll.item_create_input_invalid"); try { const item = await fastify.prisma.salaryItem.create({ @@ -291,7 +285,7 @@ async function payrollRoutes(fastify) { return sendData(reply, request, mapSalaryItem(item)); } catch (error) { if (isUniqueConstraintError(error)) { - throw buildValidationError({ name: "duplicate" }); + throw buildBusinessValidationError("payroll.item_name_duplicate", { name: "duplicate" }); } throw error; } @@ -305,7 +299,7 @@ async function payrollRoutes(fastify) { }, async (request, reply) => { const itemId = parseId(request.params?.itemId); - const body = parseBody(SALARY_ITEM_PATCH_SCHEMA, request.body); + const body = parseBody(SALARY_ITEM_PATCH_SCHEMA, request.body, "payroll.item_update_input_invalid"); await ensureSalaryItemExists(fastify.prisma, itemId); try { @@ -338,7 +332,7 @@ async function payrollRoutes(fastify) { return sendData(reply, request, mapSalaryItem(item)); } catch (error) { if (isUniqueConstraintError(error)) { - throw buildValidationError({ name: "duplicate" }); + throw buildBusinessValidationError("payroll.item_name_duplicate", { name: "duplicate" }); } throw error; } @@ -380,7 +374,7 @@ async function payrollRoutes(fastify) { }, async (request, reply) => { const employeeId = parseId(request.params?.employeeId); - const body = parseBody(SALARY_PROFILE_UPSERT_SCHEMA, request.body); + const body = parseBody(SALARY_PROFILE_UPSERT_SCHEMA, request.body, "payroll.profile_input_invalid"); const normalizedItems = parseMoneyList(body.items, "monthlyAmount"); await ensureEmployeeExists(fastify.prisma, employeeId); @@ -392,7 +386,7 @@ async function payrollRoutes(fastify) { }); if (salaryItems.length !== salaryItemIds.length) { - throw buildValidationError({ items: "salary_item_not_found" }); + throw buildBusinessValidationError("payroll.item_not_found", { items: "salary_item_not_found" }); } const effectiveDate = parseDateOnly(body.effectiveDate); @@ -500,7 +494,7 @@ async function payrollRoutes(fastify) { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { - const query = parseQuery(PAYSLIP_QUERY_SCHEMA, request.query); + const query = parseQuery(PAYSLIP_QUERY_SCHEMA, request.query, "payroll.month_invalid"); const month = parseMonthOrThrow(query.month); const dataset = await loadMonthlyPayslips(fastify.prisma, month.startDate); @@ -523,7 +517,7 @@ async function payrollRoutes(fastify) { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { - const body = parseBody(PAYSLIP_GENERATE_SCHEMA, request.body); + const body = parseBody(PAYSLIP_GENERATE_SCHEMA, request.body, "payroll.month_invalid"); const month = parseMonthOrThrow(body.month); try { @@ -532,7 +526,7 @@ async function payrollRoutes(fastify) { where: { pay_month: month.startDate }, }); if (existingCount > 0) { - throw buildValidationError({ month: "payslips_month_exists" }, 409); + throw buildBusinessValidationError("payroll.month_already_generated", { month: "payslips_month_exists" }, 409); } const employees = await tx.employee.findMany({ @@ -546,7 +540,7 @@ async function payrollRoutes(fastify) { }); if (!employees.length) { - throw buildValidationError({ month: "no_active_employees" }, 409); + throw buildBusinessValidationError("payroll.active_employees_missing", { month: "no_active_employees" }, 409); } const salaryItems = sortSalaryItems( @@ -556,7 +550,7 @@ async function payrollRoutes(fastify) { ); if (!salaryItems.length) { - throw buildValidationError({ items: "salary_items_empty" }, 409); + throw buildBusinessValidationError("payroll.enabled_items_missing", { items: "salary_items_empty" }, 409); } const salaryRows = await tx.employeeSalary.findMany({ @@ -582,7 +576,8 @@ async function payrollRoutes(fastify) { const missingEmployees = employees.filter((item) => !latestDateByEmployee.has(item.id)); if (missingEmployees.length) { - throw buildValidationError( + throw buildBusinessValidationError( + "payroll.profiles_missing", { month: "salary_profile_missing", employees: missingEmployees.map((item) => ({ @@ -670,7 +665,7 @@ async function payrollRoutes(fastify) { }); } catch (error) { if (isUniqueConstraintError(error)) { - throw buildValidationError({ month: "payslips_month_exists" }, 409); + throw buildBusinessValidationError("payroll.month_already_generated", { month: "payslips_month_exists" }, 409); } throw error; } @@ -684,12 +679,12 @@ async function payrollRoutes(fastify) { }, async (request, reply) => { const payslipId = parseId(request.params?.payslipId); - const body = parseBody(PAYSLIP_PATCH_SCHEMA, request.body); + const body = parseBody(PAYSLIP_PATCH_SCHEMA, request.body, "payroll.payslip_update_input_invalid"); const normalizedItems = parseMoneyList(body.items, "amount"); const current = await ensurePayslipExists(fastify.prisma, payslipId); if (current.status !== "draft") { - throw buildValidationError({ payslip: "payslip_not_editable" }, 409); + throw buildBusinessValidationError("payroll.payslip_not_editable", { payslip: "payslip_not_editable" }, 409); } const salaryItemIds = normalizedItems.map((item) => item.salaryItemId); @@ -697,7 +692,7 @@ async function payrollRoutes(fastify) { where: { id: { in: salaryItemIds } }, }); if (salaryItems.length !== salaryItemIds.length) { - throw buildValidationError({ items: "salary_item_not_found" }); + throw buildBusinessValidationError("payroll.item_not_found", { items: "salary_item_not_found" }); } const salaryItemMap = new Map(salaryItems.map((item) => [item.id, item])); @@ -757,7 +752,7 @@ async function payrollRoutes(fastify) { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { - const body = parseBody(PAYSLIP_ISSUE_SCHEMA, request.body); + const body = parseBody(PAYSLIP_ISSUE_SCHEMA, request.body, "payroll.month_invalid"); const month = parseMonthOrThrow(body.month); const summary = await fastify.prisma.$transaction(async (tx) => { @@ -770,12 +765,12 @@ async function payrollRoutes(fastify) { }); if (!payslips.length) { - throw buildValidationError({ month: "payslips_not_generated" }, 409); + throw buildBusinessValidationError("payroll.month_not_generated", { month: "payslips_not_generated" }, 409); } const draftIds = payslips.filter((item) => item.status === "draft").map((item) => item.id); if (!draftIds.length) { - throw buildValidationError({ month: "payslips_already_issued" }, 409); + throw buildBusinessValidationError("payroll.month_already_issued", { month: "payslips_already_issued" }, 409); } await tx.payslip.updateMany({ diff --git a/server/src/routes/performance.js b/server/src/routes/performance.js index 94626ddd..871002e2 100644 --- a/server/src/routes/performance.js +++ b/server/src/routes/performance.js @@ -1,13 +1,15 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { z } = require("zod"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); -const { currentDateString } = require("../utils/date"); +const { currentDateString, isValidDateOnlyString } = require("../utils/date"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const UUID_SCHEMA = z.string().uuid(); -const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const CYCLE_TYPE_SCHEMA = z.enum(["monthly", "quarterly", "semi_annual", "annual"]); const CYCLE_STATUS_SCHEMA = z.enum(["draft", "active", "completed"]); const DIMENSION_TYPE_SCHEMA = z.enum([ @@ -104,22 +106,18 @@ const REVIEW_DETAIL_INCLUDE = { const DIMENSION_ORDER = ["work_quality", "work_efficiency", "teamwork", "initiative", "professional_skill"]; -function buildValidationError(details, statusCode = 400) { - return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); -} - function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("performance.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } @@ -138,7 +136,7 @@ function toNumber(value) { function validateCycleDates(startDate, endDate) { if (startDate && endDate && startDate > endDate) { - throw buildValidationError({ endDate: "must_be_after_or_equal_startDate" }); + throw buildBusinessValidationError("performance.cycle_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" }); } } @@ -151,7 +149,7 @@ async function ensureFeatureEnabled(fastify, request) { strictDepartment: false, }); if (!gate.enabled) { - throw buildValidationError({ featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409); + throw buildBusinessValidationError("performance.disabled", { featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409); } } @@ -246,7 +244,7 @@ async function ensureNoOtherActiveCycle(prisma, excludedCycleId = null) { }, }); if (existing) { - throw buildValidationError({ cycle: "active_cycle_exists", activeCycleId: existing.id }, 409); + throw buildBusinessValidationError("performance.active_cycle_exists", { cycle: "active_cycle_exists", activeCycleId: existing.id }, 409); } } @@ -397,7 +395,7 @@ async function performanceRoutes(fastify) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: PERFORMANCE_MANAGE_PERMISSION } }); } - const body = parseBody(CYCLE_CREATE_SCHEMA, request.body); + const body = parseBody(CYCLE_CREATE_SCHEMA, request.body, "performance.cycle_create_input_invalid"); validateCycleDates(body.startDate, body.endDate); const cycle = await fastify.prisma.performanceReviewCycle.create({ @@ -431,14 +429,14 @@ async function performanceRoutes(fastify) { } const cycleId = parseId(request.params?.cycleId); - const body = parseBody(CYCLE_PATCH_SCHEMA, request.body); + const body = parseBody(CYCLE_PATCH_SCHEMA, request.body, "performance.cycle_update_input_invalid"); const current = await ensureCycleExists(fastify.prisma, cycleId); const nextStartDate = body.startDate ?? current.start_date?.toISOString?.()?.slice(0, 10) ?? null; const nextEndDate = body.endDate ?? current.end_date?.toISOString?.()?.slice(0, 10) ?? null; validateCycleDates(nextStartDate, nextEndDate); if (current.status === "completed") { - throw buildValidationError({ cycle: "completed_cycle_locked" }, 409); + throw buildBusinessValidationError("performance.completed_cycle_locked", { cycle: "completed_cycle_locked" }, 409); } let action = "PERFORMANCE_REVIEW_CYCLE_UPDATED"; @@ -447,7 +445,7 @@ async function performanceRoutes(fastify) { if (current.status === "active") { const invalidKeys = Object.keys(body).filter((key) => key !== "status"); if (invalidKeys.length > 0 || body.status !== "completed") { - throw buildValidationError({ cycle: "active_cycle_only_supports_complete" }, 409); + throw buildBusinessValidationError("performance.active_cycle_only_complete", { cycle: "active_cycle_only_supports_complete" }, 409); } action = "PERFORMANCE_REVIEW_CYCLE_COMPLETED"; data = { @@ -457,7 +455,7 @@ async function performanceRoutes(fastify) { }; } else { if (body.status === "completed") { - throw buildValidationError({ cycle: "draft_cycle_cannot_complete_directly" }, 409); + throw buildBusinessValidationError("performance.draft_cannot_complete", { cycle: "draft_cycle_cannot_complete_directly" }, 409); } if (body.status === "active") { await ensureNoOtherActiveCycle(fastify.prisma, cycleId); @@ -483,7 +481,7 @@ async function performanceRoutes(fastify) { }); } catch (error) { if (body.status === "active" && isPrismaUniqueConstraintError(error)) { - throw buildValidationError({ cycle: "active_cycle_exists" }, 409); + throw buildBusinessValidationError("performance.active_cycle_exists", { cycle: "active_cycle_exists" }, 409); } throw error; } @@ -545,7 +543,7 @@ async function performanceRoutes(fastify) { await ensureFeatureEnabled(fastify, request); const cycleId = parseId(request.params?.cycleId); const employeeId = parseId(request.params?.employeeId); - const body = parseBody(REVIEW_UPSERT_SCHEMA, request.body); + const body = parseBody(REVIEW_UPSERT_SCHEMA, request.body, "performance.review_input_invalid"); const employee = await ensureEmployeeExists(fastify.prisma, employeeId); const viewerRole = resolveViewerRole(fastify, request, employee); @@ -559,7 +557,7 @@ async function performanceRoutes(fastify) { } if (employee.status !== "active") { - throw buildValidationError({ employee: "inactive_employee_not_reviewable" }, 409); + throw buildBusinessValidationError("performance.employee_inactive", { employee: "inactive_employee_not_reviewable" }, 409); } await ensureCycleExists(fastify.prisma, cycleId); @@ -567,7 +565,7 @@ async function performanceRoutes(fastify) { const savedReview = await fastify.prisma.$transaction(async (tx) => { const lockedCycle = await lockCycleForUpdate(tx, cycleId); if (lockedCycle.status !== "active") { - throw buildValidationError({ cycle: "active_cycle_required" }, 409); + throw buildBusinessValidationError("performance.active_cycle_required", { cycle: "active_cycle_required" }, 409); } const existing = await tx.performanceReview.findFirst({ @@ -647,7 +645,6 @@ module.exports = performanceRoutes; module.exports.__testables = { buildCycleOverview, buildEmployeeTimeline, - buildValidationError, mapCycle, mapEmployeeSummary, mapReview, diff --git a/server/src/routes/portal.js b/server/src/routes/portal.js index f9c762bd..034b22ef 100644 --- a/server/src/routes/portal.js +++ b/server/src/routes/portal.js @@ -1,3 +1,4 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { AppError } = require("../utils/AppError"); const { buildTeamPresenceSummary, @@ -76,19 +77,10 @@ async function ensureFeatureEnabled(fastify, request) { strictDepartment: false, }); if (!gate.enabled) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { featureFlag: "portal_home_v1_disabled", reason: gate.reason }, - }); + throw buildBusinessValidationError("portal.disabled", { featureFlag: "portal_home_v1_disabled", reason: gate.reason }, 409); } } -function buildValidationError(details, statusCode = 400) { - return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); -} - async function evaluateOptionalFlag(fastify, request, flagKey) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, diff --git a/server/src/routes/presence.js b/server/src/routes/presence.js index 5bc244a4..2b9bfe00 100644 --- a/server/src/routes/presence.js +++ b/server/src/routes/presence.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { HISTORY_STATUS_VALUES, PRESENCE_TIME_RANGE_VALUES, @@ -9,7 +11,6 @@ const { buildPresenceHistoryState, buildPresenceState, buildPresenceSummary, - buildPresenceValidationError, buildTeamPresenceHistory, filterHistoryItemsByStatus, mapPresenceRow, @@ -18,11 +19,11 @@ const { resolvePresenceHistoryRange, } = require("../services/presenceHistory"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); -const { parseDateOnlyString, currentDateString } = require("../utils/date"); +const { parseDateOnlyString, currentDateString, isValidDateOnlyString } = require("../utils/date"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); -const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const QUERY_SCHEMA = z.object({ date: DATE_SCHEMA.optional() }); const WRITE_SCHEMA = z .object({ @@ -53,10 +54,10 @@ const TEAM_OVERRIDE_BODY_SCHEMA = z }) .strict(); -function parseInput(schema, value) { +function parseInput(schema, value, reasonCode) { const parsed = schema.safeParse(value ?? {}); if (!parsed.success) { - throw buildPresenceValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } @@ -74,14 +75,14 @@ async function ensureFeatureEnabled(fastify, request) { strictDepartment: false, }); if (!gate.enabled) { - throw buildPresenceValidationError({ featureFlag: "presence_board_v1_disabled", reason: gate.reason }, 409); + throw buildBusinessValidationError("presence.feature_disabled", { featureFlag: "presence_board_v1_disabled", reason: gate.reason }, 409); } } async function presenceRoutes(fastify) { fastify.get("/presence/me", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const query = parseInput(QUERY_SCHEMA, request.query); + const query = parseInput(QUERY_SCHEMA, request.query, "presence.query_invalid"); const dateString = resolveDateInput(query.date, fastify.config.appTimezone); const map = await buildPresenceState(fastify.prisma, [request.employee.id], dateString); return sendData(reply, request, { item: map.get(request.employee.id) ?? null, date: dateString }); @@ -89,11 +90,11 @@ async function presenceRoutes(fastify) { fastify.put("/presence/me", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const body = parseInput(WRITE_SCHEMA, request.body); + const body = parseInput(WRITE_SCHEMA, request.body, "presence.write_input_invalid"); const dateString = resolveDateInput(body.date, fastify.config.appTimezone); const before = await resolvePresenceForEmployee(fastify.prisma, request.employee.id, dateString); if (before?.source === "leave_sync" && before.status === "leave") { - throw buildPresenceValidationError({ date: "covered_by_approved_leave" }, 409); + throw buildBusinessValidationError("presence.approved_leave_readonly", { date: "covered_by_approved_leave" }, 409); } const record = await fastify.prisma.employeePresence.upsert({ @@ -126,7 +127,7 @@ async function presenceRoutes(fastify) { fastify.get("/presence/team", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const query = parseInput(QUERY_SCHEMA, request.query); + const query = parseInput(QUERY_SCHEMA, request.query, "presence.query_invalid"); const dateString = resolveDateInput(query.date, fastify.config.appTimezone); const employees = await listActivePresenceEmployees(fastify.prisma); const map = await buildPresenceState( @@ -147,7 +148,7 @@ async function presenceRoutes(fastify) { fastify.get("/presence/me/history", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const query = parseInput(HISTORY_QUERY_SCHEMA, request.query); + const query = parseInput(HISTORY_QUERY_SCHEMA, request.query, "presence.history_query_invalid"); const range = resolvePresenceHistoryRange(query, fastify.config.appTimezone); const history = await buildMyPresenceHistory(fastify.prisma, request.employee.id, range.dateStrings); return sendData(reply, request, { @@ -160,7 +161,7 @@ async function presenceRoutes(fastify) { fastify.get("/presence/team/history", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const query = parseInput(TEAM_HISTORY_QUERY_SCHEMA, request.query); + const query = parseInput(TEAM_HISTORY_QUERY_SCHEMA, request.query, "presence.team_history_query_invalid"); const range = resolvePresenceHistoryRange(query, fastify.config.appTimezone); const history = await buildTeamPresenceHistory(fastify.prisma, { dateStrings: range.dateStrings, @@ -184,8 +185,8 @@ async function presenceRoutes(fastify) { }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const params = parseInput(TEAM_OVERRIDE_PARAMS_SCHEMA, request.params); - const body = parseInput(TEAM_OVERRIDE_BODY_SCHEMA, request.body); + const params = parseInput(TEAM_OVERRIDE_PARAMS_SCHEMA, request.params, "presence.employee_id_invalid"); + const body = parseInput(TEAM_OVERRIDE_BODY_SCHEMA, request.body, "presence.override_input_invalid"); const employee = await fastify.prisma.employee.findUnique({ where: { id: params.employeeId }, @@ -243,7 +244,7 @@ async function presenceRoutes(fastify) { fastify.get("/presence/summary", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const query = parseInput(QUERY_SCHEMA, request.query); + const query = parseInput(QUERY_SCHEMA, request.query, "presence.query_invalid"); const dateString = resolveDateInput(query.date, fastify.config.appTimezone); const employeeIds = (await listActivePresenceEmployees(fastify.prisma)).map((item) => item.id); const map = await buildPresenceState(fastify.prisma, employeeIds, dateString); diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js index b2ebae56..2c4e72db 100644 --- a/server/src/routes/projects.js +++ b/server/src/routes/projects.js @@ -1,6 +1,9 @@ +const { resolveNumberingContext, issueBusinessNumber, reserveProjectNumber } = require("../services/businessNumbering"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); @@ -114,22 +117,18 @@ const PROJECT_CLOSURE_TEMPLATE = [ const PROJECT_COMMUNICATION_EDIT_WINDOW_MS = 24 * 60 * 60 * 1000; -function buildValidationError(details, statusCode = 400) { - return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); -} - -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } -function parseId(value, key = "id") { +function parseId(value, key = "id", reasonCode = "project.id_invalid") { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ [key]: "invalid_uuid" }); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, { [key]: "invalid_uuid" }); } return parsed.data; } @@ -138,7 +137,7 @@ function parseDateOnly(value) { return value ? new Date(`${value}T00:00:00.000Z`) : null; } -async function ensureFeatureEnabled(fastify, request, flagKey) { +async function ensureFeatureEnabled(fastify, request, flagKey, reasonCode) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, @@ -147,7 +146,7 @@ async function ensureFeatureEnabled(fastify, request, flagKey) { strictDepartment: false, }); if (!gate.enabled) { - throw buildValidationError({ featureFlag: `${flagKey}_disabled`, reason: gate.reason }, 409); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, { featureFlag: `${flagKey}_disabled`, reason: gate.reason }, 409); } } @@ -268,7 +267,6 @@ function mapEmployeeBasic(employee) { return { id: employee.id, employeeNo: employee.employee_no, name: employee.name }; } - function startOfUtcDay(value = new Date()) { return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`); } @@ -485,10 +483,10 @@ function mapProjectDetail(project) { async function projectsRoutes(fastify) { fastify.get("/projects", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const parsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("project.query_invalid", parsed.error.flatten()); } const mine = parsed.data.mine === "1"; const where = { @@ -559,33 +557,43 @@ async function projectsRoutes(fastify) { }); fastify.post("/projects", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); if (!fastify.hasPermission(request.employee, "project.manage") && !fastify.hasPermission(request.employee, "system.settings") && !fastify.hasPermission(request.employee, "report.dashboard.view")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } }); } - const body = parseBody(PROJECT_CREATE_SCHEMA, request.body); - const created = await fastify.prisma.project.create({ - data: { - project_no: body.projectNo || await buildProjectNo(fastify.prisma), - name: body.name, - client_name: body.clientName, - client_alias: body.clientAlias ?? null, - owner_employee_id: body.ownerEmployeeId ?? request.employee.id, - manager_employee_id: body.managerEmployeeId ?? null, - status: body.status ?? "draft", - starts_at: parseDateOnly(body.startsAt), - target_end_at: parseDateOnly(body.targetEndAt), - actual_end_at: parseDateOnly(body.actualEndAt), - summary: body.summary ?? null, - is_confidential: body.isConfidential ?? false, - members: { - create: { - employee_id: body.ownerEmployeeId ?? request.employee.id, - role: "owner", + const body = parseBody(PROJECT_CREATE_SCHEMA, request.body, "project.create_input_invalid"); + const created = await fastify.prisma.$transaction(async (tx) => { + let projectNo = body.projectNo; + if (projectNo) await reserveProjectNumber(tx, projectNo); + else { + const numbering = await resolveNumberingContext(tx); + projectNo = numbering.version === 2 + ? (await issueBusinessNumber(tx, { family: "project", ...numbering, timeZone: fastify.config.appTimezone })).documentNumber + : await buildProjectNo(tx); + } + return tx.project.create({ + data: { + project_no: projectNo, + name: body.name, + client_name: body.clientName, + client_alias: body.clientAlias ?? null, + owner_employee_id: body.ownerEmployeeId ?? request.employee.id, + manager_employee_id: body.managerEmployeeId ?? null, + status: body.status ?? "draft", + starts_at: parseDateOnly(body.startsAt), + target_end_at: parseDateOnly(body.targetEndAt), + actual_end_at: parseDateOnly(body.actualEndAt), + summary: body.summary ?? null, + is_confidential: body.isConfidential ?? false, + members: { + create: { + employee_id: body.ownerEmployeeId ?? request.employee.id, + role: "owner", + }, }, }, - }, - include: buildProjectDetailInclude(), + include: buildProjectDetailInclude(), + }); }); await writeAudit(fastify, request, "PROJECT_CREATED", created.id, { projectNo: created.project_no }); const payload = await buildProjectDetailPayload(fastify, request, created); @@ -593,39 +601,42 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); return sendData(reply, request, { item: await buildProjectDetailPayload(fastify, request, project) }); }); fastify.patch("/projects/:id", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); - const body = parseBody(PROJECT_PATCH_SCHEMA, request.body); + const body = parseBody(PROJECT_PATCH_SCHEMA, request.body, "project.update_input_invalid"); const wantsClosureWarning = ["completed", "archived"].includes(body.status ?? ""); if (wantsClosureWarning) { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist, "project.closure_disabled"); await ensureClosureChecklistBootstrapped(fastify.prisma, projectId); } - const updated = await fastify.prisma.project.update({ - where: { id: projectId }, - data: { - ...(body.projectNo !== undefined ? { project_no: body.projectNo } : {}), - ...(body.name !== undefined ? { name: body.name } : {}), - ...(body.clientName !== undefined ? { client_name: body.clientName } : {}), - ...(body.clientAlias !== undefined ? { client_alias: body.clientAlias ?? null } : {}), - ...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}), - ...(body.managerEmployeeId !== undefined ? { manager_employee_id: body.managerEmployeeId ?? null } : {}), - ...(body.status !== undefined ? { status: body.status } : {}), - ...(body.startsAt !== undefined ? { starts_at: parseDateOnly(body.startsAt) } : {}), - ...(body.targetEndAt !== undefined ? { target_end_at: parseDateOnly(body.targetEndAt) } : {}), - ...(body.actualEndAt !== undefined ? { actual_end_at: parseDateOnly(body.actualEndAt) } : {}), - ...(body.summary !== undefined ? { summary: body.summary ?? null } : {}), - ...(body.isConfidential !== undefined ? { is_confidential: body.isConfidential } : {}), - }, - include: buildProjectDetailInclude(), + const updated = await fastify.prisma.$transaction(async (tx) => { + if (body.projectNo !== undefined) await reserveProjectNumber(tx, body.projectNo); + return tx.project.update({ + where: { id: projectId }, + data: { + ...(body.projectNo !== undefined ? { project_no: body.projectNo } : {}), + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.clientName !== undefined ? { client_name: body.clientName } : {}), + ...(body.clientAlias !== undefined ? { client_alias: body.clientAlias ?? null } : {}), + ...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}), + ...(body.managerEmployeeId !== undefined ? { manager_employee_id: body.managerEmployeeId ?? null } : {}), + ...(body.status !== undefined ? { status: body.status } : {}), + ...(body.startsAt !== undefined ? { starts_at: parseDateOnly(body.startsAt) } : {}), + ...(body.targetEndAt !== undefined ? { target_end_at: parseDateOnly(body.targetEndAt) } : {}), + ...(body.actualEndAt !== undefined ? { actual_end_at: parseDateOnly(body.actualEndAt) } : {}), + ...(body.summary !== undefined ? { summary: body.summary ?? null } : {}), + ...(body.isConfidential !== undefined ? { is_confidential: body.isConfidential } : {}), + }, + include: buildProjectDetailInclude(), + }); }); await writeAudit(fastify, request, "PROJECT_UPDATED", projectId, { fields: Object.keys(body) }); const item = await buildProjectDetailPayload(fastify, request, updated); @@ -636,7 +647,7 @@ async function projectsRoutes(fastify) { }); fastify.post("/projects/:id/archive", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); let closureWarning = null; @@ -654,17 +665,17 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/members", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); return sendData(reply, request, { items: mapProjectDetail(project).members }); }); fastify.post("/projects/:id/members", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); - const body = parseBody(MEMBER_CREATE_SCHEMA, request.body); + const body = parseBody(MEMBER_CREATE_SCHEMA, request.body, "project.member_create_input_invalid"); const item = await fastify.prisma.projectMember.upsert({ where: { project_id_employee_id: { project_id: projectId, employee_id: body.employeeId } }, update: { role: body.role ?? "member", left_at: null }, @@ -676,11 +687,11 @@ async function projectsRoutes(fastify) { }); fastify.patch("/projects/:id/members/:memberId", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); - const memberId = parseId(request.params?.memberId, "memberId"); - const body = parseBody(MEMBER_PATCH_SCHEMA, request.body); + const memberId = parseId(request.params?.memberId, "memberId", "project.member_id_invalid"); + const body = parseBody(MEMBER_PATCH_SCHEMA, request.body, "project.member_update_input_invalid"); const updated = await fastify.prisma.projectMember.update({ where: { id: memberId }, data: { @@ -693,17 +704,17 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/milestones", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); return sendData(reply, request, { items: mapProjectDetail(project).milestones }); }); fastify.post("/projects/:id/milestones", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); - const body = parseBody(MILESTONE_CREATE_SCHEMA, request.body); + const body = parseBody(MILESTONE_CREATE_SCHEMA, request.body, "project.milestone_create_input_invalid"); const created = await fastify.prisma.projectMilestone.create({ data: { project_id: projectId, @@ -720,11 +731,11 @@ async function projectsRoutes(fastify) { }); fastify.patch("/projects/:id/milestones/:milestoneId", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); - const milestoneId = parseId(request.params?.milestoneId, "milestoneId"); - const body = parseBody(MILESTONE_PATCH_SCHEMA, request.body); + const milestoneId = parseId(request.params?.milestoneId, "milestoneId", "project.milestone_id_invalid"); + const body = parseBody(MILESTONE_PATCH_SCHEMA, request.body, "project.milestone_update_input_invalid"); const updated = await fastify.prisma.projectMilestone.update({ where: { id: milestoneId }, data: { @@ -741,17 +752,17 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/tasks", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); return sendData(reply, request, { items: mapProjectDetail(project).tasks }); }); fastify.post("/projects/:id/tasks", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); - const body = parseBody(TASK_CREATE_SCHEMA, request.body); + const body = parseBody(TASK_CREATE_SCHEMA, request.body, "project.task_create_input_invalid"); const created = await fastify.prisma.projectTask.create({ data: { project_id: projectId, @@ -770,11 +781,11 @@ async function projectsRoutes(fastify) { }); fastify.patch("/projects/:id/tasks/:taskId", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); - const taskId = parseId(request.params?.taskId, "taskId"); - const body = parseBody(TASK_PATCH_SCHEMA, request.body); + const taskId = parseId(request.params?.taskId, "taskId", "project.task_id_invalid"); + const body = parseBody(TASK_PATCH_SCHEMA, request.body, "project.task_update_input_invalid"); const updated = await fastify.prisma.projectTask.update({ where: { id: taskId }, data: { @@ -792,17 +803,17 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/risks", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); return sendData(reply, request, { items: mapProjectDetail(project).risks }); }); fastify.post("/projects/:id/risks", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); - const body = parseBody(RISK_CREATE_SCHEMA, request.body); + const body = parseBody(RISK_CREATE_SCHEMA, request.body, "project.risk_create_input_invalid"); const created = await fastify.prisma.projectRisk.create({ data: { project_id: projectId, @@ -818,11 +829,11 @@ async function projectsRoutes(fastify) { }); fastify.patch("/projects/:id/risks/:riskId", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain, "project.feature_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); - const riskId = parseId(request.params?.riskId, "riskId"); - const body = parseBody(RISK_PATCH_SCHEMA, request.body); + const riskId = parseId(request.params?.riskId, "riskId", "project.risk_id_invalid"); + const body = parseBody(RISK_PATCH_SCHEMA, request.body, "project.risk_update_input_invalid"); const updated = await fastify.prisma.projectRisk.update({ where: { id: riskId }, data: { @@ -838,7 +849,7 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/expenses/summary", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking, "project.linking_disabled"); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.reimbursementForm.findMany({ @@ -851,7 +862,7 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/travel/summary", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking, "project.linking_disabled"); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.travelRequest.findMany({ @@ -864,7 +875,7 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/purchase-requests/summary", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking, "project.linking_disabled"); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.purchaseRequest.findMany({ @@ -887,7 +898,7 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/weekly-reports", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking, "project.linking_disabled"); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.projectLinkOnWeeklyReport.findMany({ @@ -899,7 +910,7 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/communication-logs", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog, "project.communication_disabled"); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.projectCommunicationLog.findMany({ @@ -912,10 +923,10 @@ async function projectsRoutes(fastify) { }); fastify.post("/projects/:id/communication-logs", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog, "project.communication_disabled"); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); - const body = parseBody(COMMUNICATION_LOG_CREATE_SCHEMA, request.body); + const body = parseBody(COMMUNICATION_LOG_CREATE_SCHEMA, request.body, "project.communication_log_create_input_invalid"); const created = await fastify.prisma.projectCommunicationLog.create({ data: { project_id: projectId, @@ -935,9 +946,9 @@ async function projectsRoutes(fastify) { }); fastify.patch("/projects/:id/communication-logs/:logId", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog, "project.communication_disabled"); const projectId = parseId(request.params?.id); - const logId = parseId(request.params?.logId, "logId"); + const logId = parseId(request.params?.logId, "logId", "project.communication_log_id_invalid"); const project = await ensureReadableProject(fastify, request, projectId); const current = await fastify.prisma.projectCommunicationLog.findUnique({ where: { id: logId }, @@ -949,7 +960,7 @@ async function projectsRoutes(fastify) { if (!canEditCommunicationLog(fastify, request.employee, project, current)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } }); } - const body = parseBody(COMMUNICATION_LOG_PATCH_SCHEMA, request.body); + const body = parseBody(COMMUNICATION_LOG_PATCH_SCHEMA, request.body, "project.communication_log_update_input_invalid"); const updated = await fastify.prisma.projectCommunicationLog.update({ where: { id: logId }, data: { @@ -968,7 +979,7 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/closure-checklist", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist, "project.closure_disabled"); const projectId = parseId(request.params?.id); const project = await ensureReadableProject(fastify, request, projectId); await ensureClosureChecklistBootstrapped(fastify.prisma, projectId); @@ -982,11 +993,11 @@ async function projectsRoutes(fastify) { }); fastify.patch("/projects/:id/closure-checklist/:itemId", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist, "project.closure_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); await ensureClosureChecklistBootstrapped(fastify.prisma, projectId); - const itemId = parseId(request.params?.itemId, "itemId"); + const itemId = parseId(request.params?.itemId, "itemId", "project.closure_checklist_id_invalid"); const current = await fastify.prisma.projectClosureChecklistItem.findUnique({ where: { id: itemId }, include: { completer: { select: { id: true, employee_no: true, name: true } } }, @@ -994,11 +1005,11 @@ async function projectsRoutes(fastify) { if (!current || current.project_id !== projectId) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } - const body = parseBody(CLOSURE_CHECKLIST_PATCH_SCHEMA, request.body); + const body = parseBody(CLOSURE_CHECKLIST_PATCH_SCHEMA, request.body, "project.closure_checklist_update_input_invalid"); const nextStatus = body.status ?? current.status; const nextNote = body.note !== undefined ? (body.note ?? null) : current.note; if (nextStatus === "waived" && !String(nextNote ?? "").trim()) { - throw buildValidationError({ note: "waived_requires_note" }); + throw buildBusinessValidationError("project.waiver_note_required", { note: "waived_requires_note" }); } const completedMeta = nextStatus === "pending" ? { completed_at: null, completed_by: null } @@ -1017,7 +1028,7 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/deliveries", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking, "project.linking_disabled"); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.projectDeliveryRecord.findMany({ @@ -1029,10 +1040,10 @@ async function projectsRoutes(fastify) { }); fastify.post("/projects/:id/deliveries", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking, "project.linking_disabled"); const projectId = parseId(request.params?.id); await ensureManageableProject(fastify, request, projectId); - const body = parseBody(DELIVERY_CREATE_SCHEMA, request.body); + const body = parseBody(DELIVERY_CREATE_SCHEMA, request.body, "project.delivery_create_input_invalid"); const created = await fastify.prisma.projectDeliveryRecord.create({ data: { project_id: projectId, @@ -1048,7 +1059,7 @@ async function projectsRoutes(fastify) { }); fastify.get("/projects/:id/document-refs", { preHandler: fastify.authenticate }, async (request, reply) => { - await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.confidentialDocRefs); + await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.confidentialDocRefs, "project.document_refs_disabled"); const projectId = parseId(request.params?.id); await ensureReadableProject(fastify, request, projectId); const items = await fastify.prisma.confidentialDocumentRef.findMany({ where: { project_id: projectId }, orderBy: { updated_at: "desc" } }); diff --git a/server/src/routes/purchaseRequests.js b/server/src/routes/purchaseRequests.js index 548a4bc0..3d155a6f 100644 --- a/server/src/routes/purchaseRequests.js +++ b/server/src/routes/purchaseRequests.js @@ -1,9 +1,12 @@ +const { resolveNumberingContext } = require("../services/businessNumbering"); +const { getErrorReason } = require("../constants/errorReasons"); const { z } = require("zod"); +const { Prisma } = require("@prisma/client"); const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); -const { parseDateOnlyString } = require("../utils/date"); +const { parseDateOnlyString, isValidDateOnlyString } = require("../utils/date"); const { sendData } = require("../utils/reply"); const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow"); const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); @@ -15,12 +18,6 @@ const { createRiskFlagsFromPrecheck, resolveEntityPrecheck, } = require("../services/feeControl"); -const { - PROCUREMENT_ERP_TARGETS, - enqueuePurchaseAccrualJournalExport, - enqueuePurchaseArchiveSummaryExport, - loadPurchaseRequestErpContext, -} = require("../services/integrations/erpProcurement"); const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget"); const { sendNotificationEmailSafe } = require("../services/email"); const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications"); @@ -48,8 +45,18 @@ const { sanitizeDownloadReason, } = require("../services/businessAttachmentGovernance"); +const PURCHASE_ATTACHMENT_POLICY_REASONS = Object.freeze({ + "dangerous_filename": "purchase_attachment.dangerous_filename", + "corrupt_content": "purchase_attachment.corrupt_content", + "multiple_pages": "purchase_attachment.multiple_pages", + "pixel_limit": "purchase_attachment.pixel_limit", + "type_mismatch": "purchase_attachment.type_mismatch", + "empty_file": "purchase_attachment.empty_file", + "size_limit": "purchase_attachment.too_large" +}); + const UUID_SCHEMA = z.string().uuid(); -const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(?:\.\d{1,2})?$/); const PURCHASE_STATUS_SCHEMA = z.enum(["draft", "reviewing", "approved", "rejected", "cancelled"]); @@ -313,6 +320,7 @@ const PURCHASE_DETAIL_SELECT = { }; const PURCHASE_SUBMIT_SELECT = { + numbering_version: true, id: true, applicant_id: true, department_id: true, @@ -339,19 +347,10 @@ const PURCHASE_SUBMIT_SELECT = { }, }; -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parsePurchaseRequestId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("purchase_request.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } @@ -359,7 +358,7 @@ function parsePurchaseRequestId(value) { function parseAttachmentId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ attachmentId: "invalid_uuid" }); + throw buildBusinessValidationError("purchase_attachment.id_invalid", { attachmentId: "invalid_uuid" }); } return parsed.data; } @@ -367,7 +366,7 @@ function parseAttachmentId(value) { function parseListQuery(query) { const parsed = PURCHASE_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("purchase_request.query_invalid", parsed.error.flatten()); } return { @@ -388,7 +387,7 @@ function parseListQuery(query) { function parsePatchBody(body) { const parsed = PURCHASE_PATCH_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("purchase_request.update_input_invalid", parsed.error.flatten()); } return parsed.data; } @@ -396,7 +395,7 @@ function parsePatchBody(body) { function parseArchiveBody(body) { const parsed = PURCHASE_ARCHIVE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("purchase_request.archive_input_invalid", parsed.error.flatten()); } return parsed.data; } @@ -406,14 +405,9 @@ function normalizeBudgetControlMode(config) { } function buildPrecheckBlockedError(precheck) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { + return buildBusinessValidationError("purchase_request.precheck_blocked", { precheck: precheck.blockingReasons, - }, - }); + }, 409); } function ensurePurchaseReadPermission(fastify, request) { @@ -479,13 +473,13 @@ function moneyToFixed(value) { return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00"; } -function decimalInputToString(value, { min, field }) { +function decimalInputToString(value, { min, field }, invalidReason, rangeReason) { const numeric = typeof value === "number" ? value : Number(value); if (!Number.isFinite(numeric)) { - throw buildValidationError({ [field]: "invalid_decimal" }); + throw buildBusinessValidationError(getErrorReason(invalidReason).code, { [field]: "invalid_decimal" }); } if (numeric < min) { - throw buildValidationError({ [field]: min === 0 ? "must_be_non_negative" : "must_be_positive" }); + throw buildBusinessValidationError(getErrorReason(rangeReason).code, { [field]: min === 0 ? "must_be_non_negative" : "must_be_positive" }); } return numeric.toFixed(2); } @@ -695,11 +689,11 @@ async function ensureLegalEntityAvailable(tx, legalEntityId) { }); if (!item) { - throw buildValidationError({ legalEntityId: "not_found" }, 409); + throw buildBusinessValidationError("purchase_request.company_not_found", { legalEntityId: "not_found" }, 409); } if (!item.enabled || item.archived_at) { - throw buildValidationError({ legalEntityId: "disabled_or_archived" }, 409); + throw buildBusinessValidationError("purchase_request.company_inactive", { legalEntityId: "disabled_or_archived" }, 409); } return item; @@ -723,15 +717,15 @@ async function ensureCostCenterAvailable(tx, costCenterId, legalEntityId) { }); if (!item) { - throw buildValidationError({ costCenterId: "not_found" }, 409); + throw buildBusinessValidationError("purchase_request.cost_center_not_found", { costCenterId: "not_found" }, 409); } if (!item.enabled || item.archived_at) { - throw buildValidationError({ costCenterId: "disabled_or_archived" }, 409); + throw buildBusinessValidationError("purchase_request.cost_center_inactive", { costCenterId: "disabled_or_archived" }, 409); } if (legalEntityId && item.legal_entity_id !== legalEntityId) { - throw buildValidationError({ costCenterId: "legalEntityId_mismatch" }, 409); + throw buildBusinessValidationError("purchase_request.cost_center_company_mismatch", { costCenterId: "legalEntityId_mismatch" }, 409); } return item; @@ -755,15 +749,15 @@ async function ensureSupplierAvailable(tx, supplierId, legalEntityId) { }); if (!item) { - throw buildValidationError({ supplierId: "not_found" }, 409); + throw buildBusinessValidationError("purchase_request.supplier_not_found", { supplierId: "not_found" }, 409); } if (!item.enabled || item.archived_at) { - throw buildValidationError({ supplierId: "disabled_or_archived" }, 409); + throw buildBusinessValidationError("purchase_request.supplier_inactive", { supplierId: "disabled_or_archived" }, 409); } if (legalEntityId && item.legal_entity_id && item.legal_entity_id !== legalEntityId) { - throw buildValidationError({ supplierId: "legalEntityId_mismatch" }, 409); + throw buildBusinessValidationError("purchase_request.supplier_company_mismatch", { supplierId: "legalEntityId_mismatch" }, 409); } return item; @@ -787,15 +781,15 @@ async function ensureDocumentCategoryAvailable(tx, documentCategoryId) { }); if (!item) { - throw buildValidationError({ documentCategoryId: "not_found" }, 409); + throw buildBusinessValidationError("purchase_request.document_category_not_found", { documentCategoryId: "not_found" }, 409); } if (item.entity_type !== "purchase_request") { - throw buildValidationError({ documentCategoryId: "entity_type_mismatch" }, 409); + throw buildBusinessValidationError("purchase_request.document_category_type_invalid", { documentCategoryId: "entity_type_mismatch" }, 409); } if (!item.enabled || item.archived_at) { - throw buildValidationError({ documentCategoryId: "disabled_or_archived" }, 409); + throw buildBusinessValidationError("purchase_request.document_category_inactive", { documentCategoryId: "disabled_or_archived" }, 409); } return item; @@ -823,11 +817,11 @@ async function ensureBudgetPoolAvailable(tx, budgetPoolId) { }); if (!item) { - throw buildValidationError({ budgetPoolId: "not_found" }, 409); + throw buildBusinessValidationError("purchase_request.budget_pool_not_found", { budgetPoolId: "not_found" }, 409); } if (item.status !== "active" || item.archived_at) { - throw buildValidationError({ budgetPoolId: "disabled_or_archived" }, 409); + throw buildBusinessValidationError("purchase_request.budget_pool_inactive", { budgetPoolId: "disabled_or_archived" }, 409); } return item; @@ -843,7 +837,7 @@ async function ensureProjectLinkingEnabled(fastify, tx, request) { }); if (!gate.enabled) { - throw buildValidationError({ featureFlag: `${FEATURE_FLAG_KEYS.projectLinking}_disabled`, reason: gate.reason }, 409); + throw buildBusinessValidationError("purchase_request.project_link_disabled", { featureFlag: `${FEATURE_FLAG_KEYS.projectLinking}_disabled`, reason: gate.reason }, 409); } } @@ -907,7 +901,7 @@ async function ensureProjectLinkable(fastify, tx, request, projectId) { } if (project.status === "archived") { - throw buildValidationError({ projectId: "archived" }, 409); + throw buildBusinessValidationError("purchase_request.project_archived", { projectId: "archived" }, 409); } return project; @@ -943,8 +937,8 @@ async function replacePurchaseRequestItems(tx, purchaseRequestId, items, actorId } const createData = items.map((item) => { - const quantity = decimalInputToString(item.quantity, { min: 0.01, field: "quantity" }); - const unitPrice = decimalInputToString(item.unitPrice, { min: 0, field: "unitPrice" }); + const quantity = decimalInputToString(item.quantity, { min: 0.01, field: "quantity" }, "purchase_request.quantity_invalid", "purchase_request.quantity_too_small"); + const unitPrice = decimalInputToString(item.unitPrice, { min: 0, field: "unitPrice" }, "purchase_request.unit_price_invalid", "purchase_request.unit_price_negative"); const lineAmount = computeLineAmount(quantity, unitPrice); return { @@ -1064,7 +1058,7 @@ async function discardMultipartFile(stream, maxBytes) { function parsePurchaseAttachmentDownloadBody(body) { const parsed = PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("purchase_attachment.download_input_invalid", parsed.error.flatten()); } return parsed.data; } @@ -1072,21 +1066,18 @@ function parsePurchaseAttachmentDownloadBody(body) { function parsePurchaseAttachmentDeleteBody(body) { const parsed = PURCHASE_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("purchase_attachment.delete_input_invalid", parsed.error.flatten()); } return parsed.data; } function buildAttachmentBlockedError(reasonCode, message = "附件未通过安全校验", statusCode = 409) { - return new AppError({ - code: "VALID_001", - message, - statusCode, - details: { + const error = buildBusinessValidationError("purchase_attachment.download_blocked", { attachment: "security_validation_failed", reason: reasonCode, - }, - }); + }, statusCode); + error.message = message; + return error; } async function auditBlockedAttachmentDownload({ @@ -1377,116 +1368,6 @@ async function finalizePurchaseSubmitSideEffects({ }; } -async function handlePurchaseArchiveErpSync({ - tx, - config, - request, - actorId, - purchaseRequestId, - archiveId, - isFirstArchive, - loadContext = loadPurchaseRequestErpContext, - evaluateGate = evaluateFeatureFlag, - enqueueAccrual = enqueuePurchaseAccrualJournalExport, - enqueueSummary = enqueuePurchaseArchiveSummaryExport, -}) { - const context = await loadContext(tx, purchaseRequestId); - if (!context?.purchaseRequest?.order_archive) { - return { skipped: true, reason: 'purchase_order_archive_missing' }; - } - - const purchaseRequest = context.purchaseRequest; - const archive = purchaseRequest.order_archive; - const departmentId = purchaseRequest.department_id ?? request.employee.department_id ?? null; - const gate = await evaluateGate({ - prisma: tx, - config, - flagKey: FEATURE_FLAG_KEYS.erpSync, - departmentId, - strictDepartment: true, - }); - - if (!gate.enabled) { - const skipTargets = isFirstArchive - ? [PROCUREMENT_ERP_TARGETS.accrualJournal, PROCUREMENT_ERP_TARGETS.archiveSummary] - : [PROCUREMENT_ERP_TARGETS.archiveSummary]; - - for (const erpTarget of skipTargets) { - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId, - action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG', - targetType: 'purchase_order_archive', - targetId: archiveId, - extra: { - erpTarget, - eventType: erpTarget === PROCUREMENT_ERP_TARGETS.archiveSummary ? 'erp.procurement.export' : 'erp.journal.export', - purchaseRequestId, - purchaseOrderArchiveId: archiveId, - gateReason: gate.reason, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - departmentId, - }, - }), - }); - } - - return { skipped: true, gate, purchaseRequest, archive }; - } - - const enqueuedItems = []; - if (isFirstArchive) { - const accrual = await enqueueAccrual(tx, config, { - purchaseRequest, - archive, - occurredAt: archive.archived_at ?? new Date(), - actorId, - requeueOnFailure: false, - }); - enqueuedItems.push(accrual); - } - - const summary = await enqueueSummary(tx, config, { - purchaseRequest, - archive, - archivedAt: archive.archived_at ?? new Date(), - actorId, - requeueOnFailure: false, - }); - enqueuedItems.push(summary); - - for (const item of enqueuedItems) { - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId, - action: 'ERP_SYNC_ENQUEUED', - targetType: 'purchase_order_archive', - targetId: archiveId, - extra: { - erpTarget: item.erpTarget, - eventType: item.eventType, - purchaseRequestId, - purchaseOrderArchiveId: archiveId, - outboxId: item.id, - duplicated: item.duplicated, - requeued: item.requeued, - gateReason: gate.reason, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - departmentId, - }, - }), - }); - } - - return { - skipped: false, - gate, - purchaseRequest, - archive, - items: enqueuedItems, - }; -} - async function purchaseRequestRoutes(fastify, options = {}) { const attachmentStorage = options.attachmentStorage ?? defaultStorage; fastify.get( @@ -1552,9 +1433,11 @@ async function purchaseRequestRoutes(fastify, options = {}) { domain: "procurement", departmentId: request.employee.department_id ?? null, }); - const purchaseRequest = await fastify.prisma.$transaction(async (tx) => { + const createdId = await fastify.prisma.$transaction(async (tx) => { + const numbering = await resolveNumberingContext(tx); const created = await tx.purchaseRequest.create({ data: { + numbering_version: numbering.version, applicant_id: request.employee.id, department_id: request.employee.department_id, title: "", @@ -1565,7 +1448,8 @@ async function purchaseRequestRoutes(fastify, options = {}) { created_by: request.employee.id, updated_by: request.employee.id, }, - select: PURCHASE_DETAIL_SELECT, + // Keep response-only relation hydration outside the write transaction. + select: { id: true, applicant_id: true, department_id: true }, }); await tx.auditLog.create({ @@ -1581,9 +1465,13 @@ async function purchaseRequestRoutes(fastify, options = {}) { }), }); - return created; + return created.id; }); + const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({ + where: { id: createdId }, + select: PURCHASE_DETAIL_SELECT, + }); return sendData(reply, request, { purchaseRequest: mapPurchaseRequestDetail(purchaseRequest, request), }); @@ -1646,12 +1534,12 @@ async function purchaseRequestRoutes(fastify, options = {}) { const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const payload = parsePatchBody(request.body); - const purchaseRequest = await fastify.prisma.$transaction(async (tx) => { + await fastify.prisma.$transaction(async (tx) => { const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId); ensureDraftOwner(request, locked); if (locked.status !== "draft") { - throw buildValidationError({ status: "only_draft_editable" }, 409); + throw buildBusinessValidationError("purchase_request.not_editable", { status: "only_draft_editable" }, 409); } if (payload.legalEntityId) { @@ -1724,10 +1612,13 @@ async function purchaseRequestRoutes(fastify, options = {}) { }), }); - return tx.purchaseRequest.findUnique({ - where: { id: purchaseRequestId }, - select: PURCHASE_DETAIL_SELECT, - }); + }); + + // Only response hydration belongs outside the write transaction. All + // validation, item/amount changes and their audit still commit together. + const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({ + where: { id: purchaseRequestId }, + select: PURCHASE_DETAIL_SELECT, }); return sendData(reply, request, { @@ -1780,18 +1671,6 @@ async function purchaseRequestRoutes(fastify, options = {}) { }); } - const authoritativePrecheck = await resolveEntityPrecheck({ - prisma: fastify.prisma, - config: fastify.config, - logger: fastify.log, - entityType: "purchase_request", - entityId: purchaseRequestId, - actorDepartmentId: request.employee.department_id ?? null, - }); - if (authoritativePrecheck.decision === "block") { - throw buildPrecheckBlockedError(authoritativePrecheck); - } - const workflowDefinition = await fastify.prisma.workflowDefinition.findFirst({ where: { entity_type: "purchase_request", @@ -1805,7 +1684,7 @@ async function purchaseRequestRoutes(fastify, options = {}) { }); if (!workflowDefinition) { - throw buildValidationError({ workflow: "missing_definition" }, 409); + throw buildBusinessValidationError("purchase_request.workflow_missing", { workflow: "missing_definition" }, 409); } const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json); @@ -1819,7 +1698,7 @@ async function purchaseRequestRoutes(fastify, options = {}) { }); if (!actionable) { - throw buildValidationError({ workflow: "no_actionable_approver" }, 409); + throw buildBusinessValidationError("purchase_request.approver_unavailable", { workflow: "no_actionable_approver" }, 409); } const submitResult = await fastify.prisma.$transaction(async (tx) => { @@ -1827,7 +1706,7 @@ async function purchaseRequestRoutes(fastify, options = {}) { ensureDraftOwner(request, locked); if (locked.status !== "draft") { - throw buildValidationError({ status: "only_draft_submittable" }, 409); + throw buildBusinessValidationError("purchase_request.not_submittable", { status: "only_draft_submittable" }, 409); } await ensureCutoverAllowed({ @@ -1853,22 +1732,22 @@ async function purchaseRequestRoutes(fastify, options = {}) { } if (!purchaseRequest.legal_entity_id) { - throw buildValidationError({ legalEntityId: "required" }); + throw buildBusinessValidationError("purchase_request.company_required", { legalEntityId: "required" }); } if (!purchaseRequest.cost_center_id) { - throw buildValidationError({ costCenterId: "required" }); + throw buildBusinessValidationError("purchase_request.cost_center_required", { costCenterId: "required" }); } if (!purchaseRequest.supplier_id) { - throw buildValidationError({ supplierId: "required" }); + throw buildBusinessValidationError("purchase_request.supplier_required", { supplierId: "required" }); } if (!purchaseRequest.document_category_id) { - throw buildValidationError({ documentCategoryId: "required" }); + throw buildBusinessValidationError("purchase_request.document_category_required", { documentCategoryId: "required" }); } if (!purchaseRequest.items?.length) { - throw buildValidationError({ items: "at_least_one_required" }); + throw buildBusinessValidationError("purchase_request.items_required", { items: "at_least_one_required" }); } if (purchaseRequest.applicant_id !== unlockedPurchaseRequest.applicant_id) { - throw buildValidationError({ workflow: "no_actionable_approver" }, 409); + throw buildBusinessValidationError("purchase_request.approver_unavailable", { workflow: "no_actionable_approver" }, 409); } const legalEntity = await ensureLegalEntityAvailable(tx, purchaseRequest.legal_entity_id); @@ -1877,6 +1756,37 @@ async function purchaseRequestRoutes(fastify, options = {}) { const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id); const totalAmount = await recomputePurchaseRequestTotal(tx, purchaseRequestId, request.employee.id); + const effectiveDate = new Date(); + const resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, { + departmentId: purchaseRequest.department_id, + legalEntityId: purchaseRequest.legal_entity_id, + costCenterId: purchaseRequest.cost_center_id, + effectiveDate, + }, { forUpdate: true, additionalPoolIds: [purchaseRequest.budget_pool_id] }); + + let selectedBudgetPoolId = purchaseRequest.budget_pool_id ?? null; + if (selectedBudgetPoolId) { + await ensureBudgetPoolAvailable(tx, selectedBudgetPoolId); + if (!resolvedBudgetPool || resolvedBudgetPool.id !== selectedBudgetPoolId) { + throw buildBusinessValidationError("purchase_request.budget_context_mismatch", { budgetPoolId: "context_mismatch" }, 409); + } + } else if (resolvedBudgetPool) { + selectedBudgetPoolId = resolvedBudgetPool.id; + } + + const authoritativePrecheck = await resolveEntityPrecheck({ + prisma: tx, + config: fastify.config, + logger: fastify.log, + entityType: "purchase_request", + entityId: purchaseRequestId, + actorDepartmentId: request.employee.department_id ?? null, + budgetPoolForWrite: resolvedBudgetPool, + }); + if (authoritativePrecheck.decision === "block") { + throw buildPrecheckBlockedError(authoritativePrecheck); + } + const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, { entityType: "purchase_request", entityId: purchaseRequestId, @@ -1884,24 +1794,6 @@ async function purchaseRequestRoutes(fastify, options = {}) { precheck: authoritativePrecheck, }); - const effectiveDate = new Date(); - let resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, { - departmentId: purchaseRequest.department_id, - legalEntityId: purchaseRequest.legal_entity_id, - costCenterId: purchaseRequest.cost_center_id, - effectiveDate, - }); - - let selectedBudgetPoolId = purchaseRequest.budget_pool_id ?? null; - if (selectedBudgetPoolId) { - await ensureBudgetPoolAvailable(tx, selectedBudgetPoolId); - if (!resolvedBudgetPool || resolvedBudgetPool.id !== selectedBudgetPoolId) { - throw buildValidationError({ budgetPoolId: "context_mismatch" }, 409); - } - } else if (resolvedBudgetPool) { - selectedBudgetPoolId = resolvedBudgetPool.id; - } - const warnings = []; const budgetPrecheckGate = await evaluateFeatureFlag({ prisma: tx, @@ -1918,17 +1810,23 @@ async function purchaseRequestRoutes(fastify, options = {}) { : fallbackBudgetControlMode; if (!selectedBudgetPoolId) { if (!budgetPrecheckGate.enabled && budgetControlMode === "block") { - throw buildValidationError({ budget: "missing_budget_pool" }, 409); + throw buildBusinessValidationError("purchase_request.budget_required", { budget: "missing_budget_pool" }, 409); } if (!budgetPrecheckGate.enabled) { warnings.push("missing_budget_pool"); } } + if (!budgetPrecheckGate.enabled && budgetControlMode === "block" && resolvedBudgetPool + && new Prisma.Decimal(resolvedBudgetPool.used_amount).plus(totalAmount).gt(resolvedBudgetPool.allocated_amount)) { + throw buildPrecheckBlockedError({ blockingReasons: [{ code: "budget_exceeded", title: "预算将被超用", detail: "当前采购金额会导致预算池超出已分配额度。" }] }); + } + let requestNo = purchaseRequest.request_no; if (!requestNo) { const issued = await issueDocumentNumber(tx, { documentType: "purchase_request", + numberingVersion: purchaseRequest.numbering_version, documentCategoryCode: documentCategory.code, actorId: request.employee.id, timeZone: fastify.config.appTimezone, @@ -2103,7 +2001,7 @@ async function purchaseRequestRoutes(fastify, options = {}) { ensureDraftOwner(request, locked); if (locked.status !== "reviewing") { - throw buildValidationError({ status: "only_reviewing_withdrawable" }, 409); + throw buildBusinessValidationError("purchase_request.not_withdrawable", { status: "only_reviewing_withdrawable" }, 409); } const workflowInstance = await tx.workflowInstance.findFirst({ @@ -2242,7 +2140,7 @@ async function purchaseRequestRoutes(fastify, options = {}) { const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId); if (locked.status !== "approved") { - throw buildValidationError({ status: "only_approved_archivable" }, 409); + throw buildBusinessValidationError("purchase_request.not_archivable", { status: "only_approved_archivable" }, 409); } const existingArchive = await tx.purchaseOrderArchive.findUnique({ @@ -2298,23 +2196,6 @@ async function purchaseRequestRoutes(fastify, options = {}) { }), }); - try { - await handlePurchaseArchiveErpSync({ - tx, - config: fastify.config, - request, - actorId: request.employee.id, - purchaseRequestId, - archiveId: archive.id, - isFirstArchive: !existingArchive, - }); - } catch (error) { - fastify.log.error( - { err: error, purchaseRequestId, purchaseOrderArchiveId: archive.id }, - "Failed to enqueue purchase archive ERP events", - ); - } - response = { archiveOrder: mapPurchaseOrderArchive(archive), }; @@ -2351,11 +2232,11 @@ async function purchaseRequestRoutes(fastify, options = {}) { ensureDraftOwner(request, purchaseRequest); if (purchaseRequest.status !== "draft") { - throw buildValidationError({ status: "only_draft_editable" }, 409); + throw buildBusinessValidationError("purchase_attachment.not_editable", { status: "only_draft_editable" }, 409); } if (typeof request.isMultipart !== "function" || !request.isMultipart()) { - throw buildValidationError({ file: "multipart_required" }); + throw buildBusinessValidationError("purchase_attachment.multipart_required", { file: "multipart_required" }); } const maxBytes = resolveAttachmentMaxBytes(fastify.config); @@ -2370,12 +2251,12 @@ async function purchaseRequestRoutes(fastify, options = {}) { if (part.type === "file") { if (normalizedFile) { await discardMultipartFile(part.file, maxBytes); - throw buildValidationError({ file: "only_single_file_supported" }); + throw buildBusinessValidationError("purchase_attachment.single_file_required", { file: "only_single_file_supported" }); } const fileBuffer = await readBoundedStream(part.file, maxBytes); if (part.fieldname !== "file") { - throw buildValidationError({ file: "invalid_field" }); + throw buildBusinessValidationError("purchase_attachment.field_invalid", { file: "invalid_field" }); } normalizedFile = await validateAndNormalizePurchaseAttachment({ @@ -2392,23 +2273,23 @@ async function purchaseRequestRoutes(fastify, options = {}) { error instanceof PurchaseAttachmentSizeLimitError || error?.code === "FST_REQ_FILE_TOO_LARGE" ) { - throw buildValidationError({ file: "file_too_large" }); + throw buildBusinessValidationError("purchase_attachment.too_large", { file: "file_too_large" }); } if (error?.code === "FST_FILES_LIMIT") { - throw buildValidationError({ file: "only_single_file_supported" }); + throw buildBusinessValidationError("purchase_attachment.single_file_required", { file: "only_single_file_supported" }); } if (error?.code === "FST_FIELDS_LIMIT" || error?.code === "FST_PARTS_LIMIT") { - throw buildValidationError({ file: "multipart_limit_exceeded" }); + throw buildBusinessValidationError("purchase_attachment.multipart_limit", { file: "multipart_limit_exceeded" }); } if (error instanceof PurchaseAttachmentPolicyError) { if (error.reasonCode === "unsupported_type") throw buildBusinessValidationError("purchase_attachment.unsupported_type", error.details ?? { file: error.reasonCode }); - throw buildValidationError(error.details ?? { file: error.reasonCode }); + throw buildBusinessValidationError(getErrorReason(PURCHASE_ATTACHMENT_POLICY_REASONS[error.reasonCode] ?? "purchase_attachment.validation_failed").code, error.details ?? { file: error.reasonCode }); } throw error; } if (!normalizedFile) { - throw buildValidationError({ file: "required" }); + throw buildBusinessValidationError("purchase_attachment.file_required", { file: "required" }); } let storagePath; @@ -2558,7 +2439,7 @@ async function purchaseRequestRoutes(fastify, options = {}) { ensureDraftOwner(request, purchaseRequest); if (purchaseRequest.status !== "draft") { - throw buildValidationError({ status: "only_draft_editable" }, 409); + throw buildBusinessValidationError("purchase_attachment.not_editable", { status: "only_draft_editable" }, 409); } const attachment = await fastify.prisma.purchaseAttachment.findFirst({ @@ -2642,7 +2523,7 @@ async function purchaseRequestRoutes(fastify, options = {}) { }); } if (requiresDownloadReason(attachment) && !downloadReason) { - throw buildValidationError({ reason: "required" }); + throw buildBusinessValidationError("purchase_attachment.download_reason_required", { reason: "required" }); } if (attachment.governance_status === "quarantined") { @@ -2821,6 +2702,5 @@ module.exports = purchaseRequestRoutes; module.exports.__testables = { finalizePurchaseSubmitSideEffects, - handlePurchaseArchiveErpSync, parseArchiveBody, }; diff --git a/server/src/routes/reimbursementTemplates.js b/server/src/routes/reimbursementTemplates.js index 4cac9350..4e4adab3 100644 --- a/server/src/routes/reimbursementTemplates.js +++ b/server/src/routes/reimbursementTemplates.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); @@ -50,19 +52,10 @@ const UPDATE_BODY_SCHEMA = z }) .strict(); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseTemplateId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("reimbursement_template.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } @@ -70,7 +63,7 @@ function parseTemplateId(value) { function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("reimbursement_template.query_invalid", parsed.error.flatten()); } const enabledRaw = parsed.data.enabled; @@ -82,10 +75,10 @@ function parseListQuery(query) { return { enabled }; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } @@ -121,16 +114,13 @@ async function ensureExpenseTypesEnabled(tx, items) { .filter((item) => !enabledIds.has(item.expenseTypeId)); if (invalidItems.length) { - throw buildValidationError( - { + throw buildBusinessValidationError("reimbursement_template.expense_types_unavailable", { items: invalidItems.map((item) => ({ index: item.index, expenseTypeId: item.expenseTypeId, reason: "expense_type_not_found_or_disabled", })), - }, - 409, - ); + }, 409); } } @@ -200,7 +190,7 @@ async function reimbursementTemplatesRoutes(fastify) { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { - const body = parseBody(CREATE_BODY_SCHEMA, request.body); + const body = parseBody(CREATE_BODY_SCHEMA, request.body, "reimbursement_template.create_input_invalid"); const items = normalizeTemplateItems(body.items); await ensureExpenseTypesEnabled(fastify.prisma, items); @@ -256,10 +246,10 @@ async function reimbursementTemplatesRoutes(fastify) { }, async (request, reply) => { const templateId = parseTemplateId(request.params?.id); - const body = parseBody(UPDATE_BODY_SCHEMA, request.body); + const body = parseBody(UPDATE_BODY_SCHEMA, request.body, "reimbursement_template.update_input_invalid"); if (!Object.keys(body).length) { - throw buildValidationError({ body: "empty" }); + throw buildBusinessValidationError("reimbursement_template.update_empty", { body: "empty" }); } const now = new Date(); diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index a6194064..660060c3 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -23,7 +23,6 @@ const { } = require("../services/notifications/workflowCopy"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { sendNotificationEmailSafe } = require("../services/email"); -const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal"); const { mapInvoiceDocumentMeta } = require("../services/invoiceDocumentMeta"); const { dispatchNotificationBatch, flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); @@ -1365,11 +1364,11 @@ async function loadApprovalRuleForAmount(tx, totalAmount) { return approvalRule; } -async function resolveApplicableBudgetPool(tx, reimbursement) { +async function resolveApplicableBudgetPool(tx, reimbursement, options) { return resolveApplicableBudgetPoolByContext(tx, { departmentId: reimbursement.department_id, effectiveDate: reimbursement.reimbursement_date, - }); + }, options); } function normalizeBudgetControlMode(config) { @@ -4198,14 +4197,8 @@ async function reimbursementRoutes(fastify) { ); ensureNonNegativeAmount(reimbursement.total_amount); - const travelItem = await tx.reimbursementItem.findFirst({ - where: { - reimbursement_id: reimbursementId, - expense_type: { category: "travel" }, - }, - select: { id: true }, - }); - if (travelItem || reimbursement.linked_travel_request_id) { + const hasTravelItem = reimbursementItems.some((item) => item.expense_type.category === "travel"); + if (hasTravelItem || reimbursement.linked_travel_request_id) { const missingTravelFields = [ ["travelPurpose", reimbursement.travel_purpose], ["travelLocation", reimbursement.travel_location], @@ -4229,21 +4222,22 @@ async function reimbursementRoutes(fastify) { throw buildBusinessValidationError("reimbursement.submit_already_in_review", { submit: "already_in_review" }, 409); } - const boundInvoiceCount = await tx.invoicePool.count({ - where: { - linked_reimbursement_id: reimbursementId, - status: "bound", - }, - }); const requiresAnyInvoice = reimbursementItems.some( (item) => item.expense_type.requires_invoice, ); - if (!invoiceGuardGate.enabled && requiresAnyInvoice && boundInvoiceCount < 1) { - throw buildBusinessValidationError("reimbursement.invoices_bound_invoice_required", { invoices: "bound_invoice_required" }, 400); + if (!invoiceGuardGate.enabled && requiresAnyInvoice) { + const boundInvoiceCount = await tx.invoicePool.count({ + where: { linked_reimbursement_id: reimbursementId, status: "bound" }, + }); + if (boundInvoiceCount < 1) { + throw buildBusinessValidationError("reimbursement.invoices_bound_invoice_required", { invoices: "bound_invoice_required" }, 400); + } } + const budgetPool = await resolveApplicableBudgetPool(tx, reimbursement, { forUpdate: true }); const authoritativePrecheck = await resolveEntityPrecheck({ prisma: tx, + budgetPoolForWrite: budgetPool, config: fastify.config, logger: fastify.log, entityType: "reimbursement", @@ -4262,12 +4256,10 @@ async function reimbursementRoutes(fastify) { precheck: authoritativePrecheck, }); - const budgetPool = await resolveApplicableBudgetPool(tx, reimbursement); if (budgetPool) { const allocatedAmount = Number(budgetPool.allocated_amount ?? 0); - const usedAmount = Number(budgetPool.used_amount ?? 0); - const currentAmount = Number(reimbursement.total_amount ?? 0); - const nextUsed = usedAmount + currentAmount; + const nextUsed = Number(new Prisma.Decimal(budgetPool.used_amount ?? 0) + .plus(reimbursement.total_amount ?? 0).toFixed(2)); const usagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0; const budgetPrecheckGate = await evaluateFeatureFlag({ prisma: tx, @@ -4377,6 +4369,7 @@ async function reimbursementRoutes(fastify) { if (!formNumber) { const issued = await issueDocumentNumber(tx, { documentType: "reimbursement", + numberingVersion: 1, // Historical missing numbers retain legacy eligibility. actorId: request.employee.id, timeZone: fastify.config.appTimezone, }); @@ -4812,72 +4805,6 @@ async function reimbursementRoutes(fastify) { }, }); - const refreshed = await tx.reimbursementForm.findUnique({ - where: { id: reimbursementId }, - select: { - id: true, - form_number: true, - owner_id: true, - submitter_id: true, - department_id: true, - total_amount: true, - payment_voucher_no: true, - payment_date: true, - paid_at: true, - owner: { - select: { - legal_entity: { - select: { - code: true, - }, - }, - }, - }, - submitter: { - select: { - legal_entity: { - select: { - code: true, - }, - }, - }, - }, - }, - }); - - if (refreshed) { - const departmentId = - refreshed.department_id ?? reimbursement.department_id ?? request.employee.department_id ?? null; - const erpGate = await evaluateFeatureFlag({ - prisma: tx, - config: fastify.config, - flagKey: FEATURE_FLAG_KEYS.erpSync, - departmentId, - strictDepartment: true, - }); - - if (erpGate.enabled) { - await enqueueReimbursementJournalExport(tx, fastify.config, { - reimbursement: refreshed, - actorId: request.employee.id, - }); - } else { - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG", - targetType: "reimbursement_form", - targetId: refreshed.id, - extra: { - featureFlag: FEATURE_FLAG_KEYS.erpSync, - departmentId, - reason: erpGate.reason, - }, - }), - }); - } - } - try { const recipients = Array.from( new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean)), diff --git a/server/src/routes/riskFlags.js b/server/src/routes/riskFlags.js index b82b3cc2..825dfd7c 100644 --- a/server/src/routes/riskFlags.js +++ b/server/src/routes/riskFlags.js @@ -1,6 +1,7 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { buildPersistedRiskFlag } = require("../services/feeControl"); @@ -16,15 +17,6 @@ const ACTION_BODY_SCHEMA = z.object({ note: z.string().trim().max(500).nullable().optional(), }).strict(); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function hasListPermission(fastify, request) { return ( fastify.hasPermission(request.employee, "budget.view") || @@ -48,7 +40,7 @@ function ensureManagePermission(fastify, request) { function parseQuery(query) { const parsed = QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("risk_flag.query_invalid", parsed.error.flatten()); } return parsed.data; } @@ -56,7 +48,7 @@ function parseQuery(query) { function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("risk_flag.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } @@ -64,7 +56,7 @@ function parseId(value) { function parseBody(body) { const parsed = ACTION_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("risk_flag.action_input_invalid", parsed.error.flatten()); } return parsed.data; } @@ -104,7 +96,7 @@ async function riskFlagsRoutes(fastify) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (current.status !== "open") { - throw buildValidationError({ status: "risk_flag_not_open" }, 409); + throw buildBusinessValidationError("risk_flag.not_open", { status: "risk_flag_not_open" }, 409); } const updated = await fastify.prisma.entityRiskFlag.update({ diff --git a/server/src/routes/search.js b/server/src/routes/search.js index a274015b..29eaabb7 100644 --- a/server/src/routes/search.js +++ b/server/src/routes/search.js @@ -1,6 +1,7 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { buildReadableReimbursementWhere } = require("../services/reimbursementVisibility"); @@ -25,10 +26,6 @@ const ENTITY_PRIORITY = { travel_request: 11, }; -function buildValidationError(details, statusCode = 400) { - return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); -} - async function ensureFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, @@ -38,7 +35,7 @@ async function ensureFeatureEnabled(fastify, request) { strictDepartment: false, }); if (!gate.enabled) { - throw buildValidationError({ featureFlag: "global_search_v1_disabled", reason: gate.reason }, 409); + throw buildBusinessValidationError("search.feature_disabled", { featureFlag: "global_search_v1_disabled", reason: gate.reason }, 409); } } @@ -78,7 +75,7 @@ async function searchRoutes(fastify) { fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); - if (!parsed.success) throw buildValidationError(parsed.error.flatten()); + if (!parsed.success) throw buildBusinessValidationError("search.query_invalid", parsed.error.flatten()); const q = parsed.data.q.trim(); const limit = parsed.data.limit ?? 10; diff --git a/server/src/routes/systemWorkspace.js b/server/src/routes/systemWorkspace.js index bc088cbb..6fab174b 100644 --- a/server/src/routes/systemWorkspace.js +++ b/server/src/routes/systemWorkspace.js @@ -1,6 +1,6 @@ const { z } = require("zod"); -const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { @@ -19,14 +19,10 @@ const PATCH_SCHEMA = z .strict() .refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); -function buildValidationError(details, statusCode = 400) { - return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); -} - function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("workspace.update_input_invalid", parsed.error.flatten()); } return parsed.data; } diff --git a/server/src/routes/todos.js b/server/src/routes/todos.js index 6cbd2f03..edfcd5cf 100644 --- a/server/src/routes/todos.js +++ b/server/src/routes/todos.js @@ -1,6 +1,8 @@ const { z } = require("zod"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { AppError } = require("../utils/AppError"); +const { isValidDateOnlyString } = require("../utils/date"); const { sendData } = require("../utils/reply"); const { createNotifications } = require("../services/notifications"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); @@ -16,7 +18,7 @@ const CREATE_SCHEMA = z.object({ title: z.string().trim().min(1).max(200), description: z.string().trim().max(2000).nullable().optional(), assigneeEmployeeId: UUID_SCHEMA.optional(), - dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), + dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).nullable().optional(), priority: z.enum(["low", "normal", "high"]).optional(), source: z.enum(["manual", "weekly_report"]).optional(), projectId: UUID_SCHEMA.nullable().optional(), @@ -25,20 +27,16 @@ const PATCH_SCHEMA = z.object({ title: z.string().trim().min(1).max(200).optional(), description: z.string().trim().max(2000).nullable().optional(), assigneeEmployeeId: UUID_SCHEMA.optional(), - dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), + dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).nullable().optional(), priority: z.enum(["low", "normal", "high"]).optional(), status: z.enum(["todo", "doing", "done", "cancelled"]).optional(), projectId: UUID_SCHEMA.nullable().optional(), }).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); -function buildValidationError(details, statusCode = 400) { - return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); -} - function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("todo.input_invalid", parsed.error.flatten()); } return parsed.data; } @@ -46,7 +44,7 @@ function parseBody(schema, body) { function parseUuid(value, fieldName = "id") { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ [fieldName]: "invalid_uuid" }); + throw buildBusinessValidationError("todo.id_invalid", { [fieldName]: "invalid_uuid" }); } return parsed.data; } @@ -81,7 +79,7 @@ async function ensureFeatureEnabled(fastify, request) { strictDepartment: false, }); if (!gate.enabled) { - throw buildValidationError({ featureFlag: "todo_board_v1_disabled", reason: gate.reason }, 409); + throw buildBusinessValidationError("todo.disabled", { featureFlag: "todo_board_v1_disabled", reason: gate.reason }, 409); } } @@ -94,7 +92,7 @@ async function todosRoutes(fastify) { await ensureFeatureEnabled(fastify, request); const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("todo.query_invalid", parsed.error.flatten()); } const query = parsed.data; const canManage = canManageTeamTodos(fastify, request.employee); diff --git a/server/src/routes/travelAuthorizations.js b/server/src/routes/travelAuthorizations.js index 097449e0..f6725407 100644 --- a/server/src/routes/travelAuthorizations.js +++ b/server/src/routes/travelAuthorizations.js @@ -1,6 +1,7 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { normalizeTravelAuthorizationRows } = require("../services/integrations/travel"); @@ -25,15 +26,6 @@ const AUTHORIZATION_STATUS_LIST = new Set([ "expired", ]); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function normalizeAuthorizationStatus(value) { const normalized = String(value ?? "authorized").trim().toLowerCase(); return AUTHORIZATION_STATUS_LIST.has(normalized) ? normalized : "authorized"; @@ -42,7 +34,7 @@ function normalizeAuthorizationStatus(value) { function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("travel_authorization.query_invalid", parsed.error.flatten()); } return { @@ -129,17 +121,17 @@ async function travelAuthorizationsRoutes(fastify) { }, async (request, reply) => { if (!fastify.config.travelSyncEnabled) { - throw buildValidationError({ integration: "travel_sync_disabled" }, 409); + throw buildBusinessValidationError("travel_import.sync_disabled", { integration: "travel_sync_disabled" }, 409); } const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("travel_authorization.import_input_invalid", parsed.error.flatten()); } const rows = normalizeTravelAuthorizationRows(parsed.data.authorizations); if (!rows.length) { - throw buildValidationError({ authorizations: "empty_after_normalization" }); + throw buildBusinessValidationError("travel_authorization.import_empty", { authorizations: "empty_after_normalization" }); } const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId))); diff --git a/server/src/routes/travelOrders.js b/server/src/routes/travelOrders.js index 5ec79409..a416fe65 100644 --- a/server/src/routes/travelOrders.js +++ b/server/src/routes/travelOrders.js @@ -1,15 +1,11 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { normalizeTravelImportRows } = require("../services/integrations/travel"); -const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); -const { - enqueueTravelOrderPaymentJournalExport, - buildTravelOrderPaymentJournalDedupeKey, -} = require("../services/integrations/erpJournal"); -const { parseDateOnlyString } = require("../utils/date"); +const { parseDateOnlyString, isValidDateOnlyString } = require("../utils/date"); const IMPORT_SCHEMA = z.object({ orders: z.array(z.record(z.string(), z.unknown())).min(1).max(100), @@ -25,7 +21,7 @@ const PAYMENT_CHANNEL_SCHEMA = z.enum([ const MARK_PAID_SCHEMA = z .object({ paymentVoucherNo: z.string().trim().max(50).nullable().optional(), - paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), + paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).nullable().optional(), paymentChannel: PAYMENT_CHANNEL_SCHEMA.nullable().optional(), }) .strict(); @@ -53,15 +49,6 @@ function mapTravelOrder(item) { }; } -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function normalizeNullableString(value) { if (value == null) { return null; @@ -93,17 +80,17 @@ async function travelOrdersRoutes(fastify) { }, async (request, reply) => { if (!fastify.config.travelSyncEnabled) { - throw buildValidationError({ integration: "travel_sync_disabled" }, 409); + throw buildBusinessValidationError("travel_import.sync_disabled", { integration: "travel_sync_disabled" }, 409); } const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("travel_order.import_input_invalid", parsed.error.flatten()); } const rows = normalizeTravelImportRows(parsed.data.orders); if (!rows.length) { - throw buildValidationError({ orders: "empty_after_normalization" }); + throw buildBusinessValidationError("travel_order.import_empty", { orders: "empty_after_normalization" }); } const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId))); @@ -280,12 +267,12 @@ async function travelOrdersRoutes(fastify) { async (request, reply) => { const parsedId = UUID_SCHEMA.safeParse(request.params?.id); if (!parsedId.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("travel_order.id_invalid", { id: "invalid_uuid" }); } const parsedBody = MARK_PAID_SCHEMA.safeParse(request.body ?? {}); if (!parsedBody.success) { - throw buildValidationError(parsedBody.error.flatten()); + throw buildBusinessValidationError("travel_order.payment_input_invalid", parsedBody.error.flatten()); } const paymentVoucherNo = normalizeNullableString(parsedBody.data.paymentVoucherNo); @@ -315,12 +302,7 @@ async function travelOrdersRoutes(fastify) { const current = orderRows[0]; if (!["confirmed", "paid"].includes(current.status)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { status: "invalid_pay_status" }, - }); + throw buildBusinessValidationError("travel_order.invalid_pay_status", { status: "invalid_pay_status" }, 409); } const existingPaymentDate = toDateOnlyString(current.payment_date); @@ -330,12 +312,7 @@ async function travelOrdersRoutes(fastify) { const paymentChannelConflict = paymentChannel !== (current.payment_channel ?? null); if (voucherConflict || paymentDateConflict || paymentChannelConflict) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { payment: "already_paid_conflict" }, - }); + throw buildBusinessValidationError("travel_order.already_paid_conflict", { payment: "already_paid_conflict" }, 409); } } @@ -352,12 +329,7 @@ async function travelOrdersRoutes(fastify) { }); if (!matchedTransaction) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { payment: "missing_settled_transaction" }, - }); + throw buildBusinessValidationError("travel_order.settled_transaction_required", { payment: "missing_settled_transaction" }, 409); } const now = new Date(); @@ -427,49 +399,6 @@ async function travelOrdersRoutes(fastify) { }), }); - const departmentId = refreshed?.department_id ?? refreshed?.travel_request?.department_id ?? null; - const erpGate = await evaluateFeatureFlag({ - prisma: tx, - config: fastify.config, - flagKey: FEATURE_FLAG_KEYS.erpSync, - departmentId, - strictDepartment: true, - }); - - let erpJournal = { - eventType: "erp.journal.export", - outboxId: null, - dedupeKey: buildTravelOrderPaymentJournalDedupeKey(parsedId.data), - enqueued: false, - }; - - if (erpGate.enabled && refreshed) { - const outbox = await enqueueTravelOrderPaymentJournalExport(tx, fastify.config, { - travelOrder: refreshed, - actorId: request.employee.id, - }); - erpJournal = { - eventType: "erp.journal.export", - outboxId: outbox.id, - dedupeKey: buildTravelOrderPaymentJournalDedupeKey(parsedId.data), - enqueued: true, - }; - } else { - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG", - targetType: "travel_order", - targetId: parsedId.data, - extra: { - featureFlag: FEATURE_FLAG_KEYS.erpSync, - departmentId, - reason: erpGate.reason, - }, - }), - }); - } - response = { travelOrderId: refreshed.id, travelRequestId: refreshed.travel_request_id ?? null, @@ -477,7 +406,6 @@ async function travelOrdersRoutes(fastify) { paymentVoucherNo: refreshed.payment_voucher_no ?? null, paymentDate: refreshed.payment_date?.toISOString?.()?.slice(0, 10) ?? null, paymentChannel: refreshed.payment_channel ?? null, - erpJournal, }; }); diff --git a/server/src/routes/travelReconciliation.js b/server/src/routes/travelReconciliation.js index 7881cdb8..28e81e80 100644 --- a/server/src/routes/travelReconciliation.js +++ b/server/src/routes/travelReconciliation.js @@ -1,16 +1,15 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { buildAuditLogData } = require("../utils/audit"); -const { parseDateOnlyString } = require("../utils/date"); +const { parseDateOnlyString, isValidDateOnlyString } = require("../utils/date"); const { sendData } = require("../utils/reply"); const { issueDocumentNumber } = require("../services/documentSequence"); -const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { TRAVEL_RECONCILIATION_BATCH_STATUSES, TRAVEL_RECONCILIATION_RESOLUTION_STATUSES, buildReconciliationItemsSnapshot, - buildValidationError, ensureBatchFinalizable, ensureConfirmedReconciliationItem, ensureDraftBatch, @@ -22,14 +21,9 @@ const { mapBatchItem, normalizeSummaryJson, } = require("../services/travelReconciliation"); -const { - TRAVEL_ERP_EVENT_TYPE, - buildTravelReconciliationSummaryDedupeKey, - enqueueTravelReconciliationSummaryExport, -} = require("../services/integrations/erpTravel"); const UUID_SCHEMA = z.string().uuid(); -const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "false", "true"]).optional(); const BATCH_STATUS_SCHEMA = z.enum(["draft", "reconciled"]); @@ -83,7 +77,7 @@ function ensureReconciliationReadPermission(fastify, request) { function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten(), 400); + throw buildBusinessValidationError("travel_reconciliation.query_invalid", parsed.error.flatten(), 400); } return { @@ -103,7 +97,7 @@ function parseRunNow(query) { function ensureValidPeriod(periodStart, periodEnd) { if (periodStart.getTime() > periodEnd.getTime()) { - throw buildValidationError({ period: "period_start_after_period_end" }); + throw buildBusinessValidationError("travel_reconciliation.period_reversed", { period: "period_start_after_period_end" }, 409); } } @@ -122,13 +116,13 @@ async function validateBatchScopeContext(prisma, { legalEntityId, costCenterId } ]); if (!legalEntity) { - throw buildValidationError({ legalEntityId: "not_found" }); + throw buildBusinessValidationError("travel_reconciliation.company_not_found", { legalEntityId: "not_found" }, 409); } if (costCenterId && !costCenter) { - throw buildValidationError({ costCenterId: "not_found" }); + throw buildBusinessValidationError("travel_reconciliation.cost_center_not_found", { costCenterId: "not_found" }, 409); } if (costCenter && costCenter.legal_entity_id !== legalEntityId) { - throw buildValidationError({ costCenterId: "legal_entity_mismatch" }); + throw buildBusinessValidationError("travel_reconciliation.cost_center_company_mismatch", { costCenterId: "legal_entity_mismatch" }, 409); } return { legalEntity, costCenter }; @@ -298,7 +292,7 @@ async function travelReconciliationRoutes(fastify) { async (request, reply) => { const parsed = CREATE_BODY_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten(), 400); + throw buildBusinessValidationError("travel_reconciliation.create_input_invalid", parsed.error.flatten(), 400); } const costCenterId = parsed.data.costCenterId ?? null; @@ -318,7 +312,7 @@ async function travelReconciliationRoutes(fastify) { }); if (!scope.orders.length && !scope.transactions.length) { - throw buildValidationError({ batch: "travel_reconciliation_empty_scope" }); + throw buildBusinessValidationError("travel_reconciliation.empty_scope", { batch: "travel_reconciliation_empty_scope" }, 409); } const now = new Date(); @@ -384,7 +378,6 @@ async function travelReconciliationRoutes(fastify) { const detail = await loadTravelReconciliationBatchDetail(fastify.prisma, batchId, { canManagePayments: true, - canRetryErp: fastify.hasPermission(request.employee, "integration.manage"), }); return sendData(reply, request, detail); }, @@ -399,12 +392,11 @@ async function travelReconciliationRoutes(fastify) { ensureReconciliationReadPermission(fastify, request); const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId); if (!parsedId.success) { - throw buildValidationError({ batchId: "invalid_uuid" }, 400); + throw buildBusinessValidationError("travel_reconciliation.batch_id_invalid", { batchId: "invalid_uuid" }, 400); } const detail = await loadTravelReconciliationBatchDetail(fastify.prisma, parsedId.data, { canManagePayments: fastify.hasPermission(request.employee, "travel.payment.manage"), - canRetryErp: fastify.hasPermission(request.employee, "integration.manage"), }); return sendData(reply, request, detail); }, @@ -418,11 +410,11 @@ async function travelReconciliationRoutes(fastify) { async (request, reply) => { const parsedId = UUID_SCHEMA.safeParse(request.params?.itemId); if (!parsedId.success) { - throw buildValidationError({ itemId: "invalid_uuid" }, 400); + throw buildBusinessValidationError("travel_reconciliation.item_id_invalid", { itemId: "invalid_uuid" }, 400); } const parsedBody = NOTE_BODY_SCHEMA.safeParse(request.body ?? {}); if (!parsedBody.success) { - throw buildValidationError(parsedBody.error.flatten(), 400); + throw buildBusinessValidationError("travel_reconciliation.confirm_input_invalid", parsedBody.error.flatten(), 400); } let response = null; @@ -498,7 +490,7 @@ async function travelReconciliationRoutes(fastify) { async (request, reply) => { const parsedId = UUID_SCHEMA.safeParse(request.params?.itemId); if (!parsedId.success) { - throw buildValidationError({ itemId: "invalid_uuid" }, 400); + throw buildBusinessValidationError("travel_reconciliation.item_id_invalid", { itemId: "invalid_uuid" }, 400); } let response = null; @@ -574,36 +566,19 @@ async function travelReconciliationRoutes(fastify) { async (request, reply) => { const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId); if (!parsedId.success) { - throw buildValidationError({ batchId: "invalid_uuid" }, 400); + throw buildBusinessValidationError("travel_reconciliation.batch_id_invalid", { batchId: "invalid_uuid" }, 400); } let response = null; await fastify.prisma.$transaction(async (tx) => { const batch = await loadBatchForUpdate(tx, parsedId.data); - const dedupeKey = buildTravelReconciliationSummaryDedupeKey(batch.id); if (batch.status === TRAVEL_RECONCILIATION_BATCH_STATUSES.reconciled) { - const existingOutbox = await tx.integrationOutbox.findUnique({ - where: { - event_type_dedupe_key: { - event_type: TRAVEL_ERP_EVENT_TYPE, - dedupe_key: dedupeKey, - }, - }, - select: { id: true }, - }); response = { batchId: batch.id, batchNo: batch.batch_no, status: batch.status, - erpExport: { - eventType: TRAVEL_ERP_EVENT_TYPE, - outboxId: existingOutbox?.id ?? null, - duplicated: Boolean(existingOutbox), - requeued: false, - enqueued: Boolean(existingOutbox), - }, }; return; } @@ -639,88 +614,10 @@ async function travelReconciliationRoutes(fastify) { }), }); - const gate = await evaluateFeatureFlag({ - prisma: tx, - config: fastify.config, - flagKey: FEATURE_FLAG_KEYS.erpSync, - departmentId: null, - strictDepartment: false, - }); - - let erpExport = { - eventType: TRAVEL_ERP_EVENT_TYPE, - outboxId: null, - duplicated: false, - requeued: false, - enqueued: false, - }; - - if (fastify.config.erpSyncEnabled && gate.enabled) { - const exportContext = await tx.travelReconciliationBatch.findUnique({ - where: { id: batch.id }, - select: { - id: true, - batch_no: true, - legal_entity_id: true, - cost_center_id: true, - period_start: true, - period_end: true, - status: true, - summary_json: true, - confirmed_at: true, - created_at: true, - updated_at: true, - legal_entity: { select: { id: true, code: true, name: true } }, - cost_center: { select: { id: true, code: true, name: true } }, - items: { - orderBy: [{ created_at: "asc" }, { id: "asc" }], - select: { - id: true, - travel_request_id: true, - travel_order_id: true, - card_transaction_id: true, - match_status: true, - resolution_status: true, - order_amount: true, - transaction_amount: true, - currency: true, - snapshot_json: true, - }, - }, - }, - }); - const outbox = await enqueueTravelReconciliationSummaryExport(tx, fastify.config, { - batch: exportContext, - actorId: request.employee.id, - requeueOnFailure: true, - }); - erpExport = { - eventType: TRAVEL_ERP_EVENT_TYPE, - outboxId: outbox.id, - duplicated: outbox.duplicated, - requeued: outbox.requeued, - enqueued: true, - }; - } else { - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId: request.employee.id, - action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG", - targetType: "travel_reconciliation_batch", - targetId: batch.id, - extra: { - featureFlag: FEATURE_FLAG_KEYS.erpSync, - reason: fastify.config.erpSyncEnabled ? gate.reason : "env_disabled", - }, - }), - }); - } - response = { batchId: batch.id, batchNo: batch.batch_no, status: TRAVEL_RECONCILIATION_BATCH_STATUSES.reconciled, - erpExport, }; }); diff --git a/server/src/routes/travelRequests.js b/server/src/routes/travelRequests.js index 32bb5cb6..40c9295d 100644 --- a/server/src/routes/travelRequests.js +++ b/server/src/routes/travelRequests.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { buildAuditLogData } = require("../utils/audit"); const { parseDateOnlyString, isValidDateOnlyString } = require("../utils/date"); const { sendData } = require("../utils/reply"); @@ -206,15 +208,6 @@ const TRAVEL_DETAIL_SELECT = { }, }; -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseBooleanQueryValue(rawValue) { if (rawValue == null) { return null; @@ -225,7 +218,7 @@ function parseBooleanQueryValue(rawValue) { function parseListQuery(query) { const parsed = TRAVEL_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("travel_request.query_invalid", parsed.error.flatten()); } return { @@ -242,15 +235,15 @@ function parseListQuery(query) { function parseTravelRequestId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("travel_request.id_invalid", { id: "invalid_uuid" }); } return parsed.data; } -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } @@ -258,7 +251,7 @@ function parseBody(schema, body) { function parseDateTimeValue(value, field) { const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { - throw buildValidationError({ [field]: "invalid_datetime" }); + throw buildBusinessValidationError("travel_request.segment_time_invalid", { [field]: "invalid_datetime" }); } return parsed; } @@ -266,10 +259,10 @@ function parseDateTimeValue(value, field) { function decimalInputToString(value, { field, min = 0 }) { const numeric = typeof value === "number" ? value : Number(value); if (!Number.isFinite(numeric)) { - throw buildValidationError({ [field]: "invalid_decimal" }); + throw buildBusinessValidationError("travel_request.amount_invalid", { [field]: "invalid_decimal" }); } if (numeric < min) { - throw buildValidationError({ [field]: min === 0 ? "must_be_non_negative" : "must_be_positive" }); + throw buildBusinessValidationError("travel_request.amount_negative", { [field]: min === 0 ? "must_be_non_negative" : "must_be_positive" }); } return numeric.toFixed(2); } @@ -279,7 +272,7 @@ function normalizeSegments(segments) { const departAt = parseDateTimeValue(item.departAt, `segments.${index}.departAt`); const arriveAt = parseDateTimeValue(item.arriveAt, `segments.${index}.arriveAt`); if (arriveAt < departAt) { - throw buildValidationError({ [`segments.${index}.arriveAt`]: "must_be_after_departAt" }); + throw buildBusinessValidationError("travel_request.segment_time_reversed", { [`segments.${index}.arriveAt`]: "must_be_after_departAt" }); } return { @@ -296,17 +289,12 @@ function normalizeSegments(segments) { function ensureDateRangeValid(startDate, endDate) { if (endDate < startDate) { - throw buildValidationError({ endDate: "must_be_after_startDate" }); + throw buildBusinessValidationError("travel_request.date_range_reversed", { endDate: "must_be_after_startDate" }); } } function buildPrecheckBlockedError(precheck) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { precheck: precheck.blockingReasons }, - }); + return buildBusinessValidationError("travel_request.precheck_blocked", { precheck: precheck.blockingReasons }, 409); } async function ensureFinancialDimensions(tx, { legalEntityId, costCenterId }) { @@ -329,13 +317,13 @@ async function ensureFinancialDimensions(tx, { legalEntityId, costCenterId }) { ]); if (!legalEntity || !legalEntity.enabled || legalEntity.archived_at) { - throw buildValidationError({ legalEntityId: "not_found_or_disabled" }, 409); + throw buildBusinessValidationError("travel_request.company_unavailable", { legalEntityId: "not_found_or_disabled" }, 409); } if (!costCenter || !costCenter.enabled || costCenter.archived_at) { - throw buildValidationError({ costCenterId: "not_found_or_disabled" }, 409); + throw buildBusinessValidationError("travel_request.cost_center_unavailable", { costCenterId: "not_found_or_disabled" }, 409); } if (costCenter.legal_entity_id !== legalEntityId) { - throw buildValidationError({ costCenterId: "legal_entity_mismatch" }, 409); + throw buildBusinessValidationError("travel_request.cost_center_company_mismatch", { costCenterId: "legal_entity_mismatch" }, 409); } return { legalEntity, costCenter }; @@ -682,7 +670,7 @@ async function createTravelRequestsRoutes(fastify) { preHandler: fastify.requirePermission("travel.create"), }, async (request, reply) => { - const body = parseBody(TRAVEL_CREATE_BODY_SCHEMA, request.body); + const body = parseBody(TRAVEL_CREATE_BODY_SCHEMA, request.body, "travel_request.create_input_invalid"); await ensureCutoverAllowed({ prisma: fastify.prisma, config: fastify.config, @@ -795,9 +783,9 @@ async function createTravelRequestsRoutes(fastify) { }, async (request, reply) => { const travelRequestId = parseTravelRequestId(request.params?.id); - const body = parseBody(TRAVEL_PATCH_BODY_SCHEMA, request.body); + const body = parseBody(TRAVEL_PATCH_BODY_SCHEMA, request.body, "travel_request.update_input_invalid"); if (Object.keys(body).length === 0) { - throw buildValidationError({ body: "empty_patch" }); + throw buildBusinessValidationError("travel_request.update_empty", { body: "empty_patch" }); } let response = null; @@ -806,7 +794,7 @@ async function createTravelRequestsRoutes(fastify) { const locked = await loadTravelRequestForUpdate(tx, travelRequestId); ensureDraftOwner(request, locked); if (locked.status !== "draft") { - throw buildValidationError({ status: "only_draft_editable" }, 409); + throw buildBusinessValidationError("travel_request.only_draft_editable", { status: "only_draft_editable" }, 409); } const nextStartDate = Object.prototype.hasOwnProperty.call(body, "startDate") @@ -943,14 +931,22 @@ async function createTravelRequestsRoutes(fastify) { legalEntityId: travelRequest.legal_entity_id ?? null, }); if (travelRequest.status !== "draft") { - throw buildValidationError({ status: "only_draft_submittable" }, 409); + throw buildBusinessValidationError("travel_request.only_draft_submittable", { status: "only_draft_submittable" }, 409); } if (!travelRequest.segments.length) { - throw buildValidationError({ segments: "required" }, 409); + throw buildBusinessValidationError("travel_request.segments_required", { segments: "required" }, 409); } + const resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, { + legalEntityId: travelRequest.legal_entity_id, + costCenterId: travelRequest.cost_center_id, + effectiveDate: travelRequest.start_date, + }, { forUpdate: true }); + const selectedBudgetPoolId = resolvedBudgetPool?.id ?? null; + const authoritativePrecheck = await resolveEntityPrecheck({ prisma: tx, + budgetPoolForWrite: resolvedBudgetPool, config: fastify.config, logger: fastify.log, entityType: "travel_request", @@ -976,7 +972,7 @@ async function createTravelRequestsRoutes(fastify) { }, }); if (!workflowDefinition) { - throw buildValidationError({ workflow: "missing_definition" }, 409); + throw buildBusinessValidationError("travel_request.workflow_missing", { workflow: "missing_definition" }, 409); } const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json); @@ -989,7 +985,7 @@ async function createTravelRequestsRoutes(fastify) { blockOnUnresolvedAssignee: true, }); if (!actionable) { - throw buildValidationError({ workflow: "no_actionable_approver" }, 409); + throw buildBusinessValidationError("travel_request.approver_unavailable", { workflow: "no_actionable_approver" }, 409); } let requestNo = travelRequest.request_no; @@ -1002,13 +998,6 @@ async function createTravelRequestsRoutes(fastify) { requestNo = issued.documentNumber; } - const resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, { - legalEntityId: travelRequest.legal_entity_id, - costCenterId: travelRequest.cost_center_id, - effectiveDate: travelRequest.start_date, - }); - const selectedBudgetPoolId = resolvedBudgetPool?.id ?? null; - await tx.travelRequest.update({ where: { id: travelRequestId }, data: { @@ -1223,10 +1212,10 @@ async function createTravelRequestsRoutes(fastify) { strictDepartment: false, }); if (!gate.enabled) { - throw buildValidationError({ featureFlag: "project_linking_v1_disabled", reason: gate.reason }, 409); + throw buildBusinessValidationError("travel_request.project_linking_disabled", { featureFlag: "project_linking_v1_disabled", reason: gate.reason }, 409); } - const travelRequestId = parseUuid(request.params?.id, "id"); - const body = parseBody(z.object({ projectId: UUID_SCHEMA.nullable() }).strict(), request.body); + const travelRequestId = parseTravelRequestId(request.params?.id); + const body = parseBody(z.object({ projectId: UUID_SCHEMA.nullable() }).strict(), request.body, "travel_request.project_link_input_invalid"); const current = await fastify.prisma.travelRequest.findUnique({ where: { id: travelRequestId }, select: { id: true, applicant_employee_id: true, submitter_id: true, status: true }, @@ -1265,7 +1254,7 @@ async function createTravelRequestsRoutes(fastify) { const locked = await loadTravelRequestForUpdate(tx, travelRequestId); ensureDraftOwner(request, locked); if (locked.status !== "reviewing") { - throw buildValidationError({ status: "only_reviewing_withdrawable" }, 409); + throw buildBusinessValidationError("travel_request.only_reviewing_withdrawable", { status: "only_reviewing_withdrawable" }, 409); } const workflowInstance = await tx.workflowInstance.findFirst({ diff --git a/server/src/routes/travelTransactions.js b/server/src/routes/travelTransactions.js index 2ecf3e09..9c664c7f 100644 --- a/server/src/routes/travelTransactions.js +++ b/server/src/routes/travelTransactions.js @@ -1,6 +1,7 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { normalizeTravelTransactionRows } = require("../services/integrations/travel"); @@ -36,15 +37,6 @@ const FREEZE_SUGGESTION_SCHEMA = z.object({ const TRANSACTION_STATUS_LIST = new Set(["pending", "settled", "reversed", "failed"]); const TRANSACTION_TYPE_LIST = new Set(["charge", "refund", "reversal"]); -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function normalizeTransactionStatus(value) { const normalized = String(value ?? "pending").trim().toLowerCase(); return TRANSACTION_STATUS_LIST.has(normalized) ? normalized : "pending"; @@ -58,7 +50,7 @@ function normalizeTransactionType(value) { function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("travel_transaction.query_invalid", parsed.error.flatten()); } return { @@ -267,17 +259,17 @@ async function travelTransactionsRoutes(fastify) { }, async (request, reply) => { if (!fastify.config.travelSyncEnabled) { - throw buildValidationError({ integration: "travel_sync_disabled" }, 409); + throw buildBusinessValidationError("travel_import.sync_disabled", { integration: "travel_sync_disabled" }, 409); } const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("travel_transaction.import_input_invalid", parsed.error.flatten()); } const rows = normalizeTravelTransactionRows(parsed.data.transactions); if (!rows.length) { - throw buildValidationError({ transactions: "empty_after_normalization" }); + throw buildBusinessValidationError("travel_transaction.import_empty", { transactions: "empty_after_normalization" }); } const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId))); @@ -447,6 +439,16 @@ async function travelTransactionsRoutes(fastify) { ? row.travelOrderId ?? null : existing?.travel_order_id; + // Authorization import locks this row before reserving budget. Match + // that order before reversal takes a pool lock, since the snapshot + // synchronization below writes the authorization again. + if (nextCardAuthorizationId) { + await tx.$queryRaw` + SELECT id FROM card_authorizations + WHERE id = ${nextCardAuthorizationId}::uuid FOR NO KEY UPDATE + `; + } + const upserted = await tx.cardTransaction.upsert({ where: { source_external_txn_id: { @@ -587,12 +589,12 @@ async function travelTransactionsRoutes(fastify) { async (request, reply) => { const parsedId = UUID_SCHEMA.safeParse(request.params?.id); if (!parsedId.success) { - throw buildValidationError({ id: "invalid_uuid" }); + throw buildBusinessValidationError("travel_transaction.id_invalid", { id: "invalid_uuid" }); } const parsedBody = FREEZE_SUGGESTION_SCHEMA.safeParse(request.body ?? {}); if (!parsedBody.success) { - throw buildValidationError(parsedBody.error.flatten()); + throw buildBusinessValidationError("travel_transaction.freeze_input_invalid", parsedBody.error.flatten()); } const transaction = await fastify.prisma.$transaction(async (tx) => { diff --git a/server/src/routes/users.js b/server/src/routes/users.js index 6605504e..0e3ee743 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -1,3 +1,5 @@ +const { getErrorReason } = require("../constants/errorReasons"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const crypto = require("node:crypto"); const path = require("node:path"); const { Readable } = require("node:stream"); @@ -27,7 +29,7 @@ const { maskBankAccount, maskIdNumber, } = require("../services/crypto"); -const { currentDateString } = require("../utils/date"); +const { currentDateString, isValidDateOnlyString } = require("../utils/date"); const roleCodeSchema = z.enum([ "employee", @@ -71,7 +73,7 @@ const createBodySchema = z.object({ departmentId: z.string().uuid().optional(), title: z.string().min(1).max(100).optional(), directManagerId: z.string().uuid().nullable().optional(), - hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), roles: z.array(roleCodeSchema).min(1).optional(), password: z.string().trim().min(1).optional(), syncWecomDirectory: z.boolean().optional(), @@ -84,7 +86,7 @@ const updateBodySchema = z.object({ departmentId: z.string().uuid().optional(), title: z.string().trim().min(1).max(100).optional(), directManagerId: z.string().uuid().nullable().optional(), - hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(), }); const setRolesBodySchema = z.object({ @@ -133,12 +135,7 @@ const BATCH_SET_MANAGER_SCHEMA = z.object({ function parseUserId(params) { const parsed = z.string().uuid().safeParse(params?.id); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { id: "invalid_uuid" }, - }); + throw buildBusinessValidationError("user.id_invalid", { id: "invalid_uuid" }, 400); } return parsed.data; } @@ -146,12 +143,7 @@ function parseUserId(params) { function parseListQuery(query, options = {}) { const parsed = listPaginationSchema.safeParse(query ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("user.list_query_invalid", parsed.error.flatten(), 400); } return { @@ -177,23 +169,13 @@ function mapEmployeeDirectoryItem(row) { function parseCreateBody(body) { const parsed = createBodySchema.safeParse(body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("user.create_input_invalid", parsed.error.flatten()); } if (typeof parsed.data.password === "string" && parsed.data.password.length > 0) { const errors = validatePassword(parsed.data.password); if (errors.length) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { password: errors }, - }); + throw buildBusinessValidationError("user.password_policy_failed", { password: errors }, 400); } } @@ -205,30 +187,15 @@ function parseCreateBody(body) { function parseUpdateBody(body) { if (body && typeof body === "object" && Object.prototype.hasOwnProperty.call(body, "employeeNo")) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { employeeNo: "immutable" }, - }); + throw buildBusinessValidationError("user.employee_no_immutable", { employeeNo: "immutable" }, 409); } const parsed = updateBodySchema.safeParse(body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("user.update_input_invalid", parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { body: "empty" }, - }); + throw buildBusinessValidationError("user.update_empty", { body: "empty" }); } return parsed.data; @@ -290,44 +257,25 @@ function normalizeUniqueErrorDetails(error) { function parseSensitiveBody(body) { const parsed = sensitiveBodySchema.safeParse(body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("user.sensitive_input_invalid", parsed.error.flatten(), 400); } if (!Object.keys(parsed.data).length) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { body: "empty" }, - }); + throw buildBusinessValidationError("user.sensitive_update_empty", { body: "empty" }, 400); } return parsed.data; } -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - -function parseBatchBody(schema, body) { +function parseBatchBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } const employeeIds = [...new Set(parsed.data.employeeIds)]; if (employeeIds.length > 50) { - throw buildValidationError({ employeeIds: "too_many" }, 409); + throw buildBusinessValidationError("user.batch_limit_exceeded", { employeeIds: "too_many" }, 409); } return { @@ -439,7 +387,7 @@ async function ensureLifecycleFeatureEnabled(fastify, request) { }); if (!gate.enabled) { - throw buildValidationError({ featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409); + throw buildBusinessValidationError("user.lifecycle_disabled", { featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409); } } @@ -450,21 +398,11 @@ async function ensureDepartmentUsable(prisma, departmentId, fieldName = "departm }); if (!department) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { [fieldName]: "not_found" }, - }); + throw buildBusinessValidationError("user.department_not_found", { [fieldName]: "not_found" }, 400); } if (department.archived_at) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { [fieldName]: "archived" }, - }); + throw buildBusinessValidationError("user.department_archived", { [fieldName]: "archived" }, 409); } } @@ -473,12 +411,7 @@ async function ensureManagerUsable(prisma, managerId, options = {}) { const disallowedEmployeeIds = options.disallowedEmployeeIds ?? []; if (disallowedEmployeeIds.includes(managerId)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { [fieldName]: "self_not_allowed" }, - }); + throw buildBusinessValidationError("user.manager_self_forbidden", { [fieldName]: "self_not_allowed" }, 409); } const manager = await prisma.employee.findUnique({ @@ -487,21 +420,11 @@ async function ensureManagerUsable(prisma, managerId, options = {}) { }); if (!manager) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { [fieldName]: "not_found" }, - }); + throw buildBusinessValidationError("user.manager_not_found", { [fieldName]: "not_found" }, 400); } if (manager.status !== "active") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { [fieldName]: "inactive" }, - }); + throw buildBusinessValidationError("user.manager_inactive", { [fieldName]: "inactive" }, 409); } } @@ -585,7 +508,7 @@ function generateOneTimePassword() { const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`; const errors = validatePassword(password); if (errors.length) { - throw buildValidationError({ password: errors }); + throw buildBusinessValidationError("user.generated_password_unavailable", { password: errors }); } return password; } @@ -711,14 +634,46 @@ function resolveWorksheetRows(worksheet) { return { headers, rows }; } +function buildUserImportParseError(error, extension) { + // These dependencies expose plain Error messages, not stable error codes. + // Match only syntax failures reproduced with the installed fast-csv/JSZip/ + // saxes versions. Unknown failures remain internal errors without cell data. + let invalidFormat = false; + if (error instanceof Error && error.constructor === Error && typeof error.message === "string" && + error.code === undefined && error.statusCode === undefined) { + if (extension === ".csv") { + invalidFormat = /^Parse Error: missing closing: '"' in line: at '[\s\S]*'$/.test(error.message) || + /^Parse Error: expected: ',' OR new line got: '[\s\S]'\. at '[\s\S]*$/.test(error.message); + } else if (extension === ".xlsx") { + invalidFormat = error.message === "Can't find end of central directory : is this a zip file ? If it is, see https://stuk.github.io/jszip/documentation/howto/read_zip.html" || + error.message === "Corrupted zip: can't find end of central directory" || + /^\d+:\d+: (?:unmatched closing tag: [\s\S]+|undefined entity|unquoted attribute value)\.$/.test(error.message); + } + } + if (invalidFormat) { + return buildBusinessValidationError("user.import_file_invalid", { file: "invalid_format" }); + } + // Never attach the original exception as cause or copy its message/properties. + return new Error("User import parser failed"); +} + async function parseUserImportRowsFromBuffer(buffer, extension) { const workbook = new ExcelJS.Workbook(); if (extension === ".csv") { - const worksheet = await workbook.csv.read(Readable.from([buffer])); + let worksheet; + try { + worksheet = await workbook.csv.read(Readable.from([buffer]), { map: (value) => value }); + } catch (error) { + throw buildUserImportParseError(error, extension); + } return resolveWorksheetRows(worksheet); } - await workbook.xlsx.load(buffer); + try { + await workbook.xlsx.load(buffer); + } catch (error) { + throw buildUserImportParseError(error, extension); + } return resolveWorksheetRows(workbook.worksheets[0] ?? null); } @@ -728,37 +683,45 @@ async function readUserImportPayload(request) { filePart = await request.file(); } catch (error) { if (error?.code === "FST_REQ_FILE_TOO_LARGE") { - throw buildValidationError({ file: "file_too_large" }); + throw buildBusinessValidationError("user.import_file_too_large", { file: "file_too_large" }); } throw error; } if (!filePart) { - throw buildValidationError({ file: "missing" }); + throw buildBusinessValidationError("user.import_file_missing", { file: "missing" }); } const extension = path.extname(filePart.filename ?? "").toLowerCase(); if (!USER_IMPORT_ALLOWED_EXTENSIONS.has(extension)) { - throw buildValidationError({ file: "unsupported_extension" }); + throw buildBusinessValidationError("user.import_extension_unsupported", { file: "unsupported_extension" }); } if (!USER_IMPORT_ALLOWED_MIME_TYPES.has(filePart.mimetype)) { - throw buildValidationError({ file: "unsupported_mime_type" }); + throw buildBusinessValidationError("user.import_mime_unsupported", { file: "unsupported_mime_type" }); } - const buffer = await filePart.toBuffer(); + let buffer; + try { + buffer = await filePart.toBuffer(); + } catch (error) { + if (error?.code === "FST_REQ_FILE_TOO_LARGE") { + throw buildBusinessValidationError("user.import_file_too_large", { file: "file_too_large" }); + } + throw error; + } const parsed = await parseUserImportRowsFromBuffer(buffer, extension); const missingHeaders = USER_IMPORT_REQUIRED_HEADERS.filter((header) => !parsed.headers.includes(header)); if (missingHeaders.length) { - throw buildValidationError({ file: "missing_headers", missingHeaders }); + throw buildBusinessValidationError("user.import_headers_missing", { file: "missing_headers", missingHeaders }); } if (!parsed.rows.length) { - throw buildValidationError({ file: "empty" }); + throw buildBusinessValidationError("user.import_file_empty", { file: "empty" }); } if (parsed.rows.length > USER_IMPORT_MAX_ROWS) { - throw buildValidationError({ file: "row_limit_exceeded", limit: USER_IMPORT_MAX_ROWS }); + throw buildBusinessValidationError("user.import_row_limit_exceeded", { file: "row_limit_exceeded", limit: USER_IMPORT_MAX_ROWS }); } return { @@ -813,7 +776,7 @@ function validateImportRowFields(row) { pushImportRowError(row, "directManagerEmployeeNo", "too_long"); } - const parsedHireDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).safeParse(normalized.hireDate); + const parsedHireDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).safeParse(normalized.hireDate); if (!parsedHireDate.success) { pushImportRowError(row, "hireDate", "invalid"); } @@ -1013,7 +976,6 @@ async function buildUserImportPreview(prisma, rawRows, options = {}) { async function createSystemUser(tx, options) { const now = options.now ?? new Date(); - const passwordHash = await hashPassword(options.password); const employee = await tx.employee.create({ data: { @@ -1030,7 +992,7 @@ async function createSystemUser(tx, options) { status: "active", bank_account_encrypted: "", bank_name: "", - password_hash: passwordHash, + password_hash: options.passwordHash, must_change_password: true, created_at: now, created_by: options.actorId, @@ -1240,6 +1202,8 @@ async function usersRoutes(fastify, opts = {}) { let created = null; try { + // CPU work must not consume the account/role/audit transaction budget. + const passwordHash = await hashPassword(oneTimePassword); created = await fastify.prisma.$transaction(async (tx) => { const employee = await createSystemUser(tx, { employeeNo: body.employeeNo, @@ -1250,7 +1214,7 @@ async function usersRoutes(fastify, opts = {}) { title: body.title ?? "员工", directManagerId: body.directManagerId ?? null, hireDate, - password: oneTimePassword, + passwordHash, roles: body.roles, actorId: request.employee.id, now, @@ -1276,12 +1240,7 @@ async function usersRoutes(fastify, opts = {}) { }); } catch (error) { if (error?.code === "P2002") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: normalizeUniqueErrorDetails(error), - }); + throw buildBusinessValidationError("user.unique_conflict", normalizeUniqueErrorDetails(error), 409); } throw error; } @@ -1314,7 +1273,7 @@ async function usersRoutes(fastify, opts = {}) { }, async (request, reply) => { await ensureLifecycleFeatureEnabled(fastify, request); - const body = parseBatchBody(BATCH_TRANSFER_DEPARTMENT_SCHEMA, request.body); + const body = parseBatchBody(BATCH_TRANSFER_DEPARTMENT_SCHEMA, request.body, "user.batch_transfer_input_invalid"); await ensureDepartmentUsable(fastify.prisma, body.departmentId); await fastify.prisma.$transaction(async (tx) => { @@ -1364,7 +1323,7 @@ async function usersRoutes(fastify, opts = {}) { }, async (request, reply) => { await ensureLifecycleFeatureEnabled(fastify, request); - const body = parseBatchBody(BATCH_SET_MANAGER_SCHEMA, request.body); + const body = parseBatchBody(BATCH_SET_MANAGER_SCHEMA, request.body, "user.batch_manager_input_invalid"); await ensureManagerUsable(fastify.prisma, body.managerId, { fieldName: "managerId", disallowedEmployeeIds: body.employeeIds, @@ -1416,9 +1375,9 @@ async function usersRoutes(fastify, opts = {}) { preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION), }, async (request, reply) => { - const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body); + const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body, "user.batch_toggle_input_invalid"); if (body.employeeIds.includes(request.employee.id)) { - throw buildValidationError({ user: "self_disable_not_allowed" }, 409); + throw buildBusinessValidationError("user.self_disable_forbidden", { user: "self_disable_not_allowed" }, 409); } const now = new Date(); @@ -1468,7 +1427,7 @@ async function usersRoutes(fastify, opts = {}) { preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION), }, async (request, reply) => { - const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body); + const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body, "user.batch_toggle_input_invalid"); const now = new Date(); await fastify.prisma.$transaction(async (tx) => { @@ -1560,7 +1519,8 @@ async function usersRoutes(fastify, opts = {}) { }); if (preview.summary.invalidRows > 0) { - throw buildValidationError( + throw buildBusinessValidationError( + "user.import_rows_invalid", { summary: preview.summary, rows: preview.rows, @@ -1588,7 +1548,7 @@ async function usersRoutes(fastify, opts = {}) { title: row.normalized.title, directManagerId: row.resolvedImportedManagerEmployeeNo ? null : row.resolvedDirectManagerId, hireDate: new Date(`${row.normalized.hireDate}T00:00:00.000Z`), - password: oneTimePassword, + passwordHash: await hashPassword(oneTimePassword), roles: row.normalized.roles, actorId: request.employee.id, now, @@ -1645,12 +1605,7 @@ async function usersRoutes(fastify, opts = {}) { }); } catch (error) { if (error?.code === "P2002") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: normalizeUniqueErrorDetails(error), - }); + throw buildBusinessValidationError("user.unique_conflict", normalizeUniqueErrorDetails(error), 409); } throw error; } @@ -1687,12 +1642,7 @@ async function usersRoutes(fastify, opts = {}) { const hireDate = typeof body.hireDate === "string" ? new Date(`${body.hireDate}T00:00:00.000Z`) : null; if (hireDate && Number.isNaN(hireDate.getTime())) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { hireDate: "invalid_date" }, - }); + throw buildBusinessValidationError("user.update_input_invalid", { hireDate: "invalid_date" }, 400); } if (typeof body.departmentId === "string") { @@ -1829,12 +1779,7 @@ async function usersRoutes(fastify, opts = {}) { }); } catch (error) { if (error?.code === "P2002") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: normalizeUniqueErrorDetails(error), - }); + throw buildBusinessValidationError("user.unique_conflict", normalizeUniqueErrorDetails(error), 409); } throw error; } @@ -1956,12 +1901,7 @@ async function usersRoutes(fastify, opts = {}) { const userId = parseUserId(request.params); const parsed = sensitiveRevealBodySchema.safeParse(request.body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("user.bank_reveal_input_invalid", parsed.error.flatten(), 400); } const canRevealBank = fastify.hasPermission(request.employee, "employee.bank.read") || fastify.hasPermission(request.employee, "employee.bank.manage"); @@ -2050,12 +1990,7 @@ async function usersRoutes(fastify, opts = {}) { select: { id: true, code: true, name: true }, }); if (!bankInstitution) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { bankInstitutionId: "not_available" }, - }); + throw buildBusinessValidationError("user.bank_institution_unavailable", { bankInstitutionId: "not_available" }, 409); } } data.bank_institution_id = bankInstitution?.id ?? null; @@ -2129,12 +2064,7 @@ async function usersRoutes(fastify, opts = {}) { async (request, reply) => { const userId = parseUserId(request.params); if (userId === request.employee.id) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { user: "self_disable_not_allowed" }, - }); + throw buildBusinessValidationError("user.self_disable_forbidden", { user: "self_disable_not_allowed" }, 409); } const now = new Date(); const employee = await fastify.prisma.$transaction(async (tx) => { @@ -2194,12 +2124,7 @@ async function usersRoutes(fastify, opts = {}) { async (request, reply) => { const userId = parseUserId(request.params); if (userId === request.employee.id) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { user: "self_disable_not_allowed" }, - }); + throw buildBusinessValidationError("user.self_disable_forbidden", { user: "self_disable_not_allowed" }, 409); } const now = new Date(); const employee = await fastify.prisma.$transaction(async (tx) => { @@ -2268,12 +2193,7 @@ async function usersRoutes(fastify, opts = {}) { const userId = parseUserId(request.params); const parsed = resetPasswordBodySchema.safeParse(request.body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("user.reset_password_input_invalid", parsed.error.flatten(), 400); } const newPassword = typeof parsed.data.newPassword === "string" && parsed.data.newPassword.trim() @@ -2283,12 +2203,7 @@ async function usersRoutes(fastify, opts = {}) { const oneTimePassword = newPassword ?? `ResetA1${crypto.randomBytes(6).toString("hex")}`; const errors = validatePassword(oneTimePassword); if (errors.length) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { newPassword: errors }, - }); + throw buildBusinessValidationError("user.password_policy_failed", { newPassword: errors }, 400); } const passwordHash = await hashPassword(oneTimePassword); @@ -2434,22 +2349,12 @@ async function usersRoutes(fastify, opts = {}) { details: { user: "self_role_change_not_allowed" }, }); } - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { user: "self_role_change_not_allowed" }, - }); + throw buildBusinessValidationError("user.self_role_change_forbidden", { user: "self_role_change_not_allowed" }, 409); } const parsed = setRolesBodySchema.safeParse(request.body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildBusinessValidationError("user.roles_input_invalid", parsed.error.flatten(), 400); } const roles = parsed.data.roles; diff --git a/server/src/routes/wecomAuth.js b/server/src/routes/wecomAuth.js index 0b6f5c38..832875ce 100644 --- a/server/src/routes/wecomAuth.js +++ b/server/src/routes/wecomAuth.js @@ -2,6 +2,7 @@ const crypto = require("node:crypto"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { buildAuditLogData } = require("../utils/audit"); const { randomRefreshToken, sha256Hex } = require("../utils/crypto"); const { signPayload, verifyToken } = require("../utils/signedUrl"); @@ -118,10 +119,10 @@ function normalizeJsSdkPageUrl(rawUrl, config, request) { try { parsed = new URL(rawUrl); } catch { - throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { url: "invalid" } }); + throw buildBusinessValidationError("wecom.signature_url_invalid", { url: "invalid" }, 400); } if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) { - throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { url: "invalid" } }); + throw buildBusinessValidationError("wecom.signature_url_invalid", { url: "invalid" }, 400); } const allowedOrigin = new URL(resolvePrimaryAppOrigin(config, request)).origin; if (parsed.origin !== allowedOrigin) { @@ -236,12 +237,7 @@ function assertWecomOauthReady(config) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (!entryConfig.bindEnabled) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { wecom: "missing_required_config", statusReasons: entryConfig.statusReasons }, - }); + throw buildBusinessValidationError("wecom.entry_config_incomplete", { wecom: "missing_required_config", statusReasons: entryConfig.statusReasons }, 409); } return entryConfig; } @@ -282,12 +278,7 @@ function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login", entryS async function getWecomAccessToken(config) { if (!config.wecomAgentSecret) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { wecom: "missing_agent_secret" }, - }); + throw buildBusinessValidationError("wecom.auth_config_incomplete", { wecom: "missing_agent_secret" }, 409); } const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/gettoken"); url.searchParams.set("corpid", config.wecomCorpId); @@ -490,7 +481,7 @@ async function wecomAuthRoutes(fastify) { fastify.post("/wecom/js-sdk-signature", { preHandler: fastify.authenticate }, async (request, reply) => { const parsed = JS_SDK_SIGNATURE_BODY_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { - throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { url: "invalid" } }); + throw buildBusinessValidationError("wecom.signature_url_invalid", { url: "invalid" }, 400); } const pageUrl = normalizeJsSdkPageUrl(parsed.data.url, fastify.config, request); const ticketResult = await getWecomJsApiTicket({ @@ -515,17 +506,12 @@ async function wecomAuthRoutes(fastify) { fastify.get("/wecom/login", async (request, reply) => { const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { - throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() }); + throw buildBusinessValidationError("wecom.login_query_invalid", parsed.error.flatten(), 400); } const mode = parsed.data.mode ?? "login"; const entrySource = parsed.data.entrySource ?? ENTRY_SOURCE_WECOM_WORKBENCH; if (entrySource === ENTRY_SOURCE_PC_QR && mode !== "login") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { entrySource: "unsupported_for_mode" }, - }); + throw buildBusinessValidationError("wecom.pc_mode_unsupported", { entrySource: "unsupported_for_mode" }, 400); } if (mode === "bind") { assertWecomOauthReady(fastify.config); @@ -566,7 +552,7 @@ async function wecomAuthRoutes(fastify) { assertWecomOauthReady(fastify.config); const parsed = BIND_BODY_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { - throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() }); + throw buildBusinessValidationError("wecom.bind_input_invalid", parsed.error.flatten(), 400); } const returnTo = sanitizeReturnTo(parsed.data?.returnTo, fastify.config, request); const token = signPayload({ diff --git a/server/src/routes/weeklyReports.js b/server/src/routes/weeklyReports.js index 3b415770..05444d30 100644 --- a/server/src/routes/weeklyReports.js +++ b/server/src/routes/weeklyReports.js @@ -1,11 +1,13 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { sendData } = require("../utils/reply"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); -const { currentDateString, parseDateOnlyString } = require("../utils/date"); +const { currentDateString, parseDateOnlyString, isValidDateOnlyString } = require("../utils/date"); -const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const UUID_SCHEMA = z.string().uuid(); const QUERY_SCHEMA = z.object({ weekStart: DATE_SCHEMA.optional() }); const WRITE_SCHEMA = z @@ -20,14 +22,10 @@ const WRITE_SCHEMA = z .strict() .refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); -function buildValidationError(details, statusCode = 400) { - return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); -} - -function parseBody(schema, body) { +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); } return parsed.data; } @@ -53,7 +51,7 @@ async function ensureFeatureEnabled(fastify, request) { strictDepartment: false, }); if (!gate.enabled) { - throw buildValidationError({ featureFlag: "weekly_report_v1_disabled", reason: gate.reason }, 409); + throw buildBusinessValidationError("weekly_report.feature_disabled", { featureFlag: "weekly_report_v1_disabled", reason: gate.reason }, 409); } } @@ -108,7 +106,7 @@ async function weeklyReportsRoutes(fastify) { await ensureFeatureEnabled(fastify, request); const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("weekly_report.query_invalid", parsed.error.flatten()); } const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone); const report = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart); @@ -117,11 +115,11 @@ async function weeklyReportsRoutes(fastify) { fastify.put("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const body = parseBody(WRITE_SCHEMA, request.body); + const body = parseBody(WRITE_SCHEMA, request.body, "weekly_report.write_input_invalid"); const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone); const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart); if (current.status === "submitted") { - throw buildValidationError({ status: "submitted_readonly" }, 409); + throw buildBusinessValidationError("weekly_report.submitted_readonly", { status: "submitted_readonly" }, 409); } const updated = await fastify.prisma.$transaction(async (tx) => { const saved = await tx.weeklyReport.update({ @@ -136,7 +134,7 @@ async function weeklyReportsRoutes(fastify) { if (body.projectLinks !== undefined) { const linkGate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectLinking, departmentId: request.employee.department_id ?? null, strictDepartment: false }); if (!linkGate.enabled) { - throw buildValidationError({ featureFlag: "project_linking_v1_disabled", reason: linkGate.reason }, 409); + throw buildBusinessValidationError("weekly_report.project_linking_disabled", { featureFlag: "project_linking_v1_disabled", reason: linkGate.reason }, 409); } await tx.projectLinkOnWeeklyReport.deleteMany({ where: { weekly_report_id: current.id } }); if (body.projectLinks.length) { @@ -158,7 +156,7 @@ async function weeklyReportsRoutes(fastify) { fastify.post("/weekly-reports/me/submit", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); - const body = parseBody(QUERY_SCHEMA, request.body); + const body = parseBody(QUERY_SCHEMA, request.body, "weekly_report.submit_input_invalid"); const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone); const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart); const updated = await fastify.prisma.weeklyReport.update({ @@ -178,7 +176,7 @@ async function weeklyReportsRoutes(fastify) { } const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("weekly_report.query_invalid", parsed.error.flatten()); } const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone); const [employees, reports] = await Promise.all([ diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index 88891748..3bb49e47 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -14,13 +14,7 @@ const { toMoneyString, } = require("../utils/workflow"); const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave"); -const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { confirmBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget"); -const { - PROCUREMENT_ERP_TARGETS, - enqueuePurchaseCommitmentJournalExport, - loadPurchaseRequestErpContext, -} = require("../services/integrations/erpProcurement"); const { createNotificationsWithRecords, resolveRecipientsForAssignee, @@ -263,89 +257,6 @@ function parseTaskSummaryQuery(query) { }; } -async function handlePurchaseApprovalErpSync({ - tx, - config, - request, - actorId, - purchaseRequestId, - loadContext = loadPurchaseRequestErpContext, - evaluateGate = evaluateFeatureFlag, - enqueueCommitment = enqueuePurchaseCommitmentJournalExport, -}) { - const context = await loadContext(tx, purchaseRequestId); - if (!context?.purchaseRequest) { - return { skipped: true, reason: 'purchase_request_not_found' }; - } - - const purchaseRequest = context.purchaseRequest; - const departmentId = purchaseRequest.department_id ?? request.employee.department_id ?? null; - const gate = await evaluateGate({ - prisma: tx, - config, - flagKey: FEATURE_FLAG_KEYS.erpSync, - departmentId, - strictDepartment: true, - }); - - if (!gate.enabled) { - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId, - action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG', - targetType: 'purchase_request', - targetId: purchaseRequest.id, - extra: { - erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal, - eventType: 'erp.journal.export', - purchaseRequestId: purchaseRequest.id, - purchaseOrderArchiveId: null, - gateReason: gate.reason, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - departmentId, - }, - }), - }); - - return { skipped: true, gate, purchaseRequest }; - } - - const enqueued = await enqueueCommitment(tx, config, { - purchaseRequest, - occurredAt: context.approvalOccurredAt ?? new Date(), - actorId, - requeueOnFailure: false, - }); - - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId, - action: 'ERP_SYNC_ENQUEUED', - targetType: 'purchase_request', - targetId: purchaseRequest.id, - extra: { - erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal, - eventType: enqueued.eventType, - purchaseRequestId: purchaseRequest.id, - purchaseOrderArchiveId: null, - outboxId: enqueued.id, - duplicated: enqueued.duplicated, - requeued: enqueued.requeued, - gateReason: gate.reason, - featureFlag: FEATURE_FLAG_KEYS.erpSync, - departmentId, - }, - }), - }); - - return { - skipped: false, - gate, - purchaseRequest, - enqueued, - }; -} - function parseTaskListQuery(query) { const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { @@ -1700,21 +1611,6 @@ async function executeApproveWorkflowTask({ actorId: request.employee.id, note: "approved_confirm_purchase_budget", }); - - try { - await handlePurchaseApprovalErpSync({ - tx, - config: fastify.config, - request, - actorId: request.employee.id, - purchaseRequestId: workflowInstance.entity_id, - }); - } catch (error) { - fastify.log.error( - { err: error, purchaseRequestId: workflowInstance.entity_id }, - "Failed to enqueue purchase approval ERP events", - ); - } } else if (workflowInstance.entity_type === "travel_request") { await tx.travelRequest.update({ where: { id: workflowInstance.entity_id }, @@ -3265,7 +3161,6 @@ module.exports.__testables = { calculateWorkflowTaskDueAt, canViewWorkflowEntityType, employeeHasBusinessApproveRole, - handlePurchaseApprovalErpSync, isWorkflowTaskOverdue, listAllowedActionsForTask, parseTaskListQuery, diff --git a/server/src/routes/workflowDefinitions.js b/server/src/routes/workflowDefinitions.js index 0cbd31e1..61f0e07b 100644 --- a/server/src/routes/workflowDefinitions.js +++ b/server/src/routes/workflowDefinitions.js @@ -1,6 +1,8 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { getErrorReason } = require("../constants/errorReasons"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); @@ -20,14 +22,13 @@ const WRITE_SCHEMA = z.object({ }).strict(); const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); -function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } -function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); return parsed.data; } -function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) throw buildValidationError(parsed.error.flatten()); return parsed.data; } -function parseEntityType(value) { const parsed = ENTITY_TYPE_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ entityType: "invalid" }); return parsed.data; } +function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) throw buildBusinessValidationError("workflow_definition.id_invalid", { id: "invalid_uuid" }); return parsed.data; } +function parseBody(schema, body, reasonCode) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) throw buildBusinessValidationError(getErrorReason(reasonCode).code, parsed.error.flatten()); return parsed.data; } +function parseEntityType(value) { const parsed = ENTITY_TYPE_SCHEMA.safeParse(value); if (!parsed.success) throw buildBusinessValidationError("workflow_definition.entity_type_invalid", { entityType: "invalid" }); return parsed.data; } async function ensureFeatureEnabled(fastify) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin, strictDepartment: false }); - if (!gate.enabled) throw buildValidationError({ featureFlag: "workflow_definition_admin_v1_disabled", reason: gate.reason }, 409); + if (!gate.enabled) throw buildBusinessValidationError("workflow_definition.feature_disabled", { featureFlag: "workflow_definition_admin_v1_disabled", reason: gate.reason }, 409); } function mapStatus(item) { @@ -123,8 +124,8 @@ async function workflowDefinitionsRoutes(fastify) { fastify.post("/workflow-definitions", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify); - const body = parseBody(WRITE_SCHEMA, request.body); - if (body.entityType === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409); + const body = parseBody(WRITE_SCHEMA, request.body, "workflow_definition.create_input_invalid"); + if (body.entityType === "reimbursement") throw buildBusinessValidationError("workflow_definition.reimbursement_readonly", { entityType: "unsupported_entity_type" }, 409); const latest = await fastify.prisma.workflowDefinition.findFirst({ where: { entity_type: body.entityType }, orderBy: { version: "desc" }, select: { version: true } }); const created = await fastify.prisma.workflowDefinition.create({ data: { @@ -143,11 +144,11 @@ async function workflowDefinitionsRoutes(fastify) { fastify.patch("/workflow-definitions/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify); const definitionId = parseId(request.params?.id); - const body = parseBody(PATCH_SCHEMA, request.body); + const body = parseBody(PATCH_SCHEMA, request.body, "workflow_definition.update_input_invalid"); const current = await fastify.prisma.workflowDefinition.findUnique({ where: { id: definitionId } }); if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); - if (current.entity_type === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409); - if (current.archived_at || (current.published_at && current.is_active)) throw buildValidationError({ status: "published_or_archived_readonly" }, 409); + if (current.entity_type === "reimbursement") throw buildBusinessValidationError("workflow_definition.reimbursement_readonly", { entityType: "unsupported_entity_type" }, 409); + if (current.archived_at || (current.published_at && current.is_active)) throw buildBusinessValidationError("workflow_definition.not_editable", { status: "published_or_archived_readonly" }, 409); const updated = await fastify.prisma.workflowDefinition.update({ where: { id: definitionId }, data: { @@ -173,8 +174,8 @@ async function workflowDefinitionsRoutes(fastify) { const result = await fastify.prisma.$transaction(async (tx) => { const current = await tx.workflowDefinition.findUnique({ where: { id: definitionId } }); if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); - if (current.entity_type === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409); - if (current.archived_at) throw buildValidationError({ status: "archived_not_publishable" }, 409); + if (current.entity_type === "reimbursement") throw buildBusinessValidationError("workflow_definition.reimbursement_readonly", { entityType: "unsupported_entity_type" }, 409); + if (current.archived_at) throw buildBusinessValidationError("workflow_definition.archived_not_publishable", { status: "archived_not_publishable" }, 409); await tx.workflowDefinition.updateMany({ where: { entity_type: current.entity_type, id: { not: definitionId }, is_active: true }, data: { is_active: false, updated_by: request.employee.id } }); return tx.workflowDefinition.update({ where: { id: definitionId }, data: { is_active: true, published_at: new Date(), updated_by: request.employee.id } }); }); @@ -186,7 +187,7 @@ async function workflowDefinitionsRoutes(fastify) { const definitionId = parseId(request.params?.id); const current = await fastify.prisma.workflowDefinition.findUnique({ where: { id: definitionId } }); if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); - if (current.entity_type === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409); + if (current.entity_type === "reimbursement") throw buildBusinessValidationError("workflow_definition.reimbursement_readonly", { entityType: "unsupported_entity_type" }, 409); const archived = await fastify.prisma.workflowDefinition.update({ where: { id: definitionId }, data: { is_active: false, archived_at: new Date(), updated_by: request.employee.id } }); return sendData(reply, request, { item: mapDefinition(archived) }); }); diff --git a/server/src/scripts/import-project-ledgers.js b/server/src/scripts/import-project-ledgers.js index 92c7336e..e6c4b99b 100644 --- a/server/src/scripts/import-project-ledgers.js +++ b/server/src/scripts/import-project-ledgers.js @@ -4,6 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); const ExcelJS = require('exceljs'); +const { reserveProjectNumber } = require('../services/businessNumbering'); const { createPrismaClient } = require('../prismaClient'); const SUPPORTED_TYPES = ['projects', 'members', 'milestones', 'risks', 'contracts']; @@ -203,11 +204,14 @@ async function handleProjects({ prisma, rows, apply, summary }) { if (!current) { summary.createCount += 1; if (apply) { - await prisma.project.create({ - data: { - ...payload, - members: { create: { employee_id: owner.id, role: 'owner' } }, - }, + await prisma.$transaction(async (tx) => { + await reserveProjectNumber(tx, payload.project_no); + return tx.project.create({ + data: { + ...payload, + members: { create: { employee_id: owner.id, role: 'owner' } }, + }, + }); }); } continue; @@ -215,11 +219,14 @@ async function handleProjects({ prisma, rows, apply, summary }) { summary.updateCount += 1; if (apply) { - await prisma.project.update({ where: { id: current.id }, data: payload }); - await prisma.projectMember.upsert({ - where: { project_id_employee_id: { project_id: current.id, employee_id: owner.id } }, - create: { project_id: current.id, employee_id: owner.id, role: 'owner' }, - update: { role: 'owner', left_at: null }, + await prisma.$transaction(async (tx) => { + await reserveProjectNumber(tx, payload.project_no); + await tx.project.update({ where: { id: current.id }, data: payload }); + await tx.projectMember.upsert({ + where: { project_id_employee_id: { project_id: current.id, employee_id: owner.id } }, + create: { project_id: current.id, employee_id: owner.id, role: 'owner' }, + update: { role: 'owner', left_at: null }, + }); }); } } diff --git a/server/src/scripts/manage-business-numbering.js b/server/src/scripts/manage-business-numbering.js new file mode 100644 index 00000000..36db6a2b --- /dev/null +++ b/server/src/scripts/manage-business-numbering.js @@ -0,0 +1,36 @@ +// Explicit administrative operation. Does not load .env or activate at startup. +const { isValidDateOnlyString } = require("../utils/date"); +const { createPrismaClient } = require("../prismaClient"); +const { setNumberingPolicy, readLegacyPeriodOverlaps } = require("../services/businessNumbering"); + +async function main(argv = process.argv.slice(2)) { + if (argv[0] === "--help") { + console.log("status | activate --writers-compatible | pause/resume --writers-compatible"); + return; + } + const [action, ...args] = argv; + if (!["status", "activate", "pause", "resume"].includes(action) || + (action === "status" ? args.length !== 0 : args.length !== (action === "activate" ? 4 : 3) || args.at(-1) !== "--writers-compatible")) { + throw new Error("Invalid numbering command; use --help"); + } + let effectiveAt; + if (action === "activate") { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})$/.test(args[0])) throw new Error("Explicit ISO timestamp and timezone required"); + if (!isValidDateOnlyString(args[0].slice(0, 10))) throw new Error("Valid calendar date required"); + effectiveAt = new Date(args.shift()); + } + const { prisma, pool } = createPrismaClient(); + try { + const policy = action === "status" ? await prisma.businessNumberPolicy.findUnique({ where: { version: 2 } }) + : await prisma.$transaction((tx) => setNumberingPolicy(tx, { + action, effectiveAt, actorId: args[0], reference: args[1], writersCompatible: true, + })); + console.log(JSON.stringify({ version: policy?.version ?? 1, enabled: policy?.enabled ?? false, + effectiveAt: policy?.effective_at?.toISOString() ?? null, + ...(action === "status" ? { legacyPeriodReviewRequired: await readLegacyPeriodOverlaps(prisma) } : {}) })); + } finally { + await prisma.$disconnect(); await pool.end(); + } +} +if (require.main === module) main().catch(() => { console.error("Business numbering operation failed; no credentials or database details are emitted."); process.exitCode = 1; }); +module.exports = { main }; diff --git a/server/src/services/ai/provider.js b/server/src/services/ai/provider.js index 990b495e..a10414e9 100644 --- a/server/src/services/ai/provider.js +++ b/server/src/services/ai/provider.js @@ -138,7 +138,7 @@ function buildMockSuggestion(targetType, snapshot) { level: "low", code: "archive_missing", title: "归档信息尚未形成", - detail: "采购已通过但尚未录入归档摘要,后续财务/ERP协同可能延后。", + detail: "采购已通过但尚未录入归档摘要,后续财务交接可能延后。", }); } diff --git a/server/src/services/ai/suggestions.js b/server/src/services/ai/suggestions.js index 9215ddd2..8ac8369e 100644 --- a/server/src/services/ai/suggestions.js +++ b/server/src/services/ai/suggestions.js @@ -1,11 +1,70 @@ +const ASSET_AI_SELECT = { + id: true, + asset_no: true, + name: true, + category_id: true, + legal_entity_id: true, + cost_center_id: true, + purchase_request_id: true, + serial_no: true, + location: true, + status: true, + assignee_employee_id: true, + activated_at: true, + retired_at: true, + created_at: true, + updated_at: true, + category: { + select: { + id: true, + code: true, + name: true, + document_category: { + select: { + code: true, + }, + }, + }, + }, + legal_entity: { + select: { + id: true, + code: true, + name: true, + }, + }, + cost_center: { + select: { + id: true, + code: true, + name: true, + }, + }, + purchase_request: { + select: { + id: true, + request_no: true, + title: true, + status: true, + }, + }, + assignee: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, +}; + const crypto = require("node:crypto"); const { AppError } = require("../../utils/AppError"); +const { buildBusinessValidationError } = require("../../utils/businessValidationError"); const { buildAuditLogData } = require("../../utils/audit"); const { resolvePermissionsForRoles, hasPermission } = require("../../constants/permissions"); const { resolveApplicableBudgetPoolByContext } = require("../budgetPool"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../featureFlags"); -const { loadAssetErpContext } = require("../integrations/erpAsset"); const { buildAiProvider, AiProviderError } = require("./provider"); const { buildReimbursementPrompt, @@ -246,15 +305,10 @@ function normalizeProviderSuggestion(targetType, suggestion) { } function buildAiDisabledError(reason) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { + return buildBusinessValidationError("ai.feature_disabled", { ai: "disabled", featureFlagReason: reason, - }, - }); + }, 409); } function resolveUpstreamAiCode(error) { @@ -397,7 +451,7 @@ async function loadPurchaseSuggestionContext(prisma, purchaseRequestId) { } async function loadAssetSuggestionContext(prisma, assetId) { - const context = await loadAssetErpContext(prisma, assetId); + const context = { asset: await prisma.assetCard.findUnique({ where: { id: assetId }, select: ASSET_AI_SELECT }) }; if (!context?.asset) { throw new AppError({ code: "SYS_404", diff --git a/server/src/services/attendance.js b/server/src/services/attendance.js index 909fb00a..b66c37fa 100644 --- a/server/src/services/attendance.js +++ b/server/src/services/attendance.js @@ -1,4 +1,4 @@ -const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); const ATTENDANCE_STATUS_VALUES = [ @@ -27,15 +27,6 @@ const DEFAULT_ATTENDANCE_POLICY = Object.freeze({ enabled: true, }); -function buildAttendanceValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function formatDateOnly(value) { if (!value) { return null; @@ -100,7 +91,7 @@ function getZonedMinutes(date, timeZone) { function parseTimeToMinutes(value, fieldName = "time") { const input = String(value ?? "").trim(); if (!TIME_VALUE_PATTERN.test(input)) { - throw buildAttendanceValidationError({ [fieldName]: "invalid_time" }); + throw buildBusinessValidationError("attendance.policy_time_invalid", { [fieldName]: "invalid_time" }); } const [hourValue, minuteValue] = input.split(":").map(Number); @@ -112,7 +103,7 @@ function parseTimeToMinutes(value, fieldName = "time") { minuteValue < 0 || minuteValue > 59 ) { - throw buildAttendanceValidationError({ [fieldName]: "invalid_time" }); + throw buildBusinessValidationError("attendance.policy_time_invalid", { [fieldName]: "invalid_time" }); } return hourValue * 60 + minuteValue; @@ -224,19 +215,19 @@ function validateAttendancePolicyInput(input) { const lateToleranceMinutes = Number(input.lateToleranceMinutes ?? 0); if (!Number.isInteger(lateToleranceMinutes) || lateToleranceMinutes < 0 || lateToleranceMinutes > 600) { - throw buildAttendanceValidationError({ lateToleranceMinutes: "invalid" }); + throw buildBusinessValidationError("attendance.tolerance_invalid", { lateToleranceMinutes: "invalid" }); } if (workStartMinutes >= workEndMinutes) { - throw buildAttendanceValidationError({ workTime: "invalid_range" }); + throw buildBusinessValidationError("attendance.work_range_invalid", { workTime: "invalid_range" }); } if (lunchStartMinutes >= lunchEndMinutes) { - throw buildAttendanceValidationError({ lunchTime: "invalid_range" }); + throw buildBusinessValidationError("attendance.lunch_range_invalid", { lunchTime: "invalid_range" }); } if (lunchStartMinutes < workStartMinutes || lunchEndMinutes > workEndMinutes) { - throw buildAttendanceValidationError({ lunchTime: "outside_work_range" }); + throw buildBusinessValidationError("attendance.lunch_outside_work_range", { lunchTime: "outside_work_range" }); } return { @@ -689,10 +680,10 @@ function buildMonthRange(year, month) { const normalizedYear = Number(year); const normalizedMonth = Number(month); if (!Number.isInteger(normalizedYear) || normalizedYear < 2000 || normalizedYear > 2100) { - throw buildAttendanceValidationError({ year: "invalid" }); + throw buildBusinessValidationError("attendance.year_invalid", { year: "invalid" }); } if (!Number.isInteger(normalizedMonth) || normalizedMonth < 1 || normalizedMonth > 12) { - throw buildAttendanceValidationError({ month: "invalid" }); + throw buildBusinessValidationError("attendance.month_invalid", { month: "invalid" }); } const dateFrom = parseDateOnlyString(`${normalizedYear}-${String(normalizedMonth).padStart(2, "0")}-01`); @@ -814,7 +805,6 @@ module.exports = { EXEMPTION_REASON_VALUES, addUtcDays, buildAttendanceMetrics, - buildAttendanceValidationError, buildDateStrings, buildMonthRange, buildMyMonthAttendance, diff --git a/server/src/services/budgetPool.js b/server/src/services/budgetPool.js index 5590c23d..2290bb7c 100644 --- a/server/src/services/budgetPool.js +++ b/server/src/services/budgetPool.js @@ -1,4 +1,5 @@ -const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { Prisma } = require("@prisma/client"); const BUDGET_SCOPE_TYPES = Object.freeze({ global: "global", @@ -12,30 +13,21 @@ const BUDGET_SCOPE_PRIORITY = [ BUDGET_SCOPE_TYPES.global, ]; -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function resolveBudgetScope({ departmentId = null, legalEntityId = null, costCenterId = null } = {}) { const hasDepartment = Boolean(departmentId); const hasLegalEntity = Boolean(legalEntityId); const hasCostCenter = Boolean(costCenterId); if (hasCostCenter && !hasLegalEntity) { - throw buildValidationError({ costCenterId: "legalEntityId_required" }); + throw buildBusinessValidationError("budget.company_required", { costCenterId: "legalEntityId_required" }); } if (hasDepartment && (hasLegalEntity || hasCostCenter)) { - throw buildValidationError({ scope: "mixed_dimensions_not_allowed" }, 409); + throw buildBusinessValidationError("budget.scope_mixed", { scope: "mixed_dimensions_not_allowed" }, 409); } if (hasLegalEntity && !hasCostCenter) { - throw buildValidationError({ costCenterId: "required_with_legalEntityId" }); + throw buildBusinessValidationError("budget.cost_center_required", { costCenterId: "required_with_legalEntityId" }); } if (hasLegalEntity && hasCostCenter) { @@ -108,7 +100,7 @@ function matchesBudgetPoolContext(record, context) { function normalizeBudgetContext({ departmentId = null, legalEntityId = null, costCenterId = null } = {}) { if (costCenterId && !legalEntityId) { - throw buildValidationError({ costCenterId: "legalEntityId_required" }); + throw buildBusinessValidationError("budget.company_required", { costCenterId: "legalEntityId_required" }); } return { @@ -132,7 +124,7 @@ function selectBudgetPoolCandidate(records, context = {}) { if (matches.length > 1) { if (scopeType === BUDGET_SCOPE_TYPES.costCenterScoped) { - throw buildValidationError({ budget: "budget_pool_ambiguous", scopeType }, 409); + throw buildBusinessValidationError("budget.pool_ambiguous", { budget: "budget_pool_ambiguous", scopeType }, 409); } return matches[0]; } @@ -144,9 +136,10 @@ function selectBudgetPoolCandidate(records, context = {}) { async function resolveApplicableBudgetPoolByContext( tx, { departmentId = null, legalEntityId = null, costCenterId = null, effectiveDate }, + { forUpdate = false, additionalPoolIds = [] } = {}, ) { if (!(effectiveDate instanceof Date) || Number.isNaN(effectiveDate.getTime())) { - throw buildValidationError({ effectiveDate: "invalid_date" }); + throw buildBusinessValidationError("budget.effective_date_invalid", { effectiveDate: "invalid_date" }); } const records = await tx.budgetPool.findMany({ @@ -159,11 +152,38 @@ async function resolveApplicableBudgetPoolByContext( orderBy: [{ created_at: "asc" }, { code: "asc" }], }); - return selectBudgetPoolCandidate(records, { + const context = { departmentId, legalEntityId, costCenterId, - }); + }; + const candidate = selectBudgetPoolCandidate(records, context); + if (!forUpdate) return candidate; + + await lockBudgetPoolsForUpdate(tx, [candidate?.id, ...additionalPoolIds]); + const current = await resolveApplicableBudgetPoolByContext(tx, { ...context, effectiveDate }); + if ((current?.id ?? null) !== (candidate?.id ?? null)) { + throw buildBusinessValidationError("budget.pool_changed", { budget: "pool_changed" }, 409); + } + return current; +} + +// Callers own the entity/workflow lock. This helper never acquires one in the +// reverse direction. NO KEY UPDATE serializes money/state writers while +// allowing ledger/entity foreign-key KEY SHARE checks on the pool. +async function lockBudgetPoolsForUpdate(tx, poolIds) { + // Prisma 7 transaction clients support nested $transaction. $connect is + // root-only; rejecting $transaction would reject real interactive clients. + if (!tx || typeof tx.$queryRaw !== "function" || typeof tx.$connect === "function") { + throw new TypeError("budget writes require a transaction client"); + } + const ids = [...new Set(poolIds.filter(Boolean))].sort(); + if (!ids.length) return; + await tx.$queryRaw(Prisma.sql` + SELECT id FROM budget_pools + WHERE id IN (${Prisma.join(ids.map(id => Prisma.sql`${id}::uuid`))}) + ORDER BY id FOR NO KEY UPDATE + `); } module.exports = { @@ -172,4 +192,5 @@ module.exports = { resolveBudgetScopeType, selectBudgetPoolCandidate, resolveApplicableBudgetPoolByContext, + lockBudgetPoolsForUpdate, }; diff --git a/server/src/services/businessNumbering.js b/server/src/services/businessNumbering.js new file mode 100644 index 00000000..071ef26e --- /dev/null +++ b/server/src/services/businessNumbering.js @@ -0,0 +1,153 @@ +const { randomUUID } = require("node:crypto"); +const { Prisma } = require("@prisma/client"); +const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks"); + +// Keys are explicit int4 lock identities, never hashes of user input. +const BUSINESS_NUMBER_FAMILIES = Object.freeze({ + reimbursement: { lockKey: 1, prefix: "BX", period: "monthly", table: "reimbursement_forms", column: "form_number" }, + payment_batch: { lockKey: 2, prefix: "PB", period: "yearly", table: "payment_batches", column: "batch_no" }, + company_loan: { lockKey: 3, prefix: "JK", period: "yearly", table: "company_loans", column: "loan_no" }, + loan_repayment: { lockKey: 4, prefix: "HK", period: "yearly", table: "loan_repayments", column: "repayment_no" }, + purchase_request: { lockKey: 5, prefix: "CG", period: "yearly", table: "purchase_requests", column: "request_no" }, + inventory_check: { lockKey: 6, prefix: "PD", period: "yearly", table: "inventory_checks", column: "check_no" }, + project: { lockKey: 7, prefix: "PRJ", period: "yearly", table: "projects", column: "project_no" }, + asset_card: { lockKey: 8, prefix: "FA", period: "none", table: "asset_cards", column: "asset_no" }, +}); + +function businessDateAt(now, timeZone = "Asia/Shanghai") { + const parts = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }) + .formatToParts(now); + const value = (type) => parts.find((part) => part.type === type).value; + return `${value("year")}-${value("month")}-${value("day")}`; +} + +async function resolveNumberingContext(tx, { version, now, readOnly = false } = {}) { + if (version !== undefined && version !== 1 && version !== 2) throw new Error("Invalid numbering version"); + if (version !== undefined) return { version, now: now ?? new Date() }; + if (!readOnly) { + await tx.$executeRaw`SELECT pg_advisory_xact_lock_shared(${ADVISORY_LOCK_NAMESPACES.businessNumberPolicy}::int, 0::int)`; + } + const policy = await tx.businessNumberPolicy.findUnique({ where: { version: 2 } }); + const instant = now ?? new Date(); + return { version: policy?.enabled && policy.effective_at <= instant ? 2 : 1, now: instant }; +} + +function numberSpec(family, { now, timeZone, prefix } = {}) { + const familySpec = BUSINESS_NUMBER_FAMILIES[family]; + if (!familySpec) throw new Error("Unsupported numbering family"); + const businessDate = businessDateAt(now, timeZone); + const compact = businessDate.replaceAll("-", ""); + const period = familySpec.period === "monthly" ? compact.slice(0, 6) : familySpec.period === "yearly" ? compact.slice(0, 4) : ""; + const resolvedPrefix = family === "asset_card" ? prefix ?? familySpec.prefix : familySpec.prefix; + if (typeof resolvedPrefix !== "string" || !resolvedPrefix.trim() || resolvedPrefix.length > 40 || /[\x00-\x1f\x7f]/.test(resolvedPrefix)) { + throw new Error("Invalid numbering prefix"); + } + return { ...familySpec, prefix: resolvedPrefix, businessDate, datePart: period, + stem: `${resolvedPrefix}-${period ? `${period}-` : ""}`, key: `v2:${family}:${resolvedPrefix}:${period || "permanent"}` }; +} + +async function lockCounter(tx, spec) { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.businessNumberCounter}::int, ${spec.lockKey}::int)`; +} + +async function existingMaximum(tx, spec) { + // Identifiers come only from the closed family registry, all values stay bound. + const column = Prisma.raw(`"${spec.column}"`); + const escapedStem = spec.stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = `^${escapedStem}[0-9]{4,}$`; + const rows = await tx.$queryRaw(Prisma.sql` + SELECT COALESCE(MAX(SUBSTRING(${column} FROM ${Array.from(spec.stem).length + 1}::int)::bigint), 0)::bigint AS maximum + FROM ${Prisma.raw(`"${spec.table}"`)} WHERE ${column} ~ ${pattern} + `); + return BigInt(rows[0].maximum); +} + +async function saveCounter(tx, spec, value) { + return tx.businessNumberCounter.upsert({ where: { key: spec.key }, + create: { key: spec.key, current_number: value }, update: { current_number: value } }); +} + +function configFor(spec, counter) { + return { exists: Boolean(counter), source: "business_number_policy", documentType: spec.key.split(":")[1], + prefix: spec.prefix, dateFormat: spec.period === "monthly" ? "YYYYMM" : spec.period === "yearly" ? "YYYY" : "", + separator: "-", numberLength: 4, resetPeriod: spec.period, + // Number is safe up to 2^53-1; avoid silently rounding unusually large counters. + currentNumber: counter && counter.current_number > BigInt(Number.MAX_SAFE_INTEGER) + ? String(counter.current_number) : Number(counter?.current_number ?? 0), + lastResetDate: null, updatedAt: counter?.updated_at ?? null, updatedBy: null, ruleVersion: 2 }; +} + +async function readBusinessNumberConfig(prisma, family, options) { + const spec = numberSpec(family, options); + const counter = await prisma.businessNumberCounter.findUnique({ where: { key: spec.key } }); + return configFor(spec, counter); +} + +async function issueBusinessNumber(tx, { family, now, timeZone, prefix }) { + const spec = numberSpec(family, { now: now ?? new Date(), timeZone, prefix }); + await lockCounter(tx, spec); + const counter = await tx.businessNumberCounter.findUnique({ where: { key: spec.key } }); + const occupied = await existingMaximum(tx, spec); + const highWater = BigInt(counter?.current_number ?? 0); + const next = (highWater > occupied ? highWater : occupied) + 1n; + if (next > 9223372036854775807n) throw new Error("Business number capacity exhausted"); + const saved = await saveCounter(tx, spec, next); + const sequence = String(next).padStart(4, "0"); + const config = configFor(spec, saved); + return { documentNumber: `${spec.stem}${sequence}`, businessDate: spec.businessDate, serial: sequence, config, sequence: config }; +} + +// Formal project codes stay caller supplied. Reserve matching v2 codes even while +// the policy is off so later deletion cannot cause an automatic number to be reused. +async function reserveProjectNumber(tx, documentNumber) { + const match = /^PRJ-([0-9]{4})-([0-9]{4,})$/.exec(documentNumber ?? ""); + if (!match) return; + const value = BigInt(match[2]); + if (value > 9223372036854775807n) throw new Error("Business number capacity exhausted"); + const spec = { ...BUSINESS_NUMBER_FAMILIES.project, key: `v2:project:PRJ:${match[1]}` }; + await lockCounter(tx, spec); + const current = await tx.businessNumberCounter.findUnique({ where: { key: spec.key } }); + if (value > BigInt(current?.current_number ?? 0)) await saveCounter(tx, spec, value); +} + +// Existing source numbers may overlap v2 and are handled by the locked maximum. +// An actively issuing legacy rule with the same period is different: independent +// counters could strand old unnumbered drafts. Require a specific compatibility +// review before activation/resume instead of silently changing that old rule. +async function readLegacyPeriodOverlaps(prisma) { + const overlaps = []; + for (const [documentType, dateFormat] of [["reimbursement", "YYYYMM"], ["purchase_request", "YYYY"], ["inventory_check", "YYYY"]]) { + const sequence = await prisma.documentSequence.findUnique({ where: { document_type: documentType }, select: { date_format: true } }); + if (sequence?.date_format === dateFormat) overlaps.push(documentType); + } + return overlaps; +} + +async function setNumberingPolicy(tx, { action, effectiveAt, actorId, reference, writersCompatible, now }) { + if (!writersCompatible || !/^[0-9a-f-]{36}$/i.test(actorId ?? "") || !/^[A-Za-z0-9_.:-]{1,120}$/.test(reference ?? "")) { + throw new Error("Numbering activation requires compatible writers, actor and change reference"); + } + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.businessNumberPolicy}::int, 0::int)`; + const existing = await tx.businessNumberPolicy.findUnique({ where: { version: 2 } }); + let policy; + if (action === "activate") { + if (existing || !(effectiveAt instanceof Date) || !Number.isFinite(effectiveAt.getTime())) { + throw new Error("Numbering activation requires a new future effective time"); + } + if ((await readLegacyPeriodOverlaps(tx)).length) throw new Error("Legacy numbering periods require compatibility review"); + if (effectiveAt < (now ?? new Date())) throw new Error("Numbering activation requires a new future effective time"); + policy = await tx.businessNumberPolicy.create({ data: { version: 2, enabled: true, effective_at: effectiveAt, + updated_by: actorId, reference } }); + } else if (["pause", "resume"].includes(action) && existing) { + if (action === "resume" && (await readLegacyPeriodOverlaps(tx)).length) throw new Error("Legacy numbering periods require compatibility review"); + policy = await tx.businessNumberPolicy.update({ where: { version: 2 }, + data: { enabled: action === "resume", updated_by: actorId, reference } }); + } else throw new Error("Invalid numbering policy transition"); + await tx.auditLog.create({ data: { actor_id: actorId, action: "BUSINESS_NUMBER_POLICY_CHANGED", + request_id: randomUUID(), target_type: "business_number_policy", target_id: null, + extra: { action, reference, effectiveAt: policy.effective_at.toISOString(), enabled: policy.enabled } } }); + return policy; +} + +module.exports = { BUSINESS_NUMBER_FAMILIES, businessDateAt, resolveNumberingContext, issueBusinessNumber, + readBusinessNumberConfig, reserveProjectNumber, readLegacyPeriodOverlaps, setNumberingPolicy }; diff --git a/server/src/services/companyLoanPayments.js b/server/src/services/companyLoanPayments.js new file mode 100644 index 00000000..f2ac9616 --- /dev/null +++ b/server/src/services/companyLoanPayments.js @@ -0,0 +1,232 @@ +const { resolveNumberingContext, issueBusinessNumber } = require("./businessNumbering"); +const { randomUUID } = require("node:crypto"); +const { Prisma } = require("@prisma/client"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions"); +const { decryptStringOrThrow, maskBankAccount } = require("./crypto"); +const { + LENDER_INCLUDE, LOAN_INCLUDE, loanNotFound, lockLender, loanBalances, + readLenderAccount, assertEvidenceComplete, auditLoan, mapEvidence, +} = require("./companyLoans"); + +const REPAYMENT_INCLUDE = { + lender: { include: LENDER_INCLUDE }, allocations: { include: { loan: { include: LOAN_INCLUDE } }, orderBy: { loan_id: "asc" } }, +}; + + +function assertLenderReady(lender) { + if (lender?.identity_status !== "verified" || !lender.identity_confirmed_at || !lender.identity_confirmed_by) { throw buildBusinessValidationError("loan.identity_required", {}, 409); }; + if (lender.archived_at || !lender.legal_entity?.enabled || lender.legal_entity.archived_at || + !lender.bank_institution?.enabled || lender.bank_institution.archived_at || lender.bank_institution.code !== "CCB") { throw buildBusinessValidationError("loan.source_unavailable", {}, 409); }; +} + +async function activeReviewer(tx, id, excludedIds, { lock = false } = {}) { + if (!id || excludedIds.includes(id)) { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); }; + if (lock) { + await tx.$queryRaw`SELECT id FROM employees WHERE id=${id}::uuid FOR SHARE`; + await tx.$queryRaw`SELECT employee_id FROM employee_roles WHERE employee_id=${id}::uuid FOR SHARE`; + } + const employee = await tx.employee.findUnique({ where: { id }, include: { roles: true } }); + const roles = employee?.roles.map((role) => role.role_code) ?? []; + if (employee?.status !== "active" || employee.archived_at || !roles.some((r) => ["manager", "ceo"].includes(r)) || + !hasPermission(resolvePermissionsForRoles(roles), "payment.batch.review")) { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); }; + return employee; +} + +async function eligibleLoanReviewers(tx, excludedIds) { + const candidates = await tx.employee.findMany({ where: { + status: "active", archived_at: null, id: { notIn: excludedIds.filter(Boolean) }, + roles: { some: { role_code: { in: ["manager", "ceo"] } } }, + }, include: { roles: true }, orderBy: [{ employee_no: "asc" }, { id: "asc" }] }); + const eligible = candidates.filter((e) => !excludedIds.includes(e.id) && + hasPermission(resolvePermissionsForRoles(e.roles.map((r) => r.role_code)), "payment.batch.review")); + if (!eligible.length) { throw buildBusinessValidationError("loan.reviewer_required", {}, 409); }; + return [(eligible.find((e) => e.roles.some((r) => r.role_code === "ceo")) ?? eligible[0]).id]; +} + +async function readLoanRepayment(tx, batch) { + if (batch.entity_type !== "loan_repayment" || !batch.loan_repayment_id || batch.items?.length !== 1 || + batch.items[0].entity_id !== batch.loan_repayment_id) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; + const repayment = await tx.loanRepayment.findUnique({ where: { id: batch.loan_repayment_id }, include: REPAYMENT_INCLUDE }); + if (!repayment) throw loanNotFound(); + return repayment; +} + +async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null, recheckApproval = false } = {}) { + let repayment = await readLoanRepayment(tx, batch); + if (lock) { + await lockLender(tx, repayment.lender_id); + repayment = await readLoanRepayment(tx, batch); + } + const lender = repayment.lender; + assertLenderReady(lender); + if (lender.identity_version !== repayment.lender_identity_version || lender.account_version !== repayment.lender_account_version) { throw buildBusinessValidationError("loan.version_changed", {}, 409); }; + if (repayment.legal_entity_id !== batch.legal_entity_id || repayment.currency_code !== "CNY" || + !new Prisma.Decimal(repayment.amount).eq(batch.items[0].amount) || batch.items[0].currency_code !== "CNY" || + batch.default_payment_channel !== "bank" || batch.items[0].payment_channel_override && batch.items[0].payment_channel_override !== "bank") { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; + if (batch.items[0].snapshot_json?.payee?.bankAccountEncrypted !== lender.bank_account_encrypted || + batch.items[0].snapshot_json?.payee?.accountNameEncrypted !== lender.account_name_encrypted) { throw buildBusinessValidationError("loan.version_changed", {}, 409); }; + const excluded = [batch.created_by, repayment.submitted_by, lender.employee_id].filter(Boolean); + if (reviewerId) await activeReviewer(tx, reviewerId, excluded, { lock }); + if (recheckApproval) await activeReviewer(tx, batch.reviewed_by, excluded, { lock }); + if (lock) { + const ids = repayment.allocations.map((a) => a.loan_id).sort(); + if (!ids.length) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); }; + await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`); + repayment = await readLoanRepayment(tx, batch); + } + for (const allocation of repayment.allocations) { + const loan = allocation.loan; + if (loan.version !== allocation.loan_version || loan.confirmation_status !== "confirmed") { throw buildBusinessValidationError("loan.version_changed", {}, 409); }; + assertEvidenceComplete(loan); + if (new Prisma.Decimal(loanBalances(loan).available).lt(0)) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); }; + } + if (lender.employee_id && !await tx.employee.findFirst({ where: { id: lender.employee_id, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.identity_required", {}, 409); }; + return repayment; +} + +// Existing batch paths must call this only AFTER receipt advisory/attachment +// locks. Creation locks lender -> sorted loans -> daily sequence, and never +// waits on a previously existing batch. Lender/loan maintenance never locks a batch. +async function prepareLoanRepayment(tx, config, request, input) { + const lender = await lockLender(tx, input.lenderId); + assertLenderReady(lender); + if (lender.employee_id && !await tx.employee.findFirst({ where: { id: lender.employee_id, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.identity_required", {}, 409); }; + const ids = input.allocations.map((a) => a.loanId).sort(); + if (!ids.length || ids.length !== new Set(ids).size) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); }; + await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`); + const loans = await tx.companyLoan.findMany({ where: { id: { in: ids } }, include: LOAN_INCLUDE, orderBy: { id: "asc" } }); + if (loans.length !== ids.length) throw loanNotFound(); + let total = new Prisma.Decimal(0); + for (const loan of loans) { + const allocation = input.allocations.find((a) => a.loanId === loan.id); + if (loan.confirmation_status !== "confirmed") { throw buildBusinessValidationError("loan.balance_unconfirmed", {}, 409); }; + if (loan.lender_id !== lender.id || loan.legal_entity_id !== lender.legal_entity_id || loan.currency_code !== "CNY" || + !new Prisma.Decimal(allocation.amount).gt(0) || new Prisma.Decimal(allocation.amount).gt(loanBalances(loan).available)) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); }; + assertEvidenceComplete(loan); + total = total.plus(allocation.amount); + } + if (total.gt("999999999999.99")) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); }; + const reviewerIds = await eligibleLoanReviewers(tx, [request.employee.id, lender.employee_id]); + const accounts = await tx.paymentAccount.findMany({ where: { + legal_entity_id: lender.legal_entity_id, enabled: true, archived_at: null, + bank_institution: { code: "CCB", enabled: true, archived_at: null }, + }, include: { bank_institution: true }, orderBy: { id: "asc" } }); + const account = accounts[0]; + if (!account) { throw buildBusinessValidationError("payment_batch.payment_account_required", {}, 409); }; + let payer; + try { + const plain = decryptStringOrThrow(config, account.account_number_encrypted); + if (!plain) throw new Error("empty payer"); + payer = { paymentAccountId: account.id, legalEntityId: lender.legal_entity_id, accountName: account.account_name, + bankInstitutionId: account.bank_institution_id, bankInstitutionCode: "CCB", bankName: account.bank_institution.name, + accountNumberEncrypted: account.account_number_encrypted, accountNumberMasked: maskBankAccount(plain) }; + } catch { { throw buildBusinessValidationError("payment_batch.payment_account_unavailable", {}, 409); }; } + const payee = { name: `${lender.name.slice(0, 1)}***`, bankInstitutionId: lender.bank_institution_id, + bankInstitutionCode: "CCB", bankName: lender.bank_name, bankAccountEncrypted: lender.bank_account_encrypted, + accountNameEncrypted: lender.account_name_encrypted, bankAccountMasked: maskBankAccount(readLenderAccount(config, lender).bankAccount) }; + const id = randomUUID(); + const numbering = await resolveNumberingContext(tx); + const repaymentNo = numbering.version === 2 + ? (await issueBusinessNumber(tx, { family: "loan_repayment", ...numbering, timeZone: config.appTimezone })).documentNumber + : `HK-${id}`; + const repayment = await tx.loanRepayment.create({ data: { + id, repayment_no: repaymentNo, legal_entity_id: lender.legal_entity_id, lender_id: lender.id, + amount: total, reason: input.reason, submitted_by: request.employee.id, + lender_identity_version: lender.identity_version, lender_account_version: lender.account_version, + payee_snapshot_json: payee, request_key: input.requestKey, + allocations: { create: loans.map((loan) => ({ loan_id: loan.id, loan_version: loan.version, + amount: input.allocations.find((a) => a.loanId === loan.id).amount })) }, + } }); + return { repayment, numbering, reviewerIds, payee, payer, account, years: [...new Set(loans.map((l) => l.received_date.getUTCFullYear()))].sort() }; +} + +function repaymentMatchesInput(repayment, input) { + return repayment.lender_id === input.lenderId && repayment.reason === input.reason && + repayment.allocations.length === input.allocations.length && input.allocations.every((a) => + repayment.allocations.some((saved) => saved.loan_id === a.loanId && new Prisma.Decimal(saved.amount).eq(a.amount))); +} + +async function settleLoanRepayment(tx, batch, request) { + const repayment = await validateLoanPayment(tx, batch, { lock: true, recheckApproval: true }); + if (repayment.bank_result_state === "confirmed_failed") { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; + await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" }, + data: { reservation_state: "settled", settled_at: new Date() } }); + await tx.loanRepayment.update({ where: { id: repayment.id }, data: { + bank_result_state: "confirmed_success", bank_result_ref: `payment-batch:${batch.id}`, + } }); + await auditLoan(tx, request, "LOAN_PRINCIPAL_SETTLED", repayment.id, { paymentBatchId: batch.id, allocationCount: repayment.allocations.length }); +} + +async function cancelLoanRepayment(tx, batch, request, reason) { + // Stale identity/account must not prevent cancelling a never-sent request. + const repayment = await readLoanRepayment(tx, batch); + if (!["not_sent", "confirmed_failed"].includes(repayment.bank_result_state) || + repayment.bank_result_state === "not_sent" && (batch.payment_order_export_count > 0 || + await tx.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: batch.id } }) > 0)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; + const ids = repayment.allocations.map((a) => a.loan_id).sort(); + await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`); + await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id }, data: { + reservation_state: "released", released_at: new Date(), release_reason: reason, + } }); + await auditLoan(tx, request, "LOAN_RESERVATION_RELEASED", repayment.id, { paymentBatchId: batch.id }); +} + +async function loanReceiptParticipants(tx, batch) { + const repayment = await validateLoanPayment(tx, batch); + return [{ ownerId: repayment.lender.employee_id, externalOwnerVerified: repayment.lender.employee_id === null, + submitterId: repayment.submitted_by }]; +} + +async function confirmLoanBankFailure(tx, config, batch, request, attachmentId) { + const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks"); + const { batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification"); + const { matchBankSlip } = require("./paymentReceiptMatching"); + if (!["reviewed", "failed"].includes(batch.status)) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`; + await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id=${attachmentId}::uuid FOR UPDATE`; + const repayment = await validateLoanPayment(tx, batch, { lock: true, reviewerId: request.employee.id }); + const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } }); + // More than one bank attempt/evidence is ambiguous: one failed attempt cannot + // establish that another attempt did not pay. Preserve all reservations. + if (await tx.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: batch.id } }) !== 1) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + if (!attachment || attachment.payment_batch_id !== batch.id || attachment.archived_at || + attachment.governance_status !== "approved" || attachment.uploaded_by === request.employee.id) { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); }; + const verification = await tx.paymentReceiptVerification.findFirst({ where: { attachment_id: attachmentId }, orderBy: [{ created_at: "desc" }, { id: "desc" }] }); + if (!verification || verification.source_sha256 !== attachment.file_sha256 || verification.source_version !== attachment.receipt_ocr_version) { throw buildBusinessValidationError("loan.version_changed", {}, 409); }; + const recognition = JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted)); + if (recognition.status !== "recognized" || recognition.pagesExpected !== 1 || recognition.pagesProcessed !== 1 || recognition.pages.length !== 1) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + const page = recognition.pages[0]; + if (page.transactionStatus !== "failed" || page.pageNumber !== 1 || !page.reference) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + // A confirmed bank failure still needs the exact payer/payee/amount/currency. + // Reusing matching here never turns this evidence into a success verification. + const match = matchBankSlip({ ...page, transactionStatus: "success", issues: (page.issues ?? []).filter((v) => v !== "transaction_failed") }, batchCandidates(config, batch)); + if (match.status !== "matched") { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + const { fingerprint, key_id: keyId } = transactionFingerprint(config, page); + if (await tx.paymentReceiptClaim.count({ where: { key_id: { not: keyId } } }) || + await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: keyId } } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + if (await tx.paymentReceiptClaim.count({ where: { OR: [{ fingerprint }, { item_id: batch.items[0].id }] } }) || + await tx.paymentReceiptTransactionAlias.count({ where: { fingerprint } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + await tx.loanRepayment.update({ where: { id: repayment.id }, data: { + bank_result_state: "confirmed_failed", bank_result_ref: `verification:${verification.id}`, + } }); + await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: verification.id }); +} + +function mapRepayment(repayment, batch) { + return { + id: repayment.id, repaymentNo: repayment.repayment_no, lenderId: repayment.lender_id, + lenderName: repayment.lender.name, amount: repayment.amount.toFixed(2), reason: repayment.reason, + bankResultState: repayment.bank_result_state, paymentBatchId: batch.id, status: batch.status, + accountingHandoffRef: repayment.accounting_handoff_ref, accountingHandoffAt: repayment.accounting_handoff_at, + allocations: repayment.allocations.map((a) => ({ loanId: a.loan_id, loanNo: a.loan.loan_no, + amount: a.amount.toFixed(2), state: a.reservation_state, receivedDate: a.loan.received_date.toISOString().slice(0, 10), + originReference: a.loan.origin_reference, balanceAsOf: a.loan.balance_as_of.toISOString().slice(0, 10), + evidences: a.loan.evidences.filter((e) => !e.archived_at).map(mapEvidence) })), + }; +} + +module.exports = { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput, + eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure }; diff --git a/server/src/services/companyLoans.js b/server/src/services/companyLoans.js new file mode 100644 index 00000000..4ab08634 --- /dev/null +++ b/server/src/services/companyLoans.js @@ -0,0 +1,149 @@ +const { Prisma } = require("@prisma/client"); +const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { buildAuditLogData } = require("../utils/audit"); +const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("./crypto"); + +const LENDER_INCLUDE = { bank_institution: true, legal_entity: true }; +const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true }; + +function ensureLoanManager(fastify, request) { + if (!request.employee?.roleCodes?.some((role) => ["finance", "finance_mgr"].includes(role)) || + !fastify.hasPermission(request.employee, "loan.manage")) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } +} + +function loanNotFound() { return buildBusinessValidationError("loan.not_found", {}, 404); } + +async function auditLoan(tx, request, action, targetId, extra = {}) { + await tx.auditLog.create({ data: buildAuditLogData(request, { + actorId: request.employee.id, action, targetType: "company_loan", targetId, extra, + }) }); +} + +// Decimal arithmetic throughout: persisted principal never passes through Number. +function loanBalances(loan) { + const sum = (state) => (loan.allocations ?? []).filter((a) => a.reservation_state === state) + .reduce((total, a) => total.plus(a.amount), new Prisma.Decimal(0)); + const settled = sum("settled"); + const reserved = sum("reserved"); + const outstanding = new Prisma.Decimal(loan.opening_outstanding_principal).minus(settled); + return { + outstanding: outstanding.toFixed(2), reserved: reserved.toFixed(2), settled: settled.toFixed(2), + available: loan.confirmation_status === "confirmed" ? outstanding.minus(reserved).toFixed(2) : "0.00", + }; +} + +function readLenderAccount(config, lender) { + try { + const bankAccount = decryptStringOrThrow(config, lender.bank_account_encrypted); + const accountName = decryptStringOrThrow(config, lender.account_name_encrypted); + if (!bankAccount || !accountName) throw new Error("empty account"); + return { bankAccount, accountName }; + } catch { + throw buildBusinessValidationError("loan.account_unavailable", {}, 409); + } +} + +function encryptLenderAccount(config, input) { + try { + return { + bank_account_encrypted: encryptStringOrThrow(config, input.bankAccount), + account_name_encrypted: encryptStringOrThrow(config, input.accountName), + }; + } catch { + throw buildBusinessValidationError("loan.account_unavailable", {}, 409); + } +} + +function mapLender(config, lender) { + let bankAccountMasked = null; let accountUnavailable = false; + try { bankAccountMasked = maskBankAccount(readLenderAccount(config, lender).bankAccount); } + catch { accountUnavailable = true; } + return { + id: lender.id, name: lender.name, legalEntityId: lender.legal_entity_id, + legalEntityName: lender.legal_entity?.name ?? null, employeeId: lender.employee_id, + identityStatus: lender.identity_status, identityVersion: lender.identity_version, + accountVersion: lender.account_version, bankInstitutionId: lender.bank_institution_id, + bankName: lender.bank_name, bankAccountMasked, accountUnavailable, + }; +} + +function mapEvidence(evidence) { + return { + id: evidence.id, kind: evidence.kind, originalName: evidence.original_name, + fileSize: evidence.file_size, mimeType: evidence.mime_type, + governanceStatus: evidence.governance_status, uploadedAt: evidence.uploaded_at, + archivedAt: evidence.archived_at, + }; +} + +function mapLoan(config, loan) { + return { + id: loan.id, loanNo: loan.loan_no, originReference: loan.origin_reference, + lender: mapLender(config, loan.lender), currency: loan.currency_code, + originalPrincipal: new Prisma.Decimal(loan.original_principal).toFixed(2), + openingOutstandingPrincipal: new Prisma.Decimal(loan.opening_outstanding_principal).toFixed(2), + receivedDate: loan.received_date.toISOString().slice(0, 10), + balanceAsOf: loan.balance_as_of.toISOString().slice(0, 10), historicalEntry: loan.historical_entry, + confirmationStatus: loan.confirmation_status, confirmedAt: loan.confirmed_at, version: loan.version, + accountingConfirmationRef: loan.accounting_confirmation_ref, + ...loanBalances(loan), evidences: (loan.evidences ?? []).map(mapEvidence), + }; +} + +async function lockLoan(tx, id) { + const rows = await tx.$queryRaw`SELECT id FROM company_loans WHERE id=${id}::uuid FOR UPDATE`; + if (!rows.length) throw loanNotFound(); + return tx.companyLoan.findUniqueOrThrow({ where: { id }, include: LOAN_INCLUDE }); +} + +async function lockLender(tx, id) { + const rows = await tx.$queryRaw`SELECT id FROM loan_lenders WHERE id=${id}::uuid FOR UPDATE`; + if (!rows.length) throw loanNotFound(); + return tx.loanLender.findUniqueOrThrow({ where: { id }, include: LENDER_INCLUDE }); +} + +function assertEvidenceComplete(loan) { + const kinds = new Set((loan.evidences ?? []).filter((e) => !e.archived_at && e.governance_status === "approved").map((e) => e.kind)); + const required = ["agreement", "incoming", "accounting_confirmation"]; + if (new Prisma.Decimal(loan.opening_outstanding_principal).lt(loan.original_principal)) required.push("adjustment"); + if (required.some((kind) => !kinds.has(kind))) throw buildBusinessValidationError("loan.evidence_required", {}, 409); +} + +function assertCurrentVersion(actual, expected) { + if (actual !== expected) throw buildBusinessValidationError("loan.version_changed", {}, 409); +} + +async function validateLenderReferences(tx, input) { + const company = await tx.legalEntity.findFirst({ where: { id: input.legalEntityId, enabled: true, archived_at: null, currency_code: "CNY" } }); + const bank = await tx.bankInstitution.findFirst({ where: { id: input.bankInstitutionId, enabled: true, archived_at: null, code: "CCB" } }); + if (!company || !bank) throw buildBusinessValidationError("loan.source_unavailable", {}, 409); +} + +// Retry P2002 only outside the aborted transaction. The request fingerprint is +// compared by the caller; one key must never acknowledge different business data. +async function createIdempotently(prisma, operation, lookup, matches) { + try { + return await prisma.$transaction(async (tx) => { + const existing = await lookup(tx); + if (existing) { + if (!matches(existing)) throw buildBusinessValidationError("loan.duplicate_registration", {}, 409); + return existing; + } + return operation(tx); + }); + } catch (error) { + if (error?.code !== "P2002") throw error; + const existing = await lookup(prisma); + if (existing && matches(existing)) return existing; + throw buildBusinessValidationError("loan.duplicate_registration", {}, 409); + } +} + +module.exports = { + LENDER_INCLUDE, LOAN_INCLUDE, ensureLoanManager, loanNotFound, auditLoan, loanBalances, + readLenderAccount, encryptLenderAccount, mapLender, mapLoan, mapEvidence, lockLoan, lockLender, + assertEvidenceComplete, assertCurrentVersion, validateLenderReferences, createIdempotently, +}; diff --git a/server/src/services/contractRegistryFilters.js b/server/src/services/contractRegistryFilters.js index 54b6f8e0..fa27b160 100644 --- a/server/src/services/contractRegistryFilters.js +++ b/server/src/services/contractRegistryFilters.js @@ -1,12 +1,12 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { z } = require("zod"); -const { AppError } = require("../utils/AppError"); -const { currentDateString, parseDateOnlyString } = require("../utils/date"); +const { currentDateString, parseDateOnlyString, isValidDateOnlyString } = require("../utils/date"); const CONTRACT_REGISTRY_TYPE_VALUES = ["purchase", "sales", "service", "framework", "other"]; const CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES = ["draft", "active", "terminated", "archived"]; const CONTRACT_REGISTRY_EFFECTIVE_STATUS_VALUES = [...CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES, "expired"]; -const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const CONTRACT_REGISTRY_FILTER_INPUT_SCHEMA = z .object({ @@ -25,19 +25,10 @@ const CONTRACT_REGISTRY_EXPIRING_QUERY_SCHEMA = z }) .strict(); -function buildContractRegistryValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseContractRegistryFilters(input) { const parsed = CONTRACT_REGISTRY_FILTER_INPUT_SCHEMA.safeParse(input ?? {}); if (!parsed.success) { - throw buildContractRegistryValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("contract_registry.query_invalid", parsed.error.flatten()); } const filters = { @@ -49,7 +40,7 @@ function parseContractRegistryFilters(input) { const start = parseDateOnlyString(filters.expiryDateFrom); const end = parseDateOnlyString(filters.expiryDateTo); if (start > end) { - throw buildContractRegistryValidationError({ + throw buildBusinessValidationError("contract_registry.expiry_date_range_invalid", { expiryDateTo: "must_be_after_or_equal_expiryDateFrom", }); } @@ -61,7 +52,7 @@ function parseContractRegistryFilters(input) { function parseContractRegistryExpiringQuery(input) { const parsed = CONTRACT_REGISTRY_EXPIRING_QUERY_SCHEMA.safeParse(input ?? {}); if (!parsed.success) { - throw buildContractRegistryValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("contract_registry.expiring_query_invalid", parsed.error.flatten()); } return { @@ -226,7 +217,6 @@ module.exports = { CONTRACT_REGISTRY_TYPE_VALUES, buildContractRegistryExpiringWhere, buildContractRegistryScopeWhere, - buildContractRegistryValidationError, buildContractRegistryWhere, canManageContractRegistry, formatContractRegistryAmount, diff --git a/server/src/services/cutover.js b/server/src/services/cutover.js index 9a209794..87a2e8c2 100644 --- a/server/src/services/cutover.js +++ b/server/src/services/cutover.js @@ -1,4 +1,4 @@ -const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags"); async function ensureCutoverAllowed({ @@ -49,17 +49,12 @@ async function ensureCutoverAllowed({ }) ?? null; if (!matchedScope) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { + throw buildBusinessValidationError("cutover.scope_not_enabled", { cutover: "not_enabled", domain, departmentId, legalEntityId, - }, - }); + }, 409); } return { diff --git a/server/src/services/documentSequence.js b/server/src/services/documentSequence.js index e21e6514..eeca9fbf 100644 --- a/server/src/services/documentSequence.js +++ b/server/src/services/documentSequence.js @@ -1,4 +1,5 @@ -const { AppError } = require("../utils/AppError"); +const { BUSINESS_NUMBER_FAMILIES, resolveNumberingContext, issueBusinessNumber, readBusinessNumberConfig } = require("./businessNumbering"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { compactDateString } = require("../utils/date"); const DOCUMENT_TYPE_ALIASES = { @@ -96,20 +97,11 @@ const DOCUMENT_TYPE_CONFIG = { }, }; -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function normalizeDocumentType(documentType) { const rawValue = String(documentType ?? "").trim().toLowerCase(); const normalized = DOCUMENT_TYPE_ALIASES[rawValue] ?? rawValue; if (!DOCUMENT_TYPE_CONFIG[normalized]) { - throw buildValidationError({ documentType: "unsupported_document_type" }, 409); + throw buildBusinessValidationError("document_sequence.type_unsupported", { documentType: "unsupported_document_type" }, 409); } return normalized; } @@ -189,15 +181,15 @@ async function findDocumentCategory(prisma, { documentType, documentCategoryCode }); if (!category) { - throw buildValidationError({ documentCategoryCode: "not_found" }, 409); + throw buildBusinessValidationError("document_sequence.category_missing", { documentCategoryCode: "not_found" }, 409); } if (category.entity_type !== normalizedDocumentType) { - throw buildValidationError({ documentCategoryCode: "entity_type_mismatch" }, 409); + throw buildBusinessValidationError("document_sequence.category_type_mismatch", { documentCategoryCode: "entity_type_mismatch" }, 409); } if (!category.enabled || category.archived_at) { - throw buildValidationError({ documentCategoryCode: "disabled_or_archived" }, 409); + throw buildBusinessValidationError("document_sequence.category_unavailable", { documentCategoryCode: "disabled_or_archived" }, 409); } return category; @@ -250,7 +242,7 @@ async function findDocumentCategory(prisma, { documentType, documentCategoryCode }); } -async function resolveDocumentSequenceConfig(prisma, { documentType, documentCategoryCode = null, sequenceKey = null } = {}) { +async function resolveLegacySequenceConfig(prisma, { documentType, documentCategoryCode = null, sequenceKey = null } = {}) { const normalizedDocumentType = normalizeDocumentType(documentType); const documentTypeConfig = DOCUMENT_TYPE_CONFIG[normalizedDocumentType]; const resolvedSequenceKey = sequenceKey || normalizedDocumentType; @@ -301,6 +293,16 @@ async function resolveDocumentSequenceConfig(prisma, { documentType, documentCat return buildConfigFromFallback(normalizedDocumentType); } +async function resolveDocumentSequenceConfig(prisma, options = {}) { + const documentType = normalizeDocumentType(options.documentType); + const legacy = await resolveLegacySequenceConfig(prisma, options); + if (!BUSINESS_NUMBER_FAMILIES[documentType]) return legacy; + const context = await resolveNumberingContext(prisma, { readOnly: true }); + if (context.version === 1) return legacy; + const prefix = documentType === "asset_card" && options.assetType && options.assetType !== "fixed_asset" ? legacy.prefix : undefined; + return { ...legacy, ...await readBusinessNumberConfig(prisma, documentType, { ...context, timeZone: options.timeZone, prefix }) }; +} + function formatDatePart(dateFormat, timeZone) { const compact = compactDateString(timeZone); if (dateFormat === "YYYY") { @@ -320,16 +322,24 @@ function formatDocumentNumber(sequenceConfig, timeZone) { async function issueDocumentNumber( tx, - { documentType, documentCategoryCode = null, sequenceKey = null, actorId = null, timeZone = "Asia/Shanghai" } = {}, + { documentType, documentCategoryCode = null, sequenceKey = null, actorId = null, timeZone = "Asia/Shanghai", numberingVersion, assetType, now } = {}, ) { const normalizedDocumentType = normalizeDocumentType(documentType); const resolvedSequenceKey = sequenceKey || normalizedDocumentType; - const config = await resolveDocumentSequenceConfig(tx, { + const config = await resolveLegacySequenceConfig(tx, { documentType: normalizedDocumentType, documentCategoryCode, sequenceKey: resolvedSequenceKey, }); + if (BUSINESS_NUMBER_FAMILIES[normalizedDocumentType]) { + const context = await resolveNumberingContext(tx, { version: numberingVersion, now }); + if (context.version === 2) { + const prefix = normalizedDocumentType === "asset_card" && assetType && assetType !== "fixed_asset" ? config.prefix : undefined; + return issueBusinessNumber(tx, { family: normalizedDocumentType, ...context, timeZone, prefix }); + } + } + const sequenceRecord = await tx.documentSequence.upsert({ where: { document_type: resolvedSequenceKey }, update: { diff --git a/server/src/services/employeeDirectoryFilters.js b/server/src/services/employeeDirectoryFilters.js index 09de25ce..d2fc6ce6 100644 --- a/server/src/services/employeeDirectoryFilters.js +++ b/server/src/services/employeeDirectoryFilters.js @@ -1,19 +1,11 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { z } = require("zod"); -const { AppError } = require("../utils/AppError"); +const { isValidDateOnlyString } = require("../utils/date"); -const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }); const EMPLOYMENT_STATUS_VALUES = ["onboarding", "probation", "active", "offboarding", "inactive"]; -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function parseEmployeeDirectoryFilters(query, options = {}) { const allowDepartmentFilter = options.allowDepartmentFilter === true; const schema = z.object({ @@ -29,7 +21,7 @@ function parseEmployeeDirectoryFilters(query, options = {}) { const parsed = schema.safeParse(query ?? {}); if (!parsed.success) { - throw buildValidationError(parsed.error.flatten()); + throw buildBusinessValidationError("employee_directory.query_invalid", parsed.error.flatten()); } const filters = { @@ -44,7 +36,7 @@ function parseEmployeeDirectoryFilters(query, options = {}) { }; if (filters.hireDateFrom && filters.hireDateTo && filters.hireDateFrom > filters.hireDateTo) { - throw buildValidationError({ hireDateTo: "must_be_after_or_equal_hireDateFrom" }); + throw buildBusinessValidationError("employee_directory.hire_date_range_invalid", { hireDateTo: "must_be_after_or_equal_hireDateFrom" }); } return filters; @@ -98,6 +90,5 @@ module.exports = { DATE_ONLY_SCHEMA, EMPLOYMENT_STATUS_VALUES, buildEmployeeDirectoryWhere, - buildValidationError, parseEmployeeDirectoryFilters, }; diff --git a/server/src/services/featureFlags/index.js b/server/src/services/featureFlags/index.js index 5d84decc..b9e85573 100644 --- a/server/src/services/featureFlags/index.js +++ b/server/src/services/featureFlags/index.js @@ -1,6 +1,5 @@ const FEATURE_FLAG_KEYS = { aiAssist: "ai_assist", - erpSync: "erp_sync", invoiceVerification: "invoice_verification", leaveHolidayCalendar: "leave_holiday_calendar", budgetPrecheck: "budget_precheck_v1", @@ -83,10 +82,6 @@ function resolveEnvGate(config, flagKey) { return Boolean(config.aiAssistEnabled); } - if (flagKey === FEATURE_FLAG_KEYS.erpSync) { - return Boolean(config.erpSyncEnabled); - } - if (flagKey === FEATURE_FLAG_KEYS.invoiceVerification) { return Boolean(config.invoiceVerificationEnabled); } diff --git a/server/src/services/feeControl.js b/server/src/services/feeControl.js index a04fdb57..a5adca66 100644 --- a/server/src/services/feeControl.js +++ b/server/src/services/feeControl.js @@ -1,4 +1,5 @@ const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { resolveApplicableBudgetPoolByContext } = require("./budgetPool"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); @@ -22,6 +23,20 @@ const RISK_LEVELS = Object.freeze({ high: "high", }); +// Resolve the reimbursement record once per precheck. The private builders +// share server-read data; rules, people and evidence are still checked normally. +const REIMBURSEMENT_PRECHECK_SELECT = { + id: true, department_id: true, owner_id: true, submitter_id: true, + reimbursement_date: true, total_amount: true, status: true, + items: { + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, expense_type_id: true, amount: true, expense_date: true, + expense_type: { select: { id: true, name: true, category: true } }, + }, + }, +}; + function buildNotFoundError() { return new AppError({ code: "SYS_404", @@ -30,15 +45,6 @@ function buildNotFoundError() { }); } -function buildValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function toMoneyNumber(value) { const numeric = Number(value ?? 0); if (!Number.isFinite(numeric)) { @@ -109,6 +115,11 @@ async function buildReimbursementOperationalChecks(tx, { reimbursementId, config throw buildNotFoundError(); } + return buildReimbursementOperationalChecksFromRecord(tx, reimbursement, { config }); +} + +async function buildReimbursementOperationalChecksFromRecord(tx, reimbursement, { config }) { + const reimbursementId = reimbursement.id; const blockingReasons = []; try { await buildFinanceOwnerApprovalSnapshot({ @@ -459,6 +470,10 @@ async function checkExpenseStandards(tx, { reimbursementId }) { throw buildNotFoundError(); } + return checkExpenseStandardsFromRecord(tx, reimbursement); +} + +async function checkExpenseStandardsFromRecord(tx, reimbursement) { const expenseTypeIds = Array.from( new Set(reimbursement.items.map((item) => item.expense_type_id).filter(Boolean)), ); @@ -670,7 +685,7 @@ async function listRiskFlags(tx, filters = {}) { return rows.map(buildPersistedRiskFlag); } -async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger }) { +async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger, budgetPoolForWrite }) { const reimbursement = await tx.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { @@ -694,6 +709,11 @@ async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger throw buildNotFoundError(); } + return buildReimbursementPrecheckFromRecord(tx, reimbursement, { config, logger, budgetPoolForWrite }); +} + +async function buildReimbursementPrecheckFromRecord(tx, reimbursement, { config, logger, budgetPoolForWrite }) { + const reimbursementId = reimbursement.id; const response = buildBasePrecheck("reimbursement", reimbursementId); const blocking = []; @@ -791,7 +811,7 @@ async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger expenseTypeIds: Array.from(new Set(reimbursement.items.map((item) => item.expense_type_id).filter(Boolean))), }); - const budgetPool = await resolveApplicableBudgetPoolByContext(tx, { + const budgetPool = budgetPoolForWrite !== undefined ? budgetPoolForWrite : await resolveApplicableBudgetPoolByContext(tx, { departmentId: reimbursement.department_id, effectiveDate: reimbursement.reimbursement_date, }); @@ -830,7 +850,7 @@ async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger return applyDecisionByIssues(response, budgetIssues, matchedRule, config); } -async function buildPurchaseRequestPrecheck(tx, { purchaseRequestId, config, logger }) { +async function buildPurchaseRequestPrecheck(tx, { purchaseRequestId, config, logger, budgetPoolForWrite }) { const purchaseRequest = await tx.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { @@ -912,8 +932,8 @@ async function buildPurchaseRequestPrecheck(tx, { purchaseRequestId, config, log expenseTypeIds: [], }); - let resolvedBudgetPool = null; - if (purchaseRequest.legal_entity_id && purchaseRequest.cost_center_id) { + let resolvedBudgetPool = budgetPoolForWrite ?? null; + if (budgetPoolForWrite === undefined && purchaseRequest.legal_entity_id && purchaseRequest.cost_center_id) { resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, { departmentId: purchaseRequest.department_id, legalEntityId: purchaseRequest.legal_entity_id, @@ -933,7 +953,7 @@ async function buildPurchaseRequestPrecheck(tx, { purchaseRequestId, config, log }); } - const budgetPool = selectedBudgetPoolId + const budgetPool = budgetPoolForWrite !== undefined ? budgetPoolForWrite : selectedBudgetPoolId ? await tx.budgetPool.findUnique({ where: { id: selectedBudgetPoolId } }) : null; @@ -970,7 +990,7 @@ async function buildPurchaseRequestPrecheck(tx, { purchaseRequestId, config, log return applyDecisionByIssues(response, budgetIssues, matchedRule, config); } -async function buildTravelRequestPrecheck(tx, { travelRequestId, config, logger }) { +async function buildTravelRequestPrecheck(tx, { travelRequestId, config, logger, budgetPoolForWrite }) { const travelRequest = await tx.travelRequest.findUnique({ where: { id: travelRequestId }, select: { @@ -1044,7 +1064,7 @@ async function buildTravelRequestPrecheck(tx, { travelRequestId, config, logger expenseTypeIds: [], }); - const budgetPool = travelRequest.legal_entity_id && travelRequest.cost_center_id + const budgetPool = budgetPoolForWrite !== undefined ? budgetPoolForWrite : travelRequest.legal_entity_id && travelRequest.cost_center_id ? await resolveApplicableBudgetPoolByContext(tx, { departmentId: travelRequest.department_id, legalEntityId: travelRequest.legal_entity_id, @@ -1125,15 +1145,16 @@ async function loadPrecheckEntityContext(tx, { entityType, entityId }) { return { id: item.id, departmentId: item.department_id ?? null }; } - throw buildValidationError({ entityType: "unsupported" }); + throw buildBusinessValidationError("fee_control.entity_unsupported", { entityType: "unsupported" }); } -async function buildEntityPrecheck(tx, { entityType, entityId, config, logger }) { +async function buildEntityPrecheck(tx, { entityType, entityId, config, logger, budgetPoolForWrite }) { if (entityType === "reimbursement") { return buildReimbursementPrecheck(tx, { reimbursementId: entityId, config, logger, + budgetPoolForWrite, }); } @@ -1142,6 +1163,7 @@ async function buildEntityPrecheck(tx, { entityType, entityId, config, logger }) purchaseRequestId: entityId, config, logger, + budgetPoolForWrite, }); } @@ -1150,10 +1172,11 @@ async function buildEntityPrecheck(tx, { entityType, entityId, config, logger }) travelRequestId: entityId, config, logger, + budgetPoolForWrite, }); } - throw buildValidationError({ entityType: "unsupported" }); + throw buildBusinessValidationError("fee_control.entity_unsupported", { entityType: "unsupported" }); } async function resolveEntityPrecheck({ @@ -1163,8 +1186,17 @@ async function resolveEntityPrecheck({ entityType, entityId, actorDepartmentId = null, + budgetPoolForWrite, }) { - const entity = await loadPrecheckEntityContext(prisma, { entityType, entityId }); + const reimbursement = entityType === "reimbursement" + ? await prisma.reimbursementForm.findUnique({ + where: { id: entityId }, select: REIMBURSEMENT_PRECHECK_SELECT, + }) + : null; + if (entityType === "reimbursement" && !reimbursement) throw buildNotFoundError(); + const entity = reimbursement + ? { id: reimbursement.id, departmentId: reimbursement.department_id ?? null } + : await loadPrecheckEntityContext(prisma, { entityType, entityId }); const gate = await evaluateFeatureFlag({ prisma, config, @@ -1179,8 +1211,8 @@ async function resolveEntityPrecheck({ return disabledPrecheck; } const [standardChecks, operationalChecks] = await Promise.all([ - checkExpenseStandards(prisma, { reimbursementId: entityId }), - buildReimbursementOperationalChecks(prisma, { reimbursementId: entityId, config }), + checkExpenseStandardsFromRecord(prisma, reimbursement), + buildReimbursementOperationalChecksFromRecord(prisma, reimbursement, { config }), ]); return mergeReimbursementPrecheck( mergeReimbursementOperationalChecks(disabledPrecheck, operationalChecks), @@ -1188,20 +1220,17 @@ async function resolveEntityPrecheck({ ); } - const precheck = await buildEntityPrecheck(prisma, { - entityType, - entityId, - config, - logger, - }); + const precheck = reimbursement + ? await buildReimbursementPrecheckFromRecord(prisma, reimbursement, { config, logger, budgetPoolForWrite }) + : await buildEntityPrecheck(prisma, { entityType, entityId, config, logger, budgetPoolForWrite }); if (entityType !== "reimbursement") { return precheck; } const [standardChecks, operationalChecks] = await Promise.all([ - checkExpenseStandards(prisma, { reimbursementId: entityId }), - buildReimbursementOperationalChecks(prisma, { reimbursementId: entityId, config }), + checkExpenseStandardsFromRecord(prisma, reimbursement), + buildReimbursementOperationalChecksFromRecord(prisma, reimbursement, { config }), ]); return mergeReimbursementPrecheck( mergeReimbursementOperationalChecks(precheck, operationalChecks), diff --git a/server/src/services/integrations/dispatcher.js b/server/src/services/integrations/dispatcher.js index abc23ac2..b057bb51 100644 --- a/server/src/services/integrations/dispatcher.js +++ b/server/src/services/integrations/dispatcher.js @@ -1,10 +1,4 @@ -const { - pushPaidReimbursementToErp, - exportAssetToErp, - exportJournalToErp, - exportProcurementToErp, - exportTravelToErp, -} = require('./erp'); + const { sendInstantMessageByChannel } = require('../notifications/channels'); class IntegrationDispatchError extends Error { @@ -28,228 +22,9 @@ function toDispatchError(result) { }); } -function extractResponseValue(response, key) { - if (!response || !key) { - return null; - } - - const directValue = response[key]; - if (typeof directValue === 'string' && directValue.trim()) { - return directValue.trim(); - } - - const bodyValue = response.body?.[key]; - if (typeof bodyValue === 'string' && bodyValue.trim()) { - return bodyValue.trim(); - } - - return null; -} - -function createBindingSpec(entityType, localId, externalId, externalCode) { - const normalizedEntityType = String(entityType ?? '').trim(); - const normalizedLocalId = String(localId ?? '').trim(); - if (!normalizedEntityType || !normalizedLocalId) { - return null; - } - - return { - entityType: normalizedEntityType, - localId: normalizedLocalId, - externalId: typeof externalId === 'string' ? externalId.trim() || null : null, - externalCode: typeof externalCode === 'string' ? externalCode.trim() || null : null, - }; -} - -function resolveBindingSpecs(eventType, payload, response) { - if (eventType === 'erp.reimbursement.paid') { - return [ - createBindingSpec( - 'reimbursement_journal', - payload?.reimbursementId ?? payload?.entityId, - extractResponseValue(response, 'externalId'), - payload?.formNumber ?? payload?.documentNo ?? null, - ), - ].filter(Boolean); - } - - if (eventType === 'erp.journal.export') { - const journalType = String(payload?.journalType ?? '').trim(); - if (journalType === 'reimbursement_paid') { - return [ - createBindingSpec( - 'reimbursement_journal', - payload?.entityId, - extractResponseValue(response, 'externalId'), - payload?.documentNo ?? payload?.formNumber ?? null, - ), - ].filter(Boolean); - } - - if (journalType === 'purchase_request_commitment') { - return [ - createBindingSpec( - 'purchase_request_commitment_journal', - payload?.references?.purchaseRequestId ?? payload?.entityId, - extractResponseValue(response, 'externalId'), - extractResponseValue(response, 'externalCode') ?? payload?.references?.requestNo ?? payload?.documentNo ?? null, - ), - ].filter(Boolean); - } - - if (journalType === 'purchase_order_accrual') { - return [ - createBindingSpec( - 'purchase_order_accrual_journal', - payload?.references?.purchaseOrderArchiveId ?? payload?.entityId, - extractResponseValue(response, 'externalId'), - extractResponseValue(response, 'externalCode') ?? payload?.references?.poNo ?? payload?.documentNo ?? null, - ), - ].filter(Boolean); - } - - if (journalType === 'travel_order_payment') { - return [ - createBindingSpec( - 'travel_order_payment_journal', - payload?.references?.travelOrderId ?? payload?.entityId, - extractResponseValue(response, 'externalId'), - extractResponseValue(response, 'externalCode') ?? payload?.references?.externalOrderNo ?? payload?.documentNo ?? null, - ), - ].filter(Boolean); - } - } - - if (eventType === 'erp.procurement.export') { - const archiveId = payload?.entityId; - const specs = []; - - if (archiveId && (payload?.poNo || extractResponseValue(response, 'externalPoId') || extractResponseValue(response, 'externalPoNo'))) { - specs.push( - createBindingSpec( - 'purchase_order_po', - archiveId, - extractResponseValue(response, 'externalPoId'), - extractResponseValue(response, 'externalPoNo') ?? payload?.poNo ?? null, - ), - ); - } - - if (archiveId && (payload?.contractNo || extractResponseValue(response, 'externalContractId') || extractResponseValue(response, 'externalContractNo'))) { - specs.push( - createBindingSpec( - 'purchase_order_contract', - archiveId, - extractResponseValue(response, 'externalContractId'), - extractResponseValue(response, 'externalContractNo') ?? payload?.contractNo ?? null, - ), - ); - } - - return specs.filter(Boolean); - } - - if (eventType === 'erp.asset.export') { - return [ - createBindingSpec( - 'asset_erp_card', - payload?.entityId, - extractResponseValue(response, 'externalAssetId') ?? extractResponseValue(response, 'externalId'), - extractResponseValue(response, 'externalAssetNo') ?? extractResponseValue(response, 'externalCode') ?? payload?.assetNo ?? null, - ), - ].filter(Boolean); - } - - if (eventType === 'erp.travel.export') { - return [ - createBindingSpec( - 'travel_payable_summary', - payload?.entityId, - extractResponseValue(response, 'externalSummaryId'), - extractResponseValue(response, 'externalSummaryNo') ?? payload?.batchNo ?? null, - ), - createBindingSpec( - 'travel_settlement_batch', - payload?.entityId, - extractResponseValue(response, 'externalBatchId'), - extractResponseValue(response, 'externalBatchNo') ?? payload?.batchNo ?? null, - ), - ].filter(Boolean); - } - - return []; -} - -async function upsertErpBindings(prisma, eventType, payload, response) { - if (!prisma) { - return; - } - - const specs = resolveBindingSpecs(eventType, payload, response); - if (!specs.length) { - return; - } - - for (const spec of specs) { - await prisma.integrationBinding.upsert({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: spec.entityType, - local_id: spec.localId, - }, - }, - create: { - provider: 'erp', - entity_type: spec.entityType, - local_id: spec.localId, - external_id: spec.externalId, - external_code: spec.externalCode, - last_synced_at: new Date(), - }, - update: { - external_id: spec.externalId, - external_code: spec.externalCode, - last_synced_at: new Date(), - }, - }); - } -} - -async function dispatchErpEvent({ prisma, config, eventType, payload }) { - const result = - eventType === 'erp.procurement.export' - ? await exportProcurementToErp(config, payload) - : eventType === 'erp.asset.export' - ? await exportAssetToErp(config, payload) - : eventType === 'erp.travel.export' - ? await exportTravelToErp(config, payload) - : eventType === 'erp.journal.export' - ? await exportJournalToErp(config, payload) - : await pushPaidReimbursementToErp(config, payload); - - if (!result.ok) { - throw toDispatchError(result); - } - - await upsertErpBindings(prisma, eventType, payload, result.response ?? null); - - return { - channel: 'erp', - code: result.code, - response: result.response ?? null, - }; -} - async function dispatchOutboxEvent({ prisma, config, eventType, payload }) { - if ( - eventType === 'erp.reimbursement.paid' || - eventType === 'erp.journal.export' || - eventType === 'erp.procurement.export' || - eventType === 'erp.asset.export' || - eventType === 'erp.travel.export' - ) { - return dispatchErpEvent({ prisma, config, eventType, payload }); + if (String(eventType).startsWith("erp.")) { + throw new IntegrationDispatchError({ code: "erp_retired", message: "ERP integration retired", retryable: false }); } if (eventType === 'notification.im') { diff --git a/server/src/services/integrations/erp.js b/server/src/services/integrations/erp.js deleted file mode 100644 index fdaece51..00000000 --- a/server/src/services/integrations/erp.js +++ /dev/null @@ -1,420 +0,0 @@ -const { buildReimbursementJournalPayload } = require('./erpJournal'); - -function normalizeProvider(config) { - return String(config.erpSyncProvider ?? 'mock').trim().toLowerCase() || 'mock'; -} - -function buildAuthHeaders(config) { - const token = String(config.erpSyncAuthToken ?? '').trim(); - if (!token) { - return {}; - } - - return { - authorization: `Bearer ${token}`, - }; -} - -function coerceTimeout(value, fallback) { - const parsed = Number(value ?? fallback ?? 5000); - if (Number.isFinite(parsed) && parsed >= 100) { - return parsed; - } - return Math.max(100, Number(fallback ?? 5000) || 5000); -} - -function resolveMasterDataEndpoint(config) { - return String(config.erpMasterDataEndpoint ?? '').trim(); -} - -function resolveJournalExportEndpoint(config) { - return ( - String(config.erpJournalExportEndpoint ?? '').trim() || - String(config.erpSyncEndpoint ?? '').trim() - ); -} - -function resolveProcurementExportEndpoint(config) { - return ( - String(config.erpProcurementExportEndpoint ?? '').trim() || - String(config.erpSyncEndpoint ?? '').trim() - ); -} - -function resolveAssetExportEndpoint(config) { - return ( - String(config.erpAssetExportEndpoint ?? '').trim() || - String(config.erpSyncEndpoint ?? '').trim() - ); -} - -function resolveTravelExportEndpoint(config) { - return String(config.erpSyncEndpoint ?? '').trim(); -} - -async function parseResponseBody(response) { - const contentType = response.headers.get('content-type') || ''; - if (contentType.includes('application/json')) { - try { - return await response.json(); - } catch { - return null; - } - } - - try { - return await response.text(); - } catch { - return null; - } -} - -async function postJsonToErp(config, options) { - const endpoint = String(options.endpoint ?? '').trim(); - if (!endpoint) { - return { - ok: false, - code: options.missingEndpointCode, - message: options.missingEndpointMessage, - retryable: false, - }; - } - - const timeoutMs = coerceTimeout(options.timeoutMs, config.erpSyncTimeoutMs); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...buildAuthHeaders(config), - }, - body: JSON.stringify(options.payload ?? {}), - signal: controller.signal, - }); - - const responseBody = await parseResponseBody(response); - if (response.ok) { - return { - ok: true, - code: options.successCode, - message: options.successMessage, - response: { - status: response.status, - body: responseBody, - }, - data: responseBody, - }; - } - - const retryable = response.status >= 500; - return { - ok: false, - code: retryable ? options.http5xxCode : options.http4xxCode, - message: `${options.messagePrefix} HTTP ${response.status}`, - retryable, - httpStatus: response.status, - response: { - status: response.status, - body: responseBody, - }, - }; - } catch (error) { - if (error?.name === 'AbortError') { - return { - ok: false, - code: options.timeoutCode, - message: `${options.messagePrefix} timeout (${timeoutMs}ms)`, - retryable: true, - }; - } - - return { - ok: false, - code: options.networkCode, - message: String(error?.message ?? error ?? `${options.messagePrefix} network error`), - retryable: true, - }; - } finally { - clearTimeout(timer); - } -} - -async function pullMasterDataFromErp(config, options = {}) { - if (!config.erpSyncEnabled) { - return { - ok: false, - code: 'erp_sync_disabled', - message: 'ERP sync disabled', - retryable: false, - }; - } - - const provider = normalizeProvider(config); - const resources = Array.isArray(options.resources) ? options.resources : []; - - if (provider === 'mock') { - return { - ok: true, - code: 'mock_synced', - message: 'mock ERP master data pulled', - response: { - provider, - }, - data: { - ok: true, - legalEntities: [], - costCenters: [], - suppliers: [], - requestedResources: resources, - }, - }; - } - - if (provider === 'http') { - return postJsonToErp(config, { - endpoint: resolveMasterDataEndpoint(config), - timeoutMs: coerceTimeout(config.erpMasterDataTimeoutMs, config.erpSyncTimeoutMs), - payload: { resources }, - successCode: 'http_synced', - successMessage: 'ERP master data pulled', - missingEndpointCode: 'erp_master_data_endpoint_missing', - missingEndpointMessage: 'ERP master data endpoint missing', - http4xxCode: 'erp_master_data_http_4xx', - http5xxCode: 'erp_master_data_http_5xx', - timeoutCode: 'erp_master_data_timeout', - networkCode: 'erp_master_data_network_error', - messagePrefix: 'ERP master data pull', - }); - } - - return { - ok: false, - code: 'erp_master_data_provider_invalid', - message: `Unsupported ERP master data provider: ${provider}`, - retryable: false, - }; -} - -async function exportJournalToErp(config, payload) { - if (!config.erpSyncEnabled) { - return { - ok: false, - code: 'erp_sync_disabled', - message: 'ERP sync disabled', - retryable: false, - }; - } - - const provider = normalizeProvider(config); - if (provider === 'mock') { - const identity = payload?.documentNo ?? payload?.formNumber ?? payload?.entityId ?? payload?.reimbursementId ?? 'UNKNOWN'; - return { - ok: true, - code: 'mock_synced', - message: 'mock ERP synced', - response: { - externalId: `ERP-${identity}`, - externalCode: `ERP-${identity}`, - }, - }; - } - - if (provider === 'http') { - return postJsonToErp(config, { - endpoint: resolveJournalExportEndpoint(config), - timeoutMs: coerceTimeout(config.erpSyncTimeoutMs, config.erpSyncTimeoutMs), - payload, - successCode: 'http_synced', - successMessage: 'ERP synced', - missingEndpointCode: 'erp_sync_endpoint_missing', - missingEndpointMessage: 'ERP sync endpoint missing', - http4xxCode: 'erp_sync_http_4xx', - http5xxCode: 'erp_sync_http_5xx', - timeoutCode: 'erp_sync_timeout', - networkCode: 'erp_sync_network_error', - messagePrefix: 'ERP sync', - }); - } - - return { - ok: false, - code: 'erp_sync_provider_invalid', - message: `Unsupported ERP sync provider: ${provider}`, - retryable: false, - }; -} - -async function exportAssetToErp(config, payload) { - if (!config.erpSyncEnabled) { - return { - ok: false, - code: 'erp_sync_disabled', - message: 'ERP sync disabled', - retryable: false, - }; - } - - const provider = normalizeProvider(config); - if (provider === 'mock') { - const identity = payload?.assetNo ?? payload?.entityId ?? 'UNKNOWN'; - return { - ok: true, - code: 'mock_synced', - message: 'mock ERP asset synced', - response: { - externalAssetId: `ERP-ASSET-${identity}`, - externalAssetNo: `ERP-ASSET-${identity}`, - }, - }; - } - - if (provider === 'http') { - return postJsonToErp(config, { - endpoint: resolveAssetExportEndpoint(config), - timeoutMs: coerceTimeout(config.erpAssetTimeoutMs, config.erpSyncTimeoutMs), - payload, - successCode: 'http_synced', - successMessage: 'ERP asset synced', - missingEndpointCode: 'erp_asset_endpoint_missing', - missingEndpointMessage: 'ERP asset endpoint missing', - http4xxCode: 'erp_asset_http_4xx', - http5xxCode: 'erp_asset_http_5xx', - timeoutCode: 'erp_asset_timeout', - networkCode: 'erp_asset_network_error', - messagePrefix: 'ERP asset sync', - }); - } - - return { - ok: false, - code: 'erp_sync_provider_invalid', - message: `Unsupported ERP sync provider: ${provider}`, - retryable: false, - }; -} - -async function exportProcurementToErp(config, payload) { - if (!config.erpSyncEnabled) { - return { - ok: false, - code: 'erp_sync_disabled', - message: 'ERP sync disabled', - retryable: false, - }; - } - - const provider = normalizeProvider(config); - if (provider === 'mock') { - const identity = payload?.poNo ?? payload?.contractNo ?? payload?.entityId ?? 'UNKNOWN'; - return { - ok: true, - code: 'mock_synced', - message: 'mock ERP procurement synced', - response: { - externalId: `ERP-PROC-${identity}`, - externalCode: `ERP-PROC-${identity}`, - externalPoId: payload?.poNo ? `ERP-PO-${payload.poNo}` : null, - externalPoNo: payload?.poNo ? `ERP-PO-${payload.poNo}` : null, - externalContractId: payload?.contractNo ? `ERP-CT-${payload.contractNo}` : null, - externalContractNo: payload?.contractNo ? `ERP-CT-${payload.contractNo}` : null, - }, - }; - } - - if (provider === 'http') { - return postJsonToErp(config, { - endpoint: resolveProcurementExportEndpoint(config), - timeoutMs: coerceTimeout(config.erpProcurementTimeoutMs, config.erpSyncTimeoutMs), - payload, - successCode: 'http_synced', - successMessage: 'ERP procurement synced', - missingEndpointCode: 'erp_procurement_endpoint_missing', - missingEndpointMessage: 'ERP procurement endpoint missing', - http4xxCode: 'erp_procurement_http_4xx', - http5xxCode: 'erp_procurement_http_5xx', - timeoutCode: 'erp_procurement_timeout', - networkCode: 'erp_procurement_network_error', - messagePrefix: 'ERP procurement sync', - }); - } - - return { - ok: false, - code: 'erp_sync_provider_invalid', - message: `Unsupported ERP sync provider: ${provider}`, - retryable: false, - }; -} - -async function exportTravelToErp(config, payload) { - if (!config.erpSyncEnabled) { - return { - ok: false, - code: 'erp_sync_disabled', - message: 'ERP sync disabled', - retryable: false, - }; - } - - const provider = normalizeProvider(config); - if (provider === 'mock') { - const identity = payload?.batchNo ?? payload?.entityId ?? 'UNKNOWN'; - return { - ok: true, - code: 'mock_synced', - message: 'mock ERP travel synced', - response: { - externalSummaryId: `ERP-TRAVEL-SUM-${identity}`, - externalSummaryNo: `ERP-TRAVEL-SUM-${identity}`, - externalBatchId: `ERP-TRAVEL-BATCH-${identity}`, - externalBatchNo: `ERP-TRAVEL-BATCH-${identity}`, - }, - }; - } - - if (provider === 'http') { - return postJsonToErp(config, { - endpoint: resolveTravelExportEndpoint(config), - timeoutMs: coerceTimeout(config.erpSyncTimeoutMs, config.erpSyncTimeoutMs), - payload, - successCode: 'http_synced', - successMessage: 'ERP travel synced', - missingEndpointCode: 'erp_travel_endpoint_missing', - missingEndpointMessage: 'ERP travel endpoint missing', - http4xxCode: 'erp_travel_http_4xx', - http5xxCode: 'erp_travel_http_5xx', - timeoutCode: 'erp_travel_timeout', - networkCode: 'erp_travel_network_error', - messagePrefix: 'ERP travel sync', - }); - } - - return { - ok: false, - code: 'erp_sync_provider_invalid', - message: `Unsupported ERP sync provider: ${provider}`, - retryable: false, - }; -} - -function buildErpPayload(input) { - return buildReimbursementJournalPayload(input); -} - -async function pushPaidReimbursementToErp(config, payload) { - return exportJournalToErp(config, payload); -} - -module.exports = { - buildErpPayload, - exportAssetToErp, - exportJournalToErp, - exportProcurementToErp, - exportTravelToErp, - normalizeProvider, - pullMasterDataFromErp, - pushPaidReimbursementToErp, -}; diff --git a/server/src/services/integrations/erpAsset.js b/server/src/services/integrations/erpAsset.js deleted file mode 100644 index ca5caf1f..00000000 --- a/server/src/services/integrations/erpAsset.js +++ /dev/null @@ -1,552 +0,0 @@ -const crypto = require('node:crypto'); - -const { enqueueIntegrationEvent } = require('./outbox'); - -const ASSET_ERP_EVENT_TYPE = 'erp.asset.export'; -const ASSET_ERP_BINDING_ENTITY_TYPE = 'asset_erp_card'; -const ASSET_ERP_TARGET = 'asset_ledger_summary'; -const ASSET_TRACK_STATUSES = Object.freeze({ - notTriggered: 'not_triggered', - skipped: 'skipped', -}); -const ASSET_TRACK_ACTIONS = Object.freeze({ - none: 'none', - backfill: 'backfill', - retry: 'retry', - resync: 'resync', -}); -const ASSET_EXPORTABLE_STATUS_CHANGES = new Set(['retired', 'archived']); - -const ASSET_ERP_CONTEXT_SELECT = { - id: true, - asset_no: true, - name: true, - category_id: true, - legal_entity_id: true, - cost_center_id: true, - purchase_request_id: true, - serial_no: true, - location: true, - status: true, - assignee_employee_id: true, - activated_at: true, - retired_at: true, - created_at: true, - updated_at: true, - category: { - select: { - id: true, - code: true, - name: true, - document_category: { - select: { - code: true, - }, - }, - }, - }, - legal_entity: { - select: { - id: true, - code: true, - name: true, - }, - }, - cost_center: { - select: { - id: true, - code: true, - name: true, - }, - }, - purchase_request: { - select: { - id: true, - request_no: true, - title: true, - status: true, - }, - }, - assignee: { - select: { - id: true, - employee_no: true, - name: true, - }, - }, -}; - -function normalizeString(value) { - const text = String(value ?? '').trim(); - return text || null; -} - -function toIsoString(value) { - if (!value) { - return null; - } - - try { - return new Date(value).toISOString(); - } catch { - return null; - } -} - -function toDateOnly(value) { - const iso = toIsoString(value); - return iso ? iso.slice(0, 10) : null; -} - -function stableNormalizeJson(value) { - if (Array.isArray(value)) { - return value.map((item) => stableNormalizeJson(item)); - } - - if (value && typeof value === 'object' && value.constructor === Object) { - return Object.keys(value) - .sort() - .reduce((result, key) => { - result[key] = stableNormalizeJson(value[key]); - return result; - }, {}); - } - - return value; -} - -function stableStringifyJson(value) { - return JSON.stringify(stableNormalizeJson(value)); -} - -function hashAssetPayload(payload) { - return crypto.createHash('sha256').update(stableStringifyJson(payload)).digest('hex'); -} - -function buildAssetLedgerSummaryDedupeKey(assetId) { - return `asset_card:${assetId}:ledger_summary`; -} - -function resolveAssetChangeType(status, changedFields) { - const normalizedStatus = normalizeString(status); - if (normalizedStatus === 'retired') { - return 'retired'; - } - if (normalizedStatus === 'archived') { - return 'archived'; - } - return changedFields?.length ? 'financial_dimensions_changed' : 'created'; -} - -function buildAssetLedgerSummaryPayload(input) { - const asset = input?.asset ?? {}; - const changedFields = Array.isArray(input?.changedFields) - ? Array.from(new Set(input.changedFields.filter(Boolean))) - : []; - const sourceEventName = normalizeString(input?.sourceEventName) ?? 'asset.card.created'; - const changeType = normalizeString(input?.changeType) ?? resolveAssetChangeType(asset.status, changedFields); - - return { - exportType: 'asset_ledger_summary', - sourceEventName, - changeType, - entityType: 'asset_card', - entityId: asset.id ?? null, - assetNo: asset.asset_no ?? null, - name: asset.name ?? null, - status: asset.status ?? null, - location: normalizeString(asset.location), - activatedAt: toDateOnly(asset.activated_at), - retiredAt: toDateOnly(asset.retired_at), - occurredAt: - toIsoString(input?.occurredAt) ?? - toIsoString(asset.updated_at) ?? - toIsoString(asset.created_at), - changedFields, - category: asset.category - ? { - id: asset.category.id, - code: asset.category.code ?? null, - name: asset.category.name ?? null, - documentCategoryCode: asset.category.document_category?.code ?? null, - } - : { - id: asset.category_id ?? null, - code: null, - name: null, - documentCategoryCode: null, - }, - legalEntity: asset.legal_entity - ? { - id: asset.legal_entity.id, - code: asset.legal_entity.code ?? null, - name: asset.legal_entity.name ?? null, - } - : { - id: asset.legal_entity_id ?? null, - code: null, - name: null, - }, - costCenter: asset.cost_center - ? { - id: asset.cost_center.id, - code: asset.cost_center.code ?? null, - name: asset.cost_center.name ?? null, - } - : { - id: asset.cost_center_id ?? null, - code: null, - name: null, - }, - purchaseRequest: asset.purchase_request - ? { - id: asset.purchase_request.id, - requestNo: asset.purchase_request.request_no ?? null, - title: asset.purchase_request.title ?? null, - } - : { - id: asset.purchase_request_id ?? null, - requestNo: null, - title: null, - }, - assignee: asset.assignee - ? { - id: asset.assignee.id, - employeeNo: asset.assignee.employee_no ?? null, - name: asset.assignee.name ?? null, - } - : { - id: asset.assignee_employee_id ?? null, - employeeNo: null, - name: null, - }, - }; -} - -function resolveAssetLedgerSummaryChange(previousAsset, nextAsset) { - if (!previousAsset || !nextAsset) { - return null; - } - - const changedFields = []; - if (previousAsset.legal_entity_id !== nextAsset.legal_entity_id) { - changedFields.push('legalEntityId'); - } - if (previousAsset.cost_center_id !== nextAsset.cost_center_id) { - changedFields.push('costCenterId'); - } - - const nextStatus = normalizeString(nextAsset.status); - if (previousAsset.status !== nextAsset.status && ASSET_EXPORTABLE_STATUS_CHANGES.has(nextStatus)) { - changedFields.push('status'); - } - - if (!changedFields.length) { - return null; - } - - return { - sourceEventName: 'asset.ledger.summary.updated', - changeType: resolveAssetChangeType(nextAsset.status, changedFields), - changedFields, - }; -} - -function resolveAssetLedgerSummaryDrift(asset, previousPayload) { - if (!asset || !previousPayload) { - return null; - } - - const changedFields = []; - const previousLegalEntityId = previousPayload.legalEntity?.id ?? null; - const previousCostCenterId = previousPayload.costCenter?.id ?? null; - const previousStatus = previousPayload.status ?? null; - - if ((asset.legal_entity_id ?? null) !== previousLegalEntityId) { - changedFields.push('legalEntityId'); - } - if ((asset.cost_center_id ?? null) !== previousCostCenterId) { - changedFields.push('costCenterId'); - } - - const assetStatus = normalizeString(asset.status); - if (ASSET_EXPORTABLE_STATUS_CHANGES.has(assetStatus) && assetStatus !== previousStatus) { - changedFields.push('status'); - } - - if (!changedFields.length) { - return null; - } - - return { - sourceEventName: 'asset.ledger.summary.updated', - changeType: resolveAssetChangeType(asset.status, changedFields), - changedFields, - }; -} - -function mapIntegrationBinding(record) { - if (!record) { - return null; - } - - return { - id: record.id, - provider: record.provider, - entityType: record.entity_type, - localId: record.local_id, - externalId: record.external_id ?? null, - externalCode: record.external_code ?? null, - lastSyncedAt: record.last_synced_at?.toISOString?.() ?? null, - }; -} - -function redactBinding(binding, canManage) { - if (!binding) { - return null; - } - - if (canManage) { - return binding; - } - - return { - ...binding, - externalId: null, - externalCode: null, - }; -} - -function mapIntegrationOutboxState(record) { - if (!record) { - return null; - } - - return { - id: record.id, - eventType: record.event_type, - status: record.status, - attempts: Number(record.attempts ?? 0), - lastError: record.last_error ?? null, - processedAt: record.processed_at?.toISOString?.() ?? null, - updatedAt: record.updated_at?.toISOString?.() ?? null, - }; -} - -async function findOutboxByKey(prisma, eventType, dedupeKey) { - if (!prisma || !eventType || !dedupeKey) { - return null; - } - - return prisma.integrationOutbox.findUnique({ - where: { - event_type_dedupe_key: { - event_type: eventType, - dedupe_key: dedupeKey, - }, - }, - }); -} - -async function findBindingByLocalId(prisma, entityType, localId) { - if (!prisma || !entityType || !localId) { - return null; - } - - return prisma.integrationBinding.findUnique({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: entityType, - local_id: localId, - }, - }, - }); -} - -async function enqueueAssetLedgerSummaryExport(prisma, config, input) { - const payload = - input?.payload ?? - buildAssetLedgerSummaryPayload({ - asset: input?.asset, - sourceEventName: input?.sourceEventName, - changeType: input?.changeType, - occurredAt: input?.occurredAt, - changedFields: input?.changedFields, - }); - - const enqueued = await enqueueIntegrationEvent(prisma, { - eventType: ASSET_ERP_EVENT_TYPE, - payload, - dedupeKey: buildAssetLedgerSummaryDedupeKey(payload.entityId), - maxAttempts: config.integrationOutboxMaxAttempts, - actorId: input?.actorId ?? null, - requeueOnFailure: input?.requeueOnFailure === true, - resetOnPayloadChange: true, - }); - - return { - ...enqueued, - eventType: ASSET_ERP_EVENT_TYPE, - erpTarget: ASSET_ERP_TARGET, - payload, - }; -} - -async function loadAssetErpContext(prisma, assetId) { - const asset = await prisma.assetCard.findUnique({ - where: { id: assetId }, - select: ASSET_ERP_CONTEXT_SELECT, - }); - - if (!asset) { - return null; - } - - return { asset }; -} - -function buildAssetTrackSummary(context) { - const asset = context?.asset ?? {}; - return { - id: asset.id, - assetNo: asset.asset_no ?? null, - name: asset.name ?? null, - status: asset.status ?? null, - legalEntity: asset.legal_entity - ? { - id: asset.legal_entity.id, - code: asset.legal_entity.code ?? null, - name: asset.legal_entity.name ?? null, - } - : null, - costCenter: asset.cost_center - ? { - id: asset.cost_center.id, - code: asset.cost_center.code ?? null, - name: asset.cost_center.name ?? null, - } - : null, - purchaseRequest: asset.purchase_request - ? { - id: asset.purchase_request.id, - requestNo: asset.purchase_request.request_no ?? null, - title: asset.purchase_request.title ?? null, - } - : null, - }; -} - -function buildAssetTrackPreview(asset, options = {}) { - return { - changeType: options.changeType ?? 'created', - sourceEventName: options.sourceEventName ?? 'asset.card.created', - categoryCode: asset?.category?.code ?? null, - legalEntityCode: asset?.legal_entity?.code ?? null, - costCenterCode: asset?.cost_center?.code ?? null, - status: asset?.status ?? null, - }; -} - -function resolveTrackStatus({ outbox, binding, gateEnabled }) { - if (outbox?.status) { - return outbox.status; - } - if (binding) { - return 'succeeded'; - } - if (!gateEnabled) { - return ASSET_TRACK_STATUSES.skipped; - } - return ASSET_TRACK_STATUSES.notTriggered; -} - -function resolveTrackAction({ canManage, gateEnabled, outbox, binding, drift }) { - if (!canManage || !gateEnabled) { - return ASSET_TRACK_ACTIONS.none; - } - - if (outbox && ['failed', 'dead_letter'].includes(outbox.status)) { - return ASSET_TRACK_ACTIONS.retry; - } - - if (!outbox && !binding) { - return ASSET_TRACK_ACTIONS.backfill; - } - - if (drift) { - return ASSET_TRACK_ACTIONS.resync; - } - - return ASSET_TRACK_ACTIONS.none; -} - -async function buildAssetErpTrack(prisma, config, assetId, options = {}) { - const context = options.context ?? (await loadAssetErpContext(prisma, assetId)); - if (!context?.asset) { - return null; - } - - const asset = context.asset; - const canManage = Boolean(options.canManage); - const gateEnabled = Boolean(options.gate?.effectiveEnabled ?? options.gate?.enabled ?? true); - const dedupeKey = buildAssetLedgerSummaryDedupeKey(asset.id); - - const [outboxRow, bindingRow] = await Promise.all([ - findOutboxByKey(prisma, ASSET_ERP_EVENT_TYPE, dedupeKey), - findBindingByLocalId(prisma, ASSET_ERP_BINDING_ENTITY_TYPE, asset.id), - ]); - - const outbox = mapIntegrationOutboxState(outboxRow); - const binding = redactBinding(mapIntegrationBinding(bindingRow), canManage); - const drift = resolveAssetLedgerSummaryDrift(asset, outboxRow?.payload ?? null); - const preview = buildAssetTrackPreview(asset, { - changeType: drift?.changeType ?? outboxRow?.payload?.changeType ?? 'created', - sourceEventName: - drift?.sourceEventName ?? - outboxRow?.payload?.sourceEventName ?? - 'asset.card.created', - }); - - return { - asset: buildAssetTrackSummary(context), - stage: { - eligible: true, - reason: !gateEnabled && !outboxRow && !bindingRow ? (options.gate?.effectiveReason ?? options.gate?.reason ?? 'erp_sync_disabled') : null, - deliveryStatus: resolveTrackStatus({ outbox, binding: bindingRow, gateEnabled }), - actionHint: resolveTrackAction({ - canManage, - gateEnabled, - outbox, - binding: bindingRow, - drift, - }), - preview, - outbox, - binding, - }, - permissions: { - canRetry: Boolean(canManage && gateEnabled), - canSeeExternalRefs: canManage, - }, - }; -} - -module.exports = { - ASSET_ERP_BINDING_ENTITY_TYPE, - ASSET_ERP_CONTEXT_SELECT, - ASSET_ERP_EVENT_TYPE, - ASSET_ERP_TARGET, - ASSET_TRACK_ACTIONS, - ASSET_TRACK_STATUSES, - buildAssetErpTrack, - buildAssetLedgerSummaryDedupeKey, - buildAssetLedgerSummaryPayload, - buildAssetTrackPreview, - enqueueAssetLedgerSummaryExport, - hashAssetPayload, - loadAssetErpContext, - resolveAssetLedgerSummaryChange, - resolveAssetLedgerSummaryDrift, - stableStringifyJson, -}; diff --git a/server/src/services/integrations/erpJournal.js b/server/src/services/integrations/erpJournal.js deleted file mode 100644 index 0056b131..00000000 --- a/server/src/services/integrations/erpJournal.js +++ /dev/null @@ -1,348 +0,0 @@ -const { enqueueIntegrationEvent } = require('./outbox'); - -const ERP_JOURNAL_EVENT_TYPES = Object.freeze(['erp.journal.export', 'erp.reimbursement.paid']); -const REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX = 'reimbursement'; -const TRAVEL_ORDER_JOURNAL_DEDUPE_PREFIX = 'travel_order'; - -function toDateOnly(value) { - if (!value) { - return null; - } - try { - return new Date(value).toISOString().slice(0, 10); - } catch { - return null; - } -} - -function toIsoString(value) { - if (!value) { - return null; - } - try { - return new Date(value).toISOString(); - } catch { - return null; - } -} - -function resolveReimbursementLegalEntityCode(reimbursement) { - return reimbursement?.owner?.legal_entity?.code ?? reimbursement?.submitter?.legal_entity?.code ?? null; -} - -function resolveTravelRequestLegalEntityCode(travelOrder) { - return travelOrder?.travel_request?.legal_entity?.code ?? null; -} - -function resolveTravelRequestCostCenterCode(travelOrder) { - return travelOrder?.travel_request?.cost_center?.code ?? null; -} - -function resolveTravelRequestBudgetPoolCode(travelOrder) { - return travelOrder?.travel_request?.budget_pool?.code ?? null; -} - -function buildReimbursementJournalPayload(input) { - const reimbursement = input?.reimbursement ?? {}; - const occurredAt = - toIsoString(reimbursement.paid_at) ?? - (reimbursement.payment_date ? `${toDateOnly(reimbursement.payment_date)}T00:00:00.000Z` : null); - - return { - journalType: 'reimbursement_paid', - sourceEventName: 'reimbursement.paid', - entityType: 'reimbursement_form', - entityId: reimbursement.id, - documentNo: reimbursement.form_number ?? null, - currencyCode: 'CNY', - totalAmount: reimbursement.total_amount?.toString?.() ?? null, - occurredAt, - paymentVoucherNo: reimbursement.payment_voucher_no ?? null, - paymentDate: toDateOnly(reimbursement.payment_date), - dimensions: { - departmentId: reimbursement.department_id ?? null, - ownerEmployeeId: reimbursement.owner_id ?? null, - submitterEmployeeId: reimbursement.submitter_id ?? null, - legalEntityCode: resolveReimbursementLegalEntityCode(reimbursement), - costCenterCode: null, - }, - reimbursementId: reimbursement.id, - formNumber: reimbursement.form_number ?? null, - }; -} - -function buildTravelOrderPaymentJournalPayload(input) { - const travelOrder = input?.travelOrder ?? {}; - const travelRequest = travelOrder?.travel_request ?? {}; - const occurredAt = - toIsoString(travelOrder.paid_at) ?? - (travelOrder.payment_date ? `${toDateOnly(travelOrder.payment_date)}T00:00:00.000Z` : null) ?? - toIsoString(travelOrder.updated_at) ?? - toIsoString(travelOrder.created_at); - - return { - journalType: 'travel_order_payment', - sourceEventName: 'travel_order.paid', - entityType: 'travel_order', - entityId: travelOrder.id ?? null, - documentNo: travelOrder.external_order_no ?? null, - currencyCode: travelOrder.currency ?? 'CNY', - totalAmount: travelOrder.amount?.toString?.() ?? null, - occurredAt, - paymentVoucherNo: travelOrder.payment_voucher_no ?? null, - paymentDate: toDateOnly(travelOrder.payment_date), - paymentChannel: travelOrder.payment_channel ?? null, - dimensions: { - departmentId: travelOrder.department_id ?? travelRequest.department_id ?? null, - employeeId: travelOrder.employee_id ?? null, - applicantEmployeeId: travelRequest.applicant_employee_id ?? null, - submitterEmployeeId: travelRequest.submitter_id ?? null, - legalEntityCode: resolveTravelRequestLegalEntityCode(travelOrder), - costCenterCode: resolveTravelRequestCostCenterCode(travelOrder), - budgetPoolCode: resolveTravelRequestBudgetPoolCode(travelOrder), - }, - references: { - travelOrderId: travelOrder.id ?? null, - travelRequestId: travelOrder.travel_request_id ?? null, - requestNo: travelRequest.request_no ?? null, - externalOrderNo: travelOrder.external_order_no ?? null, - source: travelOrder.source ?? null, - linkedReimbursementId: travelOrder.linked_reimbursement_id ?? null, - }, - }; -} - -function buildReimbursementJournalDedupeKey(reimbursementId) { - return `${REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX}:${reimbursementId}:paid`; -} - -function buildTravelOrderPaymentJournalDedupeKey(travelOrderId) { - return `${TRAVEL_ORDER_JOURNAL_DEDUPE_PREFIX}:${travelOrderId}:paid`; -} - -async function enqueueReimbursementJournalExport(prisma, config, input) { - const payload = input?.payload ?? buildReimbursementJournalPayload({ reimbursement: input?.reimbursement }); - - const enqueued = await enqueueIntegrationEvent(prisma, { - eventType: 'erp.journal.export', - payload, - dedupeKey: buildReimbursementJournalDedupeKey(payload.entityId), - maxAttempts: config.integrationOutboxMaxAttempts, - actorId: input?.actorId ?? null, - requeueOnFailure: true, - }); - - return { - ...enqueued, - eventType: 'erp.journal.export', - payload, - }; -} - -async function enqueueTravelOrderPaymentJournalExport(prisma, config, input) { - const payload = input?.payload ?? buildTravelOrderPaymentJournalPayload({ travelOrder: input?.travelOrder }); - - const enqueued = await enqueueIntegrationEvent(prisma, { - eventType: 'erp.journal.export', - payload, - dedupeKey: buildTravelOrderPaymentJournalDedupeKey(payload.entityId), - maxAttempts: config.integrationOutboxMaxAttempts, - actorId: input?.actorId ?? null, - requeueOnFailure: true, - }); - - return { - ...enqueued, - eventType: 'erp.journal.export', - payload, - }; -} - -function mapIntegrationBinding(record) { - if (!record) { - return null; - } - - return { - id: record.id, - provider: record.provider, - entityType: record.entity_type, - localId: record.local_id, - externalId: record.external_id ?? null, - externalCode: record.external_code ?? null, - lastSyncedAt: record.last_synced_at?.toISOString?.() ?? null, - }; -} - -function mapIntegrationOutboxState(record) { - if (!record) { - return null; - } - - return { - id: record.id, - eventType: record.event_type, - status: record.status, - lastError: record.last_error ?? null, - processedAt: record.processed_at?.toISOString?.() ?? null, - updatedAt: record.updated_at?.toISOString?.() ?? null, - }; -} - -async function findLatestReimbursementJournalOutbox(prisma, reimbursementId) { - const current = await prisma.integrationOutbox.findFirst({ - where: { - event_type: 'erp.journal.export', - dedupe_key: buildReimbursementJournalDedupeKey(reimbursementId), - }, - orderBy: { updated_at: 'desc' }, - }); - - if (current) { - return current; - } - - return prisma.integrationOutbox.findFirst({ - where: { - event_type: 'erp.reimbursement.paid', - dedupe_key: reimbursementId, - }, - orderBy: { updated_at: 'desc' }, - }); -} - -async function findLatestTravelOrderPaymentJournalOutbox(prisma, travelOrderId) { - return prisma.integrationOutbox.findFirst({ - where: { - event_type: 'erp.journal.export', - dedupe_key: buildTravelOrderPaymentJournalDedupeKey(travelOrderId), - }, - orderBy: { updated_at: 'desc' }, - }); -} - -async function buildReimbursementJournalPreview(prisma, reimbursement) { - const [binding, outbox] = await Promise.all([ - prisma.integrationBinding.findUnique({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: 'reimbursement_journal', - local_id: reimbursement.id, - }, - }, - }), - findLatestReimbursementJournalOutbox(prisma, reimbursement.id), - ]); - - const journal = buildReimbursementJournalPayload({ reimbursement }); - const exportState = { - externalId: binding?.external_id ?? null, - lastOutboxId: outbox?.id ?? null, - lastOutboxStatus: outbox?.status ?? null, - lastError: outbox?.last_error ?? null, - exportedAt: binding?.last_synced_at?.toISOString?.() ?? outbox?.processed_at?.toISOString?.() ?? null, - }; - - return { - journal, - exportState, - binding: mapIntegrationBinding(binding), - outbox: mapIntegrationOutboxState(outbox), - }; -} - -async function buildTravelOrderPaymentJournalPreview(prisma, travelOrderInput) { - const travelOrder = travelOrderInput?.travel_request - ? travelOrderInput - : await prisma.travelOrder.findUnique({ - where: { id: travelOrderInput.id }, - select: { - id: true, - source: true, - external_order_no: true, - employee_id: true, - department_id: true, - linked_reimbursement_id: true, - travel_request_id: true, - amount: true, - currency: true, - payment_voucher_no: true, - payment_date: true, - payment_channel: true, - paid_at: true, - created_at: true, - updated_at: true, - travel_request: { - select: { - id: true, - request_no: true, - department_id: true, - applicant_employee_id: true, - submitter_id: true, - legal_entity: { select: { code: true } }, - cost_center: { select: { code: true } }, - budget_pool: { select: { code: true } }, - }, - }, - }, - }); - - if (!travelOrder) { - return { - journal: null, - exportState: { - externalId: null, - lastOutboxId: null, - lastOutboxStatus: null, - lastError: null, - exportedAt: null, - }, - binding: null, - outbox: null, - }; - } - - const [binding, outbox] = await Promise.all([ - prisma.integrationBinding.findUnique({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: 'travel_order_payment_journal', - local_id: travelOrder.id, - }, - }, - }), - findLatestTravelOrderPaymentJournalOutbox(prisma, travelOrder.id), - ]); - - const journal = buildTravelOrderPaymentJournalPayload({ travelOrder }); - const exportState = { - externalId: binding?.external_id ?? null, - lastOutboxId: outbox?.id ?? null, - lastOutboxStatus: outbox?.status ?? null, - lastError: outbox?.last_error ?? null, - exportedAt: binding?.last_synced_at?.toISOString?.() ?? outbox?.processed_at?.toISOString?.() ?? null, - }; - - return { - journal, - exportState, - binding: mapIntegrationBinding(binding), - outbox: mapIntegrationOutboxState(outbox), - }; -} - -module.exports = { - ERP_JOURNAL_EVENT_TYPES, - buildReimbursementJournalDedupeKey, - buildReimbursementJournalPayload, - buildReimbursementJournalPreview, - buildTravelOrderPaymentJournalDedupeKey, - buildTravelOrderPaymentJournalPayload, - buildTravelOrderPaymentJournalPreview, - enqueueReimbursementJournalExport, - enqueueTravelOrderPaymentJournalExport, - findLatestReimbursementJournalOutbox, - findLatestTravelOrderPaymentJournalOutbox, -}; diff --git a/server/src/services/integrations/erpMasterData.js b/server/src/services/integrations/erpMasterData.js deleted file mode 100644 index 62a9d848..00000000 --- a/server/src/services/integrations/erpMasterData.js +++ /dev/null @@ -1,534 +0,0 @@ -const { pullMasterDataFromErp } = require('./erp'); - -const ERP_MASTER_DATA_PROVIDER = 'erp'; -const ERP_MASTER_DATA_RESOURCES = Object.freeze(['legal_entity', 'cost_center', 'supplier']); - -function normalizeRequestedResources(resources) { - if (!Array.isArray(resources) || !resources.length) { - return [...ERP_MASTER_DATA_RESOURCES]; - } - - const seen = new Set(); - const result = []; - for (const resource of ERP_MASTER_DATA_RESOURCES) { - if (resources.includes(resource) && !seen.has(resource)) { - seen.add(resource); - result.push(resource); - } - } - return result; -} - -function makeSummary(requested) { - return { - requested, - created: 0, - updated: 0, - archived: 0, - skipped: 0, - sampleSkipped: [], - }; -} - -function addSkip(summary, key, reason) { - summary.skipped += 1; - if (summary.sampleSkipped.length < 20) { - summary.sampleSkipped.push({ key: key ?? 'unknown', reason }); - } -} - -function mapSyncState(record) { - return { - resourceType: record.resource_type, - status: record.status, - lastStartedAt: record.last_started_at?.toISOString?.() ?? null, - lastFinishedAt: record.last_finished_at?.toISOString?.() ?? null, - lastSuccessAt: record.last_success_at?.toISOString?.() ?? null, - lastError: record.last_error ?? null, - lastResult: record.last_result ?? null, - }; -} - -async function upsertSyncState(tx, resourceType, data) { - return tx.integrationSyncState.upsert({ - where: { - provider_resource_type: { - provider: ERP_MASTER_DATA_PROVIDER, - resource_type: resourceType, - }, - }, - create: { - provider: ERP_MASTER_DATA_PROVIDER, - resource_type: resourceType, - ...data, - }, - update: data, - }); -} - -async function markSyncStateRunning(tx, resourceType, actorId) { - return upsertSyncState(tx, resourceType, { - status: 'running', - last_started_at: new Date(), - last_finished_at: null, - last_error: null, - updated_by: actorId ?? null, - created_by: actorId ?? null, - }); -} - -async function markSyncStateSucceeded(tx, resourceType, actorId, result) { - const now = new Date(); - return upsertSyncState(tx, resourceType, { - status: 'succeeded', - last_result: result, - last_error: null, - last_finished_at: now, - last_success_at: now, - updated_by: actorId ?? null, - created_by: actorId ?? null, - }); -} - -async function markSyncStateFailed(tx, resourceType, actorId, error) { - return upsertSyncState(tx, resourceType, { - status: 'failed', - last_error: String(error?.message ?? error ?? 'erp_master_data_sync_failed').slice(0, 500), - last_finished_at: new Date(), - updated_by: actorId ?? null, - created_by: actorId ?? null, - }); -} - -async function listMasterDataSyncStates(prisma) { - const rows = await prisma.integrationSyncState.findMany({ - where: { - provider: ERP_MASTER_DATA_PROVIDER, - resource_type: { in: ERP_MASTER_DATA_RESOURCES }, - }, - orderBy: [{ resource_type: 'asc' }], - }); - - const map = new Map(rows.map((row) => [row.resource_type, row])); - return ERP_MASTER_DATA_RESOURCES.map((resourceType) => { - const row = map.get(resourceType); - if (!row) { - return { - resourceType, - status: 'idle', - lastStartedAt: null, - lastFinishedAt: null, - lastSuccessAt: null, - lastError: null, - lastResult: null, - }; - } - return mapSyncState(row); - }); -} - -async function ensureBindingNotConflicted(tx, entityType, localId, externalId) { - if (!externalId) { - return { ok: true }; - } - - const binding = await tx.integrationBinding.findFirst({ - where: { - provider: ERP_MASTER_DATA_PROVIDER, - entity_type: entityType, - external_id: externalId, - }, - select: { local_id: true }, - }); - - if (binding && binding.local_id !== localId) { - return { ok: false, reason: 'binding_conflict' }; - } - - return { ok: true }; -} - -async function upsertBinding(tx, entityType, localId, externalId, externalCode, actorId) { - return tx.integrationBinding.upsert({ - where: { - provider_entity_type_local_id: { - provider: ERP_MASTER_DATA_PROVIDER, - entity_type: entityType, - local_id: localId, - }, - }, - create: { - provider: ERP_MASTER_DATA_PROVIDER, - entity_type: entityType, - local_id: localId, - external_id: externalId ?? null, - external_code: externalCode ?? null, - last_synced_at: new Date(), - created_by: actorId ?? null, - updated_by: actorId ?? null, - }, - update: { - external_id: externalId ?? null, - external_code: externalCode ?? null, - last_synced_at: new Date(), - updated_by: actorId ?? null, - }, - }); -} - -function normalizePayloadRows(rows, resolveKey) { - const summary = makeSummary(Array.isArray(rows) ? rows.length : 0); - const uniqueRows = []; - const seen = new Set(); - for (const row of Array.isArray(rows) ? rows : []) { - const key = resolveKey(row); - if (!key) { - addSkip(summary, null, 'external_code_missing'); - continue; - } - if (seen.has(key)) { - addSkip(summary, key, 'duplicate_payload_key'); - continue; - } - seen.add(key); - uniqueRows.push(row); - } - return { summary, rows: uniqueRows }; -} - -function resolveArchivedState(enabled, archivedAt) { - if (enabled === false) { - return archivedAt ?? new Date(); - } - return null; -} - -async function syncLegalEntities(tx, rows, actorId) { - const prepared = normalizePayloadRows(rows, (row) => String(row?.code ?? '').trim() || null); - const summary = prepared.summary; - const codes = prepared.rows.map((row) => String(row.code).trim()); - const existingRows = await tx.legalEntity.findMany({ - where: { code: { in: codes } }, - select: { id: true, code: true, enabled: true, archived_at: true }, - }); - const existingByCode = new Map(existingRows.map((row) => [row.code, row])); - - for (const row of prepared.rows) { - const code = String(row.code).trim(); - const existing = existingByCode.get(code); - const enabled = row.enabled !== false; - const archivedAt = resolveArchivedState(enabled, existing?.archived_at ?? null); - - if (existing) { - const bindingCheck = await ensureBindingNotConflicted(tx, 'legal_entity', existing.id, row.externalId ?? null); - if (!bindingCheck.ok) { - addSkip(summary, code, bindingCheck.reason); - continue; - } - - await tx.legalEntity.update({ - where: { id: existing.id }, - data: { - name: row.name, - short_name: row.shortName ?? null, - currency_code: row.currencyCode ?? 'CNY', - tax_no: row.taxNo ?? null, - enabled, - archived_at: archivedAt, - updated_by: actorId ?? null, - }, - }); - summary.updated += 1; - if (!enabled && (existing.enabled || !existing.archived_at)) { - summary.archived += 1; - } - await upsertBinding(tx, 'legal_entity', existing.id, row.externalId ?? null, code, actorId); - continue; - } - - const created = await tx.legalEntity.create({ - data: { - code, - name: row.name, - short_name: row.shortName ?? null, - currency_code: row.currencyCode ?? 'CNY', - tax_no: row.taxNo ?? null, - enabled, - archived_at: enabled ? null : new Date(), - created_by: actorId ?? null, - updated_by: actorId ?? null, - }, - select: { id: true }, - }); - summary.created += 1; - await upsertBinding(tx, 'legal_entity', created.id, row.externalId ?? null, code, actorId); - } - - return summary; -} - -async function syncCostCenters(tx, rows, actorId) { - const prepared = normalizePayloadRows( - rows, - (row) => { - const legalEntityCode = String(row?.legalEntityCode ?? '').trim(); - const code = String(row?.code ?? '').trim(); - return legalEntityCode && code ? `${legalEntityCode}::${code}` : null; - }, - ); - const summary = prepared.summary; - const legalEntityCodes = Array.from(new Set(prepared.rows.map((row) => String(row.legalEntityCode).trim()))); - const legalEntities = await tx.legalEntity.findMany({ - where: { code: { in: legalEntityCodes } }, - select: { id: true, code: true }, - }); - const legalEntityByCode = new Map(legalEntities.map((row) => [row.code, row])); - - const existingRows = await tx.costCenter.findMany({ - where: { - OR: prepared.rows.map((row) => ({ - code: String(row.code).trim(), - legal_entity: { code: String(row.legalEntityCode).trim() }, - })), - }, - select: { id: true, code: true, legal_entity_id: true, enabled: true, archived_at: true, legal_entity: { select: { code: true } } }, - }).catch(() => []); - const existingByKey = new Map(existingRows.map((row) => [`${row.legal_entity?.code ?? ''}::${row.code}`, row])); - - for (const row of prepared.rows) { - const legalEntityCode = String(row.legalEntityCode).trim(); - const code = String(row.code).trim(); - const key = `${legalEntityCode}::${code}`; - const legalEntity = legalEntityByCode.get(legalEntityCode); - if (!legalEntity) { - addSkip(summary, key, 'legal_entity_missing'); - continue; - } - - const existing = existingByKey.get(key); - const enabled = row.enabled !== false; - const archivedAt = resolveArchivedState(enabled, existing?.archived_at ?? null); - - if (existing) { - const bindingCheck = await ensureBindingNotConflicted(tx, 'cost_center', existing.id, row.externalId ?? null); - if (!bindingCheck.ok) { - addSkip(summary, key, bindingCheck.reason); - continue; - } - - await tx.costCenter.update({ - where: { id: existing.id }, - data: { - name: row.name, - enabled, - archived_at: archivedAt, - updated_by: actorId ?? null, - }, - }); - summary.updated += 1; - if (!enabled && (existing.enabled || !existing.archived_at)) { - summary.archived += 1; - } - await upsertBinding(tx, 'cost_center', existing.id, row.externalId ?? null, code, actorId); - continue; - } - - const created = await tx.costCenter.create({ - data: { - legal_entity_id: legalEntity.id, - code, - name: row.name, - enabled, - archived_at: enabled ? null : new Date(), - created_by: actorId ?? null, - updated_by: actorId ?? null, - }, - select: { id: true }, - }); - summary.created += 1; - await upsertBinding(tx, 'cost_center', created.id, row.externalId ?? null, code, actorId); - } - - return summary; -} - -async function syncSuppliers(tx, rows, actorId) { - const prepared = normalizePayloadRows(rows, (row) => String(row?.code ?? '').trim() || null); - const summary = prepared.summary; - const legalEntityCodes = Array.from( - new Set( - prepared.rows - .map((row) => String(row.legalEntityCode ?? '').trim()) - .filter(Boolean), - ), - ); - const legalEntities = await tx.legalEntity.findMany({ - where: { code: { in: legalEntityCodes } }, - select: { id: true, code: true }, - }); - const legalEntityByCode = new Map(legalEntities.map((row) => [row.code, row])); - const codes = prepared.rows.map((row) => String(row.code).trim()); - const existingRows = await tx.supplier.findMany({ - where: { code: { in: codes } }, - select: { id: true, code: true, enabled: true, archived_at: true }, - }); - const existingByCode = new Map(existingRows.map((row) => [row.code, row])); - - for (const row of prepared.rows) { - const code = String(row.code).trim(); - const legalEntityCode = String(row.legalEntityCode ?? '').trim() || null; - const legalEntity = legalEntityCode ? legalEntityByCode.get(legalEntityCode) : null; - if (legalEntityCode && !legalEntity) { - addSkip(summary, code, 'legal_entity_missing'); - continue; - } - - const existing = existingByCode.get(code); - const enabled = row.enabled !== false; - const archivedAt = resolveArchivedState(enabled, existing?.archived_at ?? null); - - if (existing) { - const bindingCheck = await ensureBindingNotConflicted(tx, 'supplier', existing.id, row.externalId ?? null); - if (!bindingCheck.ok) { - addSkip(summary, code, bindingCheck.reason); - continue; - } - - await tx.supplier.update({ - where: { id: existing.id }, - data: { - name: row.name, - short_name: row.shortName ?? null, - tax_no: row.taxNo ?? null, - contact_name: row.contactName ?? null, - contact_phone: row.contactPhone ?? null, - payment_terms: row.paymentTerms ?? null, - legal_entity_id: legalEntity?.id ?? null, - enabled, - archived_at: archivedAt, - updated_by: actorId ?? null, - }, - }); - summary.updated += 1; - if (!enabled && (existing.enabled || !existing.archived_at)) { - summary.archived += 1; - } - await upsertBinding(tx, 'supplier', existing.id, row.externalId ?? null, code, actorId); - continue; - } - - const created = await tx.supplier.create({ - data: { - code, - name: row.name, - short_name: row.shortName ?? null, - tax_no: row.taxNo ?? null, - contact_name: row.contactName ?? null, - contact_phone: row.contactPhone ?? null, - payment_terms: row.paymentTerms ?? null, - legal_entity_id: legalEntity?.id ?? null, - enabled, - archived_at: enabled ? null : new Date(), - created_by: actorId ?? null, - updated_by: actorId ?? null, - }, - select: { id: true }, - }); - summary.created += 1; - await upsertBinding(tx, 'supplier', created.id, row.externalId ?? null, code, actorId); - } - - return summary; -} - -function resolveResourceRows(data, resourceType) { - if (resourceType === 'legal_entity') { - return data?.legalEntities ?? []; - } - if (resourceType === 'cost_center') { - return data?.costCenters ?? []; - } - return data?.suppliers ?? []; -} - -async function runResourceSync(prisma, resourceType, rows, actorId) { - return prisma.$transaction(async (tx) => { - if (resourceType === 'legal_entity') { - return syncLegalEntities(tx, rows, actorId); - } - if (resourceType === 'cost_center') { - return syncCostCenters(tx, rows, actorId); - } - return syncSuppliers(tx, rows, actorId); - }); -} - -async function syncErpMasterData(prisma, config, options = {}) { - const logger = options.logger ?? console; - const actorId = options.actorId ?? null; - const resources = normalizeRequestedResources(options.resources); - - for (const resourceType of resources) { - await prisma.$transaction((tx) => markSyncStateRunning(tx, resourceType, actorId)); - } - - const pulled = await pullMasterDataFromErp(config, { resources }); - if (!pulled.ok) { - for (const resourceType of resources) { - await prisma.$transaction((tx) => markSyncStateFailed(tx, resourceType, actorId, pulled.message)); - } - logger.error( - { - provider: 'erp', - resourceType: resources.join(','), - endpoint: config.erpMasterDataEndpoint ?? null, - reason: pulled.code, - }, - 'ERP master data pull failed', - ); - return { - ok: false, - error: pulled, - items: await listMasterDataSyncStates(prisma), - }; - } - - for (const resourceType of resources) { - try { - const summary = await runResourceSync( - prisma, - resourceType, - resolveResourceRows(pulled.data, resourceType), - actorId, - ); - await prisma.$transaction((tx) => markSyncStateSucceeded(tx, resourceType, actorId, summary)); - } catch (error) { - logger.error( - { - err: error, - provider: 'erp', - resourceType, - endpoint: config.erpMasterDataEndpoint ?? null, - reason: error?.message ?? 'sync_failed', - }, - 'ERP master data resource sync failed', - ); - await prisma.$transaction((tx) => markSyncStateFailed(tx, resourceType, actorId, error)); - } - } - - return { - ok: true, - error: null, - items: await listMasterDataSyncStates(prisma), - }; -} - -module.exports = { - ERP_MASTER_DATA_PROVIDER, - ERP_MASTER_DATA_RESOURCES, - listMasterDataSyncStates, - mapSyncState, - normalizeRequestedResources, - syncErpMasterData, -}; diff --git a/server/src/services/integrations/erpProcurement.js b/server/src/services/integrations/erpProcurement.js deleted file mode 100644 index 15ca0244..00000000 --- a/server/src/services/integrations/erpProcurement.js +++ /dev/null @@ -1,704 +0,0 @@ -const crypto = require('node:crypto'); - -const { enqueueIntegrationEvent } = require('./outbox'); - -const PROCUREMENT_TRACK_STATUSES = Object.freeze({ - notTriggered: 'not_triggered', - skipped: 'skipped', -}); - -const PROCUREMENT_ERP_TARGETS = Object.freeze({ - commitmentJournal: 'commitment_journal', - accrualJournal: 'accrual_journal', - archiveSummary: 'archive_summary', -}); - -function normalizeString(value) { - const text = String(value ?? '').trim(); - return text || null; -} - -function toIsoString(value) { - if (!value) { - return null; - } - - try { - return new Date(value).toISOString(); - } catch { - return null; - } -} - -function normalizeMoney(value) { - if (value == null) { - return '0.00'; - } - - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed || '0.00'; - } - - if (typeof value?.toString === 'function') { - return value.toString(); - } - - return '0.00'; -} - -function resolveCurrencyCode(purchaseRequest) { - return normalizeString(purchaseRequest?.currency_code) ?? 'CNY'; -} - -function resolveLegalEntityCode(purchaseRequest) { - return normalizeString(purchaseRequest?.legal_entity?.code); -} - -function resolveCostCenterCode(purchaseRequest) { - return normalizeString(purchaseRequest?.cost_center?.code); -} - -function resolveSupplierCode(purchaseRequest) { - return normalizeString(purchaseRequest?.supplier?.code); -} - -function resolveBudgetPoolCode(purchaseRequest) { - return normalizeString(purchaseRequest?.budget_pool?.code); -} - -function resolveDocumentCategoryCode(purchaseRequest) { - return normalizeString(purchaseRequest?.document_category?.code); -} - -function stableNormalizeJson(value) { - if (Array.isArray(value)) { - return value.map((item) => stableNormalizeJson(item)); - } - - if (value && typeof value === 'object' && value.constructor === Object) { - return Object.keys(value) - .sort() - .reduce((result, key) => { - result[key] = stableNormalizeJson(value[key]); - return result; - }, {}); - } - - return value; -} - -function stableStringifyJson(value) { - return JSON.stringify(stableNormalizeJson(value)); -} - -function hashProcurementPayload(value) { - return crypto.createHash('sha256').update(stableStringifyJson(value)).digest('hex'); -} - -function buildPurchaseCommitmentJournalPayload(input) { - const purchaseRequest = input?.purchaseRequest ?? {}; - - return { - journalType: 'purchase_request_commitment', - sourceEventName: 'purchase_request.approved', - entityType: 'purchase_request', - entityId: purchaseRequest.id ?? null, - documentNo: purchaseRequest.request_no ?? null, - currencyCode: resolveCurrencyCode(purchaseRequest), - totalAmount: normalizeMoney(purchaseRequest.total_amount), - occurredAt: - toIsoString(input?.occurredAt) ?? - toIsoString(purchaseRequest.updated_at) ?? - toIsoString(purchaseRequest.created_at), - dimensions: { - departmentId: purchaseRequest.department_id ?? null, - applicantEmployeeId: purchaseRequest.applicant_id ?? null, - submitterEmployeeId: purchaseRequest.submitter_id ?? null, - legalEntityCode: resolveLegalEntityCode(purchaseRequest), - costCenterCode: resolveCostCenterCode(purchaseRequest), - supplierCode: resolveSupplierCode(purchaseRequest), - budgetPoolCode: resolveBudgetPoolCode(purchaseRequest), - }, - references: { - purchaseRequestId: purchaseRequest.id ?? null, - documentCategoryCode: resolveDocumentCategoryCode(purchaseRequest), - requestNo: purchaseRequest.request_no ?? null, - }, - }; -} - -function buildPurchaseAccrualJournalPayload(input) { - const purchaseRequest = input?.purchaseRequest ?? {}; - const archive = input?.archive ?? purchaseRequest.order_archive ?? {}; - - return { - journalType: 'purchase_order_accrual', - sourceEventName: 'purchase_order.archived', - entityType: 'purchase_order_archive', - entityId: archive.id ?? null, - documentNo: archive.po_no ?? null, - currencyCode: resolveCurrencyCode(purchaseRequest), - totalAmount: normalizeMoney(purchaseRequest.total_amount), - occurredAt: - toIsoString(input?.occurredAt) ?? - toIsoString(archive.archived_at) ?? - toIsoString(purchaseRequest.updated_at) ?? - toIsoString(purchaseRequest.created_at), - dimensions: { - departmentId: purchaseRequest.department_id ?? null, - applicantEmployeeId: purchaseRequest.applicant_id ?? null, - submitterEmployeeId: purchaseRequest.submitter_id ?? null, - legalEntityCode: resolveLegalEntityCode(purchaseRequest), - costCenterCode: resolveCostCenterCode(purchaseRequest), - supplierCode: resolveSupplierCode(purchaseRequest), - budgetPoolCode: resolveBudgetPoolCode(purchaseRequest), - }, - references: { - purchaseRequestId: purchaseRequest.id ?? null, - purchaseOrderArchiveId: archive.id ?? null, - requestNo: purchaseRequest.request_no ?? null, - poNo: archive.po_no ?? null, - contractNo: archive.contract_no ?? null, - }, - }; -} - -function buildPurchaseArchiveSummaryPayload(input) { - const purchaseRequest = input?.purchaseRequest ?? {}; - const archive = input?.archive ?? purchaseRequest.order_archive ?? {}; - const archivedFiles = Array.isArray(archive.archived_files) - ? archive.archived_files.map((file) => ({ - storagePath: file?.storagePath ?? null, - originalName: file?.originalName ?? null, - mimeType: file?.mimeType ?? null, - fileSize: Number(file?.fileSize ?? 0), - })) - : []; - - return { - exportType: 'purchase_order_archive', - sourceEventName: 'purchase_order.archived', - entityType: 'purchase_order_archive', - entityId: archive.id ?? null, - purchaseRequestId: purchaseRequest.id ?? null, - requestNo: purchaseRequest.request_no ?? null, - poNo: archive.po_no ?? null, - contractNo: archive.contract_no ?? null, - currencyCode: resolveCurrencyCode(purchaseRequest), - totalAmount: normalizeMoney(purchaseRequest.total_amount), - archivedAt: - toIsoString(input?.archivedAt) ?? - toIsoString(archive.archived_at) ?? - toIsoString(purchaseRequest.updated_at) ?? - toIsoString(purchaseRequest.created_at), - supplier: purchaseRequest.supplier - ? { - id: purchaseRequest.supplier.id, - code: purchaseRequest.supplier.code ?? null, - name: purchaseRequest.supplier.name ?? null, - } - : { - id: purchaseRequest.supplier_id ?? null, - code: null, - name: null, - }, - dimensions: { - legalEntityCode: resolveLegalEntityCode(purchaseRequest), - costCenterCode: resolveCostCenterCode(purchaseRequest), - departmentId: purchaseRequest.department_id ?? null, - }, - files: archivedFiles, - }; -} - -function buildPurchaseCommitmentJournalDedupeKey(purchaseRequestId) { - return `purchase_request:${purchaseRequestId}:commitment`; -} - -function buildPurchaseAccrualJournalDedupeKey(archiveId) { - return `purchase_order_archive:${archiveId}:accrual`; -} - -function buildPurchaseArchiveSummaryDedupeKey(archiveId) { - return `purchase_order_archive:${archiveId}:summary`; -} - -function mapIntegrationBinding(record) { - if (!record) { - return null; - } - - return { - id: record.id, - provider: record.provider, - entityType: record.entity_type, - localId: record.local_id, - externalId: record.external_id ?? null, - externalCode: record.external_code ?? null, - lastSyncedAt: record.last_synced_at?.toISOString?.() ?? null, - }; -} - -function redactBinding(binding, canManage) { - if (!binding) { - return null; - } - - if (canManage) { - return binding; - } - - return { - ...binding, - externalId: null, - externalCode: null, - }; -} - -function mapIntegrationOutboxState(record) { - if (!record) { - return null; - } - - return { - id: record.id, - eventType: record.event_type, - status: record.status, - attempts: Number(record.attempts ?? 0), - lastError: record.last_error ?? null, - processedAt: record.processed_at?.toISOString?.() ?? null, - updatedAt: record.updated_at?.toISOString?.() ?? null, - }; -} - -async function findOutboxByKey(prisma, eventType, dedupeKey) { - if (!prisma || !eventType || !dedupeKey) { - return null; - } - - return prisma.integrationOutbox.findUnique({ - where: { - event_type_dedupe_key: { - event_type: eventType, - dedupe_key: dedupeKey, - }, - }, - }); -} - -async function findBindingByLocalId(prisma, entityType, localId) { - if (!prisma || !entityType || !localId) { - return null; - } - - return prisma.integrationBinding.findUnique({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: entityType, - local_id: localId, - }, - }, - }); -} - -async function enqueuePurchaseCommitmentJournalExport(prisma, config, input) { - const payload = - input?.payload ?? - buildPurchaseCommitmentJournalPayload({ - purchaseRequest: input?.purchaseRequest, - occurredAt: input?.occurredAt, - }); - - const enqueued = await enqueueIntegrationEvent(prisma, { - eventType: 'erp.journal.export', - payload, - dedupeKey: buildPurchaseCommitmentJournalDedupeKey(payload.entityId), - maxAttempts: config.integrationOutboxMaxAttempts, - actorId: input?.actorId ?? null, - requeueOnFailure: input?.requeueOnFailure === true, - }); - - return { - ...enqueued, - eventType: 'erp.journal.export', - erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal, - payload, - }; -} - -async function enqueuePurchaseAccrualJournalExport(prisma, config, input) { - const payload = - input?.payload ?? - buildPurchaseAccrualJournalPayload({ - purchaseRequest: input?.purchaseRequest, - archive: input?.archive, - occurredAt: input?.occurredAt, - }); - - const enqueued = await enqueueIntegrationEvent(prisma, { - eventType: 'erp.journal.export', - payload, - dedupeKey: buildPurchaseAccrualJournalDedupeKey(payload.entityId), - maxAttempts: config.integrationOutboxMaxAttempts, - actorId: input?.actorId ?? null, - requeueOnFailure: input?.requeueOnFailure === true, - }); - - return { - ...enqueued, - eventType: 'erp.journal.export', - erpTarget: PROCUREMENT_ERP_TARGETS.accrualJournal, - payload, - }; -} - -async function enqueuePurchaseArchiveSummaryExport(prisma, config, input) { - const payload = - input?.payload ?? - buildPurchaseArchiveSummaryPayload({ - purchaseRequest: input?.purchaseRequest, - archive: input?.archive, - archivedAt: input?.archivedAt, - }); - - const enqueued = await enqueueIntegrationEvent(prisma, { - eventType: 'erp.procurement.export', - payload, - dedupeKey: buildPurchaseArchiveSummaryDedupeKey(payload.entityId), - maxAttempts: config.integrationOutboxMaxAttempts, - actorId: input?.actorId ?? null, - requeueOnFailure: input?.requeueOnFailure === true, - resetOnPayloadChange: true, - }); - - return { - ...enqueued, - eventType: 'erp.procurement.export', - erpTarget: PROCUREMENT_ERP_TARGETS.archiveSummary, - payload, - }; -} - -async function loadPurchaseRequestErpContext(prisma, purchaseRequestId) { - const purchaseRequest = await prisma.purchaseRequest.findUnique({ - where: { id: purchaseRequestId }, - select: { - id: true, - request_no: true, - applicant_id: true, - submitter_id: true, - department_id: true, - legal_entity_id: true, - cost_center_id: true, - supplier_id: true, - document_category_id: true, - budget_pool_id: true, - status: true, - currency_code: true, - total_amount: true, - created_at: true, - updated_at: true, - applicant: { - select: { - id: true, - employee_no: true, - name: true, - }, - }, - submitter: { - select: { - id: true, - employee_no: true, - name: true, - }, - }, - legal_entity: { - select: { - id: true, - code: true, - name: true, - }, - }, - cost_center: { - select: { - id: true, - code: true, - name: true, - }, - }, - supplier: { - select: { - id: true, - code: true, - name: true, - }, - }, - document_category: { - select: { - id: true, - code: true, - name: true, - }, - }, - budget_pool: { - select: { - id: true, - code: true, - name: true, - }, - }, - order_archive: { - select: { - id: true, - po_no: true, - contract_no: true, - archived_files: true, - archived_at: true, - archived_by: true, - }, - }, - workflow_instances: { - where: { entity_type: 'purchase_request' }, - orderBy: { created_at: 'desc' }, - take: 1, - select: { id: true }, - }, - }, - }); - - if (!purchaseRequest) { - return null; - } - - const workflowInstanceId = purchaseRequest.workflow_instances?.[0]?.id ?? null; - const approvalHistory = workflowInstanceId - ? await prisma.workflowHistory.findFirst({ - where: { - instance_id: workflowInstanceId, - action: 'approve', - }, - orderBy: { created_at: 'desc' }, - select: { created_at: true }, - }) - : null; - - return { - purchaseRequest, - approvalOccurredAt: - approvalHistory?.created_at ?? purchaseRequest.updated_at ?? purchaseRequest.created_at ?? null, - }; -} - -function hasAnyBinding(binding) { - if (!binding) { - return false; - } - - return Boolean(binding.externalId || binding.externalCode || binding.lastSyncedAt); -} - -function resolveTrackStageStatus({ eligible, outbox, binding, bindings, gateEnabled }) { - if (outbox?.status) { - return outbox.status; - } - - if (binding && hasAnyBinding(binding)) { - return 'succeeded'; - } - - if (bindings && Object.values(bindings).some((item) => hasAnyBinding(item))) { - return 'succeeded'; - } - - if (!eligible) { - return PROCUREMENT_TRACK_STATUSES.notTriggered; - } - - if (!gateEnabled) { - return PROCUREMENT_TRACK_STATUSES.skipped; - } - - return PROCUREMENT_TRACK_STATUSES.notTriggered; -} - -function buildPurchaseTrackSummary(context) { - const purchaseRequest = context?.purchaseRequest ?? {}; - const archive = purchaseRequest.order_archive ?? null; - - return { - id: purchaseRequest.id, - requestNo: purchaseRequest.request_no ?? null, - status: purchaseRequest.status, - totalAmount: normalizeMoney(purchaseRequest.total_amount), - supplier: purchaseRequest.supplier - ? { - id: purchaseRequest.supplier.id, - code: purchaseRequest.supplier.code ?? null, - name: purchaseRequest.supplier.name ?? null, - } - : null, - archiveOrder: archive - ? { - id: archive.id, - poNo: archive.po_no, - contractNo: archive.contract_no ?? null, - archivedAt: toIsoString(archive.archived_at), - } - : null, - }; -} - -async function buildPurchaseRequestErpTrack(prisma, config, purchaseRequestId, options = {}) { - const context = options.context ?? (await loadPurchaseRequestErpContext(prisma, purchaseRequestId)); - if (!context) { - return null; - } - - const purchaseRequest = context.purchaseRequest; - const archive = purchaseRequest.order_archive ?? null; - const canManage = Boolean(options.canManage); - const gateEnabled = Boolean(options.gate?.effectiveEnabled ?? options.gate?.enabled ?? true); - - const [ - commitmentOutboxRow, - accrualOutboxRow, - summaryOutboxRow, - commitmentBindingRow, - accrualBindingRow, - poBindingRow, - contractBindingRow, - ] = await Promise.all([ - findOutboxByKey( - prisma, - 'erp.journal.export', - buildPurchaseCommitmentJournalDedupeKey(purchaseRequest.id), - ), - archive - ? findOutboxByKey( - prisma, - 'erp.journal.export', - buildPurchaseAccrualJournalDedupeKey(archive.id), - ) - : Promise.resolve(null), - archive - ? findOutboxByKey( - prisma, - 'erp.procurement.export', - buildPurchaseArchiveSummaryDedupeKey(archive.id), - ) - : Promise.resolve(null), - findBindingByLocalId(prisma, 'purchase_request_commitment_journal', purchaseRequest.id), - archive - ? findBindingByLocalId(prisma, 'purchase_order_accrual_journal', archive.id) - : Promise.resolve(null), - archive ? findBindingByLocalId(prisma, 'purchase_order_po', archive.id) : Promise.resolve(null), - archive - ? findBindingByLocalId(prisma, 'purchase_order_contract', archive.id) - : Promise.resolve(null), - ]); - - const commitmentPreview = buildPurchaseCommitmentJournalPayload({ - purchaseRequest, - occurredAt: context.approvalOccurredAt, - }); - const accrualPreview = archive - ? buildPurchaseAccrualJournalPayload({ - purchaseRequest, - archive, - occurredAt: archive.archived_at, - }) - : {}; - const summaryPreview = archive - ? buildPurchaseArchiveSummaryPayload({ - purchaseRequest, - archive, - archivedAt: archive.archived_at, - }) - : {}; - - const commitmentBinding = redactBinding(mapIntegrationBinding(commitmentBindingRow), canManage); - const accrualBinding = redactBinding(mapIntegrationBinding(accrualBindingRow), canManage); - const poBinding = redactBinding(mapIntegrationBinding(poBindingRow), canManage); - const contractBinding = redactBinding(mapIntegrationBinding(contractBindingRow), canManage); - - const commitmentOutbox = mapIntegrationOutboxState(commitmentOutboxRow); - const accrualOutbox = mapIntegrationOutboxState(accrualOutboxRow); - const summaryOutbox = mapIntegrationOutboxState(summaryOutboxRow); - - const commitmentEligible = purchaseRequest.status === 'approved'; - const archiveEligible = Boolean(archive); - - return { - purchaseRequest: buildPurchaseTrackSummary(context), - stages: { - commitmentJournal: { - eligible: commitmentEligible, - reason: commitmentEligible ? null : 'purchase_request_not_approved', - status: resolveTrackStageStatus({ - eligible: commitmentEligible, - outbox: commitmentOutbox, - binding: commitmentBinding, - gateEnabled, - }), - preview: commitmentPreview, - outbox: commitmentOutbox, - binding: commitmentBinding, - }, - accrualJournal: { - eligible: archiveEligible, - reason: archiveEligible ? null : 'purchase_order_archive_missing', - status: resolveTrackStageStatus({ - eligible: archiveEligible, - outbox: accrualOutbox, - binding: accrualBinding, - gateEnabled, - }), - preview: accrualPreview, - outbox: accrualOutbox, - binding: accrualBinding, - }, - archiveSummary: { - eligible: archiveEligible, - reason: archiveEligible ? null : 'purchase_order_archive_missing', - status: resolveTrackStageStatus({ - eligible: archiveEligible, - outbox: summaryOutbox, - bindings: { - po: poBinding, - contract: contractBinding, - }, - gateEnabled, - }), - preview: summaryPreview, - outbox: summaryOutbox, - bindings: { - po: poBinding, - contract: contractBinding, - }, - }, - }, - permissions: { - canRetry: Boolean(canManage && gateEnabled), - }, - }; -} - -module.exports = { - PROCUREMENT_ERP_TARGETS, - PROCUREMENT_TRACK_STATUSES, - buildPurchaseAccrualJournalDedupeKey, - buildPurchaseAccrualJournalPayload, - buildPurchaseArchiveSummaryDedupeKey, - buildPurchaseArchiveSummaryPayload, - buildPurchaseCommitmentJournalDedupeKey, - buildPurchaseCommitmentJournalPayload, - buildPurchaseRequestErpTrack, - enqueuePurchaseAccrualJournalExport, - enqueuePurchaseArchiveSummaryExport, - enqueuePurchaseCommitmentJournalExport, - hashProcurementPayload, - loadPurchaseRequestErpContext, - stableStringifyJson, -}; diff --git a/server/src/services/integrations/erpTravel.js b/server/src/services/integrations/erpTravel.js deleted file mode 100644 index b34379f7..00000000 --- a/server/src/services/integrations/erpTravel.js +++ /dev/null @@ -1,333 +0,0 @@ -const { enqueueIntegrationEvent } = require("./outbox"); -const { normalizeSummaryJson, toMoneyFixed } = require("../travelReconciliation"); - -const TRAVEL_ERP_EVENT_TYPE = "erp.travel.export"; -const TRAVEL_ERP_EXPORT_TYPE = "travel_reconciliation_summary"; -const TRAVEL_ERP_BINDING_TYPES = Object.freeze({ - payableSummary: "travel_payable_summary", - settlementBatch: "travel_settlement_batch", -}); -const TRAVEL_ERP_TRACK_STATUSES = Object.freeze({ - notTriggered: "not_triggered", - skipped: "skipped", -}); - -const TRAVEL_RECONCILIATION_ERP_SELECT = { - id: true, - batch_no: true, - legal_entity_id: true, - cost_center_id: true, - period_start: true, - period_end: true, - status: true, - summary_json: true, - confirmed_at: true, - created_at: true, - updated_at: true, - legal_entity: { - select: { - id: true, - code: true, - name: true, - }, - }, - cost_center: { - select: { - id: true, - code: true, - name: true, - }, - }, - items: { - orderBy: [{ created_at: "asc" }, { id: "asc" }], - select: { - id: true, - travel_request_id: true, - travel_order_id: true, - card_transaction_id: true, - match_status: true, - resolution_status: true, - order_amount: true, - transaction_amount: true, - currency: true, - snapshot_json: true, - }, - }, -}; - -function toDateOnly(value) { - if (!value) { - return null; - } - try { - return new Date(value).toISOString().slice(0, 10); - } catch { - return null; - } -} - -function toIsoString(value) { - if (!value) { - return null; - } - try { - return new Date(value).toISOString(); - } catch { - return null; - } -} - -function mapIntegrationBinding(record) { - if (!record) { - return null; - } - - return { - id: record.id, - provider: record.provider, - entityType: record.entity_type, - localId: record.local_id, - externalId: record.external_id ?? null, - externalCode: record.external_code ?? null, - lastSyncedAt: record.last_synced_at?.toISOString?.() ?? null, - }; -} - -function redactBinding(binding, canManage) { - if (!binding) { - return null; - } - if (canManage) { - return binding; - } - return { - ...binding, - externalId: null, - externalCode: null, - }; -} - -function mapIntegrationOutboxState(record) { - if (!record) { - return null; - } - return { - id: record.id, - eventType: record.event_type, - status: record.status, - attempts: Number(record.attempts ?? 0), - lastError: record.last_error ?? null, - processedAt: record.processed_at?.toISOString?.() ?? null, - updatedAt: record.updated_at?.toISOString?.() ?? null, - }; -} - -function buildTravelReconciliationSummaryDedupeKey(batchId) { - return `travel_reconciliation_batch:${batchId}:summary`; -} - -function mapPayloadItem(item) { - const snapshot = item.snapshot_json && typeof item.snapshot_json === "object" ? item.snapshot_json : {}; - return { - travelRequestId: item.travel_request_id ?? null, - requestNo: snapshot.requestNo ?? null, - travelOrderId: item.travel_order_id ?? null, - externalOrderNo: snapshot.externalOrderNo ?? null, - cardTransactionId: item.card_transaction_id ?? null, - externalTxnId: snapshot.externalTxnId ?? null, - matchStatus: item.match_status, - resolutionStatus: item.resolution_status, - orderAmount: item.order_amount?.toString?.() ?? null, - transactionAmount: item.transaction_amount?.toString?.() ?? null, - currency: item.currency ?? "CNY", - linkedReimbursementId: snapshot.linkedReimbursementId ?? null, - boundInvoiceCount: Number(snapshot.boundInvoiceCount ?? 0), - }; -} - -function buildTravelReconciliationSummaryPayload(input) { - const batch = input?.batch ?? {}; - const summary = normalizeSummaryJson(batch.summary_json); - const items = Array.isArray(batch.items) ? batch.items.map(mapPayloadItem) : []; - - return { - exportType: TRAVEL_ERP_EXPORT_TYPE, - entityType: "travel_reconciliation_batch", - entityId: batch.id ?? null, - batchNo: batch.batch_no ?? null, - occurredAt: - toIsoString(batch.confirmed_at) ?? - toIsoString(batch.updated_at) ?? - toIsoString(batch.created_at), - periodStart: toDateOnly(batch.period_start), - periodEnd: toDateOnly(batch.period_end), - dimensions: { - legalEntityCode: batch.legal_entity?.code ?? null, - costCenterCode: batch.cost_center?.code ?? null, - }, - summary: { - totalOrders: Number(summary.totalOrders ?? 0), - totalTransactions: Number(summary.totalTransactions ?? 0), - anomalyItems: Number(summary.anomalyItems ?? 0), - matchRate: toMoneyFixed(summary.matchRate), - totalOrderAmount: toMoneyFixed(summary.totalOrderAmount), - totalSettledAmount: toMoneyFixed(summary.totalSettledAmount), - totalReversedAmount: toMoneyFixed(summary.totalReversedAmount), - }, - items, - }; -} - -async function enqueueTravelReconciliationSummaryExport(prisma, config, input) { - const payload = - input?.payload ?? buildTravelReconciliationSummaryPayload({ - batch: input?.batch, - }); - - const enqueued = await enqueueIntegrationEvent(prisma, { - eventType: TRAVEL_ERP_EVENT_TYPE, - payload, - dedupeKey: buildTravelReconciliationSummaryDedupeKey(payload.entityId), - maxAttempts: config.integrationOutboxMaxAttempts, - actorId: input?.actorId ?? null, - requeueOnFailure: input?.requeueOnFailure === true, - }); - - return { - ...enqueued, - eventType: TRAVEL_ERP_EVENT_TYPE, - payload, - }; -} - -async function findOutboxByKey(prisma, eventType, dedupeKey) { - if (!eventType || !dedupeKey) { - return null; - } - return prisma.integrationOutbox.findUnique({ - where: { - event_type_dedupe_key: { - event_type: eventType, - dedupe_key: dedupeKey, - }, - }, - }); -} - -async function findBindingByLocalId(prisma, entityType, localId) { - return prisma.integrationBinding.findUnique({ - where: { - provider_entity_type_local_id: { - provider: "erp", - entity_type: entityType, - local_id: localId, - }, - }, - }); -} - -async function loadTravelReconciliationErpContext(prisma, batchId) { - return prisma.travelReconciliationBatch.findUnique({ - where: { id: batchId }, - select: TRAVEL_RECONCILIATION_ERP_SELECT, - }); -} - -function resolveTrackStatus({ eligible, outbox, payableBinding, settlementBinding, gateEnabled }) { - if (outbox?.status) { - return outbox.status; - } - if (payableBinding && settlementBinding) { - return "succeeded"; - } - if (!eligible) { - return TRAVEL_ERP_TRACK_STATUSES.notTriggered; - } - if (!gateEnabled) { - return TRAVEL_ERP_TRACK_STATUSES.skipped; - } - return TRAVEL_ERP_TRACK_STATUSES.notTriggered; -} - -async function buildTravelReconciliationErpTrack(prisma, config, batchId, options = {}) { - const batch = options.context ?? (await loadTravelReconciliationErpContext(prisma, batchId)); - if (!batch) { - return null; - } - - const canManage = Boolean(options.canManage); - const gateEnabled = Boolean(options.gate?.effectiveEnabled ?? options.gate?.enabled ?? true); - const dedupeKey = buildTravelReconciliationSummaryDedupeKey(batch.id); - - const [outboxRow, payableBindingRow, settlementBindingRow] = await Promise.all([ - findOutboxByKey(prisma, TRAVEL_ERP_EVENT_TYPE, dedupeKey), - findBindingByLocalId(prisma, TRAVEL_ERP_BINDING_TYPES.payableSummary, batch.id), - findBindingByLocalId(prisma, TRAVEL_ERP_BINDING_TYPES.settlementBatch, batch.id), - ]); - - const eligible = batch.status === "reconciled"; - const reason = eligible ? null : "travel_reconciliation_batch_not_reconciled"; - const outbox = mapIntegrationOutboxState(outboxRow); - const payableBinding = mapIntegrationBinding(payableBindingRow); - const settlementBinding = mapIntegrationBinding(settlementBindingRow); - - return { - batch: { - id: batch.id, - batchNo: batch.batch_no, - status: batch.status, - periodStart: toDateOnly(batch.period_start), - periodEnd: toDateOnly(batch.period_end), - legalEntity: batch.legal_entity - ? { - id: batch.legal_entity.id, - code: batch.legal_entity.code, - name: batch.legal_entity.name, - } - : null, - costCenter: batch.cost_center - ? { - id: batch.cost_center.id, - code: batch.cost_center.code, - name: batch.cost_center.name, - } - : null, - }, - stage: { - summaryExport: { - eligible, - reason, - status: resolveTrackStatus({ - eligible, - outbox, - payableBinding: payableBindingRow, - settlementBinding: settlementBindingRow, - gateEnabled, - }), - preview: buildTravelReconciliationSummaryPayload({ batch }), - outbox, - bindings: { - payableSummary: redactBinding(payableBinding, canManage), - settlementBatch: redactBinding(settlementBinding, canManage), - }, - }, - }, - permissions: { - canRetry: Boolean(canManage && gateEnabled && eligible), - canSeeExternalRefs: canManage, - }, - }; -} - -module.exports = { - TRAVEL_ERP_BINDING_TYPES, - TRAVEL_ERP_EVENT_TYPE, - TRAVEL_ERP_EXPORT_TYPE, - TRAVEL_ERP_TRACK_STATUSES, - TRAVEL_RECONCILIATION_ERP_SELECT, - buildTravelReconciliationErpTrack, - buildTravelReconciliationSummaryDedupeKey, - buildTravelReconciliationSummaryPayload, - enqueueTravelReconciliationSummaryExport, - loadTravelReconciliationErpContext, -}; diff --git a/server/src/services/integrations/outbox.js b/server/src/services/integrations/outbox.js index 38c4a019..2a19d352 100644 --- a/server/src/services/integrations/outbox.js +++ b/server/src/services/integrations/outbox.js @@ -85,6 +85,7 @@ async function resetExistingOutbox(prisma, existed, input) { } async function enqueueIntegrationEvent(prisma, input) { + if (String(input.eventType).startsWith("erp.")) throw Object.assign(new Error("erp_retired"), { code: "erp_retired" }); const dedupeKey = input.dedupeKey ?? null; const existed = @@ -153,6 +154,7 @@ async function fetchPendingOutboxEvents(prisma, options) { return prisma.integrationOutbox.findMany({ where: { + NOT: { event_type: { startsWith: "erp." } }, status: { in: [OUTBOX_STATUSES.pending, OUTBOX_STATUSES.failed], }, diff --git a/server/src/services/integrations/wecomHrMaster.js b/server/src/services/integrations/wecomHrMaster.js index b4935fc7..97698e67 100644 --- a/server/src/services/integrations/wecomHrMaster.js +++ b/server/src/services/integrations/wecomHrMaster.js @@ -2,6 +2,7 @@ const crypto = require("node:crypto"); const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken"); const { hashPassword } = require("../../utils/crypto"); +const { isValidDateOnlyString } = require("../../utils/date"); const WECOM_API_BASE = "https://qyapi.weixin.qq.com"; const MODE_LIST = Object.freeze(["dry_run", "apply"]); @@ -164,17 +165,22 @@ function parseDateOnly(value) { const direct = raw.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/); if (direct) { const [, year, month, day] = direct; - return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; + const canonical = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; + return isValidDateOnlyString(canonical) ? canonical : null; } const compact = raw.match(/^(\d{4})(\d{2})(\d{2})$/); if (compact) { const [, year, month, day] = compact; - return `${year}-${month}-${day}`; + const canonical = `${year}-${month}-${day}`; + return isValidDateOnlyString(canonical) ? canonical : null; } const numeric = Number(raw); if (Number.isFinite(numeric) && numeric > 0) { const date = new Date(numeric > 10_000_000_000 ? numeric : numeric * 1000); - if (!Number.isNaN(date.getTime())) return date.toISOString().slice(0, 10); + if (!Number.isNaN(date.getTime())) { + const canonical = date.toISOString().split("T")[0]; + return isValidDateOnlyString(canonical) ? canonical : null; + } } return null; } @@ -440,6 +446,8 @@ function normalizeWecomHrProfile({ contact = {}, staff = {}, departmentMap = new const enabled = contact.enable == null ? true : Number(contact.enable) !== 0; const statusText = firstField(staffFields, ["状态", "在职状态", "employment_status"]); const inactiveFromHr = /离职|停用|禁用|inactive|offboard/i.test(statusText); + const hireDateInput = firstField(staffFields, ["hire", "入职", "入职日期"]); + const hireDate = parseDateOnly(hireDateInput); const profile = { externalUserId, @@ -450,7 +458,8 @@ function normalizeWecomHrProfile({ contact = {}, staff = {}, departmentMap = new departmentExternalId, departmentName: departmentFromMap?.name ?? toSafeString(firstField(staffFields, ["department", "部门"]), 100), managerExternalUserId: toSafeString(directLeader, 128), - hireDate: parseDateOnly(firstField(staffFields, ["hire", "入职", "入职日期"])), + hireDate, + hireDateInvalid: Boolean(hireDateInput) && hireDate === null, sourceStatus: enabled && !inactiveFromHr ? "active" : "inactive", sourceSummary: { externalUserIdPrefix: maskExternalUserId(externalUserId), @@ -796,6 +805,18 @@ async function processProfile(prisma, runId, profile, context) { return { item, employeeId: null, managerExternalUserId: null }; } + // A missing date retains the create default; an invalid supplied date must not + // reach department or account mutations, including offline normalized profiles. + if (profile.hireDateInvalid || (profile.hireDate != null && profile.hireDate !== "" && !isValidDateOnlyString(profile.hireDate))) { + const item = await createSyncItem(prisma, runId, profile, { + action: "skip", + status: "skipped", + errorCode: "invalid_hire_date", + errorMessage: "入职日期无效,本条未同步;请修正来源日期后重试。", + }); + return { item, employeeId: null, managerExternalUserId: null }; + } + const department = await resolveDepartment(prisma, profile, context); if (!department.ok) { const item = await createSyncItem(prisma, runId, profile, { diff --git a/server/src/services/paymentOrderXls.js b/server/src/services/paymentOrderXls.js index 9bb3f278..50a8908c 100644 --- a/server/src/services/paymentOrderXls.js +++ b/server/src/services/paymentOrderXls.js @@ -109,7 +109,9 @@ function buildPaymentOrderRows(config, batch) { addBlocker(error.code ?? "payee_account_unavailable", entityCodeHint); } try { - payeeName = requiredText(payee?.name, "payee_name_missing"); + payeeName = batch.entity_type === "loan_repayment" + ? decryptRequired(config, payee?.accountNameEncrypted, "payee_name_missing") + : requiredText(payee?.name, "payee_name_missing"); } catch (error) { addBlocker(error.code ?? "payee_name_missing", entityCodeHint); } @@ -155,8 +157,8 @@ function buildPaymentOrderRows(config, batch) { "", serial, Number(amount.toFixed(2)), - "报销款", - `报销款 ${entityCode}`, + batch.entity_type === "loan_repayment" ? "还款" : "报销款", + batch.entity_type === "loan_repayment" ? "归还借款本金" : `报销款 ${entityCode}`, "0", "", "", diff --git a/server/src/services/paymentReceiptManualPersistence.js b/server/src/services/paymentReceiptManualPersistence.js index 1c9ab9cb..a04b3f61 100644 --- a/server/src/services/paymentReceiptManualPersistence.js +++ b/server/src/services/paymentReceiptManualPersistence.js @@ -11,10 +11,21 @@ function requireFact(condition, code) { if (!condition) throw new ReceiptVerificationError(code); } +async function receiptParticipants(tx, batch) { + if (batch.entity_type === "loan_repayment") return require("./companyLoanPayments").loanReceiptParticipants(tx, batch); + requireFact(batch.entity_type === "reimbursement", "receipt_business_unsupported"); + const sources = await tx.reimbursementForm.findMany({ + where: { id: { in: batch.items.map((item) => item.entity_id) } }, + select: { id: true, owner_id: true, submitter_id: true }, + }); + requireFact(sources.length === new Set(batch.items.map((item) => item.entity_id)).size, "receipt_review_identity_unresolved"); + return sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })); +} + async function lockContext(tx, { batchId, attachmentId, expectedVersion }) { await tx.$queryRaw`SELECT id FROM payment_batches WHERE id = ${batchId}::uuid FOR UPDATE`; const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } }); - requireFact(batch?.entity_type === "reimbursement" && ["reviewed", "failed"].includes(batch.status), + requireFact(["reimbursement", "loan_repayment"].includes(batch?.entity_type) && ["reviewed", "failed"].includes(batch.status), "receipt_batch_not_editable"); await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`; await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id = ${attachmentId}::uuid FOR UPDATE`; @@ -70,16 +81,13 @@ async function loadApprovedManualReceiptEvidence(tx, config, batch, attachmentId const employee = await tx.employee.findUnique({ where: { id: saved.reviewed_by }, include: { roles: true } }); requireFact(employee?.status === "active" && !employee.archived_at, "receipt_review_permission_required"); const roleCodes = employee.roles.map((role) => role.role_code); - const sources = await tx.reimbursementForm.findMany({ - where: { id: { in: batch.items.map((item) => item.entity_id) } }, - select: { id: true, owner_id: true, submitter_id: true }, - }); - requireFact(sources.length === new Set(batch.items.map((item) => item.entity_id)).size, + const participants = await receiptParticipants(tx, batch); + requireFact(participants.length === new Set(batch.items.map((item) => item.entity_id)).size, "receipt_review_identity_unresolved"); assertIndependentReceiptReviewer({ reviewer: { id: employee.id, roleCodes }, hasReviewPermission: hasPermission(resolvePermissionsForRoles(roleCodes), "payment.batch.review"), makerId: batch.created_by, proposedBy: saved.proposed_by, - participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) }); + participants }); } else { requireFact(saved.status === "association_ready", "receipt_match_changed"); } @@ -162,16 +170,13 @@ async function readManualReceiptDetail(tx, config, { batchId, attachmentId, revi let canReview = false; if (proposal?.status === "pending_review" && reviewer) { const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } }); - const sources = await tx.reimbursementForm.findMany({ - where: { id: { in: batch.items.map((item) => item.entity_id) } }, - select: { id: true, owner_id: true, submitter_id: true }, - }); + const participants = await receiptParticipants(tx, batch); if (["reviewed", "failed"].includes(batch.status) && - sources.length === new Set(batch.items.map((item) => item.entity_id)).size) { + participants.length === new Set(batch.items.map((item) => item.entity_id)).size) { try { assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, makerId: batch.created_by, proposedBy: proposal.proposed_by, - participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) }); + participants }); canReview = true; } catch (error) { if (!(error instanceof ReceiptVerificationError)) throw error; @@ -248,15 +253,12 @@ async function reviewManualReceiptProposal(tx, config, request, input, hasReview saved.source_sha256 === attachment.file_sha256, "receipt_source_changed"); requireFact(saved.status === "pending_review" && attachment.receipt_ocr_state === "pending_review", "receipt_manual_not_reviewable"); - const sources = await tx.reimbursementForm.findMany({ - where: { id: { in: batch.items.map((item) => item.entity_id) } }, - select: { id: true, owner_id: true, submitter_id: true }, - }); - requireFact(sources.length === new Set(batch.items.map((item) => item.entity_id)).size, + const participants = await receiptParticipants(tx, batch); + requireFact(participants.length === new Set(batch.items.map((item) => item.entity_id)).size, "receipt_review_identity_unresolved"); assertIndependentReceiptReviewer({ reviewer: request.employee, hasReviewPermission, makerId: batch.created_by, proposedBy: saved.proposed_by, - participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) }); + participants }); requireFact(["approve", "reject"].includes(input.decision), "receipt_manual_decision_invalid"); requireFact(typeof input.reason === "string" && input.reason.trim().length > 0 && input.reason.length <= 500, "receipt_manual_reason_required"); diff --git a/server/src/services/paymentReceiptManualReview.js b/server/src/services/paymentReceiptManualReview.js index b11b8b81..006e7a71 100644 --- a/server/src/services/paymentReceiptManualReview.js +++ b/server/src/services/paymentReceiptManualReview.js @@ -16,7 +16,8 @@ function assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, maker requireFact(typeof makerId === "string" && makerId.length > 0 && typeof proposedBy === "string" && proposedBy.length > 0 && Array.isArray(participants) && participants.length > 0 && - participants.every((entry) => typeof entry?.ownerId === "string" && entry.ownerId.length > 0 && + participants.every((entry) => ((typeof entry?.ownerId === "string" && entry.ownerId.length > 0) || + (entry?.ownerId === null && entry.externalOwnerVerified === true)) && typeof entry.submitterId === "string" && entry.submitterId.length > 0), "receipt_review_identity_unresolved"); requireFact(![makerId, proposedBy, ...participants.flatMap((entry) => [entry.ownerId, entry.submitterId])] diff --git a/server/src/services/paymentReceiptVerification.js b/server/src/services/paymentReceiptVerification.js index 915b7c0e..f97f9496 100644 --- a/server/src/services/paymentReceiptVerification.js +++ b/server/src/services/paymentReceiptVerification.js @@ -32,7 +32,10 @@ function transactionFingerprint(config, receipt) { function batchCandidates(config, batch) { receiptKey(config); - requireFact(batch.entity_type === "reimbursement", "receipt_business_unsupported"); + requireFact(["reimbursement", "loan_repayment"].includes(batch.entity_type), "receipt_business_unsupported"); + if (batch.entity_type === "loan_repayment") requireFact(batch.loan_repayment_id && batch.items.length === 1 && + batch.items[0].entity_id === batch.loan_repayment_id && batch.items[0].currency_code === "CNY" && + batch.items[0].snapshot_json?.payee?.accountNameEncrypted, "receipt_business_unsupported"); const payer = batch.payment_account_snapshot_json; requireFact(payer?.bankInstitutionCode === "CCB", "receipt_bank_unsupported"); const payerAccount = decryptStringOrThrow(config, payer.accountNumberEncrypted); diff --git a/server/src/services/paymentResponsibility.js b/server/src/services/paymentResponsibility.js new file mode 100644 index 00000000..cc76f8b9 --- /dev/null +++ b/server/src/services/paymentResponsibility.js @@ -0,0 +1,61 @@ +const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions"); +const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); + +// Result codes are the delivery facts. A generic success may be a mock, a group +// webhook or a no-recipient skip; none means a personal WeCom message was sent. +function personalNotificationText(notifications) { + const attempts = notifications.flatMap((n) => n.deliveries ?? []); + if (attempts.some((d) => d.status === "success" && d.result_code === "wecom_app_sent")) { + // Shared dispatch stores a group attempt on one anchor notification. With + // multiple intended recipients it cannot establish delivery to each person. + const recipients = new Set(notifications.map((n) => n.recipient_employee_id).filter(Boolean)); + return recipients.size > 1 || notifications.length > 1 + ? "存在企微个人消息发送成功记录,接收范围需核对(不代表已读)" + : "企微个人消息已发送(不代表已读)"; + } + if (attempts.some((d) => d.result_code === "wecom_app_no_bound_recipients")) return "企微个人消息未发送:接收人未绑定企微"; + if (attempts.some((d) => d.status === "failed")) return "企微通知发送失败,请核对通知记录"; + if (attempts.some((d) => d.result_code === "wecom_sent")) return "仅有企微群通知记录,未确认个人消息送达"; + if (attempts.some((d) => d.result_code === "mock_sent")) return "仅有模拟通知记录,未发送企微个人消息"; + return "暂无企微个人消息发送成功记录"; +} + +async function loadPendingPaymentResponsibility(tx, batch) { + const reviewing = batch.status === "draft"; + if (!reviewing && !["reviewed", "failed"].includes(batch.status)) return null; + const excludedIds = new Set([reviewing ? batch.created_by : batch.reviewed_by].filter(Boolean)); + if (reviewing && batch.entity_type === "loan_repayment") { + const repayment = await tx.loanRepayment.findUnique({ where: { id: batch.loan_repayment_id }, + select: { submitted_by: true, lender: { select: { employee_id: true } } } }); + if (!repayment) return null; + excludedIds.add(repayment.submitted_by); + if (repayment.lender.employee_id) excludedIds.add(repayment.lender.employee_id); + } + const roles = reviewing ? ["manager", "ceo"] : ["finance", "finance_mgr"]; + const permission = reviewing ? "payment.batch.review" : "payment.batch.manage"; + const candidates = await tx.employee.findMany({ where: { status: "active", archived_at: null, + id: { notIn: [...excludedIds] }, roles: { some: { role_code: { in: roles } } } }, + orderBy: [{ employee_no: "asc" }, { id: "asc" }], + select: { id: true, name: true, status: true, archived_at: true, roles: { select: { role_code: true } } }, + }); + const eligible = candidates.filter((person) => !excludedIds.has(person.id) && person.status === "active" && + !person.archived_at && person.roles.some((role) => roles.includes(role.role_code)) && + hasPermission(resolvePermissionsForRoles(person.roles.map((r) => r.role_code)), permission)); + const notifications = await tx.notification.findMany({ where: { + target_type: "payment_batch", target_id: batch.id, + type: reviewing ? NOTIFICATION_TYPES.paymentBatchReviewRequested : NOTIFICATION_TYPES.paymentBatchExecutionRequested, + }, select: { + recipient_employee_id: true, recipient: { select: { name: true } }, + deliveries: { select: { status: true, result_code: true }, orderBy: [{ attempted_at: "desc" }, { id: "desc" }] }, + }, orderBy: [{ created_at: "desc" }, { id: "desc" }] }); + const names = [...new Set(eligible.map((person) => person.name).filter(Boolean))]; + const notified = [...new Set(notifications.map((n) => n.recipient?.name).filter(Boolean))]; + return { + stageKey: reviewing ? "payment_batch_review" : "payment_and_receipt", + actorName: names.length ? `可${reviewing ? "复核" : "办理"}:${names.join("、")}` : null, + detail: [names.length ? null : "当前没有符合条件的处理人,请核对在职状态和业务角色", + notified.length ? `站内提醒对象:${notified.join("、")}` : "暂无站内提醒记录", personalNotificationText(notifications)].filter(Boolean).join(";"), + }; +} + +module.exports = { personalNotificationText, loadPendingPaymentResponsibility }; diff --git a/server/src/services/presenceHistory.js b/server/src/services/presenceHistory.js index 1a81846d..941cf898 100644 --- a/server/src/services/presenceHistory.js +++ b/server/src/services/presenceHistory.js @@ -1,4 +1,4 @@ -const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); const HISTORY_STATUS_VALUES = ["office", "remote", "out", "travel", "leave", "unspecified"]; @@ -7,15 +7,6 @@ const PRESENCE_TIME_RANGE_VALUES = ["full_day", "morning", "afternoon"]; const HISTORY_DEFAULT_DAYS = 7; const HISTORY_MAX_DAYS = 31; -function buildPresenceValidationError(details, statusCode = 400) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function formatDateOnly(value) { if (!value) { return null; @@ -54,12 +45,12 @@ function resolvePresenceHistoryRange(query, timeZone) { const start = parseDateOnlyString(dateFrom); const end = parseDateOnlyString(dateTo); if (start > end) { - throw buildPresenceValidationError({ dateTo: "must_be_after_or_equal_dateFrom" }); + throw buildBusinessValidationError("presence_history.date_range_invalid", { dateTo: "must_be_after_or_equal_dateFrom" }); } const totalDays = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1; if (totalDays > HISTORY_MAX_DAYS) { - throw buildPresenceValidationError({ dateRange: "too_large" }); + throw buildBusinessValidationError("presence_history.date_range_too_large", { dateRange: "too_large" }); } return { @@ -391,7 +382,6 @@ module.exports = { buildPresenceHistoryState, buildPresenceState, buildPresenceSummary, - buildPresenceValidationError, buildTeamPresenceHistory, buildTeamPresenceSummary, filterHistoryItemsByStatus, diff --git a/server/src/services/purchaseBudget.js b/server/src/services/purchaseBudget.js index 860e6615..c14cecdb 100644 --- a/server/src/services/purchaseBudget.js +++ b/server/src/services/purchaseBudget.js @@ -1,13 +1,5 @@ -const { AppError } = require("../utils/AppError"); - -function buildPurchaseBudgetValidationError(details, statusCode = 409) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { lockBudgetPoolsForUpdate } = require("./budgetPool"); function toMoneyNumber(value) { const numeric = Number(value ?? 0); @@ -113,12 +105,13 @@ async function reserveBudgetForEntity(tx, { return { applied: false, reservedAmount: "0.00" }; } + await lockBudgetPoolsForUpdate(tx, [budgetPoolId]); const existingEntries = await listEntityBudgetEntries(tx, { entityType, entityId }); const currentPoolSummary = summarizeBudgetLedgerByPool(existingEntries) .find((item) => item.budgetPoolId === budgetPoolId); if ((currentPoolSummary?.outstanding ?? 0) > 0) { - throw buildPurchaseBudgetValidationError({ budget: "reserve_already_exists" }); + throw buildBusinessValidationError("purchase_budget.reserve_already_exists", { budget: "reserve_already_exists" }, 409); } await appendBudgetLedgerEntry(tx, { @@ -137,7 +130,7 @@ async function reserveBudgetForEntity(tx, { }); if (!budgetPool) { - throw buildPurchaseBudgetValidationError({ budgetPoolId: "not_found" }); + throw buildBusinessValidationError("purchase_budget.pool_not_found", { budgetPoolId: "not_found" }, 409); } const nextUsedAmount = toMoneyNumber(budgetPool.used_amount) + normalizedAmount; @@ -198,7 +191,16 @@ async function releaseReservedBudgetIfAny(tx, { actorId, note, }) { + const initial = await listEntityBudgetEntries(tx, { entityType, entityId }); + const poolIds = [...new Set(initial.map(entry => entry.budget_pool_id).filter(Boolean))]; + if (!poolIds.length) return { applied: false, items: [] }; + await lockBudgetPoolsForUpdate(tx, poolIds); const entries = await listEntityBudgetEntries(tx, { entityType, entityId }); + // Entity locking by the caller prevents new pool assignments during release. + // Never acquire an additional out-of-order pool lock if that contract breaks. + if (entries.some(entry => entry.budget_pool_id && !poolIds.includes(entry.budget_pool_id))) { + throw buildBusinessValidationError("budget.pool_changed", { budget: "pool_changed" }, 409); + } const grouped = summarizeBudgetLedgerByPool(entries); const released = []; @@ -246,7 +248,6 @@ async function releaseReservedBudgetIfAny(tx, { } module.exports = { - buildPurchaseBudgetValidationError, summarizeBudgetLedgerByPool, reserveBudgetForEntity, confirmBudgetForEntity, diff --git a/server/src/services/travelPayments.js b/server/src/services/travelPayments.js index ad9857aa..85e70794 100644 --- a/server/src/services/travelPayments.js +++ b/server/src/services/travelPayments.js @@ -1,16 +1,8 @@ const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { toMoneyString } = require("../utils/workflow"); const { summarizeBudgetLedgerByPool } = require("./purchaseBudget"); -const { buildTravelOrderPaymentJournalPreview } = require("./integrations/erpJournal"); - -function buildTravelPaymentValidationError(details, statusCode = 409) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} +const { lockBudgetPoolsForUpdate } = require("./budgetPool"); function toMoneyNumber(value) { const numeric = Number(value ?? 0); @@ -152,7 +144,7 @@ async function applyBudgetPoolUsageDelta(tx, { budgetPoolId, deltaAmount, actorI }); if (!budgetPool) { - throw buildTravelPaymentValidationError({ budgetPoolId: "not_found" }); + throw buildBusinessValidationError("travel_payment.budget_pool_not_found", { budgetPoolId: "not_found" }, 409); } const nextUsedAmount = Math.max(0, toMoneyNumber(budgetPool.used_amount) + toMoneyNumber(deltaAmount)); @@ -181,6 +173,7 @@ async function authorizeBudgetForEntity(tx, { return { applied: false, authorizedAmount: "0.00", duplicated: false }; } + await lockBudgetPoolsForUpdate(tx, [budgetPoolId]); const existingEntries = await listEntityBudgetEntries(tx, { entityType: "card_authorization", entityId, @@ -281,6 +274,7 @@ async function reverseBudgetForEntity(tx, { return { applied: false, reversedAmount: "0.00", duplicated: false }; } + await lockBudgetPoolsForUpdate(tx, [budgetPoolId]); const existingEntries = await listEntityBudgetEntries(tx, { entityType: "card_transaction", entityId, @@ -644,14 +638,6 @@ async function buildTravelPaymentTrack(prisma, travelRequestId, { canManagePayme transactionsByOrderId.set(transaction.travel_order_id, items); } - const orderStages = await Promise.all( - request.travel_orders.map(async (order) => { - const preview = await buildTravelOrderPaymentJournalPreview(prisma, order); - return [order.id, preview]; - }), - ); - const orderStageMap = new Map(orderStages); - const anomalies = request.card_transactions.flatMap((transaction) => buildTransactionAnomalies(transaction, transaction.travel_order_id ? ordersById.get(transaction.travel_order_id) : null), ); @@ -669,18 +655,6 @@ async function buildTravelPaymentTrack(prisma, travelRequestId, { canManagePayme transactions: request.card_transactions.map((item) => mapTransaction(item, ordersById)), travelOrders: request.travel_orders.map((order) => { const matchedTransactions = (transactionsByOrderId.get(order.id) ?? []).map((item) => mapTransaction(item, ordersById)); - const stage = orderStageMap.get(order.id) ?? { - journal: null, - exportState: { - externalId: null, - lastOutboxId: null, - lastOutboxStatus: null, - lastError: null, - exportedAt: null, - }, - binding: null, - outbox: null, - }; return { id: order.id, @@ -696,11 +670,6 @@ async function buildTravelPaymentTrack(prisma, travelRequestId, { canManagePayme paidBy: order.paid_by ?? null, boundInvoices: orderInvoicesMap.get(order.id) ?? [], matchedTransactions, - erpStage: { - binding: stage.binding, - outbox: stage.outbox, - exportState: stage.exportState, - }, actions: { canMarkPaid: canManagePayments && order.status === "confirmed", canBindInvoice: canManagePayments && ["confirmed", "paid"].includes(order.status), @@ -720,6 +689,5 @@ module.exports = { reverseBudgetForEntity, summarizeCorporateBudgetEntries, buildTravelPaymentTrack, - buildTravelPaymentValidationError, isOrderAmountAndCurrencyMatched, }; diff --git a/server/src/services/travelReconciliation.js b/server/src/services/travelReconciliation.js index eadab6bb..74be689e 100644 --- a/server/src/services/travelReconciliation.js +++ b/server/src/services/travelReconciliation.js @@ -1,4 +1,5 @@ const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); const TRAVEL_RECONCILIATION_BATCH_STATUSES = Object.freeze({ draft: "draft", @@ -25,15 +26,6 @@ const TRAVEL_RECONCILIATION_RESOLUTION_STATUSES = Object.freeze({ confirmed: "confirmed", }); -function buildValidationError(details, statusCode = 409) { - return new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode, - details, - }); -} - function toMoneyNumber(value) { const numeric = Number(value ?? 0); if (!Number.isFinite(numeric)) { @@ -677,38 +669,37 @@ async function loadTravelReconciliationBatchDetail(prisma, batchId, options = {} items: batch.items.map((item) => mapBatchItem(item, { canManagePayments: options.canManagePayments, batchStatus: batch.status })), permissions: { canManagePayments: Boolean(options.canManagePayments), - canRetryErp: Boolean(options.canRetryErp), }, }; } function ensureDraftBatch(batch) { if (batch.status !== TRAVEL_RECONCILIATION_BATCH_STATUSES.draft) { - throw buildValidationError({ batch: "reconciliation_batch_not_editable" }); + throw buildBusinessValidationError("travel_reconciliation.batch_not_editable", { batch: "reconciliation_batch_not_editable" }, 409); } } function ensurePendingReconciliationItem(item) { if (item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending) { - throw buildValidationError({ item: "reconciliation_item_not_pending" }); + throw buildBusinessValidationError("travel_reconciliation.item_not_pending", { item: "reconciliation_item_not_pending" }, 409); } } function ensureConfirmedReconciliationItem(item) { if (item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed) { - throw buildValidationError({ item: "reconciliation_item_not_confirmed" }); + throw buildBusinessValidationError("travel_reconciliation.item_not_confirmed", { item: "reconciliation_item_not_confirmed" }, 409); } } function ensureReopenableReconciliationItem(item) { if (item.match_status === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched) { - throw buildValidationError({ item: "matched_item_not_reopenable" }); + throw buildBusinessValidationError("travel_reconciliation.matched_item_not_reopenable", { item: "matched_item_not_reopenable" }, 409); } } function ensureBatchFinalizable(items = []) { if (items.some((item) => item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed)) { - throw buildValidationError({ batch: "reconciliation_batch_has_pending_items" }); + throw buildBusinessValidationError("travel_reconciliation.batch_has_pending_items", { batch: "reconciliation_batch_has_pending_items" }, 409); } } @@ -721,7 +712,6 @@ module.exports = { BATCH_DETAIL_SELECT, buildReconciliationItemsSnapshot, buildTravelReconciliationSummary, - buildValidationError, ensureBatchFinalizable, ensureConfirmedReconciliationItem, ensureDraftBatch, diff --git a/server/src/services/workspaceSettings.js b/server/src/services/workspaceSettings.js index 93053f6f..cf8896e7 100644 --- a/server/src/services/workspaceSettings.js +++ b/server/src/services/workspaceSettings.js @@ -17,7 +17,7 @@ const FINANCE_ROLE_CODES = new Set(["finance", "finance_mgr"]); const PAYMENT_REVIEWER_ROLE_CODES = new Set(["manager", "ceo"]); const TEAM_NAVIGATION_ROLE_CODES = new Set(["manager", "ceo", "finance_mgr", "hr", "admin"]); const FINANCE_PERMISSION_CODES = Object.freeze(["payment.batch.manage", "risk.review.manage", "expense.finance.pay"]); -const FINANCE_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations", "invoicePool"]); +const FINANCE_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations", "companyLoans", "invoicePool"]); const PAYMENT_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations"]); const DAY_TO_DAY_BUSINESS_MODULES = new Set(["assets", "contractsRegistry", "purchaseRequests", "projects"]); const MANAGEMENT_CHILD_MODULES = new Set([ @@ -52,6 +52,7 @@ const NAVIGATION_SECTION_ORDER = Object.freeze({ "projects", "assets", "paymentBatches", + "companyLoans", "apOperations", "invoicePool", "team", @@ -101,6 +102,7 @@ const NAVIGATION_REGISTRY = Object.freeze({ travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] }, budgets: { key: "budgets", label: "预算管理", icon: "account_balance", href: "/budgets", anyOf: ["budget.view", "budget.manage"] }, paymentBatches: { key: "paymentBatches", label: "付款", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch }, + companyLoans: { key: "companyLoans", label: "公司借款", icon: "account_balance", href: "/company-loans", anyOf: ["loan.manage"] }, apOperations: { key: "apOperations", label: "应付账款", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["finance.ledger.view"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch }, invoicePool: { key: "invoicePool", label: "票据", icon: "receipt_long", href: "/invoice-pool", anyOf: ["invoice.manage"] }, inventoryChecks: { key: "inventoryChecks", label: "资产盘点", icon: "inventory", href: "/inventory-checks", anyOf: ["asset.inventory"] }, @@ -140,6 +142,7 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({ travelRequests: "admin", budgets: "admin", paymentBatches: "admin", + companyLoans: "admin", apOperations: "admin", invoicePool: "admin", inventoryChecks: "admin", @@ -343,6 +346,7 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission }) continue; } + if (moduleKey === "companyLoans" && !employee?.roleCodes?.some((r) => FINANCE_ROLE_CODES.has(r))) continue; const financeNavigation = hasFinanceRole(hasPermission, employee); const paymentNavigation = financeNavigation || hasPaymentReviewerRole(hasPermission, employee); if ( diff --git a/server/src/utils/workflow.js b/server/src/utils/workflow.js index 620735b3..6bab771b 100644 --- a/server/src/utils/workflow.js +++ b/server/src/utils/workflow.js @@ -1,4 +1,3 @@ -const { AppError } = require("./AppError"); const { buildBusinessValidationError } = require("./businessValidationError"); const FINANCE_OWNER_APPROVAL_STEP_ID = "approval_1_ceo_finance_owner"; @@ -35,12 +34,7 @@ const OWNER_SELF_FALLBACK_REASONS = new Set([ function parseWorkflowSteps(definitionJson) { const steps = definitionJson?.steps; if (!Array.isArray(steps) || steps.length === 0) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { workflow: "invalid_steps" }, - }); + throw buildBusinessValidationError("workflow.steps_invalid", { workflow: "invalid_steps" }, 400); } return steps; @@ -55,22 +49,12 @@ function assertBusinessApprovalRoleCode(roleCode) { return roleCode; } - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { workflow: "unsupported_business_approval_role" }, - }); + throw buildBusinessValidationError("workflow.business_role_unsupported", { workflow: "unsupported_business_approval_role" }, 400); } function buildWorkflowStepsFromApprovalRule(approvalRule) { if (!approvalRule || typeof approvalRule !== "object") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { workflow: "invalid_approval_rule" }, - }); + throw buildBusinessValidationError("workflow.approval_rule_invalid", { workflow: "invalid_approval_rule" }, 400); } const roleCodes = [ @@ -80,12 +64,7 @@ function buildWorkflowStepsFromApprovalRule(approvalRule) { ].filter(Boolean); if (roleCodes.length === 0) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { workflow: "empty_approval_chain" }, - }); + throw buildBusinessValidationError("workflow.approval_chain_empty", { workflow: "empty_approval_chain" }, 400); } roleCodes.forEach(assertBusinessApprovalRoleCode); @@ -179,24 +158,14 @@ function assertOwnerSelfFallbackRole(roleCode, ownerRoleCodes) { } if (!SUPPORTED_OWNER_SELF_FALLBACK_ROLES.has(roleCode)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { ownerSelfFallbackRole: "unsupported_business_approval_role" }, - }); + throw buildBusinessValidationError("workflow.fallback_role_unsupported", { ownerSelfFallbackRole: "unsupported_business_approval_role" }, 400); } if (ownerRoleCodes?.has(roleCode)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { + throw buildBusinessValidationError("workflow.fallback_role_is_self", { workflow: "no_actionable_approver", reason: "owner_self_fallback_self_role", - }, - }); + }, 409); } return roleCode; @@ -291,16 +260,11 @@ async function buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, submit const hasHolder = await hasActiveNonSelfRoleHolder(tx, fallbackRole, ownerId); if (!hasHolder) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { + throw buildBusinessValidationError("workflow.fallback_holder_unavailable", { workflow: "no_actionable_approver", reason: "owner_self_fallback_role_unavailable", fallbackRole, - }, - }); + }, 409); } if (!seenRoleCodes.has(fallbackRole)) { @@ -317,15 +281,10 @@ async function buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, submit } if (normalizedSteps.length === 0) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { + throw buildBusinessValidationError("workflow.fallback_chain_empty", { workflow: "no_actionable_approver", reason: "owner_self_fallback_empty_chain", - }, - }); + }, 409); } return normalizedSteps; @@ -423,22 +382,12 @@ async function resolveDepartmentManagerAssignee({ tx, ownerId, logger }) { async function resolveAssigneeFromRule(rule, options = {}) { if (!rule || typeof rule !== "object") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { workflow: "invalid_assignee_rule" }, - }); + throw buildBusinessValidationError("workflow.assignee_rule_invalid", { workflow: "invalid_assignee_rule" }, 400); } if (rule.type === "role") { if (typeof rule.value !== "string" || !rule.value) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { workflow: "invalid_role_assignee" }, - }); + throw buildBusinessValidationError("workflow.role_assignee_invalid", { workflow: "invalid_role_assignee" }, 400); } assertBusinessApprovalRoleCode(rule.value); @@ -452,12 +401,7 @@ async function resolveAssigneeFromRule(rule, options = {}) { if (rule.type === "specific_user") { if (typeof rule.value !== "string" || !UUID_REGEX.test(rule.value)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { workflow: "invalid_specific_user_assignee" }, - }); + throw buildBusinessValidationError("workflow.user_assignee_invalid", { workflow: "invalid_specific_user_assignee" }, 400); } return { @@ -473,12 +417,7 @@ async function resolveAssigneeFromRule(rule, options = {}) { const tx = options.tx; if (!ownerId || !UUID_REGEX.test(ownerId) || !tx) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { workflow: "invalid_department_manager_context" }, - }); + throw buildBusinessValidationError("workflow.manager_context_invalid", { workflow: "invalid_department_manager_context" }, 400); } const managerResult = await resolveDepartmentManagerAssignee({ @@ -504,12 +443,7 @@ async function resolveAssigneeFromRule(rule, options = {}) { }; } - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { workflow: "unsupported_assignee_rule_type" }, - }); + throw buildBusinessValidationError("workflow.assignee_type_unsupported", { workflow: "unsupported_assignee_rule_type" }, 400); } async function resolveActionableStep({ @@ -544,16 +478,11 @@ async function resolveActionableStep({ if (resolved.skipReason) { if (blockOnUnresolvedAssignee && BLOCKING_ASSIGNEE_SKIP_REASONS.has(resolved.skipReason)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { + throw buildBusinessValidationError("workflow.manager_unavailable", { workflow: "no_actionable_approver", reason: resolved.skipReason, stepId: step.id, - }, - }); + }, 409); } skipped.push({ @@ -565,12 +494,7 @@ async function resolveActionableStep({ continue; } - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { workflow: "invalid_resolved_assignee" }, - }); + throw buildBusinessValidationError("workflow.assignee_resolution_invalid", { workflow: "invalid_resolved_assignee" }, 400); } return { diff --git a/server/tests/unit/businessNumbering.service.test.js b/server/tests/unit/businessNumbering.service.test.js new file mode 100644 index 00000000..0c891279 --- /dev/null +++ b/server/tests/unit/businessNumbering.service.test.js @@ -0,0 +1,164 @@ +const { BUSINESS_NUMBER_FAMILIES, businessDateAt, resolveNumberingContext, issueBusinessNumber, + reserveProjectNumber, setNumberingPolicy } = require("../../src/services/businessNumbering"); +const { issueDocumentNumber } = require("../../src/services/documentSequence"); +const NOW = new Date("2026-09-30T16:00:00Z"); +const ACTOR = "11111111-1111-4111-8111-111111111111"; +function fixture({ policy = null, maximum = 0n } = {}) { + const counters = new Map(); + const tx = { + $executeRaw: vi.fn(async () => 0), + $queryRaw: vi.fn(async () => [{ maximum }]), + businessNumberPolicy: { + findUnique: vi.fn(async () => policy), + create: vi.fn(async ({ data }) => (policy = { ...data })), + update: vi.fn(async ({ data }) => (policy = { ...policy, ...data })), + }, + businessNumberCounter: { + findUnique: vi.fn(async ({ where }) => counters.get(where.key) ?? null), + upsert: vi.fn(async ({ where, create, update }) => { + const value = { ...(counters.get(where.key) ?? create), ...update, updated_at: NOW }; + counters.set(where.key, value); return value; + }), + }, + documentSequence: { findUnique: vi.fn(async () => null), upsert: vi.fn(async ({ create }) => create) }, + documentCategory: { findUnique: vi.fn(async () => null), findFirst: vi.fn(async () => null) }, + auditLog: { create: vi.fn(async ({ data }) => data) }, + }; + return { tx, counters }; +} +const issue = (tx, family, now = NOW, rest = {}) => issueBusinessNumber(tx, { family, now, ...rest }); +describe("business numbering v2", () => { + it.each([ + ["reimbursement", "BX-202610-0001"], ["payment_batch", "PB-2026-0001"], + ["company_loan", "JK-2026-0001"], ["loan_repayment", "HK-2026-0001"], + ["purchase_request", "CG-2026-0001"], ["inventory_check", "PD-2026-0001"], + ["project", "PRJ-2026-0001"], ["asset_card", "FA-0001"], + ])("issues %s with its approved period", async (family, expected) => { + const { tx } = fixture(); + expect((await issue(tx, family)).documentNumber).toBe(expected); + expect((await issue(tx, family)).documentNumber).toBe(expected.replace("0001", "0002")); + expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(tx.$queryRaw.mock.invocationCallOrder[0]); + expect(tx.$executeRaw.mock.calls[0].slice(1)).toEqual([1004, BUSINESS_NUMBER_FAMILIES[family].lockKey]); + }); + it("uses one instant and configured timezone at month and year boundaries", async () => { + const { tx } = fixture(); + expect((await issue(tx, "reimbursement", new Date("2026-09-30T15:59:59Z"))).documentNumber).toBe("BX-202609-0001"); + expect((await issue(tx, "reimbursement")).documentNumber).toBe("BX-202610-0001"); + expect((await issue(tx, "reimbursement", NOW, { timeZone: "Pacific/Honolulu" })).documentNumber).toBe("BX-202609-0002"); + expect((await issue(tx, "payment_batch", new Date("2026-12-31T15:59:59Z"))).documentNumber).toBe("PB-2026-0001"); + expect((await issue(tx, "payment_batch", new Date("2026-12-31T16:00:00Z"))).documentNumber).toBe("PB-2027-0001"); + expect((await issue(tx, "asset_card", new Date("2027-01-01T00:00:00Z"))).documentNumber).toBe("FA-0001"); + expect((await issue(tx, "asset_card", new Date("2028-01-01T00:00:00Z"))).documentNumber).toBe("FA-0002"); + expect(businessDateAt(NOW)).toBe("2026-10-01"); + }); + it("keeps EA categories independent and counters shared for the same prefix", async () => { + const { tx } = fixture(); + for (const [prefix, serial] of [["EA-DOC", "0001"], ["EA-SYS", "0001"], ["EA-DOC", "0002"]]) { + expect((await issue(tx, "asset_card", NOW, { prefix })).documentNumber).toBe(`${prefix}-${serial}`); + } + }); + it("continues after matching occupied numbers and expands beyond four digits", async () => { + const { tx } = fixture({ maximum: 9999n }); + expect((await issue(tx, "payment_batch")).documentNumber).toBe("PB-2026-10000"); + tx.$queryRaw.mockResolvedValue([{ maximum: 0n }]); + expect((await issue(tx, "payment_batch")).documentNumber).toBe("PB-2026-10001"); + }); + it("never rounds large counters and fails at bigint capacity", async () => { + const { tx } = fixture({ maximum: 9007199254740992n }); + const result = await issue(tx, "asset_card"); + expect(result.documentNumber).toBe("FA-9007199254740993"); + expect(result.config.currentNumber).toBe("9007199254740993"); + tx.$queryRaw.mockResolvedValue([{ maximum: 9223372036854775807n }]); + await expect(issue(tx, "asset_card")).rejects.toThrow("capacity exhausted"); + expect(tx.businessNumberCounter.upsert).toHaveBeenCalledTimes(1); + }); + it("reserves manual project codes without renaming them or activating policy", async () => { + const { tx } = fixture(); + await reserveProjectNumber(tx, "CLIENT-FORMAL-A"); + expect(tx.$executeRaw).not.toHaveBeenCalled(); + await reserveProjectNumber(tx, "PRJ-2026-0010"); + await reserveProjectNumber(tx, "PRJ-2026-0002"); + expect((await issue(tx, "project")).documentNumber).toBe("PRJ-2026-0011"); + expect(tx.businessNumberPolicy.findUnique).not.toHaveBeenCalled(); + }); + it.each([null, { enabled: false, effective_at: NOW }, { enabled: true, effective_at: new Date(NOW.getTime() + 1) }])("keeps legacy without an effective active policy: %j", async (policy) => { + const { tx } = fixture({ policy }); + expect((await resolveNumberingContext(tx, { now: NOW })).version).toBe(1); + expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(tx.businessNumberPolicy.findUnique.mock.invocationCallOrder[0]); + }); + it("activates at the exact boundary but a pinned legacy or v2 draft keeps its version", async () => { + const { tx } = fixture({ policy: { enabled: true, effective_at: NOW } }); + expect((await resolveNumberingContext(tx, { now: NOW })).version).toBe(2); + expect((await resolveNumberingContext(tx, { version: 1, now: NOW })).version).toBe(1); + tx.businessNumberPolicy.findUnique.mockResolvedValue(null); + expect((await resolveNumberingContext(tx, { version: 2, now: NOW })).version).toBe(2); + await expect(resolveNumberingContext(tx, { version: 3 })).rejects.toThrow("Invalid numbering version"); + }); + it("keeps historical missing reimbursement numbers legacy and leaves old counters untouched for new drafts", async () => { + const { tx } = fixture({ policy: { enabled: true, effective_at: NOW } }); + const old = await issueDocumentNumber(tx, { documentType: "reimbursement", numberingVersion: 1, now: NOW }); + expect(old.documentNumber).toMatch(/^BX-\d{8}-0001$/); + expect(tx.businessNumberCounter.upsert).not.toHaveBeenCalled(); + tx.documentSequence.upsert.mockClear(); + const fresh = await issueDocumentNumber(tx, { documentType: "reimbursement", now: NOW }); + expect(fresh.documentNumber).toBe("BX-202610-0001"); + expect(tx.documentSequence.upsert).not.toHaveBeenCalled(); + }); + it("read-only config context acquires no locks or writes", async () => { + const { tx } = fixture(); await resolveNumberingContext(tx, { readOnly: true, now: NOW }); + expect(tx.$executeRaw).not.toHaveBeenCalled(); expect(tx.businessNumberPolicy.create).not.toHaveBeenCalled(); + }); + it("fails closed on a policy read failure rather than silently taking legacy", async () => { + const { tx } = fixture(); tx.businessNumberPolicy.findUnique.mockRejectedValue(new Error("synthetic")); + await expect(resolveNumberingContext(tx)).rejects.toThrow("synthetic"); + }); + it("persists the activation time through pause/resume and audits each transition", async () => { + const { tx } = fixture(); + const input = { actorId: ACTOR, reference: "synthetic-change-1", writersCompatible: true, now: NOW }; + const effectiveAt = new Date(NOW.getTime() + 1000); + await setNumberingPolicy(tx, { ...input, action: "activate", effectiveAt }); + await setNumberingPolicy(tx, { ...input, action: "pause" }); + const resumed = await setNumberingPolicy(tx, { ...input, action: "resume" }); + expect(resumed.effective_at).toEqual(effectiveAt); expect(resumed.enabled).toBe(true); + expect(tx.auditLog.create).toHaveBeenCalledTimes(3); + await expect(setNumberingPolicy(tx, { ...input, action: "activate", effectiveAt })).rejects.toThrow(); + }); + it("requires forward-only explicit activation and compatible writers", async () => { + const { tx } = fixture(); + const input = { action: "activate", actorId: ACTOR, reference: "synthetic-1", writersCompatible: true, now: NOW }; + await expect(setNumberingPolicy(tx, { ...input, effectiveAt: new Date(NOW.getTime() - 1) })).rejects.toThrow(); + await expect(setNumberingPolicy(tx, { ...input, effectiveAt: NOW, writersCompatible: false })).rejects.toThrow(); + expect(tx.businessNumberPolicy.create).not.toHaveBeenCalled(); + }); +}); + +it("rejects activation whose effective time elapsed while waiting for the policy lock", async () => { + vi.useFakeTimers(); vi.setSystemTime(NOW); + try { + const { tx } = fixture(); + tx.$executeRaw.mockImplementation(async () => { vi.setSystemTime(new Date(NOW.getTime() + 2000)); return 0; }); + await expect(setNumberingPolicy(tx, { action: "activate", effectiveAt: new Date(NOW.getTime() + 1000), + actorId: ACTOR, reference: "synthetic-wait", writersCompatible: true })).rejects.toThrow("future effective time"); + expect(tx.businessNumberPolicy.create).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalled(); + } finally { vi.useRealTimers(); } +}); + +it.each([["reimbursement", "YYYYMM"], ["purchase_request", "YYYY"], ["inventory_check", "YYYY"]])("refuses to enable independent counters over an active legacy period for %s", async (documentType, dateFormat) => { + const { tx } = fixture(); + tx.documentSequence.findUnique.mockImplementation(async ({ where }) => where.document_type === documentType ? { date_format: dateFormat } : null); + await expect(setNumberingPolicy(tx, { action: "activate", effectiveAt: new Date(NOW.getTime() + 1000), now: NOW, + actorId: ACTOR, reference: "synthetic-overlap", writersCompatible: true })).rejects.toThrow("compatibility review"); + expect(tx.businessNumberPolicy.create).not.toHaveBeenCalled(); + expect(tx.documentSequence.upsert).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalled(); +}); + +it("allows pause but refuses resume after an overlapping legacy configuration is introduced", async () => { + const { tx } = fixture({ policy: { enabled: true, effective_at: NOW } }); + tx.documentSequence.findUnique.mockResolvedValue({ date_format: "YYYYMM" }); + const input = { actorId: ACTOR, reference: "synthetic-overlap", writersCompatible: true }; + expect((await setNumberingPolicy(tx, { ...input, action: "pause" })).enabled).toBe(false); + await expect(setNumberingPolicy(tx, { ...input, action: "resume" })).rejects.toThrow("compatibility review"); + expect(tx.businessNumberPolicy.update).toHaveBeenCalledTimes(1); +}); diff --git a/server/tests/unit/documentSequence.service.test.js b/server/tests/unit/documentSequence.service.test.js index 1c21cbc2..d08361a5 100644 --- a/server/tests/unit/documentSequence.service.test.js +++ b/server/tests/unit/documentSequence.service.test.js @@ -5,7 +5,9 @@ const { function buildPrisma({ sequenceMap = {}, categoryMap = {} } = {}) { return { - documentSequence: { + businessNumberPolicy: { findUnique: vi.fn(async () => null) }, + $executeRaw: vi.fn(async () => 0), + documentSequence: { findUnique: async ({ where }) => sequenceMap[where.document_type] ?? null, upsert: async ({ where, update, create }) => { const current = sequenceMap[where.document_type]; diff --git a/server/tests/unit/featureFlags.evaluate.test.js b/server/tests/unit/featureFlags.evaluate.test.js index 4fe81457..1c2f6c14 100644 --- a/server/tests/unit/featureFlags.evaluate.test.js +++ b/server/tests/unit/featureFlags.evaluate.test.js @@ -39,8 +39,8 @@ describe("evaluateFeatureFlag", () => { it("returns env_hard_off when env gate is closed", async () => { const result = await evaluateFeatureFlag({ prisma: buildPrisma(), - config: { erpSyncEnabled: false, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, - flagKey: FEATURE_FLAG_KEYS.erpSync, + config: { paymentBatchEnabled: false, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, + flagKey: FEATURE_FLAG_KEYS.paymentBatch, }); expect(result.enabled).toBe(false); @@ -50,8 +50,8 @@ describe("evaluateFeatureFlag", () => { it("returns missing_flag_record when record does not exist", async () => { const result = await evaluateFeatureFlag({ prisma: buildPrisma(), - config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, - flagKey: FEATURE_FLAG_KEYS.erpSync, + config: { paymentBatchEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, + flagKey: FEATURE_FLAG_KEYS.paymentBatch, }); expect(result.enabled).toBe(false); @@ -60,16 +60,16 @@ describe("evaluateFeatureFlag", () => { it("returns flag_force_off when override mode is force_off", async () => { const prisma = buildPrisma({ - [FEATURE_FLAG_KEYS.erpSync]: buildRecord({ - flagKey: FEATURE_FLAG_KEYS.erpSync, + [FEATURE_FLAG_KEYS.paymentBatch]: buildRecord({ + flagKey: FEATURE_FLAG_KEYS.paymentBatch, overrideMode: "force_off", }), }); const result = await evaluateFeatureFlag({ prisma, - config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, - flagKey: FEATURE_FLAG_KEYS.erpSync, + config: { paymentBatchEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, + flagKey: FEATURE_FLAG_KEYS.paymentBatch, }); expect(result.enabled).toBe(false); @@ -78,8 +78,8 @@ describe("evaluateFeatureFlag", () => { it("returns global enabled when inherit + global", async () => { const prisma = buildPrisma({ - [FEATURE_FLAG_KEYS.erpSync]: buildRecord({ - flagKey: FEATURE_FLAG_KEYS.erpSync, + [FEATURE_FLAG_KEYS.paymentBatch]: buildRecord({ + flagKey: FEATURE_FLAG_KEYS.paymentBatch, overrideMode: "inherit", rolloutType: "global", }), @@ -87,8 +87,8 @@ describe("evaluateFeatureFlag", () => { const result = await evaluateFeatureFlag({ prisma, - config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, - flagKey: FEATURE_FLAG_KEYS.erpSync, + config: { paymentBatchEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, + flagKey: FEATURE_FLAG_KEYS.paymentBatch, }); expect(result.enabled).toBe(true); @@ -97,8 +97,8 @@ describe("evaluateFeatureFlag", () => { it("returns allowlist guards for department rollout", async () => { const prisma = buildPrisma({ - [FEATURE_FLAG_KEYS.erpSync]: buildRecord({ - flagKey: FEATURE_FLAG_KEYS.erpSync, + [FEATURE_FLAG_KEYS.paymentBatch]: buildRecord({ + flagKey: FEATURE_FLAG_KEYS.paymentBatch, rolloutType: "department_allowlist", departmentIds: ["dept-a"], }), @@ -106,23 +106,23 @@ describe("evaluateFeatureFlag", () => { const missingDepartment = await evaluateFeatureFlag({ prisma, - config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, - flagKey: FEATURE_FLAG_KEYS.erpSync, + config: { paymentBatchEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, + flagKey: FEATURE_FLAG_KEYS.paymentBatch, strictDepartment: true, }); const blockedDepartment = await evaluateFeatureFlag({ prisma, - config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, - flagKey: FEATURE_FLAG_KEYS.erpSync, + config: { paymentBatchEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, + flagKey: FEATURE_FLAG_KEYS.paymentBatch, departmentId: "dept-b", strictDepartment: true, }); const allowedDepartment = await evaluateFeatureFlag({ prisma, - config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, - flagKey: FEATURE_FLAG_KEYS.erpSync, + config: { paymentBatchEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, + flagKey: FEATURE_FLAG_KEYS.paymentBatch, departmentId: "dept-a", strictDepartment: true, }); @@ -144,7 +144,7 @@ describe("evaluateFeatureFlag", () => { const result = await evaluateFeatureFlag({ prisma, - config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, + config: { paymentBatchEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, flagKey: FEATURE_FLAG_KEYS.invoiceVerification, strictDepartment: false, }); @@ -164,7 +164,7 @@ describe("evaluateFeatureFlag", () => { const result = await evaluateFeatureFlag({ prisma, - config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, + config: { paymentBatchEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, flagKey: FEATURE_FLAG_KEYS.invoiceVerification, strictDepartment: true, }); @@ -178,7 +178,7 @@ describe("feature flag env gates", () => { it("uses the dedicated leave holiday calendar env gate", async () => { const result = await evaluateFeatureFlag({ prisma: buildPrisma(), - config: { aiAssistEnabled: true, erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: false }, + config: { aiAssistEnabled: true, paymentBatchEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: false }, flagKey: FEATURE_FLAG_KEYS.leaveHolidayCalendar, }); @@ -189,7 +189,7 @@ describe("feature flag env gates", () => { it("uses the dedicated ai assist env gate", async () => { const result = await evaluateFeatureFlag({ prisma: buildPrisma(), - config: { aiAssistEnabled: false, erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, + config: { aiAssistEnabled: false, paymentBatchEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, flagKey: FEATURE_FLAG_KEYS.aiAssist, }); @@ -201,14 +201,14 @@ describe("feature flag env gates", () => { describe("listFeatureFlags", () => { it("returns all known keys even if part of records are missing", async () => { const prisma = buildPrisma({ - [FEATURE_FLAG_KEYS.erpSync]: buildRecord({ - flagKey: FEATURE_FLAG_KEYS.erpSync, + [FEATURE_FLAG_KEYS.paymentBatch]: buildRecord({ + flagKey: FEATURE_FLAG_KEYS.paymentBatch, overrideMode: "inherit", }), }); const items = await listFeatureFlags(prisma, { - erpSyncEnabled: true, + paymentBatchEnabled: true, invoiceVerificationEnabled: true, }); diff --git a/server/tests/unit/fixtures/deployment-command-client.py b/server/tests/unit/fixtures/deployment-command-client.py new file mode 100644 index 00000000..0fb140e1 --- /dev/null +++ b/server/tests/unit/fixtures/deployment-command-client.py @@ -0,0 +1,36 @@ +#!/usr/bin/python3 -IS +"""Private lifecycle-test commands; no real Docker, SSH or TCP connection.""" +import json +import os +import socket +import sys + + +def main(): + root = os.environ.get('HYB_DEPLOYMENT_FIXTURE_ROOT', '') + if not os.path.basename(root).startswith('hyb-db-lifecycle-') or os.path.dirname(__file__) != os.path.join(root, 'bin'): + raise ValueError('fixture_only') + with socket.socket(socket.AF_UNIX) as connection: + connection.connect(os.path.join(root, 'command.sock')) + with connection.makefile('rwb') as stream: + stream.write((json.dumps({'command': os.path.basename(__file__), 'args': sys.argv[1:]}) + '\n').encode()) + stream.flush() + reply = json.loads(stream.readline()) + if reply.get('action') == 'stdin': + stream.write((json.dumps(sys.stdin.read()) + '\n').encode()) + stream.flush() + reply = json.loads(stream.readline()) + if reply.get('action') == 'timeout' and os.path.basename(__file__) == 'timeout': + os.execv('/usr/bin/timeout', ['/usr/bin/timeout', *sys.argv[1:]]) + if 'action' in reply or type(reply.get('code')) is not int: + raise ValueError('invalid_reply') + sys.stdout.write(reply.get('stdout', '')) + sys.stderr.write(reply.get('stderr', '')) + return reply['code'] + + +try: + sys.exit(main()) +except Exception: + sys.stderr.write('[deployment-fixture] transport_failed\n') + sys.exit(97) diff --git a/server/tests/unit/fixtures/deployment-command-fake.cjs b/server/tests/unit/fixtures/deployment-command-fake.cjs index 4a43d709..d4fd2f52 100644 --- a/server/tests/unit/fixtures/deployment-command-fake.cjs +++ b/server/tests/unit/fixtures/deployment-command-fake.cjs @@ -1,6 +1,7 @@ const fs = require('node:fs'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); +const net = require('node:net'); +const { createInterface } = require('node:readline'); const GLOBAL_VALUES = new Set(['-p', '--project-name', '-f', '--file', '--env-file', '--profile']); const OPTION_VALUES = new Set(['--entrypoint', '-e', '--env', '--tail', '--format', '--type', '--timeout', '-t']); @@ -52,14 +53,13 @@ function lifecycleServices(call, services) { return [...result]; } -function main() { - const root = process.env.HYB_DEPLOYMENT_FIXTURE_ROOT; - if (!root || !path.basename(root).startsWith('hyb-db-lifecycle-') || path.dirname(process.argv[1]) !== path.join(root, 'bin')) { - throw new Error('fixture_only'); - } +const COMMANDS = new Set(['docker', 'docker-compose', 'curl', 'timeout', 'sleep', 'ssh', 'rsync', 'node', 'git', 'sudo']); +const result = (code = 0, stdout = '') => ({ code, stdout }); +const output = value => result(0, String(value) + '\n'); + +async function executeCommand(root, command, args, readStdin, signal) { + if (!COMMANDS.has(command) || !Array.isArray(args) || args.some(arg => typeof arg !== 'string')) throw new Error('fixture_only'); const config = JSON.parse(fs.readFileSync(path.join(root, 'config.json'), 'utf8')); - const command = path.basename(process.argv[1]); - const args = process.argv.slice(2); const event = { command, args }; if (command === 'docker-compose' || (command === 'docker' && args[0] === 'compose')) { event.compose = parseCompose(args); @@ -70,88 +70,111 @@ function main() { fs.writeFileSync(path.join(root, 'health-drift'), '1'); } if (command === 'timeout') { - const child = spawnSync('/usr/bin/timeout', args, { env: process.env, stdio: 'inherit' }); - process.exit(child.status ?? (child.signal === 'SIGKILL' ? 137 : 99)); + return { action: 'timeout' }; } - if (command === 'sleep') return; + if (command === 'sleep') return result(); if (command === 'curl') { if (args.join(' ') !== '-fsS http://127.0.0.1:31001/ready') throw new Error('unexpected_network_request'); - console.log(JSON.stringify({ data: { buildSha: '123456789abc' } })); - return; + return output(JSON.stringify({ data: { buildSha: '123456789abc' } })); } if (command === 'docker' && args[0] === 'inspect') { if (args.at(-1) === 'e'.repeat(64) && args.includes('{{.Config.Image}}')) { - console.log('synthetic-project-worker:latest'); - return; + return output('synthetic-project-worker:latest'); } if (args.at(-1) !== 'd'.repeat(64)) throw new Error('unexpected_container'); - if (config.health === 'inspect_error') process.exit(18); + if (config.health === 'inspect_error') return result(18); const counterFile = path.join(root, 'inspect-count'); const count = fs.existsSync(counterFile) ? Number(fs.readFileSync(counterFile, 'utf8')) : 0; fs.writeFileSync(counterFile, String(count + 1)); if (config.health === 'query_hang') { - setTimeout(() => {}, 20000); - return; + await new Promise(resolve => { + const finish = () => { clearTimeout(timer); signal.removeEventListener('abort', finish); resolve(); }; + const timer = setTimeout(finish, 20000); + signal.addEventListener('abort', finish, { once: true }); + if (signal.aborted) finish(); + }); + return result(); } const states = { stopped: 'exited|healthy', unhealthy: 'running|unhealthy', no_health: 'running|missing', starting: 'running|starting' }; const state = fs.existsSync(path.join(root, 'health-drift')) ? 'running|unhealthy' : config.health === 'warming' && count === 0 ? 'running|starting' : states[config.health] ?? 'running|healthy'; - console.log(state); - return; + return output(state); } - if (command === 'docker' && args.join(' ') === 'image inspect synthetic-project-worker:latest') process.exit(config.workerTagPresent ? 0 : 1); + if (command === 'docker' && args.join(' ') === 'image inspect synthetic-project-worker:latest') return result(config.workerTagPresent ? 0 : 1); const call = event.compose; if (!call) throw new Error('forbidden_external_command'); if (call.operation === 'version') { - process.exit(command === 'docker' && config.engine === 'v1' ? 1 : 0); + return result(command === 'docker' && config.engine === 'v1' ? 1 : 0); } if (call.globals['-p'] !== 'synthetic-project') throw new Error('wrong_compose_project'); - if (call.noDeps && config.unsupportedIsolation) process.exit(2); + if (call.noDeps && config.unsupportedIsolation) return result(2); if (LIFECYCLE.has(call.operation)) { if (call.operation === 'up' && call.targets.includes('db')) fs.writeFileSync(path.join(root, 'db-started'), '1'); if (call.operation === 'run') { if (call.targets.join() !== 'init' || call.containerArgs.join(' ') !== '-lc cd /app/server && npm run deploy:init' || - !args.includes('DEPLOY_DATA_MODE=' + process.env.DEPLOY_DATA_MODE)) { + !args.includes('DEPLOY_DATA_MODE=' + config.mode)) { throw new Error('unexpected_init_command'); } - if (config.initFails) process.exit(23); + if (config.initFails) return result(23); } - return; + return result(); } if (call.operation === 'ps') { - if (!call.targets.length) return; + if (!call.targets.length) return result(); if (call.targets.join() === 'worker') { - if (config.workerImagePresent) console.log('e'.repeat(64)); - return; + return config.workerImagePresent ? output('e'.repeat(64)) : result(); } if (call.targets.join() !== 'db') throw new Error('unexpected_ps_service'); - if (config.health === 'query_error') process.exit(17); - if (config.health === 'missing' && !fs.existsSync(path.join(root, 'db-started'))) return; - console.log(config.health === 'multiple' ? 'd'.repeat(64) + '\n' + 'e'.repeat(64) : 'd'.repeat(64)); - return; + if (config.health === 'query_error') return result(17); + if (config.health === 'missing' && !fs.existsSync(path.join(root, 'db-started'))) return result(); + return output(config.health === 'multiple' ? 'd'.repeat(64) + '\n' + 'e'.repeat(64) : 'd'.repeat(64)); } if (call.operation === 'port' && call.targets.join() === 'server' && call.containerArgs.join() === '3000') { - console.log('127.0.0.1:31001'); - return; + return output('127.0.0.1:31001'); } - if (call.operation === 'config') return; - if (call.operation === 'build' && ['init', 'server', 'worker'].includes(call.targets.join())) return; - if (call.operation === 'logs' && call.targets.join() === 'server') return; + if (call.operation === 'config') return result(); + if (call.operation === 'build' && ['init', 'server', 'worker'].includes(call.targets.join())) return result(); + if (call.operation === 'logs' && call.targets.join() === 'server') return result(); if (call.operation === 'exec' && call.targets.join() === 'server') { const expected = ['sh -lc cd /app/server && npx prisma migrate status', 'node src/scripts/smoke-external-deps.js', 'node src/scripts/smoke-trialrun-runtime.js']; - if (expected.includes(call.containerArgs.join(' '))) return; + if (expected.includes(call.containerArgs.join(' '))) return result(); if (call.containerArgs[0] === 'node' && call.containerArgs[1] === '-e' && call.containerArgs[2]?.includes('EXPECTED_BUILD_SHA')) { - const input = JSON.parse(fs.readFileSync(0, 'utf8')); + const input = JSON.parse(await readStdin()); if (input.data?.buildSha !== '123456789abc') throw new Error('wrong_ready_build'); - return; + return result(); } } throw new Error('unknown_fake_command'); } -module.exports = { parseCompose, lifecycleServices }; -if (require.main === module) { - try { main(); } catch (error) { - console.error('[deployment-fixture] ' + (/^[a-z_]+$/.test(error.message) ? error.message : 'unexpected_failure')); - process.exitCode = 97; - } +async function startCommandServer(root) { + if (!root || !path.basename(root).startsWith('hyb-db-lifecycle-')) throw new Error('fixture_only'); + const sockets = new Set(); + const server = net.createServer(async socket => { + sockets.add(socket); + const abort = new AbortController(); + socket.on('error', () => socket.destroy()); + socket.on('close', () => { sockets.delete(socket); abort.abort(); }); + const lines = createInterface({ input: socket, crlfDelay: Infinity }); + const reader = lines[Symbol.asyncIterator](); + try { + const request = JSON.parse((await reader.next()).value); + const reply = await executeCommand(root, request.command, request.args, async () => { + socket.write(JSON.stringify({ action: 'stdin' }) + '\n'); + const input = JSON.parse((await reader.next()).value); + if (typeof input !== 'string') throw new Error('invalid_stdin'); + return input; + }, abort.signal); + if (!socket.destroyed) socket.end(JSON.stringify(reply) + '\n'); + } catch (error) { + if (!socket.destroyed) socket.end(JSON.stringify({ code: 97, stderr: '[deployment-fixture] ' + (/^[a-z_]+$/.test(error.message) ? error.message : 'unexpected_failure') + '\n' }) + '\n'); + } finally { lines.close(); } + }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(path.join(root, 'command.sock'), resolve); }); + fs.chmodSync(path.join(root, 'command.sock'), 0o600); + return async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }; } + +module.exports = { parseCompose, lifecycleServices, startCommandServer }; diff --git a/server/tests/unit/fixtures/error-reason-messages.json b/server/tests/unit/fixtures/error-reason-messages.json index 2979b58f..f7153360 100644 --- a/server/tests/unit/fixtures/error-reason-messages.json +++ b/server/tests/unit/fixtures/error-reason-messages.json @@ -1,4 +1,615 @@ { + "integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。", + "loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。", + "loan.not_found": "借款记录不存在或已不可用,请刷新后重新选择。", + "loan.source_unavailable": "公司、银行或关联人员已停用,请核对后重新选择。", + "loan.identity_required": "请先核实出借人与 OA 账号的关联;非员工须明确确认未关联账号。", + "loan.version_changed": "借款身份、账户或余额已变更,请刷新后重新核对。", + "loan.account_unavailable": "借款收款资料暂不可用,请联系财务核对账户加密配置。", + "loan.duplicate_registration": "该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。", + "loan.confirmed_immutable": "借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。", + "loan.evidence_required": "请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。", + "loan.evidence_invalid": "借款材料不符合要求,请使用有效的 PDF 或图片文件。", + "loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。", + "loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。", + "loan.reviewer_required": "没有符合独立复核要求的经理或总经理;制单人、提交人和收款人不能复核。", + "loan.independent_review_required": "本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。", + "loan.bank_state_unsafe": "银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。", + "loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。", + "loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。", + "config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。", + "config.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。", + "document_sequence.type_unsupported": "当前单据类型不支持编号,请联系管理员核对。", + "document_sequence.category_missing": "指定的单据分类不存在,请重新选择分类。", + "document_sequence.category_type_mismatch": "单据分类与当前单据类型不匹配,请重新选择。", + "document_sequence.category_unavailable": "指定的单据分类已停用或归档,请选择可用分类。", + "workspace.update_input_invalid": "工作区设置不符合要求,请检查模块显示方式后保存。", + "confidential_ref.create_input_invalid": "资料索引信息不符合要求,请检查必填项和字段长度。", + "confidential_ref.update_input_invalid": "资料索引修改不符合要求,请检查要更新的字段。", + "confidential_ref.query_invalid": "资料索引查询条件不符合要求,请检查关联对象。", + "confidential_ref.feature_disabled": "当前未启用资料索引,请联系管理员确认。", + "cutover.id_invalid": "切换范围标识无效,请刷新列表后重新选择。", + "cutover.create_input_invalid": "切换范围信息不符合要求,请检查范围类型、标识和时间。", + "cutover.update_input_invalid": "切换范围修改不符合要求,请检查要更新的字段。", + "cutover.feature_disabled": "当前未启用切换控制台,请联系管理员确认。", + "cutover.scope_not_enabled": "当前业务尚未纳入允许操作的切换范围,请联系管理员确认。", + "migration_run.id_invalid": "迁移检查记录标识无效,请刷新列表后重新选择。", + "migration_run.file_required": "请选择用于迁移预检查的文件。", + "migration_run.type_invalid": "迁移检查类型无效,请重新选择检查类型。", + "feature_flag.version_conflict": "功能设置已被其他操作更新,请刷新后核对再保存。", + "feature_flag.key_invalid": "功能标识无效,请刷新功能设置列表后重新选择。", + "feature_flag.page_invalid": "功能设置历史查询条件不符合要求,请检查分页。", + "feature_flag.update_input_invalid": "功能设置不符合要求,请检查覆盖模式、范围和版本。", + "feature_flag.global_departments_invalid": "全局范围不能同时指定部门,请清除部门或选择部门范围。", + "feature_flag.batch_input_invalid": "批量功能设置不符合要求,请检查操作说明、版本和重复项目。", + "fee_control.input_invalid": "费用预检信息不符合要求,请检查单据类型和标识。", + "fee_control.entity_unsupported": "当前单据类型不支持费用预检,请联系管理员确认。", + "risk_flag.query_invalid": "风险记录查询条件不符合要求,请检查单据、状态和来源。", + "risk_flag.id_invalid": "风险记录标识无效,请刷新列表后重新选择。", + "risk_flag.action_input_invalid": "风险处理信息不符合要求,请检查备注长度。", + "risk_flag.not_open": "风险记录已处理,不能重复处理,请刷新后核对。", + "auth.entity_action_unsupported": "当前单据不支持此操作,请返回页面选择有效操作。", + "wecom.signature_url_invalid": "企业微信页面签名地址无效,请刷新当前页面后重试。", + "wecom.entry_config_incomplete": "企业微信登录入口尚未配置完整,请联系管理员确认。", + "wecom.auth_config_incomplete": "企业微信认证配置不完整,请联系管理员确认。", + "wecom.login_query_invalid": "企业微信登录参数无效,请从正式入口重新进入。", + "wecom.pc_mode_unsupported": "电脑扫码入口不支持此操作,请使用对应的绑定或入职入口。", + "wecom.bind_input_invalid": "企业微信绑定参数无效,请刷新绑定页面后重试。", + "integration.outbox_query_invalid": "消息队列查询条件不符合要求,请检查状态、类型和分页。", + "integration.bindings_query_invalid": "外部绑定查询条件不符合要求,请检查类型和分页。", + "integration.outbox_id_invalid": "消息队列记录标识无效,请刷新列表后重新选择。", + "integration.retry_state_invalid": "当前记录不处于失败或待人工处理状态,无法重试,请刷新后核对。", + "ai.reimbursement_id_invalid": "报销单标识无效,请刷新后重新打开报销详情。", + "ai.purchase_id_invalid": "采购申请标识无效,请刷新后重新打开采购详情。", + "ai.asset_id_invalid": "资产标识无效,请刷新后重新打开资产详情。", + "ai.feature_disabled": "当前单据未开放智能建议,请联系管理员确认。", + "weekly_report.query_invalid": "周报查询日期不符合要求,请选择有效日期。", + "weekly_report.write_input_invalid": "周报内容不符合要求,请检查文字长度和关联项目。", + "weekly_report.submit_input_invalid": "周报提交信息不符合要求,请检查周报日期。", + "weekly_report.feature_disabled": "当前未启用周报,请联系管理员确认。", + "weekly_report.submitted_readonly": "周报已提交,当前无法修改,请刷新后核对。", + "weekly_report.project_linking_disabled": "当前未启用项目关联,请移除关联项目或联系管理员确认。", + "audit.query_invalid": "审计日志查询条件不符合要求,请检查分页、日期和筛选条件。", + "audit.date_range_invalid": "审计日志结束时间早于开始时间,请调整查询范围。", + "search.feature_disabled": "当前未启用全局搜索,请联系管理员确认。", + "search.query_invalid": "搜索条件不符合要求,请输入至少2个字符并检查结果数量。", + "export.presence_query_invalid": "在岗历史导出条件不符合要求,请检查日期、人员和文件格式。", + "export.reimbursement_query_invalid": "报销导出条件不符合要求,请检查范围、状态、条数和文件格式。", + "export.invoice_query_invalid": "发票导出条件不符合要求,请检查归属、状态、条数和文件格式。", + "export.audit_query_invalid": "审计日志导出条件不符合要求,请检查日期、筛选条件和文件格式。", + "export.dashboard_query_invalid": "看板导出条件不符合要求,请检查日期、部门和文件格式。", + "export.ap_input_invalid": "应付工作台导出条件不符合要求,请检查筛选条件和文件格式。", + "export.dashboard_date_range_invalid": "看板导出结束日期早于开始日期,请调整日期范围。", + "export.payment_batch_disabled": "当前未启用付款批次功能,无法导出应付工作台数据,请联系管理员确认。", + "export.project_linking_disabled": "当前未启用项目关联,无法导出合同台账,请联系管理员确认。", + "export.ap_pending_date_range_invalid": "待付款批次导出结束日期早于开始日期,请调整创建日期范围。", + "attendance.clock_input_invalid": "打卡信息不符合要求,请检查备注后重试。", + "attendance.legacy_month_query_invalid": "考勤月份查询不符合要求,请选择2000至2100年内的有效月份。", + "attendance.team_query_invalid": "团队考勤查询不符合要求,请检查日期和部门。", + "attendance.policy_input_invalid": "考勤策略信息不符合要求,请检查时间格式和容差分钟数。", + "attendance.backfill_input_invalid": "补录信息不符合要求,请检查员工、日期和打卡时间。", + "attendance.sync_input_invalid": "考勤同步条件不符合要求,请选择不超过31天的日期范围和不超过50名员工。", + "attendance.sync_runs_query_invalid": "考勤同步记录查询不符合要求,请检查分页条件。", + "attendance.feature_disabled": "当前未启用网页打卡,请使用现行考勤渠道。", + "attendance.unified_feature_disabled": "当前未启用统一考勤,请联系管理员确认。", + "attendance.datetime_invalid": "打卡时间无法识别,请填写有效的日期时间。", + "attendance.exempt_day": "当天属于免打卡范围,无需提交打卡。", + "attendance.missing_clock_in": "当天尚未签到,请先签到或联系管理员核查。", + "attendance.missing_backfill_values": "补录缺少打卡时间,请至少填写签到或签退时间。", + "attendance.clock_range_invalid": "签退时间早于签到时间,请核对补录时间。", + "attendance.policy_time_invalid": "考勤策略时间无效,请使用00:00至23:59的小时和分钟格式。", + "attendance.tolerance_invalid": "迟到容差无效,请填写0至600之间的整数分钟数。", + "attendance.work_range_invalid": "上班时间必须早于下班时间,请调整考勤策略。", + "attendance.lunch_range_invalid": "午休开始时间必须早于结束时间,请调整考勤策略。", + "attendance.lunch_outside_work_range": "午休时间超出工作时段,请调整考勤策略。", + "attendance.year_invalid": "考勤年份无效,请选择2000至2100之间的年份。", + "presence.query_invalid": "在岗查询条件不符合要求,请检查日期。", + "presence.write_input_invalid": "在岗信息不符合要求,请检查日期、状态和备注。", + "presence.history_query_invalid": "在岗历史查询条件不符合要求,请检查日期范围。", + "presence.team_history_query_invalid": "团队在岗历史查询条件不符合要求,请检查日期、员工和筛选条件。", + "presence.employee_id_invalid": "员工标识无效,请刷新列表后重新选择。", + "presence.override_input_invalid": "在岗调整信息不符合要求,请检查日期、状态和备注。", + "presence.feature_disabled": "当前未启用在岗看板,请联系管理员确认。", + "presence.approved_leave_readonly": "当天已有获批请假记录,无法自行修改在岗状态,请联系管理员核查。", + "export.presence_too_large": "在岗历史导出条目过多,请缩小日期或人员范围。", + "delegation.id_invalid": "委托记录标识无效,请刷新委托列表后重试。", + "delegation.create_input_invalid": "委托资料格式有误,请检查受托人、范围及起止时间。", + "delegation.grant_input_invalid": "委托授权资料格式有误,请检查委托人、受托人、范围及起止时间。", + "delegation.start_time_invalid": "委托开始时间无效,请重新选择。", + "delegation.end_time_invalid": "委托结束时间无效,请重新选择。", + "delegation.time_range_invalid": "委托结束时间必须晚于开始时间,请调整后重试。", + "delegation.status_query_invalid": "委托状态筛选条件无效,请重新选择状态。", + "delegation.grants_query_invalid": "委托授权筛选条件有误,请检查人员与有效期选项。", + "delegation.delegate_unavailable": "受托人不存在或不在职,请选择在职人员。", + "delegation.delegate_proxy_permission_required": "受托人不具备报销代提交权限,请选择具备该权限的人员。", + "delegation.delegate_approval_permission_required": "受托人不具备报销审批权限,请选择具备审批权限的人员。", + "delegation.self_not_allowed": "委托人和受托人不能是同一人,请重新选择受托人。", + "delegation.principal_unavailable": "委托人不存在或不在职,请选择在职人员。", + "workflow_definition.id_invalid": "审批流程标识无效,请刷新流程列表后重试。", + "workflow_definition.create_input_invalid": "审批流程新增资料有误,请检查名称、业务类型及1至20个审批步骤。", + "workflow_definition.update_input_invalid": "审批流程修改内容为空或格式有误,请检查步骤和人员配置后保存。", + "workflow_definition.entity_type_invalid": "审批流程业务类型无效,请重新选择。", + "workflow_definition.feature_disabled": "审批流程管理功能尚未启用,请联系管理员确认。", + "workflow_definition.reimbursement_readonly": "报销审批流程由审批规则生成,请在审批规则中维护。", + "workflow_definition.not_editable": "已发布生效或已归档的审批流程不能直接修改,请先确认流程版本。", + "workflow_definition.archived_not_publishable": "已归档的审批流程不能发布,请选择可用草稿版本。", + "approval_rule.create_input_invalid": "审批规则新增资料有误,请检查金额范围、审批角色和优先级。", + "approval_rule.update_input_invalid": "审批规则修改资料有误,请检查金额范围、审批角色和优先级。", + "approval_rule.id_invalid": "审批规则标识无效,请刷新规则列表后重试。", + "approval_rule.amount_invalid": "审批金额范围超出可处理范围,请填写有效的上下限金额。", + "approval_rule.amount_range_invalid": "审批金额上限必须大于下限,请调整金额范围。", + "approval_rule.fallback_role_invalid": "负责人本人申请的兜底角色仅支持财务或财务经理,请重新选择。", + "approval_rule.empty_update": "尚未填写需要修改的审批规则内容,请修改后保存。", + "workflow.steps_invalid": "审批流程缺少有效步骤,请联系管理员检查流程配置。", + "workflow.business_role_unsupported": "审批流程包含不支持的业务审批角色,请联系管理员修正。", + "workflow.approval_rule_invalid": "审批规则配置无效,请联系管理员核对后重试。", + "workflow.approval_chain_empty": "审批规则未配置审批层级,请联系管理员补充。", + "workflow.fallback_role_unsupported": "负责人本人申请的兜底审批角色无效,请联系管理员核对。", + "workflow.fallback_role_is_self": "当前兜底审批角色由申请人本人持有,无法继续,请联系管理员安排符合要求的审批人。", + "workflow.fallback_holder_unavailable": "当前没有可处理的非本人兜底审批人,请联系管理员核对角色人员。", + "workflow.fallback_chain_empty": "本人申请调整后没有可用审批步骤,请联系管理员检查审批规则。", + "workflow.assignee_rule_invalid": "审批人分配规则无效,请联系管理员检查流程配置。", + "workflow.role_assignee_invalid": "审批步骤未配置有效的角色,请联系管理员修正。", + "workflow.user_assignee_invalid": "审批步骤指定的人员标识无效,请联系管理员重新配置。", + "workflow.manager_context_invalid": "无法确定申请人的主管关系,请联系管理员核对人员资料。", + "workflow.assignee_type_unsupported": "审批人分配方式不受支持,请联系管理员修正流程。", + "workflow.manager_unavailable": "无法找到可处理的主管审批人,请联系管理员检查汇报关系后重试。", + "workflow.assignee_resolution_invalid": "审批步骤未能解析出有效审批人,请联系管理员检查配置。", + "purchase_request.id_invalid": "采购申请标识无效,请刷新采购列表后重试。", + "purchase_attachment.id_invalid": "采购附件标识无效,请刷新附件列表后重试。", + "purchase_request.query_invalid": "采购筛选条件有误,请检查状态、页码和归属信息。", + "purchase_request.update_input_invalid": "采购修改资料格式有误,请检查字段、明细和交付日期。", + "purchase_request.archive_input_invalid": "采购归档资料有误,请填写采购单号并检查合同号和附件清单。", + "purchase_request.precheck_blocked": "采购预检未通过,请查看预检结果并处理阻断项后提交。", + "purchase_request.quantity_invalid": "采购明细数量超出可处理范围,请填写有效数量。", + "purchase_request.quantity_too_small": "采购明细数量不得小于0.01,请修正数量后保存。", + "purchase_request.unit_price_invalid": "采购明细单价超出可处理范围,请填写有效单价。", + "purchase_request.unit_price_negative": "采购明细单价不能为负数,请修正后保存。", + "purchase_request.company_not_found": "采购所属公司不存在,请刷新后重新选择。", + "purchase_request.company_inactive": "采购所属公司已停用或归档,请选择可用公司。", + "purchase_request.cost_center_not_found": "采购成本中心不存在,请刷新后重新选择。", + "purchase_request.cost_center_inactive": "采购成本中心已停用或归档,请选择可用成本中心。", + "purchase_request.cost_center_company_mismatch": "采购成本中心不属于所选公司,请核对归属信息。", + "purchase_request.supplier_not_found": "采购供应商不存在,请刷新后重新选择。", + "purchase_request.supplier_inactive": "采购供应商已停用或归档,请选择可用供应商。", + "purchase_request.supplier_company_mismatch": "采购供应商不适用于所选公司,请核对供应商归属。", + "purchase_request.document_category_not_found": "采购单据分类不存在,请刷新后重新选择。", + "purchase_request.document_category_type_invalid": "所选单据分类不适用于采购申请,请重新选择。", + "purchase_request.document_category_inactive": "采购单据分类已停用或归档,请选择可用分类。", + "purchase_request.budget_pool_not_found": "所选采购预算池不存在,请刷新后重新选择。", + "purchase_request.budget_pool_inactive": "所选采购预算池不可用,请联系财务核对预算。", + "purchase_request.project_link_disabled": "采购项目关联功能尚未启用,请联系管理员确认。", + "purchase_request.project_archived": "已归档项目不能新增采购关联,请选择可用项目。", + "purchase_attachment.download_input_invalid": "采购附件下载参数有误,请检查下载理由后重试。", + "purchase_attachment.delete_input_invalid": "删除采购附件需要2至200字的理由,请填写后重试。", + "purchase_attachment.download_blocked": "采购附件已被隔离或未通过安全校验,无法下载,请联系管理员核查。", + "purchase_request.not_editable": "只有草稿采购申请可以修改,请刷新确认申请状态。", + "purchase_request.workflow_missing": "采购审批流程尚未配置,请联系管理员配置后提交。", + "purchase_request.approver_unavailable": "采购申请当前没有可处理的审批人,请联系管理员核对审批配置后重试。", + "purchase_request.not_submittable": "只有草稿采购申请可以提交,请刷新确认申请状态。", + "purchase_request.company_required": "提交采购申请前,请选择所属公司。", + "purchase_request.cost_center_required": "提交采购申请前,请选择成本中心。", + "purchase_request.supplier_required": "提交采购申请前,请选择供应商。", + "purchase_request.document_category_required": "提交采购申请前,请选择单据分类。", + "purchase_request.items_required": "提交采购申请前,请至少添加一条采购明细。", + "purchase_request.budget_context_mismatch": "所选预算池与当前采购归属或期间不匹配,请重新选择预算池。", + "purchase_request.budget_required": "采购申请缺少适用预算池,请联系财务配置预算后提交。", + "purchase_request.not_withdrawable": "只有审批中的采购申请可以撤回,请刷新确认申请状态。", + "purchase_request.not_archivable": "只有已批准的采购申请可以登记订单归档,请先完成审批。", + "purchase_attachment.not_editable": "只有草稿采购申请可以上传或删除附件,请刷新确认申请状态。", + "purchase_attachment.multipart_required": "请通过文件上传入口选择采购附件后重试。", + "purchase_attachment.single_file_required": "每次只能上传一个采购附件,请逐个上传。", + "purchase_attachment.field_invalid": "采购附件上传字段有误,请重新选择文件后上传。", + "purchase_attachment.too_large": "采购附件超过允许大小,请缩小文件后重新上传。", + "purchase_attachment.multipart_limit": "采购附件上传字段或分段过多,请重新选择文件后上传。", + "purchase_attachment.validation_failed": "采购附件未通过文件校验,请更换有效文件后重试。", + "purchase_attachment.file_required": "请选择采购附件后上传。", + "purchase_attachment.download_reason_required": "下载此采购附件需要填写理由,请补充后重试。", + "purchase_attachment.dangerous_filename": "采购附件文件名不符合安全要求,请使用不含路径或可执行扩展名的普通文件名。", + "purchase_attachment.corrupt_content": "采购附件内容损坏或格式无效,请重新导出文件后上传。", + "purchase_attachment.multiple_pages": "采购附件图片包含多帧或多页,请转换为单张图片后上传。", + "purchase_attachment.pixel_limit": "采购附件图片像素过大,请降低分辨率后上传。", + "purchase_attachment.type_mismatch": "采购附件实际内容与文件类型不一致,请重新导出正确格式后上传。", + "purchase_attachment.empty_file": "采购附件内容为空,请选择有内容的文件。", + "asset_movement.id_invalid": "资产标识无效,请刷新资产列表后重试。", + "asset_movement.assign_input_invalid": "资产领用资料有误,请选择领用人并检查备注。", + "asset_movement.return_input_invalid": "资产归还资料有误,请检查备注后重试。", + "asset_movement.transfer_input_invalid": "资产调拨资料有误,请检查接收人、位置和备注。", + "asset_movement.status_blocked": "已报废或归档的资产不能领用、归还或调拨,请确认资产状态。", + "asset_movement.not_found": "待流转的资产不存在,请刷新资产列表后重试。", + "asset_movement.employee_not_found": "资产接收人不存在,请刷新人员列表后重新选择。", + "asset_movement.employee_inactive": "资产接收人已不在职,请选择在职人员。", + "asset_movement.already_assigned": "资产已有领用人或未关闭的领用记录,请先归还或使用调拨。", + "asset_movement.not_assigned": "资产尚未领用,无需归还,请刷新确认资产记录。", + "asset_movement.use_return": "清空资产领用人请使用归还操作,不能通过调拨清空。", + "asset_movement.no_change": "本次调拨没有改变接收人或位置,请填写实际变更。", + "inventory_check.id_invalid": "盘点单标识无效,请刷新盘点列表后重试。", + "inventory_check.scope_id_invalid": "盘点范围标识无效,请重新选择公司或成本中心。", + "inventory_check.query_invalid": "盘点筛选条件有误,请检查状态和范围类型。", + "inventory_check.create_input_invalid": "盘点创建资料有误,请检查范围类型和范围值。", + "inventory_check.record_input_invalid": "盘点结果有误,请至少填写一项并检查资产、结果和备注。", + "inventory_check.scope_required": "请填写所选盘点范围对应的公司、成本中心或位置。", + "inventory_check.company_not_found": "盘点范围的公司不存在,请刷新后重新选择。", + "inventory_check.cost_center_not_found": "盘点范围的成本中心不存在,请刷新后重新选择。", + "inventory_check.not_found": "盘点单不存在,请刷新盘点列表后重试。", + "inventory_check.start_state_invalid": "只有草稿盘点单可以开始,请刷新确认盘点状态。", + "inventory_check.record_state_invalid": "只有进行中的盘点单可以记录结果,请先开始盘点或刷新状态。", + "inventory_check.complete_state_invalid": "只有进行中的盘点单可以完成,请刷新确认盘点状态。", + "inventory_check.asset_scope_mismatch": "资产不属于本次盘点范围,请核对公司、成本中心或位置。", + "inventory_check.duplicate_asset": "同批盘点结果包含重复资产,请合并后重新提交。", + "inventory_check.asset_not_found": "盘点结果中的资产不存在,请刷新资产列表后重新选择。", + "asset.category_id_invalid": "资产分类标识无效,请刷新分类列表后重试。", + "asset.id_invalid": "资产标识无效,请刷新资产列表后重试。", + "asset.category_query_invalid": "资产分类筛选条件有误,请检查启用和归档选项。", + "asset.query_invalid": "资产筛选条件有误,请检查分类、人员、归属和状态后重试。", + "asset.assignee_query_invalid": "领用人查询条件有误,请缩短关键词后重试。", + "asset.category_create_input_invalid": "资产分类资料不完整或格式有误,请检查编码、名称和单据分类。", + "asset.category_update_input_invalid": "资产分类修改内容为空或格式有误,请检查后保存。", + "asset.create_input_invalid": "资产资料不完整或格式有误,请检查必填项、人员和日期。", + "asset.update_input_invalid": "资产修改内容为空或格式有误,请检查字段和日期后保存。", + "asset.index_import_input_invalid": "脱敏资产索引格式有误,请检查条目、字段和数量后重新导入。", + "asset.unsafe_content": "资产资料包含不允许保存的文本、哈希或元数据,请脱敏并检查格式和长度。", + "asset.index_import_unsafe_content": "脱敏资产索引含不允许保存的文本或哈希,请检查并脱敏后重试。", + "asset.document_category_not_found": "所选单据分类不存在,请刷新后重新选择。", + "asset.document_category_type_invalid": "所选单据分类不适用于资产卡片,请选择资产卡片分类。", + "asset.document_category_inactive": "所选单据分类已停用或归档,请选择可用分类。", + "asset.category_not_found": "资产分类不存在,请刷新后重新选择。", + "asset.category_inactive": "资产分类已停用或归档,请选择可用分类。", + "asset.category_document_invalid": "资产分类关联的单据分类不可用,请联系管理员修正分类设置。", + "asset.company_not_found": "资产所属公司不存在,请刷新后重新选择。", + "asset.company_inactive": "资产所属公司已停用或归档,请选择可用公司。", + "asset.cost_center_not_found": "资产成本中心不存在,请刷新后重新选择。", + "asset.cost_center_inactive": "资产成本中心已停用或归档,请选择可用成本中心。", + "asset.cost_center_company_mismatch": "资产成本中心不属于所选公司,请核对公司与成本中心。", + "asset.purchase_not_found": "关联采购申请不存在,请刷新后重新选择。", + "asset.purchase_not_approved": "关联采购申请尚未批准,请在采购审批完成后登记资产。", + "asset.index_import_dimensions_required": "写入资产索引前,请填写资产分类、所属公司和成本中心。", + "asset.not_found": "资产不存在,请刷新资产列表后重试。", + "asset.assignee_not_found": "所选资产领用人不存在,请刷新人员列表后重新选择。", + "asset.assignee_inactive": "所选资产领用人已不在职,请选择在职人员。", + "asset.owner_not_found": "所选资产责任人不存在,请刷新人员列表后重新选择。", + "asset.owner_inactive": "所选资产责任人已不在职,请选择在职人员。", + "asset.keeper_not_found": "所选资产保管人不存在,请刷新人员列表后重新选择。", + "asset.keeper_inactive": "所选资产保管人已不在职,请选择在职人员。", + "contract_registry.create_input_invalid": "合同台账新增资料不完整或格式有误,请检查必填项、金额和日期。", + "contract_registry.update_input_invalid": "合同台账修改内容为空或格式有误,请检查后重新保存。", + "contract_registry.terminate_input_invalid": "请填写10至1000字的合同终止原因后重试。", + "contract_registry.correction_input_invalid": "请填写10至1000字的合同纠错说明后重试。", + "contract_registry.feature_disabled": "合同台账功能尚未启用,请联系管理员确认。", + "contract_registry.state_conflict": "合同状态或资料已被更新,请刷新后重试。", + "contract_registry.terminated_is_final": "已终止的合同不能修改,请查看原合同记录。", + "contract_registry.archived_fields_locked": "已归档合同不能直接修改资料,请通过纠错记录补充说明。", + "contract_registry.terminate_not_allowed": "仅生效或已到期的合同可以终止,请刷新确认合同状态。", + "contract_registry.terminate_conflict": "合同已发生变更,本次未完成终止,请刷新后确认。", + "contract_registry.correction_requires_archived": "仅已归档合同可以追加纠错记录,请先确认合同状态。", + "contract_registry.correction_state_conflict": "合同归档状态已变化,本次未保存纠错记录,请刷新后重试。", + "contract_registry.renew_not_allowed": "仅生效或已到期的合同可以续订,请刷新确认合同状态。", + "purchase_budget.reserve_already_exists": "该采购申请已有未释放的预算占用,请刷新查看,避免重复提交。", + "purchase_budget.pool_not_found": "采购预算池已不存在,请联系财务核对预算后重试。", + "travel_reconciliation.query_invalid": "差旅对账筛选条件无效,请检查日期、状态和分页后重试。", + "travel_reconciliation.create_input_invalid": "对账批次资料不完整或格式有误,请检查公司、成本中心和期间后重试。", + "travel_reconciliation.period_reversed": "对账开始日期不能晚于结束日期,请调整对账期间。", + "travel_reconciliation.company_not_found": "对账公司不存在,请刷新公司列表后重新选择。", + "travel_reconciliation.cost_center_not_found": "对账成本中心不存在,请刷新列表后重新选择。", + "travel_reconciliation.cost_center_company_mismatch": "对账成本中心与所选公司不匹配,请重新选择。", + "travel_reconciliation.empty_scope": "所选范围没有可对账的订单或交易,请检查期间和公司范围后重试。", + "travel_reconciliation.batch_id_invalid": "对账批次标识无效,请刷新批次列表后重新选择。", + "travel_reconciliation.item_id_invalid": "对账条目标识无效,请刷新批次详情后重新选择。", + "travel_reconciliation.confirm_input_invalid": "对账确认备注格式有误,请检查填写内容后重新确认。", + "travel_reconciliation.batch_not_editable": "对账批次已不可修改,请刷新并确认批次状态。", + "travel_reconciliation.item_not_pending": "该对账条目当前不是待确认状态,请刷新后核对处理结果。", + "travel_reconciliation.item_not_confirmed": "只有已确认的对账条目可以重开,请刷新并确认条目状态。", + "travel_reconciliation.matched_item_not_reopenable": "自动匹配成功的对账条目不能重开,请检查关联订单和交易记录。", + "travel_reconciliation.batch_has_pending_items": "对账批次仍有未确认条目,请先处理全部条目后再结案。", + "travel_import.sync_disabled": "差旅同步尚未启用,请联系管理员确认后再导入。", + "travel_payment.budget_pool_not_found": "关联预算池不存在,请联系财务检查预算配置后重试。", + "travel_order.import_input_invalid": "差旅订单导入列表格式有误,请检查数据并确保每批为1至100项。", + "travel_order.import_empty": "未识别到有效的差旅订单记录,请检查必填字段和格式后重新导入。", + "travel_order.id_invalid": "差旅订单标识无效,请刷新列表后重新选择。", + "travel_authorization.import_input_invalid": "差旅授权导入列表格式有误,请检查数据并确保每批为1至100项。", + "travel_authorization.import_empty": "未识别到有效的差旅授权记录,请检查必填字段和格式后重新导入。", + "travel_authorization.query_invalid": "差旅授权筛选条件无效,请检查筛选项和分页后重试。", + "travel_transaction.import_input_invalid": "差旅交易导入列表格式有误,请检查数据并确保每批为1至100项。", + "travel_transaction.import_empty": "未识别到有效的差旅交易记录,请检查必填字段和格式后重新导入。", + "travel_transaction.query_invalid": "差旅交易筛选条件无效,请检查筛选项和分页后重试。", + "travel_transaction.id_invalid": "差旅交易标识无效,请刷新列表后重新选择。", + "travel_order.payment_input_invalid": "差旅付款记录格式有误,请检查付款日期、渠道和凭证号后重试。", + "travel_order.invalid_pay_status": "该差旅订单当前不能标记付款,请刷新并确认订单状态。", + "travel_order.already_paid_conflict": "该差旅订单已记录付款,本次付款资料与原记录不同,请核对原付款记录。", + "travel_order.settled_transaction_required": "该订单缺少金额和币种一致的已结算扣款交易,请先核对并补齐交易记录。", + "travel_transaction.freeze_input_invalid": "冻结建议的原因或备注格式有误,请选择有效原因后重新提交。", + "travel_request.query_invalid": "差旅申请筛选条件无效,请检查状态、分页和筛选项后重试。", + "travel_request.id_invalid": "差旅申请标识无效,请刷新列表后重新选择。", + "travel_request.create_input_invalid": "差旅申请资料不完整或格式有误,请检查事由、日期、费用及行程后重新创建。", + "travel_request.update_input_invalid": "差旅申请修改内容格式有误,请检查填写内容后重试。", + "travel_request.project_link_input_invalid": "项目关联参数无效,请重新选择项目或清除关联后重试。", + "travel_request.segment_time_invalid": "行程出发或到达时间无法识别,请填写有效时间。", + "travel_request.amount_invalid": "差旅预估金额无法识别或超出支持范围,请填写有效金额。", + "travel_request.amount_negative": "差旅预估金额不能为负数,请检查金额后重试。", + "travel_request.segment_time_reversed": "行程到达时间不能早于出发时间,请调整行程时间。", + "travel_request.date_range_reversed": "差旅结束日期不能早于开始日期,请调整出差日期。", + "travel_request.precheck_blocked": "差旅申请未通过费用预检,请检查预算与费用标准提示,处理后重新提交。", + "travel_request.company_unavailable": "所选公司不存在、已停用或已归档,请选择有效公司。", + "travel_request.cost_center_unavailable": "所选成本中心不存在、已停用或已归档,请选择有效成本中心。", + "travel_request.cost_center_company_mismatch": "成本中心与所选公司不匹配,请重新选择同一公司的成本中心。", + "travel_request.update_empty": "尚未填写差旅修改内容,请修改至少一项后保存。", + "travel_request.only_draft_editable": "只有草稿差旅申请可以修改,请刷新并确认申请状态。", + "travel_request.only_draft_submittable": "只有草稿差旅申请可以提交,请刷新并确认申请状态,避免重复提交。", + "travel_request.segments_required": "差旅申请缺少行程,请补充至少一段行程后提交。", + "travel_request.workflow_missing": "差旅审批流程尚未配置,请联系管理员配置后重新提交。", + "travel_request.approver_unavailable": "差旅审批流程没有可处理的审批人,请联系管理员检查审批人配置后重试。", + "travel_request.project_linking_disabled": "差旅项目关联功能暂不可用,请联系管理员确认开放范围后重试。", + "travel_request.only_reviewing_withdrawable": "只有审批中的差旅申请可以撤回,请刷新并确认申请状态。", + "project.create_input_invalid": "项目新增内容不完整或格式有误,请检查填写内容后重试。", + "project.update_input_invalid": "项目修改内容不完整或格式有误,请检查填写内容后重试。", + "project.member_create_input_invalid": "项目成员新增内容不完整或格式有误,请检查填写内容后重试。", + "project.member_update_input_invalid": "项目成员修改内容不完整或格式有误,请检查填写内容后重试。", + "project.milestone_create_input_invalid": "里程碑新增内容不完整或格式有误,请检查填写内容后重试。", + "project.milestone_update_input_invalid": "里程碑修改内容不完整或格式有误,请检查填写内容后重试。", + "project.task_create_input_invalid": "项目任务新增内容不完整或格式有误,请检查填写内容后重试。", + "project.task_update_input_invalid": "项目任务修改内容不完整或格式有误,请检查填写内容后重试。", + "project.risk_create_input_invalid": "项目风险新增内容不完整或格式有误,请检查填写内容后重试。", + "project.risk_update_input_invalid": "项目风险修改内容不完整或格式有误,请检查填写内容后重试。", + "project.communication_log_create_input_invalid": "项目沟通记录新增内容不完整或格式有误,请检查填写内容后重试。", + "project.communication_log_update_input_invalid": "项目沟通记录修改内容不完整或格式有误,请检查填写内容后重试。", + "project.closure_checklist_update_input_invalid": "项目收口项修改内容不完整或格式有误,请检查填写内容后重试。", + "project.delivery_create_input_invalid": "项目交付记录新增内容不完整或格式有误,请检查填写内容后重试。", + "project.id_invalid": "项目标识无效,请刷新项目列表后重新选择。", + "project.member_id_invalid": "项目成员标识无效,请刷新项目详情后重新选择。", + "project.milestone_id_invalid": "里程碑标识无效,请刷新项目详情后重新选择。", + "project.task_id_invalid": "项目任务标识无效,请刷新项目详情后重新选择。", + "project.risk_id_invalid": "项目风险标识无效,请刷新项目详情后重新选择。", + "project.communication_log_id_invalid": "项目沟通记录标识无效,请刷新项目详情后重新选择。", + "project.closure_checklist_id_invalid": "项目收口项标识无效,请刷新项目详情后重新选择。", + "project.feature_disabled": "项目功能暂不可用,请联系管理员确认开放范围后重试。", + "project.linking_disabled": "项目关联功能暂不可用,请联系管理员确认开放范围后重试。", + "project.communication_disabled": "项目沟通记录功能暂不可用,请联系管理员确认开放范围后重试。", + "project.closure_disabled": "项目收口清单功能暂不可用,请联系管理员确认开放范围后重试。", + "project.document_refs_disabled": "项目资料引用功能暂不可用,请联系管理员确认开放范围后重试。", + "project.query_invalid": "项目筛选条件无效,请检查状态和筛选项后重试。", + "project.waiver_note_required": "豁免项目收口项需要填写理由,请补充说明后保存。", + "expense_standard.id_invalid": "费用标准标识无效,请刷新列表后重新选择。", + "expense_standard.query_invalid": "费用标准筛选条件无效,请检查筛选项后重试。", + "expense_standard.create_input_invalid": "费用标准资料不完整或格式有误,请检查必填项后重新创建。", + "expense_standard.update_input_invalid": "费用标准修改内容格式有误,请检查填写内容后重试。", + "expense_standard.update_empty": "尚未填写费用标准修改内容,请修改至少一项后保存。", + "expense_type.id_invalid": "费用类型标识无效,请刷新列表后重新选择。", + "expense_type.query_invalid": "费用类型筛选条件无效,请检查筛选项后重试。", + "expense_type.create_input_invalid": "费用类型资料不完整或格式有误,请检查必填项后重新创建。", + "expense_type.update_input_invalid": "费用类型修改内容格式有误,请检查填写内容后重试。", + "expense_type.update_empty": "尚未填写费用类型修改内容,请修改至少一项后保存。", + "expense_type.code_duplicate": "费用类型编码已存在,请使用其他编码。", + "leave_type.id_invalid": "假别标识无效,请刷新列表后重新选择。", + "leave_type.query_invalid": "假别筛选条件无效,请检查筛选项后重试。", + "leave_type.create_input_invalid": "假别资料不完整或格式有误,请检查必填项后重新创建。", + "leave_type.update_input_invalid": "假别修改内容格式有误,请检查填写内容后重试。", + "leave_type.code_duplicate": "假别编码已存在,请使用其他编码。", + "reimbursement_template.id_invalid": "报销模板标识无效,请刷新列表后重新选择。", + "reimbursement_template.query_invalid": "报销模板筛选条件无效,请检查筛选项后重试。", + "reimbursement_template.create_input_invalid": "报销模板资料不完整或格式有误,请检查必填项后重新创建。", + "reimbursement_template.update_input_invalid": "报销模板修改内容格式有误,请检查填写内容后重试。", + "reimbursement_template.update_empty": "尚未填写报销模板修改内容,请修改至少一项后保存。", + "expense_standard.amount_not_positive": "费用标准金额须大于零,请填写有效的金额。", + "expense_standard.expense_type_not_found": "所选费用类型不存在,请刷新费用类型列表后重新选择。", + "expense_standard.department_required": "部门费用标准需要指定部门,请选择部门后保存。", + "expense_standard.department_unavailable": "所选部门不存在或已归档,请选择有效部门。", + "expense_standard.scope_conflict": "该费用类型在相同范围已有标准,请修改已有标准或选择其他范围。", + "reimbursement_template.expense_types_unavailable": "模板中有不存在或已停用的费用类型,请重新选择有效类型后保存。", + "holiday_calendar.id_invalid": "节假日日历标识无效,请刷新列表后重新选择。", + "holiday_calendar.query_invalid": "节假日日历筛选条件无效,请检查筛选项后重试。", + "holiday_calendar.create_input_invalid": "日历资料不完整或格式有误,请检查编码和名称后重新创建。", + "holiday_calendar.update_input_invalid": "日历修改内容格式有误,请检查填写内容后重试。", + "holiday_calendar.update_empty": "尚未填写日历修改内容,请修改至少一项后保存。", + "holiday_calendar.days_input_invalid": "日历日期列表无效,请检查真实日期、日期类型和名称,且一次不超过500项。", + "holiday_calendar.date_duplicate": "日历日期列表有重复日期,请每个日期只保留一项后重试。", + "holiday_calendar.code_duplicate": "日历编码已存在,请使用其他编码。", + "holiday_calendar.archived_locked": "该日历已归档,无法修改资料或日期;请选择有效日历。", + "holiday_calendar.bound_as_default": "该日历仍是公司的默认日历,请先调整默认日历后再归档。", + "legal_entity.id_invalid": "公司标识无效,请刷新列表后重新选择。", + "legal_entity.query_invalid": "公司筛选条件无效,请检查筛选项后重试。", + "legal_entity.create_input_invalid": "公司资料不完整或格式有误,请检查必填项后重新创建。", + "legal_entity.update_input_invalid": "公司修改内容格式有误,请检查填写内容后重试。", + "legal_entity.update_empty": "尚未填写公司修改内容,请修改至少一项后保存。", + "legal_entity.code_duplicate": "公司编码已存在,请使用其他编码。", + "legal_entity.archived_locked": "该公司已归档,无法修改;请返回列表选择有效记录。", + "cost_center.id_invalid": "成本中心标识无效,请刷新列表后重新选择。", + "cost_center.query_invalid": "成本中心筛选条件无效,请检查筛选项后重试。", + "cost_center.create_input_invalid": "成本中心资料不完整或格式有误,请检查必填项后重新创建。", + "cost_center.update_input_invalid": "成本中心修改内容格式有误,请检查填写内容后重试。", + "cost_center.update_empty": "尚未填写成本中心修改内容,请修改至少一项后保存。", + "cost_center.code_duplicate": "成本中心编码已存在,请使用其他编码。", + "cost_center.archived_locked": "该成本中心已归档,无法修改;请返回列表选择有效记录。", + "document_category.id_invalid": "单据分类标识无效,请刷新列表后重新选择。", + "document_category.query_invalid": "单据分类筛选条件无效,请检查筛选项后重试。", + "document_category.create_input_invalid": "单据分类资料不完整或格式有误,请检查必填项后重新创建。", + "document_category.update_input_invalid": "单据分类修改内容格式有误,请检查填写内容后重试。", + "document_category.update_empty": "尚未填写单据分类修改内容,请修改至少一项后保存。", + "document_category.code_duplicate": "单据分类编码已存在,请使用其他编码。", + "document_category.archived_locked": "该单据分类已归档,无法修改;请返回列表选择有效记录。", + "supplier.id_invalid": "供应商标识无效,请刷新列表后重新选择。", + "supplier.query_invalid": "供应商筛选条件无效,请检查筛选项后重试。", + "supplier.create_input_invalid": "供应商资料不完整或格式有误,请检查必填项后重新创建。", + "supplier.update_input_invalid": "供应商修改内容格式有误,请检查填写内容后重试。", + "supplier.update_empty": "尚未填写供应商修改内容,请修改至少一项后保存。", + "supplier.code_duplicate": "供应商编码已存在,请使用其他编码。", + "supplier.archived_locked": "该供应商已归档,无法修改;请返回列表选择有效记录。", + "legal_entity.reference_not_found": "所选公司不存在,请刷新公司列表后重新选择。", + "legal_entity.reference_archived": "所选公司已归档,请选择有效公司。", + "legal_entity.has_cost_centers": "该公司仍有未归档的成本中心,请先处理成本中心后再归档公司。", + "cost_center.owner_unavailable": "所选负责人不存在或已非在职状态,请选择在职员工。", + "cost_center.parent_self_forbidden": "成本中心不能作为自身的上级,请重新选择上级成本中心。", + "cost_center.parent_not_found": "所选上级成本中心不存在,请刷新列表后重新选择。", + "cost_center.parent_archived": "所选上级成本中心已归档,请选择有效的上级成本中心。", + "cost_center.parent_company_mismatch": "上级成本中心与当前成本中心须属于同一公司,请重新选择。", + "cost_center.parent_cycle_forbidden": "所选上级成本中心会形成循环层级,请选择其他上级。", + "cost_center.has_children": "该成本中心仍有未归档的子级,请先处理子级后再归档。", + "employee.contract_attachment_download_blocked": "此合同附件未通过文件安全校验,暂不能下载,请联系有权限的管理员核查并重新上传有效附件。", + "employee.id_invalid": "员工或档案记录标识无效,请刷新列表后重新选择。", + "employee.lifecycle_disabled": "员工资料与生命周期管理暂不可用,请联系管理员确认功能开放范围。", + "employee.hr_sync_disabled": "企业微信人事主数据同步暂未启用,请联系管理员确认同步安排。", + "employee.hr_sync_unavailable": "企业微信人事主数据同步所需组件不可用,请联系管理员检查系统组件后重试。", + "employee.directory_sync_unavailable": "企业微信通讯录同步所需组件不可用,请联系管理员检查系统组件后重试。", + "employee.contract_date_range_invalid": "员工合同结束日期不能早于开始日期,请调整合同日期后保存。", + "employee.contract_termination_date_invalid": "员工合同终止日期不能早于开始日期,请核对终止日期后保存。", + "employee.training_date_range_invalid": "培训结束日期不能早于开始日期,请调整培训日期后保存。", + "employee.certificate_date_range_invalid": "证书到期日期不能早于取得日期,请核对证书日期后保存。", + "employee.contract_number_duplicate": "已有相同编号的员工合同,请核对合同编号并避免重复登记。", + "employee.active_contract_delete_forbidden": "有效中的员工合同不能直接删除,请先按实际情况登记终止,再填写删除理由。", + "employee.self_profile_fields_forbidden": "本人资料中包含无权修改的字段,请仅修改允许的个人资料,其他变更联系管理员办理。", + "employee.hr_sync_input_invalid": "人事同步参数无效,请检查预览或执行模式、人员范围和数量限制后重试。", + "employee.hr_sync_query_invalid": "人事同步记录筛选条件无效,请重新选择有效的每页条数。", + "employee.directory_retry_input_invalid": "通讯录同步重试参数无效,请重新选择有效的同步操作后重试。", + "employee.contract_create_input_invalid": "员工合同资料无效,请检查编号、合同类型和日期后登记。", + "employee.contract_update_input_invalid": "员工合同修改内容无效,请检查合同类型、日期和需要修改的字段。", + "employee.contract_delete_input_invalid": "请填写有效的合同删除理由后重试。", + "employee.training_create_input_invalid": "培训资料无效,请检查名称、类型、状态和日期后登记。", + "employee.training_update_input_invalid": "培训修改内容无效,请检查类型、状态、日期和需要修改的字段。", + "employee.certificate_create_input_invalid": "证书资料无效,请检查名称、颁发机构和日期后登记。", + "employee.certificate_update_input_invalid": "证书修改内容无效,请检查名称、机构、日期和需要修改的字段。", + "employee.profile_input_invalid": "员工资料修改内容无效,请检查日期、联系方式和需要修改的字段后保存。", + "employee.onboarding_input_invalid": "入职办理资料无效,请检查生效日期、试用期结束日期和备注后重试。", + "employee.regularize_input_invalid": "转正办理资料无效,请检查生效日期和备注后重试。", + "employee.transfer_input_invalid": "调动办理资料无效,请检查生效日期、目标部门、岗位或直属主管后重试。", + "employee.offboarding_input_invalid": "离职办理资料无效,请检查生效日期、最后工作日期和备注后重试。", + "employee.avatar_multipart_required": "头像上传方式无效,请通过头像上传入口重新选择图片。", + "employee.avatar_file_required": "请选择头像图片后上传。", + "employee.avatar_field_invalid": "头像上传字段无效,请刷新页面并重新选择头像图片。", + "employee.avatar_type_invalid": "头像仅支持JPEG、PNG或WebP图片,请选择支持的图片格式。", + "employee.avatar_file_empty": "头像文件为空,请选择有内容的图片后上传。", + "employee.avatar_file_too_large": "头像文件不能超过2MiB,请压缩图片后重新上传。", + "employee.avatar_image_invalid": "头像文件无法解析为图片,请重新导出JPEG、PNG或WebP图片后上传。", + "employee.contract_attachment_multipart_required": "合同附件上传方式无效,请通过附件上传入口重新选择文件。", + "employee.contract_attachment_file_required": "请选择员工合同附件后上传。", + "employee.contract_attachment_field_invalid": "合同附件上传字段无效,请刷新页面并重新选择附件。", + "employee.contract_attachment_type_invalid": "员工合同附件仅支持PDF、JPEG或PNG,请选择支持的文件格式。", + "employee.contract_attachment_file_empty": "合同附件为空,请选择有内容的文件后上传。", + "employee.contract_attachment_file_too_large": "员工合同附件不能超过10MiB,请缩小文件后重新上传。", + "employee.contract_attachment_content_invalid": "合同附件内容与声明格式不符或未通过文件检查,请重新导出有效PDF、JPEG或PNG后上传。", + "user.id_invalid": "员工账号标识无效,请刷新列表后重新选择。", + "user.list_query_invalid": "员工列表筛选条件无效,请检查页码、每页条数和查看范围后重试。", + "user.password_policy_failed": "密码需至少12位,包含大小写字母和数字,并避免常见弱密码,请调整后重试。", + "user.sensitive_input_invalid": "敏感资料填写无效,请核对所选银行、账号格式或证件资料后保存。", + "user.sensitive_update_empty": "没有可保存的敏感资料修改,请填写需要修改的字段后保存。", + "user.department_not_found": "所选员工部门已不存在,请刷新后重新选择部门。", + "user.department_archived": "所选员工部门已归档,请选择可用部门或联系管理员核查。", + "user.manager_self_forbidden": "员工不能担任自己的直属主管,请选择其他在职人员;批量设置时主管不能在所选员工中。", + "user.manager_not_found": "所选直属主管已不存在,请刷新后重新选择。", + "user.manager_inactive": "所选直属主管当前不在职,请选择其他在职人员。", + "user.unique_conflict": "员工资料与已有记录重复,本次操作未完成,请核对工号、邮箱等唯一资料后重试。", + "user.bank_reveal_input_invalid": "请填写2至500字的查看银行账号理由后重试。", + "user.bank_institution_unavailable": "所选银行已停用、归档或不存在,请刷新后重新选择可用银行。", + "user.self_disable_forbidden": "不能停用或归档当前登录账号,请从本次操作中移除自己。", + "user.reset_password_input_invalid": "重置密码内容无效,请填写有效的新密码,或留空使用系统生成的临时密码。", + "user.self_role_change_forbidden": "不能修改当前登录账号自己的角色,请由其他有权限的管理员处理。", + "user.roles_input_invalid": "角色设置无效,请至少选择一个可用角色后保存。", + "user.batch_transfer_input_invalid": "批量调部门资料无效,请选择员工和目标部门后重试。", + "user.batch_manager_input_invalid": "批量设置主管资料无效,请选择员工和直属主管后重试。", + "user.batch_toggle_input_invalid": "批量启用或停用的员工列表无效,请重新选择员工后重试。", + "user.batch_limit_exceeded": "每次最多处理50名不同员工,请缩小选择范围后分批操作。", + "user.lifecycle_disabled": "员工生命周期管理暂不可用,请联系管理员确认功能开放范围。", + "user.generated_password_unavailable": "系统未能生成符合安全要求的临时密码,本次操作未完成,请联系管理员核查。", + "department.id_invalid": "部门标识无效,请刷新列表后重新选择。", + "department.query_invalid": "部门筛选条件无效,请重新选择是否包含已归档部门。", + "department.create_input_invalid": "部门资料无效,请检查部门名称和上级部门后创建。", + "department.update_input_invalid": "部门修改内容无效,请检查部门名称和上级部门后保存。", + "department.update_empty": "没有可保存的部门修改,请填写名称或调整上级部门后保存。", + "department.parent_not_found": "所选上级部门已不存在,请刷新后重新选择上级部门。", + "department.parent_archived": "所选上级部门已归档,请选择可用部门。", + "department.archived_locked": "该部门已归档,无法修改,请刷新确认当前部门状态。", + "department.parent_self_forbidden": "部门不能把自己设为上级,请选择其他部门或清空上级。", + "department.parent_cycle_forbidden": "所选上级部门会形成层级循环,请选择当前部门下级以外的部门。", + "department.children_exist": "该部门仍有下级部门,暂不能归档,请先调整下级部门的归属。", + "department.active_employees_exist": "该部门仍有在职员工,暂不能归档,请先办理员工部门调整。", + "budget.company_required": "选择成本中心时必须同时指定所属公司,请补齐公司后重试。", + "budget.scope_mixed": "预算范围不能同时使用部门和公司或成本中心,请选择一种范围后重试。", + "budget.cost_center_required": "公司范围预算需要指定成本中心,请补齐该公司的成本中心后重试。", + "budget.pool_changed": "预算池状态或适用范围已变化,请刷新后重新提交。", + "budget.pool_ambiguous": "同一成本中心匹配到多个有效预算,无法确定使用哪一个,请联系预算管理员核查重叠的预算期间。", + "budget.effective_date_invalid": "用于匹配预算的业务日期无效,请核对单据日期;仍无法处理时联系管理员。", + "budget_rule.id_invalid": "预算控制规则标识无效,请刷新列表后重新选择。", + "budget_rule.query_invalid": "预算控制规则筛选条件无效,请重新选择业务类型和启用状态。", + "budget_rule.create_input_invalid": "预算控制规则资料无效,请检查名称、业务类型、控制方式、优先级和适用范围后创建。", + "budget_rule.update_input_invalid": "预算控制规则修改内容无效,请检查要修改的字段并填写有效内容后保存。", + "budget_rule.company_required": "本次预算规则填写了成本中心,请同时填写所属公司后保存。", + "budget_rule.expense_type_unsupported": "指定费用类型时,本次提交的业务类型必须为报销,请同时选择报销类型或移除费用类型后保存。", + "budget.id_invalid": "预算标识无效,请刷新预算列表后重新选择。", + "budget.query_invalid": "预算筛选条件无效,请检查状态、所属范围和分页后重试。", + "budget.create_input_invalid": "预算资料不完整或格式有误,请检查编号、名称、期间、金额和预警比例后创建。", + "budget.update_input_invalid": "预算修改内容无效,请检查名称、金额、预警比例和状态后保存。", + "budget.update_empty": "没有可保存的预算修改,请修改资料后再保存。", + "budget.period_invalid": "预算结束日期不能早于开始日期,请调整预算期间后重试。", + "budget.department_not_found": "所选预算部门已不存在,请刷新后重新选择部门。", + "budget.department_archived": "所选预算部门已归档,请选择可用部门或联系管理员核查。", + "budget.legal_entity_not_found": "所选预算公司已不存在,请刷新后重新选择公司。", + "budget.legal_entity_unavailable": "所选预算公司已停用或归档,请选择可用公司或联系管理员核查。", + "budget.cost_center_not_found": "所选预算成本中心已不存在,请刷新后重新选择成本中心。", + "budget.cost_center_unavailable": "所选预算成本中心已停用或归档,请选择可用成本中心或联系管理员核查。", + "budget.cost_center_company_mismatch": "所选成本中心不属于当前预算公司,请重新选择匹配的公司和成本中心。", + "performance.id_invalid": "绩效周期或员工标识无效,请刷新列表后重新选择。", + "performance.cycle_create_input_invalid": "绩效周期资料无效,请检查名称、周期类型和起止日期后创建。", + "performance.cycle_update_input_invalid": "绩效周期修改内容无效,请检查名称、类型、日期和状态,并填写需要修改的内容。", + "performance.review_input_invalid": "绩效评价内容无效,请检查总分和各维度评分(1至5分,以0.5分递增)、重复维度及评语长度。", + "performance.cycle_date_range_invalid": "绩效周期结束日期不能早于开始日期,请调整起止日期后重试。", + "performance.disabled": "绩效管理功能暂不可用,请联系管理员确认功能开放范围。", + "performance.active_cycle_exists": "已有进行中的绩效周期,请先核对并结束该周期,再激活其他周期。", + "performance.completed_cycle_locked": "该绩效周期已结束,无法再修改,请刷新查看当前周期状态。", + "performance.active_cycle_only_complete": "进行中的绩效周期只能结束,不能修改资料,请保留周期资料后执行结束操作。", + "performance.draft_cannot_complete": "草稿绩效周期不能直接结束,请先激活周期并完成评价,再结束周期。", + "performance.employee_inactive": "该员工当前不在职,不能录入绩效评价,请刷新并核对员工状态。", + "performance.active_cycle_required": "只有进行中的绩效周期可以录入评价,请刷新并选择进行中的周期。", + "payroll.id_invalid": "薪资记录或员工标识无效,请刷新列表后重新选择。", + "payroll.item_create_input_invalid": "薪资项资料不完整或格式有误,请检查名称、收入或扣除类型及排序后创建。", + "payroll.item_update_input_invalid": "薪资项修改内容无效,请检查名称、类型和排序后保存。", + "payroll.profile_input_invalid": "薪资档案资料无效,请检查生效日期、薪资项和金额,并移除重复薪资项。", + "payroll.payslip_update_input_invalid": "工资条修改内容无效,请检查薪资项和金额,并移除重复薪资项。", + "payroll.amount_invalid": "薪资金额格式无效,请填写不小于零且最多两位小数的金额。", + "payroll.item_name_duplicate": "已有同名薪资项,请更换名称后保存。", + "payroll.item_not_found": "部分薪资项已不存在,请刷新后重新选择薪资项。", + "payroll.month_already_generated": "该月工资条已生成,请查看现有工资条,避免重复生成。", + "payroll.active_employees_missing": "没有可生成工资条的在职员工,请先核对员工状态。", + "payroll.enabled_items_missing": "没有启用的薪资项,请先设置并启用薪资项,再生成工资条。", + "payroll.profiles_missing": "部分员工缺少该月有效的薪资档案,请补齐档案和生效日期后再生成工资条。", + "payroll.payslip_not_editable": "此工资条已发放,无法继续修改,请刷新查看当前状态。", + "payroll.month_not_generated": "该月尚未生成工资条,请先生成并核对,再发放。", + "payroll.month_already_issued": "该月工资条已全部发放,无需重复操作,请刷新查看。", + "payroll.month_invalid": "工资月份参数无效,请重新选择有效的年和月后重试。", + "payroll.export_query_invalid": "工资导出条件无效,请检查月份和导出格式后重试。", + "attendance.month_query_invalid": "考勤月报筛选条件无效,请检查月份和所选员工后重试。", + "attendance.month_invalid": "考勤月份无效,请重新选择有效的年和月后重试。", + "user.import_file_too_large": "导入文件超过上传大小限制,请拆分或缩小文件后重试。", + "user.import_file_missing": "请选择员工导入文件后重试。", + "user.import_file_invalid": "导入文件格式损坏或无法解析,请重新导出为CSV或XLSX后再上传。", + "user.import_extension_unsupported": "员工导入仅支持CSV或XLSX文件,请使用导入模板重新保存。", + "user.import_mime_unsupported": "导入文件类型无法识别,请将表格另存为CSV或XLSX后重新上传。", + "user.import_headers_missing": "导入表格缺少必填列,请按模板补齐工号、姓名、邮箱和部门列。", + "user.import_file_empty": "导入表格没有员工数据,请在表头下填写员工资料后重试。", + "user.import_row_limit_exceeded": "每次最多导入500行员工数据,请拆分表格后分别预览和导入。", + "announcement.id_invalid": "公告标识无效,请刷新列表后重新选择。", + "announcement.create_input_invalid": "公告资料不完整或格式有误,请检查标题、内容和时间后重新创建。", + "announcement.update_input_invalid": "公告修改内容无效,请检查修改项及时间后重新保存。", + "announcement.acknowledgement_input_invalid": "请先勾选签收确认,再提交签收。", + "announcement.query_invalid": "公告筛选条件无效,请刷新列表后重试。", + "announcement.disabled": "公告功能暂未启用,请联系管理员确认。", + "announcement.statement_required": "制度签收公告必须填写签收声明,请补充后保存。", + "announcement.acknowledged_content_locked": "此公告已有员工签收,内容变更请发布新版本。", + "announcement.acknowledgement_not_required": "此公告无需签收,请返回公告列表查看。", + "announcement.statement_missing": "此公告缺少签收声明,暂时无法签收,请联系管理员补充。", + "employee_directory.query_invalid": "员工筛选条件无效,请检查入职日期、人员状态及导出格式后重试。", + "employee_directory.hire_date_range_invalid": "入职结束日期不能早于开始日期,请重新选择筛选范围。", + "contract_registry.query_invalid": "合同筛选条件无效,请检查到期日期、合同状态及导出格式后重试。", + "contract_registry.expiry_date_range_invalid": "合同到期结束日期不能早于开始日期,请重新选择筛选范围。", + "contract_registry.expiring_query_invalid": "即将到期的筛选天数无效,请输入1至365之间的整数。", + "presence_history.date_range_invalid": "在岗历史结束日期不能早于开始日期,请重新选择日期范围。", + "presence_history.date_range_too_large": "在岗历史每次最多查询31天,请缩小日期范围后重试。", + "dashboard.query_invalid": "统计筛选条件无效,请检查日期、部门和显示数量后重试。", + "dashboard.date_range_invalid": "统计结束日期不能早于开始日期,请重新选择日期范围。", + "ap_operations.disabled": "付款工作台暂未启用,请联系管理员确认。", + "ap_operations.ledger_query_invalid": "财务台账筛选条件无效,请检查日期、状态和分页后重试。", + "user.create_input_invalid": "账号资料不完整或格式有误,请检查姓名、邮箱、入职日期和角色后重新创建。", + "user.update_input_invalid": "账号修改内容格式有误,请检查联系方式、入职日期和角色后重新保存。", + "user.employee_no_immutable": "工号创建后不能修改,请保留原工号;如登记有误,请联系管理员核查。", + "user.update_empty": "没有可保存的账号修改,请修改资料后再保存。", + "user.import_rows_invalid": "导入表格中仍有错误行,本次未导入任何员工,请先预览并修正标出的行后重试。", "workflow.task_already_processed": "此审批任务已处理,请刷新待办查看最新进度。", "workflow.entity_unavailable": "审批对应的单据不存在或当前不可处理,请联系管理员核查。", "workflow.self_approval_forbidden": "不能审批本人申请或代提交的单据,请由符合资格的其他审批人处理。", @@ -227,5 +838,16 @@ "payment_batch.payment_account_required": "所属公司尚无可用的建行付款账户,请联系账户管理员维护后重试。", "payment_batch.payment_account_unavailable": "公司付款账户暂不可用,请联系管理员检查账户配置后重试。", "payment_batch.active_batch_exists": "所选单据已进入其他付款安排,请刷新列表并查看已有批次。", - "payment_batch.reviewer_required": "当前没有可复核的经理或负责人,制单人不能复核自己的批次,请联系管理员安排独立复核人。" + "payment_batch.reviewer_required": "当前没有可复核的经理或负责人,制单人不能复核自己的批次,请联系管理员安排独立复核人。", + "notification.list_query_invalid": "通知筛选条件无效,请检查页码、每页条数和未读筛选后重试。", + "notification.delivery_query_invalid": "通知投递筛选条件无效,请重新选择通知、渠道或投递状态后重试。", + "notification.id_invalid": "通知或渠道标识无效,请刷新列表后重新选择。", + "notification.channel_input_invalid": "通知渠道资料不完整或格式有误,请检查名称、发送方式、渠道配置和消息模板。", + "notification.channel_name_duplicate": "已有同名通知渠道,请更换名称后保存。", + "notification.encryption_key_unavailable": "通知渠道加密配置不可用,请联系管理员检查原字段加密密钥后再保存;不要生成新密钥替代。", + "todo.input_invalid": "待办资料不完整或格式有误,请检查标题、负责人、截止日期和优先级后重试。", + "todo.id_invalid": "待办标识无效,请刷新列表后重新选择。", + "todo.disabled": "待办功能暂未启用,请联系管理员确认。", + "todo.query_invalid": "待办筛选条件无效,请检查状态、范围和负责人后重试。", + "portal.disabled": "工作台暂未启用,请联系管理员确认。" } diff --git a/server/tests/unit/helpers/activeNumbering.js b/server/tests/unit/helpers/activeNumbering.js new file mode 100644 index 00000000..a13e01c0 --- /dev/null +++ b/server/tests/unit/helpers/activeNumbering.js @@ -0,0 +1,21 @@ +// Explicit opt-in for route tests. Unrelated Prisma mocks are not patched. +function enableNumbering(tx) { + const counters = new Map(); + tx.businessNumberPolicy = { findUnique: vi.fn(async () => ({ version: 2, enabled: true, effective_at: new Date('2020-01-01T00:00:00Z') })) }; + tx.businessNumberCounter = { + findUnique: vi.fn(async ({ where }) => counters.get(where.key) ?? null), + upsert: vi.fn(async ({ where, create, update }) => { + const row = { ...(counters.get(where.key) ?? create), ...update }; + counters.set(where.key, row); return row; + }), + }; + const previous = tx.$queryRaw; + tx.$queryRaw = vi.fn(async (query, ...args) => { + if (query?.sql?.includes('AS maximum')) return [{ maximum: 0n }]; + if (previous) return previous(query, ...args); + throw new Error('Unexpected route test query'); + }); + tx.$executeRaw ??= vi.fn(async () => 0); + return tx; +} +module.exports = { enableNumbering }; diff --git a/server/tests/unit/integrations.dispatcher.test.js b/server/tests/unit/integrations.dispatcher.test.js index 254e3fd8..478a3577 100644 --- a/server/tests/unit/integrations.dispatcher.test.js +++ b/server/tests/unit/integrations.dispatcher.test.js @@ -11,249 +11,6 @@ describe('dispatchOutboxEvent', () => { vi.restoreAllMocks(); }); - it('handles mock ERP success', async () => { - const result = await dispatchOutboxEvent({ - config: { - erpSyncEnabled: true, - erpSyncProvider: 'mock', - }, - eventType: 'erp.reimbursement.paid', - payload: { - reimbursementId: 'rb-1', - formNumber: 'BX-001', - }, - }); - - expect(result.channel).toBe('erp'); - expect(result.code).toBe('mock_synced'); - expect(result.response.externalId).toContain('BX-001'); - }); - - it('records integration binding for reimbursement journal export', async () => { - const prisma = { - integrationBinding: { - upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }), - }, - }; - - const result = await dispatchOutboxEvent({ - prisma, - config: { - erpSyncEnabled: true, - erpSyncProvider: 'mock', - }, - eventType: 'erp.journal.export', - payload: { - journalType: 'reimbursement_paid', - entityType: 'reimbursement_form', - entityId: 'rb-1', - documentNo: 'BX-20260308-0001', - }, - }); - - expect(result.channel).toBe('erp'); - expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: 'reimbursement_journal', - local_id: 'rb-1', - }, - }, - }), - ); - }); - - it('records commitment binding for procurement journal export', async () => { - const prisma = { - integrationBinding: { - upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }), - }, - }; - - await dispatchOutboxEvent({ - prisma, - config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, - eventType: 'erp.journal.export', - payload: { - journalType: 'purchase_request_commitment', - entityType: 'purchase_request', - entityId: 'pr-1', - documentNo: 'PR-001', - references: { purchaseRequestId: 'pr-1', requestNo: 'PR-001' }, - }, - }); - - expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: 'purchase_request_commitment_journal', - local_id: 'pr-1', - }, - }, - }), - ); - }); - - it('records accrual binding for procurement journal export', async () => { - const prisma = { - integrationBinding: { - upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }), - }, - }; - - await dispatchOutboxEvent({ - prisma, - config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, - eventType: 'erp.journal.export', - payload: { - journalType: 'purchase_order_accrual', - entityType: 'purchase_order_archive', - entityId: 'archive-1', - documentNo: 'PO-001', - references: { purchaseOrderArchiveId: 'archive-1', poNo: 'PO-001' }, - }, - }); - - expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: 'purchase_order_accrual_journal', - local_id: 'archive-1', - }, - }, - }), - ); - }); - - it('records PO and contract bindings for procurement summary export', async () => { - const prisma = { - integrationBinding: { - upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }), - }, - }; - - await dispatchOutboxEvent({ - prisma, - config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, - eventType: 'erp.procurement.export', - payload: { - exportType: 'purchase_order_archive', - entityType: 'purchase_order_archive', - entityId: 'archive-1', - poNo: 'PO-001', - contractNo: 'CT-001', - }, - }); - - expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: 'purchase_order_po', - local_id: 'archive-1', - }, - }, - }), - ); - expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: 'purchase_order_contract', - local_id: 'archive-1', - }, - }, - }), - ); - }); - - it('records asset binding for asset export', async () => { - const prisma = { - integrationBinding: { - upsert: vi.fn().mockResolvedValue({ id: 'binding-asset-1' }), - }, - }; - - await dispatchOutboxEvent({ - prisma, - config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, - eventType: 'erp.asset.export', - payload: { - exportType: 'asset_ledger_summary', - entityType: 'asset_card', - entityId: 'asset-1', - assetNo: 'ZC-001', - }, - }); - - expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: 'asset_erp_card', - local_id: 'asset-1', - }, - }, - }), - ); - }); - - it('records summary and settlement bindings for travel reconciliation export', async () => { - const prisma = { - integrationBinding: { - upsert: vi.fn().mockResolvedValue({ id: 'binding-travel-1' }), - }, - }; - - await dispatchOutboxEvent({ - prisma, - config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, - eventType: 'erp.travel.export', - payload: { - exportType: 'travel_reconciliation_summary', - entityType: 'travel_reconciliation_batch', - entityId: 'batch-1', - batchNo: 'TRB-20260311-0001', - }, - }); - - expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: 'travel_payable_summary', - local_id: 'batch-1', - }, - }, - }), - ); - expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - where: { - provider_entity_type_local_id: { - provider: 'erp', - entity_type: 'travel_settlement_batch', - local_id: 'batch-1', - }, - }, - }), - ); - }); - it('loads IM channel and dispatches mock wecom message', async () => { const prisma = { notificationChannel: { diff --git a/server/tests/unit/routes/ai.test.js b/server/tests/unit/routes/ai.test.js index 754246f9..1c31c426 100644 --- a/server/tests/unit/routes/ai.test.js +++ b/server/tests/unit/routes/ai.test.js @@ -318,3 +318,7 @@ describe("AI routes", () => { await outsiderApp.close(); }); }); + +describe("AI target input reasons", () => { + it.each([["reimbursements", "ai.reimbursement_id_invalid"], ["purchase-requests", "ai.purchase_id_invalid"], ["assets", "ai.asset_id_invalid"]])("rejects invalid %s id before DB/provider", async (path, reasonCode) => { const prisma = buildPrisma(); const app = await buildTestApp({ prisma, employee: { id: "synthetic", permissionCodes: ["ai.assist"] }, config: { aiProvider: "mock" } }); try { const r = await app.inject({ method: "POST", url: `/api/ai/suggestions/${path}/bad`, headers: { authorization: "Bearer synthetic" }, payload: {} }); expect(r.statusCode).toBe(400); expect(r.json().error.details.reasonCode).toBe(reasonCode); for (const model of Object.values(prisma)) for (const fn of Object.values(model)) expect(fn).not.toHaveBeenCalled(); } finally { await app.close(); } }); +}); diff --git a/server/tests/unit/routes/announcements.test.js b/server/tests/unit/routes/announcements.test.js index 0b5ad25b..653bd6a7 100644 --- a/server/tests/unit/routes/announcements.test.js +++ b/server/tests/unit/routes/announcements.test.js @@ -228,7 +228,7 @@ async function buildApp(options = {}) { const actor = options.actor ?? buildEmployee({ id: EMPLOYEE_ID, permissionCodes: ["expense.view_own"] }); const app = Fastify({ logger: false }); - app.decorate("config", { announcementsEnabled: true }); + app.decorate("config", { announcementsEnabled: options.enabled ?? true }); app.decorate("prisma", state.prisma); app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes(permissionCode)); app.decorate("assertPermission", (employee, permissionCode) => { @@ -283,6 +283,7 @@ describe("announcements policy acknowledgement routes", () => { expect(response.statusCode).toBe(400); expect(response.json().error.details.acknowledgementStatement).toBe("required_for_policy_acknowledgement"); + expect(response.json().error.details.reasonCode).toBe("announcement.statement_required"); await app.close(); }); @@ -346,6 +347,7 @@ describe("announcements policy acknowledgement routes", () => { expect(general.statusCode).toBe(409); expect(general.json().error.details.acknowledgement).toBe("not_required"); + expect(general.json().error.details.reasonCode).toBe("announcement.acknowledgement_not_required"); expect(hidden.statusCode).toBe(404); await app.close(); }); @@ -421,6 +423,106 @@ describe("announcements policy acknowledgement routes", () => { expect(response.statusCode).toBe(409); expect(response.json().error.details.acknowledgement).toBe("create_new_version_after_acknowledgement"); + expect(response.json().error.details.reasonCode).toBe("announcement.acknowledged_content_locked"); await app.close(); }); }); + +describe("announcement time and reason contracts", () => { + const admin = buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }); + const fields = ["startsAt", "endsAt", "acknowledgementDueAt"]; + const invalid = ["2026-02-29T12:00:00Z", "2024-02-30T08:00:00+08:00", "2026-04-31T23:59", "2026-13-01T00:00:00Z", "2026-01-01Tgarbage", "2026-01-01T25:00:00Z", "2026-01-01T12:60:00Z", "2026-01-01T"]; + function expectNoBusinessCalls(state) { + for (const group of [state.prisma.portalAnnouncement, state.prisma.portalAnnouncementAcknowledgement, state.prisma.employee, state.prisma.auditLog]) { + for (const fn of Object.values(group)) expect(fn).not.toHaveBeenCalled(); + } + expect(state.prisma.$transaction).not.toHaveBeenCalled(); + } + + it.each(["POST", "PATCH"].flatMap((method) => fields.flatMap((field) => invalid.map((value) => [method, field, value]))))("rejects %s %s=%s before business access", async (method, field, value) => { + const { app, state } = await buildApp({ actor: admin }); + try { + const response = await app.inject({ method, url: method === "POST" ? "/api/announcements" : `/api/announcements/${POLICY_ID}`, payload: { title: "合成公告", summary: "合成内容", [field]: value } }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: method === "POST" ? "announcement.create_input_invalid" : "announcement.update_input_invalid", fieldErrors: { [field]: ["invalid_calendar_datetime"] } } }); + expectNoBusinessCalls(state); + } finally { await app.close(); } + }); + + it.each(["2024-02-29T23:59:59.123Z", "2024-02-29T23:30:00-12:00", "2024-03-01T00:30:00+08:00", "2024-04-30T12:34", "2024-04-30T12:34:56.789", "2024-04-30T24:00:00Z"])("retains valid offset/local/rollover compatibility: %s", async (value) => { + const { app, state } = await buildApp({ actor: admin }); + try { + const response = await app.inject({ method: "POST", url: "/api/announcements", payload: { title: "合成公告", summary: "合成内容", category: "policy", acknowledgementStatement: "合成签收声明", ...Object.fromEntries(fields.map((field) => [field, value])) } }); + expect(response.statusCode).toBe(201); + const data = state.prisma.portalAnnouncement.create.mock.calls[0][0].data; + for (const field of ["starts_at", "ends_at", "acknowledgement_due_at"]) expect(data[field]).toEqual(new Date(value)); + const patched = await app.inject({ method: "PATCH", url: `/api/announcements/${POLICY_ID}`, payload: Object.fromEntries(fields.map((field) => [field, value])) }); + expect(patched.statusCode).toBe(200); + const patchData = state.prisma.portalAnnouncement.update.mock.calls[0][0].data; + for (const field of ["starts_at", "ends_at", "acknowledgement_due_at"]) expect(patchData[field]).toEqual(new Date(value)); + } finally { await app.close(); } + }); + + it("preserves null clearing and omitted timestamps", async () => { + const { app, state } = await buildApp({ actor: admin }); + try { + const created = await app.inject({ method: "POST", url: "/api/announcements", payload: { title: "合成公告", summary: "合成内容" } }); + expect(created.statusCode).toBe(201); + expect(state.prisma.portalAnnouncement.create.mock.calls[0][0].data).toMatchObject({ starts_at: null, ends_at: null, acknowledgement_due_at: null }); + const currentDue = state.announcements.find((item) => item.id === POLICY_ID).acknowledgement_due_at; + const omitted = await app.inject({ method: "PATCH", url: `/api/announcements/${POLICY_ID}`, payload: { priority: 11 } }); + expect(omitted.statusCode).toBe(200); + expect(state.prisma.portalAnnouncement.update.mock.calls[0][0].data).not.toHaveProperty("starts_at"); + expect(state.prisma.portalAnnouncement.update.mock.calls[0][0].data.acknowledgement_due_at).toEqual(currentDue); + const cleared = await app.inject({ method: "PATCH", url: `/api/announcements/${POLICY_ID}`, payload: { startsAt: null, endsAt: null, acknowledgementDueAt: null } }); + expect(cleared.statusCode).toBe(200); + expect(state.prisma.portalAnnouncement.update.mock.calls[1][0].data).toMatchObject({ starts_at: null, ends_at: null, acknowledgement_due_at: null }); + } finally { await app.close(); } + }); + + it.each([ + ["GET", "/api/announcements?manage=invalid", undefined, "announcement.query_invalid"], + ["PATCH", "/api/announcements/invalid", { enabled: false }, "announcement.id_invalid"], + ["PATCH", `/api/announcements/${POLICY_ID}`, {}, "announcement.update_input_invalid"], + ["POST", `/api/announcements/${POLICY_ID}/acknowledge`, { confirmed: false }, "announcement.acknowledgement_input_invalid"], + ])("explains %s %s before reads/writes", async (method, url, payload, reasonCode) => { + const { app, state } = await buildApp({ actor: admin }); + try { + const response = await app.inject({ method, url, ...(payload ? { payload } : {}) }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode } }); + expectNoBusinessCalls(state); + } finally { await app.close(); } + }); + + it("explains the disabled feature before business access", async () => { + const { app, state } = await buildApp({ actor: admin, enabled: false }); + try { + const response = await app.inject({ method: "GET", url: "/api/announcements" }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ reasonCode: "announcement.disabled", featureFlag: "announcements_v1_disabled", reason: "env_hard_off" }); + expectNoBusinessCalls(state); + } finally { await app.close(); } + }); + + it("explains a legacy policy missing its statement without acknowledgement writes", async () => { + const { app, state } = await buildApp({ announcements: [buildAnnouncement({ acknowledgement_statement: null })] }); + try { + const response = await app.inject({ method: "POST", url: `/api/announcements/${POLICY_ID}/acknowledge`, payload: { confirmed: true } }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ reasonCode: "announcement.statement_missing", acknowledgementStatement: "missing_for_policy_acknowledgement" }); + expect(state.prisma.$transaction).not.toHaveBeenCalled(); + expect(state.prisma.portalAnnouncementAcknowledgement.create).not.toHaveBeenCalled(); + expect(state.prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it("retains permission guards with valid dates", async () => { + const { app, state } = await buildApp(); + try { + const response = await app.inject({ method: "POST", url: "/api/announcements", payload: { title: "合成公告", summary: "合成内容", startsAt: "2024-02-29T00:00:00Z" } }); + expect(response.statusCode).toBe(403); + expectNoBusinessCalls(state); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/apOperations.permissions.test.js b/server/tests/unit/routes/apOperations.permissions.test.js index 5b362825..896a2041 100644 --- a/server/tests/unit/routes/apOperations.permissions.test.js +++ b/server/tests/unit/routes/apOperations.permissions.test.js @@ -35,7 +35,7 @@ async function buildApp(permissionCodes, enabled = true) { }); app.decorate("hasPermission", (employee, permission) => hasPermission(employee.permissionCodes, permission)); app.setErrorHandler((error, request, reply) => { - reply.code(error.statusCode ?? 500).send({ error: { code: error.code } }); + reply.code(error.statusCode ?? 500).send({ error: { code: error.code, ...(error.details && Object.keys(error.details).length ? { details: error.details } : {}) } }); }); await app.register(apOperationsRoutes, { prefix: "/api" }); return { app, queries }; @@ -88,10 +88,38 @@ describe("AP full-ledger permission boundary", () => { it("preserves feature-off refusal without querying financial data", async () => { const { app, queries } = await buildApp(["finance.ledger.view"], false); try { - for (const url of paths) expect((await app.inject({ method: "GET", url })).statusCode).toBe(409); + for (const url of paths) { + const response = await app.inject({ method: "GET", url }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ featureFlag: "payment_batch_v1_disabled", reason: "flag_force_off", reasonCode: "ap_operations.disabled" }); + } for (const query of queries) expect(query).not.toHaveBeenCalled(); } finally { await app.close(); } }); }); + +describe("AP direct ledger calendar boundary", () => { + it.each(["dateFrom", "dateTo"].flatMap((field) => ["2026-02-29", "2026-04-31", "2026-13-01"].map((date) => [field, date])))( + "rejects %s=%s before ledger queries", async (field, date) => { + const { app, queries } = await buildApp(["finance.ledger.view"]); + try { + const response = await app.inject({ method: "GET", url: `/api/ap-operations/reimbursement-ledger?${field}=${date}` }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe("VALID_001"); + expect(response.json().error.details).toEqual({ formErrors: [], fieldErrors: { [field]: ["invalid_calendar_date"] }, reasonCode: "ap_operations.ledger_query_invalid" }); + for (const query of queries) expect(query).not.toHaveBeenCalled(); + } finally { await app.close(); } + }, + ); + it.each([["2024-02-29", "2026-04-30"], ["2026-04-30", "2024-02-29"]])( + "preserves UTC bounds and existing reverse-range semantics %s / %s", async (dateFrom, dateTo) => { + const { app } = await buildApp(["finance.ledger.view"]); + try { + expect((await app.inject({ method: "GET", url: `/api/ap-operations/reimbursement-ledger?dateFrom=${dateFrom}&dateTo=${dateTo}` })).statusCode).toBe(200); + expect(app.prisma.reimbursementForm.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { AND: expect.arrayContaining([{ reimbursement_date: { gte: new Date(`${dateFrom}T00:00:00.000Z`), lte: new Date(`${dateTo}T23:59:59.999Z`) } }]) } })); + } finally { await app.close(); } + }, + ); +}); diff --git a/server/tests/unit/routes/approval-config.errors.test.js b/server/tests/unit/routes/approval-config.errors.test.js new file mode 100644 index 00000000..41d0ea73 --- /dev/null +++ b/server/tests/unit/routes/approval-config.errors.test.js @@ -0,0 +1,59 @@ +const Fastify = require("fastify"); +const { AppError } = require("../../../src/utils/AppError"); +const workflowRoutes = require("../../../src/routes/workflowDefinitions"); +const ruleRoutes = require("../../../src/routes/approvalRules"); +const ID = "11111111-1111-4111-8111-111111111111"; +const ACTOR = "22222222-2222-4222-8222-222222222222"; +const STEP = { id: "synthetic", name: "合成审批", assigneeRule: { type: "role", value: "finance" } }; +const DEFINITION = { entityType: "leave", name: "合成流程", steps: [STEP] }; +const RULE = { name: "合成规则", minAmount: "0.00", maxAmount: "100.00", level1Role: "manager" }; +const apps = []; +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); +async function build({ allowed = true, enabled = true } = {}) { + const app = Fastify(); apps.push(app); + const definition = { id: ID, entity_type: "leave", name: "合成流程", version: 2, is_active: false, archived_at: null, published_at: null, definition_json: { steps: [{ id: STEP.id, name: STEP.name, assignee_rule: STEP.assigneeRule }] } }; + const rule = { id: ID, name: "合成规则", min_amount: "0.00", max_amount: "100.00", level1_role: "manager", level2_role: "ceo", level3_role: "finance", owner_self_fallback_role: "finance", enabled: true, priority: 2, created_at: new Date("2026-01-01T00:00:00Z"), updated_at: new Date("2026-01-01T00:00:00Z") }; + const model = (row) => ({ findUnique: vi.fn(async () => row), findFirst: vi.fn(async () => row), findMany: vi.fn(async () => [row]), create: vi.fn(async ({ data }) => ({ ...row, ...data })), update: vi.fn(async ({ data }) => Object.assign(row, data)), updateMany: vi.fn().mockResolvedValue({ count: 1 }), aggregate: vi.fn().mockResolvedValue({ _max: { priority: 2 } }) }); + const prisma = { workflowDefinition: model(definition), approvalRule: model(rule), auditLog: { create: vi.fn().mockResolvedValue({}) }, $queryRaw: vi.fn().mockResolvedValue([{ id: ID }]), featureFlag: { findUnique: vi.fn().mockResolvedValue({ override_mode: enabled ? "force_on" : "force_off", rollout_type: "global", department_ids: [], version: 1 }) } }; + prisma.$transaction = vi.fn(async (cb) => cb(prisma)); + app.decorate("prisma", prisma); app.decorate("config", { workflowDefinitionAdminEnabled: true }); + app.decorate("requirePermission", () => async (request) => { request.employee = { id: ACTOR }; if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); }); + app.setErrorHandler((error, _, reply) => reply.status(error instanceof AppError ? error.statusCode : 500).send({ error: { code: error.code, details: error.details ?? {} } })); + await app.register(workflowRoutes, { prefix: "/api" }); await app.register(ruleRoutes, { prefix: "/api" }); + return { app, prisma, definition, rule }; +} +function noWrites(prisma) { for (const model of Object.values(prisma)) for (const op of ["create", "update", "updateMany"]) if (model[op]) expect(model[op]).not.toHaveBeenCalled(); } +function rejected(r, reason, status, details = {}) { expect(r.statusCode).toBe(status); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: reason, ...details } }); } +const inject = (app, method, path, payload) => app.inject({ method, url: `/api${path}`, payload }); +describe("workflow definition error contracts", () => { + it.each([["POST", "", {} , "create_input_invalid"], ["PATCH", `/${ID}`, {}, "update_input_invalid"], ["POST", "", { ...DEFINITION, steps: Array.from({ length: 21 }, () => STEP) }, "create_input_invalid"]])("explains %s %s input", async (method, suffix, payload, reason) => { const { app, prisma } = await build(); rejected(await inject(app, method, `/workflow-definitions${suffix}`, payload), `workflow_definition.${reason}`, 400); expect(prisma.workflowDefinition.findUnique).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each([["GET", ""], ["PATCH", ""], ["POST", "/publish"], ["POST", "/archive"]])("explains %s invalid id %s", async (method, suffix) => { const { app, prisma } = await build(); rejected(await inject(app, method, `/workflow-definitions/bad${suffix}`, method === "PATCH" ? {} : undefined), "workflow_definition.id_invalid", 400, { id: "invalid_uuid" }); noWrites(prisma); }); + it("explains invalid active entity", async () => { const { app, prisma } = await build(); rejected(await inject(app, "GET", "/workflow-definitions/entity/bad/active"), "workflow_definition.entity_type_invalid", 400, { entityType: "invalid" }); expect(prisma.workflowDefinition.findFirst).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each(["POST", "PATCH", "publish", "archive"])("preserves reimbursement projection write guard %s", async (operation) => { const { app, prisma, definition } = await build(); definition.entity_type = "reimbursement"; const method = operation === "PATCH" ? "PATCH" : "POST"; const suffix = operation === "POST" ? "" : `/${ID}${["publish", "archive"].includes(operation) ? `/${operation}` : ""}`; rejected(await inject(app, method, `/workflow-definitions${suffix}`, operation === "POST" ? { ...DEFINITION, entityType: "reimbursement" } : operation === "PATCH" ? { name: "合成" } : undefined), "workflow_definition.reimbursement_readonly", 409, { entityType: "unsupported_entity_type" }); noWrites(prisma); }); + it.each(["published", "archived"])("explains readonly %s definition", async (status) => { const { app, prisma, definition } = await build(); if (status === "archived") definition.archived_at = new Date("2026-01-01"); else { definition.published_at = new Date("2026-01-01"); definition.is_active = true; } rejected(await inject(app, "PATCH", `/workflow-definitions/${ID}`, { notes: "合成" }), "workflow_definition.not_editable", 409, { status: "published_or_archived_readonly" }); noWrites(prisma); }); + it("rejects archived publish before deactivating siblings", async () => { const { app, prisma, definition } = await build(); definition.archived_at = new Date("2026-01-01"); rejected(await inject(app, "POST", `/workflow-definitions/${ID}/publish`), "workflow_definition.archived_not_publishable", 409, { status: "archived_not_publishable" }); noWrites(prisma); }); + it("preserves feature and permission priority", async () => { + const disabled = await build({ enabled: false }); rejected(await inject(disabled.app, "POST", "/workflow-definitions", {}), "workflow_definition.feature_disabled", 409, { featureFlag: "workflow_definition_admin_v1_disabled" }); noWrites(disabled.prisma); expect(disabled.prisma.workflowDefinition.findFirst).not.toHaveBeenCalled(); + const denied = await build({ allowed: false }); expect((await inject(denied.app, "POST", "/workflow-definitions", {})).statusCode).toBe(403); expect(denied.prisma.featureFlag.findUnique).not.toHaveBeenCalled(); + }); + it("retains projection, 20-step creation, inactive-version edit, publish and archive", async () => { + const { app, prisma, definition } = await build(); + const projection = await inject(app, "GET", "/workflow-definitions/reimbursement-derived"); expect(projection.statusCode).toBe(200); expect(projection.json().data.item).toMatchObject({ source: "approval_rules_projection", status: "derived_readonly" }); + expect((await inject(app, "POST", "/workflow-definitions", { ...DEFINITION, steps: Array.from({ length: 20 }, (_, i) => ({ ...STEP, id: `synthetic-${i}` })) })).statusCode).toBe(201); expect(prisma.workflowDefinition.create.mock.calls[0][0].data).toMatchObject({ version: 3, is_active: false }); + definition.published_at = new Date("2026-01-01"); definition.is_active = false; + expect((await inject(app, "PATCH", `/workflow-definitions/${ID}`, { notes: "合成说明" })).statusCode).toBe(200); expect(definition.definition_json.steps[0].assignee_rule).toEqual(STEP.assigneeRule); + expect((await inject(app, "POST", `/workflow-definitions/${ID}/publish`)).statusCode).toBe(200); expect(prisma.workflowDefinition.updateMany).toHaveBeenCalledWith({ where: { entity_type: "leave", id: { not: ID }, is_active: true }, data: { is_active: false, updated_by: ACTOR } }); + expect((await inject(app, "POST", `/workflow-definitions/${ID}/archive`)).statusCode).toBe(200); expect(definition).toMatchObject({ is_active: false, archived_at: expect.any(Date) }); + // The existing definition route does not add audits; migration does not invent them. + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); +describe("approval rule error contracts", () => { + it.each([["POST", "", {}, "create_input_invalid"], ["PATCH", `/${ID}`, { level1Role: "admin" }, "update_input_invalid"], ["PATCH", `/${ID}`, {}, "empty_update"], ["PATCH", "/bad", {}, "id_invalid"]])("explains %s %s rule input", async (method, suffix, payload, reason) => { const { app, prisma } = await build(); rejected(await inject(app, method, `/approval-rules${suffix}`, payload), `approval_rule.${reason}`, 400); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each([["POST", { ...RULE, minAmount: "9".repeat(400) }, "amount_invalid", { amount: "invalid" }], ["POST", { ...RULE, maxAmount: "0.00" }, "amount_range_invalid", { maxAmount: "must_be_greater_than_minAmount" }], ["PATCH", { minAmount: "100.00" }, "amount_range_invalid", { maxAmount: "must_be_greater_than_minAmount" }], ["PATCH", { maxAmount: "9".repeat(400) }, "amount_invalid", { amount: "invalid" }]])("explains %s range %s", async (method, payload, reason, detail) => { const { app, prisma } = await build(); rejected(await inject(app, method, `/approval-rules${method === "PATCH" ? `/${ID}` : ""}`, payload), `approval_rule.${reason}`, 400, detail); noWrites(prisma); }); + it("preserves max null, fallback default, priority and locked update audit", async () => { + const { app, prisma, rule } = await build(); const create = await inject(app, "POST", "/approval-rules", { ...RULE, maxAmount: null }); expect(create.statusCode).toBe(200); expect(prisma.approvalRule.create.mock.calls[0][0].data).toMatchObject({ max_amount: null, owner_self_fallback_role: "finance", priority: 3 }); + expect((await inject(app, "PATCH", `/approval-rules/${ID}`, { minAmount: "200.00", maxAmount: null, ownerSelfFallbackRole: null })).statusCode).toBe(200); expect(rule).toMatchObject({ min_amount: "200.00", max_amount: null, owner_self_fallback_role: null }); expect(prisma.$queryRaw).toHaveBeenCalledOnce(); expect(prisma.auditLog.create).toHaveBeenCalledTimes(2); + }); + it("keeps rule permission and missing-lock 404", async () => { const denied = await build({ allowed: false }); expect((await inject(denied.app, "POST", "/approval-rules", {})).statusCode).toBe(403); noWrites(denied.prisma); const missing = await build(); missing.prisma.$queryRaw.mockResolvedValue([]); expect((await inject(missing.app, "PATCH", `/approval-rules/${ID}`, { name: "合成" })).statusCode).toBe(404); noWrites(missing.prisma); }); +}); diff --git a/server/tests/unit/routes/asset-operations.errors.test.js b/server/tests/unit/routes/asset-operations.errors.test.js new file mode 100644 index 00000000..5603cc89 --- /dev/null +++ b/server/tests/unit/routes/asset-operations.errors.test.js @@ -0,0 +1,121 @@ +const { enableNumbering } = require("../helpers/activeNumbering"); +const Fastify = require("fastify"); +const { AppError } = require("../../../src/utils/AppError"); +const notifications = require("../../../src/services/notifications"); +const email = require("../../../src/services/email"); +const dispatcher = require("../../../src/services/notificationDispatcher"); +const collaborators = [[notifications, "createNotificationsWithRecords"], [notifications, "resolveRecipientsForAssignee"], [email, "sendNotificationEmailSafe"], [dispatcher, "flushNotificationDispatchTasks"]]; +const saved = collaborators.map(([object, key]) => object[key]); +const mocks = {}; +collaborators.forEach(([object, key]) => { object[key] = mocks[key] = vi.fn(); }); +const paths = [require.resolve("../../../src/routes/assetMovements"), require.resolve("../../../src/routes/inventoryChecks")]; +const savedRoutes = paths.map((path) => require.cache[path]); +const routes = paths.map((path) => { delete require.cache[path]; return require(path); }); +collaborators.forEach(([object, key], i) => { object[key] = saved[i]; }); +afterAll(() => paths.forEach((path, i) => { if (savedRoutes[i]) require.cache[path] = savedRoutes[i]; else delete require.cache[path]; })); +const ID = "11111111-1111-4111-8111-111111111111"; +const PERSON = "22222222-2222-4222-8222-222222222222"; +const OTHER = "33333333-3333-4333-8333-333333333333"; +const REF = "44444444-4444-4444-8444-444444444444"; +const apps = []; +beforeEach(() => { Object.values(mocks).forEach((fn) => fn.mockReset().mockResolvedValue([])); }); +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); +async function build({ allowed = true } = {}) { + const app = Fastify(); apps.push(app); + const asset = { id: ID, asset_no: "SYNTHETIC", name: "合成资产", status: "in_stock", assignee_employee_id: null, location: "合成甲地", legal_entity_id: REF, cost_center_id: REF, created_at: new Date("2026-01-01T00:00:00Z") }; + const check = { id: ID, check_no: "SYNTHETIC-CHECK", scope_type: "all", scope_ref_id: null, status: "draft", created_by: PERSON, items: [] }; + const model = (row) => ({ findUnique: vi.fn(async () => row), findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn(async () => [row]), create: vi.fn(async ({ data }) => ({ ...row, ...data })), update: vi.fn(async ({ data }) => Object.assign(row, data)) }); + const prisma = { + assetCard: model(asset), inventoryCheck: model(check), + employee: model({ id: OTHER, status: "active", name: "合成人员" }), + assetAssignment: model({ id: REF, employee_id: PERSON, asset_id: ID }), assetTransfer: model({ id: REF }), + legalEntity: model({ id: REF }), costCenter: model({ id: REF }), auditLog: model({ id: REF }), + inventoryCheckItem: { upsert: vi.fn().mockResolvedValue({}) }, + documentCategory: { findFirst: vi.fn().mockResolvedValue(null) }, + businessNumberPolicy: { findUnique: vi.fn(async () => null) }, + $executeRaw: vi.fn(async () => 0), + documentSequence: { findUnique: vi.fn().mockResolvedValue(null), upsert: vi.fn(async ({ create }) => ({ ...create, current_number: 1 })) }, + }; + prisma.$transaction = vi.fn(async (cb) => cb(prisma)); + app.decorate("prisma", prisma); app.decorate("config", {}); + app.decorate("requirePermission", (permission) => async (request) => { request.employee = { id: PERSON }; if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限", details: { permission } }); }); + app.setErrorHandler((error, _, reply) => reply.status(error instanceof AppError ? error.statusCode : 500).send({ error: { code: error.code, details: error.details ?? {} } })); + for (const route of routes) await app.register(route, { prefix: "/api" }); + return { app, prisma, asset, check }; +} +function rejected(response, reason, status, details = {}) { expect(response.statusCode).toBe(status); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: reason, ...details } }); } +function noWrites(prisma) { + for (const [name, model] of Object.entries(prisma)) for (const op of ["create", "update", "upsert"]) if (model[op]) expect(model[op], `${name}.${op}`).not.toHaveBeenCalled(); + for (const fn of Object.values(mocks)) expect(fn).not.toHaveBeenCalled(); +} +const move = (app, op, body, id = ID) => app.inject({ method: "POST", url: `/api/assets/${id}/${op}`, payload: body }); +const inventory = (app, op, body, id = ID) => app.inject({ method: "POST", url: `/api/inventory-checks${op ? `/${id}/${op}` : ""}`, payload: body }); + +describe("asset movement rejection contracts", () => { + it.each(["assign", "return", "transfer"])("explains invalid id for %s before transaction", async (op) => { const { app, prisma } = await build(); rejected(await move(app, op, {}, "bad"), "asset_movement.id_invalid", 400, { id: "invalid_uuid" }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each([["assign", {}], ["return", { unexpected: true }], ["transfer", { toEmployeeId: "bad" }]])("explains %s schema", async (op, body) => { const { app, prisma } = await build(); rejected(await move(app, op, body), `asset_movement.${op}_input_invalid`, 400, { fieldErrors: expect.any(Object) }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each(["assign", "return", "transfer"].flatMap((op) => ["retired", "archived"].map((status) => [op, status])))("keeps %s forbidden for %s", async (op, status) => { const { app, prisma, asset } = await build(); asset.status = status; rejected(await move(app, op, op === "assign" ? { employeeId: OTHER } : {}), "asset_movement.status_blocked", 409, { status: "movement_not_allowed" }); noWrites(prisma); }); + it.each(["assign", "return", "transfer"])("keeps %s missing asset 404", async (op) => { const { app, prisma } = await build(); prisma.assetCard.findUnique.mockResolvedValue(null); rejected(await move(app, op, op === "assign" ? { employeeId: OTHER } : {}), "asset_movement.not_found", 404, { id: "not_found" }); noWrites(prisma); }); + it.each(["assignee", "open_assignment"])("rejects existing %s before employee or mutation", async (kind) => { const { app, prisma, asset } = await build(); if (kind === "assignee") asset.assignee_employee_id = PERSON; else prisma.assetAssignment.findFirst.mockResolvedValue({ id: REF }); rejected(await move(app, "assign", { employeeId: OTHER }), "asset_movement.already_assigned", 409, { asset: "already_assigned" }); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each(["assign", "transfer"].flatMap((op) => [null, { id: OTHER, status: "inactive" }].map((row) => [op, row])))("explains %s receiving employee %s", async (op, row) => { const { app, prisma } = await build(); prisma.employee.findUnique.mockResolvedValue(row); const field = op === "assign" ? "employeeId" : "toEmployeeId"; rejected(await move(app, op, { [field]: OTHER }), `asset_movement.employee_${row ? "inactive" : "not_found"}`, row ? 409 : 404, { [field]: row ? "inactive" : "not_found" }); noWrites(prisma); }); + it("rejects unassigned return", async () => { const { app, prisma } = await build(); rejected(await move(app, "return", {}), "asset_movement.not_assigned", 409, { asset: "not_assigned" }); noWrites(prisma); }); + it("directs clearing an assignee to return", async () => { const { app, prisma, asset } = await build(); asset.assignee_employee_id = PERSON; rejected(await move(app, "transfer", { toEmployeeId: null }), "asset_movement.use_return", 409, { toEmployeeId: "use_return" }); noWrites(prisma); }); + it.each([{}, { toLocation: "合成甲地" }, { toEmployeeId: null }])("rejects no-change transfer %s", async (body) => { const { app, prisma } = await build(); rejected(await move(app, "transfer", body), "asset_movement.no_change", 409, { transfer: "no_change" }); noWrites(prisma); }); + it("preserves assign, return and transfer effects with notification stubs", async () => { + const { app, prisma, asset } = await build(); + expect((await move(app, "assign", { employeeId: OTHER, note: " 合成领用 " })).statusCode).toBe(200); + expect(asset).toMatchObject({ assignee_employee_id: OTHER, status: "in_use" }); + expect(prisma.assetAssignment.create.mock.calls[0][0].data.note).toBe("合成领用"); + expect((await move(app, "return", {})).statusCode).toBe(200); + expect(asset).toMatchObject({ assignee_employee_id: null, status: "in_stock" }); + // No open assignment in this synthetic DB; the original fallback creates and closes it. + expect(prisma.assetAssignment.create).toHaveBeenCalledTimes(2); expect(prisma.assetAssignment.update).toHaveBeenCalledOnce(); + expect((await move(app, "transfer", { toEmployeeId: OTHER, toLocation: " 合成乙地 " })).statusCode).toBe(200); + expect(asset).toMatchObject({ assignee_employee_id: OTHER, location: "合成乙地", status: "in_use" }); + expect(prisma.assetTransfer.create).toHaveBeenCalledOnce(); + expect(mocks.createNotificationsWithRecords).toHaveBeenCalledTimes(3); expect(mocks.sendNotificationEmailSafe).toHaveBeenCalledTimes(3); expect(mocks.flushNotificationDispatchTasks).toHaveBeenCalledTimes(3); + expect(prisma.auditLog.create.mock.calls.map(([x]) => x.data.action)).toEqual(["ASSET_ASSIGNED", "ASSET_RETURNED", "ASSET_TRANSFERRED"]); + }); + it("preserves location-only transfer without closing assignment", async () => { const { app, prisma, asset } = await build(); asset.assignee_employee_id = PERSON; asset.status = "under_repair"; expect((await move(app, "transfer", { toLocation: null })).statusCode).toBe(200); expect(asset).toMatchObject({ assignee_employee_id: PERSON, status: "under_repair", location: null }); expect(prisma.assetAssignment.create).not.toHaveBeenCalled(); expect(prisma.assetAssignment.update).not.toHaveBeenCalled(); }); +}); + +describe("inventory rejection contracts", () => { + it("rejects bad list query before access", async () => { const { app, prisma } = await build(); rejected(await app.inject({ method: "GET", url: "/api/inventory-checks?status=bad" }), "inventory_check.query_invalid", 400); expect(prisma.inventoryCheck.findMany).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each([["", {} , "create_input_invalid"], ["items", { items: [] }, "record_input_invalid"]])("rejects %s schema", async (op, body, reason) => { const { app, prisma } = await build(); rejected(await inventory(app, op, body), `inventory_check.${reason}`, 400); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each(["start", "items", "complete"])("explains invalid %s id", async (op) => { const { app, prisma } = await build(); rejected(await inventory(app, op, {}, "bad"), "inventory_check.id_invalid", 400, { id: "invalid_uuid" }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each(["legal_entity", "cost_center", "location"])("requires scope value for %s", async (scopeType) => { const { app, prisma } = await build(); rejected(await inventory(app, "", { scopeType }), "inventory_check.scope_required", 400, { scopeRefId: "required" }); noWrites(prisma); }); + it.each(["legal_entity", "cost_center"])("validates UUID scope %s", async (scopeType) => { const { app, prisma } = await build(); rejected(await inventory(app, "", { scopeType, scopeRefId: "bad" }), "inventory_check.scope_id_invalid", 400, { scopeRefId: "invalid_uuid" }); noWrites(prisma); }); + it.each([["legal_entity", "legalEntity", "company_not_found"], ["cost_center", "costCenter", "cost_center_not_found"]])("explains missing %s scope", async (scopeType, model, reason) => { const { app, prisma } = await build(); prisma[model].findUnique.mockResolvedValue(null); rejected(await inventory(app, "", { scopeType, scopeRefId: REF }), `inventory_check.${reason}`, 404, { scopeRefId: "not_found" }); noWrites(prisma); }); + it.each(["start", "items", "complete"])("explains missing %s check", async (op) => { const { app, prisma } = await build(); prisma.inventoryCheck.findUnique.mockResolvedValue(null); rejected(await inventory(app, op, op === "items" ? { items: [{ assetId: ID, result: "matched" }] } : undefined), "inventory_check.not_found", 404, { id: "not_found" }); noWrites(prisma); }); + it.each([["start", "counting", "start", "draft"], ["items", "draft", "record", "counting"], ["complete", "completed", "complete", "counting"]])("explains %s state %s", async (op, status, reason, expected) => { const { app, prisma, check } = await build(); check.status = status; rejected(await inventory(app, op, op === "items" ? { items: [{ assetId: ID, result: "matched" }] } : undefined), `inventory_check.${reason}_state_invalid`, 409, { status: `expected_${expected}` }); noWrites(prisma); }); + it.each(["legal_entity", "cost_center", "location"])("rejects asset outside %s", async (scopeType) => { const { app, prisma, check } = await build(); Object.assign(check, { status: "counting", scope_type: scopeType, scope_ref_id: OTHER }); rejected(await inventory(app, "items", { items: [{ assetId: ID, result: "matched" }] }), "inventory_check.asset_scope_mismatch", 409, { assetId: "scope_mismatch" }); noWrites(prisma); }); + it("rejects missing first asset without writes", async () => { const { app, prisma, check } = await build(); check.status = "counting"; prisma.assetCard.findUnique.mockResolvedValue(null); rejected(await inventory(app, "items", { items: [{ assetId: ID, result: "matched" }] }), "inventory_check.asset_not_found", 404, { assetId: "not_found" }); noWrites(prisma); }); + it.each(["duplicate", "missing", "scope"])("retains first upsert before later %s rejection", async (kind) => { + const { app, prisma, check, asset } = await build(); check.status = "counting"; + if (kind === "missing") prisma.assetCard.findUnique.mockResolvedValueOnce(asset).mockResolvedValueOnce(null); + if (kind === "scope") { Object.assign(check, { scope_type: "legal_entity", scope_ref_id: REF }); prisma.assetCard.findUnique.mockResolvedValueOnce(asset).mockResolvedValueOnce({ ...asset, id: OTHER, legal_entity_id: OTHER }); } + const reason = kind === "duplicate" ? "duplicate_asset" : kind === "missing" ? "asset_not_found" : "asset_scope_mismatch"; + rejected(await inventory(app, "items", { items: [{ assetId: ID, result: "matched" }, { assetId: kind === "duplicate" ? ID : OTHER, result: "missing" }] }), `inventory_check.${reason}`, kind === "missing" ? 404 : 409); + expect(prisma.inventoryCheckItem.upsert).toHaveBeenCalledOnce(); expect(prisma.inventoryCheck.update).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); + for (const fn of Object.values(mocks)) expect(fn).not.toHaveBeenCalled(); + }); + it.each([["all", "ignored", null], ["location", " 合成位置 ", "合成位置"], ["legal_entity", REF, REF], ["cost_center", REF, REF]])("retains create %s scope", async (scopeType, scopeRefId, expected) => { const { app, prisma } = await build(); const r = await inventory(app, "", { scopeType, scopeRefId }); expect(r.statusCode).toBe(200); expect(prisma.inventoryCheck.create.mock.calls[0][0].data).toMatchObject({ scope_type: scopeType, scope_ref_id: expected }); expect(prisma.documentSequence.upsert).toHaveBeenCalledOnce(); }); + it("retains start, record and complete with mock notification collaborators", async () => { + const { app, prisma, check } = await build(); + expect((await inventory(app, "start")).statusCode).toBe(200); expect(check.status).toBe("counting"); + expect((await inventory(app, "items", { items: [{ assetId: ID, result: "damaged", note: " 合成备注 " }] })).statusCode).toBe(200); + expect(prisma.inventoryCheckItem.upsert.mock.calls[0][0]).toMatchObject({ update: { result: "damaged", note: "合成备注" }, create: { inventory_check_id: ID, asset_id: ID } }); + expect((await inventory(app, "complete")).statusCode).toBe(200); expect(check.status).toBe("completed"); + expect(prisma.auditLog.create.mock.calls.map(([x]) => x.data.action)).toEqual(["INVENTORY_CHECK_STARTED", "INVENTORY_ITEM_RECORDED", "INVENTORY_CHECK_COMPLETED"]); + expect(mocks.createNotificationsWithRecords).toHaveBeenCalledOnce(); expect(mocks.sendNotificationEmailSafe).toHaveBeenCalledOnce(); expect(mocks.flushNotificationDispatchTasks).toHaveBeenCalledOnce(); + }); + it.each(["/assets/bad/assign", "/inventory-checks/bad/start"])("keeps permission first for %s", async (url) => { const { app, prisma } = await build({ allowed: false }); const r = await app.inject({ method: "POST", url: `/api${url}`, payload: {} }); expect(r.statusCode).toBe(403); expect(r.json().error.code).toBe("PERM_001"); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); +}); + +it("creates an annual inventory number under active policy", async () => { + const { app, prisma } = await build(); enableNumbering(prisma); + const response = await inventory(app, "", { scopeType: "all" }); + expect(response.statusCode).toBe(200); + expect(prisma.inventoryCheck.create.mock.calls[0][0].data.check_no).toMatch(/^PD-\d{4}-0001$/); + expect(prisma.documentSequence.upsert).not.toHaveBeenCalled(); +}); diff --git a/server/tests/unit/routes/assets.erp.test.js b/server/tests/unit/routes/assets.erp.test.js deleted file mode 100644 index 8a543f83..00000000 --- a/server/tests/unit/routes/assets.erp.test.js +++ /dev/null @@ -1,148 +0,0 @@ -const assetsRoutes = require('../../../src/routes/assets'); -const { resolveAssetLedgerSummaryChange } = require('../../../src/services/integrations/erpAsset'); - -describe('asset ERP helper', () => { - const { handleAssetLedgerSummaryErpSync } = assetsRoutes.__testables; - - function createRequest() { - return { - id: 'req-1', - ip: '127.0.0.1', - headers: {}, - employee: { - id: 'actor-1', - permissionCodes: ['asset.manage'], - }, - }; - } - - function createTx() { - return { - auditLog: { - create: vi.fn().mockResolvedValue({ id: 'audit-1' }), - }, - }; - } - - function createAsset(overrides = {}) { - return { - id: 'asset-1', - asset_no: 'ZC-001', - legal_entity_id: 'le-1', - cost_center_id: 'cc-1', - status: 'in_use', - ...overrides, - }; - } - - it('auto enqueues asset export when gate is enabled', async () => { - const tx = createTx(); - const evaluateGate = vi.fn().mockResolvedValue({ - enabled: true, - reason: 'department_deferred', - }); - const enqueueExport = vi.fn().mockResolvedValue({ - id: 'outbox-1', - status: 'pending', - duplicated: false, - requeued: false, - eventType: 'erp.asset.export', - }); - - const result = await handleAssetLedgerSummaryErpSync({ - tx, - config: {}, - request: createRequest(), - actorId: 'actor-1', - asset: createAsset(), - sourceEventName: 'asset.card.created', - changeType: 'created', - changedFields: [], - evaluateGate, - enqueueExport, - }); - - expect(enqueueExport).toHaveBeenCalledWith( - tx, - {}, - expect.objectContaining({ - actorId: 'actor-1', - requeueOnFailure: false, - }), - ); - expect(tx.auditLog.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - action: 'ERP_SYNC_ENQUEUED', - target_type: 'asset_card', - target_id: 'asset-1', - }), - }), - ); - expect(result.skipped).toBe(false); - }); - - it('writes skip audit when asset ERP gate is disabled', async () => { - const tx = createTx(); - const evaluateGate = vi.fn().mockResolvedValue({ enabled: false, reason: 'flag_force_off' }); - const enqueueExport = vi.fn(); - - const result = await handleAssetLedgerSummaryErpSync({ - tx, - config: {}, - request: createRequest(), - actorId: 'actor-1', - asset: createAsset(), - sourceEventName: 'asset.card.created', - changeType: 'created', - changedFields: [], - evaluateGate, - enqueueExport, - }); - - expect(enqueueExport).not.toHaveBeenCalled(); - expect(tx.auditLog.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG', - target_type: 'asset_card', - target_id: 'asset-1', - }), - }), - ); - expect(result.skipped).toBe(true); - }); - - it('only marks financial field and retired status changes as ERP-relevant', () => { - const current = createAsset(); - - expect( - resolveAssetLedgerSummaryChange(current, { - ...current, - name: '新资产名称', - }), - ).toBeNull(); - - expect( - resolveAssetLedgerSummaryChange(current, { - ...current, - legal_entity_id: 'le-2', - }), - ).toEqual({ - sourceEventName: 'asset.ledger.summary.updated', - changeType: 'financial_dimensions_changed', - changedFields: ['legalEntityId'], - }); - - expect( - resolveAssetLedgerSummaryChange(current, { - ...current, - status: 'retired', - }), - ).toEqual({ - sourceEventName: 'asset.ledger.summary.updated', - changeType: 'retired', - changedFields: ['status'], - }); - }); -}); diff --git a/server/tests/unit/routes/assets.errors.test.js b/server/tests/unit/routes/assets.errors.test.js new file mode 100644 index 00000000..4e88586a --- /dev/null +++ b/server/tests/unit/routes/assets.errors.test.js @@ -0,0 +1,339 @@ +const { enableNumbering } = require("../helpers/activeNumbering"); +const Fastify = require("fastify"); +const routes = require("../../../src/routes/assets"); +const { AppError } = require("../../../src/utils/AppError"); + +const ID = "11111111-1111-4111-8111-111111111111"; +const COMPANY = "22222222-2222-4222-8222-222222222222"; +const CENTER = "33333333-3333-4333-8333-333333333333"; +const PERSON = "44444444-4444-4444-8444-444444444444"; +const OTHER = "55555555-5555-4555-8555-555555555555"; +const BODY = { name: "合成资产", categoryId: ID, legalEntityId: COMPANY, costCenterId: CENTER }; +const asset = { id: ID, name: "合成资产", asset_no: "SYNTHETIC-1", asset_type: "fixed_asset", sensitivity_level: "internal", category_id: ID, legal_entity_id: COMPANY, cost_center_id: CENTER, status: "in_stock" }; +const doc = { id: ID, code: "ASSET_CARD_DEFAULT", entity_type: "asset_card", enabled: true, archived_at: null, sequence_prefix: "SYNTHETIC", number_length: 6, reset_period: "yearly" }; +const category = { id: ID, code: "SYNTHETIC", name: "合成分类", enabled: true, archived_at: null, document_category: doc }; +function fixture() { + const model = (row) => ({ findUnique: vi.fn().mockResolvedValue(row), findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]), create: vi.fn(async ({ data }) => ({ ...row, ...data })), update: vi.fn(async ({ data }) => ({ ...row, ...data })) }); + const prisma = { + assetCard: model(asset), assetCategory: model(category), documentCategory: model(doc), + legalEntity: model({ id: COMPANY, enabled: true }), costCenter: model({ id: CENTER, legal_entity_id: COMPANY, enabled: true }), + employee: model({ id: PERSON, status: "active" }), purchaseRequest: model({ id: OTHER, status: "approved" }), + assetAssignment: model({ id: OTHER }), auditLog: model({ id: OTHER }), integrationOutbox: model({ id: OTHER }), + businessNumberPolicy: { findUnique: vi.fn(async () => null) }, + $executeRaw: vi.fn(async () => 0), + documentSequence: { findUnique: vi.fn().mockResolvedValue(null), upsert: vi.fn(async ({ create }) => ({ ...create, current_number: 1 })) }, + featureFlag: { findUnique: vi.fn().mockResolvedValue({ override_mode: "force_off", rollout_type: "global", department_ids: [], version: 1 }) }, + }; + prisma.$transaction = vi.fn(async (cb) => cb(prisma)); + return prisma; +} +async function build(prisma = fixture(), permissions = ["asset.manage"]) { + const app = Fastify(); + app.decorate("prisma", prisma); app.decorate("config", {}); + app.decorate("authenticate", async (request) => { request.employee = { id: PERSON, permissionCodes: permissions }; }); + app.decorate("hasPermission", (_, permission) => permissions.includes(permission)); + app.decorate("requirePermission", (permission) => async (request) => { await app.authenticate(request); if (!permissions.includes(permission)) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); }); + app.setErrorHandler((error, _, reply) => reply.status(error instanceof AppError ? error.statusCode : 500).send({ error: { code: error.code, details: error.details ?? {} } })); + await app.register(routes, { prefix: "/api" }); + return { app, prisma }; +} +function noWrites(prisma) { + for (const [name, model] of Object.entries(prisma)) { + if (name === "$transaction") continue; + for (const op of ["create", "update", "upsert"]) if (model[op]) expect(model[op], `${name}.${op}`).not.toHaveBeenCalled(); + } +} +function rejected(response, reason, status, details = {}) { + expect(response.statusCode).toBe(status); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: `asset.${reason}`, ...details } }); +} +afterEach(() => vi.unstubAllEnvs()); + +describe("asset operation error contracts", () => { + it.each([ + ["GET", "/asset-categories?enabled=bad", undefined, "category_query_invalid"], + ["GET", "/assets?status=bad", undefined, "query_invalid"], + ["GET", `/assets/assignees?q=${"x".repeat(101)}`, undefined, "assignee_query_invalid"], + ["POST", "/asset-categories", {}, "category_create_input_invalid"], + ["PATCH", `/asset-categories/${ID}`, {}, "category_update_input_invalid"], + ["PATCH", `/asset-categories/${ID}`, { name: "" }, "category_update_input_invalid"], + ["POST", "/assets", {}, "create_input_invalid"], + ["PATCH", `/assets/${ID}`, {}, "update_input_invalid"], + ["PATCH", `/assets/${ID}`, { name: "" }, "update_input_invalid"], + ["POST", "/assets/import/redacted-doc-index", { items: [] }, "index_import_input_invalid"], + ["POST", "/assets/import/redacted-doc-index", { items: Array.from({ length: 21 }, () => ({ name: "合成索引" })) }, "index_import_input_invalid"], + ["PATCH", "/asset-categories/bad", { name: "合成分类" }, "category_id_invalid"], + ["GET", "/assets/bad", undefined, "id_invalid"], + ["PATCH", "/assets/bad", { name: "合成资产" }, "id_invalid"], + ])("rejects %s %s before database access", async (method, url, payload, reason) => { + const { app, prisma } = await build(); + try { + const response = await app.inject({ method, url: `/api${url}`, payload }); + rejected(response, reason, 400); + for (const model of Object.values(prisma)) { + if (typeof model === "function") expect(model).not.toHaveBeenCalled(); + else for (const fn of Object.values(model)) expect(fn).not.toHaveBeenCalled(); + } + } finally { await app.close(); } + }); + + it.each([ + ["name", "raw body", "raw_payload"], + ["sourceHash", "hash with spaces", "unsafe_hash_format"], + ["metadata", { text: "x".repeat(2000) }, "too_large"], + ["metadata", { text: "raw body" }, "raw_payload"], + ])("keeps create and patch unsafe %s rejection", async (field, value, detail) => { + const { app, prisma } = await build(); + try { + for (const method of ["POST", "PATCH"]) { + const response = await app.inject({ method, url: `/api/assets${method === "PATCH" ? `/${ID}` : ""}`, payload: { ...(method === "POST" ? BODY : {}), [field]: value } }); + rejected(response, "unsafe_content", 400, { [field]: detail }); + } + noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it.each([ + ["documentCategory", null, "document_category_not_found", 404, { documentCategoryId: "not_found" }], + ["documentCategory", { ...doc, entity_type: "purchase_request" }, "document_category_type_invalid", 409, { documentCategoryId: "unsupported_entity_type" }], + ["documentCategory", { ...doc, enabled: false }, "document_category_inactive", 409, { documentCategoryId: "inactive" }], + ["documentCategory", { ...doc, archived_at: new Date("2026-01-01") }, "document_category_inactive", 409, { documentCategoryId: "inactive" }], + ])("explains category reference %s %s", async (model, row, reason, status, details) => { + const prisma = fixture(); prisma[model].findUnique.mockResolvedValue(row); + const { app } = await build(prisma); + try { + const response = await app.inject({ method: "POST", url: "/api/asset-categories", payload: { code: "SYNTHETIC", name: "合成分类", documentCategoryId: ID } }); + rejected(response, reason, status, details); noWrites(prisma); + } finally { await app.close(); } + }); + + const references = [ + ["assetCategory", null, {}, "category_not_found", 404, "categoryId", "not_found"], + ["assetCategory", { ...category, enabled: false }, {}, "category_inactive", 409, "categoryId", "inactive"], + ["assetCategory", { ...category, document_category: { ...doc, entity_type: "expense" } }, {}, "category_document_invalid", 409, "categoryId", "invalid_document_category"], + ["assetCategory", { ...category, document_category: { ...doc, enabled: false } }, {}, "category_document_invalid", 409, "categoryId", "invalid_document_category"], + ["legalEntity", null, {}, "company_not_found", 404, "legalEntityId", "not_found"], + ["legalEntity", { id: COMPANY, enabled: false }, {}, "company_inactive", 409, "legalEntityId", "inactive"], + ["costCenter", null, {}, "cost_center_not_found", 404, "costCenterId", "not_found"], + ["costCenter", { id: CENTER, enabled: false }, {}, "cost_center_inactive", 409, "costCenterId", "inactive"], + ["costCenter", { id: CENTER, enabled: true, legal_entity_id: OTHER }, {}, "cost_center_company_mismatch", 409, "costCenterId", "legal_entity_mismatch"], + ...["assignee", "owner", "keeper"].flatMap((role) => [ + ["employee", null, { [`${role}EmployeeId`]: PERSON }, `${role}_not_found`, 404, `${role}EmployeeId`, "not_found"], + ["employee", { id: PERSON, status: "inactive" }, { [`${role}EmployeeId`]: PERSON }, `${role}_inactive`, 409, `${role}EmployeeId`, "inactive"], + ]), + ["purchaseRequest", null, { purchaseRequestId: OTHER }, "purchase_not_found", 404, "purchaseRequestId", "not_found"], + ["purchaseRequest", { id: OTHER, status: "pending" }, { purchaseRequestId: OTHER }, "purchase_not_approved", 409, "purchaseRequestId", "not_approved"], + ]; + it.each(references)("explains asset reference %s %s", async (model, row, fields, reason, status, field, detail) => { + const prisma = fixture(); prisma[model].findUnique.mockResolvedValue(row); + const { app } = await build(prisma); + try { + const response = await app.inject({ method: "POST", url: "/api/assets", payload: { ...BODY, ...fields } }); + rejected(response, reason, status, { [field]: detail }); noWrites(prisma); + } finally { await app.close(); } + }); + + it.each([ + ["PATCH", "/asset-categories", "assetCategory", { name: "合成分类" }, "category_not_found"], + ["GET", "/assets", "assetCard", undefined, "not_found"], + ["PATCH", "/assets", "assetCard", { name: "合成资产" }, "not_found"], + ])("retains VALID_001 404 for %s %s", async (method, path, model, payload, reason) => { + const prisma = fixture(); prisma[model].findUnique.mockResolvedValue(null); + const { app } = await build(prisma); + try { rejected(await app.inject({ method, url: `/api${path}/${ID}`, payload }), reason, 404, { id: "not_found" }); noWrites(prisma); } + finally { await app.close(); } + }); + + it("preserves permission and own-record read denial", async () => { + const { app, prisma } = await build(fixture(), ["asset.view_own"]); + try { + for (const options of [{ method: "POST", url: "/api/assets", payload: {} }, { method: "GET", url: `/api/assets/${ID}` }]) { + const r = await app.inject(options); expect(r.statusCode).toBe(403); expect(r.json().error.code).toBe("PERM_001"); + } + noWrites(prisma); + } finally { await app.close(); } + }); + + it("retains category create/update and asset create/update/detail", async () => { + const { app, prisma } = await build(); + try { + for (const opts of [ + { method: "POST", url: "/api/asset-categories", payload: { code: "synthetic", name: "合成分类", documentCategoryId: ID } }, + { method: "PATCH", url: `/api/asset-categories/${ID}`, payload: { enabled: false } }, + { method: "POST", url: "/api/assets", payload: { ...BODY, assigneeEmployeeId: PERSON, ownerEmployeeId: PERSON, keeperEmployeeId: PERSON, purchaseRequestId: OTHER, activatedAt: "2024-02-29" } }, + { method: "PATCH", url: `/api/assets/${ID}`, payload: { name: "合成修改", ownerEmployeeId: null, keeperEmployeeId: null, expiresAt: null } }, + { method: "GET", url: `/api/assets/${ID}` }, + ]) expect((await app.inject(opts)).statusCode).toBe(200); + expect(prisma.assetCategory.create.mock.calls[0][0].data.code).toBe("SYNTHETIC"); + expect(prisma.assetCard.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: "in_use", activated_at: new Date("2024-02-29T00:00:00Z"), owner_employee_id: PERSON, keeper_employee_id: PERSON, purchase_request_id: OTHER }), select: expect.any(Object) }); + expect(prisma.assetAssignment.create).toHaveBeenCalledOnce(); + expect(prisma.assetCard.update.mock.calls[0][0].data).toMatchObject({ owner_employee_id: null, keeper_employee_id: null, expires_at: null }); + expect(prisma.auditLog.create.mock.calls.map(([x]) => x.data.action)).toEqual(expect.arrayContaining(["ASSET_CATEGORY_CREATED", "ASSET_CATEGORY_UPDATED", "ASSET_CARD_CREATED", "ASSET_CARD_UPDATED"])); + expect(prisma.integrationOutbox.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); + +describe("synthetic redacted index error contracts", () => { + const url = "/api/assets/import/redacted-doc-index"; + it.each([[{ name: "raw body" }, "items.0.name", "raw_payload"], [{ name: "合成索引", sourceHash: "bad hash" }, "items.0.sourceHash", "unsafe_hash_format"]])("rejects unsafe index before gate/access %s", async (item, key, value) => { + vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "0"); const { app, prisma } = await build(); + try { rejected(await app.inject({ method: "POST", url, payload: { write: true, items: [item] } }), "index_import_unsafe_content", 400, { [key]: value }); noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); } + finally { await app.close(); } + }); + it("keeps closed write gate and existing CONFIG_001", async () => { + vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "0"); const { app, prisma } = await build(); + try { const r = await app.inject({ method: "POST", url, payload: { write: true, items: [{ name: "合成索引" }] } }); expect(r.statusCode).toBe(409); expect(r.json().error.code).toBe("CONFIG_001"); noWrites(prisma); } + finally { await app.close(); } + }); + it("explains missing dimensions under synthetic write flag", async () => { + vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "1"); const { app, prisma } = await build(); + try { rejected(await app.inject({ method: "POST", url, payload: { write: true, items: [{ name: "合成索引" }] } }), "index_import_dimensions_required", 400, { categoryId: "required_for_write", legalEntityId: "required_for_write", costCenterId: "required_for_write" }); noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); } + finally { await app.close(); } + }); + it("retains 20-item dry run overriding write without database access", async () => { + vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "0"); const { app, prisma } = await build(); + try { const r = await app.inject({ method: "POST", url, payload: { dryRun: true, write: true, items: Array.from({ length: 20 }, () => ({ name: "合成索引" })) } }); expect(r.statusCode).toBe(200); expect(r.json().data).toMatchObject({ mode: "dry_run", readOnly: true }); expect(r.json().data.items).toHaveLength(20); noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); } + finally { await app.close(); } + }); + it("retains index-only writes and hash skips with mock DB", async () => { + vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "1"); const { app, prisma } = await build(); + prisma.assetCard.findFirst.mockResolvedValueOnce({ id: ID, asset_no: "SYNTHETIC-OLD" }).mockResolvedValueOnce(null); + try { + const r = await app.inject({ method: "POST", url, payload: { write: true, categoryId: ID, legalEntityId: COMPANY, costCenterId: CENTER, items: [{ name: "合成旧索引", sourceHash: "synthetic_old" }, { name: "合成新索引", sourceHash: "synthetic_new" }] } }); + expect(r.statusCode).toBe(200); expect(r.json().data).toMatchObject({ mode: "write", imported: 1, boundaries: { noFileUpload: true, noRawContent: true } }); + expect(r.json().data.items.map((x) => x.status)).toEqual(["skipped_existing_hash", "created"]); + expect(prisma.assetCard.create).toHaveBeenCalledOnce(); expect(prisma.documentSequence.upsert).toHaveBeenCalledOnce(); + expect(prisma.assetCard.create.mock.calls[0][0].data).toMatchObject({ asset_type: "electronic_document", status: "archived", metadata: { rawFileImported: false } }); + expect(prisma.auditLog.create).toHaveBeenCalledTimes(2); + } finally { await app.close(); } + }); +}); + +describe("asset creation transaction boundary", () => { + it.each([false, true])("commits required writes before reading full detail (assigned=%s)", async assigned => { + const prisma = fixture(); + const events = []; + let committed = false; + const createdAt = new Date("2026-01-02T03:04:05Z"); + const row = { ...asset, created_at: createdAt, updated_at: createdAt, + category: { ...category, document_category: { ...doc, name: "合成单据类型" } }, + legal_entity: { id: COMPANY, code: "SYNTHETIC", name: "合成主体" }, + cost_center: { id: CENTER, code: "SYNTHETIC", name: "合成成本中心", legal_entity_id: COMPANY }, + }; + const tx = fixture(); + tx.assetCard.create.mockImplementation(async ({ data, select }) => { + events.push("asset"); + // Relation hydration must not extend the atomic write transaction. + expect(Object.values(select).every(value => value === true)).toBe(true); + Object.assign(row, data); + return Object.fromEntries(Object.keys(select).map(key => [key, row[key]])); + }); + tx.assetAssignment.create.mockImplementation(async ({ data }) => { + events.push("assignment"); + expect(data).toMatchObject({ asset_id: ID, employee_id: PERSON, assigned_at: createdAt }); + return { id: OTHER }; + }); + prisma.$transaction.mockImplementation(async cb => { + events.push("begin"); const result = await cb(tx); committed = true; events.push("commit"); return result; + }); + prisma.auditLog.create.mockImplementation(async () => { expect(committed).toBe(true); events.push("audit"); return {}; }); + prisma.assetCard.findUnique.mockImplementation(async ({ where, select }) => { + expect(committed).toBe(true); expect(where).toEqual({ id: ID }); + for (const relation of ["category", "legal_entity", "cost_center", "purchase_request", "assignee", "owner", "keeper"]) expect(select[relation]).toBeTruthy(); + events.push("detail"); return row; + }); + const { app } = await build(prisma); + try { + const response = await app.inject({ method: "POST", url: "/api/assets", payload: { ...BODY, ...(assigned ? { assigneeEmployeeId: PERSON, ownerEmployeeId: PERSON, keeperEmployeeId: PERSON } : {}) } }); + expect(response.statusCode).toBe(200); + expect(response.json().data.asset).toMatchObject({ id: ID, name: BODY.name, status: assigned ? "in_use" : "in_stock", + category: { id: ID, name: category.name }, legalEntity: { id: COMPANY, name: "合成主体" }, costCenter: { id: CENTER, legalEntityId: COMPANY }, + assigneeEmployeeId: assigned ? PERSON : null, permissions: { canEdit: true } }); + expect(events).toEqual(["begin", "asset", ...(assigned ? ["assignment"] : []), "commit", "audit", "detail"]); + expect(prisma.auditLog.create.mock.calls[0][0].data).toMatchObject({ action: "ASSET_CARD_CREATED", target_id: ID, actor_id: PERSON, + extra: { assetNo: row.asset_no, assetType: "fixed_asset", sensitivityLevel: "internal", purchaseRequestId: null, + assigneeEmployeeId: assigned ? PERSON : null, ownerEmployeeId: assigned ? PERSON : null, keeperEmployeeId: assigned ? PERSON : null } }); + expect(tx.auditLog.create).not.toHaveBeenCalled(); + expect(tx.assetCard.findUnique).not.toHaveBeenCalled(); + if (!assigned) expect(tx.assetAssignment.create).not.toHaveBeenCalled(); + for (const method of ["assetCategory", "legalEntity", "costCenter"]) expect(tx[method].findUnique).toHaveBeenCalled(); + expect(tx.documentSequence.upsert).toHaveBeenCalledOnce(); + } finally { await app.close(); } + }); + + it.each(["asset", "assignment", "commit"])("does not hydrate or emit success audit when %s fails", async phase => { + const prisma = fixture(); + const tx = fixture(); + const failure = new Error("synthetic transaction failure"); + if (phase === "asset") tx.assetCard.create.mockRejectedValue(failure); + if (phase === "assignment") tx.assetAssignment.create.mockRejectedValue(failure); + prisma.$transaction.mockImplementation(async cb => { + const result = await cb(tx); if (phase === "commit") throw failure; return result; + }); + const { app } = await build(prisma); + try { + expect((await app.inject({ method: "POST", url: "/api/assets", payload: { ...BODY, assigneeEmployeeId: PERSON } })).statusCode).toBe(500); + expect(prisma.assetCard.findUnique).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + if (phase === "asset") expect(tx.assetAssignment.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).toHaveBeenCalledOnce(); + } finally { await app.close(); } + }); + + it.each(["unavailable", "missing"])("keeps committed writes and does not retry when response detail is %s", async mode => { + const prisma = fixture(); + let committed = false; + prisma.$transaction.mockImplementation(async cb => { const result = await cb(prisma); committed = true; return result; }); + prisma.assetCard.findUnique.mockImplementation(async () => { + expect(committed).toBe(true); if (mode === "unavailable") throw new Error("synthetic read failure"); return null; + }); + const { app } = await build(prisma); + try { + const r = await app.inject({ method: "POST", url: "/api/assets", payload: BODY }); + expect(r.statusCode).toBe(mode === "unavailable" ? 500 : 404); + if (mode === "missing") rejected(r, "not_found", 404, { id: "not_found" }); + expect(committed).toBe(true); expect(prisma.assetCard.create).toHaveBeenCalledOnce(); + expect(prisma.$transaction).toHaveBeenCalledOnce(); expect(prisma.auditLog.create).toHaveBeenCalledOnce(); + } finally { await app.close(); } + }); + + it("preserves post-commit nonblocking audit and reports its failure separately", async () => { + const prisma = fixture(); + let rejectAudit; + prisma.auditLog.create.mockReturnValue(new Promise((_, reject) => { rejectAudit = reject; })); + const { app } = await build(prisma); + const errorLog = vi.spyOn(app.log, "error"); + try { + const response = await app.inject({ method: "POST", url: "/api/assets", payload: BODY }); + expect(response.statusCode).toBe(200); expect(prisma.assetCard.findUnique).toHaveBeenCalledOnce(); + const failure = new Error("synthetic audit failure"); rejectAudit(failure); await Promise.resolve(); + expect(errorLog).toHaveBeenCalledWith({ err: failure, assetId: ID }, "Failed to write asset create audit log"); + } finally { await app.close(); } + }); +}); + +it("creates a permanent fixed-asset number under active policy", async () => { + const { app, prisma } = await build(); enableNumbering(prisma); + try { + const response = await app.inject({ method: "POST", url: "/api/assets", payload: BODY }); + expect(response.statusCode).toBe(200); + expect(prisma.assetCard.create.mock.calls[0][0].data.asset_no).toBe("FA-0001"); + expect(prisma.documentSequence.upsert).not.toHaveBeenCalled(); + } finally { await app.close(); } +}); + +it("keeps EA prefix and permanent numbering for redacted document imports", async () => { + vi.stubEnv("ASSET_INDEX_IMPORT_WRITE", "1"); + const { app, prisma } = await build(); enableNumbering(prisma); + prisma.documentCategory.findUnique.mockResolvedValue({ ...doc, code: "ASSET_EA_DOC", sequence_prefix: "EA-DOC" }); + try { + const response = await app.inject({ method: "POST", url: "/api/assets/import/redacted-doc-index", payload: { + write: true, categoryId: ID, legalEntityId: COMPANY, costCenterId: CENTER, + items: [{ name: "合成电子索引", sourceHash: "synthetic_numbering" }], + } }); + expect(response.statusCode).toBe(200); + expect(prisma.assetCard.create.mock.calls[0][0].data.asset_no).toBe("EA-DOC-0001"); + expect(prisma.documentSequence.upsert).not.toHaveBeenCalled(); + } finally { await app.close(); } +}); diff --git a/server/tests/unit/routes/attendance.errors.test.js b/server/tests/unit/routes/attendance.errors.test.js new file mode 100644 index 00000000..83666fb6 --- /dev/null +++ b/server/tests/unit/routes/attendance.errors.test.js @@ -0,0 +1,64 @@ +const Fastify = require("fastify"); +const { AppError } = require("../../../src/utils/AppError"); +const attendance = require("../../../src/services/attendance"); +const flags = require("../../../src/services/featureFlags"); +const savedGate = flags.evaluateFeatureFlag; +const gate = vi.fn(); +flags.evaluateFeatureFlag = gate; +const routePath = require.resolve("../../../src/routes/attendance"); +const savedRoute = require.cache[routePath]; delete require.cache[routePath]; +const routes = require(routePath); +flags.evaluateFeatureFlag = savedGate; +afterAll(() => { if (savedRoute) require.cache[routePath] = savedRoute; else delete require.cache[routePath]; }); +const ID = "11111111-1111-4111-8111-111111111111"; +const DATE = "2026-09-16"; +const POLICY = { workStartTime: "09:00", workEndTime: "18:00", lunchStartTime: "12:00", lunchEndTime: "13:00", lateToleranceMinutes: 0 }; +const apps = []; +beforeEach(() => { gate.mockReset().mockResolvedValue({ enabled: true, reason: "synthetic_gate" }); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date(`${DATE}T02:00:00Z`)); }); +afterEach(async () => { vi.useRealTimers(); await Promise.all(apps.splice(0).map((app) => app.close())); }); +async function build({ allowed = true } = {}) { + const app = Fastify(); apps.push(app); + const row = { id: "synthetic-record", employee_id: ID, work_date: new Date(`${DATE}T00:00:00Z`), clock_in_at: null, clock_out_at: null }; + const prisma = { + employee: { findUnique: vi.fn().mockResolvedValue({ id: ID, status: "active" }), findMany: vi.fn().mockResolvedValue([]) }, + attendanceRecord: { findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]), create: vi.fn(async ({ data }) => ({ ...row, ...data })), update: vi.fn(async ({ data }) => ({ ...row, ...data })) }, + attendancePolicy: { findFirst: vi.fn().mockResolvedValue(null), create: vi.fn(async ({ data }) => ({ id: "synthetic-policy", ...data })), update: vi.fn(async ({ data }) => ({ id: "synthetic-policy", ...data })) }, + leaveRequest: { findMany: vi.fn().mockResolvedValue([]) }, travelRequest: { findMany: vi.fn().mockResolvedValue([]) }, legalEntity: { findMany: vi.fn().mockResolvedValue([]) }, + wecomCheckinSyncRun: { findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0), create: vi.fn(), update: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + const auth = async (request) => { request.employee = { id: ID, permissionCodes: ["attendance.sync", "attendance.view_all"] }; if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403 }); }; + app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai" }); app.decorate("authenticate", auth); app.decorate("requirePermission", () => auth); app.decorate("assertEntityPermission", () => {}); + app.setErrorHandler((e, _, reply) => reply.status(e.statusCode || 500).send({ error: { code: e.code, details: e.details } })); + await app.register(routes, { prefix: "/api" }); return { app, prisma, row }; +} +function rejected(r, reason, status = 400, details = {}) { expect(r.statusCode).toBe(status); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: `attendance.${reason}`, ...details } }); } +function noWrites(p) { for (const m of Object.values(p)) for (const op of ["create", "update"]) if (m[op]) expect(m[op]).not.toHaveBeenCalled(); } +const inject = (app, method, path, payload) => app.inject({ method, url: `/api/attendance${path}`, payload }); +describe("attendance error contracts with explicit synthetic gate (legacy remains hard off)", () => { + it.each([ + ["POST", "/clock-in", { unexpected: true }, "clock_input_invalid"], ["POST", "/clock-out", { note: "x".repeat(301) }, "clock_input_invalid"], + ["GET", "/me/month?year=1999", undefined, "legacy_month_query_invalid"], ["GET", "/team?date=2026-02-29", undefined, "team_query_invalid"], + ["PUT", "/policy", { ...POLICY, workStartTime: "25:00" }, "policy_input_invalid"], ["PUT", "/policy", { ...POLICY, lateToleranceMinutes: 601 }, "policy_input_invalid"], + ["POST", "/admin/backfill", {}, "backfill_input_invalid"], ["POST", "/wecom/sync", { startDate: DATE, endDate: "2026-09-15" }, "sync_input_invalid"], + ["POST", "/wecom/sync", { startDate: DATE, endDate: "2026-10-17" }, "sync_input_invalid"], ["POST", "/wecom/sync", { startDate: DATE, endDate: DATE, employeeIds: Array(51).fill(ID) }, "sync_input_invalid"], + ["GET", "/wecom/sync-runs?page=0", undefined, "sync_runs_query_invalid"], + ])("explains %s %s before writes", async (method, path, payload, reason) => { + const { app, prisma } = await build(); const r = await inject(app, method, path, payload); rejected(r, reason); noWrites(prisma); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); + if (path === "/policy") expect(r.json().error.details.fieldErrors).not.toHaveProperty("reasonCode"); + }); + it.each([["/clock-in", "feature_disabled"], ["/wecom/sync", "unified_feature_disabled"]])("preserves disabled gate priority %s", async (path, reason) => { gate.mockResolvedValue({ enabled: false, reason: "synthetic_off" }); const { app, prisma } = await build(); rejected(await inject(app, "POST", path, {}), reason, 409, { reason: "synthetic_off" }); noWrites(prisma); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); }); + it.each(["/clock-in", "/clock-out"])("explains exempt day %s", async (path) => { const { app, prisma } = await build(); prisma.leaveRequest.findMany.mockResolvedValue([{ owner_id: ID, start_date: new Date(DATE), end_date: new Date(DATE) }]); rejected(await inject(app, "POST", path, {}), "exempt_day", 409, { exemptionReason: "leave" }); noWrites(prisma); }); + it("explains missing clock-in", async () => { const { app, prisma } = await build(); rejected(await inject(app, "POST", "/clock-out", {}), "missing_clock_in", 400, { attendance: "missing_clock_in" }); noWrites(prisma); }); + it.each([[{}, "missing_backfill_values"], [{ clockInAt: `${DATE}T09:00:00Z`, clockOutAt: `${DATE}T08:00:00Z` }, "clock_range_invalid"]])("rejects backfill %s", async (patch, reason) => { const { app, prisma } = await build(); rejected(await inject(app, "POST", "/admin/backfill", { employeeId: ID, workDate: DATE, ...patch }), reason); noWrites(prisma); }); + it.each([[{ workEndTime: "08:00" }, "work_range_invalid"], [{ lunchEndTime: "11:00" }, "lunch_range_invalid"], [{ lunchStartTime: "08:00" }, "lunch_outside_work_range"], [{ lunchEndTime: "19:00" }, "lunch_outside_work_range"]])("explains policy relation %s", async (patch, reason) => { const { app, prisma } = await build(); rejected(await inject(app, "PUT", "/policy", { ...POLICY, ...patch }), reason); noWrites(prisma); }); + it("retains first clock-in write and idempotent repeated clock-in", async () => { const { app, prisma, row } = await build(); expect((await inject(app, "POST", "/clock-in", {})).statusCode).toBe(200); expect(prisma.attendanceRecord.create).toHaveBeenCalledOnce(); prisma.attendanceRecord.create.mockClear(); prisma.attendanceRecord.findFirst.mockResolvedValue({ ...row, clock_in_at: new Date(`${DATE}T01:00:00Z`) }); expect((await inject(app, "POST", "/clock-in", {})).statusCode).toBe(200); noWrites(prisma); }); + it.each([{ clockOutAt: `${DATE}T09:00:00Z` }, { clockInAt: `${DATE}T09:00:00Z`, clockOutAt: `${DATE}T09:00:00Z` }])("preserves legal backfill %s", async (patch) => { const { app, prisma } = await build(); const r = await inject(app, "POST", "/admin/backfill", { employeeId: ID, workDate: DATE, ...patch }); expect(r.statusCode).toBe(200); expect(prisma.attendanceRecord.create).toHaveBeenCalledOnce(); expect(prisma.auditLog.create).toHaveBeenCalledOnce(); }); + it("retains policy create and permission priority", async () => { const { app, prisma } = await build(); expect((await inject(app, "PUT", "/policy", POLICY)).statusCode).toBe(200); expect(prisma.attendancePolicy.create).toHaveBeenCalledOnce(); gate.mockClear(); const denied = await build({ allowed: false }); expect((await inject(denied.app, "PUT", "/policy", {})).statusCode).toBe(403); expect(gate).not.toHaveBeenCalled(); noWrites(denied.prisma); }); +}); +describe("actual attendance service guards", () => { + it.each(["9:00", "24:00", "12:60", "bad"])("rejects time %s", (value) => { expect(() => attendance.parseTimeToMinutes(value, "workStartTime")).toThrow(expect.objectContaining({ statusCode: 400, details: { workStartTime: "invalid_time", reasonCode: "attendance.policy_time_invalid" } })); }); + it.each([-1, 601, 0.5, "bad"])("rejects tolerance %s before persistence", async (value) => { const { prisma } = await build(); await expect(attendance.upsertAttendancePolicy(prisma, ID, { ...POLICY, lateToleranceMinutes: value })).rejects.toMatchObject({ details: { reasonCode: "attendance.tolerance_invalid" } }); expect(prisma.attendancePolicy.findFirst).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each([[1999, 1, "year_invalid"], [2101, 1, "year_invalid"], [2026, 0, "month_invalid"], [2026, 13, "month_invalid"]])("rejects month range %s/%s", (year, month, reason) => { expect(() => attendance.resolveMonthInput({ year, month }, "Asia/Shanghai")).toThrow(expect.objectContaining({ details: expect.objectContaining({ reasonCode: `attendance.${reason}` }) })); }); + it("keeps legal time and leap month", () => { expect(attendance.parseTimeToMinutes("23:59")).toBe(1439); expect(attendance.resolveMonthInput({ year: 2024, month: 2 }, "Asia/Shanghai").dateStrings).toHaveLength(29); }); +}); diff --git a/server/tests/unit/routes/attendance.test.js b/server/tests/unit/routes/attendance.test.js index 93234f11..72870d0e 100644 --- a/server/tests/unit/routes/attendance.test.js +++ b/server/tests/unit/routes/attendance.test.js @@ -443,3 +443,45 @@ describe("attendance unified routes", () => { ); }); }); + +describe("unified attendance month guards", () => { + it.each(["2026-00", "2026-13", "2026-1", ""])("rejects month=%s before business reads or sync", async (month) => { + const { app, prisma } = buildApp(); + const fetchSpy = vi.fn(() => { throw new Error("no external access in month tests"); }); + global.fetch = fetchSpy; + try { + const response = await app.inject({ method: "GET", url: `/api/attendance/monthly?month=${month}` }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe("VALID_001"); + expect(response.json().error.details.reasonCode).toBe(["2026-00", "2026-13"].includes(month) ? "attendance.month_invalid" : "attendance.month_query_invalid"); + if (["2026-00", "2026-13"].includes(month)) expect(response.json().error.details).toEqual({ month: "invalid", reasonCode: "attendance.month_invalid" }); + for (const [name, group] of Object.entries(prisma)) { + if (name === "featureFlag") continue; + if (typeof group === "function") expect(group).not.toHaveBeenCalled(); + else for (const fn of Object.values(group)) expect(fn).not.toHaveBeenCalled(); + } + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { global.fetch = originalFetch; await app.close(); } + }); + + it.each(["2024-01", "2024-02", "2024-12"])("keeps the selected valid month %s", async (month) => { + const { app, prisma } = buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/attendance/monthly?month=${month}` }); + expect(response.statusCode).toBe(200); + expect(response.json().data.month).toBe(month); + expect(prisma.wecomCheckinRecord.findMany).toHaveBeenCalledOnce(); + } finally { await app.close(); } + }); + + it("keeps the omitted month default in the configured timezone", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2024-02-29T17:00:00.000Z")); + const { app } = buildApp({ employees: [] }); + try { + const response = await app.inject({ method: "GET", url: "/api/attendance/monthly" }); + expect(response.statusCode).toBe(200); + expect(response.json().data.month).toBe("2024-03"); + } finally { vi.useRealTimers(); await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/auth-entity.errors.test.js b/server/tests/unit/routes/auth-entity.errors.test.js new file mode 100644 index 00000000..fe295e67 --- /dev/null +++ b/server/tests/unit/routes/auth-entity.errors.test.js @@ -0,0 +1,21 @@ +const Fastify = require("fastify"); +const authPlugin = require("../../../src/plugins/auth"); +const { AppError } = require("../../../src/utils/AppError"); +const apps = []; +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); +async function build() { + const app = Fastify(); apps.push(app); + const employee = { id: "synthetic-user", status: "active", token_version: 2, roles: [{ role_code: "employee" }] }; + const prisma = { employee: { findUnique: vi.fn(async () => employee) } }; + app.decorate("config", { jwtSecret: "synthetic-local-auth-contract-key", accessTokenTtl: "5m" }); app.decorate("prisma", prisma); + await app.register(authPlugin); + app.setErrorHandler((e, _, reply) => reply.status(e instanceof AppError ? e.statusCode : 500).send({ error: { code: e.code, details: e.details } })); + app.get("/synthetic-unsupported", { preHandler: app.requireEntityPermission("synthetic-unknown", "submit") }, async () => ({ ok: true })); + return { app, prisma, employee }; +} +describe("auth plugin entity action contract with synthetic JWT only", () => { + it("keeps invalid/absent token rejection before entity lookup", async () => { const { app, prisma } = await build(); const r = await app.inject({ url: "/synthetic-unsupported" }); expect(r.statusCode).toBe(401); expect(r.json().error.code).toBe("AUTH_004"); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); }); + it("explains unsupported entity/action after authentication", async () => { const { app, employee } = await build(); const token = app.issueAccessToken(employee); const r = await app.inject({ url: "/synthetic-unsupported", headers: { authorization: `Bearer ${token}` } }); expect(r.statusCode).toBe(409); expect(r.json().error).toEqual({ code: "VALID_001", details: { entityType: "synthetic-unknown", action: "submit", permission: "unsupported_entity_action", reasonCode: "auth.entity_action_unsupported" } }); }); + it("preserves valid entity mapping and missing permission 403", async () => { const { app } = await build(); expect(app.assertEntityPermission({ permissionCodes: ["procurement.submit"] }, "purchase_request", "submit")).toBe("procurement.submit"); expect(() => app.assertEntityPermission({ permissionCodes: [] }, "purchase_request", "submit")).toThrow(expect.objectContaining({ code: "PERM_001", statusCode: 403 })); }); + it.each(["inactive", "stale"])("keeps %s account/token blocked before entity action", async (kind) => { const { app, employee } = await build(); const token = app.issueAccessToken(employee); if (kind === "inactive") employee.status = "archived"; else employee.token_version += 1; const r = await app.inject({ url: "/synthetic-unsupported", headers: { authorization: `Bearer ${token}` } }); expect(r.statusCode).toBe(kind === "inactive" ? 403 : 401); expect(r.json().error.code).toBe(kind === "inactive" ? "AUTH_003" : "AUTH_004"); }); +}); diff --git a/server/tests/unit/routes/budget-control.errors.test.js b/server/tests/unit/routes/budget-control.errors.test.js new file mode 100644 index 00000000..77a3c796 --- /dev/null +++ b/server/tests/unit/routes/budget-control.errors.test.js @@ -0,0 +1,137 @@ +const Fastify = require("fastify"); +const { AppError } = require("../../../src/utils/AppError"); +const rulesRoutes = require("../../../src/routes/budgetControlRules"); +const budgetsRoutes = require("../../../src/routes/budgets"); +const { resolveBudgetScope, selectBudgetPoolCandidate, resolveApplicableBudgetPoolByContext } = require("../../../src/services/budgetPool"); +const ID = "11111111-1111-4111-8111-111111111111"; +const OTHER_ID = "22222222-2222-4222-8222-222222222222"; +const input = { name: "合成控制规则", entityType: "reimbursement", controlMode: "warn" }; +const apps = []; +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); + +async function buildApp(allowed = true) { + const app = Fastify(); apps.push(app); + const prisma = { + budgetControlRule: { + findMany: vi.fn().mockResolvedValue([]), findUnique: vi.fn().mockResolvedValue({ id: ID, ...input }), + create: vi.fn(async ({ data }) => ({ id: ID, ...data })), update: vi.fn(async ({ data }) => ({ id: ID, ...data })), + }, + budgetPool: { create: vi.fn() }, department: { findUnique: vi.fn() }, + legalEntity: { findUnique: vi.fn() }, costCenter: { findUnique: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + app.decorate("prisma", prisma); + app.decorate("requirePermission", () => async (request) => { + request.employee = { id: OTHER_ID }; + if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); + }); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); + await app.register(rulesRoutes, { prefix: "/api" }); + await app.register(budgetsRoutes, { prefix: "/api" }); + return { app, prisma }; +} + +describe("shared budget matching reasons", () => { + it.each([ + [{ costCenterId: ID }, 400, "budget.company_required", { costCenterId: "legalEntityId_required" }], + [{ legalEntityId: ID }, 400, "budget.cost_center_required", { costCenterId: "required_with_legalEntityId" }], + [{ departmentId: ID, legalEntityId: ID }, 409, "budget.scope_mixed", { scope: "mixed_dimensions_not_allowed" }], + ])("rejects scope %j with unchanged details", async (scope, statusCode, reasonCode, details) => { + expect(() => resolveBudgetScope(scope)).toThrowError(expect.objectContaining({ code: "VALID_001", statusCode, details: { ...details, reasonCode } })); + const { app, prisma } = await buildApp(); + const response = await app.inject({ method: "POST", url: "/api/budgets", payload: { code: "SYNTHETIC", name: "合成预算", periodStart: "2024-02-29", periodEnd: "2024-03-31", allocatedAmount: "100.00", ...scope } }); + expect(response.statusCode).toBe(statusCode); + expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); + for (const model of ["department", "legalEntity", "costCenter"]) expect(prisma[model].findUnique).not.toHaveBeenCalled(); + expect(prisma.budgetPool.create).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it("keeps the company guard for selection callers", () => { + expect(() => selectBudgetPoolCandidate([], { costCenterId: ID })).toThrowError(expect.objectContaining({ statusCode: 400, details: { costCenterId: "legalEntityId_required", reasonCode: "budget.company_required" } })); + }); + + it("explains ambiguous scoped pools without changing query bounds", async () => { + const records = [ID, OTHER_ID].map((id) => ({ id, legal_entity_id: ID, cost_center_id: ID, department_id: null })); + const tx = { budgetPool: { findMany: vi.fn().mockResolvedValue(records) } }; + const effectiveDate = new Date("2024-02-29T00:00:00Z"); + await expect(resolveApplicableBudgetPoolByContext(tx, { legalEntityId: ID, costCenterId: ID, effectiveDate })).rejects.toMatchObject({ code: "VALID_001", statusCode: 409, details: { budget: "budget_pool_ambiguous", scopeType: "cost_center_scoped", reasonCode: "budget.pool_ambiguous" } }); + expect(tx.budgetPool.findMany).toHaveBeenCalledWith({ where: { status: "active", archived_at: null, period_start: { lte: effectiveDate }, period_end: { gte: effectiveDate } }, orderBy: [{ created_at: "asc" }, { code: "asc" }] }); + }); + + it.each([null, "2024-02-29", new Date(NaN)])("rejects invalid effective dates before querying", async (effectiveDate) => { + const tx = { budgetPool: { findMany: vi.fn() } }; + await expect(resolveApplicableBudgetPoolByContext(tx, { effectiveDate })).rejects.toMatchObject({ statusCode: 400, details: { effectiveDate: "invalid_date", reasonCode: "budget.effective_date_invalid" } }); + expect(tx.budgetPool.findMany).not.toHaveBeenCalled(); + }); + + it("preserves first-match legacy fallbacks and no-match null", () => { + const global = [{ id: ID }, { id: OTHER_ID }]; + const departments = [{ id: ID, department_id: ID }, { id: OTHER_ID, department_id: ID }]; + expect(selectBudgetPoolCandidate(global)).toBe(global[0]); + expect(selectBudgetPoolCandidate(departments, { departmentId: ID })).toBe(departments[0]); + expect(selectBudgetPoolCandidate(departments, { departmentId: OTHER_ID })).toBeNull(); + }); +}); + +describe("budget control rules error contracts", () => { + it.each([ + ["GET", "/budget-control-rules?enabled=invalid", undefined, "budget_rule.query_invalid"], + ["POST", "/budget-control-rules", { ...input, controlMode: "invalid" }, "budget_rule.create_input_invalid"], + ["PATCH", `/budget-control-rules/${ID}`, {}, "budget_rule.update_input_invalid"], + ])("validates %s %s before business access", async (method, path, payload, reasonCode) => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode, formErrors: expect.any(Array), fieldErrors: expect.any(Object) } }); + for (const fn of Object.values(prisma.budgetControlRule)) expect(fn).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it.each(["PATCH", "POST"])("validates the rule id for %s", async (method) => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method, url: `/api/budget-control-rules/bad${method === "POST" ? "/archive" : ""}`, ...(method === "PATCH" ? { payload: { name: "合成" } } : {}) }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ id: "invalid_uuid", reasonCode: "budget_rule.id_invalid" }); + expect(prisma.budgetControlRule.findUnique).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it.each([ + ["POST", { ...input, costCenterId: ID }, "budget_rule.company_required", { costCenterId: "legalEntityId_required" }], + ["PATCH", { costCenterId: ID }, "budget_rule.company_required", { costCenterId: "legalEntityId_required" }], + ["POST", { ...input, entityType: "purchase_request", expenseTypeId: ID }, "budget_rule.expense_type_unsupported", { expenseTypeId: "only_reimbursement_supported" }], + ["PATCH", { expenseTypeId: ID }, "budget_rule.expense_type_unsupported", { expenseTypeId: "only_reimbursement_supported" }], + ])("preserves body-scoped rejection for %s %j", async (method, payload, reasonCode, details) => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method, url: `/api/budget-control-rules${method === "PATCH" ? `/${ID}` : ""}`, payload }); + expect(response.statusCode).toBe(409); + expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); + for (const fn of Object.values(prisma.budgetControlRule)) expect(fn).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it("preserves successful create defaults, paired patch and archive", async () => { + const { app, prisma } = await buildApp(); + expect((await app.inject({ method: "POST", url: "/api/budget-control-rules", payload: input })).statusCode).toBe(201); + expect(prisma.budgetControlRule.create.mock.calls[0][0].data).toMatchObject({ priority: 100, enabled: true, control_mode: "warn" }); + expect((await app.inject({ method: "PATCH", url: `/api/budget-control-rules/${ID}`, payload: { entityType: "reimbursement", expenseTypeId: ID, legalEntityId: ID, costCenterId: ID } })).statusCode).toBe(200); + expect((await app.inject({ method: "POST", url: `/api/budget-control-rules/${ID}/archive` })).statusCode).toBe(200); + expect(prisma.auditLog.create).toHaveBeenCalledTimes(3); + }); + + it("preserves a missing rule as 404", async () => { + const { app, prisma } = await buildApp(); prisma.budgetControlRule.findUnique.mockResolvedValue(null); + const response = await app.inject({ method: "PATCH", url: `/api/budget-control-rules/${ID}`, payload: { name: "合成" } }); + expect(response.statusCode).toBe(404); + expect(prisma.budgetControlRule.update).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it("preserves permission rejection before validation or writes", async () => { + const { app, prisma } = await buildApp(false); + expect((await app.inject({ method: "POST", url: "/api/budget-control-rules", payload: {} })).statusCode).toBe(403); + for (const fn of Object.values(prisma.budgetControlRule)) expect(fn).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/routes/budget-performance.errors.test.js b/server/tests/unit/routes/budget-performance.errors.test.js new file mode 100644 index 00000000..cbc6547e --- /dev/null +++ b/server/tests/unit/routes/budget-performance.errors.test.js @@ -0,0 +1,262 @@ +const Fastify = require("fastify"); +const budgetsRoutes = require("../../../src/routes/budgets"); +const performanceRoutes = require("../../../src/routes/performance"); +const { AppError } = require("../../../src/utils/AppError"); + +const ID = "11111111-1111-4111-8111-111111111111"; +const OTHER_ID = "22222222-2222-4222-8222-222222222222"; +const budgetInput = { code: "SYNTHETIC", name: "合成预算", periodStart: "2024-02-29", periodEnd: "2024-03-31", allocatedAmount: "100.00" }; +const cycleInput = { name: "合成周期", cycleType: "monthly", startDate: "2024-02-29", endDate: "2024-03-31" }; +const apps = []; + +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); + +async function buildApp({ allowed = true, enabled = true } = {}) { + const app = Fastify(); + apps.push(app); + const cycle = { id: ID, status: "draft", start_date: new Date("2024-02-29T00:00:00Z"), end_date: new Date("2024-03-31T00:00:00Z") }; + const prisma = { + featureFlag: { findUnique: vi.fn().mockResolvedValue({ flag_key: "employee_lifecycle_v1", override_mode: enabled ? "force_on" : "force_off", rollout_type: "global", department_ids: [] }) }, + department: { findUnique: vi.fn().mockResolvedValue({ id: ID, archived_at: null }) }, + legalEntity: { findUnique: vi.fn().mockResolvedValue({ id: ID, archived_at: null, enabled: true }) }, + costCenter: { findUnique: vi.fn().mockResolvedValue({ id: ID, legal_entity_id: ID, archived_at: null, enabled: true }) }, + budgetPool: { + findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0), + findUnique: vi.fn().mockResolvedValue({ id: ID, ...budgetInput }), + create: vi.fn(async ({ data }) => ({ id: ID, ...data })), + update: vi.fn(async ({ data }) => ({ id: ID, ...data })), + }, + performanceReviewCycle: { + findMany: vi.fn().mockResolvedValue([]), findUnique: vi.fn().mockResolvedValue(cycle), + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn(async ({ data }) => ({ ...cycle, ...data })), + update: vi.fn(async ({ data }) => ({ ...cycle, ...data })), + }, + employee: { findUnique: vi.fn().mockResolvedValue({ id: ID, status: "active" }) }, + performanceReview: { + findFirst: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue({ id: ID }), + create: vi.fn().mockResolvedValue({ id: ID }), update: vi.fn().mockResolvedValue({ id: ID }), + }, + performanceReviewDimension: { deleteMany: vi.fn(), createMany: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + $queryRaw: vi.fn().mockResolvedValue([{ id: ID, status: "active" }]), + }; + prisma.$transaction = vi.fn(async (callback) => callback(prisma)); + app.decorate("config", { employeeLifecycleEnabled: true, appTimezone: "Asia/Shanghai" }); + app.decorate("prisma", prisma); + app.decorate("authenticate", async (request) => { request.employee = { id: OTHER_ID, department_id: null }; }); + app.decorate("requirePermission", () => async (request) => { + await app.authenticate(request); + if (!allowed) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + }); + app.decorate("hasPermission", () => allowed); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code ?? "SYS_001", details: error.details ?? null } })); + await app.register(budgetsRoutes, { prefix: "/api" }); + await app.register(performanceRoutes, { prefix: "/api" }); + return { app, prisma, cycle }; +} + +function expectNoWrites(prisma, { cycleUpdateAttempted = false } = {}) { + for (const fn of [prisma.budgetPool.create, prisma.budgetPool.update, prisma.performanceReviewCycle.create, + ...(!cycleUpdateAttempted ? [prisma.performanceReviewCycle.update] : []), prisma.performanceReview.create, + prisma.performanceReview.update, prisma.performanceReviewDimension.deleteMany, prisma.performanceReviewDimension.createMany, + prisma.auditLog.create]) expect(fn).not.toHaveBeenCalled(); +} + +function expectFailure(response, status, reasonCode, details) { + expect(response.statusCode).toBe(status); + expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); +} + +describe("budget route error contracts", () => { + it.each([ + ["GET", "/budgets?page=0", undefined, "budget.query_invalid", "page"], + ["POST", "/budgets", { ...budgetInput, allocatedAmount: "-1" }, "budget.create_input_invalid", "allocatedAmount"], + ["PATCH", `/budgets/${ID}`, { warningThresholdPercent: 101 }, "budget.update_input_invalid", "warningThresholdPercent"], + ])("%s %s rejects invalid input before budget access", async (method, path, payload, reasonCode, field) => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe("VALID_001"); + expect(response.json().error.details).toMatchObject({ reasonCode, formErrors: [], fieldErrors: { [field]: expect.any(Array) } }); + expect(prisma.budgetPool.findUnique).not.toHaveBeenCalled(); + expect(prisma.budgetPool.findMany).not.toHaveBeenCalled(); + expect(prisma.budgetPool.count).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expectNoWrites(prisma); + }); + + it.each([["PATCH", "/budgets/bad", { name: "合成" }], ["POST", "/budgets/bad/archive", undefined]])( + "%s %s rejects an invalid id", async (method, path, payload) => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }); + expectFailure(response, 400, "budget.id_invalid", { id: "invalid_uuid" }); + expect(prisma.budgetPool.findUnique).not.toHaveBeenCalled(); + expectNoWrites(prisma); + }, + ); + + it.each([ + ["department", null, { departmentId: ID }, 400, "budget.department_not_found", { departmentId: "not_found" }], + ["department", { id: ID, archived_at: new Date("2024-01-01Z") }, { departmentId: ID }, 409, "budget.department_archived", { departmentId: "archived" }], + ["legalEntity", null, { legalEntityId: ID, costCenterId: ID }, 400, "budget.legal_entity_not_found", { legalEntityId: "not_found" }], + ["legalEntity", { id: ID, enabled: false }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.legal_entity_unavailable", { legalEntityId: "disabled_or_archived" }], + ["legalEntity", { id: ID, enabled: true, archived_at: new Date("2024-01-01Z") }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.legal_entity_unavailable", { legalEntityId: "disabled_or_archived" }], + ["costCenter", null, { legalEntityId: ID, costCenterId: ID }, 400, "budget.cost_center_not_found", { costCenterId: "not_found" }], + ["costCenter", { id: ID, enabled: false }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.cost_center_unavailable", { costCenterId: "disabled_or_archived" }], + ["costCenter", { id: ID, enabled: true, archived_at: new Date("2024-01-01Z") }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.cost_center_unavailable", { costCenterId: "disabled_or_archived" }], + ["costCenter", { id: ID, enabled: true, legal_entity_id: OTHER_ID }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.cost_center_company_mismatch", { costCenterId: "legalEntityId_mismatch" }], + ])("preserves reference rejection %s %j", async (model, row, scope, status, reason, details) => { + const { app, prisma } = await buildApp(); + prisma[model].findUnique.mockResolvedValue(row); + const response = await app.inject({ method: "POST", url: "/api/budgets", payload: { ...budgetInput, ...scope } }); + expectFailure(response, status, reason, details); + if (model === "legalEntity") expect(prisma.costCenter.findUnique).not.toHaveBeenCalled(); + expectNoWrites(prisma); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it.each([ + ["PATCH", `/budgets/${ID}`, {}, "budget.update_empty", { body: "empty_patch" }], + ["POST", "/budgets", { ...budgetInput, periodEnd: "2024-02-28" }, "budget.period_invalid", { periodEnd: "must_be_after_periodStart" }], + ])("preserves %s %s business validation", async (method, path, payload, reason, details) => { + const { app, prisma } = await buildApp(); + expectFailure(await app.inject({ method, url: `/api${path}`, payload }), 400, reason, details); + expect(prisma.budgetPool.findUnique).not.toHaveBeenCalled(); + expect(prisma.department.findUnique).not.toHaveBeenCalled(); + expectNoWrites(prisma); + }); + + it("explains the shared scope conflict without writes", async () => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method: "POST", url: "/api/budgets", payload: { ...budgetInput, departmentId: ID, legalEntityId: ID, costCenterId: ID } }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ scope: "mixed_dimensions_not_allowed", reasonCode: "budget.scope_mixed" }); + expect(prisma.department.findUnique).not.toHaveBeenCalled(); + expectNoWrites(prisma); + }); + + it("keeps a valid scoped budget, patch and archive working", async () => { + const { app, prisma } = await buildApp(); + const created = await app.inject({ method: "POST", url: "/api/budgets", payload: { ...budgetInput, legalEntityId: ID, costCenterId: ID } }); + expect(created.statusCode).toBe(200); + expect(prisma.budgetPool.create.mock.calls[0][0].data).toMatchObject({ legal_entity_id: ID, cost_center_id: ID, period_start: new Date("2024-02-29T00:00:00Z"), allocated_amount: "100.00" }); + expect((await app.inject({ method: "PATCH", url: `/api/budgets/${ID}`, payload: { name: "合成修改", warningThresholdPercent: 90 } })).statusCode).toBe(200); + expect((await app.inject({ method: "POST", url: `/api/budgets/${ID}/archive` })).statusCode).toBe(200); + expect(prisma.auditLog.create).toHaveBeenCalledTimes(3); + }); +}); + +describe("performance route error contracts", () => { + it.each([ + ["POST", "/performance/cycles", { ...cycleInput, name: "" }, "performance.cycle_create_input_invalid", "name"], + ["PATCH", `/performance/cycles/${ID}`, { cycleType: "invalid" }, "performance.cycle_update_input_invalid", "cycleType"], + ["PUT", `/performance/cycles/${ID}/reviews/${ID}`, { overallScore: 4.2 }, "performance.review_input_invalid", "overallScore"], + ["PUT", `/performance/cycles/${ID}/reviews/${ID}`, { overallScore: 4, dimensions: [{ dimensionType: "teamwork", score: 4 }, { dimensionType: "teamwork", score: 5 }] }, "performance.review_input_invalid", "dimensions"], + ])("%s %s rejects invalid operation input before business access", async (method, path, payload, reason, field) => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method, url: `/api${path}`, payload }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe("VALID_001"); + expect(response.json().error.details).toMatchObject({ reasonCode: reason, formErrors: [], fieldErrors: { [field]: expect.any(Array) } }); + expect(prisma.performanceReviewCycle.findUnique).not.toHaveBeenCalled(); + expect(prisma.employee.findUnique).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expectNoWrites(prisma); + }); + + it.each([ + ["PATCH", "/performance/cycles/bad", { name: "合成" }], + ["GET", "/performance/cycles/bad/reviews"], + ["GET", "/performance/employees/bad/reviews"], + ["PUT", `/performance/cycles/${ID}/reviews/bad`, { overallScore: 4 }], + ])("%s %s rejects invalid identifiers", async (method, path, payload) => { + const { app, prisma } = await buildApp(); + expectFailure(await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }), 400, "performance.id_invalid", { id: "invalid_uuid" }); + expect(prisma.employee.findUnique).not.toHaveBeenCalled(); + expect(prisma.performanceReviewCycle.findUnique).not.toHaveBeenCalled(); + expectNoWrites(prisma); + }); + + it.each(["POST", "PATCH"])("%s retains the reversed date guard", async (method) => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method, url: `/api/performance/cycles${method === "PATCH" ? `/${ID}` : ""}`, payload: method === "POST" ? { ...cycleInput, endDate: "2024-02-28" } : { endDate: "2024-02-28" } }); + expectFailure(response, 400, "performance.cycle_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" }); + expectNoWrites(prisma); + }); + + it.each([ + ["completed", { name: "合成" }, "performance.completed_cycle_locked", "completed_cycle_locked"], + ["active", { name: "合成" }, "performance.active_cycle_only_complete", "active_cycle_only_supports_complete"], + ["draft", { status: "completed" }, "performance.draft_cannot_complete", "draft_cycle_cannot_complete_directly"], + ])("retains %s cycle mutation guard", async (status, payload, reason, detail) => { + const { app, prisma, cycle } = await buildApp(); + cycle.status = status; + expectFailure(await app.inject({ method: "PATCH", url: `/api/performance/cycles/${ID}`, payload }), 409, reason, { cycle: detail }); + expectNoWrites(prisma); + }); + + it.each(["precheck", "unique_constraint"])("explains another active cycle at %s", async (stage) => { + const { app, prisma } = await buildApp(); + if (stage === "precheck") prisma.performanceReviewCycle.findFirst.mockResolvedValue({ id: OTHER_ID }); + else prisma.performanceReviewCycle.update.mockRejectedValue({ code: "P2002" }); + const response = await app.inject({ method: "PATCH", url: `/api/performance/cycles/${ID}`, payload: { status: "active" } }); + expectFailure(response, 409, "performance.active_cycle_exists", { cycle: "active_cycle_exists", ...(stage === "precheck" ? { activeCycleId: OTHER_ID } : {}) }); + expectNoWrites(prisma, { cycleUpdateAttempted: stage === "unique_constraint" }); + if (stage === "unique_constraint") expect(prisma.performanceReviewCycle.update).toHaveBeenCalledTimes(1); + }); + + it("preserves non-unique database failures without a misleading conflict reason", async () => { + const { app, prisma } = await buildApp(); + prisma.performanceReviewCycle.update.mockRejectedValue(new Error("synthetic_database_failure")); + const response = await app.inject({ method: "PATCH", url: `/api/performance/cycles/${ID}`, payload: { status: "active" } }); + expect(response.statusCode).toBe(500); + expect(response.json().error).toEqual({ code: "SYS_001", details: null }); + expectNoWrites(prisma, { cycleUpdateAttempted: true }); + }); + + it("rejects inactive employees before the review transaction", async () => { + const { app, prisma } = await buildApp(); + prisma.employee.findUnique.mockResolvedValue({ id: ID, status: "inactive" }); + expectFailure(await app.inject({ method: "PUT", url: `/api/performance/cycles/${ID}/reviews/${ID}`, payload: { overallScore: 4 } }), 409, "performance.employee_inactive", { employee: "inactive_employee_not_reviewable" }); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.performanceReviewCycle.findUnique).not.toHaveBeenCalled(); + expectNoWrites(prisma); + }); + + it("rechecks the locked cycle before any review or dimension writes", async () => { + const { app, prisma, cycle } = await buildApp(); + cycle.status = "active"; + prisma.$queryRaw.mockResolvedValue([{ id: ID, status: "completed" }]); + expectFailure(await app.inject({ method: "PUT", url: `/api/performance/cycles/${ID}/reviews/${ID}`, payload: { overallScore: 4 } }), 409, "performance.active_cycle_required", { cycle: "active_cycle_required" }); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); + expect(prisma.performanceReview.findFirst).not.toHaveBeenCalled(); + expectNoWrites(prisma); + }); + + it("preserves the disabled feature rejection before cycle queries", async () => { + const { app, prisma } = await buildApp({ enabled: false }); + expectFailure(await app.inject({ method: "GET", url: "/api/performance/cycles" }), 409, "performance.disabled", { featureFlag: "employee_lifecycle_v1_disabled", reason: "flag_force_off" }); + expect(prisma.performanceReviewCycle.findMany).not.toHaveBeenCalled(); + expectNoWrites(prisma); + }); + + it("still writes a valid admin review in the existing transaction", async () => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method: "PUT", url: `/api/performance/cycles/${ID}/reviews/${ID}`, payload: { overallScore: 4.5, dimensions: [{ dimensionType: "teamwork", score: 4 }] } }); + expect(response.statusCode).toBe(200); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.performanceReview.create.mock.calls[0][0].data).toMatchObject({ overall_score: 4.5, cycle_id: ID, employee_id: ID }); + expect(prisma.performanceReviewDimension.createMany).toHaveBeenCalledTimes(1); + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + }); +}); + +it.each(["/budgets", "/performance/cycles"])("keeps permission rejection before %s writes", async (path) => { + const { app, prisma } = await buildApp({ allowed: false }); + const response = await app.inject({ method: "POST", url: `/api${path}`, payload: {} }); + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe("PERM_001"); + expectNoWrites(prisma); +}); diff --git a/server/tests/unit/routes/contractsRegistry.test.js b/server/tests/unit/routes/contractsRegistry.test.js index 72b7c69a..bc1f38b2 100644 --- a/server/tests/unit/routes/contractsRegistry.test.js +++ b/server/tests/unit/routes/contractsRegistry.test.js @@ -422,6 +422,7 @@ describe("contracts registry routes", () => { expect.objectContaining({ code: "VALID_001", details: expect.objectContaining({ + reasonCode: "contract_registry.expiry_date_range_invalid", expiryDateTo: "must_be_after_or_equal_expiryDateFrom", }), }), @@ -896,3 +897,170 @@ describe("contracts registry routes", () => { await app.close(); }); }); + + +describe("contract calendar input", () => { + const body = { contractNo: "SYNTHETIC-CALENDAR", contractType: "service", counterpartyName: "合成对方", summary: "合成日期验证", archiveLocation: "synthetic-archive" }; + const cases = ["POST", "PATCH"].flatMap((method) => ["signedDate", "effectiveDate", "expiryDate"].flatMap((field) => ["2026-02-29", "2026-04-31", "2026-13-01", ""].map((date) => [method, field, date]))); + it.each(cases)("rejects %s %s=%s before writes", async (method, field, date) => { + const { app, prisma } = await buildApp(); + const transaction = vi.spyOn(prisma, "$transaction"); + try { + const response = await app.inject({ method, url: `/api/contracts-registry${method === "PATCH" ? "/55555555-5555-4555-8555-555555555551" : ""}`, payload: { ...(method === "POST" ? body : {}), [field]: date } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details.fieldErrors[field].length).toBeGreaterThan(0); + expect(prisma.contractRegistry.create).not.toHaveBeenCalled(); + expect(prisma.contractRegistry.updateMany).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it.each(["2024-02-29", "2026-04-30", null, undefined])("preserves create and patch nullable/optional dates: %s", async (date) => { + const { prisma, state } = buildStatefulPrisma([buildContractRow({ status: "draft" })]); + const { app } = await buildApp({ prisma }); + try { + const dates = date === undefined ? {} : { signedDate: date, effectiveDate: date, expiryDate: date }; + const created = await app.inject({ method: "POST", url: "/api/contracts-registry", payload: { ...body, ...dates } }); + expect(created.statusCode).toBe(201); + const expected = date ? new Date(`${date}T00:00:00.000Z`) : null; + expect(prisma.contractRegistry.create.mock.calls[0][0].data).toMatchObject({ signed_date: expected, effective_date: expected, expiry_date: expected }); + const patched = await app.inject({ method: "PATCH", url: `/api/contracts-registry/${state.contracts[0].id}`, payload: { summary: "合成修改", ...dates } }); + expect(patched.statusCode).toBe(200); + expect(state.contracts[0].signed_date).toEqual(date === undefined ? new Date("2026-03-01T00:00:00.000Z") : expected); + } finally { await app.close(); } + }); + it.each(["expiryDateFrom", "expiryDateTo"])("rejects invalid filter %s before contract reads", async (field) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/contracts-registry?${field}=2026-02-30` }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: "contract_registry.query_invalid", formErrors: [], fieldErrors: { [field]: expect.arrayContaining(["invalid_calendar_date"]) } } }); + expect(prisma.contractRegistry.findMany).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); + + +describe("contract expiring query reasons", () => { + it.each(["days=0", "days=366", "days=1.5", "days=invalid", "unexpected=1"])("rejects %s before contract queries", async (query) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/contracts-registry/expiring?${query}` }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: "contract_registry.expiring_query_invalid" } }); + const details = response.json().error.details; + if (query.startsWith("days=")) expect(details.fieldErrors.days).toHaveLength(1); + else expect(details.formErrors).toHaveLength(1); + expect(prisma.contractRegistry.findMany).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it.each([1, 365])("retains the legal %s day boundary", async (days) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/contracts-registry/expiring?days=${days}` }); + expect(response.statusCode).toBe(200); + expect(prisma.contractRegistry.findMany).toHaveBeenCalledOnce(); + } finally { await app.close(); } + }); +}); + + +describe("contract write error reasons", () => { + const id = "55555555-5555-4555-8555-555555555551"; + it.each([ + ["POST", "", {}, "create"], + ["PATCH", `/${id}`, {}, "update"], + ["POST", `/${id}/terminate`, { reason: "short" }, "terminate"], + ["POST", `/${id}/corrections`, { summary: "short" }, "correction"], + ])("explains %s %s input before business access", async (method, path, payload, operation) => { + const { app, prisma } = await buildApp(); + const transaction = vi.spyOn(prisma, "$transaction"); + try { + const response = await app.inject({ method, url: `/api/contracts-registry${path}`, payload }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: `contract_registry.${operation}_input_invalid`, fieldErrors: expect.any(Object), formErrors: expect.any(Array) } }); + for (const fn of Object.values(prisma.contractRegistry)) expect(fn).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + const terminated = { status: "terminated", terminated_at: new Date("2026-04-04T08:30:00.000Z"), terminated_reason: "合成终止原因用于测试", terminated_by: ACTOR_ID }; + it.each([ + ["PATCH", "", terminated, { summary: "合成修改" }, "terminated_is_final"], + ["PATCH", "", { status: "archived" }, { summary: "合成修改" }, "archived_fields_locked"], + ["POST", "/terminate", { status: "draft" }, { reason: "合成终止原因满足原长度要求" }, "terminate_not_allowed"], + ["POST", "/corrections", { status: "active" }, { summary: "合成纠错说明满足原长度要求" }, "correction_requires_archived"], + ["POST", "/renew", { status: "draft" }, undefined, "renew_not_allowed"], + ])("explains %s %s state guard %s", async (method, suffix, row, payload, reason) => { + const stateful = buildStatefulPrisma([buildContractRow(row)]); + const { app, prisma } = await buildApp({ prisma: stateful.prisma }); + try { + const response = await app.inject({ method, url: `/api/contracts-registry/${id}${suffix}`, payload }); + expect(response.statusCode).toBe(409); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { status: reason, reasonCode: `contract_registry.${reason}` } }); + expect(prisma.contractRegistry.create).not.toHaveBeenCalled(); + expect(prisma.contractRegistry.updateMany).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(stateful.state.contracts).toHaveLength(1); + } finally { await app.close(); } + }); + + it.each([ + ["PATCH", "", "active", { summary: "合成修改" }, "contract_state_conflict", "state_conflict"], + ["POST", "/terminate", "active", { reason: "合成终止原因满足原长度要求" }, "terminate_conflict", "terminate_conflict"], + ["POST", "/corrections", "archived", { summary: "合成纠错说明满足原长度要求" }, "correction_state_conflict", "correction_state_conflict"], + ])("explains optimistic conflict for %s %s without success audit", async (method, suffix, status, payload, detail, reason) => { + const stateful = buildStatefulPrisma([buildContractRow({ status })]); + stateful.prisma.contractRegistry.updateMany.mockResolvedValueOnce({ count: 0 }); + const { app, prisma } = await buildApp({ prisma: stateful.prisma }); + try { + const response = await app.inject({ method, url: `/api/contracts-registry/${id}${suffix}`, payload }); + expect(response.statusCode).toBe(409); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { status: detail, reasonCode: `contract_registry.${reason}` } }); + expect(prisma.contractRegistry.updateMany).toHaveBeenCalledOnce(); + expect(prisma.contractRegistry.create).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(stateful.state.contracts[0].status).toBe(status); + } finally { await app.close(); } + }); + + it("retains renewal's attempted draft before source-version conflict", async () => { + const stateful = buildStatefulPrisma([buildContractRow()]); + stateful.prisma.contractRegistry.updateMany.mockResolvedValueOnce({ count: 0 }); + const { app, prisma } = await buildApp({ prisma: stateful.prisma }); + try { + const response = await app.inject({ method: "POST", url: `/api/contracts-registry/${id}/renew` }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ status: "contract_state_conflict", reasonCode: "contract_registry.state_conflict" }); + expect(prisma.contractRegistry.create).toHaveBeenCalledOnce(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + // This fixture restores its own memory; it does not prove a PostgreSQL rollback. + expect(stateful.state.contracts).toHaveLength(1); + } finally { await app.close(); } + }); + + it("keeps feature denial before business access", async () => { + const { app, prisma } = await buildApp(); + prisma.featureFlag.findUnique.mockImplementation(async ({ where }) => ({ ...buildFeatureFlagRecord(where.flag_key), override_mode: "force_off" })); + try { + const response = await app.inject({ method: "GET", url: "/api/contracts-registry" }); + expect(response.statusCode).toBe(409); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: "contract_registry.feature_disabled", featureFlag: "project_linking_v1_disabled", reason: expect.any(String) } }); + for (const fn of Object.values(prisma.contractRegistry)) expect(fn).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it("preserves management permission ahead of malformed input", async () => { + const { app, prisma } = await buildApp({ actor: { id: ACTOR_ID, permissionCodes: [] } }); + try { + const response = await app.inject({ method: "POST", url: "/api/contracts-registry", payload: {} }); + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe("PERM_001"); + expect(prisma.contractRegistry.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/dashboard.calendar.test.js b/server/tests/unit/routes/dashboard.calendar.test.js new file mode 100644 index 00000000..17d3c3f1 --- /dev/null +++ b/server/tests/unit/routes/dashboard.calendar.test.js @@ -0,0 +1,61 @@ +const Fastify = require("fastify"); +const dashboardRoutes = require("../../../src/routes/dashboard"); +const { AppError } = require("../../../src/utils/AppError"); +const paths = ["expense-overview", "trends", "top-categories", "approval-efficiency"]; + +async function buildApp(allowed = true) { + const app = Fastify(); + const prisma = { + reimbursementForm: { count: vi.fn().mockResolvedValue(0), aggregate: vi.fn().mockResolvedValue({ _sum: { total_amount: null } }), groupBy: vi.fn().mockResolvedValue([]), findMany: vi.fn().mockResolvedValue([]) }, + reimbursementItem: { groupBy: vi.fn().mockResolvedValue([]) }, + expenseType: { findMany: vi.fn().mockResolvedValue([]) }, + }; + app.decorate("prisma", prisma); + app.decorate("requirePermission", () => async (request) => { + request.employee = { id: "synthetic", roleCodes: ["finance"], permissionCodes: ["finance.ledger.view"] }; + if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); + }); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); + await app.register(dashboardRoutes, { prefix: "/api" }); + return { app, queries: Object.values(prisma).flatMap(Object.values) }; +} + +describe("dashboard calendar and explanatory read errors", () => { + it.each(paths)("%s rejects bad dates and reversed bounds before reads", async (path) => { + const { app, queries } = await buildApp(); + try { + for (const field of ["dateFrom", "dateTo"]) for (const value of ["2026-02-29", "2026-04-31", "2026-13-01"]) { + const response = await app.inject({ method: "GET", url: `/api/dashboard/${path}?${field}=${value}` }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toEqual({ code: "VALID_001", details: { formErrors: [], fieldErrors: { [field]: ["invalid_calendar_date"] }, reasonCode: "dashboard.query_invalid" } }); + } + const reversed = await app.inject({ method: "GET", url: `/api/dashboard/${path}?dateFrom=2026-05-01&dateTo=2026-04-30` }); + expect(reversed.statusCode).toBe(400); + expect(reversed.json().error.details).toEqual({ dateTo: "must_be_after_dateFrom", reasonCode: "dashboard.date_range_invalid" }); + for (const query of queries) expect(query).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it.each(paths)("%s preserves valid UTC bounds and omitted range", async (path) => { + const { app, queries } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/dashboard/${path}?dateFrom=2024-02-29&dateTo=2026-04-30` }); + expect(response.statusCode).toBe(200); + const clauses = queries.flatMap((query) => query.mock.calls).map(([arg]) => arg.where.reimbursement_form ?? arg.where); + expect(clauses.some((where) => where.AND.some((clause) => clause.reimbursement_date?.gte?.toISOString() === "2024-02-29T00:00:00.000Z" && clause.reimbursement_date?.lte?.toISOString() === "2026-04-30T23:59:59.999Z"))).toBe(true); + for (const query of queries) query.mockClear(); + expect((await app.inject({ method: "GET", url: `/api/dashboard/${path}` })).statusCode).toBe(200); + const calls = queries.flatMap((query) => query.mock.calls); + expect(calls.length).toBeGreaterThan(0); + for (const [arg] of calls) expect((arg.where.reimbursement_form ?? arg.where).AND.some((clause) => clause.reimbursement_date)).toBe(false); + } finally { await app.close(); } + }); + + it.each(paths)("%s retains permission rejection before reads", async (path) => { + const { app, queries } = await buildApp(false); + try { + expect((await app.inject({ method: "GET", url: `/api/dashboard/${path}` })).statusCode).toBe(403); + for (const query of queries) expect(query).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/date-consumers.calendar.test.js b/server/tests/unit/routes/date-consumers.calendar.test.js new file mode 100644 index 00000000..75856107 --- /dev/null +++ b/server/tests/unit/routes/date-consumers.calendar.test.js @@ -0,0 +1,92 @@ +const Fastify = require("fastify"); +const { AppError } = require("../../../src/utils/AppError"); +const routes = Object.fromEntries(["payroll", "budgets", "performance", "attendance", "attendanceCorrections", "leave"].map((name) => [name, require(`../../../src/routes/${name}`)])); +const ID = "11111111-1111-4111-8111-111111111111"; +const budgetBody = { code: "SYNTHETIC", name: "合成预算", periodStart: "2024-02-29", periodEnd: "2026-04-30", allocatedAmount: "100.00" }; +const cycleBody = { name: "合成周期", cycleType: "quarterly", startDate: "2024-02-29", endDate: "2026-04-30" }; +const leaveBody = { leaveTypeId: ID, startDate: "2024-02-29", endDate: "2026-04-30", startSession: "am", endSession: "pm", reason: "synthetic" }; +const cases = [ + ["payroll", "PUT", `/payroll/employees/${ID}/profile`, { effectiveDate: "2024-02-29", items: [{ salaryItemId: ID, monthlyAmount: "100.00" }] }, "effectiveDate"], + ...["periodStart", "periodEnd"].map((field) => ["budgets", "POST", "/budgets", budgetBody, field]), + ...["startDate", "endDate"].flatMap((field) => [["performance", "POST", "/performance/cycles", cycleBody, field], ["performance", "PATCH", `/performance/cycles/${ID}`, {}, field]]), + ["attendance", "GET", "/attendance/team", {}, "date"], + ["attendance", "POST", "/attendance/admin/backfill", { employeeId: ID, clockInAt: "2026-04-30T09:00:00+08:00" }, "workDate"], + ...["startDate", "endDate"].map((field) => ["attendance", "POST", "/attendance/wecom/sync", { startDate: "2026-04-01", endDate: "2026-04-30" }, field]), + ["attendanceCorrections", "POST", "/attendance-corrections", { kind: "missed_punch", missedPunchAt: "2026-04-30T09:00:00+08:00", reason: "synthetic" }, "workDate"], + ...["startDate", "endDate"].flatMap((field) => [["leave", "POST", "/leave-requests", leaveBody, field], ["leave", "PATCH", `/leave-requests/${ID}`, leaveBody, field], ["leave", "GET", "/leave-requests/working-days", { startDate: "2024-02-29", endDate: "2026-04-30", startSession: "am", endSession: "pm" }, field]]), + ...["dateFrom", "dateTo"].map((field) => ["leave", "GET", "/leave-requests", {}, field]), +]; + +async function buildApp(name, { allowed = true, budgetSuccess = false } = {}) { + const app = Fastify(); + const businessCalls = []; + const rejectCall = (...args) => { businessCalls.push(args); throw new Error("unexpected_business_access"); }; + const prisma = new Proxy({ + featureFlag: { findUnique: vi.fn(async ({ where }) => ({ flag_key: where.flag_key, override_mode: where.flag_key.includes("cutover") ? "force_off" : "force_on", rollout_type: "global", department_ids: [] })) }, + $transaction: vi.fn(rejectCall), + }, { get(target, key) { + if (!(key in target)) target[key] = new Proxy({}, { get(model, operation) { return model[operation] ??= vi.fn(rejectCall); } }); + return target[key]; + } }); + if (budgetSuccess) { + prisma.budgetPool = { create: vi.fn(async ({ data }) => ({ id: ID, ...data })) }; + prisma.auditLog = { create: vi.fn().mockResolvedValue({}) }; + } + const authenticate = async (request) => { request.employee = { id: ID, status: "active", roleCodes: ["admin"], permissionCodes: ["*"], department_id: null }; }; + const permission = async (request) => { await authenticate(request); if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); }; + app.decorate("config", { appTimezone: "Asia/Shanghai", employeeLifecycleEnabled: true, attendanceUnifiedEnabled: true }); + app.decorate("prisma", prisma); + app.decorate("authenticate", permission); + app.decorate("requirePermission", () => permission); + app.decorate("requireEntityPermission", () => permission); + app.decorate("assertEntityPermission", () => {}); + app.decorate("hasPermission", () => allowed); + await app.register(require("@fastify/multipart")); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); + await app.register(routes[name], { prefix: "/api" }); + return { app, prisma, businessCalls }; +} + +describe("remaining calendar consumers", () => { + it.each(cases)("%s %s %s rejects calendar overflow before business access (%j / %s)", async (name, method, path, body, field) => { + const { app, prisma, businessCalls } = await buildApp(name); + try { + for (const date of ["2026-02-29", "2026-04-31"]) { + const input = { ...body, [field]: date }; + const response = await app.inject({ method, url: `/api${path}${method === "GET" ? `?${new URLSearchParams(input)}` : ""}`, ...(method === "GET" ? {} : { payload: input }) }); + const legacyDisabled = name === "attendance" && ["/attendance/team", "/attendance/admin/backfill"].includes(path); + expect(response.statusCode).toBe(legacyDisabled ? 409 : 400); + expect(response.json().error.code).toBe("VALID_001"); + if (legacyDisabled) expect(response.json().error.details).toEqual({ featureFlag: "attendance_v1_disabled", reason: "env_hard_off", reasonCode: "attendance.feature_disabled" }); + else expect(response.json().error.details.fieldErrors[field]).toContain("invalid_calendar_date"); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(businessCalls).toEqual([]); + } + } finally { await app.close(); } + }); + + it("budget stores exact leap-day/month-end dates and retains reverse-range refusal", async () => { + const { app, prisma, businessCalls } = await buildApp("budgets", { budgetSuccess: true }); + try { + const response = await app.inject({ method: "POST", url: "/api/budgets", payload: budgetBody }); + expect(response.statusCode).toBe(200); + expect(response.json().data).toMatchObject({ periodStart: "2024-02-29", periodEnd: "2026-04-30", allocatedAmount: "100.00" }); + expect(prisma.budgetPool.create.mock.calls[0][0].data).toMatchObject({ period_start: new Date("2024-02-29T00:00:00Z"), period_end: new Date("2026-04-30T00:00:00Z") }); + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const reversed = await app.inject({ method: "POST", url: "/api/budgets", payload: { ...budgetBody, periodStart: "2026-05-01" } }); + expect(reversed.statusCode).toBe(400); + expect(reversed.json().error.details).toEqual({ periodEnd: "must_be_after_periodStart", reasonCode: "budget.period_invalid" }); + expect(prisma.budgetPool.create).toHaveBeenCalledTimes(1); + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + expect(businessCalls).toEqual([]); + } finally { await app.close(); } + }); + + it("budget retains permission refusal without writes", async () => { + const { app, businessCalls } = await buildApp("budgets", { allowed: false }); + try { + expect((await app.inject({ method: "POST", url: "/api/budgets", payload: budgetBody })).statusCode).toBe(403); + expect(businessCalls).toEqual([]); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/delegations.errors.test.js b/server/tests/unit/routes/delegations.errors.test.js new file mode 100644 index 00000000..4239819c --- /dev/null +++ b/server/tests/unit/routes/delegations.errors.test.js @@ -0,0 +1,79 @@ +const Fastify = require("fastify"); +const routes = require("../../../src/routes/delegations"); +const { AppError } = require("../../../src/utils/AppError"); +const id = (n) => `11111111-1111-4111-8111-${n.toString(16).padStart(12, "0")}`; +const ACTOR = id(1), DELEGATE = id(2), OTHER = id(3), THIRD = id(4), RECORD = id(10); +const NOW = new Date("2026-01-15T12:00:00Z"); +function row(overrides = {}) { return { id: RECORD, principal_employee_id: ACTOR, delegate_employee_id: DELEGATE, scope: "all", valid_from: new Date("2026-01-01T00:00:00Z"), valid_to: null, revoked_at: null, created_at: new Date("2026-01-01T00:00:00Z"), ...overrides }; } +// Minimal in-memory interpretation of the real route's where object, not a Prisma/PG emulator. +function matches(record, where) { + return Object.entries(where ?? {}).every(([key, value]) => { + if (key === "AND") return value.every((item) => matches(record, item)); + if (key === "OR") return value.some((item) => matches(record, item)); + if (value && typeof value === "object" && !(value instanceof Date)) { + return Object.entries(value).every(([operator, bound]) => operator === "lte" ? record[key] <= bound : operator === "gt" ? record[key] > bound : false); + } + return record[key] === value; + }); +} +const apps = []; +beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(NOW); }); +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); vi.useRealTimers(); }); +async function build({ permissions = ["expense.proxy_manage"], records = [row()] } = {}) { + const app = Fastify(); apps.push(app); + const state = { records }; + const prisma = { + employee: { findUnique: vi.fn(async ({ where }) => ({ id: where.id, status: "active", roles: [{ role_code: "finance" }] })) }, + delegationGrant: { + findUnique: vi.fn(async ({ where }) => state.records.find((item) => item.id === where.id) ?? null), + findMany: vi.fn(async ({ where }) => state.records.filter((item) => matches(item, where))), + create: vi.fn(async ({ data }) => { const created = row({ ...data, id: id(100 + state.records.length) }); state.records.push(created); return created; }), + update: vi.fn(async ({ where, data }) => Object.assign(state.records.find((item) => item.id === where.id), data)), + }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + prisma.$transaction = vi.fn(async (cb) => cb(prisma)); + app.decorate("prisma", prisma); + app.decorate("authenticate", async (request) => { request.employee = { id: ACTOR, permissionCodes: permissions }; }); + app.decorate("hasPermission", (_, permission) => permissions.includes(permission)); + app.decorate("requirePermission", (permission) => async (request) => { await app.authenticate(request); if (!permissions.includes(permission)) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); }); + app.setErrorHandler((error, _, reply) => reply.status(error instanceof AppError ? error.statusCode : 500).send({ error: { code: error.code, details: error.details ?? {} } })); + await app.register(routes, { prefix: "/api" }); return { app, prisma, state }; +} +const request = (app, method, path, payload) => app.inject({ method, url: `/api${path}`, payload }); +function noWrites(prisma) { expect(prisma.delegationGrant.create).not.toHaveBeenCalled(); expect(prisma.delegationGrant.update).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); } +function rejected(r, reason, status, details = {}) { expect(r.statusCode).toBe(status); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: `delegation.${reason}`, ...details } }); } + +describe("delegation list scope regression", () => { + function data() { return [row({ id: id(11) }), row({ id: id(12), principal_employee_id: OTHER, delegate_employee_id: ACTOR }), row({ id: id(13), principal_employee_id: OTHER, delegate_employee_id: THIRD }), row({ id: id(14), valid_from: new Date("2026-01-16T00:00:00Z") }), row({ id: id(15), valid_to: NOW }), row({ id: id(16), revoked_at: NOW }), row({ id: id(17), valid_from: NOW, valid_to: new Date("2026-01-16T00:00:00Z") })]; } + it.each(["", "?includeInactive=false", "?includeInactive=0"])("keeps both actor and active predicates %s", async (query) => { + const { app, prisma } = await build({ records: data() }); const r = await request(app, "GET", `/delegation-grants${query}`); + expect(r.statusCode).toBe(200); expect(r.json().data.items.map((item) => item.id)).toEqual([id(11), id(12), id(17)]); noWrites(prisma); + }); + it.each(["true", "1"])("includes inactive own grants while retaining actor scope %s", async (value) => { const { app } = await build({ records: data() }); const r = await request(app, "GET", `/delegation-grants?includeInactive=${value}&principalEmployeeId=${OTHER}`); expect(r.statusCode).toBe(200); expect(r.json().data.items.map((item) => item.id)).toEqual([id(11), id(12), id(14), id(15), id(16), id(17)]); }); + it.each(["false", "true"])("keeps administrator explicit filters %s", async (includeInactive) => { const { app } = await build({ records: data(), permissions: ["system.users"] }); const r = await request(app, "GET", `/delegation-grants?includeInactive=${includeInactive}&principalEmployeeId=${OTHER}&delegateEmployeeId=${THIRD}`); expect(r.statusCode).toBe(200); expect(r.json().data.items.map((item) => item.id)).toEqual([id(13)]); }); + it("keeps ordinary explicit filters from enlarging actor scope", async () => { const { app } = await build({ records: data() }); const r = await request(app, "GET", `/delegation-grants?principalEmployeeId=${OTHER}&delegateEmployeeId=${THIRD}`); expect(r.json().data.items.map((item) => item.id)).toEqual([id(11), id(12), id(17)]); }); +}); + +describe("delegation rejection contracts", () => { + it.each([["POST", "/delegations", {}, "create_input_invalid"], ["POST", "/delegation-grants", {}, "grant_input_invalid"], ["GET", "/delegations/mine?status=bad", undefined, "status_query_invalid"], ["GET", "/delegation-grants?includeInactive=bad", undefined, "grants_query_invalid"], ["POST", "/delegations/bad/revoke", undefined, "id_invalid"], ["POST", "/delegation-grants/bad/revoke", undefined, "id_invalid"]])("explains %s %s before reads", async (method, path, payload, reason) => { const { app, prisma } = await build(); rejected(await request(app, method, path, payload), reason, 400); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); expect(prisma.delegationGrant.findUnique).not.toHaveBeenCalled(); expect(prisma.delegationGrant.findMany).not.toHaveBeenCalled(); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each(["/delegations", "/delegation-grants"].flatMap((path) => ["2026-01-14T00:00:00Z", NOW.toISOString()].map((validTo) => [path, validTo])))("rejects %s reversed/equal period %s", async (path, validTo) => { const { app, prisma } = await build(); rejected(await request(app, "POST", path, { delegateEmployeeId: DELEGATE, validFrom: NOW.toISOString(), validTo }), "time_range_invalid", 400, { validTo: "must_be_after_validFrom" }); noWrites(prisma); }); + it.each(["/delegations", "/delegation-grants"])("rejects self %s", async (path) => { const { app, prisma } = await build(); rejected(await request(app, "POST", path, { delegateEmployeeId: ACTOR }), "self_not_allowed", 409, { delegateEmployeeId: "self_not_allowed" }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each([null, { id: ACTOR, status: "inactive" }])("rejects unavailable principal %s", async (principal) => { const { app, prisma } = await build(); prisma.employee.findUnique.mockResolvedValueOnce(principal); rejected(await request(app, "POST", "/delegation-grants", { delegateEmployeeId: DELEGATE }), "principal_unavailable", 409, { principalEmployeeId: "invalid_principal" }); noWrites(prisma); }); + it.each(["/delegations", "/delegation-grants"].flatMap((path) => [null, { id: DELEGATE, status: "inactive", roles: [] }].map((delegate) => [path, delegate])))("rejects unavailable delegate %s %s", async (path, delegate) => { const { app, prisma } = await build(); prisma.employee.findUnique.mockImplementation(async ({ where }) => where.id === ACTOR ? { id: ACTOR, status: "active" } : delegate); rejected(await request(app, "POST", path, { delegateEmployeeId: DELEGATE }), "delegate_unavailable", 409, { delegateEmployeeId: "invalid_delegate" }); noWrites(prisma); }); + it.each([["reimbursement_submit", "delegate_proxy_permission_required", "delegate_must_be_administration"], ["all", "delegate_approval_permission_required", "delegate_must_be_approver"]])("explains scope eligibility %s", async (scope, reason, detail) => { const { app, prisma } = await build(); prisma.employee.findUnique.mockResolvedValue({ id: DELEGATE, status: "active", roles: [{ role_code: "employee" }] }); rejected(await request(app, "POST", "/delegations", { delegateEmployeeId: DELEGATE, scope }), reason, 409, { delegateEmployeeId: detail }); noWrites(prisma); }); + it("preserves ordinary principal-write refusal", async () => { const { app, prisma } = await build(); const r = await request(app, "POST", "/delegation-grants", { principalEmployeeId: OTHER, delegateEmployeeId: DELEGATE }); expect(r.statusCode).toBe(403); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each(["/delegations", "/delegation-grants"])("retains create defaults and revoke replay %s", async (path) => { + const { app, prisma } = await build(); const created = await request(app, "POST", path, { delegateEmployeeId: DELEGATE }); expect(created.statusCode).toBe(200); const grant = created.json().data.delegation; + expect(grant).toMatchObject({ principalEmployeeId: ACTOR, delegateEmployeeId: DELEGATE, scope: "all", validFrom: NOW.toISOString(), validTo: null }); + expect((await request(app, "POST", `${path}/${grant.id}/revoke`)).json().data.outcome).toBe("revoked"); expect((await request(app, "POST", `${path}/${grant.id}/revoke`)).json().data.outcome).toBe("already_revoked"); + expect(prisma.delegationGrant.update).toHaveBeenCalledOnce(); expect(prisma.auditLog.create).toHaveBeenCalledTimes(2); + }); + it("preserves proxy-submit eligible role and scheduled validity", async () => { const { app, prisma } = await build(); prisma.employee.findUnique.mockResolvedValue({ id: DELEGATE, status: "active", roles: [{ role_code: "administration" }] }); const r = await request(app, "POST", "/delegations", { delegateEmployeeId: DELEGATE, scope: "reimbursement_submit", validFrom: "2026-01-16T00:00:00Z", validTo: "2026-01-17T00:00:00Z" }); expect(r.statusCode).toBe(200); expect(r.json().data.delegation.status).toBe("scheduled"); }); + it("preserves legacy settings revoke permission without extending grant API", async () => { + const { app, prisma } = await build({ permissions: ["expense.proxy_manage", "system.settings"], records: [row({ principal_employee_id: OTHER, delegate_employee_id: THIRD })] }); + expect((await request(app, "POST", `/delegation-grants/${RECORD}/revoke`)).statusCode).toBe(403); noWrites(prisma); + expect((await request(app, "POST", `/delegations/${RECORD}/revoke`)).statusCode).toBe(200); expect(prisma.delegationGrant.update).toHaveBeenCalledOnce(); + }); + it("keeps legacy permission gate before schema", async () => { const { app, prisma } = await build({ permissions: [] }); expect((await request(app, "POST", "/delegations", {})).statusCode).toBe(403); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); +}); diff --git a/server/tests/unit/routes/departments.errors.test.js b/server/tests/unit/routes/departments.errors.test.js new file mode 100644 index 00000000..f70b960b --- /dev/null +++ b/server/tests/unit/routes/departments.errors.test.js @@ -0,0 +1,95 @@ +const Fastify = require("fastify"); +const routes = require("../../../src/routes/departments"); +const { AppError } = require("../../../src/utils/AppError"); +const ID = "11111111-1111-4111-8111-111111111111"; +const PARENT = "22222222-2222-4222-8222-222222222222"; +const apps = []; +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); +async function buildApp(allowed = true) { + const app = Fastify(); apps.push(app); + const current = { id: ID, name: "合成部门", parent_id: null, archived_at: null }; + const parent = { id: PARENT, name: "合成上级", parent_id: null, archived_at: null }; + const prisma = { + department: { + findUnique: vi.fn(async ({ where }) => where.id === ID ? current : parent), + findMany: vi.fn(async () => [current, parent]), count: vi.fn().mockResolvedValue(0), + create: vi.fn(async ({ data }) => ({ id: ID, ...data })), update: vi.fn(async ({ data }) => ({ ...current, ...data })), + }, + employee: { count: vi.fn().mockResolvedValue(0) }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + prisma.$transaction = vi.fn(async (callback) => callback(prisma)); + app.decorate("prisma", prisma); + app.decorate("authenticate", async (request) => { request.employee = { id: ID }; }); + app.decorate("requirePermission", () => async (request) => { await app.authenticate(request); if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); }); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); + await app.register(routes, { prefix: "/api" }); return { app, prisma, current, parent }; +} +function expectNoWrites(prisma) { expect(prisma.department.create).not.toHaveBeenCalled(); expect(prisma.department.update).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); } +function expectError(response, status, reasonCode, details) { + expect(response.statusCode).toBe(status); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); +} + +it.each([ + ["GET", "/departments?includeArchived=invalid", undefined, "department.query_invalid"], + ["POST", "/departments", { name: "" }, "department.create_input_invalid"], + ["PATCH", `/departments/${ID}`, { parentId: "invalid" }, "department.update_input_invalid"], +])("explains %s %s input before database calls", async (method, path, payload, reasonCode) => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }); + expect(response.statusCode).toBe(400); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode, formErrors: [], fieldErrors: expect.any(Object) } }); + expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.department.findMany).not.toHaveBeenCalled(); expectNoWrites(prisma); +}); +it.each(["PATCH", "POST"])("rejects invalid department identifiers for %s", async (method) => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method, url: `/api/departments/bad${method === "POST" ? "/archive" : ""}`, ...(method === "PATCH" ? { payload: { name: "合成" } } : {}) }); + expectError(response, 400, "department.id_invalid", { id: "invalid_uuid" }); expect(prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(prisma); +}); +it("rejects an empty patch before starting a transaction", async () => { + const { app, prisma } = await buildApp(); + expectError(await app.inject({ method: "PATCH", url: `/api/departments/${ID}`, payload: {} }), 400, "department.update_empty", { body: "empty" }); + expect(prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(prisma); +}); +it.each(["POST", "PATCH"].flatMap((method) => ["missing", "archived"].map((state) => [method, state])))("preserves %s parent %s refusal", async (method, state) => { + const { app, prisma, current, parent } = await buildApp(); + if (state === "missing") prisma.department.findUnique.mockImplementation(async ({ where }) => where.id === ID ? current : null); + else parent.archived_at = new Date("2024-01-01T00:00:00Z"); + const response = await app.inject({ method, url: `/api/departments${method === "PATCH" ? `/${ID}` : ""}`, payload: { name: "合成", parentId: PARENT } }); + expectError(response, state === "missing" ? 400 : 409, state === "missing" ? "department.parent_not_found" : "department.parent_archived", { parentId: state === "missing" ? "not_found" : "archived" }); expectNoWrites(prisma); +}); +it.each([ + ["archived", "department.archived_locked", { status: "archived" }], + ["self", "department.parent_self_forbidden", { parentId: "self_not_allowed" }], + ["cycle", "department.parent_cycle_forbidden", { parentId: "cycle_not_allowed" }], +])("preserves the %s hierarchy guard inside the transaction", async (state, reason, details) => { + const { app, prisma, current, parent } = await buildApp(); + if (state === "archived") current.archived_at = new Date("2024-01-01T00:00:00Z"); + if (state === "cycle") parent.parent_id = ID; + const response = await app.inject({ method: "PATCH", url: `/api/departments/${ID}`, payload: { parentId: state === "self" ? ID : PARENT } }); + expectError(response, 409, reason, details); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expectNoWrites(prisma); +}); +it.each([ + ["department", "department.children_exist", "has_children"], + ["employee", "department.active_employees_exist", "has_active_employees"], +])("preserves %s references blocking archival", async (model, reason, detail) => { + const { app, prisma } = await buildApp(); prisma[model].count.mockResolvedValue(1); + expectError(await app.inject({ method: "POST", url: `/api/departments/${ID}/archive` }), 409, reason, { department: detail }); expectNoWrites(prisma); +}); +it("preserves archive idempotency and missing-department 404", async () => { + const { app, prisma, current } = await buildApp(); current.archived_at = new Date("2024-01-01T00:00:00Z"); + const archived = await app.inject({ method: "POST", url: `/api/departments/${ID}/archive` }); + expect(archived.statusCode).toBe(200); expect(archived.json().data.outcome).toBe("already_archived"); expect(prisma.department.count).not.toHaveBeenCalled(); expectNoWrites(prisma); + prisma.department.findUnique.mockResolvedValue(null); + expect((await app.inject({ method: "PATCH", url: `/api/departments/${ID}`, payload: { name: "合成" } })).statusCode).toBe(404); expectNoWrites(prisma); +}); +it("preserves normal create, parent clearing and archive audit", async () => { + const { app, prisma } = await buildApp(); + expect((await app.inject({ method: "POST", url: "/api/departments", payload: { name: "合成部门", parentId: PARENT } })).statusCode).toBe(200); + expect((await app.inject({ method: "PATCH", url: `/api/departments/${ID}`, payload: { parentId: null } })).statusCode).toBe(200); + expect(prisma.department.update.mock.calls[0][0].data.parent_id).toBeNull(); + const archived = await app.inject({ method: "POST", url: `/api/departments/${ID}/archive` }); + expect(archived.statusCode).toBe(200); expect(archived.json().data.outcome).toBe("archived"); expect(prisma.auditLog.create).toHaveBeenCalledTimes(3); +}); +it("preserves permission refusal before create validation", async () => { + const { app, prisma } = await buildApp(false); + expect((await app.inject({ method: "POST", url: "/api/departments", payload: {} })).statusCode).toBe(403); expect(prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(prisma); +}); diff --git a/server/tests/unit/routes/employees.attachments.test.js b/server/tests/unit/routes/employees.attachments.test.js index 43157f89..b4f34d31 100644 --- a/server/tests/unit/routes/employees.attachments.test.js +++ b/server/tests/unit/routes/employees.attachments.test.js @@ -16,10 +16,10 @@ const MINIMAL_PDF = Buffer.from( "ascii", ); -function buildMultipartRequest({ filename, contentType, content }) { +function buildMultipartRequest({ filename, contentType, content, field = "file" }) { const boundary = "----codex-employee-attachment-boundary"; const head = Buffer.from( - `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`, + `--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`, ); const tail = Buffer.from(`\r\n--${boundary}--\r\n`); const body = Buffer.concat([head, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"), tail]); @@ -338,8 +338,10 @@ describe("employee attachment and delete routes", () => { expect(response.statusCode).toBe(409); expect(response.headers["content-disposition"]).toBeUndefined(); expect(response.rawPayload.equals(invalidStoredContent)).toBe(false); + expect(response.json().error.message).toBe("合同附件未通过安全校验"); expect(response.json().error.details).toEqual({ attachment: "security_validation_failed", + reasonCode: "employee.contract_attachment_download_blocked", }); expect(testApp.auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -418,7 +420,7 @@ describe("employee attachment and delete routes", () => { expect(response.json().error).toEqual( expect.objectContaining({ code: "VALID_001", - details: { file: "type_mismatch" }, + details: { file: "type_mismatch", reasonCode: "employee.contract_attachment_content_invalid" }, }), ); expect(testApp.contractUpdate).not.toHaveBeenCalled(); @@ -562,6 +564,9 @@ describe("employee attachment and delete routes", () => { expect(activeResponse.statusCode).toBe(409); expect(activeResponse.json().error.code).toBe("VALID_001"); + expect(activeResponse.json().error.details).toEqual({ contract: "active_contract_must_be_terminated", reasonCode: "employee.active_contract_delete_forbidden" }); + expect(activeApp.contractDelete).not.toHaveBeenCalled(); + expect(activeApp.auditCreate).not.toHaveBeenCalled(); expect(activeApp.state.contract).not.toBeNull(); await activeApp.app.close(); @@ -617,6 +622,7 @@ describe("employee attachment and delete routes", () => { }); expect(response.statusCode).toBe(400); + expect(response.json().error.details.reasonCode).toBe("employee.contract_delete_input_invalid"); expect(testApp.featureFlagFindUnique).not.toHaveBeenCalled(); expect(testApp.transaction).not.toHaveBeenCalled(); expect(testApp.contractDelete).not.toHaveBeenCalled(); @@ -626,3 +632,43 @@ describe("employee attachment and delete routes", () => { await fs.rm(testApp.tempDir, { recursive: true, force: true }); }); }); + +describe("employee upload error reasons", () => { + const actor = { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, permissionCodes: ["system.users", "employee.contract.manage"] }; + function uploadUrl(kind) { return kind === "avatar" ? `/api/employees/${EMPLOYEE_ID}/avatar` : `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}/attachment`; } + it.each(["avatar", "contract_attachment"].flatMap((kind) => ["multipart_required", "file_required", "field_invalid", "type_invalid", "file_empty", "file_too_large"].map((reason) => [kind, reason])))("rejects %s %s before storage or mutation", async (kind, suffix) => { + const t = await buildApp({ actor }); + try { + const boundary = "synthetic-empty-file"; + let request; + if (suffix === "multipart_required") request = { payload: { file: "synthetic" } }; + else if (suffix === "file_required") request = { headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, payload: `--${boundary}\r\nContent-Disposition: form-data; name="note"\r\n\r\nsynthetic\r\n--${boundary}--\r\n` }; + else request = buildMultipartRequest({ filename: kind === "avatar" ? "synthetic.png" : "synthetic.pdf", contentType: suffix === "type_invalid" ? "text/plain" : kind === "avatar" ? "image/png" : "application/pdf", field: suffix === "field_invalid" ? "wrong" : "file", content: suffix === "file_empty" ? Buffer.alloc(0) : suffix === "file_too_large" ? Buffer.alloc((kind === "avatar" ? 2 : 10) * 1024 * 1024 + 1, 65) : Buffer.from("synthetic") }); + const response = await t.app.inject({ method: "PUT", url: uploadUrl(kind), ...request }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ file: { multipart_required: "invalid_multipart", file_required: "required", field_invalid: "invalid_field", type_invalid: "invalid_type", file_empty: "empty", file_too_large: "file_too_large" }[suffix], reasonCode: `employee.${kind}_${suffix}` }); + expect(t.employeeUpdate).not.toHaveBeenCalled(); expect(t.contractUpdate).not.toHaveBeenCalled(); expect(t.auditCreate).not.toHaveBeenCalled(); expect(t.transaction).not.toHaveBeenCalled(); expect(await fs.readdir(t.tempDir)).toEqual([]); + } finally { await t.app.close(); await fs.rm(t.tempDir, { recursive: true, force: true }); } + }); + it("rejects invalid avatar image bytes before persistence", async () => { + const t = await buildApp({ actor }); + try { + const response = await t.app.inject({ method: "PUT", url: uploadUrl("avatar"), ...buildMultipartRequest({ filename: "synthetic.png", contentType: "image/png", content: "synthetic-invalid-image" }) }); + expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ file: "invalid_image", reasonCode: "employee.avatar_image_invalid" }); + expect(t.employeeUpdate).not.toHaveBeenCalled(); expect(await fs.readdir(t.tempDir)).toEqual([]); + } finally { await t.app.close(); await fs.rm(t.tempDir, { recursive: true, force: true }); } + }); + it.each(["avatar", "contract_attachment"])("handles request.file size errors for %s and preserves other failures", async (kind) => { + for (const code of ["FST_REQ_FILE_TOO_LARGE", "SYNTHETIC_READ_FAILURE"]) { + const t = await buildApp({ actor }); + try { + t.app.addHook("preHandler", async (request) => { request.isMultipart = () => true; request.file = async () => { throw Object.assign(new Error("synthetic_file_read"), { code }); }; }); + const response = await t.app.inject({ method: "PUT", url: uploadUrl(kind), payload: {} }); + expect(response.statusCode).toBe(code === "FST_REQ_FILE_TOO_LARGE" ? 400 : 500); + if (code === "FST_REQ_FILE_TOO_LARGE") expect(response.json().error.details).toEqual({ file: "file_too_large", reasonCode: `employee.${kind}_file_too_large` }); + else expect(response.json().error.details?.reasonCode).toBeUndefined(); + expect(t.employeeUpdate).not.toHaveBeenCalled(); expect(t.contractUpdate).not.toHaveBeenCalled(); expect(t.auditCreate).not.toHaveBeenCalled(); expect(await fs.readdir(t.tempDir)).toEqual([]); + } finally { await t.app.close(); await fs.rm(t.tempDir, { recursive: true, force: true }); } + } + }); +}); diff --git a/server/tests/unit/routes/employees.calendar.test.js b/server/tests/unit/routes/employees.calendar.test.js new file mode 100644 index 00000000..29600bdc --- /dev/null +++ b/server/tests/unit/routes/employees.calendar.test.js @@ -0,0 +1,202 @@ +const Fastify = require("fastify"); +const employeesRoutes = require("../../../src/routes/employees"); +const { AppError } = require("../../../src/utils/AppError"); + +const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; +const RECORD_ID = "22222222-2222-4222-8222-222222222222"; +const baseUrl = `/api/employees/${EMPLOYEE_ID}`; +const contracts = { contractNo: "SYNTHETIC", contractType: "labor", startDate: "2024-02-29", endDate: "2026-04-30" }; +const trainings = { name: "合成培训", trainingType: "internal", status: "planned", startDate: "2024-02-29", endDate: "2026-04-30" }; +const certificates = { name: "合成证书", issuer: "合成机构", obtainedDate: "2024-02-29", expiryDate: "2026-04-30" }; +const archiveCases = [ + ["contracts", contracts, "employeeContract", ["startDate", "endDate", "terminatedDate"]], + ["trainings", trainings, "employeeTraining", ["startDate", "endDate"]], + ["certificates", certificates, "employeeCertificate", ["obtainedDate", "expiryDate"]], +]; +const lifecyclePaths = ["onboarding/start", "regularize", "transfer", "offboarding/start"]; +const invalidCases = [ + ...["hireDate", "probationEndDate", "lastWorkingDate"].map((field) => ["PATCH", "profile", {}, field]), + ...lifecyclePaths.map((route) => ["POST", route, {}, "effectiveDate"]), + ["POST", "onboarding/start", {}, "probationEndDate"], + ["POST", "offboarding/start", {}, "lastWorkingDate"], + ...archiveCases.flatMap(([route, payload, , fields]) => fields.flatMap((field) => [ + ["POST", route, payload, field], ["PATCH", `${route}/${RECORD_ID}`, {}, field], + ])), +]; + +async function buildApp({ allowed = true, self = false } = {}) { + const app = Fastify(); + const employee = { id: EMPLOYEE_ID, roles: [], department_id: null, status: "active", employment_status: "active", hire_date: new Date("2020-01-01T00:00:00Z") }; + const prisma = { featureFlag: { findUnique: vi.fn().mockResolvedValue({ override_mode: "force_on", rollout_type: "global", department_ids: [] }) } }; + for (const model of ["employee", "employeeContract", "employeeTraining", "employeeCertificate", "employeeLifecycleEvent", "employeeChecklistTask", "employeeRole", "auditLog", "wecomDirectorySyncRun", "assetCard", "workflowTask"]) { + const row = model === "employee" ? employee : { id: RECORD_ID, employee_id: EMPLOYEE_ID, start_date: new Date("2024-02-29T00:00:00Z"), end_date: new Date("2026-04-30T00:00:00Z"), obtained_date: new Date("2024-02-29T00:00:00Z"), expiry_date: new Date("2026-04-30T00:00:00Z") }; + prisma[model] = { + findUnique: vi.fn().mockResolvedValue(row), findFirst: vi.fn().mockResolvedValue(row), + findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0), + create: vi.fn(async ({ data }) => ({ ...row, ...data })), + update: vi.fn(async ({ data }) => Object.assign(row, Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined)))), + createMany: vi.fn().mockResolvedValue({ count: 1 }), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }; + } + prisma.$transaction = vi.fn(async (fn) => fn(prisma)); + const authenticate = async (request) => { request.employee = { id: self ? EMPLOYEE_ID : "synthetic-actor", permissionCodes: allowed ? ["system.users", "employee.contract.manage"] : [] }; }; + app.decorate("config", { employeeLifecycleEnabled: true, appTimezone: "Asia/Shanghai", wecomDirectorySyncEnabled: false }); + app.decorate("prisma", prisma); + app.decorate("authenticate", authenticate); + app.decorate("hasPermission", (actor, permission) => actor.permissionCodes.includes(permission)); + app.decorate("requirePermission", (permission) => async (request) => { + await authenticate(request); + if (!app.hasPermission(request.employee, permission)) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); + }); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); + await app.register(employeesRoutes, { prefix: "/api" }); + return { app, prisma }; +} + +function expectNoWrites(prisma) { + expect(prisma.$transaction).not.toHaveBeenCalled(); + for (const model of Object.values(prisma)) for (const operation of ["create", "update", "createMany", "deleteMany"]) if (model[operation]) expect(model[operation]).not.toHaveBeenCalled(); +} + +describe("employee calendar inputs before side effects", () => { + it.each(invalidCases)("%s %s rejects invalid %s %s", async (method, route, payload, field) => { + const { app, prisma } = await buildApp(); + try { + for (const date of ["2026-02-29", "2026-04-31"]) { + const response = await app.inject({ method, url: `${baseUrl}/${route}`, payload: { ...payload, [field]: date } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe("VALID_001"); + const kind = route.split("/")[0]; + const reason = { profile: "profile_input_invalid", onboarding: "onboarding_input_invalid", regularize: "regularize_input_invalid", transfer: "transfer_input_invalid", offboarding: "offboarding_input_invalid" }[kind] + ?? `${{ contracts: "contract", trainings: "training", certificates: "certificate" }[kind]}_${method === "POST" ? "create" : "update"}_input_invalid`; + expect(response.json().error.details.reasonCode).toBe(`employee.${reason}`); + expect(response.json().error.details.fieldErrors[field]).toContain("invalid_calendar_date"); + expectNoWrites(prisma); + expect(prisma.employee.findUnique).not.toHaveBeenCalled(); + } + } finally { await app.close(); } + }); + + it.each(lifecyclePaths.flatMap((route) => ["2024-02-29", "2026-04-30", undefined].map((date) => [route, date])))( + "%s preserves effective date %s and its original lifecycle event", async (route, effectiveDate) => { + const { app, prisma } = await buildApp(); + try { + const before = Date.now(); + const response = await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload: effectiveDate ? { effectiveDate } : {} }); + expect(response.statusCode).toBe(200); + expect(prisma.employeeLifecycleEvent.create).toHaveBeenCalledTimes(1); + const data = prisma.employeeLifecycleEvent.create.mock.calls[0][0].data; + expect(data.event_type).toBe({ "onboarding/start": "onboard", regularize: "regularize", transfer: "transfer", "offboarding/start": "offboard" }[route]); + if (effectiveDate) expect(data.effective_date.toISOString()).toBe(`${effectiveDate}T00:00:00.000Z`); + else { expect(data.effective_date.getTime()).toBeGreaterThanOrEqual(before); expect(data.effective_date.getTime()).toBeLessThanOrEqual(Date.now()); } + expect(prisma.wecomDirectorySyncRun.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }, + ); + + it.each(archiveCases)("%s accepts valid boundaries and nullable patches", async (route, payload, model) => { + const { app, prisma } = await buildApp(); + try { + expect((await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload })).statusCode).toBe(200); + const data = prisma[model].create.mock.calls[0][0].data; + expect((data.start_date ?? data.obtained_date).toISOString()).toBe("2024-02-29T00:00:00.000Z"); + expect((data.end_date ?? data.expiry_date).toISOString()).toBe("2026-04-30T00:00:00.000Z"); + const field = route === "contracts" ? "terminatedDate" : route === "trainings" ? "endDate" : "expiryDate"; + expect((await app.inject({ method: "PATCH", url: `${baseUrl}/${route}/${RECORD_ID}`, payload: { [field]: null } })).statusCode).toBe(200); + const updated = prisma[model].update.mock.calls[0][0].data; + expect(updated[route === "contracts" ? "terminated_date" : route === "trainings" ? "end_date" : "expiry_date"]).toBeNull(); + expect(updated).not.toHaveProperty(route === "certificates" ? "obtained_date" : "start_date"); + } finally { await app.close(); } + }); + + it.each(["hireDate", "probationEndDate", "lastWorkingDate"])("profile preserves %s valid/null/omitted", async (field) => { + const { app, prisma } = await buildApp(); + const column = { hireDate: "hire_date", probationEndDate: "probation_end_date", lastWorkingDate: "last_working_date" }[field]; + try { + for (const date of ["2024-02-29", "2026-04-30", null]) { + expect((await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { [field]: date } })).statusCode).toBe(200); + const data = prisma.employee.update.mock.calls.at(-1)[0].data; + expect(data[column]).toEqual(date ? new Date(`${date}T00:00:00Z`) : null); + } + expect((await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { notes: "synthetic" } })).statusCode).toBe(200); + expect(prisma.employee.update.mock.calls.at(-1)[0].data).not.toHaveProperty(column); + } finally { await app.close(); } + }); + + it.each(lifecyclePaths)("%s preserves unauthorized refusal", async (route) => { + const { app, prisma } = await buildApp({ allowed: false }); + try { + expect((await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload: { effectiveDate: "2024-02-29" } })).statusCode).toBe(403); + expectNoWrites(prisma); + } finally { await app.close(); } + }); +}); + +describe("employee archive and lifecycle error reasons", () => { + it.each([ + ["contracts", { ...contracts, endDate: "2024-02-28" }, "employee.contract_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" }], + ["contracts", { ...contracts, terminatedDate: "2024-02-28" }, "employee.contract_termination_date_invalid", { terminatedDate: "must_be_after_or_equal_startDate" }], + ["trainings", { ...trainings, endDate: "2024-02-28" }, "employee.training_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" }], + ["certificates", { ...certificates, expiryDate: "2024-02-28" }, "employee.certificate_date_range_invalid", { expiryDate: "must_be_after_or_equal_obtainedDate" }], + ])("retains the date range guard for %s", async (route, payload, reasonCode, details) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload }); + expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ ...details, reasonCode }); expectNoWrites(prisma); + } finally { await app.close(); } + }); + it.each(["POST", "PATCH"])("explains duplicate contract numbers on %s", async (method) => { + const { app, prisma } = await buildApp(); + try { + const write = method === "POST" ? prisma.employeeContract.create : prisma.employeeContract.update; + write.mockRejectedValue({ code: "P2002" }); + const response = await app.inject({ method, url: `${baseUrl}/contracts${method === "PATCH" ? `/${RECORD_ID}` : ""}`, payload: contracts }); + expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ contractNo: "duplicate", reasonCode: "employee.contract_number_duplicate" }); + expect(write).toHaveBeenCalledTimes(1); expect(prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it("rejects self-only forbidden fields before persistence", async () => { + const { app, prisma } = await buildApp({ allowed: false, self: true }); + try { + const response = await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { hireDate: "2024-02-29" } }); + expect(response.statusCode).toBe(403); expect(response.json().error.details).toEqual({ body: "self_profile_fields_forbidden:hireDate", reasonCode: "employee.self_profile_fields_forbidden" }); expectNoWrites(prisma); + } finally { await app.close(); } + }); + it("preserves invalid id and lifecycle disabled guards", async () => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "PATCH", url: "/api/employees/bad/profile", payload: { notes: "合成" } }); + expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ id: "invalid_uuid", reasonCode: "employee.id_invalid" }); + prisma.featureFlag.findUnique.mockResolvedValue({ override_mode: "force_off", rollout_type: "global", department_ids: [] }); + const disabled = await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { notes: "合成" } }); + expect(disabled.statusCode).toBe(409); expect(disabled.json().error.details).toEqual({ featureFlag: "employee_lifecycle_v1_disabled", reason: "flag_force_off", reasonCode: "employee.lifecycle_disabled" }); expectNoWrites(prisma); + } finally { await app.close(); } + }); + it.each([ + ["POST", "/employees/wecom-hr-master-sync/runs", { mode: "invalid" }, "employee.hr_sync_input_invalid"], + ["GET", "/employees/wecom-hr-master-sync/runs?pageSize=0", undefined, "employee.hr_sync_query_invalid"], + ["POST", `/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`, { operation: "invalid" }, "employee.directory_retry_input_invalid"], + ])("validates %s %s without any external request", async (method, path, payload, reasonCode) => { + const { app, prisma } = await buildApp(); + const originalFetch = global.fetch; const noFetch = vi.fn(() => { throw new Error("external_request_forbidden"); }); global.fetch = noFetch; + try { + app.config.wecomHrMasterSyncEnabled = true; + for (const model of ["wecomHrMasterSyncRun", "wecomHrMasterSyncItem", "wecomIdentityBinding", "integrationBinding"]) prisma[model] = { findMany: vi.fn(), create: vi.fn() }; + const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }); + expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode, fieldErrors: expect.any(Object) }); expectNoWrites(prisma); expect(noFetch).not.toHaveBeenCalled(); + } finally { global.fetch = originalFetch; await app.close(); } + }); + it.each([ + ["POST", "/employees/wecom-hr-master-sync/runs", false, "employee.hr_sync_disabled", { featureFlag: "wecom_hr_master_sync_v1_disabled", reason: "env_hard_off" }], + ["POST", "/employees/wecom-hr-master-sync/runs", true, "employee.hr_sync_unavailable", { featureFlag: "wecom_hr_master_sync_v1_unavailable" }], + ["GET", `/employees/${EMPLOYEE_ID}/wecom-directory-sync`, false, "employee.directory_sync_unavailable", { featureFlag: "hr_wecom_directory_sync_v1_unavailable" }], + ["POST", `/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`, false, "employee.directory_sync_unavailable", { featureFlag: "hr_wecom_directory_sync_v1_unavailable" }], + ])("retains sync capability guards for %s %s", async (method, path, hrEnabled, reasonCode, details) => { + const { app, prisma } = await buildApp(); + try { + app.config.wecomHrMasterSyncEnabled = hrEnabled; + const response = await app.inject({ method, url: `/api${path}`, ...(method === "POST" ? { payload: {} } : {}) }); + expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ ...details, reasonCode }); expectNoWrites(prisma); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/expenseStandards.test.js b/server/tests/unit/routes/expenseStandards.test.js index 02572427..1d63f470 100644 --- a/server/tests/unit/routes/expenseStandards.test.js +++ b/server/tests/unit/routes/expenseStandards.test.js @@ -342,7 +342,7 @@ describe("expense standards routes", () => { }); expect(response.statusCode).toBe(409); - expect(response.json().error.details).toEqual({ expenseTypeId: "not_found" }); + expect(response.json().error.details).toEqual({ expenseTypeId: "not_found", reasonCode: "expense_standard.expense_type_not_found" }); await app.close(); }); @@ -362,7 +362,7 @@ describe("expense standards routes", () => { }); expect(response.statusCode).toBe(400); - expect(response.json().error.details).toEqual({ scopeKey: "required_for_department_scope" }); + expect(response.json().error.details).toEqual({ scopeKey: "required_for_department_scope", reasonCode: "expense_standard.department_required" }); await app.close(); }); @@ -381,7 +381,7 @@ describe("expense standards routes", () => { }); expect(response.statusCode).toBe(409); - expect(response.json().error.details).toEqual({ duplicate: "expense_type_scope_conflict" }); + expect(response.json().error.details).toEqual({ duplicate: "expense_type_scope_conflict", reasonCode: "expense_standard.scope_conflict" }); await app.close(); }); diff --git a/server/tests/unit/routes/exports.apOperations.test.js b/server/tests/unit/routes/exports.apOperations.test.js index 25272c30..f5562457 100644 --- a/server/tests/unit/routes/exports.apOperations.test.js +++ b/server/tests/unit/routes/exports.apOperations.test.js @@ -374,3 +374,37 @@ describe("ap operations export route", () => { await app.close(); }); }); + + +describe("AP export calendar filters", () => { + const fields = ["pendingCreatedAtFrom", "pendingCreatedAtTo", "ledgerDateFrom", "ledgerDateTo"]; + it.each(["csv", "xlsx"].flatMap((format) => fields.flatMap((field) => ["2026-02-29", "2026-04-31", "2026-13-01", "", null].map((date) => [format, field, date]))))("rejects %s %s=%s before business reads or export audit", async (format, field, date) => { + const { app, auditCreate, ...reads } = await buildApp(); + try { + const response = await app.inject({ method: "POST", url: "/api/exports/ap-operations", payload: { format, [field]: date } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details.fieldErrors[field].length).toBeGreaterThan(0); + for (const fn of [...Object.values(reads), auditCreate]) expect(fn).not.toHaveBeenCalled(); + expect(response.headers["content-disposition"]).toBeUndefined(); + } finally { await app.close(); } + }); + it.each(["csv", "xlsx"])("preserves %s leap-day/month-end bounds for both datasets", async (format) => { + const { app, paymentBatchFindMany, reimbursementFormFindMany } = await buildApp(); + try { + const response = await app.inject({ method: "POST", url: "/api/exports/ap-operations", payload: { format, pendingCreatedAtFrom: "2024-02-29", pendingCreatedAtTo: "2024-04-30", ledgerDateFrom: "2024-02-29", ledgerDateTo: "2024-04-30" } }); + expect(response.statusCode).toBe(200); + const range = { gte: new Date("2024-02-29T00:00:00.000Z"), lte: new Date("2024-04-30T23:59:59.999Z") }; + expect(paymentBatchFindMany.mock.calls[0][0].where.created_at).toEqual(range); + expect(reimbursementFormFindMany.mock.calls[0][0].where.AND).toContainEqual({ reimbursement_date: range }); + } finally { await app.close(); } + }); + it("preserves reversed pending-range status and details before reads", async () => { + const { app, auditCreate, ...reads } = await buildApp(); + try { + const response = await app.inject({ method: "POST", url: "/api/exports/ap-operations", payload: { pendingCreatedAtFrom: "2024-05-01", pendingCreatedAtTo: "2024-04-30" } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ pendingCreatedAtTo: "must_be_after_or_equal_to_from", reasonCode: "export.ap_pending_date_range_invalid" }); + for (const fn of [...Object.values(reads), auditCreate]) expect(fn).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/exports.contractsRegistry.test.js b/server/tests/unit/routes/exports.contractsRegistry.test.js index 33ef4c0e..e7fd8811 100644 --- a/server/tests/unit/routes/exports.contractsRegistry.test.js +++ b/server/tests/unit/routes/exports.contractsRegistry.test.js @@ -1,3 +1,4 @@ +const { AppError } = require("../../../src/utils/AppError"); const Fastify = require("fastify"); const exportsRoutes = require("../../../src/routes/exports"); @@ -103,6 +104,15 @@ async function buildApp(options = {}) { }, }); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + error: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }, + }); + } + return reply.status(500).send({ error: { code: "SYS_001" } }); + }); + await app.register(exportsRoutes, { prefix: "/api" }); return { app, count, findMany, auditCreate }; } @@ -230,3 +240,40 @@ describe("contracts registry export route", () => { await app.close(); }); }); + + +describe("contract export calendar boundaries", () => { + it.each(["csv", "xlsx"].flatMap((format) => ["expiryDateFrom", "expiryDateTo"].map((field) => [format, field])))("rejects %s overflowing %s before export reads/audit", async (format, field) => { + const { app, findMany, count, auditCreate } = await buildApp(); + try { + const response = await app.inject({ method: "POST", url: "/api/exports/contracts-registry", payload: { format, [field]: "2026-02-30" } }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: "contract_registry.query_invalid", formErrors: [], fieldErrors: { [field]: expect.arrayContaining(["invalid_calendar_date"]) } } }); + expect(findMany).not.toHaveBeenCalled(); + expect(count).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); + + +describe("contract export range reasons", () => { + it.each(["csv", "xlsx"])("rejects a reversed %s range without reads/audit", async (format) => { + const { app, findMany, count, auditCreate } = await buildApp(); + try { + const response = await app.inject({ method: "POST", url: "/api/exports/contracts-registry", payload: { format, expiryDateFrom: "2024-03-01", expiryDateTo: "2024-02-29" } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ reasonCode: "contract_registry.expiry_date_range_invalid", expiryDateTo: "must_be_after_or_equal_expiryDateFrom" }); + for (const fn of [findMany, count, auditCreate]) expect(fn).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it("explains an invalid export format at the outer schema", async () => { + const { app, findMany, count, auditCreate } = await buildApp(); + try { + const response = await app.inject({ method: "POST", url: "/api/exports/contracts-registry", payload: { format: "invalid" } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toMatchObject({ reasonCode: "contract_registry.query_invalid", fieldErrors: { format: expect.any(Array) } }); + for (const fn of [findMany, count, auditCreate]) expect(fn).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/exports.dashboard.calendar.test.js b/server/tests/unit/routes/exports.dashboard.calendar.test.js new file mode 100644 index 00000000..660df2c2 --- /dev/null +++ b/server/tests/unit/routes/exports.dashboard.calendar.test.js @@ -0,0 +1,70 @@ +const Fastify = require("fastify"); +const exportsRoutes = require("../../../src/routes/exports"); +const { AppError } = require("../../../src/utils/AppError"); + +async function buildApp({ allowed = true } = {}) { + const app = Fastify(); + const actor = { id: "synthetic-viewer", roleCodes: ["finance"], permissionCodes: allowed ? ["report.dashboard.view"] : [] }; + const prisma = { + reimbursementForm: { count: vi.fn().mockResolvedValue(0), findMany: vi.fn().mockResolvedValue([]) }, + auditLog: { create: vi.fn() }, + }; + app.decorate("prisma", prisma); + app.decorate("authenticate", async (request) => { request.employee = actor; }); + app.decorate("hasPermission", (employee, permission) => employee.permissionCodes.includes(permission)); + app.decorate("requirePermission", (permission) => async (request) => { + request.employee = actor; + if (!actor.permissionCodes.includes(permission)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + }); + app.setErrorHandler((error, request, reply) => reply.status(error.statusCode || 500).send({ error: { code: error.code, details: error.details } })); + await app.register(exportsRoutes, { prefix: "/api" }); + return { app, prisma }; +} + +describe("dashboard export calendar contract", () => { + it.each(["csv", "xlsx"].flatMap((format) => ["dateFrom", "dateTo"].flatMap((field) => ["2026-02-29", "2026-04-31", "2026-13-01", ""].map((date) => [format, field, date]))))("rejects %s %s=%s before reads or export audit", async (format, field, date) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/exports/dashboard/reimbursements?format=${format}&${field}=${date}` }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details.fieldErrors[field].length).toBeGreaterThan(0); + expect(prisma.reimbursementForm.count).not.toHaveBeenCalled(); + expect(prisma.reimbursementForm.findMany).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(response.headers["content-disposition"]).toBeUndefined(); + } finally { await app.close(); } + }); + + it.each(["csv", "xlsx"])("preserves %s leap-day/month-end UTC bounds", async (format) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/exports/dashboard/reimbursements?format=${format}&dateFrom=2024-02-29&dateTo=2024-04-30` }); + expect(response.statusCode).toBe(200); + expect(prisma.reimbursementForm.count.mock.calls[0][0].where.AND).toContainEqual({ reimbursement_date: { gte: new Date("2024-02-29T00:00:00.000Z"), lte: new Date("2024-04-30T23:59:59.999Z") } }); + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + } finally { await app.close(); } + }); + + it("preserves omitted dates and reversed-range status/details", async () => { + const { app, prisma } = await buildApp(); + try { + const omitted = await app.inject({ method: "GET", url: "/api/exports/dashboard/reimbursements" }); + expect(omitted.statusCode).toBe(200); + expect(prisma.reimbursementForm.count.mock.calls[0][0].where.AND.some((clause) => Object.hasOwn(clause, "reimbursement_date"))).toBe(false); + for (const fn of [...Object.values(prisma.reimbursementForm), prisma.auditLog.create]) fn.mockClear(); + const reversed = await app.inject({ method: "GET", url: "/api/exports/dashboard/reimbursements?dateFrom=2024-05-01&dateTo=2024-04-30" }); + expect(reversed.statusCode).toBe(400); + expect(reversed.json().error.details).toEqual({ dateTo: "must_be_after_dateFrom", reasonCode: "export.dashboard_date_range_invalid" }); + for (const fn of [...Object.values(prisma.reimbursementForm), prisma.auditLog.create]) expect(fn).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it("preserves permission rejection without export reads", async () => { + const { app, prisma } = await buildApp({ allowed: false }); + try { + const response = await app.inject({ method: "GET", url: "/api/exports/dashboard/reimbursements?dateFrom=2024-02-29" }); + expect(response.statusCode).toBe(403); + for (const fn of [...Object.values(prisma.reimbursementForm), prisma.auditLog.create]) expect(fn).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/exports.employees.test.js b/server/tests/unit/routes/exports.employees.test.js index b39923f2..029bf99e 100644 --- a/server/tests/unit/routes/exports.employees.test.js +++ b/server/tests/unit/routes/exports.employees.test.js @@ -48,6 +48,15 @@ async function buildApp(actor) { }, }); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + error: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }, + }); + } + return reply.status(500).send({ error: { code: "SYS_001" } }); + }); + await app.register(exportsRoutes, { prefix: "/api" }); return { app, findMany, count, auditCreate }; } @@ -135,3 +144,50 @@ describe("employee exports route", () => { await app.close(); }); }); + + +describe("employee export calendar filters", () => { + const actor = { id: "synthetic-manager", permissionCodes: ["system.users"] }; + it.each(["csv", "xlsx"].flatMap((format) => ["hireDateFrom", "hireDateTo"].flatMap((field) => ["2026-02-29", "2026-04-31", "2026-13-01", ""].map((date) => [format, field, date]))))("rejects %s %s=%s before reads or export audit", async (format, field, date) => { + const { app, findMany, count, auditCreate } = await buildApp(actor); + try { + const response = await app.inject({ method: "GET", url: `/api/exports/employees?format=${format}&${field}=${date}` }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: "employee_directory.query_invalid", formErrors: [], fieldErrors: { [field]: expect.arrayContaining(["invalid_calendar_date"]) } } }); + for (const fn of [findMany, count, auditCreate]) expect(fn).not.toHaveBeenCalled(); + expect(response.headers["content-disposition"]).toBeUndefined(); + } finally { await app.close(); } + }); + it.each(["csv", "xlsx"])("preserves valid %s UTC bounds", async (format) => { + const { app, findMany, count } = await buildApp(actor); + try { + const response = await app.inject({ method: "GET", url: `/api/exports/employees?format=${format}&hireDateFrom=2024-02-29&hireDateTo=2024-04-30` }); + expect(response.statusCode).toBe(200); + for (const fn of [findMany, count]) expect(fn.mock.calls[0][0].where.hire_date).toEqual({ gte: new Date("2024-02-29T00:00:00.000Z"), lte: new Date("2024-04-30T23:59:59.999Z") }); + } finally { await app.close(); } + }); + it("preserves omitted filters and reversed-range rejection", async () => { + const { app, findMany, count, auditCreate } = await buildApp(actor); + try { + const omitted = await app.inject({ method: "GET", url: "/api/exports/employees" }); + expect(omitted.statusCode).toBe(200); + expect(count.mock.calls[0][0].where).not.toHaveProperty("hire_date"); + for (const fn of [findMany, count, auditCreate]) fn.mockClear(); + const reversed = await app.inject({ method: "GET", url: "/api/exports/employees?hireDateFrom=2024-05-01&hireDateTo=2024-04-30" }); + expect(reversed.statusCode).toBe(400); + expect(reversed.json().error.details).toEqual({ reasonCode: "employee_directory.hire_date_range_invalid", hireDateTo: "must_be_after_or_equal_hireDateFrom" }); + for (const fn of [findMany, count, auditCreate]) expect(fn).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); + + +it("explains invalid employee export format before reads/audit", async () => { + const { app, findMany, count, auditCreate } = await buildApp({ id: "synthetic-actor", permissionCodes: ["system.users"] }); + try { + const response = await app.inject({ method: "GET", url: "/api/exports/employees?format=invalid" }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toMatchObject({ reasonCode: "employee_directory.query_invalid", fieldErrors: { format: expect.any(Array) } }); + for (const fn of [findMany, count, auditCreate]) expect(fn).not.toHaveBeenCalled(); + } finally { await app.close(); } +}); diff --git a/server/tests/unit/routes/exports.presence.test.js b/server/tests/unit/routes/exports.presence.test.js index 3c0abe04..68fb146f 100644 --- a/server/tests/unit/routes/exports.presence.test.js +++ b/server/tests/unit/routes/exports.presence.test.js @@ -240,7 +240,7 @@ describe("presence history exports route", () => { }); expect(response.statusCode).toBe(409); - expect(response.json().error.details).toEqual({ export: "too_large" }); + expect(response.json().error.details).toEqual({ export: "too_large", reasonCode: "export.presence_too_large" }); expect(auditCreate).not.toHaveBeenCalled(); await app.close(); @@ -265,3 +265,42 @@ describe("presence history exports route", () => { await app.close(); }); }); + + +describe("presence export calendar boundaries", () => { + it.each(["dateFrom", "dateTo"])("rejects overflowing %s without export audit or employee reads", async (field) => { + const { app, employeeFindMany, auditCreate } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/exports/presence-history?${field}=2026-04-31` }); + expect(response.statusCode).toBe(400); + expect(employeeFindMany).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); + + +describe("presence history range reasons", () => { + it.each([ + ["2024-03-01", "2024-02-29", "presence_history.date_range_invalid", { dateTo: "must_be_after_or_equal_dateFrom" }], + ["2024-01-01", "2024-02-01", "presence_history.date_range_too_large", { dateRange: "too_large" }], + ])("explains %s..%s before employee queries", async (from, to, reasonCode, details) => { + const { app, employeeFindMany, auditCreate } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/exports/presence-history?dateFrom=${from}&dateTo=${to}` }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { ...details, reasonCode } }); + expect(response.json().error.details).toEqual({ ...details, reasonCode }); + expect(employeeFindMany).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it("keeps exactly 31 inclusive days valid", async () => { + const { app, employeeFindMany } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: "/api/exports/presence-history?dateFrom=2024-01-01&dateTo=2024-01-31" }); + expect(response.statusCode).toBe(200); + expect(employeeFindMany).toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/integrations.bindings.test.js b/server/tests/unit/routes/integrations.bindings.test.js index 92364fcb..51784d7a 100644 --- a/server/tests/unit/routes/integrations.bindings.test.js +++ b/server/tests/unit/routes/integrations.bindings.test.js @@ -196,3 +196,5 @@ describe('integrations binding routes', () => { await app.close(); }); }); + +it('explains invalid binding filters without reads', async () => { const { app, findMany, count } = await buildApp(); try { const r = await app.inject({ url: '/api/integrations/bindings?page=0' }); expect(r.statusCode).toBe(400); expect(r.json().error.details.reasonCode).toBe('integration.bindings_query_invalid'); expect(findMany).not.toHaveBeenCalled(); expect(count).not.toHaveBeenCalled(); } finally { await app.close(); } }); diff --git a/server/tests/unit/routes/integrations.erp.asset.test.js b/server/tests/unit/routes/integrations.erp.asset.test.js deleted file mode 100644 index 510145ed..00000000 --- a/server/tests/unit/routes/integrations.erp.asset.test.js +++ /dev/null @@ -1,139 +0,0 @@ -const integrationsRoutes = require('../../../src/routes/integrations'); - -describe('Integrations asset ERP route helpers', () => { - const { canReadAsset, enqueueAssetExportAndMaybeRun, parseRunNow } = integrationsRoutes.__testables; - - function createAsset(overrides = {}) { - return { - id: 'asset-1', - asset_no: 'ZC-001', - assignee_employee_id: 'employee-1', - legal_entity_id: 'le-1', - cost_center_id: 'cc-1', - status: 'in_use', - ...overrides, - }; - } - - it('respects asset read permission rules', () => { - const asset = createAsset(); - - expect( - canReadAsset( - { employee: { id: 'employee-1', permissionCodes: ['asset.view_own'] } }, - asset, - ), - ).toBe(true); - expect( - canReadAsset( - { employee: { id: 'other', permissionCodes: ['asset.view_own'] } }, - asset, - ), - ).toBe(false); - expect( - canReadAsset( - { employee: { id: 'other', permissionCodes: ['asset.view_all'] } }, - asset, - ), - ).toBe(true); - }); - - it('retries same asset payload without triggering runNow when duplicated', async () => { - const enqueueExport = vi.fn().mockResolvedValue({ - id: 'outbox-1', - status: 'succeeded', - duplicated: true, - requeued: false, - eventType: 'erp.asset.export', - }); - const runOutboxOnce = vi.fn(); - - const result = await enqueueAssetExportAndMaybeRun({ - prisma: {}, - config: {}, - logger: {}, - context: { asset: createAsset() }, - actorId: 'actor-1', - runNow: true, - findExistingOutbox: vi.fn().mockResolvedValue({ - id: 'outbox-1', - status: 'succeeded', - payload: { - exportType: 'asset_ledger_summary', - entityId: 'asset-1', - assetNo: 'ZC-001', - legalEntity: { id: 'le-1' }, - costCenter: { id: 'cc-1' }, - status: 'in_use', - }, - }), - enqueueExport, - runOutboxOnce, - }); - - expect(enqueueExport).toHaveBeenCalledWith( - {}, - {}, - expect.objectContaining({ - payload: expect.objectContaining({ - assetNo: 'ZC-001', - status: 'in_use', - }), - requeueOnFailure: true, - }), - ); - expect(runOutboxOnce).not.toHaveBeenCalled(); - expect(result.duplicated).toBe(true); - }); - - it('resyncs current asset snapshot when financial drift exists', async () => { - const enqueueExport = vi.fn().mockResolvedValue({ - id: 'outbox-1', - status: 'pending', - duplicated: false, - requeued: true, - eventType: 'erp.asset.export', - }); - const runOutboxOnce = vi.fn().mockResolvedValue({ total: 1, succeeded: 1, failed: 0 }); - - const result = await enqueueAssetExportAndMaybeRun({ - prisma: {}, - config: {}, - logger: {}, - context: { asset: createAsset({ cost_center_id: 'cc-2', status: 'retired' }) }, - actorId: 'actor-1', - runNow: true, - findExistingOutbox: vi.fn().mockResolvedValue({ - id: 'outbox-1', - status: 'succeeded', - payload: { - exportType: 'asset_ledger_summary', - entityId: 'asset-1', - assetNo: 'ZC-001', - legalEntity: { id: 'le-1' }, - costCenter: { id: 'cc-1' }, - status: 'in_use', - }, - }), - enqueueExport, - runOutboxOnce, - }); - - expect(enqueueExport).toHaveBeenCalledWith( - {}, - {}, - expect.objectContaining({ - payload: undefined, - changeType: 'retired', - changedFields: expect.arrayContaining(['costCenterId', 'status']), - }), - ); - expect(runOutboxOnce).toHaveBeenCalled(); - expect(result.requeued).toBe(true); - }); - - it('parses runNow flags for asset requeue route', () => { - expect(parseRunNow({ runNow: '1' })).toBe(true); - expect(parseRunNow({ runNow: '0' })).toBe(false); - }); -}); diff --git a/server/tests/unit/routes/integrations.erp.procurement.test.js b/server/tests/unit/routes/integrations.erp.procurement.test.js deleted file mode 100644 index 48c3ac2c..00000000 --- a/server/tests/unit/routes/integrations.erp.procurement.test.js +++ /dev/null @@ -1,59 +0,0 @@ -const integrationsRoutes = require('../../../src/routes/integrations'); - -describe('Integrations procurement ERP route helpers', () => { - const { - canReadPurchaseRequest, - ensureProcurementTargetEligible, - normalizeProcurementTargets, - } = integrationsRoutes.__testables; - - it('normalizes procurement targets in fixed order', () => { - expect( - normalizeProcurementTargets({ - targets: ['archive_summary', 'commitment_journal', 'archive_summary'], - }), - ).toEqual(['commitment_journal', 'archive_summary']); - }); - - it('respects purchase read permission rules', () => { - const purchaseRequest = { - applicant_id: 'employee-1', - submitter_id: 'employee-2', - }; - - expect( - canReadPurchaseRequest( - { employee: { id: 'employee-1', permissionCodes: ['procurement.view_own'] } }, - purchaseRequest, - ), - ).toBe(true); - expect( - canReadPurchaseRequest( - { employee: { id: 'other', permissionCodes: ['procurement.view_own'] } }, - purchaseRequest, - ), - ).toBe(false); - expect( - canReadPurchaseRequest( - { employee: { id: 'other', permissionCodes: ['integration.manage', 'procurement.view_all'] } }, - purchaseRequest, - ), - ).toBe(true); - }); - - it('rejects commitment target when purchase is not approved', () => { - expect(() => - ensureProcurementTargetEligible('commitment_journal', { - purchaseRequest: { status: 'reviewing' }, - }), - ).toThrow('参数校验失败'); - }); - - it('rejects archive targets when archive is missing', () => { - expect(() => - ensureProcurementTargetEligible('accrual_journal', { - purchaseRequest: { status: 'approved', order_archive: null }, - }), - ).toThrow('参数校验失败'); - }); -}); diff --git a/server/tests/unit/routes/integrations.erp.test.js b/server/tests/unit/routes/integrations.erp.test.js deleted file mode 100644 index 4bb9e42e..00000000 --- a/server/tests/unit/routes/integrations.erp.test.js +++ /dev/null @@ -1,22 +0,0 @@ -const integrationsRoutes = require('../../../src/routes/integrations'); - -describe('Integrations ERP route helpers', () => { - const { normalizeResourcesInput, parseRunNow } = integrationsRoutes.__testables; - - it('normalizes and orders resources', () => { - expect(normalizeResourcesInput({ resources: ['supplier', 'legal_entity', 'supplier'] })).toEqual([ - 'legal_entity', - 'supplier', - ]); - }); - - it('rejects invalid resource values', () => { - expect(() => normalizeResourcesInput({ resources: ['unknown'] })).toThrow(); - }); - - it('parses runNow truthy flags', () => { - expect(parseRunNow({ runNow: '1' })).toBe(true); - expect(parseRunNow({ runNow: true })).toBe(true); - expect(parseRunNow({ runNow: '0' })).toBe(false); - }); -}); diff --git a/server/tests/unit/routes/integrations.erp.travel.test.js b/server/tests/unit/routes/integrations.erp.travel.test.js deleted file mode 100644 index e735b259..00000000 --- a/server/tests/unit/routes/integrations.erp.travel.test.js +++ /dev/null @@ -1,331 +0,0 @@ -const Fastify = require('fastify'); - -const { AppError } = require('../../../src/utils/AppError'); -const integrationsRoutes = require('../../../src/routes/integrations'); - -function buildBatch(overrides = {}) { - return { - id: '66666666-6666-4666-8666-000000000001', - batch_no: 'TRB-20260311-0001', - legal_entity_id: '44444444-4444-4444-8444-444444444444', - cost_center_id: '55555555-5555-4555-8555-555555555555', - period_start: new Date('2026-03-01T00:00:00.000Z'), - period_end: new Date('2026-03-31T00:00:00.000Z'), - status: 'reconciled', - summary_json: { - totalOrders: 1, - totalTransactions: 1, - anomalyItems: 0, - matchRate: '100.00', - totalOrderAmount: '100.00', - totalSettledAmount: '100.00', - totalReversedAmount: '0.00', - }, - confirmed_at: new Date('2026-03-31T10:00:00.000Z'), - created_at: new Date('2026-03-31T09:00:00.000Z'), - updated_at: new Date('2026-03-31T10:00:00.000Z'), - legal_entity: { - id: '44444444-4444-4444-8444-444444444444', - code: 'HY', - name: '海音达', - }, - cost_center: { - id: '55555555-5555-4555-8555-555555555555', - code: 'CC001', - name: '销售中心', - }, - items: [ - { - id: 'item-1', - travel_request_id: '11111111-1111-4111-8111-111111111111', - travel_order_id: '22222222-2222-4222-8222-222222222222', - card_transaction_id: '33333333-3333-4333-8333-333333333333', - match_status: 'matched', - resolution_status: 'confirmed', - order_amount: '100.00', - transaction_amount: '100.00', - currency: 'CNY', - snapshot_json: { - requestNo: 'CL-20260310-0001', - externalOrderNo: 'EXT-001', - externalTxnId: 'TXN-001', - linkedReimbursementId: null, - boundInvoiceCount: 1, - }, - created_at: new Date('2026-03-31T09:00:00.000Z'), - }, - ], - ...overrides, - }; -} - -function buildPrisma({ batch = buildBatch(), featureFlag, existingOutbox = null }) { - const state = { - batch: batch ? { ...batch } : null, - outbox: existingOutbox ? { ...existingOutbox } : null, - auditLogs: [], - }; - - const payableBinding = { - id: 'binding-summary', - provider: 'erp', - entity_type: 'travel_payable_summary', - local_id: '66666666-6666-4666-8666-000000000001', - external_id: 'EXT-SUM-001', - external_code: 'SUM-001', - last_synced_at: new Date('2026-03-31T10:01:00.000Z'), - }; - const settlementBinding = { - id: 'binding-batch', - provider: 'erp', - entity_type: 'travel_settlement_batch', - local_id: '66666666-6666-4666-8666-000000000001', - external_id: 'EXT-BATCH-001', - external_code: 'BAT-001', - last_synced_at: new Date('2026-03-31T10:01:00.000Z'), - }; - - const prisma = { - travelReconciliationBatch: { - findUnique: vi.fn().mockImplementation(async ({ where }) => { - if (!state.batch || where.id !== state.batch.id) { - return null; - } - return state.batch; - }), - }, - featureFlag: { - findUnique: vi.fn().mockResolvedValue( - featureFlag ?? { - id: 'flag-1', - flag_key: 'erp_sync', - override_mode: 'force_on', - rollout_type: 'global', - department_ids: [], - note: '', - version: 1, - created_at: new Date('2026-03-31T00:00:00.000Z'), - updated_at: new Date('2026-03-31T00:00:00.000Z'), - }, - ), - }, - integrationOutbox: { - findUnique: vi.fn().mockImplementation(async ({ where }) => { - const key = where?.event_type_dedupe_key; - if (!key) { - return null; - } - if (!state.outbox) { - return null; - } - return state.outbox.event_type === key.event_type && state.outbox.dedupe_key === key.dedupe_key - ? state.outbox - : null; - }), - create: vi.fn().mockImplementation(async ({ data }) => { - state.outbox = { - id: 'outbox-created', - event_type: data.event_type, - status: data.status, - payload: data.payload, - dedupe_key: data.dedupe_key, - attempts: 0, - last_error: null, - processed_at: null, - updated_at: new Date('2026-03-31T10:05:00.000Z'), - }; - return { id: state.outbox.id, status: state.outbox.status }; - }), - update: vi.fn().mockImplementation(async ({ where, data }) => { - if (!state.outbox || state.outbox.id !== where.id) { - throw new Error('outbox_not_found'); - } - state.outbox = { - ...state.outbox, - ...data, - updated_at: new Date('2026-03-31T10:06:00.000Z'), - }; - return { id: state.outbox.id, status: state.outbox.status }; - }), - }, - integrationBinding: { - findUnique: vi.fn().mockImplementation(async ({ where }) => { - const bindingType = where.provider_entity_type_local_id.entity_type; - if (bindingType === 'travel_payable_summary') { - return payableBinding; - } - if (bindingType === 'travel_settlement_batch') { - return settlementBinding; - } - return null; - }), - }, - auditLog: { - create: vi.fn().mockImplementation(async ({ data }) => { - state.auditLogs.push(data); - return { id: `audit-${state.auditLogs.length}` }; - }), - }, - }; - - return { prisma, state }; -} - -async function buildApp({ prisma, permissionCodes, config = {} }) { - const app = Fastify(); - - app.decorate('prisma', prisma); - app.decorate('config', { - erpSyncEnabled: true, - erpSyncProvider: 'mock', - integrationOutboxMaxAttempts: 5, - ...config, - }); - app.decorate('authenticate', async (request) => { - if (!request.headers.authorization) { - throw new AppError({ code: 'AUTH_001', message: '未登录', statusCode: 401 }); - } - request.employee = { id: 'actor-1', permissionCodes }; - }); - app.decorate('hasPermission', (employee, permissionCode) => { - return (employee?.permissionCodes ?? []).includes('*') || (employee?.permissionCodes ?? []).includes(permissionCode); - }); - app.decorate('requirePermission', (permissionCode) => async (request) => { - await app.authenticate(request); - if (!app.hasPermission(request.employee, permissionCode)) { - throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403 }); - } - }); - - app.setErrorHandler((error, request, reply) => { - if (error instanceof AppError) { - return reply.status(error.statusCode).send({ - requestId: request.id, - error: { - code: error.code, - message: error.message, - details: error.details ?? {}, - }, - }); - } - return reply.status(500).send({ - requestId: request.id, - error: { code: 'SYS_001', message: '服务器内部错误', details: {} }, - }); - }); - - await app.register(integrationsRoutes, { prefix: '/api' }); - return app; -} - -describe('Integrations travel reconciliation ERP routes', () => { - const { canReadTravelReconciliationBatch } = integrationsRoutes.__testables; - - it('recognizes readable reconciliation roles', () => { - expect( - canReadTravelReconciliationBatch({ employee: { permissionCodes: ['travel.payment.manage'] } }), - ).toBe(true); - expect( - canReadTravelReconciliationBatch({ employee: { permissionCodes: ['integration.manage'] } }), - ).toBe(true); - expect( - canReadTravelReconciliationBatch({ employee: { permissionCodes: ['travel.view_all'] } }), - ).toBe(false); - }); - - it('redacts ERP binding refs for non integration managers on track endpoint', async () => { - const { prisma } = buildPrisma({ - existingOutbox: { - id: 'outbox-1', - event_type: 'erp.travel.export', - dedupe_key: 'travel_reconciliation_batch:66666666-6666-4666-8666-000000000001:summary', - status: 'succeeded', - payload: { batchNo: 'TRB-20260311-0001' }, - attempts: 1, - last_error: null, - processed_at: new Date('2026-03-31T10:00:00.000Z'), - updated_at: new Date('2026-03-31T10:00:00.000Z'), - }, - }); - const app = await buildApp({ prisma, permissionCodes: ['travel.payment.manage'] }); - - const response = await app.inject({ - method: 'GET', - url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/track', - headers: { authorization: 'Bearer test' }, - }); - - expect(response.statusCode).toBe(200); - const track = response.json().data; - expect(track.stage.summaryExport.bindings.payableSummary.externalId).toBe(null); - expect(track.stage.summaryExport.bindings.settlementBatch.externalCode).toBe(null); - expect(track.permissions.canRetry).toBe(false); - - await app.close(); - }); - - it('shows full ERP binding refs to integration managers and allows requeue', async () => { - const { prisma, state } = buildPrisma({ - existingOutbox: { - id: 'outbox-1', - event_type: 'erp.travel.export', - dedupe_key: 'travel_reconciliation_batch:66666666-6666-4666-8666-000000000001:summary', - status: 'failed', - payload: { entityId: '66666666-6666-4666-8666-000000000001', batchNo: 'TRB-20260311-0001' }, - attempts: 2, - last_error: 'failed_once', - processed_at: null, - updated_at: new Date('2026-03-31T10:00:00.000Z'), - }, - }); - const app = await buildApp({ prisma, permissionCodes: ['integration.manage'] }); - - const trackResponse = await app.inject({ - method: 'GET', - url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/track', - headers: { authorization: 'Bearer test' }, - }); - expect(trackResponse.statusCode).toBe(200); - expect(trackResponse.json().data.stage.summaryExport.bindings.payableSummary.externalId).toBe('EXT-SUM-001'); - - const requeueResponse = await app.inject({ - method: 'POST', - url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/requeue', - headers: { authorization: 'Bearer test' }, - payload: {}, - }); - - expect(requeueResponse.statusCode).toBe(200); - expect(requeueResponse.json().data).toEqual( - expect.objectContaining({ - outboxId: 'outbox-1', - eventType: 'erp.travel.export', - requeued: true, - }), - ); - expect(state.outbox.status).toBe('pending'); - - await app.close(); - }); - - it('blocks requeue when ERP gate is disabled', async () => { - const { prisma } = buildPrisma({}); - const app = await buildApp({ - prisma, - permissionCodes: ['integration.manage'], - config: { erpSyncEnabled: false }, - }); - - const response = await app.inject({ - method: 'POST', - url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/requeue', - headers: { authorization: 'Bearer test' }, - payload: {}, - }); - - expect(response.statusCode).toBe(409); - expect(response.json().error.details.integration).toBe('erp_sync_disabled'); - - await app.close(); - }); -}); diff --git a/server/tests/unit/routes/integrations.outbox.test.js b/server/tests/unit/routes/integrations.outbox.test.js index 4239134f..1c76b026 100644 --- a/server/tests/unit/routes/integrations.outbox.test.js +++ b/server/tests/unit/routes/integrations.outbox.test.js @@ -40,7 +40,7 @@ async function buildApp(options = {}) { const findMany = options.findMany ?? vi.fn().mockResolvedValue([buildOutbox()]); const findUnique = options.findUnique ?? vi.fn().mockResolvedValue(buildOutbox()); const update = options.update ?? vi.fn().mockImplementation(async ({ where, data }) => ({ - ...buildOutbox({ id: where.id }), + ...buildOutbox({ id: where.id, event_type: "notification.im" }), ...data, updated_at: new Date('2026-04-07T10:30:00.000Z'), })); @@ -112,6 +112,19 @@ async function buildApp(options = {}) { } describe('integrations outbox routes', () => { + it('keeps retired ERP history readable but refuses retry and removed endpoints', async () => { + const { app, update, auditCreate } = await buildApp(); + const read = await app.inject({ method: 'GET', url: `/api/integrations/outbox/${OUTBOX_ID}` }); + expect(read.statusCode).toBe(200); + const retry = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry`, payload: {} }); + expect(retry.statusCode).toBe(409); + expect(retry.json().error.details.reasonCode).toBe('integration.erp_retired'); + expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); + const removed = await app.inject({ method: 'POST', url: `/api/integrations/erp/sync/${OUTBOX_ID}`, payload: {} }); + expect(removed.statusCode).toBe(404); + await app.close(); + }); + it('lists outbox events with filters and payload summary', async () => { const { app, findMany, count } = await buildApp(); @@ -203,7 +216,7 @@ describe('integrations outbox routes', () => { it('retries failed or dead-letter outbox events by resetting status to pending', async () => { const { app, update, auditCreate } = await buildApp({ - findUnique: vi.fn().mockResolvedValue(buildOutbox({ status: 'dead_letter', dead_letter_at: new Date('2026-04-07T09:40:00.000Z') })), + findUnique: vi.fn().mockResolvedValue(buildOutbox({ event_type: 'notification.im', status: 'dead_letter', dead_letter_at: new Date('2026-04-07T09:40:00.000Z') })), }); const response = await app.inject({ @@ -230,13 +243,13 @@ describe('integrations outbox routes', () => { expect(auditCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ - action: 'ERP_OUTBOX_RETRIED', + action: 'INTEGRATION_OUTBOX_RETRIED', target_type: 'integration_outbox', target_id: OUTBOX_ID, extra: expect.objectContaining({ previousStatus: 'dead_letter', nextStatus: 'pending', - eventType: 'erp.journal.export', + eventType: 'notification.im', outboxId: OUTBOX_ID, }), }), @@ -283,3 +296,9 @@ describe('integrations outbox routes', () => { await app.close(); }); }); + +describe('shared outbox operation reasons', () => { + it.each([['GET', '/integrations/outbox?page=0', 'integration.outbox_query_invalid'], ['GET', '/integrations/outbox/bad', 'integration.outbox_id_invalid'], ['POST', '/integrations/outbox/bad/retry', 'integration.outbox_id_invalid']])('rejects %s %s before reads or mutations', async (method, path, reasonCode) => { const { app, findMany, findUnique, update, auditCreate } = await buildApp(); try { const r = await app.inject({ method, url: `/api${path}` }); expect(r.statusCode).toBe(400); expect(r.json().error.details.reasonCode).toBe(reasonCode); expect(findMany).not.toHaveBeenCalled(); expect(findUnique).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); + it.each(['pending', 'processing', 'succeeded'])('keeps non-retryable notification state %s', async (status) => { const { app, update, auditCreate } = await buildApp({ findUnique: vi.fn().mockResolvedValue(buildOutbox({ event_type: 'notification.im', status })) }); try { const r = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry` }); expect(r.statusCode).toBe(409); expect(r.json().error.details).toEqual({ outbox: 'integration_outbox_retry_status_invalid', status, reasonCode: 'integration.retry_state_invalid' }); expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); + it.each(['failed', 'dead_letter'])('retains notification %s retry as pending without delivery', async (status) => { const before = buildOutbox({ event_type: 'notification.im', status, payload: { channelId: 'synthetic-channel' } }); const update = vi.fn(async ({ data }) => ({ ...before, ...data })); const { app, auditCreate } = await buildApp({ findUnique: vi.fn().mockResolvedValue(before), update }); try { const r = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry` }); expect(r.statusCode).toBe(200); expect(update.mock.calls[0][0].data).toMatchObject({ status: 'pending', attempts: 0, last_error: null }); expect(auditCreate).toHaveBeenCalledOnce(); } finally { await app.close(); } }); +}); diff --git a/server/tests/unit/routes/master-data.errors.test.js b/server/tests/unit/routes/master-data.errors.test.js new file mode 100644 index 00000000..f481d6ff --- /dev/null +++ b/server/tests/unit/routes/master-data.errors.test.js @@ -0,0 +1,153 @@ +const Fastify = require("fastify"); +const masterDataRoutes = require("../../../src/routes/masterData"); +const supplierRoutes = require("../../../src/routes/masterDataSuppliers"); +const { AppError } = require("../../../src/utils/AppError"); + +const ID = "11111111-1111-4111-8111-111111111111"; +const COMPANY = "22222222-2222-4222-8222-222222222222"; +const PARENT = "33333333-3333-4333-8333-333333333333"; +const EMPLOYEE = "44444444-4444-4444-8444-444444444444"; +const archivedAt = new Date("2024-01-01T00:00:00Z"); +const entities = [ + { route: "legal-entities", model: "legalEntity", domain: "legal_entity", payload: { code: " lc ", name: " 合成公司 ", currencyCode: " cny " }, code: "LC", action: "LEGAL_ENTITY" }, + { route: "cost-centers", model: "costCenter", domain: "cost_center", payload: { code: " cc ", name: " 合成中心 ", legalEntityId: COMPANY }, code: "CC", action: "COST_CENTER" }, + { route: "document-categories", model: "documentCategory", domain: "document_category", payload: { code: " DOC ", name: " 合成分类 ", entityType: "general", sequencePrefix: " xx ", resetPeriod: "none" }, code: "doc", action: "DOCUMENT_CATEGORY" }, + { route: "suppliers", model: "supplier", domain: "supplier", payload: { code: " sp ", name: " 合成供应商 " }, code: "SP", action: "SUPPLIER" }, +]; +const apps = []; +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); +async function buildApp(permissions = ["masterdata.manage"]) { + const app = Fastify(); apps.push(app); + const records = Object.fromEntries(entities.map(({ model }) => [model, { id: ID, code: "TEST", name: "合成资料", enabled: true, archived_at: null, legal_entity_id: COMPANY, parent_id: null, owner_employee_id: null }])); + const parent = { id: PARENT, legal_entity_id: COMPANY, parent_id: null, archived_at: null }; + const owner = { id: EMPLOYEE, status: "active" }; + const prisma = { auditLog: { create: vi.fn().mockResolvedValue({}) }, employee: { findUnique: vi.fn(async () => owner) } }; + for (const { model } of entities) { + prisma[model] = { + findUnique: vi.fn(async ({ where }) => model === "costCenter" && where.id === PARENT ? parent : records[model]), + findMany: vi.fn(async () => [records[model]]), count: vi.fn().mockResolvedValue(0), + create: vi.fn(async ({ data }) => ({ id: ID, ...data })), update: vi.fn(async ({ data }) => ({ ...records[model], ...data })), + }; + } + prisma.$transaction = vi.fn(async (callback) => callback(prisma)); + app.decorate("prisma", prisma); + app.decorate("authenticate", async (request) => { request.employee = { id: EMPLOYEE }; }); + app.decorate("hasPermission", (_employee, permission) => permissions.includes(permission)); + app.decorate("requirePermission", (permission) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permission)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); }); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); + await app.register(masterDataRoutes, { prefix: "/api" }); await app.register(supplierRoutes, { prefix: "/api" }); + return { app, prisma, records, parent, owner }; +} +function noWrites(prisma) { + for (const { model } of entities) { expect(prisma[model].create).not.toHaveBeenCalled(); expect(prisma[model].update).not.toHaveBeenCalled(); } + expect(prisma.auditLog.create).not.toHaveBeenCalled(); +} +function noReads(prisma) { + for (const { model } of entities) { expect(prisma[model].findUnique).not.toHaveBeenCalled(); expect(prisma[model].findMany).not.toHaveBeenCalled(); expect(prisma[model].count).not.toHaveBeenCalled(); } + expect(prisma.$transaction).not.toHaveBeenCalled(); +} +function expectError(response, status, reasonCode, details) { + expect(response.statusCode).toBe(status); + expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); +} +function url(entity, suffix = "") { return `/api/master-data/${entity.route}${suffix}`; } + +for (const entity of entities) { + describe(entity.route, () => { + it.each([ + ["GET", "?includeArchived=invalid", undefined, "query_invalid"], + ["POST", "", {}, "create_input_invalid"], + ["PATCH", `/${ID}`, { name: "" }, "update_input_invalid"], + ])("explains %s schema rejection before database access", async (method, suffix, payload, reason) => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method, url: url(entity, suffix), ...(payload ? { payload } : {}) }); + expect(response.statusCode).toBe(400); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: `${entity.domain}.${reason}`, formErrors: [], fieldErrors: expect.any(Object) } }); + noReads(prisma); noWrites(prisma); + }); + it.each(["PATCH", "POST"])("explains invalid id on %s before database access", async (method) => { + const { app, prisma } = await buildApp(); + expectError(await app.inject({ method, url: url(entity, `/invalid${method === "POST" ? "/archive" : ""}`), ...(method === "PATCH" ? { payload: { name: "合成" } } : {}) }), 400, `${entity.domain}.id_invalid`, { id: "invalid_uuid" }); noReads(prisma); noWrites(prisma); + }); + it("explains an empty patch", async () => { + const { app, prisma } = await buildApp(); expectError(await app.inject({ method: "PATCH", url: url(entity, `/${ID}`), payload: {} }), 400, `${entity.domain}.update_empty`, { body: "empty" }); noReads(prisma); noWrites(prisma); + }); + it("keeps archived records locked", async () => { + const { app, prisma, records } = await buildApp(); records[entity.model].archived_at = archivedAt; + expectError(await app.inject({ method: "PATCH", url: url(entity, `/${ID}`), payload: { name: "合成" } }), 409, `${entity.domain}.archived_locked`, { status: "archived" }); noWrites(prisma); + }); + it.each(["POST", "PATCH"])("explains %s code conflicts without success audit", async (method) => { + const { app, prisma } = await buildApp(); + prisma[entity.model][method === "POST" ? "create" : "update"].mockRejectedValue({ code: "P2002", meta: { target: ["code"] } }); + expectError(await app.inject({ method, url: url(entity, method === "PATCH" ? `/${ID}` : ""), payload: entity.payload }), 409, `${entity.domain}.code_duplicate`, { code: "duplicate" }); expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + it.each(["PATCH", "POST"])("preserves missing current record 404 for %s", async (method) => { + const { app, prisma } = await buildApp(); prisma[entity.model].findUnique.mockResolvedValue(null); + const response = await app.inject({ method, url: url(entity, `/${ID}${method === "POST" ? "/archive" : ""}`), ...(method === "PATCH" ? { payload: { name: "合成" } } : {}) }); expect(response.statusCode).toBe(404); expect(response.json().error.code).toBe("SYS_404"); noWrites(prisma); + }); + it("preserves archive idempotency with no second write or audit", async () => { + const { app, prisma, records } = await buildApp(); records[entity.model].archived_at = archivedAt; + const response = await app.inject({ method: "POST", url: url(entity, `/${ID}/archive`) }); expect(response.statusCode).toBe(200); expect(response.json().data.outcome).toBe("already_archived"); noWrites(prisma); + }); + it("preserves successful archive and audit", async () => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method: "POST", url: url(entity, `/${ID}/archive`) }); expect(response.statusCode).toBe(200); expect(response.json().data.outcome).toBe("archived"); + expect(prisma[entity.model].update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: ID }, data: { enabled: false, archived_at: expect.any(Date), updated_by: EMPLOYEE } })); + expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: `${entity.action}_ARCHIVE` }) })); + }); + it.each(["POST", "PATCH"])("preserves successful %s normalization and audit", async (method) => { + const { app, prisma } = await buildApp(); const response = await app.inject({ method, url: url(entity, method === "PATCH" ? `/${ID}` : ""), payload: entity.payload }); expect(response.statusCode).toBe(200); + expect(prisma[entity.model][method === "POST" ? "create" : "update"]).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ code: entity.code, name: entity.payload.name.trim(), updated_by: EMPLOYEE }) })); + expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: `${entity.action}_${method === "POST" ? "CREATE" : "UPDATE"}` }) })); + }); + it("keeps list filters and procurement read access", async () => { + const { app, prisma } = await buildApp(["procurement.create"]); const response = await app.inject({ method: "GET", url: url(entity, "?includeArchived=true&enabled=false") }); expect(response.statusCode).toBe(200); expect(prisma[entity.model].findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { enabled: false } })); noWrites(prisma); + }); + it.each(["GET", "POST", "PATCH"])("keeps permission refusal before %s parsing", async (method) => { + const { app, prisma } = await buildApp([]); const response = await app.inject({ method, url: url(entity, method === "PATCH" ? "/invalid" : ""), ...(method !== "GET" ? { payload: {} } : {}) }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); noReads(prisma); noWrites(prisma); + }); + }); +} + +it.each([entities[1], entities[3]].flatMap((entity) => ["POST", "PATCH"].flatMap((method) => ["not_found", "archived"].map((state) => [entity, method, state]))))("preserves company reference for $0.route %s %s", async (entity, method, state) => { + const { app, prisma, records } = await buildApp(); + if (state === "not_found") prisma.legalEntity.findUnique.mockResolvedValue(null); else records.legalEntity.archived_at = archivedAt; + expectError(await app.inject({ method, url: url(entity, method === "PATCH" ? `/${ID}` : ""), payload: { ...entity.payload, legalEntityId: COMPANY } }), state === "not_found" ? 400 : 409, `legal_entity.reference_${state}`, { legalEntityId: state }); noWrites(prisma); +}); +it.each(["POST", "PATCH"].flatMap((method) => ["missing", "inactive"].map((state) => [method, state])))("preserves %s owner %s rejection", async (method, state) => { + const { app, prisma, owner } = await buildApp(); if (state === "missing") prisma.employee.findUnique.mockResolvedValue(null); else owner.status = "inactive"; + expectError(await app.inject({ method, url: url(entities[1], method === "PATCH" ? `/${ID}` : ""), payload: { ...entities[1].payload, ownerEmployeeId: EMPLOYEE } }), 400, "cost_center.owner_unavailable", { ownerEmployeeId: "not_found" }); noWrites(prisma); +}); +it.each(["POST", "PATCH"].flatMap((method) => ["missing", "archived", "cross-company"].map((state) => [method, state])))("preserves %s parent %s rejection", async (method, state) => { + const { app, prisma, records, parent } = await buildApp(); + if (state === "missing") prisma.costCenter.findUnique.mockImplementation(async ({ where }) => where.id === ID ? records.costCenter : null); + if (state === "archived") parent.archived_at = archivedAt; + if (state === "cross-company") parent.legal_entity_id = EMPLOYEE; + const [reason, detail] = { missing: ["parent_not_found", "not_found"], archived: ["parent_archived", "archived"], "cross-company": ["parent_company_mismatch", "cross_legal_entity_not_allowed"] }[state]; + expectError(await app.inject({ method, url: url(entities[1], method === "PATCH" ? `/${ID}` : ""), payload: { ...entities[1].payload, parentId: PARENT } }), state === "missing" ? 400 : 409, `cost_center.${reason}`, { parentId: detail }); noWrites(prisma); +}); +it.each(["self", "cycle"])("preserves %s hierarchy rejection", async (state) => { + const { app, prisma, records, parent } = await buildApp(); parent.parent_id = ID; prisma.costCenter.findMany.mockResolvedValue([records.costCenter, parent]); + expectError(await app.inject({ method: "PATCH", url: url(entities[1], `/${ID}`), payload: { parentId: state === "self" ? ID : PARENT } }), 409, `cost_center.parent_${state}_forbidden`, { parentId: `${state}_not_allowed` }); noWrites(prisma); +}); +it.each([ + [entities[0], "legal_entity.has_cost_centers", { legalEntity: "has_cost_centers" }, { legal_entity_id: ID, archived_at: null }], + [entities[1], "cost_center.has_children", { costCenter: "has_children" }, { parent_id: ID, archived_at: null }], +])("preserves dependent record archive guard for $0.route", async (entity, reason, details, where) => { + const { app, prisma } = await buildApp(); prisma.costCenter.count.mockResolvedValue(1); + expectError(await app.inject({ method: "POST", url: url(entity, `/${ID}/archive`) }), 409, reason, details); expect(prisma.costCenter.count).toHaveBeenCalledWith({ where }); noWrites(prisma); +}); +it("preserves explicit null clearing and omitted references on supplier patch", async () => { + const { app, prisma } = await buildApp(); + const response = await app.inject({ method: "PATCH", url: url(entities[3], `/${ID}`), payload: { legalEntityId: null, contactName: " " } }); expect(response.statusCode).toBe(200); + expect(prisma.supplier.update).toHaveBeenCalledWith(expect.objectContaining({ data: { legal_entity_id: null, contact_name: null, updated_by: EMPLOYEE } })); expect(prisma.legalEntity.findUnique).not.toHaveBeenCalled(); +}); +it("validates retained cost-center references on patch and preserves explicit null clearing", async () => { + const { app, prisma, records } = await buildApp(); records.costCenter.owner_employee_id = EMPLOYEE; records.costCenter.parent_id = PARENT; + const response = await app.inject({ method: "PATCH", url: url(entities[1], `/${ID}`), payload: { ownerEmployeeId: null, parentId: null } }); expect(response.statusCode).toBe(200); + expect(prisma.legalEntity.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: COMPANY } })); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); expect(prisma.costCenter.findUnique).toHaveBeenCalledTimes(1); + expect(prisma.costCenter.update).toHaveBeenCalledWith(expect.objectContaining({ data: { owner_employee_id: null, parent_id: null, updated_by: EMPLOYEE } })); +}); +it("preserves non-code unique failure handling for a company", async () => { + const { app, prisma } = await buildApp(); prisma.legalEntity.create.mockRejectedValue({ code: "P2002", meta: { target: ["unrelated"] } }); + const response = await app.inject({ method: "POST", url: url(entities[0]), payload: entities[0].payload }); expect(response.statusCode).toBe(500); expect(prisma.auditLog.create).not.toHaveBeenCalled(); +}); diff --git a/server/tests/unit/routes/masterDataHolidayCalendars.calendar.test.js b/server/tests/unit/routes/masterDataHolidayCalendars.calendar.test.js new file mode 100644 index 00000000..aa1e1a08 --- /dev/null +++ b/server/tests/unit/routes/masterDataHolidayCalendars.calendar.test.js @@ -0,0 +1,156 @@ +const Fastify = require("fastify"); +const holidayRoutes = require("../../../src/routes/masterDataHolidayCalendars"); +const { AppError } = require("../../../src/utils/AppError"); + +const CALENDAR = "11111111-1111-4111-8111-111111111111"; +const COMPANY = "22222222-2222-4222-8222-222222222222"; +const URL = `/api/master-data/holiday-calendars/${CALENDAR}/days`; +const day = (date) => ({ date, dayType: "holiday", name: "合成节假日" }); + +async function buildApp({ allowed = true, archived = false } = {}) { + const app = Fastify(); + const record = { id: CALENDAR, code: "SYNTHETIC", name: "合成日历", timezone: "Asia/Shanghai", enabled: true, archived_at: archived ? new Date("2026-01-01T00:00:00Z") : null }; + const company = { id: COMPANY, code: "SYNTHETIC", archived_at: null, default_holiday_calendar_id: null }; + const prisma = { + holidayCalendar: { findUnique: vi.fn(async () => record), findMany: vi.fn(async () => [record]), create: vi.fn(async ({ data }) => ({ id: CALENDAR, ...data, legal_entity: data.legal_entity_id ? { ...company } : null })), update: vi.fn(async ({ data }) => ({ ...record, ...data })) }, + legalEntity: { findUnique: vi.fn(async () => company), findFirst: vi.fn().mockResolvedValue(null), update: vi.fn().mockResolvedValue({}) }, + holidayCalendarDay: { deleteMany: vi.fn(), createMany: vi.fn(), findMany: vi.fn().mockResolvedValue([]) }, + auditLog: { create: vi.fn() }, + }; + prisma.$transaction = vi.fn(async (callback) => callback(prisma)); + app.decorate("prisma", prisma); + app.decorate("authenticate", async (request) => { request.employee = { id: "synthetic-admin" }; }); + app.decorate("hasPermission", () => allowed); + app.decorate("requirePermission", () => async (request) => { + request.employee = { id: "synthetic-admin" }; + if (!allowed) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + }); + app.setErrorHandler((error, request, reply) => reply.status(error.statusCode || 500).send({ error: { code: error.code, details: error.details } })); + await app.register(holidayRoutes, { prefix: "/api" }); + return { app, prisma, record, company }; +} + +describe("holiday calendar batch date validation", () => { + it.each(["2026-02-29", "2026-02-30", "2026-04-31", "2026-13-01", "", null, undefined])("rejects a batch containing %s before deleting any existing days", async (date) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "PUT", url: URL, payload: { items: [day("2026-01-01"), day(date)] } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details.fieldErrors.items.length).toBeGreaterThan(0); + expect(response.json().error.details.reasonCode).toBe("holiday_calendar.days_input_invalid"); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.holidayCalendar.findUnique).not.toHaveBeenCalled(); + expect(prisma.holidayCalendarDay.deleteMany).not.toHaveBeenCalled(); + expect(prisma.holidayCalendarDay.createMany).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it("keeps leap-day/month-end replacement inside the existing transaction", async () => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "PUT", url: URL, payload: { items: [day("2024-02-29"), day("2024-04-30")] } }); + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({ holidayCalendarId: CALENDAR, replacedCount: 2 }); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.holidayCalendarDay.createMany.mock.calls[0][0].data.map((row) => row.date.toISOString())).toEqual(["2024-02-29T00:00:00.000Z", "2024-04-30T00:00:00.000Z"]); + expect(prisma.holidayCalendarDay.deleteMany.mock.invocationCallOrder[0]).toBeLessThan(prisma.holidayCalendarDay.createMany.mock.invocationCallOrder[0]); + expect(prisma.holidayCalendarDay.createMany.mock.invocationCallOrder[0]).toBeLessThan(prisma.auditLog.create.mock.invocationCallOrder[0]); + } finally { await app.close(); } + }); + + it("preserves duplicate-date conflict before the transaction", async () => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "PUT", url: URL, payload: { items: [day("2024-02-29"), day("2024-02-29")] } }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ date: "duplicate", reasonCode: "holiday_calendar.date_duplicate" }); + expect(prisma.$transaction).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it.each([{ allowed: false }, { archived: true }])("keeps unavailable calendar guard %j without deleting days", async (options) => { + const { app, prisma } = await buildApp(options); + try { + const response = await app.inject({ method: "PUT", url: URL, payload: { items: [day("2024-02-29")] } }); + expect(response.statusCode).toBe(options.archived ? 409 : 403); + if (options.archived) expect(response.json().error.details).toEqual({ status: "archived", reasonCode: "holiday_calendar.archived_locked" }); + if (!options.allowed && !options.archived) expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.holidayCalendarDay.deleteMany).not.toHaveBeenCalled(); + expect(prisma.holidayCalendarDay.createMany).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it("keeps empty replacement supported", async () => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "PUT", url: URL, payload: { items: [] } }); + expect(response.statusCode).toBe(200); + expect(response.json().data.replacedCount).toBe(0); + expect(prisma.holidayCalendarDay.deleteMany).toHaveBeenCalledTimes(1); + expect(prisma.holidayCalendarDay.createMany).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + } finally { await app.close(); } + }); +}); + +const managedApps = []; +afterEach(async () => { await Promise.all(managedApps.splice(0).map((app) => app.close())); }); +async function managedApp(options) { const context = await buildApp(options); managedApps.push(context.app); return context; } +const BASE = "/api/master-data/holiday-calendars"; +function noWrites(prisma) { + for (const method of [prisma.holidayCalendar.create, prisma.holidayCalendar.update, prisma.holidayCalendarDay.deleteMany, prisma.holidayCalendarDay.createMany, prisma.legalEntity.update, prisma.auditLog.create]) expect(method).not.toHaveBeenCalled(); +} +function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); } +it.each([ + ["GET", "?includeArchived=bad", undefined, "query_invalid"], + ["POST", "", {}, "create_input_invalid"], + ["PATCH", `/${CALENDAR}`, { name: "" }, "update_input_invalid"], + ["PUT", `/${CALENDAR}/days`, { items: Array.from({ length: 501 }, () => day("2024-01-01")) }, "days_input_invalid"], +])("explains %s invalid calendar input", async (method, suffix, payload, reason) => { + const { app, prisma } = await managedApp(); const response = await app.inject({ method, url: BASE + suffix, ...(payload ? { payload } : {}) }); + expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `holiday_calendar.${reason}`, formErrors: [], fieldErrors: expect.any(Object) }); noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.holidayCalendar.findMany).not.toHaveBeenCalled(); expect(prisma.holidayCalendar.findUnique).not.toHaveBeenCalled(); +}); +it.each([["PATCH", "", { name: "合成" }], ["GET", "/days"], ["PUT", "/days", { items: [] }], ["POST", "/archive"]])("explains invalid calendar id on %s", async (method, suffix, payload) => { + const { app, prisma } = await managedApp(); expectError(await app.inject({ method, url: `${BASE}/bad${suffix}`, ...(payload ? { payload } : {}) }), 400, "holiday_calendar.id_invalid", { id: "invalid_uuid" }); noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.holidayCalendar.findUnique).not.toHaveBeenCalled(); +}); +it("explains empty patch", async () => { + const { app, prisma } = await managedApp(); expectError(await app.inject({ method: "PATCH", url: `${BASE}/${CALENDAR}`, payload: {} }), 400, "holiday_calendar.update_empty", { body: "empty" }); noWrites(prisma); +}); +it("keeps archived calendar metadata locked", async () => { + const { app, prisma } = await managedApp({ archived: true }); expectError(await app.inject({ method: "PATCH", url: `${BASE}/${CALENDAR}`, payload: { name: "合成" } }), 409, "holiday_calendar.archived_locked", { status: "archived" }); noWrites(prisma); +}); +it.each(["POST", "PATCH"].flatMap((method) => ["not_found", "archived"].map((state) => [method, state])))("explains %s company %s before writes", async (method, state) => { + const { app, prisma, company } = await managedApp(); if (state === "not_found") prisma.legalEntity.findUnique.mockResolvedValue(null); else company.archived_at = new Date("2024-01-01T00:00:00Z"); + expectError(await app.inject({ method, url: BASE + (method === "PATCH" ? `/${CALENDAR}` : ""), payload: { code: "TEST", name: "合成", legalEntityId: COMPANY } }), state === "not_found" ? 400 : 409, `legal_entity.reference_${state}`, { legalEntityId: state }); noWrites(prisma); +}); +it.each(["POST", "PATCH"])("explains %s code conflicts with no default reassignment or audit", async (method) => { + const { app, prisma } = await managedApp(); prisma.holidayCalendar[method === "POST" ? "create" : "update"].mockRejectedValue({ code: "P2002" }); + expectError(await app.inject({ method, url: BASE + (method === "PATCH" ? `/${CALENDAR}` : ""), payload: { code: "TEST", name: "合成", legalEntityId: COMPANY } }), 409, "holiday_calendar.code_duplicate", { code: "duplicate" }); expect(prisma.legalEntity.update).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); +}); +it("keeps default-calendar binding details and blocks archive", async () => { + const { app, prisma } = await managedApp(); prisma.legalEntity.findFirst.mockResolvedValue({ id: COMPANY, code: "SYNTHETIC" }); + expectError(await app.inject({ method: "POST", url: `${BASE}/${CALENDAR}/archive` }), 409, "holiday_calendar.bound_as_default", { holidayCalendar: "bound_as_default", legalEntityCode: "SYNTHETIC" }); noWrites(prisma); + expect(prisma.legalEntity.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { default_holiday_calendar_id: CALENDAR, archived_at: null } })); +}); +it.each([false, true])("preserves creation default assignment, existing default=%s", async (hasDefault) => { + const { app, prisma, company } = await managedApp(); if (hasDefault) company.default_holiday_calendar_id = COMPANY; + const response = await app.inject({ method: "POST", url: BASE, payload: { code: " test ", name: " 合成 ", legalEntityId: COMPANY } }); expect(response.statusCode).toBe(200); + expect(prisma.holidayCalendar.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ code: "TEST", name: "合成", timezone: "Asia/Shanghai", enabled: true }) })); + expect(prisma.legalEntity.update).toHaveBeenCalledTimes(hasDefault ? 0 : 1); expect(response.json().data.holidayCalendar.isDefaultForLegalEntity).toBe(!hasDefault); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); +}); +it("preserves nullable company patch without changing default assignment", async () => { + const { app, prisma } = await managedApp(); const response = await app.inject({ method: "PATCH", url: `${BASE}/${CALENDAR}`, payload: { legalEntityId: null, name: " 合成 " } }); expect(response.statusCode).toBe(200); + expect(prisma.holidayCalendar.update).toHaveBeenCalledWith(expect.objectContaining({ data: { name: "合成", legal_entity_id: null, updated_by: "synthetic-admin" } })); expect(prisma.legalEntity.findUnique).not.toHaveBeenCalled(); expect(prisma.legalEntity.update).not.toHaveBeenCalled(); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); +}); +it.each([false, true])("preserves archive success and idempotence, archived=%s", async (archived) => { + const { app, prisma } = await managedApp({ archived }); const response = await app.inject({ method: "POST", url: `${BASE}/${CALENDAR}/archive` }); expect(response.statusCode).toBe(200); expect(response.json().data.outcome).toBe(archived ? "already_archived" : "archived"); expect(prisma.holidayCalendar.update).toHaveBeenCalledTimes(archived ? 0 : 1); expect(prisma.auditLog.create).toHaveBeenCalledTimes(archived ? 0 : 1); +}); +it.each([["PATCH", "", { name: "合成" }], ["GET", "/days"], ["PUT", "/days", { items: [] }], ["POST", "/archive"]])("preserves %s missing calendar 404", async (method, suffix, payload) => { + const { app, prisma } = await managedApp(); prisma.holidayCalendar.findUnique.mockResolvedValue(null); const response = await app.inject({ method, url: `${BASE}/${CALENDAR}${suffix}`, ...(payload ? { payload } : {}) }); expect(response.statusCode).toBe(404); expect(response.json().error.code).toBe("SYS_404"); noWrites(prisma); expect(prisma.holidayCalendarDay.findMany).not.toHaveBeenCalled(); +}); +it("preserves calendar/day list success and filters", async () => { + const { app, prisma } = await managedApp(); expect((await app.inject({ method: "GET", url: `${BASE}?enabled=1&legalEntityId=${COMPANY}` })).statusCode).toBe(200); + expect(prisma.holidayCalendar.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { enabled: true, legal_entity_id: COMPANY, archived_at: null } })); expect((await app.inject({ method: "GET", url: URL })).statusCode).toBe(200); noWrites(prisma); +}); diff --git a/server/tests/unit/routes/notifications.test.js b/server/tests/unit/routes/notifications.test.js index 3ffc0a34..00cbc2ea 100644 --- a/server/tests/unit/routes/notifications.test.js +++ b/server/tests/unit/routes/notifications.test.js @@ -891,3 +891,56 @@ describe('notification inbox routes', () => { await app.close(); }); }); + + +describe('notification business error reasons', () => { + it.each([ + ['GET', '/notifications?page=0', undefined, 'notification.list_query_invalid', 'page'], + ['GET', '/notifications/deliveries?limit=0', undefined, 'notification.delivery_query_invalid', 'limit'], + ['PATCH', '/notifications/channels/not-an-id', { name: '合成渠道' }, 'notification.id_invalid', null], + ['POST', '/notifications/channels', { channelType: 'wecom', name: '' }, 'notification.channel_input_invalid', 'name'], + ])('keeps %s %s errors precise and side-effect-free', async (method, url, payload, reasonCode, field) => { + const { prisma, auditEntries, stateChannels } = createPrismaState(); + const app = await buildApp({ prisma }); + try { + const response = await app.inject({ method, url: `/api${url}`, headers: { authorization: 'Bearer synthetic' }, ...(payload ? { payload } : {}) }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe('VALID_001'); + expect(response.json().error.details.reasonCode).toBe(reasonCode); + if (field) expect(response.json().error.details.fieldErrors[field].length).toBeGreaterThan(0); + else expect(response.json().error.details.id).toBe('invalid_uuid'); + expect(prisma.notificationChannel.create).not.toHaveBeenCalled(); + expect(prisma.notificationChannel.update).not.toHaveBeenCalled(); + expect(prisma.notification.updateMany).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.notificationDelivery.findMany).not.toHaveBeenCalled(); + expect(stateChannels).toEqual([]); + expect(auditEntries).toEqual([]); + } finally { await app.close(); } + }); + it('keeps duplicate channel conflict and has no successful audit', async () => { + const channel = buildChannelRecord({ name: '合成重复渠道' }); + const { prisma, stateChannels, auditEntries } = createPrismaState({ channels: [channel] }); + const app = await buildApp({ prisma }); + try { + const response = await app.inject({ method: 'POST', url: '/api/notifications/channels', headers: { authorization: 'Bearer synthetic' }, payload: { channelType: 'wecom', name: channel.name, configJson: { mode: 'mock' } } }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ reasonCode: 'notification.channel_name_duplicate', name: 'duplicate_channel_name' }); + expect(stateChannels).toHaveLength(1); + expect(auditEntries).toEqual([]); + } finally { await app.close(); } + }); + it('reports missing encryption capability without exposing the submitted synthetic secret', async () => { + const { prisma, auditEntries } = createPrismaState(); + const app = await buildApp({ prisma }); + const secret = 'synthetic-do-not-echo'; + try { + const response = await app.inject({ method: 'POST', url: '/api/notifications/channels', headers: { authorization: 'Bearer synthetic' }, payload: { channelType: 'wecom', name: '合成渠道', configJson: { mode: 'wecom_webhook', webhookUrl: 'https://example.invalid/synthetic', secret } } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ reasonCode: 'notification.encryption_key_unavailable', secret: 'field_encryption_key_missing' }); + expect(response.body.includes(secret)).toBe(false); + expect(prisma.notificationChannel.create).not.toHaveBeenCalled(); + expect(auditEntries).toEqual([]); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/paymentBatches.reimbursement.test.js b/server/tests/unit/routes/paymentBatches.reimbursement.test.js index a2b0426f..b44ef4c9 100644 --- a/server/tests/unit/routes/paymentBatches.reimbursement.test.js +++ b/server/tests/unit/routes/paymentBatches.reimbursement.test.js @@ -1,3 +1,4 @@ +const { enableNumbering } = require("../helpers/activeNumbering"); const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); @@ -11,31 +12,22 @@ const featureFlags = require("../../../src/services/featureFlags"); const notifications = require("../../../src/services/notifications"); const email = require("../../../src/services/email"); const notificationDispatcher = require("../../../src/services/notificationDispatcher"); -const erpJournal = require("../../../src/services/integrations/erpJournal"); const { encryptStringOrThrow } = require("../../../src/services/crypto"); const evaluateFeatureFlagMock = vi.fn(); const createNotificationsWithRecordsMock = vi.fn(async () => []); const sendNotificationEmailSafeMock = vi.fn(async () => undefined); const flushNotificationDispatchTasksMock = vi.fn(async () => undefined); -const enqueueReimbursementJournalExportMock = vi.fn(async () => ({ - id: "outbox-1", - status: "pending", - duplicated: false, - requeued: false, -})); const originalEvaluateFeatureFlag = featureFlags.evaluateFeatureFlag; const originalCreateNotificationsWithRecords = notifications.createNotificationsWithRecords; const originalSendNotificationEmailSafe = email.sendNotificationEmailSafe; const originalFlushNotificationDispatchTasks = notificationDispatcher.flushNotificationDispatchTasks; -const originalEnqueueReimbursementJournalExport = erpJournal.enqueueReimbursementJournalExport; featureFlags.evaluateFeatureFlag = evaluateFeatureFlagMock; notifications.createNotificationsWithRecords = createNotificationsWithRecordsMock; email.sendNotificationEmailSafe = sendNotificationEmailSafeMock; notificationDispatcher.flushNotificationDispatchTasks = flushNotificationDispatchTasksMock; -erpJournal.enqueueReimbursementJournalExport = enqueueReimbursementJournalExportMock; delete require.cache[require.resolve("../../../src/routes/paymentBatches")]; const paymentBatchesRoutes = require("../../../src/routes/paymentBatches"); @@ -291,6 +283,7 @@ function buildPrisma(options = {}) { }; const tx = { + businessNumberPolicy: { findUnique: vi.fn(async () => null) }, paymentReceiptClaim: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() }, paymentReceiptTransactionAlias: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() }, paymentReceiptManualProposal: { findMany: vi.fn(async () => []) }, @@ -505,6 +498,7 @@ function buildPrisma(options = {}) { employee: { findMany: vi.fn(async () => state.employees.map((employee) => ({ ...employee }))), }, + notification: { findMany: vi.fn(async () => []) }, $executeRaw: vi.fn(async () => 0), $queryRaw: vi.fn(async (query, ...values) => { const text = Array.isArray(query) ? query.join(" ") : String(query?.sql ?? query ?? ""); @@ -808,7 +802,6 @@ async function buildApp(options = {}) { }; const paymentBatchEnabled = options.paymentBatchEnabled ?? true; const paymentBatchAllowedDepartmentIds = options.paymentBatchAllowedDepartmentIds ?? null; - const erpSyncEnabled = options.erpSyncEnabled ?? true; evaluateFeatureFlagMock.mockImplementation(async ({ flagKey, departmentId = null, strictDepartment = true }) => { if (flagKey === featureFlags.FEATURE_FLAG_KEYS.paymentBatch) { @@ -841,16 +834,6 @@ async function buildApp(options = {}) { requiresDepartment: false, }; } - if (flagKey === featureFlags.FEATURE_FLAG_KEYS.erpSync) { - return { - flagKey, - enabled: erpSyncEnabled, - reason: erpSyncEnabled ? "mock_on" : "mock_off", - envEnabled: erpSyncEnabled, - matchedDepartmentId: null, - requiresDepartment: false, - }; - } return { flagKey, enabled: false, @@ -924,10 +907,52 @@ afterAll(() => { notifications.createNotificationsWithRecords = originalCreateNotificationsWithRecords; email.sendNotificationEmailSafe = originalSendNotificationEmailSafe; notificationDispatcher.flushNotificationDispatchTasks = originalFlushNotificationDispatchTasks; - erpJournal.enqueueReimbursementJournalExport = originalEnqueueReimbursementJournalExport; }); describe("payment batch reimbursement routes", () => { + it.each(["list", "detail"])("sorts legacy and monthly reimbursement numbers without mutating payment order: %s", async (view) => { + const { app, prisma, state, tempDir } = await buildApp(); + const original = state.batch.items[0]; + const entries = [ + { suffix: "0003", code: "BX-20260917-10", amount: "30.00" }, + { suffix: "0002", code: "BX-20260917-2", amount: "20.00" }, + { suffix: "0001", code: "BX-20260917-2", amount: "10.00" }, + { suffix: "0006", code: "BX-202609-10000", amount: "60.00" }, + { suffix: "0005", code: "BX-202609-0010", amount: "50.00" }, + { suffix: "0004", code: "BX-202609-0002", amount: "40.00" }, + ].map(({ suffix, code, amount }) => Object.freeze({ ...original, + id: `66666666-6666-4666-8666-66666666${suffix}`, entity_code: code, amount, + })); + const batch = { ...state.batch, items: Object.freeze(entries) }; + prisma.paymentBatch.findUnique.mockResolvedValue(batch); + prisma.paymentBatch.findMany.mockResolvedValue([batch]); + try { + const response = await app.inject({ method: "GET", url: view === "list" ? "/api/payment-batches" : `/api/payment-batches/${BATCH_ID}`, headers: { authorization: "Bearer synthetic-test" } }); + expect(response.statusCode).toBe(200); + const result = response.json().data; + const items = view === "list" ? result.items[0].items : result.item.items; + expect(items.map((item) => [item.id, item.entityCode, item.amount])).toEqual( + [entries[5], entries[4], entries[3], entries[2], entries[1], entries[0]].map((item) => [item.id, item.entity_code, item.amount]), + ); + expect(batch.items).toEqual(entries); + expect(prisma.paymentBatch.update).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } + }); + + it("keeps historical travel response order unchanged", async () => { + const { app, prisma, state, tempDir } = await buildApp(); + const entries = ["TRAVEL-10", "TRAVEL-2"].map((entity_code, index) => ({ + ...state.batch.items[0], id: `travel-item-${index}`, entity_id: TRAVEL_ORDER_ID, entity_code, + })); + prisma.paymentBatch.findMany.mockResolvedValue([{ ...state.batch, entity_type: "travel_order", items: entries }]); + try { + const response = await app.inject({ method: "GET", url: "/api/payment-batches", headers: { authorization: "Bearer synthetic-test" } }); + expect(response.statusCode).toBe(200); + expect(response.json().data.items[0].items.map((item) => item.entityCode)).toEqual(["TRAVEL-10", "TRAVEL-2"]); + } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } + }); + it.each(["legacy", "superseded", "amount", "voided", "reused"])("blocks bank execution for %s evidence", async (condition) => { const { app, state, tx, tempDir } = await buildApp({ verifiedReceipt: condition !== "legacy" }); if (condition === "superseded") state.receiptAttachments[0].receipt_ocr_version += 1; @@ -1167,20 +1192,6 @@ describe("payment batch reimbursement routes", () => { updated_by: ACTOR_ID, }), }); - expect(enqueueReimbursementJournalExportMock).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ - paymentBatchEnabled: true, - erpSyncEnabled: true, - }), - expect.objectContaining({ - actorId: ACTOR_ID, - reimbursement: expect.objectContaining({ - id: REIMBURSEMENT_ID, - status: "paid", - }), - }), - ); expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ @@ -1919,7 +1930,7 @@ describe("payment batch reimbursement routes", () => { expect(tx.paymentBatch.create.mock.calls[0][0].data.name).toBe("付款批次编号验证"); expect(tx.paymentBatch.create.mock.calls[1][0].data.name).toBe("付款批次编号验证"); const advisoryLockCalls = tx.$executeRaw.mock.calls.filter(([query]) => - Array.isArray(query) && query.join(" ").includes("pg_advisory_xact_lock"), + Array.isArray(query) && query.join(" ").includes("pg_advisory_xact_lock("), ); expect(advisoryLockCalls).toHaveLength(2); @@ -2070,7 +2081,6 @@ describe("payment batch reimbursement routes", () => { expect(response.json().error.details.receiptAttachments).toBe("bank_receipt_required"); expect(state.batch.status).toBe("reviewed"); expect(state.reimbursement.status).toBe("approved"); - expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled(); await app.close(); }); @@ -2131,7 +2141,6 @@ describe("payment batch reimbursement routes", () => { expect(tx.paymentBatchItem.update).not.toHaveBeenCalled(); expect(tx.paymentBatch.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); - expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled(); expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); await app.close(); @@ -2161,7 +2170,6 @@ describe("payment batch reimbursement routes", () => { expect(state.batch.status).toBe("reviewed"); expect(state.batch.items[0].status).toBe("pending"); expect(tx.auditLog.create).not.toHaveBeenCalled(); - expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled(); expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); await app.close(); @@ -2186,7 +2194,6 @@ describe("payment batch reimbursement routes", () => { expect(tx.entityRiskFlag.create).not.toHaveBeenCalled(); expect(tx.paymentBatch.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); - expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled(); expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); await app.close(); @@ -2894,14 +2901,6 @@ describe("payment batch reimbursement routes", () => { }), }), }); - expect(enqueueReimbursementJournalExportMock).toHaveBeenCalledTimes(1); - expect(enqueueReimbursementJournalExportMock).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Object), - expect.objectContaining({ - reimbursement: expect.objectContaining({ id: REIMBURSEMENT_ID }), - }), - ); expect(createNotificationsWithRecordsMock).toHaveBeenCalledTimes(1); expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( expect.any(Object), @@ -2933,7 +2932,6 @@ describe("payment batch reimbursement routes", () => { expect(state.batch.items[0].failure_code).toBe("VALID_001"); expect(state.reimbursement.status).toBe("reviewing"); expect(state.invoice.status).toBe("bound"); - expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled(); expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); await app.close(); @@ -3277,3 +3275,14 @@ describe("payment batch reimbursement routes", () => { await app.close(); }); }); + +it("creates an annual payment batch retaining an old reimbursement source code", async () => { + const { app, tx, tempDir } = await buildApp(); enableNumbering(tx); + try { + const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, + payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] } }); + expect(response.statusCode).toBe(201); + expect(tx.paymentBatch.create.mock.calls[0][0].data.batch_no).toMatch(/^PB-\d{4}-0001$/); + expect(tx.paymentBatchItem.create.mock.calls[0][0].data.entity_code).toMatch(/^BX-/); + } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } +}); diff --git a/server/tests/unit/routes/payroll.exports.test.js b/server/tests/unit/routes/payroll.exports.test.js index 3404b953..4e179522 100644 --- a/server/tests/unit/routes/payroll.exports.test.js +++ b/server/tests/unit/routes/payroll.exports.test.js @@ -166,3 +166,18 @@ describe("payroll export routes", () => { await app.close(); }); }); + +describe("payroll export month guards", () => { + it.each(["csv", "xlsx"].flatMap((format) => ["2026-00", "2026-13", "2026-1", ""].map((month) => [format, month])))("rejects %s month=%s before reads/audit", async (format, month) => { + const { app } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/exports/payslips?format=${format}&month=${month}` }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe("VALID_001"); + expect(response.json().error.details.reasonCode).toBe((["2026-00", "2026-13"].includes(month) ? "payroll.month_invalid" : "payroll.export_query_invalid")); + if (["2026-00", "2026-13"].includes(month)) expect(response.json().error.details).toEqual({ month: "invalid_month", reasonCode: "payroll.month_invalid" }); + expect(app.prisma.payslip.findMany).not.toHaveBeenCalled(); + expect(app.prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/payroll.items.test.js b/server/tests/unit/routes/payroll.items.test.js index 11949fbc..803cf9d0 100644 --- a/server/tests/unit/routes/payroll.items.test.js +++ b/server/tests/unit/routes/payroll.items.test.js @@ -172,3 +172,32 @@ describe("payroll salary item routes", () => { await app.close(); }); }); + +describe("salary item reasons", () => { + it.each([ + ["POST", "/api/payroll/items", {}, "payroll.item_create_input_invalid"], + ["PATCH", "/api/payroll/items/11111111-1111-4111-8111-111111111111", {}, "payroll.item_update_input_invalid"], + ["PATCH", "/api/payroll/items/invalid", { enabled: false }, "payroll.id_invalid"], + ])("explains %s %s before business access", async (method, url, payload, reasonCode) => { + const { app, salaryItem } = await buildApp(); + try { + const response = await app.inject({ method, url, payload }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode } }); + if (reasonCode === "payroll.id_invalid") expect(response.json().error.details).toEqual({ id: "invalid_uuid", reasonCode }); + for (const fn of Object.values(salaryItem)) expect(fn).not.toHaveBeenCalled(); + expect(app.prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it.each(["POST", "PATCH"])("explains %s duplicate name without success audit", async (method) => { + const { app, salaryItem } = await buildApp(); + const mutation = method === "POST" ? salaryItem.create : salaryItem.update; + mutation.mockRejectedValueOnce(Object.assign(new Error("synthetic uniqueness failure"), { code: "P2002" })); + try { + const response = await app.inject({ method, url: method === "POST" ? "/api/payroll/items" : "/api/payroll/items/11111111-1111-4111-8111-111111111111", payload: { name: "合成薪资项", itemType: "earning", sortOrder: 1 } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ name: "duplicate", reasonCode: "payroll.item_name_duplicate" }); + expect(app.prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/payroll.payslips.test.js b/server/tests/unit/routes/payroll.payslips.test.js index 9668d05f..a1acb807 100644 --- a/server/tests/unit/routes/payroll.payslips.test.js +++ b/server/tests/unit/routes/payroll.payslips.test.js @@ -351,6 +351,7 @@ describe("payroll payslip routes", () => { }); expect(frozenResponse.statusCode).toBe(409); + expect(frozenResponse.json().error.details).toEqual({ payslip: "payslip_not_editable", reasonCode: "payroll.payslip_not_editable" }); await app.close(); @@ -388,8 +389,111 @@ describe("payroll payslip routes", () => { expect(response.statusCode).toBe(409); expect(response.json().error.details.month).toBe("salary_profile_missing"); + expect(response.json().error.details.reasonCode).toBe("payroll.profiles_missing"); expect(state.payslips).toHaveLength(0); await app.close(); }); }); + +describe("payroll month guards", () => { + it.each(["2026-00", "2026-13", "2026-1", "invalid", ""].flatMap((month) => [ + ["GET", "/api/payroll/payslips", month], + ["POST", "/api/payroll/payslips/generate", month], + ["POST", "/api/payroll/payslips/issue", month], + ]))("rejects %s %s month=%s before business access", async (method, url, month) => { + const { app } = await buildApp(); + try { + const response = await app.inject({ method, url: method === "GET" ? `${url}?month=${month}` : url, ...(method === "POST" ? { payload: { month } } : {}) }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe("VALID_001"); + expect(response.json().error.details.reasonCode).toBe("payroll.month_invalid"); + if (["2026-00", "2026-13"].includes(month)) expect(response.json().error.details).toEqual({ month: "invalid_month", reasonCode: "payroll.month_invalid" }); + for (const [key, group] of Object.entries(app.prisma)) { + if (key === "$transaction") expect(group).not.toHaveBeenCalled(); + else for (const fn of Object.values(group)) expect(fn).not.toHaveBeenCalled(); + } + } finally { await app.close(); } + }); + + it.each(["2024-01", "2024-02", "2024-12"])("queries the exact UTC month for %s", async (month) => { + const { app } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/payroll/payslips?month=${month}` }); + expect(response.statusCode).toBe(200); + expect(response.json().data.month).toBe(month); + expect(app.prisma.payslip.findMany.mock.calls[0][0].where.pay_month).toEqual(new Date(`${month}-01T00:00:00.000Z`)); + } finally { await app.close(); } + }); +}); + +describe("payslip operation reasons", () => { + function expectNoWrites(app) { + for (const fn of [app.prisma.payslip.create, app.prisma.payslip.update, app.prisma.payslip.updateMany, app.prisma.payslipItem.createMany, app.prisma.payslipItem.deleteMany, app.prisma.auditLog.create]) { + expect(fn).not.toHaveBeenCalled(); + } + } + it.each([ + ["already_generated", "month_already_generated", { month: "payslips_month_exists" }], + ["no_employees", "active_employees_missing", { month: "no_active_employees" }], + ["no_items", "enabled_items_missing", { items: "salary_items_empty" }], + ])("explains generation blocker %s before writes", async (scenario, reason, details) => { + const { app, state } = await buildApp(); + if (scenario === "already_generated") state.payslips.push({ pay_month: new Date("2026-04-01T00:00:00.000Z") }); + if (scenario === "no_employees") state.employees.length = 0; + if (scenario === "no_items") state.salaryItems.length = 0; + try { + const response = await app.inject({ method: "POST", url: "/api/payroll/payslips/generate", payload: { month: "2026-04" } }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ ...details, reasonCode: `payroll.${reason}` }); + expectNoWrites(app); + } finally { await app.close(); } + }); + it("explains a concurrent month uniqueness conflict without success audit", async () => { + const { app } = await buildApp(); + app.prisma.payslip.create.mockRejectedValueOnce(Object.assign(new Error("synthetic conflict"), { code: "P2002" })); + try { + const response = await app.inject({ method: "POST", url: "/api/payroll/payslips/generate", payload: { month: "2026-04" } }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ month: "payslips_month_exists", reasonCode: "payroll.month_already_generated" }); + expect(app.prisma.payslipItem.createMany).not.toHaveBeenCalled(); + expect(app.prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it.each([false, true])("explains issue rejection with existing issued slips=%s", async (existing) => { + const { app, state } = await buildApp(); + if (existing) state.payslips.push({ id: PAYSLIP_IDS[0], pay_month: new Date("2026-04-01T00:00:00.000Z"), status: "issued" }); + try { + const response = await app.inject({ method: "POST", url: "/api/payroll/payslips/issue", payload: { month: "2026-04" } }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ month: existing ? "payslips_already_issued" : "payslips_not_generated", reasonCode: existing ? "payroll.month_already_issued" : "payroll.month_not_generated" }); + expectNoWrites(app); + } finally { await app.close(); } + }); + it.each([ + [{ items: [] }, "payroll.payslip_update_input_invalid"], + [{ items: [{ salaryItemId: "11111111-1111-4111-8111-111111111111", amount: "1.234" }] }, "payroll.amount_invalid"], + ])("explains invalid payslip edit %j before reads", async (payload, reasonCode) => { + const { app } = await buildApp(); + try { + const response = await app.inject({ method: "PATCH", url: `/api/payroll/payslips/${PAYSLIP_IDS[0]}`, payload }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details.reasonCode).toBe(reasonCode); + if (reasonCode === "payroll.amount_invalid") expect(response.json().error.details).toEqual({ items: { 0: { amount: "invalid_money_amount" } }, reasonCode }); + expect(app.prisma.payslip.findUnique).not.toHaveBeenCalled(); + expectNoWrites(app); + } finally { await app.close(); } + }); + it("explains an unavailable item in an editable payslip before replacement", async () => { + const { app, state } = await buildApp(); + state.payslips.push({ id: PAYSLIP_IDS[0], pay_month: new Date("2026-04-01T00:00:00.000Z"), employee_id: EMPLOYEE_ID, status: "draft" }); + app.prisma.salaryItem.findMany.mockResolvedValueOnce([]); + try { + const response = await app.inject({ method: "PATCH", url: `/api/payroll/payslips/${PAYSLIP_IDS[0]}`, payload: { items: [{ salaryItemId: "11111111-1111-4111-8111-111111111111", amount: 100 }] } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ items: "salary_item_not_found", reasonCode: "payroll.item_not_found" }); + expectNoWrites(app); + expect(app.prisma.$transaction).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/payroll.salary-profile.test.js b/server/tests/unit/routes/payroll.salary-profile.test.js index 7e0b1ab6..5a5fff81 100644 --- a/server/tests/unit/routes/payroll.salary-profile.test.js +++ b/server/tests/unit/routes/payroll.salary-profile.test.js @@ -234,3 +234,47 @@ describe("payroll salary profile routes", () => { await app.close(); }); }); + +describe("salary profile calendar boundary", () => { + it.each(["2024-02-29", "2026-04-30"])("stores exact effectiveDate %s", async (effectiveDate) => { + const { app, state, employeeSalary } = await buildApp(); + try { + const response = await app.inject({ method: "PUT", url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`, payload: { effectiveDate, items: [{ salaryItemId: state.salaryItems[0].id, monthlyAmount: "100.00" }] } }); + expect(response.statusCode).toBe(200); + expect(employeeSalary.createMany.mock.calls[0][0].data[0].effective_date.toISOString()).toBe(`${effectiveDate}T00:00:00.000Z`); + } finally { await app.close(); } + }); +}); + +describe("salary profile reasons", () => { + it.each([ + [{ effectiveDate: "2026-02-30", items: [] }, "payroll.profile_input_invalid"], + [{ effectiveDate: "2026-04-01", items: [{ salaryItemId: "11111111-1111-4111-8111-111111111111", monthlyAmount: "1.234" }] }, "payroll.amount_invalid"], + [{ effectiveDate: "2026-04-01", items: [{ salaryItemId: "11111111-1111-4111-8111-111111111111", monthlyAmount: -1 }] }, "payroll.amount_invalid"], + ])("explains invalid profile %j", async (payload, reasonCode) => { + const { app } = await buildApp(); + try { + const response = await app.inject({ method: "PUT", url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`, payload }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details.reasonCode).toBe(reasonCode); + if (reasonCode === "payroll.amount_invalid") expect(response.json().error.details).toEqual({ items: { 0: { monthlyAmount: "invalid_money_amount" } }, reasonCode }); + expect(app.prisma.employee.findUnique).not.toHaveBeenCalled(); + expect(app.prisma.employeeSalary.deleteMany).not.toHaveBeenCalled(); + expect(app.prisma.employeeSalary.createMany).not.toHaveBeenCalled(); + expect(app.prisma.auditLog.create).not.toHaveBeenCalled(); + expect(app.prisma.$transaction).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it("explains unavailable salary items before replacing a snapshot", async () => { + const { app } = await buildApp(); + app.prisma.salaryItem.findMany.mockResolvedValueOnce([]); + try { + const response = await app.inject({ method: "PUT", url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`, payload: { effectiveDate: "2026-04-01", items: [{ salaryItemId: "11111111-1111-4111-8111-111111111111", monthlyAmount: 100 }] } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ items: "salary_item_not_found", reasonCode: "payroll.item_not_found" }); + expect(app.prisma.$transaction).not.toHaveBeenCalled(); + expect(app.prisma.employeeSalary.deleteMany).not.toHaveBeenCalled(); + expect(app.prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/performance.cycles.test.js b/server/tests/unit/routes/performance.cycles.test.js index b4ac43f1..a17d5c17 100644 --- a/server/tests/unit/routes/performance.cycles.test.js +++ b/server/tests/unit/routes/performance.cycles.test.js @@ -246,3 +246,17 @@ describe("performance cycle routes", () => { await app.close(); }); }); + +describe("performance cycle calendar boundary", () => { + it("preserves leap day, month end and a partial date patch", async () => { + const { app, state, performanceReviewCycle } = await buildApp(); + try { + const response = await app.inject({ method: "POST", url: "/api/performance/cycles", payload: { name: "合成周期", cycleType: "quarterly", startDate: "2024-02-29", endDate: "2024-04-30" } }); + expect(response.statusCode).toBe(200); + expect(response.json().data).toMatchObject({ startDate: "2024-02-29", endDate: "2024-04-30" }); + expect((await app.inject({ method: "PATCH", url: `/api/performance/cycles/${state.cycles[0].id}`, payload: { endDate: "2024-05-31" } })).statusCode).toBe(200); + expect(performanceReviewCycle.update.mock.calls[0][0].data).toMatchObject({ end_date: new Date("2024-05-31T00:00:00Z") }); + expect(performanceReviewCycle.update.mock.calls[0][0].data).not.toHaveProperty("start_date"); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/portal.test.js b/server/tests/unit/routes/portal.test.js index e6077274..d1026508 100644 --- a/server/tests/unit/routes/portal.test.js +++ b/server/tests/unit/routes/portal.test.js @@ -353,6 +353,19 @@ async function buildApp(options = {}) { } describe("portal routes", () => { + + it("returns the registered disabled reason without changing the portal gate", async () => { + featureFlagState.portalHome = false; + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: "/api/portal/home", headers: { authorization: "Bearer synthetic" } }); + expect(response.statusCode).toBe(409); + expect(response.json().error.code).toBe("VALID_001"); + expect(response.json().error.details).toMatchObject({ reasonCode: "portal.disabled", featureFlag: "portal_home_v1_disabled" }); + const businessCalls = Object.entries(prisma).filter(([name]) => name !== "featureFlag").flatMap(([, model]) => Object.values(model ?? {})).filter(vi.isMockFunction); + expect(businessCalls.every((fn) => fn.mock.calls.length === 0)).toBe(true); + } finally { await app.close(); } + }); beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-12T02:00:00.000Z")); diff --git a/server/tests/unit/routes/presence.admin.test.js b/server/tests/unit/routes/presence.admin.test.js index 7aa8684c..f86545fe 100644 --- a/server/tests/unit/routes/presence.admin.test.js +++ b/server/tests/unit/routes/presence.admin.test.js @@ -185,6 +185,7 @@ async function buildApp(options = {}) { appTimezone: "Asia/Shanghai", presenceBoardEnabled: true, }); + app.setErrorHandler((error, request, reply) => reply.status(error.statusCode || 500).send({ error: { code: error.code, details: error.details ?? {} } })); app.decorate("prisma", { employee: { findMany: employeeFindMany, @@ -394,3 +395,53 @@ describe("presence admin override routes", () => { await app.close(); }); }); + + +describe("presence calendar boundaries", () => { + const routes = [ + ["GET", "/presence/me", "date"], ["GET", "/presence/team", "date"], ["GET", "/presence/summary", "date"], + ["GET", "/presence/me/history", "dateFrom"], ["GET", "/presence/me/history", "dateTo"], + ["GET", "/presence/team/history", "dateFrom"], ["GET", "/presence/team/history", "dateTo"], + ["PUT", "/presence/me", "date"], ["PUT", `/presence/team/${EMPLOYEE_ID}`, "date"], + ]; + it.each(routes)("rejects invalid calendar %s %s %s before reads/writes", async (method, url, field) => { + const { app, auditCreate, employeePresenceUpsert } = await buildApp(); + try { + const input = { [field]: "2026-02-30" }; + const response = await app.inject({ method, url: `/api${url}` + (method === "GET" ? `?${new URLSearchParams(input)}` : ""), ...(method === "PUT" ? { payload: { ...input, status: "office" } } : {}) }); + expect(response.statusCode).toBe(400); + const reason = method === "PUT" ? (url === "/presence/me" ? "write_input_invalid" : "override_input_invalid") : url.includes("team/history") ? "team_history_query_invalid" : url.includes("history") ? "history_query_invalid" : "query_invalid"; + expect(response.json().error.details.reasonCode).toBe(`presence.${reason}`); + expect(app.prisma.employeePresence.findMany).not.toHaveBeenCalled(); + expect(employeePresenceUpsert).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it.each(["2024-02-29", "2026-04-30"])("accepts %s in self and admin writes", async (date) => { + const { app, employeePresenceUpsert } = await buildApp(); + try { + for (const url of ["/api/presence/me", `/api/presence/team/${EMPLOYEE_ID}`]) { + const response = await app.inject({ method: "PUT", url, payload: { date, status: "office" } }); + expect(response.statusCode).toBe(200); + expect(employeePresenceUpsert.mock.calls.at(-1)[0].create.date).toEqual(new Date(`${date}T00:00:00.000Z`)); + } + } finally { await app.close(); } + }); + it("keeps omitted date supported and null rejected for self writes", async () => { + const { app, employeePresenceUpsert } = await buildApp(); + try { + const omitted = await app.inject({ method: "PUT", url: "/api/presence/me", payload: { status: "office" } }); + expect(omitted.statusCode).toBe(200); + employeePresenceUpsert.mockClear(); + const nullDate = await app.inject({ method: "PUT", url: "/api/presence/me", payload: { date: null, status: "office" } }); + expect(nullDate.statusCode).toBe(400); + expect(employeePresenceUpsert).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); + +describe("presence specific error contracts", () => { + it("rejects invalid override id before employee reads", async () => { const { app, auditCreate, employeePresenceUpsert } = await buildApp(); try { const r = await app.inject({ method: "PUT", url: "/api/presence/team/bad", payload: { date: "2026-03-27", status: "office" } }); expect(r.statusCode).toBe(400); expect(r.json().error.details.reasonCode).toBe("presence.employee_id_invalid"); expect(app.prisma.employee.findUnique).not.toHaveBeenCalled(); expect(employeePresenceUpsert).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); + it("retains feature rejection before queries", async () => { const { app, auditCreate, employeePresenceUpsert } = await buildApp(); app.config.presenceBoardEnabled = false; try { const r = await app.inject({ method: "GET", url: "/api/presence/me?date=bad" }); expect(r.statusCode).toBe(409); expect(r.json().error.details).toMatchObject({ featureFlag: "presence_board_v1_disabled", reasonCode: "presence.feature_disabled" }); expect(app.prisma.employeePresence.findMany).not.toHaveBeenCalled(); expect(employeePresenceUpsert).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); + it("keeps approved leave readonly for self writes", async () => { const { app, auditCreate, employeePresenceUpsert } = await buildApp({ actor: { id: EMPLOYEE_ID, permissionCodes: [] }, leaveRows: [buildLeaveRow()] }); try { const r = await app.inject({ method: "PUT", url: "/api/presence/me", payload: { date: "2026-03-27", status: "office" } }); expect(r.statusCode).toBe(409); expect(r.json().error.details).toEqual({ date: "covered_by_approved_leave", reasonCode: "presence.approved_leave_readonly" }); expect(employeePresenceUpsert).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); +}); diff --git a/server/tests/unit/routes/presence.history.test.js b/server/tests/unit/routes/presence.history.test.js index 18bc9478..b5975a14 100644 --- a/server/tests/unit/routes/presence.history.test.js +++ b/server/tests/unit/routes/presence.history.test.js @@ -161,6 +161,15 @@ async function buildApp(options = {}) { }, }); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + error: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }, + }); + } + return reply.status(500).send({ error: { code: "SYS_001" } }); + }); + await app.register(presenceRoutes, { prefix: "/api" }); return { app, employeeFindMany }; @@ -321,3 +330,28 @@ describe("presence history routes", () => { await app.close(); }); }); + + +describe("presence history range reasons", () => { + it.each([ + ["2024-03-01", "2024-02-29", "presence_history.date_range_invalid", { dateTo: "must_be_after_or_equal_dateFrom" }], + ["2024-01-01", "2024-02-01", "presence_history.date_range_too_large", { dateRange: "too_large" }], + ])("explains %s..%s before employee queries", async (from, to, reasonCode, details) => { + const { app, employeeFindMany } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: `/api/presence/team/history?dateFrom=${from}&dateTo=${to}` }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { ...details, reasonCode } }); + expect(response.json().error.details).toEqual({ ...details, reasonCode }); + expect(employeeFindMany).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it("keeps exactly 31 inclusive days valid", async () => { + const { app, employeeFindMany } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: "/api/presence/team/history?dateFrom=2024-01-01&dateTo=2024-01-31" }); + expect(response.statusCode).toBe(200); + expect(employeeFindMany).toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/projects.test.js b/server/tests/unit/routes/projects.test.js index df089540..a58f6fb4 100644 --- a/server/tests/unit/routes/projects.test.js +++ b/server/tests/unit/routes/projects.test.js @@ -1,3 +1,4 @@ +const { enableNumbering } = require("../helpers/activeNumbering"); const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); @@ -127,6 +128,8 @@ function createPrismaState({ communicationLogs = [], closureChecklistItems = [], } const prisma = { + businessNumberPolicy: { findUnique: vi.fn(async () => null) }, + $executeRaw: vi.fn(async () => 0), featureFlag: { findUnique: vi.fn().mockImplementation(async ({ where }) => buildFeatureFlagRecord(where.flag_key)), }, @@ -297,6 +300,7 @@ function createPrismaState({ communicationLogs = [], closureChecklistItems = [], }, }; + prisma.$transaction = vi.fn(async (cb) => cb(prisma)); return { state, prisma }; } @@ -563,3 +567,106 @@ describe("project R23 routes", () => { await app.close(); }); }); + +const ERROR_CHILD_ID = "66666666-6666-4666-8666-666666666666"; +const errorApps = []; +afterEach(async () => { await Promise.all(errorApps.splice(0).map((app) => app.close())); }); +async function errorContext({ employee = { id: OWNER_ID, permissionCodes: ["project.manage"] }, emptyChecklist = false } = {}) { + const context = createPrismaState({ + communicationLogs: [{ id: ERROR_CHILD_ID, project_id: PROJECT_ID, summary: "合成沟通", channel: "meeting", happened_at: NOW, created_at: NOW, created_by: OWNER_ID }], + closureChecklistItems: emptyChecklist ? [] : [{ id: ERROR_CHILD_ID, project_id: PROJECT_ID, status: "pending", title: "合成收口项", sort_order: 1, note: null }], + }); + const { prisma, state } = context; + for (const model of ["projectMember", "projectMilestone", "projectTask", "projectRisk", "projectDeliveryRecord"]) { + prisma[model] ??= {}; + for (const method of ["create", "update", "upsert"]) prisma[model][method] = vi.fn(async ({ data, create }) => ({ id: ERROR_CHILD_ID, project_id: PROJECT_ID, ...(data ?? create) })); + } + prisma.project.create = vi.fn(async ({ data }) => ({ ...state.project, project_no: data.project_no })); + prisma.project.findMany = vi.fn().mockResolvedValue([]); + const app = await buildApp({ employee, prisma }); errorApps.push(app); + return { ...context, app }; +} +function errorRequest(method, suffix, payload) { return { method, url: `/api/projects${suffix}`, headers: { authorization: "Bearer synthetic-test" }, ...(payload ? { payload } : {}) }; } +function noProjectMutations(prisma, { bootstrap = false } = {}) { + for (const model of ["project", "projectMember", "projectMilestone", "projectTask", "projectRisk", "projectDeliveryRecord", "projectCommunicationLog", "projectClosureChecklistItem"]) { + for (const method of ["create", "update", "upsert"]) if (prisma[model]?.[method]) expect(prisma[model][method]).not.toHaveBeenCalled(); + } + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + if (!bootstrap) expect(prisma.projectClosureChecklistItem.createMany).not.toHaveBeenCalled(); +} +function expectProjectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { ...details, reasonCode } }); } +const projectInputOperations = [ + ["POST", "", "create_input_invalid"], ["PATCH", `/${PROJECT_ID}`, "update_input_invalid"], + ...[["members", "member"], ["milestones", "milestone"], ["tasks", "task"], ["risks", "risk"], ["communication-logs", "communication_log"]].flatMap(([path, reason]) => [["POST", `/${PROJECT_ID}/${path}`, `${reason}_create_input_invalid`], ["PATCH", `/${PROJECT_ID}/${path}/${ERROR_CHILD_ID}`, `${reason}_update_input_invalid`]]), + ["PATCH", `/${PROJECT_ID}/closure-checklist/${ERROR_CHILD_ID}`, "closure_checklist_update_input_invalid"], + ["POST", `/${PROJECT_ID}/deliveries`, "delivery_create_input_invalid"], +]; +it.each(projectInputOperations)("explains %s %s invalid input without target mutations", async (method, suffix, reason) => { + const { app, prisma } = await errorContext(); const response = await app.inject(errorRequest(method, suffix, {})); + expectProjectError(response, 400, `project.${reason}`, { formErrors: expect.any(Array), fieldErrors: expect.any(Object) }); noProjectMutations(prisma); +}); +it.each([ + ["/bad", "id", "id_invalid"], + ...[["members", "memberId", "member"], ["milestones", "milestoneId", "milestone"], ["tasks", "taskId", "task"], ["risks", "riskId", "risk"], ["communication-logs", "logId", "communication_log"], ["closure-checklist", "itemId", "closure_checklist"]].map(([path, key, reason]) => [`/${PROJECT_ID}/${path}/bad`, key, `${reason}_id_invalid`]), +])("explains invalid project reference %s", async (suffix, key, reason) => { + const { app, prisma } = await errorContext(); expectProjectError(await app.inject(errorRequest("PATCH", suffix, {})), 400, `project.${reason}`, { [key]: "invalid_uuid" }); noProjectMutations(prisma); +}); +it.each([ + ["", "project_domain_v1", "feature_disabled", "projectDomainEnabled"], + [`/${PROJECT_ID}/weekly-reports`, "project_linking_v1", "linking_disabled", "projectLinkingEnabled"], + [`/${PROJECT_ID}/communication-logs`, "project_communication_log_v1", "communication_disabled", "projectCommunicationLogEnabled"], + [`/${PROJECT_ID}/closure-checklist`, "project_closure_checklist_v1", "closure_disabled", "projectClosureChecklistEnabled"], + [`/${PROJECT_ID}/document-refs`, "confidential_doc_refs_v1", "document_refs_disabled", "confidentialDocRefsEnabled"], +].flatMap((row) => [false, true].map((envOff) => [...row, envOff])))("preserves %s feature gate %s before business access", async (suffix, flag, reason, configKey, envOff) => { + const { app, prisma } = await errorContext(); + if (envOff) app.config[configKey] = false; + else prisma.featureFlag.findUnique.mockImplementation(async ({ where }) => ({ ...buildFeatureFlagRecord(where.flag_key), override_mode: "force_off" })); + expectProjectError(await app.inject(errorRequest("GET", suffix)), 409, `project.${reason}`, { featureFlag: `${flag}_disabled`, reason: envOff ? "env_hard_off" : "flag_force_off" }); + expect(prisma.project.findUnique).not.toHaveBeenCalled(); expect(prisma.project.findMany).not.toHaveBeenCalled(); noProjectMutations(prisma); + if (envOff) expect(prisma.featureFlag.findUnique).not.toHaveBeenCalled(); +}); +it("explains invalid project query after the feature gate", async () => { + const { app, prisma } = await errorContext(); expectProjectError(await app.inject(errorRequest("GET", "?mine=invalid")), 400, "project.query_invalid", { fieldErrors: { mine: expect.any(Array) } }); expect(prisma.project.findMany).not.toHaveBeenCalled(); noProjectMutations(prisma); +}); +it.each(["status", "clear-note"])("keeps waived note guard on %s", async (mode) => { + const { app, prisma, state } = await errorContext(); if (mode === "clear-note") { state.closureChecklistItems[0].status = "waived"; state.closureChecklistItems[0].note = "合成理由"; } + expectProjectError(await app.inject(errorRequest("PATCH", `/${PROJECT_ID}/closure-checklist/${ERROR_CHILD_ID}`, mode === "status" ? { status: "waived", note: " " } : { note: null })), 400, "project.waiver_note_required", { note: "waived_requires_note" }); noProjectMutations(prisma); +}); +it.each(["invalid-id", "invalid-body"])("documents existing checklist bootstrap before %s validation", async (mode) => { + const { app, prisma, state } = await errorContext({ emptyChecklist: true }); + const firstId = "99999999-9999-4999-8999-000000000100"; + const response = await app.inject(errorRequest("PATCH", `/${PROJECT_ID}/closure-checklist/${mode === "invalid-id" ? "bad" : firstId}`, {})); + expectProjectError(response, 400, `project.closure_checklist_${mode === "invalid-id" ? "id_invalid" : "update_input_invalid"}`, {}); + expect(prisma.projectClosureChecklistItem.createMany).toHaveBeenCalledTimes(1); expect(state.closureChecklistItems).toHaveLength(6); noProjectMutations(prisma, { bootstrap: true }); +}); +it.each(projectInputOperations.filter(([,suffix]) => !suffix.includes("closure-checklist")))("keeps permission before %s %s validation", async (method, suffix) => { + const { app, prisma } = await errorContext({ employee: { id: OUTSIDER_ID, permissionCodes: [] } }); const response = await app.inject(errorRequest(method, suffix, {})); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); noProjectMutations(prisma); +}); +it.each([ + ["members", "projectMember", { employeeId: MEMBER_ID }, { role: "observer" }], + ["milestones", "projectMilestone", { title: "合成里程碑" }, { title: "合成修改" }], + ["tasks", "projectTask", { title: "合成任务" }, { priority: "high" }], + ["risks", "projectRisk", { title: "合成风险", detail: "合成说明" }, { level: "high" }], +].flatMap((row) => ["POST", "PATCH"].map((method) => [...row, method])))("preserves valid %s %s operation", async (path, model, createPayload, patchPayload, method) => { + const { app, prisma } = await errorContext(); const response = await app.inject(errorRequest(method, `/${PROJECT_ID}/${path}${method === "PATCH" ? `/${ERROR_CHILD_ID}` : ""}`, method === "POST" ? createPayload : patchPayload)); expect(response.statusCode).toBe(method === "POST" ? 201 : 200); + expect(prisma[model][method === "PATCH" ? "update" : model === "projectMember" ? "upsert" : "create"]).toHaveBeenCalledTimes(1); +}); +it("preserves valid project create and delivery create", async () => { + const { app, prisma } = await errorContext(); const created = await app.inject(errorRequest("POST", "", { projectNo: "SYNTHETIC", name: "合成项目", clientName: "合成客户" })); expect(created.statusCode).toBe(201); expect(prisma.project.create).toHaveBeenCalledTimes(1); + const delivery = await app.inject(errorRequest("POST", `/${PROJECT_ID}/deliveries`, { title: "合成交付", recordType: "handover", summary: "合成说明", occurredAt: "2026-01-01T00:00:00Z" })); expect(delivery.statusCode).toBe(201); expect(prisma.projectDeliveryRecord.create).toHaveBeenCalledTimes(1); +}); +it("preserves valid waived note and completion metadata", async () => { + const { app, prisma } = await errorContext(); const response = await app.inject(errorRequest("PATCH", `/${PROJECT_ID}/closure-checklist/${ERROR_CHILD_ID}`, { status: "waived", note: "合成理由" })); expect(response.statusCode).toBe(200); + expect(prisma.projectClosureChecklistItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: { status: "waived", note: "合成理由", completed_at: expect.any(Date), completed_by: OWNER_ID } })); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); +}); + +it("allocates project numbers inside creation and reserves formal matching codes", async () => { + const { app, prisma } = await errorContext(); enableNumbering(prisma); + const response = await app.inject(errorRequest("POST", "", { name: "合成年度项目", clientName: "合成客户" })); + expect(response.statusCode).toBe(201); + expect(prisma.project.create.mock.calls[0][0].data.project_no).toMatch(/^PRJ-\d{4}-0001$/); + const formal = await app.inject(errorRequest("POST", "", { projectNo: "PRJ-2026-0050", name: "合成手工号", clientName: "合成客户" })); + expect(formal.statusCode).toBe(201); + expect(prisma.project.create.mock.calls[1][0].data.project_no).toBe("PRJ-2026-0050"); + expect(prisma.businessNumberCounter.upsert.mock.calls.at(-1)[0].update.current_number).toBe(50n); +}); diff --git a/server/tests/unit/routes/purchase-request.errors.test.js b/server/tests/unit/routes/purchase-request.errors.test.js new file mode 100644 index 00000000..b9f6e238 --- /dev/null +++ b/server/tests/unit/routes/purchase-request.errors.test.js @@ -0,0 +1,302 @@ +const { enableNumbering } = require("../helpers/activeNumbering"); +const Fastify = require("fastify"); +const multipart = require("@fastify/multipart"); +const { AppError } = require("../../../src/utils/AppError"); +const workflow = require("../../../src/utils/workflow"); +const feeControl = require("../../../src/services/feeControl"); +const budget = require("../../../src/services/budgetPool"); +const purchaseBudget = require("../../../src/services/purchaseBudget"); +const cutover = require("../../../src/services/cutover"); +const notifications = require("../../../src/services/notifications"); +const email = require("../../../src/services/email"); +const dispatcher = require("../../../src/services/notificationDispatcher"); +const policy = require("../../../src/services/purchaseAttachmentPolicy"); +const mocks = {}; +const collaborators = [[workflow, "parseWorkflowSteps"], [workflow, "resolveActionableStep"], [feeControl, "resolveEntityPrecheck"], [feeControl, "createRiskFlagsFromPrecheck"], [budget, "resolveApplicableBudgetPoolByContext"], [purchaseBudget, "reserveBudgetForEntity"], [purchaseBudget, "releaseReservedBudgetIfAny"], [cutover, "ensureCutoverAllowed"], [notifications, "resolveRecipientsForAssignee"], [notifications, "createNotificationsWithRecords"], [email, "sendNotificationEmailSafe"], [dispatcher, "dispatchNotificationBatch"], [policy, "validateAndNormalizePurchaseAttachment"]]; +const saved = collaborators.map(([object, key]) => object[key]); +collaborators.forEach(([object, key]) => { object[key] = mocks[key] = vi.fn(); }); +const routePath = require.resolve("../../../src/routes/purchaseRequests"); +const savedRoute = require.cache[routePath]; delete require.cache[routePath]; const routes = require(routePath); +collaborators.forEach(([object, key], i) => { object[key] = saved[i]; }); +afterAll(() => { if (savedRoute) require.cache[routePath] = savedRoute; else delete require.cache[routePath]; }); +const ID = "11111111-1111-4111-8111-111111111111"; +const ACTOR = "22222222-2222-4222-8222-222222222222"; +const COMPANY = "33333333-3333-4333-8333-333333333333"; +const CENTER = "44444444-4444-4444-8444-444444444444"; +const OTHER = "55555555-5555-4555-8555-555555555555"; +const apps = []; +beforeEach(() => { + Object.values(mocks).forEach((fn) => fn.mockReset()); + mocks.parseWorkflowSteps.mockReturnValue([{ id: "synthetic-step" }]); + mocks.resolveActionableStep.mockResolvedValue({ actionable: { step: { id: "synthetic-step", name: "合成审批" }, assigneeId: OTHER, assigneeRole: null }, skipped: [] }); + mocks.resolveEntityPrecheck.mockResolvedValue({ decision: "pass", blockingReasons: [], riskFlags: [] }); + mocks.createRiskFlagsFromPrecheck.mockResolvedValue([]); mocks.resolveApplicableBudgetPoolByContext.mockResolvedValue({ id: OTHER }); + mocks.reserveBudgetForEntity.mockResolvedValue({ applied: true, reservedAmount: "12.50" }); mocks.releaseReservedBudgetIfAny.mockResolvedValue({ applied: true, items: [] }); + mocks.ensureCutoverAllowed.mockResolvedValue({}); mocks.resolveRecipientsForAssignee.mockResolvedValue([OTHER]); mocks.createNotificationsWithRecords.mockResolvedValue([]); + mocks.sendNotificationEmailSafe.mockResolvedValue({}); mocks.dispatchNotificationBatch.mockResolvedValue({}); +}); +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); +async function build({ allowed = true, actorId = ACTOR, budgetGate = true, projectGate = true, budgetMode = "warn" } = {}) { + const app = Fastify(); apps.push(app); + const current = { numbering_version: 1, id: ID, request_no: "SYNTHETIC", applicant_id: ACTOR, submitter_id: null, department_id: CENTER, legal_entity_id: COMPANY, cost_center_id: CENTER, supplier_id: OTHER, document_category_id: OTHER, budget_pool_id: null, project_id: null, title: "合成采购", currency_code: "CNY", total_amount: "12.50", status: "draft", items: [{ id: OTHER, description: "合成明细", quantity: "1.00", unit_price: "12.50", line_amount: "12.50" }], attachments: [], workflow_instances: [] }; + const document = { id: OTHER, code: "PR_STANDARD", entity_type: "purchase_request", enabled: true, archived_at: null }; + const model = (row) => ({ findUnique: vi.fn(async () => row), findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn(async () => [row]), count: vi.fn().mockResolvedValue(1), create: vi.fn(async ({ data }) => ({ ...row, ...data })), update: vi.fn(async ({ data }) => Object.assign(row, Object.fromEntries(Object.entries(data).filter(([,v]) => v !== undefined)))), updateMany: vi.fn().mockResolvedValue({ count: 1 }) }); + const prisma = { + purchaseRequest: model(current), legalEntity: model({ id: COMPANY, enabled: true }), costCenter: model({ id: CENTER, enabled: true, legal_entity_id: COMPANY }), + supplier: model({ id: OTHER, enabled: true, legal_entity_id: COMPANY }), documentCategory: model(document), budgetPool: model({ id: OTHER, status: "active" }), + project: model({ id: OTHER, status: "active", owner_employee_id: ACTOR, members: [] }), + purchaseRequestItem: { deleteMany: vi.fn().mockResolvedValue({}), createMany: vi.fn().mockResolvedValue({}), aggregate: vi.fn().mockResolvedValue({ _sum: { line_amount: "12.50" } }) }, + workflowDefinition: { findFirst: vi.fn().mockResolvedValue({ id: OTHER, definition_json: {} }) }, + workflowInstance: model({ id: OTHER }), workflowTask: model({ id: OTHER, assignee_id: OTHER, assignee_role: null }), workflowHistory: model({ id: OTHER }), + auditLog: model({ id: OTHER }), entityRiskFlag: { findMany: vi.fn().mockResolvedValue([]) }, + businessNumberPolicy: { findUnique: vi.fn(async () => null) }, + $executeRaw: vi.fn(async () => 0), + documentSequence: { findUnique: vi.fn().mockResolvedValue(null), upsert: vi.fn().mockResolvedValue({ current_number: 1 }) }, + purchaseOrderArchive: { findUnique: vi.fn().mockResolvedValue(null), upsert: vi.fn(async ({ create }) => ({ id: OTHER, ...create })) }, + purchaseAttachment: model({ id: OTHER }), + featureFlag: { findUnique: vi.fn(async ({ where }) => ({ flag_key: where.flag_key, override_mode: (where.flag_key === "project_linking_v1" ? projectGate : where.flag_key === "budget_precheck_v1" ? budgetGate : false) ? "force_on" : "force_off", rollout_type: "global", department_ids: [], version: 1 })) }, + $queryRaw: vi.fn(async () => [{ ...current }]), + }; + prisma.$transaction = vi.fn(async (cb) => cb(prisma)); + const storage = { writeBuffer: vi.fn().mockResolvedValue("synthetic-object"), openReadStream: vi.fn() }; + app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", budgetControlMode: budgetMode, projectLinkingEnabled: true, budgetPrecheckEnabled: true, maxUploadSizeBytes: 1024 }); + app.decorate("authenticate", async (request) => { request.employee = { id: actorId, department_id: CENTER, permissionCodes: allowed ? ["*"] : [] }; }); + app.decorate("hasPermission", () => allowed); + app.decorate("requirePermission", () => async (request) => { await app.authenticate(request); if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); }); + app.setErrorHandler((error, _, reply) => reply.status(error instanceof AppError ? error.statusCode : 500).send({ error: { code: error.code, details: error.details ?? {} } })); + await app.register(multipart, { limits: { files: 1, fileSize: 1024 } }); + await app.register(routes, { prefix: "/api", attachmentStorage: storage }); + return { app, prisma, current, storage }; +} +const req = (method, suffix = "", payload) => ({ method, url: `/api/purchase-requests${suffix}`, payload }); +function rejected(r, reason, status, details = {}) { expect(r.statusCode).toBe(status); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: reason, ...details } }); } +function noWrites(prisma) { + for (const [name, model] of Object.entries(prisma)) for (const op of ["create", "update", "updateMany", "createMany", "deleteMany", "upsert"]) if (model[op]) expect(model[op], `${name}.${op}`).not.toHaveBeenCalled(); + expect(mocks.reserveBudgetForEntity).not.toHaveBeenCalled(); expect(mocks.releaseReservedBudgetIfAny).not.toHaveBeenCalled(); + expect(mocks.createNotificationsWithRecords).not.toHaveBeenCalled(); expect(mocks.sendNotificationEmailSafe).not.toHaveBeenCalled(); expect(mocks.dispatchNotificationBatch).not.toHaveBeenCalled(); +} + +describe("purchase input, references and state reasons", () => { + it.each([["GET", "?page=0", undefined, "query_invalid"], ["PATCH", `/${ID}`, { expectedDeliveryDate: "2026-02-30" }, "update_input_invalid"], ["POST", `/${ID}/archive-order`, {}, "archive_input_invalid"]])("explains %s %s input", async (method, suffix, payload, reason) => { const { app, prisma } = await build(); rejected(await app.inject(req(method, suffix, payload)), `purchase_request.${reason}`, 400, { fieldErrors: expect.any(Object) }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each([["GET", ""], ["PATCH", ""], ["POST", "/precheck"], ["POST", "/submit"], ["POST", "/withdraw"], ["POST", "/archive-order"], ["POST", "/attachments"]])("explains %s invalid request id %s", async (method, suffix) => { const { app, prisma } = await build(); rejected(await app.inject(req(method, `/bad${suffix}`, method === "GET" ? undefined : {})), "purchase_request.id_invalid", 400, { id: "invalid_uuid" }); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.purchaseRequest.findUnique).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each(["DELETE", "POST"])("explains %s invalid attachment id", async (method) => { const { app, prisma } = await build(); rejected(await app.inject(req(method, `/${ID}/attachments/bad${method === "POST" ? "/download" : ""}`, {})), "purchase_attachment.id_invalid", 400, { attachmentId: "invalid_uuid" }); expect(prisma.purchaseRequest.findUnique).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each([ + ["legalEntity", "legalEntityId", null, "company_not_found", "not_found"], ["legalEntity", "legalEntityId", { enabled: false }, "company_inactive", "disabled_or_archived"], + ["costCenter", "costCenterId", null, "cost_center_not_found", "not_found"], ["costCenter", "costCenterId", { enabled: true, archived_at: new Date("2026-01-01") }, "cost_center_inactive", "disabled_or_archived"], ["costCenter", "costCenterId", { enabled: true, legal_entity_id: OTHER }, "cost_center_company_mismatch", "legalEntityId_mismatch"], + ["supplier", "supplierId", null, "supplier_not_found", "not_found"], ["supplier", "supplierId", { enabled: false }, "supplier_inactive", "disabled_or_archived"], ["supplier", "supplierId", { enabled: true, legal_entity_id: OTHER }, "supplier_company_mismatch", "legalEntityId_mismatch"], + ["documentCategory", "documentCategoryId", null, "document_category_not_found", "not_found"], ["documentCategory", "documentCategoryId", { enabled: true, entity_type: "expense" }, "document_category_type_invalid", "entity_type_mismatch"], ["documentCategory", "documentCategoryId", { enabled: false, entity_type: "purchase_request" }, "document_category_inactive", "disabled_or_archived"], + ["budgetPool", "budgetPoolId", null, "budget_pool_not_found", "not_found"], ["budgetPool", "budgetPoolId", { status: "inactive" }, "budget_pool_inactive", "disabled_or_archived"], + ])("explains %s reference %s", async (model, field, row, reason, detail) => { const { app, prisma } = await build(); prisma[model].findUnique.mockResolvedValue(row); rejected(await app.inject(req("PATCH", `/${ID}`, { [field]: OTHER })), `purchase_request.${reason}`, 409, { [field]: detail }); noWrites(prisma); }); + it.each([[false, "active", "project_link_disabled"], [true, "archived", "project_archived"]])("explains project gate %s and status %s", async (projectGate, status, reason) => { const { app, prisma } = await build({ projectGate }); prisma.project.findUnique.mockResolvedValue({ id: OTHER, status, owner_employee_id: ACTOR }); rejected(await app.inject(req("PATCH", `/${ID}`, { projectId: OTHER })), `purchase_request.${reason}`, 409); noWrites(prisma); if (!projectGate) expect(prisma.project.findUnique).not.toHaveBeenCalled(); }); + it.each([["PATCH", "", "reviewing", {}, "not_editable", "only_draft_editable"], ["POST", "/submit", "approved", undefined, "not_submittable", "only_draft_submittable"], ["POST", "/withdraw", "draft", undefined, "not_withdrawable", "only_reviewing_withdrawable"], ["POST", "/archive-order", "draft", { poNo: "SYNTHETIC" }, "not_archivable", "only_approved_archivable"]])("explains %s %s state %s", async (method, suffix, status, payload, reason, detail) => { const { app, prisma, current } = await build(); current.status = status; rejected(await app.inject(req(method, `/${ID}${suffix}`, payload)), `purchase_request.${reason}`, 409, { status: detail }); noWrites(prisma); }); + it("preserves permissions and applicant ownership before writes", async () => { + const denied = await build({ allowed: false }); expect((await denied.app.inject(req("PATCH", "/bad", {}))).statusCode).toBe(403); noWrites(denied.prisma); + const other = await build({ actorId: OTHER }); expect((await other.app.inject(req("PATCH", `/${ID}`, {}))).statusCode).toBe(403); noWrites(other.prisma); + }); + it("retains empty PATCH, supplier without company binding and explicit null clearing", async () => { + const { app, prisma, current } = await build(); + expect((await app.inject(req("PATCH", `/${ID}`, {}))).statusCode).toBe(200); + prisma.supplier.findUnique.mockResolvedValue({ id: OTHER, enabled: true, legal_entity_id: null }); + expect((await app.inject(req("PATCH", `/${ID}`, { supplierId: OTHER, projectId: null, expectedDeliveryDate: null, items: [] }))).statusCode).toBe(200); + expect(current).toMatchObject({ project_id: null, expected_delivery_date: null, total_amount: "0.00" }); expect(prisma.purchaseRequestItem.deleteMany).toHaveBeenCalledOnce(); expect(prisma.purchaseRequestItem.createMany).not.toHaveBeenCalled(); + }); + it.each([["quantity", "9".repeat(400), "quantity_invalid", "invalid_decimal"], ["unitPrice", "9".repeat(400), "unit_price_invalid", "invalid_decimal"], ["quantity", "0", "quantity_too_small", "must_be_positive"], ["quantity", 0.001, "quantity_too_small", "must_be_positive"]])("preserves prior update/delete before numeric rejection %s", async (field, value, reason, detail) => { + const { app, prisma } = await build(); const r = await app.inject(req("PATCH", `/${ID}`, { items: [{ description: "合成明细", quantity: "1", unitPrice: "1", [field]: value }] })); + rejected(r, `purchase_request.${reason}`, 400, { [field]: detail }); expect(prisma.purchaseRequest.update).toHaveBeenCalledOnce(); expect(prisma.purchaseRequestItem.deleteMany).toHaveBeenCalledOnce(); expect(prisma.purchaseRequestItem.createMany).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + it("keeps negative unit price at schema and valid decimal boundaries", async () => { + const { app, prisma } = await build(); rejected(await app.inject(req("PATCH", `/${ID}`, { items: [{ description: "合成", quantity: "1", unitPrice: -1 }] })), "purchase_request.update_input_invalid", 400); noWrites(prisma); + expect((await app.inject(req("PATCH", `/${ID}`, { items: [{ description: " 合成 ", quantity: "0.01", unitPrice: "0" }] }))).statusCode).toBe(200); + expect(prisma.purchaseRequestItem.createMany.mock.calls[0][0].data[0]).toMatchObject({ description: "合成", quantity: "0.01", unit_price: "0.00", line_amount: "0.00" }); + }); +}); + +describe("purchase submission error contracts", () => { + it("rechecks the authoritative budget inside the locked submit transaction before workflow writes", async () => { + const { app, prisma } = await build(); + mocks.resolveEntityPrecheck.mockResolvedValue({ decision: "block", blockingReasons: ["synthetic_block"] }); + rejected(await app.inject(req("POST", `/${ID}/submit`)), "purchase_request.precheck_blocked", 409, { precheck: ["synthetic_block"] }); + expect(prisma.$transaction).toHaveBeenCalledOnce(); + expect(mocks.resolveApplicableBudgetPoolByContext.mock.invocationCallOrder[0]).toBeLessThan(mocks.resolveEntityPrecheck.mock.invocationCallOrder[0]); + expect(prisma.purchaseRequest.update).toHaveBeenCalledOnce(); + expect(prisma.purchaseRequest.update.mock.calls[0][0].data).toMatchObject({ total_amount: "12.50" }); + expect(prisma.workflowInstance.create).not.toHaveBeenCalled(); + expect(mocks.reserveBudgetForEntity).not.toHaveBeenCalled(); + expect(mocks.createRiskFlagsFromPrecheck).not.toHaveBeenCalled(); + }); + it.each(["workflow_missing", "approver_unavailable"])("explains %s before transaction", async (reason) => { const { app, prisma } = await build(); if (reason === "workflow_missing") prisma.workflowDefinition.findFirst.mockResolvedValue(null); else mocks.resolveActionableStep.mockResolvedValue({ actionable: null, skipped: [] }); rejected(await app.inject(req("POST", `/${ID}/submit`)), `purchase_request.${reason}`, 409, { workflow: reason === "workflow_missing" ? "missing_definition" : "no_actionable_approver" }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each([["legal_entity_id", "legalEntityId", "company_required"], ["cost_center_id", "costCenterId", "cost_center_required"], ["supplier_id", "supplierId", "supplier_required"], ["document_category_id", "documentCategoryId", "document_category_required"], ["items", "items", "items_required"]])("requires %s before recomputation", async (field, detail, reason) => { const { app, prisma, current } = await build(); current[field] = field === "items" ? [] : null; rejected(await app.inject(req("POST", `/${ID}/submit`)), `purchase_request.${reason}`, 400, { [detail]: field === "items" ? "at_least_one_required" : "required" }); noWrites(prisma); }); + it("retains concurrent applicant recheck", async () => { + const { app, prisma, current } = await build(); prisma.purchaseRequest.findUnique.mockResolvedValueOnce({ ...current, applicant_id: OTHER }).mockResolvedValueOnce(current); + rejected(await app.inject(req("POST", `/${ID}/submit`)), "purchase_request.approver_unavailable", 409, { workflow: "no_actionable_approver" }); noWrites(prisma); + }); + it.each(["mismatch", "missing"])("recalculates then validates pool identity before budget %s rejection", async (kind) => { + const { app, prisma, current } = await build({ budgetGate: false, budgetMode: "block" }); + if (kind === "mismatch") current.budget_pool_id = OTHER; + mocks.resolveApplicableBudgetPoolByContext.mockResolvedValue(null); + rejected(await app.inject(req("POST", `/${ID}/submit`)), `purchase_request.${kind === "mismatch" ? "budget_context_mismatch" : "budget_required"}`, 409, kind === "mismatch" ? { budgetPoolId: "context_mismatch" } : { budget: "missing_budget_pool" }); + expect(prisma.purchaseRequest.update).toHaveBeenCalledOnce(); expect(prisma.purchaseRequest.update.mock.calls[0][0].data).toMatchObject({ total_amount: "12.50" }); expect(mocks.createRiskFlagsFromPrecheck).toHaveBeenCalledTimes(kind === "mismatch" ? 0 : 1); + expect(mocks.reserveBudgetForEntity).not.toHaveBeenCalled(); expect(prisma.workflowInstance.create).not.toHaveBeenCalled(); expect(prisma.documentSequence.upsert).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); expect(mocks.createNotificationsWithRecords).not.toHaveBeenCalled(); + }); + it("retains synthetic cutover refusal before business mutations", async () => { const { app, prisma } = await build(); mocks.ensureCutoverAllowed.mockRejectedValue(new AppError({ code: "VALID_001", statusCode: 409, message: "合成切换拒绝", details: { cutover: "synthetic_block" } })); const r = await app.inject(req("POST", `/${ID}/submit`)); expect(r.statusCode).toBe(409); expect(r.json().error.details.cutover).toBe("synthetic_block"); noWrites(prisma); }); + it("retains submit/withdraw with budget and notification stubs", async () => { + const { app, prisma, current } = await build(); const r = await app.inject(req("POST", `/${ID}/submit`)); expect(r.statusCode).toBe(200); expect(current.status).toBe("reviewing"); expect(mocks.reserveBudgetForEntity).toHaveBeenCalledOnce(); expect(prisma.workflowInstance.create).toHaveBeenCalledOnce(); expect(prisma.workflowTask.create).toHaveBeenCalledOnce(); expect(mocks.createNotificationsWithRecords).toHaveBeenCalledOnce(); + prisma.workflowInstance.findFirst.mockResolvedValue({ id: OTHER }); expect((await app.inject(req("POST", `/${ID}/withdraw`))).statusCode).toBe(200); expect(current.status).toBe("draft"); expect(mocks.releaseReservedBudgetIfAny).toHaveBeenCalledOnce(); expect(prisma.workflowTask.updateMany).toHaveBeenCalledOnce(); + }); + it("retains blank draft creation and approved order archive", async () => { + const { app, prisma, current } = await build(); expect((await app.inject(req("POST", ""))).statusCode).toBe(200); expect(prisma.purchaseRequest.create.mock.calls[0][0].data).toMatchObject({ title: "", status: "draft", total_amount: "0.00" }); + current.status = "approved"; const r = await app.inject(req("POST", `/${ID}/archive-order`, { poNo: "SYNTHETIC", contractNo: null, archivedFiles: [] })); expect(r.statusCode).toBe(200); expect(prisma.purchaseOrderArchive.upsert).toHaveBeenCalledOnce(); expect(prisma.auditLog.create.mock.calls.map(([x]) => x.data.action)).toContain("PURCHASE_ORDER_ARCHIVED"); + }); +}); + +describe("purchase attachment policy-to-route mapping", () => { + it.each([["dangerous_filename", "dangerous_filename"], ["corrupt_content", "corrupt_content"], ["multiple_pages", "multiple_pages"], ["pixel_limit", "pixel_limit"], ["type_mismatch", "type_mismatch"], ["empty_file", "empty_file"], ["size_limit", "too_large"], ["unsupported_type", "unsupported_type"], ["future_synthetic_policy", "validation_failed"]])("preserves explicit policy %s and safe mapping", async (policyReason, reason) => { + const { app, prisma, storage } = await build(); mocks.validateAndNormalizePurchaseAttachment.mockRejectedValue(new policy.PurchaseAttachmentPolicyError(policyReason)); + const r = await app.inject({ method: "POST", url: `/api/purchase-requests/${ID}/attachments`, headers: { "content-type": "multipart/form-data; boundary=synthetic-policy" }, payload: Buffer.from('--synthetic-policy\r\nContent-Disposition: form-data; name="file"; filename="synthetic.pdf"\r\nContent-Type: application/pdf\r\n\r\nsynthetic\r\n--synthetic-policy--\r\n') }); + rejected(r, `purchase_attachment.${reason}`, 400, { file: policyReason }); expect(mocks.validateAndNormalizePurchaseAttachment).toHaveBeenCalledOnce(); expect(storage.writeBuffer).not.toHaveBeenCalled(); noWrites(prisma); + }); +}); + +describe("purchase draft write transaction boundary", () => { + it("commits draft and audit before hydrating the unchanged detail response", async () => { + const { app, prisma, current } = await build(); + const events = []; + let committed = false; + const tx = { + businessNumberPolicy: { findUnique: vi.fn(async () => null) }, + $executeRaw: vi.fn(async () => 0), + purchaseRequest: { create: vi.fn(async ({ data, select }) => { + events.push("draft"); + expect(Object.keys(select).sort()).toEqual(["applicant_id", "department_id", "id"]); + Object.assign(current, data, { items: [], applicant: { id: ACTOR, name: "合成申请人", employee_no: "SYNTHETIC" } }); + return { id: ID, applicant_id: ACTOR, department_id: CENTER }; + }) }, + auditLog: { create: vi.fn(async () => { events.push("audit"); return {}; }) }, + }; + prisma.$transaction.mockImplementation(async cb => { + events.push("begin"); const result = await cb(tx); committed = true; events.push("commit"); return result; + }); + prisma.purchaseRequest.findUnique.mockImplementation(async ({ where, select }) => { + expect(committed).toBe(true); expect(where).toEqual({ id: ID }); expect(select.items).toBeTruthy(); + events.push("detail"); return current; + }); + const response = await app.inject(req("POST")); + expect(response.statusCode).toBe(200); + expect(response.json().data.purchaseRequest).toMatchObject({ id: ID, status: "draft", title: "", totalAmount: "0.00", items: [], applicant: { id: ACTOR, name: "合成申请人" } }); + expect(events).toEqual(["begin", "draft", "audit", "commit", "detail"]); + expect(tx.auditLog.create.mock.calls[0][0].data).toMatchObject({ action: "PURCHASE_REQUEST_CREATED", target_id: ID, actor_id: ACTOR }); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it.each(["draft", "audit", "commit"])("does not read or return success when %s fails", async phase => { + const { app, prisma } = await build(); + const failure = new Error("synthetic transaction failure"); + const create = vi.fn(async () => { if (phase === "draft") throw failure; return { id: ID, applicant_id: ACTOR, department_id: CENTER }; }); + const audit = vi.fn(async () => { if (phase === "audit") throw failure; return {}; }); + prisma.$transaction.mockImplementation(async cb => { + const result = await cb({ purchaseRequest: { create }, auditLog: { create: audit } }); + if (phase === "commit") throw failure; return result; + }); + expect((await app.inject(req("POST"))).statusCode).toBe(500); + expect(prisma.purchaseRequest.findUnique).not.toHaveBeenCalled(); + if (phase === "draft") expect(audit).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + +describe("purchase draft PATCH transaction boundary", () => { + async function boundary(failure) { + const { app, prisma, current } = await build(); + const events = []; + let active = false; + let committed = false; + const operation = (name, run) => vi.fn(async (...args) => { + expect(active, `${name} must remain transactional`).toBe(true); + events.push(name); + if (failure === name) throw new Error(`synthetic ${name} failure`); + return run(...args); + }); + const tx = { + ...prisma, + $queryRaw: operation("lock", async parts => { + expect(parts.join("?")).toContain("FOR UPDATE"); + return [{ ...current }]; + }), + purchaseRequest: { + update: operation("update", async ({ data }) => Object.assign(current, + Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined)))), + findUnique: operation("transactional-detail", async () => { + throw new Error("response hydration must not consume the write transaction"); + }), + }, + purchaseRequestItem: { + deleteMany: operation("items-delete", async () => { current.items = []; return { count: 1 }; }), + createMany: operation("items-create", async ({ data }) => { + current.items = data.map(item => ({ id: OTHER, ...item })); + return { count: data.length }; + }), + }, + auditLog: { create: operation("audit", async () => ({})) }, + }; + prisma.$transaction.mockImplementation(async cb => { + events.push("begin"); active = true; + try { + const result = await cb(tx); + if (failure === "commit") throw new Error("synthetic commit failure"); + committed = true; events.push("commit"); return result; + } finally { active = false; } + }); + prisma.purchaseRequest.findUnique.mockImplementation(async ({ where, select }) => { + expect(committed).toBe(true); expect(active).toBe(false); + expect(where).toEqual({ id: ID }); + expect(select).toMatchObject({ items: expect.any(Object), attachments: expect.any(Object) }); + events.push("detail"); + if (failure === "detail") throw new Error("synthetic detail failure"); + return current; + }); + return { app, prisma, tx, events, committed: () => committed }; + } + + const item = { description: "合成替换明细", quantity: "2", unitPrice: "7.50" }; + it.each([undefined, [], [item]])("hydrates saved draft only after writes and audit commit: items=%j", async items => { + const { app, prisma, tx, events } = await boundary(); + const response = await app.inject(req("PATCH", `/${ID}`, { + title: "合成已保存采购", legalEntityId: COMPANY, costCenterId: CENTER, + supplierId: OTHER, documentCategoryId: OTHER, ...(items === undefined ? {} : { items }), + })); + expect(response.statusCode).toBe(200); + const saved = response.json().data.purchaseRequest; + expect(saved).toMatchObject({ id: ID, title: "合成已保存采购", status: "draft", + totalAmount: items === undefined ? "12.50" : items.length ? "15.00" : "0.00" }); + expect(saved.items).toHaveLength(items === undefined ? 1 : items.length); + if (items?.length) expect(saved.items[0].description).toBe(item.description); + expect(events).toEqual(["begin", "lock", "update", + ...(items === undefined ? [] : ["items-delete", ...(items.length ? ["items-create"] : [])]), + "update", "audit", "commit", "detail"]); + expect(tx.auditLog.create.mock.calls[0][0].data).toMatchObject({ action: "PURCHASE_REQUEST_UPDATED", target_id: ID, actor_id: ACTOR }); + expect(tx.purchaseRequest.findUnique).not.toHaveBeenCalled(); + expect(prisma.purchaseRequest.update).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it.each(["update", "items-delete", "items-create", "audit", "commit", "detail"])("never returns success or retries on %s failure", async phase => { + const { app, prisma, tx, events, committed } = await boundary(phase); + const response = await app.inject(req("PATCH", `/${ID}`, { title: "合成修改", items: [item] })); + expect(response.statusCode).toBe(500); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.purchaseRequest.findUnique).toHaveBeenCalledTimes(phase === "detail" ? 1 : 0); + expect(committed()).toBe(phase === "detail"); + if (["update", "items-delete", "items-create"].includes(phase)) expect(tx.auditLog.create).not.toHaveBeenCalled(); + expect(events.includes("commit")).toBe(phase === "detail"); + expect(prisma.purchaseRequest.update).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + +it("pins active policy on draft creation without allocating a purchase number", async () => { + const { app, prisma } = await build(); enableNumbering(prisma); + const response = await app.inject(req("POST")); + expect(response.statusCode).toBe(200); + expect(prisma.purchaseRequest.create.mock.calls[0][0].data.numbering_version).toBe(2); + expect(prisma.businessNumberCounter.upsert).not.toHaveBeenCalled(); +}); diff --git a/server/tests/unit/routes/purchase.archive-erp.test.js b/server/tests/unit/routes/purchase.archive-erp.test.js deleted file mode 100644 index 929edc3b..00000000 --- a/server/tests/unit/routes/purchase.archive-erp.test.js +++ /dev/null @@ -1,130 +0,0 @@ -const purchaseRequestRoutes = require('../../../src/routes/purchaseRequests'); - -describe('purchase archive ERP helper', () => { - const { handlePurchaseArchiveErpSync } = purchaseRequestRoutes.__testables; - - function createRequest() { - return { - id: 'req-1', - ip: '127.0.0.1', - headers: {}, - employee: { - id: 'actor-1', - department_id: 'dept-1', - permissionCodes: ['procurement.manage'], - }, - }; - } - - function createTx() { - return { - auditLog: { - create: vi.fn().mockResolvedValue({ id: 'audit-1' }), - }, - }; - } - - function createContext() { - return { - purchaseRequest: { - id: 'pr-1', - department_id: 'dept-1', - order_archive: { - id: 'archive-1', - po_no: 'PO-001', - contract_no: 'CT-001', - archived_at: new Date('2026-03-08T10:30:00.000Z'), - }, - }, - }; - } - - it('first archive enqueues accrual and summary', async () => { - const tx = createTx(); - const loadContext = vi.fn().mockResolvedValue(createContext()); - const evaluateGate = vi.fn().mockResolvedValue({ - enabled: true, - reason: 'env_inherit_department_allowed', - }); - const enqueueAccrual = vi.fn().mockResolvedValue({ - id: 'outbox-accrual', - status: 'pending', - duplicated: false, - requeued: false, - eventType: 'erp.journal.export', - erpTarget: 'accrual_journal', - }); - const enqueueSummary = vi.fn().mockResolvedValue({ - id: 'outbox-summary', - status: 'pending', - duplicated: false, - requeued: false, - eventType: 'erp.procurement.export', - erpTarget: 'archive_summary', - }); - - const result = await handlePurchaseArchiveErpSync({ - tx, - config: {}, - request: createRequest(), - actorId: 'actor-1', - purchaseRequestId: 'pr-1', - archiveId: 'archive-1', - isFirstArchive: true, - loadContext, - evaluateGate, - enqueueAccrual, - enqueueSummary, - }); - - expect(enqueueAccrual).toHaveBeenCalledWith( - tx, - {}, - expect.objectContaining({ requeueOnFailure: false }), - ); - expect(enqueueSummary).toHaveBeenCalledWith( - tx, - {}, - expect.objectContaining({ requeueOnFailure: false }), - ); - expect(result.items).toHaveLength(2); - expect(tx.auditLog.create).toHaveBeenCalledTimes(2); - }); - - it('archive update only re-enqueues summary', async () => { - const tx = createTx(); - const loadContext = vi.fn().mockResolvedValue(createContext()); - const evaluateGate = vi.fn().mockResolvedValue({ - enabled: true, - reason: 'env_inherit_department_allowed', - }); - const enqueueAccrual = vi.fn(); - const enqueueSummary = vi.fn().mockResolvedValue({ - id: 'outbox-summary', - status: 'pending', - duplicated: false, - requeued: true, - eventType: 'erp.procurement.export', - erpTarget: 'archive_summary', - }); - - const result = await handlePurchaseArchiveErpSync({ - tx, - config: {}, - request: createRequest(), - actorId: 'actor-1', - purchaseRequestId: 'pr-1', - archiveId: 'archive-1', - isFirstArchive: false, - loadContext, - evaluateGate, - enqueueAccrual, - enqueueSummary, - }); - - expect(enqueueAccrual).not.toHaveBeenCalled(); - expect(enqueueSummary).toHaveBeenCalled(); - expect(result.items).toHaveLength(1); - expect(result.items[0].erpTarget).toBe('archive_summary'); - }); -}); diff --git a/server/tests/unit/routes/purchase.attachments.test.js b/server/tests/unit/routes/purchase.attachments.test.js index ee087bf4..13f33674 100644 --- a/server/tests/unit/routes/purchase.attachments.test.js +++ b/server/tests/unit/routes/purchase.attachments.test.js @@ -90,6 +90,7 @@ async function buildApp({ maxUploadSizeBytes = 1024 * 1024, storageOverrides = {}, auditCreate = null, + multipartLimits = {}, } = {}) { const effectiveActor = actor ?? { id: EMPLOYEE_ID, @@ -207,7 +208,7 @@ async function buildApp({ await app.register(multipart, { attachFieldsToBody: false, - limits: { files: 1, fileSize: maxUploadSizeBytes }, + limits: { files: 1, fileSize: maxUploadSizeBytes, ...multipartLimits }, }); await app.register(purchaseRequestRoutes, { prefix: "/api", @@ -361,7 +362,7 @@ describe("purchase attachment upload", () => { expect(response.statusCode).toBe(400); expect(response.json().error.code).toBe("VALID_001"); if (expectedDetails) { - expect(response.json().error.details).toEqual(expectedDetails); + expect(response.json().error.details).toEqual({ ...expectedDetails, reasonCode: ({ "non multipart": "purchase_attachment.multipart_required", "wrong file field": "purchase_attachment.field_invalid", "path-shaped filename": "purchase_attachment.dangerous_filename", "empty content": "purchase_attachment.empty_file" })[_label] }); } expect(storage.writeBuffer).not.toHaveBeenCalled(); expect(attachmentCreate).not.toHaveBeenCalled(); @@ -396,7 +397,7 @@ describe("purchase attachment upload", () => { }); expect(response.statusCode).toBe(400); - expect(response.json().error.details).toEqual({ file: "only_single_file_supported" }); + expect(response.json().error.details).toEqual({ file: "only_single_file_supported", reasonCode: "purchase_attachment.single_file_required" }); expect(storage.writeBuffer).not.toHaveBeenCalled(); expect(attachmentCreate).not.toHaveBeenCalled(); } finally { @@ -422,7 +423,7 @@ describe("purchase attachment upload", () => { }); expect(response.statusCode).toBe(400); - expect(response.json().error.details).toEqual({ file: "file_too_large" }); + expect(response.json().error.details).toEqual({ file: "file_too_large", reasonCode: "purchase_attachment.too_large" }); expect(storage.writeBuffer).not.toHaveBeenCalled(); expect(attachmentCreate).not.toHaveBeenCalled(); } finally { @@ -600,7 +601,7 @@ describe("purchase attachment authenticated download", () => { payload: {}, }); expect(response.statusCode).toBe(400); - expect(response.json().error.details).toEqual({ reason: "required" }); + expect(response.json().error.details).toEqual({ reason: "required", reasonCode: "purchase_attachment.download_reason_required" }); expect(storage.openReadStream).not.toHaveBeenCalled(); } finally { await app.close(); @@ -654,6 +655,7 @@ describe("purchase attachment authenticated download", () => { payload: {}, }); expect(response.statusCode).toBe(409); + expect(response.json().error).toMatchObject({ message: "附件已隔离,无法下载", details: { reason: "quarantined", attachment: "security_validation_failed", reasonCode: "purchase_attachment.download_blocked" } }); expect(storage.openReadStream).not.toHaveBeenCalled(); expect(downloadAuditCreate).toHaveBeenCalledWith( expect.objectContaining({ @@ -682,7 +684,7 @@ describe("purchase attachment authenticated download", () => { payload: {}, }); expect(response.statusCode).toBe(409); - expect(response.json().error.details.reason).toBe("size_limit"); + expect(response.json().error).toMatchObject({ message: "附件未通过安全校验", details: { reason: "size_limit", attachment: "security_validation_failed", reasonCode: "purchase_attachment.download_blocked" } }); expect(storage.openReadStream).not.toHaveBeenCalled(); } finally { await app.close(); @@ -746,7 +748,7 @@ describe("purchase attachment authenticated download", () => { payload: {}, }); expect(response.statusCode).toBe(409); - expect(response.json().error.details.reason).toBe("size_limit"); + expect(response.json().error).toMatchObject({ message: "附件未通过安全校验", details: { reason: "size_limit", attachment: "security_validation_failed", reasonCode: "purchase_attachment.download_blocked" } }); expect(oversizedStream.destroy).toHaveBeenCalledTimes(1); expect(storage.openReadStream).toHaveBeenCalledTimes(1); } finally { @@ -771,6 +773,7 @@ describe("purchase attachment authenticated download", () => { payload: {}, }); expect(response.statusCode).toBe(409); + expect(response.json().error).toMatchObject({ message: "附件未通过安全校验", details: { attachment: "security_validation_failed", reasonCode: "purchase_attachment.download_blocked" } }); expect(response.headers["content-disposition"]).toBeUndefined(); expect(downloadAuditCreate).toHaveBeenCalledWith( expect.objectContaining({ @@ -871,3 +874,39 @@ describe("purchase attachment authenticated download", () => { } }); }); + + +describe("purchase attachment additional reason boundaries", () => { + it.each([ + ["field-only upload", {}, [{ type: "field", name: "attachmentType", value: "quote" }], "file_required", "required"], + ["route single-file guard", { files: 2 }, Array.from({ length: 2 }, () => ({ type: "file", filename: "合成.pdf", contentType: "application/pdf", content: VALID_PDF })), "single_file_required", "only_single_file_supported"], + ["fields limit", { fields: 1 }, [{ type: "field", name: "attachmentType", value: "quote" }, { type: "field", name: "sensitivityLevel", value: "internal" }], "multipart_limit", "multipart_limit_exceeded"], + ["parts limit", { parts: 1 }, [{ type: "field", name: "attachmentType", value: "quote" }, { type: "file", filename: "合成.pdf", contentType: "application/pdf", content: VALID_PDF }], "multipart_limit", "multipart_limit_exceeded"], + ])("explains %s before storage or mutation", async (_, multipartLimits, parts, reason, file) => { + const { app, storage, attachmentCreate, uploadAuditCreate } = await buildApp({ multipartLimits }); + try { + const r = await app.inject({ method: "POST", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`, ...buildMultipartRequest(parts) }); + expect(r.statusCode).toBe(400); expect(r.json().error.details).toEqual({ file, reasonCode: `purchase_attachment.${reason}` }); + expect(storage.writeBuffer).not.toHaveBeenCalled(); expect(attachmentCreate).not.toHaveBeenCalled(); expect(uploadAuditCreate).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it.each([ + ["DELETE", {}, "delete_input_invalid"], + ["POST", { reason: 123 }, "download_input_invalid"], + ])("explains %s attachment body before lookup", async (method, payload, reason) => { + const { app, prisma, storage, attachmentDelete } = await buildApp(); + try { + const r = await app.inject({ method, url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}${method === "POST" ? "/download" : ""}`, headers: { authorization: "Bearer unit-test" }, payload }); + expect(r.statusCode).toBe(400); expect(r.json().error.details).toMatchObject({ reasonCode: `purchase_attachment.${reason}`, fieldErrors: expect.any(Object) }); + expect(prisma.purchaseRequest.findUnique).not.toHaveBeenCalled(); expect(prisma.purchaseAttachment.findFirst).not.toHaveBeenCalled(); expect(storage.openReadStream).not.toHaveBeenCalled(); expect(attachmentDelete).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it("explains non-draft delete before attachment lookup", async () => { + const { app, prisma, attachmentDelete, uploadAuditCreate } = await buildApp({ purchaseRequest: buildPurchaseRequest({ status: "approved" }) }); + try { + const r = await app.inject({ method: "DELETE", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}`, headers: { authorization: "Bearer unit-test" }, payload: { reason: "合成删除" } }); + expect(r.statusCode).toBe(409); expect(r.json().error.details).toEqual({ status: "only_draft_editable", reasonCode: "purchase_attachment.not_editable" }); + expect(prisma.purchaseAttachment.findFirst).not.toHaveBeenCalled(); expect(attachmentDelete).not.toHaveBeenCalled(); expect(uploadAuditCreate).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/purchase.project-link.test.js b/server/tests/unit/routes/purchase.project-link.test.js index ad72653b..82ed2c25 100644 --- a/server/tests/unit/routes/purchase.project-link.test.js +++ b/server/tests/unit/routes/purchase.project-link.test.js @@ -248,7 +248,7 @@ describe("purchase request project link", () => { }); expect(response.statusCode).toBe(409); - expect(response.json().error.details).toEqual({ projectId: "archived" }); + expect(response.json().error.details).toEqual({ projectId: "archived", reasonCode: "purchase_request.project_archived" }); expect(tx.purchaseRequest.update).not.toHaveBeenCalled(); await app.close(); @@ -368,3 +368,26 @@ describe("purchase request project link", () => { await app.close(); }); }); + + +describe("purchase delivery calendar input", () => { + it.each(["2026-02-29", "2026-04-31", "2026-13-01", ""])("rejects %s before the transaction", async (date) => { + const { app, tx, state } = await buildApp(); + try { + const response = await app.inject({ method: "PATCH", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, payload: { expectedDeliveryDate: date } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details.fieldErrors.expectedDeliveryDate.length).toBeGreaterThan(0); + expect(app.prisma.$transaction).not.toHaveBeenCalled(); + expect(tx.purchaseRequest.update).not.toHaveBeenCalled(); + expect(state.auditEntries).toEqual([]); + } finally { await app.close(); } + }); + it.each(["2024-02-29", "2026-04-30", null, undefined])("keeps valid or nullable/optional delivery date %s", async (date) => { + const { app, tx } = await buildApp(); + try { + const response = await app.inject({ method: "PATCH", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}`, payload: { title: "合成修改", ...(date === undefined ? {} : { expectedDeliveryDate: date }) } }); + expect(response.statusCode).toBe(200); + expect(tx.purchaseRequest.update.mock.calls[0][0].data.expected_delivery_date).toEqual(date === undefined ? undefined : date === null ? null : new Date(`${date}T00:00:00.000Z`)); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/purchase.submit-budget-precheck.test.js b/server/tests/unit/routes/purchase.submit-budget-precheck.test.js index 3fea1d88..49e7ee21 100644 --- a/server/tests/unit/routes/purchase.submit-budget-precheck.test.js +++ b/server/tests/unit/routes/purchase.submit-budget-precheck.test.js @@ -1,3 +1,4 @@ +const { enableNumbering } = require("../helpers/activeNumbering"); const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); @@ -64,8 +65,9 @@ async function buildApp(options = {}) { cost_center_id: COST_CENTER_ID, supplier_id: SUPPLIER_ID, document_category_id: DOCUMENT_CATEGORY_ID, - budget_pool_id: null, + budget_pool_id: options.explicitPool ? options.pool.id : null, request_no: null, + numbering_version: options.numberingVersion ?? 1, title: "R6 采购提交热修测试", reason: null, currency_code: "CNY", @@ -92,6 +94,8 @@ async function buildApp(options = {}) { const tx = { $queryRaw: vi.fn(async () => [purchaseRequest]), + budgetPool: { findUnique: vi.fn(async () => options.pool ?? null), update: vi.fn() }, + budgetLedgerEntry: { findMany: vi.fn(async () => []), create: vi.fn() }, legalEntity: { findUnique: vi.fn(async () => ({ id: LEGAL_ENTITY_ID, @@ -173,6 +177,7 @@ async function buildApp(options = {}) { }, }; + if (options.activeNumbering) enableNumbering(tx); const prisma = { $transaction: vi.fn(async (callback) => callback(tx)), auditLog: { @@ -251,6 +256,28 @@ describe("purchase request submit budget precheck gate", () => { resolveRecipientsForAssigneeMock.mockResolvedValue([]); }); + it.each([false, true].flatMap(enabled => [false, true].map(explicitPool => ({ enabled, explicitPool }))))( + 'blocks the latest pool usage inside the submit transaction: %j', async ({ enabled, explicitPool }) => { + const pool = { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', status: 'active', archived_at: null, + allocated_amount: '100.00', used_amount: '60.00' }; + resolveApplicableBudgetPoolByContextMock.mockResolvedValue(pool); + resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck(enabled ? { decision: 'block', + blockingReasons: [{ code: 'budget_exceeded' }] } : {})); + const { app, tx } = await buildApp({ pool, explicitPool, budgetControlMode: 'block', budgetPrecheckEnabled: enabled }); + try { + const response = await app.inject({ method: 'POST', url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/submit` }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details.reasonCode).toBe('purchase_request.precheck_blocked'); + expect(resolveEntityPrecheckMock).toHaveBeenCalledWith(expect.objectContaining({ prisma: tx, budgetPoolForWrite: pool })); + expect(resolveApplicableBudgetPoolByContextMock).toHaveBeenCalledWith(tx, expect.any(Object), + { forUpdate: true, additionalPoolIds: [explicitPool ? pool.id : null] }); + expect(tx.purchaseRequestItem.aggregate.mock.invocationCallOrder[0]).toBeLessThan(resolveEntityPrecheckMock.mock.invocationCallOrder[0]); + expect(tx.workflowInstance.create).not.toHaveBeenCalled(); + expect(tx.budgetLedgerEntry.create).not.toHaveBeenCalled(); + expect(tx.budgetPool.update).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it("does not 500 when no budget pool exists and budget precheck is disabled", async () => { resolveEntityPrecheckMock.mockResolvedValue( buildPrecheck({ @@ -289,3 +316,14 @@ describe("purchase request submit budget precheck gate", () => { await app.close(); }); }); + +it.each([1, 2])("submits a stored v%s purchase draft under active policy without reclassifying it", async (version) => { + const { app, tx } = await buildApp({ activeNumbering: true, numberingVersion: version }); + try { + const response = await app.inject({ method: "POST", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/submit` }); + expect(response.statusCode).toBe(200); + const data = tx.purchaseRequest.update.mock.calls.find(([arg]) => arg.data.request_no)?.[0].data; + expect(data.request_no).toMatch(version === 1 ? /^CG-\d{8}-0001$/ : /^CG-\d{4}-0001$/); + expect(tx.businessNumberPolicy.findUnique).not.toHaveBeenCalled(); + } finally { await app.close(); } +}); diff --git a/server/tests/unit/routes/read-export.errors.test.js b/server/tests/unit/routes/read-export.errors.test.js new file mode 100644 index 00000000..aec5bfab --- /dev/null +++ b/server/tests/unit/routes/read-export.errors.test.js @@ -0,0 +1,35 @@ +const Fastify = require("fastify"); +const { AppError } = require("../../../src/utils/AppError"); +const routes = [require("../../../src/routes/audit"), require("../../../src/routes/search"), require("../../../src/routes/exports")]; +const apps = []; +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); +async function build({ allowed = true, disabled } = {}) { + const app = Fastify(); apps.push(app); + const prisma = { featureFlag: { findUnique: vi.fn().mockResolvedValue({ override_mode: "force_on", rollout_type: "global", department_ids: [] }) } }; + for (const name of ["employee", "reimbursementForm", "invoice", "auditLog", "paymentBatch", "contractRegistry", "employeePresence"]) prisma[name] = { count: vi.fn().mockResolvedValue(0), findMany: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({}) }; + const actor = { id: "synthetic", permissionCodes: allowed ? ["*"] : [] }; + const authenticate = async (request) => { request.employee = actor; }; + app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", globalSearchEnabled: disabled !== "search", paymentBatchEnabled: disabled !== "payment", projectLinkingEnabled: disabled !== "project" }); + app.decorate("authenticate", authenticate); app.decorate("hasPermission", () => allowed); app.decorate("requirePermission", () => async (request) => { await authenticate(request); if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403 }); }); + app.setErrorHandler((e, _, reply) => reply.status(e.statusCode || 500).send({ error: { code: e.code, details: e.details } })); + for (const route of routes) await app.register(route, { prefix: "/api" }); + return { app, prisma }; +} +function noBusiness(p) { for (const [name, model] of Object.entries(p)) if (name !== "featureFlag") for (const fn of Object.values(model)) expect(fn).not.toHaveBeenCalled(); } +function rejected(r, reason, status = 400) { expect(r.statusCode).toBe(status); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: reason } }); expect(r.headers["content-disposition"]).toBeUndefined(); } +const cases = [ + ["GET", "/audit-logs?page=0", undefined, "audit.query_invalid"], ["GET", "/audit-logs?dateFrom=2026-09-17T00:00:00Z&dateTo=2026-09-16T00:00:00Z", undefined, "audit.date_range_invalid"], + ["GET", "/search?q=a", undefined, "search.query_invalid"], ["GET", "/search?q=ab&limit=21", undefined, "search.query_invalid"], + ...["presence-history", "reimbursements", "invoice-pool", "audit-logs", "dashboard/reimbursements"].map((path, i) => ["GET", `/exports/${path}?format=bad`, undefined, `export.${["presence", "reimbursement", "invoice", "audit", "dashboard"][i]}_query_invalid`]), + ["POST", "/exports/ap-operations", { format: "bad" }, "export.ap_input_invalid"], + ["POST", "/exports/ap-operations", { pendingCreatedAtFrom: "2026-09-17", pendingCreatedAtTo: "2026-09-16" }, "export.ap_pending_date_range_invalid"], + ["GET", "/exports/audit-logs?dateFrom=2026-09-17T00:00:00Z&dateTo=2026-09-16T00:00:00Z", undefined, "audit.date_range_invalid"], + ["GET", "/exports/dashboard/reimbursements?dateFrom=2026-09-17&dateTo=2026-09-16", undefined, "export.dashboard_date_range_invalid"], +]; +describe("live read and export error contracts", () => { + it.each(cases)("explains %s %s before reads/export audit", async (method, path, payload, reason) => { const { app, prisma } = await build(); rejected(await app.inject({ method, url: `/api${path}`, payload }), reason); noBusiness(prisma); }); + it.each([["search", "GET", "/search?q=ab", "search.feature_disabled"], ["payment", "POST", "/exports/ap-operations", "export.payment_batch_disabled"], ["project", "POST", "/exports/contracts-registry", "export.project_linking_disabled"]])("retains %s disabled gate", async (disabled, method, path, reason) => { const { app, prisma } = await build({ disabled }); rejected(await app.inject({ method, url: `/api${path}`, payload: method === "POST" ? {} : undefined }), reason, 409); noBusiness(prisma); }); + it.each(["/audit-logs", "/exports/audit-logs", "/exports/reimbursements", "/exports/invoice-pool"])("retains permission before reads %s", async (path) => { const { app, prisma } = await build({ allowed: false }); expect((await app.inject({ url: `/api${path}` })).statusCode).toBe(403); noBusiness(prisma); }); + it("keeps audit defaults and equal datetime bounds", async () => { const { app, prisma } = await build(); const r = await app.inject({ url: "/api/audit-logs?dateFrom=2026-09-17T00:00:00Z&dateTo=2026-09-17T00:00:00Z" }); expect(r.statusCode).toBe(200); expect(r.json().data).toMatchObject({ page: 1, pageSize: 20, items: [] }); expect(prisma.auditLog.findMany.mock.calls[0][0]).toMatchObject({ take: 20, skip: 0, where: { created_at: { gte: new Date("2026-09-17"), lte: new Date("2026-09-17") } } }); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); + it.each(["csv", "xlsx"])("keeps synthetic audit %s export and one success audit", async (format) => { const { app, prisma } = await build(); const r = await app.inject({ url: `/api/exports/audit-logs?format=${format}` }); expect(r.statusCode).toBe(200); expect(r.headers["content-disposition"]).toContain(format); expect(prisma.auditLog.create).toHaveBeenCalledOnce(); }); +}); diff --git a/server/tests/unit/routes/reference-settings.errors.test.js b/server/tests/unit/routes/reference-settings.errors.test.js new file mode 100644 index 00000000..2908aa59 --- /dev/null +++ b/server/tests/unit/routes/reference-settings.errors.test.js @@ -0,0 +1,106 @@ +const Fastify = require("fastify"); +const { AppError } = require("../../../src/utils/AppError"); +const ID = "11111111-1111-4111-8111-111111111111"; +const TYPE = "22222222-2222-4222-8222-222222222222"; +const DEPARTMENT = "33333333-3333-4333-8333-333333333333"; +const domains = [ + { file: "expenseStandards", path: "/expense-standards", model: "expenseStandard", reason: "expense_standard", payload: { expenseTypeId: TYPE, name: "合成标准", maxAmount: "12.50" }, empty: true, remove: true }, + { file: "expenseTypes", path: "/expense-types", model: "expenseType", reason: "expense_type", payload: { code: "synthetic", name: "合成类型", category: "other", sortOrder: 1 }, empty: true }, + { file: "leave-types", path: "/leave-types", model: "leaveType", reason: "leave_type", payload: { code: "synthetic", name: "合成假别", annualQuotaDays: 1.5 } }, + { file: "reimbursementTemplates", path: "/reimbursement-templates", model: "reimbursementTemplate", reason: "reimbursement_template", payload: { name: "合成模板", items: [{ expenseTypeId: TYPE, description: "合成项目", defaultAmount: "12.50" }] }, empty: true, remove: true }, +]; +const apps = []; +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); +async function buildApp({ allowed = true, canManage = true } = {}) { + const app = Fastify(); apps.push(app); + const records = {}; + const prisma = { auditLog: { create: vi.fn().mockResolvedValue({}) }, department: { findUnique: vi.fn().mockResolvedValue({ id: DEPARTMENT, name: "合成部门", archived_at: null }), findMany: vi.fn().mockResolvedValue([]) } }; + for (const { model } of domains) { + records[model] = { id: ID, name: "合成", code: "synthetic", enabled: true, expense_type_id: TYPE, max_amount: "12.50", scope_type: "global", scope_key: null, items: [], leave_policy: { id: TYPE, annual_quota_days: 1.5, enabled: true } }; + prisma[model] = { findUnique: vi.fn(async () => records[model]), findMany: vi.fn(async () => [records[model]]), create: vi.fn(async ({ data }) => ({ ...records[model], ...data })), update: vi.fn(async ({ data }) => ({ ...records[model], ...data })), findFirst: vi.fn(async () => records[model]) }; + } + prisma.expenseType.findMany.mockResolvedValue([{ id: TYPE, enabled: true }]); + prisma.$transaction = vi.fn(async (callback) => callback(prisma)); + app.decorate("prisma", prisma); app.decorate("config", {}); + app.decorate("authenticate", async (request) => { request.employee = { id: ID, department_id: DEPARTMENT }; }); + const guard = () => async (request) => { await app.authenticate(request); if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); }; + app.decorate("requirePermission", guard); app.decorate("requireEntityPermission", guard); app.decorate("hasPermission", () => canManage); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); + for (const { file } of domains) await app.register(require(`../../../src/routes/${file}`), { prefix: "/api" }); + return { app, prisma, records }; +} +function noWrites(prisma) { for (const { model } of domains) { expect(prisma[model].create).not.toHaveBeenCalled(); expect(prisma[model].update).not.toHaveBeenCalled(); } expect(prisma.auditLog.create).not.toHaveBeenCalled(); } +function noReads(prisma) { for (const { model } of domains) { expect(prisma[model].findUnique).not.toHaveBeenCalled(); expect(prisma[model].findMany).not.toHaveBeenCalled(); } expect(prisma.$transaction).not.toHaveBeenCalled(); } +function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); } +function request(entity, method, payload, suffix) { return { method, url: `/api${entity.path}${suffix ?? (method === "PATCH" ? `/${ID}` : "")}`, ...(payload ? { payload } : {}) }; } +for (const entity of domains) { + describe(entity.path, () => { + it.each([["GET", undefined, "query_invalid", "?enabled=bad"], ["POST", {}, "create_input_invalid", ""], ["PATCH", { name: "" }, "update_input_invalid", `/${ID}`]])("explains %s invalid input before database access", async (method, payload, reason, suffix) => { + const { app, prisma } = await buildApp(); const response = await app.inject(request(entity, method, payload, suffix)); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `${entity.reason}.${reason}`, formErrors: [], fieldErrors: expect.any(Object) }); noWrites(prisma); noReads(prisma); + }); + it.each(entity.remove ? ["PATCH", "DELETE"] : ["PATCH"])("explains invalid id on %s", async (method) => { + const { app, prisma } = await buildApp(); expectError(await app.inject(request(entity, method, method === "PATCH" ? { name: "合成" } : undefined, "/bad")), 400, `${entity.reason}.id_invalid`, { id: "invalid_uuid" }); noWrites(prisma); noReads(prisma); + }); + if (entity.empty) it("rejects empty patch before reads or writes", async () => { + const { app, prisma } = await buildApp(); expectError(await app.inject(request(entity, "PATCH", {})), 400, `${entity.reason}.update_empty`, { body: "empty" }); noWrites(prisma); noReads(prisma); + }); + it.each(["POST", "PATCH"])("keeps permission guard before %s validation", async (method) => { + const { app, prisma } = await buildApp({ allowed: false }); const response = await app.inject(request(entity, method, {})); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); noWrites(prisma); noReads(prisma); + }); + it("preserves missing record 404", async () => { + const { app, prisma } = await buildApp(); prisma[entity.model].findUnique.mockResolvedValue(null); const response = await app.inject(request(entity, "PATCH", { name: "合成" })); expect(response.statusCode).toBe(404); expect(response.json().error.code).toBe("SYS_404"); noWrites(prisma); + }); + }); +} +it.each([domains[0], domains[1], domains[2]].flatMap((entity) => ["POST", "PATCH"].map((method) => [entity, method])))("preserves duplicate rejection on $0.path %s", async (entity, method) => { + const { app, prisma } = await buildApp(); prisma[entity.model][method === "POST" ? "create" : "update"].mockRejectedValue({ code: "P2002", meta: { target: ["code"] } }); + const payload = method === "POST" ? entity.payload : { name: "合成修改" }; + expectError(await app.inject(request(entity, method, payload)), 409, `${entity.reason}.${entity.model === "expenseStandard" ? "scope_conflict" : "code_duplicate"}`, entity.model === "expenseStandard" ? { duplicate: "expense_type_scope_conflict" } : { code: "duplicate" }); expect(prisma.auditLog.create).not.toHaveBeenCalled(); +}); +it.each(["POST", "PATCH"])("preserves positive standard amount on %s", async (method) => { + const { app, prisma } = await buildApp(); expectError(await app.inject(request(domains[0], method, { ...(method === "POST" ? domains[0].payload : {}), maxAmount: "0.00" })), 400, "expense_standard.amount_not_positive", { maxAmount: "must_be_positive" }); noWrites(prisma); noReads(prisma); +}); +it("preserves missing expense type before standard create", async () => { + const { app, prisma } = await buildApp(); prisma.expenseType.findUnique.mockResolvedValue(null); + expectError(await app.inject(request(domains[0], "POST", domains[0].payload)), 409, "expense_standard.expense_type_not_found", { expenseTypeId: "not_found" }); noWrites(prisma); expect(prisma.department.findUnique).not.toHaveBeenCalled(); +}); +it.each(["POST", "PATCH"].flatMap((method) => ["missing-key", "missing-department", "archived-department"].map((state) => [method, state])))("preserves %s standard scope %s", async (method, state) => { + const { app, prisma } = await buildApp(); + if (state === "missing-department") prisma.department.findUnique.mockResolvedValue(null); + if (state === "archived-department") prisma.department.findUnique.mockResolvedValue({ id: DEPARTMENT, archived_at: new Date("2024-01-01T00:00:00Z") }); + const payload = { ...(method === "POST" ? domains[0].payload : {}), scopeType: "department", ...(state === "missing-key" ? {} : { scopeKey: DEPARTMENT }) }; + expectError(await app.inject(request(domains[0], method, payload)), state === "missing-key" ? 400 : 409, `expense_standard.${state === "missing-key" ? "department_required" : "department_unavailable"}`, { scopeKey: state === "missing-key" ? "required_for_department_scope" : "invalid_department" }); noWrites(prisma); +}); +it("keeps retained department scope during standard patch", async () => { + const { app, prisma, records } = await buildApp(); records.expenseStandard.scope_type = "department"; records.expenseStandard.scope_key = DEPARTMENT; + const response = await app.inject(request(domains[0], "PATCH", { name: "合成修改" })); expect(response.statusCode).toBe(200); expect(prisma.department.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: DEPARTMENT } })); + expect(prisma.expenseStandard.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ scope_type: "department", scope_key: DEPARTMENT }) })); +}); +it.each(["POST", "PATCH"])("keeps invalid template type indices in %s without partial writes", async (method) => { + const { app, prisma } = await buildApp(); + const items = [{ expenseTypeId: TYPE, description: "有效" }, { expenseTypeId: DEPARTMENT, description: "无效" }, { expenseTypeId: DEPARTMENT, description: "无效重复" }]; + expectError(await app.inject(request(domains[3], method, { name: "合成", items })), 409, "reimbursement_template.expense_types_unavailable", { items: [1, 2].map((index) => ({ index, expenseTypeId: DEPARTMENT, reason: "expense_type_not_found_or_disabled" })) }); + expect(prisma.expenseType.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: { in: [TYPE, DEPARTMENT] }, enabled: true } })); noWrites(prisma); +}); +it.each(["POST", "PATCH"].flatMap((method) => ["over-limit", "zero-amount"].map((state) => [method, state])))("keeps %s template %s rejection before type lookup", async (method, state) => { + const { app, prisma } = await buildApp(); const item = { expenseTypeId: TYPE, description: "合成", defaultAmount: state === "zero-amount" ? "0.00" : "1.00" }; + const response = await app.inject(request(domains[3], method, { name: "合成", items: Array.from({ length: state === "over-limit" ? 51 : 1 }, () => item) })); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe(`reimbursement_template.${method === "POST" ? "create" : "update"}_input_invalid`); noWrites(prisma); noReads(prisma); +}); +it.each(["POST", "PATCH"])("keeps half-day quota schema on %s", async (method) => { + const { app, prisma } = await buildApp(); const response = await app.inject(request(domains[2], method, { ...(method === "POST" ? domains[2].payload : {}), annualQuotaDays: 0.25 })); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe(`leave_type.${method === "POST" ? "create" : "update"}_input_invalid`); noWrites(prisma); noReads(prisma); +}); +it("preserves leave-type lowercase normalization, defaults and nested policy creation", async () => { + const { app, prisma } = await buildApp(); const response = await app.inject(request(domains[2], "POST", { ...domains[2].payload, code: " SYNTHETIC " })); expect(response.statusCode).toBe(200); + expect(prisma.leaveType.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ code: "synthetic", enabled: true, requires_attachment: false, leave_policy: { create: { annual_quota_days: 1.5, enabled: true, description: null, created_by: ID, updated_by: ID } } }) })); expect(prisma.auditLog.create).not.toHaveBeenCalled(); +}); +it("preserves leave-type empty patch and optional policy update", async () => { + const { app, prisma } = await buildApp(); expect((await app.inject(request(domains[2], "PATCH", {}))).statusCode).toBe(200); expect(prisma.leaveType.update).toHaveBeenLastCalledWith(expect.objectContaining({ data: { updated_by: ID, leave_policy: undefined } })); + expect((await app.inject(request(domains[2], "PATCH", { annualQuotaDays: 2.5, enabled: false, policyDescription: null }))).statusCode).toBe(200); + expect(prisma.leaveType.update).toHaveBeenLastCalledWith(expect.objectContaining({ data: { enabled: false, updated_by: ID, leave_policy: { update: { annual_quota_days: 2.5, enabled: false, description: null, updated_by: ID } } } })); expect(prisma.auditLog.create).not.toHaveBeenCalled(); +}); +it("keeps non-manager leave-type list limited to enabled entries", async () => { + const { app, prisma } = await buildApp({ canManage: false }); expect((await app.inject(request(domains[2], "GET", undefined, "?enabled=false"))).statusCode).toBe(200); expect(prisma.leaveType.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { enabled: true } })); noWrites(prisma); +}); +it("preserves unrelated unique constraint exceptions for leave types", async () => { + const { app, prisma } = await buildApp(); prisma.leaveType.create.mockRejectedValue({ code: "P2002", meta: { target: ["unrelated"] } }); expect((await app.inject(request(domains[2], "POST", domains[2].payload))).statusCode).toBe(500); expect(prisma.auditLog.create).not.toHaveBeenCalled(); +}); diff --git a/server/tests/unit/routes/reimbursements.create.test.js b/server/tests/unit/routes/reimbursements.create.test.js index c5ed06b4..33d9a41b 100644 --- a/server/tests/unit/routes/reimbursements.create.test.js +++ b/server/tests/unit/routes/reimbursements.create.test.js @@ -1,3 +1,4 @@ +const { enableNumbering } = require("../helpers/activeNumbering"); const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); @@ -242,6 +243,8 @@ async function buildApp(options = {}) { return found ? cloneTravelRequest(found) : null; }), }, + businessNumberPolicy: { findUnique: vi.fn(async () => null) }, + $executeRaw: vi.fn(async () => 0), documentSequence: { findUnique: vi.fn(async () => null), upsert: vi.fn(async ({ create, update }) => { @@ -268,7 +271,7 @@ async function buildApp(options = {}) { create: vi.fn(async ({ data, select }) => { const record = { id: `reimbursement-${stateRef.reimbursements.length + 1}`, - form_number: null, + form_number: data.form_number, submitter_id: data.submitter_id, owner_id: data.owner_id, department_id: data.department_id, @@ -348,6 +351,7 @@ async function buildApp(options = {}) { sequenceNumber: state.sequenceNumber, }; const tx = buildPrismaLike(txState); + if (options.activeNumbering) enableNumbering(tx); const result = await callback(tx); state.reimbursements = txState.reimbursements; state.reimbursementItems = txState.reimbursementItems; @@ -409,6 +413,16 @@ describe("reimbursement create enhancements", () => { listActiveDelegationPrincipalsMock.mockResolvedValue([]); }); + it("persists a monthly number on new drafts only when policy is active", async () => { + const { app, state } = await buildApp({ activeNumbering: true }); + try { + const response = await app.inject({ method: "POST", url: "/api/reimbursements", payload: { description: "合成月号" } }); + expect(response.statusCode).toBe(200); + expect(state.reimbursements[0].form_number).toMatch(/^BX-\d{6}-0001$/); + expect(state.sequenceNumber ?? 0).toBe(0); + } finally { await app.close(); } + }); + it("keeps blank reimbursement creation working", async () => { const { app, state } = await buildApp(); diff --git a/server/tests/unit/routes/reimbursements.expense-standards.test.js b/server/tests/unit/routes/reimbursements.expense-standards.test.js index 4ba1e3e2..92296ed1 100644 --- a/server/tests/unit/routes/reimbursements.expense-standards.test.js +++ b/server/tests/unit/routes/reimbursements.expense-standards.test.js @@ -64,6 +64,11 @@ async function buildApp(options = {}) { status: "draft", form_number: "BX-20260408-001", total_amount: "180.00", + ...(options.travelSummary ? { + travel_purpose: "合成出差", travel_location: "合成地点", + travel_start_date: new Date("2026-04-08T00:00:00.000Z"), + travel_end_date: new Date("2026-04-08T00:00:00.000Z"), + } : {}), }; const approvalRule = options.approvalRule ?? { id: "55555555-5555-4555-8555-555555555555", @@ -118,7 +123,7 @@ async function buildApp(options = {}) { ]).map((item) => ({ asset_prior_approval_basis: null, description: "合成支出", - expense_type: { code: "office_supplies", category: "office", requires_invoice: true }, + expense_type: { code: "office_supplies", category: options.travelItem ? "travel" : "office", requires_invoice: true }, invoice_pool_items: [{ id: "invoice-1", total_amount: item.amount }], ...item, }))), @@ -314,9 +319,37 @@ describe("reimbursements routes expense standard integration", () => { missingFields: ["travelPurpose", "travelLocation", "travelStartDate", "travelEndDate"], })); expect(tx.workflowInstance.create).not.toHaveBeenCalled(); + expect(tx.reimbursementItem.findFirst).not.toHaveBeenCalled(); await app.close(); }); + it.each([true, false])("avoids unused bound-invoice counts: per-item guard=%s", async invoiceGuardEnabled => { + resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); + const { app, tx } = await buildApp({ invoiceGuardEnabled, reimbursementItems: [{ + id: "item-no-invoice", amount: "180.00", + expense_type: { code: "office", category: "office", requires_invoice: invoiceGuardEnabled }, + }] }); + try { + const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); + expect(response.statusCode, response.body).toBe(200); + expect(tx.invoicePool.count).not.toHaveBeenCalled(); + expect(tx.reimbursementItem.findFirst).not.toHaveBeenCalled(); + expect(tx.reimbursementItem.aggregate).toHaveBeenCalledTimes(1); + expect(tx.workflowHistory.create).toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it("keeps legacy required-invoice rejection when per-item guard is disabled", async () => { + const { app, tx } = await buildApp({ invoiceGuardEnabled: false, boundInvoiceCount: 0 }); + try { + const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details.invoices).toBe("bound_invoice_required"); + expect(tx.invoicePool.count).toHaveBeenCalledTimes(1); + expect(tx.workflowInstance.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it("rejects submit when the reimbursement has no items", async () => { const { app, tx } = await buildApp({ reimbursementItems: [] }); @@ -412,6 +445,7 @@ describe("reimbursements routes expense standard integration", () => { it("skips invoice amount checks for invoice-free allowance items when the guard is on", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); const { app } = await buildApp({ + travelSummary: true, invoiceGuardEnabled: true, boundInvoiceCount: 0, reimbursementItems: [{ @@ -430,6 +464,7 @@ describe("reimbursements routes expense standard integration", () => { it("allows an invoice-free allowance-only reimbursement when the guard is off", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); const { app } = await buildApp({ + travelSummary: true, invoiceGuardEnabled: false, boundInvoiceCount: 0, reimbursementItems: [{ @@ -447,6 +482,7 @@ describe("reimbursements routes expense standard integration", () => { it("keeps the legacy whole-form invoice requirement for mixed or required items when the guard is off", async () => { const { app, tx } = await buildApp({ + travelSummary: true, invoiceGuardEnabled: false, boundInvoiceCount: 0, reimbursementItems: [ @@ -997,6 +1033,11 @@ describe("reimbursements routes expense standard integration", () => { }); expect(state.workflowInstanceCreatePayload?.context_data?.budgetCheckRecorded).toBe(true); + expect(resolveApplicableBudgetPoolByContextMock).toHaveBeenCalledWith(expect.any(Object), + expect.any(Object), { forUpdate: true }); + const submissionPrecheck = resolveEntityPrecheckMock.mock.calls.find(([arg]) => arg.budgetPoolForWrite?.id === 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); + expect(submissionPrecheck?.[0].budgetPoolForWrite).toMatchObject({ used_amount: '700.00' }); + await app.close(); }); diff --git a/server/tests/unit/routes/system-contract.errors.test.js b/server/tests/unit/routes/system-contract.errors.test.js new file mode 100644 index 00000000..df04912d --- /dev/null +++ b/server/tests/unit/routes/system-contract.errors.test.js @@ -0,0 +1,77 @@ +const Fastify = require("fastify"); +const { AppError } = require("../../../src/utils/AppError"); +const { resolveDocumentSequenceConfig } = require("../../../src/services/documentSequence"); +const { ensureCutoverAllowed } = require("../../../src/services/cutover"); +const { resolveEntityPrecheck, buildEntityPrecheck } = require("../../../src/services/feeControl"); +const routeNames = ["configRead", "systemWorkspace", "confidentialDocRefs", "cutover", "migrationRuns", "featureFlags", "feeControl", "riskFlags"]; +const ID = "11111111-1111-4111-8111-111111111111"; +const FLAG = { id: ID, flag_key: "payment_batch_v1", override_mode: "force_on", rollout_type: "global", department_ids: [], version: 2, created_at: new Date("2026-01-01"), updated_at: new Date("2026-01-01") }; +const FLAG_BODY = { overrideMode: "force_on", rolloutType: "global", departmentIds: [], expectedVersion: 2 }; +const apps = []; +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); +async function build({ allowed = true, enabled = true } = {}) { + const app = Fastify(); apps.push(app); + const prisma = {}; + for (const name of ["approvalRule", "documentSequence", "documentCategory", "workspaceSetting", "confidentialDocumentRef", "cutoverScope", "migrationRun", "entityRiskFlag", "featureFlag", "auditLog", "purchaseRequest"]) + prisma[name] = { findUnique: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0), create: vi.fn(async ({ data }) => ({ id: ID, ...data })), update: vi.fn(async ({ data }) => ({ id: ID, ...data })), updateMany: vi.fn().mockResolvedValue({ count: 1 }), upsert: vi.fn() }; + prisma.featureFlag.findUnique.mockImplementation(async ({ where }) => ({ ...FLAG, flag_key: where.flag_key, override_mode: enabled ? "force_on" : "force_off" })); + prisma.$transaction = vi.fn(async (fn) => fn(prisma)); + app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", confidentialDocRefsEnabled: true, cutoverConsoleEnabled: true, paymentBatchEnabled: true }); + const auth = async (request) => { request.employee = { id: ID, permissionCodes: allowed ? ["*"] : [] }; }; + app.decorate("authenticate", auth); app.decorate("hasPermission", () => allowed); app.decorate("requirePermission", () => async (request) => { await auth(request); if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403 }); }); + app.setErrorHandler((e, request, reply) => reply.status(e.statusCode || 500).send({ requestId: request.id, error: { code: e.code || "SYS_001", message: e.message, details: e.details ?? {} } })); + await app.register(require("@fastify/multipart")); + for (const name of routeNames) await app.register(require(`../../../src/routes/${name}`), { prefix: "/api" }); + return { app, prisma }; +} +function noWrites(p) { for (const model of Object.values(p)) for (const op of ["create", "update", "upsert", "updateMany"]) if (model[op]) expect(model[op]).not.toHaveBeenCalled(); } +function rejected(r, reason, status = 400, details = {}) { expect(r.statusCode, r.body).toBe(status); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: reason, ...details } }); } +const inject = (app, method, path, payload) => app.inject({ method, url: `/api${path}`, payload }); +const cases = [ + ["GET", "/approval-rules?enabled=bad", undefined, "config.enabled_query_invalid"], ["GET", "/document-sequences/bad-type", undefined, "config.document_type_invalid"], + ["PATCH", "/system/workspace", {}, "workspace.update_input_invalid"], ["PATCH", "/system/workspace", { moduleVisibility: { portal: "bad" } }, "workspace.update_input_invalid"], + ["POST", "/confidential-doc-refs", {}, "confidential_ref.create_input_invalid"], ["PATCH", `/confidential-doc-refs/${ID}`, {}, "confidential_ref.update_input_invalid"], ["GET", "/confidential-doc-refs?projectId=bad", undefined, "confidential_ref.query_invalid"], + ["PATCH", "/cutover/scopes/bad", {}, "cutover.id_invalid"], ["POST", "/cutover/scopes", {}, "cutover.create_input_invalid"], ["PATCH", `/cutover/scopes/${ID}`, {}, "cutover.update_input_invalid"], + ["GET", "/migration-runs/bad", undefined, "migration_run.id_invalid"], + ["GET", "/system/feature-flags/bad/changelog", undefined, "feature_flag.key_invalid"], ["GET", "/system/feature-flags/payment_batch_v1/changelog?page=0", undefined, "feature_flag.page_invalid"], + ["PUT", "/system/feature-flags/payment_batch_v1", { ...FLAG_BODY, note: "😀".repeat(251) }, "feature_flag.update_input_invalid"], ["PUT", "/system/feature-flags/payment_batch_v1", { ...FLAG_BODY, departmentIds: [ID] }, "feature_flag.global_departments_invalid"], + ["POST", "/system/feature-flags/batch", { items: [] }, "feature_flag.batch_input_invalid"], ["POST", "/system/feature-flags/batch", { items: Array(2).fill({ flagKey: "payment_batch_v1", overrideMode: "force_on", expectedVersion: 2 }), note: "合成" }, "feature_flag.batch_input_invalid"], + ["POST", "/fee-control/precheck", { entityType: "bad", entityId: ID }, "fee_control.input_invalid"], + ["GET", "/risk-flags?status=bad", undefined, "risk_flag.query_invalid"], ["POST", "/risk-flags/bad/resolve", {}, "risk_flag.id_invalid"], ["POST", `/risk-flags/${ID}/dismiss`, { note: "x".repeat(501) }, "risk_flag.action_input_invalid"], +]; +describe("system management route error contracts", () => { + it.each(cases)("explains %s %s without writes", async (method, path, payload, reason) => { const { app, prisma } = await build(); rejected(await inject(app, method, path, payload), reason); noWrites(prisma); }); + it.each([["/confidential-doc-refs", "confidential_ref.feature_disabled"], ["/cutover/scopes", "cutover.feature_disabled"], ["/migration-runs", "cutover.feature_disabled"]])("keeps gate %s", async (path, reason) => { const { app, prisma } = await build({ enabled: false }); rejected(await inject(app, "GET", path), reason, 409); noWrites(prisma); }); + it.each(["resolve", "dismiss"])("rejects previously handled risk %s", async (action) => { const { app, prisma } = await build(); prisma.entityRiskFlag.findUnique.mockResolvedValue({ id: ID, status: "resolved" }); rejected(await inject(app, "POST", `/risk-flags/${ID}/${action}`, {}), "risk_flag.not_open", 409, { status: "risk_flag_not_open" }); noWrites(prisma); }); + it.each(["single", "batch"])("preserves %s optimistic conflict", async (kind) => { const { app, prisma } = await build(); const r = kind === "single" ? await inject(app, "PUT", "/system/feature-flags/payment_batch_v1", { ...FLAG_BODY, expectedVersion: 1 }) : await inject(app, "POST", "/system/feature-flags/batch", { items: [{ flagKey: "payment_batch_v1", overrideMode: "force_on", expectedVersion: 1 }], note: "合成" }); rejected(r, "feature_flag.version_conflict", 409, { expectedVersion: 1, currentVersion: 2 }); noWrites(prisma); }); + it.each(["single", "batch"])("preserves %s concurrent update conflict after attempt", async (kind) => { const { app, prisma } = await build(); prisma.featureFlag.updateMany.mockResolvedValue({ count: 0 }); const r = kind === "single" ? await inject(app, "PUT", "/system/feature-flags/payment_batch_v1", FLAG_BODY) : await inject(app, "POST", "/system/feature-flags/batch", { items: [{ flagKey: "payment_batch_v1", overrideMode: "force_on", expectedVersion: 2 }], note: "合成" }); rejected(r, "feature_flag.version_conflict", 409); expect(prisma.featureFlag.updateMany).toHaveBeenCalledOnce(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); + it.each([undefined, "true", "1", "false", "0"])("keeps enabled filter %s", async (value) => { const { app, prisma } = await build(); const r = await inject(app, "GET", `/approval-rules${value ? `?enabled=${value}` : ""}`); expect(r.statusCode).toBe(200); expect(prisma.approvalRule.findMany.mock.calls[0][0].where).toEqual(value ? { enabled: ["true", "1"].includes(value) } : {}); noWrites(prisma); }); + it("preserves workspace create/defaults and audit", async () => { const { app, prisma } = await build(); expect((await inject(app, "PATCH", "/system/workspace", { moduleVisibility: { portal: "hidden" } })).statusCode).toBe(200); expect(prisma.workspaceSetting.create).toHaveBeenCalledOnce(); expect(prisma.auditLog.create).toHaveBeenCalledOnce(); }); + it("keeps cutover create default disabled and partial clear", async () => { const { app, prisma } = await build(); const r = await inject(app, "POST", "/cutover/scopes", { scopeType: "domain", scopeKey: "reimbursement" }); expect(r.statusCode).toBe(201); expect(prisma.cutoverScope.create.mock.calls[0][0].data).toMatchObject({ enabled: false, starts_at: null }); expect((await inject(app, "PATCH", `/cutover/scopes/${ID}`, { startsAt: null, notes: null })).statusCode).toBe(200); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); + it.each([["PATCH", "/system/workspace"], ["POST", "/confidential-doc-refs"], ["POST", "/cutover/scopes"], ["POST", "/system/feature-flags/batch"], ["GET", "/risk-flags"]])("preserves permission %s %s", async (method, path) => { const { app, prisma } = await build({ allowed: false }); expect((await inject(app, method, path, method === "GET" ? undefined : {})).statusCode).toBe(403); noWrites(prisma); }); +}); +function multipart(content) { return Buffer.from(`--synthetic\r\nContent-Disposition: form-data; name="file"; filename="synthetic.csv"\r\nContent-Type: text/csv\r\n\r\n${content}\r\n--synthetic--\r\n`); } +describe("synthetic migration precheck only", () => { + it.each([["--synthetic--\r\n", "employees", "migration_run.file_required"], [multipart("name\na"), "bad", "migration_run.type_invalid"]])("rejects missing part or type", async (payload, type, reason) => { const { app, prisma } = await build(); const r = await app.inject({ method: "POST", url: `/api/migration-runs/dry-run?runType=${type}`, headers: { "content-type": "multipart/form-data; boundary=synthetic" }, payload }); rejected(r, reason); noWrites(prisma); }); + it.each([["name\n合成部门", "dry_run_ok"], ["other\n合成", "dry_run_failed"], ["name", "dry_run_failed"]])("retains CSV outcome %s", async (content, status) => { const { app, prisma } = await build(); const r = await app.inject({ method: "POST", url: "/api/migration-runs/dry-run?runType=departments", headers: { "content-type": "multipart/form-data; boundary=synthetic" }, payload: multipart(content) }); expect(r.statusCode).toBe(201); expect(r.json().data.item.status).toBe(status); expect(prisma.migrationRun.create).toHaveBeenCalledOnce(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); +}); +describe("shared sequence, cutover and precheck guards", () => { + it("rejects unsupported sequence type before sequence access", async () => { const { app, prisma } = await build(); rejected(await inject(app, "GET", "/document-sequences/unknown"), "document_sequence.type_unsupported", 409); expect(prisma.documentSequence.findUnique).not.toHaveBeenCalled(); noWrites(prisma); }); + it.each([[null, "category_missing"], [{ entity_type: "leave", enabled: true }, "category_type_mismatch"], [{ entity_type: "purchase_request", enabled: false }, "category_unavailable"], [{ entity_type: "purchase_request", enabled: true, archived_at: new Date("2026-01-01") }, "category_unavailable"]])("rejects specified category %s", async (category, reason) => { const { prisma } = await build(); prisma.documentCategory.findUnique.mockResolvedValue(category); await expect(resolveDocumentSequenceConfig(prisma, { documentType: "purchase_request", documentCategoryCode: "SYNTHETIC" })).rejects.toMatchObject({ statusCode: 409, details: { reasonCode: `document_sequence.${reason}` } }); noWrites(prisma); }); + it.each(["domain", "department", "legal_entity"])("keeps allowed cutover match %s", async (type) => { const { prisma, app } = await build(); prisma.cutoverScope.findMany.mockResolvedValue([{ id: ID, scope_type: type, scope_key: type === "domain" ? "reimbursement" : ID }]); const r = await ensureCutoverAllowed({ prisma, config: app.config, request: { employee: { id: ID } }, domain: "reimbursement", departmentId: ID, legalEntityId: ID }); expect(r).toMatchObject({ checked: true, allowed: true, matchedScope: { scopeType: type } }); noWrites(prisma); }); + it("rejects missing active scope and leaves disabled gate permissive", async () => { const { prisma, app } = await build(); const input = { prisma, config: app.config, request: { employee: { id: ID } }, domain: "reimbursement" }; await expect(ensureCutoverAllowed(input)).rejects.toMatchObject({ statusCode: 409, details: { reasonCode: "cutover.scope_not_enabled", cutover: "not_enabled" } }); app.config.cutoverConsoleEnabled = false; prisma.cutoverScope.findMany.mockClear(); expect(await ensureCutoverAllowed(input)).toMatchObject({ checked: false, allowed: true }); expect(prisma.cutoverScope.findMany).not.toHaveBeenCalled(); noWrites(prisma); }); + it("rejects unsupported precheck entity in actual service entry points", async () => { const { prisma, app } = await build(); const input = { entityType: "bad", entityId: ID, config: app.config }; await expect(resolveEntityPrecheck({ prisma, ...input })).rejects.toMatchObject({ statusCode: 400, details: { entityType: "unsupported", reasonCode: "fee_control.entity_unsupported" } }); await expect(buildEntityPrecheck(prisma, input)).rejects.toMatchObject({ details: { reasonCode: "fee_control.entity_unsupported" } }); noWrites(prisma); }); +}); + +it("retains synthetic confidential index writes and their audits", async () => { + const { app, prisma } = await build(); + const created = await inject(app, "POST", "/confidential-doc-refs", { title: "合成索引", storageType: "offline_archive", storagePath: "synthetic-index", confidentialityLevel: "internal" }); + expect(created.statusCode).toBe(201); expect(prisma.confidentialDocumentRef.create.mock.calls[0][0].data).toMatchObject({ owner_employee_id: ID, storage_type: "offline_archive", project_id: null }); + expect((await inject(app, "PATCH", `/confidential-doc-refs/${ID}`, { title: "合成索引修订" })).statusCode).toBe(200); + expect(prisma.auditLog.create).toHaveBeenCalledTimes(2); +}); +it("retains valid disabled purchase precheck and permission refusal", async () => { + const { app, prisma } = await build(); prisma.purchaseRequest.findUnique.mockResolvedValue({ id: ID, department_id: null }); + const r = await inject(app, "POST", "/fee-control/precheck", { entityType: "purchase_request", entityId: ID }); expect(r.statusCode).toBe(200); noWrites(prisma); + const denied = await build({ allowed: false }); expect((await inject(denied.app, "POST", "/fee-control/precheck", { entityType: "purchase_request", entityId: ID })).statusCode).toBe(403); expect(denied.prisma.purchaseRequest.findUnique).not.toHaveBeenCalled(); +}); diff --git a/server/tests/unit/routes/todos.calendar.test.js b/server/tests/unit/routes/todos.calendar.test.js new file mode 100644 index 00000000..cf493755 --- /dev/null +++ b/server/tests/unit/routes/todos.calendar.test.js @@ -0,0 +1,75 @@ +const Fastify = require("fastify"); +const todosRoutes = require("../../../src/routes/todos"); + +const ACTOR = "11111111-1111-4111-8111-111111111111"; +const TODO = "22222222-2222-4222-8222-222222222222"; +const OTHER = "33333333-3333-4333-8333-333333333333"; + +async function buildApp({ owner = ACTOR } = {}) { + const app = Fastify(); + let row = { id: TODO, title: "合成待办", assignee_employee_id: owner, created_by: owner, due_date: new Date("2026-01-31T00:00:00.000Z"), status: "todo" }; + const prisma = { + featureFlag: { findUnique: vi.fn().mockResolvedValue({ flag_key: "todo_board_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] }) }, + todoItem: { + findUnique: vi.fn(async () => row), + create: vi.fn(async ({ data }) => (row = { id: TODO, ...data })), + update: vi.fn(async ({ data }) => (row = { ...row, ...data })), + }, + notification: { create: vi.fn(), createMany: vi.fn() }, + }; + app.decorate("prisma", prisma); + app.decorate("config", { todoBoardEnabled: true }); + app.decorate("authenticate", async (request) => { request.employee = { id: ACTOR, department_id: null }; }); + app.decorate("hasPermission", () => false); + app.setErrorHandler((error, request, reply) => reply.status(error.statusCode || 500).send({ error: { code: error.code, details: error.details } })); + await app.register(todosRoutes, { prefix: "/api" }); + return { app, prisma }; +} + +describe("todo due date calendar contract", () => { + it.each(["POST", "PATCH"].flatMap((method) => ["2026-02-29", "2026-02-30", "2026-04-31", "2026-13-01", ""].map((date) => [method, date])))("rejects %s date=%s before reads, writes or notification", async (method, date) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method, url: `/api/todos${method === "PATCH" ? `/${TODO}` : ""}`, payload: { title: "合成待办", dueDate: date } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details.reasonCode).toBe("todo.input_invalid"); + expect(response.json().error.details.fieldErrors.dueDate.length).toBeGreaterThan(0); + for (const fn of [...Object.values(prisma.todoItem), ...Object.values(prisma.notification)]) expect(fn).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it.each(["POST", "PATCH"].flatMap((method) => ["2024-02-29", "2026-04-30", null, undefined].map((date) => [method, date])))("preserves %s nullable/optional date=%s", async (method, date) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method, url: `/api/todos${method === "PATCH" ? `/${TODO}` : ""}`, payload: { title: "合成修改", ...(date === undefined ? {} : { dueDate: date }) } }); + expect(response.statusCode).toBe(method === "POST" ? 201 : 200); + expect(response.json().data.item.dueDate).toBe(date ?? (method === "PATCH" && date === undefined ? "2026-01-31" : null)); + const data = prisma.todoItem[method === "POST" ? "create" : "update"].mock.calls[0][0].data; + if (date === undefined && method === "PATCH") expect(data).not.toHaveProperty("due_date"); + else expect(data.due_date).toEqual(date ? new Date(`${date}T00:00:00.000Z`) : null); + for (const fn of Object.values(prisma.notification)) expect(fn).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it.each(["POST", "PATCH"])("keeps %s unauthorized assignment blocked for a valid date", async (method) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method, url: `/api/todos${method === "PATCH" ? `/${TODO}` : ""}`, payload: { title: "合成指派", dueDate: "2024-02-29", assigneeEmployeeId: OTHER } }); + expect(response.statusCode).toBe(403); + expect(response.json().error.details.permission).toBe("report.dashboard.view"); + expect(prisma.todoItem.create).not.toHaveBeenCalled(); + expect(prisma.todoItem.update).not.toHaveBeenCalled(); + for (const fn of Object.values(prisma.notification)) expect(fn).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it("keeps another employee's todo unwritable", async () => { + const { app, prisma } = await buildApp({ owner: OTHER }); + try { + const response = await app.inject({ method: "PATCH", url: `/api/todos/${TODO}`, payload: { dueDate: "2024-02-29" } }); + expect(response.statusCode).toBe(403); + expect(response.json().error.details.permission).toBe("todo.write"); + expect(prisma.todoItem.update).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/todos.errors.test.js b/server/tests/unit/routes/todos.errors.test.js new file mode 100644 index 00000000..cbd97544 --- /dev/null +++ b/server/tests/unit/routes/todos.errors.test.js @@ -0,0 +1,70 @@ +const Fastify = require("fastify"); +const todosRoutes = require("../../../src/routes/todos"); + +const ACTOR = "11111111-1111-4111-8111-111111111111"; +const TODO = "22222222-2222-4222-8222-222222222222"; + +async function buildApp({ enabled = true } = {}) { + const app = Fastify(); + const prisma = { + featureFlag: { findUnique: vi.fn().mockResolvedValue({ flag_key: "todo_board_v1", override_mode: enabled ? "force_on" : "force_off", rollout_type: "global", department_ids: [] }) }, + todoItem: { + findMany: vi.fn().mockResolvedValue([]), findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn(async ({ data }) => ({ id: TODO, ...data })), update: vi.fn(), + }, + notification: { create: vi.fn(), createMany: vi.fn() }, + }; + app.decorate("prisma", prisma); + app.decorate("config", { todoBoardEnabled: true }); + app.decorate("authenticate", async (request) => { request.employee = { id: ACTOR, department_id: null }; }); + app.decorate("hasPermission", () => false); + app.setErrorHandler((error, request, reply) => reply.status(error.statusCode || 500).send({ error: { code: error.code, details: error.details } })); + await app.register(todosRoutes, { prefix: "/api" }); + return { app, prisma }; +} + +describe("todo error reason contract", () => { + it.each([ + ["POST", "/todos", { title: "" }, "todo.input_invalid", "title"], + ["PATCH", `/todos/${TODO}`, { title: "" }, "todo.input_invalid", "title"], + ["PATCH", "/todos/bad-id", { title: "合成待办" }, "todo.id_invalid", null], + ["POST", "/todos/bad-id/complete", undefined, "todo.id_invalid", null], + ["POST", "/todos/bad-id/cancel", undefined, "todo.id_invalid", null], + ["GET", "/todos?status=unknown", undefined, "todo.query_invalid", "status"], + ])("rejects %s %s with precise compatibility details before writes", async (method, url, payload, reasonCode, field) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method, url: `/api${url}`, ...(payload ? { payload } : {}) }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe("VALID_001"); + expect(response.json().error.details.reasonCode).toBe(reasonCode); + if (field) expect(response.json().error.details.fieldErrors[field].length).toBeGreaterThan(0); + else expect(response.json().error.details.id).toBe("invalid_uuid"); + expect(Object.values(prisma.todoItem).every((fn) => fn.mock.calls.length === 0)).toBe(true); + expect(Object.values(prisma.notification).every((fn) => fn.mock.calls.length === 0)).toBe(true); + } finally { await app.close(); } + }); + it("preserves disabled status and does not create a todo", async () => { + const { app, prisma } = await buildApp({ enabled: false }); + try { + const response = await app.inject({ method: "POST", url: "/api/todos", payload: { title: "合成待办" } }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ reasonCode: "todo.disabled", featureFlag: "todo_board_v1_disabled", reason: "flag_force_off" }); + expect(prisma.todoItem.create).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it("keeps self-create successful and unauthorized assignment blocked without notification", async () => { + const { app, prisma } = await buildApp(); + try { + const own = await app.inject({ method: "POST", url: "/api/todos", payload: { title: "合成待办" } }); + expect(own.statusCode).toBe(201); + expect(own.json().data.item.assigneeEmployeeId).toBe(ACTOR); + prisma.todoItem.create.mockClear(); + const assigned = await app.inject({ method: "POST", url: "/api/todos", payload: { title: "合成待办", assigneeEmployeeId: "33333333-3333-4333-8333-333333333333" } }); + expect(assigned.statusCode).toBe(403); + expect(assigned.json().error.code).toBe("PERM_001"); + expect(prisma.todoItem.create).not.toHaveBeenCalled(); + expect(Object.values(prisma.notification).every((fn) => fn.mock.calls.length === 0)).toBe(true); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/travel-execution.errors.test.js b/server/tests/unit/routes/travel-execution.errors.test.js new file mode 100644 index 00000000..531479cb --- /dev/null +++ b/server/tests/unit/routes/travel-execution.errors.test.js @@ -0,0 +1,75 @@ +const Fastify = require("fastify"); +const { AppError } = require("../../../src/utils/AppError"); +const paymentService = require("../../../src/services/travelPayments"); +const ID = "11111111-1111-4111-8111-111111111111"; +const imports = [["orders", "travel_order", "travelOrders"], ["authorizations", "travel_authorization", "travelAuthorizations"], ["transactions", "travel_transaction", "travelTransactions"]]; +const apps = []; +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); +async function buildApp({ enabled = true, allowed = true } = {}) { + const app = Fastify(); apps.push(app); + const current = { id: ID, status: "confirmed", amount: "12.50", currency: "CNY", payment_date: null, payment_voucher_no: null, payment_channel: null }; + const prisma = {}; + for (const name of ["employee", "travelRequest", "travelOrder", "cardAuthorization", "cardTransaction", "budgetLedgerEntry", "budgetPool", "auditLog", "integrationOutbox"]) prisma[name] = Object.fromEntries(["findMany", "findUnique", "findFirst", "count", "create", "upsert", "update"].map((method) => [method, vi.fn().mockResolvedValue(method === "findMany" ? [] : null)])); + prisma.$queryRaw = vi.fn(async () => [current]); prisma.$transaction = vi.fn(async (callback) => callback(prisma)); + app.decorate("prisma", prisma); app.decorate("config", { travelSyncEnabled: enabled, erpSyncEnabled: false }); + app.decorate("authenticate", async (request) => { request.employee = { id: ID }; }); + app.decorate("requirePermission", () => async (request) => { await app.authenticate(request); if (!allowed) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); }); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); + for (const [, , file] of imports) await app.register(require(`../../../src/routes/${file}`), { prefix: "/api" }); + return { app, prisma, current }; +} +function noWrites(prisma) { for (const model of Object.values(prisma)) for (const method of ["create", "upsert", "update"]) if (model?.[method]) expect(model[method]).not.toHaveBeenCalled(); } +function noReads(prisma) { for (const model of Object.values(prisma)) for (const method of ["findMany", "findUnique", "findFirst", "count"]) if (model?.[method]) expect(model[method]).not.toHaveBeenCalled(); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.$queryRaw).not.toHaveBeenCalled(); } +function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); } +for (const [kind, reason] of imports) { + it(`${kind} import keeps disabled guard before parsing`, async () => { + const { app, prisma } = await buildApp({ enabled: false }); expectError(await app.inject({ method: "POST", url: `/api/travel/${kind}/import`, payload: {} }), 409, "travel_import.sync_disabled", { integration: "travel_sync_disabled" }); noWrites(prisma); noReads(prisma); + }); + it.each([0, 101])(`${kind} import explains list size %s before database access`, async (size) => { + const { app, prisma } = await buildApp(); const response = await app.inject({ method: "POST", url: `/api/travel/${kind}/import`, payload: { [kind]: Array.from({ length: size }, () => ({})) } }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `${reason}.import_input_invalid`, fieldErrors: { [kind]: expect.any(Array) } }); noWrites(prisma); noReads(prisma); + }); + it(`${kind} import explains no normalized rows`, async () => { + const { app, prisma } = await buildApp(); expectError(await app.inject({ method: "POST", url: `/api/travel/${kind}/import`, payload: { [kind]: [{}] } }), 400, `${reason}.import_empty`, { [kind]: "empty_after_normalization" }); noWrites(prisma); noReads(prisma); + }); + it(`${kind} import preserves permission before validation`, async () => { + const { app, prisma } = await buildApp({ allowed: false }); const response = await app.inject({ method: "POST", url: `/api/travel/${kind}/import`, payload: {} }); expect(response.statusCode).toBe(403); noWrites(prisma); noReads(prisma); + }); + if (kind !== "orders") it(`${kind} list explains invalid query`, async () => { + const { app, prisma } = await buildApp(); const response = await app.inject({ method: "GET", url: `/api/travel/${kind}?page=0` }); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe(`${reason}.query_invalid`); noWrites(prisma); noReads(prisma); + }); +} +it.each([ + ["/travel-orders/bad/mark-paid", {}, "travel_order.id_invalid", { id: "invalid_uuid" }], + ["/travel/transactions/bad/freeze-suggestion", {}, "travel_transaction.id_invalid", { id: "invalid_uuid" }], +])("explains invalid id at %s", async (path, payload, reason, details) => { + const { app, prisma } = await buildApp(); expectError(await app.inject({ method: "POST", url: "/api" + path, payload }), 400, reason, details); noWrites(prisma); noReads(prisma); +}); +it.each([ + [`/travel-orders/${ID}/mark-paid`, { paymentDate: "2026-02-30" }, "travel_order.payment_input_invalid"], + [`/travel/transactions/${ID}/freeze-suggestion`, { reasonCode: "bad" }, "travel_transaction.freeze_input_invalid"], +])("explains invalid operation input at %s", async (path, payload, reason) => { + const { app, prisma } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api" + path, payload }); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe(reason); noWrites(prisma); noReads(prisma); +}); +it.each(["status", "conflict", "settlement"])("keeps payment %s guard without writes or ERP outbox", async (state) => { + const { app, prisma, current } = await buildApp(); if (state === "status") current.status = "pending"; if (state === "conflict") { current.status = "paid"; current.payment_voucher_no = "SYNTHETIC"; } + const reason = { status: "invalid_pay_status", conflict: "already_paid_conflict", settlement: "settled_transaction_required" }[state]; + const details = state === "status" ? { status: "invalid_pay_status" } : { payment: state === "conflict" ? "already_paid_conflict" : "missing_settled_transaction" }; + expectError(await app.inject({ method: "POST", url: `/api/travel-orders/${ID}/mark-paid`, payload: {} }), 409, `travel_order.${reason}`, details); noWrites(prisma); + if (state !== "settlement") expect(prisma.cardTransaction.findFirst).not.toHaveBeenCalled(); +}); +function budgetContext() { return { $queryRaw: vi.fn().mockResolvedValue([]), budgetLedgerEntry: { findMany: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({}) }, budgetPool: { findUnique: vi.fn().mockResolvedValue({ id: ID, used_amount: "10.00" }), update: vi.fn().mockResolvedValue({}) } }; } +const budgetArgs = { budgetPoolId: ID, entityId: ID, actorId: ID, amount: "12.50", note: "synthetic" }; +it.each(["authorizeBudgetForEntity", "reverseBudgetForEntity"])("%s preserves missing pool error after existing ledger attempt", async (method) => { + const tx = budgetContext(); tx.budgetPool.findUnique.mockResolvedValue(null); + await expect(paymentService[method](tx, budgetArgs)).rejects.toMatchObject({ code: "VALID_001", statusCode: 409, details: { budgetPoolId: "not_found", reasonCode: "travel_payment.budget_pool_not_found" } }); + expect(tx.budgetLedgerEntry.create).toHaveBeenCalledTimes(1); expect(tx.budgetLedgerEntry.create.mock.invocationCallOrder[0]).toBeLessThan(tx.budgetPool.findUnique.mock.invocationCallOrder[0]); expect(tx.budgetPool.update).not.toHaveBeenCalled(); +}); +it.each([["authorizeBudgetForEntity", "22.50"], ["reverseBudgetForEntity", "0.00"]])("%s preserves usage delta and nonnegative floor", async (method, amount) => { + const tx = budgetContext(); expect(await paymentService[method](tx, budgetArgs)).toMatchObject({ applied: true, duplicated: false }); expect(tx.budgetPool.update).toHaveBeenCalledWith({ where: { id: ID }, data: { used_amount: amount, updated_by: ID } }); +}); +it("settlement preserves ledger-only behavior without checking pool or changing usage", async () => { + const tx = budgetContext(); tx.budgetPool.findUnique.mockResolvedValue(null); expect(await paymentService.settleBudgetForEntity(tx, budgetArgs)).toMatchObject({ applied: true, settledAmount: "12.50" }); expect(tx.budgetLedgerEntry.create).toHaveBeenCalledTimes(1); expect(tx.budgetPool.findUnique).not.toHaveBeenCalled(); expect(tx.budgetPool.update).not.toHaveBeenCalled(); +}); +it.each([["authorizeBudgetForEntity", "authorize"], ["settleBudgetForEntity", "settle"], ["reverseBudgetForEntity", "reverse"]])("%s preserves repeat and zero-amount idempotency", async (method, kind) => { + const tx = budgetContext(); tx.budgetLedgerEntry.findMany.mockResolvedValue([{ budget_pool_id: ID, kind, amount: "12.50" }]); expect(await paymentService[method](tx, budgetArgs)).toMatchObject({ applied: false, duplicated: true }); expect(await paymentService[method](tx, { ...budgetArgs, amount: "0.00" })).toMatchObject({ applied: false }); expect(tx.budgetLedgerEntry.create).not.toHaveBeenCalled(); expect(tx.budgetPool.update).not.toHaveBeenCalled(); +}); diff --git a/server/tests/unit/routes/travel-request.errors.test.js b/server/tests/unit/routes/travel-request.errors.test.js new file mode 100644 index 00000000..0acd02b4 --- /dev/null +++ b/server/tests/unit/routes/travel-request.errors.test.js @@ -0,0 +1,168 @@ +const Fastify = require("fastify"); +const { AppError } = require("../../../src/utils/AppError"); +const feeControl = require("../../../src/services/feeControl"); +const workflow = require("../../../src/utils/workflow"); +const budget = require("../../../src/services/budgetPool"); +const purchaseBudget = require("../../../src/services/purchaseBudget"); +const notifications = require("../../../src/services/notifications"); +const email = require("../../../src/services/email"); +const dispatcher = require("../../../src/services/notificationDispatcher"); +const mocks = {}; +const overrides = [ + [feeControl, "resolveEntityPrecheck"], [feeControl, "createRiskFlagsFromPrecheck"], + [workflow, "parseWorkflowSteps"], [workflow, "resolveActionableStep"], + [budget, "resolveApplicableBudgetPoolByContext"], [purchaseBudget, "reserveBudgetForEntity"], [purchaseBudget, "releaseReservedBudgetIfAny"], + [notifications, "resolveRecipientsForAssignee"], [notifications, "createNotificationsWithRecords"], + [email, "sendNotificationEmailSafe"], [dispatcher, "flushNotificationDispatchTasks"], +]; +const saved = overrides.map(([object, key]) => object[key]); +for (const [object, key] of overrides) object[key] = mocks[key] = vi.fn(); +const routePath = require.resolve("../../../src/routes/travelRequests"); +const savedRoute = require.cache[routePath]; delete require.cache[routePath]; +const routes = require(routePath); +// The route captures its direct collaborators; restore shared modules immediately. +overrides.forEach(([object, key], index) => { object[key] = saved[index]; }); +afterAll(() => { if (savedRoute) require.cache[routePath] = savedRoute; else delete require.cache[routePath]; }); +const ID = "11111111-1111-4111-8111-111111111111"; +const EMPLOYEE = "22222222-2222-4222-8222-222222222222"; +const COMPANY = "33333333-3333-4333-8333-333333333333"; +const CENTER = "44444444-4444-4444-8444-444444444444"; +const PROJECT = "55555555-5555-4555-8555-555555555555"; +const segment = { segmentRole: "outbound", transportType: "train", fromCity: "合成甲地", toCity: "合成乙地", departAt: "2026-01-01T01:00:00Z", arriveAt: "2026-01-01T02:00:00Z" }; +function payload() { return { purpose: "合成差旅", tripType: "domestic", startDate: "2026-01-01", endDate: "2026-01-02", legalEntityId: COMPANY, costCenterId: CENTER, estimatedAmount: "12.50", segments: [{ ...segment }] }; } +const apps = []; +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); +beforeEach(() => { + Object.values(mocks).forEach((mock) => mock.mockReset()); + mocks.resolveEntityPrecheck.mockResolvedValue({ decision: "pass", blockingReasons: [], riskFlags: [] }); + mocks.createRiskFlagsFromPrecheck.mockResolvedValue([]); + mocks.parseWorkflowSteps.mockReturnValue([{ id: "step-1" }]); + mocks.resolveActionableStep.mockResolvedValue({ actionable: { step: { id: "step-1" }, assigneeId: PROJECT, assigneeRole: null }, skipped: [] }); + mocks.resolveApplicableBudgetPoolByContext.mockResolvedValue({ id: CENTER }); + mocks.reserveBudgetForEntity.mockResolvedValue({ reserved: true }); mocks.releaseReservedBudgetIfAny.mockResolvedValue({ released: true }); + mocks.resolveRecipientsForAssignee.mockResolvedValue([]); mocks.createNotificationsWithRecords.mockResolvedValue([]); + mocks.sendNotificationEmailSafe.mockResolvedValue({}); mocks.flushNotificationDispatchTasks.mockResolvedValue({}); +}); +async function buildApp({ allowed = true, actorId = EMPLOYEE, gateEnabled = true } = {}) { + const app = Fastify(); apps.push(app); + const current = { id: ID, request_no: "SYNTHETIC", applicant_employee_id: EMPLOYEE, submitter_id: null, legal_entity_id: COMPANY, cost_center_id: CENTER, start_date: new Date("2026-01-01T00:00:00Z"), end_date: new Date("2026-01-02T00:00:00Z"), estimated_amount: "12.50", status: "draft", segments: [{ ...segment }], travel_orders: [], workflow_instances: [] }; + const company = { id: COMPANY, enabled: true, archived_at: null }; + const center = { id: CENTER, legal_entity_id: COMPANY, enabled: true, archived_at: null }; + const prisma = { + travelRequest: { findUnique: vi.fn(async () => current), findMany: vi.fn(async () => [current]), count: vi.fn().mockResolvedValue(1), create: vi.fn(async ({ data }) => ({ ...current, ...data, segments: data.segments.create })), update: vi.fn(async ({ data }) => Object.assign(current, data)) }, + legalEntity: { findUnique: vi.fn(async () => company) }, costCenter: { findUnique: vi.fn(async () => center) }, + travelRequestSegment: { deleteMany: vi.fn().mockResolvedValue({}), createMany: vi.fn().mockResolvedValue({}) }, + featureFlag: { findUnique: vi.fn(async ({ where }) => ({ flag_key: where.flag_key, override_mode: "force_on", rollout_type: "global", department_ids: [] })) }, + workflowDefinition: { findFirst: vi.fn().mockResolvedValue({ id: PROJECT, definition_json: {} }) }, + workflowInstance: { create: vi.fn().mockResolvedValue({ id: PROJECT }), findFirst: vi.fn().mockResolvedValue(null), update: vi.fn().mockResolvedValue({}) }, + workflowTask: { create: vi.fn().mockResolvedValue({ id: PROJECT, assignee_id: PROJECT }), updateMany: vi.fn().mockResolvedValue({}) }, + workflowHistory: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + entityRiskFlag: { findMany: vi.fn().mockResolvedValue([]) }, + $queryRaw: vi.fn(async () => [{ id: ID }]), + }; + prisma.$transaction = vi.fn(async (callback) => callback(prisma)); + app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", projectLinkingEnabled: gateEnabled }); + app.decorate("authenticate", async (request) => { request.employee = { id: actorId, permissionCodes: allowed ? ["travel.create", "travel.submit", "travel.view_own"] : [] }; }); + app.decorate("hasPermission", (employee, permission) => employee.permissionCodes.includes(permission)); + app.decorate("requirePermission", (permission) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permission)) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); }); + app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code ?? "SYS_001", details: error.details ?? {} } })); + await app.register(routes, { prefix: "/api" }); return { app, prisma, current, company, center }; +} +function noWrites(prisma) { + for (const method of [prisma.travelRequest.create, prisma.travelRequest.update, prisma.travelRequestSegment.deleteMany, prisma.travelRequestSegment.createMany, prisma.workflowInstance.create, prisma.workflowInstance.update, prisma.workflowTask.create, prisma.workflowTask.updateMany, prisma.workflowHistory.create, prisma.auditLog.create]) expect(method).not.toHaveBeenCalled(); + for (const key of ["reserveBudgetForEntity", "releaseReservedBudgetIfAny", "createNotificationsWithRecords", "sendNotificationEmailSafe", "flushNotificationDispatchTasks"]) expect(mocks[key]).not.toHaveBeenCalled(); +} +function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); } +function req(method, suffix = "", body) { return { method, url: `/api/travel-requests${suffix}`, ...(body ? { payload: body } : {}) }; } + +it("regression: link-project rejects malformed id as 400 before reading or writing", async () => { + const { app, prisma } = await buildApp(); const response = await app.inject(req("POST", "/bad/link-project", { projectId: PROJECT })); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ id: "invalid_uuid" }); expect(prisma.travelRequest.findUnique).not.toHaveBeenCalled(); noWrites(prisma); +}); +it.each([PROJECT, null])("regression: link-project reaches valid binding or clearing for %s", async (projectId) => { + const { app, prisma } = await buildApp(); const response = await app.inject(req("POST", `/${ID}/link-project`, { projectId })); expect(response.statusCode).toBe(200); expect(response.json().data.item.projectId).toBe(projectId); + expect(prisma.travelRequest.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: ID }, data: { project_id: projectId, updated_by: EMPLOYEE } })); +}); +it.each([ + ["GET", "?page=0", undefined, "query_invalid"], ["POST", "", {}, "create_input_invalid"], + ["PATCH", `/${ID}`, { purpose: "" }, "update_input_invalid"], ["POST", `/${ID}/link-project`, { projectId: "bad" }, "project_link_input_invalid"], +])("explains %s %s invalid schema before business access", async (method, suffix, body, reason) => { + const { app, prisma } = await buildApp(); const response = await app.inject(req(method, suffix, body)); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `travel_request.${reason}`, fieldErrors: expect.any(Object) }); expect(prisma.travelRequest.findUnique).not.toHaveBeenCalled(); expect(prisma.travelRequest.findMany).not.toHaveBeenCalled(); noWrites(prisma); +}); +it.each([["GET", ""], ["PATCH", ""], ["POST", "/precheck"], ["POST", "/submit"], ["POST", "/withdraw"], ["GET", "/payment-track"]])("explains invalid request id in %s %s", async (method, suffix) => { + const { app, prisma } = await buildApp(); expectError(await app.inject(req(method, `/bad${suffix}`, method === "PATCH" ? { purpose: "合成" } : undefined)), 400, "travel_request.id_invalid", { id: "invalid_uuid" }); noWrites(prisma); expect(prisma.$queryRaw).not.toHaveBeenCalled(); +}); +it.each(["POST", "PATCH"].flatMap((method) => ["time", "segment-range", "date-range", "overflow"].map((state) => [method, state])))("preserves %s %s validation", async (method, state) => { + const { app, prisma } = await buildApp(); const body = payload(); let reason; let details; + if (state === "time") { body.segments[0].departAt = "invalid-time"; reason = "segment_time_invalid"; details = { "segments.0.departAt": "invalid_datetime" }; } + if (state === "segment-range") { body.segments[0].arriveAt = "2026-01-01T00:00:00Z"; reason = "segment_time_reversed"; details = { "segments.0.arriveAt": "must_be_after_departAt" }; } + if (state === "date-range") { body.endDate = "2025-12-31"; reason = "date_range_reversed"; details = { endDate: "must_be_after_startDate" }; } + if (state === "overflow") { body.estimatedAmount = "9".repeat(400); reason = "amount_invalid"; details = { estimatedAmount: "invalid_decimal" }; } + expectError(await app.inject(req(method, method === "PATCH" ? `/${ID}` : "", body)), 400, `travel_request.${reason}`, details); noWrites(prisma); +}); +it.each(["POST", "PATCH"].flatMap((method) => ["company-missing", "company-disabled", "company-archived", "center-missing", "center-disabled", "center-archived", "company-mismatch"].map((state) => [method, state])))("preserves %s financial reference %s", async (method, state) => { + const { app, prisma, company, center } = await buildApp(); + if (state === "company-missing") prisma.legalEntity.findUnique.mockResolvedValue(null); if (state === "company-disabled") company.enabled = false; if (state === "company-archived") company.archived_at = new Date(); + if (state === "center-missing") prisma.costCenter.findUnique.mockResolvedValue(null); if (state === "center-disabled") center.enabled = false; if (state === "center-archived") center.archived_at = new Date(); if (state === "company-mismatch") center.legal_entity_id = PROJECT; + const mismatch = state === "company-mismatch"; const companyError = state.startsWith("company-") && !mismatch; + expectError(await app.inject(req(method, method === "PATCH" ? `/${ID}` : "", method === "POST" ? payload() : { purpose: "合成修改" })), 409, `travel_request.${mismatch ? "cost_center_company_mismatch" : companyError ? "company_unavailable" : "cost_center_unavailable"}`, { [companyError ? "legalEntityId" : "costCenterId"]: mismatch ? "legal_entity_mismatch" : "not_found_or_disabled" }); noWrites(prisma); +}); +it("preserves empty patch refusal before locking", async () => { + const { app, prisma } = await buildApp(); expectError(await app.inject(req("PATCH", `/${ID}`, {})), 400, "travel_request.update_empty", { body: "empty_patch" }); expect(prisma.$queryRaw).not.toHaveBeenCalled(); noWrites(prisma); +}); +it.each([["PATCH", "", "approved", "only_draft_editable"], ["POST", "/submit", "reviewing", "only_draft_submittable"], ["POST", "/withdraw", "draft", "only_reviewing_withdrawable"]])("preserves %s %s state guard", async (method, suffix, status, reason) => { + const { app, prisma, current } = await buildApp(); current.status = status; + expectError(await app.inject(req(method, `/${ID}${suffix}`, method === "PATCH" ? { purpose: "合成" } : undefined)), 409, `travel_request.${reason}`, { status: reason }); noWrites(prisma); expect(mocks.resolveEntityPrecheck).not.toHaveBeenCalled(); +}); +it("requires segments before precheck or reservation", async () => { + const { app, prisma, current } = await buildApp(); current.segments = []; expectError(await app.inject(req("POST", `/${ID}/submit`)), 409, "travel_request.segments_required", { segments: "required" }); expect(mocks.resolveEntityPrecheck).not.toHaveBeenCalled(); noWrites(prisma); +}); +it("preserves blocking precheck details with no later risk/approval writes", async () => { + const { app, prisma } = await buildApp(); const reasons = [{ code: "synthetic_budget_block", message: "合成预检" }]; mocks.resolveEntityPrecheck.mockResolvedValue({ decision: "block", blockingReasons: reasons }); + expectError(await app.inject(req("POST", `/${ID}/submit`)), 409, "travel_request.precheck_blocked", { precheck: reasons }); noWrites(prisma); expect(mocks.createRiskFlagsFromPrecheck).not.toHaveBeenCalled(); expect(prisma.workflowDefinition.findFirst).not.toHaveBeenCalled(); +}); +it.each(["missing", "no-approver"])("preserves workflow %s refusal after precheck", async (state) => { + const { app, prisma } = await buildApp(); if (state === "missing") prisma.workflowDefinition.findFirst.mockResolvedValue(null); else mocks.resolveActionableStep.mockResolvedValue({ actionable: null, skipped: [] }); + expectError(await app.inject(req("POST", `/${ID}/submit`)), 409, `travel_request.${state === "missing" ? "workflow_missing" : "approver_unavailable"}`, { workflow: state === "missing" ? "missing_definition" : "no_actionable_approver" }); + expect(mocks.createRiskFlagsFromPrecheck).toHaveBeenCalledTimes(1); noWrites(prisma); expect(mocks.resolveApplicableBudgetPoolByContext.mock.calls[0]).toStrictEqual([prisma, { legalEntityId: COMPANY, costCenterId: CENTER, effectiveDate: new Date("2026-01-01T00:00:00Z") }, { forUpdate: true }]); +}); +it("preserves project linking gate before input/read/write", async () => { + const { app, prisma } = await buildApp({ gateEnabled: false }); expectError(await app.inject(req("POST", "/bad/link-project", {})), 409, "travel_request.project_linking_disabled", { featureFlag: "project_linking_v1_disabled", reason: "env_hard_off" }); noWrites(prisma); expect(prisma.travelRequest.findUnique).not.toHaveBeenCalled(); +}); +it.each(["missing", "outsider"])("preserves link-project %s guard after id fix", async (state) => { + const { app, prisma } = await buildApp({ actorId: state === "outsider" ? PROJECT : EMPLOYEE }); if (state === "missing") prisma.travelRequest.findUnique.mockResolvedValue(null); + const response = await app.inject(req("POST", `/${ID}/link-project`, { projectId: PROJECT })); expect(response.statusCode).toBe(state === "missing" ? 404 : 403); expect(response.json().error.code).toBe(state === "missing" ? "SYS_404" : "PERM_001"); noWrites(prisma); +}); +it.each([["PATCH", ""], ["POST", "/submit"], ["POST", "/withdraw"]])("preserves applicant-only %s %s refusal", async (method, suffix) => { + const { app, prisma } = await buildApp({ actorId: PROJECT }); const response = await app.inject(req(method, `/${ID}${suffix}`, method === "PATCH" ? { purpose: "合成" } : undefined)); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe("PERM_001"); noWrites(prisma); +}); +it("preserves legal create, zero amount and equal segment timestamps", async () => { + const { app, prisma } = await buildApp(); const body = payload(); body.estimatedAmount = "0.00"; body.segments[0].arriveAt = body.segments[0].departAt; + const response = await app.inject(req("POST", "", body)); expect(response.statusCode).toBe(200); expect(prisma.travelRequest.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ estimated_amount: "0.00", segments: { create: [expect.objectContaining({ depart_at: new Date(segment.departAt), arrive_at: new Date(segment.departAt) })] } }) })); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); +}); +it("preserves partial date patch against retained boundary and valid segment replacement", async () => { + const { app, prisma } = await buildApp(); expectError(await app.inject(req("PATCH", `/${ID}`, { startDate: "2026-01-03" })), 400, "travel_request.date_range_reversed", { endDate: "must_be_after_startDate" }); noWrites(prisma); + const response = await app.inject(req("PATCH", `/${ID}`, { segments: [{ ...segment }] })); expect(response.statusCode).toBe(200); expect(prisma.travelRequestSegment.deleteMany).toHaveBeenCalledTimes(1); expect(prisma.travelRequestSegment.createMany).toHaveBeenCalledTimes(1); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); +}); +it("keeps travel submit on the global pool when a legacy department pool also exists", async () => { + const { app, prisma, current } = await buildApp(); + current.department_id = PROJECT; + const departmentPool = { id: COMPANY, department_id: PROJECT }; + const globalPool = { id: CENTER, department_id: null, legal_entity_id: null, cost_center_id: null }; + // Use the actual selector so an accidentally forwarded department changes the result. + mocks.resolveApplicableBudgetPoolByContext.mockImplementation(async (_tx, context) => + budget.selectBudgetPoolCandidate([departmentPool, globalPool], context)); + const response = await app.inject(req("POST", `/${ID}/submit`)); + expect(response.statusCode).toBe(200); + expect(mocks.resolveApplicableBudgetPoolByContext.mock.calls[0]).toStrictEqual([ + prisma, { legalEntityId: COMPANY, costCenterId: CENTER, effectiveDate: current.start_date }, { forUpdate: true }, + ]); + expect(mocks.resolveEntityPrecheck).toHaveBeenCalledWith(expect.objectContaining({ prisma, budgetPoolForWrite: globalPool })); + expect(mocks.reserveBudgetForEntity).toHaveBeenCalledWith(prisma, expect.objectContaining({ budgetPoolId: globalPool.id })); + expect(current.budget_pool_id).toBe(globalPool.id); +}); +it("preserves submit success using local collaborator stubs with no external dispatch", async () => { + const { app, prisma } = await buildApp(); const response = await app.inject(req("POST", `/${ID}/submit`)); expect(response.statusCode).toBe(200); expect(response.json().data.status).toBe("reviewing"); expect(prisma.workflowInstance.create).toHaveBeenCalledTimes(1); expect(prisma.workflowTask.create).toHaveBeenCalledTimes(1); expect(mocks.reserveBudgetForEntity).toHaveBeenCalledTimes(1); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); expect(mocks.flushNotificationDispatchTasks).toHaveBeenCalledTimes(1); +}); +it("preserves valid withdrawal and budget release using a local stub", async () => { + const { app, prisma, current } = await buildApp(); current.status = "reviewing"; const response = await app.inject(req("POST", `/${ID}/withdraw`)); expect(response.statusCode).toBe(200); expect(prisma.travelRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: "draft" }) })); expect(mocks.releaseReservedBudgetIfAny).toHaveBeenCalledTimes(1); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); +}); diff --git a/server/tests/unit/routes/travelAuthorizations.test.js b/server/tests/unit/routes/travelAuthorizations.test.js index 7a95d7f2..e3c7f443 100644 --- a/server/tests/unit/routes/travelAuthorizations.test.js +++ b/server/tests/unit/routes/travelAuthorizations.test.js @@ -30,6 +30,7 @@ function buildAuthorizationRecord(overrides = {}) { function buildPrisma({ employees = [], travelRequests = [], existing = null, existingLedger = [] } = {}) { const prisma = { + $queryRaw: vi.fn().mockResolvedValue([]), employee: { findMany: vi.fn().mockResolvedValue(employees), }, diff --git a/server/tests/unit/routes/travelOrders.test.js b/server/tests/unit/routes/travelOrders.test.js index f1aa65f3..350055bc 100644 --- a/server/tests/unit/routes/travelOrders.test.js +++ b/server/tests/unit/routes/travelOrders.test.js @@ -255,7 +255,7 @@ describe("travelOrders routes", () => { await app.close(); }); - it("marks confirmed order as paid and enqueues ERP journal", async () => { + it("marks confirmed order as paid without ERP side effects", async () => { const { prisma, state } = buildPrisma({ currentOrder: buildTravelOrderRecord(), }); @@ -279,11 +279,10 @@ describe("travelOrders routes", () => { paymentVoucherNo: "BANK-001", paymentDate: "2026-03-10", paymentChannel: "bank_transfer", - erpJournal: expect.objectContaining({ enqueued: true, outboxId: "outbox-1" }), }), ); expect(state.currentOrder.status).toBe("paid"); - expect(prisma.integrationOutbox.create).toHaveBeenCalledTimes(1); + expect(prisma.integrationOutbox.create).not.toHaveBeenCalled(); await app.close(); }); @@ -315,9 +314,6 @@ describe("travelOrders routes", () => { }); expect(response.statusCode).toBe(200); - expect(response.json().data.erpJournal).toEqual( - expect.objectContaining({ enqueued: true, outboxId: "outbox-existing" }), - ); expect(prisma.travelOrder.update).not.toHaveBeenCalled(); await app.close(); @@ -346,7 +342,7 @@ describe("travelOrders routes", () => { }); expect(response.statusCode).toBe(409); - expect(response.json().error.details).toEqual({ payment: "already_paid_conflict" }); + expect(response.json().error.details).toEqual({ payment: "already_paid_conflict", reasonCode: "travel_order.already_paid_conflict" }); await app.close(); }); @@ -370,34 +366,35 @@ describe("travelOrders routes", () => { }); expect(response.statusCode).toBe(409); - expect(response.json().error.details).toEqual({ payment: "missing_settled_transaction" }); - - await app.close(); - }); - - it("skips ERP enqueue when gate is off", async () => { - const { prisma } = buildPrisma({ - currentOrder: buildTravelOrderRecord(), - }); - const app = await buildApp({ prisma, config: { erpSyncEnabled: false } }); - - const response = await app.inject({ - method: "POST", - url: "/api/travel-orders/11111111-1111-4111-8111-111111111111/mark-paid", - headers: { authorization: "Bearer test" }, - payload: { - paymentVoucherNo: "BANK-001", - paymentDate: "2026-03-10", - paymentChannel: "bank_transfer", - }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json().data.erpJournal).toEqual( - expect.objectContaining({ enqueued: false, outboxId: null }), - ); - expect(prisma.integrationOutbox.create).not.toHaveBeenCalled(); + expect(response.json().error.details).toEqual({ payment: "missing_settled_transaction", reasonCode: "travel_order.settled_transaction_required" }); await app.close(); }); }); + + +describe("travel payment calendar input", () => { + it.each(["2026-02-29", "2026-04-31", "2026-13-01", ""])("rejects %s before payment side effects", async (date) => { + const { prisma, state } = buildPrisma({ currentOrder: buildTravelOrderRecord() }); + const app = await buildApp({ prisma }); + try { + const response = await app.inject({ method: "POST", url: `/api/travel-orders/${state.currentOrder.id}/mark-paid`, headers: { authorization: "Bearer test" }, payload: { paymentDate: date } }); + expect(response.statusCode).toBe(400); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.travelOrder.update).not.toHaveBeenCalled(); + expect(prisma.integrationOutbox.create).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(state.currentOrder.status).toBe("confirmed"); + } finally { await app.close(); } + }); + it.each(["2024-02-29", "2026-04-30", null, undefined])("preserves payment date %s", async (date) => { + const { prisma, state } = buildPrisma({ currentOrder: buildTravelOrderRecord() }); + const app = await buildApp({ prisma }); + try { + const response = await app.inject({ method: "POST", url: `/api/travel-orders/${state.currentOrder.id}/mark-paid`, headers: { authorization: "Bearer test" }, payload: { paymentVoucherNo: "SYNTHETIC", paymentChannel: "bank_transfer", ...(date === undefined ? {} : { paymentDate: date }) } }); + expect(response.statusCode).toBe(200); + expect(state.currentOrder.payment_date).toEqual(date ? new Date(`${date}T00:00:00.000Z`) : null); + expect(state.currentOrder.status).toBe("paid"); + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/travelReconciliation.test.js b/server/tests/unit/routes/travelReconciliation.test.js index 989860b9..91f1bed5 100644 --- a/server/tests/unit/routes/travelReconciliation.test.js +++ b/server/tests/unit/routes/travelReconciliation.test.js @@ -564,7 +564,7 @@ describe('travelReconciliation routes', () => { await app.close(); }); - it('finalizes fully confirmed batch and enqueues ERP export when gate is on', async () => { + it('finalizes confirmed batch without retired ERP side effects', async () => { const { prisma, state } = buildPrisma({ orders: [buildOrder()], transactions: [buildTransaction()], @@ -596,14 +596,9 @@ describe('travelReconciliation routes', () => { expect.objectContaining({ batchId, status: 'reconciled', - erpExport: expect.objectContaining({ - eventType: 'erp.travel.export', - enqueued: true, - }), }), ); - expect(state.integrationOutboxes).toHaveLength(1); - expect(state.integrationOutboxes[0].event_type).toBe('erp.travel.export'); + expect(state.integrationOutboxes).toHaveLength(0); await app.close(); }); @@ -648,3 +643,86 @@ describe('travelReconciliation routes', () => { await app.close(); }); }); + + +describe('travel reconciliation calendar input', () => { + it.each(['POST', 'GET'].flatMap((method) => ['periodStart', 'periodEnd'].map((field) => [method, field])))('rejects %s invalid %s before queries and writes', async (method, field) => { + const { prisma, state } = buildPrisma(); + const app = await buildApp({ prisma }); + try { + const input = { legalEntityId: '44444444-4444-4444-8444-444444444444', periodStart: '2026-04-01', periodEnd: '2026-04-30', [field]: '2026-04-31' }; + const response = await app.inject({ method, url: '/api/travel/reconciliation/batches' + (method === 'GET' ? `?${new URLSearchParams(input)}` : ''), headers: { authorization: 'Bearer test' }, ...(method === 'POST' ? { payload: input } : {}) }); + expect(response.statusCode).toBe(400); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.travelReconciliationBatch.findMany).not.toHaveBeenCalled(); + expect(state.batches).toEqual([]); + expect(state.auditLogs).toEqual([]); + expect(state.integrationOutboxes).toEqual([]); + } finally { await app.close(); } + }); + it('keeps leap-day/month-end, optional filters and reversed-period semantics', async () => { + const { prisma } = buildPrisma({ orders: [buildOrder({ payment_date: new Date('2024-03-10T00:00:00.000Z') })], transactions: [buildTransaction({ occurred_at: new Date('2024-03-10T08:30:00.000Z') })] }); + const app = await buildApp({ prisma }); + try { + const base = { legalEntityId: '44444444-4444-4444-8444-444444444444', periodStart: '2024-02-29', periodEnd: '2024-03-31' }; + const created = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: base }); + expect(created.statusCode).toBe(200); + const listed = await app.inject({ method: 'GET', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' } }); + expect(listed.statusCode).toBe(200); + prisma.$transaction.mockClear(); + const reversed = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { ...base, periodStart: '2024-04-30' } }); + expect(reversed.statusCode).toBe(409); + expect(reversed.json().error.details.period).toBe('period_start_after_period_end'); + expect(prisma.$transaction).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); + +const errorApps = []; +afterEach(async () => { await Promise.all(errorApps.splice(0).map((app) => app.close())); }); +const ERROR_ID = '66666666-6666-4666-8666-666666666666'; +const ERROR_SCOPE = { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31' }; +async function errorContext(options) { const context = buildPrisma(options); const app = await buildApp({ prisma: context.prisma }); errorApps.push(app); return { ...context, app }; } +function errorRequest(method, path, payload) { return { method, url: `/api/travel/reconciliation${path}`, headers: { authorization: 'Bearer synthetic' }, ...(payload ? { payload } : {}) }; } +function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error.details).toEqual({ ...details, reasonCode }); expect(response.json().error.code).toBe('VALID_001'); } +function noMutations(prisma) { for (const model of [prisma.travelReconciliationBatch, prisma.travelReconciliationItem, prisma.integrationOutbox, prisma.auditLog]) for (const method of ['create', 'createMany', 'update', 'upsert']) if (model?.[method]) expect(model[method]).not.toHaveBeenCalled(); } +it.each([ + ['GET', '/batches?page=0', undefined, 'query_invalid'], ['POST', '/batches', {}, 'create_input_invalid'], + ['POST', `/items/${ERROR_ID}/confirm`, { note: 'x'.repeat(501) }, 'confirm_input_invalid'], +])('explains %s %s input before transaction or queries', async (method, path, payload, reason) => { + const { app, prisma } = await errorContext(); const response = await app.inject(errorRequest(method, path, payload)); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `travel_reconciliation.${reason}`, fieldErrors: expect.any(Object) }); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); +}); +it.each([['GET', '/batches/bad', 'batchId'], ['POST', '/batches/bad/finalize', 'batchId'], ['POST', '/items/bad/confirm', 'itemId'], ['POST', '/items/bad/reopen', 'itemId']])('explains %s %s invalid id with the original 400', async (method, path, key) => { + const { app, prisma } = await errorContext(); expectError(await app.inject(errorRequest(method, path, {})), 400, `travel_reconciliation.${key === 'batchId' ? 'batch' : 'item'}_id_invalid`, { [key]: 'invalid_uuid' }); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); +}); +it.each(['period', 'company', 'center', 'mismatch', 'empty'])('preserves batch creation %s refusal as 409', async (state) => { + const { app, prisma } = await errorContext(); const payload = { ...ERROR_SCOPE }; + if (state === 'period') payload.periodStart = '2026-04-01'; + if (state === 'company') prisma.legalEntity.findUnique.mockResolvedValue(null); + if (state === 'center') prisma.costCenter.findUnique.mockResolvedValue(null); + if (state === 'mismatch') prisma.costCenter.findUnique.mockResolvedValue({ id: ERROR_SCOPE.costCenterId, legal_entity_id: ERROR_ID }); + const [reason, details] = { period: ['period_reversed', { period: 'period_start_after_period_end' }], company: ['company_not_found', { legalEntityId: 'not_found' }], center: ['cost_center_not_found', { costCenterId: 'not_found' }], mismatch: ['cost_center_company_mismatch', { costCenterId: 'legal_entity_mismatch' }], empty: ['empty_scope', { batch: 'travel_reconciliation_empty_scope' }] }[state]; + expectError(await app.inject(errorRequest('POST', '/batches', payload)), 409, `travel_reconciliation.${reason}`, details); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); +}); +async function createdBatch(matched = false) { + const context = await errorContext({ orders: [buildOrder()], transactions: matched ? [buildTransaction()] : [] }); + const response = await context.app.inject(errorRequest('POST', '/batches', ERROR_SCOPE)); expect(response.statusCode).toBe(200); + // Preserve the synthetic database contents while clearing prior creation call history. + for (const model of Object.values(context.prisma)) for (const method of Object.values(model ?? {})) if (vi.isMockFunction(method)) method.mockClear(); + return { ...context, batch: context.state.batches[0], item: context.state.items[0] }; +} +it.each(['confirm', 'reopen'])('preserves non-draft batch guard on %s', async (action) => { + const { app, prisma, state, batch, item } = await createdBatch(); batch.status = 'reconciled'; const before = JSON.stringify(state); + expectError(await app.inject(errorRequest('POST', `/items/${item.id}/${action}`, {})), 409, 'travel_reconciliation.batch_not_editable', { batch: 'reconciliation_batch_not_editable' }); noMutations(prisma); expect(JSON.stringify(state)).toBe(before); +}); +it.each(['already-confirmed', 'not-confirmed', 'matched', 'pending-items'])('preserves reconciliation %s service guard through actual route', async (condition) => { + const { app, prisma, state, batch, item } = await createdBatch(condition === 'matched'); + if (condition === 'already-confirmed') item.resolution_status = 'confirmed'; + const [path, reason, details] = { + 'already-confirmed': [`/items/${item.id}/confirm`, 'item_not_pending', { item: 'reconciliation_item_not_pending' }], + 'not-confirmed': [`/items/${item.id}/reopen`, 'item_not_confirmed', { item: 'reconciliation_item_not_confirmed' }], + matched: [`/items/${item.id}/reopen`, 'matched_item_not_reopenable', { item: 'matched_item_not_reopenable' }], + 'pending-items': [`/batches/${batch.id}/finalize`, 'batch_has_pending_items', { batch: 'reconciliation_batch_has_pending_items' }], + }[condition]; const before = JSON.stringify(state); + expectError(await app.inject(errorRequest('POST', path, {})), 409, `travel_reconciliation.${reason}`, details); noMutations(prisma); expect(JSON.stringify(state)).toBe(before); +}); diff --git a/server/tests/unit/routes/travelTransactions.test.js b/server/tests/unit/routes/travelTransactions.test.js index c9a0d6fa..c3f1b9a4 100644 --- a/server/tests/unit/routes/travelTransactions.test.js +++ b/server/tests/unit/routes/travelTransactions.test.js @@ -31,6 +31,7 @@ function buildPrisma({ }; const prisma = { + $queryRaw: vi.fn().mockResolvedValue([]), employee: { findMany: vi.fn().mockResolvedValue(employees), }, @@ -220,6 +221,7 @@ describe("travelTransactions routes", () => { it("writes reverse ledger for refund transactions", async () => { const { prisma } = buildPrisma({ travelOrders: [], + authorizations: [{ id: 'auth-1', employee_id: 'employee-1', travel_request_id: 'travel-1', budget_pool_id: 'pool-1' }], }); const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); @@ -231,6 +233,7 @@ describe("travelTransactions routes", () => { transactions: [{ source: "corp-card", externalTxnId: "TXN-REFUND-1", + cardAuthorizationId: 'auth-1', employeeId: "employee-1", travelRequestId: "travel-1", amount: "120.00", @@ -244,6 +247,10 @@ describe("travelTransactions routes", () => { expect(response.statusCode).toBe(200); expect(response.json().data).toEqual(expect.objectContaining({ reversedCount: 1, imported: 1 })); expect(prisma.budgetPool.update).toHaveBeenCalled(); + const authorizationLock = prisma.$queryRaw.mock.calls.findIndex(([query]) => Array.isArray(query) && query.join('').includes('card_authorizations')); + expect(authorizationLock).toBeGreaterThanOrEqual(0); + expect(prisma.$queryRaw.mock.invocationCallOrder[authorizationLock]).toBeLessThan(prisma.cardTransaction.upsert.mock.invocationCallOrder[0]); + expect(prisma.$queryRaw.mock.invocationCallOrder[authorizationLock]).toBeLessThan(prisma.budgetPool.update.mock.invocationCallOrder[0]); await app.close(); }); diff --git a/server/tests/unit/routes/users.import.test.js b/server/tests/unit/routes/users.import.test.js index 1c150a4c..2ed0d15a 100644 --- a/server/tests/unit/routes/users.import.test.js +++ b/server/tests/unit/routes/users.import.test.js @@ -1,10 +1,31 @@ const Fastify = require("fastify"); const multipart = require("@fastify/multipart"); const ExcelJS = require("exceljs"); +const JSZip = require("jszip"); +const fs = require("node:fs"); +const path = require("node:path"); +const ts = require("../../../../client/node_modules/typescript"); const usersRoutes = require("../../../src/routes/users"); const { AppError } = require("../../../src/utils/AppError"); +// Exercise the actual response/logging callback without importing app/config, +// opening a database connection or starting background jobs. +function productionErrorHandler(logError) { + const source = fs.readFileSync(path.resolve(__dirname, "../../../src/app.js"), "utf8"); + const ast = ts.createSourceFile("app.js", source, ts.ScriptTarget.Latest, true); + const handlers = []; + function visit(node) { + if (ts.isCallExpression(node) && node.expression.getText(ast) === "app.setErrorHandler") { + handlers.push(node.arguments[0].getText(ast)); + } + ts.forEachChild(node, visit); + } + visit(ast); + expect(handlers).toHaveLength(1); + return new Function("AppError", "app", `return (${handlers[0]});`)(AppError, { log: { error: logError } }); +} + function buildMultipartRequest({ filename, contentType, content }) { const boundary = "----codex-user-import-boundary"; const head = Buffer.from( @@ -122,8 +143,13 @@ async function buildApp(options = {}) { await app.register(multipart, { attachFieldsToBody: false, - limits: { fileSize: 1024 * 1024, files: 1 }, + limits: { fileSize: options.fileSize ?? 1024 * 1024, files: 1 }, }); + if (options.fileReader) { + app.addHook("preHandler", async (request) => { request.file = options.fileReader; }); + } + app.setErrorHandler(options.logError ? productionErrorHandler(options.logError) : + (error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); await app.register(usersRoutes, { prefix: "/api" }); return { @@ -440,3 +466,296 @@ describe("users import routes", () => { await app.close(); }); }); + +describe("user import calendar boundary", () => { + async function calendarUpload(format, dates) { + const rows = dates.map((hireDate, index) => ({ employeeNo: `000${index}`, name: `Synthetic ${index}`, email: `calendar${index}@example.test`, departmentName: "合成部门", title: "员工", roles: "employee", hireDate })); + const content = format === "xlsx" ? await buildXlsxBuffer(rows) : ["employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate", ...rows.map((r) => [r.employeeNo, r.name, "", r.email, r.departmentName, r.title, "", r.roles, r.hireDate].join(","))].join("\n"); + return buildMultipartRequest({ filename: `calendar.${format}`, contentType: format === "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv", content }); + } + + it.each(["csv", "xlsx"].flatMap((format) => ["dry-run", "apply"].map((action) => [format, action])))( + "%s %s keeps row errors and rejects a mixed invalid batch without writes", async (format, action) => { + const { app, employeeCreate, employeeUpdate, roleCreateMany, auditCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] }); + try { + const response = await app.inject({ method: "POST", url: `/api/users/import/${action}`, ...await calendarUpload(format, ["2024-02-29", "2026-02-29", "2026-04-31"]) }); + expect(response.statusCode).toBe(action === "apply" ? 409 : 200); + const result = action === "apply" ? response.json().error.details : response.json().data; + expect(result.summary).toEqual({ totalRows: 3, validRows: 1, invalidRows: 2 }); + expect(result.rows[0].errors).toEqual([]); + for (const row of result.rows.slice(1)) expect(row.errors).toContainEqual({ field: "hireDate", message: "invalid" }); + for (const write of [employeeCreate, employeeUpdate, roleCreateMany]) expect(write).not.toHaveBeenCalled(); + if (action === "apply") { + expect(auditCreate).not.toHaveBeenCalled(); + expect(result.reasonCode).toBe("user.import_rows_invalid"); + } + else expect(auditCreate).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: "SYSTEM_USERS_IMPORT_DRY_RUN" }) })); + } finally { await app.close(); } + }, + ); + + it.each(["csv", "xlsx"])("%s applies leap day, month end and blank date using the existing default", async (format) => { + const { app, employeeCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] }); + const { currentDateString } = require("../../../src/utils/date"); + try { + const before = currentDateString("Asia/Shanghai"); + const response = await app.inject({ method: "POST", url: "/api/users/import/apply", ...await calendarUpload(format, ["2024-02-29", "2026-04-30", ""]) }); + expect(response.statusCode).toBe(200); + expect(employeeCreate).toHaveBeenCalledTimes(3); + expect(employeeCreate.mock.calls[0][0].data.employee_no).toBe("0000"); + const dates = employeeCreate.mock.calls.map(([{ data }]) => data.hire_date.toISOString().slice(0, 10)); + expect(dates.slice(0, 2)).toEqual(["2024-02-29", "2026-04-30"]); + expect([before, currentDateString("Asia/Shanghai")]).toContain(dates[2]); + } finally { await app.close(); } + }); +}); + +describe("user import file reasons", () => { + const header = "employeeNo,name,email,departmentName\n"; + const row = "S001,合成员工,synthetic@example.invalid,合成部门\n"; + const cases = [ + ["file_too_large", "user.import_file_too_large", { filename: "users.csv", contentType: "text/csv", content: header + row.repeat(20) }, { fileSize: 64 }, {}], + ["missing", "user.import_file_missing", null, {}, {}], + ["unsupported_extension", "user.import_extension_unsupported", { filename: "users.txt", contentType: "text/csv", content: header + row }, {}, {}], + ["unsupported_mime_type", "user.import_mime_unsupported", { filename: "users.csv", contentType: "application/json", content: header + row }, {}, {}], + ["missing_headers", "user.import_headers_missing", { filename: "users.csv", contentType: "text/csv", content: "employeeNo,name\nS001,合成员工\n" }, {}, { missingHeaders: ["email", "departmentName"] }], + ["empty", "user.import_file_empty", { filename: "users.csv", contentType: "text/csv", content: header }, {}, {}], + ["row_limit_exceeded", "user.import_row_limit_exceeded", { filename: "users.csv", contentType: "text/csv", content: header + row.repeat(501) }, {}, { limit: 500 }], + ]; + it.each(cases.flatMap((item) => ["dry-run", "apply"].map((operation) => [operation, ...item])))("explains %s %s before business access", async (operation, file, reasonCode, upload, options, extra) => { + const { app, ...spies } = await buildApp(options); + try { + const request = upload ? buildMultipartRequest(upload) : { + payload: "--synthetic-empty--\r\n", + headers: { "content-type": "multipart/form-data; boundary=synthetic-empty" }, + }; + const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, ...request }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toEqual({ code: "VALID_001", details: { file, reasonCode, ...extra } }); + for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled(); + expect(app.prisma.$transaction).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it("allows 500 rows in preview and keeps the original preview audit", async () => { + const { app, employeeCreate, auditCreate } = await buildApp({ departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] }); + try { + const rows = Array.from({ length: 500 }, (_, index) => `S${index},合成员工${index},synthetic${index}@example.invalid,合成部门\n`).join(""); + const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run", ...buildMultipartRequest({ filename: "users.csv", contentType: "text/csv", content: header + rows }) }); + expect(response.statusCode).toBe(200); + expect(response.json().data.rows).toHaveLength(500); + expect(employeeCreate).not.toHaveBeenCalled(); + expect(app.prisma.$transaction).not.toHaveBeenCalled(); + expect(auditCreate).toHaveBeenCalledOnce(); + } finally { await app.close(); } + }); + + it("keeps permission rejection before import parsing", async () => { + const { app, ...spies } = await buildApp({ actor: { id: "synthetic-denied", permissionCodes: [] } }); + try { + const response = await app.inject({ method: "POST", url: "/api/users/import/apply", ...buildMultipartRequest({ filename: "users.txt", contentType: "text/plain", content: "synthetic" }) }); + expect(response.statusCode).toBe(403); + for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); + +describe("user import parser boundary", () => { + const marker = "SYNTHETIC_CELL_CANARY"; + const header = "employeeNo,name,email,departmentName\n"; + const mime = (format) => format === "csv" ? "text/csv" : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + let malformed; + + beforeAll(async () => { + const xlsx = await buildXlsxBuffer([]); + malformed = { + "unclosed-csv": ["csv", Buffer.from(`${header}\"${marker}`)], + "bad-quote-csv": ["csv", Buffer.from(`${header}\"${marker}\"x,synthetic`)], + "non-zip-xlsx": ["xlsx", Buffer.from(marker)], + "truncated-xlsx": ["xlsx", xlsx.subarray(0, Math.floor(xlsx.length / 2))], + }; + for (const [label, xml] of [ + ["closing-tag", `<${marker}>`], + ["entity", `&${marker};`], + ["attribute", ``], + ]) { + const zip = await JSZip.loadAsync(xlsx); + zip.file("xl/worksheets/sheet1.xml", xml); + malformed[`${label}-xlsx`] = ["xlsx", await zip.generateAsync({ type: "nodebuffer" })]; + } + }); + + afterEach(() => { vi.restoreAllMocks(); }); + + function expectNoBusinessAccess(app, spies) { + for (const fn of Object.values(spies)) expect(fn).not.toHaveBeenCalled(); + expect(app.prisma.$transaction).not.toHaveBeenCalled(); + } + + for (const operation of ["dry-run", "apply"]) { + it.each(["unclosed-csv", "bad-quote-csv", "non-zip-xlsx", "truncated-xlsx", "closing-tag-xlsx", "entity-xlsx", "attribute-xlsx"])(`${operation} explains %s without logging cells or accessing business data`, async (label) => { + const logError = vi.fn(); + const { app, ...spies } = await buildApp({ logError }); + try { + const [format, content] = malformed[label]; + const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, + ...buildMultipartRequest({ filename: `${marker}.${format}`, contentType: mime(format), content }) }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toEqual({ code: "VALID_001", message: "参数校验失败", + details: { file: "invalid_format", reasonCode: "user.import_file_invalid" } }); + expect(response.body).not.toContain(marker); + expect(logError).not.toHaveBeenCalled(); + expectNoBusinessAccess(app, spies); + } finally { await app.close(); } + }); + + it.each(["csv", "xlsx"])(`${operation} preserves 500 and discards unknown %s parser error content`, async (format) => { + const parserClass = require(`exceljs/lib/${format}/${format}`); + const original = Object.assign(new Error(`Internal csv xlsx ${marker}`), { + cause: new Error(marker), payload: marker, statusCode: 400, code: "FST_REQ_FILE_TOO_LARGE", + }); + vi.spyOn(parserClass.prototype, format === "csv" ? "read" : "load").mockRejectedValueOnce(original); + const logError = vi.fn(); + const { app, ...spies } = await buildApp({ logError }); + try { + const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, + ...buildMultipartRequest({ filename: `synthetic.${format}`, contentType: mime(format), content: header }) }); + expect(response.statusCode).toBe(500); + expect(response.json().error).toEqual({ code: "SYS_001", message: "服务器内部错误", details: {} }); + expect(response.body).not.toContain(marker); + expect(logError).toHaveBeenCalledOnce(); + const logged = logError.mock.calls[0][0].err; + expect(logged).not.toBe(original); + expect(logged.message).toBe("User import parser failed"); + expect(Object.getOwnPropertyNames(logged).sort()).toEqual(["message", "stack"]); + expect(logged.stack).not.toContain(marker); + expectNoBusinessAccess(app, spies); + } finally { await app.close(); } + }); + + it(`${operation} rejects permission before attempting even a malformed parse`, async () => { + const read = vi.spyOn(require("exceljs/lib/csv/csv").prototype, "read"); + const logError = vi.fn(); + const { app, ...spies } = await buildApp({ logError, actor: { id: "synthetic-denied", permissionCodes: [] } }); + try { + const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, + ...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"), content: `${header}\"${marker}` }) }); + expect(response.statusCode).toBe(403); + expect(read).not.toHaveBeenCalled(); + expect(logError).not.toHaveBeenCalled(); + expectNoBusinessAccess(app, spies); + } finally { await app.close(); } + }); + } + + it.each([ + ["csv", new TypeError('Parse Error: missing closing: \'"\' in line: at \'synthetic\'')], + ["xlsx", new TypeError("Corrupted zip: can't find end of central directory")], + ["csv", new Error("1:45: undefined entity.")], + ["xlsx", new Error('Parse Error: missing closing: \'"\' in line: at \'synthetic\'')], + ["xlsx", Object.assign(new Error("Corrupted zip: can't find end of central directory"), { code: "EIO" })], + ["csv", new Error("Parse Error: expected: ',' OR new line got: 'x'! at 'synthetic")], + ["csv", { message: 'Parse Error: missing closing: \'"\' in line: at \'synthetic\'' }], + ])("does not classify a different error type or parser's message as %s input failure", async (format, error) => { + vi.spyOn(require(`exceljs/lib/${format}/${format}`).prototype, format === "csv" ? "read" : "load").mockRejectedValueOnce(error); + const logError = vi.fn(); + const { app, ...spies } = await buildApp({ logError }); + try { + const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run", + ...buildMultipartRequest({ filename: `synthetic.${format}`, contentType: mime(format), content: header }) }); + expect(response.statusCode).toBe(500); + expect(logError.mock.calls[0][0].err.message).toBe("User import parser failed"); + expectNoBusinessAccess(app, spies); + } finally { await app.close(); } + }); + + it.each(["file", "toBuffer"])("keeps %s read failures outside the parser classification", async (stage) => { + const original = new Error('Parse Error: missing closing: \'"\' in line: at \'synthetic\''); + const fileReader = stage === "file" ? vi.fn().mockRejectedValue(original) : vi.fn().mockResolvedValue({ + filename: "synthetic.csv", mimetype: "text/csv", toBuffer: vi.fn().mockRejectedValue(original), + }); + const logError = vi.fn(); + const { app, ...spies } = await buildApp({ logError, fileReader }); + try { + const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run" }); + expect(response.statusCode).toBe(500); + expect(logError.mock.calls[0][0].err).toBe(original); + expectNoBusinessAccess(app, spies); + } finally { await app.close(); } + }); + + it("keeps quoted commas, escaped quotes and line breaks as CSV text", async () => { + const { app, employeeCreate, auditCreate } = await buildApp(); + try { + const response = await app.inject({ method: "POST", url: "/api/users/import/dry-run", + ...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"), + content: `${header}0001,"合成,\"\"姓名\"\"\n第二行",synthetic@example.invalid,合成部门\n` }) }); + expect(response.statusCode).toBe(200); + const row = response.json().data.rows[0]; + expect(row.normalized.employeeNo).toBe("0001"); + expect(row.normalized.name).toBe('合成,"姓名"\n第二行'); + expect(employeeCreate).not.toHaveBeenCalled(); + expect(auditCreate).toHaveBeenCalledOnce(); + } finally { await app.close(); } + }); + + it.each(["dry-run", "apply"])("keeps %s database failures outside format classification", async (operation) => { + const original = new Error("Corrupted zip: can't find end of central directory"); + const logError = vi.fn(); + const { app, departmentFindMany, employeeCreate, auditCreate } = await buildApp({ logError }); + departmentFindMany.mockRejectedValueOnce(original); + try { + const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, + ...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"), + content: `${header}S001,合成员工,synthetic@example.invalid,合成部门\n` }) }); + expect(response.statusCode).toBe(500); + expect(logError.mock.calls[0][0].err).toBe(original); + expect(employeeCreate).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + // Apply intentionally performs preview reads inside its transaction. + expect(app.prisma.$transaction).toHaveBeenCalledTimes(operation === "apply" ? 1 : 0); + } finally { await app.close(); } + }); + + it.each(["transaction", "hash"])("does not translate an apply %s failure into a file error", async (stage) => { + const original = new Error("Corrupted zip: can't find end of central directory"); + const logError = vi.fn(); + const { app, employeeCreate, auditCreate } = await buildApp({ logError, + departments: [{ id: "dept-1", name: "合成部门", archived_at: null }] }); + if (stage === "transaction") app.prisma.$transaction.mockRejectedValueOnce(original); + else vi.spyOn(require("bcryptjs"), "hash").mockRejectedValueOnce(original); + try { + const response = await app.inject({ method: "POST", url: "/api/users/import/apply", + ...buildMultipartRequest({ filename: "synthetic.csv", contentType: mime("csv"), + content: `${header.trim()},title,roles\nS001,合成员工,synthetic@example.invalid,合成部门,合成岗位,employee\n` }) }); + expect(response.statusCode).toBe(500); + expect(app.prisma.$transaction).toHaveBeenCalledOnce(); + expect(logError.mock.calls[0][0].err).toBe(original); + expect(employeeCreate).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + + it.each([["dry-run", 500, 200], ["dry-run", 501, 400], ["apply", 501, 400]])("preserves XLSX %s row limit at %s", async (operation, count, status) => { + const content = await buildXlsxBuffer(Array.from({ length: count }, (_, index) => ({ + employeeNo: `S${index}`, name: "合成员工", email: `synthetic${index}@example.invalid`, departmentName: "合成部门", + }))); + const logError = vi.fn(); + const { app, ...spies } = await buildApp({ logError }); + try { + const response = await app.inject({ method: "POST", url: `/api/users/import/${operation}`, + ...buildMultipartRequest({ filename: "synthetic.xlsx", contentType: mime("xlsx"), content }) }); + expect(response.statusCode).toBe(status); + expect(logError).not.toHaveBeenCalled(); + if (count === 501) { + expect(response.json().error.details).toEqual({ file: "row_limit_exceeded", limit: 500, reasonCode: "user.import_row_limit_exceeded" }); + expectNoBusinessAccess(app, spies); + } else { + expect(response.json().data.rows).toHaveLength(500); + expect(spies.auditCreate).toHaveBeenCalledOnce(); + expect(spies.employeeCreate).not.toHaveBeenCalled(); + expect(app.prisma.$transaction).not.toHaveBeenCalled(); + } + } finally { await app.close(); } + }); +}); diff --git a/server/tests/unit/routes/users.list.test.js b/server/tests/unit/routes/users.list.test.js index 68fa3331..150cd6a4 100644 --- a/server/tests/unit/routes/users.list.test.js +++ b/server/tests/unit/routes/users.list.test.js @@ -62,6 +62,15 @@ async function buildApp({ routeOptions = {}, employee } = {}) { }); app.decorate("config", {}); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + error: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }, + }); + } + return reply.status(500).send({ error: { code: "SYS_001" } }); + }); + await app.register(usersRoutes, { prefix: "/api", ...routeOptions }); return { app, findMany, count }; } @@ -269,3 +278,55 @@ describe("usersRoutes list query", () => { await app.close(); }); }); + + +describe("employee directory calendar filters", () => { + const routes = ["/users", "/employees"]; + it.each(routes.flatMap((route) => ["hireDateFrom", "hireDateTo"].flatMap((field) => ["2026-02-29", "2026-04-31", "2026-13-01", ""].map((date) => [route, field, date]))))("rejects %s %s=%s before employee queries", async (route, field, date) => { + const { app, findMany, count } = await buildApp({ routeOptions: { basePath: route, allowDepartmentFilter: route === "/employees" } }); + try { + const response = await app.inject({ method: "GET", url: `/api${route}?${field}=${date}` }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: "employee_directory.query_invalid", formErrors: [], fieldErrors: { [field]: expect.arrayContaining(["invalid_calendar_date"]) } } }); + expect(findMany).not.toHaveBeenCalled(); + expect(count).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it.each(routes)("preserves leap-day/month-end UTC range on %s", async (route) => { + const { app, findMany } = await buildApp({ routeOptions: { basePath: route } }); + try { + const response = await app.inject({ method: "GET", url: `/api${route}?hireDateFrom=2024-02-29&hireDateTo=2024-04-30` }); + expect(response.statusCode).toBe(200); + expect(findMany.mock.calls[0][0].where.hire_date).toEqual({ gte: new Date("2024-02-29T00:00:00.000Z"), lte: new Date("2024-04-30T23:59:59.999Z") }); + } finally { await app.close(); } + }); + it.each(routes)("keeps reversed-range rejection on %s", async (route) => { + const { app, findMany, count } = await buildApp({ routeOptions: { basePath: route } }); + try { + const response = await app.inject({ method: "GET", url: `/api${route}?hireDateFrom=2024-05-01&hireDateTo=2024-04-30` }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ reasonCode: "employee_directory.hire_date_range_invalid", hireDateTo: "must_be_after_or_equal_hireDateFrom" }); + expect(findMany).not.toHaveBeenCalled(); + expect(count).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it.each(routes)("does not grant access with valid dates on %s", async (route) => { + const { app, findMany, count } = await buildApp({ routeOptions: { basePath: route }, employee: { id: "synthetic-denied", permissionCodes: [] } }); + try { + const response = await app.inject({ method: "GET", url: `/api${route}?hireDateFrom=2024-02-29` }); + expect(response.statusCode).toBe(403); + expect(findMany).not.toHaveBeenCalled(); + expect(count).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); + +it.each(["/users", "/employees"])("explains invalid pagination for %s before queries", async (basePath) => { + const { app, findMany, count } = await buildApp({ routeOptions: { basePath, allowDepartmentFilter: true } }); + try { + const response = await app.inject({ method: "GET", url: `/api${basePath}?page=0` }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: "user.list_query_invalid", formErrors: [], fieldErrors: { page: expect.any(Array) } } }); + expect(findMany).not.toHaveBeenCalled(); expect(count).not.toHaveBeenCalled(); + } finally { await app.close(); } +}); diff --git a/server/tests/unit/routes/users.sensitive-permissions.test.js b/server/tests/unit/routes/users.sensitive-permissions.test.js index b78b901d..4d431639 100644 --- a/server/tests/unit/routes/users.sensitive-permissions.test.js +++ b/server/tests/unit/routes/users.sensitive-permissions.test.js @@ -27,7 +27,9 @@ async function buildApp(permissionCodes) { }); const employeeUpdate = vi.fn(async ({ data }) => ({ id: EMPLOYEE_ID, ...data })); const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" }); + const bankInstitutionFindFirst = vi.fn().mockResolvedValue(null); const transaction = vi.fn(async (callback) => callback({ + bankInstitution: { findFirst: bankInstitutionFindFirst }, employee: { findUnique: employeeFindUnique, update: employeeUpdate, @@ -70,7 +72,7 @@ async function buildApp(permissionCodes) { })); await app.register(usersRoutes, { prefix: "/api" }); - return { app, auditCreate, employeeFindUnique, employeeUpdate, transaction }; + return { app, auditCreate, employeeFindUnique, employeeUpdate, transaction, bankInstitutionFindFirst }; } describe("user sensitive field permissions", () => { @@ -194,3 +196,21 @@ describe("user sensitive field permissions", () => { await testApp.app.close(); }); }); + +it.each([ + ["PATCH", "/sensitive", { bankAccount: "invalid" }, 400, "user.sensitive_input_invalid", null], + ["PATCH", "/sensitive", {}, 400, "user.sensitive_update_empty", { body: "empty" }], + ["POST", "/sensitive/bank/reveal", { reason: "x" }, 400, "user.bank_reveal_input_invalid", null], + ["PATCH", "/sensitive", { bankInstitutionId: DEPARTMENT_ID }, 409, "user.bank_institution_unavailable", { bankInstitutionId: "not_available" }], +])("explains %s %s without sensitive writes or successful audit", async (method, suffix, payload, status, reasonCode, details) => { + const t = await buildApp(["*"]); + try { + const response = await t.app.inject({ method, url: `/api/users/${EMPLOYEE_ID}${suffix}`, payload }); + expect(response.statusCode).toBe(status); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode } }); + if (details) expect(response.json().error.details).toEqual({ ...details, reasonCode }); + else expect(response.json().error.details).toMatchObject({ formErrors: [], fieldErrors: expect.any(Object) }); + expect(t.employeeUpdate).not.toHaveBeenCalled(); expect(t.auditCreate).not.toHaveBeenCalled(); + if (status === 400) { expect(t.employeeFindUnique).not.toHaveBeenCalled(); expect(t.transaction).not.toHaveBeenCalled(); } + else expect(t.bankInstitutionFindFirst).toHaveBeenCalledTimes(1); + } finally { await t.app.close(); } +}); diff --git a/server/tests/unit/routes/users.write.test.js b/server/tests/unit/routes/users.write.test.js index 88661ae6..d29aa093 100644 --- a/server/tests/unit/routes/users.write.test.js +++ b/server/tests/unit/routes/users.write.test.js @@ -314,7 +314,7 @@ describe("usersRoutes write flows", () => { expect(response.json().error).toEqual( expect.objectContaining({ code: "VALID_001", - details: { employeeNo: "immutable" }, + details: { employeeNo: "immutable", reasonCode: "user.employee_no_immutable" }, }), ); expect(employeeFindUnique).not.toHaveBeenCalled(); @@ -552,3 +552,213 @@ describe("usersRoutes write flows", () => { await app.close(); }); }); + +describe("users hireDate calendar boundary", () => { + it.each(["POST", "PATCH"].flatMap((method) => ["2026-02-29", "2026-04-31", "2026-00-10"].map((date) => [method, date])))( + "%s rejects %s before account, role, audit or lifecycle writes", async (method, hireDate) => { + const fixture = await buildApp(); + const { app } = fixture; + try { + const response = await app.inject({ method, url: method === "POST" ? "/api/users" : `/api/users/${USER_ID}`, + payload: method === "POST" ? { name: "Synthetic", email: "calendar@example.test", roles: ["employee"], syncWecomDirectory: false, hireDate } : { hireDate } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.code).toBe("VALID_001"); + expect(response.json().error.details).toEqual({ formErrors: [], fieldErrors: { hireDate: ["invalid_calendar_date"] }, reasonCode: method === "POST" ? "user.create_input_invalid" : "user.update_input_invalid" }); + expect(app.prisma.$transaction).not.toHaveBeenCalled(); + for (const name of ["employeeCreate", "employeeUpdate", "employeeRoleCreateMany", "employeeRoleDeleteMany", "auditCreate", "lifecycleEventCreate", "checklistTaskCreateMany", "wecomSyncRunCreate"]) expect(fixture[name]).not.toHaveBeenCalled(); + } finally { await app.close(); } + }, + ); + + it.each(["POST", "PATCH"].flatMap((method) => ["2024-02-29", "2026-04-30", undefined].map((date) => [method, date])))( + "%s preserves valid or omitted hireDate %s", async (method, hireDate) => { + const { app, employeeCreate, employeeUpdate } = await buildApp(); + try { + const before = Date.now(); + const response = await app.inject({ method, url: method === "POST" ? "/api/users" : `/api/users/${USER_ID}`, + payload: method === "POST" ? { name: "Synthetic", email: "calendar@example.test", roles: ["employee"], syncWecomDirectory: false, ...(hireDate ? { hireDate } : {}) } : { title: "员工", ...(hireDate ? { hireDate } : {}) } }); + expect(response.statusCode).toBe(200); + const data = (method === "POST" ? employeeCreate : employeeUpdate).mock.calls[0][0].data; + if (hireDate) expect(data.hire_date.toISOString()).toBe(`${hireDate}T00:00:00.000Z`); + else if (method === "PATCH") expect(data).not.toHaveProperty("hire_date"); + else { expect(data.hire_date.getTime()).toBeGreaterThanOrEqual(before); expect(data.hire_date.getTime()).toBeLessThanOrEqual(Date.now()); } + } finally { await app.close(); } + }, + ); +}); + + +describe("user immutable and empty update reasons", () => { + it.each([[{ employeeNo: "SYNTHETIC" }, 409, { employeeNo: "immutable", reasonCode: "user.employee_no_immutable" }], [{}, 400, { body: "empty", reasonCode: "user.update_empty" }]])("rejects %j without account writes", async (payload, status, details) => { + const { app, employeeUpdate, auditCreate } = await buildApp(); + try { + const response = await app.inject({ method: "PATCH", url: `/api/users/${USER_ID}`, payload }); + expect(response.statusCode).toBe(status); + expect(response.json().error).toEqual({ code: "VALID_001", message: "参数校验失败", details }); + expect(app.prisma.$transaction).not.toHaveBeenCalled(); + expect(employeeUpdate).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); + +describe("remaining account error reasons", () => { + function expectNoWrites(t) { + for (const fn of [t.employeeCreate, t.employeeUpdate, t.employeeRoleCreateMany, t.employeeRoleDeleteMany, t.auditCreate, t.authSessionUpdateMany, t.passwordResetCodeUpdateMany, t.emailLoginCodeUpdateMany, t.lifecycleEventCreate, t.checklistTaskCreateMany, t.wecomSyncRunCreate]) expect(fn).not.toHaveBeenCalled(); + } + it.each([ + ["PATCH", "/api/users/bad", { name: "合成" }, "user.id_invalid", { id: "invalid_uuid" }], + ["POST", `/api/users/${USER_ID}/reset-password`, { newPassword: "" }, "user.reset_password_input_invalid", null], + ["POST", `/api/users/${USER_ID}/roles`, { roles: [] }, "user.roles_input_invalid", null], + ["POST", "/api/users/batch/transfer-department", { employeeIds: [] }, "user.batch_transfer_input_invalid", null], + ["POST", "/api/users/batch/set-manager", { employeeIds: [] }, "user.batch_manager_input_invalid", null], + ["POST", "/api/users/batch/disable", { employeeIds: [] }, "user.batch_toggle_input_invalid", null], + ["POST", "/api/users/batch/enable", { employeeIds: [] }, "user.batch_toggle_input_invalid", null], + ])("%s %s explains invalid input before transactions", async (method, url, payload, reasonCode, details) => { + const t = await buildApp(); + try { + const response = await t.app.inject({ method, url, payload }); + expect(response.statusCode).toBe(400); + expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode } }); + if (details) expect(response.json().error.details).toEqual({ ...details, reasonCode }); + else expect(response.json().error.details).toMatchObject({ formErrors: expect.any(Array), fieldErrors: expect.any(Object) }); + expect(t.app.prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(t); + } finally { await t.app.close(); } + }); + + it.each(["create", "reset"])("preserves password policy failure for %s", async (operation) => { + const t = await buildApp(); + try { + const response = await t.app.inject({ method: "POST", url: operation === "create" ? "/api/users" : `/api/users/${USER_ID}/reset-password`, payload: operation === "create" ? { name: "合成", email: "synthetic@example.test", password: "short" } : { newPassword: "short" } }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toMatchObject({ reasonCode: "user.password_policy_failed", [operation === "create" ? "password" : "newPassword"]: expect.arrayContaining(["密码长度必须 ≥ 12", "密码必须包含大写字母", "密码必须包含数字"]) }); + expect(t.app.prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(t); + } finally { await t.app.close(); } + }); + + it.each([ + ["department", null, { departmentId: DEPARTMENT_ID }, 400, "user.department_not_found", { departmentId: "not_found" }], + ["department", { id: DEPARTMENT_ID, archived_at: new Date("2024-01-01T00:00:00Z") }, { departmentId: DEPARTMENT_ID }, 409, "user.department_archived", { departmentId: "archived" }], + ["employee", null, { directManagerId: MANAGER_ID }, 400, "user.manager_not_found", { directManagerId: "not_found" }], + ["employee", { id: MANAGER_ID, status: "archived" }, { directManagerId: MANAGER_ID }, 409, "user.manager_inactive", { directManagerId: "inactive" }], + ["employee", null, { directManagerId: USER_ID }, 409, "user.manager_self_forbidden", { directManagerId: "self_not_allowed" }], + ])("explains unavailable %s reference", async (model, row, payload, status, reasonCode, details) => { + const t = await buildApp(); + try { + (model === "department" ? t.departmentFindUnique : t.employeeFindUnique).mockResolvedValue(row); + const response = await t.app.inject({ method: "PATCH", url: `/api/users/${USER_ID}`, payload }); + expect(response.statusCode).toBe(status); expect(response.json().error.details).toEqual({ ...details, reasonCode }); + expect(t.app.prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(t); + } finally { await t.app.close(); } + }); + + it.each(["archive", "disable", "batch/disable", "roles"])("keeps the self-protection guard for %s", async (operation) => { + const t = await buildApp({ actor: { id: ACTOR_ID, department_id: DEPARTMENT_ID, roleCodes: ["admin"], permissionCodes: ["*"] } }); + try { + const response = await t.app.inject({ method: "POST", url: operation === "batch/disable" ? "/api/users/batch/disable" : `/api/users/${ACTOR_ID}/${operation}`, ...(operation === "batch/disable" ? { payload: { employeeIds: Array(51).fill(ACTOR_ID) } } : operation === "roles" ? { payload: { roles: ["employee"] } } : {}) }); + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual(operation === "roles" ? { user: "self_role_change_not_allowed", reasonCode: "user.self_role_change_forbidden" } : { user: "self_disable_not_allowed", reasonCode: "user.self_disable_forbidden" }); + expect(t.app.prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(t); + } finally { await t.app.close(); } + }); + + it("limits distinct batch employees before transactions", async () => { + const t = await buildApp(); + try { + const employeeIds = Array.from({ length: 51 }, (_, i) => `00000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`); + const response = await t.app.inject({ method: "POST", url: "/api/users/batch/enable", payload: { employeeIds } }); + expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ employeeIds: "too_many", reasonCode: "user.batch_limit_exceeded" }); + expect(t.app.prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(t); + } finally { await t.app.close(); } + }); + + it("preserves feature refusal before batch validation", async () => { + const t = await buildApp(); + try { + t.app.prisma.featureFlag.findUnique.mockResolvedValue({ flag_key: "employee_lifecycle_v1", override_mode: "force_off", rollout_type: "global", department_ids: [] }); + const response = await t.app.inject({ method: "POST", url: "/api/users/batch/set-manager", payload: {} }); + expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ featureFlag: "employee_lifecycle_v1_disabled", reason: "flag_force_off", reasonCode: "user.lifecycle_disabled" }); + expect(t.app.prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(t); + } finally { await t.app.close(); } + }); + + it.each(["POST", "PATCH"])("keeps unique-conflict details for %s", async (method) => { + const t = await buildApp(); + try { + t.app.prisma.$transaction.mockRejectedValue({ code: "P2002", meta: { target: ["email"] } }); + const response = await t.app.inject({ method, url: method === "POST" ? "/api/users" : `/api/users/${USER_ID}`, payload: method === "POST" ? { name: "合成", email: "synthetic@example.test" } : { email: "synthetic@example.test" } }); + expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ email: "duplicate", reasonCode: "user.unique_conflict" }); expectNoWrites(t); + } finally { await t.app.close(); } + }); + + it("fails closed when a synthetic random-source fault produces an unsafe temporary password", async () => { + const t = await buildApp(); const random = vi.spyOn(require("node:crypto"), "randomBytes").mockReturnValueOnce(Buffer.alloc(0)); + try { + const response = await t.app.inject({ method: "POST", url: "/api/users", payload: { name: "合成", email: "synthetic@example.test" } }); + expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: "user.generated_password_unavailable", password: expect.arrayContaining(["密码长度必须 ≥ 12"]) }); + expect(t.app.prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(t); + } finally { random.mockRestore(); await t.app.close(); } + }); +}); + +describe("system-user creation transaction cost boundary", () => { + it("finishes real cost12 hashing before beginning the atomic account-role-audit transaction", async () => { + const t = await buildApp(); + const bcrypt = require("bcryptjs"); + const realHash = bcrypt.hash; + const events = []; + const hash = vi.spyOn(bcrypt, "hash").mockImplementation(async (...args) => { + expect(args[1]).toBe(12); events.push("hash:start"); + const result = await realHash(...args); events.push("hash:done"); return result; + }); + const transaction = t.app.prisma.$transaction.getMockImplementation(); + t.app.prisma.$transaction.mockImplementation(async cb => { + events.push("begin"); const result = await transaction(cb); events.push("commit"); return result; + }); + for (const [name, mock] of [["employee", t.employeeCreate], ["roles", t.employeeRoleCreateMany], ["audit", t.auditCreate]]) { + const original = mock.getMockImplementation(); + mock.mockImplementation(async (...args) => { events.push(name); return original(...args); }); + } + try { + const r = await t.app.inject({ method: "POST", url: "/api/users", payload: { name: "合成账号", email: "boundary@example.test", roles: ["employee"], syncWecomDirectory: false } }); + expect(r.statusCode).toBe(200); expect(hash).toHaveBeenCalledOnce(); + expect(events).toEqual(["hash:start", "hash:done", "begin", "employee", "roles", "audit", "commit"]); + const stored = t.employeeCreate.mock.calls[0][0].data; + expect(stored.must_change_password).toBe(true); + expect(await bcrypt.compare(r.json().data.oneTimePassword, stored.password_hash)).toBe(true); + expect(t.auditCreate.mock.calls[0][0].data).toMatchObject({ action: "SYSTEM_USER_CREATED", actor_id: ACTOR_ID, target_id: USER_ID }); + expect(t.wecomSyncRunCreate).not.toHaveBeenCalled(); + } finally { hash.mockRestore(); await t.app.close(); } + }); + + it("does not enter a transaction or create an account if password hashing fails", async () => { + const t = await buildApp(); + const hash = vi.spyOn(require("bcryptjs"), "hash").mockRejectedValue(new Error("synthetic hash failure")); + try { + const r = await t.app.inject({ method: "POST", url: "/api/users", payload: { name: "合成账号", email: "hash-failure@example.test", syncWecomDirectory: false } }); + expect(r.statusCode).toBe(500); expect(hash).toHaveBeenCalledOnce(); + expect(t.app.prisma.$transaction).not.toHaveBeenCalled(); + for (const mock of [t.employeeCreate, t.employeeRoleCreateMany, t.auditCreate, t.wecomSyncRunCreate]) expect(mock).not.toHaveBeenCalled(); + } finally { hash.mockRestore(); await t.app.close(); } + }); + + it.each(["roles", "audit", "commit"])("does not read completed roles or sync externally after a %s failure", async phase => { + const t = await buildApp(); + const failure = new Error("synthetic transaction failure"); + if (phase === "roles") t.employeeRoleCreateMany.mockRejectedValue(failure); + if (phase === "audit") t.auditCreate.mockRejectedValue(failure); + if (phase === "commit") { + const original = t.app.prisma.$transaction.getMockImplementation(); + t.app.prisma.$transaction.mockImplementation(async cb => { await original(cb); throw failure; }); + } + try { + const r = await t.app.inject({ method: "POST", url: "/api/users", payload: { name: "合成账号", email: "write-failure@example.test", roles: ["employee"], syncWecomDirectory: false } }); + expect(r.statusCode).toBe(500); + expect(t.app.prisma.$transaction).toHaveBeenCalledOnce(); + expect(t.employeeCreate).toHaveBeenCalledOnce(); + expect(t.employeeRoleFindMany).not.toHaveBeenCalled(); + expect(t.wecomSyncRunCreate).not.toHaveBeenCalled(); + if (phase === "roles") expect(t.auditCreate).not.toHaveBeenCalled(); + } finally { await t.app.close(); } + }); +}); diff --git a/server/tests/unit/routes/wecomAuth.test.js b/server/tests/unit/routes/wecomAuth.test.js index d1ebd280..0153a6eb 100644 --- a/server/tests/unit/routes/wecomAuth.test.js +++ b/server/tests/unit/routes/wecomAuth.test.js @@ -216,7 +216,7 @@ describe("wecom auth routes R27", () => { url: "/api/auth/wecom/login?entrySource=pc_qr&mode=onboard&returnTo=%2Fportal", }); expect(unsupported.statusCode).toBe(400); - expect(unsupported.json().error.details).toEqual({ entrySource: "unsupported_for_mode" }); + expect(unsupported.json().error.details).toEqual({ entrySource: "unsupported_for_mode", reasonCode: "wecom.pc_mode_unsupported" }); await app.close(); }); @@ -1185,3 +1185,33 @@ describe("wecom auth routes R27", () => { await app.close(); }); }); + +describe("wecom validation reasons without provider access", () => { + it.each([ + ["POST", "/wecom/js-sdk-signature", {}, "wecom.signature_url_invalid"], + ["POST", "/wecom/js-sdk-signature", { url: "not-a-url" }, "wecom.signature_url_invalid"], + ["POST", "/wecom/js-sdk-signature", { url: "file:///synthetic" }, "wecom.signature_url_invalid"], + ["POST", "/wecom/js-sdk-signature", { url: "https://oa.hyinda.com/path#fragment" }, "wecom.signature_url_invalid"], + ["POST", "/wecom/js-sdk-signature", { url: "https://synthetic:synthetic@oa.hyinda.com/path" }, "wecom.signature_url_invalid"], + ["GET", "/wecom/login?mode=bad", undefined, "wecom.login_query_invalid"], + ["GET", "/wecom/login?mode=bind&entrySource=pc_qr", undefined, "wecom.pc_mode_unsupported"], + ["GET", "/wecom/login?mode=onboard&entrySource=pc_qr", undefined, "wecom.pc_mode_unsupported"], + ["POST", "/wecom/bind", { returnTo: 1 }, "wecom.bind_input_invalid"], + ])("explains %s %s before fetch/cookies/writes", async (method, path, payload, reason) => { + global.fetch = vi.fn().mockRejectedValue(new Error("unexpected_network")); const prisma = buildPrisma(); const app = await buildApp({ prisma }); + try { const r = await app.inject({ method, url: `/api/auth${path}`, payload, headers: { authorization: "Bearer synthetic" } }); expect(r.statusCode).toBe(400); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: reason } }); expect(r.headers["set-cookie"]).toBeUndefined(); expect(global.fetch).not.toHaveBeenCalled(); expect(prisma.wecomIdentityBinding.upsert).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); } finally { await app.close(); } + }); + it("explains missing entry configuration without reflecting it or fetching", async () => { global.fetch = vi.fn(); const app = await buildApp({ config: { wecomCorpId: "" } }); try { const r = await app.inject({ url: "/api/auth/wecom/login" }); expect(r.statusCode).toBe(409); expect(r.json().error.details).toMatchObject({ wecom: "missing_required_config", reasonCode: "wecom.entry_config_incomplete" }); expect(r.headers["set-cookie"]).toBeUndefined(); expect(global.fetch).not.toHaveBeenCalled(); } finally { await app.close(); } }); +}); + +it("explains missing synthetic auth credential only at callback token exchange", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("unexpected_network")); + const prisma = buildPrisma(); const app = await buildApp({ prisma, config: { wecomAgentSecret: "" } }); + try { + expect((await app.inject({ url: "/api/auth/wecom/login" })).statusCode).toBe(302); + const state = signPayload({ exp: Date.now() + 60_000, nonce: "synthetic", returnTo: "/portal", mode: "login" }, JWT_SECRET); + const r = await app.inject({ url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=synthetic` }); + expect(r.statusCode).toBe(409); expect(r.json().error.details).toEqual({ wecom: "missing_agent_secret", reasonCode: "wecom.auth_config_incomplete" }); + expect(global.fetch).not.toHaveBeenCalled(); expect(prisma.wecomIdentityBinding.findUnique).not.toHaveBeenCalled(); expect(prisma.wecomIdentityBinding.upsert).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); + } finally { await app.close(); } +}); diff --git a/server/tests/unit/routes/weeklyReports.calendar.test.js b/server/tests/unit/routes/weeklyReports.calendar.test.js new file mode 100644 index 00000000..58f6f688 --- /dev/null +++ b/server/tests/unit/routes/weeklyReports.calendar.test.js @@ -0,0 +1,72 @@ +const Fastify = require("fastify"); +const weeklyReportsRoutes = require("../../../src/routes/weeklyReports"); + +async function buildApp(options = {}) { + const app = Fastify(); + let row = options.row; + const prisma = { + featureFlag: { findUnique: vi.fn().mockResolvedValue({ flag_key: "weekly_report_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] }) }, + weeklyReport: { + findUnique: vi.fn(async () => row ?? null), + findMany: vi.fn(async () => row ? [row] : []), + create: vi.fn(async ({ data }) => (row = { id: "synthetic-report", ...data, status: "draft", project_links: [] })), + update: vi.fn(async ({ data }) => (row = { ...row, ...data })), + }, + projectLinkOnWeeklyReport: { deleteMany: vi.fn(), createMany: vi.fn() }, + employee: { findMany: vi.fn().mockResolvedValue([]) }, + $transaction: vi.fn(async (callback) => callback(prisma)), + }; + app.decorate("prisma", prisma); + app.decorate("config", { weeklyReportEnabled: options.enabled ?? true, projectLinkingEnabled: options.projectLinking ?? false, appTimezone: "Asia/Shanghai" }); + app.decorate("authenticate", async (request) => { request.employee = { id: "synthetic-employee", department_id: null }; }); + app.decorate("hasPermission", () => options.allowed ?? true); + app.setErrorHandler((error, request, reply) => reply.status(error.statusCode || 500).send({ error: { code: error.code, details: error.details } })); + await app.register(weeklyReportsRoutes, { prefix: "/api" }); + return { app, prisma }; +} + +describe("weekly report raw date validation before Monday normalization", () => { + const routes = [["GET", "/weekly-reports/me"], ["GET", "/weekly-reports/team"], ["PUT", "/weekly-reports/me"], ["POST", "/weekly-reports/me/submit"]]; + it.each(routes.flatMap(([method, url]) => ["2026-02-29", "2026-04-31", "2026-13-01", ""].map((date) => [method, url, date])))("rejects %s %s date=%s without creating a draft", async (method, url, date) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method, url: `/api${url}` + (method === "GET" ? `?weekStart=${date}` : ""), ...(method === "GET" ? {} : { payload: { weekStart: date } }) }); + expect(response.statusCode).toBe(400); + expect(response.json().error.details.reasonCode).toBe(method === "GET" ? "weekly_report.query_invalid" : method === "PUT" ? "weekly_report.write_input_invalid" : "weekly_report.submit_input_invalid"); + expect(response.json().error.details.fieldErrors.weekStart.length).toBeGreaterThan(0); + expect(prisma.weeklyReport.findUnique).not.toHaveBeenCalled(); + expect(prisma.weeklyReport.create).not.toHaveBeenCalled(); + expect(prisma.weeklyReport.update).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); + it.each([["2024-02-29", "2024-02-26"], ["2026-04-30", "2026-04-27"]])("keeps %s normalized to Monday %s", async (date, monday) => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "PUT", url: "/api/weekly-reports/me", payload: { weekStart: date, highlightsText: "合成周报" } }); + expect(response.statusCode).toBe(200); + expect(response.json().data.item.weekStart).toBe(monday); + expect(prisma.weeklyReport.create.mock.calls[0][0].data.week_start).toEqual(new Date(`${monday}T00:00:00.000Z`)); + } finally { await app.close(); } + }); + it("keeps omitted week supported and null invalid", async () => { + const { app, prisma } = await buildApp(); + try { + const response = await app.inject({ method: "GET", url: "/api/weekly-reports/me" }); + expect(response.statusCode).toBe(200); + expect(prisma.weeklyReport.create.mock.calls[0][0].data.week_start.getUTCDay()).toBe(1); + prisma.weeklyReport.update.mockClear(); + const invalid = await app.inject({ method: "PUT", url: "/api/weekly-reports/me", payload: { weekStart: null } }); + expect(invalid.statusCode).toBe(400); + expect(prisma.weeklyReport.update).not.toHaveBeenCalled(); + } finally { await app.close(); } + }); +}); + +describe("weekly report operation contracts", () => { + it.each([[{}, "write_input_invalid"], [{ highlightsText: "x".repeat(5001) }, "write_input_invalid"], [{ projectLinks: Array.from({ length: 21 }, () => ({ projectId: "11111111-1111-4111-8111-111111111111" })) }, "write_input_invalid"]])("rejects invalid write %s before draft", async (payload, reason) => { const { app, prisma } = await buildApp(); try { const r = await app.inject({ method: "PUT", url: "/api/weekly-reports/me", payload }); expect(r.statusCode).toBe(400); expect(r.json().error.details.reasonCode).toBe(`weekly_report.${reason}`); expect(prisma.weeklyReport.create).not.toHaveBeenCalled(); expect(prisma.weeklyReport.update).not.toHaveBeenCalled(); } finally { await app.close(); } }); + it("preserves feature and team permission guards", async () => { for (const [options, path, status, reason] of [[{ enabled: false }, "me", 409, "weekly_report.feature_disabled"], [{ allowed: false }, "team", 403, undefined]]) { const { app, prisma } = await buildApp(options); try { const r = await app.inject({ url: `/api/weekly-reports/${path}?weekStart=bad` }); expect(r.statusCode).toBe(status); if (reason) expect(r.json().error.details.reasonCode).toBe(reason); expect(prisma.weeklyReport.findUnique).not.toHaveBeenCalled(); expect(prisma.weeklyReport.create).not.toHaveBeenCalled(); expect(prisma.employee.findMany).not.toHaveBeenCalled(); } finally { await app.close(); } } }); + it("preserves submitted read-only", async () => { const { app, prisma } = await buildApp({ row: { id: "synthetic", status: "submitted" } }); try { const r = await app.inject({ method: "PUT", url: "/api/weekly-reports/me", payload: { needsHelp: true } }); expect(r.statusCode).toBe(409); expect(r.json().error.details).toEqual({ status: "submitted_readonly", reasonCode: "weekly_report.submitted_readonly" }); expect(prisma.weeklyReport.update).not.toHaveBeenCalled(); expect(prisma.$transaction).not.toHaveBeenCalled(); } finally { await app.close(); } }); + it("keeps original update attempt before project gate rejection", async () => { const { app, prisma } = await buildApp(); try { const r = await app.inject({ method: "PUT", url: "/api/weekly-reports/me", payload: { highlightsText: "合成", projectLinks: [] } }); expect(r.statusCode).toBe(409); expect(r.json().error.details.reasonCode).toBe("weekly_report.project_linking_disabled"); expect(prisma.weeklyReport.create).toHaveBeenCalledOnce(); expect(prisma.weeklyReport.update).toHaveBeenCalledOnce(); expect(prisma.projectLinkOnWeeklyReport.deleteMany).not.toHaveBeenCalled(); expect(prisma.projectLinkOnWeeklyReport.createMany).not.toHaveBeenCalled(); } finally { await app.close(); } }); + it("retains 20 links and repeated submission semantics", async () => { const { app, prisma } = await buildApp({ projectLinking: true }); try { const r = await app.inject({ method: "PUT", url: "/api/weekly-reports/me", payload: { projectLinks: Array.from({ length: 20 }, () => ({ projectId: "11111111-1111-4111-8111-111111111111" })) } }); expect(r.statusCode).toBe(200); expect(prisma.projectLinkOnWeeklyReport.createMany.mock.calls[0][0].data).toHaveLength(20); for (let i = 0; i < 2; i += 1) expect((await app.inject({ method: "POST", url: "/api/weekly-reports/me/submit", payload: {} })).statusCode).toBe(200); expect(prisma.weeklyReport.update).toHaveBeenCalledTimes(3); } finally { await app.close(); } }); +}); diff --git a/server/tests/unit/routes/workflow.purchase-erp.test.js b/server/tests/unit/routes/workflow.purchase-erp.test.js deleted file mode 100644 index 92bfb689..00000000 --- a/server/tests/unit/routes/workflow.purchase-erp.test.js +++ /dev/null @@ -1,114 +0,0 @@ -const workflowRoutes = require('../../../src/routes/workflow'); - -describe('workflow purchase ERP helper', () => { - const { handlePurchaseApprovalErpSync } = workflowRoutes.__testables; - - function createRequest() { - return { - id: 'req-1', - ip: '127.0.0.1', - headers: {}, - employee: { - id: 'actor-1', - department_id: 'dept-1', - permissionCodes: ['procurement.manage'], - }, - }; - } - - function createTx() { - return { - auditLog: { - create: vi.fn().mockResolvedValue({ id: 'audit-1' }), - }, - }; - } - - it('auto enqueues commitment journal when gate is enabled', async () => { - const tx = createTx(); - const loadContext = vi.fn().mockResolvedValue({ - purchaseRequest: { - id: 'pr-1', - department_id: 'dept-1', - }, - approvalOccurredAt: new Date('2026-03-08T10:00:00.000Z'), - }); - const evaluateGate = vi.fn().mockResolvedValue({ - enabled: true, - reason: 'env_inherit_department_allowed', - }); - const enqueueCommitment = vi.fn().mockResolvedValue({ - id: 'outbox-1', - status: 'pending', - duplicated: false, - requeued: false, - eventType: 'erp.journal.export', - }); - - const result = await handlePurchaseApprovalErpSync({ - tx, - config: {}, - request: createRequest(), - actorId: 'actor-1', - purchaseRequestId: 'pr-1', - loadContext, - evaluateGate, - enqueueCommitment, - }); - - expect(enqueueCommitment).toHaveBeenCalledWith( - tx, - {}, - expect.objectContaining({ - actorId: 'actor-1', - requeueOnFailure: false, - }), - ); - expect(tx.auditLog.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - action: 'ERP_SYNC_ENQUEUED', - target_type: 'purchase_request', - target_id: 'pr-1', - }), - }), - ); - expect(result.skipped).toBe(false); - }); - - it('writes skip audit when approval gate is disabled', async () => { - const tx = createTx(); - const loadContext = vi.fn().mockResolvedValue({ - purchaseRequest: { - id: 'pr-1', - department_id: 'dept-1', - }, - approvalOccurredAt: new Date('2026-03-08T10:00:00.000Z'), - }); - const evaluateGate = vi.fn().mockResolvedValue({ enabled: false, reason: 'flag_force_off' }); - const enqueueCommitment = vi.fn(); - - const result = await handlePurchaseApprovalErpSync({ - tx, - config: {}, - request: createRequest(), - actorId: 'actor-1', - purchaseRequestId: 'pr-1', - loadContext, - evaluateGate, - enqueueCommitment, - }); - - expect(enqueueCommitment).not.toHaveBeenCalled(); - expect(tx.auditLog.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG', - target_type: 'purchase_request', - target_id: 'pr-1', - }), - }), - ); - expect(result.skipped).toBe(true); - }); -}); diff --git a/server/tests/unit/scripts/deployment-db-lifecycle.test.js b/server/tests/unit/scripts/deployment-db-lifecycle.test.js index 6d97e62c..e575eecd 100644 --- a/server/tests/unit/scripts/deployment-db-lifecycle.test.js +++ b/server/tests/unit/scripts/deployment-db-lifecycle.test.js @@ -4,13 +4,13 @@ const { promisify } = require('node:util'); const os = require('node:os'); const path = require('node:path'); const YAML = require('yaml'); -const { parseCompose, lifecycleServices } = require('../fixtures/deployment-command-fake.cjs'); +const { parseCompose, lifecycleServices, startCommandServer } = require('../fixtures/deployment-command-fake.cjs'); const execute = promisify(execFile); const ROOT = path.resolve(__dirname, '../../../..'); const DEPLOY = path.join(ROOT, 'deploy/trialrun/deploy.sh'); const COMPOSE = path.join(ROOT, 'deploy/trialrun/docker-compose.yml'); -const FAKE = path.join(__dirname, '../fixtures/deployment-command-fake.cjs'); +const FAKE = path.join(__dirname, '../fixtures/deployment-command-client.py'); const ORIGINAL_PATH = process.env.PATH; function remoteSection(source) { @@ -33,6 +33,7 @@ describe('database lifecycle isolation in the actual deployment remote script', let fixtureRoot; let source; let services; + let closeCommands; beforeEach(async () => { fixtureRoot = await mkdtemp(path.join(os.tmpdir(), 'hyb-db-lifecycle-')); @@ -40,7 +41,7 @@ describe('database lifecycle isolation in the actual deployment remote script', services = YAML.parse(await readFile(COMPOSE, 'utf8')).services; const bin = path.join(fixtureRoot, 'bin'); await mkdir(bin); - const fakeSource = '#!' + process.execPath + '\n' + await readFile(FAKE, 'utf8'); + const fakeSource = await readFile(FAKE, 'utf8'); for (const command of ['docker', 'docker-compose', 'curl', 'timeout', 'sleep', 'ssh', 'rsync', 'node', 'git', 'sudo']) { const file = path.join(bin, command); await writeFile(file, fakeSource); @@ -52,15 +53,18 @@ describe('database lifecycle isolation in the actual deployment remote script', await mkdir(path.join(fixtureRoot, 'postgres')); await writeFile(path.join(fixtureRoot, 'postgres/PG_VERSION'), '16\n'); await writeFile(path.join(fixtureRoot, 'env'), 'APP_ORIGIN=https://example.invalid\n', { mode: 0o600 }); + closeCommands = await startCommandServer(fixtureRoot); }); afterEach(async () => { expect(process.env.PATH).toBe(ORIGINAL_PATH); + await closeCommands?.(); + closeCommands = undefined; await rm(fixtureRoot, { recursive: true, force: true }); }); async function run({ mode = 'existing', worker = false, minio = false, engine = 'v2', health = 'healthy', initFails = false, unsupportedIsolation = false, driftAfter = '', workerImagePresent = true, workerTagPresent = true, script = remoteSection(source) } = {}) { - await writeFile(path.join(fixtureRoot, 'config.json'), JSON.stringify({ services, engine, health, initFails, unsupportedIsolation, driftAfter, workerImagePresent, workerTagPresent })); + await writeFile(path.join(fixtureRoot, 'config.json'), JSON.stringify({ services, mode, engine, health, initFails, unsupportedIsolation, driftAfter, workerImagePresent, workerTagPresent })); await writeFile(path.join(fixtureRoot, 'trace.jsonl'), ''); const scriptPath = path.join(fixtureRoot, 'remote.sh'); await writeFile(scriptPath, script); @@ -253,6 +257,28 @@ describe('database lifecycle isolation in the actual deployment remote script', expect(result.trace).toEqual([{ command: 'ssh', args: ['unexpected.invalid'] }]); }); + it('the private transport preserves empty, quoted and multiline command arguments', async () => { + const result = await run({ script: "set -e\nssh '' 'space 中文' $'first\\nsecond'\n" }); + expect(result.code).toBe(97); + expect(result.trace).toEqual([{ command: 'ssh', args: ['', 'space 中文', 'first\nsecond'] }]); + }); + + it('the private transport preserves ready stdin validation and failure status', async () => { + const result = await run({ script: `set -e\nprintf '%s' '{"data":{"buildSha":"wrong"}}' | docker compose -p synthetic-project exec -T server node -e 'EXPECTED_BUILD_SHA'\n` }); + expect(result.code).toBe(97); + expect(result.stderr).toContain('wrong_ready_build'); + expect(result.trace).toHaveLength(1); + }); + + it('an unavailable private transport fails instead of invoking real commands', async () => { + await closeCommands(); + closeCommands = undefined; + const result = await run({ script: 'set -e\ndocker compose version\n' }); + expect(result.code).toBe(97); + expect(result.stderr).toContain('transport_failed'); + expect(result.trace).toEqual([]); + }); + it('the existing wrapper pins mode regardless of an inherited initialize value', async () => { const wrapper = await readFile(path.join(ROOT, 'deploy/trialrun/deploy-existing.sh'), 'utf8'); const invocations = [...wrapper.matchAll(/DEPLOY_SERVER="\$DEPLOY_SERVER" \\\nREMOTE_DIR="\$\{contract\[REMOTE_DIR\]\}"[\s\S]*?bash "\$ROOT_DIR\/deploy\/trialrun\/deploy\.sh"/g)]; diff --git a/server/tests/unit/scripts/error-reason-client.test.js b/server/tests/unit/scripts/error-reason-client.test.js index 28bed700..9c2b550a 100644 --- a/server/tests/unit/scripts/error-reason-client.test.js +++ b/server/tests/unit/scripts/error-reason-client.test.js @@ -2,7 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); const ts = require("../../../../client/node_modules/typescript"); const expectedMessages = require("../fixtures/error-reason-messages.json"); -const { getErrorReason } = require("../../../src/constants/errorReasons"); +const { ERROR_REASONS, getErrorReason } = require("../../../src/constants/errorReasons"); const { getMissingRequiredOcrFields } = require("../../../src/services/ocr"); const { validatePassword } = require("../../../src/utils/passwordPolicy"); @@ -32,6 +32,13 @@ describe("client business error projection", () => { expect(formatDisplayDateTime("2026-12-31T18:30:45.000Z", { includeSeconds: true })).toBe("2027年1月1日 02:30:45"); }); + it("requires an independent message fixture for every user-facing registered reason", () => { + const missing = ERROR_REASONS + .filter((reason) => reason.audience === "user" && !Object.hasOwn(expectedMessages, reason.code)) + .map((reason) => reason.code); + expect(missing).toEqual([]); + }); + it.each(Object.entries(expectedMessages))("keeps exact registered and displayed content for %s", (reasonCode, expected) => { expect(getErrorReason(reasonCode).defaultMessage).toBe(expected); expect(resolveErrorMessage(new ApiError({ status: 409, code: "VALID_001", message: "private payload", details: { reasonCode } }))).toBe(expected); diff --git a/server/tests/unit/services/ai.suggestions.test.js b/server/tests/unit/services/ai.suggestions.test.js index 6633ed72..8b24f1ad 100644 --- a/server/tests/unit/services/ai.suggestions.test.js +++ b/server/tests/unit/services/ai.suggestions.test.js @@ -183,6 +183,7 @@ describe("AI suggestions service", () => { statusCode: 409, details: expect.objectContaining({ ai: "disabled", + reasonCode: "ai.feature_disabled", featureFlagReason: "department_not_allowed", }), }); diff --git a/server/tests/unit/services/budgetPool.locking.test.js b/server/tests/unit/services/budgetPool.locking.test.js new file mode 100644 index 00000000..b1fe92dc --- /dev/null +++ b/server/tests/unit/services/budgetPool.locking.test.js @@ -0,0 +1,27 @@ +const { lockBudgetPoolsForUpdate, resolveApplicableBudgetPoolByContext } = require('../../../src/services/budgetPool'); + +describe('budget pool write protection', () => { + it('locks unique pools in stable order with parameters and rejects a root client', async () => { + const tx = { $queryRaw: vi.fn().mockResolvedValue([]), $transaction: vi.fn() }; + await lockBudgetPoolsForUpdate(tx, ['b', 'a', 'b', null]); + const query = tx.$queryRaw.mock.calls[0][0]; + expect(query.sql).toMatch(/ORDER BY id FOR NO KEY UPDATE/); + expect(query.values).toEqual(['a', 'b']); + await expect(lockBudgetPoolsForUpdate({ ...tx, $connect: vi.fn() }, ['a'])).rejects.toThrow(/transaction/); + }); + it('does not lock preview reads but refreshes usage after a submission lock', async () => { + const first = { id: 'pool', used_amount: '0.00' }; const latest = { ...first, used_amount: '60.00' }; + const tx = { $queryRaw: vi.fn().mockResolvedValue([]), budgetPool: { findMany: vi.fn().mockResolvedValueOnce([first]).mockResolvedValueOnce([first]).mockResolvedValueOnce([latest]) } }; + const context = { effectiveDate: new Date('2026-09-19') }; + expect(await resolveApplicableBudgetPoolByContext(tx, context)).toEqual(first); + expect(tx.$queryRaw).not.toHaveBeenCalled(); + expect(await resolveApplicableBudgetPoolByContext(tx, context, { forUpdate: true })).toEqual(latest); + expect(tx.budgetPool.findMany.mock.invocationCallOrder[1]).toBeLessThan(tx.$queryRaw.mock.invocationCallOrder[0]); + expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(tx.budgetPool.findMany.mock.invocationCallOrder[2]); + }); + it.each([{ records: [] }, { records: [{ id: 'replacement' }] }])('rejects a candidate changed during the wait: %j', async ({ records }) => { + const tx = { $queryRaw: vi.fn().mockResolvedValue([]), budgetPool: { findMany: vi.fn().mockResolvedValueOnce([{ id: 'old' }]).mockResolvedValueOnce(records) } }; + await expect(resolveApplicableBudgetPoolByContext(tx, { effectiveDate: new Date('2026-09-19') }, { forUpdate: true })) + .rejects.toMatchObject({ statusCode: 409, details: { reasonCode: 'budget.pool_changed' } }); + }); +}); diff --git a/server/tests/unit/services/erpAsset.test.js b/server/tests/unit/services/erpAsset.test.js deleted file mode 100644 index 36a4cfcc..00000000 --- a/server/tests/unit/services/erpAsset.test.js +++ /dev/null @@ -1,138 +0,0 @@ -const { - buildAssetLedgerSummaryDedupeKey, - buildAssetLedgerSummaryPayload, - enqueueAssetLedgerSummaryExport, - hashAssetPayload, - resolveAssetLedgerSummaryChange, -} = require('../../../src/services/integrations/erpAsset'); - -describe('ERP Asset Service', () => { - function createAsset(overrides = {}) { - return { - id: 'asset-1', - asset_no: 'ZC-001', - name: 'ThinkPad X1', - status: 'in_use', - location: '上海办公室-A区', - activated_at: '2026-03-09T00:00:00.000Z', - retired_at: null, - created_at: '2026-03-09T08:00:00.000Z', - updated_at: '2026-03-09T08:00:00.000Z', - legal_entity_id: 'le-1', - cost_center_id: 'cc-1', - purchase_request_id: 'pr-1', - assignee_employee_id: 'emp-1', - category: { - id: 'cat-1', - code: 'LAPTOP', - name: '笔记本电脑', - document_category: { code: 'ASSET_STD' }, - }, - legal_entity: { id: 'le-1', code: 'HY', name: '海音达' }, - cost_center: { id: 'cc-1', code: 'IT001', name: 'IT中心' }, - purchase_request: { id: 'pr-1', request_no: 'PR-001', title: '办公电脑采购' }, - assignee: { id: 'emp-1', employee_no: 'E0001', name: '张三' }, - ...overrides, - }; - } - - it('builds asset ledger summary payload for create event', () => { - const payload = buildAssetLedgerSummaryPayload({ - asset: createAsset(), - sourceEventName: 'asset.card.created', - changeType: 'created', - changedFields: [], - occurredAt: '2026-03-09T08:00:00.000Z', - }); - - expect(payload.exportType).toBe('asset_ledger_summary'); - expect(payload.sourceEventName).toBe('asset.card.created'); - expect(payload.changeType).toBe('created'); - expect(payload.assetNo).toBe('ZC-001'); - expect(payload.category.code).toBe('LAPTOP'); - expect(payload.legalEntity.code).toBe('HY'); - expect(payload.costCenter.code).toBe('IT001'); - expect(payload.purchaseRequest.requestNo).toBe('PR-001'); - expect(payload.assignee.employeeNo).toBe('E0001'); - }); - - it('detects financial-dimension changes only', () => { - const previousAsset = createAsset(); - - expect( - resolveAssetLedgerSummaryChange(previousAsset, { - ...previousAsset, - name: 'ThinkPad X1 Carbon Gen 12', - location: '上海办公室-B区', - }), - ).toBeNull(); - - expect( - resolveAssetLedgerSummaryChange(previousAsset, { - ...previousAsset, - cost_center_id: 'cc-2', - }), - ).toEqual({ - sourceEventName: 'asset.ledger.summary.updated', - changeType: 'financial_dimensions_changed', - changedFields: ['costCenterId'], - }); - - expect( - resolveAssetLedgerSummaryChange(previousAsset, { - ...previousAsset, - status: 'retired', - }), - ).toEqual({ - sourceEventName: 'asset.ledger.summary.updated', - changeType: 'retired', - changedFields: ['status'], - }); - }); - - it('builds deterministic dedupe key and payload hash', () => { - const asset = createAsset(); - const payload = buildAssetLedgerSummaryPayload({ asset, changedFields: [] }); - const changedPayload = buildAssetLedgerSummaryPayload({ - asset: createAsset({ cost_center: { id: 'cc-2', code: 'IT002', name: 'IT二部' }, cost_center_id: 'cc-2' }), - sourceEventName: 'asset.ledger.summary.updated', - changeType: 'financial_dimensions_changed', - changedFields: ['costCenterId'], - }); - - expect(buildAssetLedgerSummaryDedupeKey('asset-1')).toBe('asset_card:asset-1:ledger_summary'); - expect(hashAssetPayload(payload)).not.toBe(hashAssetPayload(changedPayload)); - }); - - it('resets same outbox when asset payload changes', async () => { - const prisma = { - integrationOutbox: { - findUnique: vi.fn().mockResolvedValue({ - id: 'outbox-1', - status: 'succeeded', - payload: { assetNo: 'ZC-001', costCenter: { id: 'cc-1', code: 'IT001' } }, - }), - update: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }), - }, - }; - - const result = await enqueueAssetLedgerSummaryExport( - prisma, - { integrationOutboxMaxAttempts: 5 }, - { - actorId: 'actor-1', - asset: createAsset({ - cost_center_id: 'cc-2', - cost_center: { id: 'cc-2', code: 'IT002', name: 'IT二部' }, - }), - sourceEventName: 'asset.ledger.summary.updated', - changeType: 'financial_dimensions_changed', - changedFields: ['costCenterId'], - }, - ); - - expect(prisma.integrationOutbox.update).toHaveBeenCalled(); - expect(result.eventType).toBe('erp.asset.export'); - expect(result.requeued).toBe(true); - }); -}); diff --git a/server/tests/unit/services/erpJournal.test.js b/server/tests/unit/services/erpJournal.test.js deleted file mode 100644 index 4a211574..00000000 --- a/server/tests/unit/services/erpJournal.test.js +++ /dev/null @@ -1,80 +0,0 @@ -const { - buildReimbursementJournalDedupeKey, - buildReimbursementJournalPayload, - enqueueReimbursementJournalExport, -} = require('../../../src/services/integrations/erpJournal'); - -describe('ERP Journal Service', () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it('prefers owner legal entity code and keeps cost center null', () => { - const payload = buildReimbursementJournalPayload({ - reimbursement: { - id: 'rb-1', - form_number: 'BX-001', - owner_id: 'owner-1', - submitter_id: 'submitter-1', - department_id: 'dept-1', - total_amount: { toString: () => '88.80' }, - payment_date: '2026-03-08T00:00:00.000Z', - paid_at: '2026-03-08T12:00:00.000Z', - owner: { legal_entity: { code: 'OWNER_LE' } }, - submitter: { legal_entity: { code: 'SUBMITTER_LE' } }, - }, - }); - - expect(payload.dimensions.legalEntityCode).toBe('OWNER_LE'); - expect(payload.dimensions.costCenterCode).toBeNull(); - expect(payload.currencyCode).toBe('CNY'); - }); - - it('falls back to submitter legal entity code', () => { - const payload = buildReimbursementJournalPayload({ - reimbursement: { - id: 'rb-1', - form_number: 'BX-001', - owner: { legal_entity: null }, - submitter: { legal_entity: { code: 'SUBMITTER_LE' } }, - }, - }); - - expect(payload.dimensions.legalEntityCode).toBe('SUBMITTER_LE'); - }); - - it('builds dedupe key and enqueues standard export event', async () => { - const prisma = { - integrationOutbox: { - findUnique: vi.fn().mockResolvedValue(null), - create: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }), - }, - }; - const config = { integrationOutboxMaxAttempts: 5 }; - const reimbursement = { - id: 'rb-1', - form_number: 'BX-001', - owner_id: 'owner-1', - submitter_id: 'submitter-1', - department_id: 'dept-1', - }; - - const result = await enqueueReimbursementJournalExport(prisma, config, { - reimbursement, - actorId: 'actor-1', - }); - - expect(buildReimbursementJournalDedupeKey('rb-1')).toBe('reimbursement:rb-1:paid'); - expect(prisma.integrationOutbox.findUnique).toHaveBeenCalledWith({ - where: { - event_type_dedupe_key: { - event_type: 'erp.journal.export', - dedupe_key: 'reimbursement:rb-1:paid', - }, - }, - select: { id: true, status: true, payload: true }, - }); - expect(prisma.integrationOutbox.create).toHaveBeenCalled(); - expect(result.eventType).toBe('erp.journal.export'); - }); -}); diff --git a/server/tests/unit/services/erpJournal.travel.test.js b/server/tests/unit/services/erpJournal.travel.test.js deleted file mode 100644 index e7638a8d..00000000 --- a/server/tests/unit/services/erpJournal.travel.test.js +++ /dev/null @@ -1,121 +0,0 @@ -const { - buildTravelOrderPaymentJournalPayload, - buildTravelOrderPaymentJournalDedupeKey, - buildTravelOrderPaymentJournalPreview, -} = require("../../../src/services/integrations/erpJournal"); - -describe("erpJournal travel helpers", () => { - it("builds payment journal payload with travel dimensions", () => { - const payload = buildTravelOrderPaymentJournalPayload({ - travelOrder: { - id: "order-1", - source: "ctrip", - external_order_no: "EXT-001", - employee_id: "employee-1", - department_id: "dept-1", - travel_request_id: "travel-1", - amount: "500.00", - currency: "CNY", - payment_voucher_no: "BANK-001", - payment_date: new Date("2026-03-10T00:00:00.000Z"), - payment_channel: "bank_transfer", - paid_at: new Date("2026-03-10T09:00:00.000Z"), - created_at: new Date("2026-03-10T08:00:00.000Z"), - updated_at: new Date("2026-03-10T09:00:00.000Z"), - travel_request: { - id: "travel-1", - request_no: "CL-20260310-0001", - applicant_employee_id: "employee-1", - submitter_id: null, - legal_entity: { code: "HY" }, - cost_center: { code: "CC001" }, - budget_pool: { code: "POOL-1" }, - }, - }, - }); - - expect(payload).toEqual( - expect.objectContaining({ - journalType: "travel_order_payment", - sourceEventName: "travel_order.paid", - entityType: "travel_order", - entityId: "order-1", - documentNo: "EXT-001", - paymentVoucherNo: "BANK-001", - paymentDate: "2026-03-10", - paymentChannel: "bank_transfer", - dimensions: expect.objectContaining({ - legalEntityCode: "HY", - costCenterCode: "CC001", - budgetPoolCode: "POOL-1", - }), - }), - ); - }); - - it("builds stable travel-order dedupe key", () => { - expect(buildTravelOrderPaymentJournalDedupeKey("order-1")).toBe("travel_order:order-1:paid"); - }); - - it("builds preview with current binding and outbox state", async () => { - const prisma = { - integrationBinding: { - findUnique: vi.fn().mockResolvedValue({ - id: "binding-1", - provider: "erp", - entity_type: "travel_order_payment_journal", - local_id: "order-1", - external_id: "erp-1", - external_code: "EXT-001", - last_synced_at: new Date("2026-03-10T10:00:00.000Z"), - }), - }, - integrationOutbox: { - findFirst: vi.fn().mockResolvedValue({ - id: "outbox-1", - event_type: "erp.journal.export", - status: "succeeded", - last_error: null, - processed_at: new Date("2026-03-10T10:00:00.000Z"), - updated_at: new Date("2026-03-10T10:00:00.000Z"), - }), - }, - }; - - const preview = await buildTravelOrderPaymentJournalPreview(prisma, { - id: "order-1", - source: "ctrip", - external_order_no: "EXT-001", - employee_id: "employee-1", - department_id: "dept-1", - linked_reimbursement_id: null, - travel_request_id: "travel-1", - amount: "500.00", - currency: "CNY", - payment_voucher_no: "BANK-001", - payment_date: new Date("2026-03-10T00:00:00.000Z"), - payment_channel: "bank_transfer", - paid_at: new Date("2026-03-10T09:00:00.000Z"), - created_at: new Date("2026-03-10T08:00:00.000Z"), - updated_at: new Date("2026-03-10T09:00:00.000Z"), - travel_request: { - id: "travel-1", - request_no: "CL-20260310-0001", - applicant_employee_id: "employee-1", - submitter_id: null, - legal_entity: { code: "HY" }, - cost_center: { code: "CC001" }, - budget_pool: { code: "POOL-1" }, - }, - }); - - expect(preview.exportState).toEqual( - expect.objectContaining({ - externalId: "erp-1", - lastOutboxId: "outbox-1", - lastOutboxStatus: "succeeded", - }), - ); - expect(preview.binding).toEqual(expect.objectContaining({ localId: "order-1" })); - }); -}); diff --git a/server/tests/unit/services/erpMasterData.test.js b/server/tests/unit/services/erpMasterData.test.js deleted file mode 100644 index 7b5df57e..00000000 --- a/server/tests/unit/services/erpMasterData.test.js +++ /dev/null @@ -1,145 +0,0 @@ -const { - normalizeRequestedResources, - syncErpMasterData, -} = require('../../../src/services/integrations/erpMasterData'); - -function createPrismaMock() { - const syncStates = new Map(); - - const prisma = { - $transaction: async (fn) => fn(prisma), - integrationSyncState: { - upsert: vi.fn().mockImplementation(async ({ where, create, update }) => { - const key = `${where.provider_resource_type.provider}:${where.provider_resource_type.resource_type}`; - const current = syncStates.get(key) ?? null; - const next = current ? { ...current, ...update } : { ...create }; - syncStates.set(key, next); - return next; - }), - findMany: vi.fn().mockImplementation(async () => Array.from(syncStates.values())), - }, - integrationBinding: { - findFirst: vi.fn().mockResolvedValue(null), - upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }), - }, - legalEntity: { - findMany: vi.fn().mockResolvedValue([]), - create: vi.fn().mockResolvedValue({ id: 'le-1' }), - update: vi.fn().mockResolvedValue({ id: 'le-1' }), - }, - costCenter: { - findMany: vi.fn().mockResolvedValue([]), - create: vi.fn().mockResolvedValue({ id: 'cc-1' }), - update: vi.fn().mockResolvedValue({ id: 'cc-1' }), - }, - supplier: { - findMany: vi.fn().mockResolvedValue([]), - create: vi.fn().mockResolvedValue({ id: 'sup-1' }), - update: vi.fn().mockResolvedValue({ id: 'sup-1' }), - }, - }; - - return prisma; -} - -describe('ERP Master Data Service', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it('normalizes resource order and dedupes input', () => { - expect(normalizeRequestedResources(['supplier', 'legal_entity', 'supplier'])).toEqual([ - 'legal_entity', - 'supplier', - ]); - expect(normalizeRequestedResources([])).toEqual(['legal_entity', 'cost_center', 'supplier']); - }); - - it('skips supplier rows without stable code', async () => { - const prisma = createPrismaMock(); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: vi.fn().mockResolvedValue({ - suppliers: [{ code: '', name: 'bad supplier' }], - }), - }); - - const result = await syncErpMasterData(prisma, { - erpSyncEnabled: true, - erpSyncProvider: 'http', - erpMasterDataEndpoint: 'https://example.com/master-data', - erpMasterDataTimeoutMs: 500, - }, { - resources: ['supplier'], - actorId: 'actor-1', - logger: { error: vi.fn() }, - }); - - const supplierState = result.items.find((item) => item.resourceType === 'supplier'); - expect(result.ok).toBe(true); - expect(supplierState.lastResult.skipped).toBe(1); - expect(prisma.supplier.create).not.toHaveBeenCalled(); - }); - - it('skips cost centers when legal entity is missing', async () => { - const prisma = createPrismaMock(); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: vi.fn().mockResolvedValue({ - costCenters: [{ code: 'IT001', legalEntityCode: 'MISSING', name: 'IT中心' }], - }), - }); - - const result = await syncErpMasterData(prisma, { - erpSyncEnabled: true, - erpSyncProvider: 'http', - erpMasterDataEndpoint: 'https://example.com/master-data', - erpMasterDataTimeoutMs: 500, - }, { - resources: ['cost_center'], - actorId: 'actor-1', - logger: { error: vi.fn() }, - }); - - const costCenterState = result.items.find((item) => item.resourceType === 'cost_center'); - expect(costCenterState.lastResult.skipped).toBe(1); - expect(costCenterState.lastResult.sampleSkipped[0].reason).toBe('legal_entity_missing'); - }); - - it('skips rows when integration binding conflicts', async () => { - const prisma = createPrismaMock(); - prisma.supplier.findMany.mockResolvedValue([{ id: 'sup-local', code: 'SUP001', enabled: true, archived_at: null }]); - prisma.integrationBinding.findFirst.mockResolvedValue({ local_id: 'another-local' }); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: vi.fn().mockResolvedValue({ - suppliers: [{ code: 'SUP001', externalId: 'ERP-SUP-001', name: '供应商' }], - }), - }); - - const result = await syncErpMasterData(prisma, { - erpSyncEnabled: true, - erpSyncProvider: 'http', - erpMasterDataEndpoint: 'https://example.com/master-data', - erpMasterDataTimeoutMs: 500, - }, { - resources: ['supplier'], - actorId: 'actor-1', - logger: { error: vi.fn() }, - }); - - const supplierState = result.items.find((item) => item.resourceType === 'supplier'); - expect(supplierState.lastResult.skipped).toBe(1); - expect(supplierState.lastResult.sampleSkipped[0].reason).toBe('binding_conflict'); - expect(prisma.supplier.update).not.toHaveBeenCalled(); - }); -}); diff --git a/server/tests/unit/services/erpProcurement.test.js b/server/tests/unit/services/erpProcurement.test.js deleted file mode 100644 index 9ebcd58f..00000000 --- a/server/tests/unit/services/erpProcurement.test.js +++ /dev/null @@ -1,144 +0,0 @@ -const { - buildPurchaseAccrualJournalPayload, - buildPurchaseArchiveSummaryPayload, - buildPurchaseCommitmentJournalPayload, - enqueuePurchaseArchiveSummaryExport, - hashProcurementPayload, -} = require('../../../src/services/integrations/erpProcurement'); - -describe('ERP Procurement Service', () => { - it('builds commitment payload with procurement dimensions', () => { - const payload = buildPurchaseCommitmentJournalPayload({ - purchaseRequest: { - id: 'pr-1', - request_no: 'PR-001', - applicant_id: 'applicant-1', - submitter_id: 'submitter-1', - department_id: 'dept-1', - total_amount: { toString: () => '880.00' }, - currency_code: 'CNY', - legal_entity: { code: 'HY' }, - cost_center: { code: 'IT001' }, - supplier: { code: 'SUP001' }, - budget_pool: { code: 'BG-IT-2026' }, - document_category: { code: 'PR_STANDARD' }, - }, - occurredAt: '2026-03-08T10:00:00.000Z', - }); - - expect(payload.journalType).toBe('purchase_request_commitment'); - expect(payload.references.requestNo).toBe('PR-001'); - expect(payload.dimensions.legalEntityCode).toBe('HY'); - expect(payload.dimensions.costCenterCode).toBe('IT001'); - expect(payload.dimensions.supplierCode).toBe('SUP001'); - expect(payload.dimensions.budgetPoolCode).toBe('BG-IT-2026'); - }); - - it('builds accrual payload from archive facts', () => { - const payload = buildPurchaseAccrualJournalPayload({ - purchaseRequest: { - id: 'pr-1', - request_no: 'PR-001', - applicant_id: 'applicant-1', - submitter_id: 'submitter-1', - department_id: 'dept-1', - total_amount: '880.00', - currency_code: 'CNY', - legal_entity: { code: 'HY' }, - cost_center: { code: 'IT001' }, - supplier: { code: 'SUP001' }, - budget_pool: { code: 'BG-IT-2026' }, - }, - archive: { - id: 'archive-1', - po_no: 'PO-001', - contract_no: 'CT-001', - archived_at: '2026-03-08T10:30:00.000Z', - }, - }); - - expect(payload.journalType).toBe('purchase_order_accrual'); - expect(payload.entityId).toBe('archive-1'); - expect(payload.documentNo).toBe('PO-001'); - expect(payload.references.contractNo).toBe('CT-001'); - }); - - it('builds archive summary payload and hashes payload changes', () => { - const basePayload = buildPurchaseArchiveSummaryPayload({ - purchaseRequest: { - id: 'pr-1', - request_no: 'PR-001', - department_id: 'dept-1', - total_amount: '880.00', - currency_code: 'CNY', - legal_entity: { code: 'HY' }, - cost_center: { code: 'IT001' }, - supplier: { id: 'sup-1', code: 'SUP001', name: '演示供应商' }, - }, - archive: { - id: 'archive-1', - po_no: 'PO-001', - contract_no: 'CT-001', - archived_at: '2026-03-08T10:30:00.000Z', - archived_files: [ - { - storagePath: 'purchase-requests/pr-1/contract.pdf', - originalName: 'contract.pdf', - mimeType: 'application/pdf', - fileSize: 1024, - }, - ], - }, - }); - - const changedPayload = { - ...basePayload, - contractNo: 'CT-002', - }; - - expect(basePayload.files).toHaveLength(1); - expect(hashProcurementPayload(basePayload)).not.toBe(hashProcurementPayload(changedPayload)); - }); - - it('resets archive summary outbox when payload changes', async () => { - const prisma = { - integrationOutbox: { - findUnique: vi.fn().mockResolvedValue({ - id: 'outbox-1', - status: 'succeeded', - payload: { contractNo: 'CT-001', poNo: 'PO-001' }, - }), - update: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }), - }, - }; - - const result = await enqueuePurchaseArchiveSummaryExport( - prisma, - { integrationOutboxMaxAttempts: 5 }, - { - actorId: 'actor-1', - purchaseRequest: { - id: 'pr-1', - request_no: 'PR-001', - department_id: 'dept-1', - total_amount: '880.00', - currency_code: 'CNY', - supplier: { id: 'sup-1', code: 'SUP001', name: '演示供应商' }, - legal_entity: { code: 'HY' }, - cost_center: { code: 'IT001' }, - }, - archive: { - id: 'archive-1', - po_no: 'PO-001', - contract_no: 'CT-002', - archived_at: '2026-03-08T10:30:00.000Z', - archived_files: [], - }, - }, - ); - - expect(prisma.integrationOutbox.update).toHaveBeenCalled(); - expect(result.requeued).toBe(true); - expect(result.eventType).toBe('erp.procurement.export'); - }); -}); diff --git a/server/tests/unit/services/erpTravel.test.js b/server/tests/unit/services/erpTravel.test.js deleted file mode 100644 index b686a238..00000000 --- a/server/tests/unit/services/erpTravel.test.js +++ /dev/null @@ -1,170 +0,0 @@ -const { - TRAVEL_ERP_BINDING_TYPES, - TRAVEL_ERP_EVENT_TYPE, - buildTravelReconciliationErpTrack, - buildTravelReconciliationSummaryDedupeKey, - buildTravelReconciliationSummaryPayload, -} = require('../../../src/services/integrations/erpTravel'); - -describe('erpTravel service', () => { - function createBatch(overrides = {}) { - return { - id: 'batch-1', - batch_no: 'TRB-20260311-0001', - status: 'reconciled', - period_start: new Date('2026-03-01T00:00:00.000Z'), - period_end: new Date('2026-03-31T00:00:00.000Z'), - confirmed_at: new Date('2026-03-31T10:00:00.000Z'), - created_at: new Date('2026-03-31T09:00:00.000Z'), - updated_at: new Date('2026-03-31T10:00:00.000Z'), - summary_json: { - totalOrders: 2, - totalTransactions: 2, - anomalyItems: 1, - matchRate: '50.00', - totalOrderAmount: '200.00', - totalSettledAmount: '180.00', - totalReversedAmount: '20.00', - }, - legal_entity: { - id: 'legal-1', - code: 'HY', - name: '海音达', - }, - cost_center: { - id: 'cc-1', - code: 'CC001', - name: '销售中心', - }, - items: [ - { - id: 'item-1', - travel_request_id: 'travel-1', - travel_order_id: 'order-1', - card_transaction_id: 'txn-1', - match_status: 'matched', - resolution_status: 'confirmed', - order_amount: '100.00', - transaction_amount: '100.00', - currency: 'CNY', - snapshot_json: { - requestNo: 'CL-20260310-0001', - externalOrderNo: 'EXT-001', - externalTxnId: 'TXN-001', - linkedReimbursementId: null, - boundInvoiceCount: 1, - }, - }, - ], - ...overrides, - }; - } - - it('builds travel reconciliation summary payload with batch snapshot', () => { - const payload = buildTravelReconciliationSummaryPayload({ batch: createBatch() }); - - expect(payload).toEqual( - expect.objectContaining({ - exportType: 'travel_reconciliation_summary', - entityType: 'travel_reconciliation_batch', - entityId: 'batch-1', - batchNo: 'TRB-20260311-0001', - periodStart: '2026-03-01', - periodEnd: '2026-03-31', - dimensions: { - legalEntityCode: 'HY', - costCenterCode: 'CC001', - }, - summary: { - totalOrders: 2, - totalTransactions: 2, - anomalyItems: 1, - matchRate: '50.00', - totalOrderAmount: '200.00', - totalSettledAmount: '180.00', - totalReversedAmount: '20.00', - }, - }), - ); - expect(payload.items[0]).toEqual( - expect.objectContaining({ - travelRequestId: 'travel-1', - requestNo: 'CL-20260310-0001', - travelOrderId: 'order-1', - externalOrderNo: 'EXT-001', - cardTransactionId: 'txn-1', - externalTxnId: 'TXN-001', - linkedReimbursementId: null, - boundInvoiceCount: 1, - }), - ); - }); - - it('builds stable dedupe key for travel reconciliation batch summary export', () => { - expect(buildTravelReconciliationSummaryDedupeKey('batch-1')).toBe( - 'travel_reconciliation_batch:batch-1:summary', - ); - }); - - it('builds ERP track with redacted bindings for non integration managers', async () => { - const batch = createBatch(); - const prisma = { - integrationOutbox: { - findUnique: vi.fn().mockResolvedValue({ - id: 'outbox-1', - event_type: TRAVEL_ERP_EVENT_TYPE, - status: 'succeeded', - attempts: 1, - last_error: null, - processed_at: new Date('2026-03-31T10:01:00.000Z'), - updated_at: new Date('2026-03-31T10:01:00.000Z'), - }), - }, - integrationBinding: { - findUnique: vi - .fn() - .mockResolvedValueOnce({ - id: 'binding-summary', - provider: 'erp', - entity_type: TRAVEL_ERP_BINDING_TYPES.payableSummary, - local_id: 'batch-1', - external_id: 'SUM-001', - external_code: 'TRB-SUM-001', - last_synced_at: new Date('2026-03-31T10:02:00.000Z'), - }) - .mockResolvedValueOnce({ - id: 'binding-batch', - provider: 'erp', - entity_type: TRAVEL_ERP_BINDING_TYPES.settlementBatch, - local_id: 'batch-1', - external_id: 'BAT-001', - external_code: 'TRB-BAT-001', - last_synced_at: new Date('2026-03-31T10:02:00.000Z'), - }), - }, - }; - - const track = await buildTravelReconciliationErpTrack(prisma, {}, 'batch-1', { - context: batch, - canManage: false, - gate: { effectiveEnabled: true }, - }); - - expect(track.stage.summaryExport.status).toBe('succeeded'); - expect(track.stage.summaryExport.bindings.payableSummary).toEqual( - expect.objectContaining({ - entityType: TRAVEL_ERP_BINDING_TYPES.payableSummary, - externalId: null, - externalCode: null, - }), - ); - expect(track.stage.summaryExport.bindings.settlementBatch).toEqual( - expect.objectContaining({ - entityType: TRAVEL_ERP_BINDING_TYPES.settlementBatch, - externalId: null, - externalCode: null, - }), - ); - expect(track.permissions.canRetry).toBe(false); - }); -}); diff --git a/server/tests/unit/services/integrations/dispatcher.test.js b/server/tests/unit/services/integrations/dispatcher.test.js index d1473a49..72fdf0a2 100644 --- a/server/tests/unit/services/integrations/dispatcher.test.js +++ b/server/tests/unit/services/integrations/dispatcher.test.js @@ -2,22 +2,19 @@ const dispatcher = require("../../../../src/services/integrations/dispatcher"); const { clearWecomAppAccessTokenCache } = require("../../../../src/services/integrations/wecomAccessToken"); describe("Integration Dispatcher", () => { + it.each(["erp.reimbursement.paid", "erp.journal.export", "erp.procurement.export", "erp.asset.export", "erp.travel.export", "erp.future"])("refuses retired %s without any dispatch or binding write", async (eventType) => { + const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); + try { + await expect(dispatcher.dispatchOutboxEvent({ eventType, payload: {} })).rejects.toMatchObject({ code: "erp_retired", retryable: false }); + expect(fetch).not.toHaveBeenCalled(); + } finally { vi.unstubAllGlobals(); } + }); + afterEach(() => { clearWecomAppAccessTokenCache(); vi.restoreAllMocks(); }); - it("dispatches erp.reimbursement.paid", async () => { - const result = await dispatcher.dispatchOutboxEvent({ - config: { erpSyncEnabled: true, erpSyncProvider: "mock" }, - eventType: "erp.reimbursement.paid", - payload: { reimbursementId: "rb-1", formNumber: "BX-001" }, - }); - - expect(result.channel).toBe("erp"); - expect(result.code).toBe("mock_synced"); - }); - it("dispatches notification.im with mock channel", async () => { const result = await dispatcher.dispatchOutboxEvent({ prisma: { diff --git a/server/tests/unit/services/integrations/erp.test.js b/server/tests/unit/services/integrations/erp.test.js deleted file mode 100644 index b23df1d0..00000000 --- a/server/tests/unit/services/integrations/erp.test.js +++ /dev/null @@ -1,237 +0,0 @@ -const erp = require('../../../../src/services/integrations/erp'); - -describe('ERP Sync Services', () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it('builds reimbursement journal payload via compat helper', () => { - const payload = erp.buildErpPayload({ - reimbursement: { - id: 'r1', - form_number: 'F001', - owner_id: 'owner-1', - submitter_id: 'submitter-1', - department_id: 'dept-1', - total_amount: { toString: () => '100.00' }, - payment_voucher_no: 'PV001', - payment_date: '2026-03-01T12:00:00.000Z', - paid_at: '2026-03-02T08:00:00.000Z', - owner: { legal_entity: { code: 'HY' } }, - }, - }); - - expect(payload.journalType).toBe('reimbursement_paid'); - expect(payload.documentNo).toBe('F001'); - expect(payload.paymentDate).toBe('2026-03-01'); - expect(payload.dimensions.legalEntityCode).toBe('HY'); - }); - - it('returns mock synced when journal export provider is mock', async () => { - const result = await erp.exportJournalToErp( - { erpSyncEnabled: true, erpSyncProvider: 'mock' }, - { journalType: 'purchase_request_commitment', documentNo: 'PR-001' }, - ); - expect(result.ok).toBe(true); - expect(result.code).toBe('mock_synced'); - expect(result.response.externalId).toContain('PR-001'); - }); - - it('returns mock procurement result when provider is mock', async () => { - const result = await erp.exportProcurementToErp( - { erpSyncEnabled: true, erpSyncProvider: 'mock' }, - { poNo: 'PO-001', contractNo: 'CT-001' }, - ); - expect(result.ok).toBe(true); - expect(result.code).toBe('mock_synced'); - expect(result.response.externalPoNo).toContain('PO-001'); - expect(result.response.externalContractNo).toContain('CT-001'); - }); - - it('returns mock master data result when provider is mock', async () => { - const result = await erp.pullMasterDataFromErp( - { erpSyncEnabled: true, erpSyncProvider: 'mock' }, - { resources: ['legal_entity', 'supplier'] }, - ); - expect(result.ok).toBe(true); - expect(result.code).toBe('mock_synced'); - expect(result.data.legalEntities).toEqual([]); - expect(result.data.suppliers).toEqual([]); - }); - - it('handles http master data 4xx errors', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 422, - headers: { get: () => 'application/json' }, - json: vi.fn().mockResolvedValue({ code: 'invalid_payload' }), - }); - - const result = await erp.pullMasterDataFromErp( - { - erpSyncEnabled: true, - erpSyncProvider: 'http', - erpMasterDataEndpoint: 'https://example.com/master-data', - erpMasterDataTimeoutMs: 500, - }, - { resources: ['legal_entity'] }, - ); - - expect(result.ok).toBe(false); - expect(result.code).toBe('erp_master_data_http_4xx'); - expect(result.retryable).toBe(false); - }); - - it('handles http journal export 5xx errors', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 503, - headers: { get: () => 'application/json' }, - json: vi.fn().mockResolvedValue({ code: 'service_unavailable' }), - }); - - const result = await erp.exportJournalToErp( - { - erpSyncEnabled: true, - erpSyncProvider: 'http', - erpJournalExportEndpoint: 'https://example.com/journals', - erpSyncTimeoutMs: 500, - }, - { journalType: 'purchase_order_accrual', documentNo: 'PO-001' }, - ); - - expect(result.ok).toBe(false); - expect(result.code).toBe('erp_sync_http_5xx'); - expect(result.retryable).toBe(true); - }); - - it('handles http procurement export 4xx errors', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - headers: { get: () => 'application/json' }, - json: vi.fn().mockResolvedValue({ code: 'bad_request' }), - }); - - const result = await erp.exportProcurementToErp( - { - erpSyncEnabled: true, - erpSyncProvider: 'http', - erpProcurementExportEndpoint: 'https://example.com/procurement', - erpProcurementTimeoutMs: 500, - }, - { exportType: 'purchase_order_archive', poNo: 'PO-001' }, - ); - - expect(result.ok).toBe(false); - expect(result.code).toBe('erp_procurement_http_4xx'); - expect(result.retryable).toBe(false); - }); - - it('handles http procurement export timeout', async () => { - global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' }); - - const result = await erp.exportProcurementToErp( - { - erpSyncEnabled: true, - erpSyncProvider: 'http', - erpProcurementExportEndpoint: 'https://example.com/procurement', - erpProcurementTimeoutMs: 500, - }, - { exportType: 'purchase_order_archive', poNo: 'PO-001' }, - ); - - expect(result.ok).toBe(false); - expect(result.code).toBe('erp_procurement_timeout'); - expect(result.retryable).toBe(true); - }); - - it('handles http journal export timeout', async () => { - global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' }); - - const result = await erp.exportJournalToErp( - { - erpSyncEnabled: true, - erpSyncProvider: 'http', - erpJournalExportEndpoint: 'https://example.com/journals', - erpSyncTimeoutMs: 500, - }, - { documentNo: 'BX-001' }, - ); - - expect(result.ok).toBe(false); - expect(result.code).toBe('erp_sync_timeout'); - expect(result.retryable).toBe(true); - }); - - it('returns mock asset export result when provider is mock', async () => { - const result = await erp.exportAssetToErp( - { erpSyncEnabled: true, erpSyncProvider: 'mock' }, - { assetNo: 'ZC-001', entityId: 'asset-1' }, - ); - - expect(result.ok).toBe(true); - expect(result.code).toBe('mock_synced'); - expect(result.response.externalAssetId).toContain('ZC-001'); - expect(result.response.externalAssetNo).toContain('ZC-001'); - }); - - it('handles http asset export 4xx errors', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - headers: { get: () => 'application/json' }, - json: vi.fn().mockResolvedValue({ code: 'bad_request' }), - }); - - const result = await erp.exportAssetToErp( - { - erpSyncEnabled: true, - erpSyncProvider: 'http', - erpAssetExportEndpoint: 'https://example.com/assets', - erpAssetTimeoutMs: 500, - }, - { exportType: 'asset_ledger_summary', assetNo: 'ZC-001' }, - ); - - expect(result.ok).toBe(false); - expect(result.code).toBe('erp_asset_http_4xx'); - expect(result.retryable).toBe(false); - }); - - it('handles http asset export timeout', async () => { - global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' }); - - const result = await erp.exportAssetToErp( - { - erpSyncEnabled: true, - erpSyncProvider: 'http', - erpAssetExportEndpoint: 'https://example.com/assets', - erpAssetTimeoutMs: 500, - }, - { exportType: 'asset_ledger_summary', assetNo: 'ZC-001' }, - ); - - expect(result.ok).toBe(false); - expect(result.code).toBe('erp_asset_timeout'); - expect(result.retryable).toBe(true); - }); - - it('returns invalid provider if unsupported', async () => { - const result = await erp.exportJournalToErp( - { erpSyncEnabled: true, erpSyncProvider: 'invalid_xyz' }, - {}, - ); - expect(result.ok).toBe(false); - expect(result.code).toBe('erp_sync_provider_invalid'); - }); - - it('returns disabled if ERP sync is disabled', async () => { - const result = await erp.exportJournalToErp({ erpSyncEnabled: false }, {}); - expect(result.ok).toBe(false); - expect(result.code).toBe('erp_sync_disabled'); - }); -}); diff --git a/server/tests/unit/services/integrations/outbox.test.js b/server/tests/unit/services/integrations/outbox.test.js index fa105630..dbe69ea0 100644 --- a/server/tests/unit/services/integrations/outbox.test.js +++ b/server/tests/unit/services/integrations/outbox.test.js @@ -2,6 +2,10 @@ const outbox = require("../../../../src/services/integrations/outbox"); describe("Integration Outbox", () => { + it("rejects retired ERP enqueue before touching history", async () => { + await expect(outbox.enqueueIntegrationEvent({}, { eventType: "erp.journal.export" })).rejects.toMatchObject({ code: "erp_retired" }); + }); + it("enqueues new event", async () => { const mockPrisma = { integrationOutbox: { @@ -43,6 +47,9 @@ describe("Integration Outbox", () => { }; const items = await outbox.fetchPendingOutboxEvents(mockPrisma, { batchSize: 5 }); expect(items.length).toBe(1); + expect(mockPrisma.integrationOutbox.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ NOT: { event_type: { startsWith: "erp." } } }), + })); }); it("marks outbox succeeded", async () => { diff --git a/server/tests/unit/services/paymentOrderXls.test.js b/server/tests/unit/services/paymentOrderXls.test.js index e712462b..6852ba59 100644 --- a/server/tests/unit/services/paymentOrderXls.test.js +++ b/server/tests/unit/services/paymentOrderXls.test.js @@ -63,6 +63,19 @@ describe("payment order BIFF8 generator", () => { expect(result.totalAmount).toBe("3.20"); }); + it("exports principal repayment as one transfer with decrypted account name and repayment purpose", () => { + const batch = buildBatch({ entity_type: "loan_repayment" }); + batch.items = [batch.items[0]]; + batch.items[0].entity_code = "HK-SYNTHETIC"; + batch.items[0].snapshot_json.payee.name = "合***"; + batch.items[0].snapshot_json.payee.accountNameEncrypted = encrypt("合成出借人"); + const row = buildPaymentOrderRows(config, batch).rows[0]; + expect(row[5]).toBe("合成出借人"); + expect(row.slice(10, 12)).toEqual(["还款", "归还借款本金"]); + batch.items[0].snapshot_json.payee.accountNameEncrypted = "invalid"; + expect(() => buildPaymentOrderRows(config, batch)).toThrow(); + }); + it("preserves complete names and document codes without truncation", () => { const batch = buildBatch(); const name = "合成姓名".repeat(20); @@ -270,3 +283,17 @@ describe("payment order BIFF8 generator", () => { } }); }); + +it("keeps customer serial and business row identity with an annual PB and mixed source formats", () => { + const batch = buildBatch({ batch_no: "PB-2026-0001" }); + batch.items[0].entity_code = "BX-202609-0001"; + const original = structuredClone(batch); + const rows = buildPaymentOrderRows(config, batch); + expect(rows.serials).toEqual(["PB-2026-0001-R001", "PB-2026-0001-R002"]); + const reversed = buildPaymentOrderRows(config, { ...batch, items: [...batch.items].reverse() }); + expect(reversed).toEqual(rows); + expect(batch).toEqual(original); + const workbook = generatePaymentOrderXls(config, batch); + expect(workbook.serials).toEqual(rows.serials); + expect(workbook.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1"); +}); diff --git a/server/tests/unit/services/paymentResponsibility.test.js b/server/tests/unit/services/paymentResponsibility.test.js new file mode 100644 index 00000000..6883467b --- /dev/null +++ b/server/tests/unit/services/paymentResponsibility.test.js @@ -0,0 +1,43 @@ +const { personalNotificationText, loadPendingPaymentResponsibility } = require("../../../src/services/paymentResponsibility"); + +describe("payment responsibility and personal notification facts", () => { + it.each([ + ["wecom_app_sent", "success", "企微个人消息已发送(不代表已读)"], + ["wecom_app_no_bound_recipients", "success", "企微个人消息未发送:接收人未绑定企微"], + ["wecom_sent", "success", "仅有企微群通知记录,未确认个人消息送达"], + ["mock_sent", "success", "仅有模拟通知记录,未发送企微个人消息"], + ["wecom_app_invaliduser", "failed", "企微通知发送失败,请核对通知记录"], + ])("projects %s without converting generic success into personal delivery", (result_code, status, message) => { + expect(personalNotificationText([{ deliveries: [{ result_code, status }] }])).toBe(message); + }); + + it("does not attribute one anchor delivery to every intended recipient", () => { + expect(personalNotificationText([ + { recipient_employee_id: "first", deliveries: [{ status: "success", result_code: "wecom_app_sent" }] }, + { recipient_employee_id: "second", deliveries: [] }, + ])).toBe("存在企微个人消息发送成功记录,接收范围需核对(不代表已读)"); + }); + + it("does not infer delivery from a notification record alone", () => { + expect(personalNotificationText([{ deliveries: [] }])).toBe("暂无企微个人消息发送成功记录"); + }); + + it("separates eligible people from the selected notification recipient and omits disabled/self/admin", async () => { + const person = (id, role, extra = {}) => ({ id, name: id, status: "active", archived_at: null, roles: [{ role_code: role }], ...extra }); + const tx = { employee: { findMany: vi.fn(async () => [ + person("maker", "ceo"), person("ceo", "ceo"), person("manager", "manager"), + person("disabled", "manager", { status: "inactive" }), person("archived", "manager", { archived_at: new Date() }), + person("admin", "admin"), + ]) }, notification: { findMany: vi.fn(async () => [{ recipient: { name: "ceo" }, deliveries: [] }]) } }; + const projection = await loadPendingPaymentResponsibility(tx, { id: "batch", status: "draft", created_by: "maker" }); + expect(projection.actorName).toBe("可复核:ceo、manager"); + expect(projection.detail).toBe("站内提醒对象:ceo;暂无企微个人消息发送成功记录"); + expect(tx.notification.findMany.mock.calls[0][0].select).not.toHaveProperty("response_body"); + }); + + it("does not replace actual completed reviewers with current role holders", async () => { + const tx = { employee: { findMany: vi.fn() } }; + expect(await loadPendingPaymentResponsibility(tx, { status: "executed" })).toBeNull(); + expect(tx.employee.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/services/payroll.month.test.js b/server/tests/unit/services/payroll.month.test.js new file mode 100644 index 00000000..9a2a39bf --- /dev/null +++ b/server/tests/unit/services/payroll.month.test.js @@ -0,0 +1,17 @@ +const { parsePayMonth } = require("../../../src/services/payroll"); + +describe("pay month UTC boundaries", () => { + it.each([ + ["2024-02", "2024-02-29T23:59:59.999Z"], + ["2026-02", "2026-02-28T23:59:59.999Z"], + ["2024-12", "2024-12-31T23:59:59.999Z"], + ])("keeps exact leap/month-end %s", (month, end) => { + const result = parsePayMonth(month); + expect(result.value).toBe(month); + expect(result.startDate.toISOString()).toBe(`${month}-01T00:00:00.000Z`); + expect(result.endDate.toISOString()).toBe(end); + }); + it.each(["2026-00", "2026-13", "2026-1", "", undefined, null])("rejects invalid month %s", (input) => { + expect(parsePayMonth(input)).toBeNull(); + }); +}); diff --git a/server/tests/unit/services/purchaseBudget.test.js b/server/tests/unit/services/purchaseBudget.test.js index 71bf75d6..c1d9349e 100644 --- a/server/tests/unit/services/purchaseBudget.test.js +++ b/server/tests/unit/services/purchaseBudget.test.js @@ -1,6 +1,8 @@ const { summarizeBudgetLedgerByPool, - buildPurchaseBudgetValidationError, + reserveBudgetForEntity, + confirmBudgetForEntity, + releaseReservedBudgetIfAny, } = require("../../../src/services/purchaseBudget"); describe("purchaseBudget service", () => { @@ -32,10 +34,62 @@ describe("purchaseBudget service", () => { ]); }); - it("builds VALID_001 validation error", () => { - const error = buildPurchaseBudgetValidationError({ budget: "reserve_already_exists" }); - expect(error.code).toBe("VALID_001"); - expect(error.statusCode).toBe(409); - expect(error.details).toEqual({ budget: "reserve_already_exists" }); + function fixture(entries = [], pool = { id: "pool-1", used_amount: "20.00" }) { + return { + $queryRaw: vi.fn().mockResolvedValue([]), + budgetLedgerEntry: { findMany: vi.fn().mockResolvedValue(entries), create: vi.fn().mockResolvedValue({}) }, + budgetPool: { findUnique: vi.fn().mockResolvedValue(pool), update: vi.fn().mockResolvedValue({}) }, + }; + } + const input = { budgetPoolId: "pool-1", entityType: "purchase_request", entityId: "synthetic-request", amount: "12.34", actorId: "synthetic-actor", note: "synthetic" }; + + it("explains duplicate reserve before writes", async () => { + const tx = fixture([{ budget_pool_id: "pool-1", kind: "reserve", amount: "10.00" }]); + await expect(reserveBudgetForEntity(tx, input)).rejects.toMatchObject({ code: "VALID_001", statusCode: 409, details: { budget: "reserve_already_exists", reasonCode: "purchase_budget.reserve_already_exists" } }); + expect(tx.budgetLedgerEntry.create).not.toHaveBeenCalled(); + expect(tx.budgetPool.findUnique).not.toHaveBeenCalled(); + expect(tx.budgetPool.update).not.toHaveBeenCalled(); + }); + it("preserves attempted ledger before missing-pool rejection", async () => { + const tx = fixture([], null); + await expect(reserveBudgetForEntity(tx, input)).rejects.toMatchObject({ code: "VALID_001", statusCode: 409, details: { budgetPoolId: "not_found", reasonCode: "purchase_budget.pool_not_found" } }); + expect(tx.budgetLedgerEntry.create).toHaveBeenCalledWith({ data: { budget_pool_id: "pool-1", entity_type: "purchase_request", entity_id: "synthetic-request", kind: "reserve", amount: "12.34", created_by: "synthetic-actor", note: "synthetic" } }); + expect(tx.budgetLedgerEntry.create.mock.invocationCallOrder[0]).toBeLessThan(tx.budgetPool.findUnique.mock.invocationCallOrder[0]); + expect(tx.budgetPool.update).not.toHaveBeenCalled(); + }); + it("retains successful reserve precision and pool accumulation", async () => { + const tx = fixture(); + await expect(reserveBudgetForEntity(tx, input)).resolves.toEqual({ applied: true, reservedAmount: "12.34" }); + expect(tx.budgetPool.update).toHaveBeenCalledWith({ where: { id: "pool-1" }, data: { used_amount: "32.34", updated_by: "synthetic-actor" } }); + }); + it.each([{ budgetPoolId: null }, { amount: "0" }, { amount: "-1" }, { amount: "invalid" }])("retains no-op reserve %s", async (override) => { + const tx = fixture(); + await expect(reserveBudgetForEntity(tx, { ...input, ...override })).resolves.toEqual({ applied: false, reservedAmount: "0.00" }); + expect(tx.$queryRaw).not.toHaveBeenCalled(); + for (const model of [tx.budgetLedgerEntry, tx.budgetPool]) for (const fn of Object.values(model)) expect(fn).not.toHaveBeenCalled(); + }); + it("confirms remaining amount once without touching pool", async () => { + const tx = fixture([{ budget_pool_id: "pool-1", kind: "reserve", amount: "12.34" }, { budget_pool_id: "pool-1", kind: "confirm", amount: "2.34" }]); + await expect(confirmBudgetForEntity(tx, input)).resolves.toEqual({ applied: true, items: [{ budgetPoolId: "pool-1", amount: "10.00" }] }); + expect(tx.budgetLedgerEntry.create).toHaveBeenCalledWith({ data: expect.objectContaining({ kind: "confirm", amount: "10.00" }) }); + expect(tx.budgetPool.findUnique).not.toHaveBeenCalled(); + expect(tx.budgetPool.update).not.toHaveBeenCalled(); + }); + it("retains missing-pool release success with ledger and no pool update", async () => { + const tx = fixture([{ budget_pool_id: "pool-1", kind: "reserve", amount: "12.34" }], null); + await expect(releaseReservedBudgetIfAny(tx, input)).resolves.toEqual({ applied: true, items: [{ budgetPoolId: "pool-1", amount: "12.34" }] }); + expect(tx.budgetLedgerEntry.create).toHaveBeenCalledWith({ data: expect.objectContaining({ kind: "release", amount: "12.34" }) }); + expect(tx.budgetPool.update).not.toHaveBeenCalled(); + }); + it("retains release clamping and no-op fully released ledger", async () => { + const tx = fixture([{ budget_pool_id: "pool-1", kind: "reserve", amount: "30.00" }]); + await releaseReservedBudgetIfAny(tx, input); + expect(tx.budgetPool.update).toHaveBeenCalledWith({ where: { id: "pool-1" }, data: { used_amount: "0.00", updated_by: "synthetic-actor" } }); + const completed = fixture([{ budget_pool_id: "pool-1", kind: "reserve", amount: "30.00" }, { budget_pool_id: "pool-1", kind: "release", amount: "30.00" }]); + await expect(releaseReservedBudgetIfAny(completed, input)).resolves.toEqual({ applied: false, items: [] }); + await expect(confirmBudgetForEntity(completed, input)).resolves.toEqual({ applied: false, items: [] }); + expect(completed.budgetLedgerEntry.create).not.toHaveBeenCalled(); + expect(completed.budgetPool.findUnique).not.toHaveBeenCalled(); + expect(completed.budgetPool.update).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/services/reimbursementPrecheck.snapshot.test.js b/server/tests/unit/services/reimbursementPrecheck.snapshot.test.js new file mode 100644 index 00000000..35931c63 --- /dev/null +++ b/server/tests/unit/services/reimbursementPrecheck.snapshot.test.js @@ -0,0 +1,109 @@ +const { resolveEntityPrecheck } = require('../../../src/services/feeControl'); + +const ID = '11111111-1111-4111-8111-111111111111'; +function project(value, select) { + if (!select || value == null) return value; + if (Array.isArray(value)) return value.map(row => project(row, select)); + return Object.fromEntries(Object.entries(select).map(([key, choice]) => [ + key, choice === true ? value[key] : project(value[key], choice.select), + ])); +} +function fixture() { + const state = { form: { + id: ID, department_id: 'department', owner_id: 'owner', submitter_id: 'owner', + reimbursement_date: new Date('2026-09-18T00:00:00Z'), total_amount: '180.00', status: 'draft', + items: [{ id: 'item', expense_type_id: 'type', amount: '180.00', + expense_date: new Date('2026-09-18T00:00:00Z'), + expense_type: { id: 'type', name: '合成办公费', category: 'office' } }], + } }; + const tx = { + reimbursementForm: { findUnique: vi.fn(async ({ select }) => project(state.form, select)) }, + featureFlag: { findUnique: vi.fn(async () => ({ flag_key: 'budget_precheck_v1', + override_mode: 'force_on', rollout_type: 'global', department_ids: [], version: 1 })) }, + expenseStandard: { findMany: vi.fn(async () => [{ id: 'standard', expense_type_id: 'type', + name: '合成上限', max_amount: '100.00', per_unit: 'item', control_mode: 'block', scope_type: 'global' }]) }, + employee: { findUnique: vi.fn(async () => ({ id: 'owner', roles: [{ role_code: 'employee' }] })) }, + employeeRole: { findFirst: vi.fn(async () => ({ employee_id: 'approver' })) }, + invoicePool: { count: vi.fn(async () => 1), findMany: vi.fn(async () => [{ linked_item_id: 'item' }]) }, + reimbursementAttachment: { count: vi.fn(async () => 2) }, + workflowInstance: { findFirst: vi.fn(async () => null) }, + approvalRule: { findFirst: vi.fn(async () => ({ id: 'rule', level1_role: 'finance', priority: 1 })) }, + budgetControlRule: { findMany: vi.fn(async () => []) }, + budgetPool: { findMany: vi.fn(async () => []) }, + purchaseRequest: { findUnique: vi.fn(async () => ({ id: ID, department_id: 'department' })) }, + }; + const run = (enabled = false, extra = {}) => resolveEntityPrecheck({ + prisma: tx, config: { budgetPrecheckEnabled: enabled, appTimezone: 'Asia/Shanghai' }, + entityType: 'reimbursement', entityId: ID, ...extra, + }); + return { state, tx, run }; +} + +describe('reimbursement precheck record hydration', () => { + beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-09-18T04:00:00Z')); }); + afterEach(() => vi.useRealTimers()); + + it.each([false, true])('shares one database record across all checks with budget enabled=%s', async enabled => { + const { tx, run } = fixture(); + const result = await run(enabled); + expect(result.decision).toBe('block'); + expect(result.standardChecks).toEqual([expect.objectContaining({ amount: '180.00', exceededAmount: '80.00', controlMode: 'block' })]); + expect(result.evidenceSummary).toEqual({ invoiceCount: 1, attachmentCount: 2, + items: [{ itemId: 'item', linkedInvoiceCount: 1, invoiceState: 'linked' }] }); + expect(result.timelinessChecks[0]).toMatchObject({ ageDays: 0, expenseDate: '2026-09-18' }); + // A relation hydration issues multiple SQL statements; repeated reads were + // measured inside the five-second submit transaction under load. + expect(tx.reimbursementForm.findUnique).toHaveBeenCalledTimes(1); + expect(tx.approvalRule.findFirst).toHaveBeenCalledTimes(enabled ? 1 : 0); + }); + + it('does not cache a record between requests', async () => { + const { tx, state, run } = fixture(); + expect((await run()).standardChecks[0].amount).toBe('180.00'); + state.form.items[0].amount = '80.00'; + expect((await run()).standardChecks[0]).toMatchObject({ amount: '80.00', exceeded: false }); + expect(tx.reimbursementForm.findUnique).toHaveBeenCalledTimes(2); + }); + + it('uses the locked pool snapshot without a second budget selection and preserves one entity read', async () => { + const { tx, run } = fixture(); + tx.expenseStandard.findMany.mockResolvedValue([]); + const result = await run(true, { budgetPoolForWrite: { id: 'locked-pool', code: 'SYN', name: '合成预算', + used_amount: '60.00', allocated_amount: '200.00', warning_threshold_percent: 100 } }); + expect(result.budgetHint).toMatchObject({ budgetId: 'locked-pool', usedAmount: '60.00', nextUsedAmount: '240.00', exceedsBudget: true }); + expect(tx.budgetPool.findMany).not.toHaveBeenCalled(); + expect(tx.reimbursementForm.findUnique).toHaveBeenCalledTimes(1); + }); + + it('keeps finance-owner CEO requirements when budget checks are disabled', async () => { + const { tx, run } = fixture(); + tx.employee.findUnique.mockResolvedValue({ roles: [{ role_code: 'finance' }] }); + tx.employeeRole.findFirst.mockResolvedValue(null); + const result = await run(); + expect(result.blockingReasons).toContainEqual(expect.objectContaining({ code: 'finance_owner_ceo_unavailable' })); + expect(tx.employeeRole.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { + role_code: 'ceo', employee_id: { notIn: ['owner'] }, employee: { status: 'active', archived_at: null }, + } })); + }); + + it.each([false, true])('rejects missing records before downstream checks: budget=%s', async enabled => { + const { state, tx, run } = fixture(); state.form = null; + await expect(run(enabled)).rejects.toMatchObject({ code: 'SYS_404', statusCode: 404 }); + expect(tx.featureFlag.findUnique).not.toHaveBeenCalled(); + expect(tx.expenseStandard.findMany).not.toHaveBeenCalled(); + }); + + it('propagates database failures without a disabled or empty fallback', async () => { + const { tx, run } = fixture(); const failure = new Error('synthetic_read_failure'); + tx.reimbursementForm.findUnique.mockRejectedValue(failure); + await expect(run()).rejects.toBe(failure); + }); + + it('retains the non-reimbursement disabled branch', async () => { + const { tx, run } = fixture(); + const result = await run(false, { entityType: 'purchase_request' }); + expect(result).toMatchObject({ entityType: 'purchase_request', decision: 'pass', standardChecks: [] }); + expect(tx.reimbursementForm.findUnique).not.toHaveBeenCalled(); + expect(tx.purchaseRequest.findUnique).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/tests/unit/services/sensitiveRecoveryContract.test.js b/server/tests/unit/services/sensitiveRecoveryContract.test.js new file mode 100644 index 00000000..84a82b8b --- /dev/null +++ b/server/tests/unit/services/sensitiveRecoveryContract.test.js @@ -0,0 +1,145 @@ +const { randomBytes, createHash } = require("node:crypto"); +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); +const cryptoService = require("../../../src/services/crypto"); +const { + checkSyntheticSample, + runSyntheticContract, +} = require("../../../../scripts/qa/check-sensitive-recovery-contract.cjs"); + +describe("K0 synthetic sensitive recovery contract", () => { + let config; + let plaintext; + let encrypted; + + beforeEach(() => { + config = { fieldEncryptionKey: randomBytes(32).toString("base64") }; + plaintext = `synthetic-only-${randomBytes(16).toString("hex")}`; + encrypted = cryptoService.encryptStringOrThrow(config, plaintext); + }); + + // Boolean assertions keep failed tests from printing fixture values/digests. + function expectNoFixture(output, additional = []) { + const key = Buffer.from(config.fieldEncryptionKey, "base64"); + const forbidden = [ + plaintext, encrypted, config.fieldEncryptionKey, key.toString("hex"), + createHash("sha256").update(key).digest("hex"), + createHash("sha256").update(config.fieldEncryptionKey).digest("hex"), + ...encrypted.split(":").slice(1), ...additional, + ]; + expect(forbidden.some((value) => output.includes(value))).toBe(false); + } + + it("round-trips with the actual production crypto exports and only reports status", () => { + const report = checkSyntheticSample(config, encrypted, plaintext); + expect(report).toEqual({ status: "PASS", reason: "SYNTHETIC_MATCH" }); + expectNoFixture(JSON.stringify(report)); + }); + + it.each([ + ["no config", undefined], ["no key", {}], + ["null", { fieldEncryptionKey: null }], ["empty", { fieldEncryptionKey: "" }], + ["whitespace", { fieldEncryptionKey: " " }], + ["wrong type", { fieldEncryptionKey: 32 }], + ["unparseable", { fieldEncryptionKey: "%%%" }], + ["short", { fieldEncryptionKey: Buffer.alloc(31).toString("base64") }], + ["long", { fieldEncryptionKey: Buffer.alloc(33).toString("base64") }], + ])("stops for an unavailable or invalid key: %s", (_label, candidate) => { + expect(checkSyntheticSample(candidate, encrypted, plaintext)).toEqual({ + status: "STOP", reason: "KEY_UNAVAILABLE_OR_INVALID", + }); + }); + + it("stops for a different but valid 32-byte key", () => { + const wrongKey = Buffer.from(config.fieldEncryptionKey, "base64"); + wrongKey[0] ^= 1; + const report = checkSyntheticSample({ fieldEncryptionKey: wrongKey.toString("base64") }, encrypted, plaintext); + expect(report).toEqual({ status: "STOP", reason: "DECRYPTION_FAILED" }); + expectNoFixture(JSON.stringify(report), [wrongKey.toString("base64")]); + }); + + it.each([["iv", 1], ["tag", 2], ["ciphertext", 3]])("stops after tampering with %s", (_label, index) => { + const parts = encrypted.split(":"); + const value = Buffer.from(parts[index], "base64url"); + value[0] ^= 1; + parts[index] = value.toString("base64url"); + const tampered = parts.join(":"); + const report = checkSyntheticSample(config, tampered, plaintext); + expect(report).toEqual({ status: "STOP", reason: "DECRYPTION_FAILED" }); + expectNoFixture(JSON.stringify(report), [tampered]); + }); + + it.each([["empty", ""], ["null", null], ["absent", undefined]])("marks %s fields NOT_TESTED", (_label, value) => { + expect(checkSyntheticSample(config, value, plaintext)).toEqual({ status: "NOT_TESTED", reason: "EMPTY_FIELD" }); + }); + + it("does not let an empty field hide a missing key", () => { + expect(checkSyntheticSample({}, "", plaintext)).toEqual({ status: "STOP", reason: "KEY_UNAVAILABLE_OR_INVALID" }); + }); + + it.each([ + ["legacy plaintext", "synthetic-unencrypted"], ["unknown version", "v2:a:b:c"], + ["missing parts", "v1:a:b"], ["extra parts", "v1:a:b:c:d"], + ["empty parts", "v1:::"], ["invalid encoding", "v1:%%:%%:%%"], + ["wrong type", 123], + ])("stops instead of treating a null decryption as success: %s", (_label, payload) => { + expect(checkSyntheticSample(config, payload, plaintext)).toEqual({ status: "STOP", reason: "INVALID_PAYLOAD" }); + }); + + it("requires the synthetic expectation and detects a plaintext mismatch", () => { + expect(checkSyntheticSample(config, encrypted)).toEqual({ status: "STOP", reason: "SYNTHETIC_EXPECTATION_REQUIRED" }); + const report = checkSyntheticSample(config, encrypted, "different-synthetic-value"); + expect(report).toEqual({ status: "STOP", reason: "PLAINTEXT_MISMATCH" }); + expectNoFixture(JSON.stringify(report)); + }); + + it("drops raw exceptions, does not log, and never uses the logging decrypt branch", () => { + const marker = `synthetic-error-${randomBytes(8).toString("hex")}`; + const spies = ["log", "warn", "error", "info", "debug"].map((name) => vi.spyOn(console, name).mockImplementation(() => {})); + const safe = vi.spyOn(cryptoService, "decryptStringSafe").mockImplementation(() => { throw new Error(marker); }); + try { + const throwingConfig = { get fieldEncryptionKey() { throw new Error(marker); } }; + const report = checkSyntheticSample(throwingConfig, encrypted, plaintext); + expect(report).toEqual({ status: "STOP", reason: "DECRYPTION_FAILED" }); + expectNoFixture(JSON.stringify(report), [marker]); + expect(runSyntheticContract().status).toBe("PASS"); + expect(safe).not.toHaveBeenCalled(); + expect(spies.every((spy) => spy.mock.calls.length === 0)).toBe(true); + } finally { + safe.mockRestore(); + spies.forEach((spy) => spy.mockRestore()); + } + }); + + it("keeps real custody and real-data decryption untested after the synthetic matrix passes", () => { + const report = runSyntheticContract(); + expect(report.status).toBe("PASS"); + expect(report.checks).toHaveLength(7); + expect(report.checks.every((check) => check.matched)).toBe(true); + expect(report).toMatchObject({ scope: "K0_SYNTHETIC_ONLY", originalKeySource: "UNKNOWN", custody: "NOT_TESTED", realDataDecryption: "NOT_TESTED" }); + }); + + it("CLI ignores ambient key inputs and produces only the fixed aggregate contract", () => { + const script = path.resolve(__dirname, "../../../../scripts/qa/check-sensitive-recovery-contract.cjs"); + const child = spawnSync(process.execPath, [script], { + encoding: "utf8", + env: { FIELD_ENCRYPTION_KEY: config.fieldEncryptionKey, DATABASE_URL: "synthetic-unused" }, + }); + expect(child.status).toBe(0); + expect(child.stderr.length).toBe(0); + expectNoFixture(child.stdout + child.stderr); + const report = JSON.parse(child.stdout); + expect(Object.keys(report).sort()).toEqual(["checks", "custody", "originalKeySource", "realDataDecryption", "scope", "status"]); + expect(report.status).toBe("PASS"); + expect(report.checks.every((check) => Object.keys(check).sort().join(",") === "category,matched,reason,status")).toBe(true); + }); + + it("CLI rejects arbitrary arguments without echoing them", () => { + const script = path.resolve(__dirname, "../../../../scripts/qa/check-sensitive-recovery-contract.cjs"); + const child = spawnSync(process.execPath, [script, plaintext], { encoding: "utf8", env: {} }); + expect(child.status).toBe(1); + expect(child.stderr.length).toBe(0); + expectNoFixture(child.stdout + child.stderr); + expect(JSON.parse(child.stdout)).toEqual({ scope: "K0_SYNTHETIC_ONLY", status: "FAIL", reason: "ARGUMENTS_NOT_ALLOWED" }); + }); +}); diff --git a/server/tests/unit/utils/client-pwa-lifecycle.test.js b/server/tests/unit/utils/client-pwa-lifecycle.test.js new file mode 100644 index 00000000..cc8e39c0 --- /dev/null +++ b/server/tests/unit/utils/client-pwa-lifecycle.test.js @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +class Worker extends EventTarget { + constructor(state = "activated") { super(); this.state = state; } + transition(state) { this.state = state; this.dispatchEvent(new Event("statechange")); } +} + +async function harness({ pathname = "/portal", controller = new Worker(), registrationRejects = false } = {}) { + const container = new EventTarget(); + container.controller = controller; + // Leave registration pending to reproduce the missed asynchronous window. + container.register = vi.fn(() => registrationRejects ? Promise.reject(new Error("synthetic unavailable")) : new Promise(() => {})); + const win = new EventTarget(); + win.navigator = { serviceWorker: container }; + win.location = { pathname, reload: vi.fn() }; + win.caches = { + keys: vi.fn().mockResolvedValue(["app-api-runtime", "app-static-runtime", "workbox-precache-v2-synthetic", "unrelated-cache"]), + delete: vi.fn().mockResolvedValue(true), + }; + const notices = []; + const offline = vi.fn(); + win.addEventListener("hyb-oa:pwa-update-ready", e => notices.push(e.detail)); + win.addEventListener("hyb-oa:pwa-offline-ready", offline); + vi.stubGlobal("window", win); + const pwa = await import("../../../../client/src/pwa"); + const change = worker => { container.controller = worker; container.dispatchEvent(new Event("controllerchange")); }; + return { pwa, win, container, notices, offline, change }; +} + +beforeEach(() => { + vi.resetModules(); + vi.stubEnv("DEV", false); + vi.stubEnv("VITE_PWA_ENABLED", "true"); + vi.stubEnv("BASE_URL", "/"); +}); +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); + +describe("native PWA update lifecycle", () => { + it("observes takeover before register resolves, waits for activation and refreshes once", async () => { + const h = await harness(); + h.pwa.registerPwaIfEnabled(); + h.pwa.registerPwaIfEnabled(); + expect(h.container.register).toHaveBeenCalledTimes(1); + const next = new Worker("activating"); + h.change(next); h.change(next); + await Promise.resolve(); + expect(h.win.location.reload).not.toHaveBeenCalled(); + next.transition("activated"); + await vi.waitFor(() => expect(h.win.location.reload).toHaveBeenCalledTimes(1)); + h.change(new Worker()); + await Promise.resolve(); + expect(h.win.location.reload).toHaveBeenCalledTimes(1); + expect(h.win.caches.delete.mock.calls.map(([name]) => name).sort()).toEqual(["app-api-runtime", "app-static-runtime"]); + }); + + it("first installation becomes offline-ready without a reload or cache deletion", async () => { + const h = await harness({ controller: null }); + h.pwa.registerPwaIfEnabled(); + const first = new Worker("activating"); + h.change(first); first.transition("activated"); + await vi.waitFor(() => expect(h.offline).toHaveBeenCalledOnce()); + expect(h.win.location.reload).not.toHaveBeenCalled(); + expect(h.win.caches.delete).not.toHaveBeenCalled(); + h.change(new Worker()); + await vi.waitFor(() => expect(h.win.location.reload).toHaveBeenCalledOnce()); + }); + + it.each(["/login", "/change-password", "/forgot-password", "/reset-password/token"])("defers updates on %s until the user applies them", async pathname => { + const h = await harness({ pathname }); + h.pwa.registerPwaIfEnabled(); h.change(new Worker()); + await vi.waitFor(() => expect(h.notices).toHaveLength(1)); + expect(h.win.location.reload).not.toHaveBeenCalled(); + expect(h.win.caches.delete).not.toHaveBeenCalled(); + h.notices[0].applyUpdate(); h.notices[0].applyUpdate(); + await vi.waitFor(() => expect(h.win.location.reload).toHaveBeenCalledTimes(1)); + }); + + it("ignores a superseded/redundant controller and then accepts the current one", async () => { + const h = await harness(); h.pwa.registerPwaIfEnabled(); + const stale = new Worker("activating"); h.change(stale); + stale.transition("redundant"); await Promise.resolve(); + expect(h.win.location.reload).not.toHaveBeenCalled(); + h.change(new Worker()); + await vi.waitFor(() => expect(h.win.location.reload).toHaveBeenCalledOnce()); + }); + + it("preserves explicit logout/manual-refresh precache clearing", async () => { + const h = await harness(); await h.pwa.clearPwaRuntimeCaches(); + expect(h.win.caches.delete.mock.calls.map(([name]) => name).sort()).toEqual(["app-api-runtime", "app-static-runtime", "workbox-precache-v2-synthetic"]); + }); + + it("still refreshes once when runtime cache deletion is unavailable", async () => { + const h = await harness(); h.win.caches.keys.mockRejectedValue(new Error("synthetic unavailable")); + h.pwa.registerPwaIfEnabled(); h.change(new Worker()); + await vi.waitFor(() => expect(h.win.location.reload).toHaveBeenCalledOnce()); + }); + + it.each(["development", "disabled", "unsupported"])("does not register when %s", async mode => { + const h = await harness(); + if (mode === "development") vi.stubEnv("DEV", true); + if (mode === "disabled") vi.stubEnv("VITE_PWA_ENABLED", "false"); + if (mode === "unsupported") delete h.win.navigator.serviceWorker; + h.pwa.registerPwaIfEnabled(); expect(h.container.register).not.toHaveBeenCalled(); + }); + + it("handles registration failure without a refresh", async () => { + const h = await harness({ registrationRejects: true }); + h.pwa.registerPwaIfEnabled(); await Promise.resolve(); + expect(h.win.location.reload).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/utils/workflow.test.js b/server/tests/unit/utils/workflow.test.js index 54a9d52d..77bdf318 100644 --- a/server/tests/unit/utils/workflow.test.js +++ b/server/tests/unit/utils/workflow.test.js @@ -453,3 +453,25 @@ describe("workflow assignee resolution", () => { ]); }); }); + +describe("workflow shared error reasons", () => { + function errorFrom(run) { try { run(); } catch (error) { return error; } throw new Error("expected rejection"); } + it.each([undefined, { steps: [] }, { steps: "bad" }])("explains invalid steps %s", (definition) => { expect(errorFrom(() => workflowUtils.parseWorkflowSteps(definition))).toMatchObject({ code: "VALID_001", statusCode: 400, details: { workflow: "invalid_steps", reasonCode: "workflow.steps_invalid" } }); }); + it.each([[null, "approval_rule_invalid", "invalid_approval_rule"], [{}, "approval_chain_empty", "empty_approval_chain"], [{ level1_role: "admin" }, "business_role_unsupported", "unsupported_business_approval_role"]])("explains approval rule %s", (rule, reason, detail) => { expect(errorFrom(() => workflowUtils.buildWorkflowStepsFromApprovalRule(rule))).toMatchObject({ code: "VALID_001", statusCode: 400, details: { workflow: detail, reasonCode: `workflow.${reason}` } }); }); + it.each([[null, {}, "assignee_rule_invalid", "invalid_assignee_rule"], [{ type: "role", value: "" }, {}, "role_assignee_invalid", "invalid_role_assignee"], [{ type: "role", value: "admin" }, {}, "business_role_unsupported", "unsupported_business_approval_role"], [{ type: "specific_user", value: "bad" }, {}, "user_assignee_invalid", "invalid_specific_user_assignee"], [{ type: "department_manager" }, {}, "manager_context_invalid", "invalid_department_manager_context"], [{ type: "unsupported" }, {}, "assignee_type_unsupported", "unsupported_assignee_rule_type"]])("explains assignee rule %s", async (rule, context, reason, detail) => { await expect(workflowUtils.resolveAssigneeFromRule(rule, context)).rejects.toMatchObject({ code: "VALID_001", statusCode: 400, details: { workflow: detail, reasonCode: `workflow.${reason}` } }); }); + it.each([["hr", ["ceo"], true, "fallback_role_unsupported", 400, { ownerSelfFallbackRole: "unsupported_business_approval_role" }], ["finance", ["ceo", "finance"], true, "fallback_role_is_self", 409, { workflow: "no_actionable_approver", reason: "owner_self_fallback_self_role" }], ["finance", ["ceo"], false, "fallback_holder_unavailable", 409, { workflow: "no_actionable_approver", reason: "owner_self_fallback_role_unavailable", fallbackRole: "finance" }]])("explains fallback %s with roles %s", async (role, roles, available, reason, status, details) => { + const tx = buildTx({ ownerRoles: roles, fallbackHasHolder: available }); + await expect(workflowUtils.buildApprovalWorkflowSnapshot({ tx, ownerId: OWNER_ID, approvalRule: { level1_role: "manager", owner_self_fallback_role: role } })).rejects.toMatchObject({ code: "VALID_001", statusCode: status, details: { reasonCode: `workflow.${reason}`, ...details } }); + if (reason !== "fallback_holder_unavailable") expect(tx.employeeRole.findFirst).not.toHaveBeenCalled(); + }); + it.each([[null, "chain_broken"], [{ id: OWNER_ID, direct_manager_id: null }, "no_manager_found"], [{ id: OWNER_ID, direct_manager_id: OWNER_ID }, "self_loop"]])("explains blocked manager %s while preserving optional skip", async (row, reason) => { + const tx = { employee: { findUnique: vi.fn().mockResolvedValue(row) } }; + const options = { tx, steps: buildSteps(), startIndex: 0, ownerId: OWNER_ID }; + await expect(workflowUtils.resolveActionableStep({ ...options, blockOnUnresolvedAssignee: true })).rejects.toMatchObject({ code: "VALID_001", statusCode: 409, details: { workflow: "no_actionable_approver", reason, stepId: "approval_1_manager", reasonCode: "workflow.manager_unavailable" } }); + const skipped = await workflowUtils.resolveActionableStep(options); expect(skipped.actionable.assigneeRole).toBe("finance"); expect(skipped.skipped[0].reason).toBe(reason); + }); + it("preserves valid specific-user and historical snapshot selection", async () => { + await expect(workflowUtils.resolveAssigneeFromRule({ type: "specific_user", value: MANAGER_ID })).resolves.toMatchObject({ assigneeId: MANAGER_ID, assigneeRole: null }); + const steps = buildSteps(); expect(workflowUtils.getWorkflowStepsSnapshot({ context_data: { workflow_steps_snapshot: steps }, definition: { definition_json: { steps: [{ id: "different" }] } } })).toEqual(steps); + }); +}); diff --git a/server/tests/unit/wecomHrMaster.calendar.test.js b/server/tests/unit/wecomHrMaster.calendar.test.js new file mode 100644 index 00000000..7f48cee2 --- /dev/null +++ b/server/tests/unit/wecomHrMaster.calendar.test.js @@ -0,0 +1,129 @@ +const { normalizeWecomHrProfile, runWecomHrMasterSync } = require("../../src/services/integrations/wecomHrMaster"); + +function profileFor(value, userid = "synthetic-calendar") { + return normalizeWecomHrProfile({ + contact: { userid, name: "合成员工", email: `${userid}@example.invalid`, department: [1] }, + departmentMap: new Map([["1", { name: "合成部门" }]]), + staff: { staff_info: value === undefined ? [] : [{ fieldid: "入职日期", value }] }, + }); +} + +function fixture(existing = null) { + const items = []; + let run; + const prisma = { + integrationBinding: { findFirst: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue({}) }, + department: { + findUnique: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]), + create: vi.fn(async ({ data }) => ({ ...data, id: "synthetic-department" })), update: vi.fn(), + }, + wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(existing ? { employee: existing } : null), upsert: vi.fn().mockResolvedValue({}) }, + employee: { + findUnique: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]), + create: vi.fn(async ({ data }) => ({ ...data, id: "synthetic-created" })), + update: vi.fn(async ({ data }) => ({ ...data, id: existing?.id })), + }, + employeeRole: { createMany: vi.fn().mockResolvedValue({ count: 1 }) }, + wecomHrMasterSyncRun: { + create: vi.fn(async ({ data }) => (run = { ...data, id: "synthetic-run" })), + update: vi.fn(async ({ data }) => (run = { ...run, ...data })), + }, + wecomHrMasterSyncItem: { + create: vi.fn(async ({ data }) => { const item = { ...data, id: `synthetic-item-${items.length}` }; items.push(item); return item; }), + findMany: vi.fn(async () => items), update: vi.fn(), + }, + }; + prisma.$transaction = vi.fn(async (callback) => callback(prisma)); + const fetchImpl = vi.fn(() => { throw new Error("offline test must not fetch"); }); + return { + prisma, fetchImpl, + run: (profiles, mode = "apply") => runWecomHrMasterSync({ prisma, config: {}, actorId: "synthetic-actor", mode, sourceProfiles: profiles, fetchImpl }), + }; +} + +const invalidInputs = ["2026-02-29", "2024/2/30", "2026.4.31", "20260230", "2026-13-01", "2026-01-00", "not-a-date", "0", "-1", "253402300800000", "999999999999999999999"]; +const validInputs = [ + ["2024-02-29", "2024-02-29"], ["2024/2/29", "2024-02-29"], ["2024.2.29", "2024-02-29"], + ["20240229", "2024-02-29"], ["2026-4-30", "2026-04-30"], + ["1709164800", "2024-02-29"], [1709164800000, "2024-02-29"], +]; + +function expectNoBusinessCalls(prisma) { + for (const name of ["integrationBinding", "department", "employee", "employeeRole", "wecomIdentityBinding"]) { + for (const fn of Object.values(prisma[name])) expect(fn).not.toHaveBeenCalled(); + } + expect(prisma.wecomHrMasterSyncItem.update).not.toHaveBeenCalled(); +} + +describe("offline HR hire-date calendar contract", () => { + afterEach(() => vi.useRealTimers()); + + it.each(invalidInputs.flatMap((value) => ["apply", "dry_run"].map((mode) => [value, mode])))("skips invalid %s in %s before business access", async (value, mode) => { + const f = fixture(); + const profile = { ...profileFor(value), managerExternalUserId: "synthetic-manager" }; + expect(profile.hireDate).toBeNull(); + expect(profile.hireDateInvalid).toBe(true); + const result = await f.run([profile], mode); + expect(result.status).toBe("partial"); + expect(result.items[0]).toMatchObject({ action: "skip", status: "skipped", errorCode: "invalid_hire_date", employeeId: null, + errorMessage: "入职日期无效,本条未同步;请修正来源日期后重试。" }); + expect(result.items[0].fieldChanges).toBeNull(); + expect(result.items[0].sourceSummary).not.toHaveProperty("hireDate"); + expectNoBusinessCalls(f.prisma); + expect(f.prisma.wecomHrMasterSyncRun.create).toHaveBeenCalledOnce(); + expect(f.prisma.wecomHrMasterSyncItem.create).toHaveBeenCalledOnce(); + expect(f.fetchImpl).not.toHaveBeenCalled(); + }); + + it.each(validInputs)("creates exact UTC date for %s", async (input, canonical) => { + const f = fixture(); + const profile = profileFor(input); + expect(profile.hireDate).toBe(canonical); + expect(profile.hireDateInvalid).toBe(false); + const result = await f.run([profile]); + expect(result.status).toBe("success"); + expect(f.prisma.employee.create.mock.calls[0][0].data.hire_date).toEqual(new Date(`${canonical}T00:00:00.000Z`)); + expect(f.prisma.employeeRole.createMany).toHaveBeenCalledOnce(); + expect(f.prisma.wecomIdentityBinding.upsert).toHaveBeenCalledOnce(); + expect(f.fetchImpl).not.toHaveBeenCalled(); + }); + + it.each([undefined, null, "", " "])("keeps missing %s defaulted to the run time", async (value) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-09-17T01:02:03.000Z")); + const f = fixture(); + const result = await f.run([profileFor(value)]); + expect(result.status).toBe("success"); + expect(f.prisma.employee.create.mock.calls[0][0].data.hire_date).toEqual(new Date("2026-09-17T01:02:03.000Z")); + expect(f.fetchImpl).not.toHaveBeenCalled(); + }); + + it.each(["apply", "dry_run"])("rejects invalid directly supplied normalized profiles in %s", async (mode) => { + const f = fixture({ id: "synthetic-existing" }); + const result = await f.run([{ ...profileFor(undefined), hireDate: "2026-02-30", managerExternalUserId: "synthetic-manager" }], mode); + expect(result.items[0].errorCode).toBe("invalid_hire_date"); + expectNoBusinessCalls(f.prisma); + expect(f.fetchImpl).not.toHaveBeenCalled(); + }); + + it("never adds hire_date to existing employee updates", async () => { + const f = fixture({ id: "synthetic-existing", name: "合成员工", email: "synthetic-calendar@example.invalid", hire_date: new Date("2020-01-01T00:00:00Z") }); + const result = await f.run([profileFor("2024-02-29")]); + expect(result.status).toBe("success"); + expect(f.prisma.employee.create).not.toHaveBeenCalled(); + expect(f.prisma.employee.update).toHaveBeenCalledOnce(); + expect(f.prisma.employee.update.mock.calls[0][0].data).not.toHaveProperty("hire_date"); + }); + + it.each(["apply", "dry_run"])("continues valid profiles after an invalid one in %s", async (mode) => { + const f = fixture(); + const result = await f.run([profileFor("2026-02-30", "synthetic-invalid"), profileFor("2024-02-29", "synthetic-valid")], mode); + expect(result.status).toBe("partial"); + expect(result.summary).toMatchObject({ total: 2, skipped: 1, failed: 0, created: 1 }); + expect(result.items.map((item) => item.status)).toEqual(["skipped", mode === "apply" ? "success" : "planned"]); + for (const fn of [f.prisma.department.create, f.prisma.integrationBinding.create, f.prisma.employee.create, f.prisma.employeeRole.createMany, f.prisma.wecomIdentityBinding.upsert]) { + expect(fn).toHaveBeenCalledTimes(mode === "apply" ? 1 : 0); + } + expect(f.fetchImpl).not.toHaveBeenCalled(); + }); +});