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

18 KiB
Raw Permalink Blame History

外包服务模式设计方案

场景:客户企业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 表扩展角色

现有角色:ADMINHR

新增角色:

角色 说明 归属
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 结构

interface JwtPayload {
  userId: string
  orgId: string          // 用户所属企业(provider 或 client
  orgRole: string        // 角色
  actingOrgId?: string   // 外包专员当前操作的客户企业(仅 provider)
  allowedModules?: string[] // 授权模块列表(仅 provider
  isProvider: boolean
}

3.2 权限中间件

// 现有: 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

读写权限控制

// 模块读写权限配置
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 被拒绝

中间件使用示例

// 社保路由 — 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)

// 改造前
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

新增字段:

// AuditLog 新增字段
{
  operatorOrgId: string    // 操作者所属企业(外包操作时 ≠ orgId)
  actingOrgId: string      // 被操作的企业(= getEffectiveOrgId
  operatorRole: string     // 操作者角色(PROVIDER_STAFF 等)
}
  • 非 provider 用户操作时:operatorOrgId = orgIdactingOrgId = orgId
  • 外包专员操作时:operatorOrgId = 外包企业IDactingOrgId = 客户企业ID
  • 客户HR可在审计日志中筛选 operatorOrgId ≠ orgId 查看外包操作记录

3.7 风险提醒过滤

外包专员看到的风险提醒只包含授权模块相关的风险:

// 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 企业切换器组件

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 菜单配置(按角色过滤)

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_INSURANCEHOUSING_FUND 两个模块。前端页面内根据 allowedModules 控制 Tab 或区块的显隐。

4.5 Dashboard 差异

客户HR Dashboard

  • 完整概览:员工数、合同、薪酬、考勤、社保、风险
  • 全部 Tab:概览 / 风险提醒 / 月度任务
  • 所有统计卡片

外包专员 Dashboard

  • 精简概览:在管员工数(只读)、社保缴纳状态、公积金缴纳状态、特殊状态员工数
  • Tab:概览 / 风险提醒(仅社保/公积金/特殊状态相关)
  • 隐藏:薪酬汇总、合同到期预警、考勤统计、解聘动态等卡片
  • 统计卡片只显示授权模块相关的

4.6 前端状态管理

// 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 路由守卫

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 登录后自动进入逻辑

// 外包专员登录后的路由策略
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的使用体验完全不变