fix: 人力成本分析按员工去重,统一与Dashboard口径;环比/同比无数据时显示暂无数据

This commit is contained in:
selfrelease
2026-07-31 14:24:37 +08:00
parent 9df147ce59
commit c15e11ec22
3 changed files with 577 additions and 31 deletions
+45 -17
View File
@@ -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]) => ({
+506
View File
@@ -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
→ 验证用户名密码
→ 返回 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的使用体验完全不变
+26 -14
View File
@@ -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">