From f52763371655bf3f0384f10f64319574238bffab Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 7 Feb 2026 15:34:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=AE=8C=E6=88=90=20P0+P1=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E8=83=BD=E5=8A=9B=EF=BC=88=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E3=80=81RBAC=E3=80=81=E4=BB=98=E6=AC=BE=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 7 + docker-compose.yml | 42 + docs/implementation_plan.md | 150 ++ docs/requirements-specification.md | 1231 +++++++++ docs/tech_stack_report.md | 66 + server/.env.example | 9 + server/README.md | 148 ++ server/package-lock.json | 2282 +++++++++++++++++ server/package.json | 40 + server/postman/hyb-oa.postman_collection.json | 186 ++ .../postman/hyb-oa.postman_environment.json | 24 + server/prisma.config.ts | 13 + .../20260206064822_init/migration.sql | 695 +++++ .../migration.sql | 2 + .../migration.sql | 22 + .../migration.sql | 3 + server/prisma/migrations/migration_lock.toml | 3 + server/prisma/schema.prisma | 595 +++++ server/scripts/regression-all.sh | 18 + server/scripts/regression-dedup.sh | 298 +++ server/scripts/regression-workflow-actions.sh | 1107 ++++++++ server/scripts/smoke-test.sh | 208 ++ server/src/app.js | 121 + server/src/config.js | 48 + server/src/constants/permissions.js | 62 + server/src/index.js | 15 + server/src/plugins/auth.js | 105 + server/src/plugins/prisma.js | 16 + server/src/prismaClient.js | 19 + server/src/routes/auth.js | 528 ++++ server/src/routes/invoicePool.js | 645 +++++ server/src/routes/me.js | 42 + server/src/routes/reimbursements.js | 724 ++++++ server/src/routes/workflow.js | 659 +++++ server/src/scripts/seed-config.js | 233 ++ server/src/scripts/seed-dev.js | 274 ++ server/src/utils/AppError.js | 15 + server/src/utils/audit.js | 17 + server/src/utils/crypto.js | 27 + server/src/utils/date.js | 24 + server/src/utils/passwordPolicy.js | 21 + server/src/utils/reply.js | 6 + server/src/utils/signedUrl.js | 45 + server/src/utils/workflow.js | 305 +++ 44 files changed, 11100 insertions(+) create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 docs/implementation_plan.md create mode 100644 docs/requirements-specification.md create mode 100644 docs/tech_stack_report.md create mode 100644 server/.env.example create mode 100644 server/README.md create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/postman/hyb-oa.postman_collection.json create mode 100644 server/postman/hyb-oa.postman_environment.json create mode 100644 server/prisma.config.ts create mode 100644 server/prisma/migrations/20260206064822_init/migration.sql create mode 100644 server/prisma/migrations/20260206070703_allow_null_direct_manager/migration.sql create mode 100644 server/prisma/migrations/20260206071653_password_reset_codes/migration.sql create mode 100644 server/prisma/migrations/20260206202000_workflow_tasks_status_assignee_idx/migration.sql create mode 100644 server/prisma/migrations/migration_lock.toml create mode 100644 server/prisma/schema.prisma create mode 100755 server/scripts/regression-all.sh create mode 100755 server/scripts/regression-dedup.sh create mode 100755 server/scripts/regression-workflow-actions.sh create mode 100755 server/scripts/smoke-test.sh create mode 100644 server/src/app.js create mode 100644 server/src/config.js create mode 100644 server/src/constants/permissions.js create mode 100644 server/src/index.js create mode 100644 server/src/plugins/auth.js create mode 100644 server/src/plugins/prisma.js create mode 100644 server/src/prismaClient.js create mode 100644 server/src/routes/auth.js create mode 100644 server/src/routes/invoicePool.js create mode 100644 server/src/routes/me.js create mode 100644 server/src/routes/reimbursements.js create mode 100644 server/src/routes/workflow.js create mode 100644 server/src/scripts/seed-config.js create mode 100644 server/src/scripts/seed-dev.js create mode 100644 server/src/utils/AppError.js create mode 100644 server/src/utils/audit.js create mode 100644 server/src/utils/crypto.js create mode 100644 server/src/utils/date.js create mode 100644 server/src/utils/passwordPolicy.js create mode 100644 server/src/utils/reply.js create mode 100644 server/src/utils/signedUrl.js create mode 100644 server/src/utils/workflow.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a332e174 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +**/node_modules + +.env +**/.env + +server/generated/prisma + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..0c8c9a75 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md new file mode 100644 index 00000000..f542d35a --- /dev/null +++ b/docs/implementation_plan.md @@ -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` 配置解决,避免改代码 +- 转审/例外权限属于高风险动作:必须上线前完成审计与权限回归测试 diff --git a/docs/requirements-specification.md b/docs/requirements-specification.md new file mode 100644 index 00000000..868f29cf --- /dev/null +++ b/docs/requirements-specification.md @@ -0,0 +1,1231 @@ +# 海音达 OA 系统 - 需求规格说明书 + +**版本**:1.4 +**日期**:2026-02-06 +**范围**:报销系统及其依赖模块(员工/权限/工作流/附件) + +**相关文档**: +- [技术栈与复用报告](./tech_stack_report.md) +- [实施计划](./implementation_plan.md) + +--- + +## 1. 项目概述 + +### 1.1 背景 +海音达科技需要一套独立的 OA 系统,用于内部办公管理。Phase 1 聚焦**报销系统**及其依赖(员工管理、权限、工作流、附件/发票)。 + +### 1.2 目标(Phase 1) +- 实现完整的费用报销流程:草稿 → 提交 → 审批(工作流任务)→ 通过 → 付款标记 → 完成 +- 审批规则可配置(金额区间 → 多级审批角色),P0 预置 3 条默认规则 +- 审批引擎采用通用 4 表式工作流(Definition/Instance/Task/History),后续扩展到请假/采购无需重构 +- 支持行政代提交(需授权)、发票附件上传、双策略判重、离线打款标记 +- 响应式 Web(PC/移动端) + +### 1.3 技术栈(定案) +| 层级 | 选型 | +| :--- | :--- | +| 前端(OA) | React + Vite + Tailwind | +| 后端(OA API) | Fastify + Prisma | +| 数据库 | PostgreSQL | + +### 1.4 复用资产(来自 `shenzhou-business-suite`) +> 目的:减少踩坑与重复造轮子。本文档的表结构/枚举/约束均以该项目的已验证实现为基线,再按 hyb-oa 目标做裁剪与补齐。 + +| 资产 | 来源路径(参考) | 在 hyb-oa 的复用方式 | +| :--- | :--- | :--- | +| 工作流 4 表结构 + `approval_rules`/`approval_transfers`/`document_sequences` | `shenzhou-business-suite/supabase/schema/70_workflow/01_workflow_02_tables.sql` | 转为 Prisma schema;服务端实现工作流事务与权限校验 | +| 工作流类型定义(assignee_rule 等) | `shenzhou-business-suite/src/types/workflow.ts` | 作为工作流 JSON 配置与服务层契约参考 | +| 权限码体系 | `shenzhou-business-suite/src/types/permissions.ts` | 复用命名与粒度;补齐行政代提交权限码 | +| 报销表结构参考(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 的约束与鉴权规则 | + +--- + +## 2. 用户角色与权限模型 + +### 2.1 角色(role_code)与职责边界(P0) +| 角色(展示) | role_code | 职责/范围 | +| :--- | :--- | :--- | +| 普通员工 | `employee` | 创建/提交报销,查看/编辑本人草稿与记录 | +| 部门经理 | `manager` | 审批直属下属报销(工作流任务);可查看直属下属报销(P0:不递归) | +| 总经理 | `ceo` | 审批大额报销(工作流任务),查看全公司数据(只读) | +| 财务专员 | `finance` | 财务复核(工作流任务)、付款标记、票据管理(全量) | +| 财务主管 | `finance_mgr` | 大额复核、发票判重例外、审批规则维护(P1 UI) | +| 行政 | `administration` | 代提交(授权由 principal 本人或 `hr/admin` 维护;不含审批/付款) | +| 人事 | `hr` | 员工与组织维护(不含财务/审批) | +| 系统管理员 | `admin` | 系统设置、用户、审计(可授予 `*`) | + +> 说明:一个员工可同时拥有多个 `role_code`(例如 `finance` + `admin`),权限按并集生效。 + +### 2.2 权限码(Permission Codes,P0 必需) +> 命名规则:`domain.action[_scope]`。后端接口必须声明所需权限码并统一校验。 + +| permission | 说明 | +| :--- | :--- | +| `expense.create` | 创建报销单(草稿) | +| `expense.view_own` | 查看自己的报销单 | +| `expense.view_all` | 查看所有报销单(数据范围由角色决定:`manager`=直属下属(`owner.direct_manager_id = me`,不递归);`ceo/finance/finance_mgr`=全公司;`hr`=全公司只读) | +| `expense.edit_own` | 编辑自己的报销单(仅草稿/驳回) | +| `expense.delete_own` | 删除自己的报销单(仅草稿) | +| `expense.submit` | 提交报销单(发起工作流) | +| `expense.withdraw` | 撤回报销单(将流程取消并回到草稿) | +| `expense.approve` | 审批报销单(处理工作流任务) | +| `expense.transfer` | 转审(转交当前待办任务) | +| `expense.finance.pay` | 付款标记(approved → paid) | +| `expense.finance.override_duplicate` | 发票重复例外(需填原单号+原因) | +| `expense.proxy_submit` | 行政代提交 | +| `expense.proxy_manage` | 代提交授权管理(Delegation Grants:本人可管自己的授权;`hr/admin` 可代维护) | +| `invoice.upload` | 上传发票/附件 | +| `invoice.view_own` | 查看自己的发票/附件 | +| `invoice.manage` | 票据中心管理(可查看全量附件/发票信息) | +| `system.settings` | 系统设置(含费用类型/单号规则/审批规则等配置入口) | +| `system.users` | 用户与组织管理 | +| `system.audit` | 审计日志查看 | +| `*` | 超级权限(仅 admin 可授予) | + +### 2.3 角色默认权限映射(P0 定案) +- `employee`:`expense.create/view_own/edit_own/delete_own/submit/withdraw` + `invoice.upload/invoice.view_own` + `expense.proxy_manage`(仅管自己的授权) +- `manager`:在 `employee` 基础上 + `expense.approve/expense.transfer`(仅对其审批范围内单据)+ `expense.view_all`(数据范围=直属下属,`owner.direct_manager_id = me`,不递归) +- `ceo`:`expense.approve/expense.transfer`(仅对其审批范围内单据)+ `expense.view_all`(数据范围=全公司) +- `finance`:`invoice.manage` + `expense.approve`(财务复核任务)+ `expense.finance.pay` + `expense.view_all`(数据范围=全公司) +- `finance_mgr`:在 `finance` 基础上 + `expense.finance.override_duplicate`(判重例外)+ `system.settings`(审批规则/费用类型配置,P1 提供 UI) +- `administration`:`expense.proxy_submit` + `expense.view_own`(不含审批/付款;授权管理由 principal 本人或 hr/admin 执行) +- `hr`:`system.users`(仅员工/组织域)+ `expense.view_all`(数据范围=全公司只读) +- `admin`:`*` + +### 2.4 权限校验原则(实现约束) +- 后端为单一事实源:所有关键动作必须在服务端做权限码校验(前端仅用于 UI 隐藏)。 +- `*_own` 语义(P0 定案): + - 对报销单(reimbursement),`own` 定义为:`owner_id = me OR submitter_id = me`(代提交场景)。 + - 因此 `expense.view_own/edit_own/delete_own/withdraw` 均按该规则判定(另受状态约束:edit 仅 `draft/rejected`;delete 仅 `draft`;withdraw 仅“审批人未处理前”)。 + - `administration` 持有 `expense.view_own` 的目的,是允许其查看“自己代提交”的单据(submitter 维度),不代表可查看全公司。 +- “审批范围”由工作流任务的 `assignee_id/assignee_role` 决定:只有当前待办任务的处理人/处理角色可执行审批/转审。 +- “财务例外”必须写入审计日志(见第 12 章),且需要 `expense.finance.override_duplicate`。 + +--- + +## 3. 组织架构与部门模型 + +### 3.1 组织架构示例 +``` +总经理 +├── 部门经理 A +│ ├── 员工 A1 +│ └── 员工 A2 +└── 部门经理 B + ├── 员工 B1 + └── 员工 B2 +``` + +- 普通员工的直属上级 = 部门经理 +- 部门经理的直属上级 = 总经理 + +### 3.2 部门模型(P0) +- 支持多级部门(`parent_id`)。 +- 部门归档约束(禁止硬删除): + - 有子部门:禁止归档 + - 有在职员工:禁止归档 + - 归档后不可再分配新员工;组织树默认隐藏归档部门(可筛选查看) + +--- + +## 4. 功能需求 + +### 4.1 员工管理 + +#### 4.1.1 员工信息字段(核心) +| 字段 | 类型 | 必填 | 说明 | +| :--- | :--- | :---: | :--- | +| 工号 | String | ✅ | 唯一标识 | +| 姓名 | String | ✅ | | +| 手机号 | String | ✅ | 登录标识(identifier 之一) | +| 邮箱 | String | ✅ | 登录标识(identifier 之一)/通知 | +| 部门 | FK | ✅ | 关联部门表 | +| 职位 | String | ✅ | | +| 直属上级 `direct_manager_id` | FK | ✅(顶层可空) | 审批链与 `department_manager` 解析依赖;顶层角色(如 CEO)可为空 | +| 入职日期 | Date | ✅ | | +| 状态 | Enum | ✅ | 在职/离职 | +| 角色集合 | M2M | ✅ | 通过 `employee_roles(employee_id, role_code)` 维护;权限按并集生效 | +| 银行卡号 | String | ✅ | 敏感字段:加密存储/脱敏展示(见第 12 章) | +| 开户行 | String | ✅ | | +| 身份证号 | String | ❌ | 敏感字段:加密存储/脱敏展示(见第 12 章) | + +#### 4.1.2 功能点 +- 员工 CRUD(`system.users`,通常由 `hr/admin` 执行) +- 组织架构树展示(含归档部门过滤) +- 离职员工归档:离职后禁止登录并强制下线(见第 11 章 tokenVersion) + +--- + +### 4.2 报销系统 + +#### 4.2.1 报销单(Reimbursement Form) +| 字段 | 说明 | +| :--- | :--- | +| 报销单号 `form_number` | 由单号规则生成(见第 8 章) | +| 申请人 `submitter_id` | 当前登录用户或代提交人 | +| 实际报销人 `owner_id` | 费用归属人 | +| 部门 `department_id` | 默认取 `owner_id` 所属部门 | +| 费用明细 `items[]` | 多条,见下表 | +| 总金额 `total_amount` | 自动汇总 | +| 报销日期 `reimbursement_date` | 业务发生日期(整单) | +| 状态 `status` | 固定 6 状态(见 4.3.3) | +| 备注 `description` | 可选 | + +#### 4.2.2 报销明细(Reimbursement Item) +| 字段 | 说明 | +| :--- | :--- | +| 费用类型 `expense_type_id` | 关联 `expense_types`(见第 9 章) | +| 发生日期 `expense_date` | 日期 | +| 金额 `amount` | 数值 | +| 说明 `description` | 必填(用于审计与复核) | + +> 附件(发票池条目)与明细的关联由 `invoice_pool.linked_item_id` 实现(可选),详见第 10 章。 + +> 费用类型不再是“21 种硬编码”,改为动态 `expense_types`;系统默认启用 37 项(见附录 A)。 + +#### 4.2.3 代提交(Proxy Submit,P0) +- 允许角色:`administration`(需权限 `expense.proxy_submit`) +- 约束:代提交必须存在有效授权(由 `delegation_grants` 控制;授权由本人或 `hr/admin` 维护),否则拒绝提交/创建 +- 记录要求: + - 报销单必须同时记录 `submitter_id`(代提交人)与 `owner_id`(实际报销人) + - 审批与通知以 `owner_id` 为主,同时抄送 `submitter_id` + +#### 4.2.4 代提交授权(Delegation Grants,P0) +> 用于控制“行政可以为哪些员工代提交”,避免权限过大。P0 默认“创建即生效”,无需被授权人二次确认。 + +**`delegation_grants` 表(建议字段)**: +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| id | UUID | 主键 | +| principal_employee_id | UUID | 被代提交人(员工) | +| delegate_employee_id | UUID | 代办人(行政) | +| scope | String | `reimbursement_submit \| all`(P0;P1+ 可扩展其他 scope) | +| valid_from/valid_to | DateTime | 生效/失效时间(`valid_to` 可空表示长期) | +| revoked_at | DateTime | 撤销时间(可空) | +| created_at/created_by | DateTime/UUID | 创建时间/操作者 | + +**授权规则(P0 定案)**: +- `delegate_employee_id` 必须包含 `administration` 角色 +- 授权生效条件:`revoked_at is null` 且当前时间在 `[valid_from, valid_to]` 范围内 +- 授权管理权限: + - 员工本人可对“授予给行政的授权”执行创建/撤销(UI:个人设置 → 授权管理) + - `hr/admin` 可代维护(用于补录/紧急处理),所有变更必须进入 `audit_logs` + +--- + +### 4.3 审批与流程(业务视角) + +#### 4.3.1 默认审批链(P0 预置规则) +审批链不再硬编码在业务代码里,而是由 `approval_rules` 匹配得到(见第 6 章)。P0 预置 3 条规则: +- 小额:`[0, 500)` → `manager` → `finance` +- 中额:`[500, 5000)` → `manager` → `finance_mgr` +- 大额:`[5000, +∞)` → `manager` → `ceo` → `finance_mgr` + +#### 4.3.2 审批操作(P0) +| 操作 | 触发者 | 结果 | +| :--- | :--- | :--- | +| 提交 `submit` | 申请人/代提交人 | 生成单号,发起工作流实例与首个任务 | +| 通过 `approve` | 当前任务处理人 | 完成当前任务并推进到下一任务/流程结束 | +| 驳回 `reject` | 当前任务处理人 | 流程终止(instance=`rejected`),报销单状态变为 `rejected`(可编辑后再次 submit 进入新一轮) | +| 退回 `return` | 当前任务处理人 | 终止当前流程实例(instance=`terminated`),报销单回到 `draft`(可修改后重新 submit) | +| 撤回 `withdraw` | 申请人 | 取消当前流程实例(instance=`cancelled`),报销单回到 `draft`(仅在审批人未处理前) | +| 转审 `transfer` | 当前任务处理人 | 将当前任务转交他人;需记录原因(≥10 字) | +| 付款标记 `pay` | 财务 | 流程通过后,将 `approved` 标记为 `paid` | + +#### 4.3.3 报销单数据状态机(固定 6 状态) +> “待部门审批/待财务复核”等是任务节点文案,由工作流当前待办任务决定,不作为数据状态写入 DB。 + +``` +draft + └─ submit → reviewing +reviewing + ├─ approve(最终) → approved + ├─ reject → rejected + ├─ return → draft + └─ withdraw → draft +approved + └─ pay → paid +paid (终态) +rejected (可由申请人编辑后再 submit 进入新一轮) +cancelled (作废/终止,终态) +``` + +#### 4.3.4 `return` vs `reject` vs `withdraw`(P0 定案) +- `return`(退回):用于“需要申请人修改后再提交”的场景。动作发生后: + - 当前 `workflow_instances` 必须结束并标记为 `terminated` + - 报销单状态回到 `draft`;申请人修改后需再次 `submit` 才会进入新一轮审批 + - 所有未完成任务必须被取消(`workflow_tasks.status=cancelled`),并写入 `workflow_history(action=return)` +- `reject`(驳回):用于“本轮不通过”的场景。动作发生后: + - 当前 `workflow_instances` 必须结束并标记为 `rejected` + - 报销单状态变为 `rejected`(保留驳回意见);申请人可编辑后再次 `submit` 进入新一轮审批 + - 写入 `workflow_history(action=reject)` +- `withdraw`(撤回):由申请人发起的“主动取消”。动作发生后: + - 当前 `workflow_instances` 必须结束并标记为 `cancelled` + - 报销单状态回到 `draft` + - 约束:仅允许在**审批人尚未处理**前撤回(即当前实例内不存在已完成的审批任务) + - 写入 `workflow_history(action=withdraw)` + +--- + +### 4.4 发票池(Invoice Pool)与 OCR(P0) +> 目的:把“票据文件 + OCR 结果 + 发票元数据 + 绑定关系 + 生命周期状态”从报销单附件中解耦出来,形成可复用、可审计、可判重的单一事实源。 + +#### 4.4.1 模块定位(P0) +- `invoice_pool` 是票据/附件的单一事实源(含发票、行程单、火车票、出租车票等图片/PDF)。 +- 报销单不再直接管理“附件文件”,而是**绑定** `invoice_pool` 条目(见 4.4.6)。 +- 判重与例外属于“使用/报销”层面的合规约束:P0 允许先入池,**在绑定/提交阶段**做硬校验(见 4.4.7)。 + +#### 4.4.2 生命周期状态机(5 态,P0) +`invoice_pool.status`: +- `pending_ocr`:已上传,OCR 未完成(或排队中) +- `ocr_failed`:OCR 失败(可重试,或人工修正后转为 `available`) +- `available`:可绑定(OCR 成功或人工确认/修正后) +- `bound`:已绑定到某报销单(1:1) +- `archived`:已归档(不可再绑定;P0 不提供恢复) + +**不变量(P0,必须由 DB + 服务端共同保证)**: +- `status = bound` ⇒ `linked_reimbursement_id is not null` +- `status in (pending_ocr, ocr_failed, available)` ⇒ `linked_reimbursement_id is null` +- `status = archived` ⇒ 不允许再执行 bind +- `status = archived AND archived_reason = used_complete` ⇒ `linked_reimbursement_id is not null`(用于追溯) +- `status = archived AND archived_reason in (unused_timeout, manual)` ⇒ `linked_reimbursement_id is null` + +#### 4.4.3 归档原因(用于判重口径,P0) +`invoice_pool.archived_reason`(仅当 `status=archived` 时有意义): +- `used_complete`:已被报销使用且流程完成后归档 +- `unused_timeout`:90 天未绑定自动归档(从未被使用) +- `manual`:人工归档(清理/隐藏;从未被使用或不再使用) + +**判重口径(P0 定案)**: +- 只有 `status=bound` 与 `status=archived AND archived_reason=used_complete` 的记录参与“全局判重历史”。 +- `unused_timeout/manual` 的归档记录不参与全局判重(避免“未使用的旧票据”阻塞新票据入库)。 + +#### 4.4.4 90 天未绑定自动归档(P0) +- 目标:控制“孤儿发票/票据”堆积。 +- 规则:对 `status in (available, ocr_failed)` 且未绑定的条目,超过 90 天自动转 `archived` 且 `archived_reason=unused_timeout`。 +- `pending_ocr` 超时策略(建议):先转 `ocr_failed` 再进入自动归档规则(超时阈值由实现配置)。 + +#### 4.4.5 OCR(腾讯云,P0) +- OCR 提供方:腾讯云(参考既有项目实现)。 +- OCR 结果必须持久化(用于复核/搜索/审计/复用);P0 不落库保存腾讯云 rawResponse(仅日志)。 +- `ocr_type`(P0 枚举): + - `vat`(增值税发票;覆盖专票/普票/数电票,细分类型体现在标准化结果中) + - `train`(火车票) + - `flight`(机票行程单) + - `taxi`(出租车票) + - `general`(其他通用票据,默认值) +- 交互策略:用户可选 `ocr_type`;默认 `general`。 +- 提交不阻塞:OCR 失败/未完成时,允许用户人工补齐关键字段后继续(UI 提示即可,不让流程卡死)。 +- 发票验真(税局/验真接口):**P2**,P0/P1 不做。 + +#### 4.4.6 绑定/解绑规则(P0) +- 绑定关系:一张 `invoice_pool` 条目只能绑定一个报销单(1:1)。 +- 绑定前置条件:`status=available` 且未绑定。 +- 绑定后:`status=bound`,写入 `linked_reimbursement_id`(可选 `linked_item_id`)。 +- 解绑后:清空 `linked_reimbursement_id/linked_item_id`,回到 `available`(P0 不自动恢复到 `ocr_failed`)。 + +**与报销流程的联动(P0 定案)**: +- 报销草稿被删除(仅 `draft`):必须自动解绑其绑定的发票池条目并回到 `available`。 +- 报销单进入 `paid`(完成节点):其绑定的发票池条目自动归档为 `archived` 且 `archived_reason=used_complete`。 + +#### 4.4.7 判重与例外(P0) +- 全局双策略判重(公司级): + 1) `invoice_code + invoice_number`(齐全时) + 2) `file_sha256` +- 生效点:不阻塞“入池上传”;在 **bind/submit** 阶段做硬校验。 +- 例外(允许重复):仅 `finance_mgr`,必填原单号 + 原因(≥10 字),并写入审计;当原单处于 `approved` 或 `paid` 时禁止例外(见错误码 `VALID_004`)。 + +> 存储与签名 URL 访问规则见第 10 章;接口契约见第 13 章。 + +--- + +### 4.5 支付管理 +- 打款方式:线下打款,系统仅做“付款标记” +- 付款状态:`approved` → `paid` +- 付款记录:付款时间、操作人、银行流水号(可选)、备注 + +--- + +### 4.6 通知系统(P0) +| 场景 | 通知方式 | +| :--- | :--- | +| 待审批提醒 | 站内 + 邮件(P1:企微/钉钉) | +| 审批结果 | 站内 + 邮件 | +| 付款完成 | 站内 + 邮件 | + +--- + +## 5. 工作流引擎(Workflow,P0 采用) +> 采用 4 表式通用工作流,审批任务由 `workflow_tasks` 表驱动。P0 仅用线性审批链,但表结构支持后续扩展为条件分支/动作节点。 + +### 5.1 表结构(核心四表) + +#### 5.1.1 `workflow_definitions`(流程定义) +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| id | UUID | 主键 | +| name | String | 流程名称(如“报销审批流程”) | +| entity_type | String | 实体类型(如 `reimbursement`) | +| definition_json | JSON | 流程配置(见 5.2) | +| version | Int | 版本号(递增) | +| is_active | Bool | 是否启用 | + +#### 5.1.2 `workflow_instances`(流程实例) +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| id | UUID | 主键 | +| workflow_id | UUID | 关联 `workflow_definitions` | +| entity_type | String | 实体类型(`reimbursement`) | +| entity_id | UUID | 业务实体 ID(报销单 ID) | +| submitter_id | UUID | 发起人(提交者) | +| current_status | String | `pending/approved/rejected/cancelled/terminated` | +| current_step_id | String | 当前步骤 ID(与任务 step_id 对齐) | +| context_data | JSON | 实体快照(**必须包含 `owner_id`、`department_id`**;如 `total_amount`、命中 rule_id、审批链等) | +| started_at/completed_at | DateTime | 开始/结束时间 | + +#### 5.1.3 `workflow_tasks`(审批任务) +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| id | UUID | 主键 | +| instance_id | UUID | 关联 `workflow_instances` | +| step_id/step_name | String | 步骤标识与名称 | +| assignee_id | UUID | 指派到具体人(可空) | +| assignee_role | String | 指派到角色(可空) | +| status | String | `pending/completed/rejected/cancelled/skipped` | +| outcome | String | `approved/rejected/returned/start/transfer/skipped` | +| comment | String | 审批意见/原因 | +| due_date | DateTime | 截止时间(Phase 2 可启用超时) | +| created_at/completed_at | DateTime | 创建/完成时间 | +| completed_by | UUID | 完成人 | + +#### 5.1.4 `workflow_history`(流程历史) +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| id | UUID | 主键 | +| instance_id | UUID | 关联实例(必填) | +| task_id | UUID | 关联任务(可空) | +| actor_id | UUID | 操作人(必填) | +| action | String | 动作(approve/reject/return/transfer/start/withdraw 等) | +| comment | String | 说明 | +| payload | JSON | 扩展信息(如转审 from/to、判重例外引用原单号等) | +| created_at | DateTime | 时间戳(高精度) | + +### 5.2 流程定义 JSON 规范(WorkflowConfig) +> 参考 `shenzhou-business-suite/src/types/workflow.ts`。 + +```json +{ + "steps": [ + { + "id": "dept_manager", + "name": "部门经理审批", + "type": "approval", + "assignee_rule": { "type": "department_manager" } + }, + { + "id": "ceo", + "name": "总经理审批", + "type": "approval", + "assignee_rule": { "type": "role", "value": "ceo" } + }, + { + "id": "finance", + "name": "财务复核", + "type": "approval", + "assignee_rule": { "type": "role", "value": "finance" } + }, + { + "id": "finance_mgr", + "name": "财务主管复核", + "type": "approval", + "assignee_rule": { "type": "role", "value": "finance_mgr" } + } + ] +} +``` + +### 5.3 指派规则(assignee_rule)解析(P0) +支持类型: +- `specific_user`:指派到指定用户 ID +- `role`:指派到指定 `role_code` +- `department_manager`:对报销(`entity_type=reimbursement`),必须基于 `owner_id`(费用归属人)的直属上级链向上寻找经理(最多 10 层;若经理=owner 本人则继续向上;找不到则该步骤可被跳过并写入 history) +- `submitter_manager`:基于 `submitter_id` 的直属上级链向上寻找经理(为未来其他流程预留;报销流程默认不使用该规则) + +### 5.4 一致性与并发要求(实现约束) +- 任务处理必须加锁:完成 `workflow_tasks` 时必须对该任务行做行级锁(防止重复审批)。 +- 单实例串行:同一个 `workflow_instances` 在任意时刻最多只有 1 条 `pending` 任务。 +- 幂等提交:同一 `entity_type+entity_id` 若存在未结束实例(非 approved/rejected/cancelled/terminated),再次提交应返回同一实例或拒绝重复提交。 +- 状态同步: + - 创建首个任务后,报销单状态应进入 `reviewing` + - 实例 approved → 报销单状态 `approved` + - 实例 rejected → 报销单状态 `rejected` + - 实例 cancelled(撤回)→ 报销单状态回到 `draft` + - 实例 terminated(退回)→ 报销单状态回到 `draft` + +### 5.5 动作→状态对照表(消歧义) +> 用于避免 `workflow_tasks.status` / `outcome` / `workflow_instances.current_status` / 报销 `status` 之间的实现分歧。 + +| 动作 | `workflow_tasks.status` | `workflow_tasks.outcome` | `workflow_instances.current_status` | 报销 `status` | +| :--- | :--- | :--- | :--- | :--- | +| 提交 `submit` | 首个 task=`pending` | `start` | `pending` | `reviewing` | +| 通过 `approve`(非最终) | 当前=`completed`;下一=`pending` | `approved` | `pending` | 保持 `reviewing` | +| 通过 `approve`(最终) | 当前=`completed` | `approved` | `approved` | `approved` | +| 驳回 `reject` | 当前=`rejected` | `rejected` | `rejected` | `rejected` | +| 退回 `return` | 当前=`completed`;其余 pending=`cancelled` | `returned` | `terminated` | `draft` | +| 撤回 `withdraw` | 所有 pending=`cancelled` | N/A | `cancelled` | `draft` | +| 转审 `transfer` | 保持 `pending`;`assignee_id` 更新 | `transfer` | 保持 `pending` | 保持 `reviewing` | +| 跳过(无审批人) | 当前=`skipped` | `skipped` | `pending`(推进到下一) | 保持 `reviewing` | + +--- + +## 6. 审批规则(Approval Rules,P0 可配置) +> 用于决定报销审批链的层级与角色,不在业务代码中硬编码金额阈值。 + +### 6.1 `approval_rules` 表(字段) +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| id | UUID | 主键 | +| name | String | 规则名(如“小额/中额/大额”) | +| min_amount | Decimal | 最小金额(含) | +| max_amount | Decimal | 最大金额(不含;NULL 表示无上限) | +| level1_role | String | 一级审批角色(role_code) | +| level2_role | String | 二级审批角色(可空) | +| level3_role | String | 三级审批角色(可空) | +| enabled | Bool | 启用 | +| priority | Int | 优先级(越小越优先) | + +### 6.2 匹配算法(P0 定案) +- `amount >= min_amount` +- `max_amount is null OR amount < max_amount` +- `ORDER BY priority ASC, min_amount ASC LIMIT 1` +- 若无匹配:fallback 到“默认小额规则” + +### 6.3 P0 预置 3 条规则(默认值) +| name | min_amount | max_amount | level1_role | level2_role | level3_role | priority | +| :--- | ---: | ---: | :--- | :--- | :--- | ---: | +| 小额 | 0 | 500 | `manager` | `finance` | NULL | 10 | +| 中额 | 500 | 5000 | `manager` | `finance_mgr` | NULL | 20 | +| 大额 | 5000 | NULL | `manager` | `ceo` | `finance_mgr` | 30 | + +### 6.4 审批角色 → 指派规则映射(消歧义,P0 定案) +> `approval_rules.level*_role` 存的是 **role_code**;工作流落到 `workflow_tasks` 时,需要把部分 role_code 映射为“解析规则”(assignee_rule),避免把 `manager` 误解为“一个静态角色池”。 + +| `approval_rules.level*_role`(role_code) | 生成的工作流指派规则 | `workflow_tasks` 落地形态 | +| :--- | :--- | :--- | +| `manager` | `assignee_rule.type=department_manager`(以 `owner_id` 为基准解析直属经理) | 写入 `assignee_id`(解析到具体人);若找不到则该步骤可跳过并写入 history | +| 其他 role_code(如 `finance/finance_mgr/ceo`) | `assignee_rule.type=role`,`value=role_code` | 写入 `assignee_role=role_code`(允许该角色下任一成员处理;行级锁保证只完成一次) | + +--- + +## 7. 转审(Transfer,P0) + +### 7.1 `approval_transfers` 表(字段) +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| id | UUID | 主键 | +| reimbursement_id | UUID | 关联报销单 | +| from_approver_id | UUID | 原审批人 | +| to_approver_id | UUID | 新审批人 | +| reason | String | 转审原因(至少 10 字) | +| status | String | `active/cancelled` | +| created_at/created_by | DateTime/UUID | 创建时间/操作者 | + +### 7.2 业务规则(P0 定案) +- 仅当前 `workflow_tasks.status=pending` 的实际处理人可转审 +- 转审后原审批人失去操作权(只读),新审批人成为任务 `assignee_id` +- 转审链最大次数:3(超过需 `admin` 介入) +- 转审必须落 3 处证据: + 1) 更新 `workflow_tasks.assignee_id` + 2) 插入 `approval_transfers` + 3) 插入 `workflow_history`,`action=transfer`,payload 含 from/to/reason + +--- + +## 8. 单据编号规则(Document Sequences,P0) + +### 8.1 `document_sequences` 表(字段) +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| document_type | String | 文档类型(如 `reimbursement`) | +| prefix | String | 前缀(如 `BX`) | +| date_format | String | 日期格式(如 `YYYYMMDD`) | +| separator | String | 分隔符(如 `-`) | +| number_length | Int | 流水号长度(默认 4) | +| reset_period | String | `none/daily/monthly/yearly` | +| current_number | Int | 当前序号(原子递增) | +| last_reset_date | DateTime | 上次重置时间 | + +> 约束:`document_type` 必须唯一(避免多条序列配置导致重复单号)。 + +### 8.2 P0 默认报销单号格式 +- document_type=`reimbursement` +- prefix=`BX` +- date_format=`YYYYMMDD` +- separator=`-` +- number_length=`4` +- reset_period=`daily` + +示例:`BX-20260205-0001` + +### 8.3 并发安全要求(实现约束) +- 编号生成必须数据库原子递增(等价于 `UPDATE ... RETURNING`),并在同一事务内写入报销单 +- `form_number` 必须全局唯一;失败必须回滚,禁止出现重复编号或“跳号后写入失败但编号已占用”的不可追溯状态 +- `daily reset` 的日期边界以公司本地时区为准(默认 `Asia/Shanghai`);实现时应统一使用显式时区计算,禁止依赖数据库/容器时区的隐式行为 + +--- + +## 9. 费用类型与科目映射(Expense Types,P0) + +### 9.1 `expense_types`(动态费用类型) +字段至少包含: +- `code`(全局唯一、不可修改) +- `name` +- `description` +- `category`(用于 UI 分组;系统默认使用 `travel/rd/production/office/business/assets/other`) +- `sort_order` +- `account_code_default`(默认会计科目,可配置) +- `is_system_default`(系统内置:不可删除、仅可禁用) +- `enabled`(软禁用:不在录入下拉显示,但历史可展示) + +### 9.2 系统默认费用类型(P0:37 项启用) +- 基线:复用 `shenzhou-business-suite/supabase/seed.sql` 中已启用的 35 项 +- 补齐:启用 `utilities`(水电费)与 `rent`(房租租赁费)两项高频类型 +- 兼容预留:保留 3 个 legacy 类型(`transport/meal/rd`)为禁用状态,不在 P0 下拉显示 + +详见附录 A(37 项启用)与附录 B(legacy 禁用)。 + +--- + +## 10. 发票池与文件存储(P0:本地 + 签名 URL) +> 关键原则:文件与票据元数据以 `invoice_pool` 为单一事实源;报销单通过“绑定”使用发票池条目(见 4.4)。 + +### 10.1 P0 存储方案(本地存储) +- 存储介质:本地磁盘(Docker volume) +- DB 仅保存 `storage_path`,不保存公网 URL +- 访问方式:后端生成签名 URL(TTL=10 分钟)用于浏览器预览/下载 + +### 10.2 安全基线(P0) +- 文件类型白名单:`application/pdf`、`image/jpeg`、`image/png`、`image/webp` +- 单文件大小:≤ 10MB +- 权限: + - 未绑定时:`owner_employee_id`、`uploaded_by_employee_id`、`finance/finance_mgr`、`admin` 可访问 + - 绑定后:除以上人员外,报销相关人员(owner/submitter/审批链/财务/admin)可访问 +- 文件元数据(用于判重/审计): + - `file_sha256`(必填) + - `invoice_number`/`invoice_code`(可选;齐全时参与判重) + +### 10.3 P2 迁移(MinIO/OSS) +- 替换为对象存储(S3 pre-signed URL) +- 接口与 DB 字段保持不变(`storage_path` 语义不变) + +### 10.4 `invoice_pool` 表结构(P0) +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| id | UUID | 主键 | +| owner_employee_id | UUID | 费用归属人(决定可见范围与绑定归属) | +| uploaded_by_employee_id | UUID | 上传人(可能是行政代上传) | +| storage_path | String | 本地存储路径(相对路径) | +| original_name | String | 原始文件名 | +| mime_type | String | MIME 类型 | +| file_size | Int | 文件大小(字节) | +| file_sha256 | String | 文件哈希(必填;用于判重与 OCR 去重) | +| status | String | `pending_ocr/ocr_failed/available/bound/archived` | +| archived_reason | String | `used_complete/unused_timeout/manual`(仅 `archived` 有意义) | +| archived_at | DateTime | 归档时间(可空) | +| linked_reimbursement_id | UUID | 绑定到报销单(可空;`status=bound` 必填) | +| linked_item_id | UUID | 绑定到费用明细(可空) | +| remarks | String | 备注/人工说明(可空) | +| ocr_provider | String | OCR 提供方(P0 固定 `tencent`) | +| ocr_type | String | `vat/train/flight/taxi/general` | +| ocr_result_id | UUID | 关联 `ocr_results`(可空) | +| ocr_attempts | Int | OCR 重试次数(默认 0) | +| ocr_last_error | String | OCR 最近一次错误(可空) | +| ocr_completed_at | DateTime | OCR 完成时间(可空) | +| invoice_code | String | 发票代码(可空) | +| invoice_number | String | 发票号码(可空) | +| invoice_date | Date | 开票日期(可空) | +| seller_name | String | 销售方名称(可空) | +| buyer_name | String | 购买方名称(可空) | +| amount | Decimal | 不含税金额(可空) | +| tax_amount | Decimal | 税额(可空) | +| total_amount | Decimal | 价税合计(可空) | +| is_duplicate_override | Bool | 是否为判重例外(默认 false) | +| override_ref_form_number | String | 例外引用的原单号(`is_duplicate_override=true` 时必填) | +| override_reason | String | 例外原因(`is_duplicate_override=true` 时必填,≥10 字) | +| overridden_by | UUID | 谁批准的例外(`finance_mgr`) | +| created_at/created_by | DateTime/UUID | 创建时间/操作者 | +| updated_at/updated_by | DateTime/UUID | 更新时间/操作者 | + +### 10.5 `ocr_results`(去重 OCR 结果表,P0) +> 目的:避免同一文件重复计费调用 OCR,同时持久化标准化结果用于复核与复用。 + +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| id | UUID | 主键 | +| file_sha256 | String | 文件哈希(与 `invoice_pool.file_sha256` 对齐) | +| ocr_provider | String | 提供方(P0 固定 `tencent`) | +| ocr_type | String | `vat/train/flight/taxi/general` | +| result_json | JSON | 标准化 OCR 结果(必须保存) | +| confidence | Int | 置信度(可空) | +| created_at | DateTime | 创建时间 | + +**唯一约束(P0 定案)**: +- `UNIQUE (file_sha256, ocr_provider, ocr_type)` + +### 10.6 索引与约束(P0 定案) +> Prisma Schema 不能直接表达 partial unique index,需要通过 **自定义 SQL migration** 创建(`prisma migrate dev --create-only` 后编辑迁移 SQL)。 + +**并发绑定保护(实现约束)**: +- `POST /api/invoice-pool/:id/bind` 必须对目标 `invoice_pool` 行加行级锁(`SELECT ... FOR UPDATE`),避免并发双绑。 + +**全局双策略判重(只对已使用历史生效,P0 定案)**: +- `UNIQUE (file_sha256) WHERE (status = 'bound' OR (status = 'archived' AND archived_reason = 'used_complete')) AND is_duplicate_override = false` +- `UNIQUE (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` + +**例外字段完整性(DB check,防漏填)**: +- 当 `is_duplicate_override = true` 时,`override_ref_form_number`、`override_reason`、`overridden_by` 必填,且 `char_length(override_reason) >= 10` + +### 10.7 API 约定(P0) +- 上传:`POST /api/invoice-pool/upload`(multipart;入参 `file` + 可选 `owner_employee_id` + 可选 `ocr_type`;默认 `ocr_type=general`) + - 上传即创建 `invoice_pool` 记录并返回 `invoice_pool_id` + - OCR 结果命中 `ocr_results` 时应复用(不重复计费) +- 签名 URL:`POST /api/invoice-pool/:id/signed-url`(TTL=10m;服务端按 10.2 的规则鉴权) + +--- + +## 11. 认证与会话(P0) + +### 11.1 登录方式(P0) +- 支持 `手机号/邮箱 + 密码` 登录 +- 验证码仅用于“找回/重置密码”(P0:邮件验证码;短信验证码/SSO 放 P1/P2) + +### 11.2 密码策略(P0) +- 长度:≥12 +- 复杂度:必须同时包含 **大写 + 小写 + 数字**(允许特殊字符但不强制) +- 弱密码拦截:禁止命中弱密码库(Top 10k) +- 错误次数锁定:15 分钟内失败 5 次 → 锁定 30 分钟;管理员可解锁 +- 首次登录:必须修改初始密码 + +### 11.3 Token 与会话(JWT + Refresh Rotation) +- Access Token: + - 过期:15 分钟 + - 传输:`Authorization: Bearer ` + - 存储:仅内存(禁止 localStorage/sessionStorage) +- Refresh Token: + - 过期:7 天 + - 客户端存储:`HttpOnly + Secure + SameSite=Strict` Cookie + - 轮换:每次 refresh 都签发新 refresh,旧 refresh 立即失效 + - 服务端存储:数据库保存 refresh 的哈希(不可明文),并绑定设备信息(deviceFingerprint/ipAddress/userAgent) +- 强制下线:员工 `tokenVersion + 1`(密码重置/离职/管理员强制下线)使旧 access 立即失效 + +**实现约束(P0 定案)**: +- `tokenVersion` 立即失效机制: + - Access Token 载荷必须包含 `tokenVersion` + - 每次请求服务端必须校验 `tokenVersion`(从 DB 或缓存读取当前值) + - 若 Token 中 `tokenVersion < DB tokenVersion`,立即返回 `AUTH_004` +- Refresh 重放检测(防止 refresh 泄漏后被持续利用): + - 每次 refresh 成功后,旧 refresh 的 hash 必须标记为已使用或直接删除 + - 若检测到"已使用的旧 refresh 被重放",说明可能存在泄漏: + - 立即撤销该员工所有有效会话(`revoked_at = now()`) + - 员工 `tokenVersion + 1` + - 写入 `audit_logs`(`action=REFRESH_REUSE_DETECTED`) + - 返回 `AUTH_005` 并要求用户重新登录 +- Refresh 同站校验(额外防线): + - `POST /api/auth/refresh` 若请求携带 `Origin`/`Referer`,则必须与本站域名匹配(按服务端配置的 `APP_ORIGIN`) + - 不匹配则拒绝该请求并返回 `AUTH_005`,同时写入 `audit_logs`(`action=AUTH_REFRESH_ORIGIN_MISMATCH`) + +### 11.4 Auth API(P0) +- `POST /api/auth/login` → 返回 access;refresh 通过 `Set-Cookie` +- `POST /api/auth/refresh` → 从 cookie 轮换 refresh,返回新 access +- `POST /api/auth/logout` → 撤销当前会话并清 cookie +- `POST /api/auth/forgot-password`(邮件验证码) +- `POST /api/auth/reset-password` + +### 11.5 `auth_sessions`(Refresh 会话表,P0) +> 目的:服务端可撤销 refresh、限制并发会话数、绑定设备信息以支持 Phase 2 异常登录检测。 + +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| id | UUID | 主键 | +| employee_id | UUID | 所属员工 | +| refresh_token_hash | String | refresh 的哈希(不可明文) | +| device_fingerprint | String | 设备指纹(必填) | +| ip_address | String | 登录 IP(必填) | +| user_agent | String | UA(必填) | +| created_at | DateTime | 创建时间 | +| last_seen_at | DateTime | 最近活跃时间(refresh 时更新) | +| expires_at | DateTime | 过期时间(= 7 天) | +| revoked_at | DateTime | 撤销时间(可空) | + +**会话规则(P0 定案)**: +- 同一员工最多 5 个有效会话(`revoked_at is null AND expires_at > now()`);超过时撤销最旧会话 +- 退出登录:撤销当前会话(写 `revoked_at`) +- 密码重置/离职/管理员强制下线:撤销该员工所有有效会话 + `tokenVersion + 1` + +--- + +## 12. 数据安全与审计(P0) + +### 12.1 敏感字段清单 +- 员工:银行卡号、身份证号 +- 报销:发票/附件文件(可能包含敏感信息) + +### 12.2 脱敏展示规则 +- 银行卡:仅显示后 4 位(例:`**** **** **** 1234`) +- 身份证:前 3 位 + 后 4 位(例:`310***********1234`) + +### 12.3 加密存储(应用层加密) +- 算法:AES-256-GCM +- 每条记录随机 nonce +- 密钥来自服务端环境变量(P0);后续可迁移 KMS +- 明确:加密字段不支持按明文检索(禁止为“搜索”而明文存储) + +### 12.4 审计分层(必须写清楚) +- `workflow_history`:审批链路与流程动作的权威记录(谁在何时对哪个任务做了什么) +- `audit_logs`(系统审计):登录/锁定/重置/敏感字段明文查看/权限配置变更/判重例外等安全关键事件 + +**`audit_logs` 表(建议字段)**: +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| id | UUID | 主键 | +| request_id | String | 关联请求 ID | +| actor_id | UUID | 操作人 | +| action | String | 动作码(如 `AUTH_LOGIN_SUCCESS`/`INVOICE_DUPLICATE_OVERRIDE`) | +| target_type | String | 目标类型(如 `employee`/`reimbursement`/`attachment`) | +| target_id | UUID | 目标 ID(可空) | +| ip/user_agent | String | 来源信息 | +| extra | JSON | 扩展信息(含变更快照) | +| created_at | DateTime | 时间戳 | + +对“敏感字段变更/授权变更/关键配置变更”,`extra.changes` 必须包含脱敏前后快照: +`{ changes: { field: { before: masked, after: masked } } }`(禁止记录明文) + +保留期:至少 1 年。 + +### 12.5 审计动作码(P0 最小集) +> 目的:把“高风险动作”与“关键链路”落到可检索的审计事件上,便于排查与合规追溯。 + +**OCR(发票识别)** +- `OCR_REQUESTED`:发起 OCR 请求(含 `ocr_type`、file_sha256) +- `OCR_SUCCEEDED`:OCR 成功(含 `ocr_result_id`、confidence 摘要) +- `OCR_FAILED`:OCR 失败(含错误摘要;禁止记录 rawResponse) +- `OCR_RETRIED`:OCR 重试(含 attempts 与节流结果) + +**发票池(Invoice Pool)** +- `INVOICE_POOL_UPLOADED`:文件入池(owner/uploaded_by、mime/size/hash) +- `INVOICE_POOL_EDITED`:人工修正关键字段(`extra.changes` 记录脱敏前后快照) +- `INVOICE_POOL_BOUND`:绑定到报销单 +- `INVOICE_POOL_UNBOUND`:从报销单解绑 +- `INVOICE_POOL_ARCHIVED`:人工归档(`archived_reason=manual`) +- `INVOICE_POOL_AUTO_ARCHIVED`:系统自动归档(`archived_reason=unused_timeout/used_complete`) + +**判重与例外** +- `INVOICE_DUPLICATE_OVERRIDE`:判重例外(必须包含引用原单号 + 原因摘要) + +--- + +## 13. API 契约与错误码(P0) +> 本章为 API 契约唯一权威(P0 定案)。目的:前后端联调与测试可直接以此为准,不在实现阶段临场补协议。 + +### 13.1 通用响应结构(P0) +- 所有响应必须返回 `requestId`(可放在响应头 `x-request-id`,并在 body 同步返回) +- 失败响应结构: +```json +{ + "requestId": "01J....", + "error": { + "code": "AUTH_001", + "message": "用户名或密码错误", + "details": {} + } +} +``` + +- 成功响应结构(对象): +```json +{ + "requestId": "01J....", + "data": {} +} +``` + +- 成功响应结构(列表 + 分页): +```json +{ + "requestId": "01J....", + "data": [], + "pageInfo": { + "page": 1, + "pageSize": 20, + "total": 0, + "totalPages": 0 + } +} +``` + +#### 13.1.1 分页 / 排序 / 筛选约定(P0) +> 目的:前后端统一口径,避免“边开发边发明 query 参数”。 + +- `page`:页码(从 1 开始;默认 1) +- `pageSize`:每页数量(默认 20;最大 100) +- `sort`:排序(格式:`field:asc|desc`;多个排序用逗号分隔,例如 `sort=created_at:desc,total_amount:desc`) +- `q`:可选的关键字搜索(各接口自行决定是否支持) +- 过滤参数:按接口逐项定义(P0 不引入通用 filter DSL) + +#### 13.1.2 字段类型约定(P0) +- `UUID`:JSON 中一律为 string +- `Date`:`YYYY-MM-DD`(业务日期) +- `DateTime`:ISO 8601(建议带时区,例如 `2026-02-06T09:00:00+08:00`) +- `Decimal/Money`:JSON 中一律为 string(禁止用 number,避免精度丢失) + +### 13.2 核心业务 API(P0) + +#### 13.2.1 报销单(Reimbursements)CRUD +| 方法 | 路径 | 权限码 | 说明 | +| :--- | :--- | :--- | :--- | +| POST | `/api/reimbursements` | `expense.create` | 创建报销草稿(默认 `owner_id=me, submitter_id=me`;代提交场景可指定 owner,但必须存在有效授权) | +| GET | `/api/reimbursements` | `expense.view_own`(或 `expense.view_all`) | 报销列表(分页/筛选/排序);服务端按权限自动收敛数据范围 | +| GET | `/api/reimbursements/:id` | `expense.view_own`(或 `expense.view_all`) | 报销详情(含 items + 已绑定的发票池条目摘要;可包含当前 pending task 用于“节点文案”展示) | +| PATCH | `/api/reimbursements/:id` | `expense.edit_own` | 更新草稿/驳回单(仅允许 `status in (draft, rejected)`) | +| DELETE | `/api/reimbursements/:id` | `expense.delete_own` | 删除草稿(仅允许 `status=draft`;需删除 items;并自动解绑其绑定的发票池条目(回到 `available`;不删除发票池文件)) | + +**列表筛选建议(P0)**: +- `status`:`draft/reviewing/approved/paid/rejected/cancelled` +- `formNumber`:精确匹配 +- `ownerId` / `submitterId` / `departmentId` +- `reimbursementDateFrom` / `reimbursementDateTo`(业务日期) +- `createdAtFrom` / `createdAtTo`(时间戳) + +#### 13.2.2 审批待办(Workflow Tasks) +| 方法 | 路径 | 权限码 | 说明 | +| :--- | :--- | :--- | :--- | +| GET | `/api/workflow/tasks` | 登录即可(服务端鉴权) | 待办/已办列表(分页/筛选/排序);默认只返回“我可处理”的任务(`assignee_id=me` 或 `assignee_role in myRoles`) | + +**列表筛选建议(P0)**: +- `status`:默认 `pending`;可选 `completed/rejected/cancelled/skipped` +- `entityType`:默认 `reimbursement` +- `reimbursementId`(便于从报销详情页拉取当前任务/历史) + +#### 13.2.3 流程动作(Workflow / Reimbursement Actions) +| 方法 | 路径 | 权限码 | 说明 | +| :--- | :--- | :--- | :--- | +| POST | `/api/reimbursements/:id/submit` | `expense.submit` | 提交报销并发起工作流(生成单号、创建 instance/task/history) | +| POST | `/api/reimbursements/:id/withdraw` | `expense.withdraw` | 撤回:取消流程实例并回到草稿(仅审批人未处理前) | +| POST | `/api/workflow/tasks/:taskId/approve` | `expense.approve` | 通过当前任务并推进 | +| POST | `/api/workflow/tasks/:taskId/reject` | `expense.approve` | 驳回并终止流程 | +| POST | `/api/workflow/tasks/:taskId/return` | `expense.approve` | 退回:终止当前实例(instance=`terminated`)并回到草稿;申请人修改后需重新 submit 才会进入新一轮 | +| POST | `/api/workflow/tasks/:taskId/transfer` | `expense.transfer` | 转审(reason≥10,链≤3) | +| POST | `/api/reimbursements/:id/pay` | `expense.finance.pay` | 付款标记(approved → paid) | + +#### 13.2.4 员工与组织(Employees / Departments,P0) +| 方法 | 路径 | 权限码 | 说明 | +| :--- | :--- | :--- | :--- | +| GET | `/api/me` | 登录即可 | 获取当前登录员工信息(含 role_codes;前端用于菜单与数据范围判断) | +| GET | `/api/employees` | `system.users` | 员工列表(分页/筛选/排序) | +| GET | `/api/employees/:id` | `system.users`(或 `expense.view_own` 仅本人) | 员工详情 | +| POST | `/api/employees` | `system.users` | 创建员工(初始密码策略见第 11 章;首次登录强制改密) | +| PATCH | `/api/employees/:id` | `system.users` | 更新员工信息(敏感字段加密/脱敏与审计见第 12 章) | +| POST | `/api/employees/:id/archive` | `system.users` | 员工离职/归档(禁止登录并强制下线,见 4.1.2/11.3) | +| GET | `/api/departments` | 登录即可 | 部门树/列表(默认隐藏归档部门,可通过 query 开启) | +| POST | `/api/departments` | `system.users` | 创建部门 | +| PATCH | `/api/departments/:id` | `system.users` | 更新部门 | +| POST | `/api/departments/:id/archive` | `system.users` | 归档部门(归档约束见 3.2) | + +#### 13.2.5 认证(Auth,P0) +| 方法 | 路径 | 权限码 | 说明 | +| :--- | :--- | :--- | :--- | +| POST | `/api/auth/login` | N/A | 登录:返回 access token;refresh token 通过 `Set-Cookie` 写入 `HttpOnly` cookie(见 11.3) | +| POST | `/api/auth/refresh` | N/A | 刷新:轮换 refresh cookie,返回新 access token(refresh rotation + 重放检测见 11.3) | +| POST | `/api/auth/logout` | 登录即可 | 退出:撤销当前会话并清 cookie | +| POST | `/api/auth/forgot-password` | N/A | 忘记密码:发送邮件验证码(P0) | +| POST | `/api/auth/reset-password` | N/A | 重置密码:验证码校验 + `tokenVersion + 1` 强制下线(见 11.3) | + +#### 13.2.6 发票池(Invoice Pool)+ OCR(P0) +| 方法 | 路径 | 权限码 | 说明 | +| :--- | :--- | :--- | :--- | +| POST | `/api/invoice-pool/upload` | `invoice.upload` | 上传票据文件入池(multipart;入参 `file` + 可选 `owner_employee_id` + 可选 `ocr_type`;默认 `ocr_type=general`;返回 `invoice_pool_id` + 初始 `status`) | +| GET | `/api/invoice-pool` | 登录即可(服务端鉴权) | 发票池列表(分页/筛选/排序);默认只返回与本人相关(owner 或 uploaded_by),`finance/admin` 可查看全量 | +| GET | `/api/invoice-pool/:id` | 登录即可(服务端鉴权) | 发票池详情(含 OCR 摘要字段) | +| PATCH | `/api/invoice-pool/:id` | 登录即可(服务端鉴权) | 更新备注/人工修正关键字段(如 `invoice_code/number/date/amount`);允许将 `ocr_failed` 条目“人工确认”转为 `available` | +| POST | `/api/invoice-pool/:id/retry-ocr` | 登录即可(服务端鉴权) | 重试 OCR(仅 `status=ocr_failed`;次数/频控见下) | +| POST | `/api/invoice-pool/:id/bind` | 登录即可(服务端鉴权) | 绑定到报销单(1:1;并发与判重硬校验在此发生) | +| POST | `/api/invoice-pool/:id/unbind` | 登录即可(服务端鉴权) | 从报销单解绑(仅草稿/驳回可解绑;解绑后回到 `available`) | +| POST | `/api/invoice-pool/:id/archive` | 登录即可(服务端鉴权) | 手工归档(仅未绑定;归档为 `archived_reason=manual`) | +| POST | `/api/invoice-pool/:id/signed-url` | 登录即可(服务端鉴权) | 获取票据文件签名 URL(TTL=10m;服务端按第 10 章规则鉴权) | + +**上传归属权限链(P0 定案)**: +- 当 `owner_employee_id != 当前登录用户` 时: + - 若当前登录用户持有 `system.users`(hr/admin)→ 允许 + - 若当前登录用户持有 `expense.proxy_submit` 且角色包含 `administration` → 必须存在有效 `delegation_grants`(principal=owner, delegate=当前用户, scope 包含 `reimbursement_submit`) + - 否则拒绝(`PERM_001`) + +**绑定并发语义(P0 定案)**: +- `POST /api/invoice-pool/:id/bind` 必须在事务内对目标 `invoice_pool` 行执行 `SELECT ... FOR UPDATE`(行级锁)。 +- 若已是 `bound` 或 `linked_reimbursement_id` 非空:返回 409 `VALID_005`(发票已被其他报销单占用/绑定)。 +- 若命中全局判重唯一约束:返回 409 `VALID_003`(发票判重命中)。 + +**retry-ocr 次数与频控(P0 固定值)**: +- 次数:`ocr_attempts <= 3`;超过返回 429 `VALID_006`(OCR 重试次数超限)。 +- 间隔:同一条目两次重试间隔 `>= 60s`;过于频繁返回 429 `VALID_007`(OCR 重试过于频繁)。 + +### 13.3 配置读取 API(P0 只读) +| 方法 | 路径 | 权限码 | 说明 | +| :--- | :--- | :--- | :--- | +| GET | `/api/expense-types?enabled=true` | 登录即可 | 获取启用费用类型(下拉选项) | +| GET | `/api/approval-rules?enabled=true` | 登录即可 | 获取启用审批规则(便于运维/排查) | +| GET | `/api/document-sequences/reimbursement` | `system.settings`(可选) | 查看报销单号规则配置 | + +### 13.4 文件与签名 URL(P0) +> 上传与文件预览/下载统一归口到“发票池”模块,避免 `files` 与 `invoice_pool` 双源。 + +| 方法 | 路径 | 权限码 | 说明 | +| :--- | :--- | :--- | :--- | +| POST | `/api/invoice-pool/upload` | `invoice.upload` | 上传票据文件入池(见 13.2.6) | +| POST | `/api/invoice-pool/:id/signed-url` | 登录即可(服务端鉴权) | 获取签名 URL(TTL=10m;服务端校验“是否与票据相关/是否有权限”) | + +### 13.5 Delegation Grants API(P0) +| 方法 | 路径 | 权限码 | 说明 | +| :--- | :--- | :--- | :--- | +| GET | `/api/delegation-grants` | 登录即可 | 默认返回与本人相关(principal/delegate);`system.users` 可查询任意 | +| POST | `/api/delegation-grants` | 登录即可 | 本人创建:principal=自己;代维护需 `system.users`;所有变更写入 `audit_logs` | +| POST | `/api/delegation-grants/:id/revoke` | 登录即可 | 本人撤销:principal=自己;代维护需 `system.users`;所有变更写入 `audit_logs` | + +**实现约定(P0)**: +- `GET /api/delegation-grants` 响应建议内嵌 `principal`/`delegate` 的员工摘要(如 `id/employee_no/name/department_id`),用于“行政代提交选择人员”场景,避免前端额外拉取员工目录。 +- 默认只返回“当前仍有效”的授权(`revoked_at is null` 且时间在有效期内);如需查看历史可通过 query 显式开启(例如 `includeInactive=true`)。 + +### 13.6 错误码(P0 最小集) +#### 13.6.1 认证(Auth) +- `AUTH_001`:用户名或密码错误(401) +- `AUTH_002`:账号已锁定(423) +- `AUTH_003`:账号已离职/禁用(403) +- `AUTH_004`:Access Token 已过期/无效(401) +- `AUTH_005`:Refresh Token 无效/已撤销(401) + +#### 13.6.2 权限(Permission) +- `PERM_001`:无权限(403) +- `PERM_002`:不在审批范围/非当前处理人(403) + +#### 13.6.3 校验(Validation) +- `VALID_001`:参数校验失败(400) +- `VALID_002`:转审原因不足 10 字(400) +- `VALID_003`:发票判重命中(409) +- `VALID_004`:发票重复例外被禁止(原单已打款/待打款)(409) +- `VALID_005`:发票已被其他报销单占用/绑定(409) +- `VALID_006`:OCR 重试次数超限(429) +- `VALID_007`:OCR 重试过于频繁(429) + +--- + +## 14. 非功能需求 +| 类别 | 要求 | +| :--- | :--- | +| 响应式 | PC + 移动端(响应式 Web) | +| 性能 | 页面加载 <2s,API 响应 <500ms(P95) | +| 安全 | JWT(access+refresh rotation)、RBAC 权限码、审计日志、敏感字段加密与脱敏 | +| 部署 | Docker + Nginx(反向代理),域名 oa.hyinda.com | + +--- + +## 15. 数据模型概览(高层) +```mermaid +erDiagram + departments ||--o{ employees : contains + employees ||--o{ employee_roles : assigns + employees ||--o{ delegation_grants : principal + employees ||--o{ delegation_grants : delegate + + employees ||--o{ auth_sessions : has + employees ||--o{ audit_logs : writes + + employees ||--o{ reimbursement_forms : submits + employees ||--o{ reimbursement_forms : owns + reimbursement_forms ||--|{ reimbursement_items : has + reimbursement_items }o--|| expense_types : uses + + employees ||--o{ invoice_pool : owns + employees ||--o{ invoice_pool : uploads + ocr_results ||--o{ invoice_pool : caches + reimbursement_forms ||--o{ invoice_pool : binds + reimbursement_items ||--o{ invoice_pool : links + + workflow_definitions ||--o{ workflow_instances : defines + reimbursement_forms ||--o{ workflow_instances : starts + workflow_instances ||--o{ workflow_tasks : has + workflow_instances ||--o{ workflow_history : logs + + reimbursement_forms ||--o{ approval_transfers : transfers +``` + +> 说明:上图为概念关系图,具体字段以各章节表结构为准。 + +--- + +## 16. 迭代计划 + +### Phase 1(核心 / P0) +- [x] 技术栈确定(Fastify + Prisma + PostgreSQL) +- [ ] 员工与组织(含归档约束、角色集合) +- [ ] 权限码体系与服务端校验(RBAC) +- [ ] 工作流引擎(4 表)落库与服务层事务 +- [ ] `approval_rules`(预置 3 条)+ 审批链生效 +- [ ] 转审(reason≥10,链路≤3) +- [ ] `document_sequences` 单号规则(BX-YYYYMMDD-0001) +- [ ] 费用类型 `expense_types`(默认启用 37 项)+ 下拉动态加载 +- [ ] 发票池上传(本地存储 + 签名 URL)+ SHA256 + 腾讯云 OCR(结果持久化/去重/重试;不阻塞提交)+ 双策略判重(bind/submit 生效)+ 例外审计 +- [ ] 报销单 CRUD + 提交/撤回/审批/付款标记 +- [ ] 站内通知 + 邮件 + +### Phase 2(扩展 / P1-P2) +- [ ] OCR 能力增强(更多票种/批量处理/队列与更完整的失败重试策略) +- [ ] 企微/钉钉通知 +- [ ] 报表导出 +- [ ] 对象存储(MinIO/OSS) +- [ ] 工作流扩展到其他实体(请假/采购等) + +--- + +## 附录 A:系统默认费用类型(P0:37 项启用) +> 来源:`shenzhou-business-suite/supabase/seed.sql`(启用 35 项)+ hyb-oa 补齐启用 `utilities/rent`(2 项)。 + +| code | name | category | sort_order | account_code_default | description | +| :--- | :--- | :--- | ---: | :--- | :--- | +| `transport_long` | 长途交通费 | `travel` | 101 | `5102-01` | 飞机、火车、长途大巴等城市间交通费用 | +| `accommodation` | 住宿费 | `travel` | 102 | `5102-01` | 出差期间酒店、宾馆住宿费 | +| `meal_allowance` | 伙食补贴 | `travel` | 103 | `5102-01` | 出差期间伙食补助 | +| `transport_city` | 市内交通费 | `travel` | 104 | `5102-01` | 出差地或日常公事打车、公交费 | +| `rd_material` | 研发直接材料 | `rd` | 201 | `5102-07` | 研发专用的元器件、耗材、PCB打样等 | +| `rd_test` | 研发测试试验费 | `rd` | 202 | `5102-07` | 第三方检测、环境试验费 | +| `rd_service` | 研发技术服务费 | `rd` | 203 | `5102-07` | 外包开发、技术咨询服务 | +| `rd_sample` | 研发样品/样机费 | `rd` | 204 | `5102-07` | 原型机、模型制作费用 | +| `hydro_calibration` | 水声设备标定费 | `rd` | 205 | `5102-07` | 水听器、换能器等核心设备标定 | +| `test_site_rental` | 试验场地租赁费 | `rd` | 206 | `5102-07` | 水池、消声室、湖试场地租赁 | +| `rd_software` | 研发软件订阅 | `rd` | 207 | `5102-07` | 水声仿真、CAD设计软件(≤5000元) | +| `rd_patent` | 专利申请/维护费 | `rd` | 208 | `5102-07` | 发明专利代理费、年费 | +| `raw_material` | 原材料 | `production` | 301 | `1403` | 生产用主材、辅材 | +| `prod_direct_material` | 生产成本-直接材料 | `production` | 302 | `5001-01` | 生产过程直接消耗的材料 | +| `mfg_consumable` | 制造费用-物料消耗 | `production` | 303 | `5101-02` | 车间消耗性物料 | +| `outsourcing` | 外协加工费 | `production` | 304 | `5001-03` | 外壳加工、组件代工等 | +| `mfg_repair` | 生产设备维修费 | `production` | 305 | `5101-03` | 车间仪器设备维修保养 | +| `office` | 办公费 | `office` | 401 | `5102-02` | 文具、纸张、打印耗材等 | +| `communication` | 通讯费 | `office` | 402 | `5102-03` | 话费、宽带、网络服务费 | +| `property` | 物业费 | `office` | 403 | `5102-15` | 物业管理、停车费 | +| `vehicle` | 车辆费 | `office` | 404 | `5102-10` | 油费、过路费、保养(私车公用需附协议) | +| `welfare` | 福利费 | `office` | 405 | `5102-19` | 下午茶、团建、礼品(不超工资14%) | +| `maintenance` | 维修费 | `office` | 406 | `5102-09` | 办公电脑、空调、设施维修 | +| `training` | 培训费 | `office` | 407 | `5102-05` | 员工外部培训、购书 | +| `software` | 软件服务费 | `office` | 408 | `5102-02` | Office/Adobe/SaaS订阅(非研发) | +| `express` | 快递物流费 | `office` | 409 | `5102-02` | 样品寄送、文件快递 | +| `utilities` | 水电费 | `office` | 410 | `5102-14` | 办公场地水电费、能耗费 | +| `rent` | 房租租赁费 | `office` | 411 | `5102-06` | 办公室/设备租赁费 | +| `entertainment` | 业务招待费 | `business` | 501 | `5102-04` | 客户宴请、礼品(注意限额) | +| `meeting` | 会议费 | `business` | 502 | `5102-13` | 会议场地租赁、茶歇 | +| `marketing` | 市场推广费 | `business` | 503 | `5101-01` | 展会、画册制作、广告 | +| `consulting` | 咨询服务费 | `business` | 504 | `5102-08` | 法律顾问、咨询费 | +| `consulting_audit` | 代账/审计费 | `business` | 505 | `5102-08` | 记账报税、年度审计费 | +| `fixed_assets` | 固定资产 | `assets` | 601 | `1601` | 单价≥2000元 + 使用年限≥1年 | +| `low_value_consumables` | 低值易耗品 | `assets` | 602 | `1411` | 工具、小电器(<2000) | +| `bank_fees` | 银行手续费 | `other` | 701 | `5103-01` | 转账手续费、账户维护费 | +| `other` | 其他合规费用 | `other` | 901 | `5102-99` | 无法归类的小额费用 | + +## 附录 B:Legacy 费用类型(禁用,迁移预留) +| code | name | enabled | note | +| :--- | :--- | :---: | :--- | +| `meal` | 旧-餐费 | ❌ | 兼容历史/迁移预留,P0 不在下拉显示 | +| `rd` | 旧-研发费 | ❌ | 兼容历史/迁移预留,P0 不在下拉显示 | +| `transport` | 旧-交通费 | ❌ | 兼容历史/迁移预留,P0 不在下拉显示 | + +## 附录 C:P0 验收用例(最小集) +> 每条用例需覆盖:输入 → 状态流转 → 权限结果 → 日志记录点(workflow_history / audit_logs)。 + +1) **审批规则匹配(3 档)** + - 输入:`total_amount=100/1000/6000` + - 预期:分别命中“小额/中额/大额”规则,生成对应审批链(manager→finance / manager→finance_mgr / manager→ceo→finance_mgr) + +2) **工作流实例与任务创建** + - 输入:员工提交草稿 + - 预期:创建 `workflow_instances`(pending)+ 首个 `workflow_tasks`(pending)+ `workflow_history`(start);报销状态进入 `reviewing` + +3) **任务推进(approve)** + - 输入:审批人执行 approve + - 预期:当前 task completed;创建下一 task(若存在);或实例 approved 并将报销状态置为 `approved` + +4) **驳回(reject) vs 退回(return)** + - 输入:审批人执行 reject/return(带 comment) + - 预期(reject):实例 rejected;报销状态 `rejected`;history 记录 reject;申请人可编辑后再次 submit + - 预期(return):实例 terminated;报销状态回到 `draft`;所有 pending task cancelled;history 记录 return;申请人修改后再次 submit + +5) **撤回(withdraw)** + - 输入:申请人在审批人未处理前撤回 + - 预期:实例 cancelled;所有 pending task cancelled;报销状态回到 `draft`;history 记录 withdraw + +6) **转审(transfer)约束与证据** + - 输入:当前审批人转审(reason 少于/不少于 10 字) + - 预期:不足 10 字返回 `VALID_002`;满足时: + - `workflow_tasks.assignee_id` 更新 + - 插入 `approval_transfers` + - 插入 `workflow_history`(action=transfer,payload 含 from/to/reason) + +7) **状态 vs 节点一致性** + - 输入:报销单处于 `reviewing` + - 预期:页面“当前节点文案”来自 `workflow_tasks` 的 pending 任务(而非 DB status 细分) + +8) **费用类型启用/禁用语义** + - 输入:禁用某费用类型 + - 预期:新建报销下拉不再显示;历史报销仍可展示该类型名称(不回退为 code) + +9) **发票池入池 + OCR 结果持久化 + 去重复用(`ocr_results`)** + - 输入:上传同一文件两次(同 `file_sha256`),OCR 类型相同 + - 预期: + - 第二次上传命中 `ocr_results`(不重复计费调用 OCR) + - `invoice_pool` 记录复用 `ocr_result_id` 并写入关键字段摘要(invoice_code/number/date/amount 等) + - 日志:写 `INVOICE_POOL_UPLOADED`;如触发 OCR,写 `OCR_REQUESTED/OCR_SUCCEEDED`(禁止落库 rawResponse) + +10) **OCR 失败与重试(次数/频控)** + - 输入:制造 OCR 失败 → 多次调用 `POST /api/invoice-pool/:id/retry-ocr` + - 预期: + - `ocr_attempts` 递增;失败条目保持 `status=ocr_failed` + - 超过 3 次返回 `VALID_006`(429) + - 60 秒内重复重试返回 `VALID_007`(429) + - 日志:写 `OCR_FAILED/OCR_RETRIED`(错误摘要即可) + +11) **发票池绑定并发冲突(行锁 + 409)** + - 输入:两个报销草稿同时 bind 同一张发票池条目 + - 预期:一个成功,一个返回 `VALID_005`(409);服务端在 bind 事务内对 `invoice_pool` 行 `SELECT ... FOR UPDATE` + +12) **全局判重与例外(绑定/提交阶段硬校验)** + - 输入:已有“使用完成并归档”的发票(`status=archived, archived_reason=used_complete`),再尝试用相同 `file_sha256` 或相同 `invoice_code+invoice_number` 绑定/提交 + - 预期: + - 普通用户返回 `VALID_003`(409) + - `finance_mgr` 可执行判重例外,必填原单号+原因(≥10);若原单处于 `approved/paid` 则返回 `VALID_004` + - 日志:例外必须写 `audit_logs`(`INVOICE_DUPLICATE_OVERRIDE`) + +13) **90 天游离自动归档不参与全局判重** + - 输入:一张发票池条目 90 天未绑定自动归档(`archived_reason=unused_timeout`),再上传/绑定同一张文件 + - 预期:不因“未使用的超期归档”触发全局判重拦截;仅当新条目被使用并进入 `bound/used_complete` 后才进入判重历史 + +14) **签名 URL 鉴权与过期** + - 输入:非票据相关人员请求 `POST /api/invoice-pool/:id/signed-url`;或使用过期 URL + - 预期:无权获取(403/`PERM_001`);过期访问失败;必要时写入 `audit_logs` + +15) **行政代上传授权链(owner ≠ uploaded_by)** + - 输入:行政为员工 A 上传票据(`owner_employee_id=A, uploaded_by=行政`) + - 预期: + - 有有效 `delegation_grants`(principal=A, delegate=行政, scope 含 `reimbursement_submit`)时允许上传 + - 无授权时拒绝(403/`PERM_001`) + - 补充:hr/admin(持 `system.users`)可在无 delegation 的情况下代维护上传;所有代维护必须写入 `audit_logs` + +16) **敏感字段脱敏(masking)** + - 输入:读取/变更员工敏感字段(银行卡/身份证)并查看相关响应与审计 + - 预期:对外响应仅返回脱敏格式;`audit_logs.extra.changes` 仅包含脱敏前后快照(禁止明文) + +17) **代提交审批链解析(owner 基准)** + - 输入:行政为员工 A 代提交报销单(submitter=行政,owner=A) + - 预期:首节点审批人按 **A 的直属经理** 解析(而非行政的直属经理);`workflow_instances.context_data` 包含 `owner_id/department_id` + +18) **`*_own` 语义(owner 或 submitter)** + - 输入:行政查看/协助处理其代提交的单据;owner 编辑草稿/驳回单 + - 预期:当 `owner_id=me OR submitter_id=me` 时允许 `view_own`;`draft/rejected` 允许 `edit_own`;仅 `draft` 允许 `delete_own`;`withdraw` 仍需满足“审批人未处理前”等状态约束 diff --git a/docs/tech_stack_report.md b/docs/tech_stack_report.md new file mode 100644 index 00000000..d0dfc38b --- /dev/null +++ b/docs/tech_stack_report.md @@ -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(本地/开发用) diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 00000000..fac80d26 --- /dev/null +++ b/server/.env.example @@ -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" diff --git a/server/README.md b/server/README.md new file mode 100644 index 00000000..19f33b11 --- /dev/null +++ b/server/README.md @@ -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 冒烟。 diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 00000000..07b6794f --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,2282 @@ +{ + "name": "server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "server", + "version": "1.0.0", + "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" + } + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", + "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", + "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/types": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", + "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", + "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz", + "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==", + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz", + "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==", + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.0.tgz", + "integrity": "sha512-aO5giNgFN+rD4fMUAkro9nEL7c9gh5Q3lh0ZGKMDAhQAytf22HLicF/qZ2EYTDmH+XL2WvdazwBfOdmp6NiwBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/jwt": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz", + "integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.2.0", + "@lukeed/ms": "^2.0.2", + "fast-jwt": "^6.0.2", + "fastify-plugin": "^5.0.1", + "steed": "^1.1.3" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/multipart": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz", + "integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz", + "integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^1.0.1", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@mrleebo/prisma-ast": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", + "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==", + "license": "MIT", + "dependencies": { + "chevrotain": "^10.5.0", + "lilconfig": "^2.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@prisma/adapter-pg": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.3.0.tgz", + "integrity": "sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.3.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/client": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.3.0.tgz", + "integrity": "sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.3.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.3.0.tgz", + "integrity": "sha512-dG/ceD9c+tnXATPk8G+USxxYM9E6UdMTnQeQ+1SZUDxTz7SgQcfxEqafqIQHcjdlcNK/pvmmLfSwAs3s2gYwUw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.3.0.tgz", + "integrity": "sha512-QyMV67+eXF7uMtKxTEeQqNu/Be7iH+3iDZOQZW5ttfbSwBamCSdwPszA0dum+Wx27I7anYTPLmRmMORKViSW1A==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.3.0.tgz", + "integrity": "sha512-yh/tHhraCzYkffsI1/3a7SHX8tpgbJu1NPnuxS4rEpJdWAUDHUH25F1EDo6PPzirpyLNkgPPZdhojQK804BGtg==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz", + "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==", + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.3.15", + "@electric-sql/pglite-socket": "0.0.20", + "@electric-sql/pglite-tools": "0.2.20", + "@hono/node-server": "1.19.9", + "@mrleebo/prisma-ast": "0.13.1", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "4.11.4", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.3.0.tgz", + "integrity": "sha512-Wdlezh1ck0Rq2dDINkfSkwbR53q53//Eo1vVqVLwtiZ0I6fuWDGNPxwq+SNAIHnsU+FD/m3aIJKevH3vF13U3w==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.3.0" + } + }, + "node_modules/@prisma/engines": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.3.0.tgz", + "integrity": "sha512-cWRQoPDXPtR6stOWuWFZf9pHdQ/o8/QNWn0m0zByxf5Kd946Q875XdEJ52pEsX88vOiXUmjuPG3euw82mwQNMg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.3.0", + "@prisma/engines-version": "7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735", + "@prisma/fetch-engine": "7.3.0", + "@prisma/get-platform": "7.3.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735.tgz", + "integrity": "sha512-IH2va2ouUHihyiTTRW889LjKAl1CusZOvFfZxCDNpjSENt7g2ndFsK0vdIw/72v7+jCN6YgkHmdAP/BI7SDgyg==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.3.0.tgz", + "integrity": "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.3.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.3.0.tgz", + "integrity": "sha512-Mm0F84JMqM9Vxk70pzfNpGJ1lE4hYjOeLMu7nOOD1i83nvp8MSAcFYBnHqLvEZiA6onUR+m8iYogtOY4oPO5lQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.3.0", + "@prisma/engines-version": "7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735", + "@prisma/get-platform": "7.3.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.3.0.tgz", + "integrity": "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.3.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/studio-core": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", + "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", + "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz", + "integrity": "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/chevrotain": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", + "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "10.5.0", + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "@chevrotain/utils": "10.5.0", + "lodash": "4.17.21", + "regexp-to-ast": "0.5.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.2.0.tgz", + "integrity": "sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-jwt": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz", + "integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fastify": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fastseries": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", + "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/find-my-way": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", + "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "license": "MIT" + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", + "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.1.2", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.0.tgz", + "integrity": "sha512-0E62MaTW5rPZVRLyIJZG/YejmdA/Xr1QydHEw3Vt+qOKkMIOE8WDLc9ZX2bmAjtJFZcId4lEdrdmASsEy7D1QA==" + }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mnemonist": { + "version": "0.40.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz", + "integrity": "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==", + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.0.tgz", + "integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==", + "license": "MIT" + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pino": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz", + "integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prisma": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.3.0.tgz", + "integrity": "sha512-ApYSOLHfMN8WftJA+vL6XwAPOh/aZ0BgUyyKPwUFgjARmG6EBI9LzDPf6SWULQMSAxydV9qn5gLj037nPNlg2w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.3.0", + "@prisma/dev": "0.20.0", + "@prisma/engines": "7.3.0", + "@prisma/studio-core": "0.13.1", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "license": "MIT" + }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/steed": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", + "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", + "license": "MIT", + "dependencies": { + "fastfall": "^1.5.0", + "fastparallel": "^2.2.0", + "fastq": "^1.3.0", + "fastseries": "^1.7.0", + "reusify": "^1.0.0" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 00000000..ac4c0ef5 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/postman/hyb-oa.postman_collection.json b/server/postman/hyb-oa.postman_collection.json new file mode 100644 index 00000000..9bbca536 --- /dev/null +++ b/server/postman/hyb-oa.postman_collection.json @@ -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" + } + } + ] +} diff --git a/server/postman/hyb-oa.postman_environment.json b/server/postman/hyb-oa.postman_environment.json new file mode 100644 index 00000000..3c147cad --- /dev/null +++ b/server/postman/hyb-oa.postman_environment.json @@ -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": "employee@hyinda.com", "type": "default", "enabled": true }, + { "key": "employeePassword", "value": "EmployeePassword1234", "type": "secret", "enabled": true }, + { "key": "ceoIdentifier", "value": "ceo@hyinda.com", "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" +} diff --git a/server/prisma.config.ts b/server/prisma.config.ts new file mode 100644 index 00000000..52c5d1d5 --- /dev/null +++ b/server/prisma.config.ts @@ -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"], + }, +}); + diff --git a/server/prisma/migrations/20260206064822_init/migration.sql b/server/prisma/migrations/20260206064822_init/migration.sql new file mode 100644 index 00000000..4514b2af --- /dev/null +++ b/server/prisma/migrations/20260206064822_init/migration.sql @@ -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 +); diff --git a/server/prisma/migrations/20260206070703_allow_null_direct_manager/migration.sql b/server/prisma/migrations/20260206070703_allow_null_direct_manager/migration.sql new file mode 100644 index 00000000..7403d1bd --- /dev/null +++ b/server/prisma/migrations/20260206070703_allow_null_direct_manager/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "employees" ALTER COLUMN "direct_manager_id" DROP NOT NULL; diff --git a/server/prisma/migrations/20260206071653_password_reset_codes/migration.sql b/server/prisma/migrations/20260206071653_password_reset_codes/migration.sql new file mode 100644 index 00000000..28d7afb0 --- /dev/null +++ b/server/prisma/migrations/20260206071653_password_reset_codes/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260206202000_workflow_tasks_status_assignee_idx/migration.sql b/server/prisma/migrations/20260206202000_workflow_tasks_status_assignee_idx/migration.sql new file mode 100644 index 00000000..c56d0879 --- /dev/null +++ b/server/prisma/migrations/20260206202000_workflow_tasks_status_assignee_idx/migration.sql @@ -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"); diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/server/prisma/migrations/migration_lock.toml @@ -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" diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma new file mode 100644 index 00000000..7cef487f --- /dev/null +++ b/server/prisma/schema.prisma @@ -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") +} diff --git a/server/scripts/regression-all.sh b/server/scripts/regression-all.sh new file mode 100755 index 00000000..2fb95b59 --- /dev/null +++ b/server/scripts/regression-all.sh @@ -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" diff --git a/server/scripts/regression-dedup.sh b/server/scripts/regression-dedup.sh new file mode 100755 index 00000000..dbd1e9cc --- /dev/null +++ b/server/scripts/regression-dedup.sh @@ -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" < VALID_003" +cat > "$TMP_DIR/code-1.pdf" < "$TMP_DIR/code-2.pdf" < "$TMP_DIR/non-dedup-1.pdf" < "$TMP_DIR/non-dedup-2.pdf" <&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" diff --git a/server/scripts/regression-workflow-actions.sh b/server/scripts/regression-workflow-actions.sh new file mode 100755 index 00000000..f24bcc84 --- /dev/null +++ b/server/scripts/regression-workflow-actions.sh @@ -0,0 +1,1107 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" +DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-workflow-actions}" +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}" +CEO_IDENTIFIER="${CEO_IDENTIFIER:-ceo@hyinda.com}" +CEO_PASSWORD="${CEO_PASSWORD:-CeoPassword1234}" +RUN_PAY_CONCURRENCY_CHECK="${RUN_PAY_CONCURRENCY_CHECK:-0}" + +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)" +RUN_SUFFIX="$(date +%s)-$RANDOM" +EMPLOYEE_ID="" +ORIGINAL_EMPLOYEE_MANAGER_ID="__NULL__" +EMPLOYEE_MANAGER_PATCHED=0 +TMP_APPROVAL_RULE_ID="" + +cleanup() { + if [[ -n "$TMP_APPROVAL_RULE_ID" ]]; then + delete_approval_rule "$TMP_APPROVAL_RULE_ID" || true + fi + + if [[ "$EMPLOYEE_MANAGER_PATCHED" == "1" ]] && [[ -n "$EMPLOYEE_ID" ]]; then + set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID" || 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 +} + +assert_error_code() { + local file_path="$1" + local expected="$2" + local name="$3" + local actual + actual="$(jq -r '.error.code // empty' "$file_path")" + if [[ "$actual" != "$expected" ]]; then + echo "[ERROR] $name expected error code $expected but got $actual" >&2 + cat "$file_path" >&2 + exit 1 + fi +} + +assert_error_detail() { + local file_path="$1" + local jq_path="$2" + local expected="$3" + local name="$4" + local actual + actual="$(jq -r "$jq_path // empty" "$file_path")" + if [[ "$actual" != "$expected" ]]; then + echo "[ERROR] $name expected $jq_path=$expected but got $actual" >&2 + cat "$file_path" >&2 + exit 1 + fi +} + +assert_jq() { + local file_path="$1" + local jq_expr="$2" + local name="$3" + + if ! jq -e "$jq_expr" "$file_path" >/dev/null; then + echo "[ERROR] $name assertion failed: $jq_expr" >&2 + cat "$file_path" >&2 + exit 1 + fi +} + +api_json_auth() { + local method="$1" + local url="$2" + local token="$3" + local out_file="$4" + local body="${5:-}" + + 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_invoice() { + local token="$1" + local file_path="$2" + local out_file="$3" + + curl -sS -o "$out_file" -w "%{http_code}" \ + -X POST "$BASE_URL/api/invoice-pool/upload" \ + -H "authorization: Bearer $token" \ + -F "file=@$file_path" +} + +login_user() { + local identifier="$1" + local password="$2" + local fingerprint="$3" + local out_file="$4" + + local status + status="$(curl -sS -o "$out_file" -w "%{http_code}" \ + -X POST "$BASE_URL/api/auth/login" \ + -H "content-type: application/json" \ + -H "x-device-fingerprint: $fingerprint" \ + -d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")" + assert_status "$status" "200" "login $identifier" "$out_file" + jq -er '.data.accessToken' "$out_file" +} + +create_reimbursement() { + local token="$1" + local out_file="$2" + + local status + status="$(api_json_auth POST "$BASE_URL/api/reimbursements" "$token" "$out_file" '{}')" + assert_status "$status" "200" "create reimbursement" "$out_file" + jq -er '.data.id' "$out_file" +} + +submit_reimbursement() { + local token="$1" + local reimbursement_id="$2" + local out_file="$3" + + local status + status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/submit" "$token" "$out_file" '{}')" + assert_status "$status" "200" "submit reimbursement $reimbursement_id" "$out_file" +} + +pay_reimbursement() { + local token="$1" + local reimbursement_id="$2" + local out_file="$3" + + api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/pay" "$token" "$out_file" '{}' +} + +transfer_task() { + local token="$1" + local task_id="$2" + local to_approver_id="$3" + local reason="$4" + local out_file="$5" + + api_json_auth POST "$BASE_URL/api/workflow/tasks/$task_id/transfer" "$token" "$out_file" "{\"toApproverId\":\"$to_approver_id\",\"reason\":\"$reason\"}" +} + +withdraw_reimbursement() { + local token="$1" + local reimbursement_id="$2" + local comment="$3" + local out_file="$4" + + local status + status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/withdraw" "$token" "$out_file" "{\"comment\":\"$comment\"}")" + assert_status "$status" "200" "withdraw reimbursement $reimbursement_id" "$out_file" +} + +create_and_bind_invoice() { + local token="$1" + local reimbursement_id="$2" + local label="$3" + + local file_path="$TMP_DIR/${label}-${RUN_SUFFIX}-${RANDOM}.pdf" + local upload_body="$TMP_DIR/${label}-upload.json" + local bind_body="$TMP_DIR/${label}-bind.json" + + printf '%%PDF-1.4\n%s\n' "$label-$RUN_SUFFIX-$(date +%s%N)-$RANDOM" > "$file_path" + + local status + status="$(upload_invoice "$token" "$file_path" "$upload_body")" + assert_status "$status" "200" "upload invoice $label" "$upload_body" + + local invoice_id + invoice_id="$(jq -er '.data.invoicePoolId' "$upload_body")" + + status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/bind" "$token" "$bind_body" "{\"reimbursementId\":\"$reimbursement_id\"}")" + assert_status "$status" "200" "bind invoice $label" "$bind_body" + + echo "$invoice_id" +} + +set_reimbursement_total_amount() { + local reimbursement_id="$1" + local total_amount="$2" + + DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$total_amount" <<'NODE' +const reimbursementId = process.argv[2]; +const totalAmount = process.argv[3]; +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + await prisma.reimbursementForm.update({ + where: { id: reimbursementId }, + data: { total_amount: totalAmount }, + }); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +set_reimbursement_status() { + local reimbursement_id="$1" + local status="$2" + + DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$status" <<'NODE' +const reimbursementId = process.argv[2]; +const status = process.argv[3]; +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + await prisma.reimbursementForm.update({ + where: { id: reimbursementId }, + data: { status }, + }); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +set_employee_direct_manager() { + local employee_id="$1" + local manager_id="$2" + + DATABASE_URL="$DATABASE_URL" node - "$employee_id" "$manager_id" <<'NODE' +const employeeId = process.argv[2]; +const managerIdRaw = process.argv[3]; +const managerId = managerIdRaw === '__NULL__' ? null : managerIdRaw; +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + await prisma.employee.update({ + where: { id: employeeId }, + data: { direct_manager_id: managerId }, + }); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +create_temp_manager_only_rule() { + local name="$1" + local min_amount="$2" + local max_amount="$3" + local priority="$4" + + DATABASE_URL="$DATABASE_URL" node - "$name" "$min_amount" "$max_amount" "$priority" <<'NODE' +const [name, minAmount, maxAmount, priorityRaw] = process.argv.slice(2); +const priority = Number(priorityRaw); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const rule = await prisma.approvalRule.create({ + data: { + name, + min_amount: minAmount, + max_amount: maxAmount, + level1_role: 'manager', + level2_role: null, + level3_role: null, + enabled: true, + priority, + }, + select: { id: true }, + }); + + process.stdout.write(rule.id); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +delete_approval_rule() { + local rule_id="$1" + + DATABASE_URL="$DATABASE_URL" node - "$rule_id" <<'NODE' +const ruleId = process.argv[2]; +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + await prisma.approvalRule.deleteMany({ where: { id: ruleId } }); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_instance_status() { + local instance_id="$1" + local expected_status="$2" + local scenario="$3" + + DATABASE_URL="$DATABASE_URL" node - "$instance_id" "$expected_status" "$scenario" <<'NODE' +const [instanceId, expectedStatus, scenario] = process.argv.slice(2); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const instance = await prisma.workflowInstance.findUnique({ + where: { id: instanceId }, + select: { id: true, current_status: true }, + }); + + if (!instance) { + console.error(`[ERROR] ${scenario} workflow instance not found: ${instanceId}`); + process.exit(1); + } + + if (instance.current_status !== expectedStatus) { + console.error( + `[ERROR] ${scenario} expected workflow instance status ${expectedStatus} but got ${instance.current_status}`, + ); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_reimbursement_status_db() { + local reimbursement_id="$1" + local expected_status="$2" + local scenario="$3" + + DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$expected_status" "$scenario" <<'NODE' +const [reimbursementId, expectedStatus, scenario] = process.argv.slice(2); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const reimbursement = await prisma.reimbursementForm.findUnique({ + where: { id: reimbursementId }, + select: { id: true, status: true }, + }); + + if (!reimbursement) { + console.error(`[ERROR] ${scenario} reimbursement not found: ${reimbursementId}`); + process.exit(1); + } + + if (reimbursement.status !== expectedStatus) { + console.error( + `[ERROR] ${scenario} expected reimbursement status ${expectedStatus} but got ${reimbursement.status}`, + ); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_skip_history_record() { + local instance_id="$1" + local expected_step_id="$2" + local expected_reason="$3" + local scenario="$4" + + DATABASE_URL="$DATABASE_URL" node - "$instance_id" "$expected_step_id" "$expected_reason" "$scenario" <<'NODE' +const [instanceId, expectedStepId, expectedReason, scenario] = process.argv.slice(2); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const history = await prisma.workflowHistory.findFirst({ + where: { + instance_id: instanceId, + action: 'skip', + }, + orderBy: { created_at: 'asc' }, + select: { payload: true }, + }); + + if (!history) { + console.error(`[ERROR] ${scenario} expected skip history record but none found`); + process.exit(1); + } + + const payload = history.payload ?? {}; + const actualStepId = payload.step_id ?? null; + const actualReason = payload.reason ?? null; + if (actualStepId !== expectedStepId || actualReason !== expectedReason) { + console.error( + `[ERROR] ${scenario} expected skip payload step_id=${expectedStepId}, reason=${expectedReason} but got step_id=${actualStepId}, reason=${actualReason}`, + ); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_task_step_and_role() { + local task_id="$1" + local expected_step_id="$2" + local expected_role="$3" + local scenario="$4" + + DATABASE_URL="$DATABASE_URL" node - "$task_id" "$expected_step_id" "$expected_role" "$scenario" <<'NODE' +const [taskId, expectedStepId, expectedRole, scenario] = process.argv.slice(2); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const task = await prisma.workflowTask.findUnique({ + where: { id: taskId }, + select: { + id: true, + step_id: true, + assignee_role: true, + assignee_id: true, + }, + }); + + if (!task) { + console.error(`[ERROR] ${scenario} task not found: ${taskId}`); + process.exit(1); + } + + if (task.step_id !== expectedStepId) { + console.error( + `[ERROR] ${scenario} expected step_id ${expectedStepId} but got ${task.step_id}`, + ); + process.exit(1); + } + + if (task.assignee_role !== expectedRole || task.assignee_id !== null) { + console.error( + `[ERROR] ${scenario} expected role assignee ${expectedRole} with null assignee_id but got assignee_role=${task.assignee_role}, assignee_id=${task.assignee_id}`, + ); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_no_workflow_instance() { + local reimbursement_id="$1" + local scenario="$2" + + DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$scenario" <<'NODE' +const [reimbursementId, scenario] = process.argv.slice(2); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const count = await prisma.workflowInstance.count({ + where: { + entity_type: 'reimbursement', + entity_id: reimbursementId, + }, + }); + + if (count !== 0) { + console.error(`[ERROR] ${scenario} expected no workflow instance but found ${count}`); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_invoices_archived_used_complete() { + local reimbursement_id="$1" + local scenario="$2" + + DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$scenario" <<'NODE' +const [reimbursementId, scenario] = process.argv.slice(2); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const invoices = await prisma.invoicePool.findMany({ + where: { + linked_reimbursement_id: reimbursementId, + }, + select: { + id: true, + status: true, + archived_reason: true, + archived_at: true, + }, + }); + + if (!invoices.length) { + console.error(`[ERROR] ${scenario} expected linked invoices but found none`); + process.exit(1); + } + + const invalid = invoices.find( + (invoice) => + invoice.status !== 'archived' || + invoice.archived_reason !== 'used_complete' || + invoice.archived_at == null, + ); + + if (invalid) { + console.error( + `[ERROR] ${scenario} invoice ${invalid.id} expected archived/used_complete/archived_at but got status=${invalid.status}, reason=${invalid.archived_reason}, archived_at=${invalid.archived_at}`, + ); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_transfer_evidence() { + local task_id="$1" + local reimbursement_id="$2" + local from_approver_id="$3" + local to_approver_id="$4" + local reason="$5" + local expected_transfer_count="$6" + local scenario="$7" + + DATABASE_URL="$DATABASE_URL" node - "$task_id" "$reimbursement_id" "$from_approver_id" "$to_approver_id" "$reason" "$expected_transfer_count" "$scenario" <<'NODE' +const [ + taskId, + reimbursementId, + fromApproverId, + toApproverId, + reason, + expectedTransferCountRaw, + scenario, +] = process.argv.slice(2); +const expectedTransferCount = Number(expectedTransferCountRaw); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const task = await prisma.workflowTask.findUnique({ + where: { id: taskId }, + select: { + assignee_id: true, + assignee_role: true, + }, + }); + if (!task) { + console.error(`[ERROR] ${scenario} task not found: ${taskId}`); + process.exit(1); + } + if (task.assignee_id !== toApproverId || task.assignee_role !== null) { + console.error( + `[ERROR] ${scenario} expected task assignee_id=${toApproverId}, assignee_role=null but got assignee_id=${task.assignee_id}, assignee_role=${task.assignee_role}`, + ); + process.exit(1); + } + + const transfer = await prisma.approvalTransfer.findFirst({ + where: { + reimbursement_id: reimbursementId, + from_approver_id: fromApproverId, + to_approver_id: toApproverId, + reason, + status: 'active', + }, + orderBy: { created_at: 'desc' }, + select: { id: true }, + }); + if (!transfer) { + console.error(`[ERROR] ${scenario} approval_transfers evidence not found`); + process.exit(1); + } + + const history = await prisma.workflowHistory.findFirst({ + where: { + task_id: taskId, + action: 'transfer', + actor_id: fromApproverId, + }, + orderBy: { created_at: 'desc' }, + select: { + comment: true, + payload: true, + }, + }); + + if (!history) { + console.error(`[ERROR] ${scenario} workflow_history transfer evidence not found`); + process.exit(1); + } + + const payload = history.payload ?? {}; + const payloadFrom = payload.from_approver_id ?? null; + const payloadTo = payload.to_approver_id ?? null; + const payloadReason = payload.reason ?? null; + const payloadTransferCount = Number(payload.transfer_count); + + if ( + history.comment !== reason || + payloadFrom !== fromApproverId || + payloadTo !== toApproverId || + payloadReason !== reason || + payloadTransferCount !== expectedTransferCount + ) { + console.error( + `[ERROR] ${scenario} workflow_history payload mismatch: comment=${history.comment}, from=${payloadFrom}, to=${payloadTo}, reason=${payloadReason}, transfer_count=${payloadTransferCount}`, + ); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +echo "[1/19] Health check + login baseline users" +health_status="$(curl -sS -o "$TMP_DIR/health.json" -w "%{http_code}" "$BASE_URL/health")" +assert_status "$health_status" "200" "health check" "$TMP_DIR/health.json" + +EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$DEVICE_FINGERPRINT-employee" "$TMP_DIR/login-employee.json")" +MANAGER_TOKEN="$(login_user "$MANAGER_IDENTIFIER" "$MANAGER_PASSWORD" "$DEVICE_FINGERPRINT-manager" "$TMP_DIR/login-manager.json")" +FINANCE_TOKEN="$(login_user "$FINANCE_IDENTIFIER" "$FINANCE_PASSWORD" "$DEVICE_FINGERPRINT-finance" "$TMP_DIR/login-finance.json")" +CEO_TOKEN="$(login_user "$CEO_IDENTIFIER" "$CEO_PASSWORD" "$DEVICE_FINGERPRINT-ceo" "$TMP_DIR/login-ceo.json")" + +me_status="$(api_json_auth GET "$BASE_URL/api/me" "$EMPLOYEE_TOKEN" "$TMP_DIR/me.json")" +assert_status "$me_status" "200" "get /api/me" "$TMP_DIR/me.json" +EMPLOYEE_ID="$(jq -er '.data.id' "$TMP_DIR/me.json")" +ORIGINAL_EMPLOYEE_MANAGER_ID="$(jq -r '.data.directManagerId // "__NULL__"' "$TMP_DIR/me.json")" + +manager_me_status="$(api_json_auth GET "$BASE_URL/api/me" "$MANAGER_TOKEN" "$TMP_DIR/me-manager.json")" +assert_status "$manager_me_status" "200" "get /api/me manager" "$TMP_DIR/me-manager.json" +MANAGER_ID="$(jq -er '.data.id' "$TMP_DIR/me-manager.json")" + +ceo_me_status="$(api_json_auth GET "$BASE_URL/api/me" "$CEO_TOKEN" "$TMP_DIR/me-ceo.json")" +assert_status "$ceo_me_status" "200" "get /api/me ceo" "$TMP_DIR/me-ceo.json" +CEO_ID="$(jq -er '.data.id' "$TMP_DIR/me-ceo.json")" + +echo "[2/19] reject: instance=rejected, reimbursement=rejected" +RID_REJECT="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/reject-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_REJECT" "reject" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_REJECT" "$TMP_DIR/reject-submit.json" +TASK_REJECT="$(jq -er '.data.currentTaskId' "$TMP_DIR/reject-submit.json")" +INSTANCE_REJECT="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/reject-submit.json")" + +reject_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_REJECT/reject" "$MANAGER_TOKEN" "$TMP_DIR/reject-action.json" '{"comment":"reject by regression script"}')" +assert_status "$reject_status" "200" "workflow reject" "$TMP_DIR/reject-action.json" +assert_jq "$TMP_DIR/reject-action.json" '.data.outcome == "rejected" and .data.reimbursementStatus == "rejected"' "reject action response" +assert_instance_status "$INSTANCE_REJECT" "rejected" "reject scenario" +assert_reimbursement_status_db "$RID_REJECT" "rejected" "reject scenario" + +echo "[3/19] return + resubmit: terminated/draft and new instance" +RID_RETURN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/return-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_RETURN" "return" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "$TMP_DIR/return-submit-1.json" +TASK_RETURN_1="$(jq -er '.data.currentTaskId' "$TMP_DIR/return-submit-1.json")" +INSTANCE_RETURN_1="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/return-submit-1.json")" + +return_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_RETURN_1/return" "$MANAGER_TOKEN" "$TMP_DIR/return-action.json" '{"comment":"return by regression script"}')" +assert_status "$return_status" "200" "workflow return" "$TMP_DIR/return-action.json" +assert_jq "$TMP_DIR/return-action.json" '.data.outcome == "returned" and .data.reimbursementStatus == "draft"' "return action response" +assert_instance_status "$INSTANCE_RETURN_1" "terminated" "return scenario" +assert_reimbursement_status_db "$RID_RETURN" "draft" "return scenario" + +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "$TMP_DIR/return-submit-2.json" +INSTANCE_RETURN_2="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/return-submit-2.json")" +if [[ "$INSTANCE_RETURN_2" == "$INSTANCE_RETURN_1" ]]; then + echo "[ERROR] return resubmit expected new workflow instance but got same id" >&2 + cat "$TMP_DIR/return-submit-2.json" >&2 + exit 1 +fi + +withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "cleanup after return-resubmit" "$TMP_DIR/return-withdraw-cleanup.json" +assert_instance_status "$INSTANCE_RETURN_2" "cancelled" "return resubmit cleanup" +assert_reimbursement_status_db "$RID_RETURN" "draft" "return resubmit cleanup" + +echo "[4/19] withdraw: before approver processing" +RID_WITHDRAW="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/withdraw-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "withdraw" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "$TMP_DIR/withdraw-submit.json" +INSTANCE_WITHDRAW="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/withdraw-submit.json")" + +withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "withdraw before approver handle" "$TMP_DIR/withdraw-action.json" +assert_jq "$TMP_DIR/withdraw-action.json" '.data.outcome == "withdrawn" and .data.reimbursementStatus == "draft"' "withdraw response" +assert_instance_status "$INSTANCE_WITHDRAW" "cancelled" "withdraw scenario" +assert_reimbursement_status_db "$RID_WITHDRAW" "draft" "withdraw scenario" + +echo "[5/19] PERM_002: non-assignee cannot operate" +RID_PERM="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/perm-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PERM" "perm" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "$TMP_DIR/perm-submit.json" +TASK_PERM="$(jq -er '.data.currentTaskId' "$TMP_DIR/perm-submit.json")" +INSTANCE_PERM="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/perm-submit.json")" + +perm_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_PERM/approve" "$FINANCE_TOKEN" "$TMP_DIR/perm-finance-approve.json" '{}')" +assert_status "$perm_status" "403" "PERM_002 non-assignee approve" "$TMP_DIR/perm-finance-approve.json" +assert_error_code "$TMP_DIR/perm-finance-approve.json" "PERM_002" "PERM_002 non-assignee approve" + +withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "cleanup after PERM_002" "$TMP_DIR/perm-withdraw-cleanup.json" +assert_instance_status "$INSTANCE_PERM" "cancelled" "PERM_002 cleanup" +assert_reimbursement_status_db "$RID_PERM" "draft" "PERM_002 cleanup" + +echo "[6/19] negative_not_allowed: submit blocked when total_amount < 0" +RID_NEGATIVE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/negative-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NEGATIVE" "negative" >/dev/null +set_reimbursement_total_amount "$RID_NEGATIVE" "-1.00" + +negative_submit_status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$RID_NEGATIVE/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/negative-submit.json" '{}')" +assert_status "$negative_submit_status" "400" "negative_not_allowed submit" "$TMP_DIR/negative-submit.json" +assert_error_code "$TMP_DIR/negative-submit.json" "VALID_001" "negative_not_allowed submit" +assert_error_detail "$TMP_DIR/negative-submit.json" '.error.details.total_amount' "negative_not_allowed" "negative_not_allowed submit" +assert_no_workflow_instance "$RID_NEGATIVE" "negative_not_allowed" + +echo "[7/19] Prepare manager-missing context" +set_employee_direct_manager "$EMPLOYEE_ID" "__NULL__" +EMPLOYEE_MANAGER_PATCHED=1 + +echo "[8/19] manager skip: missing manager -> skip with history + finance task" +RID_SKIP="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/skip-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_SKIP" "skip" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_SKIP" "$TMP_DIR/skip-submit.json" +TASK_SKIP="$(jq -er '.data.currentTaskId' "$TMP_DIR/skip-submit.json")" +INSTANCE_SKIP="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/skip-submit.json")" + +assert_task_step_and_role "$TASK_SKIP" "approval_2_finance" "finance" "manager skip scenario" +assert_skip_history_record "$INSTANCE_SKIP" "approval_1_manager" "no_manager_found" "manager skip scenario" + +skip_finance_approve_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_SKIP/approve" "$FINANCE_TOKEN" "$TMP_DIR/skip-finance-approve.json" '{}')" +assert_status "$skip_finance_approve_status" "200" "manager skip finance approve" "$TMP_DIR/skip-finance-approve.json" +assert_reimbursement_status_db "$RID_SKIP" "approved" "manager skip scenario" + +echo "[9/19] no_actionable_approver: full chain non-actionable" +TMP_APPROVAL_RULE_ID="$(create_temp_manager_only_rule "tmp-manager-only-$RUN_SUFFIX" "9000.00" "9001.00" "1")" + +RID_NO_ACTIONABLE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/no-actionable-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NO_ACTIONABLE" "no-actionable" >/dev/null +set_reimbursement_total_amount "$RID_NO_ACTIONABLE" "9000.50" + +no_actionable_submit_status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$RID_NO_ACTIONABLE/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/no-actionable-submit.json" '{}')" +assert_status "$no_actionable_submit_status" "409" "no_actionable_approver submit" "$TMP_DIR/no-actionable-submit.json" +assert_error_code "$TMP_DIR/no-actionable-submit.json" "VALID_001" "no_actionable_approver submit" +assert_error_detail "$TMP_DIR/no-actionable-submit.json" '.error.details.workflow' "no_actionable_approver" "no_actionable_approver submit" +assert_no_workflow_instance "$RID_NO_ACTIONABLE" "no_actionable_approver" + +delete_approval_rule "$TMP_APPROVAL_RULE_ID" +TMP_APPROVAL_RULE_ID="" + +echo "[10/19] restore manager relation before transfer/pay scenarios" +set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID" +EMPLOYEE_MANAGER_PATCHED=0 + +echo "[11/19] transfer success: manager -> ceo with 3 evidences" +RID_TRANSFER_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-ok-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_OK" "transfer-ok" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_OK" "$TMP_DIR/transfer-ok-submit.json" +TASK_TRANSFER_OK="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-ok-submit.json")" +INSTANCE_TRANSFER_OK="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/transfer-ok-submit.json")" + +transfer_ok_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_OK" "$CEO_ID" "transfer to ceo for regression" "$TMP_DIR/transfer-ok-action.json")" +assert_status "$transfer_ok_status" "200" "transfer success" "$TMP_DIR/transfer-ok-action.json" +assert_jq "$TMP_DIR/transfer-ok-action.json" '.data.outcome == "transferred" and .data.reimbursementStatus == "reviewing" and .data.transferCount == 1' "transfer success response" +assert_transfer_evidence "$TASK_TRANSFER_OK" "$RID_TRANSFER_OK" "$MANAGER_ID" "$CEO_ID" "transfer to ceo for regression" "1" "transfer success" + +transfer_after_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_OK" "$CEO_ID" "second transfer should fail by perm" "$TMP_DIR/transfer-after-perm.json")" +assert_status "$transfer_after_status" "403" "transfer after reassignment should be forbidden" "$TMP_DIR/transfer-after-perm.json" +assert_error_code "$TMP_DIR/transfer-after-perm.json" "PERM_002" "transfer after reassignment should be forbidden" + +ceo_approve_after_transfer_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_TRANSFER_OK/approve" "$CEO_TOKEN" "$TMP_DIR/transfer-ok-ceo-approve.json" '{}')" +assert_status "$ceo_approve_after_transfer_status" "200" "ceo approve transferred task" "$TMP_DIR/transfer-ok-ceo-approve.json" + +NEXT_TASK_TRANSFER_OK="$(jq -r '.data.nextTaskId // empty' "$TMP_DIR/transfer-ok-ceo-approve.json")" +if [[ -n "$NEXT_TASK_TRANSFER_OK" ]]; then + finance_approve_after_transfer_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$NEXT_TASK_TRANSFER_OK/approve" "$FINANCE_TOKEN" "$TMP_DIR/transfer-ok-finance-approve-next.json" '{}')" + assert_status "$finance_approve_after_transfer_status" "200" "finance approve transferred next task" "$TMP_DIR/transfer-ok-finance-approve-next.json" +fi + +assert_reimbursement_status_db "$RID_TRANSFER_OK" "approved" "transfer success approval" + +echo "[12/19] transfer reason too short => VALID_002" +RID_TRANSFER_SHORT="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-short-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "transfer-short" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "$TMP_DIR/transfer-short-submit.json" +TASK_TRANSFER_SHORT="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-short-submit.json")" + +transfer_short_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_SHORT" "$CEO_ID" "too short" "$TMP_DIR/transfer-short-action.json")" +assert_status "$transfer_short_status" "400" "transfer reason too short" "$TMP_DIR/transfer-short-action.json" +assert_error_code "$TMP_DIR/transfer-short-action.json" "VALID_002" "transfer reason too short" + +withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "cleanup transfer short" "$TMP_DIR/transfer-short-withdraw-cleanup.json" +assert_reimbursement_status_db "$RID_TRANSFER_SHORT" "draft" "transfer short cleanup" + +echo "[13/19] transfer target not approver => VALID_001" +RID_TRANSFER_TARGET="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-target-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "transfer-target" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "$TMP_DIR/transfer-target-submit.json" +TASK_TRANSFER_TARGET="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-target-submit.json")" + +transfer_target_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_TARGET" "$EMPLOYEE_ID" "transfer target is not approver" "$TMP_DIR/transfer-target-action.json")" +assert_status "$transfer_target_status" "409" "transfer target not approver" "$TMP_DIR/transfer-target-action.json" +assert_error_code "$TMP_DIR/transfer-target-action.json" "VALID_001" "transfer target not approver" +assert_error_detail "$TMP_DIR/transfer-target-action.json" '.error.details.toApproverId' "target_not_approver" "transfer target not approver" + +withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "cleanup transfer target" "$TMP_DIR/transfer-target-withdraw-cleanup.json" +assert_reimbursement_status_db "$RID_TRANSFER_TARGET" "draft" "transfer target cleanup" + +echo "[14/19] transfer same assignee => VALID_001" +RID_TRANSFER_SAME="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-same-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "transfer-same" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "$TMP_DIR/transfer-same-submit.json" +TASK_TRANSFER_SAME="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-same-submit.json")" + +transfer_same_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_SAME" "$MANAGER_ID" "transfer to same assignee should fail" "$TMP_DIR/transfer-same-action.json")" +assert_status "$transfer_same_status" "409" "transfer same assignee" "$TMP_DIR/transfer-same-action.json" +assert_error_code "$TMP_DIR/transfer-same-action.json" "VALID_001" "transfer same assignee" +assert_error_detail "$TMP_DIR/transfer-same-action.json" '.error.details.transfer' "same_assignee_not_allowed" "transfer same assignee" + +withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "cleanup transfer same assignee" "$TMP_DIR/transfer-same-withdraw-cleanup.json" +assert_reimbursement_status_db "$RID_TRANSFER_SAME" "draft" "transfer same cleanup" + +echo "[15/19] transfer chain limit: 4th transfer blocked" +RID_TRANSFER_CHAIN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-chain-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "transfer-chain" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "$TMP_DIR/transfer-chain-submit.json" +TASK_TRANSFER_CHAIN="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-chain-submit.json")" + +transfer_chain_1_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_CHAIN" "$CEO_ID" "transfer chain step one" "$TMP_DIR/transfer-chain-1.json")" +assert_status "$transfer_chain_1_status" "200" "transfer chain step 1" "$TMP_DIR/transfer-chain-1.json" +assert_transfer_evidence "$TASK_TRANSFER_CHAIN" "$RID_TRANSFER_CHAIN" "$MANAGER_ID" "$CEO_ID" "transfer chain step one" "1" "transfer chain step 1" + +transfer_chain_2_status="$(transfer_task "$CEO_TOKEN" "$TASK_TRANSFER_CHAIN" "$MANAGER_ID" "transfer chain step two" "$TMP_DIR/transfer-chain-2.json")" +assert_status "$transfer_chain_2_status" "200" "transfer chain step 2" "$TMP_DIR/transfer-chain-2.json" +assert_transfer_evidence "$TASK_TRANSFER_CHAIN" "$RID_TRANSFER_CHAIN" "$CEO_ID" "$MANAGER_ID" "transfer chain step two" "2" "transfer chain step 2" + +transfer_chain_3_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_CHAIN" "$CEO_ID" "transfer chain step three" "$TMP_DIR/transfer-chain-3.json")" +assert_status "$transfer_chain_3_status" "200" "transfer chain step 3" "$TMP_DIR/transfer-chain-3.json" +assert_transfer_evidence "$TASK_TRANSFER_CHAIN" "$RID_TRANSFER_CHAIN" "$MANAGER_ID" "$CEO_ID" "transfer chain step three" "3" "transfer chain step 3" + +transfer_chain_4_status="$(transfer_task "$CEO_TOKEN" "$TASK_TRANSFER_CHAIN" "$MANAGER_ID" "transfer chain step four should fail" "$TMP_DIR/transfer-chain-4.json")" +assert_status "$transfer_chain_4_status" "409" "transfer chain step 4" "$TMP_DIR/transfer-chain-4.json" +assert_error_code "$TMP_DIR/transfer-chain-4.json" "VALID_001" "transfer chain step 4" +assert_error_detail "$TMP_DIR/transfer-chain-4.json" '.error.details.transfer' "chain_limit_exceeded" "transfer chain step 4" + +ceo_approve_chain_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_TRANSFER_CHAIN/approve" "$CEO_TOKEN" "$TMP_DIR/transfer-chain-approve-ceo.json" '{}')" +assert_status "$ceo_approve_chain_status" "200" "transfer chain final approval" "$TMP_DIR/transfer-chain-approve-ceo.json" + +NEXT_TASK_TRANSFER_CHAIN="$(jq -r '.data.nextTaskId // empty' "$TMP_DIR/transfer-chain-approve-ceo.json")" +if [[ -n "$NEXT_TASK_TRANSFER_CHAIN" ]]; then + finance_approve_chain_next_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$NEXT_TASK_TRANSFER_CHAIN/approve" "$FINANCE_TOKEN" "$TMP_DIR/transfer-chain-approve-finance-next.json" '{}')" + assert_status "$finance_approve_chain_next_status" "200" "transfer chain finance next approval" "$TMP_DIR/transfer-chain-approve-finance-next.json" +fi + +assert_reimbursement_status_db "$RID_TRANSFER_CHAIN" "approved" "transfer chain final approval" + +echo "[16/19] pay success: approved -> paid and invoices archived" +RID_PAY_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-ok-create.json")" +IID_PAY_OK="$(create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "pay-ok")" +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-submit.json" +TASK_PAY_OK="$(jq -er '.data.currentTaskId' "$TMP_DIR/pay-ok-submit.json")" + +approve_pay_ok_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_PAY_OK/approve" "$MANAGER_TOKEN" "$TMP_DIR/pay-ok-approve-manager.json" '{}')" +assert_status "$approve_pay_ok_status" "200" "approve for pay success scenario" "$TMP_DIR/pay-ok-approve-manager.json" + +NEXT_TASK_PAY_OK="$(jq -r '.data.nextTaskId // empty' "$TMP_DIR/pay-ok-approve-manager.json")" +if [[ -n "$NEXT_TASK_PAY_OK" ]]; then + approve_pay_ok_finance_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$NEXT_TASK_PAY_OK/approve" "$FINANCE_TOKEN" "$TMP_DIR/pay-ok-approve-finance.json" '{}')" + assert_status "$approve_pay_ok_finance_status" "200" "approve finance step for pay success" "$TMP_DIR/pay-ok-approve-finance.json" +fi + +assert_reimbursement_status_db "$RID_PAY_OK" "approved" "pay success precondition" + +pay_ok_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-pay.json")" +assert_status "$pay_ok_status" "200" "pay approved reimbursement" "$TMP_DIR/pay-ok-pay.json" +assert_jq "$TMP_DIR/pay-ok-pay.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 1' "pay success response" +assert_reimbursement_status_db "$RID_PAY_OK" "paid" "pay success" +assert_invoices_archived_used_complete "$RID_PAY_OK" "pay success" + +echo "[17/19] pay invalid status: draft/reviewing/rejected/cancelled" +pay_invalid_draft_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_NEGATIVE" "$TMP_DIR/pay-invalid-draft.json")" +assert_status "$pay_invalid_draft_status" "409" "pay invalid status draft" "$TMP_DIR/pay-invalid-draft.json" +assert_error_code "$TMP_DIR/pay-invalid-draft.json" "VALID_001" "pay invalid status draft" +assert_error_detail "$TMP_DIR/pay-invalid-draft.json" '.error.details.status' "invalid_pay_status" "pay invalid status draft" + +RID_PAY_REVIEWING="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-reviewing-create.json")" +create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "pay-reviewing" >/dev/null +submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "$TMP_DIR/pay-reviewing-submit.json" +pay_invalid_reviewing_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_REVIEWING" "$TMP_DIR/pay-invalid-reviewing.json")" +assert_status "$pay_invalid_reviewing_status" "409" "pay invalid status reviewing" "$TMP_DIR/pay-invalid-reviewing.json" +assert_error_code "$TMP_DIR/pay-invalid-reviewing.json" "VALID_001" "pay invalid status reviewing" +assert_error_detail "$TMP_DIR/pay-invalid-reviewing.json" '.error.details.status' "invalid_pay_status" "pay invalid status reviewing" + +withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "cleanup pay invalid reviewing" "$TMP_DIR/pay-reviewing-withdraw-cleanup.json" +assert_reimbursement_status_db "$RID_PAY_REVIEWING" "draft" "pay invalid reviewing cleanup" + +pay_invalid_rejected_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_REJECT" "$TMP_DIR/pay-invalid-rejected.json")" +assert_status "$pay_invalid_rejected_status" "409" "pay invalid status rejected" "$TMP_DIR/pay-invalid-rejected.json" +assert_error_code "$TMP_DIR/pay-invalid-rejected.json" "VALID_001" "pay invalid status rejected" +assert_error_detail "$TMP_DIR/pay-invalid-rejected.json" '.error.details.status' "invalid_pay_status" "pay invalid status rejected" + +RID_PAY_CANCELLED="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-cancelled-create.json")" +set_reimbursement_status "$RID_PAY_CANCELLED" "cancelled" +pay_invalid_cancelled_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_CANCELLED" "$TMP_DIR/pay-invalid-cancelled.json")" +assert_status "$pay_invalid_cancelled_status" "409" "pay invalid status cancelled" "$TMP_DIR/pay-invalid-cancelled.json" +assert_error_code "$TMP_DIR/pay-invalid-cancelled.json" "VALID_001" "pay invalid status cancelled" +assert_error_detail "$TMP_DIR/pay-invalid-cancelled.json" '.error.details.status' "invalid_pay_status" "pay invalid status cancelled" + +echo "[18/19] pay permission: non-finance forbidden" +pay_perm_status="$(pay_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-perm-employee.json")" +assert_status "$pay_perm_status" "403" "pay forbidden for non-finance" "$TMP_DIR/pay-perm-employee.json" +assert_error_code "$TMP_DIR/pay-perm-employee.json" "PERM_001" "pay forbidden for non-finance" + +echo "[19/19] pay idempotent: paid reimbursement can be paid again" +pay_idempotent_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-idempotent.json")" +assert_status "$pay_idempotent_status" "200" "pay idempotent" "$TMP_DIR/pay-idempotent.json" +assert_jq "$TMP_DIR/pay-idempotent.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 0' "pay idempotent response" +assert_reimbursement_status_db "$RID_PAY_OK" "paid" "pay idempotent" +assert_invoices_archived_used_complete "$RID_PAY_OK" "pay idempotent" + +if [[ "$RUN_PAY_CONCURRENCY_CHECK" == "1" ]]; then + echo "[19.1/19] optional pay concurrency check" + RID_PAY_CONCURRENCY="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-concurrency-create.json")" + create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_CONCURRENCY" "pay-concurrency" >/dev/null + submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_CONCURRENCY" "$TMP_DIR/pay-concurrency-submit.json" + TASK_PAY_CONCURRENCY="$(jq -er '.data.currentTaskId' "$TMP_DIR/pay-concurrency-submit.json")" + approve_pay_concurrency_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_PAY_CONCURRENCY/approve" "$MANAGER_TOKEN" "$TMP_DIR/pay-concurrency-approve.json" '{}')" + assert_status "$approve_pay_concurrency_status" "200" "approve pay concurrency scenario" "$TMP_DIR/pay-concurrency-approve.json" + + ( + pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_CONCURRENCY" "$TMP_DIR/pay-concurrency-a.json" > "$TMP_DIR/pay-concurrency-a.status" + ) & + pid_a=$! + ( + pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_CONCURRENCY" "$TMP_DIR/pay-concurrency-b.json" > "$TMP_DIR/pay-concurrency-b.status" + ) & + pid_b=$! + wait "$pid_a" + wait "$pid_b" + + status_a="$(cat "$TMP_DIR/pay-concurrency-a.status")" + status_b="$(cat "$TMP_DIR/pay-concurrency-b.status")" + assert_status "$status_a" "200" "pay concurrency request a" "$TMP_DIR/pay-concurrency-a.json" + assert_status "$status_b" "200" "pay concurrency request b" "$TMP_DIR/pay-concurrency-b.json" + assert_reimbursement_status_db "$RID_PAY_CONCURRENCY" "paid" "pay concurrency" + assert_invoices_archived_used_complete "$RID_PAY_CONCURRENCY" "pay concurrency" +fi + +echo "[19/19] Final manager relation restore" +set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID" +EMPLOYEE_MANAGER_PATCHED=0 + +echo "[OK] Workflow action regression passed" +echo "- reject reimbursementId: $RID_REJECT" +echo "- return reimbursementId: $RID_RETURN" +echo "- withdraw reimbursementId: $RID_WITHDRAW" +echo "- perm reimbursementId: $RID_PERM" +echo "- negative reimbursementId: $RID_NEGATIVE" +echo "- manager-skip reimbursementId: $RID_SKIP" +echo "- no-actionable reimbursementId: $RID_NO_ACTIONABLE" +echo "- transfer-ok reimbursementId: $RID_TRANSFER_OK" +echo "- transfer-chain reimbursementId: $RID_TRANSFER_CHAIN" +echo "- pay reimbursementId: $RID_PAY_OK" diff --git a/server/scripts/smoke-test.sh b/server/scripts/smoke-test.sh new file mode 100755 index 00000000..3825afeb --- /dev/null +++ b/server/scripts/smoke-test.sh @@ -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" diff --git a/server/src/app.js b/server/src/app.js new file mode 100644 index 00000000..925b35f9 --- /dev/null +++ b/server/src/app.js @@ -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 }; diff --git a/server/src/config.js b/server/src/config.js new file mode 100644 index 00000000..a59b6ac1 --- /dev/null +++ b/server/src/config.js @@ -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 }; diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js new file mode 100644 index 00000000..66ec347b --- /dev/null +++ b/server/src/constants/permissions.js @@ -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, +}; diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 00000000..b1ffa397 --- /dev/null +++ b/server/src/index.js @@ -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); +}); + diff --git a/server/src/plugins/auth.js b/server/src/plugins/auth.js new file mode 100644 index 00000000..ce444dfb --- /dev/null +++ b/server/src/plugins/auth.js @@ -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" }); diff --git a/server/src/plugins/prisma.js b/server/src/plugins/prisma.js new file mode 100644 index 00000000..2d678b3a --- /dev/null +++ b/server/src/plugins/prisma.js @@ -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" }); diff --git a/server/src/prismaClient.js b/server/src/prismaClient.js new file mode 100644 index 00000000..f24e6269 --- /dev/null +++ b/server/src/prismaClient.js @@ -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 }; + diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100644 index 00000000..87ed5483 --- /dev/null +++ b/server/src/routes/auth.js @@ -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; diff --git a/server/src/routes/invoicePool.js b/server/src/routes/invoicePool.js new file mode 100644 index 00000000..db1c9129 --- /dev/null +++ b/server/src/routes/invoicePool.js @@ -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; diff --git a/server/src/routes/me.js b/server/src/routes/me.js new file mode 100644 index 00000000..b78f92b0 --- /dev/null +++ b/server/src/routes/me.js @@ -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; + diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js new file mode 100644 index 00000000..6ec87277 --- /dev/null +++ b/server/src/routes/reimbursements.js @@ -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; diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js new file mode 100644 index 00000000..7038f1bb --- /dev/null +++ b/server/src/routes/workflow.js @@ -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; diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js new file mode 100644 index 00000000..7ec31298 --- /dev/null +++ b/server/src/scripts/seed-config.js @@ -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); +}); diff --git a/server/src/scripts/seed-dev.js b/server/src/scripts/seed-dev.js new file mode 100644 index 00000000..3588d3ba --- /dev/null +++ b/server/src/scripts/seed-dev.js @@ -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 ?? "ceo@hyinda.com", + 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 ?? "admin@hyinda.com", + 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 ?? "manager@hyinda.com", + 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 ?? "employee@hyinda.com", + 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 ?? "finance@hyinda.com", + 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 ?? "finance_mgr@hyinda.com", + 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 ?? "hr@hyinda.com", + 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 ?? "administration@hyinda.com", + 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); +}); diff --git a/server/src/utils/AppError.js b/server/src/utils/AppError.js new file mode 100644 index 00000000..1c0e6106 --- /dev/null +++ b/server/src/utils/AppError.js @@ -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 }; + diff --git a/server/src/utils/audit.js b/server/src/utils/audit.js new file mode 100644 index 00000000..e14bf000 --- /dev/null +++ b/server/src/utils/audit.js @@ -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 }; + diff --git a/server/src/utils/crypto.js b/server/src/utils/crypto.js new file mode 100644 index 00000000..836b9db5 --- /dev/null +++ b/server/src/utils/crypto.js @@ -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, +}; + diff --git a/server/src/utils/date.js b/server/src/utils/date.js new file mode 100644 index 00000000..d247ca1a --- /dev/null +++ b/server/src/utils/date.js @@ -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, +}; diff --git a/server/src/utils/passwordPolicy.js b/server/src/utils/passwordPolicy.js new file mode 100644 index 00000000..9ce60324 --- /dev/null +++ b/server/src/utils/passwordPolicy.js @@ -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 }; + diff --git a/server/src/utils/reply.js b/server/src/utils/reply.js new file mode 100644 index 00000000..1538f968 --- /dev/null +++ b/server/src/utils/reply.js @@ -0,0 +1,6 @@ +function sendData(reply, request, data) { + return reply.send({ requestId: request.id, data }); +} + +module.exports = { sendData }; + diff --git a/server/src/utils/signedUrl.js b/server/src/utils/signedUrl.js new file mode 100644 index 00000000..e85ef857 --- /dev/null +++ b/server/src/utils/signedUrl.js @@ -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, +}; diff --git a/server/src/utils/workflow.js b/server/src/utils/workflow.js new file mode 100644 index 00000000..bcd22991 --- /dev/null +++ b/server/src/utils/workflow.js @@ -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, +};