文件
hyb-oa/docs/requirements-specification.md
T
wangsli f755ff0dda feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)
- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40)

- 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5)

- 行政代提交/代上传后端校验与审计(F7/F8)

- 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
2026-02-09 16:20:31 +08:00

1233 行
66 KiB
Markdown
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 海音达 OA 系统 - 需求规格说明书
**版本**:1.4
**日期**:2026-02-06
**范围**:报销系统及其依赖模块(员工/权限/工作流/附件)
**相关文档**:
- [系统架构概览](./architecture.md)
- [技术栈与复用报告](./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 <token>`
- 存储:仅内存(禁止 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` 仍需满足“审批人未处理前”等状态约束