From c4df9a419a78e0e23d0f49c63c1b6bd8bfdcc004 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 7 Feb 2026 21:54:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=AE=8C=E6=88=90=20P2=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E8=83=BD=E5=8A=9B=EF=BC=88=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E3=80=81=E5=A7=94=E6=B4=BE=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E3=80=81OCR=E5=BC=82=E6=AD=A5=E3=80=81=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + server/.env.example | 12 + server/README.md | 58 ++- server/package-lock.json | 433 ++++++++++++++++++ server/package.json | 8 +- .../20260207180145_p2_phase1/migration.sql | 25 + server/prisma/schema.prisma | 18 +- server/scripts/regression-workflow-actions.sh | 210 +++++++-- server/src/app.js | 9 + server/src/config.js | 13 + server/src/constants/permissions.js | 1 + server/src/jobs/delegationRevokeJob.js | 103 +++++ server/src/jobs/ocrJob.js | 314 +++++++++++++ server/src/routes/audit.js | 127 +++++ server/src/routes/delegations.js | 297 ++++++++++++ server/src/routes/invoicePool.js | 93 +++- server/src/routes/reimbursements.js | 83 +++- server/src/routes/workflow.js | 130 ++++-- .../src/scripts/revoke-expired-delegations.js | 19 + server/src/scripts/run-ocr-once.js | 26 ++ server/src/services/ocr/index.js | 22 + server/src/services/ocr/tencent.js | 103 +++++ server/src/utils/delegation.js | 66 +++ 23 files changed, 2102 insertions(+), 70 deletions(-) create mode 100644 server/prisma/migrations/20260207180145_p2_phase1/migration.sql create mode 100644 server/src/jobs/delegationRevokeJob.js create mode 100644 server/src/jobs/ocrJob.js create mode 100644 server/src/routes/audit.js create mode 100644 server/src/routes/delegations.js create mode 100644 server/src/scripts/revoke-expired-delegations.js create mode 100644 server/src/scripts/run-ocr-once.js create mode 100644 server/src/services/ocr/index.js create mode 100644 server/src/services/ocr/tencent.js create mode 100644 server/src/utils/delegation.js diff --git a/.gitignore b/.gitignore index 721dd683..7a2a4b07 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ server/generated/prisma **/dist + +server/generated/storage diff --git a/server/.env.example b/server/.env.example index fac80d26..88495b6c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -7,3 +7,15 @@ APP_TIMEZONE="Asia/Shanghai" STORAGE_DIR="generated/storage" SIGNED_URL_TTL_SECONDS="600" MAX_UPLOAD_SIZE_BYTES="10485760" + +DELEGATION_REVOKE_CRON="0 * * * *" + +OCR_PROVIDER="off" +OCR_BATCH_SIZE="10" +OCR_LOCK_TTL_SECONDS="300" +OCR_POLL_CRON="* * * * *" +OCR_MAX_ATTEMPTS="3" +OCR_TIMEOUT_MS="5000" +TENCENT_SECRET_ID="" +TENCENT_SECRET_KEY="" +TENCENT_OCR_REGION="ap-beijing" diff --git a/server/README.md b/server/README.md index 417957b9..f714202b 100644 --- a/server/README.md +++ b/server/README.md @@ -90,6 +90,9 @@ cd /home/shunli/Projects/hyb-oa/server - `pay` 权限:非财务返回 `PERM_001` - `pay` 状态校验:非 `approved/paid` 返回 `VALID_001` - `pay` 幂等:重复付款返回 200(`archivedInvoiceCount` 可为 0) +- `pay` 幂等冲突:已付款后提交冲突凭证字段返回 `409 VALID_001`(`already_paid_conflict`) +- `delegation`:有效期内可代办并在 history 写入 delegation 证据 +- `delegation` 过期:代理人返回 `PERM_002`,自动回收任务可撤销过期委派 ### 4.4 一键全量回归 ```bash @@ -126,19 +129,62 @@ npm run dev --- -## 5. 关键环境变量 +## 5. P2 API 摘要 + +### 5.1 付款标记(pay) +- `POST /api/reimbursements/:id/pay` +- 权限:`expense.finance.pay` +- 可选请求体:`paymentVoucherNo`、`paymentDate(YYYY-MM-DD)`、`paymentChannel(bank/alipay/wechat/cash/other)` +- 语义: + - `approved -> paid`:返回 200,并归档关联 `bound` 发票 + - 已 `paid` 重复同值:返回 200(`payment.idempotent=true`) + - 已 `paid` 重复冲突值:返回 `409 VALID_001`,`details.payment=already_paid_conflict` + +### 5.2 委派管理 +- `POST /api/delegations`:创建委派(scope + validFrom/validTo) +- `GET /api/delegations/mine`:查看我发起/我接收的委派 +- `POST /api/delegations/:id/revoke`:撤销委派 +- 委派仅作用于 `assignee_id` 任务,不改写 `assignee_role` 语义。 + +### 5.3 OCR 异步状态查询 +- 上传发票在 OCR 开启且 `ocr_type=vat` 时会进入 `pending_ocr`。 +- `GET /api/invoice-pool/:id` 可轮询 OCR 处理状态(`pending_ocr/available/ocr_failed`)。 + +### 5.4 审计日志查询 +- `GET /api/audit-logs` +- 权限:`system.audit.view`(finance_mgr/admin) +- 支持分页与筛选(actor/action/targetType/date range)。 + +### 5.5 定时任务(可手动执行) +```bash +cd /home/shunli/Projects/hyb-oa/server +npm run job:revoke-expired-delegations +npm run job:ocr-once +``` + +--- + +## 6. 关键环境变量 - `DATABASE_URL`: PostgreSQL 连接串 - `JWT_SECRET`: JWT & signed-url token 签名密钥 - `APP_TIMEZONE`: 默认 `Asia/Shanghai` - `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`) - `SIGNED_URL_TTL_SECONDS`: 默认 `600` - `MAX_UPLOAD_SIZE_BYTES`: 默认 `10485760`(10MB) +- `DELEGATION_REVOKE_CRON`: 委派自动回收定时表达式(默认 `0 * * * *`) +- `OCR_PROVIDER`: `off` 或 `tencent`(默认 `off`) +- `OCR_BATCH_SIZE`: 每轮 OCR 认领数量(默认 `10`) +- `OCR_LOCK_TTL_SECONDS`: OCR 锁超时秒数(默认 `300`) +- `OCR_POLL_CRON`: OCR 轮询 cron(默认 `* * * * *`) +- `OCR_MAX_ATTEMPTS`: OCR 最大重试次数(默认 `3`) +- `OCR_TIMEOUT_MS`: 腾讯 OCR SDK 超时(默认 `5000`) +- `TENCENT_SECRET_ID` / `TENCENT_SECRET_KEY` / `TENCENT_OCR_REGION`: 腾讯 OCR 配置 > 生产提示:大表加索引需评估 `CREATE INDEX CONCURRENTLY`,避免锁表影响。 --- -## 6. 常见失败排查 +## 7. 常见失败排查 | 现象 | 可能原因 | 解决方案 | |---|---|---| @@ -147,14 +193,16 @@ npm run dev | signed-url 接口返回 403 | `JWT_SECRET` 不一致、token 过期、访问人无权限 | 统一服务密钥、确认 URL 在 TTL 内、检查账号角色 | | 上传返回 `file_too_large` | 文件超 `MAX_UPLOAD_SIZE_BYTES` | 调整环境变量或使用更小文件 | | 上传失败且报目录/权限错误 | `STORAGE_DIR` 不存在或不可写 | 创建目录并修正权限;Docker 需确认 volume 挂载 | +| OCR 长期停留 `pending_ocr` | `OCR_PROVIDER=off` 或密钥无效 | 检查 OCR 环境变量,手动执行 `npm run job:ocr-once` 验证 | +| 委派过期后仍可操作 | 自动回收任务未运行 | 检查 `DELEGATION_REVOKE_CRON`,手动执行 `npm run job:revoke-expired-delegations` | --- -## 7. 手工故障注入(非阻塞) +## 8. 手工故障注入(非阻塞) -### 7.1 `STORAGE_DIR` 不可写路径 +### 8.1 `STORAGE_DIR` 不可写路径 - 临时将 `STORAGE_DIR` 指向只读目录,执行上传接口,确认错误可观测(日志+返回码)。 -### 7.2 磁盘空间不足(ENOSPC) +### 8.2 磁盘空间不足(ENOSPC) - 在受控环境中制造磁盘满条件后重试上传。 - 预期:错误路径可观测(日志中能定位到 I/O 错误),不要求纳入日常 CI 冒烟。 diff --git a/server/package-lock.json b/server/package-lock.json index 07b6794f..82c7eb65 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -19,8 +19,10 @@ "bcryptjs": "^3.0.3", "fastify": "^5.7.4", "fastify-plugin": "^5.1.0", + "node-cron": "^4.2.1", "pg": "^8.18.0", "prisma": "^7.3.0", + "tencentcloud-sdk-nodejs": "^4.1.14", "zod": "^4.3.6" }, "devDependencies": { @@ -625,6 +627,18 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -670,6 +684,12 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -707,6 +727,15 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bn.js": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", @@ -741,6 +770,19 @@ } } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/chevrotain": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", @@ -779,6 +821,18 @@ "consola": "^3.2.3" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", @@ -841,6 +895,23 @@ "license": "MIT", "peer": true }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -856,6 +927,15 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -901,6 +981,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -929,6 +1023,51 @@ "node": ">=14" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1159,6 +1298,31 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -1168,12 +1332,61 @@ "is-property": "^1.0.2" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-port-please": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", "license": "MIT" }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -1208,6 +1421,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1225,6 +1450,45 @@ "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.0.tgz", "integrity": "sha512-0E62MaTW5rPZVRLyIJZG/YejmdA/Xr1QydHEw3Vt+qOKkMIOE8WDLc9ZX2bmAjtJFZcId4lEdrdmASsEy7D1QA==" }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hono": { "version": "4.11.4", "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", @@ -1260,6 +1524,19 @@ "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -1282,6 +1559,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -1297,6 +1583,18 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1312,6 +1610,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -1419,6 +1726,15 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -1431,6 +1747,27 @@ "node": ">=10.0.0" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -1470,6 +1807,12 @@ "obliterator": "^2.0.4" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/mysql2": { "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", @@ -1502,6 +1845,35 @@ "node": ">=8.0.0" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -2182,6 +2554,26 @@ "reusify": "^1.0.0" } }, + "node_modules/tencentcloud-sdk-nodejs": { + "version": "4.1.184", + "resolved": "https://registry.npmjs.org/tencentcloud-sdk-nodejs/-/tencentcloud-sdk-nodejs-4.1.184.tgz", + "integrity": "sha512-OEHfAk4iUuDECw1phx/WxZbigLUZoSdjj+mErzs1+hRWeD2bTy79beMTep5W0Loq4rHIMRu8rXchq0n89mKchA==", + "license": "Apache-2.0", + "dependencies": { + "form-data": "^3.0.4", + "get-stream": "^6.0.0", + "https-proxy-agent": "^5.0.0", + "ini": "^5.0.0", + "is-stream": "^2.0.0", + "json-bigint": "^1.0.0", + "node-fetch": "^2.2.0", + "tslib": "1.13.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -2221,6 +2613,31 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", + "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==", + "license": "0BSD" + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/valibot": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", @@ -2235,6 +2652,22 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/server/package.json b/server/package.json index ac4c0ef5..0e689f2a 100644 --- a/server/package.json +++ b/server/package.json @@ -14,7 +14,9 @@ "prisma:migrate:deploy": "prisma migrate deploy", "seed:config": "node src/scripts/seed-config.js", "seed:dev-users": "node src/scripts/seed-dev.js", - "seed:dev": "npm run seed:config && npm run seed:dev-users" + "seed:dev": "npm run seed:config && npm run seed:dev-users", + "job:revoke-expired-delegations": "node src/scripts/revoke-expired-delegations.js", + "job:ocr-once": "node src/scripts/run-ocr-once.js" }, "keywords": [], "author": "", @@ -32,7 +34,9 @@ "fastify-plugin": "^5.1.0", "pg": "^8.18.0", "prisma": "^7.3.0", - "zod": "^4.3.6" + "zod": "^4.3.6", + "node-cron": "^4.2.1", + "tencentcloud-sdk-nodejs": "^4.1.14" }, "devDependencies": { "dotenv": "^16.4.5" diff --git a/server/prisma/migrations/20260207180145_p2_phase1/migration.sql b/server/prisma/migrations/20260207180145_p2_phase1/migration.sql new file mode 100644 index 00000000..0eaa306b --- /dev/null +++ b/server/prisma/migrations/20260207180145_p2_phase1/migration.sql @@ -0,0 +1,25 @@ +-- reimbursement_forms: payment voucher fields +ALTER TABLE "reimbursement_forms" + ADD COLUMN "payment_voucher_no" VARCHAR(50), + ADD COLUMN "payment_date" DATE, + ADD COLUMN "payment_channel" VARCHAR(20), + ADD COLUMN "paid_at" TIMESTAMP(3), + ADD COLUMN "paid_by" UUID; + +ALTER TABLE "reimbursement_forms" + ADD CONSTRAINT "reimbursement_forms_paid_by_fkey" + FOREIGN KEY ("paid_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "reimbursement_forms_paid_by_idx" ON "reimbursement_forms"("paid_by"); + +-- invoice_pool: OCR lock fields +ALTER TABLE "invoice_pool" + ADD COLUMN "ocr_locked_at" TIMESTAMP(3), + ADD COLUMN "ocr_lock_owner" VARCHAR(128); + +CREATE INDEX "idx_invoice_pool_ocr_queue" +ON "invoice_pool"("status", "ocr_locked_at", "created_at"); + +-- audit_logs: query acceleration +CREATE INDEX "idx_audit_logs_query" +ON "audit_logs"("created_at" DESC, "actor_id", "action"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7cef487f..ba390a3a 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -171,6 +171,7 @@ model Employee { submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter") owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner") + paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy") owned_invoices InvoicePool[] @relation("InvoiceOwner") uploaded_invoices InvoicePool[] @relation("InvoiceUploader") @@ -276,6 +277,7 @@ model AuditLog { @@index([request_id]) @@index([actor_id, created_at]) + @@index([created_at, actor_id, action], map: "idx_audit_logs_query") @@map("audit_logs") } @@ -327,15 +329,21 @@ model ReimbursementForm { total_amount Decimal @default(0) @db.Decimal(14, 2) status ReimbursementStatus @default(draft) description String? + payment_voucher_no String? @db.VarChar(50) + payment_date DateTime? @db.Date + payment_channel String? @db.VarChar(20) + paid_at DateTime? + paid_by String? @db.Uuid created_at DateTime @default(now()) created_by String? @db.Uuid updated_at DateTime @updatedAt updated_by String? @db.Uuid - submitter Employee @relation("ReimbursementSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict) - owner Employee @relation("ReimbursementOwner", fields: [owner_id], references: [id], onDelete: Restrict) - department Department @relation(fields: [department_id], references: [id], onDelete: Restrict) + submitter Employee @relation("ReimbursementSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict) + owner Employee @relation("ReimbursementOwner", fields: [owner_id], references: [id], onDelete: Restrict) + paid_by_employee Employee? @relation("ReimbursementPaidBy", fields: [paid_by], references: [id], onDelete: SetNull) + department Department @relation(fields: [department_id], references: [id], onDelete: Restrict) items ReimbursementItem[] invoice_pool_items InvoicePool[] @@ -345,6 +353,7 @@ model ReimbursementForm { @@index([owner_id]) @@index([submitter_id]) @@index([department_id]) + @@index([paid_by]) @@index([status]) @@index([reimbursement_date]) @@map("reimbursement_forms") @@ -414,6 +423,8 @@ model InvoicePool { ocr_attempts Int @default(0) ocr_last_error String? ocr_completed_at DateTime? + ocr_locked_at DateTime? + ocr_lock_owner String? @db.VarChar(128) invoice_code String? invoice_number String? @@ -447,6 +458,7 @@ model InvoicePool { @@index([file_sha256]) @@index([invoice_code, invoice_number]) @@index([status]) + @@index([status, ocr_locked_at, created_at], map: "idx_invoice_pool_ocr_queue") @@index([linked_reimbursement_id]) @@map("invoice_pool") } diff --git a/server/scripts/regression-workflow-actions.sh b/server/scripts/regression-workflow-actions.sh index f24bcc84..dff833aa 100755 --- a/server/scripts/regression-workflow-actions.sh +++ b/server/scripts/regression-workflow-actions.sh @@ -188,8 +188,13 @@ pay_reimbursement() { local token="$1" local reimbursement_id="$2" local out_file="$3" + local body="${4:-}" - api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/pay" "$token" "$out_file" '{}' + if [[ -z "$body" ]]; then + body='{}' + fi + + api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/pay" "$token" "$out_file" "$body" } transfer_task() { @@ -213,6 +218,25 @@ withdraw_reimbursement() { assert_status "$status" "200" "withdraw reimbursement $reimbursement_id" "$out_file" } +create_delegation() { + local token="$1" + local delegate_id="$2" + local scope="$3" + local valid_from="$4" + local valid_to="$5" + local out_file="$6" + + api_json_auth POST "$BASE_URL/api/delegations" "$token" "$out_file" "{\"delegateEmployeeId\":\"$delegate_id\",\"scope\":\"$scope\",\"validFrom\":\"$valid_from\",\"validTo\":\"$valid_to\"}" +} + +revoke_delegation() { + local token="$1" + local delegation_id="$2" + local out_file="$3" + + api_json_auth POST "$BASE_URL/api/delegations/$delegation_id/revoke" "$token" "$out_file" '{}' +} + create_and_bind_invoice() { local token="$1" local reimbursement_id="$2" @@ -761,7 +785,86 @@ const { createPrismaClient } = require('./src/prismaClient'); NODE } -echo "[1/19] Health check + login baseline users" +assert_delegation_history_evidence() { + local task_id="$1" + local delegation_id="$2" + local principal_id="$3" + local delegate_id="$4" + local scenario="$5" + + DATABASE_URL="$DATABASE_URL" node - "$task_id" "$delegation_id" "$principal_id" "$delegate_id" "$scenario" <<'NODE' +const [taskId, delegationId, principalId, delegateId, 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: { task_id: taskId }, + orderBy: { created_at: 'desc' }, + select: { payload: true }, + }); + + if (!history) { + console.error(`[ERROR] ${scenario} history not found`); + process.exit(1); + } + + const payload = history.payload ?? {}; + if ( + payload.delegation_id !== delegationId || + payload.principal_id !== principalId || + payload.delegate_id !== delegateId + ) { + console.error( + `[ERROR] ${scenario} delegation payload mismatch: delegation_id=${payload.delegation_id}, principal_id=${payload.principal_id}, delegate_id=${payload.delegate_id}`, + ); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_delegation_revoked_db() { + local delegation_id="$1" + local scenario="$2" + + DATABASE_URL="$DATABASE_URL" node - "$delegation_id" "$scenario" <<'NODE' +const [delegationId, scenario] = process.argv.slice(2); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const delegation = await prisma.delegationGrant.findUnique({ + where: { id: delegationId }, + select: { revoked_at: true }, + }); + + if (!delegation || delegation.revoked_at == null) { + console.error(`[ERROR] ${scenario} expected revoked delegation but got none`); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} +echo "[1/23] Health check + login baseline users" health_status="$(curl -sS -o "$TMP_DIR/health.json" -w "%{http_code}" "$BASE_URL/health")" assert_status "$health_status" "200" "health check" "$TMP_DIR/health.json" @@ -783,7 +886,7 @@ ceo_me_status="$(api_json_auth GET "$BASE_URL/api/me" "$CEO_TOKEN" "$TMP_DIR/me- assert_status "$ceo_me_status" "200" "get /api/me ceo" "$TMP_DIR/me-ceo.json" CEO_ID="$(jq -er '.data.id' "$TMP_DIR/me-ceo.json")" -echo "[2/19] reject: instance=rejected, reimbursement=rejected" +echo "[2/23] reject: instance=rejected, reimbursement=rejected" RID_REJECT="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/reject-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_REJECT" "reject" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_REJECT" "$TMP_DIR/reject-submit.json" @@ -796,7 +899,7 @@ assert_jq "$TMP_DIR/reject-action.json" '.data.outcome == "rejected" and .data.r assert_instance_status "$INSTANCE_REJECT" "rejected" "reject scenario" assert_reimbursement_status_db "$RID_REJECT" "rejected" "reject scenario" -echo "[3/19] return + resubmit: terminated/draft and new instance" +echo "[3/23] return + resubmit: terminated/draft and new instance" RID_RETURN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/return-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_RETURN" "return" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "$TMP_DIR/return-submit-1.json" @@ -821,7 +924,7 @@ withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "cleanup after return-res assert_instance_status "$INSTANCE_RETURN_2" "cancelled" "return resubmit cleanup" assert_reimbursement_status_db "$RID_RETURN" "draft" "return resubmit cleanup" -echo "[4/19] withdraw: before approver processing" +echo "[4/23] withdraw: before approver processing" RID_WITHDRAW="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/withdraw-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "withdraw" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "$TMP_DIR/withdraw-submit.json" @@ -832,7 +935,7 @@ assert_jq "$TMP_DIR/withdraw-action.json" '.data.outcome == "withdrawn" and .dat assert_instance_status "$INSTANCE_WITHDRAW" "cancelled" "withdraw scenario" assert_reimbursement_status_db "$RID_WITHDRAW" "draft" "withdraw scenario" -echo "[5/19] PERM_002: non-assignee cannot operate" +echo "[5/23] PERM_002: non-assignee cannot operate" RID_PERM="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/perm-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PERM" "perm" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "$TMP_DIR/perm-submit.json" @@ -847,7 +950,7 @@ withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "cleanup after PERM_002" "$ assert_instance_status "$INSTANCE_PERM" "cancelled" "PERM_002 cleanup" assert_reimbursement_status_db "$RID_PERM" "draft" "PERM_002 cleanup" -echo "[6/19] negative_not_allowed: submit blocked when total_amount < 0" +echo "[6/23] negative_not_allowed: submit blocked when total_amount < 0" RID_NEGATIVE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/negative-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NEGATIVE" "negative" >/dev/null set_reimbursement_total_amount "$RID_NEGATIVE" "-1.00" @@ -858,11 +961,11 @@ assert_error_code "$TMP_DIR/negative-submit.json" "VALID_001" "negative_not_allo assert_error_detail "$TMP_DIR/negative-submit.json" '.error.details.total_amount' "negative_not_allowed" "negative_not_allowed submit" assert_no_workflow_instance "$RID_NEGATIVE" "negative_not_allowed" -echo "[7/19] Prepare manager-missing context" +echo "[7/23] Prepare manager-missing context" set_employee_direct_manager "$EMPLOYEE_ID" "__NULL__" EMPLOYEE_MANAGER_PATCHED=1 -echo "[8/19] manager skip: missing manager -> skip with history + finance task" +echo "[8/23] manager skip: missing manager -> skip with history + finance task" 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" @@ -876,7 +979,7 @@ skip_finance_approve_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/ 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" -echo "[9/19] no_actionable_approver: full chain non-actionable" +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")" RID_NO_ACTIONABLE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/no-actionable-create.json")" @@ -892,11 +995,11 @@ assert_no_workflow_instance "$RID_NO_ACTIONABLE" "no_actionable_approver" delete_approval_rule "$TMP_APPROVAL_RULE_ID" TMP_APPROVAL_RULE_ID="" -echo "[10/19] restore manager relation before transfer/pay scenarios" +echo "[10/23] restore manager relation before transfer/pay scenarios" set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID" EMPLOYEE_MANAGER_PATCHED=0 -echo "[11/19] transfer success: manager -> ceo with 3 evidences" +echo "[11/23] transfer success: manager -> ceo with 3 evidences" RID_TRANSFER_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-ok-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_OK" "transfer-ok" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_OK" "$TMP_DIR/transfer-ok-submit.json" @@ -923,7 +1026,7 @@ fi assert_reimbursement_status_db "$RID_TRANSFER_OK" "approved" "transfer success approval" -echo "[12/19] transfer reason too short => VALID_002" +echo "[12/23] transfer reason too short => VALID_002" RID_TRANSFER_SHORT="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-short-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "transfer-short" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "$TMP_DIR/transfer-short-submit.json" @@ -936,7 +1039,7 @@ assert_error_code "$TMP_DIR/transfer-short-action.json" "VALID_002" "transfer re withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "cleanup transfer short" "$TMP_DIR/transfer-short-withdraw-cleanup.json" assert_reimbursement_status_db "$RID_TRANSFER_SHORT" "draft" "transfer short cleanup" -echo "[13/19] transfer target not approver => VALID_001" +echo "[13/23] transfer target not approver => VALID_001" RID_TRANSFER_TARGET="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-target-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "transfer-target" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "$TMP_DIR/transfer-target-submit.json" @@ -950,7 +1053,7 @@ assert_error_detail "$TMP_DIR/transfer-target-action.json" '.error.details.toApp withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "cleanup transfer target" "$TMP_DIR/transfer-target-withdraw-cleanup.json" assert_reimbursement_status_db "$RID_TRANSFER_TARGET" "draft" "transfer target cleanup" -echo "[14/19] transfer same assignee => VALID_001" +echo "[14/23] transfer same assignee => VALID_001" RID_TRANSFER_SAME="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-same-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "transfer-same" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "$TMP_DIR/transfer-same-submit.json" @@ -964,7 +1067,7 @@ assert_error_detail "$TMP_DIR/transfer-same-action.json" '.error.details.transfe withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "cleanup transfer same assignee" "$TMP_DIR/transfer-same-withdraw-cleanup.json" assert_reimbursement_status_db "$RID_TRANSFER_SAME" "draft" "transfer same cleanup" -echo "[15/19] transfer chain limit: 4th transfer blocked" +echo "[15/23] transfer chain limit: 4th transfer blocked" RID_TRANSFER_CHAIN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-chain-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "transfer-chain" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "$TMP_DIR/transfer-chain-submit.json" @@ -998,7 +1101,54 @@ fi assert_reimbursement_status_db "$RID_TRANSFER_CHAIN" "approved" "transfer chain final approval" -echo "[16/19] pay success: approved -> paid and invoices archived" +echo "[17/23] delegation active window: delegate can approve" +RID_DELEGATION_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/delegation-ok-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_DELEGATION_OK" "delegation-ok" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_DELEGATION_OK" "$TMP_DIR/delegation-ok-submit.json" +TASK_DELEGATION_OK="$(jq -er '.data.currentTaskId' "$TMP_DIR/delegation-ok-submit.json")" + +DELEGATION_VALID_FROM="$(date -u -d '-5 minutes' '+%Y-%m-%dT%H:%M:%SZ')" +DELEGATION_VALID_TO="$(date -u -d '+1 hour' '+%Y-%m-%dT%H:%M:%SZ')" +delegation_create_status="$(create_delegation "$MANAGER_TOKEN" "$CEO_ID" "all" "$DELEGATION_VALID_FROM" "$DELEGATION_VALID_TO" "$TMP_DIR/delegation-create-ok.json")" +assert_status "$delegation_create_status" "200" "create delegation active" "$TMP_DIR/delegation-create-ok.json" +DELEGATION_OK_ID="$(jq -er '.data.delegation.id' "$TMP_DIR/delegation-create-ok.json")" + +ceo_approve_via_delegation_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_OK/approve" "$CEO_TOKEN" "$TMP_DIR/delegation-ok-ceo-approve.json" '{}')" +assert_status "$ceo_approve_via_delegation_status" "200" "delegated approve by ceo" "$TMP_DIR/delegation-ok-ceo-approve.json" +assert_delegation_history_evidence "$TASK_DELEGATION_OK" "$DELEGATION_OK_ID" "$MANAGER_ID" "$CEO_ID" "delegation active approve" + +revoke_delegation_status="$(revoke_delegation "$MANAGER_TOKEN" "$DELEGATION_OK_ID" "$TMP_DIR/delegation-revoke-ok.json")" +assert_status "$revoke_delegation_status" "200" "revoke delegation" "$TMP_DIR/delegation-revoke-ok.json" +assert_delegation_revoked_db "$DELEGATION_OK_ID" "delegation revoke" + +echo "[18/23] delegation expired: delegate forbidden" +DELEGATION_EXPIRED_FROM="$(date -u -d '-2 hours' '+%Y-%m-%dT%H:%M:%SZ')" +DELEGATION_EXPIRED_TO="$(date -u -d '-1 hour' '+%Y-%m-%dT%H:%M:%SZ')" +delegation_expired_create_status="$(create_delegation "$MANAGER_TOKEN" "$CEO_ID" "all" "$DELEGATION_EXPIRED_FROM" "$DELEGATION_EXPIRED_TO" "$TMP_DIR/delegation-create-expired.json")" +assert_status "$delegation_expired_create_status" "200" "create expired delegation" "$TMP_DIR/delegation-create-expired.json" +DELEGATION_EXPIRED_ID="$(jq -er '.data.delegation.id' "$TMP_DIR/delegation-create-expired.json")" + +RID_DELEGATION_EXPIRED="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/delegation-expired-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_DELEGATION_EXPIRED" "delegation-expired" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_DELEGATION_EXPIRED" "$TMP_DIR/delegation-expired-submit.json" +TASK_DELEGATION_EXPIRED="$(jq -er '.data.currentTaskId' "$TMP_DIR/delegation-expired-submit.json")" + +ceo_approve_expired_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_EXPIRED/approve" "$CEO_TOKEN" "$TMP_DIR/delegation-expired-ceo-approve.json" '{}')" +assert_status "$ceo_approve_expired_status" "403" "expired delegation should be forbidden" "$TMP_DIR/delegation-expired-ceo-approve.json" +assert_error_code "$TMP_DIR/delegation-expired-ceo-approve.json" "PERM_002" "expired delegation should be forbidden" + +revoke_job_output="$(npm run --silent job:revoke-expired-delegations)" +revoke_job_status="$(printf '%s\n' "$revoke_job_output" | tail -n 1)" +if [[ "$revoke_job_status" != revoked=* ]]; then + echo "[ERROR] revoke expired delegations script output invalid: $revoke_job_output" >&2 + exit 1 +fi +assert_delegation_revoked_db "$DELEGATION_EXPIRED_ID" "delegation auto revoke" + +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" 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" @@ -1015,13 +1165,14 @@ fi assert_reimbursement_status_db "$RID_PAY_OK" "approved" "pay success precondition" -pay_ok_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-pay.json")" +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")" 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' "pay success response" +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 "[17/19] pay invalid status: draft/reviewing/rejected/cancelled" +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")" 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" @@ -1050,20 +1201,27 @@ assert_status "$pay_invalid_cancelled_status" "409" "pay invalid status cancelle assert_error_code "$TMP_DIR/pay-invalid-cancelled.json" "VALID_001" "pay invalid status cancelled" assert_error_detail "$TMP_DIR/pay-invalid-cancelled.json" '.error.details.status' "invalid_pay_status" "pay invalid status cancelled" -echo "[18/19] pay permission: non-finance forbidden" +echo "[21/23] pay permission: non-finance 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 "[19/19] pay idempotent: paid reimbursement can be paid again" -pay_idempotent_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-idempotent.json")" +echo "[22/23] pay idempotent: paid reimbursement can be paid again" +pay_idempotent_body="{\"paymentVoucherNo\":\"VOUCHER-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-07\",\"paymentChannel\":\"bank\"}" +pay_idempotent_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-idempotent.json" "$pay_idempotent_body")" assert_status "$pay_idempotent_status" "200" "pay idempotent" "$TMP_DIR/pay-idempotent.json" -assert_jq "$TMP_DIR/pay-idempotent.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 0' "pay idempotent response" +assert_jq "$TMP_DIR/pay-idempotent.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 0 and .data.payment.idempotent == true and .data.payment.paymentVoucherNo != null' "pay idempotent response" assert_reimbursement_status_db "$RID_PAY_OK" "paid" "pay idempotent" assert_invoices_archived_used_complete "$RID_PAY_OK" "pay idempotent" +pay_conflict_body="{\"paymentVoucherNo\":\"VOUCHER-CONFLICT-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-06\",\"paymentChannel\":\"alipay\"}" +pay_conflict_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-conflict.json" "$pay_conflict_body")" +assert_status "$pay_conflict_status" "409" "pay idempotent conflict" "$TMP_DIR/pay-conflict.json" +assert_error_code "$TMP_DIR/pay-conflict.json" "VALID_001" "pay idempotent conflict" +assert_error_detail "$TMP_DIR/pay-conflict.json" '.error.details.payment' "already_paid_conflict" "pay idempotent conflict" + if [[ "$RUN_PAY_CONCURRENCY_CHECK" == "1" ]]; then - echo "[19.1/19] optional pay concurrency check" + echo "[22.1/23] optional pay concurrency check" RID_PAY_CONCURRENCY="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-concurrency-create.json")" create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_CONCURRENCY" "pay-concurrency" >/dev/null submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_CONCURRENCY" "$TMP_DIR/pay-concurrency-submit.json" @@ -1090,7 +1248,7 @@ if [[ "$RUN_PAY_CONCURRENCY_CHECK" == "1" ]]; then assert_invoices_archived_used_complete "$RID_PAY_CONCURRENCY" "pay concurrency" fi -echo "[19/19] Final manager relation restore" +echo "[23/23] Final manager relation restore" set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID" EMPLOYEE_MANAGER_PATCHED=0 diff --git a/server/src/app.js b/server/src/app.js index 925b35f9..a4565c34 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -16,6 +16,10 @@ const meRoutes = require("./routes/me"); const reimbursementRoutes = require("./routes/reimbursements"); const invoicePoolRoutes = require("./routes/invoicePool"); const workflowRoutes = require("./routes/workflow"); +const delegationRoutes = require("./routes/delegations"); +const auditRoutes = require("./routes/audit"); +const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob"); +const { startOcrJob } = require("./jobs/ocrJob"); const { AppError } = require("./utils/AppError"); async function buildApp() { @@ -114,6 +118,11 @@ async function buildApp() { await app.register(reimbursementRoutes, { prefix: "/api" }); await app.register(invoicePoolRoutes, { prefix: "/api" }); await app.register(workflowRoutes, { prefix: "/api" }); + await app.register(delegationRoutes, { prefix: "/api" }); + await app.register(auditRoutes, { prefix: "/api" }); + + startDelegationRevokeJob(app); + startOcrJob(app); return app; } diff --git a/server/src/config.js b/server/src/config.js index a59b6ac1..e9b31ee0 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -42,6 +42,19 @@ function getConfig() { storageDir: path.resolve(serverRoot, storageDir), signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600), maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024), + + delegationRevokeCron: process.env.DELEGATION_REVOKE_CRON ?? "0 * * * *", + + ocrProvider: process.env.OCR_PROVIDER ?? "off", + ocrBatchSize: Number(process.env.OCR_BATCH_SIZE ?? 10), + ocrLockTtlSeconds: Number(process.env.OCR_LOCK_TTL_SECONDS ?? 300), + ocrPollCron: process.env.OCR_POLL_CRON ?? "* * * * *", + ocrMaxAttempts: Number(process.env.OCR_MAX_ATTEMPTS ?? 3), + ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS ?? 5000), + + tencentSecretId: process.env.TENCENT_SECRET_ID ?? "", + tencentSecretKey: process.env.TENCENT_SECRET_KEY ?? "", + tencentOcrRegion: process.env.TENCENT_OCR_REGION ?? "ap-beijing", }; } diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index 66ec347b..98cf6bbb 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -29,6 +29,7 @@ const ROLE_PERMISSIONS = { "expense.view_all", "expense.finance.override_duplicate", "system.settings", + "system.audit.view", ], administration: ["expense.proxy_submit", "expense.view_own"], hr: ["system.users", "expense.view_all"], diff --git a/server/src/jobs/delegationRevokeJob.js b/server/src/jobs/delegationRevokeJob.js new file mode 100644 index 00000000..2188b716 --- /dev/null +++ b/server/src/jobs/delegationRevokeJob.js @@ -0,0 +1,103 @@ +const cron = require("node-cron"); + +async function revokeExpiredDelegations(prisma, logger, options = {}) { + const batchSize = options.batchSize ?? 200; + let total = 0; + + while (true) { + const now = new Date(); + const expired = await prisma.delegationGrant.findMany({ + where: { + revoked_at: null, + valid_to: { + not: null, + lte: now, + }, + }, + orderBy: { valid_to: "asc" }, + take: batchSize, + select: { + id: true, + principal_employee_id: true, + delegate_employee_id: true, + }, + }); + + if (!expired.length) { + break; + } + + await prisma.$transaction(async (tx) => { + const ids = expired.map((item) => item.id); + await tx.delegationGrant.updateMany({ + where: { + id: { in: ids }, + revoked_at: null, + }, + data: { + revoked_at: now, + }, + }); + + await tx.auditLog.createMany({ + data: expired.map((item) => ({ + request_id: `job:delegation-revoke:${now.getTime()}:${item.id}`, + actor_id: item.principal_employee_id, + action: "DELEGATION_AUTO_REVOKED", + target_type: "delegation_grant", + target_id: item.id, + ip_address: null, + user_agent: "system/node-cron", + extra: { + principalEmployeeId: item.principal_employee_id, + delegateEmployeeId: item.delegate_employee_id, + }, + })), + }); + }); + + total += expired.length; + } + + if (total > 0) { + logger.info({ revoked: total }, "Expired delegations auto-revoked"); + } + + return total; +} + +function startDelegationRevokeJob(fastify) { + let running = false; + + const task = cron.schedule(fastify.config.delegationRevokeCron, async () => { + if (running) { + return; + } + + running = true; + try { + await revokeExpiredDelegations(fastify.prisma, fastify.log); + } catch (error) { + fastify.log.error({ err: error }, "Delegation revoke job failed"); + } finally { + running = false; + } + }); + + fastify.addHook("onClose", async () => { + task.stop(); + task.destroy(); + }); + + fastify.log.info( + { cron: fastify.config.delegationRevokeCron }, + "Delegation revoke job scheduled", + ); + + return task; +} + +module.exports = { + revokeExpiredDelegations, + startDelegationRevokeJob, +}; diff --git a/server/src/jobs/ocrJob.js b/server/src/jobs/ocrJob.js new file mode 100644 index 00000000..a2135d56 --- /dev/null +++ b/server/src/jobs/ocrJob.js @@ -0,0 +1,314 @@ +const fs = require("node:fs/promises"); +const path = require("node:path"); +const cron = require("node-cron"); + +const { runOcrForInvoice } = require("../services/ocr"); + +const OCR_DUPLICATE_ERROR = "duplicate_invoice_code_number"; + +function trimErrorMessage(error) { + const message = error?.message ?? "ocr_failed"; + if (message.length > 500) { + return message.slice(0, 500); + } + return message; +} + +function toDateOnly(value) { + if (!value) { + return null; + } + + return `${value}T00:00:00.000Z`; +} + +function toMoney(value) { + if (value == null) { + return null; + } + + const num = Number(value); + if (!Number.isFinite(num)) { + return null; + } + + return num.toFixed(2); +} + +async function claimPendingInvoices(prisma, config, workerId) { + const batchSize = Math.max(1, Number(config.ocrBatchSize || 10)); + const lockTtlSeconds = Math.max(60, Number(config.ocrLockTtlSeconds || 300)); + + return prisma.$queryRaw` + WITH candidates AS ( + SELECT id + FROM "invoice_pool" + WHERE status = 'pending_ocr' + AND (ocr_locked_at IS NULL OR ocr_locked_at < NOW() - make_interval(secs => ${lockTtlSeconds})) + ORDER BY created_at ASC + LIMIT ${batchSize} + FOR UPDATE SKIP LOCKED + ) + UPDATE "invoice_pool" AS i + SET ocr_locked_at = NOW(), + ocr_lock_owner = ${workerId} + FROM candidates + WHERE i.id = candidates.id + RETURNING i.id, i.file_sha256, i.ocr_type, i.storage_path, i.ocr_attempts + `; +} + +async function findDuplicateInvoice(prisma, invoiceId, invoiceCode, invoiceNumber) { + if (!invoiceCode || !invoiceNumber) { + return null; + } + + return prisma.invoicePool.findFirst({ + where: { + id: { not: invoiceId }, + invoice_code: invoiceCode, + invoice_number: invoiceNumber, + is_duplicate_override: false, + OR: [ + { status: "bound" }, + { + status: "archived", + archived_reason: "used_complete", + }, + ], + }, + select: { + id: true, + }, + }); +} + +async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64) { + const where = { + file_sha256_ocr_provider_ocr_type: { + file_sha256: claimedInvoice.file_sha256, + ocr_provider: "tencent", + ocr_type: claimedInvoice.ocr_type, + }, + }; + + const cached = await prisma.ocrResult.findUnique({ + where, + select: { + id: true, + result_json: true, + }, + }); + + if (cached) { + return { + ocrResultId: cached.id, + normalized: cached.result_json, + }; + } + + const normalized = await runOcrForInvoice({ + config, + ocrType: claimedInvoice.ocr_type, + imageBase64, + }); + + try { + const created = await prisma.ocrResult.create({ + data: { + file_sha256: claimedInvoice.file_sha256, + ocr_provider: "tencent", + ocr_type: claimedInvoice.ocr_type, + result_json: normalized, + }, + select: { + id: true, + }, + }); + + return { + ocrResultId: created.id, + normalized, + }; + } catch (error) { + if (error?.code !== "P2002") { + throw error; + } + + const raced = await prisma.ocrResult.findUnique({ + where, + select: { + id: true, + result_json: true, + }, + }); + + if (!raced) { + throw error; + } + + return { + ocrResultId: raced.id, + normalized: raced.result_json, + }; + } +} + +async function markInvoiceAsDuplicate(prisma, invoiceId, normalized, ocrResultId) { + const now = new Date(); + await prisma.invoicePool.update({ + where: { id: invoiceId }, + data: { + status: "ocr_failed", + ocr_result_id: ocrResultId, + invoice_code: normalized.invoiceCode ?? null, + invoice_number: normalized.invoiceNumber ?? null, + invoice_date: normalized.invoiceDate ? new Date(toDateOnly(normalized.invoiceDate)) : null, + amount: toMoney(normalized.amount), + tax_amount: toMoney(normalized.taxAmount), + total_amount: toMoney(normalized.totalAmount), + seller_name: normalized.sellerName ?? null, + buyer_name: normalized.buyerName ?? null, + ocr_last_error: OCR_DUPLICATE_ERROR, + ocr_completed_at: now, + ocr_attempts: { increment: 1 }, + ocr_locked_at: null, + ocr_lock_owner: null, + }, + }); +} + +async function markInvoiceAsAvailable(prisma, invoiceId, normalized, ocrResultId) { + const now = new Date(); + await prisma.invoicePool.update({ + where: { id: invoiceId }, + data: { + status: "available", + ocr_result_id: ocrResultId, + invoice_code: normalized.invoiceCode ?? null, + invoice_number: normalized.invoiceNumber ?? null, + invoice_date: normalized.invoiceDate ? new Date(toDateOnly(normalized.invoiceDate)) : null, + amount: toMoney(normalized.amount), + tax_amount: toMoney(normalized.taxAmount), + total_amount: toMoney(normalized.totalAmount), + seller_name: normalized.sellerName ?? null, + buyer_name: normalized.buyerName ?? null, + ocr_last_error: null, + ocr_completed_at: now, + ocr_attempts: { increment: 1 }, + ocr_locked_at: null, + ocr_lock_owner: null, + }, + }); +} + +async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config) { + const now = new Date(); + const nextAttempts = Number(claimedInvoice.ocr_attempts || 0) + 1; + const isTerminal = nextAttempts >= config.ocrMaxAttempts; + + await prisma.invoicePool.update({ + where: { id: claimedInvoice.id }, + data: { + status: isTerminal ? "ocr_failed" : "pending_ocr", + ocr_attempts: nextAttempts, + ocr_last_error: errorMessage, + ocr_completed_at: isTerminal ? now : null, + ocr_locked_at: null, + ocr_lock_owner: null, + }, + }); +} + +async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, logger) { + try { + const absolutePath = path.resolve(config.storageDir, claimedInvoice.storage_path); + const fileBuffer = await fs.readFile(absolutePath); + const imageBase64 = fileBuffer.toString("base64"); + + const { ocrResultId, normalized } = await getOrCreateOcrResult( + prisma, + config, + claimedInvoice, + imageBase64, + ); + + const duplicate = await findDuplicateInvoice( + prisma, + claimedInvoice.id, + normalized.invoiceCode, + normalized.invoiceNumber, + ); + + if (duplicate) { + await markInvoiceAsDuplicate(prisma, claimedInvoice.id, normalized, ocrResultId); + logger.warn( + { + invoiceId: claimedInvoice.id, + duplicateInvoiceId: duplicate.id, + }, + "OCR invoice marked as duplicate", + ); + return; + } + + await markInvoiceAsAvailable(prisma, claimedInvoice.id, normalized, ocrResultId); + } catch (error) { + const message = trimErrorMessage(error); + await markInvoiceAsFailed(prisma, claimedInvoice, message, config); + logger.error({ err: error, invoiceId: claimedInvoice.id, workerId }, "OCR processing failed"); + } +} + +async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid}`) { + const claimed = await claimPendingInvoices(prisma, config, workerId); + if (!claimed.length) { + return { claimed: 0 }; + } + + for (const item of claimed) { + await processClaimedInvoice(prisma, config, item, workerId, logger); + } + + return { claimed: claimed.length }; +} + +function startOcrJob(fastify) { + if (fastify.config.ocrProvider !== "tencent") { + fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR job disabled"); + return null; + } + + let running = false; + const workerId = `ocr-worker:${process.pid}`; + + const task = cron.schedule(fastify.config.ocrPollCron, async () => { + if (running) { + return; + } + + running = true; + try { + await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId); + } catch (error) { + fastify.log.error({ err: error }, "OCR job tick failed"); + } finally { + running = false; + } + }); + + fastify.addHook("onClose", async () => { + task.stop(); + task.destroy(); + }); + + fastify.log.info({ cron: fastify.config.ocrPollCron }, "OCR job scheduled"); + return task; +} + +module.exports = { + OCR_DUPLICATE_ERROR, + claimPendingInvoices, + runOcrOnce, + startOcrJob, +}; diff --git a/server/src/routes/audit.js b/server/src/routes/audit.js new file mode 100644 index 00000000..264fed75 --- /dev/null +++ b/server/src/routes/audit.js @@ -0,0 +1,127 @@ +const { z } = require("zod"); +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); + +const QUERY_SCHEMA = z.object({ + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), + actorId: z.string().uuid().optional(), + action: z.string().min(1).max(100).optional(), + targetType: z.string().min(1).max(100).optional(), + dateFrom: z.string().datetime().optional(), + dateTo: z.string().datetime().optional(), +}); + +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(), + }); + } + + const page = parsed.data.page ?? 1; + const pageSize = parsed.data.pageSize ?? 20; + const dateFrom = parsed.data.dateFrom ? new Date(parsed.data.dateFrom) : null; + 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" }, + }); + } + + return { + page, + pageSize, + actorId: parsed.data.actorId, + action: parsed.data.action, + targetType: parsed.data.targetType, + dateFrom, + dateTo, + }; +} + +async function auditRoutes(fastify) { + fastify.get( + "/audit-logs", + { + preHandler: fastify.requirePermission("system.audit.view"), + }, + async (request, reply) => { + const query = parseQuery(request.query); + const skip = (query.page - 1) * query.pageSize; + + const where = { + ...(query.actorId ? { actor_id: query.actorId } : {}), + ...(query.action ? { action: query.action } : {}), + ...(query.targetType ? { target_type: query.targetType } : {}), + ...(query.dateFrom || query.dateTo + ? { + created_at: { + ...(query.dateFrom ? { gte: query.dateFrom } : {}), + ...(query.dateTo ? { lte: query.dateTo } : {}), + }, + } + : {}), + }; + + const [items, total] = await Promise.all([ + fastify.prisma.auditLog.findMany({ + where, + orderBy: { created_at: "desc" }, + skip, + take: query.pageSize, + select: { + id: true, + request_id: true, + actor_id: true, + action: true, + target_type: true, + target_id: true, + ip_address: true, + user_agent: true, + extra: true, + created_at: true, + actor: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }), + fastify.prisma.auditLog.count({ where }), + ]); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + items: items.map((item) => ({ + id: item.id, + requestId: item.request_id, + actorId: item.actor_id, + actorName: item.actor?.name ?? "", + actorEmail: item.actor?.email ?? "", + action: item.action, + targetType: item.target_type, + targetId: item.target_id, + ipAddress: item.ip_address, + userAgent: item.user_agent, + extra: item.extra, + createdAt: item.created_at.toISOString(), + })), + }); + }, + ); +} + +module.exports = auditRoutes; diff --git a/server/src/routes/delegations.js b/server/src/routes/delegations.js new file mode 100644 index 00000000..5252cfa3 --- /dev/null +++ b/server/src/routes/delegations.js @@ -0,0 +1,297 @@ +const { z } = require("zod"); +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); +const { computeDelegationStatus } = require("../utils/delegation"); + +const UUID_SCHEMA = z.string().uuid(); +const CREATE_DELEGATION_SCHEMA = z.object({ + delegateEmployeeId: z.string().uuid(), + scope: z.enum(["reimbursement_submit", "all"]).optional(), + validFrom: z.string().datetime().optional(), + validTo: z.string().datetime().optional(), +}); + +const QUERY_STATUS_SCHEMA = z.object({ + status: z.enum(["active", "scheduled", "expired", "revoked", "all"]).optional(), +}); + +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" }, + }); + } + + return parsed.data; +} + +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(), + }); + } + + 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" }, + }); + } + + if (validTo && Number.isNaN(validTo.getTime())) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { validTo: "invalid_datetime" }, + }); + } + + if (validTo && validTo <= validFrom) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { validTo: "must_be_after_validFrom" }, + }); + } + + return { + delegateEmployeeId: parsed.data.delegateEmployeeId, + scope: parsed.data.scope ?? "all", + validFrom, + validTo, + }; +} + +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(), + }); + } + + return parsed.data.status ?? "active"; +} + +function matchesStatusFilter(record, status, now) { + if (status === "all") { + return true; + } + + return computeDelegationStatus(record, now) === status; +} + +function toResponseItem(record, now) { + return { + id: record.id, + principalEmployeeId: record.principal_employee_id, + delegateEmployeeId: record.delegate_employee_id, + scope: record.scope, + validFrom: record.valid_from.toISOString(), + validTo: record.valid_to ? record.valid_to.toISOString() : null, + revokedAt: record.revoked_at ? record.revoked_at.toISOString() : null, + createdAt: record.created_at.toISOString(), + status: computeDelegationStatus(record, now), + }; +} + +async function delegationRoutes(fastify) { + fastify.post( + "/delegations", + { + preHandler: fastify.requirePermission("expense.proxy_manage"), + }, + async (request, reply) => { + 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" }, + }); + } + + const delegateEmployee = await fastify.prisma.employee.findUnique({ + where: { id: input.delegateEmployeeId }, + select: { + id: true, + status: true, + }, + }); + + if (!delegateEmployee || delegateEmployee.status !== "active") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { delegateEmployeeId: "invalid_delegate" }, + }); + } + + const delegation = await fastify.prisma.$transaction(async (tx) => { + const created = await tx.delegationGrant.create({ + data: { + principal_employee_id: request.employee.id, + delegate_employee_id: input.delegateEmployeeId, + scope: input.scope, + valid_from: input.validFrom, + valid_to: input.validTo, + created_by: request.employee.id, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "DELEGATION_CREATE", + targetType: "delegation_grant", + targetId: created.id, + extra: { + principalEmployeeId: request.employee.id, + delegateEmployeeId: input.delegateEmployeeId, + scope: input.scope, + }, + }), + }); + + return created; + }); + + return sendData(reply, request, { + delegation: toResponseItem(delegation, new Date()), + }); + }, + ); + + fastify.get( + "/delegations/mine", + { + preHandler: fastify.requirePermission("expense.proxy_manage"), + }, + async (request, reply) => { + const status = parseStatusQuery(request.query); + const now = new Date(); + + const [initiatedRaw, receivedRaw] = await Promise.all([ + fastify.prisma.delegationGrant.findMany({ + where: { + principal_employee_id: request.employee.id, + }, + orderBy: { created_at: "desc" }, + }), + fastify.prisma.delegationGrant.findMany({ + where: { + delegate_employee_id: request.employee.id, + }, + orderBy: { created_at: "desc" }, + }), + ]); + + const initiated = initiatedRaw + .filter((item) => matchesStatusFilter(item, status, now)) + .map((item) => toResponseItem(item, now)); + const received = receivedRaw + .filter((item) => matchesStatusFilter(item, status, now)) + .map((item) => toResponseItem(item, now)); + + return sendData(reply, request, { + status, + initiated, + received, + }); + }, + ); + + fastify.post( + "/delegations/:id/revoke", + { + preHandler: fastify.requirePermission("expense.proxy_manage"), + }, + async (request, reply) => { + const delegationId = parseDelegationId(request.params?.id); + const now = new Date(); + + const delegation = await fastify.prisma.delegationGrant.findUnique({ + where: { id: delegationId }, + }); + if (!delegation) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const canRevoke = + delegation.principal_employee_id === request.employee.id || + fastify.hasPermission(request.employee, "system.settings"); + if (!canRevoke) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (delegation.revoked_at) { + return sendData(reply, request, { + delegation: toResponseItem(delegation, now), + outcome: "already_revoked", + }); + } + + const revoked = await fastify.prisma.$transaction(async (tx) => { + const updated = await tx.delegationGrant.update({ + where: { id: delegationId }, + data: { + revoked_at: now, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "DELEGATION_REVOKE", + targetType: "delegation_grant", + targetId: delegationId, + extra: { + principalEmployeeId: delegation.principal_employee_id, + delegateEmployeeId: delegation.delegate_employee_id, + }, + }), + }); + + return updated; + }); + + return sendData(reply, request, { + delegation: toResponseItem(revoked, now), + outcome: "revoked", + }); + }, + ); +} + +module.exports = delegationRoutes; diff --git a/server/src/routes/invoicePool.js b/server/src/routes/invoicePool.js index db1c9129..60d5f231 100644 --- a/server/src/routes/invoicePool.js +++ b/server/src/routes/invoicePool.js @@ -10,6 +10,7 @@ const { compactDateString } = require("../utils/date"); const { sha256Hex } = require("../utils/crypto"); const { signPayload, verifyToken } = require("../utils/signedUrl"); const { toMoneyString } = require("../utils/workflow"); +const { isOcrEnabledForType } = require("../services/ocr"); const MIME_ALLOWLIST = new Set([ "application/pdf", @@ -258,6 +259,8 @@ async function invoicePoolRoutes(fastify) { await fs.mkdir(path.dirname(absoluteStoragePath), { recursive: true }); await fs.writeFile(absoluteStoragePath, fileBuffer); + const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available"; + const invoice = await fastify.prisma.invoicePool.create({ data: { owner_employee_id: ownerEmployeeId, @@ -268,7 +271,7 @@ async function invoicePoolRoutes(fastify) { file_size: fileSize, file_sha256: fileSha256, ocr_type: ocrType, - status: "available", + status, created_by: request.employee.id, updated_by: request.employee.id, }, @@ -287,6 +290,94 @@ async function invoicePoolRoutes(fastify) { }, ); + fastify.get( + "/invoice-pool/:id", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); + if (!parsedInvoiceId.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { id: "invalid_uuid" }, + }); + } + + const invoice = await fastify.prisma.invoicePool.findUnique({ + where: { id: parsedInvoiceId.data }, + select: { + id: true, + status: true, + owner_employee_id: true, + uploaded_by_employee_id: true, + ocr_type: true, + ocr_provider: true, + ocr_attempts: true, + ocr_last_error: true, + ocr_completed_at: true, + invoice_code: true, + invoice_number: true, + invoice_date: true, + amount: true, + tax_amount: true, + total_amount: true, + linked_reimbursement_id: true, + linked_item_id: true, + created_at: true, + updated_at: true, + linked_reimbursement: { + select: { + owner_id: true, + submitter_id: true, + }, + }, + }, + }); + + if (!invoice) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + let canAccess = ensureInvoiceReadableByUser(request, invoice); + if (!canAccess && invoice.linked_reimbursement_id) { + const pendingTask = await fastify.prisma.workflowTask.findFirst({ + where: { + status: "pending", + workflow_instance: { + entity_type: "reimbursement", + entity_id: invoice.linked_reimbursement_id, + current_status: "pending", + }, + OR: [ + { assignee_id: request.employee.id }, + { assignee_role: { in: request.employee.roleCodes } }, + ], + }, + select: { id: true }, + }); + + canAccess = Boolean(pendingTask); + } + + if (!canAccess) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + return sendData(reply, request, normalizeInvoiceResponse(invoice)); + }, + ); + fastify.post( "/invoice-pool/:id/signed-url", { diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 6ec87277..bcb12d48 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -9,6 +9,11 @@ const { } = require("../utils/workflow"); const UUID_SCHEMA = z.string().uuid(); +const PAY_BODY_SCHEMA = z.object({ + paymentVoucherNo: z.string().trim().min(1).max(50).optional(), + paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(), +}); function ensureOwnOrCanViewAll(fastify, request, reimbursement) { const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); @@ -74,6 +79,32 @@ function parseReimbursementId(id) { return parsed.data; } +function parsePayBody(body) { + const parsed = PAY_BODY_SCHEMA.safeParse(body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + return parsed.data; +} + +function toDateOnlyString(dateValue) { + if (!dateValue) { + return null; + } + + if (typeof dateValue === "string") { + return dateValue.slice(0, 10); + } + + return new Date(dateValue).toISOString().slice(0, 10); +} + function ensureNonNegativeAmount(totalAmount) { const asNumber = Number(totalAmount); if (!Number.isFinite(asNumber)) { @@ -655,11 +686,12 @@ async function reimbursementRoutes(fastify) { }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); + const payInput = parsePayBody(request.body); let response = null; await fastify.prisma.$transaction(async (tx) => { const reimbursementRows = await tx.$queryRaw` - SELECT id, status + SELECT id, status, payment_voucher_no, payment_date, payment_channel, paid_at, paid_by FROM "reimbursement_forms" WHERE id = ${reimbursementId}::uuid FOR UPDATE @@ -683,6 +715,27 @@ async function reimbursementRoutes(fastify) { }); } + const existingPaymentDate = toDateOnlyString(reimbursement.payment_date); + if (reimbursement.status === "paid") { + const hasVoucherConflict = + payInput.paymentVoucherNo != null && + payInput.paymentVoucherNo !== (reimbursement.payment_voucher_no ?? null); + const hasPaymentDateConflict = + payInput.paymentDate != null && payInput.paymentDate !== existingPaymentDate; + const hasChannelConflict = + payInput.paymentChannel != null && + payInput.paymentChannel !== (reimbursement.payment_channel ?? null); + + if (hasVoucherConflict || hasPaymentDateConflict || hasChannelConflict) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { payment: "already_paid_conflict" }, + }); + } + } + const now = new Date(); const archivedResult = await tx.invoicePool.updateMany({ where: { @@ -702,6 +755,11 @@ async function reimbursementRoutes(fastify) { where: { id: reimbursementId }, data: { status: "paid", + payment_voucher_no: payInput.paymentVoucherNo ?? null, + payment_date: payInput.paymentDate ? parseDateOnlyString(payInput.paymentDate) : null, + payment_channel: payInput.paymentChannel ?? null, + paid_at: now, + paid_by: request.employee.id, updated_by: request.employee.id, }, }); @@ -713,6 +771,29 @@ async function reimbursementRoutes(fastify) { reimbursementStatus: "paid", // archivedInvoiceCount=0 is valid for idempotent pay or when no bound invoices remain. archivedInvoiceCount: archivedResult.count, + payment: { + paymentVoucherNo: + reimbursement.status === "paid" + ? reimbursement.payment_voucher_no ?? null + : payInput.paymentVoucherNo ?? null, + paymentDate: + reimbursement.status === "paid" + ? existingPaymentDate + : payInput.paymentDate ?? null, + paymentChannel: + reimbursement.status === "paid" + ? reimbursement.payment_channel ?? null + : payInput.paymentChannel ?? null, + paidAt: + reimbursement.status === "paid" + ? reimbursement.paid_at?.toISOString?.() ?? null + : now.toISOString(), + paidBy: + reimbursement.status === "paid" + ? reimbursement.paid_by ?? null + : request.employee.id, + idempotent: reimbursement.status === "paid", + }, }; }); diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index 7038f1bb..deaa9b65 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -3,6 +3,10 @@ const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions"); const { getWorkflowStepsSnapshot, resolveActionableStep } = require("../utils/workflow"); +const { + findActiveDelegationForDelegate, + listActiveDelegationPrincipals, +} = require("../utils/delegation"); const TASK_ID_SCHEMA = z.string().uuid(); const REQUIRED_COMMENT_SCHEMA = z.object({ @@ -98,20 +102,54 @@ function ensurePendingTask(task) { } } -function ensureTaskAssignableToCurrentEmployee(task, request) { +async function ensureTaskAssignableToCurrentEmployee(tx, task, request) { const matchesAssigneeId = Boolean(task.assignee_id && task.assignee_id === request.employee.id); const matchesAssigneeRole = Boolean( task.assignee_role && request.employee.roleCodes.includes(task.assignee_role), ); const isUnassigned = !task.assignee_id && !task.assignee_role; - if (!(matchesAssigneeId || matchesAssigneeRole || isUnassigned)) { - throw new AppError({ - code: "PERM_002", - message: "不在审批范围/非当前处理人", - statusCode: 403, - }); + if (matchesAssigneeId || matchesAssigneeRole || isUnassigned) { + return { + delegated: false, + delegationId: null, + principalId: null, + }; } + + if (task.assignee_id) { + const delegation = await findActiveDelegationForDelegate(tx, { + principalEmployeeId: task.assignee_id, + delegateEmployeeId: request.employee.id, + allowedScopes: ["all"], + }); + + if (delegation) { + return { + delegated: true, + delegationId: delegation.id, + principalId: delegation.principal_employee_id, + }; + } + } + + throw new AppError({ + code: "PERM_002", + message: "不在审批范围/非当前处理人", + statusCode: 403, + }); +} + +function withDelegationPayload(payload, assignment, delegateId) { + const nextPayload = payload ? { ...payload } : {}; + + if (assignment?.delegated) { + nextPayload.delegation_id = assignment.delegationId; + nextPayload.principal_id = assignment.principalId; + nextPayload.delegate_id = delegateId; + } + + return nextPayload; } async function loadWorkflowInstanceForTask(tx, instanceId) { @@ -253,11 +291,19 @@ async function workflowRoutes(fastify) { const roleFilters = request.employee.roleCodes.length ? [{ assignee_role: { in: request.employee.roleCodes } }] : []; + const delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, { + delegateEmployeeId: request.employee.id, + allowedScopes: ["all"], + }); + const assigneeFilters = [{ assignee_id: request.employee.id }, ...roleFilters]; + if (delegatedPrincipalIds.length) { + assigneeFilters.push({ assignee_id: { in: delegatedPrincipalIds } }); + } const tasks = await fastify.prisma.workflowTask.findMany({ where: { status, - OR: [{ assignee_id: request.employee.id }, ...roleFilters], + OR: assigneeFilters, }, orderBy: { created_at: "desc" }, select: { @@ -299,7 +345,7 @@ async function workflowRoutes(fastify) { await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); ensurePendingTask(task); - ensureTaskAssignableToCurrentEmployee(task, request); + const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request); const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id); const steps = getWorkflowStepsSnapshot(workflowInstance); @@ -367,10 +413,14 @@ async function workflowRoutes(fastify) { task_id: taskId, actor_id: request.employee.id, action: "approve", - payload: { - from_step_id: task.step_id, - to_step_id: actionable.step.id, - }, + payload: withDelegationPayload( + { + from_step_id: task.step_id, + to_step_id: actionable.step.id, + }, + assignment, + request.employee.id, + ), }, }); @@ -407,10 +457,14 @@ async function workflowRoutes(fastify) { task_id: taskId, actor_id: request.employee.id, action: "approve", - payload: { - from_step_id: task.step_id, - terminal: true, - }, + payload: withDelegationPayload( + { + from_step_id: task.step_id, + terminal: true, + }, + assignment, + request.employee.id, + ), }, }); @@ -440,7 +494,7 @@ async function workflowRoutes(fastify) { await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); ensurePendingTask(task); - ensureTaskAssignableToCurrentEmployee(task, request); + const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request); const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id); const now = new Date(); @@ -482,9 +536,13 @@ async function workflowRoutes(fastify) { actor_id: request.employee.id, action: "reject", comment, - payload: { - from_step_id: task.step_id, - }, + payload: withDelegationPayload( + { + from_step_id: task.step_id, + }, + assignment, + request.employee.id, + ), }, }); @@ -513,7 +571,7 @@ async function workflowRoutes(fastify) { await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); ensurePendingTask(task); - ensureTaskAssignableToCurrentEmployee(task, request); + const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request); const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id); const now = new Date(); @@ -555,9 +613,13 @@ async function workflowRoutes(fastify) { actor_id: request.employee.id, action: "return", comment, - payload: { - from_step_id: task.step_id, - }, + payload: withDelegationPayload( + { + from_step_id: task.step_id, + }, + assignment, + request.employee.id, + ), }, }); @@ -586,7 +648,7 @@ async function workflowRoutes(fastify) { await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); ensurePendingTask(task); - ensureTaskAssignableToCurrentEmployee(task, request); + const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request); const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id); const transferCount = await ensureTransferChainNotExceeded(tx, taskId); @@ -630,12 +692,16 @@ async function workflowRoutes(fastify) { actor_id: request.employee.id, action: "transfer", comment: reason, - payload: { - from_approver_id: request.employee.id, - to_approver_id: toApproverId, - reason, - transfer_count: transferCount + 1, - }, + payload: withDelegationPayload( + { + from_approver_id: request.employee.id, + to_approver_id: toApproverId, + reason, + transfer_count: transferCount + 1, + }, + assignment, + request.employee.id, + ), }, }); diff --git a/server/src/scripts/revoke-expired-delegations.js b/server/src/scripts/revoke-expired-delegations.js new file mode 100644 index 00000000..d0b58b2c --- /dev/null +++ b/server/src/scripts/revoke-expired-delegations.js @@ -0,0 +1,19 @@ +require("dotenv/config"); + +const { createPrismaClient } = require("../prismaClient"); +const { revokeExpiredDelegations } = require("../jobs/delegationRevokeJob"); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const count = await revokeExpiredDelegations(prisma, console); + process.stdout.write(`revoked=${count}\n`); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/src/scripts/run-ocr-once.js b/server/src/scripts/run-ocr-once.js new file mode 100644 index 00000000..c4736527 --- /dev/null +++ b/server/src/scripts/run-ocr-once.js @@ -0,0 +1,26 @@ +require("dotenv/config"); + +const { createPrismaClient } = require("../prismaClient"); +const { getConfig } = require("../config"); +const { runOcrOnce } = require("../jobs/ocrJob"); + +(async () => { + const config = getConfig(); + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + if (config.ocrProvider !== "tencent") { + process.stdout.write("ocr_provider_disabled\n"); + return; + } + + const result = await runOcrOnce(prisma, config, console, `ocr-script:${process.pid}`); + process.stdout.write(`claimed=${result.claimed}\n`); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/src/services/ocr/index.js b/server/src/services/ocr/index.js new file mode 100644 index 00000000..eff46885 --- /dev/null +++ b/server/src/services/ocr/index.js @@ -0,0 +1,22 @@ +const { runTencentVatOcr } = require("./tencent"); + +function isOcrEnabledForType(config, ocrType) { + return config.ocrProvider === "tencent" && ocrType === "vat"; +} + +async function runOcrForInvoice({ config, ocrType, imageBase64 }) { + if (config.ocrProvider !== "tencent") { + throw new Error(`Unsupported OCR provider: ${config.ocrProvider}`); + } + + if (ocrType !== "vat") { + throw new Error(`Unsupported OCR type: ${ocrType}`); + } + + return runTencentVatOcr(config, imageBase64); +} + +module.exports = { + isOcrEnabledForType, + runOcrForInvoice, +}; diff --git a/server/src/services/ocr/tencent.js b/server/src/services/ocr/tencent.js new file mode 100644 index 00000000..c563543b --- /dev/null +++ b/server/src/services/ocr/tencent.js @@ -0,0 +1,103 @@ +const tencentcloud = require("tencentcloud-sdk-nodejs"); + +function normalizeDate(value) { + if (!value || typeof value !== "string") { + return null; + } + + const compact = value.trim(); + if (!compact) { + return null; + } + + const replaced = compact.replace(/年|\//g, "-").replace(/月/g, "-").replace(/日/g, ""); + const matched = replaced.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); + if (!matched) { + return null; + } + + const [, year, month, day] = matched; + return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; +} + +function toNumberString(value) { + if (value == null) { + return null; + } + + if (typeof value === "number" && Number.isFinite(value)) { + return value.toFixed(2); + } + + if (typeof value !== "string") { + return null; + } + + const stripped = value.replace(/[,¥\s]/g, "").trim(); + if (!stripped) { + return null; + } + + const num = Number(stripped); + if (!Number.isFinite(num)) { + return null; + } + + return num.toFixed(2); +} + +function normalizeString(value) { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return normalized || null; +} + +function createTencentOcrClient(config) { + if (!config.tencentSecretId || !config.tencentSecretKey) { + throw new Error("Tencent OCR credentials are missing"); + } + + const OcrClient = tencentcloud.ocr.v20181119.Client; + return new OcrClient({ + credential: { + secretId: config.tencentSecretId, + secretKey: config.tencentSecretKey, + }, + region: config.tencentOcrRegion, + profile: { + httpProfile: { + timeout: config.ocrTimeoutMs, + }, + }, + }); +} + +function normalizeVatInvoiceResult(result) { + return { + invoiceCode: normalizeString(result?.InvoiceCode), + invoiceNumber: normalizeString(result?.InvoiceNum), + invoiceDate: normalizeDate(result?.InvoiceDate), + amount: toNumberString(result?.AmountWithoutTax), + taxAmount: toNumberString(result?.TaxAmount), + totalAmount: toNumberString(result?.TotalAmount ?? result?.AmountInFiguers), + sellerName: normalizeString(result?.SellerName), + buyerName: normalizeString(result?.BuyerName), + raw: result ?? {}, + }; +} + +async function runTencentVatOcr(config, imageBase64) { + const client = createTencentOcrClient(config); + const result = await client.VatInvoiceOCR({ + ImageBase64: imageBase64, + }); + + return normalizeVatInvoiceResult(result); +} + +module.exports = { + runTencentVatOcr, +}; diff --git a/server/src/utils/delegation.js b/server/src/utils/delegation.js new file mode 100644 index 00000000..d49fa8a7 --- /dev/null +++ b/server/src/utils/delegation.js @@ -0,0 +1,66 @@ +function buildActiveDelegationWhere(now, delegateEmployeeId, allowedScopes) { + return { + delegate_employee_id: delegateEmployeeId, + scope: { in: allowedScopes }, + revoked_at: null, + valid_from: { lte: now }, + OR: [{ valid_to: null }, { valid_to: { gt: now } }], + }; +} + +async function findActiveDelegationForDelegate( + tx, + { principalEmployeeId, delegateEmployeeId, allowedScopes = ["all"], now = new Date() }, +) { + return tx.delegationGrant.findFirst({ + where: { + principal_employee_id: principalEmployeeId, + ...buildActiveDelegationWhere(now, delegateEmployeeId, allowedScopes), + }, + orderBy: { valid_from: "desc" }, + select: { + id: true, + principal_employee_id: true, + delegate_employee_id: true, + scope: true, + valid_from: true, + valid_to: true, + }, + }); +} + +async function listActiveDelegationPrincipals( + tx, + { delegateEmployeeId, allowedScopes = ["all"], now = new Date() }, +) { + const delegations = await tx.delegationGrant.findMany({ + where: buildActiveDelegationWhere(now, delegateEmployeeId, allowedScopes), + select: { + principal_employee_id: true, + }, + }); + + return Array.from(new Set(delegations.map((item) => item.principal_employee_id))); +} + +function computeDelegationStatus(delegation, now = new Date()) { + if (delegation.revoked_at) { + return "revoked"; + } + + if (delegation.valid_to && delegation.valid_to <= now) { + return "expired"; + } + + if (delegation.valid_from > now) { + return "scheduled"; + } + + return "active"; +} + +module.exports = { + findActiveDelegationForDelegate, + listActiveDelegationPrincipals, + computeDelegationStatus, +};