# 外包服务模式设计方案

> 场景：客户企业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
  → 验证用户名密码
  → 返回 JWT，payload 包含:
    {
      userId, orgId (所属企业), orgRole,
      isProvider: boolean
    }
```

#### 企业切换（仅 PROVIDER 角色）

```
POST /auth/switch-org/:clientOrgId
  → 校验: ServiceProviderBinding(providerOrgId=用户所属企业, clientOrgId, status=ACTIVE)
  → 返回新 JWT，payload 增加:
    {
      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的使用体验完全不变
