From c15e11ec22bcaa102a6842d8c227df88f012a75d Mon Sep 17 00:00:00 2001 From: selfrelease Date: Fri, 31 Jul 2026 14:24:37 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BA=BA=E5=8A=9B=E6=88=90=E6=9C=AC?= =?UTF-8?q?=E5=88=86=E6=9E=90=E6=8C=89=E5=91=98=E5=B7=A5=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=EF=BC=8C=E7=BB=9F=E4=B8=80=E4=B8=8EDashboard=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=EF=BC=9B=E7=8E=AF=E6=AF=94/=E5=90=8C=E6=AF=94=E6=97=A0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=97=B6=E6=98=BE=E7=A4=BA=E6=9A=82=E6=97=A0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/services/risk.service.ts | 62 +++- docs/外包服务模式设计.md | 506 +++++++++++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 40 ++- 3 files changed, 577 insertions(+), 31 deletions(-) create mode 100644 docs/外包服务模式设计.md diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts index 05ef23e..6edec1c 100644 --- a/backend/src/services/risk.service.ts +++ b/backend/src/services/risk.service.ts @@ -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() + 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 = {} + // 先按员工去重,同一员工多批次累加金额 + const deptEmpMap = new Map() 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 = {} + 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]) => ({ diff --git a/docs/外包服务模式设计.md b/docs/外包服务模式设计.md new file mode 100644 index 0000000..2d42ebe --- /dev/null +++ b/docs/外包服务模式设计.md @@ -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> = { + 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 = { + '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 ( + + ) +} +``` + +- 切换时调用 `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 + } + 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的使用体验完全不变 diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0c1de58..a9706e1 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -597,23 +597,35 @@ export default function Dashboard() { {costAnalysis ? (
-
= 0 ? 'bg-red-50' : 'bg-green-50'}`}> +
0 ? (costAnalysis.monthOnMonth?.change >= 0 ? 'bg-red-50' : 'bg-green-50') : 'bg-gray-50'}`}>
环比上月
-
= 0 ? 'text-red-600' : 'text-green-600'}`}> - {costAnalysis.monthOnMonth?.change >= 0 ? '+' : ''}{costAnalysis.monthOnMonth?.changePercent?.toFixed(1)}% -
-
- {costAnalysis.monthOnMonth?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.monthOnMonth?.change || 0))} -
+ {costAnalysis.monthOnMonth?.prevTotal > 0 ? ( + <> +
= 0 ? 'text-red-600' : 'text-green-600'}`}> + {costAnalysis.monthOnMonth?.change >= 0 ? '+' : ''}{costAnalysis.monthOnMonth?.changePercent?.toFixed(1)}% +
+
+ {costAnalysis.monthOnMonth?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.monthOnMonth?.change || 0))} +
+ + ) : ( +
暂无数据
+ )}
-
= 0 ? 'bg-red-50' : 'bg-green-50'}`}> +
0 ? (costAnalysis.yearOnYear?.change >= 0 ? 'bg-red-50' : 'bg-green-50') : 'bg-gray-50'}`}>
同比去年
-
= 0 ? 'text-red-600' : 'text-green-600'}`}> - {costAnalysis.yearOnYear?.change >= 0 ? '+' : ''}{costAnalysis.yearOnYear?.changePercent?.toFixed(1)}% -
-
- {costAnalysis.yearOnYear?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.yearOnYear?.change || 0))} -
+ {costAnalysis.yearOnYear?.lastYearTotal > 0 ? ( + <> +
= 0 ? 'text-red-600' : 'text-green-600'}`}> + {costAnalysis.yearOnYear?.change >= 0 ? '+' : ''}{costAnalysis.yearOnYear?.changePercent?.toFixed(1)}% +
+
+ {costAnalysis.yearOnYear?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.yearOnYear?.change || 0))} +
+ + ) : ( +
暂无数据
+ )}