feat(server): 完成 P2 后端能力(支付幂等、委派回收、OCR异步、审计)
这个提交包含在:
+2
@@ -7,3 +7,5 @@ server/generated/prisma
|
|||||||
|
|
||||||
|
|
||||||
**/dist
|
**/dist
|
||||||
|
|
||||||
|
server/generated/storage
|
||||||
|
|||||||
@@ -7,3 +7,15 @@ APP_TIMEZONE="Asia/Shanghai"
|
|||||||
STORAGE_DIR="generated/storage"
|
STORAGE_DIR="generated/storage"
|
||||||
SIGNED_URL_TTL_SECONDS="600"
|
SIGNED_URL_TTL_SECONDS="600"
|
||||||
MAX_UPLOAD_SIZE_BYTES="10485760"
|
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"
|
||||||
|
|||||||
+53
-5
@@ -90,6 +90,9 @@ cd /home/shunli/Projects/hyb-oa/server
|
|||||||
- `pay` 权限:非财务返回 `PERM_001`
|
- `pay` 权限:非财务返回 `PERM_001`
|
||||||
- `pay` 状态校验:非 `approved/paid` 返回 `VALID_001`
|
- `pay` 状态校验:非 `approved/paid` 返回 `VALID_001`
|
||||||
- `pay` 幂等:重复付款返回 200(`archivedInvoiceCount` 可为 0)
|
- `pay` 幂等:重复付款返回 200(`archivedInvoiceCount` 可为 0)
|
||||||
|
- `pay` 幂等冲突:已付款后提交冲突凭证字段返回 `409 VALID_001`(`already_paid_conflict`)
|
||||||
|
- `delegation`:有效期内可代办并在 history 写入 delegation 证据
|
||||||
|
- `delegation` 过期:代理人返回 `PERM_002`,自动回收任务可撤销过期委派
|
||||||
|
|
||||||
### 4.4 一键全量回归
|
### 4.4 一键全量回归
|
||||||
```bash
|
```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 连接串
|
- `DATABASE_URL`: PostgreSQL 连接串
|
||||||
- `JWT_SECRET`: JWT & signed-url token 签名密钥
|
- `JWT_SECRET`: JWT & signed-url token 签名密钥
|
||||||
- `APP_TIMEZONE`: 默认 `Asia/Shanghai`
|
- `APP_TIMEZONE`: 默认 `Asia/Shanghai`
|
||||||
- `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`)
|
- `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`)
|
||||||
- `SIGNED_URL_TTL_SECONDS`: 默认 `600`
|
- `SIGNED_URL_TTL_SECONDS`: 默认 `600`
|
||||||
- `MAX_UPLOAD_SIZE_BYTES`: 默认 `10485760`(10MB)
|
- `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`,避免锁表影响。
|
> 生产提示:大表加索引需评估 `CREATE INDEX CONCURRENTLY`,避免锁表影响。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 常见失败排查
|
## 7. 常见失败排查
|
||||||
|
|
||||||
| 现象 | 可能原因 | 解决方案 |
|
| 现象 | 可能原因 | 解决方案 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -147,14 +193,16 @@ npm run dev
|
|||||||
| signed-url 接口返回 403 | `JWT_SECRET` 不一致、token 过期、访问人无权限 | 统一服务密钥、确认 URL 在 TTL 内、检查账号角色 |
|
| signed-url 接口返回 403 | `JWT_SECRET` 不一致、token 过期、访问人无权限 | 统一服务密钥、确认 URL 在 TTL 内、检查账号角色 |
|
||||||
| 上传返回 `file_too_large` | 文件超 `MAX_UPLOAD_SIZE_BYTES` | 调整环境变量或使用更小文件 |
|
| 上传返回 `file_too_large` | 文件超 `MAX_UPLOAD_SIZE_BYTES` | 调整环境变量或使用更小文件 |
|
||||||
| 上传失败且报目录/权限错误 | `STORAGE_DIR` 不存在或不可写 | 创建目录并修正权限;Docker 需确认 volume 挂载 |
|
| 上传失败且报目录/权限错误 | `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` 指向只读目录,执行上传接口,确认错误可观测(日志+返回码)。
|
- 临时将 `STORAGE_DIR` 指向只读目录,执行上传接口,确认错误可观测(日志+返回码)。
|
||||||
|
|
||||||
### 7.2 磁盘空间不足(ENOSPC)
|
### 8.2 磁盘空间不足(ENOSPC)
|
||||||
- 在受控环境中制造磁盘满条件后重试上传。
|
- 在受控环境中制造磁盘满条件后重试上传。
|
||||||
- 预期:错误路径可观测(日志中能定位到 I/O 错误),不要求纳入日常 CI 冒烟。
|
- 预期:错误路径可观测(日志中能定位到 I/O 错误),不要求纳入日常 CI 冒烟。
|
||||||
|
|||||||
+433
@@ -19,8 +19,10 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.3.0",
|
||||||
|
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -625,6 +627,18 @@
|
|||||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "8.17.1",
|
"version": "8.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
@@ -670,6 +684,12 @@
|
|||||||
"safer-buffer": "^2.1.0"
|
"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": {
|
"node_modules/atomic-sleep": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
@@ -707,6 +727,15 @@
|
|||||||
"bcrypt": "bin/bcrypt"
|
"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": {
|
"node_modules/bn.js": {
|
||||||
"version": "4.12.2",
|
"version": "4.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
"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": {
|
"node_modules/chevrotain": {
|
||||||
"version": "10.5.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz",
|
||||||
@@ -779,6 +821,18 @@
|
|||||||
"consola": "^3.2.3"
|
"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": {
|
"node_modules/confbox": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
||||||
@@ -841,6 +895,23 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/deepmerge-ts": {
|
||||||
"version": "7.1.5",
|
"version": "7.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
@@ -856,6 +927,15 @@
|
|||||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/denque": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
@@ -901,6 +981,20 @@
|
|||||||
"url": "https://dotenvx.com"
|
"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": {
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
@@ -929,6 +1023,51 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -1159,6 +1298,31 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/generate-function": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
@@ -1168,12 +1332,61 @@
|
|||||||
"is-property": "^1.0.2"
|
"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": {
|
"node_modules/get-port-please": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
|
||||||
"integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==",
|
"integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/giget": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||||
@@ -1208,6 +1421,18 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.0.tgz",
|
||||||
"integrity": "sha512-0E62MaTW5rPZVRLyIJZG/YejmdA/Xr1QydHEw3Vt+qOKkMIOE8WDLc9ZX2bmAjtJFZcId4lEdrdmASsEy7D1QA=="
|
"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": {
|
"node_modules/hono": {
|
||||||
"version": "4.11.4",
|
"version": "4.11.4",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
||||||
@@ -1260,6 +1524,19 @@
|
|||||||
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@@ -1282,6 +1559,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||||
@@ -1297,6 +1583,18 @@
|
|||||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -1312,6 +1610,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/json-schema-ref-resolver": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/mime": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||||
@@ -1431,6 +1747,27 @@
|
|||||||
"node": ">=10.0.0"
|
"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": {
|
"node_modules/minimalistic-assert": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
@@ -1470,6 +1807,12 @@
|
|||||||
"obliterator": "^2.0.4"
|
"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": {
|
"node_modules/mysql2": {
|
||||||
"version": "3.15.3",
|
"version": "3.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
||||||
@@ -1502,6 +1845,35 @@
|
|||||||
"node": ">=8.0.0"
|
"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": {
|
"node_modules/node-fetch-native": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
@@ -2182,6 +2554,26 @@
|
|||||||
"reusify": "^1.0.0"
|
"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": {
|
"node_modules/thread-stream": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||||
@@ -2221,6 +2613,31 @@
|
|||||||
"node": ">=0.6"
|
"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": {
|
"node_modules/valibot": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
+6
-2
@@ -14,7 +14,9 @@
|
|||||||
"prisma:migrate:deploy": "prisma migrate deploy",
|
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||||
"seed:config": "node src/scripts/seed-config.js",
|
"seed:config": "node src/scripts/seed-config.js",
|
||||||
"seed:dev-users": "node src/scripts/seed-dev.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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -32,7 +34,9 @@
|
|||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"prisma": "^7.3.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": {
|
"devDependencies": {
|
||||||
"dotenv": "^16.4.5"
|
"dotenv": "^16.4.5"
|
||||||
|
|||||||
@@ -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");
|
||||||
+15
-3
@@ -171,6 +171,7 @@ model Employee {
|
|||||||
|
|
||||||
submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter")
|
submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter")
|
||||||
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
|
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
|
||||||
|
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
|
||||||
|
|
||||||
owned_invoices InvoicePool[] @relation("InvoiceOwner")
|
owned_invoices InvoicePool[] @relation("InvoiceOwner")
|
||||||
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
|
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
|
||||||
@@ -276,6 +277,7 @@ model AuditLog {
|
|||||||
|
|
||||||
@@index([request_id])
|
@@index([request_id])
|
||||||
@@index([actor_id, created_at])
|
@@index([actor_id, created_at])
|
||||||
|
@@index([created_at, actor_id, action], map: "idx_audit_logs_query")
|
||||||
@@map("audit_logs")
|
@@map("audit_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,15 +329,21 @@ model ReimbursementForm {
|
|||||||
total_amount Decimal @default(0) @db.Decimal(14, 2)
|
total_amount Decimal @default(0) @db.Decimal(14, 2)
|
||||||
status ReimbursementStatus @default(draft)
|
status ReimbursementStatus @default(draft)
|
||||||
description String?
|
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_at DateTime @default(now())
|
||||||
created_by String? @db.Uuid
|
created_by String? @db.Uuid
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
updated_by String? @db.Uuid
|
updated_by String? @db.Uuid
|
||||||
|
|
||||||
submitter Employee @relation("ReimbursementSubmitter", fields: [submitter_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)
|
owner Employee @relation("ReimbursementOwner", fields: [owner_id], references: [id], onDelete: Restrict)
|
||||||
department Department @relation(fields: [department_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[]
|
items ReimbursementItem[]
|
||||||
invoice_pool_items InvoicePool[]
|
invoice_pool_items InvoicePool[]
|
||||||
@@ -345,6 +353,7 @@ model ReimbursementForm {
|
|||||||
@@index([owner_id])
|
@@index([owner_id])
|
||||||
@@index([submitter_id])
|
@@index([submitter_id])
|
||||||
@@index([department_id])
|
@@index([department_id])
|
||||||
|
@@index([paid_by])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([reimbursement_date])
|
@@index([reimbursement_date])
|
||||||
@@map("reimbursement_forms")
|
@@map("reimbursement_forms")
|
||||||
@@ -414,6 +423,8 @@ model InvoicePool {
|
|||||||
ocr_attempts Int @default(0)
|
ocr_attempts Int @default(0)
|
||||||
ocr_last_error String?
|
ocr_last_error String?
|
||||||
ocr_completed_at DateTime?
|
ocr_completed_at DateTime?
|
||||||
|
ocr_locked_at DateTime?
|
||||||
|
ocr_lock_owner String? @db.VarChar(128)
|
||||||
|
|
||||||
invoice_code String?
|
invoice_code String?
|
||||||
invoice_number String?
|
invoice_number String?
|
||||||
@@ -447,6 +458,7 @@ model InvoicePool {
|
|||||||
@@index([file_sha256])
|
@@index([file_sha256])
|
||||||
@@index([invoice_code, invoice_number])
|
@@index([invoice_code, invoice_number])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([status, ocr_locked_at, created_at], map: "idx_invoice_pool_ocr_queue")
|
||||||
@@index([linked_reimbursement_id])
|
@@index([linked_reimbursement_id])
|
||||||
@@map("invoice_pool")
|
@@map("invoice_pool")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,8 +188,13 @@ pay_reimbursement() {
|
|||||||
local token="$1"
|
local token="$1"
|
||||||
local reimbursement_id="$2"
|
local reimbursement_id="$2"
|
||||||
local out_file="$3"
|
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() {
|
transfer_task() {
|
||||||
@@ -213,6 +218,25 @@ withdraw_reimbursement() {
|
|||||||
assert_status "$status" "200" "withdraw reimbursement $reimbursement_id" "$out_file"
|
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() {
|
create_and_bind_invoice() {
|
||||||
local token="$1"
|
local token="$1"
|
||||||
local reimbursement_id="$2"
|
local reimbursement_id="$2"
|
||||||
@@ -761,7 +785,86 @@ const { createPrismaClient } = require('./src/prismaClient');
|
|||||||
NODE
|
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")"
|
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"
|
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"
|
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")"
|
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")"
|
RID_REJECT="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/reject-create.json")"
|
||||||
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_REJECT" "reject" >/dev/null
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_REJECT" "reject" >/dev/null
|
||||||
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_REJECT" "$TMP_DIR/reject-submit.json"
|
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_instance_status "$INSTANCE_REJECT" "rejected" "reject scenario"
|
||||||
assert_reimbursement_status_db "$RID_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")"
|
RID_RETURN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/return-create.json")"
|
||||||
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_RETURN" "return" >/dev/null
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_RETURN" "return" >/dev/null
|
||||||
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "$TMP_DIR/return-submit-1.json"
|
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_instance_status "$INSTANCE_RETURN_2" "cancelled" "return resubmit cleanup"
|
||||||
assert_reimbursement_status_db "$RID_RETURN" "draft" "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")"
|
RID_WITHDRAW="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/withdraw-create.json")"
|
||||||
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "withdraw" >/dev/null
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "withdraw" >/dev/null
|
||||||
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "$TMP_DIR/withdraw-submit.json"
|
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_instance_status "$INSTANCE_WITHDRAW" "cancelled" "withdraw scenario"
|
||||||
assert_reimbursement_status_db "$RID_WITHDRAW" "draft" "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")"
|
RID_PERM="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/perm-create.json")"
|
||||||
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PERM" "perm" >/dev/null
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PERM" "perm" >/dev/null
|
||||||
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "$TMP_DIR/perm-submit.json"
|
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_instance_status "$INSTANCE_PERM" "cancelled" "PERM_002 cleanup"
|
||||||
assert_reimbursement_status_db "$RID_PERM" "draft" "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")"
|
RID_NEGATIVE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/negative-create.json")"
|
||||||
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NEGATIVE" "negative" >/dev/null
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NEGATIVE" "negative" >/dev/null
|
||||||
set_reimbursement_total_amount "$RID_NEGATIVE" "-1.00"
|
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_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"
|
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__"
|
set_employee_direct_manager "$EMPLOYEE_ID" "__NULL__"
|
||||||
EMPLOYEE_MANAGER_PATCHED=1
|
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")"
|
RID_SKIP="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/skip-create.json")"
|
||||||
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_SKIP" "skip" >/dev/null
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_SKIP" "skip" >/dev/null
|
||||||
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_SKIP" "$TMP_DIR/skip-submit.json"
|
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_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"
|
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")"
|
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")"
|
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"
|
delete_approval_rule "$TMP_APPROVAL_RULE_ID"
|
||||||
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"
|
set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID"
|
||||||
EMPLOYEE_MANAGER_PATCHED=0
|
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")"
|
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
|
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"
|
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"
|
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")"
|
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
|
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"
|
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"
|
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"
|
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")"
|
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
|
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"
|
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"
|
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"
|
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")"
|
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
|
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"
|
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"
|
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"
|
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")"
|
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
|
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"
|
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"
|
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")"
|
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")"
|
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"
|
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"
|
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_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_reimbursement_status_db "$RID_PAY_OK" "paid" "pay success"
|
||||||
assert_invoices_archived_used_complete "$RID_PAY_OK" "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")"
|
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_status "$pay_invalid_draft_status" "409" "pay invalid status draft" "$TMP_DIR/pay-invalid-draft.json"
|
||||||
assert_error_code "$TMP_DIR/pay-invalid-draft.json" "VALID_001" "pay invalid status draft"
|
assert_error_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_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"
|
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")"
|
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_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"
|
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"
|
echo "[22/23] pay idempotent: paid reimbursement can be paid again"
|
||||||
pay_idempotent_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-idempotent.json")"
|
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_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_reimbursement_status_db "$RID_PAY_OK" "paid" "pay idempotent"
|
||||||
assert_invoices_archived_used_complete "$RID_PAY_OK" "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
|
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")"
|
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
|
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"
|
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"
|
assert_invoices_archived_used_complete "$RID_PAY_CONCURRENCY" "pay concurrency"
|
||||||
fi
|
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"
|
set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID"
|
||||||
EMPLOYEE_MANAGER_PATCHED=0
|
EMPLOYEE_MANAGER_PATCHED=0
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ const meRoutes = require("./routes/me");
|
|||||||
const reimbursementRoutes = require("./routes/reimbursements");
|
const reimbursementRoutes = require("./routes/reimbursements");
|
||||||
const invoicePoolRoutes = require("./routes/invoicePool");
|
const invoicePoolRoutes = require("./routes/invoicePool");
|
||||||
const workflowRoutes = require("./routes/workflow");
|
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");
|
const { AppError } = require("./utils/AppError");
|
||||||
|
|
||||||
async function buildApp() {
|
async function buildApp() {
|
||||||
@@ -114,6 +118,11 @@ async function buildApp() {
|
|||||||
await app.register(reimbursementRoutes, { prefix: "/api" });
|
await app.register(reimbursementRoutes, { prefix: "/api" });
|
||||||
await app.register(invoicePoolRoutes, { prefix: "/api" });
|
await app.register(invoicePoolRoutes, { prefix: "/api" });
|
||||||
await app.register(workflowRoutes, { 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;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ function getConfig() {
|
|||||||
storageDir: path.resolve(serverRoot, storageDir),
|
storageDir: path.resolve(serverRoot, storageDir),
|
||||||
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
|
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
|
||||||
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
|
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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const ROLE_PERMISSIONS = {
|
|||||||
"expense.view_all",
|
"expense.view_all",
|
||||||
"expense.finance.override_duplicate",
|
"expense.finance.override_duplicate",
|
||||||
"system.settings",
|
"system.settings",
|
||||||
|
"system.audit.view",
|
||||||
],
|
],
|
||||||
administration: ["expense.proxy_submit", "expense.view_own"],
|
administration: ["expense.proxy_submit", "expense.view_own"],
|
||||||
hr: ["system.users", "expense.view_all"],
|
hr: ["system.users", "expense.view_all"],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -10,6 +10,7 @@ const { compactDateString } = require("../utils/date");
|
|||||||
const { sha256Hex } = require("../utils/crypto");
|
const { sha256Hex } = require("../utils/crypto");
|
||||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||||
const { toMoneyString } = require("../utils/workflow");
|
const { toMoneyString } = require("../utils/workflow");
|
||||||
|
const { isOcrEnabledForType } = require("../services/ocr");
|
||||||
|
|
||||||
const MIME_ALLOWLIST = new Set([
|
const MIME_ALLOWLIST = new Set([
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
@@ -258,6 +259,8 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
await fs.mkdir(path.dirname(absoluteStoragePath), { recursive: true });
|
await fs.mkdir(path.dirname(absoluteStoragePath), { recursive: true });
|
||||||
await fs.writeFile(absoluteStoragePath, fileBuffer);
|
await fs.writeFile(absoluteStoragePath, fileBuffer);
|
||||||
|
|
||||||
|
const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available";
|
||||||
|
|
||||||
const invoice = await fastify.prisma.invoicePool.create({
|
const invoice = await fastify.prisma.invoicePool.create({
|
||||||
data: {
|
data: {
|
||||||
owner_employee_id: ownerEmployeeId,
|
owner_employee_id: ownerEmployeeId,
|
||||||
@@ -268,7 +271,7 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
file_size: fileSize,
|
file_size: fileSize,
|
||||||
file_sha256: fileSha256,
|
file_sha256: fileSha256,
|
||||||
ocr_type: ocrType,
|
ocr_type: ocrType,
|
||||||
status: "available",
|
status,
|
||||||
created_by: request.employee.id,
|
created_by: request.employee.id,
|
||||||
updated_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(
|
fastify.post(
|
||||||
"/invoice-pool/:id/signed-url",
|
"/invoice-pool/:id/signed-url",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ const {
|
|||||||
} = require("../utils/workflow");
|
} = require("../utils/workflow");
|
||||||
|
|
||||||
const UUID_SCHEMA = z.string().uuid();
|
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) {
|
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||||
@@ -74,6 +79,32 @@ function parseReimbursementId(id) {
|
|||||||
return parsed.data;
|
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) {
|
function ensureNonNegativeAmount(totalAmount) {
|
||||||
const asNumber = Number(totalAmount);
|
const asNumber = Number(totalAmount);
|
||||||
if (!Number.isFinite(asNumber)) {
|
if (!Number.isFinite(asNumber)) {
|
||||||
@@ -655,11 +686,12 @@ async function reimbursementRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||||
|
const payInput = parsePayBody(request.body);
|
||||||
let response = null;
|
let response = null;
|
||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const reimbursementRows = await tx.$queryRaw`
|
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"
|
FROM "reimbursement_forms"
|
||||||
WHERE id = ${reimbursementId}::uuid
|
WHERE id = ${reimbursementId}::uuid
|
||||||
FOR UPDATE
|
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 now = new Date();
|
||||||
const archivedResult = await tx.invoicePool.updateMany({
|
const archivedResult = await tx.invoicePool.updateMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -702,6 +755,11 @@ async function reimbursementRoutes(fastify) {
|
|||||||
where: { id: reimbursementId },
|
where: { id: reimbursementId },
|
||||||
data: {
|
data: {
|
||||||
status: "paid",
|
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,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -713,6 +771,29 @@ async function reimbursementRoutes(fastify) {
|
|||||||
reimbursementStatus: "paid",
|
reimbursementStatus: "paid",
|
||||||
// archivedInvoiceCount=0 is valid for idempotent pay or when no bound invoices remain.
|
// archivedInvoiceCount=0 is valid for idempotent pay or when no bound invoices remain.
|
||||||
archivedInvoiceCount: archivedResult.count,
|
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",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+98
-32
@@ -3,6 +3,10 @@ const { AppError } = require("../utils/AppError");
|
|||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
|
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
|
||||||
const { getWorkflowStepsSnapshot, resolveActionableStep } = require("../utils/workflow");
|
const { getWorkflowStepsSnapshot, resolveActionableStep } = require("../utils/workflow");
|
||||||
|
const {
|
||||||
|
findActiveDelegationForDelegate,
|
||||||
|
listActiveDelegationPrincipals,
|
||||||
|
} = require("../utils/delegation");
|
||||||
|
|
||||||
const TASK_ID_SCHEMA = z.string().uuid();
|
const TASK_ID_SCHEMA = z.string().uuid();
|
||||||
const REQUIRED_COMMENT_SCHEMA = z.object({
|
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 matchesAssigneeId = Boolean(task.assignee_id && task.assignee_id === request.employee.id);
|
||||||
const matchesAssigneeRole = Boolean(
|
const matchesAssigneeRole = Boolean(
|
||||||
task.assignee_role && request.employee.roleCodes.includes(task.assignee_role),
|
task.assignee_role && request.employee.roleCodes.includes(task.assignee_role),
|
||||||
);
|
);
|
||||||
const isUnassigned = !task.assignee_id && !task.assignee_role;
|
const isUnassigned = !task.assignee_id && !task.assignee_role;
|
||||||
|
|
||||||
if (!(matchesAssigneeId || matchesAssigneeRole || isUnassigned)) {
|
if (matchesAssigneeId || matchesAssigneeRole || isUnassigned) {
|
||||||
throw new AppError({
|
return {
|
||||||
code: "PERM_002",
|
delegated: false,
|
||||||
message: "不在审批范围/非当前处理人",
|
delegationId: null,
|
||||||
statusCode: 403,
|
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) {
|
async function loadWorkflowInstanceForTask(tx, instanceId) {
|
||||||
@@ -253,11 +291,19 @@ async function workflowRoutes(fastify) {
|
|||||||
const roleFilters = request.employee.roleCodes.length
|
const roleFilters = request.employee.roleCodes.length
|
||||||
? [{ assignee_role: { in: request.employee.roleCodes } }]
|
? [{ 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({
|
const tasks = await fastify.prisma.workflowTask.findMany({
|
||||||
where: {
|
where: {
|
||||||
status,
|
status,
|
||||||
OR: [{ assignee_id: request.employee.id }, ...roleFilters],
|
OR: assigneeFilters,
|
||||||
},
|
},
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
select: {
|
select: {
|
||||||
@@ -299,7 +345,7 @@ async function workflowRoutes(fastify) {
|
|||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const task = await lockWorkflowTask(tx, taskId);
|
const task = await lockWorkflowTask(tx, taskId);
|
||||||
ensurePendingTask(task);
|
ensurePendingTask(task);
|
||||||
ensureTaskAssignableToCurrentEmployee(task, request);
|
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
||||||
|
|
||||||
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
||||||
const steps = getWorkflowStepsSnapshot(workflowInstance);
|
const steps = getWorkflowStepsSnapshot(workflowInstance);
|
||||||
@@ -367,10 +413,14 @@ async function workflowRoutes(fastify) {
|
|||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
actor_id: request.employee.id,
|
actor_id: request.employee.id,
|
||||||
action: "approve",
|
action: "approve",
|
||||||
payload: {
|
payload: withDelegationPayload(
|
||||||
from_step_id: task.step_id,
|
{
|
||||||
to_step_id: actionable.step.id,
|
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,
|
task_id: taskId,
|
||||||
actor_id: request.employee.id,
|
actor_id: request.employee.id,
|
||||||
action: "approve",
|
action: "approve",
|
||||||
payload: {
|
payload: withDelegationPayload(
|
||||||
from_step_id: task.step_id,
|
{
|
||||||
terminal: true,
|
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) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const task = await lockWorkflowTask(tx, taskId);
|
const task = await lockWorkflowTask(tx, taskId);
|
||||||
ensurePendingTask(task);
|
ensurePendingTask(task);
|
||||||
ensureTaskAssignableToCurrentEmployee(task, request);
|
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
||||||
|
|
||||||
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -482,9 +536,13 @@ async function workflowRoutes(fastify) {
|
|||||||
actor_id: request.employee.id,
|
actor_id: request.employee.id,
|
||||||
action: "reject",
|
action: "reject",
|
||||||
comment,
|
comment,
|
||||||
payload: {
|
payload: withDelegationPayload(
|
||||||
from_step_id: task.step_id,
|
{
|
||||||
},
|
from_step_id: task.step_id,
|
||||||
|
},
|
||||||
|
assignment,
|
||||||
|
request.employee.id,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -513,7 +571,7 @@ async function workflowRoutes(fastify) {
|
|||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const task = await lockWorkflowTask(tx, taskId);
|
const task = await lockWorkflowTask(tx, taskId);
|
||||||
ensurePendingTask(task);
|
ensurePendingTask(task);
|
||||||
ensureTaskAssignableToCurrentEmployee(task, request);
|
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
||||||
|
|
||||||
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -555,9 +613,13 @@ async function workflowRoutes(fastify) {
|
|||||||
actor_id: request.employee.id,
|
actor_id: request.employee.id,
|
||||||
action: "return",
|
action: "return",
|
||||||
comment,
|
comment,
|
||||||
payload: {
|
payload: withDelegationPayload(
|
||||||
from_step_id: task.step_id,
|
{
|
||||||
},
|
from_step_id: task.step_id,
|
||||||
|
},
|
||||||
|
assignment,
|
||||||
|
request.employee.id,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -586,7 +648,7 @@ async function workflowRoutes(fastify) {
|
|||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const task = await lockWorkflowTask(tx, taskId);
|
const task = await lockWorkflowTask(tx, taskId);
|
||||||
ensurePendingTask(task);
|
ensurePendingTask(task);
|
||||||
ensureTaskAssignableToCurrentEmployee(task, request);
|
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
|
||||||
|
|
||||||
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
|
||||||
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
|
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
|
||||||
@@ -630,12 +692,16 @@ async function workflowRoutes(fastify) {
|
|||||||
actor_id: request.employee.id,
|
actor_id: request.employee.id,
|
||||||
action: "transfer",
|
action: "transfer",
|
||||||
comment: reason,
|
comment: reason,
|
||||||
payload: {
|
payload: withDelegationPayload(
|
||||||
from_approver_id: request.employee.id,
|
{
|
||||||
to_approver_id: toApproverId,
|
from_approver_id: request.employee.id,
|
||||||
reason,
|
to_approver_id: toApproverId,
|
||||||
transfer_count: transferCount + 1,
|
reason,
|
||||||
},
|
transfer_count: transferCount + 1,
|
||||||
|
},
|
||||||
|
assignment,
|
||||||
|
request.employee.id,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
在新工单中引用
屏蔽一个用户