feat(server): 完成 P0+P1 后端能力(工作流、RBAC、付款归档)

P0 MVP:

- 认证:login/refresh/logout/forgot/reset + tokenVersion

- RBAC:角色到权限映射 + requirePermission

- 发票池:upload/bind/signed-url + 判重(VALID_003/VALID_005)

- 报销:create/view/submit + 单号生成

- 工作流:approve + department_manager 解析

P1 扩展:

- 动作:reject/return/withdraw(含 comment)

- transfer:任务转审 + 链路上限 3

- pay:approved -> paid + 发票归档 used_complete

- 动态审批链:approval_rules 驱动

回归脚本:

- smoke-test.sh / regression-dedup.sh / regression-workflow-actions.sh
这个提交包含在:
2026-02-07 16:43:31 +08:00
当前提交 f527633716
共修改 44 个文件,包含 11100 行新增和 0 行删除
+7
查看文件
@@ -0,0 +1,7 @@
**/node_modules
.env
**/.env
server/generated/prisma
+42
查看文件
@@ -0,0 +1,42 @@
version: '3.8'
services:
db:
image: postgres:15-alpine
container_name: hyb-oa-db
restart: always
environment:
POSTGRES_USER: hyboa_user
POSTGRES_PASSWORD: hyboa_password
POSTGRES_DB: hyb_oa
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
server:
image: node:20-alpine
container_name: hyb-oa-server
restart: unless-stopped
working_dir: /app/server
command: sh -c "npm install && npm run dev"
environment:
DATABASE_URL: "postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa?schema=public"
JWT_SECRET: "change_me"
APP_TIMEZONE: "Asia/Shanghai"
STORAGE_DIR: "generated/storage"
SIGNED_URL_TTL_SECONDS: "600"
MAX_UPLOAD_SIZE_BYTES: "10485760"
PORT: "3000"
HOST: "0.0.0.0"
ports:
- "3000:3000"
depends_on:
- db
volumes:
- ./:/app
- server_storage:/app/server/generated/storage
volumes:
postgres_data:
server_storage:
+150
查看文件
@@ -0,0 +1,150 @@
# hyb-oa 实施计划(Phase 1 / P0)
**日期**:2026-02-06
**范围**:报销系统及其依赖模块(员工/权限/工作流/附件)
## 0. 前置结论(不可再分叉)
- 技术栈:Fastify + Prisma + PostgreSQL(见 `docs/tech_stack_report.md`)
- 规格书:以 `docs/requirements-specification.md` 为单一事实源
## 1. 复用资产清单(从 shenzhou-business-suite 迁移到 hyb-oa)
| 资产 | 来源 | 复用方式 | 需要改造 |
| :--- | :--- | :--- | :--- |
| 工作流 4 表结构 | `shenzhou-business-suite/supabase/schema/70_workflow/01_workflow_02_tables.sql` | 转 Prisma schema | Supabase/RPC → Fastify service 事务 |
| `approval_rules` | 同上 | 转 Prisma schema + seed 3 条 | 管理 UI 放 P1(P0 先 DB/接口维护) |
| `approval_transfers` | 同上 | 转 Prisma schema | 与 `workflow_tasks` 联动,reason≥10 约束 |
| `document_sequences` | 同上 | 转 Prisma schema + seed 默认报销单号 | 生成逻辑改为服务端原子更新 |
| 权限码体系 | `shenzhou-business-suite/src/types/permissions.ts` | 复用命名与粒度 | 增加 `expense.proxy_submit/proxy_manage` |
| 工作流类型 | `shenzhou-business-suite/src/types/workflow.ts` | 参考 JSON 结构 | P0 只用线性审批链(后续扩展保留) |
| 报销表结构参考 | `shenzhou-business-suite/supabase/schema/20_finance/06_reimbursement_01_tables.sql` | 参考 forms/items/attachments 字段 | 补 `file_sha256`、签名 URL、判重例外审计 |
| 默认费用类型 seed | `shenzhou-business-suite/supabase/seed.sql` | 迁移 35 启用 + 3 legacy 禁用 | hyb-oa 额外启用 `utilities/rent` 形成 37 启用 |
| 存储安全基线思路 | `shenzhou-business-suite/supabase/migrations/20260131175000_storage_security_baseline.sql` | 迁移约束到 API 层 | P0 本地存储 + 签名 URL;P2 MinIO/OSS |
| UI 组件 | `shenzhou-business-suite/src/components/ui/*` | 实现阶段复制到 `client/src/` | 替换路由/样式变量;移除 Supabase 依赖 |
| 测试基建 | `playwright` + `vitest` 配置 | 实现阶段迁移 | P0 覆盖鉴权/工作流/判重核心路径 |
## 2. Phase 1(P0)里程碑拆解(按可开工顺序)
### Milestone 1:Prisma schema 草稿 + 评审
**目标**:把关键表结构确定下来(后续开发不再反复改 schema)。
**范围(至少)**:
- 组织/人员:`departments`、`employees`、`employee_roles`
- 权限:权限码为代码常量;角色集合落 `employee_roles`
- 报销:`reimbursement_forms`、`reimbursement_items`、`invoice_pool`(票据/附件池 + 绑定关系)
- OCR:`ocr_results`(OCR 去重与结果持久化)
- 费用类型:`expense_types`(含 37 默认启用 seed)
- 单号:`document_sequences`
- 工作流:`workflow_definitions`、`workflow_instances`、`workflow_tasks`、`workflow_history`
- 审批规则/转审:`approval_rules`、`approval_transfers`
- 认证与审计:`auth_sessions`、`audit_logs`
- 代提交授权:`delegation_grants`
**验收**:
- schema 与 `docs/requirements-specification.md` 一致
- 关键约束可实现(单号唯一、转审原因≥10、费用类型 code 唯一、软禁用等)
**评审截止(Schema Freeze)**:
- 截止:Milestone 1 合并后的 **2 个工作日**
- 截止后变更规则:
- 仅允许 `ADD COLUMN / ADD TABLE / ADD INDEX` 这类向前兼容变更
- `DROP/RENAME/CHANGE TYPE/CHANGE NULLABLE` 等破坏性变更必须走“变更申请”,并同步更新规格书与迁移计划
### Milestone 2:认证与会话(JWT + refresh cookie)
**范围**:
- `POST /api/auth/login|refresh|logout|forgot-password|reset-password`
- `auth_sessions`:refresh hash + deviceFingerprint/ip/UA + session 限额(5)
- `tokenVersion` 踢下线
**验收**:
- refresh rotation 生效;退出/重置能撤销会话;access 不落 localStorage
### Milestone 3A:发票池上传(本地存储)+ SHA256 + 签名 URL
**范围**:
- `POST /api/invoice-pool/upload`(multipart;入参 `file` + 可选 `owner_employee_id` + 可选 `ocr_type`;返回 `invoice_pool_id`)
- `POST /api/invoice-pool/:id/signed-url`(TTL=10m)
- 上传约束:类型白名单 + ≤10MB
- 哈希:计算并落库 `file_sha256`(作为 OCR 去重 key)
**验收**:
- 未授权用户无法获取签名 URL;过期不可访问;同文件哈希可用于 OCR 去重复用
### Milestone 3B:腾讯云 OCR + `ocr_results` 去重 + retry 策略
**范围**:
- OCR:调用腾讯云 OCR,保存“标准化结果”(rawResponse 仅日志不落库)
- 去重:`ocr_results` 以 `file_sha256 + ocr_provider + ocr_type` 唯一,命中时复用(不重复计费)
- 状态机:`invoice_pool` 从 `pending_ocr` → `available/ocr_failed`
- 重试:`POST /api/invoice-pool/:id/retry-ocr`(attempt<=3;间隔>=60s;错误码 `VALID_006/VALID_007`)
- 审计:`OCR_REQUESTED/OCR_SUCCEEDED/OCR_FAILED/OCR_RETRIED`
- 自动归档:`available/ocr_failed` 未绑定条目 90 天后自动归档为 `archived_reason=unused_timeout`
**验收**:
- 同一文件二次上传不重复计费;OCR 失败可重试且次数/频控生效;90 天游离自动归档生效且不参与全局判重;状态与审计一致
### Milestone 4:费用类型系统(动态 `expense_types`)
**范围**:
- `GET /api/expense-types?enabled=true`
- seed:默认启用 37 项 + legacy 3 项禁用
- 软禁用语义:禁用后不出现在下拉,但历史可展示
**验收**:
- 下拉加载启用类型;禁用类型不显示但历史单据不丢
### Milestone 5:单号生成(`document_sequences`)
**范围**:
- 报销单号:`BX-YYYYMMDD-0001`(daily reset)
- 生成逻辑:数据库原子递增(同事务写入 `reimbursement_forms.form_number`)
**验收**:
- 并发下无重复编号;失败回滚不产生脏占用
### Milestone 6:报销单 CRUD + 提交发起工作流
**范围**:
- 报销单:创建/更新/删除草稿/查看
- 发票池绑定/解绑:`POST /api/invoice-pool/:id/bind|unbind`(并发行锁;冲突 409/`VALID_005`;绑定时触发全局判重 409/`VALID_003`)
- 删除草稿:自动解绑其绑定的发票池条目并回到 `available`
- 提交:校验 items、票据/发票要求(发票池绑定)、判重;生成单号;启动工作流实例并创建首个任务
- 代提交:校验 `delegation_grants`(scope & 生效时间)
- 代提交授权(Delegation Grants)CRUD API(用于自助/代维护):
- `GET /api/delegation-grants`(默认返回与本人相关;`system.users` 可查询任意)
- `POST /api/delegation-grants`(本人创建:principal=自己;`system.users` 可代创建)
- `POST /api/delegation-grants/:id/revoke`(本人撤销;`system.users` 可代撤销)
**验收**:
- draft → submitted/reviewing;workflow_instance/task/history 创建完整
- delegation_grants 可被创建/查询/撤销;所有变更写入 `audit_logs`
### Milestone 7:审批动作(approve/reject/return/withdraw/transfer)
**范围**:
- `approval_rules` 匹配审批链(3 条默认规则)
- `POST /api/workflow/tasks/:taskId/approve|reject|return`
- `POST /api/workflow/tasks/:taskId/transfer`(reason≥10;链路≤3)
- 审计:动作写入 `workflow_history`;关键例外写 `audit_logs`
**验收**:
- 审批推进正确;转审三处证据齐全;状态与任务一致(status=reviewing 但节点来自 task)
### Milestone 8:付款标记 + 发票判重例外 + 发票池归档
**范围**:
- `POST /api/reimbursements/:id/pay`(需 `expense.finance.pay`)
- 付款完成联动:将该报销单绑定的发票池条目归档(`status=archived, archived_reason=used_complete`)
- 发票双策略判重 + 例外(仅 `finance_mgr`,需原单号+原因,原单 `approved/paid` 禁止)
**验收**:
- approved → paid;发票池条目自动归档为 `used_complete`;例外受限且审计完整
### Milestone 9:最小 CI 门槛(护栏)
**范围**:
- typecheck + lint(不改写源码)
- 最小集成测试:登录/发票池上传+OCR+绑定/提交/审批/转审/判重/付款标记/归档
- 安全回归:敏感字段脱敏测试(银行卡/身份证等字段在所有对外响应均为脱敏格式;审计日志禁止明文)
**验收**:
- CI 通过:typecheck + lint + 最小集成测试 + 脱敏测试全绿
## 3. Phase 2(扩展)
- OCR、企微/钉钉通知、报表导出
- 对象存储(MinIO/OSS)
- 工作流扩展到其他实体(请假/采购)
## 4. 风险与阻塞项(P0)
- 财务对“费用类型/科目默认值”仍可能需要微调:通过 `expense_types` 配置解决,避免改代码
- 转审/例外权限属于高风险动作:必须上线前完成审计与权限回归测试
文件差异内容过多而无法显示 加载差异
+66
查看文件
@@ -0,0 +1,66 @@
# hyb-oa 技术栈与复用报告(定案)
**日期**:2026-02-05
**适用范围**:OA 报销系统(Phase 1)
## 1. 定案结论(统一口径)
| 层级 | 选型 |
| :--- | :--- |
| 前端(OA) | React + Vite + Tailwind |
| 后端(OA API) | Fastify |
| ORM | Prisma |
| 数据库 | PostgreSQL |
| 认证 | JWT(access + refresh rotation),refresh 以 httpOnly Cookie 传输 |
| 权限 | RBAC(权限码) |
| 审计 | `workflow_history`(流程)+ `audit_logs`(安全与关键变更) |
| 部署 | Docker + Nginx(反向代理) |
## 2. 关键取舍(为什么这么选)
### 2.1 常驻 Node 服务场景:优先“稳定性/少踩坑”
本项目目标是云服务器常驻 Node 服务 + 长期演进,因此选型优先级为:
1) 工具链成熟度与可维护性
2) 团队上手成本与踩坑风险
3) 可观测性/审计/权限等生产配套
4) 性能(在常驻服务场景下,冷启动优势不显著)
### 2.2 Prisma vs Drizzle(定案:Prisma)
- Prisma:迁移范式统一、资料多、团队协作成本低;适合“新人不踩坑”
- Drizzle:更轻、更 SQL 直观,但更依赖团队 SQL/事务/索引纪律;在本项目“稳定性优先”的定义下不作为默认
### 2.3 Fastify vs Hono(定案:Fastify)
- Fastify 适合常驻服务:生态成熟、插件与生产实践丰富(鉴权、日志、schema 校验等)
- Hono 更偏极简/Edge/Serverless 方向;并非本项目的默认最优解
### 2.4 为什么不使用 Supabase(本项目定案)
本项目部署形态为“自建常驻 API + 自建权限/审计/工作流/文件存储”,Supabase 在此形态下会带来:
- 额外的运维与概念复杂度(RLS、RPC、Storage、Auth 等)
- 架构边界不清(哪些逻辑在 DB,哪些在服务层)
因此 hyb-oa 选择“Fastify + Prisma + PostgreSQL”作为单一事实源。
## 3. 复用资产清单(来自 `shenzhou-business-suite`)
> 原则:复用“成熟结构/约束/枚举/经验”,把改造集中在“Supabase 调用 → REST API 调用”。
| 资产 | 来源路径(参考) | 复用价值 | 备注/改造点 |
| :--- | :--- | :---: | :--- |
| 工作流引擎 4 表 + `approval_rules`/`approval_transfers`/`document_sequences` | `shenzhou-business-suite/supabase/schema/70_workflow/01_workflow_02_tables.sql` | ⭐⭐⭐⭐⭐ | 转 Prisma schema;工作流事务由 Fastify 服务层实现 |
| 工作流配置/类型(assignee_rule 等) | `shenzhou-business-suite/src/types/workflow.ts` | ⭐⭐⭐⭐ | 作为工作流 JSON 配置与契约参考 |
| 权限码体系 | `shenzhou-business-suite/src/types/permissions.ts` | ⭐⭐⭐⭐ | 复用命名与粒度;补 `expense.proxy_*` |
| 报销表结构参考(forms/items/attachments) | `shenzhou-business-suite/supabase/schema/20_finance/06_reimbursement_01_tables.sql` | ⭐⭐⭐⭐ | 字段设计参考;hyb-oa 简化并补 `file_sha256` 等 |
| 默认费用类型 seed(35 启用 + 3 legacy 禁用) | `shenzhou-business-suite/supabase/seed.sql` | ⭐⭐⭐⭐⭐ | hyb-oa 额外启用 `utilities/rent` 形成 37 启用 |
| 存储安全基线(类型/大小/访问控制) | `shenzhou-business-suite/supabase/migrations/20260131175000_storage_security_baseline.sql` | ⭐⭐⭐⭐ | 思路迁移到“本地存储 + 签名 URL” |
| UI 组件(shadcn/ui 风格) | `shenzhou-business-suite/src/components/ui/*` | ⭐⭐⭐⭐ | 实现阶段复制到 `hyb-oa/client/src/`(需统一路径与样式变量) |
| 报销前端页面结构 | `shenzhou-business-suite/src/pages/finance/reimbursement/*` | ⭐⭐⭐ | 需移除 Supabase 调用,替换为 OA API |
| 测试框架 | `shenzhou-business-suite/playwright.config.ts` + `vitest.config.ts` | ⭐⭐⭐ | 实现阶段迁移;补齐鉴权/工作流核心路径 |
## 4. 关键设计原则(写入规格书的那几条)
- 状态 vs 节点分离:报销单 `status` 固定 7 状态;审批节点由 `workflow_tasks` 驱动
- 配置驱动:`approval_rules`/`expense_types`/`document_sequences` 均可配置;P0 预置默认值即可生效
- 审计分层:
- `workflow_history`:流程动作与审批链路(权威)
- `audit_logs`:登录/敏感字段/配置变更/判重例外等安全关键事件
- 文件存储:P0 本地存储 + 签名 URL;P2 切换 MinIO/OSS(接口不变)
## 5. 与本仓库当前现状对齐(信息校验)
- `server/package.json` 已包含:`fastify`、`@fastify/jwt`、`prisma/@prisma/client`、`zod`
- `docker-compose.yml` 已提供 PostgreSQL 15(本地/开发用)
+9
查看文件
@@ -0,0 +1,9 @@
DATABASE_URL="postgresql://hyboa_user:hyboa_password@localhost:5432/hyb_oa?schema=public"
JWT_SECRET="change_me"
# APP_ORIGIN="https://oa.hyinda.com"
# COOKIE_SECURE="true"
# DEV_SHOW_PASSWORD_RESET_CODE="true"
APP_TIMEZONE="Asia/Shanghai"
STORAGE_DIR="generated/storage"
SIGNED_URL_TTL_SECONDS="600"
MAX_UPLOAD_SIZE_BYTES="10485760"
+148
查看文件
@@ -0,0 +1,148 @@
# hyb-oa Server Runbook (P0/P1)
## 1. 环境准备
- Node.js: `>=20`
- Docker + Docker Compose
- 本地工具:`curl`、`jq`
- 配置文件:从 `server/.env.example` 复制为 `server/.env` 并按需修改
```bash
cd /home/shunli/Projects/hyb-oa/server
cp .env.example .env
```
## 2. Gate 基线命令(必须顺序执行)
### 2.1 启动数据库
```bash
cd /home/shunli/Projects/hyb-oa
docker compose up -d db
```
### 2.2 应用迁移
```bash
cd /home/shunli/Projects/hyb-oa/server
npm run prisma:migrate:deploy
```
### 2.3 执行 seed(幂等)
```bash
cd /home/shunli/Projects/hyb-oa/server
npm run seed:dev
```
### 2.4 启动服务
```bash
cd /home/shunli/Projects/hyb-oa/server
npm run dev
```
### 2.5 冒烟验证
```bash
cd /home/shunli/Projects/hyb-oa/server
./scripts/smoke-test.sh
```
## 3. 1 分钟快速验证
```bash
cd /home/shunli/Projects/hyb-oa/server
./scripts/smoke-test.sh
```
---
## 4. 回归入口
### 4.1 脚本回归(推荐)
```bash
cd /home/shunli/Projects/hyb-oa/server
BASE_URL=http://127.0.0.1:3000 ./scripts/smoke-test.sh
```
### 4.2 判重专项回归(dedup)
```bash
cd /home/shunli/Projects/hyb-oa/server
DATABASE_URL='postgresql://hyboa_user:hyboa_password@127.0.0.1:5432/hyb_oa?schema=public' \
BASE_URL=http://127.0.0.1:3000 ./scripts/regression-dedup.sh
```
### 4.3 工作流动作回归
```bash
cd /home/shunli/Projects/hyb-oa/server
./scripts/regression-workflow-actions.sh
```
覆盖场景:
- `reject`:实例=`rejected`,报销=`rejected`
- `return`:实例=`terminated`,报销=`draft`
- `withdraw`:审批未处理前可撤回
- `return` 后二次 `submit`:创建新实例
- `PERM_002`:非 assignee 无法操作
- `no_actionable_approver`:全链无可执行审批人阻断
- `negative_not_allowed`:负金额防御
- `manager skip`:直属上级缺失时跳过并记录
- `transfer`:转审成功(任务改派 + 任务/转审记录/history 三处证据)
- `transfer` 参数校验:reason<10 返回 `VALID_002`
- `transfer` 同人转审:返回 `VALID_001`(`same_assignee_not_allowed`)
- `transfer` 链路上限:同 task 第 4 次转审阻断(`chain_limit_exceeded`)
- `transfer` 目标校验:目标审批人须 active 且具备 `expense.approve`
- `pay`:财务付款成功(`approved -> paid`,发票 `bound -> archived(used_complete)`)
- `pay` 权限:非财务返回 `PERM_001`
- `pay` 状态校验:非 `approved/paid` 返回 `VALID_001`
- `pay` 幂等:重复付款返回 200(`archivedInvoiceCount` 可为 0)
### 4.4 一键全量回归
```bash
cd /home/shunli/Projects/hyb-oa/server
./scripts/regression-all.sh
```
串联 `smoke-test.sh + regression-dedup.sh + regression-workflow-actions.sh`,适用于 CI 或发版前检查。
### 4.5 Postman 回归
- Collection: `server/postman/hyb-oa.postman_collection.json`
- Environment: `server/postman/hyb-oa.postman_environment.json`
- 核心链路:
1. Login Employee
2. Create Reimbursement
3. Upload Invoice
4. Bind Invoice
5. Submit Reimbursement
6. Login CEO
7. Approve Workflow Task
8. Get Signed URL
---
## 5. 关键环境变量
- `DATABASE_URL`: PostgreSQL 连接串
- `JWT_SECRET`: JWT & signed-url token 签名密钥
- `APP_TIMEZONE`: 默认 `Asia/Shanghai`
- `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`)
- `SIGNED_URL_TTL_SECONDS`: 默认 `600`
- `MAX_UPLOAD_SIZE_BYTES`: 默认 `10485760`(10MB)
> 生产提示:大表加索引需评估 `CREATE INDEX CONCURRENTLY`,避免锁表影响。
---
## 6. 常见失败排查
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| `prisma migrate deploy` 失败 | `DATABASE_URL` 未配置/不可达 | 检查 `server/.env` 与数据库容器状态 |
| `npm run seed:dev` 重复执行报错 | seed 脚本被改坏,未保持幂等 | 检查 `seed-config.js`/`seed-dev.js` 的 `upsert` / `skipDuplicates` |
| signed-url 接口返回 403 | `JWT_SECRET` 不一致、token 过期、访问人无权限 | 统一服务密钥、确认 URL 在 TTL 内、检查账号角色 |
| 上传返回 `file_too_large` | 文件超 `MAX_UPLOAD_SIZE_BYTES` | 调整环境变量或使用更小文件 |
| 上传失败且报目录/权限错误 | `STORAGE_DIR` 不存在或不可写 | 创建目录并修正权限;Docker 需确认 volume 挂载 |
---
## 7. 手工故障注入(非阻塞)
### 7.1 `STORAGE_DIR` 不可写路径
- 临时将 `STORAGE_DIR` 指向只读目录,执行上传接口,确认错误可观测(日志+返回码)。
### 7.2 磁盘空间不足(ENOSPC)
- 在受控环境中制造磁盘满条件后重试上传。
- 预期:错误路径可观测(日志中能定位到 I/O 错误),不要求纳入日常 CI 冒烟。
+2282
查看文件
文件差异内容过多而无法显示 加载差异
+40
查看文件
@@ -0,0 +1,40 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "node src/index.js",
"start": "node src/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"prisma:format": "prisma format",
"prisma:validate": "prisma validate",
"prisma:generate": "prisma generate",
"prisma:migrate:dev": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy",
"seed:config": "node src/scripts/seed-config.js",
"seed:dev-users": "node src/scripts/seed-dev.js",
"seed:dev": "npm run seed:config && npm run seed:dev-users"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0",
"@fastify/static": "^9.0.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
"fastify": "^5.7.4",
"fastify-plugin": "^5.1.0",
"pg": "^8.18.0",
"prisma": "^7.3.0",
"zod": "^4.3.6"
},
"devDependencies": {
"dotenv": "^16.4.5"
}
}
@@ -0,0 +1,186 @@
{
"info": {
"_postman_id": "d5575dd2-7e6f-4675-9f82-b52f22de8de0",
"name": "hyb-oa P0 MVP",
"description": "P0 vertical slice: login -> draft -> upload -> bind -> submit -> approve",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "1. Login Employee",
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "x-device-fingerprint", "value": "{{deviceFingerprint}}" }
],
"url": "{{baseUrl}}/api/auth/login",
"body": {
"mode": "raw",
"raw": "{\n \"identifier\": \"{{employeeIdentifier}}\",\n \"password\": \"{{employeePassword}}\"\n}"
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.environment.set('employeeAccessToken', json.data.accessToken);"
]
}
}
]
},
{
"name": "2. Create Reimbursement",
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Authorization", "value": "Bearer {{employeeAccessToken}}" }
],
"url": "{{baseUrl}}/api/reimbursements",
"body": {
"mode": "raw",
"raw": "{\n \"description\": \"P0 冒烟报销单\"\n}"
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.environment.set('reimbursementId', json.data.id);"
]
}
}
]
},
{
"name": "3. Upload Invoice",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{employeeAccessToken}}" }
],
"url": "{{baseUrl}}/api/invoice-pool/upload",
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "{{sampleInvoiceFile}}"
},
{
"key": "ocr_type",
"type": "text",
"value": "general"
}
]
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.environment.set('invoicePoolId', json.data.invoicePoolId);"
]
}
}
]
},
{
"name": "4. Bind Invoice",
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Authorization", "value": "Bearer {{employeeAccessToken}}" }
],
"url": "{{baseUrl}}/api/invoice-pool/{{invoicePoolId}}/bind",
"body": {
"mode": "raw",
"raw": "{\n \"reimbursementId\": \"{{reimbursementId}}\"\n}"
}
}
},
{
"name": "5. Submit Reimbursement",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{employeeAccessToken}}" }
],
"url": "{{baseUrl}}/api/reimbursements/{{reimbursementId}}/submit"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.environment.set('workflowTaskId', json.data.currentTaskId);",
"pm.environment.set('workflowInstanceId', json.data.workflowInstanceId);"
]
}
}
]
},
{
"name": "6. Login CEO",
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "x-device-fingerprint", "value": "{{deviceFingerprint}}" }
],
"url": "{{baseUrl}}/api/auth/login",
"body": {
"mode": "raw",
"raw": "{\n \"identifier\": \"{{ceoIdentifier}}\",\n \"password\": \"{{ceoPassword}}\"\n}"
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.environment.set('ceoAccessToken', json.data.accessToken);"
]
}
}
]
},
{
"name": "7. Approve Workflow Task",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{ceoAccessToken}}" }
],
"url": "{{baseUrl}}/api/workflow/tasks/{{workflowTaskId}}/approve"
}
},
{
"name": "8. Get Signed URL",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{employeeAccessToken}}" }
],
"url": "{{baseUrl}}/api/invoice-pool/{{invoicePoolId}}/signed-url"
}
}
]
}
@@ -0,0 +1,24 @@
{
"id": "6d11aa57-5444-4cc2-b9ff-b8dcfa5d44ee",
"name": "hyb-oa local",
"values": [
{ "key": "baseUrl", "value": "http://localhost:3000", "type": "default", "enabled": true },
{ "key": "deviceFingerprint", "value": "dev-local-fingerprint", "type": "default", "enabled": true },
{ "key": "sampleInvoiceFile", "value": "/tmp/hyb-oa-sample-invoice.pdf", "type": "default", "enabled": true },
{ "key": "employeeIdentifier", "value": "[email protected]", "type": "default", "enabled": true },
{ "key": "employeePassword", "value": "EmployeePassword1234", "type": "secret", "enabled": true },
{ "key": "ceoIdentifier", "value": "[email protected]", "type": "default", "enabled": true },
{ "key": "ceoPassword", "value": "CeoPassword1234", "type": "secret", "enabled": true },
{ "key": "employeeAccessToken", "value": "", "type": "default", "enabled": true },
{ "key": "ceoAccessToken", "value": "", "type": "default", "enabled": true },
{ "key": "reimbursementId", "value": "", "type": "default", "enabled": true },
{ "key": "invoicePoolId", "value": "", "type": "default", "enabled": true },
{ "key": "workflowTaskId", "value": "", "type": "default", "enabled": true },
{ "key": "workflowInstanceId", "value": "", "type": "default", "enabled": true }
],
"_postman_variable_scope": "environment",
"_postman_exported_at": "2026-02-06T00:00:00.000Z",
"_postman_exported_using": "Codex CLI"
}
+13
查看文件
@@ -0,0 +1,13 @@
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
@@ -0,0 +1,695 @@
-- CreateEnum
CREATE TYPE "RoleCode" AS ENUM ('employee', 'manager', 'ceo', 'finance', 'finance_mgr', 'administration', 'hr', 'admin');
-- CreateEnum
CREATE TYPE "EmployeeStatus" AS ENUM ('active', 'archived');
-- CreateEnum
CREATE TYPE "DelegationGrantScope" AS ENUM ('reimbursement_submit', 'all');
-- CreateEnum
CREATE TYPE "ReimbursementStatus" AS ENUM ('draft', 'reviewing', 'approved', 'paid', 'rejected', 'cancelled');
-- CreateEnum
CREATE TYPE "InvoicePoolStatus" AS ENUM ('pending_ocr', 'ocr_failed', 'available', 'bound', 'archived');
-- CreateEnum
CREATE TYPE "InvoiceArchivedReason" AS ENUM ('used_complete', 'unused_timeout', 'manual');
-- CreateEnum
CREATE TYPE "OcrProvider" AS ENUM ('tencent');
-- CreateEnum
CREATE TYPE "OcrType" AS ENUM ('vat', 'train', 'flight', 'taxi', 'general');
-- CreateEnum
CREATE TYPE "DocumentResetPeriod" AS ENUM ('none', 'daily', 'monthly', 'yearly');
-- CreateEnum
CREATE TYPE "WorkflowInstanceStatus" AS ENUM ('pending', 'approved', 'rejected', 'cancelled', 'terminated');
-- CreateEnum
CREATE TYPE "WorkflowTaskStatus" AS ENUM ('pending', 'completed', 'rejected', 'cancelled', 'skipped');
-- CreateEnum
CREATE TYPE "WorkflowTaskOutcome" AS ENUM ('approved', 'rejected', 'returned', 'start', 'transfer', 'skipped');
-- CreateEnum
CREATE TYPE "ApprovalTransferStatus" AS ENUM ('active', 'cancelled');
-- CreateEnum
CREATE TYPE "ExpenseCategory" AS ENUM ('travel', 'rd', 'production', 'office', 'business', 'assets', 'other');
-- CreateTable
CREATE TABLE "departments" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"parent_id" UUID,
"archived_at" TIMESTAMP(3),
"archived_by" UUID,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "departments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "employees" (
"id" UUID NOT NULL,
"employee_no" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"email" TEXT NOT NULL,
"department_id" UUID NOT NULL,
"title" TEXT NOT NULL,
"direct_manager_id" UUID NOT NULL,
"hire_date" DATE NOT NULL,
"status" "EmployeeStatus" NOT NULL DEFAULT 'active',
"bank_account_encrypted" TEXT NOT NULL,
"bank_name" TEXT NOT NULL,
"id_number_encrypted" TEXT,
"password_hash" TEXT NOT NULL,
"token_version" INTEGER NOT NULL DEFAULT 0,
"archived_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "employees_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "employee_roles" (
"employee_id" UUID NOT NULL,
"role_code" "RoleCode" NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
CONSTRAINT "employee_roles_pkey" PRIMARY KEY ("employee_id","role_code")
);
-- CreateTable
CREATE TABLE "delegation_grants" (
"id" UUID NOT NULL,
"principal_employee_id" UUID NOT NULL,
"delegate_employee_id" UUID NOT NULL,
"scope" "DelegationGrantScope" NOT NULL,
"valid_from" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"valid_to" TIMESTAMP(3),
"revoked_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
CONSTRAINT "delegation_grants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "auth_sessions" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"refresh_token_hash" TEXT NOT NULL,
"device_fingerprint" TEXT NOT NULL,
"ip_address" TEXT NOT NULL,
"user_agent" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"revoked_at" TIMESTAMP(3),
CONSTRAINT "auth_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" UUID NOT NULL,
"request_id" TEXT NOT NULL,
"actor_id" UUID NOT NULL,
"action" TEXT NOT NULL,
"target_type" TEXT NOT NULL,
"target_id" UUID,
"ip_address" TEXT,
"user_agent" TEXT,
"extra" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "expense_types" (
"id" UUID NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"category" "ExpenseCategory" NOT NULL,
"sort_order" INTEGER NOT NULL,
"account_code_default" TEXT,
"is_system_default" BOOLEAN NOT NULL DEFAULT false,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "expense_types_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "document_sequences" (
"document_type" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"date_format" TEXT NOT NULL,
"separator" TEXT NOT NULL,
"number_length" INTEGER NOT NULL DEFAULT 4,
"reset_period" "DocumentResetPeriod" NOT NULL,
"current_number" INTEGER NOT NULL DEFAULT 0,
"last_reset_date" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "document_sequences_pkey" PRIMARY KEY ("document_type")
);
-- CreateTable
CREATE TABLE "reimbursement_forms" (
"id" UUID NOT NULL,
"form_number" TEXT,
"submitter_id" UUID NOT NULL,
"owner_id" UUID NOT NULL,
"department_id" UUID NOT NULL,
"reimbursement_date" DATE NOT NULL,
"total_amount" DECIMAL(14,2) NOT NULL DEFAULT 0,
"status" "ReimbursementStatus" NOT NULL DEFAULT 'draft',
"description" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "reimbursement_forms_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "reimbursement_items" (
"id" UUID NOT NULL,
"reimbursement_id" UUID NOT NULL,
"expense_type_id" UUID NOT NULL,
"expense_date" DATE NOT NULL,
"amount" DECIMAL(14,2) NOT NULL,
"description" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "reimbursement_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ocr_results" (
"id" UUID NOT NULL,
"file_sha256" TEXT NOT NULL,
"ocr_provider" "OcrProvider" NOT NULL DEFAULT 'tencent',
"ocr_type" "OcrType" NOT NULL,
"result_json" JSONB NOT NULL,
"confidence" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ocr_results_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "invoice_pool" (
"id" UUID NOT NULL,
"owner_employee_id" UUID NOT NULL,
"uploaded_by_employee_id" UUID NOT NULL,
"storage_path" TEXT NOT NULL,
"original_name" TEXT NOT NULL,
"mime_type" TEXT NOT NULL,
"file_size" INTEGER NOT NULL,
"file_sha256" TEXT NOT NULL,
"status" "InvoicePoolStatus" NOT NULL DEFAULT 'pending_ocr',
"archived_reason" "InvoiceArchivedReason",
"archived_at" TIMESTAMP(3),
"linked_reimbursement_id" UUID,
"linked_item_id" UUID,
"remarks" TEXT,
"ocr_provider" "OcrProvider" NOT NULL DEFAULT 'tencent',
"ocr_type" "OcrType" NOT NULL DEFAULT 'general',
"ocr_result_id" UUID,
"ocr_attempts" INTEGER NOT NULL DEFAULT 0,
"ocr_last_error" TEXT,
"ocr_completed_at" TIMESTAMP(3),
"invoice_code" TEXT,
"invoice_number" TEXT,
"invoice_date" DATE,
"seller_name" TEXT,
"buyer_name" TEXT,
"amount" DECIMAL(14,2),
"tax_amount" DECIMAL(14,2),
"total_amount" DECIMAL(14,2),
"is_duplicate_override" BOOLEAN NOT NULL DEFAULT false,
"override_ref_form_number" TEXT,
"override_reason" TEXT,
"overridden_by" UUID,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "invoice_pool_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workflow_definitions" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"entity_type" TEXT NOT NULL,
"definition_json" JSONB NOT NULL,
"version" INTEGER NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
CONSTRAINT "workflow_definitions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workflow_instances" (
"id" UUID NOT NULL,
"workflow_id" UUID NOT NULL,
"entity_type" TEXT NOT NULL,
"entity_id" UUID NOT NULL,
"submitter_id" UUID NOT NULL,
"current_status" "WorkflowInstanceStatus" NOT NULL DEFAULT 'pending',
"current_step_id" TEXT NOT NULL,
"context_data" JSONB NOT NULL,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
"context_department_id" UUID,
CONSTRAINT "workflow_instances_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workflow_tasks" (
"id" UUID NOT NULL,
"instance_id" UUID NOT NULL,
"step_id" TEXT NOT NULL,
"step_name" TEXT NOT NULL,
"assignee_id" UUID,
"assignee_role" "RoleCode",
"status" "WorkflowTaskStatus" NOT NULL DEFAULT 'pending',
"outcome" "WorkflowTaskOutcome",
"comment" TEXT,
"due_date" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
"completed_by" UUID,
CONSTRAINT "workflow_tasks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workflow_history" (
"id" UUID NOT NULL,
"instance_id" UUID NOT NULL,
"task_id" UUID,
"actor_id" UUID NOT NULL,
"action" TEXT NOT NULL,
"comment" TEXT,
"payload" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "workflow_history_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "approval_rules" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"min_amount" DECIMAL(14,2) NOT NULL,
"max_amount" DECIMAL(14,2),
"level1_role" "RoleCode" NOT NULL,
"level2_role" "RoleCode",
"level3_role" "RoleCode",
"enabled" BOOLEAN NOT NULL DEFAULT true,
"priority" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "approval_rules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "approval_transfers" (
"id" UUID NOT NULL,
"reimbursement_id" UUID NOT NULL,
"from_approver_id" UUID NOT NULL,
"to_approver_id" UUID NOT NULL,
"reason" TEXT NOT NULL,
"status" "ApprovalTransferStatus" NOT NULL DEFAULT 'active',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
CONSTRAINT "approval_transfers_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "departments_parent_id_idx" ON "departments"("parent_id");
-- CreateIndex
CREATE UNIQUE INDEX "employees_employee_no_key" ON "employees"("employee_no");
-- CreateIndex
CREATE UNIQUE INDEX "employees_phone_key" ON "employees"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "employees_email_key" ON "employees"("email");
-- CreateIndex
CREATE INDEX "employees_department_id_idx" ON "employees"("department_id");
-- CreateIndex
CREATE INDEX "employees_direct_manager_id_idx" ON "employees"("direct_manager_id");
-- CreateIndex
CREATE INDEX "delegation_grants_principal_employee_id_idx" ON "delegation_grants"("principal_employee_id");
-- CreateIndex
CREATE INDEX "delegation_grants_delegate_employee_id_idx" ON "delegation_grants"("delegate_employee_id");
-- CreateIndex
CREATE INDEX "auth_sessions_employee_id_idx" ON "auth_sessions"("employee_id");
-- CreateIndex
CREATE INDEX "auth_sessions_expires_at_idx" ON "auth_sessions"("expires_at");
-- CreateIndex
CREATE INDEX "auth_sessions_revoked_at_idx" ON "auth_sessions"("revoked_at");
-- CreateIndex
CREATE INDEX "audit_logs_request_id_idx" ON "audit_logs"("request_id");
-- CreateIndex
CREATE INDEX "audit_logs_actor_id_created_at_idx" ON "audit_logs"("actor_id", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "expense_types_code_key" ON "expense_types"("code");
-- CreateIndex
CREATE INDEX "expense_types_enabled_sort_order_idx" ON "expense_types"("enabled", "sort_order");
-- CreateIndex
CREATE UNIQUE INDEX "reimbursement_forms_form_number_key" ON "reimbursement_forms"("form_number");
-- CreateIndex
CREATE INDEX "reimbursement_forms_owner_id_idx" ON "reimbursement_forms"("owner_id");
-- CreateIndex
CREATE INDEX "reimbursement_forms_submitter_id_idx" ON "reimbursement_forms"("submitter_id");
-- CreateIndex
CREATE INDEX "reimbursement_forms_department_id_idx" ON "reimbursement_forms"("department_id");
-- CreateIndex
CREATE INDEX "reimbursement_forms_status_idx" ON "reimbursement_forms"("status");
-- CreateIndex
CREATE INDEX "reimbursement_forms_reimbursement_date_idx" ON "reimbursement_forms"("reimbursement_date");
-- CreateIndex
CREATE INDEX "reimbursement_items_reimbursement_id_idx" ON "reimbursement_items"("reimbursement_id");
-- CreateIndex
CREATE INDEX "reimbursement_items_expense_type_id_idx" ON "reimbursement_items"("expense_type_id");
-- CreateIndex
CREATE INDEX "reimbursement_items_expense_date_idx" ON "reimbursement_items"("expense_date");
-- CreateIndex
CREATE INDEX "ocr_results_file_sha256_idx" ON "ocr_results"("file_sha256");
-- CreateIndex
CREATE UNIQUE INDEX "ocr_results_file_sha256_ocr_provider_ocr_type_key" ON "ocr_results"("file_sha256", "ocr_provider", "ocr_type");
-- CreateIndex
CREATE INDEX "invoice_pool_owner_employee_id_idx" ON "invoice_pool"("owner_employee_id");
-- CreateIndex
CREATE INDEX "invoice_pool_uploaded_by_employee_id_idx" ON "invoice_pool"("uploaded_by_employee_id");
-- CreateIndex
CREATE INDEX "invoice_pool_file_sha256_idx" ON "invoice_pool"("file_sha256");
-- CreateIndex
CREATE INDEX "invoice_pool_invoice_code_invoice_number_idx" ON "invoice_pool"("invoice_code", "invoice_number");
-- CreateIndex
CREATE INDEX "invoice_pool_status_idx" ON "invoice_pool"("status");
-- CreateIndex
CREATE INDEX "invoice_pool_linked_reimbursement_id_idx" ON "invoice_pool"("linked_reimbursement_id");
-- CreateIndex
CREATE INDEX "workflow_definitions_entity_type_is_active_idx" ON "workflow_definitions"("entity_type", "is_active");
-- CreateIndex
CREATE UNIQUE INDEX "workflow_definitions_entity_type_version_key" ON "workflow_definitions"("entity_type", "version");
-- CreateIndex
CREATE INDEX "workflow_instances_workflow_id_idx" ON "workflow_instances"("workflow_id");
-- CreateIndex
CREATE INDEX "workflow_instances_entity_type_entity_id_idx" ON "workflow_instances"("entity_type", "entity_id");
-- CreateIndex
CREATE INDEX "workflow_instances_current_status_idx" ON "workflow_instances"("current_status");
-- CreateIndex
CREATE INDEX "workflow_instances_submitter_id_idx" ON "workflow_instances"("submitter_id");
-- CreateIndex
CREATE INDEX "workflow_instances_context_department_id_idx" ON "workflow_instances"("context_department_id");
-- CreateIndex
CREATE INDEX "workflow_tasks_instance_id_idx" ON "workflow_tasks"("instance_id");
-- CreateIndex
CREATE INDEX "workflow_tasks_status_idx" ON "workflow_tasks"("status");
-- CreateIndex
CREATE INDEX "workflow_tasks_assignee_id_idx" ON "workflow_tasks"("assignee_id");
-- CreateIndex
CREATE INDEX "workflow_tasks_assignee_role_idx" ON "workflow_tasks"("assignee_role");
-- CreateIndex
CREATE INDEX "workflow_history_instance_id_created_at_idx" ON "workflow_history"("instance_id", "created_at");
-- CreateIndex
CREATE INDEX "workflow_history_task_id_idx" ON "workflow_history"("task_id");
-- CreateIndex
CREATE INDEX "workflow_history_actor_id_idx" ON "workflow_history"("actor_id");
-- CreateIndex
CREATE INDEX "approval_rules_enabled_priority_idx" ON "approval_rules"("enabled", "priority");
-- CreateIndex
CREATE INDEX "approval_transfers_reimbursement_id_idx" ON "approval_transfers"("reimbursement_id");
-- CreateIndex
CREATE INDEX "approval_transfers_from_approver_id_idx" ON "approval_transfers"("from_approver_id");
-- CreateIndex
CREATE INDEX "approval_transfers_to_approver_id_idx" ON "approval_transfers"("to_approver_id");
-- AddForeignKey
ALTER TABLE "departments" ADD CONSTRAINT "departments_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "departments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employees" ADD CONSTRAINT "employees_department_id_fkey" FOREIGN KEY ("department_id") REFERENCES "departments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employees" ADD CONSTRAINT "employees_direct_manager_id_fkey" FOREIGN KEY ("direct_manager_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employee_roles" ADD CONSTRAINT "employee_roles_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "delegation_grants" ADD CONSTRAINT "delegation_grants_principal_employee_id_fkey" FOREIGN KEY ("principal_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "delegation_grants" ADD CONSTRAINT "delegation_grants_delegate_employee_id_fkey" FOREIGN KEY ("delegate_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "auth_sessions" ADD CONSTRAINT "auth_sessions_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reimbursement_forms" ADD CONSTRAINT "reimbursement_forms_submitter_id_fkey" FOREIGN KEY ("submitter_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reimbursement_forms" ADD CONSTRAINT "reimbursement_forms_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reimbursement_forms" ADD CONSTRAINT "reimbursement_forms_department_id_fkey" FOREIGN KEY ("department_id") REFERENCES "departments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reimbursement_items" ADD CONSTRAINT "reimbursement_items_reimbursement_id_fkey" FOREIGN KEY ("reimbursement_id") REFERENCES "reimbursement_forms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reimbursement_items" ADD CONSTRAINT "reimbursement_items_expense_type_id_fkey" FOREIGN KEY ("expense_type_id") REFERENCES "expense_types"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoice_pool" ADD CONSTRAINT "invoice_pool_owner_employee_id_fkey" FOREIGN KEY ("owner_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoice_pool" ADD CONSTRAINT "invoice_pool_uploaded_by_employee_id_fkey" FOREIGN KEY ("uploaded_by_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoice_pool" ADD CONSTRAINT "invoice_pool_overridden_by_fkey" FOREIGN KEY ("overridden_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoice_pool" ADD CONSTRAINT "invoice_pool_ocr_result_id_fkey" FOREIGN KEY ("ocr_result_id") REFERENCES "ocr_results"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoice_pool" ADD CONSTRAINT "invoice_pool_linked_reimbursement_id_fkey" FOREIGN KEY ("linked_reimbursement_id") REFERENCES "reimbursement_forms"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoice_pool" ADD CONSTRAINT "invoice_pool_linked_item_id_fkey" FOREIGN KEY ("linked_item_id") REFERENCES "reimbursement_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workflow_instances" ADD CONSTRAINT "workflow_instances_workflow_id_fkey" FOREIGN KEY ("workflow_id") REFERENCES "workflow_definitions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workflow_instances" ADD CONSTRAINT "workflow_instances_entity_id_fkey" FOREIGN KEY ("entity_id") REFERENCES "reimbursement_forms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workflow_instances" ADD CONSTRAINT "workflow_instances_submitter_id_fkey" FOREIGN KEY ("submitter_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workflow_instances" ADD CONSTRAINT "workflow_instances_context_department_id_fkey" FOREIGN KEY ("context_department_id") REFERENCES "departments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workflow_tasks" ADD CONSTRAINT "workflow_tasks_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "workflow_instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workflow_tasks" ADD CONSTRAINT "workflow_tasks_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workflow_tasks" ADD CONSTRAINT "workflow_tasks_completed_by_fkey" FOREIGN KEY ("completed_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workflow_history" ADD CONSTRAINT "workflow_history_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "workflow_instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workflow_history" ADD CONSTRAINT "workflow_history_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "workflow_tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workflow_history" ADD CONSTRAINT "workflow_history_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "approval_transfers" ADD CONSTRAINT "approval_transfers_reimbursement_id_fkey" FOREIGN KEY ("reimbursement_id") REFERENCES "reimbursement_forms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "approval_transfers" ADD CONSTRAINT "approval_transfers_from_approver_id_fkey" FOREIGN KEY ("from_approver_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "approval_transfers" ADD CONSTRAINT "approval_transfers_to_approver_id_fkey" FOREIGN KEY ("to_approver_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- ------------------------------------------------------------
-- Custom constraints & partial unique indexes (P0 requirements)
-- ------------------------------------------------------------
-- approval_transfers: 转审 reason≥10(P0)
ALTER TABLE "approval_transfers"
ADD CONSTRAINT "approval_transfers_reason_len_chk"
CHECK (char_length("reason") >= 10);
-- invoice_pool: 判重例外字段完整性(P0)
ALTER TABLE "invoice_pool"
ADD CONSTRAINT "invoice_pool_duplicate_override_chk"
CHECK (
"is_duplicate_override" = false
OR (
"override_ref_form_number" IS NOT NULL
AND "override_reason" IS NOT NULL
AND char_length("override_reason") >= 10
AND "overridden_by" IS NOT NULL
)
);
-- invoice_pool: linked_item_id 只能在存在 linked_reimbursement_id 时出现(P0)
ALTER TABLE "invoice_pool"
ADD CONSTRAINT "invoice_pool_linked_item_requires_reimbursement_chk"
CHECK ("linked_item_id" IS NULL OR "linked_reimbursement_id" IS NOT NULL);
-- invoice_pool: 状态机不变量(P0,见规格书 4.4.2/4.4.3)
ALTER TABLE "invoice_pool"
ADD CONSTRAINT "invoice_pool_status_invariants_chk"
CHECK (
(
"status" IN ('pending_ocr', 'ocr_failed', 'available')
AND "linked_reimbursement_id" IS NULL
AND "linked_item_id" IS NULL
AND "archived_reason" IS NULL
AND "archived_at" IS NULL
)
OR (
"status" = 'bound'
AND "linked_reimbursement_id" IS NOT NULL
AND "archived_reason" IS NULL
AND "archived_at" IS NULL
)
OR (
"status" = 'archived'
AND "archived_reason" IS NOT NULL
AND "archived_at" IS NOT NULL
AND (
(
"archived_reason" = 'used_complete'
AND "linked_reimbursement_id" IS NOT NULL
)
OR (
"archived_reason" IN ('unused_timeout', 'manual')
AND "linked_reimbursement_id" IS NULL
)
)
)
);
-- invoice_pool: 全局双策略判重(只对已使用历史生效;P0 定案)
CREATE UNIQUE INDEX "invoice_pool_file_sha256_dedup_ux"
ON "invoice_pool" ("file_sha256")
WHERE (
(
"status" = 'bound'
OR ("status" = 'archived' AND "archived_reason" = 'used_complete')
)
AND "is_duplicate_override" = false
);
CREATE UNIQUE INDEX "invoice_pool_invoice_code_number_dedup_ux"
ON "invoice_pool" ("invoice_code", "invoice_number")
WHERE (
"invoice_code" IS NOT NULL
AND "invoice_number" IS NOT NULL
AND (
"status" = 'bound'
OR ("status" = 'archived' AND "archived_reason" = 'used_complete')
)
AND "is_duplicate_override" = false
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "employees" ALTER COLUMN "direct_manager_id" DROP NOT NULL;
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "password_reset_codes" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"code_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_ip" TEXT,
"created_user_agent" TEXT,
CONSTRAINT "password_reset_codes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "password_reset_codes_employee_id_created_at_idx" ON "password_reset_codes"("employee_id", "created_at");
-- CreateIndex
CREATE INDEX "password_reset_codes_expires_at_idx" ON "password_reset_codes"("expires_at");
-- AddForeignKey
ALTER TABLE "password_reset_codes" ADD CONSTRAINT "password_reset_codes_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- Add composite index for workflow task inbox query (status + assignee filters)
CREATE INDEX "idx_workflow_tasks_status_assignee"
ON "workflow_tasks" ("status", "assignee_id", "assignee_role");
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+595
查看文件
@@ -0,0 +1,595 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum RoleCode {
employee
manager
ceo
finance
finance_mgr
administration
hr
admin
}
enum EmployeeStatus {
active
archived
}
enum DelegationGrantScope {
reimbursement_submit
all
}
enum ReimbursementStatus {
draft
reviewing
approved
paid
rejected
cancelled
}
enum InvoicePoolStatus {
pending_ocr
ocr_failed
available
bound
archived
}
enum InvoiceArchivedReason {
used_complete
unused_timeout
manual
}
enum OcrProvider {
tencent
}
enum OcrType {
vat
train
flight
taxi
general
}
enum DocumentResetPeriod {
none
daily
monthly
yearly
}
enum WorkflowInstanceStatus {
pending
approved
rejected
cancelled
terminated
}
enum WorkflowTaskStatus {
pending
completed
rejected
cancelled
skipped
}
enum WorkflowTaskOutcome {
approved
rejected
returned
start
transfer
skipped
}
enum ApprovalTransferStatus {
active
cancelled
}
enum ExpenseCategory {
travel
rd
production
office
business
assets
other
}
model Department {
id String @id @default(uuid()) @db.Uuid
name String
parent_id String? @db.Uuid
archived_at DateTime?
archived_by String? @db.Uuid
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
parent Department? @relation("DepartmentToParent", fields: [parent_id], references: [id], onDelete: Restrict)
children Department[] @relation("DepartmentToParent")
employees Employee[]
reimbursement_forms ReimbursementForm[]
workflow_instances WorkflowInstance[]
@@index([parent_id])
@@map("departments")
}
model Employee {
id String @id @default(uuid()) @db.Uuid
employee_no String @unique
name String
phone String @unique
email String @unique
department_id String @db.Uuid
title String
direct_manager_id String? @db.Uuid
hire_date DateTime @db.Date
status EmployeeStatus @default(active)
bank_account_encrypted String
bank_name String
id_number_encrypted String?
password_hash String
token_version Int @default(0)
archived_at DateTime?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
direct_manager Employee? @relation("EmployeeToManager", fields: [direct_manager_id], references: [id], onDelete: Restrict)
subordinates Employee[] @relation("EmployeeToManager")
roles EmployeeRole[]
delegation_grants_as_principal DelegationGrant[] @relation("DelegationPrincipal")
delegation_grants_as_delegate DelegationGrant[] @relation("DelegationDelegate")
auth_sessions AuthSession[]
audit_logs AuditLog[] @relation("AuditActor")
submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter")
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
owned_invoices InvoicePool[] @relation("InvoiceOwner")
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
overridden_invoices InvoicePool[] @relation("InvoiceOverrideBy")
workflow_instances_started WorkflowInstance[] @relation("WorkflowInstanceSubmitter")
workflow_history_actions WorkflowHistory[] @relation("WorkflowHistoryActor")
workflow_tasks_assigned WorkflowTask[] @relation("WorkflowTaskAssignee")
workflow_tasks_completed WorkflowTask[] @relation("WorkflowTaskCompleter")
approval_transfers_from ApprovalTransfer[] @relation("TransferFrom")
approval_transfers_to ApprovalTransfer[] @relation("TransferTo")
password_reset_codes PasswordResetCode[]
@@index([department_id])
@@index([direct_manager_id])
@@map("employees")
}
model PasswordResetCode {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
code_hash String
expires_at DateTime
used_at DateTime?
created_at DateTime @default(now())
created_ip String?
created_user_agent String?
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
@@index([employee_id, created_at])
@@index([expires_at])
@@map("password_reset_codes")
}
model EmployeeRole {
employee_id String @db.Uuid
role_code RoleCode
created_at DateTime @default(now())
created_by String? @db.Uuid
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
@@id([employee_id, role_code])
@@map("employee_roles")
}
model DelegationGrant {
id String @id @default(uuid()) @db.Uuid
principal_employee_id String @db.Uuid
delegate_employee_id String @db.Uuid
scope DelegationGrantScope
valid_from DateTime @default(now())
valid_to DateTime?
revoked_at DateTime?
created_at DateTime @default(now())
created_by String? @db.Uuid
principal_employee Employee @relation("DelegationPrincipal", fields: [principal_employee_id], references: [id], onDelete: Restrict)
delegate_employee Employee @relation("DelegationDelegate", fields: [delegate_employee_id], references: [id], onDelete: Restrict)
@@index([principal_employee_id])
@@index([delegate_employee_id])
@@map("delegation_grants")
}
model AuthSession {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
refresh_token_hash String
device_fingerprint String
ip_address String
user_agent String
created_at DateTime @default(now())
last_seen_at DateTime @default(now())
expires_at DateTime
revoked_at DateTime?
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
@@index([employee_id])
@@index([expires_at])
@@index([revoked_at])
@@map("auth_sessions")
}
model AuditLog {
id String @id @default(uuid()) @db.Uuid
request_id String
actor_id String @db.Uuid
action String
target_type String
target_id String? @db.Uuid
ip_address String?
user_agent String?
extra Json
created_at DateTime @default(now())
actor Employee @relation("AuditActor", fields: [actor_id], references: [id], onDelete: Restrict)
@@index([request_id])
@@index([actor_id, created_at])
@@map("audit_logs")
}
model ExpenseType {
id String @id @default(uuid()) @db.Uuid
code String @unique
name String
description String?
category ExpenseCategory
sort_order Int
account_code_default String?
is_system_default Boolean @default(false)
enabled Boolean @default(true)
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
reimbursement_items ReimbursementItem[]
@@index([enabled, sort_order])
@@map("expense_types")
}
model DocumentSequence {
document_type String @id
prefix String
date_format String
separator String
number_length Int @default(4)
reset_period DocumentResetPeriod
current_number Int @default(0)
last_reset_date DateTime?
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
@@map("document_sequences")
}
model ReimbursementForm {
id String @id @default(uuid()) @db.Uuid
form_number String? @unique
submitter_id String @db.Uuid
owner_id String @db.Uuid
department_id String @db.Uuid
reimbursement_date DateTime @db.Date
total_amount Decimal @default(0) @db.Decimal(14, 2)
status ReimbursementStatus @default(draft)
description String?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
submitter Employee @relation("ReimbursementSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict)
owner Employee @relation("ReimbursementOwner", fields: [owner_id], references: [id], onDelete: Restrict)
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
items ReimbursementItem[]
invoice_pool_items InvoicePool[]
workflow_instances WorkflowInstance[]
approval_transfers ApprovalTransfer[]
@@index([owner_id])
@@index([submitter_id])
@@index([department_id])
@@index([status])
@@index([reimbursement_date])
@@map("reimbursement_forms")
}
model ReimbursementItem {
id String @id @default(uuid()) @db.Uuid
reimbursement_id String @db.Uuid
expense_type_id String @db.Uuid
expense_date DateTime @db.Date
amount Decimal @db.Decimal(14, 2)
description String
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
reimbursement_form ReimbursementForm @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade)
expense_type ExpenseType @relation(fields: [expense_type_id], references: [id], onDelete: Restrict)
invoice_pool_items InvoicePool[]
@@index([reimbursement_id])
@@index([expense_type_id])
@@index([expense_date])
@@map("reimbursement_items")
}
model OcrResult {
id String @id @default(uuid()) @db.Uuid
file_sha256 String
ocr_provider OcrProvider @default(tencent)
ocr_type OcrType
result_json Json
confidence Int?
created_at DateTime @default(now())
invoice_pool_items InvoicePool[]
@@unique([file_sha256, ocr_provider, ocr_type])
@@index([file_sha256])
@@map("ocr_results")
}
model InvoicePool {
id String @id @default(uuid()) @db.Uuid
owner_employee_id String @db.Uuid
uploaded_by_employee_id String @db.Uuid
storage_path String
original_name String
mime_type String
file_size Int
file_sha256 String
status InvoicePoolStatus @default(pending_ocr)
archived_reason InvoiceArchivedReason?
archived_at DateTime?
linked_reimbursement_id String? @db.Uuid
linked_item_id String? @db.Uuid
remarks String?
ocr_provider OcrProvider @default(tencent)
ocr_type OcrType @default(general)
ocr_result_id String? @db.Uuid
ocr_attempts Int @default(0)
ocr_last_error String?
ocr_completed_at DateTime?
invoice_code String?
invoice_number String?
invoice_date DateTime? @db.Date
seller_name String?
buyer_name String?
amount Decimal? @db.Decimal(14, 2)
tax_amount Decimal? @db.Decimal(14, 2)
total_amount Decimal? @db.Decimal(14, 2)
is_duplicate_override Boolean @default(false)
override_ref_form_number String?
override_reason String?
overridden_by String? @db.Uuid
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
owner_employee Employee @relation("InvoiceOwner", fields: [owner_employee_id], references: [id], onDelete: Restrict)
uploaded_by_employee Employee @relation("InvoiceUploader", fields: [uploaded_by_employee_id], references: [id], onDelete: Restrict)
overridden_by_employee Employee? @relation("InvoiceOverrideBy", fields: [overridden_by], references: [id], onDelete: Restrict)
ocr_result OcrResult? @relation(fields: [ocr_result_id], references: [id], onDelete: SetNull)
linked_reimbursement ReimbursementForm? @relation(fields: [linked_reimbursement_id], references: [id], onDelete: SetNull)
linked_item ReimbursementItem? @relation(fields: [linked_item_id], references: [id], onDelete: SetNull)
@@index([owner_employee_id])
@@index([uploaded_by_employee_id])
@@index([file_sha256])
@@index([invoice_code, invoice_number])
@@index([status])
@@index([linked_reimbursement_id])
@@map("invoice_pool")
}
model WorkflowDefinition {
id String @id @default(uuid()) @db.Uuid
name String
entity_type String
definition_json Json
version Int
is_active Boolean @default(true)
created_at DateTime @default(now())
created_by String? @db.Uuid
workflow_instances WorkflowInstance[]
@@unique([entity_type, version])
@@index([entity_type, is_active])
@@map("workflow_definitions")
}
model WorkflowInstance {
id String @id @default(uuid()) @db.Uuid
workflow_id String @db.Uuid
entity_type String
entity_id String @db.Uuid
submitter_id String @db.Uuid
current_status WorkflowInstanceStatus @default(pending)
current_step_id String
context_data Json
started_at DateTime @default(now())
completed_at DateTime?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
workflow_definition WorkflowDefinition @relation(fields: [workflow_id], references: [id], onDelete: Restrict)
reimbursement_form ReimbursementForm @relation(fields: [entity_id], references: [id], onDelete: Cascade)
submitter Employee @relation("WorkflowInstanceSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict)
department Department? @relation(fields: [context_department_id], references: [id], onDelete: SetNull)
workflow_tasks WorkflowTask[]
workflow_history WorkflowHistory[]
/// Denormalized for fast filtering (must match context_data.department_id)
context_department_id String? @db.Uuid
@@index([workflow_id])
@@index([entity_type, entity_id])
@@index([current_status])
@@index([submitter_id])
@@index([context_department_id])
@@map("workflow_instances")
}
model WorkflowTask {
id String @id @default(uuid()) @db.Uuid
instance_id String @db.Uuid
step_id String
step_name String
assignee_id String? @db.Uuid
assignee_role RoleCode?
status WorkflowTaskStatus @default(pending)
outcome WorkflowTaskOutcome?
comment String?
due_date DateTime?
created_at DateTime @default(now())
completed_at DateTime?
completed_by String? @db.Uuid
workflow_instance WorkflowInstance @relation(fields: [instance_id], references: [id], onDelete: Cascade)
assignee_employee Employee? @relation("WorkflowTaskAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
completer_employee Employee? @relation("WorkflowTaskCompleter", fields: [completed_by], references: [id], onDelete: SetNull)
workflow_history WorkflowHistory[]
@@index([instance_id])
@@index([status])
@@index([assignee_id])
@@index([assignee_role])
@@index([status, assignee_id, assignee_role], map: "idx_workflow_tasks_status_assignee")
@@map("workflow_tasks")
}
model WorkflowHistory {
id String @id @default(uuid()) @db.Uuid
instance_id String @db.Uuid
task_id String? @db.Uuid
actor_id String @db.Uuid
action String
comment String?
payload Json?
created_at DateTime @default(now())
workflow_instance WorkflowInstance @relation(fields: [instance_id], references: [id], onDelete: Cascade)
workflow_task WorkflowTask? @relation(fields: [task_id], references: [id], onDelete: SetNull)
actor Employee @relation("WorkflowHistoryActor", fields: [actor_id], references: [id], onDelete: Restrict)
@@index([instance_id, created_at])
@@index([task_id])
@@index([actor_id])
@@map("workflow_history")
}
model ApprovalRule {
id String @id @default(uuid()) @db.Uuid
name String
min_amount Decimal @db.Decimal(14, 2)
max_amount Decimal? @db.Decimal(14, 2)
level1_role RoleCode
level2_role RoleCode?
level3_role RoleCode?
enabled Boolean @default(true)
priority Int
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
@@index([enabled, priority])
@@map("approval_rules")
}
model ApprovalTransfer {
id String @id @default(uuid()) @db.Uuid
reimbursement_id String @db.Uuid
from_approver_id String @db.Uuid
to_approver_id String @db.Uuid
reason String
status ApprovalTransferStatus @default(active)
created_at DateTime @default(now())
created_by String? @db.Uuid
reimbursement_form ReimbursementForm @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade)
from_approver Employee @relation("TransferFrom", fields: [from_approver_id], references: [id], onDelete: Restrict)
to_approver Employee @relation("TransferTo", fields: [to_approver_id], references: [id], onDelete: Restrict)
@@index([reimbursement_id])
@@index([from_approver_id])
@@index([to_approver_id])
@@map("approval_transfers")
}
+18
查看文件
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "=== [1/3] Smoke Test ==="
"$SCRIPT_DIR/smoke-test.sh"
echo ""
echo "=== [2/3] Dedup Regression ==="
"$SCRIPT_DIR/regression-dedup.sh"
echo ""
echo "=== [3/3] Workflow Actions Regression ==="
"$SCRIPT_DIR/regression-workflow-actions.sh"
echo ""
echo "[OK] All regressions passed"
+298
查看文件
@@ -0,0 +1,298 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-dedup}"
EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}"
EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}"
if ! command -v curl >/dev/null 2>&1; then
echo "[ERROR] curl not found" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "[ERROR] jq not found" >&2
exit 1
fi
if ! command -v node >/dev/null 2>&1; then
echo "[ERROR] node not found" >&2
exit 1
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
exit 1
fi
TMP_DIR="$(mktemp -d)"
TMP_INDEX_CREATED=0
RUN_SUFFIX="$(date +%s)-$RANDOM"
DEDUP_CODE="RCODE-$RUN_SUFFIX"
DEDUP_NUMBER="RNO-$RUN_SUFFIX"
cleanup() {
if [[ "$TMP_INDEX_CREATED" == "1" ]]; then
drop_tmp_non_dedup_index || true
fi
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
assert_status() {
local actual="$1"
local expected="$2"
local name="$3"
local body_file="${4:-}"
if [[ "$actual" != "$expected" ]]; then
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
if [[ -n "$body_file" && -f "$body_file" ]]; then
cat "$body_file" >&2
fi
exit 1
fi
}
api_json() {
local method="$1"
local url="$2"
local out_file="$3"
local body="${4:-}"
if [[ -n "$body" ]]; then
curl -sS -o "$out_file" -w "%{http_code}" \
-X "$method" "$url" \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d "$body"
else
curl -sS -o "$out_file" -w "%{http_code}" \
-X "$method" "$url" \
-H "authorization: Bearer $TOKEN"
fi
}
upload_file() {
local file_path="$1"
local out_file="$2"
curl -sS -o "$out_file" -w "%{http_code}" \
-X POST "$BASE_URL/api/invoice-pool/upload" \
-H "authorization: Bearer $TOKEN" \
-F "file=@$file_path"
}
create_reimbursement() {
local out_file="$1"
local status
status="$(api_json POST "$BASE_URL/api/reimbursements" "$out_file" '{}')"
assert_status "$status" "200" "create reimbursement" "$out_file"
jq -er '.data.id' "$out_file"
}
set_invoice_code_number() {
local invoice_id="$1"
local invoice_code="$2"
local invoice_number="$3"
DATABASE_URL="$DATABASE_URL" node - "$invoice_id" "$invoice_code" "$invoice_number" <<'NODE'
const invoiceId = process.argv[2];
const invoiceCode = process.argv[3];
const invoiceNumber = process.argv[4];
const { createPrismaClient } = require('./src/prismaClient');
(async () => {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
await prisma.invoicePool.update({
where: { id: invoiceId },
data: {
invoice_code: invoiceCode,
invoice_number: invoiceNumber,
},
});
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
create_tmp_non_dedup_index() {
DATABASE_URL="$DATABASE_URL" node <<'NODE'
const { createPrismaClient } = require('./src/prismaClient');
(async () => {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
await prisma.$executeRawUnsafe(
'CREATE UNIQUE INDEX IF NOT EXISTS tmp_unique_linked_reim ON "invoice_pool" ("linked_reimbursement_id") WHERE "linked_reimbursement_id" IS NOT NULL',
);
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
drop_tmp_non_dedup_index() {
DATABASE_URL="$DATABASE_URL" node <<'NODE'
const { createPrismaClient } = require('./src/prismaClient');
(async () => {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
await prisma.$executeRawUnsafe('DROP INDEX IF EXISTS "tmp_unique_linked_reim"');
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
assert_error_code() {
local file_path="$1"
local expected="$2"
local actual
actual="$(jq -r '.error.code // empty' "$file_path")"
if [[ "$actual" != "$expected" ]]; then
echo "[ERROR] expected error code $expected but got $actual" >&2
cat "$file_path" >&2
exit 1
fi
}
echo "[1/7] Login employee"
login_code="$(curl -sS -o "$TMP_DIR/login.json" -w "%{http_code}" \
-X POST "$BASE_URL/api/auth/login" \
-H "content-type: application/json" \
-H "x-device-fingerprint: $DEVICE_FINGERPRINT" \
-d "{\"identifier\":\"$EMPLOYEE_IDENTIFIER\",\"password\":\"$EMPLOYEE_PASSWORD\"}")"
assert_status "$login_code" "200" "login" "$TMP_DIR/login.json"
TOKEN="$(jq -er '.data.accessToken' "$TMP_DIR/login.json")"
echo "[2/7] Dedup by file_sha256 => VALID_003"
cat > "$TMP_DIR/same-file.pdf" <<PDF
%PDF-1.4
same-content-$RUN_SUFFIX
PDF
RID_A="$(create_reimbursement "$TMP_DIR/rA.json")"
status="$(upload_file "$TMP_DIR/same-file.pdf" "$TMP_DIR/uA.json")"
assert_status "$status" "200" "upload first same-file" "$TMP_DIR/uA.json"
IID_A="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uA.json")"
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_A/bind" "$TMP_DIR/bA.json" "{\"reimbursementId\":\"$RID_A\"}")"
assert_status "$status" "200" "bind first same-file" "$TMP_DIR/bA.json"
RID_B="$(create_reimbursement "$TMP_DIR/rB.json")"
status="$(upload_file "$TMP_DIR/same-file.pdf" "$TMP_DIR/uB.json")"
assert_status "$status" "200" "upload second same-file" "$TMP_DIR/uB.json"
IID_B="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uB.json")"
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_B/bind" "$TMP_DIR/bB.json" "{\"reimbursementId\":\"$RID_B\"}")"
assert_status "$status" "409" "file_sha256 dedup bind" "$TMP_DIR/bB.json"
assert_error_code "$TMP_DIR/bB.json" "VALID_003"
echo "[3/7] Dedup by invoice_code+invoice_number => VALID_003"
cat > "$TMP_DIR/code-1.pdf" <<PDF
%PDF-1.4
code-one-$RUN_SUFFIX
PDF
cat > "$TMP_DIR/code-2.pdf" <<PDF
%PDF-1.4
code-two-$RUN_SUFFIX
PDF
RID_C="$(create_reimbursement "$TMP_DIR/rC.json")"
status="$(upload_file "$TMP_DIR/code-1.pdf" "$TMP_DIR/uC.json")"
assert_status "$status" "200" "upload code-1" "$TMP_DIR/uC.json"
IID_C="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uC.json")"
set_invoice_code_number "$IID_C" "$DEDUP_CODE" "$DEDUP_NUMBER"
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_C/bind" "$TMP_DIR/bC.json" "{\"reimbursementId\":\"$RID_C\"}")"
assert_status "$status" "200" "bind code-1" "$TMP_DIR/bC.json"
RID_D="$(create_reimbursement "$TMP_DIR/rD.json")"
status="$(upload_file "$TMP_DIR/code-2.pdf" "$TMP_DIR/uD.json")"
assert_status "$status" "200" "upload code-2" "$TMP_DIR/uD.json"
IID_D="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uD.json")"
set_invoice_code_number "$IID_D" "$DEDUP_CODE" "$DEDUP_NUMBER"
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_D/bind" "$TMP_DIR/bD.json" "{\"reimbursementId\":\"$RID_D\"}")"
assert_status "$status" "409" "code+number dedup bind" "$TMP_DIR/bD.json"
assert_error_code "$TMP_DIR/bD.json" "VALID_003"
echo "[4/7] Non-dedup P2002 should not map to VALID_003"
create_tmp_non_dedup_index
TMP_INDEX_CREATED=1
cat > "$TMP_DIR/non-dedup-1.pdf" <<PDF
%PDF-1.4
non-dedup-1-$RUN_SUFFIX
PDF
cat > "$TMP_DIR/non-dedup-2.pdf" <<PDF
%PDF-1.4
non-dedup-2-$RUN_SUFFIX
PDF
RID_E="$(create_reimbursement "$TMP_DIR/rE.json")"
status="$(upload_file "$TMP_DIR/non-dedup-1.pdf" "$TMP_DIR/uE1.json")"
assert_status "$status" "200" "upload non-dedup-1" "$TMP_DIR/uE1.json"
IID_E1="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uE1.json")"
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_E1/bind" "$TMP_DIR/bE1.json" "{\"reimbursementId\":\"$RID_E\"}")"
assert_status "$status" "200" "bind non-dedup-1" "$TMP_DIR/bE1.json"
status="$(upload_file "$TMP_DIR/non-dedup-2.pdf" "$TMP_DIR/uE2.json")"
assert_status "$status" "200" "upload non-dedup-2" "$TMP_DIR/uE2.json"
IID_E2="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uE2.json")"
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_E2/bind" "$TMP_DIR/bE2.json" "{\"reimbursementId\":\"$RID_E\"}")"
if [[ "$status" == "409" ]] && [[ "$(jq -r '.error.code // empty' "$TMP_DIR/bE2.json")" == "VALID_003" ]]; then
echo "[ERROR] non-dedup P2002 was incorrectly mapped to VALID_003" >&2
cat "$TMP_DIR/bE2.json" >&2
exit 1
fi
echo "[5/7] Validate non-dedup result is server-side failure path"
if [[ "$status" != "500" ]]; then
echo "[ERROR] expected HTTP 500 for temporary non-dedup unique index violation, got $status" >&2
cat "$TMP_DIR/bE2.json" >&2
exit 1
fi
echo "[6/7] Ensure non-dedup response is not VALID_003"
unexpected_code="$(jq -r '.error.code // empty' "$TMP_DIR/bE2.json")"
if [[ "$unexpected_code" == "VALID_003" ]]; then
echo "[ERROR] unexpected VALID_003 for non-dedup conflict" >&2
cat "$TMP_DIR/bE2.json" >&2
exit 1
fi
drop_tmp_non_dedup_index
TMP_INDEX_CREATED=0
echo "[7/7] Done"
echo "[OK] Dedup regression passed"
文件差异内容过多而无法显示 加载差异
+208
查看文件
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-smoke-script-device}"
EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}"
EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}"
MANAGER_IDENTIFIER="${MANAGER_IDENTIFIER:-manager@hyinda.com}"
MANAGER_PASSWORD="${MANAGER_PASSWORD:-ManagerPassword1234}"
FINANCE_IDENTIFIER="${FINANCE_IDENTIFIER:-finance@hyinda.com}"
FINANCE_PASSWORD="${FINANCE_PASSWORD:-FinancePassword1234}"
SAMPLE_FILE="${SAMPLE_FILE:-/tmp/hyb-oa-sample-invoice.pdf}"
if ! command -v curl >/dev/null 2>&1; then
echo "[ERROR] curl not found" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "[ERROR] jq not found" >&2
exit 1
fi
if [[ ! -f "$SAMPLE_FILE" ]]; then
echo "[ERROR] Sample file not found: $SAMPLE_FILE" >&2
exit 1
fi
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
RUN_UPLOAD_FILE="$TMP_DIR/upload-$(basename "$SAMPLE_FILE")"
cp "$SAMPLE_FILE" "$RUN_UPLOAD_FILE"
printf '\n%%RUN-%s\n' "$(date +%s%N)" >> "$RUN_UPLOAD_FILE"
extract_json_field() {
local file_path="$1"
local jq_expr="$2"
jq -er "$jq_expr" "$file_path"
}
assert_status() {
local actual="$1"
local expected="$2"
local name="$3"
if [[ "$actual" != "$expected" ]]; then
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
exit 1
fi
}
assert_error_code() {
local file_path="$1"
local expected_code="$2"
local name="$3"
local actual_code
actual_code="$(jq -r '.error.code // empty' "$file_path")"
if [[ "$actual_code" != "$expected_code" ]]; then
echo "[ERROR] $name expected error code $expected_code but got $actual_code" >&2
cat "$file_path" >&2
exit 1
fi
}
echo "[1/13] Health check"
health_body="$TMP_DIR/health.json"
health_status="$(curl -sS -o "$health_body" -w "%{http_code}" "$BASE_URL/health")"
assert_status "$health_status" "200" "health"
echo "[2/13] Negative: no token should be 401"
no_token_body="$TMP_DIR/no-token.json"
no_token_status="$(curl -sS -o "$no_token_body" -w "%{http_code}" "$BASE_URL/api/reimbursements/test-id")"
assert_status "$no_token_status" "401" "no token"
echo "[3/13] Login employee"
employee_login_body="$TMP_DIR/employee-login.json"
employee_login_status="$(curl -sS -o "$employee_login_body" -w "%{http_code}" \
-X POST "$BASE_URL/api/auth/login" \
-H "content-type: application/json" \
-H "x-device-fingerprint: $DEVICE_FINGERPRINT" \
-d "{\"identifier\":\"$EMPLOYEE_IDENTIFIER\",\"password\":\"$EMPLOYEE_PASSWORD\"}")"
assert_status "$employee_login_status" "200" "employee login"
EMPLOYEE_TOKEN="$(extract_json_field "$employee_login_body" '.data.accessToken')"
echo "[4/13] Create reimbursement draft"
create_body="$TMP_DIR/create.json"
create_status="$(curl -sS -o "$create_body" -w "%{http_code}" \
-X POST "$BASE_URL/api/reimbursements" \
-H "authorization: Bearer $EMPLOYEE_TOKEN" \
-H "content-type: application/json" \
-d '{}')"
assert_status "$create_status" "200" "create reimbursement"
REIMBURSEMENT_ID="$(extract_json_field "$create_body" '.data.id')"
echo "[5/13] Upload invoice"
upload_body="$TMP_DIR/upload.json"
upload_status="$(curl -sS -o "$upload_body" -w "%{http_code}" \
-X POST "$BASE_URL/api/invoice-pool/upload" \
-H "authorization: Bearer $EMPLOYEE_TOKEN" \
-F "file=@$RUN_UPLOAD_FILE")"
assert_status "$upload_status" "200" "upload invoice"
INVOICE_ID="$(extract_json_field "$upload_body" '.data.invoicePoolId')"
echo "[6/13] Bind invoice"
bind_body="$TMP_DIR/bind.json"
bind_status="$(curl -sS -o "$bind_body" -w "%{http_code}" \
-X POST "$BASE_URL/api/invoice-pool/$INVOICE_ID/bind" \
-H "authorization: Bearer $EMPLOYEE_TOKEN" \
-H "content-type: application/json" \
-d "{\"reimbursementId\":\"$REIMBURSEMENT_ID\"}")"
assert_status "$bind_status" "200" "bind invoice"
echo "[7/13] Negative: bind same invoice again should be VALID_005"
rebind_body="$TMP_DIR/rebind.json"
rebind_status="$(curl -sS -o "$rebind_body" -w "%{http_code}" \
-X POST "$BASE_URL/api/invoice-pool/$INVOICE_ID/bind" \
-H "authorization: Bearer $EMPLOYEE_TOKEN" \
-H "content-type: application/json" \
-d "{\"reimbursementId\":\"$REIMBURSEMENT_ID\"}")"
assert_status "$rebind_status" "409" "rebind conflict"
assert_error_code "$rebind_body" "VALID_005" "rebind conflict"
echo "[8/13] Submit reimbursement"
submit_body="$TMP_DIR/submit.json"
submit_status="$(curl -sS -o "$submit_body" -w "%{http_code}" \
-X POST "$BASE_URL/api/reimbursements/$REIMBURSEMENT_ID/submit" \
-H "authorization: Bearer $EMPLOYEE_TOKEN" \
-H "content-type: application/json" \
-d '{}')"
assert_status "$submit_status" "200" "submit reimbursement"
echo "[9/13] Login manager"
manager_login_body="$TMP_DIR/manager-login.json"
manager_login_status="$(curl -sS -o "$manager_login_body" -w "%{http_code}" \
-X POST "$BASE_URL/api/auth/login" \
-H "content-type: application/json" \
-H "x-device-fingerprint: ${DEVICE_FINGERPRINT}-manager" \
-d "{\"identifier\":\"$MANAGER_IDENTIFIER\",\"password\":\"$MANAGER_PASSWORD\"}")"
assert_status "$manager_login_status" "200" "manager login"
MANAGER_TOKEN="$(extract_json_field "$manager_login_body" '.data.accessToken')"
echo "[10/13] Login finance (approve permission, non-assignee)"
finance_login_body="$TMP_DIR/finance-login.json"
finance_login_status="$(curl -sS -o "$finance_login_body" -w "%{http_code}" \
-X POST "$BASE_URL/api/auth/login" \
-H "content-type: application/json" \
-H "x-device-fingerprint: ${DEVICE_FINGERPRINT}-finance" \
-d "{\"identifier\":\"$FINANCE_IDENTIFIER\",\"password\":\"$FINANCE_PASSWORD\"}")"
assert_status "$finance_login_status" "200" "finance login"
FINANCE_TOKEN="$(extract_json_field "$finance_login_body" '.data.accessToken')"
echo "[11/13] Query pending tasks as manager"
tasks_body="$TMP_DIR/tasks.json"
tasks_status="$(curl -sS -o "$tasks_body" -w "%{http_code}" \
-X GET "$BASE_URL/api/workflow/tasks?status=pending" \
-H "authorization: Bearer $MANAGER_TOKEN")"
assert_status "$tasks_status" "200" "query workflow tasks"
TASK_ID="$(jq -er --arg rid "$REIMBURSEMENT_ID" '.data.items[] | select(.workflow_instance.entity_id == $rid) | .id' "$tasks_body" | head -n 1)"
if [[ -z "$TASK_ID" ]]; then
echo "[ERROR] No pending task found for reimbursement $REIMBURSEMENT_ID" >&2
cat "$tasks_body" >&2
exit 1
fi
echo "[12/13] Negative: finance cannot approve manager task (PERM_002)"
finance_approve_body="$TMP_DIR/finance-approve.json"
finance_approve_status="$(curl -sS -o "$finance_approve_body" -w "%{http_code}" \
-X POST "$BASE_URL/api/workflow/tasks/$TASK_ID/approve" \
-H "authorization: Bearer $FINANCE_TOKEN" \
-H "content-type: application/json" \
-d '{}')"
assert_status "$finance_approve_status" "403" "finance approve forbidden"
assert_error_code "$finance_approve_body" "PERM_002" "finance approve forbidden"
echo "[13/13] Approve by manager and fetch signed URL"
manager_approve_body="$TMP_DIR/manager-approve.json"
manager_approve_status="$(curl -sS -o "$manager_approve_body" -w "%{http_code}" \
-X POST "$BASE_URL/api/workflow/tasks/$TASK_ID/approve" \
-H "authorization: Bearer $MANAGER_TOKEN" \
-H "content-type: application/json" \
-d '{}')"
assert_status "$manager_approve_status" "200" "manager approve"
signed_body="$TMP_DIR/signed-url.json"
signed_status="$(curl -sS -o "$signed_body" -w "%{http_code}" \
-X POST "$BASE_URL/api/invoice-pool/$INVOICE_ID/signed-url" \
-H "authorization: Bearer $EMPLOYEE_TOKEN" \
-H "content-type: application/json" \
-d '{}')"
assert_status "$signed_status" "200" "signed url"
SIGNED_URL="$(extract_json_field "$signed_body" '.data.signedUrl')"
signed_download="$TMP_DIR/signed-download.bin"
download_status="$(curl -sS -L -o "$signed_download" -w "%{http_code}" "$SIGNED_URL")"
assert_status "$download_status" "200" "signed url download"
if [[ ! -s "$signed_download" ]]; then
echo "[ERROR] Signed URL download is empty" >&2
exit 1
fi
echo "[OK] Smoke test passed"
echo "- reimbursementId: $REIMBURSEMENT_ID"
echo "- invoiceId: $INVOICE_ID"
echo "- taskId: $TASK_ID"
+121
查看文件
@@ -0,0 +1,121 @@
const crypto = require("node:crypto");
const fs = require("node:fs/promises");
require("dotenv/config");
const fastify = require("fastify");
const cors = require("@fastify/cors");
const cookie = require("@fastify/cookie");
const multipart = require("@fastify/multipart");
const { getConfig } = require("./config");
const authPlugin = require("./plugins/auth");
const prismaPlugin = require("./plugins/prisma");
const authRoutes = require("./routes/auth");
const meRoutes = require("./routes/me");
const reimbursementRoutes = require("./routes/reimbursements");
const invoicePoolRoutes = require("./routes/invoicePool");
const workflowRoutes = require("./routes/workflow");
const { AppError } = require("./utils/AppError");
async function buildApp() {
const config = getConfig();
const app = fastify({
logger: true,
trustProxy: true,
genReqId: () => crypto.randomUUID(),
});
app.decorate("config", config);
await fs.mkdir(config.storageDir, { recursive: true });
await app.register(cookie);
await app.register(multipart, {
attachFieldsToBody: false,
limits: {
fileSize: config.maxUploadSizeBytes,
files: 1,
},
});
const allowedOrigins = config.appOrigin
? config.appOrigin
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
await app.register(cors, {
credentials: true,
origin: (origin, cb) => {
if (!origin) return cb(null, true);
if (!allowedOrigins) return cb(null, true);
if (allowedOrigins.includes(origin)) return cb(null, true);
return cb(new Error("CORS origin not allowed"), false);
},
});
app.addHook("onRequest", async (request, reply) => {
reply.header("x-request-id", request.id);
});
app.setNotFoundHandler(async () => {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
});
app.setErrorHandler((error, request, reply) => {
if (error.code === "FST_REQ_FILE_TOO_LARGE") {
return reply.status(400).send({
requestId: request.id,
error: {
code: "VALID_001",
message: "参数校验失败",
details: { file: "file_too_large" },
},
});
}
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: {
code: error.code,
message: error.message,
details: error.details ?? {},
},
});
}
app.log.error({ err: error, requestId: request.id }, "Unhandled error");
return reply.status(500).send({
requestId: request.id,
error: {
code: "SYS_001",
message: "服务器内部错误",
details: {},
},
});
});
await app.register(prismaPlugin);
await app.register(authPlugin);
app.get("/health", async (request, reply) => {
return reply.send({ requestId: request.id, data: { ok: true } });
});
await app.register(authRoutes, { prefix: "/api/auth" });
await app.register(meRoutes, { prefix: "/api" });
await app.register(reimbursementRoutes, { prefix: "/api" });
await app.register(invoicePoolRoutes, { prefix: "/api" });
await app.register(workflowRoutes, { prefix: "/api" });
return app;
}
module.exports = { buildApp };
+48
查看文件
@@ -0,0 +1,48 @@
const path = require("node:path");
const ONE_DAY_SECONDS = 24 * 60 * 60;
function requiredEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env: ${name}`);
}
return value;
}
function getConfig() {
const nodeEnv = process.env.NODE_ENV ?? "development";
const serverRoot = path.resolve(__dirname, "..");
const storageDir = process.env.STORAGE_DIR ?? "generated/storage";
return {
nodeEnv,
port: Number(process.env.PORT ?? 3000),
host: process.env.HOST ?? "0.0.0.0",
appOrigin: process.env.APP_ORIGIN ?? "",
jwtSecret: requiredEnv("JWT_SECRET"),
accessTokenTtl: "15m",
refreshTtlSeconds: 7 * ONE_DAY_SECONDS,
refreshCookieName: "refresh_token",
cookieSecure:
process.env.COOKIE_SECURE != null
? process.env.COOKIE_SECURE === "true"
: nodeEnv === "production",
passwordResetCodeTtlSeconds: Number(
process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * 60,
),
devShowPasswordResetCode: process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true",
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
storageDir: path.resolve(serverRoot, storageDir),
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
};
}
module.exports = { getConfig };
+62
查看文件
@@ -0,0 +1,62 @@
const EMPLOYEE_BASE_PERMISSIONS = [
"expense.create",
"expense.view_own",
"expense.edit_own",
"expense.delete_own",
"expense.submit",
"expense.withdraw",
"invoice.upload",
"invoice.view_own",
"expense.proxy_manage",
];
const ROLE_PERMISSIONS = {
employee: EMPLOYEE_BASE_PERMISSIONS,
manager: [...EMPLOYEE_BASE_PERMISSIONS, "expense.approve", "expense.transfer", "expense.view_all"],
ceo: [...EMPLOYEE_BASE_PERMISSIONS, "expense.approve", "expense.transfer", "expense.view_all"],
finance: [
...EMPLOYEE_BASE_PERMISSIONS,
"invoice.manage",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
],
finance_mgr: [
...EMPLOYEE_BASE_PERMISSIONS,
"invoice.manage",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
"expense.finance.override_duplicate",
"system.settings",
],
administration: ["expense.proxy_submit", "expense.view_own"],
hr: ["system.users", "expense.view_all"],
admin: ["*"],
};
function getRolePermissions(roleCode) {
return ROLE_PERMISSIONS[roleCode] ?? [];
}
function resolvePermissionsForRoles(roleCodes) {
const permissions = new Set();
for (const roleCode of roleCodes ?? []) {
for (const permission of getRolePermissions(roleCode)) {
permissions.add(permission);
}
}
return Array.from(permissions);
}
function hasPermission(permissionCodes, permission) {
return permissionCodes.includes("*") || permissionCodes.includes(permission);
}
module.exports = {
ROLE_PERMISSIONS,
resolvePermissionsForRoles,
hasPermission,
};
+15
查看文件
@@ -0,0 +1,15 @@
const { buildApp } = require("./app");
async function main() {
const app = await buildApp();
const { host, port } = app.config;
await app.listen({ host, port });
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});
+105
查看文件
@@ -0,0 +1,105 @@
const fastifyJwt = require("@fastify/jwt");
const fp = require("fastify-plugin");
const { AppError } = require("../utils/AppError");
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
async function authPlugin(fastify) {
const { jwtSecret, accessTokenTtl } = fastify.config;
await fastify.register(fastifyJwt, {
secret: jwtSecret,
});
fastify.decorateRequest("employee", null);
fastify.decorate("issueAccessToken", (employee) => {
return fastify.jwt.sign(
{
sub: employee.id,
tokenVersion: employee.token_version,
},
{ expiresIn: accessTokenTtl },
);
});
fastify.decorate("hasPermission", (employee, permissionCode) => {
return hasPermission(employee?.permissionCodes ?? [], permissionCode);
});
fastify.decorate("requirePermission", (permissionCode) => {
return async (request) => {
await fastify.authenticate(request);
if (!fastify.hasPermission(request.employee, permissionCode)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { permission: permissionCode },
});
}
};
});
fastify.decorate("authenticate", async (request) => {
try {
await request.jwtVerify();
} catch {
throw new AppError({
code: "AUTH_004",
message: "Access Token 已过期/无效",
statusCode: 401,
});
}
const employeeId = request.user?.sub;
const tokenVersion = request.user?.tokenVersion;
if (typeof employeeId !== "string" || typeof tokenVersion !== "number") {
throw new AppError({
code: "AUTH_004",
message: "Access Token 已过期/无效",
statusCode: 401,
});
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
status: true,
token_version: true,
department_id: true,
roles: { select: { role_code: true } },
},
});
if (!employee || employee.status !== "active") {
throw new AppError({
code: "AUTH_003",
message: "账号已离职/禁用",
statusCode: 403,
});
}
if (tokenVersion < employee.token_version) {
throw new AppError({
code: "AUTH_004",
message: "Access Token 已过期/无效",
statusCode: 401,
});
}
const roleCodes = employee.roles.map((item) => item.role_code);
const permissionCodes = resolvePermissionsForRoles(roleCodes);
request.employee = {
id: employee.id,
status: employee.status,
token_version: employee.token_version,
department_id: employee.department_id,
roleCodes,
permissionCodes,
};
});
}
module.exports = fp(authPlugin, { name: "auth" });
+16
查看文件
@@ -0,0 +1,16 @@
const { createPrismaClient } = require("../prismaClient");
const fp = require("fastify-plugin");
async function prismaPlugin(fastify) {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
fastify.decorate("prisma", prisma);
fastify.addHook("onClose", async (instance) => {
await instance.prisma.$disconnect();
await pool.end();
});
}
module.exports = fp(prismaPlugin, { name: "prisma" });
+19
查看文件
@@ -0,0 +1,19 @@
const { PrismaClient } = require("@prisma/client");
const { PrismaPg } = require("@prisma/adapter-pg");
const { Pool } = require("pg");
function createPrismaClient() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("Missing DATABASE_URL");
}
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool, { disposeExternalPool: false });
const prisma = new PrismaClient({ adapter });
return { prisma, pool };
}
module.exports = { createPrismaClient };
+528
查看文件
@@ -0,0 +1,528 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { hashPassword, randomRefreshToken, sha256Hex, verifyPassword } = require("../utils/crypto");
const { validatePassword } = require("../utils/passwordPolicy");
const { sendData } = require("../utils/reply");
function getHeaderString(request, name) {
const value = request.headers[name];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function getRequestOrigin(request) {
const origin = getHeaderString(request, "origin");
if (origin) return origin;
const referer = getHeaderString(request, "referer");
if (!referer) return null;
try {
return new URL(referer).origin;
} catch {
return null;
}
}
function refreshCookieOptions(config) {
return {
httpOnly: true,
sameSite: "strict",
secure: config.cookieSecure,
path: "/api/auth",
maxAge: config.refreshTtlSeconds,
};
}
async function authRoutes(fastify) {
const loginBodySchema = z.object({
identifier: z.string().min(3),
password: z.string().min(1),
});
fastify.post("/login", async (request, reply) => {
const parsed = loginBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
}
const { identifier, password } = parsed.data;
const employee = await fastify.prisma.employee.findFirst({
where: { OR: [{ phone: identifier }, { email: identifier }] },
select: {
id: true,
status: true,
password_hash: true,
token_version: true,
},
});
if (!employee) {
throw new AppError({
code: "AUTH_001",
message: "用户名或密码错误",
statusCode: 401,
});
}
if (employee.status !== "active") {
throw new AppError({
code: "AUTH_003",
message: "账号已离职/禁用",
statusCode: 403,
});
}
const ok = await verifyPassword(password, employee.password_hash);
if (!ok) {
throw new AppError({
code: "AUTH_001",
message: "用户名或密码错误",
statusCode: 401,
});
}
const refreshToken = randomRefreshToken();
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
await fastify.prisma.$transaction(async (tx) => {
const activeSessions = await tx.authSession.findMany({
where: {
employee_id: employee.id,
revoked_at: null,
expires_at: { gt: now },
},
orderBy: { created_at: "asc" },
select: { id: true },
});
const maxSessions = 5;
const allowedExisting = maxSessions - 1;
if (activeSessions.length > allowedExisting) {
const revokeCount = activeSessions.length - allowedExisting;
const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id);
await tx.authSession.updateMany({
where: { id: { in: idsToRevoke } },
data: { revoked_at: now },
});
}
await tx.authSession.create({
data: {
employee_id: employee.id,
refresh_token_hash: refreshTokenHash,
device_fingerprint: deviceFingerprint,
ip_address: request.ip,
user_agent: getHeaderString(request, "user-agent") ?? "",
expires_at: expiresAt,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_LOGIN_SUCCESS",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
});
reply.setCookie(
fastify.config.refreshCookieName,
refreshToken,
refreshCookieOptions(fastify.config),
);
const accessToken = fastify.issueAccessToken(employee);
return sendData(reply, request, { accessToken });
});
fastify.post("/refresh", async (request, reply) => {
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
if (typeof refreshToken !== "string" || !refreshToken) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const deviceFingerprint = getHeaderString(request, "x-device-fingerprint");
if (!deviceFingerprint) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { "x-device-fingerprint": "missing" },
});
}
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const session = await fastify.prisma.authSession.findFirst({
where: { refresh_token_hash: refreshTokenHash },
select: {
id: true,
employee_id: true,
device_fingerprint: true,
expires_at: true,
revoked_at: true,
},
});
if (!session) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
if (session.device_fingerprint !== deviceFingerprint) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const expectedOrigin = fastify.config.appOrigin;
const requestOrigin = getRequestOrigin(request);
if (expectedOrigin && requestOrigin && requestOrigin !== expectedOrigin) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: session.employee_id,
action: "AUTH_REFRESH_ORIGIN_MISMATCH",
targetType: "employee",
targetId: session.employee_id,
extra: { expectedOrigin, requestOrigin },
}),
});
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
if (session.expires_at <= now) {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
if (session.revoked_at) {
const rotatedReplacement = await fastify.prisma.authSession.findFirst({
where: {
employee_id: session.employee_id,
device_fingerprint: session.device_fingerprint,
revoked_at: null,
expires_at: { gt: now },
created_at: { gt: session.revoked_at },
},
select: { id: true },
});
if (rotatedReplacement) {
await fastify.prisma.$transaction(async (tx) => {
await tx.authSession.updateMany({
where: { employee_id: session.employee_id, revoked_at: null },
data: { revoked_at: now },
});
await tx.employee.update({
where: { id: session.employee_id },
data: { token_version: { increment: 1 } },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: session.employee_id,
action: "REFRESH_REUSE_DETECTED",
targetType: "employee",
targetId: session.employee_id,
extra: {},
}),
});
});
}
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: session.employee_id },
select: { id: true, status: true, token_version: true },
});
if (!employee || employee.status !== "active") {
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
const newRefreshToken = randomRefreshToken();
const newRefreshTokenHash = sha256Hex(newRefreshToken);
const newExpiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
await fastify.prisma.$transaction(async (tx) => {
const updated = await tx.authSession.updateMany({
where: {
id: session.id,
revoked_at: null,
expires_at: { gt: now },
},
data: { revoked_at: now, last_seen_at: now },
});
if (updated.count !== 1) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "REFRESH_REUSE_DETECTED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
throw new AppError({
code: "AUTH_005",
message: "Refresh Token 无效/已撤销",
statusCode: 401,
});
}
await tx.authSession.create({
data: {
employee_id: employee.id,
refresh_token_hash: newRefreshTokenHash,
device_fingerprint: deviceFingerprint,
ip_address: request.ip,
user_agent: getHeaderString(request, "user-agent") ?? "",
expires_at: newExpiresAt,
},
});
});
reply.setCookie(
fastify.config.refreshCookieName,
newRefreshToken,
refreshCookieOptions(fastify.config),
);
const accessToken = fastify.issueAccessToken(employee);
return sendData(reply, request, { accessToken });
});
fastify.post("/logout", async (request, reply) => {
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
if (typeof refreshToken === "string" && refreshToken) {
const refreshTokenHash = sha256Hex(refreshToken);
const now = new Date();
const session = await fastify.prisma.authSession.findFirst({
where: { refresh_token_hash: refreshTokenHash },
select: { id: true, employee_id: true, revoked_at: true },
});
await fastify.prisma.authSession.updateMany({
where: { refresh_token_hash: refreshTokenHash, revoked_at: null },
data: { revoked_at: now, last_seen_at: now },
});
if (session?.employee_id) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: session.employee_id,
action: "AUTH_LOGOUT",
targetType: "employee",
targetId: session.employee_id,
extra: {},
}),
});
}
}
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
return sendData(reply, request, {});
});
const forgotPasswordBodySchema = z.object({
email: z.string().email(),
});
fastify.post("/forgot-password", async (request, reply) => {
const parsed = forgotPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const { email } = parsed.data;
const employee = await fastify.prisma.employee.findFirst({
where: { email },
select: { id: true, status: true },
});
if (!employee || employee.status !== "active") {
return sendData(reply, request, {});
}
const code = String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0");
const codeHash = sha256Hex(code);
const now = new Date();
const expiresAt = new Date(now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000);
await fastify.prisma.passwordResetCode.create({
data: {
employee_id: employee.id,
code_hash: codeHash,
expires_at: expiresAt,
created_ip: request.ip,
created_user_agent: getHeaderString(request, "user-agent"),
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_PASSWORD_RESET_REQUESTED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
if (fastify.config.devShowPasswordResetCode) {
fastify.log.info(
{ employeeId: employee.id, email, code, requestId: request.id },
"DEV password reset code",
);
}
return sendData(reply, request, {});
});
const resetPasswordBodySchema = z.object({
email: z.string().email(),
code: z.string().length(6),
newPassword: z.string().min(12),
});
fastify.post("/reset-password", async (request, reply) => {
const parsed = resetPasswordBodySchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const { email, code, newPassword } = parsed.data;
const policyErrors = validatePassword(newPassword);
if (policyErrors.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { password: policyErrors },
});
}
const employee = await fastify.prisma.employee.findFirst({
where: { email },
select: { id: true, status: true },
});
if (!employee || employee.status !== "active") {
return sendData(reply, request, {});
}
const now = new Date();
const codeHash = sha256Hex(code);
const resetCode = await fastify.prisma.passwordResetCode.findFirst({
where: {
employee_id: employee.id,
code_hash: codeHash,
used_at: null,
expires_at: { gt: now },
},
orderBy: { created_at: "desc" },
select: { id: true },
});
if (!resetCode) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { code: "invalid_or_expired" },
});
}
const newPasswordHash = await hashPassword(newPassword);
await fastify.prisma.$transaction(async (tx) => {
await tx.passwordResetCode.update({
where: { id: resetCode.id },
data: { used_at: now },
});
await tx.employee.update({
where: { id: employee.id },
data: {
password_hash: newPasswordHash,
token_version: { increment: 1 },
},
});
await tx.authSession.updateMany({
where: { employee_id: employee.id, revoked_at: null },
data: { revoked_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_PASSWORD_RESET_SUCCEEDED",
targetType: "employee",
targetId: employee.id,
extra: {},
}),
});
});
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
return sendData(reply, request, {});
});
}
module.exports = authRoutes;
+645
查看文件
@@ -0,0 +1,645 @@
const crypto = require("node:crypto");
const fs = require("node:fs/promises");
const fsSync = require("node:fs");
const path = require("node:path");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { compactDateString } = require("../utils/date");
const { sha256Hex } = require("../utils/crypto");
const { signPayload, verifyToken } = require("../utils/signedUrl");
const { toMoneyString } = require("../utils/workflow");
const MIME_ALLOWLIST = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
]);
const OCR_TYPE_ALLOWLIST = new Set(["vat", "train", "flight", "taxi", "general"]);
const bindBodySchema = z.object({
reimbursementId: z.string().uuid(),
linkedItemId: z.string().uuid().optional(),
});
const DEDUP_CONSTRAINT_NAMES = new Set([
"invoice_pool_file_sha256_dedup_ux",
"invoice_pool_invoice_code_number_dedup_ux",
]);
const DEDUP_FIELD_NAMES = new Set(["file_sha256", "invoice_code", "invoice_number"]);
function tokenizeMetaString(value) {
if (typeof value !== "string") {
return [];
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return [];
}
const splitTokens = normalized
.split(/[^a-z0-9_]+/i)
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
return [normalized, ...splitTokens];
}
function normalizeConstraintMeta(value) {
if (Array.isArray(value)) {
return value
.flatMap((item) => tokenizeMetaString(String(item)))
.filter(Boolean);
}
if (typeof value === "string") {
return tokenizeMetaString(value);
}
return [];
}
function collectFieldTokens(meta) {
return new Set(
[
...normalizeConstraintMeta(meta.target),
...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.constraint?.fields),
].filter((token) => DEDUP_FIELD_NAMES.has(token)),
);
}
function containsDedupConstraintName(metaTokens) {
const constraints = Array.from(DEDUP_CONSTRAINT_NAMES);
return metaTokens.some((token) => constraints.some((constraint) => token.includes(constraint)));
}
function isFieldBasedDedup(fieldTokens) {
if (fieldTokens.size === 1 && fieldTokens.has("file_sha256")) {
return true;
}
if (
fieldTokens.size === 2 &&
fieldTokens.has("invoice_code") &&
fieldTokens.has("invoice_number")
) {
return true;
}
return false;
}
function isInvoiceDedupConflict(error) {
if (!error || error.code !== "P2002") {
return false;
}
const meta = error.meta ?? {};
const metaTokens = [
...normalizeConstraintMeta(meta.constraint),
...normalizeConstraintMeta(meta.target),
...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.originalMessage),
];
if (containsDedupConstraintName(metaTokens)) {
return true;
}
const fieldTokens = collectFieldTokens(meta);
return isFieldBasedDedup(fieldTokens);
}
function ensureInvoiceReadableByUser(request, invoice) {
const roleCodes = request.employee.roleCodes;
if (
roleCodes.includes("admin") ||
roleCodes.includes("finance") ||
roleCodes.includes("finance_mgr")
) {
return true;
}
if (
invoice.owner_employee_id === request.employee.id ||
invoice.uploaded_by_employee_id === request.employee.id
) {
return true;
}
if (invoice.linked_reimbursement) {
const reimbursement = invoice.linked_reimbursement;
if (
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id
) {
return true;
}
}
return false;
}
function sanitizeFilename(fileName) {
if (!fileName) return "file";
return fileName.replace(/[\r\n"]/g, "_");
}
async function invoicePoolRoutes(fastify) {
fastify.post(
"/invoice-pool/upload",
{
preHandler: fastify.requirePermission("invoice.upload"),
},
async (request, reply) => {
let filePart = null;
const fields = {};
try {
for await (const part of request.parts()) {
if (part.type === "file") {
if (filePart) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "only_single_file_supported" },
});
}
filePart = part;
} else {
fields[part.fieldname] = part.value;
}
}
} catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "file_too_large" },
});
}
throw error;
}
if (!filePart) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "missing" },
});
}
if (!MIME_ALLOWLIST.has(filePart.mimetype)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "unsupported_mime_type" },
});
}
const ownerEmployeeId = fields.owner_employee_id ?? request.employee.id;
if (ownerEmployeeId !== request.employee.id) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { owner_employee_id: "cross_owner_upload_not_allowed_in_mvp" },
});
}
const ocrType = fields.ocr_type ?? "general";
if (!OCR_TYPE_ALLOWLIST.has(ocrType)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { ocr_type: "invalid" },
});
}
const chunks = [];
let fileSize = 0;
for await (const chunk of filePart.file) {
fileSize += chunk.length;
if (fileSize > fastify.config.maxUploadSizeBytes) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "file_too_large" },
});
}
chunks.push(chunk);
}
if (fileSize === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { file: "empty_file" },
});
}
const fileBuffer = Buffer.concat(chunks);
const fileSha256 = sha256Hex(fileBuffer);
const datePrefix = compactDateString(fastify.config.appTimezone);
const extension = path.extname(filePart.filename ?? "").toLowerCase();
const storagePath = path.join(datePrefix, `${crypto.randomUUID()}${extension}`);
const absoluteStoragePath = path.resolve(fastify.config.storageDir, storagePath);
await fs.mkdir(path.dirname(absoluteStoragePath), { recursive: true });
await fs.writeFile(absoluteStoragePath, fileBuffer);
const invoice = await fastify.prisma.invoicePool.create({
data: {
owner_employee_id: ownerEmployeeId,
uploaded_by_employee_id: request.employee.id,
storage_path: storagePath,
original_name: filePart.filename ?? "upload",
mime_type: filePart.mimetype,
file_size: fileSize,
file_sha256: fileSha256,
ocr_type: ocrType,
status: "available",
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
file_sha256: true,
},
});
return sendData(reply, request, {
invoicePoolId: invoice.id,
status: invoice.status,
fileSha256: invoice.file_sha256,
});
},
);
fastify.post(
"/invoice-pool/:id/signed-url",
{
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,
owner_employee_id: true,
uploaded_by_employee_id: true,
linked_reimbursement_id: 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,
});
}
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
const token = signPayload(
{
invoiceId: invoice.id,
exp: expiresAt,
},
fastify.config.jwtSecret,
);
const origin =
fastify.config.appOrigin ||
`${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
const signedUrl = `${origin}/api/invoice-pool/${invoice.id}/file?token=${encodeURIComponent(token)}`;
return sendData(reply, request, {
signedUrl,
expiresAt: new Date(expiresAt).toISOString(),
});
},
);
fastify.get("/invoice-pool/:id/file", async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
const token = request.query?.token;
if (!parsedInvoiceId.success || typeof token !== "string") {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const verified = verifyToken(token, fastify.config.jwtSecret);
if (!verified.ok) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const payload = verified.payload;
if (
payload?.invoiceId !== parsedInvoiceId.data ||
typeof payload?.exp !== "number" ||
payload.exp <= Date.now()
) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: parsedInvoiceId.data },
select: {
storage_path: true,
mime_type: true,
original_name: true,
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const storageRoot = path.resolve(fastify.config.storageDir);
const absolutePath = path.resolve(storageRoot, invoice.storage_path);
if (!absolutePath.startsWith(`${storageRoot}${path.sep}`)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
try {
await fs.access(absolutePath);
} catch {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
reply.type(invoice.mime_type || "application/octet-stream");
reply.header(
"content-disposition",
`inline; filename=\"${sanitizeFilename(invoice.original_name)}\"`,
);
return reply.send(fsSync.createReadStream(absolutePath));
});
fastify.post(
"/invoice-pool/:id/bind",
{
preHandler: fastify.requirePermission("invoice.upload"),
},
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 parsedBody = bindBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
}
const invoiceId = parsedInvoiceId.data;
const { reimbursementId, linkedItemId } = parsedBody.data;
try {
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (![
"draft",
"rejected",
].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_bind" },
});
}
if (linkedItemId) {
const linkedItem = await tx.reimbursementItem.findFirst({
where: {
id: linkedItemId,
reimbursement_id: reimbursementId,
},
select: { id: true },
});
if (!linkedItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { linkedItemId: "item_not_in_reimbursement" },
});
}
}
const invoiceRows = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
if (!Array.isArray(invoiceRows) || invoiceRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const invoice = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
status: true,
linked_reimbursement_id: true,
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (invoice.status === "bound" || invoice.linked_reimbursement_id) {
throw new AppError({
code: "VALID_005",
message: "发票已被其他报销单占用/绑定",
statusCode: 409,
});
}
if (invoice.status !== "available") {
throw new AppError({
code: "VALID_005",
message: "发票已被其他报销单占用/绑定",
statusCode: 409,
details: { status: invoice.status },
});
}
await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "bound",
linked_reimbursement_id: reimbursementId,
linked_item_id: linkedItemId ?? null,
updated_by: request.employee.id,
},
});
});
} catch (error) {
if (isInvoiceDedupConflict(error)) {
throw new AppError({
code: "VALID_003",
message: "发票判重命中",
statusCode: 409,
});
}
if (error?.code === "P2002") {
fastify.log.warn(
{ meta: error?.meta ?? {} },
"Unexpected P2002 not from invoice dedup constraints",
);
}
throw error;
}
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
linked_reimbursement_id: true,
linked_item_id: true,
total_amount: true,
},
});
return sendData(reply, request, {
id: invoice.id,
status: invoice.status,
linkedReimbursementId: invoice.linked_reimbursement_id,
linkedItemId: invoice.linked_item_id,
totalAmount: toMoneyString(invoice.total_amount),
});
},
);
}
module.exports = invoicePoolRoutes;
+42
查看文件
@@ -0,0 +1,42 @@
const { sendData } = require("../utils/reply");
async function meRoutes(fastify) {
fastify.get(
"/me",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const employee = await fastify.prisma.employee.findUnique({
where: { id: request.employee.id },
select: {
id: true,
employee_no: true,
name: true,
phone: true,
email: true,
department_id: true,
direct_manager_id: true,
status: true,
roles: { select: { role_code: true } },
},
});
const roleCodes = (employee?.roles ?? []).map((r) => r.role_code);
return sendData(reply, request, {
id: employee?.id ?? request.employee.id,
employeeNo: employee?.employee_no ?? "",
name: employee?.name ?? "",
phone: employee?.phone ?? "",
email: employee?.email ?? "",
departmentId: employee?.department_id ?? "",
directManagerId: employee?.direct_manager_id ?? null,
status: employee?.status ?? "active",
roleCodes,
});
},
);
}
module.exports = meRoutes;
+724
查看文件
@@ -0,0 +1,724 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
const {
buildWorkflowStepsFromApprovalRule,
resolveActionableStep,
toMoneyString,
} = require("../utils/workflow");
const UUID_SCHEMA = z.string().uuid();
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (canViewAll) {
return;
}
if (!fastify.hasPermission(request.employee, "expense.view_own")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
if (!isOwn) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
}
function formatFormNumber(sequence, timezone) {
const datePart = compactDateString(timezone);
const serial = String(sequence.current_number).padStart(sequence.number_length, "0");
return `${sequence.prefix}${sequence.separator}${datePart}${sequence.separator}${serial}`;
}
function parseWithdrawBody(body) {
const bodySchema = z.object({
comment: z.string().trim().min(1).max(500).optional(),
});
const parsed = bodySchema.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseReimbursementId(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 ensureNonNegativeAmount(totalAmount) {
const asNumber = Number(totalAmount);
if (!Number.isFinite(asNumber)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { total_amount: "invalid" },
});
}
if (asNumber < 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { total_amount: "negative_not_allowed" },
});
}
}
async function loadApprovalRuleForAmount(tx, totalAmount) {
const amountString = toMoneyString(totalAmount);
const approvalRule = await tx.approvalRule.findFirst({
where: {
enabled: true,
min_amount: { lte: amountString },
OR: [{ max_amount: null }, { max_amount: { gt: amountString } }],
},
orderBy: { priority: "asc" },
select: {
id: true,
level1_role: true,
level2_role: true,
level3_role: true,
priority: true,
},
});
if (!approvalRule) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "missing_approval_rule" },
});
}
return approvalRule;
}
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().max(500).optional(),
});
fastify.post(
"/reimbursements",
{
preHandler: fastify.requirePermission("expense.create"),
},
async (request, reply) => {
const parsed = createBodySchema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const reimbursementDate =
parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
const reimbursement = await fastify.prisma.reimbursementForm.create({
data: {
submitter_id: request.employee.id,
owner_id: request.employee.id,
department_id: request.employee.department_id,
reimbursement_date: parseDateOnlyString(reimbursementDate),
description: parsed.data.description ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
reimbursement_date: true,
description: true,
total_amount: true,
form_number: true,
},
});
return sendData(reply, request, {
id: reimbursement.id,
formNumber: reimbursement.form_number,
reimbursementDate,
status: reimbursement.status,
description: reimbursement.description,
totalAmount: toMoneyString(reimbursement.total_amount),
});
},
);
fastify.get(
"/reimbursements/:id",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
submitter_id: true,
owner_id: true,
department_id: true,
reimbursement_date: true,
total_amount: true,
status: true,
description: true,
owner: {
select: { id: true, employee_no: true, name: true },
},
submitter: {
select: { id: true, employee_no: true, name: true },
},
invoice_pool_items: {
select: {
id: true,
status: true,
invoice_code: true,
invoice_number: true,
total_amount: true,
},
},
workflow_instances: {
where: { current_status: "pending" },
orderBy: { created_at: "desc" },
take: 1,
select: {
id: true,
current_step_id: true,
workflow_tasks: {
where: { status: "pending" },
take: 1,
select: {
id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
},
},
},
},
},
});
if (!reimbursement) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
ensureOwnOrCanViewAll(fastify, request, reimbursement);
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
return sendData(reply, request, {
id: reimbursement.id,
formNumber: reimbursement.form_number,
submitterId: reimbursement.submitter_id,
ownerId: reimbursement.owner_id,
departmentId: reimbursement.department_id,
reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10),
totalAmount: toMoneyString(reimbursement.total_amount),
status: reimbursement.status,
description: reimbursement.description,
owner: reimbursement.owner,
submitter: reimbursement.submitter,
invoices: reimbursement.invoice_pool_items.map((item) => ({
id: item.id,
status: item.status,
invoiceCode: item.invoice_code,
invoiceNumber: item.invoice_number,
totalAmount: toMoneyString(item.total_amount),
})),
pendingWorkflow: pendingInstance
? {
instanceId: pendingInstance.id,
stepId: pendingInstance.current_step_id,
task: pendingTask,
}
: null,
});
},
);
fastify.post(
"/reimbursements/:id/submit",
{
preHandler: fastify.requirePermission("expense.submit"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
let submitResult = null;
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, department_id, status, form_number, total_amount
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
ensureOwnOrCanViewAll(fastify, request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_submit_status" },
});
}
ensureNonNegativeAmount(reimbursement.total_amount);
const activeInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
current_status: "pending",
},
select: { id: true },
});
if (activeInstance) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { submit: "already_in_review" },
});
}
const boundInvoiceCount = await tx.invoicePool.count({
where: {
linked_reimbursement_id: reimbursementId,
status: "bound",
},
});
if (boundInvoiceCount < 1) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { invoices: "bound_invoice_required" },
});
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: {
entity_type: "reimbursement",
is_active: true,
},
orderBy: { version: "desc" },
select: {
id: true,
},
});
if (!workflowDefinition) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "missing_definition" },
});
}
const approvalRule = await loadApprovalRuleForAmount(tx, reimbursement.total_amount);
const workflowSteps = buildWorkflowStepsFromApprovalRule(approvalRule);
const { actionable, skipped } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: reimbursement.owner_id,
logger: fastify.log,
});
if (!actionable) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
workflow: "no_actionable_approver",
},
});
}
let formNumber = reimbursement.form_number;
if (!formNumber) {
const sequence = await tx.documentSequence.upsert({
where: { document_type: "reimbursement" },
update: {
current_number: { increment: 1 },
updated_by: request.employee.id,
},
create: {
document_type: "reimbursement",
prefix: "BX",
date_format: "YYYYMMDD",
separator: "-",
number_length: 4,
reset_period: "none",
current_number: 1,
updated_by: request.employee.id,
},
select: {
prefix: true,
separator: true,
number_length: true,
current_number: true,
},
});
formNumber = formatFormNumber(sequence, fastify.config.appTimezone);
}
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
form_number: formNumber,
status: "reviewing",
updated_by: request.employee.id,
},
});
const workflowInstance = await tx.workflowInstance.create({
data: {
workflow_id: workflowDefinition.id,
entity_type: "reimbursement",
entity_id: reimbursementId,
submitter_id: request.employee.id,
current_status: "pending",
current_step_id: actionable.step.id,
context_data: {
owner_id: reimbursement.owner_id,
department_id: reimbursement.department_id,
workflow_steps_snapshot: workflowSteps,
approval_rule_id: approvalRule.id,
},
context_department_id: reimbursement.department_id,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
current_step_id: true,
},
});
for (const skippedStep of skipped) {
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
actor_id: request.employee.id,
action: "skip",
payload: {
step_id: skippedStep.step.id,
reason: skippedStep.reason,
owner_id: reimbursement.owner_id,
depth_checked: skippedStep.depthChecked,
},
},
});
}
const workflowTask = await tx.workflowTask.create({
data: {
instance_id: workflowInstance.id,
step_id: actionable.step.id,
step_name: actionable.step.name ?? actionable.step.id,
assignee_id: actionable.assigneeId,
assignee_role: actionable.assigneeRole,
},
select: {
id: true,
assignee_id: true,
assignee_role: true,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: workflowTask.id,
actor_id: request.employee.id,
action: "start",
payload: {
step_id: actionable.step.id,
approval_rule_id: approvalRule.id,
skipped_step_count: skipped.length,
},
},
});
submitResult = {
reimbursementId,
formNumber,
workflowInstanceId: workflowInstance.id,
currentTaskId: workflowTask.id,
status: "reviewing",
};
});
return sendData(reply, request, submitResult);
},
);
fastify.post(
"/reimbursements/:id/withdraw",
{
preHandler: fastify.requirePermission("expense.withdraw"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const { comment } = parseWithdrawBody(request.body);
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isApplicant =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
if (!isApplicant) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { withdraw: "only_owner_or_submitter" },
});
}
if (reimbursement.status !== "reviewing") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_withdraw_status" },
});
}
const activeInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
current_status: "pending",
},
orderBy: { created_at: "desc" },
select: { id: true },
});
if (!activeInstance) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { withdraw: "no_pending_instance" },
});
}
const processedTaskCount = await tx.workflowTask.count({
where: {
instance_id: activeInstance.id,
status: { in: ["completed", "rejected"] },
},
});
if (processedTaskCount > 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { withdraw: "already_processed_by_approver" },
});
}
const now = new Date();
await tx.workflowTask.updateMany({
where: {
instance_id: activeInstance.id,
status: "pending",
},
data: {
status: "cancelled",
completed_at: now,
completed_by: request.employee.id,
},
});
await tx.workflowInstance.update({
where: { id: activeInstance.id },
data: {
current_status: "cancelled",
completed_at: now,
updated_by: request.employee.id,
},
});
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: activeInstance.id,
actor_id: request.employee.id,
action: "withdraw",
comment: comment ?? null,
payload: {
reimbursement_id: reimbursementId,
},
},
});
response = {
reimbursementId,
outcome: "withdrawn",
workflowInstanceId: activeInstance.id,
reimbursementStatus: "draft",
};
});
return sendData(reply, request, response);
},
);
fastify.post(
"/reimbursements/:id/pay",
{
preHandler: fastify.requirePermission("expense.finance.pay"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
if (!["approved", "paid"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_pay_status" },
});
}
const now = new Date();
const archivedResult = await tx.invoicePool.updateMany({
where: {
linked_reimbursement_id: reimbursementId,
status: "bound",
},
data: {
status: "archived",
archived_reason: "used_complete",
archived_at: now,
updated_by: request.employee.id,
},
});
if (reimbursement.status === "approved") {
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
status: "paid",
updated_by: request.employee.id,
},
});
}
response = {
reimbursementId,
outcome: "paid",
reimbursementStatus: "paid",
// archivedInvoiceCount=0 is valid for idempotent pay or when no bound invoices remain.
archivedInvoiceCount: archivedResult.count,
};
});
return sendData(reply, request, response);
},
);
}
module.exports = reimbursementRoutes;
+659
查看文件
@@ -0,0 +1,659 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
const { getWorkflowStepsSnapshot, resolveActionableStep } = require("../utils/workflow");
const TASK_ID_SCHEMA = z.string().uuid();
const REQUIRED_COMMENT_SCHEMA = z.object({
comment: z.string().trim().min(1).max(500),
});
const TRANSFER_BODY_SCHEMA = z.object({
toApproverId: z.string().uuid(),
reason: z.string().max(500),
});
function parseRequiredComment(body) {
const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data.comment;
}
function parseTransferBody(body) {
const parsed = TRANSFER_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const reason = parsed.data.reason.trim();
if (reason.length < 10) {
throw new AppError({
code: "VALID_002",
message: "转审原因不足 10 字",
statusCode: 400,
details: { reason: "too_short" },
});
}
return {
toApproverId: parsed.data.toApproverId,
reason,
};
}
function parseTaskId(taskId) {
const parsed = TASK_ID_SCHEMA.safeParse(taskId);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { taskId: "invalid_uuid" },
});
}
return parsed.data;
}
async function lockWorkflowTask(tx, taskId) {
const taskRows = await tx.$queryRaw`
SELECT id, instance_id, step_id, step_name, assignee_id, assignee_role, status
FROM "workflow_tasks"
WHERE id = ${taskId}::uuid
FOR UPDATE
`;
if (!Array.isArray(taskRows) || taskRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return taskRows[0];
}
function ensurePendingTask(task) {
if (task.status !== "pending") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { task: "already_processed" },
});
}
}
function ensureTaskAssignableToCurrentEmployee(task, request) {
const matchesAssigneeId = Boolean(task.assignee_id && task.assignee_id === request.employee.id);
const matchesAssigneeRole = Boolean(
task.assignee_role && request.employee.roleCodes.includes(task.assignee_role),
);
const isUnassigned = !task.assignee_id && !task.assignee_role;
if (!(matchesAssigneeId || matchesAssigneeRole || isUnassigned)) {
throw new AppError({
code: "PERM_002",
message: "不在审批范围/非当前处理人",
statusCode: 403,
});
}
}
async function loadWorkflowInstanceForTask(tx, instanceId) {
const workflowInstance = await tx.workflowInstance.findUnique({
where: { id: instanceId },
select: {
id: true,
entity_id: true,
current_step_id: true,
context_data: true,
workflow_definition: {
select: {
definition_json: true,
},
},
},
});
if (!workflowInstance) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return workflowInstance;
}
async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) {
for (const skipped of skippedSteps) {
await tx.workflowHistory.create({
data: {
instance_id: workflowInstanceId,
actor_id: actorId,
action: "skip",
payload: {
step_id: skipped.step.id,
reason: skipped.reason,
owner_id: ownerId,
depth_checked: skipped.depthChecked,
},
},
});
}
}
async function cancelOtherPendingTasks(tx, workflowInstanceId, taskId, actorId) {
await tx.workflowTask.updateMany({
where: {
instance_id: workflowInstanceId,
status: "pending",
NOT: { id: taskId },
},
data: {
status: "cancelled",
completed_at: new Date(),
completed_by: actorId,
},
});
}
async function ensureTransferChainNotExceeded(tx, taskId) {
const transferCount = await tx.workflowHistory.count({
where: {
task_id: taskId,
action: "transfer",
},
});
if (transferCount >= 3) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { transfer: "chain_limit_exceeded" },
});
}
return transferCount;
}
async function ensureTransferTargetEligible(tx, toApproverId) {
const target = await tx.employee.findUnique({
where: { id: toApproverId },
select: {
id: true,
status: true,
roles: {
select: {
role_code: true,
},
},
},
});
if (!target || target.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { toApproverId: "invalid_target" },
});
}
const roleCodes = target.roles.map((item) => item.role_code);
const permissionCodes = resolvePermissionsForRoles(roleCodes);
if (!hasPermission(permissionCodes, "expense.approve")) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { toApproverId: "target_not_approver" },
});
}
}
async function workflowRoutes(fastify) {
fastify.get(
"/workflow/tasks",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const querySchema = z.object({
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
});
const parsedQuery = querySchema.safeParse(request.query ?? {});
if (!parsedQuery.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedQuery.error.flatten(),
});
}
const status = parsedQuery.data.status ?? "pending";
const roleFilters = request.employee.roleCodes.length
? [{ assignee_role: { in: request.employee.roleCodes } }]
: [];
const tasks = await fastify.prisma.workflowTask.findMany({
where: {
status,
OR: [{ assignee_id: request.employee.id }, ...roleFilters],
},
orderBy: { created_at: "desc" },
select: {
id: true,
instance_id: true,
step_id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
status: true,
outcome: true,
created_at: true,
completed_at: true,
workflow_instance: {
select: {
entity_type: true,
entity_id: true,
current_status: true,
},
},
},
});
return sendData(reply, request, {
items: tasks,
});
},
);
fastify.post(
"/workflow/tasks/:taskId/approve",
{
preHandler: fastify.requirePermission("expense.approve"),
},
async (request, reply) => {
const taskId = parseTaskId(request.params?.taskId);
let result = null;
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
ensureTaskAssignableToCurrentEmployee(task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
const steps = getWorkflowStepsSnapshot(workflowInstance);
const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id);
if (currentStepIndex < 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "current_step_not_found" },
});
}
const now = new Date();
await tx.workflowTask.update({
where: { id: taskId },
data: {
status: "completed",
outcome: "approved",
completed_at: now,
completed_by: request.employee.id,
},
});
const { actionable, skipped } = await resolveActionableStep({
tx,
steps,
startIndex: currentStepIndex + 1,
ownerId: workflowInstance.context_data?.owner_id,
logger: fastify.log,
});
await createSkipHistoryRecords(
tx,
workflowInstance.id,
request.employee.id,
workflowInstance.context_data?.owner_id,
skipped,
);
if (actionable) {
const nextTask = await tx.workflowTask.create({
data: {
instance_id: workflowInstance.id,
step_id: actionable.step.id,
step_name: actionable.step.name ?? actionable.step.id,
assignee_id: actionable.assigneeId,
assignee_role: actionable.assigneeRole,
},
select: { id: true },
});
await tx.workflowInstance.update({
where: { id: workflowInstance.id },
data: {
current_step_id: actionable.step.id,
current_status: "pending",
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: taskId,
actor_id: request.employee.id,
action: "approve",
payload: {
from_step_id: task.step_id,
to_step_id: actionable.step.id,
},
},
});
result = {
taskId,
outcome: "approved",
workflowInstanceId: workflowInstance.id,
nextTaskId: nextTask.id,
reimbursementStatus: "reviewing",
};
return;
}
await tx.workflowInstance.update({
where: { id: workflowInstance.id },
data: {
current_status: "approved",
completed_at: now,
updated_by: request.employee.id,
},
});
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "approved",
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: taskId,
actor_id: request.employee.id,
action: "approve",
payload: {
from_step_id: task.step_id,
terminal: true,
},
},
});
result = {
taskId,
outcome: "approved",
workflowInstanceId: workflowInstance.id,
nextTaskId: null,
reimbursementStatus: "approved",
};
});
return sendData(reply, request, result);
},
);
fastify.post(
"/workflow/tasks/:taskId/reject",
{
preHandler: fastify.requirePermission("expense.approve"),
},
async (request, reply) => {
const taskId = parseTaskId(request.params?.taskId);
const comment = parseRequiredComment(request.body);
let result = null;
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
ensureTaskAssignableToCurrentEmployee(task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
const now = new Date();
await tx.workflowTask.update({
where: { id: taskId },
data: {
status: "rejected",
outcome: "rejected",
comment,
completed_at: now,
completed_by: request.employee.id,
},
});
await cancelOtherPendingTasks(tx, workflowInstance.id, taskId, request.employee.id);
await tx.workflowInstance.update({
where: { id: workflowInstance.id },
data: {
current_status: "rejected",
completed_at: now,
updated_by: request.employee.id,
},
});
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "rejected",
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: taskId,
actor_id: request.employee.id,
action: "reject",
comment,
payload: {
from_step_id: task.step_id,
},
},
});
result = {
taskId,
outcome: "rejected",
workflowInstanceId: workflowInstance.id,
reimbursementStatus: "rejected",
};
});
return sendData(reply, request, result);
},
);
fastify.post(
"/workflow/tasks/:taskId/return",
{
preHandler: fastify.requirePermission("expense.approve"),
},
async (request, reply) => {
const taskId = parseTaskId(request.params?.taskId);
const comment = parseRequiredComment(request.body);
let result = null;
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
ensureTaskAssignableToCurrentEmployee(task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
const now = new Date();
await tx.workflowTask.update({
where: { id: taskId },
data: {
status: "completed",
outcome: "returned",
comment,
completed_at: now,
completed_by: request.employee.id,
},
});
await cancelOtherPendingTasks(tx, workflowInstance.id, taskId, request.employee.id);
await tx.workflowInstance.update({
where: { id: workflowInstance.id },
data: {
current_status: "terminated",
completed_at: now,
updated_by: request.employee.id,
},
});
await tx.reimbursementForm.update({
where: { id: workflowInstance.entity_id },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: taskId,
actor_id: request.employee.id,
action: "return",
comment,
payload: {
from_step_id: task.step_id,
},
},
});
result = {
taskId,
outcome: "returned",
workflowInstanceId: workflowInstance.id,
reimbursementStatus: "draft",
};
});
return sendData(reply, request, result);
},
);
fastify.post(
"/workflow/tasks/:taskId/transfer",
{
preHandler: fastify.requirePermission("expense.transfer"),
},
async (request, reply) => {
const taskId = parseTaskId(request.params?.taskId);
const { toApproverId, reason } = parseTransferBody(request.body);
let result = null;
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
ensureTaskAssignableToCurrentEmployee(task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
await ensureTransferTargetEligible(tx, toApproverId);
if (toApproverId === request.employee.id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { transfer: "same_assignee_not_allowed" },
});
}
await tx.workflowTask.update({
where: { id: taskId },
data: {
assignee_id: toApproverId,
assignee_role: null,
},
});
const transfer = await tx.approvalTransfer.create({
data: {
reimbursement_id: workflowInstance.entity_id,
from_approver_id: request.employee.id,
to_approver_id: toApproverId,
reason,
status: "active",
created_by: request.employee.id,
},
select: {
id: true,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: taskId,
actor_id: request.employee.id,
action: "transfer",
comment: reason,
payload: {
from_approver_id: request.employee.id,
to_approver_id: toApproverId,
reason,
transfer_count: transferCount + 1,
},
},
});
result = {
taskId,
outcome: "transferred",
workflowInstanceId: workflowInstance.id,
reimbursementStatus: "reviewing",
transferId: transfer.id,
fromApproverId: request.employee.id,
toApproverId,
transferCount: transferCount + 1,
};
});
return sendData(reply, request, result);
},
);
}
module.exports = workflowRoutes;
+233
查看文件
@@ -0,0 +1,233 @@
require("dotenv/config");
const { createPrismaClient } = require("../prismaClient");
const ENABLED_EXPENSE_TYPES = [
{ code: "travel_airfare", name: "机票", category: "travel" },
{ code: "travel_train", name: "火车票", category: "travel" },
{ code: "travel_taxi", name: "打车费", category: "travel" },
{ code: "travel_hotel", name: "住宿费", category: "travel" },
{ code: "travel_bus", name: "市内交通", category: "travel" },
{ code: "travel_meal", name: "差旅餐补", category: "travel" },
{ code: "travel_fuel", name: "油费", category: "travel" },
{ code: "travel_toll", name: "过路费", category: "travel" },
{ code: "travel_parking", name: "停车费", category: "travel" },
{ code: "travel_visa", name: "签证费", category: "travel" },
{ code: "rd_materials", name: "研发材料", category: "rd" },
{ code: "rd_testing", name: "测试费用", category: "rd" },
{ code: "rd_prototype", name: "样机制作", category: "rd" },
{ code: "rd_software", name: "软件订阅", category: "rd" },
{ code: "rd_external_service", name: "外部技术服务", category: "rd" },
{ code: "production_raw_material", name: "生产原材料", category: "production" },
{ code: "production_processing", name: "加工费", category: "production" },
{ code: "production_quality", name: "质量检测", category: "production" },
{ code: "production_maintenance", name: "设备维护", category: "production" },
{ code: "production_energy", name: "生产能耗", category: "production" },
{ code: "office_stationery", name: "文具耗材", category: "office" },
{ code: "office_supplies", name: "办公用品", category: "office" },
{ code: "office_property_fee", name: "物业费用", category: "office" },
{ code: "office_communication", name: "通讯费用", category: "office" },
{ code: "office_postal", name: "邮寄快递", category: "office" },
{ code: "office_cleaning", name: "保洁服务", category: "office" },
{ code: "business_client_meal", name: "客户招待", category: "business" },
{ code: "business_gift", name: "商务礼品", category: "business" },
{ code: "business_event", name: "活动执行", category: "business" },
{ code: "business_marketing", name: "营销投放", category: "business" },
{ code: "business_training", name: "培训会议", category: "business" },
{ code: "assets_equipment", name: "设备采购", category: "assets" },
{ code: "assets_furniture", name: "家具采购", category: "assets" },
{ code: "assets_it_hardware", name: "IT 硬件", category: "assets" },
{ code: "utilities", name: "水电费", category: "other" },
{ code: "rent", name: "房租租赁费", category: "other" },
{ code: "other_misc", name: "其他杂项", category: "other" },
];
const DISABLED_LEGACY_EXPENSE_TYPES = [
{ code: "transport", name: "legacy-交通费", category: "travel" },
{ code: "meal", name: "legacy-餐费", category: "business" },
{ code: "rd", name: "legacy-研发费", category: "rd" },
];
const DEFAULT_APPROVAL_RULES = [
{
name: "小额",
min_amount: "0",
max_amount: "500",
level1_role: "manager",
level2_role: "finance",
level3_role: null,
priority: 10,
},
{
name: "中额",
min_amount: "500",
max_amount: "5000",
level1_role: "manager",
level2_role: "finance_mgr",
level3_role: null,
priority: 20,
},
{
name: "大额",
min_amount: "5000",
max_amount: null,
level1_role: "manager",
level2_role: "ceo",
level3_role: "finance_mgr",
priority: 30,
},
];
const DEFAULT_WORKFLOW_DEFINITION = {
steps: [
{
id: "ceo_review",
name: "总经理审批",
type: "approval",
assignee_rule: {
type: "role",
value: "ceo",
},
},
],
};
async function seedExpenseTypes(prisma) {
const now = new Date();
const allTypes = [
...ENABLED_EXPENSE_TYPES.map((item) => ({ ...item, enabled: true })),
...DISABLED_LEGACY_EXPENSE_TYPES.map((item) => ({ ...item, enabled: false })),
];
for (const [index, item] of allTypes.entries()) {
await prisma.expenseType.upsert({
where: { code: item.code },
update: {
name: item.name,
description: null,
category: item.category,
sort_order: index + 1,
account_code_default: null,
is_system_default: true,
enabled: item.enabled,
updated_at: now,
updated_by: null,
},
create: {
code: item.code,
name: item.name,
description: null,
category: item.category,
sort_order: index + 1,
account_code_default: null,
is_system_default: true,
enabled: item.enabled,
created_at: now,
created_by: null,
updated_at: now,
updated_by: null,
},
});
}
}
async function seedApprovalRules(prisma) {
await prisma.approvalRule.deleteMany({});
await prisma.approvalRule.createMany({
data: DEFAULT_APPROVAL_RULES.map((item) => ({
...item,
enabled: true,
created_by: null,
updated_by: null,
})),
});
}
async function seedDocumentSequence(prisma) {
await prisma.documentSequence.upsert({
where: { document_type: "reimbursement" },
update: {
prefix: "BX",
date_format: "YYYYMMDD",
separator: "-",
number_length: 4,
reset_period: "none",
},
create: {
document_type: "reimbursement",
prefix: "BX",
date_format: "YYYYMMDD",
separator: "-",
number_length: 4,
reset_period: "none",
current_number: 0,
updated_by: null,
},
});
}
async function seedWorkflowDefinition(prisma) {
await prisma.workflowDefinition.updateMany({
where: {
entity_type: "reimbursement",
},
data: {
is_active: false,
},
});
await prisma.workflowDefinition.upsert({
where: {
entity_type_version: {
entity_type: "reimbursement",
version: 1,
},
},
update: {
name: "报销审批流程(MVP)",
definition_json: DEFAULT_WORKFLOW_DEFINITION,
is_active: true,
created_by: null,
},
create: {
name: "报销审批流程(MVP)",
entity_type: "reimbursement",
definition_json: DEFAULT_WORKFLOW_DEFINITION,
version: 1,
is_active: true,
created_by: null,
},
});
}
async function main() {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
await seedExpenseTypes(prisma);
await seedApprovalRules(prisma);
await seedDocumentSequence(prisma);
await seedWorkflowDefinition(prisma);
// eslint-disable-next-line no-console
console.log("Seeded config data:");
// eslint-disable-next-line no-console
console.log(`- expense types: ${ENABLED_EXPENSE_TYPES.length} enabled + ${DISABLED_LEGACY_EXPENSE_TYPES.length} disabled`);
// eslint-disable-next-line no-console
console.log("- approval rules: 3");
// eslint-disable-next-line no-console
console.log("- document sequence: reimbursement(reset_period=none)");
// eslint-disable-next-line no-console
console.log("- workflow definition: reimbursement v1 (1-step role=ceo)");
} finally {
await prisma.$disconnect();
await pool.end();
}
}
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});
+274
查看文件
@@ -0,0 +1,274 @@
require("dotenv/config");
const { createPrismaClient } = require("../prismaClient");
const { hashPassword } = require("../utils/crypto");
async function ensureDepartment(prisma, payload) {
const now = new Date();
const existing = await prisma.department.findFirst({
where: {
name: payload.name,
parent_id: payload.parent_id ?? null,
},
});
if (existing) {
return prisma.department.update({
where: { id: existing.id },
data: {
parent_id: payload.parent_id ?? null,
archived_at: null,
updated_at: now,
},
});
}
return prisma.department.create({
data: {
name: payload.name,
parent_id: payload.parent_id ?? null,
created_at: now,
updated_at: now,
},
});
}
async function ensureEmployee(prisma, payload) {
const now = new Date();
const passwordHash = await hashPassword(payload.password);
const existing = await prisma.employee.findFirst({
where: { email: payload.email },
select: { id: true },
});
if (existing) {
const employee = await prisma.employee.update({
where: { id: existing.id },
data: {
employee_no: payload.employeeNo,
name: payload.name,
phone: payload.phone,
department_id: payload.departmentId,
title: payload.title,
direct_manager_id: payload.directManagerId,
hire_date: payload.hireDate,
status: "active",
bank_account_encrypted: "ENC_DEV_ONLY",
bank_name: "DEV_BANK",
password_hash: passwordHash,
updated_at: now,
},
select: { id: true, email: true },
});
await prisma.employeeRole.deleteMany({
where: { employee_id: employee.id },
});
await prisma.employeeRole.createMany({
data: payload.roles.map((roleCode) => ({
employee_id: employee.id,
role_code: roleCode,
created_at: now,
})),
skipDuplicates: true,
});
return employee;
}
const employee = await prisma.employee.create({
data: {
employee_no: payload.employeeNo,
name: payload.name,
phone: payload.phone,
email: payload.email,
department_id: payload.departmentId,
title: payload.title,
direct_manager_id: payload.directManagerId,
hire_date: payload.hireDate,
status: "active",
bank_account_encrypted: "ENC_DEV_ONLY",
bank_name: "DEV_BANK",
password_hash: passwordHash,
created_at: now,
updated_at: now,
},
select: { id: true, email: true },
});
await prisma.employeeRole.createMany({
data: payload.roles.map((roleCode) => ({
employee_id: employee.id,
role_code: roleCode,
created_at: now,
})),
skipDuplicates: true,
});
return employee;
}
async function main() {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
const hireDate = new Date("2020-01-01");
try {
const rootDepartment = await ensureDepartment(prisma, { name: "公司" });
const managementDepartment = await ensureDepartment(prisma, {
name: "管理部",
parent_id: rootDepartment.id,
});
const financeDepartment = await ensureDepartment(prisma, {
name: "财务部",
parent_id: rootDepartment.id,
});
const hrDepartment = await ensureDepartment(prisma, {
name: "人事部",
parent_id: rootDepartment.id,
});
const administrationDepartment = await ensureDepartment(prisma, {
name: "行政部",
parent_id: rootDepartment.id,
});
const rdDepartment = await ensureDepartment(prisma, {
name: "研发部",
parent_id: rootDepartment.id,
});
const ceo = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_CEO_EMPLOYEE_NO ?? "E0001",
name: "CEO",
phone: process.env.SEED_CEO_PHONE ?? "13900000000",
email: process.env.SEED_CEO_EMAIL ?? "[email protected]",
password: process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234",
departmentId: rootDepartment.id,
title: "总经理",
directManagerId: null,
roles: ["ceo", "employee"],
hireDate,
});
const admin = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_ADMIN_EMPLOYEE_NO ?? "E0000",
name: "Admin",
phone: process.env.SEED_ADMIN_PHONE ?? "13800000000",
email: process.env.SEED_ADMIN_EMAIL ?? "[email protected]",
password: process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234",
departmentId: managementDepartment.id,
title: "系统管理员",
directManagerId: ceo.id,
roles: ["admin", "employee"],
hireDate,
});
const manager = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_MANAGER_EMPLOYEE_NO ?? "E0100",
name: "Manager",
phone: process.env.SEED_MANAGER_PHONE ?? "13700000000",
email: process.env.SEED_MANAGER_EMAIL ?? "[email protected]",
password: process.env.SEED_MANAGER_PASSWORD ?? "ManagerPassword1234",
departmentId: rdDepartment.id,
title: "研发经理",
directManagerId: ceo.id,
roles: ["manager", "employee"],
hireDate,
});
const employee = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_EMPLOYEE_EMPLOYEE_NO ?? "E0101",
name: "Employee",
phone: process.env.SEED_EMPLOYEE_PHONE ?? "13600000000",
email: process.env.SEED_EMPLOYEE_EMAIL ?? "[email protected]",
password: process.env.SEED_EMPLOYEE_PASSWORD ?? "EmployeePassword1234",
departmentId: rdDepartment.id,
title: "研发工程师",
directManagerId: manager.id,
roles: ["employee"],
hireDate,
});
const finance = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_FINANCE_EMPLOYEE_NO ?? "E0200",
name: "Finance",
phone: process.env.SEED_FINANCE_PHONE ?? "13500000000",
email: process.env.SEED_FINANCE_EMAIL ?? "[email protected]",
password: process.env.SEED_FINANCE_PASSWORD ?? "FinancePassword1234",
departmentId: financeDepartment.id,
title: "财务专员",
directManagerId: ceo.id,
roles: ["finance", "employee"],
hireDate,
});
const financeMgr = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_FINANCE_MGR_EMPLOYEE_NO ?? "E0201",
name: "Finance Manager",
phone: process.env.SEED_FINANCE_MGR_PHONE ?? "13400000000",
email: process.env.SEED_FINANCE_MGR_EMAIL ?? "[email protected]",
password: process.env.SEED_FINANCE_MGR_PASSWORD ?? "FinanceMgrPassword1234",
departmentId: financeDepartment.id,
title: "财务主管",
directManagerId: ceo.id,
roles: ["finance", "finance_mgr", "employee"],
hireDate,
});
const hr = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_HR_EMPLOYEE_NO ?? "E0300",
name: "HR",
phone: process.env.SEED_HR_PHONE ?? "13300000000",
email: process.env.SEED_HR_EMAIL ?? "[email protected]",
password: process.env.SEED_HR_PASSWORD ?? "HrPassword1234",
departmentId: hrDepartment.id,
title: "人事专员",
directManagerId: ceo.id,
roles: ["hr"],
hireDate,
});
const administration = await ensureEmployee(prisma, {
employeeNo: process.env.SEED_ADMINISTRATION_EMPLOYEE_NO ?? "E0400",
name: "Administration",
phone: process.env.SEED_ADMINISTRATION_PHONE ?? "13200000000",
email: process.env.SEED_ADMINISTRATION_EMAIL ?? "[email protected]",
password: process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234",
departmentId: administrationDepartment.id,
title: "行政专员",
directManagerId: manager.id,
roles: ["administration"],
hireDate,
});
// eslint-disable-next-line no-console
console.log("Seeded dev users:");
// eslint-disable-next-line no-console
console.log(`- CEO: ${ceo.email} / ${process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Admin: ${admin.email} / ${process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Manager: ${manager.email} / ${process.env.SEED_MANAGER_PASSWORD ?? "ManagerPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Employee: ${employee.email} / ${process.env.SEED_EMPLOYEE_PASSWORD ?? "EmployeePassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Finance: ${finance.email} / ${process.env.SEED_FINANCE_PASSWORD ?? "FinancePassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Finance Manager: ${financeMgr.email} / ${process.env.SEED_FINANCE_MGR_PASSWORD ?? "FinanceMgrPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- HR: ${hr.email} / ${process.env.SEED_HR_PASSWORD ?? "HrPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Administration: ${administration.email} / ${process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234"}`);
} finally {
await prisma.$disconnect();
await pool.end();
}
}
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});
+15
查看文件
@@ -0,0 +1,15 @@
class AppError extends Error {
/**
* @param {{ code: string; message: string; statusCode: number; details?: any }} params
*/
constructor({ code, message, statusCode, details }) {
super(message);
this.name = "AppError";
this.code = code;
this.statusCode = statusCode;
this.details = details ?? {};
}
}
module.exports = { AppError };
+17
查看文件
@@ -0,0 +1,17 @@
function buildAuditLogData(request, params) {
const userAgent = request.headers["user-agent"];
return {
request_id: request.id,
actor_id: params.actorId,
action: params.action,
target_type: params.targetType,
target_id: params.targetId ?? null,
ip_address: request.ip,
user_agent: typeof userAgent === "string" ? userAgent : null,
extra: params.extra ?? {},
};
}
module.exports = { buildAuditLogData };
+27
查看文件
@@ -0,0 +1,27 @@
const crypto = require("node:crypto");
const bcrypt = require("bcryptjs");
function sha256Hex(input) {
return crypto.createHash("sha256").update(input).digest("hex");
}
function randomRefreshToken() {
return crypto.randomBytes(32).toString("base64url");
}
async function hashPassword(password) {
const saltRounds = 12;
return bcrypt.hash(password, saltRounds);
}
async function verifyPassword(password, passwordHash) {
return bcrypt.compare(password, passwordHash);
}
module.exports = {
hashPassword,
randomRefreshToken,
sha256Hex,
verifyPassword,
};
+24
查看文件
@@ -0,0 +1,24 @@
function currentDateString(timeZone) {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return formatter.format(new Date());
}
function compactDateString(timeZone) {
return currentDateString(timeZone).replaceAll("-", "");
}
function parseDateOnlyString(value) {
return new Date(`${value}T00:00:00.000Z`);
}
module.exports = {
compactDateString,
currentDateString,
parseDateOnlyString,
};
+21
查看文件
@@ -0,0 +1,21 @@
function validatePassword(password) {
const errors = [];
if (typeof password !== "string" || password.length < 12) {
errors.push("密码长度必须 ≥ 12");
}
if (!/[a-z]/.test(password)) {
errors.push("密码必须包含小写字母");
}
if (!/[A-Z]/.test(password)) {
errors.push("密码必须包含大写字母");
}
if (!/[0-9]/.test(password)) {
errors.push("密码必须包含数字");
}
return errors;
}
module.exports = { validatePassword };
+6
查看文件
@@ -0,0 +1,6 @@
function sendData(reply, request, data) {
return reply.send({ requestId: request.id, data });
}
module.exports = { sendData };
+45
查看文件
@@ -0,0 +1,45 @@
const crypto = require("node:crypto");
function toBase64Url(value) {
const source = typeof value === "string" ? value : JSON.stringify(value);
return Buffer.from(source).toString("base64url");
}
function fromBase64Url(value) {
return Buffer.from(value, "base64url").toString("utf8");
}
function signPayload(payload, secret) {
const body = toBase64Url(payload);
const signature = crypto.createHmac("sha256", secret).update(body).digest("base64url");
return `${body}.${signature}`;
}
function verifyToken(token, secret) {
if (typeof token !== "string") {
return { ok: false, reason: "invalid_format" };
}
const segments = token.split(".");
if (segments.length !== 2) {
return { ok: false, reason: "invalid_format" };
}
const [body, signature] = segments;
const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url");
if (signature !== expected) {
return { ok: false, reason: "invalid_signature" };
}
try {
const payload = JSON.parse(fromBase64Url(body));
return { ok: true, payload };
} catch {
return { ok: false, reason: "invalid_payload" };
}
}
module.exports = {
signPayload,
verifyToken,
};
+305
查看文件
@@ -0,0 +1,305 @@
const { AppError } = require("./AppError");
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const MAX_MANAGER_DEPTH = 10;
function parseWorkflowSteps(definitionJson) {
const steps = definitionJson?.steps;
if (!Array.isArray(steps) || steps.length === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_steps" },
});
}
return steps;
}
function buildWorkflowStepsFromApprovalRule(approvalRule) {
if (!approvalRule || typeof approvalRule !== "object") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_approval_rule" },
});
}
const roleCodes = [
approvalRule.level1_role,
approvalRule.level2_role,
approvalRule.level3_role,
].filter(Boolean);
if (roleCodes.length === 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "empty_approval_chain" },
});
}
return roleCodes.map((roleCode, index) => ({
id: `approval_${index + 1}_${roleCode}`,
name: `审批节点${index + 1}`,
type: "approval",
assignee_rule:
roleCode === "manager"
? { type: "department_manager" }
: { type: "role", value: roleCode },
}));
}
async function resolveDepartmentManagerAssignee({ tx, ownerId, logger }) {
let currentId = ownerId;
const visited = new Set();
for (let depth = 1; depth <= MAX_MANAGER_DEPTH; depth += 1) {
if (visited.has(currentId)) {
logger?.warn?.(
{ ownerId, currentId, reason: "cycle_detected" },
"Circular manager reference detected",
);
return {
assigneeId: null,
reason: "cycle_detected",
depthChecked: depth - 1,
};
}
visited.add(currentId);
const employee = await tx.employee.findUnique({
where: { id: currentId },
select: {
id: true,
direct_manager_id: true,
},
});
if (!employee) {
return {
assigneeId: null,
reason: "chain_broken",
depthChecked: depth,
};
}
const managerId = employee.direct_manager_id;
if (!managerId) {
return {
assigneeId: null,
reason: "no_manager_found",
depthChecked: depth,
};
}
if (managerId === currentId) {
logger?.warn?.(
{ ownerId, currentId, reason: "self_loop" },
"Self-loop manager reference detected",
);
return {
assigneeId: null,
reason: "self_loop",
depthChecked: depth,
};
}
if (managerId === ownerId) {
currentId = managerId;
continue;
}
if (visited.has(managerId)) {
logger?.warn?.(
{ ownerId, currentId: managerId, reason: "cycle_detected" },
"Circular manager reference detected",
);
return {
assigneeId: null,
reason: "cycle_detected",
depthChecked: depth,
};
}
return {
assigneeId: managerId,
reason: null,
depthChecked: depth,
};
}
logger?.warn?.(
{ ownerId, currentId, reason: "max_depth_exceeded" },
"Manager resolution reached max depth",
);
return {
assigneeId: null,
reason: "max_depth_exceeded",
depthChecked: MAX_MANAGER_DEPTH,
};
}
async function resolveAssigneeFromRule(rule, options = {}) {
if (!rule || typeof rule !== "object") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_assignee_rule" },
});
}
if (rule.type === "role") {
if (typeof rule.value !== "string" || !rule.value) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_role_assignee" },
});
}
return {
assigneeId: null,
assigneeRole: rule.value,
skipReason: null,
depthChecked: 0,
};
}
if (rule.type === "specific_user") {
if (typeof rule.value !== "string" || !UUID_REGEX.test(rule.value)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_specific_user_assignee" },
});
}
return {
assigneeId: rule.value,
assigneeRole: null,
skipReason: null,
depthChecked: 0,
};
}
if (rule.type === "department_manager") {
const ownerId = options.ownerId;
const tx = options.tx;
if (!ownerId || !UUID_REGEX.test(ownerId) || !tx) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_department_manager_context" },
});
}
const managerResult = await resolveDepartmentManagerAssignee({
tx,
ownerId,
logger: options.logger,
});
if (!managerResult.assigneeId) {
return {
assigneeId: null,
assigneeRole: null,
skipReason: managerResult.reason,
depthChecked: managerResult.depthChecked,
};
}
return {
assigneeId: managerResult.assigneeId,
assigneeRole: null,
skipReason: null,
depthChecked: managerResult.depthChecked,
};
}
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "unsupported_assignee_rule_type" },
});
}
async function resolveActionableStep({ tx, steps, startIndex, ownerId, logger }) {
const skipped = [];
for (let index = startIndex; index < steps.length; index += 1) {
const step = steps[index];
const resolved = await resolveAssigneeFromRule(step.assignee_rule, {
tx,
ownerId,
logger,
});
if (resolved.assigneeId || resolved.assigneeRole) {
return {
actionable: {
index,
step,
assigneeId: resolved.assigneeId,
assigneeRole: resolved.assigneeRole,
},
skipped,
};
}
if (resolved.skipReason) {
skipped.push({
index,
step,
reason: resolved.skipReason,
depthChecked: resolved.depthChecked,
});
continue;
}
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "invalid_resolved_assignee" },
});
}
return {
actionable: null,
skipped,
};
}
function getWorkflowStepsSnapshot(workflowInstance) {
const snapshot = workflowInstance?.context_data?.workflow_steps_snapshot;
if (Array.isArray(snapshot) && snapshot.length > 0) {
return parseWorkflowSteps({ steps: snapshot });
}
return parseWorkflowSteps(workflowInstance?.workflow_definition?.definition_json);
}
function toMoneyString(value) {
if (value == null) return null;
return typeof value === "string" ? value : value.toString();
}
module.exports = {
buildWorkflowStepsFromApprovalRule,
getWorkflowStepsSnapshot,
parseWorkflowSteps,
resolveActionableStep,
resolveAssigneeFromRule,
resolveDepartmentManagerAssignee,
toMoneyString,
};