fix: 人力成本分析按员工去重,统一与Dashboard口径;环比/同比无数据时显示暂无数据
This commit is contained in:
@@ -1116,18 +1116,34 @@ export async function getCostAnalysis(orgId: string, month: string) {
|
||||
const lastYearMonthEnd = new Date(year - 1, monthNum, 0, 23, 59, 59)
|
||||
const lastYearMonthStr = `${lastYearMonthStart.getFullYear()}-${String(lastYearMonthStart.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
// 获取各月归档批次汇总
|
||||
// 获取各月归档批次汇总(按员工去重,与 getDashboardData 口径一致)
|
||||
const getMonthCost = async (m: string) => {
|
||||
const batches = await prisma.payrollBatch.findMany({
|
||||
where: { orgId, month: m, status: 'ARCHIVED' },
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { orgId, batch: { month: m, status: 'ARCHIVED' } },
|
||||
select: { employeeId: true, totalPay: true, socialOrg: true, housingOrg: true, tax: true },
|
||||
})
|
||||
return batches.reduce((acc, b) => ({
|
||||
totalPay: acc.totalPay + b.totalPay,
|
||||
totalSocialOrg: acc.totalSocialOrg + b.totalSocialOrg,
|
||||
totalHousingOrg: acc.totalHousingOrg + b.totalHousingOrg,
|
||||
totalTax: acc.totalTax + b.totalTax,
|
||||
employeeCount: acc.employeeCount + b.employeeCount,
|
||||
}), { totalPay: 0, totalSocialOrg: 0, totalHousingOrg: 0, totalTax: 0, employeeCount: 0 })
|
||||
// 同一员工多批次只取一条(取最后一条,金额通常已合并)
|
||||
const empMap = new Map<string, { totalPay: number; socialOrg: number; housingOrg: number; tax: number }>()
|
||||
for (const e of entries) {
|
||||
const ex = empMap.get(e.employeeId)
|
||||
if (!ex) {
|
||||
empMap.set(e.employeeId, { totalPay: e.totalPay, socialOrg: e.socialOrg, housingOrg: e.housingOrg, tax: e.tax })
|
||||
} else {
|
||||
// 同一员工多批次:累加金额
|
||||
ex.totalPay += e.totalPay
|
||||
ex.socialOrg += e.socialOrg
|
||||
ex.housingOrg += e.housingOrg
|
||||
ex.tax += e.tax
|
||||
}
|
||||
}
|
||||
const deduped = Array.from(empMap.values())
|
||||
return {
|
||||
totalPay: deduped.reduce((s, e) => s + e.totalPay, 0),
|
||||
totalSocialOrg: deduped.reduce((s, e) => s + e.socialOrg, 0),
|
||||
totalHousingOrg: deduped.reduce((s, e) => s + e.housingOrg, 0),
|
||||
totalTax: deduped.reduce((s, e) => s + e.tax, 0),
|
||||
employeeCount: deduped.length,
|
||||
}
|
||||
}
|
||||
|
||||
const current = await getMonthCost(month)
|
||||
@@ -1183,7 +1199,7 @@ export async function getCostAnalysis(orgId: string, month: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// 按部门拆分成本
|
||||
// 按部门拆分成本(按员工去重)
|
||||
const deptEntries = await prisma.batchEntry.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
@@ -1191,14 +1207,26 @@ export async function getCostAnalysis(orgId: string, month: string) {
|
||||
},
|
||||
include: { employee: { select: { department: true } } },
|
||||
})
|
||||
const deptMap: Record<string, { totalPay: number; socialOrg: number; housingOrg: number; headcount: number }> = {}
|
||||
// 先按员工去重,同一员工多批次累加金额
|
||||
const deptEmpMap = new Map<string, { dept: string; totalPay: number; socialOrg: number; housingOrg: number }>()
|
||||
for (const e of deptEntries) {
|
||||
const dept = e.employee?.department || '未分配'
|
||||
if (!deptMap[dept]) deptMap[dept] = { totalPay: 0, socialOrg: 0, housingOrg: 0, headcount: 0 }
|
||||
deptMap[dept].totalPay += e.totalPay
|
||||
deptMap[dept].socialOrg += e.socialOrg
|
||||
deptMap[dept].housingOrg += e.housingOrg
|
||||
deptMap[dept].headcount += 1
|
||||
const ex = deptEmpMap.get(e.employeeId)
|
||||
if (!ex) {
|
||||
deptEmpMap.set(e.employeeId, { dept, totalPay: e.totalPay, socialOrg: e.socialOrg, housingOrg: e.housingOrg })
|
||||
} else {
|
||||
ex.totalPay += e.totalPay
|
||||
ex.socialOrg += e.socialOrg
|
||||
ex.housingOrg += e.housingOrg
|
||||
}
|
||||
}
|
||||
const deptMap: Record<string, { totalPay: number; socialOrg: number; housingOrg: number; headcount: number }> = {}
|
||||
for (const [, v] of deptEmpMap) {
|
||||
if (!deptMap[v.dept]) deptMap[v.dept] = { totalPay: 0, socialOrg: 0, housingOrg: 0, headcount: 0 }
|
||||
deptMap[v.dept].totalPay += v.totalPay
|
||||
deptMap[v.dept].socialOrg += v.socialOrg
|
||||
deptMap[v.dept].housingOrg += v.housingOrg
|
||||
deptMap[v.dept].headcount += 1
|
||||
}
|
||||
const departmentCost = Object.entries(deptMap)
|
||||
.map(([dept, v]) => ({
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
# 外包服务模式设计方案
|
||||
|
||||
> 场景:客户企业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的使用体验完全不变
|
||||
@@ -597,23 +597,35 @@ export default function Dashboard() {
|
||||
{costAnalysis ? (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className={`p-2 rounded-lg ${costAnalysis.monthOnMonth?.change >= 0 ? 'bg-red-50' : 'bg-green-50'}`}>
|
||||
<div className={`p-2 rounded-lg ${costAnalysis.monthOnMonth?.prevTotal > 0 ? (costAnalysis.monthOnMonth?.change >= 0 ? 'bg-red-50' : 'bg-green-50') : 'bg-gray-50'}`}>
|
||||
<div className="text-xs text-gray-500">环比上月</div>
|
||||
<div className={`text-sm font-bold ${costAnalysis.monthOnMonth?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{costAnalysis.monthOnMonth?.change >= 0 ? '+' : ''}{costAnalysis.monthOnMonth?.changePercent?.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{costAnalysis.monthOnMonth?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.monthOnMonth?.change || 0))}
|
||||
</div>
|
||||
{costAnalysis.monthOnMonth?.prevTotal > 0 ? (
|
||||
<>
|
||||
<div className={`text-sm font-bold ${costAnalysis.monthOnMonth?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{costAnalysis.monthOnMonth?.change >= 0 ? '+' : ''}{costAnalysis.monthOnMonth?.changePercent?.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{costAnalysis.monthOnMonth?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.monthOnMonth?.change || 0))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-2 rounded-lg ${costAnalysis.yearOnYear?.change >= 0 ? 'bg-red-50' : 'bg-green-50'}`}>
|
||||
<div className={`p-2 rounded-lg ${costAnalysis.yearOnYear?.lastYearTotal > 0 ? (costAnalysis.yearOnYear?.change >= 0 ? 'bg-red-50' : 'bg-green-50') : 'bg-gray-50'}`}>
|
||||
<div className="text-xs text-gray-500">同比去年</div>
|
||||
<div className={`text-sm font-bold ${costAnalysis.yearOnYear?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{costAnalysis.yearOnYear?.change >= 0 ? '+' : ''}{costAnalysis.yearOnYear?.changePercent?.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{costAnalysis.yearOnYear?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.yearOnYear?.change || 0))}
|
||||
</div>
|
||||
{costAnalysis.yearOnYear?.lastYearTotal > 0 ? (
|
||||
<>
|
||||
<div className={`text-sm font-bold ${costAnalysis.yearOnYear?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{costAnalysis.yearOnYear?.change >= 0 ? '+' : ''}{costAnalysis.yearOnYear?.changePercent?.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{costAnalysis.yearOnYear?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.yearOnYear?.change || 0))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t pt-2">
|
||||
|
||||
Reference in New Issue
Block a user