Files
TurboHR/docs/外包服务模式设计.md
T

507 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 外包服务模式设计方案
> 场景:客户企业HR与外包社保专员共用本系统,外包公司负责客户企业的社保公积金、特殊状态员工服务等业务。
## 一、核心问题
| 维度 | 客户HR | 外包社保专员 |
|------|--------|-------------|
| 管理范围 | 本企业员工 | 多个客户企业的员工 |
| 可操作模块 | 全部(合同/薪酬/考勤/解聘/社保等) | 仅社保公积金、特殊状态 |
| 数据可见性 | 本企业全部 | 被授权的客户企业的指定模块 |
| 登录后视图 | 直接进入本企业Dashboard | 选择客户企业 → 进入受限Dashboard |
## 二、数据模型变更
### 2.1 Organization 表新增字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `isProvider` | Boolean (default: false) | 标记是否为外包服务商企业 |
| `providerType` | String? | 服务商类型:`SOCIAL_INSURANCE` / `COMPREHENSIVE` 等 |
### 2.2 新增 ServiceProviderBinding 表
外包企业与客户企业的绑定关系,控制授权范围。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | String (cuid) | 主键 |
| `providerOrgId` | String | 外包服务商企业ID → Organization.id |
| `clientOrgId` | String | 客户企业ID → Organization.id |
| `modules` | String[] | 授权模块列表:`SOCIAL_INSURANCE`, `HOUSING_FUND`, `SPECIAL_STATUS`, `PAYSLIP` |
| `status` | String | `ACTIVE` / `SUSPENDED` / `TERMINATED` |
| `startDate` | DateTime | 服务开始日期 |
| `endDate` | DateTime? | 服务结束日期(null = 长期) |
| `createdBy` | String | 创建人 |
| `createdAt` | DateTime | 创建时间 |
| `updatedAt` | DateTime | 更新时间 |
**唯一约束**: `(providerOrgId, clientOrgId)` 联合唯一
### 2.3 User 表扩展角色
现有角色:`ADMIN``HR`
新增角色:
| 角色 | 说明 | 归属 |
|------|------|------|
| `PROVIDER_ADMIN` | 外包企业管理员,可管理本企业员工、绑定客户企业 | 外包企业 |
| `PROVIDER_STAFF` | 外包专员,仅可操作授权模块 | 外包企业 |
### 2.4 User 表变更
现有 User 表已有 `role` 字段(值为 `ADMIN` / `HR`),直接扩展该字段的枚举值,无需新增 `orgRole`
| 字段 | 类型 | 说明 |
|------|------|------|
| `role` | String | **现有字段,扩展枚举值**`ADMIN` / `HR` / `PROVIDER_ADMIN` / `PROVIDER_STAFF` |
| `allowedModules` | String[]? | PROVIDER_STAFF 的模块级授权。为 null 时回退到 ServiceProviderBinding.modules 的并集;有值时覆盖绑定关系默认授权,用于精细控制 |
## 三、后端设计
### 3.1 认证与会话
#### 登录流程
```
POST /auth/login
→ 验证用户名密码
→ 返回 JWTpayload 包含:
{
userId, orgId (所属企业), orgRole,
isProvider: boolean
}
```
#### 企业切换(仅 PROVIDER 角色)
```
POST /auth/switch-org/:clientOrgId
→ 校验: ServiceProviderBinding(providerOrgId=用户所属企业, clientOrgId, status=ACTIVE)
→ 返回新 JWTpayload 增加:
{
actingOrgId: clientOrgId, // 当前操作的企业
allowedModules: [...] // 从绑定关系获取
}
→ 原有 orgId 保持为 providerOrgId
```
#### Session 结构
```typescript
interface JwtPayload {
userId: string
orgId: string // 用户所属企业(provider 或 client
orgRole: string // 角色
actingOrgId?: string // 外包专员当前操作的客户企业(仅 provider)
allowedModules?: string[] // 授权模块列表(仅 provider
isProvider: boolean
}
```
### 3.2 权限中间件
```typescript
// 现有: authMiddleware → req.user.orgId
// 改造: authMiddleware → 设置 req.user.orgId 和 req.user.actingOrgId
// 数据隔离统一使用 actingOrgId(有值时)或 orgId
function getEffectiveOrgId(req: Request): string {
return req.user.actingOrgId || req.user.orgId
}
// 新增: moduleAccessMiddleware(moduleName, accessLevel?)
// 1. 非 provider 用户直接放行
// 2. provider 用户检查 moduleName 是否在 allowedModules 中
// 3. accessLevel 可选: 'read' / 'write'PROVIDER_ADMIN 对部分模块只有 read 权限
// 4. 不在则返回 403
```
#### 读写权限控制
```typescript
// 模块读写权限配置
const MODULE_ACCESS: Record<string, Record<string, 'read' | 'write' | 'none'>> = {
ROSTER: { ADMIN: 'write', HR: 'write', PROVIDER_ADMIN: 'read', PROVIDER_STAFF: 'none' },
REPORT: { ADMIN: 'write', HR: 'write', PROVIDER_ADMIN: 'read', PROVIDER_STAFF: 'none' },
SOCIAL_INSURANCE: { ADMIN: 'write', HR: 'write', PROVIDER_ADMIN: 'write', PROVIDER_STAFF: 'write' },
// ... 其他模块
}
// 在路由级别区分
router.get('/roster', authMiddleware, moduleAccessMiddleware('ROSTER', 'read')) // PROVIDER_ADMIN 可访问
router.post('/roster', authMiddleware, moduleAccessMiddleware('ROSTER', 'write')) // PROVIDER_ADMIN 被拒绝
```
#### 中间件使用示例
```typescript
// 社保路由 — provider 有读写权限
router.use('/social-insurance', authMiddleware, moduleAccessMiddleware('SOCIAL_INSURANCE', 'write'))
// 花名册路由 — provider 只有读权限
router.get('/roster', authMiddleware, moduleAccessMiddleware('ROSTER', 'read'))
router.post('/roster', authMiddleware, moduleAccessMiddleware('ROSTER', 'write')) // provider 被拒绝
// 合同路由 — provider 无权限
router.use('/contracts', authMiddleware, moduleAccessMiddleware('CONTRACT', 'write'))
// 薪酬路由 — provider 无权限
router.use('/payroll', authMiddleware, moduleAccessMiddleware('PAYROLL', 'write'))
```
### 3.3 模块权限矩阵
| 模块 | 模块标识 | ADMIN/HR | PROVIDER_ADMIN | PROVIDER_STAFF |
|------|----------|----------|----------------|----------------|
| 概览/Dashboard | `DASHBOARD` | ✅ | ✅ | ✅ |
| 花名册 | `ROSTER` | ✅ | ✅(只读) | ❌ |
| 合同管理 | `CONTRACT` | ✅ | ❌ | ❌ |
| 薪酬工资 | `PAYROLL` | ✅ | ❌ | ❌ |
| 工资条 | `PAYSLIP` | ✅ | ❌ | ❌ |
| 考勤工时 | `ATTENDANCE` | ✅ | ❌ | ❌ |
| 社保公积金 | `SOCIAL_INSURANCE` | ✅ | ✅ | ✅ |
| 公积金 | `HOUSING_FUND` | ✅ | ✅ | ✅ |
| 特殊状态 | `SPECIAL_STATUS` | ✅ | ✅ | ✅ |
| 解聘管理 | `TERMINATION` | ✅ | ❌ | ❌ |
| 规章制度 | `POLICY` | ✅ | ❌ | ❌ |
| 证据链 | `EVIDENCE` | ✅ | ❌ | ❌ |
| 年度报告 | `REPORT` | ✅ | ✅(只读) | ❌ |
| 风险提醒 | `RISK` | ✅ | ✅(仅授权模块相关) | ✅(仅授权模块相关) |
| 系统设置 | `SETTINGS` | ✅ | ❌ | ❌ |
### 3.4 API 路由变更
#### 新增路由
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | `/provider/clients` | 外包企业的客户列表 | PROVIDER_ADMIN |
| POST | `/provider/bindings` | 创建客户绑定 | PROVIDER_ADMIN |
| PATCH | `/provider/bindings/:id` | 修改绑定(模块/状态) | PROVIDER_ADMIN |
| DELETE | `/provider/bindings/:id` | 解除绑定 | PROVIDER_ADMIN |
| GET | `/provider/bindings` | 绑定列表 | PROVIDER_ADMIN |
| POST | `/auth/switch-org/:orgId` | 切换操作企业 | PROVIDER_* |
| GET | `/auth/current-org` | 当前操作企业信息 | ALL |
#### 现有路由改造
所有业务路由的数据隔离从 `req.user.orgId` 改为 `getEffectiveOrgId(req)`
```typescript
// 改造前
const orgId = req.user.orgId
// 改造后
const orgId = req.user.actingOrgId || req.user.orgId
```
### 3.5 数据操作边界
| 操作 | PROVIDER_ADMIN | PROVIDER_STAFF |
|------|----------------|----------------|
| 增删员工 | ❌ | ❌ |
| 签订/修改合同 | ❌ | ❌ |
| 发起解聘 | ❌ | ❌ |
| 修改社保基数/缴纳状态 | ✅ | ✅ |
| 录入特殊状态(孕期/工伤/医疗期) | ✅ | ✅ |
| 查看花名册 | ✅(只读) | ❌ |
| 查看年度报告 | ✅(只读) | ❌ |
| 系统设置 | ❌ | ❌ |
### 3.6 审计日志增强
现有 AuditLog 表已有:`id`, `orgId`, `userId`, `action`, `target`, `detail`, `createdAt`
新增字段:
```typescript
// AuditLog 新增字段
{
operatorOrgId: string // 操作者所属企业(外包操作时 ≠ orgId)
actingOrgId: string // 被操作的企业(= getEffectiveOrgId
operatorRole: string // 操作者角色(PROVIDER_STAFF 等)
}
```
- 非 provider 用户操作时:`operatorOrgId = orgId``actingOrgId = orgId`
- 外包专员操作时:`operatorOrgId = 外包企业ID``actingOrgId = 客户企业ID`
- 客户HR可在审计日志中筛选 `operatorOrgId ≠ orgId` 查看外包操作记录
### 3.7 风险提醒过滤
外包专员看到的风险提醒只包含授权模块相关的风险:
```typescript
// getDashboardData 中过滤
if (req.user.isProvider) {
// 按授权模块映射到风险类型
const moduleTypeMap: Record<string, string[]> = {
'SOCIAL_INSURANCE': ['MONTHLY'], // 社保月度任务(title 包含「社保」)
'HOUSING_FUND': ['MONTHLY'], // 公积金月度任务(title 包含「公积金」)
'SPECIAL_STATUS': ['TERMINATION', 'CONTRACT'], // 特殊状态:解聘受限 + 合同到期不得终止
}
const allowedTypes = req.user.allowedModules
.flatMap(m => moduleTypeMap[m] || [])
todos = todos.filter(t => allowedTypes.includes(t.type))
// 对 MONTHLY 类型还需按 title 二次过滤(社保 vs 公积金 vs 工资 vs 个税)
if (allowedTypes.includes('MONTHLY')) {
const monthlyKeywords: string[] = []
if (req.user.allowedModules.includes('SOCIAL_INSURANCE')) monthlyKeywords.push('社保')
if (req.user.allowedModules.includes('HOUSING_FUND')) monthlyKeywords.push('公积金')
todos = todos.filter(t => {
if (t.type !== 'MONTHLY') return true
return monthlyKeywords.some(kw => t.title.includes(kw))
})
}
}
```
## 四、前端设计
### 4.1 路由结构
```
现有路由(不变):
/ → Dashboard
/roster → 花名册
/contracts → 合同管理
...
新增路由:
/provider/clients → 客户企业管理(PROVIDER_ADMIN
/provider/bindings → 绑定关系管理
```
### 4.2 顶部导航栏改造
#### 客户HR(不变)
```
[Logo] [工作台] [花名册] [合同] [薪酬] [考勤] [社保] [解聘] [制度] [证据链] [设置] [用户]
```
#### 外包专员
```
[Logo] [当前服务企业 ▼] [工作台] [社保公积金] [特殊状态] [用户]
```
- 顶部增加「当前服务企业」下拉切换器
- 菜单只显示授权模块
- 隐藏所有未授权模块的菜单项和路由入口
### 4.3 企业切换器组件
```tsx
function OrgSwitcher({ bindings, currentOrgId, onSwitch }) {
return (
<Select value={currentOrgId} onValueChange={onSwitch}>
{bindings.map(b => (
<SelectItem key={b.clientOrgId} value={b.clientOrgId}>
{b.clientOrg.name}
<span className="text-xs text-gray-400 ml-2">
{b.modules.join('、')}
</span>
</SelectItem>
))}
</Select>
)
}
```
- 切换时调用 `POST /auth/switch-org/:orgId`
- 刷新页面数据(invalidate all queries
- 切换后菜单按新企业的授权模块重新渲染
### 4.4 菜单配置(按角色过滤)
```typescript
const ALL_MENUS = [
{ key: 'dashboard', label: '工作台', path: '/', module: 'DASHBOARD' },
{ key: 'roster', label: '花名册', path: '/roster', module: 'ROSTER' },
{ key: 'contract', label: '合同管理', path: '/contracts', module: 'CONTRACT' },
{ key: 'payroll', label: '薪酬工资', path: '/money', module: 'PAYROLL' },
{ key: 'attendance', label: '考勤工时', path: '/attendance', module: 'ATTENDANCE' },
// 社保和公积金在前端合为一个菜单项,但后端模块标识分离
// 菜单可见条件:用户有 SOCIAL_INSURANCE 或 HOUSING_FUND 任一权限
{ key: 'social', label: '社保公积金', path: '/social', module: 'SOCIAL_INSURANCE', altModule: 'HOUSING_FUND' },
{ key: 'special', label: '特殊状态', path: '/special-status', module: 'SPECIAL_STATUS' },
{ key: 'termination', label: '解聘管理', path: '/termination', module: 'TERMINATION' },
{ key: 'policy', label: '规章制度', path: '/policies', module: 'POLICY' },
{ key: 'evidence', label: '证据链', path: '/evidence', module: 'EVIDENCE' },
{ key: 'report', label: '年度报告', path: '/tools/annual-value', module: 'REPORT' },
{ key: 'settings', label: '系统设置', path: '/settings', module: 'SETTINGS' },
]
function getVisibleMenus(user: User): Menu[] {
if (!user.isProvider) return ALL_MENUS
return ALL_MENUS.filter(m => {
// 主模块或有备选模块任一在授权列表中即可
const hasMain = user.allowedModules?.includes(m.module)
const hasAlt = m.altModule && user.allowedModules?.includes(m.altModule)
return hasMain || hasAlt
})
}
```
> **注意**:社保和公积金在前端合为一个页面 `/social`,但后端 API 路由分离为 `SOCIAL_INSURANCE` 和 `HOUSING_FUND` 两个模块。前端页面内根据 `allowedModules` 控制 Tab 或区块的显隐。
### 4.5 Dashboard 差异
#### 客户HR Dashboard
- 完整概览:员工数、合同、薪酬、考勤、社保、风险
- 全部 Tab:概览 / 风险提醒 / 月度任务
- 所有统计卡片
#### 外包专员 Dashboard
- 精简概览:在管员工数(只读)、社保缴纳状态、公积金缴纳状态、特殊状态员工数
- Tab:概览 / 风险提醒(仅社保/公积金/特殊状态相关)
- 隐藏:薪酬汇总、合同到期预警、考勤统计、解聘动态等卡片
- 统计卡片只显示授权模块相关的
### 4.6 前端状态管理
```typescript
// useAuth hook 扩展
interface AuthState {
user: User
isProvider: boolean
actingOrgId: string | null
allowedModules: string[]
bindings: ServiceBinding[] // 可切换的客户企业列表
}
// 切换企业
function switchOrg(orgId: string) {
await api.post(`/auth/switch-org/${orgId}`)
// 更新 token
// invalidate 所有 query
queryClient.invalidateQueries()
// 重新加载菜单
}
```
### 4.7 路由守卫
```tsx
function PrivateRoute({ module, children }) {
const { user } = useAuth()
// 非 provider 直接放行
if (!user.isProvider) return children
// provider 检查模块权限
if (module && !user.allowedModules?.includes(module)) {
return <Navigate to="/" replace />
}
return children
}
```
## 五、外包企业注册与初始化流程
### 5.1 外包企业创建
1. **平台管理员创建**:由系统 SUPER_ADMIN 在管理后台创建外包企业账号,设置 `isProvider: true`
2. **创建管理员账号**:为外包企业创建第一个 `PROVIDER_ADMIN` 用户
3. **外包管理员登录**PROVIDER_ADMIN 登录后进入外包企业管理界面
### 5.2 客户绑定流程
```
外包管理员 → /provider/clients → 搜索客户企业(按企业名称/统一社会信用代码)
→ 选择客户企业 → 选择授权模块 → 创建绑定
→ 客户企业 ADMIN 收到绑定通知 → 确认/拒绝
→ 确认后绑定状态变为 ACTIVE
```
> 绑定需要客户企业确认,防止未经授权的外包企业接入。
### 5.3 外包专员账号创建
PROVIDER_ADMIN 在外包企业内创建 PROVIDER_STAFF 账号:
1. 填写用户名、密码、姓名
2. 可选:设置 `allowedModules`(为空则继承绑定关系的全部授权模块)
3. 可选:分配特定客户企业(默认可见全部已绑定客户)
### 5.4 登录后自动进入逻辑
```typescript
// 外包专员登录后的路由策略
if (user.isProvider) {
const bindings = await getBindings(user.orgId)
if (bindings.length === 0) {
// 无绑定:跳转到「等待绑定」提示页
navigate('/provider/no-clients')
} else if (bindings.length === 1) {
// 仅一个客户:自动切换并进入 Dashboard
await switchOrg(bindings[0].clientOrgId)
navigate('/')
} else {
// 多个客户:跳转到客户选择页
navigate('/provider/select-client')
}
}
```
## 六、通知机制
### 6.1 外包操作通知客户HR
外包专员完成以下操作时,客户HR收到站内通知:
| 事件 | 通知内容 |
|------|----------|
| 社保缴纳完成 | 「XX外包公司已完成 2026-07 月社保缴纳」 |
| 公积金缴纳完成 | 「XX外包公司已完成 2026-07 月公积金缴纳」 |
| 特殊状态录入 | 「XX外包公司录入了员工张三的孕期状态」 |
| 特殊状态变更 | 「XX外包公司更新了员工李四的工伤状态」 |
### 6.2 客户HR操作通知外包专员
客户HR完成以下操作时,外包专员收到站内通知:
| 事件 | 通知内容 |
|------|----------|
| 新员工入职 | 「客户企业新增员工王五,请及时办理社保增员」 |
| 员工离职 | 「客户企业员工赵六已离职,请及时办理社保减员」 |
| 社保基数变更 | 「客户企业修改了员工孙七的社保基数」 |
### 6.3 通知实现
- 复用现有 `Notification` 表(如有)或新增 `Notification`
- 字段:`userId`, `orgId`, `type`, `title`, `content`, `isRead`, `createdAt`
- 前端 Header 增加通知铃铛图标,轮询或 WebSocket 推送
## 七、实施步骤
| 阶段 | 内容 | 预估工作量 |
|------|------|-----------|
| 1 | 数据库迁移:Organization.isProvider、ServiceProviderBinding 表、User.role 扩展、User.allowedModules、Notification 表 | 0.5天 |
| 2 | 后端:JWT 扩展、企业切换接口、权限中间件(含读写级别) | 1天 |
| 3 | 后端:现有路由数据隔离改造(orgId → actingOrgId | 0.5天 |
| 4 | 后端:provider 管理接口(绑定/解绑/列表/确认)、通知接口 | 1天 |
| 5 | 前端:企业切换器、菜单过滤、路由守卫、客户选择页 | 1天 |
| 6 | 前端:Dashboard 精简视图、模块级数据过滤、通知铃铛 | 1天 |
| 7 | 审计日志增强 + 通知机制 + 测试 | 1天 |
| **合计** | | **6天** |
## 八、安全要点
1. **后端独立校验**:前端隐藏菜单 ≠ 后端放权,每个 API 都经过 `moduleAccessMiddleware` 校验
2. **绑定关系校验**:每次企业切换都验证 `ServiceProviderBinding` 的有效性(status=ACTIVE、未过期)
3. **数据隔离不变**:所有业务数据仍按 `actingOrgId`(客户企业)隔离,外包企业本身不存储业务数据
4. **审计可追溯**:外包专员的每次操作都记录操作者企业和被操作企业
5. **会话隔离**JWT 中 `actingOrgId` 不可篡改,切换企业必须走 `/auth/switch-org` 接口
## 九、不改动部分
- 现有所有业务逻辑不变(合同、薪酬、考勤、社保等)
- 员工、合同、薪酬等数据仍归属客户企业
- 数据库表结构基本不变(只新增字段和表)
- 现有客户HR的使用体验完全不变