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
这个提交包含在:
+7
@@ -0,0 +1,7 @@
|
||||
**/node_modules
|
||||
|
||||
.env
|
||||
**/.env
|
||||
|
||||
server/generated/prisma
|
||||
|
||||
@@ -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:
|
||||
@@ -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` 配置解决,避免改代码
|
||||
- 转审/例外权限属于高风险动作:必须上线前完成审计与权限回归测试
|
||||
文件差异内容过多而无法显示
加载差异
@@ -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(本地/开发用)
|
||||
@@ -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
文件差异内容过多而无法显示
加载差异
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
+1107
文件差异内容过多而无法显示
加载差异
+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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
@@ -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" });
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
function sendData(reply, request, data) {
|
||||
return reply.send({ requestId: request.id, data });
|
||||
}
|
||||
|
||||
module.exports = { sendData };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户