Compare commits

...

87 Commits

Author SHA1 Message Date
selfrelease aea88fd385 feat: 工作台合规评分优化、面包屑补全、花名册斑马纹
- 合规评分维度拆分:薪酬社保拆为薪酬工资+社保公积金两个独立维度
- 合同管理评分纳入待签署合同(signDate为null)扣分
- 维度评分hover显示得分依据findings和建议recommendations
- 概览统计卡片支持点击跳转(在管员工/高风险/待办/工资条)
- 面包屑补全10个缺失路由,统一分组名与侧边栏一致
- 移除子Tab中重复的PageGuide组件
- 花名册列表添加斑马纹隔行变色
- 薪酬结构模版表格列宽优化,计算公式列截断+title显示

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 17:33:48 +08:00
selfrelease 5196d7c80a fix: portal端token过期后自动跳转登录页,避免显示空数据
- portalAxios 401 响应拦截器:清除 token 并跳转 /portal/login
- PortalLayoutWrapper:检查 token 是否存在,不存在则跳转登录页
- 排除 /portal/login 和 /portal/auto-login 页面避免重复跳转

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 15:06:46 +08:00
selfrelease c66ea290eb fix: 签署日期不能晚于当前日期
后端 /sign-date 和 /paper-sign 均增加校验,签署日期晚于今天则报错。
前端日期选择器增加 max 属性限制为今天。
2026-08-17 12:48:07 +08:00
selfrelease e824cd1629 fix: 线下签署登记完成时间改为实际签署日期
/sign-date 路径 completedAt 原来用 new Date()(HR登记时间),
导致发起时间和完成时间完全一样。改为用 parsedDate(HR选择的签署日期)。
2026-08-17 12:44:39 +08:00
selfrelease c039f0a3cf fix: 员工端工资条补全明细项(奖金/社保/公积金/个税/实发)
原只显示基本工资/加班费/津贴/扣款4项,补全为:
基本工资/加班费/津贴/奖金/扣款 → 应发合计
→ 个人社保/个人公积金/个人所得税 → 实发工资

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 12:38:05 +08:00
selfrelease 61873b2581 fix: 公积金账户下拉只显示基本公积金,补充公积金下拉只显示补充
添加员工表单中:
- 公积金账户下拉:只显示 accountType !== 'SUPPLEMENTARY' 的基本公积金账户
- 补充公积金下拉:只显示 accountType === 'SUPPLEMENTARY' 的补充公积金账户

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 12:35:51 +08:00
selfrelease ef685663ac fix: 公积金表单去掉多余的housingBaseMin/Max输入,只保留一套基数上下限
新架构下社保和公积金是不同账户,各自有独立的年度标准,
baseMin/baseMax 已各自独立,housingBaseMin/Max是旧架构遗留。
公积金表单标签改为"公积金基数下限/上限",默认值改为2540/35811。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 12:30:55 +08:00
selfrelease d3a9ea041f feat: AI风险检查以身份证号为员工唯一标识
- buildOrgContext 员工列表加入脱敏身份证号(前6后4,中间*)
- 单员工/部门预测上下文加入脱敏身份证号
- AI prompt 更新:要求用身份证号标识员工,姓名仅辅助参考
- 同名员工以身份证号区分

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 11:48:42 +08:00
selfrelease 874f8c49bd fix: 懒加载chunk失败时自动刷新页面
部署后旧chunk文件名(带hash)被删除,浏览器若缓存了旧index.html
仍会请求旧chunk导致 "Failed to fetch dynamically imported module"。
新增 lazyRetry 包装器,检测到此错误时自动刷新页面获取新index.html。
用 sessionStorage 标记防止无限刷新。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 11:42:19 +08:00
selfrelease fa7bf69682 ui: 优化侧边栏菜单分组
- 提成奖金从「团队」移到「薪酬」
- 待签合同从「首页」移到「团队」
- 「合规」拆分:风险中心/证据链条/规章制度保留,用工体检/医疗期/年度价值独立为「工具」组
2026-08-17 11:35:58 +08:00
selfrelease d017d6337f ui: 登录页Logo放大 2026-08-17 11:25:13 +08:00
selfrelease 2afbdacae3 ui: 侧边栏左上角只显示公司名,不显示Logo 2026-08-17 11:22:18 +08:00
selfrelease 6e80bcb330 brand: 品牌名改回安职通,公司名嘉驰英才 2026-08-17 11:12:52 +08:00
selfrelease 7ba357a6e5 ui: 登录页Logo单独一行放大显示 2026-08-17 11:07:03 +08:00
selfrelease 668a908dd6 ui: 登录页Logo单独显示,去掉背景框
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 11:05:17 +08:00
selfrelease 99aa6d6998 brand: 替换品牌名称为嘉驰英才,使用新Logo
- 登录页/注册页/忘记密码/员工端登录:品牌名改为嘉驰英才
- 登录页公司全称:嘉驰英才(上海)信息科技有限公司
- Logo组件改为使用 JClog.png 图片
- 全局替换所有"安职通"为"嘉驰英才"

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 10:56:10 +08:00
selfrelease 6919f997c6 fix: 员工端首页应发合计显示为0
后端返回字段是 totalPay,前端误用 grossPay(不存在的字段),
导致应发合计始终显示 0。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 10:37:20 +08:00
selfrelease 16f38f3c36 feat: 补充公积金支持(员工级别可选)
上海等城市存在补充公积金账户,同一企业内高管可能有但普通员工
没有。新增员工级别补充公积金账户关联:

- Employee 表加 supplementaryHousingAccountId 字段
- BatchEntry 表加 supplementaryHousingEmp/Org 字段
- 薪资计算 calcBatchEntry 支持补充公积金(额外算一笔,纳入
  个税扣除、最低工资保护递延逻辑)
- 新增员工弹窗加补充公积金账户选择器(可选,仅显示
  accountType=SUPPLEMENTARY 的公积金账户)
- 员工档案编辑支持补充公积金账户

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 10:20:59 +08:00
selfrelease 1e7932c36c ui: 删除新增员工弹窗的参保城市字段
参保城市由社保账户自动确定,无需单独显示。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 10:03:23 +08:00
selfrelease def0fe3a1f refactor: 删除 PRE_ONBOARD 显式状态,统一按入职日期判定预入职
预入职不再需要显式设置 PRE_ONBOARD 状态,统一按入职日期自动
判定:入职日期在未来 = 预入职(不进入薪资),入职日期 <= 今天
= 正式在职。

删除内容:
- schema: EmployeeStatus 枚举移除 PRE_ONBOARD
- roster.routes.ts: 移除 PRE_ONBOARD 筛选和 /activate API
- payroll2.routes.ts: 移除 NOT PRE_ONBOARD 排除条件
- modals.tsx: 移除入职状态选择器

保留:按 hireDate > 今天 自动判定预入职的逻辑

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 09:59:21 +08:00
selfrelease c5f4731d5c fix: 创建批次后自动切换列表筛选到批次所属月
问题:批次列表默认按当前月筛选,创建其他月份的批次后列表
不显示新批次,用户以为创建失败。

修复:创建成功后自动将列表月份筛选切换到批次所属月。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-17 06:46:19 +08:00
selfrelease 1d9890e607 fix: 创建年度标准时同effectiveFrom已存在则更新而非报错
问题:创建年度标准时如果该账户已有同 effectiveFrom 的标准,
会触发 P2002 唯一约束冲突,返回400"数据已存在"。

修复:先查是否已存在同 effectiveFrom 的标准,存在则更新,
不存在才创建新标准。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:48:21 +08:00
selfrelease b5aa5552ff chore: 添加社保公积金账户数据修复脚本
- fix-missing-standards.ts: 补齐无年度标准的账户
- fix-housing-base-limits.ts: 更新各城市公积金基数上下限
- check-test-accounts.ts: 删除不完整的测试账户
- assign-accounts.ts: 为全部员工按城市匹配社保公积金账户

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:42:37 +08:00
selfrelease 38075420f4 feat: 社保年度标准完善 — 医疗附加 + 继承旧配置 + 公积金独立上下限
1. 医疗附加金额:SocialYearStandard 和 SocialInsuranceConfig 新增
   medicalOrgExtra/medicalEmpExtra 字段(如北京3元大病医疗)
   - calcSocialInsurance 计算时加上医疗附加
   - 前端年度标准表单加医疗企业/个人附加输入

2. 新建年度标准继承:新增 /inherit-config API
   - 优先用当前年度标准
   - 回退到旧 SocialInsuranceConfig/HousingFundConfig
   - 前端点击"新建年度标准"时自动继承填充

3. 公积金独立上下限:SocialYearStandard 新增 housingBaseMin/housingBaseMax
   - calcHousingFund 优先用公积金专用上下限,为0时回退到社保
   - 前端公积金年度标准表单加公积金基数上下限输入

4. blank_employees/blank_all 模式跳过试用期工资覆盖

5. 劳务协议/实习协议人员 calcBatchEntry 强制社保公积金为0

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:31:56 +08:00
selfrelease 3ac333891c fix: 劳务协议/实习协议人员薪资计算强制社保公积金为0
calcBatchEntry 查询员工时 include contracts,判断合同类型:
- LABOR(劳务协议)/ INTERNSHIP(实习协议)→ 社保公积金基数强制0,跳过社保计算
- 其他合同类型 → 正常计算

无论哪种批次创建模式(copy_last/blank_employees/custom等),
劳务协议人员的社保公积金都为0。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:24:49 +08:00
selfrelease c3ca2c9f50 fix: 公积金基数上下限独立于社保
问题:SocialYearStandard 只有一套 baseMin/baseMax,社保和公积金
共用,但公积金下限通常是最低工资标准(如北京2420),远低于社保
下限(如北京6326)。

修复:
- SocialYearStandard 新增 housingBaseMin/housingBaseMax 字段
- clampHousingFundBase 优先用公积金专用上下限,为0时回退到社保
- calcHousingFund 同样优先用公积金专用上下限
- 前端公积金年度标准表单加公积金基数上下限输入
- 前端公积金当前标准展示用公积金专用上下限

同时修复 blank_employees/blank_all 模式跳过试用期工资覆盖

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:23:08 +08:00
selfrelease 94959522df fix: blank_employees/blank_all 模式跳过试用期工资覆盖
blank_employees 和 blank_all 模式下所有金额应为 0,但统一试用期
判定逻辑会把 probationSalary 覆盖到 baseSalary,导致有试用期合同
的员工基本工资不为 0。

修复:试用期判定仅对 copy_last / copy_batch / custom 模式执行,
blank_employees / blank_all 模式跳过。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:20:05 +08:00
selfrelease 2f1e339413 fix: 劳务协议人员社保基数为0时仍按基本工资计算的bug
根因:socialInsBase || baseSalary 中 0 是 falsy,导致基数为0
的劳务协议/实习人员回退到基本工资计算社保。

修复:改为 nullish check(!= null),区分"明确设置为0"
和"未设置(null)":
- socialInsBase = 0 → 用 0(劳务协议不交社保)
- socialInsBase = null → 用 baseSalary(回退)
- socialInsBase = 5000 → 用 5000(核定基数)

涉及文件:
- payroll.service.ts calcBatchEntry + prePayrollCheck
- payroll.routes.ts 旧版薪资计算

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:12:07 +08:00
selfrelease 54d138cc73 ux: 新建账户按钮移到社保/公积金Tab内部,按类型显示
按钮从页面顶部移到Tab内容区顶部,文案改为"新建社保账户"/"新建公积金账户",
明确表示新建的是当前Tab对应类型的账户。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:10:38 +08:00
selfrelease de4f8c90fb feat: 最低工资保护按当月累计判断 + 预入职员工 + 发薪年月
1. 最低工资保护:第二批次递延时判断当月累计实发(已归档批次
   netPay 之和 + 本批次 netPay)是否低于 minWage,仅累计低于
   时才触发递延补齐,补齐目标 = minWage - 已归档累计实发

2. 预入职员工:EmployeeStatus 新增 PRE_ONBOARD 状态
   - 新增员工表单加"入职状态"选择器(正式入职/预入职)
   - 薪资批次创建排除 PRE_ONBOARD 员工
   - 花名册支持按预入职状态过滤
   - 新增 POST /:id/activate API 转正式

3. 发薪年月:PayrollBatch 新增 payMonth 字段
   - 所属月(计薪月)决定社保标准、个税累计
   - 发薪年月=实际发放月份(如十一提前发薪:所属月=10月,发薪月=9月)
   - 创建批次表单加所属月和发薪年月输入
   - 批次列表显示所属月/发薪月

4. blank_employees 模式不再自动带出基本工资

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:06:58 +08:00
selfrelease 8391c123fc refactor: 社保公积金Tab重构为账户卡片列表+展开年度标准管理
1. 社保/公积金Tab从"选账户→展示配置"改为"账户卡片列表+展开管理"
2. 每个账户卡片可展开显示:当前标准/最低工资编辑/新建年度标准/版本历史/调基/试算
3. 账户新建/编辑/删除/设默认从设置页面迁移到社保公积金菜单
4. 设置页面去掉"社保公积金账户"Tab
5. "新建版本"改名为"新建年度标准"
6. 新增AccountCard组件独立管理每个账户的展开状态和数据查询

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:05:06 +08:00
selfrelease a5911d1874 fix: blank_employees 模式不再自动带出基本工资
本月空白模式应所有金额默认 0,去掉从 emp.monthlySalary 自动填充
基本工资的逻辑,保持与"空白"语义一致。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 14:57:10 +08:00
selfrelease 63a0a6934b fix: 优化最低工资保护tooltip文案,明确免扣社保/公积金与额外补齐的区分
minWageApplied 包含免扣社保+免扣公积金+额外补齐三部分,
原tooltip统称"补齐"容易误解为额外补了这么多现金。
改为分项显示:免扣社保 ¥X + 免扣公积金 ¥Y + 额外补齐 ¥Z

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 14:35:25 +08:00
selfrelease 904c9c6369 feat: 薪资表格增加最低工资保护和递延扣款的可视化提示
1. 实发列:最低工资保护触发时显示橙色 + ★标记 + tooltip
2. 新增「递延扣款」列:显示递延金额(橙色)或补扣金额(蓝色)
3. 风险列:最低工资保护触发时显示橙色警告图标 + tooltip
4. 工作流步骤从4步改为3步(编辑薪资→归档锁定→发布工资条)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 14:30:08 +08:00
selfrelease 2875965113 fix: 修复薪资编辑保存失败 + 应发计算丢失细化字段
两个问题:
1. 编辑保存 422 错误:updateEntrySchema 要求 min(0),但 bonus
   可能为负数(如扣款),导致 Zod 验证失败。放宽为 z.number().optional()
2. 应发不随编辑变化:编辑条目时 inputs 只合并 5 个基本字段,
   丢失了 positionSalary/performanceSalary 等细化字段,
   导致 calcBatchEntry 计算的 totalPay 不包含细化薪资项。
   修复:从 entry 中补全所有细化字段

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 14:19:43 +08:00
selfrelease ea2dfe3b4b feat: 最低工资保护 + 社保/最低工资递延补扣机制
新增功能:
1. SocialYearStandard / SocialInsuranceConfig 增加 minWage 字段
2. BatchEntry 增加递延扣款字段(deferredSocialEmp/HousingEmp/MinWage)
3. calcBatchEntry 增加最低工资保护逻辑:
   - 实发 < 最低工资时,优先递延社保 → 递延公积金 → 递延最低工资补齐
   - 递延金额记录到 BatchEntry,次月创建批次时自动补扣
4. prePayrollCheck 增加最低工资检查(第 11/12 项)
5. 前端社保配置增加最低工资输入框
6. 前端 BatchTab 质量门禁增加最低工资/递延扣款提示

处理场景:
- 入职当月工资不足扣社保个人部分 → 递延到次月补扣
- 实发低于最低工资 → 补齐到最低工资,差额递延次月扣
- 次月创建批次时自动读取上月递延金额并补扣

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 14:15:39 +08:00
selfrelease fe427ef13f refactor: 新增员工去掉参保城市选择器,城市从社保账户继承
- 前端:参保城市改为只读显示,从选中的社保账户自动继承
- 前端:去掉参保城市下拉选择器和 cities 查询
- 后端:clampSocialInsBase/clampHousingFundBase 改为优先按 accountId
  查年度标准裁剪基数,回退旧配置表
- 后端:createEmployee 传入 socialAccountId/housingAccountId 给 clamp 函数

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 14:08:11 +08:00
selfrelease 014c94e482 feat: 账户关联根部门 + 员工新增自动带出账户
1. 账户管理:新建/编辑时可勾选关联 level=0 根部门(公司/分公司/子公司)
2. 后端新增 API:
   - PUT /social/accounts/:id/departments 批量关联根部门
   - GET /social/accounts/:id/departments 查询已关联部门
   - GET /social/department-account/:departmentId 按部门带出适用账户+标准
3. 部门更新 API 支持 socialAccountId/housingAccountId 字段
4. 员工新增表单:选定部门后自动带出社保公积金账户,可手动调整
5. 参保记录创建时写入 accountId

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 13:59:06 +08:00
selfrelease 63b6c9dcc7 feat: 社保公积金账户化重构
新增 SocialAccount(账户)+ SocialYearStandard(年度标准)两层实体,
替代原 SocialInsuranceConfig/HousingFundConfig 按城市管理的方式。

- DB: 新增 SocialAccount、SocialYearStandard 表,Department 加账户关联
- 迁移: 旧 Config 表数据迁移到 Account + YearStandard
- 后端: 新增账户 CRUD + 年度标准 API,薪资计算适配 accountId
- 前端: 设置页新增账户管理 Tab,组织架构提示 level=0 可关联账户
- 前端: SocialInsurance.tsx 城市选择器改为账户选择器
- 兼容: 旧 Config 表保留,薪资计算回退旧表

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 13:50:15 +08:00
selfrelease 454b3d4b05 fix: 个税累计预扣中专项附加扣除改为按月读取实际填报金额
原逻辑用 employee.specialDeduction(便捷字段)× 月数计算累计
专项附加扣除,未按月读取 SpecialDeductionRecord 实际金额。
若员工某月填报/取消专项附加扣除,累计值会不准。

修复:
- 优先按月查询 SpecialDeductionRecord(当年至当月)累加实际金额
- 无按月记录时回退到便捷字段 × 月数(兼容旧数据)
- taxBreakdown 增加 specialDeductionSource 字段标识数据来源

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 13:36:02 +08:00
selfrelease ce1670d171 fix: 所有批次模式统一试用期工资判定
之前只有copy_last模式有试用期判定,copy_batch和blank_employees模式
未判定试用期,试用期员工baseSalary错误填为转正工资。

修复: 在所有模式赋值后统一加试用期覆盖逻辑(SEVERANCE/TERMINATION除外),
试用期且probationSalary>0时强制覆盖baseSalary为试用期工资。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 13:20:50 +08:00
selfrelease 37649419c4 fix: copy_last模式试用期工资被上月工资条覆盖
bug: copy_last模式下试用期判定后被prevPayslip.baseSalary无条件覆盖,
导致试用期员工复制上月批次时仍用转正工资而非probationSalary。

修复: 试用期判定优先,仅非试用期时才用上月工资条baseSalary。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 13:16:42 +08:00
selfrelease 83b4b37de0 docs: 新增20260816用户测试说明
包含14个测试项,覆盖花名册合规校验、工作台待办跳转、薪税管理、
提成奖金新模块、员工端登录、待签合同催办等15项优化功能。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 13:09:14 +08:00
selfrelease 5ef18cb05b fix: 风险检测同步更新actionUrl+MONTHLY类型actionUrl修正
- runRiskDetection 更新逻辑增加 actionUrl 变化检测(非月度风险)
- 新增 MONTHLY 类型 PENDING 记录的 actionUrl 同步更新逻辑
- existingRisks 查询补充 actionUrl 字段
- 修复存量月度待办跳转目标不更新问题

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 13:00:33 +08:00
selfrelease 851557729f fix: 月度待办跳转精准化+优化清单状态同步
- 社保/公积金待办 actionUrl 从 /money 改为 /social?tab=monthly(跳到社保公积金月度办理 Tab)
- 工资/个税待办 actionUrl 从 /money 改为 /money?tab=batch(跳到薪税管理发薪批次 Tab)
- SocialInsurance.tsx 支持 ?tab= 参数初始化 Tab
- Money.tsx 支持 ?tab= 参数初始化 Tab
- 已更新存量 PENDING 记录的 actionUrl
- 优化清单全部 10 项标注  已完成

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 12:55:05 +08:00
selfrelease 327a41d619 fix: 线下签署登记完成时间改为记录创建时间
completedAt 之前用签署日期(12:00:00),早于发起时间(12:23:52),
逻辑不合理。改为 completedAt = 当前时间(HR 登记时间),
signedAt = 签署日期(HR 选择的日子)。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 12:33:30 +08:00
selfrelease 5a9d440339 fix: 线下签署登记标题英文转中文+完成时间时区+签署记录角标
1. documentTitle 中 contractType 枚举值转中文
   (LABOR→劳务协议、FIXED→固定期限劳动合同等)
2. 签署日期解析改为本地时区构造(new Date(y,m-1,d,12)),
   避免 new Date('2026-08-16') 被解析为 UTC 00:00 导致
   本地 +8 显示为 08:00:00
3. 签署记录 Tab 增加数字角标显示总记录数

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 12:26:13 +08:00
selfrelease 72a6eab3bd fix: risk.service.ts priorityOrder 重复声明导致后端启动失败
用户手动修改 risk.service.ts 时在第 966 行重复声明了
priorityOrder(第 955 行已声明),导致 esbuild 报错
"The symbol priorityOrder has already been declared",
后端 502 Bad Gateway。删除重复声明。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 12:22:00 +08:00
selfrelease ef6d4591be fix: 登记线下签署日期时同步创建 EsignRecord
登记签署日期后,签署记录 Tab 中也能看到这条记录。
创建一条 status=COMPLETED 的线下手签 EsignRecord,
避免已签合同在签署记录中无痕可查。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 12:17:39 +08:00
selfrelease 040c5a3ab9 feat: 待签合同支持登记线下签署日期
- 线下手签合同:展开后可点击「登记签署日期」选择日期并保存
  保存后合同从待签列表移除(signDate 已填写)
- 电子签合同:签署日期由电签系统自动回写,不可手动修改
- 后端新增 POST /esign/sign-date 接口
  电子签合同拒绝手动修改签署日期

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 12:13:07 +08:00
selfrelease 79145b47b9 fix: 待签合同列表中合同类型英文改为中文
LaborContract.contractType 枚举值(FIXED/UNFIXED/LABOR等)
转为中文显示(固定期限劳动合同/劳务协议等)。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 12:08:06 +08:00
selfrelease 8c7fb99219 ux: 待签合同列表新增「待签内容」列,展示场景标签+签署方式
每行员工直接显示待签文件的类型(劳动合同/离职协议/规章制度等)
和签署方式(电子签/线下手签),无需展开即可看出是什么待签。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 12:06:15 +08:00
selfrelease 71f0619d18 feat: 电子签署改为「待签合同」,按员工聚合+催办功能
1. 菜单「电子签署」改名为「待签合同」
2. 页面重构为两个 Tab:
   - 待签合同:按员工聚合展示所有未签文件(EsignRecord
     PENDING/SIGNING + LaborContract signDate 为空),展开可
     查看该员工名下所有待签文件详情
   - 签署记录:原有全部签署记录列表(保留筛选功能)
3. 催办功能:点击催办生成一次性自动登录链接(24h有效),
   指向员工端签署页,弹窗展示二维码+可复制链接,HR 发给
   员工扫码直接进入签署
4. 后端新增接口:
   - GET /esign/pending 待签合同按员工聚合列表
   - POST /esign/remind 催办生成自动登录链接

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 11:57:43 +08:00
selfrelease ebd47e4500 fix: Excel 批量导入员工时自动设置默认密码(手机号后6位)
与 createEmployee 保持一致,导入时自动生成 passwordHash,
无需管理员再手动重置。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 11:34:58 +08:00
selfrelease d7fdec49fa feat: 员工端密码登录完整支持
1. createEmployee 创建员工时自动设置默认密码(手机号后6位)
2. 管理员重置密码接口 POST /employees/:id/reset-password
   (重置为手机号后6位)
3. 员工自己修改密码接口 POST /portal/change-password
   (需验证旧密码,新密码至少6位)
4. 花名册详情添加"重置密码"按钮,显示默认密码提示
5. 员工端导航栏添加"修改密码"入口,弹窗修改密码

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 11:28:56 +08:00
selfrelease 26d0b7070d fix: 员工端 portalAxios 缺少 response interceptor 导致登录失败
问题:portalAxios 没有 response interceptor,axios 返回完整的
response 对象,unwrap 取 res.data 得到的是 { success, data: {...} }
而非 { token, employee },导致前端 data.token 为 undefined,
自动登录/密码登录/验证码登录全部失败。

修复:给 portalAxios 添加与管理端 api 实例一致的 response
interceptor:(response) => response.data,使 unwrap 能正确
解包到 { token, employee }。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 11:11:43 +08:00
selfrelease 94b85d5c36 fix: 同企业内员工身份证和手机号唯一性完整校验
补充以下场景的查重:
1. updateEmployee:编辑员工信息时手机号/身份证查重
   (排除自身,同组织内不可与其他员工重复)
2. import.routes.ts:Excel 批量导入时身份证和手机号查重
   (重复则跳过并记录错误日志)

至此同企业内身份证和手机号唯一性校验覆盖全部入口:
- createEmployee(新增员工)
- updateEmployee(编辑员工)
- Excel 导入(批量导入)
- work-process(走 createEmployee)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 10:59:38 +08:00
selfrelease 6b3e1f9d46 fix: 手机号查重硬校验 + portal 登录跨组织安全
问题:
1. 后端 createEmployee 只有身份证查重,没有手机号查重,
   同组织内可重复录入相同手机号,导致员工端登录混乱
2. portal 登录用 findFirst 按 phone 查,未考虑跨组织重复,
   多组织同手机号时会登录到错误员工
3. Contracts.tsx 的 AddEmployeeModal 完全没有手机号查重

修复:
1. contract.service.ts createEmployee 添加手机号查重硬校验
   (同组织内 phone 唯一,抛 DUPLICATE_PHONE 错误)
2. portal.routes.ts 密码登录改为 findMany 遍历校验密码,
   验证码登录改为 findMany 取第一个匹配
3. Contracts.tsx AddEmployeeModal 添加手机号查重和警告提示

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 10:57:06 +08:00
selfrelease 13f485a049 fix: contractType zod 枚举与 Prisma/前端对齐
zod schema 只允许 FIXED|UNFIXED|UNSIGNED 三种,但 Prisma 枚举和
前端 /contract-types 接口返回 8 种(含 LABOR/INTERNSHIP/DISPATCH/
OUTSOURCING/PARTTIME),导致选择劳务协议等类型时校验失败。

扩展 createEmployeeSchema 和 addContractSchema 的 contractType
枚举为全部 8 种,与 Prisma ContractType 枚举一致。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 09:28:58 +08:00
selfrelease dd0e84a219 ux: 部门字段统一改为下拉选择(组织架构部门树)
将以下页面的部门输入框从 Input 改为 Select 下拉选择,
数据源为组织架构部门树(带层级缩进):

1. roster/modals.tsx - RehireModal 重新入职弹窗
2. roster/modals.tsx - AddEmployeeModal 添加员工弹窗
3. roster/BasicInfo.tsx - 编辑员工基本信息
4. Contracts.tsx - 合同管理新增员工弹窗

下拉选项从 /departments 接口获取,按树形结构展示,
提交时传部门名称字符串(保持后端兼容)。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 09:23:24 +08:00
selfrelease 9a826c16b0 fix: 组织架构根部门自动创建为公司名
1. 注册时自动创建根部门(公司名,level=0),作为组织架构顶层节点
2. 部门列表查询时兼容历史组织:若无任何部门,自动补建根部门
3. 创建部门时若未指定父部门,默认挂到根部门下
4. 前端 OrgChart:
   - 根部门显示"公司"标签,不可编辑/删除
   - 新增部门时默认父部门为根部门
   - 上级部门下拉移除"无(根部门)"选项,改为列出所有可选部门

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 09:16:31 +08:00
selfrelease c2c99126be fix: 部门创建/更新时 parentId 空字符串导致外键约束违反
前端选择"无(根部门)"时传 parentId="",空字符串是 falsy 跳过了
父部门存在性检查,但 ...data 展开后 Prisma 尝试创建 parentId=""
的部门,数据库外键约束违反(无 id="" 的部门)。

修复:创建/更新时将空字符串统一转为 null,并显式传字段而非展开。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 09:12:40 +08:00
selfrelease aa8189f571 docs: 新增 20260815 优化批次用户测试指南
面向测试人员的功能验证手册,覆盖 5 个批次共 43 项任务的完整测试用例:
- P0 紧急(6 项):草稿保存、补偿金合计、驳回更新、违法解除风险、日期校验、批次数据源
- P1 高(14 项):社保联动、合规软阻断、补偿月数、年龄校验、转正、三期性别等
- P2 中(9 项):文案调整、年假折算、证件号码、手机查重、批量操作、菜单调整
- P3 规划(2 项):组织架构+审批流、客服工作台
- 电子签署(12 项):发起签署、验证码签署、证据链、线下手签、花名册联动

每个测试用例含前置条件、详细测试步骤、预期结果,附测试结果记录模板。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 17:34:44 +08:00
selfrelease 1f545fb489 fix: 补充社保截止月自动推导+草稿schema字段+面包屑文案
测试验证发现3个问题并修复:

1. TASK-007 社保联动:Organization.socialInsCutoffDay 字段缺失,
   createDraft 未自动推导社保/公积金截止月。
   - 新增 Organization.socialInsCutoffDay 字段(默认15日)
   - createDraft 中根据离职日期与截止日比较,自动推导截止月
   - cutoffDay 日前离职 → 截止月=离职月-1,日后 → 截止月=离职月

2. TASK-002 补偿金合计:createTerminationDraftSchema 缺少
   compensationBreakdown 和 checklistOverrides 字段,导致 zod parse
   时被过滤。已补充字段声明。

3. TASK-028 去掉用工办理:Breadcrumb 中 /work-process 标签仍为
   "用工办理",已改为"批量流程"。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 17:26:14 +08:00
selfrelease 101cfcf508 fix: 补充缺失的DB字段 idType + contractCategory
测试验证发现两个字段声明在优化计划中标记完成但实际未添加到schema:
- Employee.idType:证件类型(ID_CARD/PASSPORT/HK_MACAO_TAIWAN/OTHER)
- LaborContract.contractCategory:合同分类(LABOR_CONTRACT/LABOR_AGREEMENT/INTERNSHIP/FLEXIBLE)

已通过 prisma db push 应用到数据库,历史数据为NULL(兼容)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 17:12:56 +08:00
selfrelease fa0dc82d38 docs: 优化计划补充第五批电子签署任务(TASK-031~042)
新增12项电子签署优化任务:
- P0:后端路由重写+模板渲染、验证码确认、证据链体系
- P1:回写合同、组织开关校验、线下手签、花名册联动、转正联动
- P2:scene模板填充、管理端UI、员工端UI

更新执行状态总览(31→43条)、批次总览、依赖关系图、风险注意事项

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 16:56:24 +08:00
selfrelease 8fcc7ae143 feat: 转正弹窗显示原薪资 + 薪资变化时联动签署
- ConfirmModal 显示原薪资(¥xxx),转正薪资默认填入原薪资
- 转正薪资与原薪资不同时,提示"将发起薪酬调整确认书签署"
- confirmMutation onSuccess:薪资变化时弹出签署方式选择弹窗
  - scene=POLICY,文件标题为"转正薪酬调整确认书"
  - 备注记录薪资变化:¥原薪资 → ¥新薪资

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 16:51:41 +08:00
selfrelease 3da61d5a09 feat: 花名册操作联动电子签署 + 修复重复创建
## 修复
- POST /contracts 不再自动创建签署记录(由前端根据signMethod决定),避免与ContractInfo重复创建

## 新增 SignMethodChoice 通用组件
- 花名册操作成功后弹窗选择签署方式:电子签/线下手签/稍后处理
- 电子签:自动创建ESignRecord(PENDING),员工在员工端签署
- 线下手签:跳转签署页面,预填员工/场景/标题,上传扫描件登记
- 稍后处理:跳过,可后续在签署页面手动发起

## 花名册4项操作联动签署方式选择
- 重新入职:操作成功后弹窗(scene=CONTRACT,劳动合同)
- 合同续签:操作成功后弹窗(scene=CONTRACT,续签劳动合同)
- 薪酬变更:操作成功后弹窗(scene=POLICY,薪酬调整确认书)
- 调岗调动:操作成功后弹窗(scene=POLICY,调岗确认书)

## ESign页面支持URL参数
- 读取 ?action=paper-sign 自动打开线下手签登记Modal
- 预填employeeId/scene/documentTitle

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 16:45:53 +08:00
selfrelease 434c63c6d3 feat: 线下手签完整流程 + 证据链体系
## 数据库
- ESignRecord 增加 signMethod 字段(ELECTRONIC/PAPER)
- 增加线下手签专用字段:signedAt、signedLocation、witnessName、witnessPhone、scanFileUrls

## 后端
- esign.routes.ts 新增线下手签接口:
  - POST /esign/paper-upload:上传签署扫描件(multer多文件,10MB限制)
  - POST /esign/paper-sign:线下手签登记(创建COMPLETED记录+证据链+回写合同)
- esign.service.ts:autoCreateEsignRecord 设置 signMethod=ELECTRONIC
- 静态文件服务复用 /uploads 统一映射

## 前端管理端
- ESign.tsx 新增"线下手签登记"按钮和 Modal:
  - 选择员工、文件类型、签署日期、地点、见证人
  - 上传签署扫描件(多文件)
  - 登记后自动创建证据链
- 列表增加"签署方式"列(电子签/线下手签标签)
- 详情页增加签署方式标签 + 线下手签信息卡片 + 扫描件列表

## 前端员工端
- MyEsign.tsx 列表增加线下手签标签
- 详情页增加线下手签信息卡片(签署日期/地点/见证人/扫描件)
- 签署操作区域:线下手签记录显示"线下手签已登记",不显示验证码签署

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 16:36:46 +08:00
selfrelease 12f1cde7c5 feat: 花名册操作自动创建电子签署流程
## 新增 esign.service.ts
- autoCreateEsignRecord 公共方法,供其他业务模块调用
- 自动渲染模板文件内容 + 创建证据链

## 自动触发签署
- 增加员工(创建合同时):自动创建劳动合同电子签署记录(scene=CONTRACT)
- 主动离职(createDraft type=RESIGNATION):自动创建离职协议签署(scene=RESIGNATION)
- 公司解聘(executeTermination type=TERMINATION):执行完成后自动创建离职协议签署

## 前端提示
- 添加员工成功后提示"劳动合同电子签署已自动发起",可跳转签署页面
- 主动离职成功后提示"离职协议电子签署已自动发起"

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 16:27:15 +08:00
selfrelease b6fc93cc61 feat: 电子签署完整流程 + 证据链体系
## 后端
- esign.routes.ts 重写:
  - 发起签署时校验组织电子签开关(POLICY/PAYSLIP/ONBOARDING)
  - 自动从模板渲染文件内容(CONTRACT→劳动合同模板,RESIGNATION→离职协议模板)
  - 创建签署记录时自动创建证据链(发起签署事件)
  - 新增签署详情接口、证据链查看接口
  - 取消签署追加证据链事件
  - 签署状态查询自动处理过期记录
- portal.routes.ts 员工端 esign 重写:
  - 新增验证码发送接口(/esign/:id/send-code)
  - 签署操作增加验证码校验(5次错误限制)
  - 签署完成追加证据链(IP/UA/时间戳/验证码/签署人)
  - 签署完成回写合同 signMethod + attachmentName(签署证据)
  - 列表和详情接口自动处理过期记录

## 前端
- ESign.tsx 管理端重写:
  - 发起签署增加场景选择(5种场景)
  - 场景选择后自动填充默认文件标题
  - 新增签署详情页(文件内容预览 + 证据链时间线)
  - 签署流程说明
- MyEsign.tsx 员工端重写:
  - 签署操作增加验证码确认流程
  - 60秒倒计时限制
  - 文件内容预览
  - 签署完成状态展示
- api-services.ts:
  - esignApi 增加 detail/evidence 接口
  - portalApi 增加 esignSendCode 接口
  - signEsign 增加 verifyCode 参数

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 16:19:37 +08:00
selfrelease d216e4a57a fix: AI助手页面输入框超出屏幕(适配PageGuide高度)
ChatTab 高度从 calc(100vh - 220px) 调整为 calc(100vh - 250px),
补偿新增 PageGuide 操作说明占用的高度,避免输入框被挤出屏幕。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 16:04:46 +08:00
selfrelease 7e720d9bfc feat: 北京解聘合规增强 — 政策法规库+地区过滤+工会回执证据链
1. 政策法规库(RAG知识库):
   - 新增6条北京地区单方解除劳动合同工作指引种子数据
   - 涵盖通知工会程序、函件内容要求、回执要求、监督提示函、仲裁审查等
   - 企业用户通过AI问答可检索到北京地区工会通知规定

2. 地区差异化合规检查:
   - getChecklistForReason 增加 orgCity 参数
   - 工会通知检查项仅北京地区显示(FAULT/NONFAULT/LAYOFF)
   - 前端解聘方式说明中北京工会提示仅北京地区动态显示
   - 非北京地区不显示工会通知检查项,避免误导

3. 工会回执上传+证据链留存:
   - 后端新增3个接口:上传回执文件、保存回执信息、获取回执信息
   - 回执信息保存到草稿 checklistOverrides
   - 自动追加到证据链(appendEvidence),作为劳动仲裁举证材料
   - 前端合规检查步骤增加工会回执上传区域
   - 确认提交步骤展示回执文件链接
   - 新增 uploads 静态文件服务

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 16:01:02 +08:00
selfrelease e8c6d27979 feat: 增加北京解聘合规性环节(通知工会程序+文档样本)
依据《规范用人单位单方解除劳动合同工作指引(试行)》

后端:
- 合规检查增加"通知工会"和"工会回执"检查项(FAULT/NONFAULT/LAYOFF)
- 检查项标注北京地区要求:提前5个工作日书面通知工会
- 模板系统新增3个文档样本:
  · 拟解除劳动合同通知工会函(附件1样式)
  · 工会回执(附件2样式)
  · 工会劳动法律监督提示函(附件3样式)

前端:
- 过错解除/非过错解除/裁员三种方式的步骤说明增加工会通知程序
- 确认提交步骤展示工会通知状态(已通知/未通知/已收到回执)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:51:45 +08:00
selfrelease f8dca2f22c fix: 修复TASK-024/025/026/027验收标准 + 更新优化计划文档
- TASK-024: 证明Modal增加类型选择(收入/在职/离职证明)
- TASK-025: 续签按钮增加合同到期状态过滤(仅expiring/expired显示)
- TASK-026: 批量转正增加弹窗填写转正日期和薪资
- TASK-027: 批量开具证明增加类型选择弹窗
- 更新20260815-优化计划.md,标注复查结果

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:43:23 +08:00
selfrelease 75e08b90c0 ux: 统一PageGuide位置到页面最顶部 + 侧边栏不显示品牌名
- SalaryDashboard/ESign: PageGuide 从标题下方移到标题上方
- Termination: PageGuide 从列表视图块内移到页面顶部
- SocialInsurance/Dashboard/Attendance: 页面顶部新增总览 PageGuide
- 侧边栏左上角不再回退显示品牌名,仅显示企业名称

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:34:51 +08:00
selfrelease 456cd91a83 brand: 软件名称从"企业用工专家/TurboHR"统一改为"安职通"
- 登录/注册/忘记密码页标题
- 侧边栏和员工端布局默认名称
- 新手引导欢迎语
- 帮助中心问答
- 验收测试页面标题
- 后端 RAG 知识库
- 部署脚本日志输出

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:29:13 +08:00
selfrelease f523f84c18 ux: 全部页面添加操作说明 PageGuide
为以下17个缺少操作说明的页面添加 PageGuide 组件:
- OrgChart: 组织架构管理
- SupportDashboard: 客服工作台
- Money: 薪酬管理
- AIAssistant: AI 智能助手
- Settings: 系统设置
- Calendar: 人事日历
- Templates: 模板管理
- AuditLog: 审计日志
- Notifications: 通知中心
- MedicalPeriodCalculator: 医疗期计算器
- HealthCheck: 用工健康检查
- AnnualValueReport: 年度价值报表
- CompanyFiles: 公司文件管理
- LeaveApproval: 请假审批
- TrainingRecords: 培训记录
- PerformanceRecords: 绩效记录
- DisciplinaryRecords: 违纪记录

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:26:02 +08:00
selfrelease 5c3f3503ea feat: 花名册增加职务筛选 + 去掉解聘页新建按钮
- 后端:roster list 接口增加 position 筛选参数
- 后端:新增 /roster/positions 接口返回在职员工职务去重列表
- 前端:花名册筛选栏增加"全部职务"下拉框
- 前端:导出也支持 position 参数
- 前端:去掉解聘补偿页面的"新建解聘"按钮,解聘统一从花名册发起
- 前端:空状态提示改为"请在花名册中选择员工发起解聘"

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:22:06 +08:00
selfrelease 81e1cd0f72 ux: 花名册列表两行记录样式美化
- 姓名列:姓名加粗深色 + 证件号码淡灰小字,紧凑行间距
- 社保缴费列:个人/企业改为右对齐标签式,字号区分主次
- 操作列:两行按钮间增加分隔线,按钮间距收紧
- 全行 padding 从 py-3 调整为 py-2.5,行高更紧凑
- 行间分隔线变淡(gray-100 → gray-50),hover 效果加 group

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:14:13 +08:00
selfrelease 34b82bdbe7 feat: 建立部门和职务关联关系,调动时按部门过滤可选职务
- 17个部门专属职务绑定到对应部门(如工人→项目运营中心、会计→财务部)
- 8个通用职务保持不绑定(部门经理/主管/组长等,所有部门可用)
- 前端调动弹窗优化职务过滤:通用职务 + 该部门专属 + 父部门专属(子部门继承)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:06:23 +08:00
selfrelease e3d46d9142 feat: 调部门改为调动,使用组织架构部门和职务下拉选择
- 后端:EmployeeDepartmentRecord 增加 oldPosition/newPosition 字段
- 后端:department-change 接口支持 departmentId 关联 + position 职务变动
- 后端:同步更新 Employee.departmentId 和 Employee.position
- 前端:DeptChangeModal 改为从组织架构下拉选择目标部门(树形)和职务
- 前端:选择部门后自动过滤该部门下的职务
- 前端:按钮文案从"调部门"改为"调动"

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:00:38 +08:00
selfrelease e8470e7bb4 fix: 组织架构页面无数据 - 响应拦截器已剥离一层data,无需再取r.data.data
api.ts 响应拦截器 (response) => response.data 已剥离 axios response,
OrgChart 中 api.get().then(r => r.data.data) 多取了一层导致返回 undefined。
改为 .then(r => r.data) 即可正确获取部门/岗位数组。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 14:52:24 +08:00
selfrelease 55f89d384b ux: 花名册操作图标按钮分两行显示
第一行:调薪、转正、调部门
第二行:开具证明、续签合同、离职

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 14:47:48 +08:00
selfrelease 4486adc957 ux: 花名册操作优化 - 去掉发薪、开具证明/续签合同直接操作、删除用工办理页面
- 去掉花名册操作栏的"发薪"按钮
- 开具证明改为弹窗直接创建+提交,不再跳转用工办理页面
- 续签合同改为弹窗直接创建+提交,自动推导新合同开始日期
- 批量开具证明改为直接API调用,不再跳转用工办理页面
- 删除用工办理页面(WorkProcess.tsx)和路由
- 去掉工具栏"用工办理"按钮

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 14:41:37 +08:00
selfrelease 757cbc8740 fix: 增加 /api/v1/health 路由,修复 deploy.sh 验证 404
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 14:34:39 +08:00
selfrelease e1b5ae9aab feat: 20260815 系统优化 - 全部31项问题修复(P0×6+P1×14+P2×9+P3×2)
P0紧急修复(6项):
- 草稿保存完整恢复所有字段(含socialAvgWage)
- 补偿金批次从compensationBreakdown读取
- 违法解除风险确认UI
- 合同结束日期前后校验(前后端双保险)

P1高优先级(14项):
- 离职日期联动社保/公积金截止月(15号规则)
- 合规检查+工作交接改为软阻断(生成待办)
- 补偿月数(N/N+1/2N/自定义)+计算基数(近12月/合同/自定义)
- 解聘并入花名册操作栏(类型选择跳转向导)
- 合同续签开始日期自动推导(原合同结束日+1天)
- 年龄合规筛查(童工阻断/未成年工/退休警告)
- 编辑入职日期后状态联动(待入职↔在职)
- 转正移植到花名册操作栏+薪资回写
- 男职工无法选择三期

P2体验优化(9项):
- "劳动合同"调整为"用工关系"
- 费用结算新增剩余年假折算(300%日工资)
- 身份证号全域改为"证件号码"(前后端18个文件)
- 手机号查重
- 开具证明+合同续签移植到花名册操作栏
- 批量转正+批量开具证明
- 去掉用工办理模块

P3规划(2项):
- 组织架构+审批流(Department/Position/ApprovalFlow/ApprovalInstance)
- 客服工作台(Ticket/ChatSession+SUPPORT角色)

新增模型: Department/Position/ApprovalFlow/ApprovalInstance/Ticket/TicketMessage/ChatSession/ChatMessage
新增字段: Employee.departmentId/supervisorId
新增角色: SUPPORT

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 12:37:27 +08:00
124 changed files with 15829 additions and 2655 deletions
+32
View File
@@ -0,0 +1,32 @@
序号,类型,页面,功能点,需调整项目,字典表,逻辑,备注
1,调整,,,“劳动合同”,是否应调整为更为宽泛的用工关系,,
2,调整,离职管理,,“离职日期”,,办理离职的离职日期调整后,应影响社保截止年月和公积金截止年月(例如15日之前离职不缴纳当月社保、15日之后离职缴纳当月社保)可配置,
3,调整,离职管理,合规检查,“是否已支付经济补偿金”,,不勾选无法进行下一步,应增加保存后未办事项进入代办和风险提醒中,
4,新增,离职管理,费用结算,“剩余年假天数”,,调用或录入,影响离职日期计算,
5,调整,离职管理,费用结算,“补偿月数”,N、N+1、2N、其他,选择协商解除时可填写修改并重新运算补偿金,
6,调整,离职管理,费用结算,“计算基数”,近12月平均工资、合同工资、其他,可调整,按照选用算法计算,其他需手动填写,
7,调整,离职管理,费用结算,“手动调整补偿金分项”,,调整后合计应付未同步更新,
8,调整,离职管理,工作交接,勾选的内容应为待办事项,前三项为离职必须要执行的任务,,未到离职日期,工作交接未完成,无法进行下一步操作,应调整为可进行下一步,未完成事项进入代办提醒,
9,调整,离职管理,,将离职管理模块中的“新建解聘”功能并入到花名册员工列表的操作栏中的离职功能,,,
10,调整,离职管理,,“解聘驳回”,,解聘驳回后,重新修改了解聘类型、金额等,系统未按照新内容更新,
11,调整,离职管理,,“新增解聘”,,选择了违法解除后(2N),修改降低了经济补偿金金额,合规提醒并未风险提示可继续进行下一步操作,需要增加合规风险提醒,
12,调整,用工办理,合同续签,“新增合同”,,续签新增合同,合同开始日期应为上一份合同结束日期+1(例如第一份合同结束日期为2023年5月31日,那么新增合同后合同开始日期应为2026年6月1日),
13,调整,用工办理,合同续签,“新增合同”,,续签新增合同,合同结束日期早于合同开始日期,系统未校验判断产生了时间倒置,
14,调整,花名册,添加员工,“身份证号”,,全域修改为“证件号码”,
15,调整,花名册,添加员工,年龄限制,,添加员工时根据证件号码对员工年龄进行合规性筛查,
16,调整,花名册,员工明细编辑,编辑修改“入职日期”,,花名册明细中的状态未变更(例如从今天变更为下月1日入职,状态应变更为待入职),
17,调整,花名册,添加员工,“手机号”,,录入手机号时进行查重,
18,调整,用工办理,员工转正,将用工办理模块中的“员工转正”功能移植到花名册员工列表的操作栏中,替换发薪功能,,,
19,调整,用工办理,员工转正,“员工转正”,,填写了转正薪资,在员工花名册里未体现变更后内容,是否与已签订的用工协议匹配需要验证,如不一致应发起合同变更流程,
20,调整,用工办理,开具证明,将用工办理模块中的“开具证明”功能移植到花名册员工列表的操作栏中,,,
21,调整,用工办理,合同续签,将用工办理模块中的“合同续签”功能移植到花名册员工列表的操作栏中,,,
22,调整,用工办理,合同变更,“合同变更”,,选择员工无法查询到对应的合同,
23,调整,用工办理,合同续签,“合同续签”,,原合同ID无法查询,建议修改为选择该员工已存在的合同,
24,调整,花名册,,勾选多名员工时,增加批量转正功能,,,
25,调整,花名册,,勾选多名员工时,增加批量开具证明功能,,,
26,调整,用工办理,,去掉用工办理模块,,,
27,调整,系统设置,,增加简单的组织架构,用于满足三步以内的审批流转,,,
28,调整,薪税管理,创建发薪批次,补偿金批次,,批次类型为补偿金批次的,经济补偿金发放数据读取错误,
29,调整,,特殊员工,“特殊员工”,,男职工应无法选择三期,
30,BUG,全域,,全域保存草稿后部分录入的数据未保存,,全域保存草稿后部分录入的数据未保存,
31,调整,,,单立户客服端,,,
1 序号 类型 页面 功能点 需调整项目 字典表 逻辑 备注
2 1 调整 “劳动合同” 是否应调整为更为宽泛的用工关系
3 2 调整 离职管理 “离职日期” 办理离职的离职日期调整后,应影响社保截止年月和公积金截止年月(例如15日之前离职不缴纳当月社保、15日之后离职缴纳当月社保)可配置
4 3 调整 离职管理 合规检查 “是否已支付经济补偿金” 不勾选无法进行下一步,应增加保存后未办事项进入代办和风险提醒中
5 4 新增 离职管理 费用结算 “剩余年假天数” 调用或录入,影响离职日期计算
6 5 调整 离职管理 费用结算 “补偿月数” N、N+1、2N、其他 选择协商解除时可填写修改并重新运算补偿金
7 6 调整 离职管理 费用结算 “计算基数” 近12月平均工资、合同工资、其他 可调整,按照选用算法计算,其他需手动填写
8 7 调整 离职管理 费用结算 “手动调整补偿金分项” 调整后合计应付未同步更新
9 8 调整 离职管理 工作交接 勾选的内容应为待办事项,前三项为离职必须要执行的任务 未到离职日期,工作交接未完成,无法进行下一步操作,应调整为可进行下一步,未完成事项进入代办提醒
10 9 调整 离职管理 将离职管理模块中的“新建解聘”功能并入到花名册员工列表的操作栏中的离职功能
11 10 调整 离职管理 “解聘驳回” 解聘驳回后,重新修改了解聘类型、金额等,系统未按照新内容更新
12 11 调整 离职管理 “新增解聘” 选择了违法解除后(2N),修改降低了经济补偿金金额,合规提醒并未风险提示可继续进行下一步操作,需要增加合规风险提醒
13 12 调整 用工办理 合同续签 “新增合同” 续签新增合同,合同开始日期应为上一份合同结束日期+1(例如第一份合同结束日期为2023年5月31日,那么新增合同后合同开始日期应为2026年6月1日)
14 13 调整 用工办理 合同续签 “新增合同” 续签新增合同,合同结束日期早于合同开始日期,系统未校验判断产生了时间倒置
15 14 调整 花名册 添加员工 “身份证号” 全域修改为“证件号码”
16 15 调整 花名册 添加员工 年龄限制 添加员工时根据证件号码对员工年龄进行合规性筛查
17 16 调整 花名册 员工明细编辑 编辑修改“入职日期” 花名册明细中的状态未变更(例如从今天变更为下月1日入职,状态应变更为待入职)
18 17 调整 花名册 添加员工 “手机号” 录入手机号时进行查重
19 18 调整 用工办理 员工转正 将用工办理模块中的“员工转正”功能移植到花名册员工列表的操作栏中,替换发薪功能
20 19 调整 用工办理 员工转正 “员工转正” 填写了转正薪资,在员工花名册里未体现变更后内容,是否与已签订的用工协议匹配需要验证,如不一致应发起合同变更流程
21 20 调整 用工办理 开具证明 将用工办理模块中的“开具证明”功能移植到花名册员工列表的操作栏中
22 21 调整 用工办理 合同续签 将用工办理模块中的“合同续签”功能移植到花名册员工列表的操作栏中
23 22 调整 用工办理 合同变更 “合同变更” 选择员工无法查询到对应的合同
24 23 调整 用工办理 合同续签 “合同续签” 原合同ID无法查询,建议修改为选择该员工已存在的合同
25 24 调整 花名册 勾选多名员工时,增加批量转正功能
26 25 调整 花名册 勾选多名员工时,增加批量开具证明功能
27 26 调整 用工办理 去掉用工办理模块
28 27 调整 系统设置 增加简单的组织架构,用于满足三步以内的审批流转
29 28 调整 薪税管理 创建发薪批次 补偿金批次 批次类型为补偿金批次的,经济补偿金发放数据读取错误
30 29 调整 特殊员工 “特殊员工” 男职工应无法选择三期
31 30 BUG 全域 全域保存草稿后部分录入的数据未保存 全域保存草稿后部分录入的数据未保存
32 31 调整 单立户客服端
+384
View File
@@ -0,0 +1,384 @@
# 20260815 优化清单
> 来源:`20260815-优化.csv`(共 31 条)
> 结合 TurboHR 当期功能梳理,按模块归类,标注根因 / 修复方向 / 优先级 / 涉及文件。
> 优先级:P0 紧急(影响业务正确性/数据丢失)|P1 高(流程阻塞或合规风险)|P2 中(体验/增强)|P3 规划(新模块)
---
## 一、离职管理(问题 2-11
### 问题 2:离职日期调整后未联动社保/公积金截止年月
- **现状**`Termination.tsx:1334-1338` 社保截止、公积金截止为 `<Input type="month">` 手动填写,默认值取 `terminationDate.slice(0,7)`,但修改离职日期后不会重算,也无"15 日前/后"规则。
- **修复方向**
1. 增加"离职日期 → 社保/公积金截止月"自动推导规则:每月 15 日(含)前离职不缴当月、15 日后离职缴纳当月,截止月 = 离职月 - 0 或 -1。
2. 阈值日期(15 日)做成系统设置可配置项(`org_settings.socialInsCutoffDay`,默认 15)。
3. 离职日期变更时自动回填两个截止月,同时保留手动覆盖入口。
- **优先级**P1 高
- **涉及文件**`frontend/src/pages/Termination.tsx``backend/src/services/termination.service.ts``backend/prisma/schema.prisma`org_settings 增字段)
### 问题 3:合规检查"是否已支付经济补偿金"硬阻断
- **现状**:合规检查为必勾才能进入下一步,未勾选时无法继续,且未保存的检查项不进入待办/风险提醒。
- **修复方向**
1. 将"必勾才能下一步"改为"未勾选可下一步,但自动写入待办事项 + 风险提醒"。
2. 待办项关联 `termination_draft`,在 Dashboard/风险中心展示"XX 员工解聘未支付经济补偿金"。
3. 提交审批时若仍存在未闭环必检项,给出强提示但不阻断(除非法定禁止情形,如孕期/工伤)。
- **优先级**P1 高
- **涉及文件**`Termination.tsx`step 2 校验逻辑)、`backend/src/services/notification.service.ts``frontend/src/pages/Dashboard.tsx`
### 问题 4:费用结算新增"剩余年假天数"
- **现状**:费用结算步骤仅展示补偿金/代通知金/赔偿金,未涉及年假折算。
- **修复方向**
1. 费用结算区新增"剩余年假天数"字段,支持手动录入或调用考勤模块年假余额(若有)。
2. 按规则计算未休年假折算工资:`日工资 × 剩余年假天数 × 300%`(未休部分)。
3. 折算金额计入"合计应付"。
- **优先级**P2 中
- **涉及文件**`Termination.tsx`step 3)、`backend/src/services/termination.service.ts`cost 计算扩展)
### 问题 5:补偿月数支持 N / N+1 / 2N / 其他
- **现状**`Termination.tsx:1442` 补偿月数取 `costResult.cappedMonths`(系统按工龄自动算 N),不允许选择其他模式。
- **修复方向**
1. 补偿月数改为下拉:N、N+1、2N、其他(自定义输入)。
2. 选择"协商解除"时允许修改月数并实时重算补偿金。
3. 选择"其他"时需填写说明,留痕到 `compensationBreakdown.adjustments`
- **优先级**P1 高
- **涉及文件**`Termination.tsx`step 3 补偿金区)、`backend/src/services/termination.service.ts`
### 问题 6:计算基数支持多种来源
- **现状**`Termination.tsx:1443` 计算基数固定取 `costResult.cappedWage`(近 12 月平均工资,且有三倍社平工资封顶)。
- **修复方向**
1. 计算基数改为下拉:近 12 月平均工资(默认)、合同工资、其他(手动填写)。
2. 切换来源后实时重算补偿金,并记录选用算法到草稿。
3. "其他"需填写说明,受三倍社平封顶提示但不强制。
- **优先级**P1 高
- **涉及文件**`Termination.tsx``termination.service.ts`
### 问题 7:手动调整补偿金分项后合计未同步
- **现状**`Termination.tsx:530` 注释提到"系统预估 + 手动调整差额",但 `handleSave`578 行附近)仍使用 `costResult.grandTotal`,未叠加 `compAdjustments`。20260803 问题 13 已识别但未闭环。
- **修复方向**
1. 实际合计 = `costResult.grandTotal + Σ(adjustments.to - adjustments.from)`,保存与确认页统一使用此值。
2. 调整任一分项后实时刷新"合计应付"显示。
3. 后端 `compensationBreakdown.adjustments` 已支持,前端需正确传递并回显。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx``handleSave`、确认步骤、step 3 合计显示)
### 问题 8:工作交接未完成无法下一步
- **现状**`Termination.tsx:552-554` step 4 校验 `work_handover/equipment_return/access_revoke` 三项必须完成才能下一步。
- **修复方向**
1. 改为可下一步,未完成事项自动写入待办(关联离职草稿)。
2. 交接清单前三项标注"离职必办",未完成时在确认页和风险中心强提示。
3. 待办在到达离职日期前持续提醒,超期升级为风险。
- **优先级**P1 高
- **涉及文件**`Termination.tsx`step 4 校验)、`notification.service.ts``Dashboard.tsx`
### 问题 9:离职管理"新建解聘"并入花名册操作栏
- **现状**:花名册操作栏已有"离职"按钮(`Roster.tsx:734-746`,触发 `ResignModal` 创建 RESIGNATION 草稿),但"新建解聘"仍在离职管理列表页独立入口,且仅支持 RESIGNATION 类型。
- **修复方向**
1. 花名册操作栏"离职"按钮扩展为支持所有解聘类型(协商/过错/非过错/裁员/到期/违法),弹出类型选择后跳转 Termination 向导并预填员工。
2. 离职管理列表页移除"新建解聘"按钮,仅保留草稿列表与审批。
- **优先级**P1 高
- **涉及文件**`Roster.tsx``roster/modals.tsx``ResignModal`)、`Termination.tsx`(支持 URL 参数预填)
### 问题 10:解聘驳回后修改内容未更新
- **现状**:驳回后重新修改解聘类型、金额等,系统未按新内容更新(疑似使用旧草稿快照或前端未刷新)。
- **修复方向**
1. 排查 `updateDraft` 是否正确覆盖 `reason/compensationBreakdown/terminationDate`
2. 驳回后再编辑走 `updateDraft`(而非新建),版本号 +1,旧版本留快照。
3. 前端进入驳回草稿时强制重新拉取最新数据。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx`(编辑驳回草稿逻辑)、`backend/src/services/termination.service.ts``backend/src/routes/termination.routes.ts`
### 问题 11:违法解除降低补偿金后无合规风险提醒
- **现状**:选择 ILLEGAL(2N)后手动降低补偿金金额,`acknowledgeRisk` 仅在初始选择违法解除时提示,金额变动后不再校验。
- **修复方向**
1. 实际补偿金 < 法定 2N 时,强制弹出合规风险提醒,需勾选"已知风险并继续"才能下一步。
2. 风险提醒记录到 `compensationBreakdown.riskAcknowledged`,留痕可审计。
3. 同步推送风险中心。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx`step 3 风险校验)、`risk-center`
---
## 二、用工办理 / 合同(问题 12-13、18、20-23、26
### 问题 12:合同续签新增合同开始日期应为上一份结束日期 +1
- **现状**`WorkProcess.tsx:93-99` RENEW 表单 `newStartDate` 为手动填写,无自动推导。
- **修复方向**
1. 选择员工后自动拉取最新已签合同,`newStartDate` 默认 = 上一份合同 `endDate + 1 天`
2. 允许手动覆盖,但偏离时提示。
- **优先级**P1 高
- **涉及文件**`WorkProcess.tsx`RENEW 字段)、`backend/src/services/work-process.service.ts`
### 问题 13:合同结束日期早于开始日期未校验
- **现状**`WorkProcess.tsx` 表单提交前无日期前后关系校验,已产生时间倒置数据。
- **修复方向**
1. 前端表单提交前校验 `endDate >= startDate`,否则阻断并提示。
2. 后端 `work-process.service.ts` 增加同样校验,双保险。
3. 历史倒置数据提供修复脚本/列表提示。
- **优先级**P0 紧急
- **涉及文件**`WorkProcess.tsx``work-process.service.ts`
### 问题 18:员工转正移植到花名册操作栏,替换发薪
- **现状**:花名册操作栏(`Roster.tsx:694-747`)有调薪/发薪/调部门/离职,无转正入口;转正仍在用工办理 CONFIRM 流程。
- **修复方向**
1. 操作栏"发薪"按钮(`Wallet`711-720 行)替换为"转正"按钮(`CheckCircle`),点击弹出转正 Modal(转正日期 + 转正薪资)。
2. 仅试用期员工(`probationInfo.isProbation`)显示转正按钮。
3. 发薪入口保留在薪税管理模块。
- **优先级**P1 高
- **涉及文件**`Roster.tsx``roster/modals.tsx`(新增 `ConfirmModal`
### 问题 19:转正薪资未回写花名册,未与用工协议匹配校验
- **现状**CONFIRM 流程填写 `regularSalary` 后未同步到员工 `monthlySalary`,也未校验与已签合同薪资是否一致。
- **修复方向**
1. 转正完成后同步更新员工 `monthlySalary`,并在花名册明细展示"试用期薪资 → 转正薪资"变更记录。
2. 校验转正薪资与最新合同 `monthlySalary` 是否一致,不一致时提示并支持发起合同变更流程(CHANGE)。
- **优先级**P1 高
- **涉及文件**`work-process.service.ts`CONFIRM 完成回调)、`Roster.tsx``roster/SalaryInfo.tsx`
### 问题 20:开具证明移植到花名册操作栏
- **现状**:操作栏无开具证明入口,需进用工办理 INCOME_CERT/LEAVING_CERT 流程。
- **修复方向**:操作栏新增"证明"按钮(`FileText`),点击弹出证明类型选择(收入证明/离职证明/在职证明),复用 WorkProcess 的 INCOME_CERT/LEAVING_CERT 表单。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``roster/modals.tsx`
### 问题 21:合同续签移植到花名册操作栏
- **现状**:操作栏无续签入口,批量续签在列表顶部(`Roster.tsx:444`),单人续签需进用工办理。
- **修复方向**:操作栏新增"续签"按钮(`Repeat`),仅合同即将到期(≤30 天)或已到期员工显示,点击跳转 WorkProcess RENEW 并预填员工。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``WorkProcess.tsx`(支持 URL 参数预填)
### 问题 22:合同变更选择员工无法查询到对应合同
- **现状**`WorkProcess.tsx:88-92` CHANGE 表单 `contractId``contract-select` 类型,但选择员工后未按员工过滤合同列表,导致查不到。
- **修复方向**
1. `contract-select` 组件接收 `employeeId` 参数,仅返回该员工的有效合同。
2. 后端合同查询接口支持 `employeeId` 过滤。
- **优先级**P1 高
- **涉及文件**`WorkProcess.tsx`contract-select 组件)、`backend/src/routes/contract.routes.ts`
### 问题 23:合同续签原合同 ID 无法查询,改为选择已存在合同
- **现状**`WorkProcess.tsx:95` RENEW 表单 `oldContractId``text` 类型,需手动输入 UUID,无法查询。
- **修复方向**:将 `oldContractId` 改为 `contract-select`(同问题 22),选择员工后下拉展示该员工已存在合同。
- **优先级**P1 高
- **涉及文件**`WorkProcess.tsx`RENEW 字段类型)
### 问题 26:去掉用工办理模块
- **现状**:侧边栏"团队"分组有"用工办理"入口(`SidebarNav.tsx:47`),承载 HIRE/ONBOARD/CONFIRM/CHANGE/RENEW/SUSPEND/INCOME_CERT/LEAVING_CERT/FLEXIBLE 等流程。
- **修复方向**
1. 将各流程入口下沉到花名册操作栏(转正/续签/变更/开具证明/入职)和独立页面(合同管理/电子签署)。
2. 侧边栏移除"用工办理"菜单项,保留 WorkProcess 页面作为批量流程入口(或迁移到"更多"分组)。
3. 需先完成问题 18/20/21/22/23 的入口下沉,再移除菜单。
- **优先级**:P2 中(依赖前置问题完成)
- **涉及文件**`SidebarNav.tsx``Roster.tsx``WorkProcess.tsx``App.tsx`(路由)
---
## 三、花名册(问题 14-17、24-25
### 问题 14:身份证号全域改为"证件号码"
- **现状**`roster/modals.tsx:687``WorkProcess.tsx:60/109/132/140` 等多处 label 为"身份证号"。
- **修复方向**:全域搜索替换 label "身份证号" → "证件号码",字段名 `idCardNumber` 保持不变(兼容历史数据),同时支持证件类型下拉(身份证/护照/港澳台通行证)。
- **优先级**P2 中
- **涉及文件**`roster/modals.tsx``WorkProcess.tsx``Roster.tsx`(搜索 placeholder)、`SpecialStatus.tsx`
### 问题 15:添加员工根据证件号码进行年龄合规筛查
- **现状**`roster/modals.tsx:557` 已根据身份证号自动计算性别,未做年龄校验。
- **修复方向**
1. 解析证件号码出生日期,计算年龄。
2. 年龄 < 16 岁禁止录入(童工红线),强提示并阻断。
3. 年龄 ≥ 60(男)/55(女干部)/50(女工人)提示已达法定退休年龄,确认后允许录入但标记"退休返聘"。
- **优先级**P1 高
- **涉及文件**`roster/modals.tsx``handleIdCardChange`
### 问题 16:编辑入职日期后状态未变更
- **现状**:花名册明细编辑入职日期后,员工状态未联动(如改为下月 1 日入职,状态应从"在职"变为"待入职")。
- **修复方向**
1. 编辑入职日期时,若新入职日期 > 今天,状态自动设为 `PENDING_ONBOARD`(待入职);若 ≤ 今天,设为 `ACTIVE`
2. 状态变更需二次确认,避免误操作。
3. 变更记录留痕到员工档案。
- **优先级**P1 高
- **涉及文件**`roster/BasicInfo.tsx``backend/src/services/employee.service.ts`
### 问题 17:录入手机号查重
- **现状**`roster/modals.tsx:702` 手机号为选填,无查重;身份证号已有查重(691 行)。
- **修复方向**
1. 手机号录入时调用查重接口,已存在则提示"该手机号已用于 XX 人员"。
2. 允许继续保存(一人多号/家庭号场景),但强提示确认。
- **优先级**P2 中
- **涉及文件**`roster/modals.tsx``backend/src/routes/employee.routes.ts`(新增手机号查重接口)
### 问题 24:花名册多选增加批量转正
- **现状**`Roster.tsx:444-447` 批量操作仅有"批量续签""批量解聘",无批量转正。
- **修复方向**
1. 多选后新增"批量转正"按钮,仅对试用期员工生效。
2. 弹窗统一填写转正日期(默认今天)和转正薪资(可按原薪资倍数/手动逐人填写)。
3. 调用后端批量转正接口,复用问题 18 的转正逻辑。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``roster/modals.tsx``backend/src/services/employee.service.ts`
### 问题 25:花名册多选增加批量开具证明
- **现状**:无批量开具证明入口。
- **修复方向**
1. 多选后新增"批量开具证明"按钮,选择证明类型(在职/收入)后批量生成。
2. 支持批量下载(ZIP)或逐个下载。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``backend/src/services/work-process.service.ts`(批量生成接口)
---
## 四、薪税管理(问题 28
### 问题 28:补偿金批次经济补偿金发放数据读取错误
- **现状**`money/BatchTab.tsx:110` 批次类型支持 `SEVERANCE`(补偿金),但创建补偿金批次时经济补偿金发放数据读取逻辑有误(疑似读取了工资数据或未关联离职草稿的 `compensationBreakdown`)。
- **修复方向**
1. 排查 `createBatch``type=SEVERANCE` 时的数据源,应从已审批通过的 `termination_draft` 读取 `compensationBreakdown.grandTotal`
2. 校验员工范围:仅包含有未发放补偿金的离职员工。
3. 增加预览页展示每位员工的补偿金明细,确认后再创建批次。
- **优先级**P0 紧急
- **涉及文件**`money/BatchTab.tsx``backend/src/services/payroll.service.ts`SEVERANCE 分支)
---
## 五、特殊员工(问题 29
### 问题 29:男职工应无法选择三期
- **现状**`SpecialStatus.tsx:123` 默认 `type: 'PREGNANCY'`,新增/编辑表单未根据员工性别过滤类型,男职工也可选"三期"。
- **修复方向**
1. 选择员工后,若 `gender === '男'`,类型下拉移除"三期"选项或置灰并提示"三期仅适用于女性员工"。
2. 后端保存时增加校验,男职工 + PREGNANCY 组合拒绝并返回 400。
- **优先级**P1 高
- **涉及文件**`SpecialStatus.tsx`(表单 type 下拉)、`backend/src/services/special-status.service.ts`
---
## 六、系统设置 / 组织架构(问题 27)
### 问题 27:增加简单组织架构,支持三步以内审批流转
- **现状**:系统无组织架构模块,`LeaveApproval.tsx:90` 仅有简单审批,无层级流转;`settingsApi.org()` 仅返回企业基本信息。当前员工仅有 `department`(字符串)和 `position`(字符串,前端 label "职务/岗位")两个字段,无上下级关系。
- **职位/岗位设计决策**
- **现状**:数据库只有一个 `position` 字段(`schema.prisma:225`,注释"岗位"),前端 label 统一为"职务/岗位"`roster/modals.tsx:686``roster/BasicInfo.tsx:153/328`),已合并为一个字段。
- **建议方案 A(推荐,保留一个字段)**:维持 `position` 单字段,label 保持"职务/岗位"。组织架构中"岗位"作为独立实体管理(岗位字典,含岗位名称、职级、编制人数),员工 `position` 关联到岗位字典。优点:改动小,兼容历史数据,符合当前使用习惯。
- **方案 B(拆分两个字段)**:新增 `jobTitle`(职位,如"经理/主管/专员"+ `position`(岗位,如"前端工程师")。优点:职级体系更清晰;缺点:需改 schema + 全域表单 + 历史 position 数据需清洗归类,工作量大。
- **结论**:建议采用方案 A,组织架构模块中"岗位"独立建表(`Position` 字典),员工 `position` 字段值关联岗位字典 ID 或保持文本(兼容旧数据),审批流按"直属上级 → 部门负责人"两级流转,无需引入职级。
- **修复方向**
1. 系统设置新增"组织架构"子页:树形部门(`Department` 自引用 parent+ 岗位字典(`Position`,含名称/所属部门/编制)+ 上下级关系(`Employee.supervisorId`)。
2. 新建简单审批流配置:最多 3 步(发起人 → 直属上级 → 部门负责人),支持按流程类型(休假/离职/调薪)配置是否启用某步。
3. 现有 `LeaveApproval` 接入审批流引擎,离职/调薪等流程可选启用。
4. 数据模型:`Department`(树形,parent 自引用)、`Position`(岗位字典)、`Employee.supervisorId``ApprovalFlow`type + steps)。
- **优先级**:P3 规划(新模块,建议单独排期)
- **涉及文件**`Settings.tsx`、新增 `pages/OrgChart.tsx``backend/prisma/schema.prisma``backend/src/services/approval.service.ts`
---
## 七、全域问题(问题 1、30、31)
### 问题 1"劳动合同"调整为更宽泛的"用工关系"
- **现状**:系统多处文案使用"劳动合同"(合同管理、离职管理、WorkProcess 等)。
- **修复方向**
1. 全域文案审计:将面向用户的"劳动合同"在合适场景改为"用工关系"或"用工协议"(涵盖劳动合同/劳务协议/实习协议/灵活用工协议)。
2. 数据层 `Contract` 模型保持不变,新增 `contractCategory` 字段区分劳动关系类型。
3. 注意法律文书模板中的"劳动合同"为法定术语,不可改。
- **优先级**P2 中
- **涉及文件**`SidebarNav.tsx``Contracts.tsx``WorkProcess.tsx``Termination.tsx` 等文案
### 问题 30:全域保存草稿后部分录入数据未保存(BUG)
- **现状**:多个流程页面支持"保存草稿",但部分字段(如 `compAdjustments``handoverItems` 备注、`checklistOverrides`)未持久化。
- **修复方向**
1. 排查各流程 `saveDraft` 的 payload,确保覆盖所有前端 state。
2. 后端 `draft` 表 schema 检查是否有字段缺失(`compensationBreakdown.adjustments``handoverItems.remark` 等)。
3. 增加"草稿完整性校验":保存后立即回读对比,缺失字段告警。
4. 重点排查:Termination(补偿金调整/交接备注)、WorkProcess(自定义字段)。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx``handleSaveDraft`)、`WorkProcess.tsx``backend/src/services/termination.service.ts``backend/src/services/work-process.service.ts`
### 问题 31:单立户客服端
#### 已实现部分(平台管理端,代码完整可运行)
系统已有完整的"平台管理端"(Platform),前后端代码均已实现:
**前端(4 页面 + 1 侧边栏)**
- `frontend/src/pages/platform/PlatformLogin.tsx` — 独立登录页
- `frontend/src/pages/platform/PlatformDashboard.tsx` — 数据总览(企业数/用户数/员工数/合同数/工资条数 + 套餐分布 + 最近注册企业)
- `frontend/src/pages/platform/PlatformOrgs.tsx` — 企业租户管理(创建/搜索/编辑套餐/删除/管理员账号管理)
- `frontend/src/pages/platform/PlatformUsers.tsx` — 用户管理(启用/禁用)
- `frontend/src/components/layout/PlatformSidebar.tsx` — 独立侧边栏(带 ADMIN 徽章)
- `frontend/src/lib/api-services.ts:938-965``platformApi` 完整定义
- `frontend/src/App.tsx:217-220` — 四条路由注册 + `PlatformRoute` 角色校验(`SUPER_ADMIN`
**后端(1 路由文件 437 行 + 1 schema**
- `backend/src/routes/platform.routes.ts` — 完整实现 11 个接口:
- `GET /platform/dashboard` — 平台总览数据
- `GET /platform/orgs` — 企业列表(分页+搜索+套餐筛选)
- `POST /platform/orgs` — 创建企业(含管理员账号自动创建)
- `GET /platform/orgs/:id` — 企业详情(含用户列表)
- `PUT /platform/orgs/:id` — 编辑企业(套餐/上限/联系人)
- `PUT /platform/orgs/:id/admin` — 编辑企业管理员(姓名/手机号/重置密码)
- `DELETE /platform/orgs/:id` — 删除企业(级联)
- `GET /platform/users` — 全平台用户列表
- `PUT /platform/users/:id/toggle` — 启用/禁用用户
- `GET /platform/admins` — 平台管理员列表
- `POST /platform/admins` — 创建平台管理员
- `backend/src/schemas/platform.schema.ts` — 入参校验
- `backend/src/middleware/auth.ts``platformAdminMiddleware` 权限校验
**数据模型**
- `Organization` 模型支持多租户(`plan`/`maxEmployees`/`city`/`contactName`/`contactPhone`
- `User.role``SUPER_ADMIN` 角色,`orgId` 为 null(平台管理员不归属任何租户)
- `backend/prisma/seed-multi-org.ts` — 多租户种子数据
**结论**:单立户服务(开户+租户管理+用户管理+数据总览)**已完整实现**,CSV 标注为"调整"类型也印证了该功能已存在。
#### 可增强部分(客服工作台,尚未实现)
当前平台管理端是"运营管理后台",定位为超级管理员/运营使用。如需扩展为客服人员日常服务客户的工作台,以下功能尚未实现:
| 增强项 | 现状 | 说明 |
|--------|------|------|
| 工单管理 | 缺失 | 无 `Ticket` 模型、无工单页面,客服无法接收/分派/跟进客户问题 |
| 客户会话 | 缺失 | 无会话模块,客服无法与客户企业管理员实时沟通或留言 |
| 租户数据穿透 | 缺失 | `SUPER_ADMIN` 仅能看租户列表和统计,无法穿透查看指定租户的花名册/合同/薪税/风险等业务数据 |
| 协助操作 | 部分已有 | 管理员重置密码已有,代客户发起流程/调整套餐等未实现 |
| AI 辅助 | 缺失 | 当前 AI 仅在企业端,客服侧无 AI 问答辅助 |
| 操作日志 | 缺失 | 当前 `AuditLog` 仅在企业端,客服操作无留痕 |
**增强修复方向(如需)**
1. 新增角色 `SUPPORT`(客服),独立路由 `/support/*`,独立登录页 `/support/login`
2. 客服端侧边栏含:工单中心、客户会话、租户数据查看、协助操作、AI 辅助、操作日志。
3. 数据模型新增:`Ticket`(工单:标题/内容/状态/优先级/归属租户/处理人)、`TicketMessage`(工单回复)、`ChatSession`(客户会话)。
4. 租户数据穿透:客服选择租户后,以"只读+代操作"模式访问该租户的业务数据(复用现有 API,请求头带 `X-Support-Tenant-Id`,后端中间件切换租户上下文)。
5. AI 辅助复用现有 AI 接口,独立会话隔离。
6. MVP 建议:先做工单 + 租户数据查看 + AI 辅助三项。
- **优先级**:核心功能已完成;增强部分 P3 规划(新模块,建议单独排期)
- **涉及文件(增强部分)**:新增 `frontend/src/pages/support/*``frontend/src/components/layout/SupportSidebar.tsx``backend/prisma/schema.prisma`Ticket/ChatSession 模型)、`backend/src/routes/support.routes.ts``backend/src/middleware/support-tenant.ts`
---
## 优先级汇总
| 优先级 | 问题 | 说明 |
|--------|------|------|
| **P0 紧急** | 7、10、11、13、28、30 | 数据丢失/业务正确性/合规风险 |
| **P1 高** | 2、3、5、6、8、9、12、15、16、18、19、22、23、29 | 流程阻塞或合规校验缺失 |
| **P2 中** | 1、4、14、17、20、21、24、25、26 | 体验优化/功能增强 |
| **P3 规划** | 27、31增强 | 新模块(组织架构+审批流 / 客服工作台增强),需单独排期 |
| **已完成** | 31核心 | 单立户服务(平台管理端)已完整实现,前后端代码可运行 |
---
## 建议执行顺序
1. **第一批(P0,立即)**:问题 30(草稿丢失)→ 问题 7(补偿金合计)→ 问题 10(驳回未更新)→ 问题 11(违法解除风险)→ 问题 13(合同日期校验)→ 问题 28(补偿金批次数据)
2. **第二批(P1,本迭代)**:离职管理联动(2/3/5/6/8/9)→ 花名册校验(15/16)→ 用工办理入口下沉(18/19/22/23)→ 特殊员工性别校验(29)
3. **第三批(P2,排期)**:花名册批量操作(24/25)→ 文案统一(1/14)→ 入口下沉收尾(20/21/26)→ 年假折算(4)→ 手机号查重(17)
4. **第四批(P3,规划)**:组织架构与审批流(27)→ 客服工作台增强(31增强,MVP = 工单 + 租户数据查看 + AI 辅助)
- 问题 31 核心功能(单立户服务/平台管理端)已完整实现,无需开发
---
## 备注
- 本清单基于当期代码梳理,部分"根因"为基于代码静态分析的推断,实际修复前需运行复现确认。
- 涉及数据库 schema 变更的(问题 2/27/31),需走 migration + 回滚方案,符合数据 8 铁律。
- 涉及文案全域替换的(问题 1/14),需同步更新 HelpModal、OnboardingGuide 等帮助文案。
- 问题 26(去掉用工办理模块)依赖问题 18/20/21/22/23 完成,不可先行移除。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
# 20260816 优化清单
> 来源:用户测试反馈(编号 4-13,共 10 项)
> 结合 TurboHR 当期代码梳理,按模块归类,标注根因 / 修复方向 / 优先级 / 涉及文件。
> 优先级:P0 紧急(影响业务正确性/合规)|P1 高(流程阻塞或合规风险)|P2 中(体验/增强)|P3 规划(新模块)
>
> **✅ 全部 10 项已于 20260816 完成实现,前后端 tsc 均通过。完成明细见"五、已完成优化"章节(编号 5-15)。**
---
## 一、花名册 · 添加雇员(问题 4-8)
### 问题 4:合同状态未签署时仍显示"正常"
- **现状**`backend/src/services/contract.service.ts:74` 注释明确"有合同记录(FIXED/UNFIXED/LABOR/INTERNSHIP),即使 signDate 为 null 也按正常合同处理",`getContractStatus` 在有合同记录时直接返回 `active`/`正常`,未区分"已签署"与"待签署"。
- **修复方向**
1. `getContractStatus` 增加"待签署"判定:有合同记录但 `signDate` 为空,或关联的电子签署记录状态为 `PENDING`/未 `COMPLETED` 时,返回 `status: 'pending_sign'``statusText: '待签署'``riskLevel: 'medium'`
2. 仅当 `signDate` 已填且(无电子签署或电子签署已 COMPLETED)时才返回 `active`/`正常`
3. 前端花名册列表/详情同步展示"待签署"标签。
- **优先级**P1 高
- **涉及文件**`backend/src/services/contract.service.ts``frontend/src/pages/Roster.tsx``frontend/src/pages/roster/ContractInfo.tsx`
### 问题 5:社保状态未办理时仍显示"正常"
- **现状**:花名册添加雇员表单(`roster/modals.tsx:719`)有 `socialInsBase`/`socialInsStartMonth`/`housingFundBase`/`housingFundStartMonth`,但无"社保状态"字段;花名册列表/详情也未展示社保办理状态,默认视为"正常"。
- **修复方向**
1. 新增社保状态字段(派生而非存储):根据 `socialInsStartMonth` 是否填写、是否已做"办理完成"操作判定。
2. 状态枚举:`待办`(未填写基数/起始月或未办理完成)、`正常`(已办理且在缴)、`停缴`(已设截止月)。
3. 后端 `employee.service.ts` 在花名册列表返回 `socialInsStatus` 派生字段。
4. 前端花名册列表/详情展示社保状态标签。
5. 提供"办理完成"操作入口(可在社保公积金模块或花名册操作栏)。
- **优先级**P1 高
- **涉及文件**`backend/src/services/employee.service.ts``backend/src/services/contract.service.ts`(或新增 `social-insurance.service.ts` 派生方法)、`frontend/src/pages/Roster.tsx``frontend/src/pages/roster/PayslipSocialInfo.tsx`
### 问题 6:证件号码未验证有效性
- **现状**`roster/modals.tsx:886` `canSubmit` 仅校验 `idCardNumber.length >= 18`,未做身份证校验位(第 18 位)、出生日期合法性、月份/日期范围等有效性验证;`handleIdCardChange`(755 行)只做性别推导和查重,未做格式校验。
- **修复方向**
1. 新增身份证有效性校验:校验位算法(前 17 位加权求和取模映射第 18 位)、出生日期合法、月份 01-12、日期合法。
2. 校验失败时 `ageWarning` 返回 `BLOCK`,阻断保存。
3. 后端 `employee.service.ts` 创建/更新员工时同步做校验(双保险)。
4. 支持非身份证类型(护照/港澳台通行证)时跳过 18 位校验(已部分实现,需确认)。
- **优先级**P1 高
- **涉及文件**`frontend/src/pages/roster/modals.tsx``handleIdCardChange``canSubmit`)、`backend/src/services/employee.service.ts``backend/src/routes/employee.routes.ts`
### 问题 7:超龄人员仍可选择劳动合同
- **现状**`roster/modals.tsx:784` 超龄仅 `WARN` 提示"已达法定退休年龄,建议确认是否按退休处理",未阻断选择 `FIXED`/`UNFIXED` 劳动合同;超龄人员依法应签劳务协议/实习协议,不能签劳动合同。
- **修复方向**
1. 年龄 ≥ 法定退休年龄(男 60、女干部 55、女工人 50)时,合同类型下拉移除 `FIXED`/`UNFIXED`,仅保留 `LABOR`/`INTERNSHIP`/`UNSIGNED`,并提示"超龄人员不可签订劳动合同,请选择劳务协议"。
2. 强制选 `LABOR` 时联动问题 8 的社保基数置 0 逻辑。
3. 后端 `employee.service.ts`/`work-process.service.ts` 增加校验:超龄 + 劳动合同类型组合拒绝并返回 400。
4. `femaleWorkerType` 字段用于判定女职工退休年龄(干部 55/工人 50),需在年龄计算时引用。
- **优先级**P1 高
- **涉及文件**`frontend/src/pages/roster/modals.tsx`(合同类型下拉、`ageWarning` 逻辑)、`backend/src/services/employee.service.ts``backend/src/services/work-process.service.ts`
### 问题 8:签署劳务协议员工社保基数未置 0 且仍可缴纳社保
- **现状**`roster/modals.tsx:983` 社保缴费基数为独立输入框,选择 `LABOR` 合同类型时未联动置 0 或禁用;劳务协议人员依法不缴纳社保。
- **修复方向**
1. 合同类型选择 `LABOR`/`INTERNSHIP` 时,社保缴费基数、公积金缴费基数自动置 0 且字段禁用,社保开始年月清空。
2. UI 提示"劳务协议/实习协议人员不缴纳社保公积金"。
3. 后端 `employee.service.ts` 保存时校验:`contractType === LABOR``socialInsBase > 0` 时拒绝或强制置 0。
4. 薪税批次生成时排除劳务协议人员,或其社保公积金项强制为 0。
- **优先级**P1 高
- **涉及文件**`frontend/src/pages/roster/modals.tsx`(社保公积金区联动)、`backend/src/services/employee.service.ts``backend/src/services/payroll.service.ts`
---
## 二、工作台(问题 9-10
### 问题 9:待办事项无法点击跳转
- **现状**`Dashboard.tsx:1148` 已有 `<Link to={todo.actionUrl}>`,但部分待办的 `actionUrl` 为空、指向不存在的路由,或后端 `dashboard.service.ts` 生成待办时未填充 `actionUrl`,导致点击无反应或跳转 404。
- **修复方向**
1. 排查后端 `dashboard.service.ts` 各类待办(CONTRACT/TERMINATION/ONBOARDING/RETIREMENT/MONTHLY/SALARY)的 `actionUrl` 生成逻辑,确保每条待办都有有效跳转目标。
2. `actionUrl` 为空时前端降级为"查看详情"按钮,弹窗展示待办内容,而非死链。
3. 校验 `actionUrl` 指向的路由真实存在(如 `/roster?employeeId=xxx``/termination?draftId=xxx`)。
4. 跳转后自动定位到对应员工/草稿/合同。
- **优先级**P1 高
- **涉及文件**`backend/src/services/dashboard.service.ts``frontend/src/pages/Dashboard.tsx``TodoIcon`/待办列表渲染)
### 问题 10:风险提醒内容不合理(含反向引导 bug)
- **现状**`Dashboard.tsx:202` 风险提醒按 `todo.type` 过滤(CONTRACT/TERMINATION/ONBOARDING/RETIREMENT),具体提醒文案、阈值、优先级、跳转目标生成逻辑在后端 `risk.service.ts`。存在两类问题:
**问题 A · 反向引导(典型 bug)**`risk.service.ts:309-337` 三期/医疗期/工伤员工的风险提醒,文案是"解聘受限/不得解除劳动合同",但 `actionUrl` 却指向 `/termination?employee=xxx`(解聘向导页面)。等于提示"该员工不能解聘,点这里去解聘她"——完全反向引导,可能诱导 HR 误操作违法解除。
```ts
// risk.service.ts:309-317(错误示例)
if (emp.isPregnant) {
risks.push({
type: 'TERMINATION',
title: `${emp.name}处于孕期/哺乳期,解聘受限`,
description: '三期女职工不得依非过错理由解除劳动合同...',
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`, // ← 反向引导
})
}
// isInMedicalPeriod319-328)、isWorkInjured329-338)同样指向 /termination
```
**问题 B · 提醒规则/阈值/去重不合理**:部分提醒内容重复、阈值不合理或与实际业务无关(如合同到期 30 天提前量偏短,同一合同到期可能生成多条提醒)。
- **修复方向**
1. **修正反向引导(问题 A,P0 紧急)**:
- 三期/医疗期/工伤员工的"解聘受限"提醒,`actionUrl` 改为指向员工详情或特殊状态页(`/roster?employee=xxx` 或 `/special-status?type=PREGNANCY`),让 HR 确认员工状态,而非跳到解聘页面。
- 这类提醒的本质是"信息提示 + 合规警示",不是"可操作待办"。应在解聘向导 step 2 合规检查时硬阻断(Termination 已实现法定禁止情形阻断),工作台只做信息展示。
- 可考虑将这类提醒的 `type` 从 `TERMINATION` 改为 `COMPLIANCE`(合规提示),与可操作的解聘待办区分。
2. **梳理风险提醒规则清单(问题 B)**:合同到期(30/60/90 天分级)、未签合同(>30 天/>1 年)、试用期将满、退休预警、社保断缴、离职未办结等。
3. **每条提醒明确**:触发条件、提醒文案模板、优先级、跳转目标、是否可忽略、是"可操作待办"还是"信息提示"。
4. **去除重复提醒**(如同一合同到期生成多条),合并同类项。
5. **调整阈值至合理区间**(如合同到期提醒从 30 天提前到 60 天,给 HR 反应时间)。
6. **前端按优先级排序展示**,高风险置顶;信息提示类提醒不提供"去处理"按钮,仅提供"查看详情"。
- **优先级**:P0 紧急(问题 A 反向引导)/ P2 中(问题 B 规则优化)
- **涉及文件**`backend/src/services/risk.service.ts``detectTerminationRisks` 309-342 行、`detectSpecialStatusRisks`)、`frontend/src/pages/Dashboard.tsx`(风险提醒 Tab 渲染、按 type 区分操作按钮)
---
## 三、薪税管理 · 发放批次(问题 11、13)
### 问题 11:获取工资时自动识别试用期并填充试用期工资
- **现状**:不需要新增"获取试用期工资"按钮。已有的"获取工资"逻辑(创建批次、添加人员时自动填充 `baseSalary`)应自动识别员工是否处于试用期,是则填 `probationSalary`,否则填 `monthlySalary`。
**根因**:当前 3 处获取工资逻辑用了**错误的试用期判定**,判定的是"入职不到 1 年"而非"当前在试用期内"
```ts
// payroll2.routes.ts:355-359(创建批次 copy_last 模式)
// payroll2.routes.ts:610-614(添加人员到批次)
// payroll.routes.ts:380-388(旧版批量生成工资条)
if (emp.contracts?.[0]?.probationSalary
&& new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
baseSalary = emp.contracts[0].probationSalary // ← 只要入职不到1年就用试用期工资,转正后仍用
} else if (emp.monthlySalary) { ... }
```
问题:员工转正后(如试用期 3 个月,入职 6 个月),仍会被判定为试用期,`baseSalary` 错误填为 `probationSalary` 而非转正工资。
**正确逻辑**`payroll.service.ts:326-331`、`payroll.service.ts:600-603` 已实现):
```ts
const probationEnd = new Date(contract.startDate)
probationEnd.setMonth(probationEnd.getMonth() + contract.probationMonths)
if (probationEnd > new Date()) { ... } // 试用期未结束
```
- **修复方向**
1. 抽取统一的试用期判定工具函数 `isInProbation(contract, referenceDate?)``contract.startDate + contract.probationMonths > referenceDate`(默认当前日期,批次场景按批次月份的月末判定)。
2. 修正 3 处错误逻辑,统一调用 `isInProbation`
- `payroll2.routes.ts:355-359`(创建批次 copy_last 模式)
- `payroll2.routes.ts:610-614`(添加人员到批次)
- `payroll.routes.ts:380-388`(旧版批量生成工资条,如仍在用)
3. 判定为试用期 → `baseSalary = contract.probationSalary`;已转正 → `baseSalary = employee.monthlySalary`。
4. 批次场景按批次月份判定(如批次月份是 2026-08,则判定 2026-08-31 时员工是否仍在试用期),避免跨月转正时填错。
5. `probationSalary` 为 0 或未填时,即使试用期也回退用 `monthlySalary`(兼容旧数据)。
- **优先级**:P1 高(影响工资发放正确性)
- **涉及文件**`backend/src/routes/payroll2.routes.ts`355-359、610-614)、`backend/src/routes/payroll.routes.ts`380-388)、`backend/src/services/payroll.service.ts`(抽取 `isInProbation` 工具函数,或放 `contract.service.ts`
### 问题 13:编辑薪资新增"获取提成奖金"功能
- **现状**`money/BatchTab.tsx` 已有 `BONUS` 批次类型(`isBonus` 分支,617 行),但普通薪资批次编辑薪资时无"获取提成奖金"按钮,无法从提成奖金数据按月拉取填充 `bonus` 字段。
- **修复方向**
1. 编辑薪资步骤新增"获取提成奖金"按钮,调用后端接口按批次月份从提成奖金表拉取每位员工的提成奖金。
2. 拉取的奖金填入 `entry.bonus` 字段,支持正负值(奖金/扣款)。
3. 后端新增接口 `POST /payroll2/batches/:id/fetch-bonus`,按批次月份查询提成奖金表并返回。
4. 前端展示获取结果摘要(X 人提成奖金已填充,合计 ¥Y),支持覆盖确认。
5. 依赖问题 12 的提成奖金数据表先建立。
- **优先级**:P2 中(依赖问题 12)
- **涉及文件**`frontend/src/pages/money/BatchTab.tsx`(编辑薪资步骤按钮区)、`backend/src/routes/payroll.routes.ts`、`backend/src/services/payroll.service.ts`、`backend/src/services/commission-bonus.service.ts`(新增)
---
## 四、提成奖金 · 新模块(问题 12)
### 问题 12:团队页面下新增提成奖金页面
- **现状**`SidebarNav.tsx:44` "团队"分组下仅有花名册,无提成奖金入口;`money/BatchTab.tsx` 的 `BONUS` 批次是年终奖发放,非按月提成奖金管理。需新建独立提成奖金模块。
- **修复方向**
1. **数据模型**:新增 `CommissionBonus` 表(`orgId`、`employeeId`、`month` YYYY-MM、`amount` 支持正负值、`remark`、`createdAt`、`createdBy`),唯一索引 `(orgId, employeeId, month)`。
2. **后端**:新增 `commission-bonus.service.ts` + `commission-bonus.routes.ts`
- `GET /commission-bonus?month=YYYY-MM` 按月列表
- `POST /commission-bonus/batch` 按月批量导入(JSON 数组或 Excel)
- `PUT /commission-bonus/:id` 编辑单条
- `DELETE /commission-bonus/:id` 删除单条
- `GET /commission-bonus/template` 下载导入模板
3. **前端**:新增 `frontend/src/pages/CommissionBonus.tsx` 页面:
- 月份选择器(默认当月)
- 列表展示该月所有员工提成奖金(员工/部门/金额/备注)
- 支持单条编辑、删除
- "批量导入"按钮(上传 Excel,字段:员工姓名/证件号码/月份/金额/备注)
- 金额支持正负值(正为奖金,负为扣款)
- 合计展示(总奖金/总扣款/净额)
4. **菜单**`SidebarNav.tsx` "团队"分组新增"提成奖金"入口,路由 `/commission-bonus`。
5. **权限**:新增权限码 `PERM_COMMISSION_BONUS_VIEW`/`PERM_COMMISSION_BONUS_EDIT`/`PERM_COMMISSION_BONUS_IMPORT`。
- **优先级**:P3 规划(新模块)
- **涉及文件**`backend/prisma/schema.prisma`(新增 `CommissionBonus` 模型 + migration)、`backend/src/services/commission-bonus.service.ts`(新增)、`backend/src/routes/commission-bonus.routes.ts`(新增)、`frontend/src/pages/CommissionBonus.tsx`(新增)、`frontend/src/components/layout/SidebarNav.tsx`、`frontend/src/App.tsx`(路由注册)、`frontend/src/lib/api-services.ts``commissionBonusApi`
---
## 优先级汇总
| 优先级 | 问题 | 说明 | 状态 |
|--------|------|------|------|
| **P0 紧急** | 10A | 风险提醒反向引导(三期/医疗期/工伤提醒跳转到解聘页面) | ✅ 已完成 |
| **P1 高** | 4 | 合同状态增加待签署判定 | ✅ 已完成 |
| **P1 高** | 5 | 社保状态派生字段(待办/正常/停缴) | ✅ 已完成 |
| **P1 高** | 6 | 证件号码有效性校验(校验位+出生日期) | ✅ 已完成 |
| **P1 高** | 7 | 超龄人员合同类型限制(禁止选劳动合同) | ✅ 已完成 |
| **P1 高** | 8 | 劳务协议社保联动(选 LABOR 社保基数置 0 禁用) | ✅ 已完成 |
| **P1 高** | 9 | 待办跳转 actionUrl 修正 + 空值降级 | ✅ 已完成 |
| **P1 高** | 11 | 试用期工资判定修正(抽取 isInProbation 工具函数) | ✅ 已完成 |
| **P2 中** | 10B | 风险提醒规则优化(到期 30→60 天分级 + 去重排序) | ✅ 已完成 |
| **P2 中** | 13 | 薪资批次获取提成奖金(依赖 12) | ✅ 已完成 |
| **P3 规划** | 12 | 提成奖金新模块(DB migration + 全栈实现) | ✅ 已完成 |
---
## 建议执行顺序
> ✅ 全部 10 项已于 20260816 完成实现,前后端 tsc 均通过。
1. **第一批(P0 + P1,立即)** ✅:
- 问题 10A(风险提醒反向引导,改 actionUrl)← 最先,改动小风险高
- 问题 11(试用期工资判定修正)← 纯后端逻辑修正,抽取 `isInProbation` 工具函数
- 问题 6(证件号码有效性校验)→ 问题 7(超龄人员合同类型限制)→ 问题 8(劳务协议社保联动)→ 问题 4(合同状态待签署)→ 问题 5(社保状态待办)→ 问题 9(待办跳转)
- 问题 6/7/8 均在 `roster/modals.tsx` 添加雇员表单,可一并修改;问题 4/5 在后端 `contract.service.ts`/`employee.service.ts` 派生字段,前后端协同。
2. **第二批(P2,本迭代)** ✅:
- 问题 10B(风险提醒规则梳理)→ 问题 13(获取提成奖金,依赖问题 12)
3. **第三批(P3,规划)** ✅:
- 问题 12(提成奖金新模块,含 DB migration)→ 问题 13(获取提成奖金,依赖 12 完成)
---
## 备注
- 本清单基于当期代码静态分析,部分"根因"为推断,实际修复前需运行复现确认。
- 问题 12 涉及数据库 schema 变更(新增 `CommissionBonus` 表),需走 migration + 回滚方案。
- 问题 4/5 的"待签署""待办"状态为派生字段,不新增存储字段,避免数据冗余;如需记录"办理完成"操作时间,可新增 `socialInsConfirmedAt` 等时间戳字段。
- 问题 7 的超龄判定需引用 `femaleWorkerType`(干部 55/工人 50),需确认该字段在添加雇员表单已正确填写。
- 问题 13 依赖问题 12 的提成奖金数据表,不可先行实现。
---
## 五、已完成优化(20260816 补充)
> 以下为本次会话中已完成并部署的优化项。
### ✅ 已完成 1:员工端密码登录完整支持
- **默认密码**:创建员工时自动设置,密码 = 手机号后6位(无手机号则 `123456`
- **管理员重置密码**:花名册详情 → "重置密码"按钮,重置为手机号后6位
- **员工修改密码**:员工端导航栏 → "修改密码",需验证旧密码,新密码至少6位
- **Excel 批量导入**:导入时自动设置默认密码(手机号后6位),与新增员工一致
- **现有员工补初始化**:已通过脚本批量补设 512 名无密码员工(478 人手机号后6位,34 人无手机号用 123456)
- **涉及文件**
- `backend/src/services/contract.service.ts`createEmployee 添加 passwordHash
- `backend/src/routes/import.routes.ts`Excel 导入添加 passwordHash
- `backend/src/routes/employee.routes.ts`(新增 `POST /employees/:id/reset-password`
- `backend/src/routes/portal.routes.ts`(新增 `POST /portal/change-password`
- `frontend/src/pages/roster/BasicInfo.tsx`(重置密码按钮 + 默认密码提示)
- `frontend/src/pages/portal/PortalNav.tsx`(修改密码弹窗)
- `frontend/src/lib/api-services.ts`resetPassword / changePassword 接口)
### ✅ 已完成 2:员工端 portalAxios response interceptor 修复
- **问题**`portalAxios` 缺少 response interceptor,导致 `unwrap` 取到的是 `{ success, data: {...} }` 而非 `{ token, employee }`,密码登录/验证码登录/扫码自动登录全部失败
- **修复**:给 `portalAxios` 添加与管理端 `api` 实例一致的 response interceptor `(response) => response.data`
- **涉及文件**`frontend/src/lib/api-services.ts`
### ✅ 已完成 3:同企业内员工身份证和手机号唯一性完整校验
- **createEmployee**:新增手机号查重硬校验(原有身份证查重)
- **updateEmployee**:新增手机号 + 身份证查重(排除自身)
- **Excel 批量导入**:新增身份证 + 手机号查重(重复则跳过并记录错误日志)
- **portal 登录安全**:密码登录改为 `findMany` 遍历校验,避免跨组织手机号重复时登录到错误员工
- **涉及文件**
- `backend/src/services/contract.service.ts`createEmployee / updateEmployee 查重)
- `backend/src/routes/import.routes.ts`(导入查重)
- `backend/src/routes/portal.routes.ts`login / send-code / verify-code 跨组织安全)
### ✅ 已完成 4:电子签署改为「待签合同」,按员工聚合 + 催办功能
- **菜单改名**:「电子签署」→「待签合同」
- **页面重构为两个 Tab**
- **待签合同**:按员工聚合,每行一个员工,显示待签数,点击展开查看该员工名下所有待签文件(EsignRecord PENDING/SIGNING + LaborContract signDate 为空)
- **签署记录**:原有全部签署记录列表(保留状态/场景筛选)
- **催办功能**:点击催办 → 生成 24 小时有效的一次性自动登录链接(指向员工端签署页)→ 弹窗展示二维码 + 可复制链接 → HR 发给员工扫码直接进入签署
- **后端新增接口**
- `GET /esign/pending` — 待签合同按员工聚合列表
- `POST /esign/remind` — 催办生成自动登录链接
- **涉及文件**
- `backend/src/routes/esign.routes.ts`pending + remind 接口)
- `frontend/src/components/layout/SidebarNav.tsx`(菜单改名)
- `frontend/src/pages/ESign.tsx`Tab 切换 + PendingTab 组件 + 催办弹窗)
- `frontend/src/lib/api-services.ts`pending / remind 接口调用)
### ✅ 已完成 5:风险提醒反向引导修正(问题 10A)
- **问题**:三期/医疗期/工伤员工的风险提醒文案是"解聘受限",但 `actionUrl` 却指向 `/termination`(解聘向导),等于提示"不能解聘,点这里去解聘她"——反向引导,可能诱导违法解除。
- **修复**3 处 `actionUrl` 从 `/termination?employee=xxx` 改为 `/special-status?employee=xxx&type=PREGNANCY|MEDICAL_PERIOD|WORK_INJURY`,让 HR 确认员工状态而非跳去解聘。文案补充"此为合规提示,请勿发起解聘"。
- **涉及文件**`backend/src/services/risk.service.ts``detectTerminationRisks` 309-342 行)
### ✅ 已完成 6:试用期工资判定修正(问题 11)
- **问题**:3 处获取工资逻辑用错误的试用期判定 `startDate > now - 365d`(入职不到1年),转正后仍用 `probationSalary`。正确逻辑应是 `startDate + probationMonths > now`(试用期未结束)。
- **修复**
1. 抽取统一工具函数 `isInProbation(contract, referenceDate?)` 到 `contract.service.ts`。
2. 修正 3 处错误逻辑,统一调用 `isInProbation`,按批次月份月末判定。
3. `probationSalary` 为 0 时回退用 `monthlySalary`(兼容旧数据)。
- **涉及文件**
- `backend/src/services/contract.service.ts`(新增 `isInProbation`
- `backend/src/routes/payroll2.routes.ts`copy_last 模式 355 行、添加人员 614 行)
- `backend/src/routes/payroll.routes.ts`(旧版批量生成 380 行)
### ✅ 已完成 7:证件号码有效性校验(问题 6)
- **问题**:添加雇员仅校验证件号码长度 ≥18,未做校验位、出生日期合法性验证。
- **修复**
1. 前端 `handleIdCardChange` 增加校验位算法(GB 11643-1999)、出生日期合法性(月份/日期范围、是否存在如2月30日、是否晚于今天),校验失败阻断保存。
2. 后端新增 `validateIdCard`/`getAgeFromIdCard`/`isOverageEmployee` 工具函数,`createEmployee` 增加双保险校验。
- **涉及文件**
- `frontend/src/pages/roster/modals.tsx``handleIdCardChange`、`canSubmit`
- `backend/src/services/contract.service.ts`(新增 3 个工具函数 + `createEmployee` 校验)
### ✅ 已完成 8:超龄人员合同类型限制(问题 7)
- **问题**:超龄人员仅 WARN 提示,仍可选劳动合同(FIXED/UNFIXED),违反合规要求。
- **修复**
1. 前端派生 `isOverage`(男≥60、女干部≥55、女工人≥50),合同类型下拉过滤掉 FIXED/UNFIXED,仅保留 LABOR/INTERNSHIP/UNSIGNED。
2. 草稿恢复时若当前合同类型非法,useEffect 自动切到 UNSIGNED。
3. 后端 `createEmployee` 增加校验:超龄 + FIXED/UNFIXED 拒绝并返回 400。
- **涉及文件**
- `frontend/src/pages/roster/modals.tsx``isOverage` 派生、合同类型下拉过滤、useEffect 自动修正)
- `backend/src/services/contract.service.ts``createEmployee` 超龄校验)
### ✅ 已完成 9:劳务协议社保联动(问题 8)
- **问题**:选劳务协议(LABOR)时社保基数未置 0 且仍可缴纳,违反合规要求。
- **修复**
1. 前端选 LABOR/INTERNSHIP 时,社保/公积金基数自动置 0、起始月清空、字段禁用,提示"劳务协议/实习协议人员不缴纳社保公积金"。
2. 后端 `createEmployee` 强制:LABOR/INTERNSHIP 时 `socialInsBase`/`housingFundBase` = 0,起始月清空。
- **涉及文件**
- `frontend/src/pages/roster/modals.tsx`(合同类型 onChange 联动、社保区 disabled
- `backend/src/services/contract.service.ts``createEmployee` 社保强制 0
### ✅ 已完成 10:合同状态增加待签署判定(问题 4)
- **问题**:有合同记录但 `signDate` 为空时仍显示"正常",应显示"待签署"。
- **修复**`getContractStatus` 增加判定:有合同记录但 `signDate` 为空 → `status: 'pending_sign'``statusText: '待签署'``riskLevel: 'medium'`。电子签署完成后会回写 `signDate`,故 `signDate` 为空即未签署。前端花名册列表 `statusTextMap` 增加 `pending_sign: '待签署'`。
- **涉及文件**
- `backend/src/services/contract.service.ts``getContractStatus` 74-91 行重写)
- `frontend/src/pages/Roster.tsx``tagStyles`/`statusTextMap` 增加 `pending_sign`
### ✅ 已完成 11:社保状态派生字段(问题 5)
- **问题**:社保状态无"待办"判定,未办理社保的在职员工显示"—"而非"待办理"。
- **修复**:后端 `socialInsuranceStatus` 派生逻辑:
- 劳务协议/实习协议 → null(不缴社保,显示"—"
- 在职 + 应缴社保 + 无社保记录 → `PENDING`(待办理)
- 有社保记录 + endMonth 为 null → `ACTIVE`(在保)
- 有社保记录 + endMonth 不为 null → `SUSPENDED`(停保)
- 前端已有 PENDING/UNINSURED 的标签映射,无需改前端。
- **涉及文件**`backend/src/routes/roster.routes.ts``socialInsuranceStatus` 派生 243-249 行)
### ✅ 已完成 12:待办跳转 actionUrl 空值降级(问题 9
- **问题**:待办事项的"立刻办理"链接在 `actionUrl` 为空或 '/' 时仍渲染为 Link,点击跳首页无意义。
- **修复**Dashboard 待办列表渲染时,`actionUrl` 为空或 '/' 时不渲染 Link 和"立刻办理"按钮,改为纯展示 div;有有效 URL 时才渲染可跳转链接。问题 10A 已修正三期/医疗期/工伤的反向引导 URL,其余 actionUrl`/roster?employee=xxx`、`/special-status`、`/money`)均指向有效路由,Roster 已处理 `?employee` 参数自动定位。
- **涉及文件**`frontend/src/pages/Dashboard.tsx`(待办列表渲染 1136-1233 行)
### ✅ 已完成 13:风险提醒规则优化(问题 10B)
- **问题**:合同到期提醒仅 30 天预警,HR 反应时间不足;去重后的待办列表未按优先级排序。
- **修复**
1. 合同到期预警从 30 天提前到 60 天,分级:≤30 天 HIGH(需立即处理),31-60 天 MEDIUM(提前预警)。
2. `dedupedTodos` 按优先级排序(URGENT > HIGH > MEDIUM > LOW),确保最紧急的待办排在最前。
- **涉及文件**`backend/src/services/risk.service.ts``detectContractRisks` 216-248 行、`getDashboardData` dedupedTodos 排序 941-952 行)
### ✅ 已完成 14:提成奖金新模块(问题 12)
- **问题**:无独立提成奖金管理模块,`BONUS` 批次是年终奖发放,非按月提成奖金管理。
- **实现**
1. **数据模型**:新增 `CommissionBonus` 表(`orgId`、`employeeId`、`month` YYYY-MM、`amount` 支持正负值、`remark`、`createdBy`),唯一索引 `(orgId, employeeId, month)`。
2. **后端**:新增 `commission-bonus.service.ts` + `commission-bonus.routes.ts`
- `GET /commission-bonus?month=YYYY-MM` 按月列表 + 汇总(总奖金/总扣款/净额)
- `POST /commission-bonus` 新增单条(upsert,同员工同月自动覆盖)
- `PUT /commission-bonus/:id` 编辑单条
- `DELETE /commission-bonus/:id` 删除单条
- `POST /commission-bonus/import` 批量导入 Excel(字段:员工姓名/证件号码/金额/备注)
- `GET /commission-bonus/template` 下载导入模板
3. **前端**:新增 `CommissionBonus.tsx` 页面:
- 月份选择器(默认当月)
- 汇总卡片(记录数/总奖金/总扣款/净额)
- 列表展示(员工/部门/金额/备注),支持搜索、分页
- 单条新增/编辑/删除
- 批量导入 Excel + 模板下载
- 金额支持正负值(正=奖金,负=扣款)
4. **菜单**`SidebarNav.tsx` "团队"分组新增"提成奖金"入口,路由 `/commission-bonus`。
5. **DB 同步**`prisma db push` 已执行,Prisma Client 已重新生成。
- **涉及文件**
- `backend/prisma/schema.prisma`(新增 `CommissionBonus` 模型 + Employee/Organization 反向关联)
- `backend/src/services/commission-bonus.service.ts`(新增)
- `backend/src/routes/commission-bonus.routes.ts`(新增)
- `backend/src/app.ts`(路由注册)
- `frontend/src/pages/CommissionBonus.tsx`(新增)
- `frontend/src/components/layout/SidebarNav.tsx`(菜单项)
- `frontend/src/App.tsx`(路由注册)
- `frontend/src/lib/api-services.ts``commissionBonusApi`
### ✅ 已完成 15:获取提成奖金(问题 13)
- **问题**:薪资批次编辑薪资时无"获取提成奖金"按钮,无法从提成奖金模块按月拉取填充 `bonus` 字段。
- **实现**
1. 后端新增 `POST /payroll2/batches/:batchId/fetch-bonus`:按批次月份从 `CommissionBonus` 表拉取,填充到对应 `entries.bonus`,重算批次汇总。
2. 前端 `BatchTab.tsx` 编辑薪资步骤新增"获取提成奖金"按钮(在"导入加班费"旁),调用后展示结果摘要(X 人提成奖金已填充,合计 ¥Y)。
3. 已归档批次禁用按钮。
- **涉及文件**
- `backend/src/routes/payroll2.routes.ts``fetch-bonus` 接口 552-628 行)
- `frontend/src/lib/api-services.ts``fetchBonusToBatch`
- `frontend/src/pages/money/BatchTab.tsx``fetchBonusMutation` + 按钮 784-798 行)
+259
View File
@@ -0,0 +1,259 @@
# 20260816 用户测试说明
> 本次更新共完成 **15 项优化**,覆盖花名册合规校验、工作台待办跳转、薪税管理、提成奖金新模块等。
> 测试地址:https://on.hr8ai.top/
> 测试前请先**强制刷新浏览器**Mac: Cmd+Shift+R / Windows: Ctrl+Shift+R)清除缓存。
---
## 一、花名册 · 添加雇员(问题 4-8)
### 测试 1:证件号码有效性校验(问题 6)
**操作步骤**
1. 花名册 → 添加雇员
2. 证件类型选"身份证",输入以下测试用例:
- `11010119900101123X`(校验位错误)→ 应提示"身份证校验位错误",阻断保存
- `110101199013011234`(月份 13 非法)→ 应提示"出生日期不合法"
- `110101199002301234`(2 月 30 日不存在)→ 应提示"出生日期不合法"
- `110101209901011234`(出生日期在未来)→ 应提示"出生日期不合法"
- `110101199003075476`(正确身份证)→ 应通过校验
**预期**:非法身份证无法保存,页面显示具体错误原因。
---
### 测试 2:超龄人员合同类型限制(问题 7)
**操作步骤**
1. 花名册 → 添加雇员
2. 输入一位超龄人员信息:
- 男,出生日期选 1960-01-01(≥60 岁)
- 查看合同类型下拉
3. 再输入一位非超龄人员:
- 男,出生日期选 1990-01-01
- 查看合同类型下拉
**预期**
- 超龄人员:合同类型下拉**无**"固定期限劳动合同""无固定期限劳动合同",仅有"劳务协议""实习协议""未签合同"
- 非超龄人员:所有合同类型可选
---
### 测试 3:劳务协议社保联动(问题 8)
**操作步骤**
1. 花名册 → 添加雇员
2. 合同类型选"劳务协议"
3. 查看社保公积金区域
**预期**
- 社保缴费基数、公积金缴费基数自动置 0 且**不可编辑**(灰色禁用)
- 社保开始年月清空且禁用
- 显示提示"劳务协议/实习协议人员不缴纳社保公积金"
4. 切回"固定期限劳动合同"
**预期**:社保公积金区域恢复可编辑。
---
### 测试 4:合同状态"待签署"(问题 4
**操作步骤**
1. 花名册 → 添加雇员,填写合同信息但**不登记签署日期**,保存
2. 查看花名册列表中该员工的合同状态列
**预期**
- 合同状态显示"待签署"标签(黄色/橙色样式)
- 登记签署日期后,状态变为"正常"(绿色)
---
### 测试 5:社保状态"待办理"(问题 5
**操作步骤**
1. 花名册 → 添加一位劳动合同类型员工,不填写社保基数
2. 查看花名册列表中该员工的社保状态列
**预期**
- 社保状态显示"待办理"标签
- 劳务协议员工社保状态显示"—"(不缴社保)
---
## 二、工作台 · 待办与风险提醒(问题 9-10)
### 测试 6:待办跳转精准化(问题 9 + 月度待办优化)
**操作步骤**
1. 工作台 → 待办事项 Tab
2. 查看月度待办(缴纳社保、缴纳公积金、发放工资、申报个税)
3. 点击各待办的"立刻办理 →"
**预期**
- 缴纳社保 → 跳转到**社保公积金**页面,自动定位到"月度办理"Tab
- 缴纳公积金 → 跳转到**社保公积金**页面,自动定位到"月度办理"Tab
- 发放工资 → 跳转到**薪税管理**页面,自动定位到"发薪批次"Tab
- 申报个税 → 跳转到**薪税管理**页面,自动定位到"发薪批次"Tab
- 无 actionUrl 的待办 → 不显示"立刻办理"链接,仅展示文字
---
### 测试 7:风险提醒反向引导修正(问题 10A)
**操作步骤**
1. 找一位标记为"孕期/哺乳期"或"医疗期"或"工伤"的员工
2. 工作台 → 风险提醒 Tab,查看对应提醒
3. 点击"立刻办理 →"
**预期**
- 跳转到**特殊员工**页面(`/special-status`),而非解聘页面
- 文案包含"此为合规提示,请勿发起解聘"
---
### 测试 8:合同到期分级预警(问题 10B)
**操作步骤**
1. 找一位合同 45 天后到期的员工
2. 工作台 → 风险提醒 Tab
**预期**
- 31-60 天到期:显示"中"优先级预警(MEDIUM,黄色)
- ≤30 天到期:显示"高"优先级预警(HIGH,红色)
- 待办列表按优先级排序:高优先级置顶
---
## 三、薪税管理(问题 11、13)
### 测试 9:试用期工资自动判定(问题 11)
**操作步骤**
1. 确保有一位试用期员工(合同 startDate + probationMonths > 当前日期)和一位已转正员工
2. 薪税管理 → 发薪批次 → 创建当月批次(或复制上月)
3. 查看两位员工的"基本工资"
**预期**
- 试用期员工:基本工资 = 合同的 `probationSalary`
- 已转正员工:基本工资 = 员工的 `monthlySalary`
- `probationSalary` 为 0 的试用期员工:回退用 `monthlySalary`
---
### 测试 10:获取提成奖金(问题 13)
**前提**:先在"提成奖金"模块录入当月数据(见测试 11)。
**操作步骤**
1. 薪税管理 → 发薪批次 → 进入某未归档批次详情
2. 编辑薪资步骤,找到"获取提成奖金"按钮(在"导入加班费"旁)
3. 点击该按钮
**预期**
- 提示"已填充 X 人提成奖金,合计 ¥Y"
- 对应员工的"奖金"字段自动填充
- 已归档批次该按钮禁用
---
## 四、提成奖金新模块(问题 12)
### 测试 11:提成奖金完整功能
**操作步骤**
1. 左侧菜单 → 团队 → **提成奖金**DollarSign 图标)
2. **月份切换**:选择 2026-08,查看列表和汇总卡片
3. **新增单条**:点击"新增" → 选员工 → 输入金额(如 5000)→ 保存
4. **新增扣款**:再新增一条,金额输入负数(如 -200)→ 保存
5. **编辑**:点击列表行的编辑图标 → 修改金额 → 保存
6. **删除**:点击列表行的删除图标 → 确认删除
7. **搜索**:在搜索框输入员工姓名 → 验证过滤
8. **下载模板**:点击"模板"按钮 → 下载 Excel 模板
9. **批量导入**:在模板中填入数据 → 点击"批量导入"上传 → 验证导入结果
10. **汇总验证**:检查汇总卡片(记录数/总奖金/总扣款/净额)是否正确
**预期**
- 菜单显示"提成奖金"入口
- 列表显示员工/部门/金额/备注
- 金额正数显示绿色(奖金),负数显示红色(扣款)
- 汇总卡片:总奖金 = 所有正数之和,总扣款 = 所有负数绝对值之和,净额 = 总奖金 - 总扣款
- 导入成功后提示"新增 X,更新 Y,跳过 Z"
- 同员工同月重复新增自动覆盖(upsert)
---
## 五、员工端密码登录(已完成 1-3)
### 测试 12:员工端登录
**操作步骤**
1. 员工端登录页 → 密码登录
2. 输入手机号 + 密码(默认密码 = 手机号后 6 位)
3. 登录成功后 → 导航栏 → 修改密码
4. 输入旧密码 + 新密码(≥6 位)→ 确认
**预期**
- 默认密码可登录
- 修改密码后用新密码可登录,旧密码不可用
- 无手机号员工默认密码为 `123456`
---
### 测试 13:管理员重置密码
**操作步骤**
1. 花名册 → 某员工详情 → "重置密码"按钮
2. 确认重置
**预期**:密码重置为手机号后 6 位,员工可用新密码登录。
---
## 六、待签合同 + 催办(已完成 4)
### 测试 14:待签合同按员工聚合 + 催办
**操作步骤**
1. 左侧菜单 → **待签合同**
2. 查看"待签合同"Tab:按员工聚合,每行一个员工,显示待签数
3. 点击某员工行展开 → 查看该员工名下待签文件列表
4. 点击"催办" → 弹窗显示二维码 + 可复制链接
5. 切换到"签署记录"Tab → 查看全部签署记录
**预期**
- 待签合同按员工聚合展示
- 催办弹窗显示二维码和链接(24 小时有效)
- 签署记录 Tab 显示历史记录,支持状态/场景筛选
---
## 测试结果记录
| 编号 | 测试项 | 通过 | 备注 |
|------|--------|------|------|
| 1 | 证件号码校验 | ☐ | |
| 2 | 超龄合同限制 | ☐ | |
| 3 | 劳务协议社保联动 | ☐ | |
| 4 | 合同待签署状态 | ☐ | |
| 5 | 社保待办理状态 | ☐ | |
| 6 | 待办跳转精准化 | ☐ | |
| 7 | 风险提醒反向引导修正 | ☐ | |
| 8 | 合同到期分级预警 | ☐ | |
| 9 | 试用期工资判定 | ☐ | |
| 10 | 获取提成奖金 | ☐ | |
| 11 | 提成奖金模块 | ☐ | |
| 12 | 员工端登录 | ☐ | |
| 13 | 管理员重置密码 | ☐ | |
| 14 | 待签合同+催办 | ☐ | |
---
## 注意事项
1. 测试前务必**强制刷新浏览器**清除缓存(Cmd+Shift+R
2. 测试 9-11 需要有测试员工数据,建议先用花名册添加 2-3 名测试员工
3. 测试 10 依赖测试 11 先录入提成奖金数据
4. 如发现菜单未显示"提成奖金",请清除浏览器缓存后重试
5. 测试完成后请在测试结果记录表勾选通过项,未通过项请描述具体问题
+422
View File
@@ -0,0 +1,422 @@
# 20260816 社保公积金账户化重构
> 目标:将社保公积金从"按城市直接关联版本"改为"账户 + 年度标准"两层实体。
> 账户代表用户开设的社保/公积金账户(对应不同子公司/分公司/地区),年度标准是账户下每个年度的缴费比例和基数标准。
> **状态:已完成实施**2026-08-16
> 账户+年度标准模型已上线,前端已重构为账户卡片列表+展开年度标准管理。
---
## 一、现状分析
### 当前数据模型(已重构)
| 表 | 说明 | 唯一约束 |
|----|------|---------|
| `SocialAccount` | 社保/公积金账户实体(type=SOCIAL/HOUSING | `@@unique([orgId, type, name])` |
| `SocialYearStandard` | 年度标准(比例+基数+最低工资+生效月份),关联到账户 | `@@unique([accountId, effectiveFrom])` |
| `SocialInsuranceConfig` | 旧社保版本表(保留兼容,回退用) | `@@unique([orgId, city, effectiveFrom])` |
| `HousingFundConfig` | 旧公积金版本表(保留兼容) | — |
| `EmployeeSocialInsRecord` | 员工社保参保记录,已加 accountId | — |
| `EmployeeHousingFundRecord` | 员工公积金参保记录,已加 accountId | — |
| `SocialMonthlyProcess` | 月度办理记录 | `@@unique([orgId, month, type])` |
### 已解决的问题
1.**"城市"只是字符串** → 已改为 SocialAccount 实体化管理
2.**多子公司/分公司场景缺失** → 同一城市可有多个账户,按根部门关联
3.**版本直接挂在 orgId+city 上** → 改为 SocialYearStandard 关联到 accountId
4.**员工参保记录只有 city** → 已加 accountId 字段
5.**薪资计算中社保配置查询** → 改为按 accountId 查 SocialYearStandard,旧表回退
6.**最低工资标准** → SocialYearStandard 新增 minWage 字段,支持最低工资保护和递延扣款
---
## 二、目标数据模型
### 新增:SocialAccount(社保/公积金账户)
```
model SocialAccount {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
type String // SOCIAL=社保账户, HOUSING=公积金账户
name String // 账户名称,如"北京总公司社保账户"、"上海分公司社保账户"
city String // 参保城市
accountNo String? // 社保登记号 / 公积金单位账号
bankName String? // 公积金开户行(公积金专用)
bankAccount String? // 公积金银行账号(公积金专用)
orgName String? // 缴费主体名称(子公司/分公司名称)
orgCode String? // 缴费主体统一社会信用代码
accountType String? // 公积金账户类型 BASIC=基本, SUPPLEMENTARY=补充(公积金专用)
isDefault Boolean @default(false) // 是否默认账户(同 type 下仅一个默认)
status String @default("ACTIVE") // ACTIVE=正常, SUSPENDED=停用
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
yearStandards SocialYearStandard[]
socialRecords EmployeeSocialInsRecord[]
housingRecords EmployeeHousingFundRecord[]
// 部门关联(根部门 level=0 代表分公司/子公司)
deptSocialAccounts Department[] @relation("SocialAccountDepartments")
deptHousingAccounts Department[] @relation("HousingAccountDepartments")
@@unique([orgId, type, name])
@@index([orgId, type, city])
@@index([orgId, type, isDefault])
}
```
### 新增:SocialYearStandard(年度标准,替代原 Config 表)
```
model SocialYearStandard {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
accountId String // 关联到账户
account SocialAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
// 社保比例(type=SOCIAL 时使用)
pensionOrg Float @default(16)
pensionEmp Float @default(8)
medicalOrg Float @default(9.8)
medicalEmp Float @default(2)
unemploymentOrg Float @default(0.5)
unemploymentEmp Float @default(0.5)
injuryOrg Float @default(0.2)
maternityOrg Float @default(0.8)
baseMin Float @default(6326)
baseMax Float @default(33891)
medicalBaseMin Float @default(0)
medicalBaseMax Float @default(0)
extraInsurances Json?
// 最低工资标准(仅社保,20260816新增)
minWage Float @default(0) // 当地月最低工资标准,0=不检查
// 公积金比例(type=HOUSING 时使用)
housingOrg Float @default(12)
housingEmp Float @default(12)
effectiveFrom String // 生效月份 YYYY-MM
effectiveTo String? // 失效月份 YYYY-MMnull=当前有效)
isCurrent Boolean @default(true)
adjustmentDone Boolean @default(false)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([accountId, effectiveFrom])
@@index([accountId, isCurrent])
}
```
### 修改:EmployeeSocialInsRecord
```
model EmployeeSocialInsRecord {
// 新增字段
accountId String? // 关联到 SocialAccount(迁移后非空)
account SocialAccount? @relation(fields: [accountId], references: [id])
// 保留 city 字段用于兼容(迁移后从 account.city 派生,后续可废弃)
city String @default("北京")
// 其余字段不变
}
```
### 修改:EmployeeHousingFundRecord
```
model EmployeeHousingFundRecord {
// 新增字段
accountId String?
account SocialAccount? @relation(fields: [accountId], references: [id])
// 保留 city 字段用于兼容
city String @default("北京")
// 其余字段不变
}
```
### 修改:SocialMonthlyProcess
```
model SocialMonthlyProcess {
// 新增字段
accountId String? // 关联到账户(迁移后非空)
// 保留原字段
month String
type String
// ...
}
```
### 修改:Organization
```
model Organization {
// 新增关联
socialAccounts SocialAccount[]
}
```
---
## 三、数据迁移方案
### 迁移步骤
1. **创建新表**`SocialAccount` + `SocialYearStandard`
2. **给员工记录表加 accountId 字段**(可空,迁移期间兼容)
3. **迁移 SocialInsuranceConfig → SocialAccount + SocialYearStandard**
-`(orgId, city)` 分组,每组创建一个 `SocialAccount`type=SOCIAL
- 每条 Config 创建一条 `SocialYearStandard`accountId 关联到对应账户)
4. **迁移 HousingFundConfig → SocialAccount + SocialYearStandard**
-`(orgId, city, accountType)` 分组,每组创建一个 `SocialAccount`type=HOUSING
- 每条 Config 创建一条 `SocialYearStandard`
5. **迁移 EmployeeSocialInsRecord.accountId**:按 `(orgId, city)` 匹配到 SocialAccount
6. **迁移 EmployeeHousingFundRecord.accountId**:按 `(orgId, city)` 匹配到 SocialAccount
7. **迁移 SocialMonthlyProcess.accountId**:按 `(orgId, type, snapshot.city)` 匹配
8. **验证数据完整性**:确认所有记录都有 accountId
9. **将 accountId 设为非空**(可选,或保持可空兼容)
10. **保留旧表**SocialInsuranceConfig / HousingFundConfig)作为备份,代码切换后再删除
### 迁移脚本
```sql
-- 1. 创建社保账户(从 SocialInsuranceConfig 提取唯一 orgId+city
INSERT INTO "SocialAccount" (id, orgId, type, name, city, "isDefault", status, "createdBy", "createdAt", "updatedAt")
SELECT
gen_random_uuid(),
"orgId",
'SOCIAL',
city || '社保账户',
city,
true,
'ACTIVE',
'migration',
NOW(),
NOW()
FROM (SELECT DISTINCT "orgId", city FROM "SocialInsuranceConfig") t;
-- 2. 创建公积金账户(从 HousingFundConfig 提取唯一 orgId+city+accountType
INSERT INTO "SocialAccount" (id, orgId, type, name, city, "accountType", "isDefault", status, "createdBy", "createdAt", "updatedAt")
SELECT
gen_random_uuid(),
"orgId",
'HOUSING',
city || COALESCE("accountType", 'BASIC') || '公积金账户',
city,
COALESCE("accountType", 'BASIC'),
true,
'ACTIVE',
'migration',
NOW(),
NOW()
FROM (SELECT DISTINCT "orgId", city, "accountType" FROM "HousingFundConfig") t;
-- 3. 迁移社保年度标准
INSERT INTO "SocialYearStandard" (id, orgId, "accountId", "pensionOrg", "pensionEmp", ...)
SELECT
gen_random_uuid(),
c."orgId",
a.id,
c."pensionOrg", c."pensionEmp", ...
FROM "SocialInsuranceConfig" c
JOIN "SocialAccount" a ON a."orgId" = c."orgId" AND a.city = c.city AND a.type = 'SOCIAL';
-- 4. 迁移公积金年度标准
-- 5. 迁移员工参保记录 accountId
-- 6. 迁移月度办理记录 accountId
```
---
## 四、后端 API 变更
### 新增:账户管理 API(已实施)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/social/accounts` | 账户列表(支持 type 筛选) |
| POST | `/social/accounts` | 新建账户 |
| PUT | `/social/accounts/:id` | 编辑账户 |
| DELETE | `/social/accounts/:id` | 删除账户(无关联记录时可删) |
| PUT | `/social/accounts/:id/default` | 设为默认账户 |
| PUT | `/social/accounts/:id/departments` | 账户关联根部门(批量) |
| GET | `/social/accounts/:id/departments` | 获取账户已关联的根部门 |
### 新增:年度标准 API(已实施)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/social/accounts/:accountId/standards` | 账户的年度标准列表 |
| GET | `/social/accounts/:accountId/current-standard` | 账户的当前生效标准 |
| GET | `/social/accounts/:accountId/standard-by-month/:month` | 按账户+月份获取适用标准 |
| POST | `/social/accounts/:accountId/standards` | 新建年度标准(旧版本自动归档) |
| PUT | `/social/accounts/:accountId/min-wage` | 快速更新当前标准的最低工资 |
### 改造:薪资计算(已实施)
- `payroll.service.ts` 中社保配置查询:从 `orgId + city` 改为通过员工→根部门→账户→年度标准
- 员工参保记录查询:从 `employeeId + city` 改为 `employeeId + accountId`
- 新增最低工资保护和递延扣款逻辑(详见 20260816-薪税管理逻辑.md 第十五章)
### 废弃:城市相关 API
| 方法 | 路径 | 说明 | 处理 |
|------|------|------|------|
| GET | `/social/config/cities` | 城市列表 | 废弃,改为 `/social/accounts` 返回账户列表(含 city |
### 改造:月度办理 API
- 月度办理按账户分别办理,不同账户不同城市的增减员
- `SocialMonthlyProcess` 新增 accountId
### 改造:薪资计算
- `payroll.service.ts` 中社保配置查询:从 `orgId + city` 改为 `accountId`
- 员工参保记录查询:从 `employeeId + city` 改为 `employeeId + accountId`
---
## 五、前端 UI 变更(已实施)
### 1. 社保公积金菜单(SocialInsurance.tsx)— 已重构
**社保/公积金 Tab**20260816 重构):
- ~~原城市选择器 → 改为账户选择器~~ → 已改为**账户卡片列表 + 展开年度标准管理**
- 每个账户卡片:名称、城市、账号、关联部门数、参保记录数
- 卡片可展开/折叠(ChevronDown/ChevronRight 图标)
- 展开后显示:
- 当前年度标准(比例/基数/最低工资)
- 最低工资快速编辑(仅社保,Input + 保存按钮)
- 操作按钮:新建年度标准 | 批量调基 | 查看历史
- 新建年度标准弹窗(原"新建版本"改名,保留 AI 建议、附加险种配置)
- 版本历史展示
- 调基预览(保留原有展示和编辑逻辑)
- 试算工具(使用该账户的城市配置)
- 顶部"新建账户"按钮(账户新建/编辑/删除/设默认已从设置页面迁移到此)
- 新增组件:`AccountCard.tsx`(账户卡片+展开内容)、`AccountFormModal.tsx`(账户新建/编辑弹窗)
**月度办理 Tab**:保留不变
**员工参保 Tab**:保留不变
**专项附加扣除 Tab**:保留不变
### 2. 设置页面(Settings.tsx)— 已简化
- ~~新增"社保公积金账户管理"Tab~~ → 已去掉该 Tab
- 账户管理全部移到社保公积金菜单中完成
- SocialAccountSettings 和 AccountFormModal 组件定义保留在文件中(不再显示)
### 3. 员工表单(roster/modals.tsx
- 社保公积金区:原城市选择 → 改为**账户选择**(下拉,按 type 筛选)
- 选中账户后自动带出城市(只读展示)
---
## 六、影响范围清单
### 后端
| 文件 | 改动范围 |
|------|---------|
| `prisma/schema.prisma` | 新增 SocialAccount、SocialYearStandard,修改员工记录表加 accountId |
| `src/routes/social.routes.ts` | 全面重构:账户 CRUD + 年度标准改为按 accountId |
| `src/services/payroll.service.ts` | 社保配置查询改为 account → yearStandard 两级 |
| `src/routes/payroll2.routes.ts` | 批次计算中社保配置查询适配 |
| `src/routes/payroll.routes.ts` | 旧版薪资计算适配 |
| `src/routes/roster.routes.ts` | socialInsuranceStatus 派生逻辑适配 |
| `src/services/contract.service.ts` | createEmployee 社保记录创建时关联 accountId |
| `src/routes/import.routes.ts` | Excel 导入时社保账户关联 |
### 前端
| 文件 | 改动范围 |
|------|---------|
| `src/pages/SocialInsurance.tsx` | 全面重构:账户选择器 + 年度标准 + 月度办理 |
| `src/pages/Settings.tsx` | 新增账户管理入口 |
| `src/pages/roster/modals.tsx` | 社保公积金区改为账户选择 |
| `src/lib/api-services.ts` | 新增 socialAccountApi,改造 socialInsuranceApi |
### 数据库迁移
| 操作 | 说明 |
|------|------|
| 新增表 | SocialAccount、SocialYearStandard |
| 修改表 | EmployeeSocialInsRecord 加 accountIdEmployeeHousingFundRecord 加 accountIdSocialMonthlyProcess 加 accountId |
| 数据迁移 | SocialInsuranceConfig → SocialAccount + SocialYearStandard |
| 数据迁移 | HousingFundConfig → SocialAccount + SocialYearStandard |
| 数据迁移 | 员工参保记录按 city 匹配 accountId |
| 保留旧表 | SocialInsuranceConfig / HousingFundConfig 暂保留,代码切换后删除 |
---
## 七、实施计划与完成状态
### 第一步:DB schema + 迁移脚本 ✅ 已完成
- ✅ 新增 SocialAccount、SocialYearStandard 表
- ✅ 员工记录表加 accountId 字段(可空)
- ✅ SocialYearStandard 新增 minWage 字段(最低工资标准)
- ✅ Department 表新增 socialAccountId / housingAccountId(根部门关联账户)
- ✅ 编写并执行迁移脚本
- ✅ 验证数据完整性
### 第二步:后端 API 重构 ✅ 已完成
- ✅ 新增账户 CRUD API`/social/accounts`
- ✅ 年度标准 API 改为按 accountId`/social/accounts/:accountId/standards`
- ✅ 新增按账户获取当前标准 API`/social/accounts/:accountId/current-standard`
- ✅ 新增快速更新最低工资 API`/social/accounts/:accountId/min-wage`
- ✅ 薪资计算适配(payroll.service.ts 按 accountId 查标准,旧表回退)
- ✅ 员工参保记录适配
- ✅ 保留旧 API 兼容(过渡期)
### 第三步:前端账户管理 ✅ 已完成
-~~设置页新增账户管理 UI~~ → 已移到社保公积金菜单
- ✅ SocialInsurance.tsx 改为账户卡片列表 + 展开年度标准管理
- ✅ 新建版本改名为"新建年度标准"
- ✅ 最低工资快速编辑(当前配置区域可直接编辑)
- ✅ 新增 AccountCard.tsx 组件(账户卡片+展开内容)
- ✅ 新增 AccountFormModal.tsx 组件(账户新建/编辑弹窗)
### 第四步:前端参保流程适配 ✅ 已完成
- ✅ 员工表单社保公积金区改为账户选择
- ✅ 月度办理保留不变
- ✅ 员工参保记录展示账户名称
### 第五步:清理 ⏳ 待执行
- ⏳ 删除旧 SocialInsuranceConfig / HousingFundConfig 表
- ⏳ 删除旧 API
- ⏳ 删除前端城市选择器残留代码
### 额外完成的功能(20260816
- ✅ 最低工资保护与递延扣款机制(详见 20260816-薪税管理逻辑.md 第十五章)
- ✅ 最低工资保护支持当月累计实发判断(多批次场景)
- ✅ 预入职状态(PRE_ONBOARD),预入职员工不进入薪资批次
- ✅ 薪资批次新增 payMonth(发薪年月)字段,支持提前发薪场景
---
## 八、风险与回滚
| 风险 | 应对 |
|------|------|
| 迁移脚本数据丢失 | 迁移前备份数据库,旧表保留不删 |
| 薪资计算引用旧表 | 过渡期双写,确认新表数据正确后再切换 |
| 前端缓存旧城市选择器 | 强制刷新 + 版本号 |
| 员工参保记录无 accountId | 迁移脚本按 city 匹配,未匹配的标记待处理 |
**回滚方案**:保留旧表和旧 API,前端可切回旧版本,后端旧 API 仍可用。
+734
View File
@@ -0,0 +1,734 @@
# 薪税管理完整处理逻辑和取数逻辑
> 生成时间:2026-08-16
> 涉及模块:薪资发放批次、社保公积金、个税累计预扣、工资条生成与发布
> 核心文件:`backend/src/services/payroll.service.ts`、`backend/src/routes/payroll2.routes.ts`、`backend/src/routes/social.routes.ts`
---
## 一、整体架构
```
┌─────────────────────────────────────────────────────────────┐
│ 薪税管理三大模块 │
├──────────────┬──────────────────┬───────────────────────────┤
│ 社保公积金 │ 个税计算 │ 工资条生成/发布 │
│ (缴纳/扣除) │ (累计预扣法) │ (汇总/发布/定时) │
└──────────────┴──────────────────┴───────────────────────────┘
↓ 取数 ↓ ↓ 取数 ↓
┌──────────────┬──────────────────┬───────────────────────────┐
│ SocialAccount│ Employee 表 │ PayrollBatch (已归档) │
│ + YearStandard│ SpecialDeduction │ + BatchEntry │
│ + 旧Config表 │ + Record表 │ → Payslip │
└──────────────┴──────────────────┴───────────────────────────┘
```
---
## 二、核心计算函数 `calcBatchEntry`
这是所有薪税计算的入口,在三个时机被调用:
1. **创建批次时**:为每个员工初始化计算
2. **编辑条目时**:修改任意金额后实时重算
3. **归档时**:最终锁定前再重算一遍
### 输入参数
```typescript
calcBatchEntry(
orgId, // 组织ID
employeeId, // 员工ID
month, // 批次月份 YYYY-MM
inputs: { // HR 可编辑的输入项
baseSalary, // 基本工资
overtimePay, // 加班费
allowance, // 其他津贴
deduction, // 扣款
bonus, // 奖金
positionSalary?, // 岗位工资
performanceSalary?, // 绩效工资
senioritySalary?, // 工龄工资
transportAllowance?, // 交通补贴
mealAllowance?, // 餐补
housingAllowance?, // 住房补贴
communicationAllowance?, // 通讯补贴
otherDeduction?, // 其他扣款
},
batchType, // REGULAR | TERMINATION | BONUS | SEVERANCE
options?: { // 可选覆盖
skipSocial?, // 跳过社保计算
overrideSocial?, // 手动覆盖社保值
prevDeferred?, // 上月递延扣款(次月补扣){ socialEmp, housingEmp, minWage }
}
)
```
---
## 三、社保公积金取数逻辑
### 取数优先级(三层回退)
```
① 员工 → 根部门(level=0) → socialAccount / housingAccount
↓ 找不到
② 公司默认账户 (isDefault=true)
↓ 找不到
③ 旧配置表 SocialInsuranceConfig / HousingFundConfig(按城市匹配)
```
### 新逻辑(账户体系)
```
getEmployeeAccounts(orgId, employeeId)
├─ 查员工所属部门
├─ 向上遍历到 level=0 的根部门
├─ 读取根部门的 socialAccountId / housingAccountId
├─ 若根部门未关联 → 查公司默认账户 (isDefault=true)
└─ 返回 { socialAccount, housingAccount }
getStandardByAccountAndMonth(accountId, month)
├─ 查 SocialYearStandard: accountId + effectiveFrom ≤ month ≤ effectiveTo
├─ 找不到 → 回退到 isCurrent=true 的当前标准
└─ 返回年度标准(比例 + 基数上下限)
```
### 旧逻辑(兼容回退)
```
SocialInsuranceConfig.findFirst({
orgId, city: employee.city,
effectiveFrom ≤ month, effectiveTo ≥ month 或 null
})
```
### 社保基数确定
```typescript
socialBase = employee.socialInsBase || inputs.baseSalary
housingBase = employee.housingFundBase || inputs.baseSalary
```
优先级:员工核定基数 > 基本工资
### 社保金额计算
```typescript
// 当月应缴全额
fullSocialEmp = calcSocialInsurance(socialBase, socialConfig).socialEmp
fullSocialOrg = calcSocialInsurance(socialBase, socialConfig).socialOrg
fullHousingEmp = calcHousingFund(housingBase, housingConfig).housingEmp
fullHousingOrg = calcHousingFund(housingBase, housingConfig).housingOrg
// 已归档批次已扣金额(避免多批次重复扣社保)
deductedSocialEmp = SUM(archivedEntries.socialEmp)
deductedSocialOrg = SUM(archivedEntries.socialOrg)
deductedHousingEmp = SUM(archivedEntries.housingEmp)
deductedHousingOrg = SUM(archivedEntries.housingOrg)
// 本批次应扣 = 应缴全额 - 已扣金额(差额补扣,足额为0)
socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp)
socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg)
housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp)
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
// 叠加上月递延的社保/公积金(入职当月未扣完的部分,本月补扣)
if (options?.prevDeferred) {
socialEmp += options.prevDeferred.socialEmp || 0
housingEmp += options.prevDeferred.housingEmp || 0
}
```
### 关键设计:差额补扣机制
- 同一员工同一月份可能有多个批次(如常规发薪 + 离职结算)
- 第一个批次扣全额,后续批次扣差额(应缴全额 - 已扣)
- 避免重复扣除
### 批次类型对社保的影响
| 批次类型 | 社保公积金 | 说明 |
|---------|-----------|------|
| REGULAR | ✅ 计算 | 常规发薪,差额补扣 |
| TERMINATION | ✅ 计算 | 离职结算,差额补扣 |
| BONUS | ❌ 不扣 | 年终奖单独计税,无社保 |
| SEVERANCE | ❌ 不扣 | 补偿金不走社保个税 |
### 手动覆盖
```typescript
// HR 可在编辑界面手动修改社保值
if (options?.overrideSocial) {
socialEmp = overrideSocial.socialEmp // 覆盖个人社保
socialOrg = overrideSocial.socialOrg // 覆盖单位社保
housingEmp = overrideSocial.housingEmp // 覆盖个人公积金
housingOrg = overrideSocial.housingOrg // 覆盖单位公积金
}
// 同时保存 systemSocialEmp 等系统计算值,用于对比展示
```
---
## 四、应发合计计算
```typescript
totalPay =
baseSalary // 基本工资
+ positionSalary // 岗位工资
+ performanceSalary // 绩效工资
+ senioritySalary // 工龄工资
+ overtimePay // 加班费
+ transportAllowance // 交通补贴
+ mealAllowance // 餐补
+ housingAllowance // 住房补贴
+ communicationAllowance // 通讯补贴
+ allowance // 其他津贴
+ bonus // 奖金
- deduction // 扣款
- otherDeduction // 其他扣款
```
---
## 五、个税计算逻辑
### 两种计税方式
#### 1. 年终奖单独计税(BONUS 批次)
```typescript
tax = calcBonusTax(inputs.bonus)
// 年终奖 ÷ 12 → 找税率区间 → 年终奖 × 税率 - 速算扣除数
```
#### 2. 累计预扣法(REGULAR / TERMINATION / SEVERANCE
```typescript
// ── 取数:当年已归档批次的历史数据 ──
archivedEntries = BatchEntry.findMany({
orgId, employeeId,
batch: { month: startsWith(year), status: 'ARCHIVED' }
})
// 注意:不依赖 Payslip 是否已生成,直接从已归档批次取数
// ── 累计计算 ──
ytdIncome = SUM(archivedEntries.totalPay) + totalPay
ytdSocialEmp = SUM(archivedEntries.socialEmp) + socialEmp
ytdHousingEmp = SUM(archivedEntries.housingEmp) + housingEmp
ytdTaxDeducted = SUM(archivedEntries.tax)
// ── 专项附加扣除取数(三层回退)──
deductionRecords = SpecialDeductionRecord.findMany({
orgId, employeeId,
month: startsWith(year) AND lte(month) // 当年至当月
})
if (deductionRecords.length > 0) {
// ✅ 优先:按月实际填报金额累加
ytdSpecialDeduction = SUM(deductionRecords.amount)
} else {
// ⚠️ 回退:员工便捷字段 × 月数(兼容旧数据)
ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
}
// ── 累计应纳税所得额 ──
deductionAmount = 5000 × // 基本减除费用
ytdTaxableIncome = max(0,
ytdIncome
- deductionAmount // 累计减除费用(5000/月)
- ytdSocialEmp // 累计个人社保
- ytdHousingEmp // 累计个人公积金
- ytdSpecialDeduction // 累计专项附加扣除
)
// ── 当月应扣个税 ──
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
// = 累计应纳税额 - 已预扣个税
```
### 专项附加扣除数据来源
| 来源 | 表 | 说明 |
|------|---|------|
| **按月记录**(优先) | `SpecialDeductionRecord` | HR/员工按月填报,含子女教育、赡养老人、住房贷款、继续教育、婴幼儿照护 |
| **便捷字段**(回退) | `Employee.specialDeduction` | 员工表上的当前值,× 月数估算累计 |
**按月记录的优势**:员工某月取消专项附加扣除,该月金额为 0,累计值准确反映实际。
### taxBreakdown 返回的明细
```json
{
"method": "累计预扣法",
"month": 8,
"ytdIncome": 80000, // 累计收入
"deductionAmount": 40000, // 累计减除费用 (5000×8)
"ytdSocialEmp": 6720, // 累计个人社保
"ytdHousingEmp": 3840, // 累计个人公积金
"ytdSpecialDeduction": 12000, // 累计专项附加扣除
"specialDeductionSource": "按月记录", // 数据来源标识
"specialDeductionRecords": 8, // 按月记录条数
"ytdTaxableIncome": 17440, // 累计应纳税所得额
"ytdTaxDeducted": 480, // 已预扣个税
"currentMonthTax": 360, // 当月应扣个税
"archivedCount": 7 // 已归档批次数
}
```
---
## 六、实发工资与最低工资保护
```typescript
// 初始实发 = 应发 - 个人社保 - 个人公积金 - 个税 - 上月递延最低工资补扣
netPay = totalPay - socialEmp - housingEmp - tax - prevDeferredMinWage
// 最低工资保护(仅 REGULAR / TERMINATION 批次)
if (minWage > 0 && netPay < minWage) {
// 优先递延社保 → 递延公积金 → 递延最低工资补齐
// 详见第十五章「最低工资保护与递延扣款机制」
netPay = minWage
}
```
---
## 七、创建批次时的初始化取数
### 员工来源
| 批次类型 | 员工来源 |
|---------|---------|
| REGULAR / BONUS | `status=ACTIVE` + 本月离职(`status=RESIGNED, updatedAt 在本月` |
| TERMINATION | 本月 `TerminationRecord` 关联的员工 |
| SEVERANCE | 本月离职记录中已审批(APPROVED/EXECUTING/COMPLETED)且有补偿金的 |
### 数据初始化(5 种模式)
| 模式 | baseSalary 取数 | overtimePay | allowance/deduction |
|------|----------------|-------------|---------------------|
| **copy_last** | 试用期→`probationSalary`;转正→上月工资条`baseSalary`;无→`monthlySalary` | 当月 `OvertimeRecord.totalPay` | 上月工资条复制 |
| **blank_employees** | `employee.monthlySalary`(解密) | 0 | 0 |
| **blank_all** | 0(无员工) | 0 | 0 |
| **copy_batch** | 源批次条目复制 | 源批次复制 | 源批次复制 |
| **custom** | 0(手动填写) | 0 | 0 |
### 试用期判定
```typescript
isInProbation(contract, batchMonthEnd)
// 试用期结束日 = contract.startDate + contract.probationMonths
// 若 batchMonthEnd < 试用期结束日 → 在试用期内
```
统一规则(所有模式适用,SEVERANCE/TERMINATION 除外):
- 在试用期内且 `probationSalary > 0``baseSalary = probationSalary`
---
## 八、编辑条目时的重算
```
HR 修改任意金额字段
PUT /batches/:batchId/entries/:employeeId
合并新旧 inputs(未修改的保留原值)
重新调用 calcBatchEntry() → 重算社保/个税/实发
更新 BatchEntry + 重算批次汇总
```
**社保手动覆盖**:编辑社保字段时,通过 `overrideSocial` 传入,覆盖系统计算值,同时保存 `systemSocial*` 用于对比。
---
## 九、归档时的最终重算
```
POST /batches/:batchId/archive
① 遍历所有条目,重新调用 calcBatchEntry()
- 此时已归档批次的累计数据是最新的
- 社保差额补扣准确
- 个税累计预扣准确
② 更新批次汇总(totalPay/totalNetPay/各项合计)
③ 标记 status=ARCHIVED, archivedAt=now
④ 批次锁定,不可再编辑
```
**归档时不自动生成工资条**,需单独调用 `POST /payslips/generate`
---
## 十、工资条生成与发布
### 生成工资条
```
POST /payslips/generate { month }
generatePayslipFromBatches(orgId, month)
① 查当月所有已归档批次(status=ARCHIVED
② 按员工汇总所有批次的 BatchEntry(多批次合并)
③ 查当年历史工资条计算累计数据
④ upsert PayslipemployeeId+month 唯一键)
```
### 发布工资条
```
POST /batches/:batchId/publish
更新 Payslip.publishStatus = 'PUBLISHED', publishedAt = now
员工端可见
```
### 定时发送
```
POST /batches/:batchId/schedule { scheduledAt }
更新 Payslip.publishStatus = 'SCHEDULED', scheduledAt = 指定时间
(定时任务到点后发布)
```
---
## 十一、取消归档
```
POST /batches/:batchId/unarchive
① 只能取消最后一个归档批次
② 批次恢复为 DRAFT
③ 如果还有其他归档批次 → 重新生成工资条(基于剩余批次)
④ 如果没有归档批次了 → 删除该月工资条
```
---
## 十二、完整数据流图
```
┌─────────────────┐
│ Employee 表 │
│ socialInsBase │
│ housingFundBase │
│ monthlySalary │
│ specialDeduction│
└────────┬────────┘
┌────────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────────┐ ┌─────────────────┐
│SocialAccount│ │SpecialDeduction │ │ OvertimeRecord │
│+YearStandard│ │Record (按月) │ │ (加班费) │
│(社保比例) │ └──────────────────┘ └─────────────────┘
│+minWage │ │ │
└──────┬─────┘ │ │
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────┐
│ calcBatchEntry() │
│ │
│ 社保 = 应缴全额 - 已归档批次已扣(差额补扣) │
│ + 上月递延社保(次月补扣) │
│ 应发 = 基本工资 + 各项津贴 + 奖金 - 扣款 │
│ 个税 = 累计预扣法(从已归档批次取累计数据) │
│ 实发 = 应发 - 个人社保 - 个人公积金 - 个税 │
│ - 上月递延最低工资补扣 │
│ 最低工资保护:实发 < minWage 时 → 递延扣款补齐 │
│ 递延社保 → 递延公积金 → 递延最低工资补齐 │
└──────────────────────┬───────────────────────────────┘
┌────────────────┐
│ BatchEntry │
│ (批次条目) │
│ + minWage │
│ + minWageApplied│
│ + deferred* │
│ + prevDeferred*│
└───────┬────────┘
│ 归档
┌────────────────┐ 汇总生成
│ PayrollBatch │ ──────────────→ ┌──────────┐
│ status=ARCHIVED│ │ Payslip │
└────────────────┘ │ (工资条) │
└────┬─────┘
│ 发布
员工端可见
次月创建批次时:
getPrevDeferred() 读取上月 BatchEntry 的 deferred* 字段
→ 传入 calcBatchEntry 的 options.prevDeferred
→ 本月社保/公积金叠加补扣上月递延金额
```
---
## 十三、关键取数表汇总
| 数据项 | 取数表 | 取数条件 | 用途 |
|--------|--------|---------|------|
| 社保比例 | `SocialYearStandard` | accountId + 月份在生效区间 | 计算社保公积金 |
| 社保比例(回退) | `SocialInsuranceConfig` | orgId + city + 月份在生效区间 | 兼容旧数据 |
| 最低工资标准 | `SocialYearStandard.minWage` | accountId + 月份在生效区间 | 最低工资保护 |
| 最低工资标准(回退) | `SocialInsuranceConfig.minWage` | orgId + city + 月份在生效区间 | 兼容旧数据 |
| 社保基数 | `Employee.socialInsBase` | — | 优先于基本工资 |
| 公积金基数 | `Employee.housingFundBase` | — | 优先于基本工资 |
| 已扣社保 | `BatchEntry` | 当月已归档批次 | 差额补扣 |
| 累计收入 | `BatchEntry` | 当年已归档批次 | 个税累计预扣 |
| 累计社保 | `BatchEntry` | 当年已归档批次 | 个税累计预扣 |
| 累计个税 | `BatchEntry` | 当年已归档批次 | 个税累计预扣 |
| 专项附加扣除 | `SpecialDeductionRecord` | 当年至当月 | 个税累计预扣 |
| 专项附加扣除(回退) | `Employee.specialDeduction` | × 月数 | 兼容旧数据 |
| 基本减除费用 | 固定 5000 | × 月数 | 个税累计预扣 |
| 加班费 | `OvertimeRecord` | employeeId + month | 创建批次时自动拉取 |
| 基本工资 | `Payslip`(上月) | employeeId + 上月 | copy_last 模式 |
| 基本工资(回退) | `Employee.monthlySalary` | 解密 | 无上月工资条时 |
| 试用期工资 | `LaborContract.probationSalary` | 最新合同 | 试用期内的 baseSalary |
| 上月递延社保 | `BatchEntry.deferredSocialEmp` | 上月已归档批次 | 次月补扣 |
| 上月递延公积金 | `BatchEntry.deferredHousingEmp` | 上月已归档批次 | 次月补扣 |
| 上月递延最低工资补齐 | `BatchEntry.deferredMinWage` | 上月已归档批次 | 次月补扣 |
---
## 十四、3 步工作流
```
① 编辑薪资(含核对) → ② 归档锁定 → ③ 发布工资条
```
### 步骤 1:编辑薪资(DRAFT 状态)
可操作:
- **点击单元格编辑**:基本工资、加班费、津贴、奖金、扣款、社保个人/单位、公积金个人/单位
- **导入工资表 Excel**:批量填充薪资数据
- **下载导入模板**
- **导入加班费**:从加班记录按月自动填充 `overtimePay`
- **获取提成奖金**:从提成奖金模块按月填充 `bonus`
- **添加/删除人员**:动态调整批次人员
- **查看个税明细**:点击个税金额查看累计预扣计算过程
核对(编辑页面内直接展示,非独立步骤):
- 批次汇总卡片:应发合计、社保合计、公积金合计、个税合计、实发合计
- 质量门禁自动检查:实发为负、全零记录、最低工资、递延扣款等
- 可导出 **薪资汇总表** / **薪资明细表**CSV
质量门禁(草稿状态自动检查):
- 实发为负的员工 → 红色警告
- 实发低于最低工资(未触发保护)→ 红色警告
- 最低工资保护已触发(递延扣款)→ 黄色警告
- 本月补扣上月递延 → 黄色警告
- 全零记录 → 黄色警告
表格可视化提示:
- **实发列**:最低工资保护触发时显示橙色 + ★标记,悬停显示补齐明细
- **递延扣款列**(新增):橙色"递延 ¥X"或蓝色"补扣 ¥X",悬停显示分项明细
- **风险列**:最低工资保护触发时显示橙色警告图标
### 步骤 2:归档锁定(ARCHIVED
**归档时后端自动执行**
1. **重算所有条目**:调用 `calcBatchEntry` 重新计算社保公积金和个税
2. **更新批次汇总**:重算 totalPay/totalNetPay/各项合计
3. **标记为 ARCHIVED**:设置 `archivedAt`,不可再编辑
### 步骤 3:发布工资条
- 调用 `POST /batches/:id/publish` → 更新 `Payslip.publishStatus = PUBLISHED`
- 或调用 `POST /batches/:id/schedule` → 设置 `publishStatus = SCHEDULED` + `scheduledAt`
- 员工在员工端查看已发布的工资条
---
## 十五、最低工资保护与递延扣款机制
### 背景
入职当月工资按天折算后,可能不足以扣除社保/公积金个人部分,导致实发为负。
《劳动法》第四十八条规定用人单位支付劳动者的工资不得低于当地最低工资标准。
### 最低工资标准配置
| 配置位置 | 表 | 字段 | 说明 |
|---------|---|------|------|
| 新账户体系 | `SocialYearStandard` | `minWage` | 按账户+年度设置,优先使用 |
| 旧配置表 | `SocialInsuranceConfig` | `minWage` | 兼容回退,按城市匹配 |
前端配置入口:社保配置页面 → 新建版本 → "最低工资标准"输入框(仅社保 Tab)
设为 0 表示不检查最低工资。
### 保护逻辑(calcBatchEntry 中执行)
```
初始实发 = 应发 - 社保个人 - 公积金个人 - 个税 - 上月递延最低工资补扣
if (minWage > 0 且 实发 < minWage) {
差额 = minWage - 实发
① 优先递延社保个人部分
if (差额 <= 社保个人) {
递延社保 = 差额
社保个人 -= 差额
实发 = minWage
}
② 社保不够,递延全部社保 + 部分公积金
else if (差额 <= 社保个人 + 公积金个人) {
递延社保 = 社保个人
递延公积金 = 差额 - 社保个人
社保个人 = 0
公积金个人 -= 递延公积金
实发 = minWage
}
③ 社保+公积金都不够,差额作为最低工资补齐递延
else {
递延社保 = 社保个人
递延公积金 = 公积金个人
递延最低工资补齐 = 差额 - 社保个人 - 公积金个人
社保个人 = 0
公积金个人 = 0
实发 = minWage
}
}
```
### minWageApplied 的含义
`minWageApplied` = 为让实发达到最低工资而减少的扣款总额,包含三部分:
| 组成 | 字段 | 含义 |
|------|------|------|
| 免扣社保 | `deferredSocialEmp` | 本该扣但没扣的社保个人部分 |
| 免扣公积金 | `deferredHousingEmp` | 本该扣但没扣的公积金个人部分 |
| 额外补齐 | `deferredMinWage` | 社保+公积金全免后仍不足的差额 |
| **合计** | `minWageApplied` | = 免扣社保 + 免扣公积金 + 额外补齐 |
**注意**`minWageApplied` 不是"额外补了这么多现金",而是"减少了这么多扣款"。
### 递延扣款的次月补扣
```
本月(入职当月):
BatchEntry 记录 deferredSocialEmp / deferredHousingEmp / deferredMinWage
实发 = minWage(保护后)
次月(创建批次时):
getPrevDeferred() 读取上月已归档批次的递延金额
calcBatchEntry 的 options.prevDeferred 传入
本月社保个人 = 当月应缴社保 + 上月递延社保
本月实发 = 应发 - (当月社保 + 上月递延社保) - (当月公积金 + 上月递延公积金) - 个税 - 上月递延最低工资补齐
BatchEntry 记录 prevDeferredSocialEmp / prevDeferredHousingEmp / prevDeferredMinWage
```
### 示例
**张三 8 月 20 日入职,月薪 12000,扣款 20000(测试数据)**
```
应发 = 12000 + 0 + 0 + (-500) - 20000 = -8500
社保个人(原)= 1575
公积金个人(原)= 1800
个税 = 0
初始实发 = -8500 - 1575 - 1800 - 0 = -11875
最低工资 = 2420
保护触发:
差额 = 2420 - (-11875) = 14295
递延社保 = 1575(全部免扣)
递延公积金 = 1800(全部免扣)
递延最低工资补齐 = 14295 - 1575 - 1800 = 10920
实发 = 2420
BatchEntry 记录:
minWage = 2420
minWageApplied = 14295
deferredSocialEmp = 1575
deferredHousingEmp = 1800
deferredMinWage = 10920
socialEmp = 0(当月不扣)
housingEmp = 0(当月不扣)
netPay = 2420
次月(9 月):
getPrevDeferred() 读取 → { socialEmp: 1575, housingEmp: 1800, minWage: 10920 }
9 月社保个人 = 当月应缴社保 + 1575
9 月实发 = 应发 - 9月社保(含补扣) - 9月公积金(含补扣) - 个税 - 10920
```
### 前端展示
| 展示位置 | 样式 | 内容 |
|---------|------|------|
| 实发列 | 橙色 + ★ | 触发最低工资保护时,悬停显示补齐明细 |
| 递延扣款列 | 橙色"递延 ¥X" | 当月递延,悬停显示分项(免扣社保/公积金/额外补齐) |
| 递延扣款列 | 蓝色"补扣 ¥X" | 次月补扣上月递延,悬停显示分项 |
| 风险列 | 橙色警告图标 | 最低工资保护触发 |
| 质量门禁 | 黄色提示 | "N 名员工实发已补齐到最低工资标准..." |
### prePayrollCheck 检查项(第 11/12 项)
```
// 11. 实发低于最低工资标准(剔除加班费后比较)
if (minWage > 0 且 可比收入 < minWage 且 实发 < minWage) {
if (minWageApplied > 0) {
→ WARNING: 最低工资保护已触发(递延扣款)
} else {
→ FAIL: 实发低于最低工资标准
}
}
// 12. 上月递延扣款待补扣
if (prevDeferred > 0) {
→ WARNING: 上月递延扣款已补扣
}
```
---
## 十六、状态流转
```
DRAFT(草稿,可编辑)
↓ 归档
ARCHIVED(已归档,锁定不可编辑)
↓ 取消归档
DRAFT(恢复草稿)
↓ 发布工资条
Payslip.publishStatus: PUBLISHED(员工端可见)
```
---
## 十七、批次类型详解
| 类型 | 说明 | 社保公积金 | 个税计算 | 员工来源 |
|------|------|-----------|---------|---------|
| **REGULAR** 常规发薪 | 月度工资 | ✅ 差额补扣 | 累计预扣法 | 在职 + 本月离职 |
| **TERMINATION** 离职结算 | 离职员工当月工资 | ✅ 差额补扣 | 累计预扣法 | 本月离职记录 |
| **BONUS** 年终奖/奖金 | 单独计税 | ❌ 不扣 | 单独计税 | 在职 + 本月离职 |
| **SEVERANCE** 补偿金 | 离职补偿金 | ❌ 不扣 | 累计预扣法(无社保扣除) | 已审批且有补偿金的离职记录 |
---
## 十八、创建批次的 5 种数据初始化模式
| 模式 | 说明 | 员工来源 | 数据来源 |
|------|------|---------|---------|
| **copy_last** 复制上月 | 默认模式 | 在职 + 本月离职 | 上月工资条复制基本工资/津贴/扣款 + 当月加班费 |
| **blank_employees** 本月空白 | 拉入员工 | 在职 + 本月离职 | 所有金额为 0,手动填写 |
| **blank_all** 全空白 | 不拉入员工 | 无 | 后续手动添加人员 |
| **copy_batch** 复制指定批次 | 从源批次 | 源批次的员工 | 复制源批次薪资数据 |
| **custom** 自定义选择 | 按部门/姓名筛选勾选 | 指定员工 | 金额为 0,手动填写 |
+119
View File
@@ -0,0 +1,119 @@
# 社保公积金账户关联根部门 + 员工新增自动带出账户
> 生成时间:2026-08-16
> 涉及模块:设置(账户管理)、花名册(新增员工)、组织架构(根部门关联)
> 核心文件:`backend/src/routes/social.routes.ts`、`backend/src/routes/department.routes.ts`、`backend/src/services/contract.service.ts`、`frontend/src/pages/Settings.tsx`、`frontend/src/pages/roster/modals.tsx`、`frontend/src/lib/api-services.ts`
---
## 一、需求背景
1. 社保公积金账户创建后,需要关联组织架构的根节点(公司/分公司/子公司,即 level=0 的部门)。
2. 员工新增时,选定部门后自动带出该部门所属根节点关联的社保公积金账户,可手工调整,确定后按账户当前生效的年度标准执行。
---
## 二、完成内容
### 1. 账户关联组织架构根节点
#### 前端:设置页 → 社保公积金账户 Tab
- 新建/编辑账户弹窗新增"关联根部门"区域
- 列出所有 level=0 根部门(公司/分公司/子公司),可多选勾选
- 保存账户时同步关联到选中的根部门
- 编辑时自动加载已关联的部门并回显勾选状态
#### 后端新增 API
| 方法 | 路径 | 说明 |
|---|---|---|
| `PUT` | `/social/accounts/:id/departments` | 批量设置账户关联的根部门(先清除旧关联,再批量设置新关联,仅限 level=0) |
| `GET` | `/social/accounts/:id/departments` | 查询账户已关联的根部门列表 |
| `GET` | `/social/department-account/:departmentId` | 按部门带出适用账户 + 当前生效年度标准 |
#### 部门 API
- `PUT /departments/:id` 的 schema 新增 `socialAccountId``housingAccountId` 可选字段
- 更新时写入 `department.socialAccountId` / `department.housingAccountId`
#### 账户关联逻辑
- 账户类型为 `SOCIAL` 时,写入 `department.socialAccountId`
- 账户类型为 `HOUSING` 时,写入 `department.housingAccountId`
- 设置新关联前,先清除该账户的所有旧关联(`updateMany` 置 null
- 仅允许关联 `level=0` 的根部门
---
### 2. 员工新增时自动带出账户
#### 前端:花名册 → 新增员工
- 表单 state 新增 `departmentId` 字段,部门选择改为按 `id` 取值
- 选定部门后,自动调用 `GET /social/department-account/:departmentId` 查询适用账户
- 社保账户、公积金账户下拉框自动选中继承的账户
- 账户下方显示当前生效标准(基数范围、比例)
- 可手动调整账户选择(下拉切换到其他账户)
- 缴费基数默认与月工资一致,可手动修改
- 提交时将 `socialAccountId` / `housingAccountId` 写入参保记录
#### 后端:按部门带出账户逻辑
`GET /social/department-account/:departmentId` 处理流程:
1. 从当前部门向上查找,直到 `level=0` 的根部门
2. 读取根部门的 `socialAccount` / `housingAccount`
3. 若根部门未关联账户,回退到公司默认账户(`isDefault=true`
4. 查询账户当前生效的 `SocialYearStandard``isCurrent=true`
5. 返回 `{ socialAccount, housingAccount, socialStandard, housingStandard }`
#### 后端:参保记录写入 accountId
`contract.service.ts``createEmployee`
- `EmployeeSocialInsRecord.create` 写入 `accountId: data.socialAccountId || null`
- `EmployeeHousingFundRecord.create` 写入 `accountId: data.housingAccountId || null`
---
## 三、数据流
```
设置页:创建账户 → 勾选根部门 → 保存
department.socialAccountId / housingAccountId
新增员工:选择部门 → 向上找根部门 → 读取关联账户
自动带出账户 + 当前标准 → 可手动调整
提交 → EmployeeSocialInsRecord.accountId / EmployeeHousingFundRecord.accountId
薪资计算:通过员工账户查年度标准 → 计算社保公积金
```
---
## 四、使用流程
1.**设置 → 社保公积金账户** 中创建账户,勾选关联的根部门
2. 新增员工时选择部门 → 自动带出对应账户 → 确认基数 → 保存
3. 薪资计算时按员工账户的年度标准执行
---
## 五、兼容性
-`SocialInsuranceConfig` / `HousingFundConfig` 表保留
- 薪资计算优先用新账户年度标准,查不到回退旧表
- 未关联账户的根部门,员工新增时回退到公司默认账户
- RehireModal(重新入职)保持原有逻辑,不自动带出账户(员工已有参保记录)
---
## 六、部署记录
- 提交:`014c94e` feat: 账户关联根部门 + 员工新增自动带出账户
- 部署:2026-08-16 已部署到 https://on.hr8ai.top/
- 数据库:schema 同步完成(Department 表 socialAccountId/housingAccountId 字段已存在)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+338 -7
View File
@@ -20,6 +20,7 @@ enum Role {
ADMIN ADMIN
HR HR
VIEWER VIEWER
SUPPORT
} }
enum EmployeeStatus { enum EmployeeStatus {
@@ -139,6 +140,7 @@ model Organization {
esignTrainingEnabled Boolean @default(false) // 培训记录电子签 esignTrainingEnabled Boolean @default(false) // 培训记录电子签
esignPerformanceEnabled Boolean @default(false) // 绩效考核电子签 esignPerformanceEnabled Boolean @default(false) // 绩效考核电子签
esignDisciplinaryEnabled Boolean @default(false) // 违纪记录电子签 esignDisciplinaryEnabled Boolean @default(false) // 违纪记录电子签
socialInsCutoffDay Int @default(15) // 社保/公积金截止日:每月该日前离职→截止月=离职月-1,该日后→截止月=离职月
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -196,7 +198,18 @@ model Organization {
benefitPlans EmployeeBenefitPlan[] benefitPlans EmployeeBenefitPlan[]
benefitEnrollments EmployeeBenefitEnrollment[] benefitEnrollments EmployeeBenefitEnrollment[]
eSignRecords ESignRecord[] eSignRecords ESignRecord[]
commissionBonuses CommissionBonus[]
medicalPeriodPolicies MedicalPeriodPolicy[] medicalPeriodPolicies MedicalPeriodPolicy[]
socialAccounts SocialAccount[]
socialYearStandards SocialYearStandard[]
// 组织架构 + 审批流
departments Department[]
positions Position[]
approvalFlows ApprovalFlow[]
approvalInstances ApprovalInstance[]
// 客服工作台
tickets Ticket[]
chatSessions ChatSession[]
} }
model User { model User {
@@ -224,11 +237,17 @@ model Employee {
department String department String
position String? // 岗位 position String? // 岗位
hireDate DateTime hireDate DateTime
monthlySalary String // AES-256 加密存储 // 组织架构扩展
departmentId String? // 关联 Department 表(兼容旧 department 字符串)
supervisorId String? // 直属上级员工ID
monthlySalary String // AES-256 加密存储(月薪总额 = 基本工资 + 绩效工资)
baseSalary String? // 基本工资(AES-256 加密,null=旧数据未拆分)
performanceSalary String? // 绩效工资(AES-256 加密,可为"0"null=旧数据未拆分)
status EmployeeStatus @default(ACTIVE) status EmployeeStatus @default(ACTIVE)
gender String? gender String?
phone String? phone String?
idCardNumber String? // AES-256 加密存储 idCardNumber String? // AES-256 加密存储
idType String? // 证件类型:ID_CARD(身份证,默认)/PASSPORT/HK_MACAO_TAIWAN/OTHER
idCardHash String? // SHA-256 哈希,用于按身份证号查询匹配 idCardHash String? // SHA-256 哈希,用于按身份证号查询匹配
emergencyContact String? emergencyContact String?
emergencyPhone String? emergencyPhone String?
@@ -246,6 +265,8 @@ model Employee {
socialInsEndMonth String? // 当前社保截止年月(便捷字段,null=在保) socialInsEndMonth String? // 当前社保截止年月(便捷字段,null=在保)
housingFundStartMonth String? // 当前公积金开始年月(便捷字段) housingFundStartMonth String? // 当前公积金开始年月(便捷字段)
housingFundEndMonth String? // 当前公积金截止年月(便捷字段) housingFundEndMonth String? // 当前公积金截止年月(便捷字段)
supplementaryHousingAccountId String? // 补充公积金账户(可选,高管才有)
supplementaryHousingAccount SocialAccount? @relation("EmpSupplementaryHousingAccount", fields: [supplementaryHousingAccountId], references: [id], onDelete: SetNull)
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报) specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
city String? // 员工社保参保城市 city String? // 员工社保参保城市
birthDate DateTime? // 出生日期(从身份证号提取) birthDate DateTime? // 出生日期(从身份证号提取)
@@ -285,10 +306,35 @@ model Employee {
commercialInsEnrollments CommercialInsuranceEnrollment[] commercialInsEnrollments CommercialInsuranceEnrollment[]
benefitEnrollments EmployeeBenefitEnrollment[] benefitEnrollments EmployeeBenefitEnrollment[]
eSignRecords ESignRecord[] eSignRecords ESignRecord[]
commissionBonuses CommissionBonus[]
// 组织架构关联
dept Department? @relation("DeptEmployees", fields: [departmentId], references: [id])
supervisor Employee? @relation("SupervisorSubordinates", fields: [supervisorId], references: [id])
subordinates Employee[] @relation("SupervisorSubordinates")
approvalInstances ApprovalInstance[]
@@unique([orgId, idCardHash]) @@unique([orgId, idCardHash])
} }
/// 提成奖金记录(按月管理,支持正负值:正=奖金,负=扣款)
model CommissionBonus {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
month String // YYYY-MM
amount Float // 正=奖金,负=扣款
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, employeeId, month])
@@index([orgId, month])
@@index([employeeId])
}
model LaborContract { model LaborContract {
id String @id @default(cuid()) id String @id @default(cuid())
orgId String orgId String
@@ -299,10 +345,13 @@ model LaborContract {
startDate DateTime startDate DateTime
endDate DateTime? endDate DateTime?
contractType ContractType contractType ContractType
contractCategory String? // 合同分类:LABOR_CONTRACT(劳动合同,默认)/LABOR_AGREEMENT(劳务协议)/INTERNSHIP(实习协议)/FLEXIBLE(灵活用工)
signMethod SignMethod @default(PAPER) signMethod SignMethod @default(PAPER)
contractYears Int @default(3) contractYears Int @default(3)
probationMonths Int @default(0) probationMonths Int @default(0)
probationSalary Int @default(0) probationSalary Int @default(0)
baseSalary Int @default(0) // 基本工资(转正后)
performanceSalary Int @default(0) // 绩效工资(转正后,可为0)
renewalCount Int @default(0) renewalCount Int @default(0)
attachmentName String? attachmentName String?
attachmentUrl String? attachmentUrl String?
@@ -418,6 +467,88 @@ model AuditLog {
// ========== 社保 & 通知 & 附件 ========== // ========== 社保 & 通知 & 附件 ==========
/// 社保/公积金账户(实体化管理,替代原 city 字符串)
model SocialAccount {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
type String // SOCIAL=社保账户, HOUSING=公积金账户
name String // 账户名称,如"北京总公司社保账户"
city String // 参保城市
accountNo String? // 社保登记号 / 公积金单位账号
bankName String? // 公积金开户行(公积金专用)
bankAccount String? // 公积金银行账号(公积金专用)
orgName String? // 缴费主体名称(子公司/分公司名称)
orgCode String? // 缴费主体统一社会信用代码
accountType String? // 公积金账户类型 BASIC=基本, SUPPLEMENTARY=补充
isDefault Boolean @default(false)
status String @default("ACTIVE") // ACTIVE=正常, SUSPENDED=停用
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
yearStandards SocialYearStandard[]
socialRecords EmployeeSocialInsRecord[]
housingRecords EmployeeHousingFundRecord[]
monthlyProcesses SocialMonthlyProcess[]
// 部门关联(员工通过部门继承账户)
deptSocialAccounts Department[] @relation("DeptSocialAccount")
deptHousingAccounts Department[] @relation("DeptHousingAccount")
// 员工补充公积金关联
empSupplementaryHousing Employee[] @relation("EmpSupplementaryHousingAccount")
@@unique([orgId, type, name])
@@index([orgId, type, city])
@@index([orgId, type, isDefault])
}
/// 年度标准(替代原 SocialInsuranceConfig / HousingFundConfig
model SocialYearStandard {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
accountId String
account SocialAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
// 社保比例(type=SOCIAL 时使用)
pensionOrg Float @default(16)
pensionEmp Float @default(8)
medicalOrg Float @default(9.8)
medicalEmp Float @default(2)
medicalOrgExtra Float @default(0) // 医疗企业附加(固定金额,如北京大病医疗)
medicalEmpExtra Float @default(0) // 医疗个人附加(固定金额,如北京3元大病医疗)
unemploymentOrg Float @default(0.5)
unemploymentEmp Float @default(0.5)
injuryOrg Float @default(0.2)
maternityOrg Float @default(0.8)
baseMin Float @default(6326)
baseMax Float @default(33891)
medicalBaseMin Float @default(0)
medicalBaseMax Float @default(0)
extraInsurances Json?
// 公积金比例(type=HOUSING 时使用)
housingOrg Float @default(12)
housingEmp Float @default(12)
housingBaseMin Float @default(0) // 公积金基数下限(0 时 fallback 到 baseMin
housingBaseMax Float @default(0) // 公积金基数上限(0 时 fallback 到 baseMax
// 最低工资标准(按参保城市,每年调整)
minWage Float @default(0) // 当地月最低工资标准(0=不检查)
effectiveFrom String // 生效月份 YYYY-MM
effectiveTo String? // 失效月份 YYYY-MMnull=当前有效)
isCurrent Boolean @default(true)
adjustmentDone Boolean @default(false)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([accountId, effectiveFrom])
@@index([accountId, isCurrent])
}
model SocialInsuranceConfig { model SocialInsuranceConfig {
id String @id @default(cuid()) id String @id @default(cuid())
orgId String orgId String
@@ -427,6 +558,8 @@ model SocialInsuranceConfig {
pensionEmp Float @default(8) // 养老保险 个人比例 % pensionEmp Float @default(8) // 养老保险 个人比例 %
medicalOrg Float @default(9.8) // 医疗保险 企业比例 % medicalOrg Float @default(9.8) // 医疗保险 企业比例 %
medicalEmp Float @default(2) // 医疗保险 个人比例 % medicalEmp Float @default(2) // 医疗保险 个人比例 %
medicalOrgExtra Float @default(0) // 医疗企业附加(固定金额)
medicalEmpExtra Float @default(0) // 医疗个人附加(固定金额,如北京3元)
unemploymentOrg Float @default(0.5) // 失业保险 企业比例 % unemploymentOrg Float @default(0.5) // 失业保险 企业比例 %
unemploymentEmp Float @default(0.5) // 失业保险 个人比例 % unemploymentEmp Float @default(0.5) // 失业保险 个人比例 %
injuryOrg Float @default(0.2) // 工伤保险 企业比例 % injuryOrg Float @default(0.2) // 工伤保险 企业比例 %
@@ -436,6 +569,7 @@ model SocialInsuranceConfig {
medicalBaseMin Float @default(0) // 医疗/生育保险基数下限(0 时 fallback 到 baseMin medicalBaseMin Float @default(0) // 医疗/生育保险基数下限(0 时 fallback 到 baseMin
medicalBaseMax Float @default(0) // 医疗/生育保险基数上限(0 时 fallback 到 baseMax medicalBaseMax Float @default(0) // 医疗/生育保险基数上限(0 时 fallback 到 baseMax
extraInsurances Json? // 附加险种配置 JSON: [{ name, orgRate, empRate, baseType: 'pension'|'medical'|'fixed', fixedAmount }] extraInsurances Json? // 附加险种配置 JSON: [{ name, orgRate, empRate, baseType: 'pension'|'medical'|'fixed', fixedAmount }]
minWage Float @default(0) // 当地月最低工资标准(0=不检查)
effectiveFrom String // 生效月份 YYYY-MM effectiveFrom String // 生效月份 YYYY-MM
effectiveTo String? // 失效月份 YYYY-MMnull=当前有效) effectiveTo String? // 失效月份 YYYY-MMnull=当前有效)
isCurrent Boolean @default(true) // 是否当前生效版本 isCurrent Boolean @default(true) // 是否当前生效版本
@@ -583,6 +717,7 @@ model DisciplinaryRecord {
severity String @default("WARNING") // WARNING/SERIOUS/SEVERE severity String @default("WARNING") // WARNING/SERIOUS/SEVERE
action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION
actionDetail String? actionDetail String?
deductionAmount Float @default(0) // 扣款金额(action=DEDUCTION 时填写,用于薪资批次导入)
employeeAck Boolean @default(false) // 员工是否签字确认 employeeAck Boolean @default(false) // 员工是否签字确认
ackDate DateTime? ackDate DateTime?
ackMethod String? // SIGN/ELECTRONIC/REFUSED ackMethod String? // SIGN/ELECTRONIC/REFUSED
@@ -690,6 +825,7 @@ model Payslip {
month String // YYYY-MM month String // YYYY-MM
// 薪酬构成 // 薪酬构成
baseSalary Float @default(0) baseSalary Float @default(0)
performanceSalary Float @default(0) // 绩效工资
overtimePay Float @default(0) overtimePay Float @default(0)
weekdayOvertimePay Float @default(0) weekdayOvertimePay Float @default(0)
weekendOvertimePay Float @default(0) weekendOvertimePay Float @default(0)
@@ -728,7 +864,8 @@ model PayrollBatch {
id String @id @default(cuid()) id String @id @default(cuid())
orgId String orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
month String // YYYY-MM month String // 所属月(计薪月)YYYY-MM,决定调用哪个月的社保标准、个税累计
payMonth String? // 发薪年月 YYYY-MM(实际发放月份,如十一提前发薪时=9月,所属月=10月)。null=与所属月相同
batchNo Int // 批次序号(1, 2, 3... batchNo Int // 批次序号(1, 2, 3...
name String // 批次名称 name String // 批次名称
type PayrollBatchType @default(REGULAR) type PayrollBatchType @default(REGULAR)
@@ -781,9 +918,20 @@ model BatchEntry {
socialOrg Float @default(0) socialOrg Float @default(0)
housingEmp Float @default(0) housingEmp Float @default(0)
housingOrg Float @default(0) housingOrg Float @default(0)
supplementaryHousingEmp Float @default(0) // 补充公积金个人部分
supplementaryHousingOrg Float @default(0) // 补充公积金单位部分
tax Float @default(0) tax Float @default(0)
totalPay Float @default(0) // 应发合计 totalPay Float @default(0) // 应发合计
netPay Float @default(0) // 实发工资 netPay Float @default(0) // 实发工资
// 最低工资保护 + 递延扣款(入职当月工资不足扣社保时,递延到次月补扣)
minWage Float @default(0) // 当月适用的最低工资标准
minWageApplied Float @default(0) // 当月实际补齐到最低工资的金额(0=未补齐)
deferredSocialEmp Float @default(0) // 递延到次月补扣的社保个人部分
deferredHousingEmp Float @default(0) // 递延到次月补扣的公积金个人部分
deferredMinWage Float @default(0) // 递延到次月补扣的最低工资补齐差额
prevDeferredSocialEmp Float @default(0) // 从上月继承的递延社保(本批次已补扣)
prevDeferredHousingEmp Float @default(0) // 从上月继承的递延公积金(本批次已补扣)
prevDeferredMinWage Float @default(0) // 从上月继承的递延最低工资(本批次已补扣)
// 风险提示 // 风险提示
riskWarnings Json? riskWarnings Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -836,7 +984,9 @@ model EmployeeSocialInsRecord {
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
city String @default("北京") // 参保城市 accountId String? // 关联到 SocialAccount(迁移后非空)
account SocialAccount? @relation(fields: [accountId], references: [id])
city String @default("北京") // 参保城市(兼容旧数据,从 account.city 派生)
startMonth String // 开始缴费年月 YYYY-MM startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效) endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数 base Float // 缴费基数
@@ -849,6 +999,7 @@ model EmployeeSocialInsRecord {
@@index([orgId, employeeId]) @@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth]) @@index([employeeId, startMonth, endMonth])
@@index([orgId, city]) @@index([orgId, city])
@@index([accountId])
} }
model EmployeeHousingFundRecord { model EmployeeHousingFundRecord {
@@ -857,7 +1008,9 @@ model EmployeeHousingFundRecord {
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
city String @default("北京") // 参保城市 accountId String? // 关联到 SocialAccount(迁移后非空)
account SocialAccount? @relation(fields: [accountId], references: [id])
city String @default("北京") // 参保城市(兼容旧数据)
startMonth String // 开始缴费年月 YYYY-MM startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效) endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数 base Float // 缴费基数
@@ -869,6 +1022,7 @@ model EmployeeHousingFundRecord {
@@index([orgId, employeeId]) @@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth]) @@index([employeeId, startMonth, endMonth])
@@index([accountId])
} }
/// 月度社保/公积金办理记录(标记某月已办理完成,保存快照) /// 月度社保/公积金办理记录(标记某月已办理完成,保存快照)
@@ -876,6 +1030,8 @@ model SocialMonthlyProcess {
id String @id @default(cuid()) id String @id @default(cuid())
orgId String orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
accountId String? // 关联到账户(迁移后非空)
account SocialAccount? @relation(fields: [accountId], references: [id])
month String // 办理月份 YYYY-MM month String // 办理月份 YYYY-MM
type String // SOCIAL=社保, HOUSING=公积金 type String // SOCIAL=社保, HOUSING=公积金
status String @default("COMPLETED") // COMPLETED=已办理 status String @default("COMPLETED") // COMPLETED=已办理
@@ -887,6 +1043,7 @@ model SocialMonthlyProcess {
@@unique([orgId, month, type]) @@unique([orgId, month, type])
@@index([orgId, month]) @@index([orgId, month])
@@index([accountId, month])
} }
model EmployeeDepartmentRecord { model EmployeeDepartmentRecord {
@@ -897,10 +1054,12 @@ model EmployeeDepartmentRecord {
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
oldDepartment String // 调整前部门 oldDepartment String // 调整前部门
newDepartment String // 调整后部门 newDepartment String // 调整后部门
oldPosition String? // 调整前职务
newPosition String? // 调整后职务
effectiveMonth String // 生效年月 YYYY-MM effectiveMonth String // 生效年月 YYYY-MM
endMonth String? // 失效年月 YYYY-MMnull=至今有效) endMonth String? // 失效年月 YYYY-MMnull=至今有效)
reason String? // 调部门原因 reason String? // 调原因
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门 changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调
createdBy String createdBy String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -1041,6 +1200,7 @@ model AttendanceConfirmation {
weekendHours Float @default(0) weekendHours Float @default(0)
holidayHours Float @default(0) holidayHours Float @default(0)
overtimePay Float @default(0) overtimePay Float @default(0)
deductionAmount Float @default(0) // 考勤扣款金额(迟到/早退/旷工等,手动填写,用于薪资批次导入)
confirmedAt DateTime? confirmedAt DateTime?
confirmedBy String? confirmedBy String?
confirmIp String? confirmIp String?
@@ -1509,6 +1669,7 @@ model ESignRecord {
employeeId String employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
scene String @default("CONTRACT") // CONTRACT | RESIGNATION | POLICY | PAYSLIP | ONBOARDING scene String @default("CONTRACT") // CONTRACT | RESIGNATION | POLICY | PAYSLIP | ONBOARDING
signMethod String @default("ELECTRONIC") // ELECTRONIC=电子签署 | PAPER=线下手签
flowId String? // 易签宝流程ID flowId String? // 易签宝流程ID
documentTitle String // 文件标题 documentTitle String // 文件标题
documentContent String? // 文件内容(HTML/PDF base64 documentContent String? // 文件内容(HTML/PDF base64
@@ -1518,13 +1679,183 @@ model ESignRecord {
initiatedBy String // 发起人(HR用户ID initiatedBy String // 发起人(HR用户ID
completedAt DateTime? completedAt DateTime?
expiredAt DateTime? expiredAt DateTime?
callbackData Json? // 易签宝回调数据 callbackData Json? // 易签宝回调数据 / 线下手签证据
remark String? remark String?
createdBy String createdBy String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// 线下手签专用字段
signedAt DateTime? // 线下签署日期
signedLocation String? // 签署地点
witnessName String? // 见证人姓名
witnessPhone String? // 见证人手机号
scanFileUrls Json? // 线下签署扫描件URL列表 [{name, url}]
@@index([orgId, status]) @@index([orgId, status])
@@index([employeeId]) @@index([employeeId])
@@index([contractId]) @@index([contractId])
} }
// ==================== 组织架构 + 审批流 ====================
/// 部门(树形结构,自引用 parent)
model Department {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
parentId String?
parent Department? @relation("DeptChildren", fields: [parentId], references: [id], onDelete: SetNull)
children Department[] @relation("DeptChildren")
level Int @default(0) // 层级(0=根)
sortOrder Int @default(0) // 同级排序
description String?
// 社保公积金账户关联(员工通过部门继承账户)
socialAccountId String? // 社保账户
socialAccount SocialAccount? @relation("DeptSocialAccount", fields: [socialAccountId], references: [id], onDelete: SetNull)
housingAccountId String? // 公积金账户
housingAccount SocialAccount? @relation("DeptHousingAccount", fields: [housingAccountId], references: [id], onDelete: SetNull)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
employees Employee[] @relation("DeptEmployees")
positions Position[]
@@unique([orgId, name, parentId])
@@index([orgId, parentId])
}
/// 岗位字典
model Position {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
departmentId String?
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
headcount Int @default(0) // 编制人数
level String? // 职级(如 P1/P2/M1
description String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, name, departmentId])
@@index([orgId, departmentId])
}
/// 审批流配置
model ApprovalFlow {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
type String // LEAVE / TERMINATION / SALARY_CHANGE / OTHER
name String
enabled Boolean @default(true)
// 步骤配置 JSON: [{ step: 1, approverType: 'SUPERVISOR' | 'PERSON', approverId?: string, name: string }]
steps Json
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
instances ApprovalInstance[]
@@unique([orgId, type])
@@index([orgId])
}
/// 审批实例
model ApprovalInstance {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
flowId String
flow ApprovalFlow @relation(fields: [flowId], references: [id], onDelete: Cascade)
type String // 冗余 flow.type,便于查询
bizId String? // 关联业务记录ID(如 LeaveRequest.id
bizType String? // 业务类型
status String @default("PENDING") // PENDING / APPROVED / REJECTED / CANCELLED
currentStep Int @default(1)
// 审批记录 JSON: [{ step, approverId, approverName, result, comment, timestamp }]
approvals Json @default("[]")
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([employeeId])
@@index([bizId])
}
// ==================== 客服工作台 ====================
/// 工单
model Ticket {
id String @id @default(cuid())
orgId String // 提交方租户
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
title String
content String
category String? // 分类(如:系统bug/功能咨询/数据问题)
priority String @default("NORMAL") // LOW / NORMAL / HIGH / URGENT
status String @default("OPEN") // OPEN / IN_PROGRESS / RESOLVED / CLOSED
assigneeId String? // 客服处理人 userId
createdBy String // 提交人 userId
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages TicketMessage[]
@@index([orgId, status])
@@index([assigneeId])
}
/// 工单回复
model TicketMessage {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
content String
senderId String
senderRole String // USER / SUPPORT
createdAt DateTime @default(now())
@@index([ticketId])
}
/// 客服-客户会话
model ChatSession {
id String @id @default(cuid())
orgId String // 客户租户
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
supportUserId String // 客服 userId
lastMessage String?
lastMessageAt DateTime?
unreadByUser Int @default(0)
unreadBySupport Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages ChatMessage[]
@@unique([orgId, supportUserId])
@@index([supportUserId])
}
/// 会话消息
model ChatMessage {
id String @id @default(cuid())
sessionId String
session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
content String
senderId String
senderRole String // USER / SUPPORT
read Boolean @default(false)
createdAt DateTime @default(now())
@@index([sessionId])
}
+154
View File
@@ -0,0 +1,154 @@
/**
* 给全部员工对应上正确的社保和公积金账户
* 逻辑:按员工所在城市的账户匹配,回退到默认账户
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
// 1. 查所有账户(按城市分组)
const accounts = await prisma.socialAccount.findMany({
select: { id: true, name: true, type: true, city: true, isDefault: true },
})
console.log('=== 全部账户 ===')
for (const a of accounts) {
console.log(`${a.type} | ${a.name} | ${a.city} | default=${a.isDefault} | ${a.id}`)
}
// 按城市+类型建索引
const socialByCity: Record<string, string> = {}
const housingByCity: Record<string, string> = {}
let defaultSocial: string | null = null
let defaultHousing: string | null = null
for (const a of accounts) {
if (a.type === 'SOCIAL') {
if (a.isDefault) defaultSocial = a.id
if (a.city) socialByCity[a.city] = a.id
} else {
if (a.isDefault) defaultHousing = a.id
if (a.city) housingByCity[a.city] = a.id
}
}
// 2. 查所有部门
const depts = await prisma.department.findMany({
select: { id: true, name: true, level: true, parentId: true, socialAccountId: true, housingAccountId: true },
})
console.log('\n=== 部门账户关联(修改前)===')
for (const d of depts) {
console.log(`${d.name} | level=${d.level} | social=${d.socialAccountId || '无'} | housing=${d.housingAccountId || '无'}`)
}
// 3. 查所有员工及其部门和城市
const employees = await prisma.employee.findMany({
select: { id: true, name: true, department: true, departmentId: true, city: true, dept: true },
})
console.log(`\n=== 员工总数: ${employees.length} ===`)
// 4. 为每个员工找到根部门,检查是否有账户
// 如果根部门没有账户,按员工城市匹配账户,更新根部门
const rootDeptMap = new Map<string, { name: string; city: string; empCount: number }>()
for (const emp of employees) {
// 向上找根部门
let dept: any = emp.dept
if (!dept && emp.departmentId) {
dept = await prisma.department.findUnique({ where: { id: emp.departmentId } })
}
while (dept && dept.level > 0 && dept.parentId) {
dept = await prisma.department.findUnique({ where: { id: dept.parentId } })
}
if (dept) {
const existing = rootDeptMap.get(dept.id)
if (existing) {
existing.empCount++
} else {
rootDeptMap.set(dept.id, { name: dept.name, city: emp.city || '', empCount: 1 })
}
}
}
console.log('\n=== 根部门及员工城市 ===')
for (const [deptId, info] of rootDeptMap) {
const dept = await prisma.department.findUnique({ where: { id: deptId }, select: { socialAccountId: true, housingAccountId: true } })
console.log(`${info.name} | 员工数=${info.empCount} | 员工城市=${info.city || '无'} | social=${dept?.socialAccountId || '无'} | housing=${dept?.housingAccountId || '无'}`)
}
// 5. 为没有账户的根部门按员工城市匹配账户
let updated = 0
for (const [deptId, info] of rootDeptMap) {
const dept = await prisma.department.findUnique({ where: { id: deptId }, select: { socialAccountId: true, housingAccountId: true, name: true } })
let socialId = dept?.socialAccountId || null
let housingId = dept?.housingAccountId || null
// 社保账户
if (!socialId) {
if (info.city && socialByCity[info.city]) {
socialId = socialByCity[info.city]
} else if (defaultSocial) {
socialId = defaultSocial
}
}
// 公积金账户
if (!housingId) {
if (info.city && housingByCity[info.city]) {
housingId = housingByCity[info.city]
} else if (defaultHousing) {
housingId = defaultHousing
}
}
if ((socialId && !dept?.socialAccountId) || (housingId && !dept?.housingAccountId)) {
await prisma.department.update({
where: { id: deptId },
data: {
...(socialId && !dept?.socialAccountId ? { socialAccountId: socialId } : {}),
...(housingId && !dept?.housingAccountId ? { housingAccountId: housingId } : {}),
},
})
console.log(`[更新] 根部门 ${dept?.name} → social=${socialId || '无'} housing=${housingId || '无'}`)
updated++
}
}
console.log(`\n更新了 ${updated} 个根部门的账户关联`)
// 6. 验证:重新查每个员工是否有账户
let noSocial = 0
let noHousing = 0
let hasBoth = 0
for (const emp of employees) {
let dept: any = emp.dept
if (!dept && emp.departmentId) {
dept = await prisma.department.findUnique({ where: { id: emp.departmentId } })
}
while (dept && dept.level > 0 && dept.parentId) {
dept = await prisma.department.findUnique({ where: { id: dept.parentId } })
}
let socialId = dept?.socialAccountId || null
let housingId = dept?.housingAccountId || null
// 回退到默认
if (!socialId) socialId = defaultSocial
if (!housingId) housingId = defaultHousing
if (socialId && housingId) {
hasBoth++
} else {
if (!socialId) noSocial++
if (!housingId) noHousing++
console.log(`[缺失] ${emp.name} | social=${socialId ? '有' : '无'} housing=${housingId ? '有' : '无'} | city=${emp.city || '无'} | dept=${dept?.name || '无'}`)
}
}
console.log(`\n=== 验证结果 ===`)
console.log(`有社保+公积金: ${hasBoth}`)
console.log(`缺社保: ${noSocial}`)
console.log(`缺公积金: ${noHousing}`)
}
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
+10
View File
@@ -0,0 +1,10 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const accounts = await prisma.socialAccount.findMany({ where: { type: 'SOCIAL', city: '北京' }, select: { id: true, name: true } })
for (const a of accounts) {
const std = await prisma.socialYearStandard.findFirst({ where: { accountId: a.id, isCurrent: true }, select: { medicalOrgExtra: true, medicalEmpExtra: true, extraInsurances: true, effectiveFrom: true } })
console.log(a.name + ' | effective=' + std?.effectiveFrom + ' | medicalOrgExtra=' + std?.medicalOrgExtra + ' | medicalEmpExtra=' + std?.medicalEmpExtra + ' | extraInsurances=' + JSON.stringify(std?.extraInsurances))
}
}
main().then(() => process.exit(0))
+36
View File
@@ -0,0 +1,36 @@
import prisma from '../src/lib/prisma'
async function main() {
// 查看已发布的7条工资条明细
const published = await prisma.$queryRaw<any[]>`
SELECT p."employeeId", e.name, e.department,
p."baseSalary", p."overtimePay", p.allowance, p.deduction, p.bonus,
p."totalPay", p."socialEmp", p."housingEmp", p.tax, p."netPay",
p."publishStatus", p.status, p.month
FROM "Payslip" p
JOIN "Employee" e ON p."employeeId" = e.id
WHERE p.month='2026-08' AND p."publishStatus" = 'PUBLISHED'
ORDER BY e.name
`
console.log('=== 已发布工资条(7条)===')
for (const p of published) {
console.log(`${p.name} | ${p.department} | base=${p.baseSalary} total=${p.totalPay} net=${p.netPay} social=${p.socialEmp} housing=${p.housingEmp} tax=${p.tax}`)
}
// 查看未发布的23条
const unpublished = await prisma.$queryRaw<any[]>`
SELECT e.name, e.department,
p."totalPay", p."netPay", p."publishStatus"
FROM "Payslip" p
JOIN "Employee" e ON p."employeeId" = e.id
WHERE p.month='2026-08' AND p."publishStatus" IS NULL
ORDER BY e.name
`
console.log('\n=== 未发布工资条(23条)===')
for (const p of unpublished) {
console.log(`${p.name} | ${p.department} | total=${p.totalPay} net=${p.netPay}`)
}
await prisma.$disconnect()
}
main()
+24
View File
@@ -0,0 +1,24 @@
import prisma from '../src/lib/prisma'
async function main() {
const payslips = await prisma.$queryRaw<any[]>`
SELECT month, status, "publishStatus", count(*)::int as cnt
FROM "Payslip" WHERE month='2026-08'
GROUP BY month, status, "publishStatus"
`
console.log('Payslip 2026-08:', JSON.stringify(payslips, null, 2))
const batches = await prisma.$queryRaw<any[]>`
SELECT id, name, month, status, "batchNo"::text as bn
FROM "PayrollBatch" WHERE month='2026-08' ORDER BY "batchNo"
`
console.log('Batches 2026-08:', JSON.stringify(batches, null, 2))
const totalPayslips = await prisma.$queryRaw<any[]>`
SELECT count(*)::int as cnt FROM "Payslip" WHERE month='2026-08'
`
console.log('Total payslips 2026-08:', JSON.stringify(totalPayslips))
await prisma.$disconnect()
}
main()
+80
View File
@@ -0,0 +1,80 @@
/**
* 检查测试账户是否有关联数据(员工/部门),然后删除无关联的测试账户
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const TEST_IDS = [
'cmsve0ucj000h5kfta5te1qli', // 几何社保账户
'cmsve0uea001x5kftbvf9e6zq', // 北公积金账户
'cmsve0uec001z5kft5sfl4ika', // s公积金账户
'cmsve0ued00215kft32gmeug5', // sh公积金账户
'cmsve0uef00235kftz4clk842', // sha公积金账户
'cmsve0ueg00255kfttwtfeikj', // shan公积金账户
'cmsve0uei00275kftbwdrkf2n', // shang公积金账户
'cmsve0uej00295kftc157ts8b', // 上公积金账户
'cmsve0uek002b5kftnmmsqpfl', // shi公积金账户
'cmsve0uen002d5kftnzstcqa3', // shi'j公积金账户
'cmsve0ueo002f5kftxhh9r5lw', // shi'ji公积金账户
'cmsve0ueq002h5kft2fdu2tlk', // shi'jia公积金账户
'cmsve0uer002j5kftsi37wcuo', // shi'jia'z公积金账户
'cmsve0uet002l5kftev27qy7o', // shi'jia'zh公积金账户
'cmsve0uf5002x5kft16esy662', // 唐山12+6公积金账户
'cmsve0uf9002z5kft3lx7yl1x', // 几何公积金账户
'cmsve0ufb00315kftp9mnovag', // 几何5+5公积金账户
]
async function main() {
console.log('=== 检查关联 ===')
let canDelete: string[] = []
let blocked: string[] = []
for (const id of TEST_IDS) {
const account = await prisma.socialAccount.findUnique({ where: { id }, select: { name: true, city: true, type: true } })
if (!account) {
console.log(`[不存在] ${id}`)
continue
}
const d1 = await prisma.department.count({ where: { socialAccountId: id } })
const d2 = await prisma.department.count({ where: { housingAccountId: id } })
if (d1 + d2 > 0) {
console.log(`[阻止] ${account.type} ${account.name} (${account.city}) — 部门S/H:${d1}/${d2}`)
blocked.push(id)
} else {
console.log(`[可删] ${account.type} ${account.name} (${account.city})`)
canDelete.push(id)
}
}
console.log(`\n可删除 ${canDelete.length} 个,阻止 ${blocked.length}`)
if (canDelete.length === 0) {
console.log('无可删除账户')
return
}
// 删除:先删关联的年度标准、社保记录、公积金记录,再删账户
console.log('\n=== 开始删除 ===')
for (const id of canDelete) {
const account = await prisma.socialAccount.findUnique({ where: { id }, select: { name: true } })
// 删年度标准
const stdDeleted = await prisma.socialYearStandard.deleteMany({ where: { accountId: id } })
// 删员工社保记录
const srDeleted = await prisma.employeeSocialInsRecord.deleteMany({ where: { accountId: id } })
// 删员工公积金记录
const hrDeleted = await prisma.employeeHousingFundRecord.deleteMany({ where: { accountId: id } })
// 删月度处理记录
const mpDeleted = await prisma.socialMonthlyProcess.deleteMany({ where: { accountId: id } })
// 删账户
await prisma.socialAccount.delete({ where: { id } })
console.log(`[已删] ${account?.name} — 标准:${stdDeleted.count} 社保记录:${srDeleted.count} 公积金记录:${hrDeleted.count} 月度:${mpDeleted.count}`)
}
console.log('\n删除完成')
}
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
@@ -0,0 +1,47 @@
/**
* 批量更新公积金账户的 housingBaseMin/housingBaseMax
* 数据来源:各城市2025年度公积金缴存基数上下限
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
// 2025年度各城市公积金基数上下限
const CITY_LIMITS: Record<string, { min: number; max: number }> = {
'北京': { min: 2540, max: 35811 },
'上海': { min: 2690, max: 37302 },
'深圳': { min: 2360, max: 44265 },
'杭州': { min: 2490, max: 40694 },
'石家庄': { min: 2200, max: 26420 },
'天津': { min: 2320, max: 27861 },
'唐山': { min: 2200, max: 26420 }, // 河北省标准
}
async function main() {
const accounts = await prisma.socialAccount.findMany({ where: { type: 'HOUSING' } })
let updated = 0
let skipped = 0
for (const account of accounts) {
const limits = CITY_LIMITS[account.city]
if (!limits) {
console.log(`[跳过] ${account.name} (${account.city}) — 无该城市的公积金上下限数据`)
skipped++
continue
}
// 更新该账户下所有年度标准的 housingBaseMin/housingBaseMax
const result = await prisma.socialYearStandard.updateMany({
where: { accountId: account.id },
data: {
housingBaseMin: limits.min,
housingBaseMax: limits.max,
},
})
console.log(`[更新] ${account.name} (${account.city}) → 下限 ${limits.min} / 上限 ${limits.max},影响 ${result.count} 条标准`)
updated++
}
console.log(`\n完成:更新 ${updated} 个账户,跳过 ${skipped}`)
}
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
+25
View File
@@ -0,0 +1,25 @@
/**
* 更新北京社保账户的大病医疗个人附加(3元/月)
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
// 北京社保账户:医疗个人附加3元(大病医疗)
const beijingSocial = await prisma.socialAccount.findMany({ where: { type: 'SOCIAL', city: '北京' }, select: { id: true, name: true } })
for (const a of beijingSocial) {
const result = await prisma.socialYearStandard.updateMany({
where: { accountId: a.id, isCurrent: true },
data: { medicalEmpExtra: 3 },
})
console.log(`[更新] ${a.name} → medicalEmpExtra=3,影响 ${result.count}`)
}
// 验证
for (const a of beijingSocial) {
const std = await prisma.socialYearStandard.findFirst({ where: { accountId: a.id, isCurrent: true }, select: { medicalEmpExtra: true, effectiveFrom: true } })
console.log(`[验证] ${a.name} | effective=${std?.effectiveFrom} | medicalEmpExtra=${std?.medicalEmpExtra}`)
}
}
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
+99
View File
@@ -0,0 +1,99 @@
/**
* 批量补齐无年度标准的账户:从旧 SocialInsuranceConfig / HousingFundConfig 继承数据
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const accounts = await prisma.socialAccount.findMany()
let created = 0
let skipped = 0
for (const account of accounts) {
// 检查是否已有标准
const existing = await prisma.socialYearStandard.findFirst({
where: { accountId: account.id, isCurrent: true },
})
if (existing) {
skipped++
continue
}
// 查旧配置
let oldConfig: any = null
if (account.type === 'HOUSING') {
oldConfig = await prisma.housingFundConfig.findFirst({
where: { orgId: account.orgId, city: account.city },
orderBy: { effectiveFrom: 'desc' },
})
} else {
oldConfig = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: account.orgId, city: account.city },
orderBy: { effectiveFrom: 'desc' },
})
}
if (!oldConfig) {
console.log(`[跳过] ${account.type} ${account.name} (${account.city}) — 无旧配置可继承`)
continue
}
// 检查是否已有同 effectiveFrom 的标准(含历史标准)
const dupCheck = await prisma.socialYearStandard.findFirst({
where: { accountId: account.id, effectiveFrom: oldConfig.effectiveFrom },
})
if (dupCheck) {
// 把已有的设为 current
await prisma.socialYearStandard.updateMany({
where: { accountId: account.id, effectiveFrom: oldConfig.effectiveFrom },
data: { isCurrent: true },
})
console.log(`[修复] ${account.type} ${account.name} (${account.city}) — 已有标准设为 current`)
created++
continue
}
// 创建年度标准
const std = await prisma.socialYearStandard.create({
data: {
orgId: account.orgId,
accountId: account.id,
// 社保比例
pensionOrg: oldConfig.pensionOrg || 16,
pensionEmp: oldConfig.pensionEmp || 8,
medicalOrg: oldConfig.medicalOrg || 9.8,
medicalEmp: oldConfig.medicalEmp || 2,
medicalOrgExtra: (oldConfig as any).medicalOrgExtra || 0,
medicalEmpExtra: (oldConfig as any).medicalEmpExtra || 0,
unemploymentOrg: oldConfig.unemploymentOrg || 0.5,
unemploymentEmp: oldConfig.unemploymentEmp || 0.5,
injuryOrg: oldConfig.injuryOrg || 0.2,
maternityOrg: oldConfig.maternityOrg || 0.8,
baseMin: oldConfig.baseMin || 6326,
baseMax: oldConfig.baseMax || 33891,
medicalBaseMin: oldConfig.medicalBaseMin || 0,
medicalBaseMax: oldConfig.medicalBaseMax || 0,
extraInsurances: oldConfig.extraInsurances || null,
// 公积金比例
housingOrg: (oldConfig as any).housingOrg || 12,
housingEmp: (oldConfig as any).housingEmp || 12,
housingBaseMin: 0,
housingBaseMax: 0,
// 最低工资
minWage: (oldConfig as any).minWage || 0,
// 生效信息
effectiveFrom: oldConfig.effectiveFrom,
effectiveTo: null,
isCurrent: true,
adjustmentDone: false,
createdBy: account.createdBy,
},
})
console.log(`[创建] ${account.type} ${account.name} (${account.city}) ← ${oldConfig.effectiveFrom}`)
created++
}
console.log(`\n完成:创建 ${created} 个标准,跳过 ${skipped} 个已有标准`)
}
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
+199
View File
@@ -0,0 +1,199 @@
/**
* 社保公积金账户化迁移脚本
* 将旧 SocialInsuranceConfig / HousingFundConfig 迁移到 SocialAccount + SocialYearStandard
* 将员工参保记录的 city 关联到 accountId
*
* 用法: npx tsx scripts/migrate-social-accounts.ts
*/
import prisma from '../src/lib/prisma'
async function main() {
console.log('========== 社保公积金账户化迁移 ==========')
// ========== 1. 迁移社保配置 → 社保账户 + 年度标准 ==========
console.log('[1/6] 迁移社保配置...')
// 按 (orgId, city) 分组创建社保账户
const socialConfigs = await prisma.socialInsuranceConfig.findMany()
const socialAccountMap = new Map<string, string>() // key: orgId:city → accountId
for (const config of socialConfigs) {
const key = `${config.orgId}:${config.city}`
if (socialAccountMap.has(key)) continue
const account = await prisma.socialAccount.create({
data: {
orgId: config.orgId,
type: 'SOCIAL',
name: `${config.city}社保账户`,
city: config.city,
isDefault: true,
status: 'ACTIVE',
createdBy: 'migration',
},
})
socialAccountMap.set(key, account.id)
console.log(` 创建社保账户: ${config.city}${account.id}`)
}
// 迁移每条 Config → YearStandard
for (const config of socialConfigs) {
const key = `${config.orgId}:${config.city}`
const accountId = socialAccountMap.get(key)!
await prisma.socialYearStandard.create({
data: {
orgId: config.orgId,
accountId,
pensionOrg: config.pensionOrg,
pensionEmp: config.pensionEmp,
medicalOrg: config.medicalOrg,
medicalEmp: config.medicalEmp,
unemploymentOrg: config.unemploymentOrg,
unemploymentEmp: config.unemploymentEmp,
injuryOrg: config.injuryOrg,
maternityOrg: config.maternityOrg,
baseMin: config.baseMin,
baseMax: config.baseMax,
medicalBaseMin: config.medicalBaseMin,
medicalBaseMax: config.medicalBaseMax,
extraInsurances: config.extraInsurances as any,
effectiveFrom: config.effectiveFrom,
effectiveTo: config.effectiveTo,
isCurrent: config.isCurrent,
adjustmentDone: config.adjustmentDone,
createdBy: config.createdBy,
},
})
}
console.log(` 迁移 ${socialConfigs.length} 条社保年度标准`)
// ========== 2. 迁移公积金配置 → 公积金账户 + 年度标准 ==========
console.log('[2/6] 迁移公积金配置...')
const housingConfigs = await prisma.housingFundConfig.findMany()
const housingAccountMap = new Map<string, string>() // key: orgId:city:accountType → accountId
for (const config of housingConfigs) {
const accountType = config.accountType || 'BASIC'
const key = `${config.orgId}:${config.city}:${accountType}`
if (housingAccountMap.has(key)) continue
const account = await prisma.socialAccount.create({
data: {
orgId: config.orgId,
type: 'HOUSING',
name: `${config.city}${accountType === 'SUPPLEMENTARY' ? '补充' : ''}公积金账户`,
city: config.city,
accountType,
isDefault: accountType === 'BASIC',
status: 'ACTIVE',
createdBy: 'migration',
},
})
housingAccountMap.set(key, account.id)
console.log(` 创建公积金账户: ${config.city} ${accountType}${account.id}`)
}
for (const config of housingConfigs) {
const accountType = config.accountType || 'BASIC'
const key = `${config.orgId}:${config.city}:${accountType}`
const accountId = housingAccountMap.get(key)!
await prisma.socialYearStandard.create({
data: {
orgId: config.orgId,
accountId,
housingOrg: config.housingOrg,
housingEmp: config.housingEmp,
baseMin: config.baseMin,
baseMax: config.baseMax,
effectiveFrom: config.effectiveFrom,
effectiveTo: config.effectiveTo,
isCurrent: config.isCurrent,
adjustmentDone: config.adjustmentDone,
createdBy: config.createdBy,
},
})
}
console.log(` 迁移 ${housingConfigs.length} 条公积金年度标准`)
// ========== 3. 迁移员工社保参保记录 accountId ==========
console.log('[3/6] 迁移员工社保参保记录 accountId...')
const socialRecords = await prisma.employeeSocialInsRecord.findMany({ where: { accountId: null } })
let socialUpdated = 0
for (const record of socialRecords) {
const key = `${record.orgId}:${record.city}`
const accountId = socialAccountMap.get(key)
if (accountId) {
await prisma.employeeSocialInsRecord.update({ where: { id: record.id }, data: { accountId } })
socialUpdated++
}
}
console.log(` 更新 ${socialUpdated}/${socialRecords.length} 条社保参保记录`)
// ========== 4. 迁移员工公积金参保记录 accountId ==========
console.log('[4/6] 迁移员工公积金参保记录 accountId...')
const housingRecords = await prisma.employeeHousingFundRecord.findMany({ where: { accountId: null } })
let housingUpdated = 0
for (const record of housingRecords) {
// 公积金默认用 BASIC 类型账户
const key = `${record.orgId}:${record.city}:BASIC`
const accountId = housingAccountMap.get(key)
if (accountId) {
await prisma.employeeHousingFundRecord.update({ where: { id: record.id }, data: { accountId } })
housingUpdated++
}
}
console.log(` 更新 ${housingUpdated}/${housingRecords.length} 条公积金参保记录`)
// ========== 5. 迁移月度办理记录 accountId ==========
console.log('[5/6] 迁移月度办理记录 accountId...')
const monthlyProcesses = await prisma.socialMonthlyProcess.findMany({ where: { accountId: null } })
let monthlyUpdated = 0
for (const proc of monthlyProcesses) {
const snapshot = proc.snapshot as any
const city = snapshot?.city || snapshot?.configCity
if (city) {
const accountMap = proc.type === 'SOCIAL' ? socialAccountMap : housingAccountMap
// 社保用 orgId:city,公积金用 orgId:city:BASIC
const key = proc.type === 'SOCIAL' ? `${proc.orgId}:${city}` : `${proc.orgId}:${city}:BASIC`
const accountId = accountMap.get(key)
if (accountId) {
await prisma.socialMonthlyProcess.update({ where: { id: proc.id }, data: { accountId } })
monthlyUpdated++
}
}
}
console.log(` 更新 ${monthlyUpdated}/${monthlyProcesses.length} 条月度办理记录`)
// ========== 6. 验证数据完整性 ==========
console.log('[6/6] 验证数据完整性...')
const accounts = await prisma.socialAccount.count()
const standards = await prisma.socialYearStandard.count()
const socialWithoutAccount = await prisma.employeeSocialInsRecord.count({ where: { accountId: null } })
const housingWithoutAccount = await prisma.employeeHousingFundRecord.count({ where: { accountId: null } })
console.log(` 账户总数: ${accounts}`)
console.log(` 年度标准总数: ${standards}`)
console.log(` 社保参保记录无 accountId: ${socialWithoutAccount}`)
console.log(` 公积金参保记录无 accountId: ${housingWithoutAccount}`)
if (socialWithoutAccount > 0 || housingWithoutAccount > 0) {
console.log(' ⚠️ 部分参保记录未关联账户(可能城市无对应配置),需手动处理')
}
console.log('')
console.log('========== 迁移完成 ==========')
}
main()
.catch((e) => {
console.error('迁移失败:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+19
View File
@@ -1,4 +1,5 @@
import express from 'express' import express from 'express'
import path from 'path'
import cors from 'cors' import cors from 'cors'
import helmet from 'helmet' import helmet from 'helmet'
import morgan from 'morgan' import morgan from 'morgan'
@@ -35,6 +36,11 @@ app.get('/health', (_req, res) => {
res.json({ success: true, data: { status: 'ok', timestamp: new Date().toISOString() } }) res.json({ success: true, data: { status: 'ok', timestamp: new Date().toISOString() } })
}) })
// deploy.sh 验证脚本检查 /api/v1/health
app.get('/api/v1/health', (_req, res) => {
res.json({ success: true, data: { status: 'ok', timestamp: new Date().toISOString() } })
})
app.use('/api/v1', apiLimiter) app.use('/api/v1', apiLimiter)
// 路由挂载 // 路由挂载
@@ -61,6 +67,10 @@ import auditRoutes from './routes/audit.routes'
import calendarRoutes from './routes/calendar.routes' import calendarRoutes from './routes/calendar.routes'
import platformRoutes from './routes/platform.routes' import platformRoutes from './routes/platform.routes'
import workProcessRoutes from './routes/work-process.routes' import workProcessRoutes from './routes/work-process.routes'
import departmentRoutes from './routes/department.routes'
import positionRoutes from './routes/position.routes'
import approvalRoutes from './routes/approval.routes'
import supportRoutes from './routes/support.routes'
import enterpriseTemplateRoutes from './routes/enterprise-template.routes' import enterpriseTemplateRoutes from './routes/enterprise-template.routes'
import specialStatusRoutes from './routes/special-status.routes' import specialStatusRoutes from './routes/special-status.routes'
import companyFileRoutes from './routes/company-file.routes' import companyFileRoutes from './routes/company-file.routes'
@@ -70,6 +80,7 @@ import salaryRoutes from './routes/salary.routes'
import commercialInsuranceRoutes from './routes/commercial-insurance.routes' import commercialInsuranceRoutes from './routes/commercial-insurance.routes'
import benefitRoutes from './routes/benefit.routes' import benefitRoutes from './routes/benefit.routes'
import esignRoutes from './routes/esign.routes' import esignRoutes from './routes/esign.routes'
import commissionBonusRoutes from './routes/commission-bonus.routes'
app.use('/api/v1/auth', authRoutes) app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes) app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes) app.use('/api/v1/employees', employeeRoutes)
@@ -93,6 +104,10 @@ app.use('/api/v1/audit', auditRoutes)
app.use('/api/v1/calendar', calendarRoutes) app.use('/api/v1/calendar', calendarRoutes)
app.use('/api/v1/platform', platformRoutes) app.use('/api/v1/platform', platformRoutes)
app.use('/api/v1/work-processes', workProcessRoutes) app.use('/api/v1/work-processes', workProcessRoutes)
app.use('/api/v1/departments', departmentRoutes)
app.use('/api/v1/positions', positionRoutes)
app.use('/api/v1/approvals', approvalRoutes)
app.use('/api/v1/support', supportRoutes)
app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes) app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes)
app.use('/api/v1/special-statuses', specialStatusRoutes) app.use('/api/v1/special-statuses', specialStatusRoutes)
app.use('/api/v1/company-files', companyFileRoutes) app.use('/api/v1/company-files', companyFileRoutes)
@@ -102,6 +117,10 @@ app.use('/api/v1/salary', salaryRoutes)
app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes) app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes)
app.use('/api/v1/benefits', benefitRoutes) app.use('/api/v1/benefits', benefitRoutes)
app.use('/api/v1/esign', esignRoutes) app.use('/api/v1/esign', esignRoutes)
app.use('/api/v1/commission-bonus', commissionBonusRoutes)
// 静态文件服务:上传的文件(入职文件、工会回执等)
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')))
app.use(errorHandler) app.use(errorHandler)
+24 -5
View File
@@ -77,6 +77,23 @@ function daysFromNow(date: Date): number {
return Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) return Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
} }
/**
* 脱敏身份证号:保留前6位和后4位,中间用 * 代替
* 例:310101199001011234 → 310101********1234
*/
function maskIdCard(encrypted: string | null): string {
if (!encrypted) return '未登记'
try {
const plain = decrypt(encrypted)
if (plain && plain.length >= 10) {
return plain.slice(0, 6) + '********' + plain.slice(-4)
}
return '未登记'
} catch {
return '未登记'
}
}
async function buildOrgContext(orgId: string): Promise<string> { async function buildOrgContext(orgId: string): Promise<string> {
const [employees, risks] = await Promise.all([ const [employees, risks] = await Promise.all([
prisma.employee.findMany({ prisma.employee.findMany({
@@ -98,7 +115,7 @@ async function buildOrgContext(orgId: string): Promise<string> {
if (e.isPregnant) specialStatus.push('孕期/哺乳期') if (e.isPregnant) specialStatus.push('孕期/哺乳期')
if (e.isInMedicalPeriod) specialStatus.push('医疗期') if (e.isInMedicalPeriod) specialStatus.push('医疗期')
if (e.isWorkInjured) specialStatus.push('工伤') if (e.isWorkInjured) specialStatus.push('工伤')
return `- ${e.name}${e.department}),入职${e.hireDate.toISOString().slice(0, 10)}(已工作${tenure}),${contract ? `合同:${contract.contractType}${contract.endDate ? `到期${contract.endDate.toISOString().slice(0, 10)}(剩余${daysToExpire}天)` : '无固定期限'}` : '未签合同'}${specialStatus.length > 0 ? `,特殊状态:${specialStatus.join('/')}` : ''}` return `- ${e.name}身份证:${maskIdCard(e.idCardNumber)}${e.department}),入职${e.hireDate.toISOString().slice(0, 10)}(已工作${tenure}),${contract ? `合同:${contract.contractType}${contract.endDate ? `到期${contract.endDate.toISOString().slice(0, 10)}(剩余${daysToExpire}天)` : '无固定期限'}` : '未签合同'}${specialStatus.length > 0 ? `,特殊状态:${specialStatus.join('/')}` : ''}`
}).join('\n') }).join('\n')
const riskSummary = risks.map((r) => `- ${r.title}${r.level}):${r.description || '无详细描述'}`).join('\n') const riskSummary = risks.map((r) => `- ${r.title}${r.level}):${r.description || '无详细描述'}`).join('\n')
@@ -248,16 +265,17 @@ router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
orgContext = `当前日期:${new Date().toISOString().slice(0, 10)} orgContext = `当前日期:${new Date().toISOString().slice(0, 10)}
员工详情: 员工详情:
- 姓名:${emp.name} - 姓名:${emp.name}
- 身份证号:${maskIdCard(emp.idCardNumber)}
- 部门:${emp.department} - 部门:${emp.department}
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure} - 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure}
- 状态:${emp.status} - 状态:${emp.status}
- 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'} - 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'}
- 合同:${contract ? `${contract.contractType}${contract.startDate.toISOString().slice(0, 10)}${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'}${term ? `\n- 离职/解聘记录:${typeMap[term.type] || term.type},原因:${reasonMap[term.reason] || term.reason},离职日期:${term.terminationDate.toISOString().slice(0, 10)},流程状态:${statusMap[term.status] || term.status},经济补偿金:¥${term.compensation.toFixed(2)}${term.resignationReason ? `,离职原因说明:${term.resignationReason}` : ''}${socialInsEnd}${housingFundEnd}${handoverDone ? '工作交接已完成' : '工作交接未完成'}${agreementSigned ? '已签署解除协议' : '未签署解除协议'}` : ''}` - 合同:${contract ? `${contract.contractType}${contract.startDate.toISOString().slice(0, 10)}${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'}${term ? `\n- 离职/解聘记录:${typeMap[term.type] || term.type},原因:${reasonMap[term.reason] || term.reason},离职日期:${term.terminationDate.toISOString().slice(0, 10)},流程状态:${statusMap[term.status] || term.status},经济补偿金:¥${term.compensation.toFixed(2)}${term.resignationReason ? `,离职原因说明:${term.resignationReason}` : ''}${socialInsEnd}${housingFundEnd}${handoverDone ? '工作交接已完成' : '工作交接未完成'}${agreementSigned ? '已签署解除协议' : '未签署解除协议'}` : ''}`
scopeHint = `请只针对员工【${emp.name}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。` scopeHint = `请只针对员工【${emp.name}(身份证:${maskIdCard(emp.idCardNumber)}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。`
} }
} else if (department) { } else if (department) {
const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } }) const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)}${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n') const empSummary = employees.map(e => `- ${e.name}(身份证:${maskIdCard(e.idCardNumber)},入职${e.hireDate.toISOString().slice(0, 10)}${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}` orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}`
scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。` scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。`
} }
@@ -306,16 +324,17 @@ router.get('/predict-stream', authMiddleware, async (req: AuthRequest, res, next
orgContext = `当前日期:${new Date().toISOString().slice(0, 10)} orgContext = `当前日期:${new Date().toISOString().slice(0, 10)}
员工详情: 员工详情:
- 姓名:${emp.name} - 姓名:${emp.name}
- 身份证号:${maskIdCard(emp.idCardNumber)}
- 部门:${emp.department} - 部门:${emp.department}
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure} - 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure}
- 状态:${emp.status} - 状态:${emp.status}
- 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'} - 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'}
- 合同:${contract ? `${contract.contractType}${contract.startDate.toISOString().slice(0, 10)}${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'}${term ? `\n- 离职/解聘记录:${typeMap[term.type] || term.type},原因:${reasonMap[term.reason] || term.reason},离职日期:${term.terminationDate.toISOString().slice(0, 10)},流程状态:${statusMap[term.status] || term.status},经济补偿金:¥${term.compensation.toFixed(2)}${term.resignationReason ? `,离职原因说明:${term.resignationReason}` : ''}${socialInsEnd}${housingFundEnd}${handoverDone ? '工作交接已完成' : '工作交接未完成'}${agreementSigned ? '已签署解除协议' : '未签署解除协议'}` : ''}` - 合同:${contract ? `${contract.contractType}${contract.startDate.toISOString().slice(0, 10)}${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'}${term ? `\n- 离职/解聘记录:${typeMap[term.type] || term.type},原因:${reasonMap[term.reason] || term.reason},离职日期:${term.terminationDate.toISOString().slice(0, 10)},流程状态:${statusMap[term.status] || term.status},经济补偿金:¥${term.compensation.toFixed(2)}${term.resignationReason ? `,离职原因说明:${term.resignationReason}` : ''}${socialInsEnd}${housingFundEnd}${handoverDone ? '工作交接已完成' : '工作交接未完成'}${agreementSigned ? '已签署解除协议' : '未签署解除协议'}` : ''}`
scopeHint = `请只针对员工【${emp.name}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。` scopeHint = `请只针对员工【${emp.name}(身份证:${maskIdCard(emp.idCardNumber)}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。`
} }
} else if (department) { } else if (department) {
const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } }) const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)}${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n') const empSummary = employees.map(e => `- ${e.name}(身份证:${maskIdCard(e.idCardNumber)},入职${e.hireDate.toISOString().slice(0, 10)}${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}` orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}`
scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。` scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。`
} }
+107
View File
@@ -0,0 +1,107 @@
/**
* 审批流路由
* 提供审批流配置和实例管理
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
import { processApproval, cancelApproval } from '../services/approval.service'
const router = Router()
const flowSchema = z.object({
type: z.string(), // LEAVE / TERMINATION / SALARY_CHANGE / OTHER
name: z.string().min(1),
enabled: z.boolean().default(true),
steps: z.array(z.object({
step: z.number().int().min(1).max(3),
approverType: z.enum(['SUPERVISOR', 'DEPT_HEAD', 'PERSON']),
approverId: z.string().optional(),
name: z.string(),
})).min(1, '至少一个审批步骤').max(3, '最多三个审批步骤'),
})
/** 获取审批流配置列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const flows = await prisma.approvalFlow.findMany({
where: { orgId: req.user!.orgId! },
orderBy: { createdAt: 'asc' },
})
res.json({ success: true, data: flows })
} catch (err) {
next(err)
}
})
/** 创建/更新审批流配置(upsert by type */
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = flowSchema.parse(req.body)
const existing = await prisma.approvalFlow.findFirst({
where: { orgId: req.user!.orgId!, type: data.type },
})
let flow
if (existing) {
flow = await prisma.approvalFlow.update({
where: { id: existing.id },
data: { ...data, createdBy: req.user!.id },
})
} else {
flow = await prisma.approvalFlow.create({
data: {
...data,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
}
res.json({ success: true, data: flow })
} catch (err) {
next(err)
}
})
/** 获取待我审批的实例 */
router.get('/pending', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const instances = await prisma.approvalInstance.findMany({
where: { orgId: req.user!.orgId!, status: 'PENDING' },
orderBy: { createdAt: 'desc' },
include: { employee: { select: { id: true, name: true, department: true } } },
})
res.json({ success: true, data: instances })
} catch (err) {
next(err)
}
})
/** 处理审批 */
router.post('/:id/process', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const { result, comment } = req.body as { result: 'APPROVED' | 'REJECTED'; comment?: string }
if (!result || !['APPROVED', 'REJECTED'].includes(result)) {
throw { code: 'VALIDATION_ERROR', message: 'result 必须为 APPROVED 或 REJECTED' }
}
const approverName = req.user!.id || '审批人'
const outcome = await processApproval(req.user!.orgId!, id, req.user!.id, approverName, result, comment)
res.json({ success: true, data: outcome })
} catch (err) {
next(err)
}
})
/** 取消审批 */
router.post('/:id/cancel', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
await cancelApproval(req.user!.orgId!, id)
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+17
View File
@@ -67,6 +67,7 @@ router.post('/batch', authMiddleware, async (req: AuthRequest, res: Response, ne
weekendHours: z.number().min(0), weekendHours: z.number().min(0),
holidayHours: z.number().min(0), holidayHours: z.number().min(0),
overtimePay: z.number().min(0), overtimePay: z.number().min(0),
deductionAmount: z.number().min(0).optional(),
})), })),
}) })
const { month, items } = schema.parse(req.body) const { month, items } = schema.parse(req.body)
@@ -126,6 +127,22 @@ router.post('/batch-confirm', authMiddleware, async (req: AuthRequest, res: Resp
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
/** 更新考勤确认的扣款金额 */
router.put('/confirmations/:id/deduction', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { deductionAmount } = req.body
const record = await prisma.attendanceConfirmation.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '考勤确认记录不存在' } })
const updated = await prisma.attendanceConfirmation.update({
where: { id: req.params.id },
data: { deductionAmount: Number(deductionAmount) || 0 },
})
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
// ========== 班次管理 ========== // ========== 班次管理 ==========
router.get('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { router.get('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
@@ -0,0 +1,140 @@
/**
* 提成奖金路由
* 管理按月提成奖金/扣款的 CRUD、批量导入、模板下载
*/
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import multer from 'multer'
import * as XLSX from 'xlsx'
import {
listByMonth,
summaryByMonth,
create,
update,
remove,
batchImport,
} from '../services/commission-bonus.service'
import { auditLog } from '../middleware/auditLog'
const router = Router()
router.use(authMiddleware)
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } })
/**
* 按月查询提成奖金列表
* GET /commission-bonus?month=YYYY-MM
*/
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const [records, summary] = await Promise.all([
listByMonth(orgId, month),
summaryByMonth(orgId, month),
])
res.json({ success: true, data: { records, summary, month } })
} catch (err) { next(err) }
})
/**
* 新增单条提成奖金
* POST /commission-bonus body: { employeeId, month, amount, remark? }
*/
router.post('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { employeeId, month, amount, remark } = req.body
if (!employeeId || !month || amount === undefined) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId/month/amount' } })
}
const record = await create(orgId, req.user!.id, { employeeId, month, amount: Number(amount), remark })
await auditLog(req, 'CREATE', 'COMMISSION_BONUS', record.id, { employeeId, month, amount })
res.json({ success: true, data: record })
} catch (err: any) {
if (err.code === 'NOT_FOUND') return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
next(err)
}
})
/**
* 更新单条
* PUT /commission-bonus/:id body: { amount?, remark? }
*/
router.put('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { amount, remark } = req.body
const record = await update(orgId, req.params.id, { amount: amount !== undefined ? Number(amount) : undefined, remark })
await auditLog(req, 'UPDATE', 'COMMISSION_BONUS', req.params.id, { amount, remark })
res.json({ success: true, data: record })
} catch (err: any) {
if (err.code === 'NOT_FOUND') return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
next(err)
}
})
/**
* 删除单条
* DELETE /commission-bonus/:id
*/
router.delete('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
await remove(orgId, req.params.id)
await auditLog(req, 'DELETE', 'COMMISSION_BONUS', req.params.id)
res.json({ success: true, data: { message: '已删除' } })
} catch (err: any) {
if (err.code === 'NOT_FOUND') return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
next(err)
}
})
/**
* 批量导入 Excel
* POST /commission-bonus/import multipart: file, month
* Excel 列:员工姓名 | 证件号码 | 金额 | 备注
*/
router.post('/import', upload.single('file'), async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const month = req.body.month || new Date().toISOString().slice(0, 7)
if (!req.file) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传 Excel 文件' } })
}
const wb = XLSX.read(req.file.buffer, { type: 'buffer' })
const ws = wb.Sheets[wb.SheetNames[0]]
const rows: any[] = XLSX.utils.sheet_to_json(ws, { defval: '' })
const parsed = rows.map((r) => ({
employeeName: String(r['员工姓名'] || r['姓名'] || '').trim() || undefined,
idCardNumber: String(r['证件号码'] || r['身份证号'] || '').trim() || undefined,
amount: parseFloat(r['金额'] || r['提成奖金'] || '0') || 0,
remark: String(r['备注'] || '').trim() || undefined,
})).filter((r) => r.employeeName || r.idCardNumber)
const result = await batchImport(orgId, req.user!.id, month, parsed)
await auditLog(req, 'IMPORT', 'COMMISSION_BONUS', undefined, { month, ...result })
res.json({ success: true, data: result })
} catch (err) { next(err) }
})
/**
* 下载导入模板
* GET /commission-bonus/template
*/
router.get('/template', (req: AuthRequest, res: Response) => {
const ws = XLSX.utils.aoa_to_sheet([
['员工姓名', '证件号码', '金额', '备注'],
['张三', '110101199001011234', '5000', '销售提成'],
['李四', '', '-200', '迟到扣款'],
])
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '提成奖金模板')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="commission-bonus-template.xlsx"')
res.send(buf)
})
export default router
+142
View File
@@ -0,0 +1,142 @@
/**
* 部门管理路由
* 提供部门的增删改查(树形结构)
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
const createDeptSchema = z.object({
name: z.string().min(1, '部门名称必填'),
parentId: z.string().nullable().optional(),
sortOrder: z.number().int().default(0),
description: z.string().max(200).optional(),
})
const updateDeptSchema = createDeptSchema.partial().extend({
socialAccountId: z.string().nullable().optional(),
housingAccountId: z.string().nullable().optional(),
})
/** 获取部门树 */
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
// 兼容历史组织:若无任何部门,自动创建根部门(公司名)
const existing = await prisma.department.findFirst({ where: { orgId: req.user!.orgId! } })
if (!existing) {
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId! } })
if (org) {
await prisma.department.create({
data: {
orgId: org.id,
name: org.name,
parentId: null,
level: 0,
sortOrder: 0,
description: '组织根节点',
createdBy: req.user!.id,
},
})
}
}
const departments = await prisma.department.findMany({
where: { orgId: req.user!.orgId! },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
include: { _count: { select: { employees: true, positions: true } } },
})
res.json({ success: true, data: departments })
} catch (err) {
next(err)
}
})
/** 创建部门 */
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createDeptSchema.parse(req.body)
// 空字符串视为无父部门,统一转为 null
let parentId = data.parentId && data.parentId.trim() !== '' ? data.parentId : null
// 若未指定父部门,默认挂到根部门(level=0)下
if (!parentId) {
const root = await prisma.department.findFirst({ where: { orgId: req.user!.orgId!, parentId: null }, orderBy: { sortOrder: 'asc' } })
if (root) parentId = root.id
}
let level = 0
if (parentId) {
const parent = await prisma.department.findFirst({ where: { id: parentId, orgId: req.user!.orgId! } })
if (!parent) throw { code: 'NOT_FOUND', message: '父部门不存在' }
level = parent.level + 1
}
const dept = await prisma.department.create({
data: {
name: data.name,
parentId,
level,
sortOrder: data.sortOrder,
description: data.description,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: dept })
} catch (err) {
next(err)
}
})
/** 更新部门 */
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const data = updateDeptSchema.parse(req.body)
// 空字符串视为无父部门,统一转为 null
const parentId = data.parentId !== undefined && data.parentId !== null && data.parentId.trim() !== '' ? data.parentId : data.parentId === undefined ? undefined : null
// 防止循环引用
if (parentId === id) throw { code: 'VALIDATION_ERROR', message: '不能将自身设为父部门' }
let level: number | undefined
if (parentId) {
const parent = await prisma.department.findFirst({ where: { id: parentId, orgId: req.user!.orgId! } })
if (!parent) throw { code: 'NOT_FOUND', message: '父部门不存在' }
level = parent.level + 1
} else if (parentId === null) {
level = 0
}
const dept = await prisma.department.update({
where: { id },
data: {
...(data.name !== undefined ? { name: data.name } : {}),
...(parentId !== undefined ? { parentId } : {}),
...(data.sortOrder !== undefined ? { sortOrder: data.sortOrder } : {}),
...(data.description !== undefined ? { description: data.description } : {}),
...(level !== undefined ? { level } : {}),
...(data.socialAccountId !== undefined ? { socialAccountId: data.socialAccountId } : {}),
...(data.housingAccountId !== undefined ? { housingAccountId: data.housingAccountId } : {}),
},
})
res.json({ success: true, data: dept })
} catch (err) {
next(err)
}
})
/** 删除部门 */
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
// 检查是否有子部门
const children = await prisma.department.findFirst({ where: { parentId: id, orgId: req.user!.orgId! } })
if (children) throw { code: 'VALIDATION_ERROR', message: '请先删除子部门' }
// 检查是否有关联员工
const employees = await prisma.employee.findFirst({ where: { departmentId: id, orgId: req.user!.orgId! } })
if (employees) throw { code: 'VALIDATION_ERROR', message: '该部门下仍有员工,无法删除' }
await prisma.department.delete({ where: { id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+50 -1
View File
@@ -1,9 +1,11 @@
import { Router } from 'express' import { Router } from 'express'
import bcrypt from 'bcryptjs'
import { authMiddleware, AuthRequest } from '../middleware/auth' import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog' import { auditLog } from '../middleware/auditLog'
import { createEvidence } from '../services/evidence.service' import { createEvidence } from '../services/evidence.service'
import { autoCreateEsignRecord } from '../services/esign.service'
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
import { sha256 } from '../lib/crypto' import { sha256, decrypt } from '../lib/crypto'
import { import {
createEmployeeSchema, createEmployeeSchema,
updateEmployeeSchema, updateEmployeeSchema,
@@ -116,6 +118,27 @@ router.get('/check-id-card', authMiddleware, async (req: AuthRequest, res, next)
} }
}) })
// 手机号查重
router.get('/check-phone', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const phone = req.query.phone as string
if (!phone || phone.length < 11) {
return res.json({ success: true, data: { exists: false } })
}
// 手机号加密存储,需遍历匹配(量小可接受)
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId },
select: { id: true, name: true, department: true, status: true, phone: true },
})
const matched = employees.find(e => {
try { return e.phone ? decrypt(e.phone) === phone : false } catch { return false }
})
res.json({ success: true, data: { exists: !!matched, employee: matched ? { id: matched.id, name: matched.name, department: matched.department, status: matched.status } : undefined } })
} catch (err) {
next(err)
}
})
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => { router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try { try {
const data = createEmployeeSchema.parse(req.body) const data = createEmployeeSchema.parse(req.body)
@@ -173,6 +196,30 @@ router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
} }
}) })
/**
* 管理员重置员工密码(重置为手机号后6位)
* POST /employees/:id/reset-password
*/
router.post('/:id/reset-password', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
select: { id: true, phone: true, name: true },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
// 重置为手机号后6位,无手机号则用 123456
const defaultPassword = employee.phone ? employee.phone.slice(-6) : '123456'
const passwordHash = await bcrypt.hash(defaultPassword, 10)
await prisma.employee.update({ where: { id: employee.id }, data: { passwordHash } })
await auditLog(req, 'RESET_PASSWORD', 'EMPLOYEE', employee.id, { employeeName: employee.name })
res.json({ success: true, data: { message: `密码已重置为手机号后6位:${defaultPassword}` } })
} catch (err) {
next(err)
}
})
// 批量续签合规预检 // 批量续签合规预检
router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest, res, next) => { router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest, res, next) => {
try { try {
@@ -289,6 +336,8 @@ router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) =>
events: [{ action: '合同签订', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }], events: [{ action: '合同签订', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id, createdBy: req.user!.id,
}).catch(() => {}) }).catch(() => {})
// 注意:电子签署记录由前端根据 signMethod 决定是否创建,避免重复
res.json({ success: true, data: result }) res.json({ success: true, data: result })
} catch (err) { } catch (err) {
next(err) next(err)
+607 -42
View File
@@ -1,11 +1,49 @@
/**
* 电子签署路由 — 管理端
*
* 完整签署流程:
* 1. HR 发起签署(校验组织电子签开关 + 自动渲染模板文件内容 + 创建证据链)
* 2. 员工在 portal 端查看待签文件 → 发送验证码 → 验证码确认签署
* 3. 签署完成 → 回写合同 + 追加证据链 + 生成签署确认文件
* 4. 易签宝回调(对接后启用):更新状态 + 回写PDF
*
* 证据链事件:发起签署 / 验证码发送 / 签署完成 / 取消签署
*/
import { Router, Response, NextFunction } from 'express' import { Router, Response, NextFunction } from 'express'
import { z } from 'zod' import { z } from 'zod'
import jwt from 'jsonwebtoken'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth' import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { createEvidence, appendEvidence } from '../services/evidence.service'
import { renderTemplate, getTemplateById } from '../services/template.service'
const router = Router() const router = Router()
router.use(authMiddleware) router.use(authMiddleware)
const AUTO_LOGIN_SECRET = process.env.JWT_SECRET || 'dev-secret'
/** 场景与组织电子签开关的映射 */
const SCENE_ORG_FLAG_MAP: Record<string, string | null> = {
CONTRACT: null, // 合同签署不需要额外开关(默认允许)
RESIGNATION: null, // 离职协议不需要额外开关
POLICY: 'esignPolicyEnabled',
PAYSLIP: 'esignPayslipEnabled',
ONBOARDING: 'esignOnboardingEnabled',
}
/** 场景与模板ID的映射(自动渲染文件内容) */
const SCENE_TEMPLATE_MAP: Record<string, string | null> = {
CONTRACT: 'tpl_fixed_term_contract',
RESIGNATION: 'tpl_termination_agreement',
POLICY: null,
PAYSLIP: null,
ONBOARDING: null,
}
const createSignSchema = z.object({ const createSignSchema = z.object({
contractId: z.string().optional(), contractId: z.string().optional(),
employeeId: z.string().min(1), employeeId: z.string().min(1),
@@ -13,9 +51,13 @@ const createSignSchema = z.object({
documentContent: z.string().optional(), documentContent: z.string().optional(),
remark: z.string().optional(), remark: z.string().optional(),
scene: z.string().optional(), scene: z.string().optional(),
templateId: z.string().optional(), // 可指定模板,不传则按 scene 自动匹配
templateVars: z.record(z.string()).optional(), // 模板变量
}) })
// 签署记录列表 /**
* 签署记录列表
*/
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => { router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const status = req.query.status as string | undefined const status = req.query.status as string | undefined
@@ -35,53 +77,353 @@ router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
// 发起签署 /**
// TODO: 对接易签宝 API — 此处为框架预留 * 待签合同列表(按员工聚合)
// 1. 调用易签宝创建签署流程 * GET /esign/pending
// 2. 获取签署链接 *
// 3. 保存 flowId 和 signUrl * 汇总两类未签署记录,按员工聚合:
* 1. EsignRecord 中 status 为 PENDING/SIGNING 的(所有场景)
* 2. LaborContract 中 signDate 为空且员工在职的(纸质合同未登记签署日期)
*
* 返回格式:[{ employeeId, name, department, phone, pendingItems: [{ type, title, scene, signMethod, status, createdAt, recordId, contractId }] }]
*/
router.get('/pending', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
// 1. 查询未完成的 EsignRecord
const pendingEsign = await prisma.eSignRecord.findMany({
where: {
orgId,
status: { in: ['PENDING', 'SIGNING'] },
},
include: {
employee: { select: { id: true, name: true, department: true, phone: true, status: true } },
},
orderBy: { createdAt: 'desc' },
})
// 2. 查询 signDate 为空的 LaborContract(且员工在职)
const pendingContracts = await prisma.laborContract.findMany({
where: {
orgId,
signDate: null,
employee: { status: 'ACTIVE' },
},
include: {
employee: { select: { id: true, name: true, department: true, phone: true, status: true } },
},
orderBy: { createdAt: 'desc' },
})
// 3. 按员工聚合
const employeeMap = new Map<string, {
employeeId: string
name: string
department: string | null
phone: string | null
pendingItems: any[]
}>()
// 辅助函数:添加员工到 map
const ensureEmployee = (emp: { id: string; name: string; department: string | null; phone: string | null }) => {
if (!employeeMap.has(emp.id)) {
employeeMap.set(emp.id, {
employeeId: emp.id,
name: emp.name,
department: emp.department,
phone: emp.phone,
pendingItems: [],
})
}
return employeeMap.get(emp.id)!
}
// 汇总 EsignRecord
for (const r of pendingEsign) {
const emp = ensureEmployee(r.employee)
emp.pendingItems.push({
type: 'esign',
recordId: r.id,
contractId: r.contractId,
title: r.documentTitle,
scene: r.scene,
signMethod: r.signMethod,
status: r.status,
createdAt: r.createdAt,
})
}
// 汇总 LaborContract(排除已有 EsignRecord 关联的,避免重复)
const CONTRACT_TYPE_LABEL: Record<string, string> = {
FIXED: '固定期限劳动合同',
UNFIXED: '无固定期限劳动合同',
UNSIGNED: '未签合同',
LABOR: '劳务协议',
INTERNSHIP: '实习协议',
DISPATCH: '劳务派遣合同',
OUTSOURCING: '外包合同',
PARTTIME: '非全日制合同',
}
const esignContractIds = new Set(pendingEsign.filter(r => r.contractId).map(r => r.contractId))
for (const c of pendingContracts) {
if (esignContractIds.has(c.id)) continue // 已有电子签署记录的不重复
const emp = ensureEmployee(c.employee)
emp.pendingItems.push({
type: 'contract',
recordId: null,
contractId: c.id,
title: CONTRACT_TYPE_LABEL[c.contractType] || `${c.contractType}合同`,
scene: 'CONTRACT',
signMethod: c.signMethod,
status: 'PENDING',
createdAt: c.createdAt,
})
}
// 转为数组,按待签数量降序、姓名排序
const result = Array.from(employeeMap.values()).sort((a, b) => {
if (b.pendingItems.length !== a.pendingItems.length) return b.pendingItems.length - a.pendingItems.length
return a.name.localeCompare(b.name)
})
res.json({ success: true, data: result })
} catch (err) { next(err) }
})
/**
* 登记线下合同签署日期
* POST /esign/sign-date body: { contractId, signDate }
*
* 适用于纸质合同(signMethod=PAPER)未登记签署日期的情况。
* 电子签合同的签署日期由电签系统回写,不通过此接口修改。
*/
router.post('/sign-date', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { contractId, signDate } = req.body
if (!contractId || !signDate) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 contractId 或 signDate' } })
}
const contract = await prisma.laborContract.findFirst({
where: { id: contractId, orgId: req.user!.orgId },
select: { id: true, signMethod: true, contractType: true, employeeId: true, employee: { select: { name: true } } },
})
if (!contract) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
}
if (contract.signMethod === 'ELECTRONIC') {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '电子签合同的签署日期由电签系统自动回写,不可手动修改' } })
}
// 解析日期字符串(YYYY-MM-DD),手动构造本地中午时间避免时区偏移
const dateStr = String(signDate).slice(0, 10)
const [y, m, d] = dateStr.split('-').map(Number)
const parsedDate = new Date(y, (m || 1) - 1, d || 1, 12, 0, 0, 0)
if (isNaN(parsedDate.getTime())) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期格式无效' } })
}
// 签署日期不能晚于当前日期
const today = new Date()
today.setHours(23, 59, 59, 999)
if (parsedDate > today) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期不能晚于当前日期' } })
}
// 更新合同签署日期
await prisma.laborContract.update({
where: { id: contractId },
data: { signDate: parsedDate },
})
// 同时创建一条已完成的线下手签 EsignRecord,使签署记录 Tab 可见
const existingRecord = await prisma.eSignRecord.findFirst({
where: { contractId, status: 'COMPLETED' },
select: { id: true },
})
if (!existingRecord) {
// 合同类型枚举转中文
const CONTRACT_TYPE_LABEL: Record<string, string> = {
FIXED: '固定期限劳动合同',
UNFIXED: '无固定期限劳动合同',
UNSIGNED: '未签合同',
LABOR: '劳务协议',
INTERNSHIP: '实习协议',
DISPATCH: '劳务派遣合同',
OUTSOURCING: '外包合同',
PARTTIME: '非全日制合同',
}
const contractLabel = CONTRACT_TYPE_LABEL[contract.contractType] || '合同'
await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
contractId,
employeeId: contract.employeeId,
scene: 'CONTRACT',
signMethod: 'PAPER',
documentTitle: `${contractLabel}线下签署登记`,
status: 'COMPLETED',
completedAt: parsedDate, // 完成时间 = 实际签署日期
signedAt: parsedDate, // 签署时间 = HR 选择的签署日期
signedLocation: null,
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: 'HR 登记线下签署日期',
},
})
}
await auditLog(req, 'SIGN_DATE', 'CONTRACT', contractId, { employeeName: contract.employee.name, signDate: parsedDate.toISOString() })
res.json({ success: true, data: { message: '签署日期已登记', signDate: parsedDate.toISOString() } })
} catch (err) { next(err) }
})
/**
* 催办 — 生成员工一次性自动登录链接(指向员工端签署页)
* POST /esign/remind body: { employeeId }
*
* 返回自动登录 URL,HR 可复制或生成二维码发给员工
*/
router.post('/remind', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeId } = req.body
if (!employeeId) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } })
}
const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId: req.user!.orgId, status: 'ACTIVE' },
select: { id: true, name: true, phone: true, orgId: true },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } })
}
// 生成一次性 token(24 小时有效,给员工充足时间签署)
const token = jwt.sign(
{ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE_AUTO', name: employee.name },
AUTO_LOGIN_SECRET,
{ expiresIn: '24h' },
)
const url = `${process.env.PORTAL_BASE_URL || ''}/portal/auto-login?token=${token}&redirect=/portal/esign`
await auditLog(req, 'REMIND', 'ESIGN', employeeId, { employeeName: employee.name })
res.json({ success: true, data: { url, token, employeeName: employee.name, phone: employee.phone } })
} catch (err) { next(err) }
})
/**
* 发起签署
* - 校验组织电子签开关
* - 自动从模板渲染文件内容
* - 创建 ESignRecord
* - 创建证据链(发起签署事件)
*/
router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => { router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const data = createSignSchema.parse(req.body) const data = createSignSchema.parse(req.body)
const scene = data.scene || 'CONTRACT'
// 预留:调用易签宝 API 创建签署流程 // 1. 校验组织电子签开关
// const esignResult = await esignApi.createFlow({ const orgFlag = SCENE_ORG_FLAG_MAP[scene]
// title: data.documentTitle, if (orgFlag) {
// signerPhone: employee.phone, const org = await prisma.organization.findUnique({
// signerName: employee.name, where: { id: req.user!.orgId },
// content: data.documentContent, select: { [orgFlag]: true, name: true } as any,
// }) })
// const flowId = esignResult.flowId if (org && !(org as any)[orgFlag]) {
// const signUrl = esignResult.signUrl return res.status(403).json({
success: false,
error: { code: 'ESIGN_DISABLED', message: `组织未开启${scene === 'POLICY' ? '规章制度' : scene === 'PAYSLIP' ? '工资条' : scene === 'ONBOARDING' ? '入职文件' : '该场景'}电子签功能,请在系统设置中开启` },
})
}
}
// 2. 获取员工信息
const employee = await prisma.employee.findFirst({
where: { id: data.employeeId, orgId: req.user!.orgId },
select: { id: true, name: true, phone: true, department: true, idCardNumber: true, position: true, monthlySalary: true, hireDate: true },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
// 3. 自动渲染文件内容(优先使用指定模板,否则按 scene 匹配)
let documentContent = data.documentContent || ''
if (!documentContent) {
const templateId = data.templateId || SCENE_TEMPLATE_MAP[scene]
if (templateId) {
const template = getTemplateById(templateId)
if (template) {
// 自动填充模板变量
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { name: true } })
const vars: Record<string, string> = {
companyName: org?.name || '',
employeeName: employee.name || '',
idCard: employee.idCardNumber || '',
position: employee.position || '',
monthlySalary: String(employee.monthlySalary || ''),
...data.templateVars,
}
documentContent = renderTemplate(templateId, vars) || ''
}
}
}
// 4. 创建签署记录
const record = await prisma.eSignRecord.create({ const record = await prisma.eSignRecord.create({
data: { data: {
orgId: req.user!.orgId, orgId: req.user!.orgId,
contractId: data.contractId || null, contractId: data.contractId || null,
employeeId: data.employeeId, employeeId: data.employeeId,
scene: data.scene || 'CONTRACT', scene,
documentTitle: data.documentTitle, documentTitle: data.documentTitle,
documentContent: data.documentContent || null, documentContent: documentContent || null,
// flowId: esignResult.flowId, // TODO: 易签宝对接后启用
// signUrl: esignResult.signUrl, // TODO: 易签宝对接后启用
status: 'PENDING', status: 'PENDING',
initiatedBy: req.user!.id, initiatedBy: req.user!.id,
createdBy: req.user!.id, createdBy: req.user!.id,
remark: data.remark || null, remark: data.remark || null,
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30天过期 expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
}, },
}) })
// 5. 创建证据链 — 发起签署事件
await createEvidence({
orgId: req.user!.orgId,
category: 'CONTRACT_SIGN',
refId: record.id,
employeeId: data.employeeId,
events: [{
action: `发起电子签署:${data.documentTitle}`,
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent'] as string,
location: `场景:${scene},发起人:${req.user!.id}`,
}],
createdBy: req.user!.id,
}).catch(() => {})
await auditLog(req, 'ESIGN_CREATE', 'ESIGN_RECORD', record.id, { employeeId: data.employeeId, scene, documentTitle: data.documentTitle })
res.json({ res.json({
success: true, success: true,
data: record, data: record,
message: '签署记录已创建。对接易签宝API后,将自动生成签署链接并发送给员工。', message: '签署记录已创建,员工可在员工端查看并签署',
}) })
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
// 查询签署状态 /**
// TODO: 对接易签宝 API — 查询流程状态并同步 * 签署记录详情
*/
router.get('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
include: {
employee: { select: { id: true, name: true, department: true, phone: true } },
},
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
/**
* 查询签署状态
*/
router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => { router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const record = await prisma.eSignRecord.findFirst({ const record = await prisma.eSignRecord.findFirst({
@@ -89,22 +431,41 @@ router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunc
}) })
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } }) if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
// 预留:调用易签宝 API 查询流程状态 // 检查是否已过期但状态仍为 PENDING
// const esignStatus = await esignApi.getFlowStatus(record.flowId) if (record.status === 'PENDING' && record.expiredAt && record.expiredAt < new Date()) {
// if (esignStatus !== record.status) { const updated = await prisma.eSignRecord.update({
// await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: esignStatus } }) where: { id: record.id },
// } data: { status: 'EXPIRED' },
})
return res.json({ success: true, data: updated })
}
res.json({ success: true, data: record }) res.json({ success: true, data: record })
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
// 易签宝回调接口(无需认证) /**
* 获取签署记录的证据链
*/
router.get('/:id/evidence', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const evidence = await prisma.evidenceChain.findMany({
where: { orgId: req.user!.orgId, refId: req.params.id },
orderBy: { createdAt: 'asc' },
})
res.json({ success: true, data: evidence })
} catch (err) { next(err) }
})
/**
* 易签宝回调接口(无需认证)
* 对接易签宝后启用:验证签名 + 更新状态 + 回写PDF + 追加证据链
*/
router.post('/callback', async (req, res: Response, next: NextFunction) => { router.post('/callback', async (req, res: Response, next: NextFunction) => {
try { try {
const { flowId, status, signedPdfUrl, ...rest } = req.body const { flowId, status, signedPdfUrl, ...rest } = req.body
// TODO: 验证易签宝回调签名 // TODO: 对接易签宝后验证回调签名
// if (!verifyEsignCallback(req.headers, req.body)) { // if (!verifyEsignCallback(req.headers, req.body)) {
// return res.status(401).json({ success: false, error: { message: '无效回调' } }) // return res.status(401).json({ success: false, error: { message: '无效回调' } })
// } // }
@@ -122,16 +483,26 @@ router.post('/callback', async (req, res: Response, next: NextFunction) => {
}, },
}) })
// 如果关联了合同,更新合同的签署方式和电子合同URL // 签署完成 → 回写合同 + 追加证据链
if (record.contractId && status === 'COMPLETED') { if (status === 'COMPLETED') {
await prisma.laborContract.update({ if (record.contractId) {
where: { id: record.contractId }, await prisma.laborContract.update({
data: { where: { id: record.contractId },
signMethod: 'ELECTRONIC', data: {
electronicContractUrl: signedPdfUrl || null, signMethod: 'ELECTRONIC',
}, electronicContractUrl: signedPdfUrl || null,
}) },
})
}
await appendEvidence(record.orgId, '', {
action: '易签宝回调:签署完成',
timestamp: new Date().toISOString(),
location: `flowId: ${flowId}PDF: ${signedPdfUrl || '无'}`,
}).catch(() => {})
} }
await auditLog({} as any, 'ESIGN_CALLBACK', 'ESIGN_RECORD', record.id, { flowId, status })
} }
} }
@@ -139,14 +510,208 @@ router.post('/callback', async (req, res: Response, next: NextFunction) => {
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
// 取消签署 /**
* 取消签署
* - 更新状态为 CANCELLED
* - 追加证据链
*/
router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => { router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const record = await prisma.eSignRecord.update({ const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId }, where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
if (record.status === 'COMPLETED') {
return res.status(400).json({ success: false, error: { message: '已完成的签署不可取消' } })
}
const updated = await prisma.eSignRecord.update({
where: { id: record.id },
data: { status: 'CANCELLED' }, data: { status: 'CANCELLED' },
}) })
res.json({ success: true, data: record })
// 追加证据链
const evidence = await prisma.evidenceChain.findFirst({
where: { orgId: req.user!.orgId, refId: record.id },
})
if (evidence) {
await appendEvidence(req.user!.orgId, evidence.id, {
action: '取消签署',
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent'] as string,
location: `操作人:${req.user!.id}`,
}).catch(() => {})
}
await auditLog(req, 'ESIGN_CANCEL', 'ESIGN_RECORD', record.id, {})
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
// ========== 线下手签登记 ==========
/** 线下签署扫描件上传目录 */
const paperSignDir = path.join(process.cwd(), 'uploads', 'paper-sign')
if (!fs.existsSync(paperSignDir)) fs.mkdirSync(paperSignDir, { recursive: true })
const paperSignUpload = multer({
storage: multer.diskStorage({
destination: paperSignDir,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname)
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp', '.webp', '.tiff', '.tif']
const ext = path.extname(file.originalname).toLowerCase()
if (allowed.includes(ext)) cb(null, true)
else cb(new Error('仅支持 JPG/PNG/PDF/BMP/WEBP/TIFF 格式'))
},
})
/**
* 上传线下签署扫描件
* 支持多文件上传,返回文件URL列表
*/
router.post('/paper-upload', authMiddleware, paperSignUpload.array('files', 10), async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const files = req.files as Express.Multer.File[]
if (!files || files.length === 0) {
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
}
const fileUrls = files.map(f => ({
name: f.originalname,
url: `/uploads/paper-sign/${f.filename}`,
size: f.size,
}))
res.json({ success: true, data: fileUrls })
} catch (err) { next(err) }
})
/** 线下手签登记 Schema */
const paperSignSchema = z.object({
employeeId: z.string().min(1),
contractId: z.string().optional(),
scene: z.string().default('CONTRACT'),
documentTitle: z.string().min(1),
signedAt: z.string().min(1), // 签署日期
signedLocation: z.string().optional(), // 签署地点
witnessName: z.string().optional(), // 见证人姓名
witnessPhone: z.string().optional(), // 见证人手机号
scanFileUrls: z.array(z.object({
name: z.string(),
url: z.string(),
})).min(1, '至少上传一份签署扫描件'),
remark: z.string().optional(),
})
/**
* 线下手签登记
* - 创建 ESignRecordsignMethod=PAPER, status=COMPLETED
* - 保存签署信息(签署日期/地点/见证人/扫描件)
* - 创建证据链(线下签署登记事件)
* - 回写合同签署方式
*/
router.post('/paper-sign', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = paperSignSchema.parse(req.body)
// 获取员工信息
const employee = await prisma.employee.findFirst({
where: { id: data.employeeId, orgId: req.user!.orgId },
select: { id: true, name: true, department: true },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
// 签署日期不能晚于当前日期
const signDate = new Date(data.signedAt)
const todayEnd = new Date()
todayEnd.setHours(23, 59, 59, 999)
if (signDate > todayEnd) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期不能晚于当前日期' } })
}
// 创建签署记录(线下手签直接为 COMPLETED 状态)
const record = await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
contractId: data.contractId || null,
employeeId: data.employeeId,
scene: data.scene,
signMethod: 'PAPER',
documentTitle: data.documentTitle,
status: 'COMPLETED',
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: data.remark || null,
completedAt: new Date(data.signedAt),
expiredAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 线下签署记录保留1年
// 线下手签专用字段
signedAt: new Date(data.signedAt),
signedLocation: data.signedLocation || null,
witnessName: data.witnessName || null,
witnessPhone: data.witnessPhone || null,
scanFileUrls: data.scanFileUrls as any,
// 签署证据
callbackData: {
signMethod: 'PAPER',
signedAt: data.signedAt,
signedLocation: data.signedLocation,
witnessName: data.witnessName,
witnessPhone: data.witnessPhone,
scanFileCount: data.scanFileUrls.length,
registeredBy: req.user!.id,
registeredAt: new Date().toISOString(),
} as any,
},
})
// 创建证据链 — 线下签署登记
await createEvidence({
orgId: req.user!.orgId,
category: 'CONTRACT_SIGN',
refId: record.id,
employeeId: data.employeeId,
events: [{
action: `线下手签登记:${data.documentTitle}`,
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent'] as string,
location: `签署日期:${data.signedAt.slice(0, 10)},签署地点:${data.signedLocation || '未填写'},见证人:${data.witnessName || '无'},扫描件:${data.scanFileUrls.length}`,
}],
createdBy: req.user!.id,
}).catch(() => {})
// 回写合同签署方式
if (data.contractId) {
await prisma.laborContract.update({
where: { id: data.contractId },
data: {
signMethod: 'PAPER',
// 第一份扫描件作为合同附件
attachmentUrl: data.scanFileUrls[0]?.url || null,
},
})
}
await auditLog(req, 'PAPER_SIGN_REGISTER', 'ESIGN_RECORD', record.id, {
employeeId: data.employeeId,
documentTitle: data.documentTitle,
signedAt: data.signedAt,
scanFileCount: data.scanFileUrls.length,
})
res.json({
success: true,
data: record,
message: '线下手签登记成功,证据链已记录',
})
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
+2 -2
View File
@@ -287,7 +287,7 @@ router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, ne
{ header: '状态', key: 'status', width: 8 }, { header: '状态', key: 'status', width: 8 },
{ header: '入职日期', key: 'hireDate', width: 12 }, { header: '入职日期', key: 'hireDate', width: 12 },
{ header: '手机号', key: 'phone', width: 13 }, { header: '手机号', key: 'phone', width: 13 },
{ header: '身份证号', key: 'idCardNumber', width: 20 }, { header: '证件号码', key: 'idCardNumber', width: 20 },
{ header: '月工资', key: 'monthlySalary', width: 10 }, { header: '月工资', key: 'monthlySalary', width: 10 },
{ header: '社保基数', key: 'socialInsBase', width: 10 }, { header: '社保基数', key: 'socialInsBase', width: 10 },
{ header: '公积金基数', key: 'housingFundBase', width: 10 }, { header: '公积金基数', key: 'housingFundBase', width: 10 },
@@ -478,7 +478,7 @@ router.get('/tax-declaration', authMiddleware, requireAdmin, async (req: AuthReq
let seq = 0 let seq = 0
for (const e of entries) { for (const e of entries) {
seq++ seq++
// 解密身份证号 // 解密证件号码
let idCard: string = '' let idCard: string = ''
try { if (e.employee.idCardNumber) idCard = decrypt(e.employee.idCardNumber) || '' } catch { idCard = e.employee.idCardNumber || '' } try { if (e.employee.idCardNumber) idCard = decrypt(e.employee.idCardNumber) || '' } catch { idCard = e.employee.idCardNumber || '' }
+76 -41
View File
@@ -1,6 +1,7 @@
import { Router, Response, NextFunction } from 'express' import { Router, Response, NextFunction } from 'express'
import multer from 'multer' import multer from 'multer'
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
import bcrypt from 'bcryptjs'
import { authMiddleware, AuthRequest } from '../middleware/auth' import { authMiddleware, AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/rbac' import { requireAdmin } from '../middleware/rbac'
import { encrypt, decrypt, sha256 } from '../lib/crypto' import { encrypt, decrypt, sha256 } from '../lib/crypto'
@@ -18,17 +19,17 @@ function contentDisposition(filename: string): string {
const router = Router() const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }) const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
// 身份证号格式校验(18位正则 + 校验位算法) // 证件号码格式校验(18位正则 + 校验位算法)
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } { function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
if (!idCard) return { valid: true } if (!idCard) return { valid: true }
const s = idCard.trim() const s = idCard.trim()
// 15位身份证号升级为18位 // 15位证件号码升级为18位
if (/^\d{15}$/.test(s)) { if (/^\d{15}$/.test(s)) {
const upgraded = upgrade15To18(s) const upgraded = upgrade15To18(s)
return { valid: true, upgraded } return { valid: true, upgraded }
} }
if (!/^\d{17}[\dXx]$/.test(s)) { if (!/^\d{17}[\dXx]$/.test(s)) {
return { valid: false, error: '身份证号格式错误(应为18位)' } return { valid: false, error: '证件号码格式错误(应为18位)' }
} }
// 校验位算法 // 校验位算法
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
@@ -36,7 +37,7 @@ function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; er
const sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0) const sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
const expected = checkCodes[sum % 11] const expected = checkCodes[sum % 11]
if (s.charAt(17).toUpperCase() !== expected) { if (s.charAt(17).toUpperCase() !== expected) {
return { valid: false, error: '身份证号校验位错误' } return { valid: false, error: '证件号码校验位错误' }
} }
return { valid: true } return { valid: true }
} }
@@ -124,7 +125,7 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(empSheet) const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '身份证号')), city: val(getField(r, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] } const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '证件号码')), city: val(getField(r, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] }
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') } if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
const hireDate = parseDate(getField(r, '入职日期')) const hireDate = parseDate(getField(r, '入职日期'))
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') } if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
@@ -144,8 +145,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(contractSheet) const rows = XLSX.utils.sheet_to_json(contractSheet)
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), contractType: val(getField(r, '合同类型')), startDate: getField(r, '合同开始日期'), endDate: getField(r, '合同结束日期'), status: 'normal', errors: [] as string[] } const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), contractType: val(getField(r, '合同类型')), startDate: getField(r, '合同开始日期'), endDate: getField(r, '合同结束日期'), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') } if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
const sd = parseDate(getField(r, '合同开始日期')) const sd = parseDate(getField(r, '合同开始日期'))
if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') } if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '劳动合同', row: i + 2, name: row.name, errors: row.errors }) if (row.status === 'error') preview.errors.push({ sheet: '劳动合同', row: i + 2, name: row.name, errors: row.errors })
@@ -159,8 +160,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any const r = rows[i] as any
const otType = val(getField(r, '加班类型')) || '工作日加班' const otType = val(getField(r, '加班类型')) || '工作日加班'
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), hours: num(getField(r, '加班时长')), otType, status: 'normal', errors: [] as string[] } const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), hours: num(getField(r, '加班时长')), otType, status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') } if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
const dt = parseDate(getField(r, '日期')) const dt = parseDate(getField(r, '日期'))
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') } if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '加班记录', row: i + 2, name: row.name, errors: row.errors }) if (row.status === 'error') preview.errors.push({ sheet: '加班记录', row: i + 2, name: row.name, errors: row.errors })
@@ -173,8 +174,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(discSheet) const rows = XLSX.utils.sheet_to_json(discSheet)
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), violationType: val(getField(r, '违纪类型')), description: val(getField(r, '描述')), status: 'normal', errors: [] as string[] } const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), violationType: val(getField(r, '违纪类型')), description: val(getField(r, '描述')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') } if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors }) if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors })
preview.disciplinary.push(row) preview.disciplinary.push(row)
} }
@@ -185,8 +186,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(attSheet) const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), attStatus: val(getField(r, '考勤状态')), status: 'normal', errors: [] as string[] } const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), attStatus: val(getField(r, '考勤状态')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') } if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
const dt = parseDate(getField(r, '日期')) const dt = parseDate(getField(r, '日期'))
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') } if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '考勤记录', row: i + 2, name: row.name, errors: row.errors }) if (row.status === 'error') preview.errors.push({ sheet: '考勤记录', row: i + 2, name: row.name, errors: row.errors })
@@ -259,14 +260,42 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
const salary = String(num(getField(r, '月工资'))) const salary = String(num(getField(r, '月工资')))
if (salary === '0') { result.skipped++; result.errors.push(`员工第${i + 2}行:月工资为空`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '月工资为空' }); continue } if (salary === '0') { result.skipped++; result.errors.push(`员工第${i + 2}行:月工资为空`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '月工资为空' }); continue }
let idCard = val(getField(r, '身份证号')) let idCard = val(getField(r, '证件号码'))
if (!idCard) { result.skipped++; result.errors.push(`员工第${i + 2}行:身份证号为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '身份证号为空' }); continue } if (!idCard) { result.skipped++; result.errors.push(`员工第${i + 2}行:证件号码为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '证件号码为空' }); continue }
if (idCard) { if (idCard) {
const idCheck = validateIdCard(idCard) const idCheck = validateIdCard(idCard)
if (!idCheck.valid) { result.skipped++; result.errors.push(`员工第${i + 2}行:${idCheck.error}`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: idCheck.error }); continue } if (!idCheck.valid) { result.skipped++; result.errors.push(`员工第${i + 2}行:${idCheck.error}`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: idCheck.error }); continue }
if (idCheck.upgraded) idCard = idCheck.upgraded if (idCheck.upgraded) idCard = idCheck.upgraded
} }
// 同企业内身份证查重
if (idCard) {
const idCardExists = await prisma.employee.findFirst({
where: { orgId, idCardHash: sha256(idCard) },
select: { id: true, name: true },
})
if (idCardExists) {
result.skipped++
result.errors.push(`员工第${i + 2}行:证件号码已存在(${idCardExists.name}),跳过`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '证件号码已存在' })
continue
}
}
// 同企业内手机号查重
const importPhone = val(getField(r, '手机号'))
if (importPhone) {
const phoneExists = await prisma.employee.findFirst({
where: { orgId, phone: importPhone },
select: { id: true, name: true },
})
if (phoneExists) {
result.skipped++
result.errors.push(`员工第${i + 2}行:手机号已存在(${phoneExists.name}),跳过`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '手机号已存在' })
continue
}
}
// 社保基数:值为 0 或「无」表示不参保 // 社保基数:值为 0 或「无」表示不参保
const socialInsBaseVal = num(getField(r, '社保基数')) const socialInsBaseVal = num(getField(r, '社保基数'))
const socialInsOptOut = val(getField(r, '社保基数')) === '无' || val(getField(r, '社保基数')) === '不缴' const socialInsOptOut = val(getField(r, '社保基数')) === '无' || val(getField(r, '社保基数')) === '不缴'
@@ -277,6 +306,11 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
const housingFundOptOut = val(getField(r, '公积金基数')) === '无' || val(getField(r, '公积金基数')) === '不缴' const housingFundOptOut = val(getField(r, '公积金基数')) === '无' || val(getField(r, '公积金基数')) === '不缴'
const housingFundBase = housingFundOptOut ? 0 : (housingFundBaseVal || num(salary)) const housingFundBase = housingFundOptOut ? 0 : (housingFundBaseVal || num(salary))
// 默认密码:手机号后6位(无手机号则 123456)
const importPhoneVal = val(getField(r, '手机号')) || null
const defaultPwd = importPhoneVal ? importPhoneVal.slice(-6) : '123456'
const importPasswordHash = await bcrypt.hash(defaultPwd, 10)
const emp = await prisma.employee.create({ const emp = await prisma.employee.create({
data: { data: {
orgId, name, department: dept, hireDate, orgId, name, department: dept, hireDate,
@@ -284,7 +318,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
gender: val(getField(r, '性别')) || (idCard ? extractGenderFromIdCard(idCard) : null), gender: val(getField(r, '性别')) || (idCard ? extractGenderFromIdCard(idCard) : null),
femaleWorkerType: (val(getField(r, '女性岗位类型')) === '工人' || val(getField(r, '女性岗位类型')) === 'WORKER') ? 'WORKER' femaleWorkerType: (val(getField(r, '女性岗位类型')) === '工人' || val(getField(r, '女性岗位类型')) === 'WORKER') ? 'WORKER'
: (val(getField(r, '女性岗位类型')) === '干部' || val(getField(r, '女性岗位类型')) === 'CADRE') ? 'CADRE' : null, : (val(getField(r, '女性岗位类型')) === '干部' || val(getField(r, '女性岗位类型')) === 'CADRE') ? 'CADRE' : null,
phone: val(getField(r, '手机号')) || null, phone: importPhoneVal,
passwordHash: importPasswordHash,
idCardNumber: idCard ? encrypt(idCard) : null, idCardNumber: idCard ? encrypt(idCard) : null,
idCardHash: idCard ? sha256(idCard) : null, idCardHash: idCard ? sha256(idCard) : null,
birthDate: idCard ? extractBirthDateFromIdCard(idCard) : null, birthDate: idCard ? extractBirthDateFromIdCard(idCard) : null,
@@ -329,8 +364,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
const msg = e?.message || '' const msg = e?.message || ''
if (msg.includes('Unique constraint')) { if (msg.includes('Unique constraint')) {
result.duplicates++ result.duplicates++
result.errors.push(`员工第${i + 2}行:该员工已存在(身份证号重复),跳过`) result.errors.push(`员工第${i + 2}行:该员工已存在(证件号码重复),跳过`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '身份证号重复' }) result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '证件号码重复' })
} else if (msg.includes('invalid') || msg.includes('validation')) { } else if (msg.includes('invalid') || msg.includes('validation')) {
result.skipped++ result.skipped++
result.errors.push(`员工第${i + 2}行:数据格式不正确,请检查各项填写`) result.errors.push(`员工第${i + 2}行:数据格式不正确,请检查各项填写`)
@@ -353,7 +388,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any const r = rows[i] as any
try { try {
const idCard = val(getField(r, '身份证号')) const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名'))) const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue } if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const startDate = parseDate(getField(r, '合同开始日期')) const startDate = parseDate(getField(r, '合同开始日期'))
@@ -399,7 +434,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any const r = rows[i] as any
try { try {
const idCard = val(getField(r, '身份证号')) const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名'))) const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue } if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期')) const date = parseDate(getField(r, '日期'))
@@ -429,7 +464,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any const r = rows[i] as any
try { try {
const idCard = val(getField(r, '身份证号')) const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名'))) const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue } if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期')) const date = parseDate(getField(r, '日期'))
@@ -462,7 +497,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any const r = rows[i] as any
try { try {
const idCard = val(getField(r, '身份证号')) const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名'))) const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue } if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期')) const date = parseDate(getField(r, '日期'))
@@ -495,30 +530,30 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
const empData = [ const empData = [
{ '姓名*': '张三', '部门': '技术部', '性别(选填,留空自动识别)': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期*': '2023-03-01', '月工资*': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' }, { '姓名*': '张三', '部门': '技术部', '性别(选填,留空自动识别)': '男', '手机号': '13800138000', '证件号码': '110101199001011234', '入职日期*': '2023-03-01', '月工资*': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
] ]
const empWs = XLSX.utils.json_to_sheet(empData, { header: ['姓名*', '部门', '性别(选填,留空自动识别)', '手机号', '身份证号', '入职日期*', '月工资*', '社保基数', '公积金基数', '专项附加扣除', '参保城市', '紧急联系人', '紧急联系电话', '住址', '开户行', '银行账号', '孕期', '医疗期', '工伤'] }) const empWs = XLSX.utils.json_to_sheet(empData, { header: ['姓名*', '部门', '性别(选填,留空自动识别)', '手机号', '证件号码', '入职日期*', '月工资*', '社保基数', '公积金基数', '专项附加扣除', '参保城市', '紧急联系人', '紧急联系电话', '住址', '开户行', '银行账号', '孕期', '医疗期', '工伤'] })
// 设置示例行样式(灰色背景) // 设置示例行样式(灰色背景)
empWs['!cols'] = [{ wch: 10 }, { wch: 12 }, { wch: 22 }, { wch: 13 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 13 }, { wch: 18 }, { wch: 10 }, { wch: 18 }, { wch: 6 }, { wch: 6 }, { wch: 6 }] empWs['!cols'] = [{ wch: 10 }, { wch: 12 }, { wch: 22 }, { wch: 13 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 13 }, { wch: 18 }, { wch: 10 }, { wch: 18 }, { wch: 6 }, { wch: 6 }, { wch: 6 }]
XLSX.utils.book_append_sheet(wb, empWs, '员工信息') XLSX.utils.book_append_sheet(wb, empWs, '员工信息')
const contractData = [ const contractData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期*': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 }, { '姓名*': '张三', '证件号码': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期*': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
] ]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同') XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
const otData = [ const otData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' }, { '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
] ]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录') XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const discData = [ const discData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }, { '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
] ]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录') XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const attData = [ const attData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }, { '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
] ]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录') XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
@@ -558,7 +593,7 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
} }
function findEmp(r: any) { function findEmp(r: any) {
const idCard = val(getField(r, '身份证号')) const idCard = val(getField(r, '证件号码'))
if (idCard) { if (idCard) {
const emp = empByHash.get(sha256(idCard)) const emp = empByHash.get(sha256(idCard))
if (emp) return emp if (emp) return emp
@@ -778,10 +813,10 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => { router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
// 合并考勤+加班为一个Sheet,减少重复录入姓名身份证号 // 合并考勤+加班为一个Sheet,减少重复录入姓名证件号码
const attOtData = [{ const attOtData = [{
'姓名': '张三', '姓名': '张三',
'身份证号': '110101199001011234', '证件号码': '110101199001011234',
'日期': '2024-06-01', '日期': '2024-06-01',
'考勤状态': '正常', '考勤状态': '正常',
'上班时间': '09:00', '上班时间': '09:00',
@@ -798,16 +833,16 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R
] ]
XLSX.utils.book_append_sheet(wb, attOtWs, '考勤与加班') XLSX.utils.book_append_sheet(wb, attOtWs, '考勤与加班')
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }] const salaryData = [{ '姓名': '张三', '证件号码': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整') XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
const socialData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }] const socialData = [{ '姓名': '张三', '证件号码': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(socialData), '社保变动') XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(socialData), '社保变动')
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }] const hfData = [{ '姓名': '张三', '证件号码': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动') XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
const discData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }] const discData = [{ '姓名': '张三', '证件号码': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录') XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
@@ -847,7 +882,7 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any const r = rows[i] as any
try { try {
const idCard = val(getField(r, '身份证号')) const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名'))) const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue } if (!empId) { result.errors.push(`${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const entryId = entryByEmp.get(empId) const entryId = entryByEmp.get(empId)
@@ -906,8 +941,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Response) => { router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
const data = [ const data = [
{ '姓名*': '张三', '身份证号*': '110101199001011234', '基本工资': 10000, '加班费': 500, '津贴': 800, '扣款': 0, '奖金': 2000 }, { '姓名*': '张三', '证件号码*': '110101199001011234', '基本工资': 10000, '加班费': 500, '津贴': 800, '扣款': 0, '奖金': 2000 },
{ '姓名*': '李四', '身份证号*': '110101199002021234', '基本工资': 12000, '加班费': 0, '津贴': 600, '扣款': 100, '奖金': 0 }, { '姓名*': '李四', '证件号码*': '110101199002021234', '基本工资': 12000, '加班费': 0, '津贴': 600, '扣款': 100, '奖金': 0 },
] ]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '工资表') XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '工资表')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
@@ -921,8 +956,8 @@ router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Respons
router.get('/special-deduction/template', authMiddleware, (_req: AuthRequest, res: Response) => { router.get('/special-deduction/template', authMiddleware, (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
const data = [ const data = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '子女教育': 1000, '赡养老人': 2000, '住房': 1500, '继续教育': 0, '婴幼儿照护': 0, '备注': '' }, { '姓名*': '张三', '证件号码': '110101199001011234', '子女教育': 1000, '赡养老人': 2000, '住房': 1500, '继续教育': 0, '婴幼儿照护': 0, '备注': '' },
{ '姓名*': '李四', '身份证号': '110101199002021234', '子女教育': 0, '赡养老人': 1000, '住房': 0, '继续教育': 400, '婴幼儿照护': 1000, '备注': '继续教育证书' }, { '姓名*': '李四', '证件号码': '110101199002021234', '子女教育': 0, '赡养老人': 1000, '住房': 0, '继续教育': 400, '婴幼儿照护': 1000, '备注': '继续教育证书' },
] ]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '专项附加扣除') XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '专项附加扣除')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
@@ -953,7 +988,7 @@ router.post('/special-deduction', authMiddleware, requireAdmin, upload.single('f
const r = rows[i] as any const r = rows[i] as any
try { try {
const name = val(getField(r, '姓名')) const name = val(getField(r, '姓名'))
const idCard = val(getField(r, '身份证号')) const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(name) const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(name)
if (!empId) { result.skipped++; result.errors.push(`${i + 2}行:找不到员工「${name}`); result.details.push({ row: i + 2, name, status: 'skipped', message: '找不到员工' }); continue } if (!empId) { result.skipped++; result.errors.push(`${i + 2}行:找不到员工「${name}`); result.details.push({ row: i + 2, name, status: 'skipped', message: '找不到员工' }); continue }
+8 -4
View File
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto' import { decrypt } from '../lib/crypto'
import { authMiddleware, AuthRequest } from '../middleware/auth' import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod' import { z } from 'zod'
import { isInProbation } from '../services/contract.service'
const router = Router() const router = Router()
router.use(authMiddleware) router.use(authMiddleware)
@@ -377,8 +378,11 @@ router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, n
const deduction = deductions[emp.id] || 0 const deduction = deductions[emp.id] || 0
let baseSalary = 0 let baseSalary = 0
if (emp.contracts[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) { // 按月份判定是否仍在试用期
baseSalary = emp.contracts[0].probationSalary const monthEnd = new Date(`${month}-28T23:59:59`)
const latestContract = emp.contracts[0]
if (isInProbation(latestContract, monthEnd) && latestContract?.probationSalary > 0) {
baseSalary = latestContract.probationSalary
} else if (emp.monthlySalary) { } else if (emp.monthlySalary) {
try { try {
baseSalary = Number(decrypt(emp.monthlySalary)) || 0 baseSalary = Number(decrypt(emp.monthlySalary)) || 0
@@ -592,8 +596,8 @@ router.post('/tax-preview', async (req: AuthRequest, res: Response, next: NextFu
]) ])
const emp = employee || { socialInsBase: baseSalary, housingFundBase: baseSalary } const emp = employee || { socialInsBase: baseSalary, housingFundBase: baseSalary }
const socialBase = emp.socialInsBase || baseSalary const socialBase = emp.socialInsBase != null ? emp.socialInsBase : baseSalary
const housingBase = emp.housingFundBase || baseSalary const housingBase = emp.housingFundBase != null ? emp.housingFundBase : baseSalary
// 计算社保公积金 // 计算社保公积金
let socialEmp = 0, housingEmp = 0 let socialEmp = 0, housingEmp = 0
+554 -38
View File
@@ -10,6 +10,42 @@ import {
generatePayslipFromBatches, generatePayslipFromBatches,
prePayrollCheck, prePayrollCheck,
} from '../services/payroll.service' } from '../services/payroll.service'
import { isInProbation } from '../services/contract.service'
import { getBonusByMonthAndEmployeeIds } from '../services/commission-bonus.service'
/**
* 查询上月递延扣款数据(上月已归档批次中该员工的递延金额)
* 用于本月创建批次时补扣上月未扣完的社保/公积金/最低工资补齐
*/
async function getPrevDeferred(orgId: string, employeeId: string, currentMonth: string): Promise<{ socialEmp: number; housingEmp: number; minWage: number }> {
// 计算上月份
const [y, m] = currentMonth.split('-').map(Number)
const prevDate = new Date(y, m - 2, 1)
const prevMonth = `${prevDate.getFullYear()}-${String(prevDate.getMonth() + 1).padStart(2, '0')}`
// 查上月已归档批次中该员工的递延记录
const prevEntries = await prisma.batchEntry.findMany({
where: {
orgId,
employeeId,
batch: { month: prevMonth, status: 'ARCHIVED' },
},
select: {
deferredSocialEmp: true,
deferredHousingEmp: true,
deferredMinWage: true,
},
})
// 汇总上月所有批次的递延金额
const result = {
socialEmp: prevEntries.reduce((s, e) => s + (e.deferredSocialEmp || 0), 0),
housingEmp: prevEntries.reduce((s, e) => s + (e.deferredHousingEmp || 0), 0),
minWage: prevEntries.reduce((s, e) => s + (e.deferredMinWage || 0), 0),
}
return result
}
// RFC 5987 编码中文文件名 // RFC 5987 编码中文文件名
function contentDisposition(filename: string): string { function contentDisposition(filename: string): string {
@@ -218,6 +254,7 @@ router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: Ne
// 创建批次 // 创建批次
const createBatchSchema = z.object({ const createBatchSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/), month: z.string().regex(/^\d{4}-\d{2}$/),
payMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'), type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch', 'custom']).default('copy_last'), mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch', 'custom']).default('copy_last'),
sourceBatchId: z.string().optional(), sourceBatchId: z.string().optional(),
@@ -228,7 +265,7 @@ const createBatchSchema = z.object({
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => { router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const { month, type, mode, sourceBatchId, employeeIds, name, remark } = createBatchSchema.parse(req.body) const { month, payMonth, type, mode, sourceBatchId, employeeIds, name, remark } = createBatchSchema.parse(req.body)
const orgId = req.user!.orgId const orgId = req.user!.orgId
// 查询当月最大批次号,避免删除后 count 不准导致唯一键冲突 // 查询当月最大批次号,避免删除后 count 不准导致唯一键冲突
@@ -283,7 +320,18 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } }, where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } }, include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
}) })
employees = terminations.map(t => t.employee) // SEVERANCE 批次:仅包含已审批通过(APPROVED/EXECUTING/COMPLETED)且有补偿金的离职记录
if (type === 'SEVERANCE') {
const eligibleTerms = terminations.filter(t =>
(t.status === 'APPROVED' || t.status === 'EXECUTING' || t.status === 'COMPLETED') &&
(t.compensation > 0 || (t.compensationBreakdown as any)?.total > 0)
)
employees = eligibleTerms.map(t => t.employee)
// 缓存 terminationRecord 以便后续 entry 创建时读取补偿金
;(req as any)._severanceTerms = new Map(eligibleTerms.map(t => [t.employeeId, t]))
} else {
employees = terminations.map(t => t.employee)
}
} else { } else {
employees = await prisma.employee.findMany({ employees = await prisma.employee.findMany({
where: { where: {
@@ -303,6 +351,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
data: { data: {
orgId, orgId,
month, month,
payMonth: payMonth || null,
batchNo, batchNo,
name: batchName, name: batchName,
type, type,
@@ -317,16 +366,28 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
const failedEmployees: { employeeId: string; name: string; error: string }[] = [] const failedEmployees: { employeeId: string; name: string; error: string }[] = []
for (const emp of employees) { for (const emp of employees) {
let baseSalary = 0 let baseSalary = 0
let performanceSalary = 0
let overtimePay = 0 let overtimePay = 0
let allowance = 0 let allowance = 0
let deduction = 0 let deduction = 0
let bonus = 0 let bonus = 0
// 解密员工工资结构
let empBaseSalary = 0
let empPerformanceSalary = 0
if (emp.baseSalary) {
try { empBaseSalary = Number(decrypt(emp.baseSalary)) || 0 } catch { empBaseSalary = Number(emp.baseSalary) || 0 }
}
if (emp.performanceSalary) {
try { empPerformanceSalary = Number(decrypt(emp.performanceSalary)) || 0 } catch { empPerformanceSalary = Number(emp.performanceSalary) || 0 }
}
if (mode === 'copy_batch' && sourceEntries) { if (mode === 'copy_batch' && sourceEntries) {
// 复制指定批次:从源条目复制数据 // 复制指定批次:从源条目复制数据
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id) const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
if (srcEntry) { if (srcEntry) {
baseSalary = srcEntry.baseSalary baseSalary = srcEntry.baseSalary
performanceSalary = srcEntry.performanceSalary || 0
overtimePay = srcEntry.overtimePay overtimePay = srcEntry.overtimePay
allowance = srcEntry.allowance allowance = srcEntry.allowance
deduction = srcEntry.deduction deduction = srcEntry.deduction
@@ -341,30 +402,88 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
where: { employeeId_month: { employeeId: emp.id, month } }, where: { employeeId_month: { employeeId: emp.id, month } },
}) })
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) { // 按批次月份判定是否仍在试用期(试用期结束日 = 合同开始日 + 试用期月数)
baseSalary = emp.contracts[0].probationSalary // 试用期且 probationSalary > 0 → 用试用期工资;否则用转正工资
const batchMonthEnd = new Date(`${month}-28T23:59:59`) // 月末近似
const latestContract = emp.contracts?.[0]
const inProbation = isInProbation(latestContract, batchMonthEnd)
if (inProbation && latestContract.probationSalary > 0) {
baseSalary = latestContract.probationSalary
performanceSalary = 0 // 试用期一般不发绩效
} else if (prevPayslip) {
// 非试用期:优先用上月工资条的基本工资(保持薪资连续性)
baseSalary = prevPayslip.baseSalary
performanceSalary = prevPayslip.performanceSalary || 0
} else if (empBaseSalary > 0 || empPerformanceSalary > 0) {
// 新数据:有工资结构
baseSalary = empBaseSalary
performanceSalary = empPerformanceSalary
} else if (emp.monthlySalary) { } else if (emp.monthlySalary) {
// 旧数据兼容:回退到 monthlySalary
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
} }
if (prevPayslip) baseSalary = prevPayslip.baseSalary
overtimePay = overtime?.totalPay || 0 overtimePay = overtime?.totalPay || 0
allowance = prevPayslip?.allowance || 0 allowance = prevPayslip?.allowance || 0
deduction = prevPayslip?.deduction || 0 deduction = prevPayslip?.deduction || 0
} else if (mode === 'custom' || mode === 'blank_employees') {
// 自定义/空白员工模式:取员工工资结构(非试用期)
const batchMonthEnd = new Date(`${month}-28T23:59:59`)
const latestContract = emp.contracts?.[0]
const inProbation = isInProbation(latestContract, batchMonthEnd)
if (inProbation && latestContract?.probationSalary > 0) {
baseSalary = latestContract.probationSalary
performanceSalary = 0
} else if (empBaseSalary > 0 || empPerformanceSalary > 0) {
baseSalary = empBaseSalary
performanceSalary = empPerformanceSalary
} else if (emp.monthlySalary && mode === 'custom') {
// custom 模式旧数据兼容
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
}
// blank_employees 模式:金额默认0,不自动带出
} }
// blank_employees 和 blank_all: 所有金额默认 0 // blank_all: 所有金额默认 0
// blank_employees 模式下尝试从员工记录获取基本工资
if (mode === 'blank_employees' && emp.monthlySalary) { // 统一试用期判定(仅 copy_last / copy_batch / custom 模式适用,SEVERANCE 除外)
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } // blank_employees / blank_all 模式下所有金额应为 0,不覆盖试用期工资
if (type !== 'SEVERANCE' && type !== 'TERMINATION' && mode !== 'blank_employees' && mode !== 'blank_all') {
const batchMonthEnd = new Date(`${month}-28T23:59:59`)
const latestContract = emp.contracts?.[0]
if (isInProbation(latestContract, batchMonthEnd) && latestContract?.probationSalary > 0) {
baseSalary = latestContract.probationSalary
performanceSalary = 0
}
}
// SEVERANCE 批次:从离职记录读取补偿金作为应发金额,不走工资/社保/个税计算
let severanceAmount = 0
let severanceBreakdown: any = null
if (type === 'SEVERANCE') {
const severanceTerms: Map<string, any> = (req as any)._severanceTerms || new Map()
const termRecord = severanceTerms.get(emp.id)
if (termRecord) {
severanceBreakdown = termRecord.compensationBreakdown
// 优先取 compensationBreakdown.total(含手动调整),否则取 compensation
severanceAmount = (severanceBreakdown as any)?.total || termRecord.compensation || 0
baseSalary = severanceAmount
}
} }
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过 // calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过
// SEVERANCE 批次:补偿金不走社保/个税计算,直接作为应发和实发金额
let calcResult: any let calcResult: any
try { if (type === 'SEVERANCE' && severanceAmount > 0) {
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type) calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: severanceAmount, netPay: severanceAmount, minWage: 0, minWageApplied: 0, deferredSocialEmp: 0, deferredHousingEmp: 0, deferredMinWage: 0, prevDeferredSocialEmp: 0, prevDeferredHousingEmp: 0, prevDeferredMinWage: 0 }
} catch (calcErr: any) { } else {
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值 try {
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' }) // 查询上月递延数据(上月已归档批次中该员工的递延金额)
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: baseSalary + overtimePay + allowance + bonus - deduction, netPay: baseSalary + overtimePay + allowance + bonus - deduction } const prevDeferred = await getPrevDeferred(orgId, emp.id, month)
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, performanceSalary, overtimePay, allowance, deduction, bonus }, type, { prevDeferred })
} catch (calcErr: any) {
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: baseSalary + overtimePay + allowance + bonus - deduction, netPay: baseSalary + overtimePay + allowance + bonus - deduction, minWage: 0, minWageApplied: 0, deferredSocialEmp: 0, deferredHousingEmp: 0, deferredMinWage: 0, prevDeferredSocialEmp: 0, prevDeferredHousingEmp: 0, prevDeferredMinWage: 0 }
}
} }
// 风险提示 // 风险提示
@@ -376,6 +495,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
orgId, orgId,
employeeId: emp.id, employeeId: emp.id,
baseSalary, baseSalary,
performanceSalary,
overtimePay, overtimePay,
allowance, allowance,
deduction, deduction,
@@ -384,9 +504,20 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
socialOrg: calcResult.socialOrg, socialOrg: calcResult.socialOrg,
housingEmp: calcResult.housingEmp, housingEmp: calcResult.housingEmp,
housingOrg: calcResult.housingOrg, housingOrg: calcResult.housingOrg,
supplementaryHousingEmp: calcResult.supplementaryHousingEmp || 0,
supplementaryHousingOrg: calcResult.supplementaryHousingOrg || 0,
tax: calcResult.tax, tax: calcResult.tax,
totalPay: calcResult.totalPay, totalPay: calcResult.totalPay,
netPay: calcResult.netPay, netPay: calcResult.netPay,
// 最低工资保护 + 递延扣款
minWage: calcResult.minWage || 0,
minWageApplied: calcResult.minWageApplied || 0,
deferredSocialEmp: calcResult.deferredSocialEmp || 0,
deferredHousingEmp: calcResult.deferredHousingEmp || 0,
deferredMinWage: calcResult.deferredMinWage || 0,
prevDeferredSocialEmp: calcResult.prevDeferredSocialEmp || 0,
prevDeferredHousingEmp: calcResult.prevDeferredHousingEmp || 0,
prevDeferredMinWage: calcResult.prevDeferredMinWage || 0,
riskWarnings, riskWarnings,
}, },
}) })
@@ -426,15 +557,15 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖) // 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
const updateEntrySchema = z.object({ const updateEntrySchema = z.object({
baseSalary: z.number().min(0).optional(), baseSalary: z.number().optional(),
overtimePay: z.number().min(0).optional(), overtimePay: z.number().optional(),
allowance: z.number().min(0).optional(), allowance: z.number().optional(),
deduction: z.number().min(0).optional(), deduction: z.number().optional(),
bonus: z.number().min(0).optional(), bonus: z.number().optional(),
socialEmp: z.number().min(0).optional(), socialEmp: z.number().optional(),
socialOrg: z.number().min(0).optional(), socialOrg: z.number().optional(),
housingEmp: z.number().min(0).optional(), housingEmp: z.number().optional(),
housingOrg: z.number().min(0).optional(), housingOrg: z.number().optional(),
}) })
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => { router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
@@ -452,13 +583,21 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
}) })
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } }) if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
// 合并输入项 // 合并输入项(包含细化薪资字段,避免编辑时丢失岗位工资/绩效工资等)
const inputs = { const inputs = {
baseSalary: data.baseSalary ?? entry.baseSalary, baseSalary: data.baseSalary ?? entry.baseSalary,
overtimePay: data.overtimePay ?? entry.overtimePay, overtimePay: data.overtimePay ?? entry.overtimePay,
allowance: data.allowance ?? entry.allowance, allowance: data.allowance ?? entry.allowance,
deduction: data.deduction ?? entry.deduction, deduction: data.deduction ?? entry.deduction,
bonus: data.bonus ?? entry.bonus, bonus: data.bonus ?? entry.bonus,
positionSalary: entry.positionSalary || undefined,
performanceSalary: entry.performanceSalary || undefined,
senioritySalary: entry.senioritySalary || undefined,
transportAllowance: entry.transportAllowance || undefined,
mealAllowance: entry.mealAllowance || undefined,
housingAllowance: entry.housingAllowance || undefined,
communicationAllowance: entry.communicationAllowance || undefined,
otherDeduction: entry.otherDeduction || undefined,
} }
// 构建社保覆盖参数(如果请求中包含社保字段) // 构建社保覆盖参数(如果请求中包含社保字段)
@@ -468,15 +607,17 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp
if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg
// 查询上月递延数据(与创建批次时一致)
const prevDeferred = await getPrevDeferred(orgId, employeeId, batch.month)
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,手动覆盖优先 // calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,手动覆盖优先
const options = Object.keys(overrideSocial).length > 0 const options: any = { prevDeferred }
? { overrideSocial } if (Object.keys(overrideSocial).length > 0) options.overrideSocial = overrideSocial
: undefined
// 重新计算 // 重新计算
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options) const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, taxBreakdown, ...entryData } = calcResult const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, ...entryData } = calcResult
const updated = await prisma.batchEntry.update({ const updated = await prisma.batchEntry.update({
where: { id: entry.id }, where: { id: entry.id },
data: { ...inputs, ...entryData }, data: { ...inputs, ...entryData },
@@ -513,6 +654,374 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
} }
}) })
// 获取提成奖金:按批次月份从 CommissionBonus 表拉取,填充到 entries.bonus
router.post('/batches/:batchId/fetch-bonus', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
// 获取批次所有条目
const entries = await prisma.batchEntry.findMany({
where: { batchId },
select: { id: true, employeeId: true, bonus: true },
})
if (entries.length === 0) {
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
}
// 按批次月份查询提成奖金
const bonusMap = await getBonusByMonthAndEmployeeIds(orgId, batch.month, entries.map((e) => e.employeeId))
let filled = 0
let totalAmount = 0
for (const entry of entries) {
const bonus = bonusMap.get(entry.employeeId)
if (bonus) {
await prisma.batchEntry.update({
where: { id: entry.id },
data: { bonus: bonus.amount },
})
filled++
totalAmount += bonus.amount
}
}
// 重算批次汇总
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = allEntries.reduce((acc, e) => ({
totalPay: acc.totalPay + e.baseSalary + e.overtimePay + e.allowance + e.bonus - e.deduction,
totalNetPay: acc.totalNetPay + e.netPay,
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
totalTax: acc.totalTax + e.tax,
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
await prisma.payrollBatch.update({
where: { id: batchId },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
})
res.json({
success: true,
data: {
filled,
totalAmount: Math.round(totalAmount * 100) / 100,
message: filled > 0 ? `已填充 ${filled} 人提成奖金,合计 ¥${Math.round(totalAmount * 100) / 100}` : `${batch.month} 无提成奖金数据`,
},
})
} catch (err) {
next(err)
}
})
// 获取绩效工资:按批次月份从 PerformanceRecord 查考核等级,结合 Employee.performanceSalary 计算绩效工资
// 绩效系数:A=1.2, B=1.0, C=0.8, D=0.6;无绩效记录则系数=1.0
router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
// 获取批次所有条目
const entries = await prisma.batchEntry.findMany({
where: { batchId },
select: { id: true, employeeId: true, performanceSalary: true },
})
if (entries.length === 0) {
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
}
// 查询批次月份的绩效记录(按 period = YYYY-MM 匹配)
const performanceRecords = await prisma.performanceRecord.findMany({
where: { orgId, period: batch.month, periodType: 'MONTHLY' },
select: { employeeId: true, grade: true, score: true, result: true },
})
const perfMap = new Map<string, { grade: string; score: number }>()
for (const r of performanceRecords) {
// 同一员工同月可能有多条记录,取最新一条(按创建时间倒序已由 DB 保证)
if (!perfMap.has(r.employeeId)) {
perfMap.set(r.employeeId, { grade: r.grade, score: r.score })
}
}
// 查询员工绩效工资基数
const employees = await prisma.employee.findMany({
where: { id: { in: entries.map(e => e.employeeId) } },
select: { id: true, performanceSalary: true },
})
const empPerfSalaryMap = new Map<string, number>()
for (const emp of employees) {
if (emp.performanceSalary) {
try { empPerfSalaryMap.set(emp.id, Number(decrypt(emp.performanceSalary)) || 0) } catch {}
}
}
// 绩效系数映射
const gradeCoefficients: Record<string, number> = { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
let filled = 0
let totalAmount = 0
for (const entry of entries) {
const basePerfSalary = empPerfSalaryMap.get(entry.employeeId) || 0
if (basePerfSalary <= 0) continue // 绩效工资基数为0则跳过
const perfRecord = perfMap.get(entry.employeeId)
const coefficient = perfRecord ? (gradeCoefficients[perfRecord.grade] || 1.0) : 1.0
const calculatedPerfSalary = Math.round(basePerfSalary * coefficient * 100) / 100
await prisma.batchEntry.update({
where: { id: entry.id },
data: { performanceSalary: calculatedPerfSalary },
})
filled++
totalAmount += calculatedPerfSalary
}
// 重算批次汇总
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = allEntries.reduce((acc, e) => ({
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
totalNetPay: acc.totalNetPay + e.netPay,
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
totalTax: acc.totalTax + e.tax,
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
await prisma.payrollBatch.update({
where: { id: batchId },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
})
res.json({
success: true,
data: {
filled,
totalAmount: Math.round(totalAmount * 100) / 100,
message: filled > 0
? `已填充 ${filled} 人绩效工资,合计 ¥${Math.round(totalAmount * 100) / 100}${performanceRecords.length > 0 ? `${performanceRecords.length} 人有考核记录)` : '(无考核记录,按系数1.0计算)'}`
: '无绩效工资数据(员工未设置绩效工资基数)',
},
})
} catch (err) {
next(err)
}
})
// 获取违纪扣款:按批次月份从 DisciplinaryRecord 查 action=DEDUCTION 的记录,汇总扣款金额填充到 entries.deduction
router.post('/batches/:batchId/fetch-disciplinary', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
// 获取批次所有条目
const entries = await prisma.batchEntry.findMany({
where: { batchId },
select: { id: true, employeeId: true, deduction: true },
})
if (entries.length === 0) {
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
}
// 查询批次月份内的违纪扣款记录(按 violationDate 落在批次月份内)
const monthStart = new Date(batch.month + '-01')
const monthEnd = new Date(monthStart)
monthEnd.setMonth(monthEnd.getMonth() + 1)
const disciplinaryRecords = await prisma.disciplinaryRecord.findMany({
where: {
orgId,
employeeId: { in: entries.map(e => e.employeeId) },
action: 'DEDUCTION',
deductionAmount: { gt: 0 },
violationDate: { gte: monthStart, lt: monthEnd },
},
select: { employeeId: true, deductionAmount: true },
})
// 按员工汇总扣款金额
const deductionMap = new Map<string, number>()
for (const r of disciplinaryRecords) {
deductionMap.set(r.employeeId, (deductionMap.get(r.employeeId) || 0) + r.deductionAmount)
}
let filled = 0
let totalAmount = 0
for (const entry of entries) {
const deduction = deductionMap.get(entry.employeeId)
if (deduction && deduction > 0) {
await prisma.batchEntry.update({
where: { id: entry.id },
data: { deduction: Math.round(deduction * 100) / 100 },
})
filled++
totalAmount += deduction
}
}
// 重算批次汇总
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = allEntries.reduce((acc, e) => ({
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
totalNetPay: acc.totalNetPay + e.netPay,
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
totalTax: acc.totalTax + e.tax,
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
await prisma.payrollBatch.update({
where: { id: batchId },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
})
res.json({
success: true,
data: {
filled,
totalAmount: Math.round(totalAmount * 100) / 100,
message: filled > 0
? `已填充 ${filled} 人违纪扣款,合计 ¥${Math.round(totalAmount * 100) / 100}`
: `${batch.month} 无违纪扣款数据`,
},
})
} catch (err) {
next(err)
}
})
// 获取考勤扣款:按批次月份从 AttendanceConfirmation 查 deductionAmount > 0 的记录,填充到 entries.deduction
router.post('/batches/:batchId/fetch-attendance-deduction', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
// 获取批次所有条目
const entries = await prisma.batchEntry.findMany({
where: { batchId },
select: { id: true, employeeId: true, deduction: true },
})
if (entries.length === 0) {
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
}
// 查询批次月份的考勤确认记录
const confirmations = await prisma.attendanceConfirmation.findMany({
where: {
orgId,
employeeId: { in: entries.map(e => e.employeeId) },
month: batch.month,
deductionAmount: { gt: 0 },
},
select: { employeeId: true, deductionAmount: true },
})
const deductionMap = new Map<string, number>()
for (const c of confirmations) {
deductionMap.set(c.employeeId, c.deductionAmount)
}
let filled = 0
let totalAmount = 0
for (const entry of entries) {
const deduction = deductionMap.get(entry.employeeId)
if (deduction && deduction > 0) {
await prisma.batchEntry.update({
where: { id: entry.id },
data: { deduction: Math.round(deduction * 100) / 100 },
})
filled++
totalAmount += deduction
}
}
// 重算批次汇总
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = allEntries.reduce((acc, e) => ({
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
totalNetPay: acc.totalNetPay + e.netPay,
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
totalTax: acc.totalTax + e.tax,
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
await prisma.payrollBatch.update({
where: { id: batchId },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
})
res.json({
success: true,
data: {
filled,
totalAmount: Math.round(totalAmount * 100) / 100,
message: filled > 0
? `已填充 ${filled} 人考勤扣款,合计 ¥${Math.round(totalAmount * 100) / 100}`
: `${batch.month} 无考勤扣款数据(需先在考勤确认中填写扣款金额)`,
},
})
} catch (err) {
next(err)
}
})
// 获取条目个税计算明细 // 获取条目个税计算明细
router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => { router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
@@ -541,10 +1050,14 @@ router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthR
taxBreakdown.systemSocialOrg = calcResult.systemSocialOrg taxBreakdown.systemSocialOrg = calcResult.systemSocialOrg
taxBreakdown.systemHousingEmp = calcResult.systemHousingEmp taxBreakdown.systemHousingEmp = calcResult.systemHousingEmp
taxBreakdown.systemHousingOrg = calcResult.systemHousingOrg taxBreakdown.systemHousingOrg = calcResult.systemHousingOrg
taxBreakdown.systemSupplementaryHousingEmp = calcResult.systemSupplementaryHousingEmp
taxBreakdown.systemSupplementaryHousingOrg = calcResult.systemSupplementaryHousingOrg
taxBreakdown.actualSocialEmp = entry.socialEmp taxBreakdown.actualSocialEmp = entry.socialEmp
taxBreakdown.actualSocialOrg = entry.socialOrg taxBreakdown.actualSocialOrg = entry.socialOrg
taxBreakdown.actualHousingEmp = entry.housingEmp taxBreakdown.actualHousingEmp = entry.housingEmp
taxBreakdown.actualHousingOrg = entry.housingOrg taxBreakdown.actualHousingOrg = entry.housingOrg
taxBreakdown.actualSupplementaryHousingEmp = entry.supplementaryHousingEmp || 0
taxBreakdown.actualSupplementaryHousingOrg = entry.supplementaryHousingOrg || 0
res.json({ success: true, data: taxBreakdown }) res.json({ success: true, data: taxBreakdown })
} catch (err) { } catch (err) {
next(err) next(err)
@@ -577,8 +1090,11 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
if (!emp) continue if (!emp) continue
let baseSalary = 0 let baseSalary = 0
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) { // 按批次月份判定是否仍在试用期
baseSalary = emp.contracts[0].probationSalary const batchMonthEnd = new Date(`${batch.month}-28T23:59:59`)
const latestContract = emp.contracts?.[0]
if (isInProbation(latestContract, batchMonthEnd) && latestContract.probationSalary > 0) {
baseSalary = latestContract.probationSalary
} else if (emp.monthlySalary) { } else if (emp.monthlySalary) {
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
} }
@@ -592,7 +1108,7 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type) const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId) const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult
const entry = await prisma.batchEntry.create({ const entry = await prisma.batchEntry.create({
data: { data: {
batchId, orgId, employeeId, batchId, orgId, employeeId,
@@ -727,15 +1243,15 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
otherDeduction: entry.otherDeduction || undefined, otherDeduction: entry.otherDeduction || undefined,
} }
// 社保如被手动覆盖,保留覆盖值 // 社保如被手动覆盖,保留覆盖值
// 社保如被手动覆盖,保留覆盖值(通过比较系统值和实际值判断是否覆盖过)
const overrideSocial: any = {} const overrideSocial: any = {}
if (entry.socialEmp !== undefined) overrideSocial.socialEmp = entry.socialEmp // 归档重算时不强制覆盖社保,让系统重新计算(除非之前有手动覆盖)
if (entry.socialOrg !== undefined) overrideSocial.socialOrg = entry.socialOrg // 这里简化处理:不传 overrideSocial,让系统重算
if (entry.housingEmp !== undefined) overrideSocial.housingEmp = entry.housingEmp const prevDeferred = await getPrevDeferred(orgId, entry.employeeId, batch.month)
if (entry.housingOrg !== undefined) overrideSocial.housingOrg = entry.housingOrg const options: any = { prevDeferred }
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options) const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult
await prisma.batchEntry.update({ await prisma.batchEntry.update({
where: { id: entry.id }, where: { id: entry.id },
data: { ...entryData }, data: { ...entryData },
+234 -17
View File
@@ -9,7 +9,7 @@ import { signAccessToken, verifyAccessToken } from '../lib/jwt'
import { authMiddleware, AuthRequest } from '../middleware/auth' import { authMiddleware, AuthRequest } from '../middleware/auth'
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema' import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore' import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
import { createEvidence } from '../services/evidence.service' import { createEvidence, appendEvidence } from '../services/evidence.service'
const router = Router() const router = Router()
@@ -36,18 +36,30 @@ function portalAuth(req: Request, res: Response, next: NextFunction) {
router.post('/login', async (req, res, next) => { router.post('/login', async (req, res, next) => {
try { try {
const data = portalLoginSchema.parse(req.body) const data = portalLoginSchema.parse(req.body)
const employee = await prisma.employee.findFirst({ // 查找所有匹配手机号的在职员工(可能跨组织)
const employees = await prisma.employee.findMany({
where: { phone: data.phone, status: 'ACTIVE' }, where: { phone: data.phone, status: 'ACTIVE' },
select: { id: true, name: true, department: true, orgId: true, passwordHash: true },
}) })
if (!employee || !employee.passwordHash) { if (employees.length === 0) {
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } }) return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
} }
const valid = await bcrypt.compare(data.password, employee.passwordHash) // 逐个校验密码,找到匹配的员工
if (!valid) { let matchedEmployee = null
for (const emp of employees) {
if (emp.passwordHash) {
const valid = await bcrypt.compare(data.password, emp.passwordHash)
if (valid) {
matchedEmployee = emp
break
}
}
}
if (!matchedEmployee) {
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } }) return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
} }
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' }) const token = signAccessToken({ id: matchedEmployee.id, orgId: matchedEmployee.orgId, role: 'EMPLOYEE' })
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } }) res.json({ success: true, data: { token, employee: { id: matchedEmployee.id, name: matchedEmployee.name, department: matchedEmployee.department } } })
} catch (err) { } catch (err) {
next(err) next(err)
} }
@@ -94,10 +106,16 @@ router.post('/verify-code', async (req, res, next) => {
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } }) return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
} }
await deleteCode(data.phone) await deleteCode(data.phone)
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } }) // 查找所有匹配手机号的在职员工(可能跨组织)
if (!employee) { const employees = await prisma.employee.findMany({
where: { phone: data.phone, status: 'ACTIVE' },
select: { id: true, name: true, department: true, orgId: true },
})
if (employees.length === 0) {
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
} }
// 如果只有一个匹配,直接登录
const employee = employees[0]
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' }) const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } }) res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
} catch (err) { } catch (err) {
@@ -105,6 +123,38 @@ router.post('/verify-code', async (req, res, next) => {
} }
}) })
/**
* 员工修改自己的密码
* POST /portal/change-password body: { oldPassword, newPassword }
*/
router.post('/change-password', portalAuth, async (req: any, res, next) => {
try {
const { oldPassword, newPassword } = req.body
if (!oldPassword || !newPassword) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请输入旧密码和新密码' } })
}
if (newPassword.length < 6) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '新密码至少6位' } })
}
const employee = await prisma.employee.findFirst({
where: { id: req.employee.id },
select: { id: true, passwordHash: true },
})
if (!employee || !employee.passwordHash) {
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '当前未设置密码,请联系管理员重置' } })
}
const valid = await bcrypt.compare(oldPassword, employee.passwordHash)
if (!valid) {
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '旧密码错误' } })
}
const passwordHash = await bcrypt.hash(newPassword, 10)
await prisma.employee.update({ where: { id: employee.id }, data: { passwordHash } })
res.json({ success: true, data: { message: '密码修改成功' } })
} catch (err) {
next(err)
}
})
// 工资条 // 工资条
router.get('/payslip', portalAuth, async (req: any, res, next) => { router.get('/payslip', portalAuth, async (req: any, res, next) => {
try { try {
@@ -940,7 +990,7 @@ router.get('/resignation/:id/certificate', portalAuth, async (req: any, res, nex
} }
} else { } else {
content = `<h1>解除/终止劳动合同证明书</h1> content = `<h1>解除/终止劳动合同证明书</h1>
<p>兹证明 ${variables.employeeName}身份证号${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate}${reason} 原因,正式解除/终止劳动合同。</p> <p>兹证明 ${variables.employeeName}证件号码${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate}${reason} 原因,正式解除/终止劳动合同。</p>
<p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p> <p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p>
<p>特此证明。</p> <p>特此证明。</p>
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>` <div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>`
@@ -1029,6 +1079,12 @@ router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
// ========== 员工端:电子签署 ========== // ========== 员工端:电子签署 ==========
// 查看自己的签署记录列表 // 查看自己的签署记录列表
// ========== 员工端:电子签署(验证码确认 + 证据链) ==========
/**
* 我的电子签署列表
* - 自动将过期的 PENDING 记录标记为 EXPIRED
*/
router.get('/esign', portalAuth, async (req: any, res, next) => { router.get('/esign', portalAuth, async (req: any, res, next) => {
try { try {
const { id: employeeId, orgId } = req.employee const { id: employeeId, orgId } = req.employee
@@ -1036,11 +1092,30 @@ router.get('/esign', portalAuth, async (req: any, res, next) => {
where: { employeeId, orgId }, where: { employeeId, orgId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })
// 自动处理过期:PENDING 且已超过 expiredAt 的记录标记为 EXPIRED
const now = new Date()
const expiredIds = records
.filter(r => r.status === 'PENDING' && r.expiredAt && r.expiredAt < now)
.map(r => r.id)
if (expiredIds.length > 0) {
await prisma.eSignRecord.updateMany({
where: { id: { in: expiredIds } },
data: { status: 'EXPIRED' },
})
expiredIds.forEach(id => {
const r = records.find(rec => rec.id === id)
if (r) r.status = 'EXPIRED'
})
}
res.json({ success: true, data: records }) res.json({ success: true, data: records })
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
// 查看签署详情 /**
* 查看签署详情(含文件内容)
*/
router.get('/esign/:id', portalAuth, async (req: any, res, next) => { router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
try { try {
const { id: employeeId, orgId } = req.employee const { id: employeeId, orgId } = req.employee
@@ -1048,37 +1123,179 @@ router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
where: { id: req.params.id, employeeId, orgId }, where: { id: req.params.id, employeeId, orgId },
}) })
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } }) if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
// 检查过期
if (record.status === 'PENDING' && record.expiredAt && record.expiredAt < new Date()) {
const updated = await prisma.eSignRecord.update({
where: { id: record.id },
data: { status: 'EXPIRED' },
})
return res.json({ success: true, data: updated })
}
res.json({ success: true, data: record }) res.json({ success: true, data: record })
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
// 员工签署操作(预留:对接易签宝后跳转到签署页面或提交签署结果) /**
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => { * 发送签署验证码
* - 通过手机号发送6位验证码
* - 验证码5分钟有效,最多5次错误
*/
router.post('/esign/:id/send-code', portalAuth, async (req: any, res, next) => {
try { try {
const { id: employeeId, orgId } = req.employee const { id: employeeId, orgId } = req.employee
const record = await prisma.eSignRecord.findFirst({ const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' }, where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
include: { employee: { select: { phone: true, name: true } } },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
if (!record.employee.phone) {
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '未登记手机号,无法发送验证码' } })
}
// 限流:同一记录60秒内只能发一次
const rateLimitKey = `esign-rate-${record.id}`
const allowed = await checkRateLimit(rateLimitKey, 60 * 1000)
if (!allowed) {
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '请求过于频繁,请60秒后重试' } })
}
const code = Math.random().toString().slice(2, 8)
await setCode(`esign-${record.id}`, code)
// 追加证据链 — 验证码发送事件
const evidence = await prisma.evidenceChain.findFirst({
where: { orgId, refId: record.id, category: 'CONTRACT_SIGN' },
})
if (evidence) {
await appendEvidence(orgId, evidence.id, {
action: '发送签署验证码',
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent'] as string,
location: `手机号:${record.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}`,
}).catch(() => {})
}
res.json({
success: true,
data: {
code, // 开发阶段直接返回,生产环境通过短信发送
message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)',
phone: record.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
},
})
} catch (err) { next(err) }
})
/**
* 员工签署操作(验证码确认 + 证据链 + 回写合同)
* - 校验验证码
* - 更新签署状态为 COMPLETED
* - 追加证据链(签署完成事件,含IP/UA/验证码/时间戳)
* - 回写合同 signMethod 和 electronicContractUrl
*/
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const { verifyCode } = req.body || {}
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
include: { employee: { select: { name: true, phone: true } } },
}) })
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } }) if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
// 预留:对接易签宝后,此处应跳转到易签宝签署页面或接收签署结果 // 检查过期
// 当前框架阶段:直接标记为已签署 if (record.expiredAt && record.expiredAt < new Date()) {
await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: 'EXPIRED' } })
return res.status(400).json({ success: false, error: { code: 'EXPIRED', message: '签署链接已过期' } })
}
// 校验验证码
if (!verifyCode) {
return res.status(400).json({ success: false, error: { code: 'NO_CODE', message: '请输入验证码' } })
}
const stored = await getCode(`esign-${record.id}`)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.failCount >= 5) {
await deleteCode(`esign-${record.id}`)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== verifyCode) {
await updateCode(`esign-${record.id}`, { failCount: stored.failCount + 1 })
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
}
await deleteCode(`esign-${record.id}`)
// 签署完成
const userAgent = req.headers['user-agent'] || ''
const signEvidence = JSON.stringify({
ip: req.ip,
userAgent,
timestamp: new Date().toISOString(),
verifyCode: true,
employeeName: record.employee.name,
phone: record.employee.phone,
})
const updated = await prisma.eSignRecord.update({ const updated = await prisma.eSignRecord.update({
where: { id: record.id }, where: { id: record.id },
data: { data: {
status: 'COMPLETED', status: 'COMPLETED',
completedAt: new Date(), completedAt: new Date(),
// 保存签署证据到 callbackData(作为签署凭证)
callbackData: { signEvidence, signedAt: new Date().toISOString() } as any,
}, },
}) })
// 如果关联了合同,更新合同签署信息 // 回写合同
if (record.contractId) { if (record.contractId) {
await prisma.laborContract.update({ await prisma.laborContract.update({
where: { id: record.contractId }, where: { id: record.contractId },
data: { signMethod: 'ELECTRONIC' }, data: {
signMethod: 'ELECTRONIC',
// 签署证据写入 attachmentName
attachmentName: `e-sign:${new Date().toISOString()}|evidence:${signEvidence}`,
},
}) })
} }
// 追加证据链 — 签署完成事件
const evidence = await prisma.evidenceChain.findFirst({
where: { orgId, refId: record.id, category: 'CONTRACT_SIGN' },
})
if (evidence) {
await appendEvidence(orgId, evidence.id, {
action: `员工签署完成:${record.documentTitle}`,
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent,
smsCode: verifyCode,
location: `签署人:${record.employee.name},验证码已验证`,
}).catch(() => {})
} else {
// 证据链不存在时创建新的
await createEvidence({
orgId,
category: 'CONTRACT_SIGN',
refId: record.id,
employeeId,
events: [{
action: `员工签署完成:${record.documentTitle}`,
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent,
smsCode: verifyCode,
location: `签署人:${record.employee.name},验证码已验证`,
}],
createdBy: employeeId,
}).catch(() => {})
}
res.json({ success: true, data: updated, message: '签署成功' }) res.json({ success: true, data: updated, message: '签署成功' })
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
+74
View File
@@ -0,0 +1,74 @@
/**
* 岗位字典路由
* 提供岗位的增删改查
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
const createPositionSchema = z.object({
name: z.string().min(1, '岗位名称必填'),
departmentId: z.string().nullable().optional(),
headcount: z.number().int().min(0).default(0),
level: z.string().max(20).optional(),
description: z.string().max(200).optional(),
})
/** 获取岗位列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const positions = await prisma.position.findMany({
where: { orgId: req.user!.orgId! },
orderBy: { createdAt: 'asc' },
include: { department: { select: { id: true, name: true } } },
})
res.json({ success: true, data: positions })
} catch (err) {
next(err)
}
})
/** 创建岗位 */
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createPositionSchema.parse(req.body)
const position = await prisma.position.create({
data: {
...data,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: position })
} catch (err) {
next(err)
}
})
/** 更新岗位 */
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const data = createPositionSchema.partial().parse(req.body)
const position = await prisma.position.update({ where: { id }, data })
res.json({ success: true, data: position })
} catch (err) {
next(err)
}
})
/** 删除岗位 */
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
await prisma.position.delete({ where: { id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+71 -19
View File
@@ -46,6 +46,21 @@ router.get('/departments', authMiddleware, async (req: AuthRequest, res, next) =
} }
}) })
/** 花名册可选职务列表(从在职员工中提取去重) */
router.get('/positions', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
select: { position: true },
distinct: 'position',
})
const positions = employees.map((e) => e.position).filter(Boolean).sort()
res.json({ success: true, data: positions })
} catch (err) {
next(err)
}
})
// 花名册列表(含汇总信息,支持分页和过滤) // 花名册列表(含汇总信息,支持分页和过滤)
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try { try {
@@ -55,6 +70,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc. const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
const department = req.query.department as string const department = req.query.department as string
const position = req.query.position as string
const skip = (page - 1) * pageSize const skip = (page - 1) * pageSize
// 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职 // 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职
@@ -71,6 +87,9 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
if (department) { if (department) {
whereBase.department = department whereBase.department = department
} }
if (position) {
whereBase.position = position
}
if (search && !isIdCardSearch) { if (search && !isIdCardSearch) {
whereBase.OR = [ whereBase.OR = [
{ name: { contains: search } }, { name: { contains: search } },
@@ -81,7 +100,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
// 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤) // 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤)
if (status === 'RESIGNED') { if (status === 'RESIGNED') {
whereBase.status = 'RESIGNED' whereBase.status = 'RESIGNED'
} else if (status === 'PRE_HIRE') { } else if (status === 'PRE_HIRE' || status === 'PRE_ONBOARD') {
// 预入职:ACTIVE 状态但入职日期在未来(按入职日期自动判定)
whereBase.status = 'ACTIVE' whereBase.status = 'ACTIVE'
whereBase.hireDate = { gt: todayEnd } whereBase.hireDate = { gt: todayEnd }
} else if (status === 'ACTIVE') { } else if (status === 'ACTIVE') {
@@ -94,7 +114,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
whereBase.contracts = { none: {} } whereBase.contracts = { none: {} }
} }
// 当有 contractStatus(非 unsigned)筛选或身份证号搜索时,需要先查全部再过滤后分页 // 当有 contractStatus(非 unsigned)筛选或证件号码搜索时,需要先查全部再过滤后分页
const needPostFilter = (!!contractStatus && contractStatus !== 'unsigned') || isIdCardSearch const needPostFilter = (!!contractStatus && contractStatus !== 'unsigned') || isIdCardSearch
const [dbTotal, employees] = await Promise.all([ const [dbTotal, employees] = await Promise.all([
@@ -170,7 +190,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
const isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) const isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today)
const isPreHire = !isResigned && e.hireDate > todayEnd const isPreHire = !isResigned && e.hireDate > todayEnd
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE') const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
// 身份证号脱敏显示 // 证件号码脱敏显示
let idCardMasked: string | null = null let idCardMasked: string | null = null
if (e.idCardNumber) { if (e.idCardNumber) {
try { try {
@@ -200,6 +220,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
idCardMasked, idCardMasked,
idCardNumber: safeDecryptStr(e.idCardNumber), idCardNumber: safeDecryptStr(e.idCardNumber),
monthlySalary: safeDecrypt(e.monthlySalary), monthlySalary: safeDecrypt(e.monthlySalary),
baseSalary: e.baseSalary ? safeDecrypt(e.baseSalary) : null,
performanceSalary: e.performanceSalary ? safeDecrypt(e.performanceSalary) : null,
socialInsBase: e.socialInsBase, socialInsBase: e.socialInsBase,
housingFundBase: e.housingFundBase, housingFundBase: e.housingFundBase,
socialInsCalc: (() => { socialInsCalc: (() => {
@@ -223,7 +245,13 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
riskLevel: contractInfo.riskLevel, riskLevel: contractInfo.riskLevel,
socialInsuranceStatus: (() => { socialInsuranceStatus: (() => {
const sr = (e as any).socialInsRecords?.[0] const sr = (e as any).socialInsRecords?.[0]
if (!sr) return null // 劳务协议/实习协议:不缴纳社保,返回 null(前端显示"—"
const isNoSocialContract = latestContract && ['LABOR', 'INTERNSHIP'].includes(latestContract.contractType)
if (isNoSocialContract) return null
if (!sr) {
// 在职且应缴社保但无社保记录 → 待办理
return 'PENDING'
}
// endMonth 为 null 表示在保,否则已停保 // endMonth 为 null 表示在保,否则已停保
if (sr.endMonth) return 'SUSPENDED' if (sr.endMonth) return 'SUSPENDED'
return 'ACTIVE' return 'ACTIVE'
@@ -250,7 +278,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
result = result.filter((e) => e.contractStatus === contractStatus) result = result.filter((e) => e.contractStatus === contractStatus)
} }
// 身份证号后N位搜索:在内存中过滤(idCardNumber 已解密为明文) // 证件号码后N位搜索:在内存中过滤(idCardNumber 已解密为明文)
if (isIdCardSearch) { if (isIdCardSearch) {
result = result.filter((e: any) => { result = result.filter((e: any) => {
if (!e.idCardNumber) return false if (!e.idCardNumber) return false
@@ -347,7 +375,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
if (found) monthlyProcessRecords.push(recordData) if (found) monthlyProcessRecords.push(recordData)
} }
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee const { monthlySalary, baseSalary, performanceSalary, bankAccount, idCardNumber, ...rest } = employee
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
const dynamicStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE' const dynamicStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE'
@@ -357,6 +385,8 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
...rest, ...rest,
status: dynamicStatus, status: dynamicStatus,
monthlySalary: safeDecrypt(monthlySalary), monthlySalary: safeDecrypt(monthlySalary),
baseSalary: baseSalary ? safeDecrypt(baseSalary) : null,
performanceSalary: performanceSalary ? safeDecrypt(performanceSalary) : null,
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null, bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
idCardNumber: safeDecryptStr(idCardNumber), idCardNumber: safeDecryptStr(idCardNumber),
monthlyProcessRecords, monthlyProcessRecords,
@@ -935,7 +965,7 @@ router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest,
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => { router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
try { try {
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body const { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
const record = await prisma.disciplinaryRecord.create({ const record = await prisma.disciplinaryRecord.create({
data: { data: {
orgId: req.user!.orgId, orgId: req.user!.orgId,
@@ -946,6 +976,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
severity: severity || 'WARNING', severity: severity || 'WARNING',
action: action || 'ORAL_WARNING', action: action || 'ORAL_WARNING',
actionDetail, actionDetail,
deductionAmount: Number(deductionAmount) || 0,
employeeAck: employeeAck || false, employeeAck: employeeAck || false,
ackDate: ackDate ? new Date(ackDate) : null, ackDate: ackDate ? new Date(ackDate) : null,
ackMethod, ackMethod,
@@ -969,7 +1000,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try { try {
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body const { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
const record = await prisma.disciplinaryRecord.findFirst({ const record = await prisma.disciplinaryRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId }, where: { id: req.params.recordId, orgId: req.user!.orgId },
}) })
@@ -983,6 +1014,7 @@ router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: Au
severity, severity,
action, action,
actionDetail, actionDetail,
deductionAmount: deductionAmount !== undefined ? Number(deductionAmount) || 0 : undefined,
employeeAck, employeeAck,
ackDate: ackDate ? new Date(ackDate) : null, ackDate: ackDate ? new Date(ackDate) : null,
ackMethod, ackMethod,
@@ -1025,7 +1057,7 @@ router.get('/:employeeId/disciplinary/:recordId/certificate', authMiddleware, as
const content = `违纪确认证明 const content = `违纪确认证明
兹证明 ${record.employee.name}身份证号${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为: 兹证明 ${record.employee.name}证件号码${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为:
违纪类型:${typeMap[record.violationType] || record.violationType} 违纪类型:${typeMap[record.violationType] || record.violationType}
严重程度:${severityMap[record.severity] || record.severity} 严重程度:${severityMap[record.severity] || record.severity}
@@ -1386,7 +1418,7 @@ function prevMonth(month: string): string {
// 调薪 // 调薪
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => { router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
try { try {
const { newSalary, effectiveMonth, reason } = req.body const { newSalary, baseSalary, performanceSalary, effectiveMonth, reason } = req.body
const employee = await prisma.employee.findFirst({ const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId }, where: { id: req.params.id, orgId: req.user!.orgId },
}) })
@@ -1420,10 +1452,13 @@ router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res,
}, },
}) })
// 同步 Employee 便捷字段 // 同步 Employee 便捷字段(含工资结构)
const updateData: any = { monthlySalary: encrypt(String(newSalary)) }
if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0))
if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0))
await prisma.employee.update({ await prisma.employee.update({
where: { id: req.params.id }, where: { id: req.params.id },
data: { monthlySalary: encrypt(String(newSalary)) }, data: updateData,
}) })
await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary }) await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary })
@@ -1442,10 +1477,10 @@ router.get('/:id/salary-records', authMiddleware, async (req: AuthRequest, res,
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
// 调部门 // 调动(部门+职务变动)
router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => { router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => {
try { try {
const { newDepartment, effectiveMonth, reason } = req.body const { newDepartment, newPosition, departmentId, effectiveMonth, reason } = req.body
const employee = await prisma.employee.findFirst({ const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId }, where: { id: req.params.id, orgId: req.user!.orgId },
}) })
@@ -1454,22 +1489,35 @@ router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, r
} }
const oldDepartment = employee.department const oldDepartment = employee.department
const oldPosition = employee.position || null
const effMonth = effectiveMonth || dateToMonth(new Date()) const effMonth = effectiveMonth || dateToMonth(new Date())
const prevEffMonth = prevMonth(effMonth) const prevEffMonth = prevMonth(effMonth)
// 校验 departmentId 是否属于当前组织
let deptName = newDepartment
if (departmentId) {
const dept = await prisma.department.findFirst({ where: { id: departmentId, orgId: req.user!.orgId } })
if (!dept) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '目标部门不存在' } })
}
deptName = dept.name
}
// 关闭之前有效记录 // 关闭之前有效记录
await prisma.employeeDepartmentRecord.updateMany({ await prisma.employeeDepartmentRecord.updateMany({
where: { employeeId: req.params.id, endMonth: null }, where: { employeeId: req.params.id, endMonth: null },
data: { endMonth: prevEffMonth }, data: { endMonth: prevEffMonth },
}) })
// 创建新部门记录 // 创建新调动记录
const record = await prisma.employeeDepartmentRecord.create({ const record = await prisma.employeeDepartmentRecord.create({
data: { data: {
orgId: req.user!.orgId, orgId: req.user!.orgId,
employeeId: req.params.id, employeeId: req.params.id,
oldDepartment, oldDepartment,
newDepartment, newDepartment: deptName,
oldPosition,
newPosition: newPosition || null,
effectiveMonth: effMonth, effectiveMonth: effMonth,
endMonth: null, endMonth: null,
changeType: 'TRANSFER', changeType: 'TRANSFER',
@@ -1478,13 +1526,17 @@ router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, r
}, },
}) })
// 同步 Employee 便捷字段 // 同步 Employee 字段department 文本 + departmentId 关联 + position 职务)
await prisma.employee.update({ await prisma.employee.update({
where: { id: req.params.id }, where: { id: req.params.id },
data: { department: newDepartment }, data: {
department: deptName,
departmentId: departmentId || null,
position: newPosition || employee.position,
},
}) })
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment }) await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment: deptName, oldPosition, newPosition })
res.json({ success: true, data: record }) res.json({ success: true, data: record })
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
+426 -3
View File
@@ -14,6 +14,8 @@ const socialConfigFields = {
pensionEmp: z.number().optional(), pensionEmp: z.number().optional(),
medicalOrg: z.number().optional(), medicalOrg: z.number().optional(),
medicalEmp: z.number().optional(), medicalEmp: z.number().optional(),
medicalOrgExtra: z.number().min(0).optional(),
medicalEmpExtra: z.number().min(0).optional(),
unemploymentOrg: z.number().optional(), unemploymentOrg: z.number().optional(),
unemploymentEmp: z.number().optional(), unemploymentEmp: z.number().optional(),
injuryOrg: z.number().optional(), injuryOrg: z.number().optional(),
@@ -23,6 +25,7 @@ const socialConfigFields = {
medicalBaseMin: z.number().optional(), medicalBaseMin: z.number().optional(),
medicalBaseMax: z.number().optional(), medicalBaseMax: z.number().optional(),
extraInsurances: z.any().optional(), extraInsurances: z.any().optional(),
minWage: z.number().min(0).optional(),
} }
const housingConfigFields = { const housingConfigFields = {
@@ -34,6 +37,426 @@ const housingConfigFields = {
baseMax: z.number().optional(), baseMax: z.number().optional(),
} }
// ==========================================
// 账户管理 API(新)
// ==========================================
const accountSchema = z.object({
type: z.enum(['SOCIAL', 'HOUSING']),
name: z.string().min(1),
city: z.string().min(1),
accountNo: z.string().optional(),
bankName: z.string().optional(),
bankAccount: z.string().optional(),
orgName: z.string().optional(),
orgCode: z.string().optional(),
accountType: z.string().optional(),
isDefault: z.boolean().optional(),
remark: z.string().optional(),
})
// 账户列表
router.get('/accounts', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const type = req.query.type as string | undefined
const where: any = { orgId }
if (type) where.type = type
const accounts = await prisma.socialAccount.findMany({
where,
orderBy: [{ type: 'asc' }, { isDefault: 'desc' }, { city: 'asc' }],
include: {
_count: { select: { socialRecords: true, housingRecords: true, deptSocialAccounts: true, deptHousingAccounts: true } },
},
})
res.json({ success: true, data: accounts })
} catch (err) { next(err) }
})
// 新建账户
router.post('/accounts', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const data = accountSchema.parse(req.body)
// 如果设为默认,先取消同 type 其他默认
if (data.isDefault) {
await prisma.socialAccount.updateMany({ where: { orgId, type: data.type, isDefault: true }, data: { isDefault: false } })
}
const account = await prisma.socialAccount.create({
data: { ...data, orgId, createdBy: req.user!.id },
})
res.json({ success: true, data: account })
} catch (err) { next(err) }
})
// 编辑账户
router.put('/accounts/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const data = accountSchema.partial().parse(req.body)
if (data.isDefault) {
const account = await prisma.socialAccount.findUnique({ where: { id: req.params.id } })
await prisma.socialAccount.updateMany({ where: { orgId, type: account?.type, isDefault: true, id: { not: req.params.id } }, data: { isDefault: false } })
}
const account = await prisma.socialAccount.update({
where: { id: req.params.id },
data,
})
res.json({ success: true, data: account })
} catch (err) { next(err) }
})
// 删除账户(无关联记录时可删)
router.delete('/accounts/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const account = await prisma.socialAccount.findFirst({ where: { id: req.params.id, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
// 检查是否有关联记录
const [socialCount, housingCount, deptCount] = await Promise.all([
prisma.employeeSocialInsRecord.count({ where: { accountId: account.id } }),
prisma.employeeHousingFundRecord.count({ where: { accountId: account.id } }),
prisma.department.count({ where: { OR: [{ socialAccountId: account.id }, { housingAccountId: account.id }] } }),
])
if (socialCount + housingCount + deptCount > 0) {
return res.status(400).json({ success: false, error: { code: 'IN_USE', message: `账户仍关联 ${socialCount + housingCount} 条参保记录、${deptCount} 个部门,无法删除` } })
}
await prisma.socialAccount.delete({ where: { id: account.id } })
res.json({ success: true, data: { message: '已删除' } })
} catch (err) { next(err) }
})
// 设为默认账户
router.put('/accounts/:id/default', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const account = await prisma.socialAccount.findFirst({ where: { id: req.params.id, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
await prisma.socialAccount.updateMany({ where: { orgId, type: account.type, isDefault: true }, data: { isDefault: false } })
await prisma.socialAccount.update({ where: { id: account.id }, data: { isDefault: true } })
res.json({ success: true, data: { message: '已设为默认' } })
} catch (err) { next(err) }
})
// ==========================================
// 年度标准 API(新,按 accountId
// ==========================================
// 按账户获取年度标准列表
router.get('/accounts/:accountId/standards', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId } = req.params
const standards = await prisma.socialYearStandard.findMany({
where: { orgId, accountId },
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: standards })
} catch (err) { next(err) }
})
// 按账户获取当前生效标准
router.get('/accounts/:accountId/current-standard', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId } = req.params
const standard = await prisma.socialYearStandard.findFirst({
where: { orgId, accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: standard })
} catch (err) { next(err) }
})
// 按账户获取继承数据(当前标准 → 旧社保配置 → 默认值),用于新建年度标准初始值
router.get('/accounts/:accountId/inherit-config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId } = req.params
const account = await prisma.socialAccount.findFirst({ where: { id: accountId, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
// 1. 优先用当前年度标准
const currentStandard = await prisma.socialYearStandard.findFirst({
where: { orgId, accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
if (currentStandard) {
return res.json({ success: true, data: { ...currentStandard, source: 'current_standard' } })
}
// 2. 回退到旧社保/公积金配置表(按城市)
if (account.type === 'HOUSING') {
const oldConfig = await prisma.housingFundConfig.findFirst({
where: { orgId, city: account.city },
orderBy: { effectiveFrom: 'desc' },
})
if (oldConfig) {
return res.json({ success: true, data: { ...oldConfig, source: 'old_housing_config' } })
}
} else {
const oldConfig = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, city: account.city },
orderBy: { effectiveFrom: 'desc' },
})
if (oldConfig) {
return res.json({ success: true, data: { ...oldConfig, source: 'old_social_config' } })
}
}
// 3. 都没有,返回 null
res.json({ success: true, data: null })
} catch (err) { next(err) }
})
// 按账户+月份获取适用标准
router.get('/accounts/:accountId/standard-by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId, month } = req.params
const standard = await prisma.socialYearStandard.findFirst({
where: {
orgId, accountId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
if (!standard) {
const current = await prisma.socialYearStandard.findFirst({ where: { orgId, accountId, isCurrent: true } })
return res.json({ success: true, data: current })
}
res.json({ success: true, data: standard })
} catch (err) { next(err) }
})
// 新建年度标准
const yearStandardSchema = z.object({
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
pensionOrg: z.number().optional(),
pensionEmp: z.number().optional(),
medicalOrg: z.number().optional(),
medicalEmp: z.number().optional(),
medicalOrgExtra: z.number().min(0).optional(),
medicalEmpExtra: z.number().min(0).optional(),
unemploymentOrg: z.number().optional(),
unemploymentEmp: z.number().optional(),
injuryOrg: z.number().optional(),
maternityOrg: z.number().optional(),
baseMin: z.number().optional(),
baseMax: z.number().optional(),
medicalBaseMin: z.number().optional(),
medicalBaseMax: z.number().optional(),
extraInsurances: z.any().optional(),
housingOrg: z.number().optional(),
housingEmp: z.number().optional(),
housingBaseMin: z.number().min(0).optional(),
housingBaseMax: z.number().min(0).optional(),
minWage: z.number().min(0).optional(),
})
router.post('/accounts/:accountId/standards', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId } = req.params
const data = yearStandardSchema.parse(req.body)
const account = await prisma.socialAccount.findFirst({ where: { id: accountId, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
// 检查是否已存在同 effectiveFrom 的标准
const existing = await prisma.socialYearStandard.findFirst({ where: { accountId, effectiveFrom: data.effectiveFrom } })
if (existing) {
// 已存在:更新而非报错
const standard = await prisma.socialYearStandard.update({
where: { id: existing.id },
data: { ...data, isCurrent: true, effectiveTo: null },
})
// 将其他当前版本标记为失效
await prisma.socialYearStandard.updateMany({
where: { accountId, isCurrent: true, id: { not: existing.id } },
data: { isCurrent: false, effectiveTo: data.effectiveFrom },
})
return res.json({ success: true, data: standard })
}
// 将旧当前版本标记为失效
const current = await prisma.socialYearStandard.findFirst({ where: { accountId, isCurrent: true } })
if (current) {
const prevMonth = data.effectiveFrom
await prisma.socialYearStandard.update({
where: { id: current.id },
data: { isCurrent: false, effectiveTo: prevMonth },
})
}
const standard = await prisma.socialYearStandard.create({
data: { ...data, orgId, accountId, isCurrent: true, createdBy: req.user!.id },
})
res.json({ success: true, data: standard })
} catch (err) { next(err) }
})
/**
*
*/
router.put('/accounts/:accountId/min-wage', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId } = req.params
const { minWage } = req.body as { minWage: number }
if (minWage === undefined || minWage < 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: 'minWage 必须为非负数' } })
}
const account = await prisma.socialAccount.findFirst({ where: { id: accountId, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
const standard = await prisma.socialYearStandard.findFirst({ where: { accountId, isCurrent: true } })
if (!standard) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '当前年度标准不存在' } })
const updated = await prisma.socialYearStandard.update({ where: { id: standard.id }, data: { minWage } })
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
// 账户关联根部门(level=0)批量设置
router.put('/accounts/:id/departments', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { id } = req.params
const { departmentIds } = req.body as { departmentIds: string[] }
const account = await prisma.socialAccount.findFirst({ where: { id, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
// 先清除该账户的所有部门关联
const field = account.type === 'SOCIAL' ? 'socialAccountId' : 'housingAccountId'
await prisma.department.updateMany({
where: { orgId, [field]: id },
data: { [field]: null },
})
// 批量设置新关联(仅 level=0 根部门)
if (departmentIds && departmentIds.length > 0) {
await prisma.department.updateMany({
where: { id: { in: departmentIds }, orgId, level: 0 },
data: { [field]: id },
})
}
res.json({ success: true, data: { message: `已关联 ${departmentIds?.length || 0} 个根部门` } })
} catch (err) { next(err) }
})
// 获取账户已关联的根部门列表
router.get('/accounts/:id/departments', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { id } = req.params
const account = await prisma.socialAccount.findFirst({ where: { id, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
const field = account.type === 'SOCIAL' ? 'socialAccountId' : 'housingAccountId'
const departments = await prisma.department.findMany({
where: { orgId, level: 0, [field]: id },
select: { id: true, name: true },
})
res.json({ success: true, data: departments })
} catch (err) { next(err) }
})
// 按部门获取适用账户(选部门时自动带出)
router.get('/department-account/:departmentId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { departmentId } = req.params
// 向上找到 level=0 的根部门
let currentDept: any = await prisma.department.findFirst({ where: { id: departmentId, orgId } })
if (!currentDept) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '部门不存在' } })
while (currentDept && currentDept.level > 0 && currentDept.parentId) {
currentDept = await prisma.department.findUnique({ where: { id: currentDept.parentId } })
}
const rootDeptId = currentDept?.id || null
let socialAccount: any = null
let housingAccount: any = null
if (rootDeptId) {
const rootDept = await prisma.department.findUnique({
where: { id: rootDeptId },
include: { socialAccount: true, housingAccount: true },
})
socialAccount = rootDept?.socialAccount || null
housingAccount = rootDept?.housingAccount || null
}
// 回退到公司默认账户
if (!socialAccount) {
socialAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'SOCIAL', isDefault: true } })
}
if (!housingAccount) {
housingAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'HOUSING', isDefault: true } })
}
// 获取当前生效标准
const currentMonth = new Date().toISOString().slice(0, 7)
let socialStandard: any = null
let housingStandard: any = null
if (socialAccount) {
socialStandard = await prisma.socialYearStandard.findFirst({
where: { accountId: socialAccount.id, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
if (housingAccount) {
housingStandard = await prisma.socialYearStandard.findFirst({
where: { accountId: housingAccount.id, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
res.json({ success: true, data: { socialAccount, housingAccount, socialStandard, housingStandard } })
} catch (err) { next(err) }
})
// 按员工获取适用账户(通过根部门 level=0 继承,不向下到普通部门)
router.get('/employee-account/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { employeeId } = req.params
const emp = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { dept: true },
})
if (!emp) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
// 向上找到 level=0 的根部门(代表分公司/子公司)
let currentDept: any = emp.dept
while (currentDept && currentDept.level > 0 && currentDept.parentId) {
currentDept = await prisma.department.findUnique({
where: { id: currentDept.parentId },
})
}
const rootDeptId = currentDept?.id || null
// 从根部门获取关联账户
let socialAccount: any = null
let housingAccount: any = null
if (rootDeptId) {
const rootDept = await prisma.department.findUnique({
where: { id: rootDeptId },
include: { socialAccount: true, housingAccount: true },
})
socialAccount = rootDept?.socialAccount || null
housingAccount = rootDept?.housingAccount || null
}
// 回退到公司默认账户
if (!socialAccount) {
socialAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'SOCIAL', isDefault: true } })
}
if (!housingAccount) {
housingAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'HOUSING', isDefault: true } })
}
res.json({ success: true, data: { socialAccount, housingAccount } })
} catch (err) { next(err) }
})
// 获取当前生效版本(支持按城市筛选) // 获取当前生效版本(支持按城市筛选)
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => { router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
@@ -398,8 +821,8 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
const pensionOrg = actualBase * config.pensionOrg / 100 const pensionOrg = actualBase * config.pensionOrg / 100
const pensionEmp = actualBase * config.pensionEmp / 100 const pensionEmp = actualBase * config.pensionEmp / 100
const medicalOrg = medicalBase * config.medicalOrg / 100 const medicalOrg = medicalBase * config.medicalOrg / 100 + (config.medicalOrgExtra || 0)
const medicalEmp = medicalBase * config.medicalEmp / 100 const medicalEmp = medicalBase * config.medicalEmp / 100 + (config.medicalEmpExtra || 0)
const unemploymentOrg = actualBase * config.unemploymentOrg / 100 const unemploymentOrg = actualBase * config.unemploymentOrg / 100
const unemploymentEmp = actualBase * config.unemploymentEmp / 100 const unemploymentEmp = actualBase * config.unemploymentEmp / 100
const injuryOrg = actualBase * config.injuryOrg / 100 const injuryOrg = actualBase * config.injuryOrg / 100
@@ -788,7 +1211,7 @@ function calcSocialDetail(base: number, config: any) {
const medicalBase = Math.min(Math.max(base, medMin), medMax) const medicalBase = Math.min(Math.max(base, medMin), medMax)
const items = [ const items = [
{ name: '养老', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: actualBase * config.pensionOrg / 100, empAmount: actualBase * config.pensionEmp / 100 }, { name: '养老', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: actualBase * config.pensionOrg / 100, empAmount: actualBase * config.pensionEmp / 100 },
{ name: '医疗', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalBase * config.medicalOrg / 100, empAmount: medicalBase * config.medicalEmp / 100 }, { name: '医疗', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalBase * config.medicalOrg / 100 + (config.medicalOrgExtra || 0), empAmount: medicalBase * config.medicalEmp / 100 + (config.medicalEmpExtra || 0) },
{ name: '失业', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: actualBase * config.unemploymentOrg / 100, empAmount: actualBase * config.unemploymentEmp / 100 }, { name: '失业', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: actualBase * config.unemploymentOrg / 100, empAmount: actualBase * config.unemploymentEmp / 100 },
{ name: '工伤', orgRate: config.injuryOrg, empRate: 0, orgAmount: actualBase * config.injuryOrg / 100, empAmount: 0 }, { name: '工伤', orgRate: config.injuryOrg, empRate: 0, orgAmount: actualBase * config.injuryOrg / 100, empAmount: 0 },
{ name: '生育', orgRate: config.maternityOrg, empRate: 0, orgAmount: medicalBase * config.maternityOrg / 100, empAmount: 0 }, { name: '生育', orgRate: config.maternityOrg, empRate: 0, orgAmount: medicalBase * config.maternityOrg / 100, empAmount: 0 },
+237
View File
@@ -0,0 +1,237 @@
/**
*
* 穿
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
// 客服权限中间件
const supportMiddleware = (req: AuthRequest, res: any, next: any) => {
if (req.user!.role !== 'SUPPORT' && req.user!.role !== 'SUPER_ADMIN') {
return res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: '仅客服或超级管理员可访问' } })
}
next()
}
const createTicketSchema = z.object({
title: z.string().min(1, '标题必填'),
content: z.string().min(1, '内容必填'),
category: z.string().optional(),
priority: z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
})
const createMessageSchema = z.object({
content: z.string().min(1, '内容必填'),
})
// ==================== 工单管理 ====================
/** 获取工单列表(客服看全部,企业用户看自己租户的) */
router.get('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const where = isSupport ? {} : { orgId: req.user!.orgId! }
const tickets = await prisma.ticket.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
org: { select: { id: true, name: true } },
_count: { select: { messages: true } },
},
})
res.json({ success: true, data: tickets })
} catch (err) {
next(err)
}
})
/** 获取工单详情 */
router.get('/tickets/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const ticket = await prisma.ticket.findUnique({
where: { id },
include: {
org: { select: { id: true, name: true } },
messages: { orderBy: { createdAt: 'asc' } },
},
})
if (!ticket) throw { code: 'NOT_FOUND', message: '工单不存在' }
res.json({ success: true, data: ticket })
} catch (err) {
next(err)
}
})
/** 创建工单(企业用户提交) */
router.post('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createTicketSchema.parse(req.body)
const ticket = await prisma.ticket.create({
data: {
...data,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: ticket })
} catch (err) {
next(err)
}
})
/** 回复工单 */
router.post('/tickets/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const data = createMessageSchema.parse(req.body)
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const message = await prisma.ticketMessage.create({
data: {
ticketId: id,
content: data.content,
senderId: req.user!.id,
senderRole: isSupport ? 'SUPPORT' : 'USER',
},
})
// 客服回复时更新工单状态为处理中
if (isSupport) {
await prisma.ticket.update({ where: { id }, data: { status: 'IN_PROGRESS', assigneeId: req.user!.id } })
}
res.json({ success: true, data: message })
} catch (err) {
next(err)
}
})
/** 关闭工单 */
router.post('/tickets/:id/close', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
await prisma.ticket.update({ where: { id }, data: { status: 'CLOSED' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
/** 转派工单 */
router.post('/tickets/:id/assign', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const { assigneeId } = req.body
await prisma.ticket.update({ where: { id }, data: { assigneeId } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ==================== 客户会话 ====================
/** 获取会话列表 */
router.get('/chat/sessions', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const where = isSupport ? { supportUserId: req.user!.id } : { orgId: req.user!.orgId! }
const sessions = await prisma.chatSession.findMany({
where,
orderBy: { lastMessageAt: 'desc' },
include: { org: { select: { id: true, name: true } } },
})
res.json({ success: true, data: sessions })
} catch (err) {
next(err)
}
})
/** 获取会话消息 */
router.get('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const messages = await prisma.chatMessage.findMany({
where: { sessionId: id },
orderBy: { createdAt: 'asc' },
})
res.json({ success: true, data: messages })
} catch (err) {
next(err)
}
})
/** 发送会话消息 */
router.post('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const { content } = req.body as { content: string }
if (!content) throw { code: 'VALIDATION_ERROR', message: '内容必填' }
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const session = await prisma.chatSession.findUnique({ where: { id } })
if (!session) throw { code: 'NOT_FOUND', message: '会话不存在' }
const message = await prisma.chatMessage.create({
data: {
sessionId: id,
content,
senderId: req.user!.id,
senderRole: isSupport ? 'SUPPORT' : 'USER',
},
})
await prisma.chatSession.update({
where: { id },
data: {
lastMessage: content,
lastMessageAt: new Date(),
unreadBySupport: isSupport ? session.unreadBySupport : session.unreadBySupport + 1,
unreadByUser: isSupport ? session.unreadByUser + 1 : session.unreadByUser,
},
})
res.json({ success: true, data: message })
} catch (err) {
next(err)
}
})
// ==================== 租户数据穿透 ====================
/** 获取租户列表(客服用) */
router.get('/tenants', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const tenants = await prisma.organization.findMany({
select: {
id: true, name: true, plan: true, maxEmployees: true,
contactName: true, contactPhone: true, createdAt: true,
_count: { select: { employees: true, users: true } },
},
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: tenants })
} catch (err) {
next(err)
}
})
/** 获取租户数据概览(客服代查看) */
router.get('/tenants/:orgId/overview', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const { orgId } = req.params
const [employeeCount, activeContracts, pendingApprovals, openTickets] = await Promise.all([
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
prisma.laborContract.count({ where: { orgId } }),
prisma.approvalInstance.count({ where: { orgId, status: 'PENDING' } }),
prisma.ticket.count({ where: { orgId, status: { in: ['OPEN', 'IN_PROGRESS'] } } }),
])
res.json({
success: true,
data: { employeeCount, activeContracts, pendingApprovals, openTickets },
})
} catch (err) {
next(err)
}
})
export default router
+153 -2
View File
@@ -4,7 +4,10 @@ import { auditLog } from '../middleware/auditLog'
import { terminationChecklistSchema, resignationSchema, batchTerminatePreviewSchema, batchTerminateSchema, createTerminationDraftSchema, updateTerminationDraftSchema } from '../schemas/termination.schema' import { terminationChecklistSchema, resignationSchema, batchTerminatePreviewSchema, batchTerminateSchema, createTerminationDraftSchema, updateTerminationDraftSchema } from '../schemas/termination.schema'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems, validateTerminationStep } from '../services/termination.service' import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems, validateTerminationStep } from '../services/termination.service'
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
import { createEvidence } from '../services/evidence.service' import { createEvidence, appendEvidence } from '../services/evidence.service'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
const router = Router() const router = Router()
@@ -39,7 +42,11 @@ router.get('/checklist/:reason', authMiddleware, async (req: AuthRequest, res, n
} }
} }
const checklist = getChecklistForReason(req.params.reason, employee) // 获取组织所在城市,用于地区差异化合规检查(如北京通知工会程序)
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { city: true } })
const orgCity = org?.city || undefined
const checklist = getChecklistForReason(req.params.reason, employee, orgCity)
res.json({ success: true, data: checklist }) res.json({ success: true, data: checklist })
} catch (err) { } catch (err) {
next(err) next(err)
@@ -360,4 +367,148 @@ router.delete('/draft/:id', authMiddleware, async (req: AuthRequest, res, next)
} }
}) })
// ========== 工会回执上传(北京地区单方解除证据链) ==========
// 工会回执文件上传目录
const unionReceiptDir = path.join(process.cwd(), 'uploads', 'union-receipt')
if (!fs.existsSync(unionReceiptDir)) fs.mkdirSync(unionReceiptDir, { recursive: true })
const unionReceiptUpload = multer({
storage: multer.diskStorage({
destination: unionReceiptDir,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname)
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp']
const ext = path.extname(file.originalname).toLowerCase()
if (allowed.includes(ext)) cb(null, true)
else cb(new Error('仅支持 JPG/PNG/PDF/BMP 格式'))
},
})
/**
*
*
*/
router.post('/draft/:id/union-receipt/upload', authMiddleware, unionReceiptUpload.single('file'), async (req: AuthRequest, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
}
const record = await prisma.terminationRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) {
fs.unlinkSync(req.file.path)
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '解聘记录不存在' } })
}
const fileUrl = `/uploads/union-receipt/${req.file.filename}`
res.json({ success: true, data: { fileName: req.file.originalname, fileUrl, fileSize: req.file.size } })
} catch (err) {
next(err)
}
})
/**
* URL + + 稿
*/
router.post('/draft/:id/union-receipt', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const record = await prisma.terminationRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '解聘记录不存在' } })
}
const { receiptNo, unionName, receiptDate, fileUrl, fileName, unionOpinion } = req.body
// 将工会回执信息保存到草稿的 checklistOverrides 中
const checklistOverrides: any = (record.checklistOverrides as any) || {}
checklistOverrides['union_receipt'] = {
checked: true,
overrideReason: '已收到工会书面回执',
receiptNo,
unionName,
receiptDate,
fileUrl,
fileName,
unionOpinion,
}
// 同步标记 notify_union 已完成
if (!checklistOverrides['notify_union']) {
checklistOverrides['notify_union'] = {
checked: true,
overrideReason: '已通知工会并收到回执',
}
}
await prisma.terminationRecord.update({
where: { id: record.id },
data: { checklistOverrides },
})
// 追加到证据链
await appendEvidence(
req.user!.orgId,
// 查找该解聘记录对应的证据链
(await prisma.evidenceChain.findFirst({
where: { orgId: req.user!.orgId, category: 'TERMINATION', refId: record.id },
}))?.id || '',
{
action: '工会书面回执已收到',
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent'] as string,
location: `回执编号:${receiptNo || '无'},工会:${unionName || '未填写'},回执日期:${receiptDate || '未填写'}`,
}
).catch(() => {
// 证据链可能不存在(草稿阶段未创建),创建新的证据链
return createEvidence({
orgId: req.user!.orgId,
category: 'TERMINATION',
refId: record.id,
employeeId: record.employeeId,
events: [{
action: '工会书面回执已收到',
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent'] as string,
location: `回执编号:${receiptNo || '无'},工会:${unionName || '未填写'},回执日期:${receiptDate || '未填写'}`,
}],
createdBy: req.user!.id,
})
})
await auditLog(req, 'UNION_RECEIPT', 'TERMINATION_RECORD', record.id, { receiptNo, unionName, fileUrl })
res.json({ success: true, data: { receiptNo, unionName, receiptDate, fileUrl, fileName, unionOpinion } })
} catch (err) {
next(err)
}
})
/**
*
*/
router.get('/draft/:id/union-receipt', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const record = await prisma.terminationRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '解聘记录不存在' } })
}
const checklistOverrides: any = (record.checklistOverrides as any) || {}
const unionReceipt = checklistOverrides['union_receipt'] || null
res.json({ success: true, data: unionReceipt })
} catch (err) {
next(err)
}
})
export default router export default router
+28 -1
View File
@@ -27,7 +27,7 @@ const REQUIRED_FIELDS: Record<string, string[]> = {
// 必填字段中文标签映射 // 必填字段中文标签映射
const FIELD_LABELS: Record<string, string> = { const FIELD_LABELS: Record<string, string> = {
name: '员工姓名', department: '部门', hireDate: '入职日期', phone: '手机号', name: '员工姓名', department: '部门', hireDate: '入职日期', phone: '手机号',
idCardNumber: '身份证号', employeeId: '员工ID', contractId: '合同ID', idCardNumber: '证件号码', employeeId: '员工ID', contractId: '合同ID',
contractStartDate: '合同开始日期', confirmDate: '转正日期', contractStartDate: '合同开始日期', confirmDate: '转正日期',
newEndDate: '新到期日期', newStartDate: '新合同开始日期', newEndDate: '新到期日期', newStartDate: '新合同开始日期',
suspendDate: '中止日期', employeeName: '员工姓名', suspendDate: '中止日期', employeeName: '员工姓名',
@@ -35,11 +35,38 @@ const FIELD_LABELS: Record<string, string> = {
leaveDate: '离职日期', agreementStartDate: '协议开始日期', leaveDate: '离职日期', agreementStartDate: '协议开始日期',
} }
/** 日期字段对配置:各流程类型的开始/结束日期字段对 */
const DATE_RANGE_FIELDS: Record<string, Array<{ start: string; end: string; startLabel: string; endLabel: string }>> = {
HIRE: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
CUSTOM_CONTRACT: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
RENEW: [{ start: 'newStartDate', end: 'newEndDate', startLabel: '新合同开始日期', endLabel: '新合同结束日期' }],
FLEXIBLE: [{ start: 'agreementStartDate', end: 'agreementEndDate', startLabel: '协议开始日期', endLabel: '协议结束日期' }],
}
/** 校验日期前后关系:结束日期不能早于开始日期 */
function validateWorkProcessDateRange(type: string, formData: Record<string, any>): string | null {
const pairs = DATE_RANGE_FIELDS[type]
if (!pairs) return null
for (const pair of pairs) {
const start = formData[pair.start]
const end = formData[pair.end]
if (start && end && new Date(end) < new Date(start)) {
return `${pair.endLabel}不能早于${pair.startLabel}`
}
}
return null
}
// 创建办理(含草稿) // 创建办理(含草稿)
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const data = createWorkProcessSchema.parse(req.body) const data = createWorkProcessSchema.parse(req.body)
const { type, title, employeeId, formData, status, remark } = data const { type, title, employeeId, formData, status, remark } = data
// 后端日期前后关系校验(双保险)
const dateError = validateWorkProcessDateRange(type, formData || {})
if (dateError) {
return res.status(400).json({ success: false, error: { code: 'INVALID_DATE_RANGE', message: dateError } })
}
const process = await (prisma as any).workProcess.create({ const process = await (prisma as any).workProcess.create({
data: { data: {
orgId: req.user!.orgId, orgId: req.user!.orgId,
+7 -3
View File
@@ -5,7 +5,7 @@ export const createEmployeeSchema = z.object({
department: z.string().min(1, '部门不能为空').max(50, '部门最多50个字'), department: z.string().min(1, '部门不能为空').max(50, '部门最多50个字'),
hireDate: z.string().datetime(), hireDate: z.string().datetime(),
monthlySalary: z.string().min(1, '月薪不能为空'), monthlySalary: z.string().min(1, '月薪不能为空'),
idCardNumber: z.string().min(18, '身份证号不能为空且必须18位').max(18, '身份证号必须18位'), idCardNumber: z.string().min(18, '证件号码不能为空且必须18位').max(18, '证件号码必须18位'),
gender: z.enum(['男', '女']).optional(), gender: z.enum(['男', '女']).optional(),
femaleWorkerType: z.enum(['CADRE', 'WORKER']).optional(), femaleWorkerType: z.enum(['CADRE', 'WORKER']).optional(),
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(), phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
@@ -15,11 +15,12 @@ export const createEmployeeSchema = z.object({
city: z.string().max(20).optional(), city: z.string().max(20).optional(),
education: z.string().max(20).optional(), education: z.string().max(20).optional(),
position: z.string().max(50).optional(), position: z.string().max(50).optional(),
supplementaryHousingAccountId: z.string().nullable().optional(),
contract: z.object({ contract: z.object({
signDate: z.string().datetime().nullable(), signDate: z.string().datetime().nullable(),
startDate: z.string().datetime(), startDate: z.string().datetime(),
endDate: z.string().datetime().nullable(), endDate: z.string().datetime().nullable(),
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED']), contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED', 'LABOR', 'INTERNSHIP', 'DISPATCH', 'OUTSOURCING', 'PARTTIME']),
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'), signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
contractYears: z.number().int().min(1).max(10).default(3), contractYears: z.number().int().min(1).max(10).default(3),
probationMonths: z.number().int().min(0).max(6).default(0), probationMonths: z.number().int().min(0).max(6).default(0),
@@ -50,6 +51,9 @@ export const updateEmployeeSchema = z.object({
city: z.string().max(20).optional(), city: z.string().max(20).optional(),
education: z.string().max(20).optional(), education: z.string().max(20).optional(),
position: z.string().max(50).optional(), position: z.string().max(50).optional(),
status: z.enum(['ACTIVE', 'PENDING_ONBOARD', 'RESIGNED', 'BLACKLISTED']).optional(),
supplementaryHousingAccountId: z.string().nullable().optional(),
cityChangeReason: z.string().max(200).optional(),
}) })
export const batchRenewSchema = z.object({ export const batchRenewSchema = z.object({
@@ -62,7 +66,7 @@ export const addContractSchema = z.object({
signDate: z.string().datetime().nullable(), signDate: z.string().datetime().nullable(),
startDate: z.string().datetime(), startDate: z.string().datetime(),
endDate: z.string().datetime().nullable(), endDate: z.string().datetime().nullable(),
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED']), contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED', 'LABOR', 'INTERNSHIP', 'DISPATCH', 'OUTSOURCING', 'PARTTIME']),
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'), signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
contractYears: z.number().int().min(1).max(10).default(3), contractYears: z.number().int().min(1).max(10).default(3),
probationMonths: z.number().int().min(0).max(6).default(0), probationMonths: z.number().int().min(0).max(6).default(0),
@@ -44,7 +44,9 @@ export const createTerminationDraftSchema = z.object({
reason: z.string().max(200).optional(), reason: z.string().max(200).optional(),
terminationDate: z.string().optional(), terminationDate: z.string().optional(),
compensation: z.number().min(0).optional(), compensation: z.number().min(0).optional(),
compensationBreakdown: z.any().optional(),
handoverItems: z.array(z.any()).optional(), handoverItems: z.array(z.any()).optional(),
checklistOverrides: z.any().optional(),
remark: z.string().max(500).optional(), remark: z.string().max(500).optional(),
}) })
@@ -52,6 +54,8 @@ export const updateTerminationDraftSchema = z.object({
reason: z.string().max(200).optional(), reason: z.string().max(200).optional(),
terminationDate: z.string().optional(), terminationDate: z.string().optional(),
compensation: z.number().min(0).optional(), compensation: z.number().min(0).optional(),
compensationBreakdown: z.any().optional(),
handoverItems: z.array(z.any()).optional(), handoverItems: z.array(z.any()).optional(),
checklistOverrides: z.any().optional(),
remark: z.string().max(500).optional(), remark: z.string().max(500).optional(),
}) })
+8 -4
View File
@@ -181,10 +181,12 @@ ${orgContext}
30 30
- [/] [] - [()//] []
[]` []
`
const response = await client.chat.completions.create({ const response = await client.chat.completions.create({
model: 'qwen-plus', model: 'qwen-plus',
@@ -208,8 +210,8 @@ export async function* predictRisksStream(orgContext: string) {
| | / | | | | | | | (/) | | | | |
|--------|-----------|----------|----------|----------|----------| |--------|---------------------|----------|----------|----------|----------|
## ##
@@ -219,6 +221,8 @@ export async function* predictRisksStream(orgContext: string) {
${orgContext}` ${orgContext}`
+162
View File
@@ -0,0 +1,162 @@
/**
*
* 3
*/
import prisma from '../lib/prisma'
interface ApprovalStep {
step: number
approverType: 'SUPERVISOR' | 'DEPT_HEAD' | 'PERSON'
approverId?: string
name: string
}
interface ApprovalRecord {
step: number
approverId: string
approverName: string
result: 'APPROVED' | 'REJECTED'
comment?: string
timestamp: string
}
/**
*
*/
export async function createApprovalInstance(
orgId: string,
userId: string,
type: string,
bizId: string,
bizType: string,
employeeId: string,
): Promise<{ instance: any; firstApprover?: any }> {
// 查找该类型的审批流配置
const flow = await prisma.approvalFlow.findFirst({
where: { orgId, type, enabled: true },
})
if (!flow) {
// 无审批流配置,直接通过
return { instance: null }
}
const steps = flow.steps as unknown as ApprovalStep[]
if (!steps || steps.length === 0) {
return { instance: null }
}
const instance = await prisma.approvalInstance.create({
data: {
orgId,
flowId: flow.id,
type,
bizId,
bizType,
status: 'PENDING',
currentStep: 1,
approvals: [],
employeeId,
createdBy: userId,
},
})
// 计算第一步审批人
const firstApprover = await resolveApprover(orgId, employeeId, steps[0])
return { instance, firstApprover }
}
/**
*
*/
async function resolveApprover(orgId: string, employeeId: string, step: ApprovalStep): Promise<any> {
const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { dept: true, supervisor: true },
})
if (!employee) return null
if (step.approverType === 'SUPERVISOR') {
return employee.supervisor ? { id: employee.supervisor.id, name: employee.supervisor.name } : null
} else if (step.approverType === 'DEPT_HEAD') {
// 部门负责人暂用部门创建人(简化实现)
if (employee.dept) {
return { id: employee.dept.createdBy, name: '部门负责人' }
}
return null
} else if (step.approverType === 'PERSON' && step.approverId) {
const approver = await prisma.employee.findFirst({ where: { id: step.approverId, orgId } })
return approver ? { id: approver.id, name: approver.name } : null
}
return null
}
/**
*
*/
export async function processApproval(
orgId: string,
instanceId: string,
approverId: string,
approverName: string,
result: 'APPROVED' | 'REJECTED',
comment?: string,
): Promise<{ status: string; nextApprover?: any }> {
const instance = await prisma.approvalInstance.findFirst({
where: { id: instanceId, orgId },
include: { flow: true },
})
if (!instance) throw { code: 'NOT_FOUND', message: '审批实例不存在' }
if (instance.status !== 'PENDING') throw { code: 'VALIDATION_ERROR', message: '审批实例已处理' }
const steps = instance.flow.steps as unknown as ApprovalStep[]
const currentStepConfig = steps.find(s => s.step === instance.currentStep)
if (!currentStepConfig) throw { code: 'VALIDATION_ERROR', message: '步骤配置错误' }
// 记录审批结果
const approvals = (instance.approvals as unknown as ApprovalRecord[]) || []
approvals.push({
step: instance.currentStep,
approverId,
approverName,
result,
comment,
timestamp: new Date().toISOString(),
})
if (result === 'REJECTED') {
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { status: 'REJECTED', approvals: approvals as any },
})
return { status: 'REJECTED' }
}
// 查找下一步
const nextStepConfig = steps.find(s => s.step === instance.currentStep + 1)
if (!nextStepConfig) {
// 全部通过
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { status: 'APPROVED', approvals: approvals as any },
})
return { status: 'APPROVED' }
}
// 进入下一步
const nextApprover = await resolveApprover(orgId, instance.employeeId || '', nextStepConfig)
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { currentStep: instance.currentStep + 1, approvals: approvals as any },
})
return { status: 'PENDING', nextApprover }
}
/**
*
*/
export async function cancelApproval(orgId: string, instanceId: string): Promise<void> {
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { status: 'CANCELLED' },
})
}
@@ -48,6 +48,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
weekendHours: number weekendHours: number
holidayHours: number holidayHours: number
overtimePay: number overtimePay: number
deductionAmount?: number
}>) { }>) {
const results: Array<{ employeeId: string; success: boolean; error?: string }> = [] const results: Array<{ employeeId: string; success: boolean; error?: string }> = []
@@ -66,6 +67,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
weekendHours: item.weekendHours, weekendHours: item.weekendHours,
holidayHours: item.holidayHours, holidayHours: item.holidayHours,
overtimePay: item.overtimePay, overtimePay: item.overtimePay,
deductionAmount: item.deductionAmount || 0,
status: 'PENDING', status: 'PENDING',
}, },
}) })
@@ -80,6 +82,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
weekendHours: item.weekendHours, weekendHours: item.weekendHours,
holidayHours: item.holidayHours, holidayHours: item.holidayHours,
overtimePay: item.overtimePay, overtimePay: item.overtimePay,
deductionAmount: item.deductionAmount || 0,
createdBy: userId, createdBy: userId,
}, },
}) })
+13
View File
@@ -27,6 +27,19 @@ export async function register(orgName: string, phone: string, password: string)
}, },
}) })
// 自动创建根部门(公司名),作为组织架构的顶层节点
await prisma.department.create({
data: {
orgId: org.id,
name: orgName,
parentId: null,
level: 0,
sortOrder: 0,
description: '组织根节点',
createdBy: user.id,
},
})
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { lastLoginAt: new Date() }, data: { lastLoginAt: new Date() },
@@ -0,0 +1,187 @@
/**
*
* / CRUD
*/
import prisma from '../lib/prisma'
import { decrypt, sha256 } from '../lib/crypto'
/**
*
*/
export async function listByMonth(orgId: string, month: string) {
const records = await prisma.commissionBonus.findMany({
where: { orgId, month },
include: {
employee: {
select: { id: true, name: true, department: true, idCardHash: true },
},
},
orderBy: [{ employee: { department: 'asc' } }, { employee: { name: 'asc' } }],
})
return records
}
/**
*
*/
export async function summaryByMonth(orgId: string, month: string) {
const records = await prisma.commissionBonus.findMany({
where: { orgId, month },
select: { amount: true },
})
const totalBonus = records.filter((r) => r.amount > 0).reduce((s, r) => s + r.amount, 0)
const totalDeduction = records.filter((r) => r.amount < 0).reduce((s, r) => s + Math.abs(r.amount), 0)
return {
count: records.length,
totalBonus,
totalDeduction,
netAmount: totalBonus - totalDeduction,
}
}
/**
*
*/
export async function create(orgId: string, userId: string, data: {
employeeId: string
month: string
amount: number
remark?: string
}) {
const emp = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
if (!emp) throw { code: 'NOT_FOUND', message: '员工不存在' }
// 唯一约束冲突时更新(upsert)
return prisma.commissionBonus.upsert({
where: { orgId_employeeId_month: { orgId, employeeId: data.employeeId, month: data.month } },
create: {
orgId,
employeeId: data.employeeId,
month: data.month,
amount: data.amount,
remark: data.remark || null,
createdBy: userId,
},
update: {
amount: data.amount,
remark: data.remark || null,
},
})
}
/**
*
*/
export async function update(orgId: string, id: string, data: { amount?: number; remark?: string }) {
const existing = await prisma.commissionBonus.findFirst({ where: { id, orgId } })
if (!existing) throw { code: 'NOT_FOUND', message: '提成奖金记录不存在' }
return prisma.commissionBonus.update({
where: { id },
data: {
...(data.amount !== undefined && { amount: data.amount }),
...(data.remark !== undefined && { remark: data.remark }),
},
})
}
/**
*
*/
export async function remove(orgId: string, id: string) {
const existing = await prisma.commissionBonus.findFirst({ where: { id, orgId } })
if (!existing) throw { code: 'NOT_FOUND', message: '提成奖金记录不存在' }
await prisma.commissionBonus.delete({ where: { id } })
}
/**
*
* @param rows { employeeName?, idCardNumber?, amount, remark? }
* @param month YYYY-MM
* @returns { created, updated, skipped, errors }
*/
export async function batchImport(
orgId: string,
userId: string,
month: string,
rows: { employeeName?: string; idCardNumber?: string; amount: number; remark?: string }[],
) {
let created = 0
let updated = 0
const errors: { row: number; message: string }[] = []
// 预加载该月所有在职员工用于匹配
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true, name: true, idCardNumber: true, idCardHash: true, department: true },
})
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
try {
// 匹配员工:优先证件号码,其次姓名
let emp: typeof employees[number] | undefined
if (row.idCardNumber) {
const hash = sha256(row.idCardNumber)
emp = employees.find((e) => e.idCardHash === hash)
}
if (!emp && row.employeeName) {
const matches = employees.filter((e) => e.name === row.employeeName)
if (matches.length === 1) emp = matches[0]
else if (matches.length > 1) {
errors.push({ row: i + 2, message: `姓名"${row.employeeName}"匹配到多个员工,请用证件号码` })
continue
}
}
if (!emp) {
errors.push({ row: i + 2, message: `未匹配到员工:${row.employeeName || row.idCardNumber || '行'}` })
continue
}
// upsert
const existing = await prisma.commissionBonus.findUnique({
where: { orgId_employeeId_month: { orgId, employeeId: emp.id, month } },
})
await prisma.commissionBonus.upsert({
where: { orgId_employeeId_month: { orgId, employeeId: emp.id, month } },
create: {
orgId,
employeeId: emp.id,
month,
amount: row.amount,
remark: row.remark || null,
createdBy: userId,
},
update: {
amount: row.amount,
remark: row.remark || null,
},
})
if (existing) updated++
else created++
} catch (err: any) {
errors.push({ row: i + 2, message: err.message || '处理失败' })
}
}
return { created, updated, skipped: errors.length, errors }
}
/**
* + ID "获取提成奖金"使
* @returns Map<employeeId, amount>
*/
export async function getBonusByMonthAndEmployeeIds(
orgId: string,
month: string,
employeeIds: string[],
): Promise<Map<string, { amount: number; remark: string | null }>> {
if (employeeIds.length === 0) return new Map()
const records = await prisma.commissionBonus.findMany({
where: { orgId, month, employeeId: { in: employeeIds } },
select: { employeeId: true, amount: true, remark: true },
})
return new Map(records.map((r) => [r.employeeId, { amount: r.amount, remark: r.remark }]))
}
+228 -12
View File
@@ -2,6 +2,7 @@ import prisma from '../lib/prisma'
import { encrypt, decrypt, sha256 } from '../lib/crypto' import { encrypt, decrypt, sha256 } from '../lib/crypto'
import { runRiskDetection } from './risk.service' import { runRiskDetection } from './risk.service'
import { extractBirthDateFromIdCard, extractGenderFromIdCard } from './retirement.service' import { extractBirthDateFromIdCard, extractGenderFromIdCard } from './retirement.service'
import bcrypt from 'bcryptjs'
function daysBetween(a: Date, b: Date): number { function daysBetween(a: Date, b: Date): number {
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24)) return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
@@ -13,7 +14,25 @@ function dateToMonth(date: Date): string {
return `${y}-${m}` return `${y}-${m}`
} }
async function clampSocialInsBase(orgId: string, base: number, city?: string): Promise<number> { /** 安全解密数字(工资字段) */
function safeDecryptNum(encrypted: string): number {
try {
return Number(decrypt(encrypted)) || 0
} catch {
return Number(encrypted) || 0
}
}
async function clampSocialInsBase(orgId: string, base: number, city?: string, accountId?: string): Promise<number> {
// 优先按账户查年度标准
if (accountId) {
const standard = await prisma.socialYearStandard.findFirst({
where: { accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
if (standard) return Math.min(Math.max(base, standard.baseMin), standard.baseMax)
}
// 回退到旧配置表
const config = await prisma.socialInsuranceConfig.findFirst({ const config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) }, where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' }, orderBy: { effectiveFrom: 'desc' },
@@ -22,7 +41,21 @@ async function clampSocialInsBase(orgId: string, base: number, city?: string): P
return base return base
} }
async function clampHousingFundBase(orgId: string, base: number, city?: string): Promise<number> { async function clampHousingFundBase(orgId: string, base: number, city?: string, accountId?: string): Promise<number> {
// 优先按账户查年度标准
if (accountId) {
const standard = await prisma.socialYearStandard.findFirst({
where: { accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
if (standard) {
// 公积金专用上下限(housingBaseMin/Max),为0时回退到社保的 baseMin/baseMax
const min = standard.housingBaseMin > 0 ? standard.housingBaseMin : standard.baseMin
const max = standard.housingBaseMax > 0 ? standard.housingBaseMax : standard.baseMax
return Math.min(Math.max(base, min), max)
}
}
// 回退到旧配置表
const config = await prisma.housingFundConfig.findFirst({ const config = await prisma.housingFundConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) }, where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' }, orderBy: { effectiveFrom: 'desc' },
@@ -31,12 +64,59 @@ async function clampHousingFundBase(orgId: string, base: number, city?: string):
return base return base
} }
function prevMonth(month: string): string { export function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number) const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1) const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
} }
/**
* GB 11643-1999 + +
* @returns null=string=
*/
export function validateIdCard(idCard: string): string | null {
if (!idCard) return null
if (idCard.length !== 18) return '证件号码必须为18位'
if (!/^\d{17}[\dXx]$/.test(idCard)) return '证件号码格式错误:前17位必须为数字,第18位为数字或X'
const WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const CHECK_CODES = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
const sum = idCard.substring(0, 17).split('').reduce((s, c, i) => s + parseInt(c) * WEIGHTS[i], 0)
if (idCard[17].toUpperCase() !== CHECK_CODES[sum % 11]) return '证件号码校验位错误'
const birthYear = parseInt(idCard.substring(6, 10))
const birthMonth = parseInt(idCard.substring(10, 12))
const birthDay = parseInt(idCard.substring(12, 14))
if (birthMonth < 1 || birthMonth > 12 || birthDay < 1 || birthDay > 31) return '证件号码出生日期非法'
const birthDate = new Date(birthYear, birthMonth - 1, birthDay)
if (isNaN(birthDate.getTime()) || birthDate.getFullYear() !== birthYear || birthDate.getMonth() !== birthMonth - 1 || birthDate.getDate() !== birthDay) return '证件号码出生日期不存在'
if (birthDate > new Date()) return '证件号码出生日期晚于今天'
return null
}
/**
*
*/
export function getAgeFromIdCard(idCard: string, referenceDate: Date = new Date()): number | null {
if (!idCard || idCard.length !== 18) return null
const birthYear = parseInt(idCard.substring(6, 10))
const birthMonth = parseInt(idCard.substring(10, 12))
const birthDay = parseInt(idCard.substring(12, 14))
if (isNaN(birthYear) || isNaN(birthMonth) || isNaN(birthDay)) return null
let age = referenceDate.getFullYear() - birthYear
const monthDiff = referenceDate.getMonth() - (birthMonth - 1)
if (monthDiff < 0 || (monthDiff === 0 && referenceDate.getDate() < birthDay)) age--
return age
}
/**
* 退60555050
*/
export function isOverageEmployee(idCard: string, gender: string, femaleWorkerType?: string | null): boolean {
const age = getAgeFromIdCard(idCard)
if (age === null) return false
if (gender === '男') return age >= 60
return age >= (femaleWorkerType === 'CADRE' ? 55 : 50)
}
export function getContractStatus(contract: { export function getContractStatus(contract: {
signDate: Date | null signDate: Date | null
startDate: Date startDate: Date
@@ -70,7 +150,21 @@ export function getContractStatus(contract: {
return { status: 'unsigned', statusText: `未签合同(${days}天)`, riskLevel: 'medium' } return { status: 'unsigned', statusText: `未签合同(${days}天)`, riskLevel: 'medium' }
} }
// 有合同记录(FIXED/UNFIXED/LABOR/INTERNSHIP,即使 signDate 为 null 也按正常合同处理 // 有合同记录(FIXED/UNFIXED/LABOR/INTERNSHIP但未填签署日期 → 待签署
// 电子签署完成后会回写 signDate,故 signDate 为空即视为未签署
if (!contract.signDate) {
if (contract.endDate) {
const daysToExpire = daysBetween(contract.endDate, today)
if (daysToExpire < 0) {
return { status: 'pending_sign', statusText: `${typeLabel}·待签署(已到期)`, riskLevel: 'high' }
} else if (daysToExpire <= 30) {
return { status: 'pending_sign', statusText: `${typeLabel}·待签署(${daysToExpire}天到期)`, riskLevel: 'medium' }
}
}
return { status: 'pending_sign', statusText: `${typeLabel}·待签署`, riskLevel: 'medium' }
}
// 已签署合同:按到期日判定
if (contract.endDate) { if (contract.endDate) {
const daysToExpire = daysBetween(contract.endDate, today) const daysToExpire = daysBetween(contract.endDate, today)
if (daysToExpire < 0) { if (daysToExpire < 0) {
@@ -106,6 +200,31 @@ export function validateProbation(contractMonths: number, probationMonths: numbe
return { valid: true, max } return { valid: true, max }
} }
/**
*
* = + >
*
* @param contract startDateprobationMonthsprobationSalary
* @param referenceDate
* @returns true=false=
*
* "试用期是否结束""入职是否满 1 年"
* `startDate > now - 365d` probationSalary
*/
export function isInProbation(
contract: { startDate: Date | string | null; probationMonths: number | null } | null | undefined,
referenceDate: Date = new Date(),
): boolean {
if (!contract || !contract.startDate || !contract.probationMonths || contract.probationMonths <= 0) {
return false
}
const start = new Date(contract.startDate)
if (isNaN(start.getTime())) return false
const probationEnd = new Date(start)
probationEnd.setMonth(probationEnd.getMonth() + contract.probationMonths)
return probationEnd > referenceDate
}
export async function getEmployees(orgId: string, params: { page?: number; pageSize?: number; search?: string; department?: string }) { export async function getEmployees(orgId: string, params: { page?: number; pageSize?: number; search?: string; department?: string }) {
const page = params.page || 1 const page = params.page || 1
const pageSize = params.pageSize || 20 const pageSize = params.pageSize || 20
@@ -161,6 +280,9 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
} catch { } catch {
decryptedSalary = Number(emp.monthlySalary) || 0 decryptedSalary = Number(emp.monthlySalary) || 0
} }
// 解密工资结构
const decryptedBaseSalary = emp.baseSalary ? safeDecryptNum(emp.baseSalary) : null
const decryptedPerformanceSalary = emp.performanceSalary ? safeDecryptNum(emp.performanceSalary) : null
return { return {
id: emp.id, id: emp.id,
@@ -169,6 +291,8 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
hireDate: emp.hireDate.toISOString().slice(0, 10), hireDate: emp.hireDate.toISOString().slice(0, 10),
status: emp.status, status: emp.status,
monthlySalary: decryptedSalary, monthlySalary: decryptedSalary,
baseSalary: decryptedBaseSalary,
performanceSalary: decryptedPerformanceSalary,
contractStatus: contractInfo.status, contractStatus: contractInfo.status,
contractStatusText: contractInfo.statusText, contractStatusText: contractInfo.statusText,
riskLevel: contractInfo.riskLevel, riskLevel: contractInfo.riskLevel,
@@ -205,18 +329,47 @@ export async function getEmployeeDetail(orgId: string, id: string) {
return { return {
...employee, ...employee,
monthlySalary: decryptedSalary, monthlySalary: decryptedSalary,
baseSalary: employee.baseSalary ? safeDecryptNum(employee.baseSalary) : null,
performanceSalary: employee.performanceSalary ? safeDecryptNum(employee.performanceSalary) : null,
} }
} }
export async function createEmployee(orgId: string, userId: string, data: any) { export async function createEmployee(orgId: string, userId: string, data: any) {
// 身份证号查重 // 证件号码有效性校验
if (data.idCardNumber) { if (data.idCardNumber) {
const idCardError = validateIdCard(data.idCardNumber)
if (idCardError) {
throw { code: 'VALIDATION_ERROR', message: idCardError }
}
// 童工阻断
const age = getAgeFromIdCard(data.idCardNumber)
if (age !== null && age < 16) {
throw { code: 'VALIDATION_ERROR', message: `该员工年龄 ${age} 岁,未满16周岁,禁止招用童工(《劳动法》第15条)` }
}
// 证件号码查重
const existing = await prisma.employee.findFirst({ const existing = await prisma.employee.findFirst({
where: { orgId, idCardHash: sha256(data.idCardNumber) }, where: { orgId, idCardHash: sha256(data.idCardNumber) },
select: { id: true, name: true, department: true, status: true }, select: { id: true, name: true, department: true, status: true },
}) })
if (existing) { if (existing) {
throw { code: 'DUPLICATE_ID_CARD', message: `身份证号已存在:${existing.name}${existing.department}${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` } throw { code: 'DUPLICATE_ID_CARD', message: `证件号码已存在:${existing.name}${existing.department}${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` }
}
}
// 超龄人员不得签订劳动合同(FIXED/UNFIXED
if (data.idCardNumber && data.contract && data.contract.contractType) {
const overage = isOverageEmployee(data.idCardNumber, data.gender, data.femaleWorkerType)
if (overage && ['FIXED', 'UNFIXED'].includes(data.contract.contractType)) {
throw { code: 'VALIDATION_ERROR', message: '超龄人员(达法定退休年龄)不可签订劳动合同,请选择劳务协议或实习协议' }
}
}
// 手机号查重(同组织内不允许重复,影响员工端登录)
if (data.phone) {
const phoneExists = await prisma.employee.findFirst({
where: { orgId, phone: data.phone },
select: { id: true, name: true, department: true, status: true },
})
if (phoneExists) {
throw { code: 'DUPLICATE_PHONE', message: `手机号已存在:${phoneExists.name}${phoneExists.department}${phoneExists.status === 'ACTIVE' ? '在职' : '离职'}),员工端登录需手机号唯一,请确认是否重复录入` }
} }
} }
@@ -231,15 +384,23 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
const hireDate = new Date(data.hireDate) const hireDate = new Date(data.hireDate)
const hireMonth = dateToMonth(hireDate) const hireMonth = dateToMonth(hireDate)
const salaryNum = Number(data.monthlySalary) || 0 const salaryNum = Number(data.monthlySalary) || 0
// 工资结构:基本工资 + 绩效工资 = 月薪总额
const baseSalaryNum = Number(data.baseSalary) || 0
const performanceSalaryNum = Number(data.performanceSalary) || 0
const city = data.city || '北京' const city = data.city || '北京'
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum // 劳务协议/实习协议:不缴纳社保公积金,基数强制 0
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum const isNoSocialContract = data.contract && ['LABOR', 'INTERNSHIP'].includes(data.contract.contractType)
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city) const rawSocialInsBase = isNoSocialContract ? 0 : (data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum)
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city) const rawHousingFundBase = isNoSocialContract ? 0 : (data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum)
const socialInsStartMonth = data.socialInsStartMonth || hireMonth const socialInsBase = isNoSocialContract ? 0 : await clampSocialInsBase(orgId, rawSocialInsBase, city, data.socialAccountId)
const housingFundStartMonth = data.housingFundStartMonth || hireMonth const housingFundBase = isNoSocialContract ? 0 : await clampHousingFundBase(orgId, rawHousingFundBase, city, data.housingAccountId)
const socialInsStartMonth = isNoSocialContract ? '' : (data.socialInsStartMonth || hireMonth)
const housingFundStartMonth = isNoSocialContract ? '' : (data.housingFundStartMonth || hireMonth)
const employee = await prisma.$transaction(async (tx) => { const employee = await prisma.$transaction(async (tx) => {
// 默认密码:手机号后6位(员工可在员工端自行修改)
const defaultPassword = data.phone ? data.phone.slice(-6) : '123456'
const passwordHash = await bcrypt.hash(defaultPassword, 10)
const emp = await tx.employee.create({ const emp = await tx.employee.create({
data: { data: {
orgId, orgId,
@@ -247,9 +408,12 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
department: data.department, department: data.department,
hireDate, hireDate,
monthlySalary: encrypt(data.monthlySalary), monthlySalary: encrypt(data.monthlySalary),
baseSalary: encrypt(String(baseSalaryNum)),
performanceSalary: encrypt(String(performanceSalaryNum)),
gender: data.gender, gender: data.gender,
femaleWorkerType: data.femaleWorkerType, femaleWorkerType: data.femaleWorkerType,
phone: data.phone, phone: data.phone,
passwordHash,
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null, idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null, idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
isPregnant: data.isPregnant || false, isPregnant: data.isPregnant || false,
@@ -263,6 +427,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
city: data.city || '北京', city: data.city || '北京',
education: data.education || null, education: data.education || null,
position: data.position || null, position: data.position || null,
status: data.status || 'ACTIVE',
supplementaryHousingAccountId: data.supplementaryHousingAccountId || null,
}, },
}) })
@@ -276,6 +442,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
changeType: 'ONBOARDING', changeType: 'ONBOARDING',
createdBy: userId, createdBy: userId,
city: data.city || '北京', city: data.city || '北京',
accountId: data.socialAccountId || null,
}, },
}) })
@@ -289,6 +456,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
changeType: 'ONBOARDING', changeType: 'ONBOARDING',
createdBy: userId, createdBy: userId,
city: data.city || '北京', city: data.city || '北京',
accountId: data.housingAccountId || null,
}, },
}) })
@@ -341,6 +509,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
contractYears: data.contract.contractYears || 3, contractYears: data.contract.contractYears || 3,
probationMonths: data.contract.probationMonths || 0, probationMonths: data.contract.probationMonths || 0,
probationSalary: data.contract.probationSalary || 0, probationSalary: data.contract.probationSalary || 0,
baseSalary: baseSalaryNum,
performanceSalary: performanceSalaryNum,
createdBy: userId, createdBy: userId,
}, },
}) })
@@ -379,6 +549,10 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
const newHireMonth = dateToMonth(newHireDate) const newHireMonth = dateToMonth(newHireDate)
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0 const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
// 重新入职时支持更新工资结构
const baseSalaryNum = data.baseSalary != null ? Number(data.baseSalary) : (employee.baseSalary ? Number(decrypt(employee.baseSalary)) || 0 : 0)
const performanceSalaryNum = data.performanceSalary != null ? Number(data.performanceSalary) : (employee.performanceSalary ? Number(decrypt(employee.performanceSalary)) || 0 : 0)
const newMonthlySalary = data.baseSalary != null ? baseSalaryNum + performanceSalaryNum : salaryNum
const city = data.city || employee.city || '北京' const city = data.city || employee.city || '北京'
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
@@ -428,6 +602,12 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
housingFundStartMonth, housingFundStartMonth,
housingFundEndMonth: null, housingFundEndMonth: null,
city: data.city || employee.city || '北京', city: data.city || employee.city || '北京',
// 更新工资结构
...(data.baseSalary != null ? {
monthlySalary: encrypt(String(newMonthlySalary)),
baseSalary: encrypt(String(baseSalaryNum)),
performanceSalary: encrypt(String(performanceSalaryNum)),
} : {}),
}, },
}) })
@@ -510,6 +690,8 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
contractYears: data.contract.contractYears || 3, contractYears: data.contract.contractYears || 3,
probationMonths: data.contract.probationMonths || 0, probationMonths: data.contract.probationMonths || 0,
probationSalary: data.contract.probationSalary || 0, probationSalary: data.contract.probationSalary || 0,
baseSalary: baseSalaryNum,
performanceSalary: performanceSalaryNum,
createdBy: userId, createdBy: userId,
}, },
}) })
@@ -526,6 +708,27 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
throw { code: 'NOT_FOUND', message: '员工不存在' } throw { code: 'NOT_FOUND', message: '员工不存在' }
} }
// 手机号查重(同组织内排除自身,phone 唯一影响员工端登录)
if (data.phone !== undefined && data.phone) {
const phoneExists = await prisma.employee.findFirst({
where: { orgId, phone: data.phone, NOT: { id } },
select: { id: true, name: true, department: true, status: true },
})
if (phoneExists) {
throw { code: 'DUPLICATE_PHONE', message: `手机号已存在:${phoneExists.name}${phoneExists.department}),同企业内手机号不可重复` }
}
}
// 身份证查重(同组织内排除自身)
if (data.idCardNumber !== undefined && data.idCardNumber) {
const idCardExists = await prisma.employee.findFirst({
where: { orgId, idCardHash: sha256(data.idCardNumber), NOT: { id } },
select: { id: true, name: true, department: true, status: true },
})
if (idCardExists) {
throw { code: 'DUPLICATE_ID_CARD', message: `证件号码已存在:${idCardExists.name}${idCardExists.department}),同企业内身份证号不可重复` }
}
}
const updateData: any = {} const updateData: any = {}
if (data.name !== undefined) updateData.name = data.name if (data.name !== undefined) updateData.name = data.name
if (data.department !== undefined) updateData.department = data.department if (data.department !== undefined) updateData.department = data.department
@@ -534,6 +737,13 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
const oldSalary = Number(decrypt(employee.monthlySalary)) || 0 const oldSalary = Number(decrypt(employee.monthlySalary)) || 0
const newSalary = Number(data.monthlySalary) || 0 const newSalary = Number(data.monthlySalary) || 0
updateData.monthlySalary = encrypt(data.monthlySalary) updateData.monthlySalary = encrypt(data.monthlySalary)
// 同步更新工资结构
if (data.baseSalary !== undefined) {
updateData.baseSalary = encrypt(String(Number(data.baseSalary) || 0))
}
if (data.performanceSalary !== undefined) {
updateData.performanceSalary = encrypt(String(Number(data.performanceSalary) || 0))
}
// 记录薪资变更 // 记录薪资变更
if (oldSalary !== newSalary) { if (oldSalary !== newSalary) {
const now = new Date() const now = new Date()
@@ -591,6 +801,8 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
if (data.city !== undefined) updateData.city = data.city if (data.city !== undefined) updateData.city = data.city
if (data.education !== undefined) updateData.education = data.education if (data.education !== undefined) updateData.education = data.education
if (data.position !== undefined) updateData.position = data.position if (data.position !== undefined) updateData.position = data.position
if (data.status !== undefined) updateData.status = data.status
if (data.supplementaryHousingAccountId !== undefined) updateData.supplementaryHousingAccountId = data.supplementaryHousingAccountId || null
// 参保城市变更:关闭旧城市在保记录,创建新城市记录 // 参保城市变更:关闭旧城市在保记录,创建新城市记录
if (data.city !== undefined && data.city !== employee.city) { if (data.city !== undefined && data.city !== employee.city) {
@@ -742,6 +954,8 @@ export async function batchRenew(orgId: string, userId: string, contractIds: str
contractYears: years, contractYears: years,
probationMonths: 0, probationMonths: 0,
probationSalary: 0, probationSalary: 0,
baseSalary: contract.baseSalary || 0,
performanceSalary: contract.performanceSalary || 0,
renewalCount: contract.renewalCount + 1, renewalCount: contract.renewalCount + 1,
createdBy: userId, createdBy: userId,
}, },
@@ -793,6 +1007,8 @@ export async function addContract(orgId: string, userId: string, data: any) {
contractYears: data.contractYears || 3, contractYears: data.contractYears || 3,
probationMonths: data.probationMonths || 0, probationMonths: data.probationMonths || 0,
probationSalary: data.probationSalary || 0, probationSalary: data.probationSalary || 0,
baseSalary: data.baseSalary || 0,
performanceSalary: data.performanceSalary || 0,
attachmentName: data.attachmentUrl ? '合同扫描件' : null, attachmentName: data.attachmentUrl ? '合同扫描件' : null,
attachmentUrl: data.attachmentUrl || null, attachmentUrl: data.attachmentUrl || null,
electronicContractNo: data.electronicContractNo || null, electronicContractNo: data.electronicContractNo || null,
+107
View File
@@ -0,0 +1,107 @@
/**
*
*
*/
import prisma from '../lib/prisma'
import { createEvidence } from './evidence.service'
import { renderTemplate, getTemplateById } from './template.service'
/** 场景与模板ID的映射 */
const SCENE_TEMPLATE_MAP: Record<string, string | null> = {
CONTRACT: 'tpl_fixed_term_contract',
RESIGNATION: 'tpl_termination_agreement',
POLICY: null,
PAYSLIP: null,
ONBOARDING: null,
}
/**
*
* @param params.orgId ID
* @param params.employeeId ID
* @param params.scene
* @param params.documentTitle
* @param params.contractId ID
* @param params.remark
* @param params.createdBy ID
* @param params.templateVars
* @returns ESignRecord null
*/
export async function autoCreateEsignRecord(params: {
orgId: string
employeeId: string
scene: string
documentTitle: string
contractId?: string
remark?: string
createdBy: string
templateVars?: Record<string, string>
}): Promise<any | null> {
try {
const { orgId, employeeId, scene, documentTitle, contractId, remark, createdBy, templateVars } = params
// 获取员工信息
const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
select: { id: true, name: true, phone: true, idCardNumber: true, position: true, monthlySalary: true, department: true },
})
if (!employee) return null
// 自动渲染文件内容
let documentContent = ''
const templateId = SCENE_TEMPLATE_MAP[scene]
if (templateId) {
const template = getTemplateById(templateId)
if (template) {
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { name: true } })
const vars: Record<string, string> = {
companyName: org?.name || '',
employeeName: employee.name || '',
idCard: employee.idCardNumber || '',
position: employee.position || '',
monthlySalary: String(employee.monthlySalary || ''),
department: employee.department || '',
...templateVars,
}
documentContent = renderTemplate(templateId, vars) || ''
}
}
// 创建签署记录
const record = await prisma.eSignRecord.create({
data: {
orgId,
contractId: contractId || null,
employeeId,
scene,
signMethod: 'ELECTRONIC',
documentTitle,
documentContent: documentContent || null,
status: 'PENDING',
initiatedBy: createdBy,
createdBy,
remark: remark || null,
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
})
// 创建证据链
await createEvidence({
orgId,
category: 'CONTRACT_SIGN',
refId: record.id,
employeeId,
events: [{
action: `自动发起电子签署:${documentTitle}`,
timestamp: new Date().toISOString(),
location: `场景:${scene},触发:系统自动`,
}],
createdBy,
}).catch(() => {})
return record
} catch (err) {
// 自动创建失败不阻断主流程
return null
}
}
+308 -37
View File
@@ -1,5 +1,69 @@
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
// ========== 社保公积金账户辅助函数 ==========
/**
* /
* level=0退
*/
async function getEmployeeAccounts(orgId: string, employeeId: string) {
const emp = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { dept: true },
})
if (!emp) return { socialAccount: null, housingAccount: null }
// 向上找到 level=0 的根部门
let currentDept: any = emp.dept
while (currentDept && currentDept.level > 0 && currentDept.parentId) {
currentDept = await prisma.department.findUnique({ where: { id: currentDept.parentId } })
}
const rootDeptId = currentDept?.id || null
let socialAccount: any = null
let housingAccount: any = null
if (rootDeptId) {
const rootDept = await prisma.department.findUnique({
where: { id: rootDeptId },
include: { socialAccount: true, housingAccount: true },
})
socialAccount = rootDept?.socialAccount || null
housingAccount = rootDept?.housingAccount || null
}
// 回退到公司默认账户
if (!socialAccount) {
socialAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'SOCIAL', isDefault: true } })
}
if (!housingAccount) {
housingAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'HOUSING', isDefault: true } })
}
return { socialAccount, housingAccount }
}
/**
*
*/
async function getStandardByAccountAndMonth(accountId: string, month: string) {
const standard = await prisma.socialYearStandard.findFirst({
where: {
accountId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
if (!standard) {
// 回退到当前生效标准
return prisma.socialYearStandard.findFirst({
where: { accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
return standard
}
// ========== 薪酬模版 ========== // ========== 薪酬模版 ==========
const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [ const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
@@ -49,8 +113,8 @@ export function calcSocialInsurance(base: number, config: any) {
const medMin = config.medicalBaseMin && config.medicalBaseMin > 0 ? config.medicalBaseMin : config.baseMin const medMin = config.medicalBaseMin && config.medicalBaseMin > 0 ? config.medicalBaseMin : config.baseMin
const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax
const medicalBase = Math.min(Math.max(base, medMin), medMax) const medicalBase = Math.min(Math.max(base, medMin), medMax)
let socialEmp = actualBase * (config.pensionEmp + config.unemploymentEmp) / 100 + medicalBase * config.medicalEmp / 100 let socialEmp = actualBase * (config.pensionEmp + config.unemploymentEmp) / 100 + medicalBase * config.medicalEmp / 100 + (config.medicalEmpExtra || 0)
let socialOrg = actualBase * (config.pensionOrg + config.unemploymentOrg + config.injuryOrg) / 100 + medicalBase * (config.medicalOrg + config.maternityOrg) / 100 let socialOrg = actualBase * (config.pensionOrg + config.unemploymentOrg + config.injuryOrg) / 100 + medicalBase * (config.medicalOrg + config.maternityOrg) / 100 + (config.medicalOrgExtra || 0)
// 附加险种(大病险/长护险等) // 附加险种(大病险/长护险等)
const extraItems: any[] = [] const extraItems: any[] = []
if (config.extraInsurances && Array.isArray(config.extraInsurances)) { if (config.extraInsurances && Array.isArray(config.extraInsurances)) {
@@ -75,7 +139,10 @@ export function calcSocialInsurance(base: number, config: any) {
} }
export function calcHousingFund(base: number, config: any) { export function calcHousingFund(base: number, config: any) {
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) // 公积金专用上下限(housingBaseMin/Max),为0时回退到社保的 baseMin/baseMax
const min = (config.housingBaseMin && config.housingBaseMin > 0) ? config.housingBaseMin : config.baseMin
const max = (config.housingBaseMax && config.housingBaseMax > 0) ? config.housingBaseMax : config.baseMax
const actualBase = Math.min(Math.max(base, min), max)
const housingEmp = actualBase * config.housingEmp / 100 const housingEmp = actualBase * config.housingEmp / 100
const housingOrg = actualBase * config.housingOrg / 100 const housingOrg = actualBase * config.housingOrg / 100
return { actualBase, housingEmp, housingOrg } return { actualBase, housingEmp, housingOrg }
@@ -137,42 +204,63 @@ export async function calcBatchEntry(
month: string, month: string,
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number; positionSalary?: number; performanceSalary?: number; senioritySalary?: number; transportAllowance?: number; mealAllowance?: number; housingAllowance?: number; communicationAllowance?: number; otherDeduction?: number }, inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number; positionSalary?: number; performanceSalary?: number; senioritySalary?: number; transportAllowance?: number; mealAllowance?: number; housingAllowance?: number; communicationAllowance?: number; otherDeduction?: number },
batchType: string = 'REGULAR', batchType: string = 'REGULAR',
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } }, options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number }; prevDeferred?: { socialEmp?: number; housingEmp?: number; minWage?: number } },
) { ) {
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } }) const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' } if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
const cityWhere = employee.city ? { orgId, city: employee.city } : { orgId } // 劳务协议/实习协议人员:不缴纳社保公积金,基数强制 0
const [socialConfig, housingConfig] = await Promise.all([ const latestContract = employee.contracts[0]
prisma.socialInsuranceConfig.findFirst({ const isNoSocialContract = latestContract && ['LABOR', 'INTERNSHIP'].includes(latestContract.contractType)
where: {
...cityWhere,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: {
...cityWhere,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
}),
])
// 社保基数:优先用员工核定基数,否则用基本工资 // 通过员工账户查年度标准(新逻辑),回退到旧配置(兼容)
const socialBase = employee.socialInsBase || inputs.baseSalary const { socialAccount, housingAccount } = await getEmployeeAccounts(orgId, employeeId)
const housingBase = employee.housingFundBase || inputs.baseSalary let socialConfig: any = null
let housingConfig: any = null
let supplementaryHousingConfig: any = null
if (socialAccount) {
socialConfig = await getStandardByAccountAndMonth(socialAccount.id, month)
}
if (housingAccount) {
housingConfig = await getStandardByAccountAndMonth(housingAccount.id, month)
}
// 补充公积金:员工单独关联的补充公积金账户
if (employee.supplementaryHousingAccountId) {
supplementaryHousingConfig = await getStandardByAccountAndMonth(employee.supplementaryHousingAccountId, month)
}
// 回退到旧配置表(兼容未迁移数据)
const cityWhere = employee.city ? { orgId, city: employee.city } : { orgId }
if (!socialConfig) {
socialConfig = await prisma.socialInsuranceConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
})
}
if (!housingConfig) {
housingConfig = await prisma.housingFundConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
})
}
// 社保基数:劳务协议/实习协议人员强制 0;否则优先用员工核定基数(含0),未设置时回退到基本工资
const socialBase = isNoSocialContract ? 0 : (employee.socialInsBase != null ? employee.socialInsBase : inputs.baseSalary)
const housingBase = isNoSocialContract ? 0 : (employee.housingFundBase != null ? employee.housingFundBase : inputs.baseSalary)
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0 let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
let suppHousingEmp = 0, suppHousingOrg = 0
// 年终奖/奖金批次、补偿金批次:不扣社保公积金 // 年终奖/奖金批次、补偿金批次、劳务协议/实习协议人员:不扣社保公积金
// 其他批次:计算当月应缴全额,减去已归档批次已扣金额,差额为本批次应扣 // 其他批次:计算当月应缴全额,减去已归档批次已扣金额,差额为本批次应扣
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial) { if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial && !isNoSocialContract) {
// 1. 计算当月应缴社保公积金全额 // 1. 计算当月应缴社保公积金全额
let fullSocialEmp = 0, fullSocialOrg = 0, fullHousingEmp = 0, fullHousingOrg = 0 let fullSocialEmp = 0, fullSocialOrg = 0, fullHousingEmp = 0, fullHousingOrg = 0
let fullSuppHousingEmp = 0, fullSuppHousingOrg = 0
if (socialConfig) { if (socialConfig) {
const social = calcSocialInsurance(socialBase, socialConfig) const social = calcSocialInsurance(socialBase, socialConfig)
fullSocialEmp = social.socialEmp fullSocialEmp = social.socialEmp
@@ -183,6 +271,12 @@ export async function calcBatchEntry(
fullHousingEmp = housing.housingEmp fullHousingEmp = housing.housingEmp
fullHousingOrg = housing.housingOrg fullHousingOrg = housing.housingOrg
} }
// 补充公积金(用同样的公积金基数)
if (supplementaryHousingConfig) {
const suppHousing = calcHousingFund(housingBase, supplementaryHousingConfig)
fullSuppHousingEmp = suppHousing.housingEmp
fullSuppHousingOrg = suppHousing.housingOrg
}
// 2. 查询该员工当月已归档批次中已扣的社保公积金金额 // 2. 查询该员工当月已归档批次中已扣的社保公积金金额
const archivedEntries = await prisma.batchEntry.findMany({ const archivedEntries = await prisma.batchEntry.findMany({
@@ -191,25 +285,37 @@ export async function calcBatchEntry(
employeeId, employeeId,
batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } }, batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
}, },
select: { socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true }, select: { socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, supplementaryHousingEmp: true, supplementaryHousingOrg: true },
}) })
const deductedSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) const deductedSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0)
const deductedSocialOrg = archivedEntries.reduce((s, e) => s + e.socialOrg, 0) const deductedSocialOrg = archivedEntries.reduce((s, e) => s + e.socialOrg, 0)
const deductedHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) const deductedHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0)
const deductedHousingOrg = archivedEntries.reduce((s, e) => s + e.housingOrg, 0) const deductedHousingOrg = archivedEntries.reduce((s, e) => s + e.housingOrg, 0)
const deductedSuppHousingEmp = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingEmp || 0), 0)
const deductedSuppHousingOrg = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingOrg || 0), 0)
// 3. 本批次应扣 = 应缴全额 - 已扣金额(不足额补扣,足额为0) // 3. 本批次应扣 = 应缴全额 - 已扣金额(不足额补扣,足额为0)
socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp) socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp)
socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg) socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg)
housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp) housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp)
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg) housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
suppHousingEmp = Math.max(0, fullSuppHousingEmp - deductedSuppHousingEmp)
suppHousingOrg = Math.max(0, fullSuppHousingOrg - deductedSuppHousingOrg)
// 4. 叠加上月递延的社保/公积金(入职当月未扣完的部分,本月补扣)
if (options?.prevDeferred) {
socialEmp += options.prevDeferred.socialEmp || 0
housingEmp += options.prevDeferred.housingEmp || 0
}
} }
// 保存系统计算值(覆盖前) // 保存系统计算值(覆盖前,含递延
const systemSocialEmp = socialEmp const systemSocialEmp = socialEmp
const systemSocialOrg = socialOrg const systemSocialOrg = socialOrg
const systemHousingEmp = housingEmp const systemHousingEmp = housingEmp
const systemHousingOrg = housingOrg const systemHousingOrg = housingOrg
const systemSupplementaryHousingEmp = suppHousingEmp
const systemSupplementaryHousingOrg = suppHousingOrg
// 手动覆盖社保值 // 手动覆盖社保值
if (options?.overrideSocial) { if (options?.overrideSocial) {
@@ -253,12 +359,27 @@ export async function calcBatchEntry(
status: 'ARCHIVED', status: 'ARCHIVED',
}, },
}, },
select: { totalPay: true, socialEmp: true, housingEmp: true, tax: true }, select: { totalPay: true, socialEmp: true, housingEmp: true, supplementaryHousingEmp: true, tax: true },
}) })
const ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay const ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay
const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp + (e.supplementaryHousingEmp || 0), 0) + housingEmp + suppHousingEmp
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
// 专项附加扣除:按月读取 SpecialDeductionRecord 实际填报金额累加
// 优先使用按月记录;若无按月记录,回退到员工便捷字段 × 月数(兼容旧数据)
const deductionRecords = await prisma.specialDeductionRecord.findMany({
where: { orgId, employeeId, month: { startsWith: year, lte: month } },
select: { amount: true, month: true },
})
let ytdSpecialDeduction: number
if (deductionRecords.length > 0) {
// 按月实际填报金额累加
ytdSpecialDeduction = deductionRecords.reduce((s, r) => s + r.amount, 0)
} else {
// 回退:员工便捷字段 × 月数(兼容未填报按月记录的旧数据)
ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
}
const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0) const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0)
const deductionAmount = 5000 * Number(month.slice(5, 7)) const deductionAmount = 5000 * Number(month.slice(5, 7))
const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction) const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
@@ -271,6 +392,8 @@ export async function calcBatchEntry(
ytdSocialEmp, ytdSocialEmp,
ytdHousingEmp, ytdHousingEmp,
ytdSpecialDeduction, ytdSpecialDeduction,
specialDeductionSource: deductionRecords.length > 0 ? '按月记录' : '便捷字段',
specialDeductionRecords: deductionRecords.length,
ytdTaxableIncome, ytdTaxableIncome,
ytdTaxDeducted, ytdTaxDeducted,
currentMonthTax: tax, currentMonthTax: tax,
@@ -278,21 +401,116 @@ export async function calcBatchEntry(
} }
} }
const netPay = totalPay - socialEmp - housingEmp - tax // ── 最低工资保护 + 递延扣款逻辑 ──
// 读取最低工资标准(从年度标准或旧配置表)
let minWage = 0
if (socialConfig && (socialConfig as any).minWage) {
minWage = (socialConfig as any).minWage
}
// 上月递延的最低工资补齐差额(本批次需扣回)
const prevDeferredMinWage = options?.prevDeferred?.minWage || 0
// 初始实发 = 应发 - 社保 - 公积金 - 补充公积金 - 个税 - 上月递延最低工资补扣
let netPay = totalPay - socialEmp - housingEmp - suppHousingEmp - tax - prevDeferredMinWage
// 递延金额(本批次扣不动、递延到次月的部分)
let deferredSocialEmp = 0
let deferredHousingEmp = 0
let deferredMinWage = 0
let minWageApplied = 0 // 当月实际补齐到最低工资的金额
// 最低工资保护:当月累计实发不得低于最低工资标准(仅 REGULAR / TERMINATION 批次)
// 第二批次时需判断"当月累计实发"(已归档批次 netPay 之和 + 本批次 netPay)是否低于 minWage
if (minWage > 0 && batchType !== 'BONUS' && batchType !== 'SEVERANCE') {
// 查当月已归档批次的累计实发(同月已归档的 REGULAR/TERMINATION 批次)
const archivedNetPayEntries = await prisma.batchEntry.findMany({
where: {
orgId,
employeeId,
batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
},
select: { netPay: true },
})
const archivedNetPay = archivedNetPayEntries.reduce((s, e) => s + e.netPay, 0)
// 当月累计实发 = 已归档批次实发 + 本批次实发
const monthlyCumulativeNetPay = archivedNetPay + netPay
// 仅当当月累计实发低于最低工资时才触发保护
if (monthlyCumulativeNetPay < minWage) {
// 需要补齐的金额 = 最低工资 - 当月累计实发
// 但本批次最多补齐到本批次实发为正,且不超过 minWage - archivedNetPay
const targetNetPay = Math.max(0, minWage - archivedNetPay)
const shortfall = targetNetPay - netPay // 需要补齐的金额(正数表示需要补齐)
if (shortfall > 0) {
// 优先递延社保个人部分(减少当月社保扣款)
if (shortfall <= socialEmp) {
// 只递延社保就够了
deferredSocialEmp = shortfall
socialEmp -= shortfall
netPay = targetNetPay
minWageApplied = shortfall
} else if (shortfall <= socialEmp + housingEmp + suppHousingEmp) {
// 递延全部社保 + 部分公积金(含补充)
deferredSocialEmp = socialEmp
socialEmp = 0
const housingShortfall = shortfall - deferredSocialEmp
// 先递延基本公积金,再递延补充公积金
if (housingShortfall <= housingEmp) {
deferredHousingEmp = housingShortfall
housingEmp -= housingShortfall
} else {
deferredHousingEmp = housingEmp
const suppShortfall = housingShortfall - housingEmp
housingEmp = 0
suppHousingEmp -= suppShortfall
}
netPay = targetNetPay
minWageApplied = shortfall
} else {
// 递延全部社保 + 全部公积金(含补充),仍不足 → 差额作为最低工资补齐递延
deferredSocialEmp = socialEmp
deferredHousingEmp = housingEmp
const remainingShortfall = shortfall - socialEmp - housingEmp - suppHousingEmp
socialEmp = 0
housingEmp = 0
suppHousingEmp = 0
deferredMinWage = remainingShortfall
netPay = targetNetPay
minWageApplied = shortfall
}
}
}
}
return { return {
socialEmp: Math.round(socialEmp * 100) / 100, socialEmp: Math.round(socialEmp * 100) / 100,
socialOrg: Math.round(socialOrg * 100) / 100, socialOrg: Math.round(socialOrg * 100) / 100,
housingEmp: Math.round(housingEmp * 100) / 100, housingEmp: Math.round(housingEmp * 100) / 100,
housingOrg: Math.round(housingOrg * 100) / 100, housingOrg: Math.round(housingOrg * 100) / 100,
supplementaryHousingEmp: Math.round(suppHousingEmp * 100) / 100,
supplementaryHousingOrg: Math.round(suppHousingOrg * 100) / 100,
systemSocialEmp: Math.round(systemSocialEmp * 100) / 100, systemSocialEmp: Math.round(systemSocialEmp * 100) / 100,
systemSocialOrg: Math.round(systemSocialOrg * 100) / 100, systemSocialOrg: Math.round(systemSocialOrg * 100) / 100,
systemHousingEmp: Math.round(systemHousingEmp * 100) / 100, systemHousingEmp: Math.round(systemHousingEmp * 100) / 100,
systemHousingOrg: Math.round(systemHousingOrg * 100) / 100, systemHousingOrg: Math.round(systemHousingOrg * 100) / 100,
systemSupplementaryHousingEmp: Math.round(systemSupplementaryHousingEmp * 100) / 100,
systemSupplementaryHousingOrg: Math.round(systemSupplementaryHousingOrg * 100) / 100,
tax, tax,
taxBreakdown, taxBreakdown,
totalPay: Math.round(totalPay * 100) / 100, totalPay: Math.round(totalPay * 100) / 100,
netPay: Math.round(netPay * 100) / 100, netPay: Math.round(netPay * 100) / 100,
// 最低工资保护 + 递延信息
minWage,
minWageApplied: Math.round(minWageApplied * 100) / 100,
deferredSocialEmp: Math.round(deferredSocialEmp * 100) / 100,
deferredHousingEmp: Math.round(deferredHousingEmp * 100) / 100,
deferredMinWage: Math.round(deferredMinWage * 100) / 100,
prevDeferredSocialEmp: options?.prevDeferred?.socialEmp || 0,
prevDeferredHousingEmp: options?.prevDeferred?.housingEmp || 0,
prevDeferredMinWage,
} }
} }
@@ -420,6 +638,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
where: { employeeId_month: { employeeId, month } }, where: { employeeId_month: { employeeId, month } },
update: { update: {
baseSalary: Math.round(summary.baseSalary * 100) / 100, baseSalary: Math.round(summary.baseSalary * 100) / 100,
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
overtimePay: Math.round(summary.overtimePay * 100) / 100, overtimePay: Math.round(summary.overtimePay * 100) / 100,
allowance: Math.round(summary.allowance * 100) / 100, allowance: Math.round(summary.allowance * 100) / 100,
deduction: Math.round(summary.deduction * 100) / 100, deduction: Math.round(summary.deduction * 100) / 100,
@@ -441,6 +660,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
employeeId, employeeId,
month, month,
baseSalary: Math.round(summary.baseSalary * 100) / 100, baseSalary: Math.round(summary.baseSalary * 100) / 100,
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
overtimePay: Math.round(summary.overtimePay * 100) / 100, overtimePay: Math.round(summary.overtimePay * 100) / 100,
allowance: Math.round(summary.allowance * 100) / 100, allowance: Math.round(summary.allowance * 100) / 100,
deduction: Math.round(summary.deduction * 100) / 100, deduction: Math.round(summary.deduction * 100) / 100,
@@ -514,7 +734,7 @@ export async function prePayrollCheck(orgId: string, batchId: string): Promise<P
// 1. 社保基数是否在上下限范围内 // 1. 社保基数是否在上下限范围内
if (socialConfig) { if (socialConfig) {
for (const entry of entries) { for (const entry of entries) {
const base = entry.employee.socialInsBase || entry.baseSalary const base = entry.employee.socialInsBase != null ? entry.employee.socialInsBase : entry.baseSalary
if (base < socialConfig.baseMin || base > socialConfig.baseMax) { if (base < socialConfig.baseMin || base > socialConfig.baseMax) {
checks.push({ checks.push({
code: 'SOCIAL_BASE_OUT_OF_RANGE', code: 'SOCIAL_BASE_OUT_OF_RANGE',
@@ -532,7 +752,7 @@ export async function prePayrollCheck(orgId: string, batchId: string): Promise<P
// 2. 公积金基数是否在上下限范围内 // 2. 公积金基数是否在上下限范围内
if (housingConfig) { if (housingConfig) {
for (const entry of entries) { for (const entry of entries) {
const base = entry.employee.housingFundBase || entry.baseSalary const base = entry.employee.housingFundBase != null ? entry.employee.housingFundBase : entry.baseSalary
if (base < housingConfig.baseMin || base > housingConfig.baseMax) { if (base < housingConfig.baseMin || base > housingConfig.baseMax) {
checks.push({ checks.push({
code: 'HOUSING_BASE_OUT_OF_RANGE', code: 'HOUSING_BASE_OUT_OF_RANGE',
@@ -712,6 +932,57 @@ export async function prePayrollCheck(orgId: string, batchId: string): Promise<P
} }
} }
// 11. 实发工资低于最低工资标准(剔除加班费后比较)
for (const entry of entries) {
const minWage = (entry as any).minWage || 0
if (minWage > 0 && batch.type !== 'BONUS' && batch.type !== 'SEVERANCE') {
// 最低工资剔除项:加班费、高温/夜班津贴等不计入
const comparablePay = entry.totalPay - entry.overtimePay
if (comparablePay < minWage && entry.netPay < minWage) {
const applied = (entry as any).minWageApplied || 0
if (applied > 0) {
// 已触发最低工资保护,检查递延情况
const deferredTotal = ((entry as any).deferredSocialEmp || 0) + ((entry as any).deferredHousingEmp || 0) + ((entry as any).deferredMinWage || 0)
checks.push({
code: 'MIN_WAGE_DEFERRED',
name: '最低工资保护已触发(递延扣款)',
status: 'WARNING',
message: `${entry.employee.name} 应发 ¥${comparablePay}(剔除加班费)低于最低工资 ¥${minWage},已补齐 ¥${applied},递延扣款 ¥${deferredTotal} 将在次月补扣`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { comparablePay, minWage, applied, deferredTotal },
})
} else {
checks.push({
code: 'BELOW_MIN_WAGE',
name: '实发低于最低工资标准',
status: 'FAIL',
message: `${entry.employee.name} 实发 ¥${entry.netPay} 低于当地最低工资标准 ¥${minWage},请检查社保基数或启用最低工资保护`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { netPay: entry.netPay, minWage, comparablePay },
})
}
}
}
}
// 12. 上月递延扣款待补扣
for (const entry of entries) {
const prevDeferred = ((entry as any).prevDeferredSocialEmp || 0) + ((entry as any).prevDeferredHousingEmp || 0) + ((entry as any).prevDeferredMinWage || 0)
if (prevDeferred > 0) {
checks.push({
code: 'PREV_DEFERRED_RECOVERED',
name: '上月递延扣款已补扣',
status: 'WARNING',
message: `${entry.employee.name} 本月补扣上月递延 ¥${prevDeferred}(社保 ¥${(entry as any).prevDeferredSocialEmp || 0} + 公积金 ¥${(entry as any).prevDeferredHousingEmp || 0} + 最低工资补齐 ¥${(entry as any).prevDeferredMinWage || 0}`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { prevDeferred },
})
}
}
// 汇总 // 汇总
const passedCount = entries.length > 0 ? Math.max(0, entries.length - checks.filter(c => c.employeeId).length) : 0 const passedCount = entries.length > 0 ? Math.max(0, entries.length - checks.filter(c => c.employeeId).length) : 0
const failedCount = checks.filter(c => c.status === 'FAIL').length const failedCount = checks.filter(c => c.status === 'FAIL').length
+9 -2
View File
@@ -30,6 +30,13 @@ const SEED_DATA: KnowledgeSeed[] = [
{ title: '劳动法 第四十四条 加班工资标准', content: '延长工作时间不低于工资150%;休息日加班不能补休的不低于200%;法定休假日不低于300%。', source: '劳动法', category: '加班' }, { title: '劳动法 第四十四条 加班工资标准', content: '延长工作时间不低于工资150%;休息日加班不能补休的不低于200%;法定休假日不低于300%。', source: '劳动法', category: '加班' },
{ title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', source: '社会保险法', category: '社保' }, { title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', source: '社会保险法', category: '社保' },
{ title: '劳动合同法 第八十二条 二倍工资起算', content: '用人单位自用工之日起满一年不与劳动者订立书面劳动合同的,视为用人单位与劳动者已订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' }, { title: '劳动合同法 第八十二条 二倍工资起算', content: '用人单位自用工之日起满一年不与劳动者订立书面劳动合同的,视为用人单位与劳动者已订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
// ===== 北京地区地方性指引 =====
{ title: '北京:规范用人单位单方解除劳动合同工作指引(试行)- 通知工会程序', content: '用人单位单方解除劳动合同的,应当提前五个工作日将理由书面通知本单位工会;尚未建立工会组织的,应当通知上一级工会。上一级工会,原则上是用人单位实际经营地的乡镇、街道、园区、开发区总工会。用人单位隶属区产业工会的,应通知其所属区产业工会。用人单位隶属市产业工会的,应通知其所属市产业工会中的上级工会。用人单位可联系所在地的乡镇、街道、园区、开发区总工会,或拨打12351职工服务热线,咨询本单位对应的上一级工会等事宜。', source: '北京市协调劳动关系三方', category: '解除终止' },
{ title: '北京:通知工会函内容要求', content: '用人单位单方解除劳动合同书面通知工会时,通知文本应当写明劳动者基本情况(姓名、性别、年龄、身份证号、工作岗位、工作年限、劳动合同期限、联系方式),解除劳动合同所依据的基本事实,解除劳动合同援引的法律、法规、本单位规章制度的相关条款,以及用人单位的联系人和联系方式等内容。', source: '北京市协调劳动关系三方', category: '解除终止' },
{ title: '北京:工会回执要求', content: '用人单位书面通知工会的送达方式为直接送达、邮寄送达等。用人单位工会收到通知后,应及时出具书面回执;上一级工会收到尚未建立工会组织的用人单位通知后,确定用人单位属于联系范围的,应出具书面回执,认为不属于的,应及时提醒用人单位。', source: '北京市协调劳动关系三方', category: '解除终止' },
{ title: '北京:工会劳动法律监督提示函', content: '工会认为用人单位违反法律、法规和有关合同的,应当自收到用人单位书面通知五个工作日内,通过发放《工会劳动法律监督提示函》等方式提出意见建议。用人单位应当研究工会的意见,并将处理结果书面通知工会。', source: '北京市协调劳动关系三方', category: '解除终止' },
{ title: '北京:仲裁审查通知工会义务', content: '劳动人事争议仲裁委员会在审理解除劳动合同争议案件过程中,依法审查用人单位单方解除劳动合同时是否履行通知工会的义务,了解掌握工会提出的意见建议。未履行通知工会程序的可能被认定为违法解除。', source: '北京市协调劳动关系三方', category: '解除终止' },
{ title: '北京市实施《中华人民共和国工会法》办法', content: '北京市实施《中华人民共和国工会法》办法是北京市地方性法规,对工会组织建设、工会权利义务、工会经费等作出规定。用人单位单方解除劳动合同应当遵守该办法关于通知工会的规定。', source: '北京市实施工会法办法', category: '解除终止' },
] ]
let initialized = false let initialized = false
@@ -109,8 +116,8 @@ const HELP_SEED_DATA: KnowledgeSeed[] = [
{ title: '怎么修改公司信息', content: '在设置页面可以修改公司名称、行业、规模等基本信息。这些信息会影响风险检测的准确性,请如实填写。', source: '使用帮助', category: '系统帮助-通知设置' }, { title: '怎么修改公司信息', content: '在设置页面可以修改公司名称、行业、规模等基本信息。这些信息会影响风险检测的准确性,请如实填写。', source: '使用帮助', category: '系统帮助-通知设置' },
{ title: '可以在手机上使用吗', content: '可以。用手机浏览器打开本网站即可,手机版会自动显示底部导航栏。建议添加到手机桌面像App一样使用。苹果手机Safari打开点击底部分享按钮选择添加到主屏幕。安卓手机Chrome打开点击右上角菜单选择添加到主屏幕。', source: '使用帮助', category: '系统帮助-快速入门' }, { title: '可以在手机上使用吗', content: '可以。用手机浏览器打开本网站即可,手机版会自动显示底部导航栏。建议添加到手机桌面像App一样使用。苹果手机Safari打开点击底部分享按钮选择添加到主屏幕。安卓手机Chrome打开点击右上角菜单选择添加到主屏幕。', source: '使用帮助', category: '系统帮助-快速入门' },
{ title: '第一次使用该从哪里开始', content: '建议按以下顺序:1添加员工信息,2填写合同信息,3设置社保基数和比例,4创建发薪批次,5有不懂的随时点帮助图标查看。不用担心填错,所有信息都可以随时修改。', source: '使用帮助', category: '系统帮助-快速入门' }, { title: '第一次使用该从哪里开始', content: '建议按以下顺序:1添加员工信息,2填写合同信息,3设置社保基数和比例,4创建发薪批次,5有不懂的随时点帮助图标查看。不用担心填错,所有信息都可以随时修改。', source: '使用帮助', category: '系统帮助-快速入门' },
{ title: '企业用工专家是什么', content: '这是一个帮您管理员工、合同、工资和社保的工具,可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。', source: '使用帮助', category: '系统帮助-快速入门' }, { title: '安职通是什么', content: '这是一个帮您管理员工、合同、工资和社保的工具,可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。', source: '使用帮助', category: '系统帮助-快速入门' },
{ title: '我的数据安全吗', content: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息如身份证号都经过加密存储。', source: '使用帮助', category: '系统帮助-常见问题' }, { title: '我的数据安全吗', content: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息如证件号码都经过加密存储。', source: '使用帮助', category: '系统帮助-常见问题' },
{ title: '可以导出数据吗', content: '可以。在员工管理页面可以导出员工名单为Excel文件。工资批次也可以导出为Excel方便财务对账。', source: '使用帮助', category: '系统帮助-常见问题' }, { title: '可以导出数据吗', content: '可以。在员工管理页面可以导出员工名单为Excel文件。工资批次也可以导出为Excel方便财务对账。', source: '使用帮助', category: '系统帮助-常见问题' },
{ title: '可以多人同时使用吗', content: '可以。在设置页面可以添加多个HR账号,不同账号可以设置不同权限。比如一个管理员、几个普通HR。', source: '使用帮助', category: '系统帮助-常见问题' }, { title: '可以多人同时使用吗', content: '可以。在设置页面可以添加多个HR账号,不同账号可以设置不同权限。比如一个管理员、几个普通HR。', source: '使用帮助', category: '系统帮助-常见问题' },
] ]
+2 -2
View File
@@ -1,7 +1,7 @@
import crypto from 'crypto' import crypto from 'crypto'
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
// 从身份证号提取出生日期 // 从证件号码提取出生日期
export function extractBirthDateFromIdCard(idCard: string): Date | null { export function extractBirthDateFromIdCard(idCard: string): Date | null {
// 18位身份证:7-14位为出生日期 YYYYMMDD // 18位身份证:7-14位为出生日期 YYYYMMDD
if (idCard.length === 18) { if (idCard.length === 18) {
@@ -24,7 +24,7 @@ export function extractBirthDateFromIdCard(idCard: string): Date | null {
return null return null
} }
// 从身份证号提取性别(18位:第17位奇数为男,偶数为女;15位:第15位) // 从证件号码提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
export function extractGenderFromIdCard(idCard: string): string | null { export function extractGenderFromIdCard(idCard: string): string | null {
if (idCard.length === 18) { if (idCard.length === 18) {
const genderCode = parseInt(idCard.substring(16, 17)) const genderCode = parseInt(idCard.substring(16, 17))
+94 -34
View File
@@ -225,12 +225,23 @@ export async function detectContractRisks(orgId: string) {
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
}) })
} else if (daysToExpire <= 30) { } else if (daysToExpire <= 30) {
// 30 天内到期:HIGH,需立即处理
risks.push({
employeeId: emp.id,
type: 'CONTRACT',
level: 'HIGH',
title: `${emp.name}的合同即将到期(${daysToExpire}天)`,
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)}30天内到期需立即准备续签或终止。`,
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
})
} else if (daysToExpire <= 60) {
// 31-60 天到期:MEDIUM,提前预警给 HR 反应时间
risks.push({ risks.push({
employeeId: emp.id, employeeId: emp.id,
type: 'CONTRACT', type: 'CONTRACT',
level: 'MEDIUM', level: 'MEDIUM',
title: `${emp.name}的合同即将到期(${daysToExpire}`, title: `${emp.name}的合同将于${daysToExpire}后到期`,
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)}提前准备续签或终止。`, description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)}建议提前准备续签或终止。`,
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
}) })
} }
@@ -312,8 +323,8 @@ export async function detectTerminationRisks(orgId: string) {
type: 'TERMINATION', type: 'TERMINATION',
level: 'HIGH', level: 'HIGH',
title: `${emp.name}处于孕期/哺乳期,解聘受限`, title: `${emp.name}处于孕期/哺乳期,解聘受限`,
description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。', description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`, actionUrl: `/special-status?employee=${encodeURIComponent(emp.name)}&type=PREGNANCY`,
}) })
} }
if (emp.isInMedicalPeriod) { if (emp.isInMedicalPeriod) {
@@ -322,8 +333,8 @@ export async function detectTerminationRisks(orgId: string) {
type: 'TERMINATION', type: 'TERMINATION',
level: 'MEDIUM', level: 'MEDIUM',
title: `${emp.name}处于医疗期,解聘需谨慎`, title: `${emp.name}处于医疗期,解聘需谨慎`,
description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。', description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`, actionUrl: `/special-status?employee=${encodeURIComponent(emp.name)}&type=MEDICAL_PERIOD`,
}) })
} }
if (emp.isWorkInjured) { if (emp.isWorkInjured) {
@@ -332,8 +343,8 @@ export async function detectTerminationRisks(orgId: string) {
type: 'TERMINATION', type: 'TERMINATION',
level: 'HIGH', level: 'HIGH',
title: `${emp.name}工伤期间,解聘受限`, title: `${emp.name}工伤期间,解聘受限`,
description: '工伤职工在停工留薪期内不得解除劳动合同。', description: '工伤职工在停工留薪期内不得解除劳动合同。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`, actionUrl: `/special-status?employee=${encodeURIComponent(emp.name)}&type=WORK_INJURY`,
}) })
} }
} }
@@ -424,10 +435,10 @@ export async function detectMonthlyTasks(orgId: string) {
const today = now.getDate() const today = now.getDate()
const tasks = [ const tasks = [
{ day: setting.payrollDay, title: `${currentMonth}月 发放工资`, desc: `每月${setting.payrollDay}日前完成工资发放`, url: '/money' }, { day: setting.payrollDay, title: `${currentMonth}月 发放工资`, desc: `每月${setting.payrollDay}日前完成工资发放`, url: '/money?tab=batch' },
{ day: setting.socialInsDay, title: `${currentMonth}月 缴纳社保`, desc: `每月${setting.socialInsDay}日前完成社保缴纳`, url: '/money' }, { day: setting.socialInsDay, title: `${currentMonth}月 缴纳社保`, desc: `每月${setting.socialInsDay}日前完成社保缴纳`, url: '/social?tab=monthly' },
{ day: setting.housingFundDay, title: `${currentMonth}月 缴纳公积金`, desc: `每月${setting.housingFundDay}日前完成公积金缴纳`, url: '/money' }, { day: setting.housingFundDay, title: `${currentMonth}月 缴纳公积金`, desc: `每月${setting.housingFundDay}日前完成公积金缴纳`, url: '/social?tab=monthly' },
{ day: setting.taxDay, title: `${currentMonth}月 申报个税`, desc: `每月${setting.taxDay}日前完成个税申报`, url: '/money' }, { day: setting.taxDay, title: `${currentMonth}月 申报个税`, desc: `每月${setting.taxDay}日前完成个税申报`, url: '/money?tab=batch' },
] ]
const risks: { employeeId: null; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = [] const risks: { employeeId: null; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
@@ -535,7 +546,7 @@ export async function runRiskDetection(orgId: string) {
// 按 employeeId:type 归并,不依赖 actionUrlactionUrl 可能因天数变化而不同) // 按 employeeId:type 归并,不依赖 actionUrlactionUrl 可能因天数变化而不同)
const existingRisks = await prisma.riskItem.findMany({ const existingRisks = await prisma.riskItem.findMany({
where: { orgId, status: { in: ['PENDING', 'RESOLVED', 'IGNORED'] } }, where: { orgId, status: { in: ['PENDING', 'RESOLVED', 'IGNORED'] } },
select: { id: true, employeeId: true, type: true, title: true, status: true }, select: { id: true, employeeId: true, type: true, title: true, status: true, actionUrl: true },
}) })
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}`)) const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}`))
// PENDING 风险的 employeeId:type → record 映射,用于更新标题 // PENDING 风险的 employeeId:type → record 映射,用于更新标题
@@ -581,18 +592,19 @@ export async function runRiskDetection(orgId: string) {
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)), ...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
] ]
// 更新已存在的 PENDING 风险:标题/量化信息可能因天数变化而变化 // 更新已存在的 PENDING 风险:标题/量化信息/actionUrl 可能因天数变化或代码更新而变化
const toUpdate: { id: string; title: string; description: string; estimatedLoss: number; lossRange: [number, number]; deadline?: Date }[] = [] const toUpdate: { id: string; title: string; description: string; actionUrl: string; estimatedLoss: number; lossRange: [number, number]; deadline?: Date }[] = []
for (const r of nonMonthlyRisks) { for (const r of nonMonthlyRisks) {
const key = `${r.employeeId}:${r.type}` const key = `${r.employeeId}:${r.type}`
const existing = pendingRiskMap.get(key) const existing = pendingRiskMap.get(key)
if (existing && existing.title !== r.title) { if (existing && (existing.title !== r.title || existing.actionUrl !== r.actionUrl)) {
const emp = r.employeeId ? empMap.get(r.employeeId) : null const emp = r.employeeId ? empMap.get(r.employeeId) : null
const cost = estimateRiskCost(r, emp) const cost = estimateRiskCost(r, emp)
toUpdate.push({ toUpdate.push({
id: existing.id, id: existing.id,
title: r.title, title: r.title,
description: r.description, description: r.description,
actionUrl: r.actionUrl,
estimatedLoss: cost.estimatedLoss, estimatedLoss: cost.estimatedLoss,
lossRange: cost.lossRange, lossRange: cost.lossRange,
deadline: cost.deadline, deadline: cost.deadline,
@@ -605,6 +617,7 @@ export async function runRiskDetection(orgId: string) {
data: { data: {
title: u.title, title: u.title,
description: u.description, description: u.description,
actionUrl: u.actionUrl,
estimatedLoss: u.estimatedLoss, estimatedLoss: u.estimatedLoss,
lossRange: u.lossRange, lossRange: u.lossRange,
deadline: u.deadline, deadline: u.deadline,
@@ -612,6 +625,19 @@ export async function runRiskDetection(orgId: string) {
}) })
} }
// 更新 MONTHLY 类型 PENDING 记录的 actionUrl(标题不变但 actionUrl 可能因代码更新而变化)
const monthlyPending = existingRisks.filter((r: typeof existingRisks[number]) => r.status === 'PENDING' && r.type === 'MONTHLY')
const monthlyActionUrlMap = new Map(monthlyTasks.map((r) => [r.title, r.actionUrl]))
for (const r of monthlyPending) {
const newUrl = monthlyActionUrlMap.get(r.title)
if (newUrl && r.actionUrl !== newUrl) {
await prisma.riskItem.update({
where: { id: r.id },
data: { actionUrl: newUrl },
})
}
}
if (toCreate.length > 0) { if (toCreate.length > 0) {
// 为每个风险计算量化信息 // 为每个风险计算量化信息
const createData = toCreate.map((r) => { const createData = toCreate.map((r) => {
@@ -933,12 +959,17 @@ export async function getDashboardData(orgId: string) {
const catKey = getTodoRiskCategoryKey(t.title || '') const catKey = getTodoRiskCategoryKey(t.title || '')
const dedupKey = `${personKey}:${catKey}` const dedupKey = `${personKey}:${catKey}`
const existing = dedupedTodoMap.get(dedupKey) const existing = dedupedTodoMap.get(dedupKey)
// 去重保留优先级更高(estimatedLoss 更大或 deadline 更近)的一条
if (!existing || t.estimatedLoss > existing.estimatedLoss) { if (!existing || t.estimatedLoss > existing.estimatedLoss) {
dedupedTodoMap.set(dedupKey, t) dedupedTodoMap.set(dedupKey, t)
} }
} }
const dedupedTodos = Array.from(dedupedTodoMap.values()) const dedupedTodos = Array.from(dedupedTodoMap.values())
// 去重后的待办按优先级排序:URGENT > HIGH > MEDIUM > LOW
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
dedupedTodos.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
// 风险分布:使用去重后的数据,与待办列表一致 // 风险分布:使用去重后的数据,与待办列表一致
const riskDistribution = { const riskDistribution = {
contract: dedupedTodos.filter((t) => t.type === 'CONTRACT').length, contract: dedupedTodos.filter((t) => t.type === 'CONTRACT').length,
@@ -946,8 +977,7 @@ export async function getDashboardData(orgId: string) {
termination: dedupedTodos.filter((t) => t.type === 'TERMINATION').length, termination: dedupedTodos.filter((t) => t.type === 'TERMINATION').length,
} }
// 按优先级排序:URGENT > HIGH > MEDIUM > LOW // 按优先级排序:URGENT > HIGH > MEDIUM > LOWpriorityOrder 已在上方声明)
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
todosWithCost.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]) todosWithCost.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
const topRisks = todosWithCost const topRisks = todosWithCost
@@ -1411,6 +1441,7 @@ export async function getComplianceScore(orgId: string) {
const [ const [
totalEmployees, totalEmployees,
unsignedContracts, unsignedContracts,
pendingSignContracts,
expiringContracts, expiringContracts,
pendingRisks, pendingRisks,
highRisks, highRisks,
@@ -1426,6 +1457,14 @@ export async function getComplianceScore(orgId: string) {
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }), prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
// 未签合同:完全无合同记录的在职员工 // 未签合同:完全无合同记录的在职员工
prisma.employee.count({ where: { orgId, status: 'ACTIVE', contracts: { none: {} } } }), prisma.employee.count({ where: { orgId, status: 'ACTIVE', contracts: { none: {} } } }),
// 待签署:有合同记录但 signDate 为 null(合同已创建但未完成签署)
prisma.employee.count({
where: {
orgId,
status: 'ACTIVE',
contracts: { some: { signDate: null } },
},
}),
prisma.laborContract.count({ prisma.laborContract.count({
where: { where: {
orgId, orgId,
@@ -1453,8 +1492,8 @@ export async function getComplianceScore(orgId: string) {
}), }),
]) ])
// 5 维度评分计算 // 5 维度评分计算(未签合同 + 待签署都扣分)
const contractScore = Math.max(0, 100 - unsignedContracts * 15 - expiringContracts * 5) const contractScore = Math.max(0, 100 - unsignedContracts * 15 - pendingSignContracts * 10 - expiringContracts * 5)
const policyScore = Math.max(0, 100 - policiesWithoutPublish * 12) const policyScore = Math.max(0, 100 - policiesWithoutPublish * 12)
const attendanceScore = Math.max(0, 100 - attendanceUnconfirmed * 8) const attendanceScore = Math.max(0, 100 - attendanceUnconfirmed * 8)
const salaryScore = totalPayslips > 0 const salaryScore = totalPayslips > 0
@@ -1463,7 +1502,7 @@ export async function getComplianceScore(orgId: string) {
const socialScore = (socialConfig ? 50 : 0) + (housingConfig ? 50 : 0) const socialScore = (socialConfig ? 50 : 0) + (housingConfig ? 50 : 0)
const dimensions = [ const dimensions = [
{ key: 'contract', name: '合同管理', score: contractScore, todoCount: unsignedContracts + expiringContracts }, { key: 'contract', name: '合同管理', score: contractScore, todoCount: unsignedContracts + pendingSignContracts + expiringContracts },
{ key: 'policy', name: '规章制度', score: policyScore, todoCount: policiesWithoutPublish }, { key: 'policy', name: '规章制度', score: policyScore, todoCount: policiesWithoutPublish },
{ key: 'attendance', name: '考勤工时', score: attendanceScore, todoCount: attendanceUnconfirmed }, { key: 'attendance', name: '考勤工时', score: attendanceScore, todoCount: attendanceUnconfirmed },
{ key: 'salary', name: '薪酬工资', score: salaryScore, todoCount: unconfirmedPayslips }, { key: 'salary', name: '薪酬工资', score: salaryScore, todoCount: unconfirmedPayslips },
@@ -1583,6 +1622,7 @@ export async function getHealthCheck(orgId: string) {
const [ const [
totalEmployees, totalEmployees,
unsignedContracts, unsignedContracts,
pendingSignContracts,
expiringContracts, expiringContracts,
policiesWithoutPublish, policiesWithoutPublish,
totalPolicies, totalPolicies,
@@ -1602,6 +1642,14 @@ export async function getHealthCheck(orgId: string) {
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }), prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
// 未签合同:完全无合同记录的在职员工 // 未签合同:完全无合同记录的在职员工
prisma.employee.count({ where: { orgId, status: 'ACTIVE', contracts: { none: {} } } }), prisma.employee.count({ where: { orgId, status: 'ACTIVE', contracts: { none: {} } } }),
// 待签署:有合同记录但 signDate 为 null(合同已创建但未完成签署)
prisma.employee.count({
where: {
orgId,
status: 'ACTIVE',
contracts: { some: { signDate: null } },
},
}),
prisma.laborContract.count({ prisma.laborContract.count({
where: { where: {
orgId, orgId,
@@ -1635,37 +1683,49 @@ export async function getHealthCheck(orgId: string) {
{ {
key: 'contract', key: 'contract',
name: '合同管理', name: '合同管理',
score: Math.max(0, 100 - unsignedContracts * 15 - expiringContracts * 5), score: Math.max(0, 100 - unsignedContracts * 15 - pendingSignContracts * 10 - expiringContracts * 5),
findings: [ findings: [
unsignedContracts > 0 ? `${unsignedContracts} 名员工未签书面合同,存在双倍工资风险` : '全员已签书面合同', unsignedContracts > 0 ? `${unsignedContracts} 名员工未签书面合同,存在双倍工资风险` : '全员已有合同记录',
pendingSignContracts > 0 ? `${pendingSignContracts} 份合同待签署(已创建未完成签署)` : '',
expiringContracts > 0 ? `${expiringContracts} 份合同 30 天内到期,需及时续签` : '无即将到期合同', expiringContracts > 0 ? `${expiringContracts} 份合同 30 天内到期,需及时续签` : '无即将到期合同',
].filter(Boolean), ].filter(Boolean),
recommendations: [ recommendations: [
unsignedContracts > 0 ? '立即补签书面合同,超过1个月未签需支付双倍工资' : '', unsignedContracts > 0 ? '立即补签书面合同,超过1个月未签需支付双倍工资' : '',
pendingSignContracts > 0 ? '尽快完成待签署合同的签署流程,未签署合同在仲裁中可能无效' : '',
expiringContracts > 0 ? '提前30天发送续签意向书,保留协商证据' : '', expiringContracts > 0 ? '提前30天发送续签意向书,保留协商证据' : '',
].filter(Boolean), ].filter(Boolean),
}, },
{ {
key: 'salary_social', key: 'salary',
name: '薪酬社保', name: '薪酬工资',
score: totalPayslips > 0
? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100))
: 100,
findings: [
totalPayslips > 0
? `${unconfirmedPayslips}/${totalPayslips} 张工资条未确认`
: '暂无工资条记录',
].filter(Boolean),
recommendations: [
unconfirmedPayslips > 0 ? '提醒员工及时确认工资条,保留薪酬合规证据' : '',
].filter(Boolean),
},
{
key: 'social',
name: '社保公积金',
score: Math.round( score: Math.round(
(socialConfig ? 40 : 0) + (socialConfig ? 50 : 0) +
(housingConfig ? 20 : 0) + (housingConfig ? 30 : 0) +
(totalPayslips > 0 ? Math.max(0, 20 - Math.round((unconfirmedPayslips / totalPayslips) * 20)) : 20) +
(employeesNoSocial === 0 ? 20 : Math.max(0, 20 - employeesNoSocial * 2)) (employeesNoSocial === 0 ? 20 : Math.max(0, 20 - employeesNoSocial * 2))
), ),
findings: [ findings: [
socialConfig ? '社保配置已设置' : '未配置社保比例,建议尽快设置', socialConfig ? '社保配置已设置' : '未配置社保比例,建议尽快设置',
housingConfig ? '公积金配置已设置' : '未配置公积金比例', housingConfig ? '公积金配置已设置' : '未配置公积金比例',
totalPayslips > 0
? `${unconfirmedPayslips}/${totalPayslips} 张工资条未确认`
: '暂无工资条记录',
employeesNoSocial > 0 ? `${employeesNoSocial} 名员工社保记录异常` : '社保缴纳记录正常', employeesNoSocial > 0 ? `${employeesNoSocial} 名员工社保记录异常` : '社保缴纳记录正常',
].filter(Boolean), ].filter(Boolean),
recommendations: [ recommendations: [
!socialConfig ? '前往社保管理页面设置企业/个人缴纳比例' : '', !socialConfig ? '前往社保管理页面设置企业/个人缴纳比例' : '',
!housingConfig ? '根据当地政策配置公积金比例' : '', !housingConfig ? '根据当地政策配置公积金比例' : '',
unconfirmedPayslips > 0 ? '提醒员工及时确认工资条,保留薪酬合规证据' : '',
].filter(Boolean), ].filter(Boolean),
}, },
{ {
@@ -1928,7 +1988,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
}), }),
]) ])
// 按身份证号去重(同一人可能有多条 Employee 记录),无身份证号时回退到 employeeId // 按证件号码去重(同一人可能有多条 Employee 记录),无证件号码时回退到 employeeId
// 同时按风险类型去重(同一风险被重复创建解决多次,只取 estimatedLoss 最大的一条) // 同时按风险类型去重(同一风险被重复创建解决多次,只取 estimatedLoss 最大的一条)
const personBreakdown: Record<string, { const personBreakdown: Record<string, {
personKey: string personKey: string
@@ -1988,7 +2048,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
} }
for (const r of employeeRiskDetails) { for (const r of employeeRiskDetails) {
// 去重优先级:身份证号 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算 // 去重优先级:证件号码 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算
const personKey = r.employee?.idCardHash || r.employee?.name || r.employeeId || '_unknown' const personKey = r.employee?.idCardHash || r.employee?.name || r.employeeId || '_unknown'
if (!personBreakdown[personKey]) { if (!personBreakdown[personKey]) {
personBreakdown[personKey] = { personBreakdown[personKey] = {
@@ -92,6 +92,11 @@ export async function createSpecialStatus(orgId: string, userId: string, data: a
throw { code: 'NOT_FOUND', message: '员工不存在' } throw { code: 'NOT_FOUND', message: '员工不存在' }
} }
// 合规校验:男职工不可选择三期
if (data.type === 'PREGNANCY' && employee.gender === '男') {
throw { code: 'VALIDATION_ERROR', message: '三期仅适用于女性员工,男职工不可选择三期' }
}
// 三期自动计算 // 三期自动计算
let pregnancyData: any = {} let pregnancyData: any = {}
if (data.type === 'PREGNANCY' && data.expectedDueDate) { if (data.type === 'PREGNANCY' && data.expectedDueDate) {
+102 -8
View File
@@ -18,7 +18,7 @@ export const documentTemplates: DocumentTemplate[] = [
name: '固定期限劳动合同', name: '固定期限劳动合同',
category: 'CONTRACT', category: 'CONTRACT',
description: '标准固定期限劳动合同模板,适用于大多数正式员工', description: '标准固定期限劳动合同模板,适用于大多数正式员工',
variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'monthlySalary', 'socialInsBase'], variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'baseSalary', 'performanceSalary', 'probationSalary', 'socialInsBase'],
content: `劳动合同书 content: `劳动合同书
{{companyName}} {{companyName}}
@@ -36,7 +36,7 @@ export const documentTemplates: DocumentTemplate[] = [
840 840
{{monthlySalary}}{{monthlySalary}}15 {{baseSalary}}/{{performanceSalary}}/{{probationSalary}}15
{{socialInsBase}} {{socialInsBase}}
@@ -64,7 +64,7 @@ export const documentTemplates: DocumentTemplate[] = [
name: '无固定期限劳动合同', name: '无固定期限劳动合同',
category: 'CONTRACT', category: 'CONTRACT',
description: '无固定期限劳动合同模板,适用于符合签订条件的情况', description: '无固定期限劳动合同模板,适用于符合签订条件的情况',
variables: ['companyName', 'employeeName', 'startDate', 'position', 'monthlySalary'], variables: ['companyName', 'employeeName', 'startDate', 'position', 'baseSalary', 'performanceSalary'],
content: `无固定期限劳动合同书 content: `无固定期限劳动合同书
{{companyName}} {{companyName}}
@@ -79,7 +79,7 @@ export const documentTemplates: DocumentTemplate[] = [
{{position}} {{position}}
{{monthlySalary}} {{baseSalary}}/{{performanceSalary}}/
@@ -95,7 +95,7 @@ export const documentTemplates: DocumentTemplate[] = [
content: `解除劳动合同协议书 content: `解除劳动合同协议书
{{companyName}} {{companyName}}
{{employeeName}}{{idCard}} {{employeeName}}{{idCard}}
@@ -135,7 +135,7 @@ export const documentTemplates: DocumentTemplate[] = [
content: `解除劳动合同协议书 content: `解除劳动合同协议书
{{companyName}} {{companyName}}
{{employeeName}}{{idCard}} {{employeeName}}{{idCard}}
@@ -241,7 +241,7 @@ ____年__月__日
name: '试用期转正通知书', name: '试用期转正通知书',
category: 'NOTICE', category: 'NOTICE',
description: '试用期考核合格转正通知', description: '试用期考核合格转正通知',
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'monthlySalary', 'companyName'], variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'baseSalary', 'performanceSalary', 'companyName'],
content: `试用期转正通知书 content: `试用期转正通知书
{{employeeName}} {{employeeName}}
@@ -249,7 +249,7 @@ ____年__月__日
{{regularDate}}{{position}} {{regularDate}}{{position}}
{{monthlySalary}} {{baseSalary}}/{{performanceSalary}}/
{{probationEndDate}} {{probationEndDate}}
@@ -278,6 +278,100 @@ ____年__月__日
____________ ____年__月__日`, ____________ ____年__月__日`,
}, },
{
id: 'tpl_union_termination_notice',
name: '拟解除劳动合同通知工会函(北京)',
category: 'NOTICE',
description: '北京地区单方解除劳动合同前通知工会的函件样式(依据《规范用人单位单方解除劳动合同工作指引》)',
variables: ['companyName', 'employeeName', 'employeeGender', 'employeeAge', 'idCard', 'employeePosition', 'workYears', 'contractPeriod', 'employeePhone', 'terminationReason', 'legalBasis', 'contactPerson', 'contactPhone', 'companyAddress'],
content: `拟解除劳动合同通知工会函
{{companyName}}{{employeeName}}
{{companyName}}
{{employeeName}}
{{employeeName}}
{{employeeGender}}
{{employeeAge}}
{{idCard}}
{{employeePosition}}
{{workYears}}
{{contractPeriod}}
{{employeePhone}}
{{terminationReason}}
{{legalBasis}}
{{contactPerson}}
{{contactPhone}}
{{companyAddress}}
{{companyName}}
____年__月__日`,
},
{
id: 'tpl_union_receipt',
name: '工会回执(北京)',
category: 'OTHER',
description: '工会收到用人单位单方解除劳动合同通知后的书面回执样式',
variables: ['receiptNo', 'companyName', 'employeeName', 'unionContactPerson', 'unionContactPhone', 'unionAddress'],
content: `回执
{{receiptNo}}
{{companyName}}
{{employeeName}}
{{unionContactPerson}}
{{unionContactPhone}}
{{unionAddress}}
____年__月__日`,
},
{
id: 'tpl_union_supervision_letter',
name: '工会劳动法律监督提示函(北京)',
category: 'NOTICE',
description: '工会认为用人单位违反法律法规时提出的意见建议函件样式',
variables: ['letterNo', 'companyName', 'employeeName', 'supervisionIssues', 'supervisionOpinions', 'unionContactPerson', 'unionContactPhone', 'unionAddress'],
content: `工会劳动法律监督提示函
{{letterNo}}
{{companyName}}
{{employeeName}}
{{supervisionIssues}}
{{supervisionOpinions}}
{{unionContactPerson}}
{{unionContactPhone}}
{{unionAddress}}
____年__月__日`,
},
] ]
/** /**
+83 -4
View File
@@ -1,5 +1,6 @@
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
import { RiskAssessment, TerminationReason } from '@prisma/client' import { RiskAssessment, TerminationReason } from '@prisma/client'
import { autoCreateEsignRecord } from './esign.service'
function dateToMonth(date: Date): string { function dateToMonth(date: Date): string {
const y = date.getFullYear() const y = date.getFullYear()
@@ -7,6 +8,13 @@ function dateToMonth(date: Date): string {
return `${y}-${m}` return `${y}-${m}`
} }
/** 计算上一个月,格式 YYYY-MM */
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
if (m === 1) return `${y - 1}-12`
return `${y}-${String(m - 1).padStart(2, '0')}`
}
export interface ChecklistItem { export interface ChecklistItem {
key: string key: string
label: string label: string
@@ -16,7 +24,9 @@ export interface ChecklistItem {
suggestionType?: 'info' | 'warning' | 'required' suggestionType?: 'info' | 'warning' | 'required'
} }
export function getChecklistForReason(reason: string, employee?: any): ChecklistItem[] { export function getChecklistForReason(reason: string, employee?: any, orgCity?: string): ChecklistItem[] {
// 北京地区单方解除须通知工会(依据《规范用人单位单方解除劳动合同工作指引》)
const isBeijing = !orgCity || orgCity === '北京' || orgCity === '北京市' || orgCity?.includes('北京')
switch (reason) { switch (reason) {
case 'NEGOTIATED': case 'NEGOTIATED':
return [ return [
@@ -33,7 +43,14 @@ export function getChecklistForReason(reason: string, employee?: any): Checklist
return [ return [
{ key: 'has_rules', label: '是否有规章制度依据', autoChecked: null }, { key: 'has_rules', label: '是否有规章制度依据', autoChecked: null },
{ key: 'has_evidence', label: '是否有违纪证据', autoChecked: null }, { key: 'has_evidence', label: '是否有违纪证据', autoChecked: null },
{ key: 'notify_union', label: '是否事先通知工会', autoChecked: null }, ...(isBeijing ? [{
key: 'notify_union',
label: '是否提前5个工作日书面通知工会',
autoChecked: null as any,
suggestion: '北京地区要求:单方解除劳动合同须提前5个工作日将理由书面通知本单位工会;未建立工会的通知上一级工会(用人单位实际经营地的乡镇/街道/园区/开发区总工会)。可在「文本模板库」中使用《拟解除劳动合同通知工会函》模板。',
suggestionType: 'required' as const,
}] : []),
...(isBeijing ? [{ key: 'union_receipt', label: '是否收到工会书面回执', autoChecked: null as any }] : []),
{ key: 'written_notice', label: '是否出具书面解除通知', autoChecked: null }, { key: 'written_notice', label: '是否出具书面解除通知', autoChecked: null },
] ]
case 'NONFAULT': { case 'NONFAULT': {
@@ -86,12 +103,29 @@ export function getChecklistForReason(reason: string, employee?: any): Checklist
suggestionType: 'required', suggestionType: 'required',
}) })
// 通知工会 — 北京地区单方解除必经程序
if (isBeijing) {
items.push({
key: 'notify_union',
label: '是否提前5个工作日书面通知工会',
autoChecked: null,
suggestion: '北京地区要求:单方解除须提前5个工作日将理由书面通知本单位工会;未建立工会的通知上一级工会。可在「文本模板库」中使用《拟解除劳动合同通知工会函》模板。',
suggestionType: 'required',
})
items.push({
key: 'union_receipt',
label: '是否收到工会书面回执',
autoChecked: null,
})
}
return items return items
} }
case 'LAYOFF': case 'LAYOFF':
return [ return [
{ key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明', autoChecked: null }, { key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明', autoChecked: null },
{ key: 'listen_opinions', label: '是否听取工会或职工意见', autoChecked: null }, { key: 'listen_opinions', label: '是否听取工会或职工意见', autoChecked: null },
...(isBeijing ? [{ key: 'union_receipt', label: '是否收到工会书面回执', autoChecked: null as any }] : []),
{ key: 'report_labor_dept', label: '是否向劳动行政部门报告', autoChecked: null }, { key: 'report_labor_dept', label: '是否向劳动行政部门报告', autoChecked: null },
{ {
key: 'compensation_paid', label: '是否支付经济补偿金', key: 'compensation_paid', label: '是否支付经济补偿金',
@@ -545,6 +579,26 @@ export async function createDraft(orgId: string, userId: string, data: any) {
const { level } = assessRisk(employee, data.reason || 'NEGOTIATED') const { level } = assessRisk(employee, data.reason || 'NEGOTIATED')
// 自动推导社保/公积金截止月(基于组织 socialInsCutoffDay 配置)
let socialInsEndMonth = data.socialInsEndMonth || null
let housingFundEndMonth = data.housingFundEndMonth || null
if (data.terminationDate && !socialInsEndMonth) {
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { socialInsCutoffDay: true } })
const cutoffDay = org?.socialInsCutoffDay ?? 15
const termDate = new Date(data.terminationDate)
const termDay = termDate.getDate()
const termMonth = dateToMonth(termDate)
// cutoffDay 日前离职 → 截止月 = 离职月 - 1;cutoffDay 日后离职 → 截止月 = 离职月
if (termDay <= cutoffDay) {
const prevMon = prevMonth(termMonth)
socialInsEndMonth = prevMon
housingFundEndMonth = prevMon
} else {
socialInsEndMonth = termMonth
housingFundEndMonth = termMonth
}
}
const record = await prisma.terminationRecord.create({ const record = await prisma.terminationRecord.create({
data: { data: {
orgId, orgId,
@@ -554,8 +608,8 @@ export async function createDraft(orgId: string, userId: string, data: any) {
terminationDate: data.terminationDate ? new Date(data.terminationDate) : new Date(), terminationDate: data.terminationDate ? new Date(data.terminationDate) : new Date(),
resignationReason: data.resignationReason || null, resignationReason: data.resignationReason || null,
compensation: data.compensation || 0, compensation: data.compensation || 0,
socialInsEndMonth: data.socialInsEndMonth || null, socialInsEndMonth,
housingFundEndMonth: data.housingFundEndMonth || null, housingFundEndMonth,
riskLevel: level, riskLevel: level,
checklist: data.checklist || {}, checklist: data.checklist || {},
remark: data.remark || null, remark: data.remark || null,
@@ -568,6 +622,18 @@ export async function createDraft(orgId: string, userId: string, data: any) {
}, },
}) })
// 主动离职时自动创建离职协议电子签署记录
if (data.type === 'RESIGNATION') {
await autoCreateEsignRecord({
orgId,
employeeId: data.employeeId,
scene: 'RESIGNATION',
documentTitle: `${employee.name}的离职协议`,
remark: '员工主动离职时自动发起',
createdBy: userId,
})
}
return { id: record.id } return { id: record.id }
} }
@@ -723,6 +789,19 @@ export async function executeTermination(orgId: string, recordId: string, userId
}) })
}) })
// 公司解聘执行完成后自动创建离职协议电子签署记录
if (record.type === 'TERMINATION') {
const employee = await prisma.employee.findFirst({ where: { id: record.employeeId }, select: { name: true } })
await autoCreateEsignRecord({
orgId,
employeeId: record.employeeId,
scene: 'RESIGNATION',
documentTitle: `${employee?.name || '员工'}的解除劳动合同协议`,
remark: '公司解聘执行完成时自动发起',
createdBy: userId,
})
}
return { id: recordId } return { id: recordId }
} }
+44 -6
View File
@@ -1,7 +1,7 @@
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
import { encrypt } from '../lib/crypto' import { encrypt, decrypt } from '../lib/crypto'
import { createDraft as createTerminationDraft, executeTermination } from './termination.service' import { createDraft as createTerminationDraft, executeTermination } from './termination.service'
import { createEmployee, addContract } from './contract.service' import { createEmployee, addContract, prevMonth } from './contract.service'
import { runRiskDetection } from './risk.service' import { runRiskDetection } from './risk.service'
// 13类流程定义 // 13类流程定义
@@ -66,13 +66,51 @@ export async function executeWorkProcess(processId: string, type: string, formDa
return { employeeId } return { employeeId }
} }
case 'CONFIRM': { case 'CONFIRM': {
const { employeeId, regularSalary } = formData const { employeeId, regularSalary, baseSalary, performanceSalary } = formData
if (employeeId) { if (employeeId) {
if (regularSalary) { if (regularSalary) {
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
const oldSalary = employee ? Number(decrypt(employee.monthlySalary)) || 0 : 0
const newSalary = Number(regularSalary) || 0
// 同步更新工资结构
const updateData: any = { monthlySalary: encrypt(String(regularSalary)) }
if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0))
if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0))
await prisma.employee.update({ await prisma.employee.update({
where: { id: employeeId }, where: { id: employeeId },
data: { monthlySalary: encrypt(String(regularSalary)) }, data: updateData,
}) })
// 记录薪资变更(试用期薪资 → 转正薪资)
if (oldSalary !== newSalary) {
const now = new Date()
const nowMonth = now.toISOString().slice(0, 7)
await prisma.salaryChangeRecord.updateMany({
where: { employeeId, endMonth: null },
data: { endMonth: prevMonth(nowMonth) },
})
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId,
oldSalary,
newSalary,
effectiveDate: now,
effectiveMonth: nowMonth,
endMonth: null,
changeType: 'CONFIRM',
reason: '试用期转正薪资调整',
createdBy: userId,
},
})
}
// 校验转正薪资与最新合同试用期薪资是否一致(提示性校验)
const latestContract = await prisma.laborContract.findFirst({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
})
if (latestContract?.probationSalary && latestContract.probationSalary !== newSalary) {
console.warn(`[CONFIRM] 转正薪资 ¥${newSalary} 与合同试用期薪资 ¥${latestContract.probationSalary} 不一致,员工: ${employeeId}`)
}
await runRiskDetection(orgId) await runRiskDetection(orgId)
} }
} }
@@ -275,13 +313,13 @@ ${body}
const templates: Record<string, (data: any, org: string) => string> = { const templates: Record<string, (data: any, org: string) => string> = {
INCOME_CERT: (data, org) => wrapHtml('收入证明', ` INCOME_CERT: (data, org) => wrapHtml('收入证明', `
<div class="body"> ${data.employeeName || '___'}${data.idCardNumber || '___'} ${data.hireDate || '___'} ${data.position || '___'} </div> <div class="body"> ${data.employeeName || '___'}${data.idCardNumber || '___'} ${data.hireDate || '___'} ${data.position || '___'} </div>
<div class="body"> ${data.monthlyIncome || '___'} </div> <div class="body"> ${data.monthlyIncome || '___'} </div>
<div class="body"> ${data.purpose || '___'}</div> <div class="body"> ${data.purpose || '___'}</div>
<div class="body"></div> <div class="body"></div>
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`), <div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
LEAVING_CERT: (data, org) => wrapHtml('离职证明', ` LEAVING_CERT: (data, org) => wrapHtml('离职证明', `
<div class="body"> ${data.employeeName || '___'}${data.idCardNumber || '___'} ${data.hireDate || '___'} ${data.leaveDate || '___'} ${data.position || '___'}</div> <div class="body"> ${data.employeeName || '___'}${data.idCardNumber || '___'} ${data.hireDate || '___'} ${data.leaveDate || '___'} ${data.position || '___'}</div>
<div class="body"> ${data.leaveDate || '___'} </div> <div class="body"> ${data.leaveDate || '___'} </div>
<div class="body"></div> <div class="body"></div>
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`), <div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
+3 -3
View File
@@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
# 企业用工专家 一键部署脚本 # 安职通 一键部署脚本
# 部署到 154.8.162.18 -> /var/www/turbohr/ -> https://on.hr8ai.top/ # 部署到 154.8.162.18 -> /var/www/turbohr/ -> https://on.hr8ai.top/
# #
# 用法: bash deploy.sh # 用法: bash deploy.sh
@@ -342,7 +342,7 @@ verify() {
echo "" echo ""
echo "=========================================" echo "========================================="
echo " 企业用工专家 部署完成!" echo " 安职通 部署完成!"
echo " 访问地址: https://${DOMAIN}/" echo " 访问地址: https://${DOMAIN}/"
echo " 后端 API: https://${DOMAIN}/api/v1/" echo " 后端 API: https://${DOMAIN}/api/v1/"
echo " PM2 管理: pm2 list (服务器上)" echo " PM2 管理: pm2 list (服务器上)"
@@ -357,7 +357,7 @@ if [ "$1" = "--init" ]; then
IS_INIT=true IS_INIT=true
fi fi
log "开始部署 企业用工专家${REMOTE_HOST}" log "开始部署 安职通${REMOTE_HOST}"
log "域名: ${DOMAIN} 目录: ${REMOTE_DIR}" log "域名: ${DOMAIN} 目录: ${REMOTE_DIR}"
echo "" echo ""
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
<title>企业用工专家</title> <title>安职通</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+4 -4
View File
@@ -16,7 +16,7 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"lucide-react": "^0.428.0", "lucide-react": "^0.428.0",
"mammoth": "^1.12.0", "mammoth": "^1.12.1",
"qrcode.react": "^4.0.1", "qrcode.react": "^4.0.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -3348,9 +3348,9 @@
} }
}, },
"node_modules/mammoth": { "node_modules/mammoth": {
"version": "1.12.0", "version": "1.12.1",
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz", "resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.1.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", "integrity": "sha512-nCH9KKjWi3jQ+i8bUKs7k1yrXtSEGpWgF8IYkzsFMcbn+5S6l4bZEBbyx2hOQErFiXPuAs9RPa6qjXVxhyx/8g==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"@xmldom/xmldom": "^0.8.6", "@xmldom/xmldom": "^0.8.6",
+1 -1
View File
@@ -17,7 +17,7 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"lucide-react": "^0.428.0", "lucide-react": "^0.428.0",
"mammoth": "^1.12.0", "mammoth": "^1.12.1",
"qrcode.react": "^4.0.1", "qrcode.react": "^4.0.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+2 -2
View File
@@ -6,7 +6,7 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0"> <meta http-equiv="Expires" content="0">
<title>TurboHR 验收测试清单</title> <title>安职通 验收测试清单</title>
<style> <style>
:root { :root {
--primary: #4f46e5; --primary: #4f46e5;
@@ -280,7 +280,7 @@ body {
<!-- Header --> <!-- Header -->
<div class="header"> <div class="header">
<h1>TurboHR 验收测试清单</h1> <h1>安职通 验收测试清单</h1>
<div class="verifier-section"> <div class="verifier-section">
<label>验收人:</label> <label>验收人:</label>
<div class="autocomplete-wrap"> <div class="autocomplete-wrap">
+66 -53
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useState, useEffect } from 'react' import { Suspense, useState, useEffect } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate } from 'react-router-dom'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { useAuthStore } from './store/authStore' import { useAuthStore } from './store/authStore'
@@ -11,62 +11,66 @@ import PageContainer from './components/layout/PageContainer'
import { CommandPalette } from './components/ui/CommandPalette' import { CommandPalette } from './components/ui/CommandPalette'
import { SkeletonPage } from './components/ui/Skeleton' import { SkeletonPage } from './components/ui/Skeleton'
import ErrorBoundary from './components/ui/ErrorBoundary' import ErrorBoundary from './components/ui/ErrorBoundary'
import { lazyRetry } from './lib/lazyRetry'
const Login = lazy(() => import('./pages/auth/Login')) const Login = lazyRetry(() => import('./pages/auth/Login'))
const Register = lazy(() => import('./pages/auth/Register')) const Register = lazyRetry(() => import('./pages/auth/Register'))
const ForgotPassword = lazy(() => import('./pages/auth/ForgotPassword')) const ForgotPassword = lazyRetry(() => import('./pages/auth/ForgotPassword'))
const Dashboard = lazy(() => import('./pages/Dashboard')) const Dashboard = lazyRetry(() => import('./pages/Dashboard'))
const Money = lazy(() => import('./pages/Money')) const Money = lazyRetry(() => import('./pages/Money'))
const SocialInsurance = lazy(() => import('./pages/SocialInsurance')) const SocialInsurance = lazyRetry(() => import('./pages/SocialInsurance'))
const Roster = lazy(() => import('./pages/Roster')) const Overtime = lazyRetry(() => import('./pages/Overtime'))
const Termination = lazy(() => import('./pages/Termination')) const Roster = lazyRetry(() => import('./pages/Roster'))
const AIAssistant = lazy(() => import('./pages/AIAssistant')) const OrgChart = lazyRetry(() => import('./pages/OrgChart'))
const Settings = lazy(() => import('./pages/Settings')) const SupportDashboard = lazyRetry(() => import('./pages/support/SupportDashboard'))
const Evidence = lazy(() => import('./pages/Evidence')) const Termination = lazyRetry(() => import('./pages/Termination'))
const Policies = lazy(() => import('./pages/Policies')) const AIAssistant = lazyRetry(() => import('./pages/AIAssistant'))
const Attendance = lazy(() => import('./pages/Attendance')) const Settings = lazyRetry(() => import('./pages/Settings'))
const Templates = lazy(() => import('./pages/Templates')) const Evidence = lazyRetry(() => import('./pages/Evidence'))
const AuditLog = lazy(() => import('./pages/AuditLog')) const Policies = lazyRetry(() => import('./pages/Policies'))
const Notifications = lazy(() => import('./pages/Notifications')) const Attendance = lazyRetry(() => import('./pages/Attendance'))
const PortalLogin = lazy(() => import('./pages/portal/PortalLogin')) const Templates = lazyRetry(() => import('./pages/Templates'))
const Payslip = lazy(() => import('./pages/portal/Payslip')) const AuditLog = lazyRetry(() => import('./pages/AuditLog'))
const MyContract = lazy(() => import('./pages/portal/MyContract')) const Notifications = lazyRetry(() => import('./pages/Notifications'))
const Onboarding = lazy(() => import('./pages/portal/Onboarding')) const PortalLogin = lazyRetry(() => import('./pages/portal/PortalLogin'))
const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm')) const Payslip = lazyRetry(() => import('./pages/portal/Payslip'))
const MyPolicies = lazy(() => import('./pages/portal/MyPolicies')) const MyContract = lazyRetry(() => import('./pages/portal/MyContract'))
const AutoLogin = lazy(() => import('./pages/portal/AutoLogin')) const Onboarding = lazyRetry(() => import('./pages/portal/Onboarding'))
const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator')) const ContractConfirm = lazyRetry(() => import('./pages/portal/ContractConfirm'))
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck')) const MyPolicies = lazyRetry(() => import('./pages/portal/MyPolicies'))
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport')) const AutoLogin = lazyRetry(() => import('./pages/portal/AutoLogin'))
const CalendarPage = lazy(() => import('./pages/Calendar')) const MedicalPeriodCalculator = lazyRetry(() => import('./pages/tools/MedicalPeriodCalculator'))
const WorkProcess = lazy(() => import('./pages/WorkProcess')) const HealthCheck = lazyRetry(() => import('./pages/tools/HealthCheck'))
const MyAttendance = lazy(() => import('./pages/portal/MyAttendance')) const AnnualValueReport = lazyRetry(() => import('./pages/tools/AnnualValueReport'))
const MyLeave = lazy(() => import('./pages/portal/MyLeave')) const CalendarPage = lazyRetry(() => import('./pages/Calendar'))
const SpecialStatus = lazy(() => import('./pages/SpecialStatus')) const MyAttendance = lazyRetry(() => import('./pages/portal/MyAttendance'))
const CompanyFiles = lazy(() => import('./pages/CompanyFiles')) const MyLeave = lazyRetry(() => import('./pages/portal/MyLeave'))
const LeaveApproval = lazy(() => import('./pages/LeaveApproval')) const SpecialStatus = lazyRetry(() => import('./pages/SpecialStatus'))
const TrainingRecords = lazy(() => import('./pages/roster/TrainingRecords')) const CompanyFiles = lazyRetry(() => import('./pages/CompanyFiles'))
const PerformanceRecords = lazy(() => import('./pages/roster/PerformanceRecords')) const LeaveApproval = lazyRetry(() => import('./pages/LeaveApproval'))
const DisciplinaryRecords = lazy(() => import('./pages/roster/DisciplinaryRecords')) const TrainingRecords = lazyRetry(() => import('./pages/roster/TrainingRecords'))
const PerformanceRecords = lazyRetry(() => import('./pages/roster/PerformanceRecords'))
const DisciplinaryRecords = lazyRetry(() => import('./pages/roster/DisciplinaryRecords'))
// Sprint 4-5 新增页面 // Sprint 4-5 新增页面
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome')) const EmployeeHome = lazyRetry(() => import('./pages/portal/EmployeeHome'))
const OnboardingProgress = lazy(() => import('./pages/portal/OnboardingProgress')) const OnboardingProgress = lazyRetry(() => import('./pages/portal/OnboardingProgress'))
const ResignationApply = lazy(() => import('./pages/portal/ResignationApply')) const ResignationApply = lazyRetry(() => import('./pages/portal/ResignationApply'))
const MyEsign = lazy(() => import('./pages/portal/MyEsign')) const MyEsign = lazyRetry(() => import('./pages/portal/MyEsign'))
const MyRecords = lazy(() => import('./pages/portal/MyRecords')) const MyRecords = lazyRetry(() => import('./pages/portal/MyRecords'))
const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter')) const RiskCenter = lazyRetry(() => import('./pages/compliance/RiskCenter'))
const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard')) const SalaryDashboard = lazyRetry(() => import('./pages/SalaryDashboard'))
const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance')) const CommercialInsurance = lazyRetry(() => import('./pages/CommercialInsurance'))
const EmployeeBenefits = lazy(() => import('./pages/EmployeeBenefits')) const EmployeeBenefits = lazyRetry(() => import('./pages/EmployeeBenefits'))
const ESign = lazy(() => import('./pages/ESign')) const ESign = lazyRetry(() => import('./pages/ESign'))
const CommissionBonus = lazyRetry(() => import('./pages/CommissionBonus'))
// 平台管理端 // 平台管理端
const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin')) const PlatformLogin = lazyRetry(() => import('./pages/platform/PlatformLogin'))
const PlatformDashboard = lazy(() => import('./pages/platform/PlatformDashboard')) const PlatformDashboard = lazyRetry(() => import('./pages/platform/PlatformDashboard'))
const PlatformOrgs = lazy(() => import('./pages/platform/PlatformOrgs')) const PlatformOrgs = lazyRetry(() => import('./pages/platform/PlatformOrgs'))
const PlatformUsers = lazy(() => import('./pages/platform/PlatformUsers')) const PlatformUsers = lazyRetry(() => import('./pages/platform/PlatformUsers'))
const PlatformSidebar = lazy(() => import('./components/layout/PlatformSidebar')) const PlatformSidebar = lazyRetry(() => import('./components/layout/PlatformSidebar'))
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated) const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
@@ -138,6 +142,12 @@ function PlatformLayout({ children }: { children: React.ReactNode }) {
} }
function PortalLayoutWrapper({ children, showNav = true }: { children: React.ReactNode; showNav?: boolean }) { function PortalLayoutWrapper({ children, showNav = true }: { children: React.ReactNode; showNav?: boolean }) {
// 检查 portal token 是否存在,不存在则跳转登录页
const portalToken = localStorage.getItem('portalToken')
if (!portalToken) {
window.location.href = '/portal/login'
return null
}
if (!showNav) { if (!showNav) {
return ( return (
<div className="min-h-screen bg-surface pt-safe pb-safe"> <div className="min-h-screen bg-surface pt-safe pb-safe">
@@ -185,8 +195,11 @@ export default function App() {
{/* 管理端业务页面 */} {/* 管理端业务页面 */}
<Route path="/" element={<ProtectedRoute><AdminLayout><Dashboard /></AdminLayout></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><AdminLayout><Dashboard /></AdminLayout></ProtectedRoute>} />
<Route path="/roster" element={<ProtectedRoute><AdminLayout><Roster /></AdminLayout></ProtectedRoute>} /> <Route path="/roster" element={<ProtectedRoute><AdminLayout><Roster /></AdminLayout></ProtectedRoute>} />
<Route path="/org-chart" element={<ProtectedRoute><AdminLayout><OrgChart /></AdminLayout></ProtectedRoute>} />
<Route path="/support" element={<ProtectedRoute><AdminLayout><SupportDashboard /></AdminLayout></ProtectedRoute>} />
<Route path="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} /> <Route path="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} />
<Route path="/social" element={<ProtectedRoute><AdminLayout><SocialInsurance /></AdminLayout></ProtectedRoute>} /> <Route path="/social" element={<ProtectedRoute><AdminLayout><SocialInsurance /></AdminLayout></ProtectedRoute>} />
<Route path="/overtime" element={<ProtectedRoute><AdminLayout><Overtime /></AdminLayout></ProtectedRoute>} />
<Route path="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} /> <Route path="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} />
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} /> <Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></AdminLayout></ProtectedRoute>} /> <Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></AdminLayout></ProtectedRoute>} />
@@ -200,8 +213,8 @@ export default function App() {
<Route path="/tools/medical-period" element={<ProtectedRoute><AdminLayout><MedicalPeriodCalculator /></AdminLayout></ProtectedRoute>} /> <Route path="/tools/medical-period" element={<ProtectedRoute><AdminLayout><MedicalPeriodCalculator /></AdminLayout></ProtectedRoute>} />
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} /> <Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} /> <Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
<Route path="/work-process" element={<ProtectedRoute><AdminLayout><WorkProcess /></AdminLayout></ProtectedRoute>} />
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} /> <Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
<Route path="/commission-bonus" element={<ProtectedRoute><AdminLayout><CommissionBonus /></AdminLayout></ProtectedRoute>} />
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} /> <Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} /> <Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
<Route path="/training-records" element={<ProtectedRoute><AdminLayout><TrainingRecords /></AdminLayout></ProtectedRoute>} /> <Route path="/training-records" element={<ProtectedRoute><AdminLayout><TrainingRecords /></AdminLayout></ProtectedRoute>} />
+9 -9
View File
@@ -139,13 +139,13 @@ const categories: HelpCategory[] = [
{ {
id: 'update-termination-cert', id: 'update-termination-cert',
question: '修复:离职证明下载内容为乱码', question: '修复:离职证明下载内容为乱码',
answer: '修复离职证明下载后打开为乱码的问题:\n• 使用 Word HTML 格式生成 .doc 文件,设置 charset=utf-8\n• 指定 SimSun(宋体)字体,确保中文正常显示\n• 离职证明内容包含:员工姓名、身份证号、入职日期、离职日期、企业名称等', answer: '修复离职证明下载后打开为乱码的问题:\n• 使用 Word HTML 格式生成 .doc 文件,设置 charset=utf-8\n• 指定 SimSun(宋体)字体,确保中文正常显示\n• 离职证明内容包含:员工姓名、证件号码、入职日期、离职日期、企业名称等',
tip: '路径:离职管理 → 已完成解聘 → 下载离职证明', tip: '路径:离职管理 → 已完成解聘 → 下载离职证明',
}, },
{ {
id: 'update-attendance-template', id: 'update-attendance-template',
question: '优化:考勤导入模板合并Sheet,减少重复录入', question: '优化:考勤导入模板合并Sheet,减少重复录入',
answer: '考勤月度导入模板从多个Sheet合并为单Sheet:\n• 考勤记录和加班记录合并为「考勤与加班」一个Sheet\n• 不再需要分别填写考勤Sheet和加班Sheet\n• 导入时自动识别合并Sheet或独立Sheet,兼容旧模板\n• 减少重复录入姓名和身份证号', answer: '考勤月度导入模板从多个Sheet合并为单Sheet:\n• 考勤记录和加班记录合并为「考勤与加班」一个Sheet\n• 不再需要分别填写考勤Sheet和加班Sheet\n• 导入时自动识别合并Sheet或独立Sheet,兼容旧模板\n• 减少重复录入姓名和证件号码',
tip: '路径:考勤管理 → 考勤确认 → 导入考勤 → 下载模板', tip: '路径:考勤管理 → 考勤确认 → 导入考勤 → 下载模板',
}, },
{ {
@@ -240,14 +240,14 @@ const categories: HelpCategory[] = [
}, },
{ {
id: 'update-idcard-dedup', id: 'update-idcard-dedup',
question: '新增:用工办理按身份证号查重', question: '新增:用工办理按证件号码查重',
answer: '创建员工时自动按身份证号查重:\n• 后端创建员工前先检查身份证号是否已存在\n• 如果已存在,返回已有员工姓名、部门、在职状态\n• 前端显示明确的重复提示信息,避免重复录入', answer: '创建员工时自动按证件号码查重:\n• 后端创建员工前先检查证件号码是否已存在\n• 如果已存在,返回已有员工姓名、部门、在职状态\n• 前端显示明确的重复提示信息,避免重复录入',
tip: '路径:用工办理 → 入职办理 / 花名册 → 添加员工', tip: '路径:用工办理 → 入职办理 / 花名册 → 添加员工',
}, },
{ {
id: 'update-income-cert-select', id: 'update-income-cert-select',
question: '优化:收入证明支持员工下拉选择并自动填充', question: '优化:收入证明支持员工下拉选择并自动填充',
answer: '用工办理中收入证明和离职证明支持员工下拉选择:\n• 员工字段从手动输入改为下拉搜索选择\n• 选择员工后自动填充:姓名、身份证号、职务、月收入、入职日期、部门、手机号\n• 减少手动输入,避免信息不一致', answer: '用工办理中收入证明和离职证明支持员工下拉选择:\n• 员工字段从手动输入改为下拉搜索选择\n• 选择员工后自动填充:姓名、证件号码、职务、月收入、入职日期、部门、手机号\n• 减少手动输入,避免信息不一致',
tip: '路径:用工办理 → 收入证明/离职证明 → 选择员工', tip: '路径:用工办理 → 收入证明/离职证明 → 选择员工',
}, },
{ {
@@ -296,7 +296,7 @@ const categories: HelpCategory[] = [
{ {
id: 'system-intro', id: 'system-intro',
question: '本系统能帮企业做什么?', question: '本系统能帮企业做什么?',
answer: '「企业用工专家」是一站式人力资源管理平台,覆盖员工全生命周期管理,帮助企业高效管理人事业务的同时确保合规运营:\n• 员工管理:入职登记、合同签订、转正调岗、离职解聘\n• 薪税管理:工资计算、个税申报、社保公积金缴纳\n• 考勤管理:排班打卡、加班统计、休假记录、月度报表\n• 合同管理:电子合同、到期提醒、续签流程\n• 风险管控:自动扫描法律风险、合规预警、判赔预测\n• AI 助手:劳动法咨询、智能问答、文档生成', answer: '「安职通」是一站式人力资源管理平台,覆盖员工全生命周期管理,帮助企业高效管理人事业务的同时确保合规运营:\n• 员工管理:入职登记、合同签订、转正调岗、离职解聘\n• 薪税管理:工资计算、个税申报、社保公积金缴纳\n• 考勤管理:排班打卡、加班统计、休假记录、月度报表\n• 合同管理:电子合同、到期提醒、续签流程\n• 风险管控:自动扫描法律风险、合规预警、判赔预测\n• AI 助手:劳动法咨询、智能问答、文档生成',
}, },
{ {
id: 'compliance', id: 'compliance',
@@ -330,7 +330,7 @@ const categories: HelpCategory[] = [
articles: [ articles: [
{ {
id: 'what-is', id: 'what-is',
question: '「企业用工专家」是什么?', question: '「安职通」是什么?',
answer: '这是一个帮您管理员工、合同、工资和社保的工具。您可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如:记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。', answer: '这是一个帮您管理员工、合同、工资和社保的工具。您可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如:记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。',
}, },
{ {
@@ -496,7 +496,7 @@ const categories: HelpCategory[] = [
id: 'attendance-import', id: 'attendance-import',
question: '怎么导入考勤数据?', question: '怎么导入考勤数据?',
answer: '在考勤管理「考勤确认」标签页中,点击「导入考勤」按钮,下载模板填写后上传。系统会自动匹配员工并生成考勤记录。', answer: '在考勤管理「考勤确认」标签页中,点击「导入考勤」按钮,下载模板填写后上传。系统会自动匹配员工并生成考勤记录。',
tip: '身份证号优先匹配,未填时用姓名匹配。', tip: '证件号码优先匹配,未填时用姓名匹配。',
}, },
{ {
id: 'attendance-correct', id: 'attendance-correct',
@@ -613,7 +613,7 @@ const categories: HelpCategory[] = [
{ {
id: 'data-safe', id: 'data-safe',
question: '我的数据安全吗?', question: '我的数据安全吗?',
answer: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息(如身份证号)都经过加密存储。', answer: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息(如证件号码)都经过加密存储。',
}, },
{ {
id: 'data-export', id: 'data-export',
+1 -1
View File
@@ -95,7 +95,7 @@ export default function OnboardingGuide() {
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden"> <div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100"> <div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
<h2 className="text-base font-semibold">使</h2> <h2 className="text-base font-semibold">使</h2>
<button onClick={close} className="text-gray-400 hover:text-gray-600"> <button onClick={close} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
+41 -24
View File
@@ -12,29 +12,46 @@ interface BreadcrumbItem {
} }
const ROUTE_MAP: Record<string, BreadcrumbItem> = { const ROUTE_MAP: Record<string, BreadcrumbItem> = {
'/': { group: '工作台', label: '总览' }, // 首页
'/calendar': { group: '工作台', label: '工作日历' }, '/': { group: '首页', label: '工作' },
'/roster': { group: '员工管理', label: '花名册' }, '/calendar': { group: '首页', label: '工作日历' },
'/work-process': { group: '员工管理', label: '用工办理' }, // 团队
'/attendance': { group: '员工管理', label: '考勤确认' }, '/roster': { group: '团队', label: '花名册' },
'/leave-approval': { group: '员工管理', label: '休假审批' }, '/org-chart': { group: '团队', label: '组织架构' },
'/termination': { group: '员工管理', label: '解聘补偿' }, '/esign': { group: '团队', label: '待签合同' },
'/special-status': { group: '员工管理', label: '特殊状态' }, '/termination': { group: '团队', label: '离职管理' },
'/training-records': { group: '员工管理', label: '培训记录' }, '/training-records': { group: '团队', label: '培训记录' },
'/performance-records': { group: '员工管理', label: '绩效考核' }, '/performance-records': { group: '团队', label: '绩效考核' },
'/disciplinary-records': { group: '员工管理', label: '违纪记录' }, '/disciplinary-records': { group: '团队', label: '违纪记录' },
'/money': { group: '薪税社保', label: '薪税管理' }, '/special-status': { group: '团队', label: '特殊员工' },
'/social': { group: '薪税社保', label: '社保公积金' }, // 时间
'/evidence': { group: '合规风控', label: '证据链' }, '/attendance': { group: '时间', label: '考勤排班' },
'/policies': { group: '合规风控', label: '规章制度' }, '/leave-approval': { group: '时间', label: '休假审批' },
'/tools/health-check': { group: '合规风控', label: '用工体检' }, // 薪酬
'/tools/medical-period': { group: '合规风控', label: '医疗期计算' }, '/money': { group: '薪酬', label: '薪税管理' },
'/tools/annual-value': { group: '合规风控', label: '年度价值' }, '/social': { group: '薪酬', label: '社保公积金' },
'/ai-assistant': { group: 'AI 辅助', label: 'AI 顾问' }, '/overtime': { group: '薪酬', label: '加班费计算' },
'/templates': { group: 'AI 辅助', label: '文本模板' }, '/commission-bonus': { group: '薪酬', label: '提成奖金' },
'/notifications': { group: '系统', label: '通知管理' }, '/salary-dashboard': { group: '薪酬', label: '薪酬分析' },
'/audit': { group: '系统', label: '操作日志' }, // 福利保障
'/settings': { group: '系统', label: '设置' }, '/commercial-insurance': { group: '福利保障', label: '商业保险' },
'/benefits': { group: '福利保障', label: '员工福利' },
// 合规
'/risk-center': { group: '合规', label: '风险中心' },
'/evidence': { group: '合规', label: '证据链条' },
'/policies': { group: '合规', label: '规章制度' },
// 工具
'/tools/health-check': { group: '工具', label: '用工体检' },
'/tools/medical-period': { group: '工具', label: '医疗期' },
'/tools/annual-value': { group: '工具', label: '年度价值' },
// 更多
'/ai-assistant': { group: '更多', label: 'AI顾问' },
'/templates': { group: '更多', label: '文本模板' },
'/notifications': { group: '更多', label: '通知管理' },
'/audit': { group: '更多', label: '操作日志' },
'/company-files': { group: '更多', label: '公司文件' },
'/settings': { group: '更多', label: '系统设置' },
'/support': { group: '更多', label: '客服支持' },
} }
export default function Breadcrumb() { export default function Breadcrumb() {
@@ -50,7 +67,7 @@ export default function Breadcrumb() {
<Home className="w-3 h-3" /> <Home className="w-3 h-3" />
<span></span> <span></span>
</Link> </Link>
{item.group !== '工作台' && ( {item.group !== '首页' && (
<> <>
<ChevronRight className="w-3 h-3 text-gray-300" /> <ChevronRight className="w-3 h-3 text-gray-300" />
<span className="text-gray-400">{item.group}</span> <span className="text-gray-400">{item.group}</span>
@@ -35,8 +35,8 @@ export default function PortalLayout({ children }: { children: React.ReactNode }
<header className="sticky top-0 z-30 bg-white border-b border-gray-200 pt-safe"> <header className="sticky top-0 z-30 bg-white border-b border-gray-200 pt-safe">
<div className="max-w-md mx-auto h-14 flex items-center justify-between px-4"> <div className="max-w-md mx-auto h-14 flex items-center justify-between px-4">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Logo className="w-6 h-6 text-primary flex-shrink-0" /> <Logo className="w-6 h-6 object-contain flex-shrink-0" />
<span className="text-sm font-bold text-gray-900 truncate"></span> <span className="text-sm font-bold text-gray-900 truncate"></span>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{employee.name && ( {employee.name && (
+13 -7
View File
@@ -16,8 +16,8 @@ import {
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
Building2, CalendarDays, ClipboardList, Heart, CalendarClock, Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle, Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
DollarSign, Clock,
} from 'lucide-react' } from 'lucide-react'
import Logo from '../ui/Logo'
import { settingsApi } from '../../lib/api-services' import { settingsApi } from '../../lib/api-services'
interface NavItem { interface NavItem {
@@ -37,14 +37,14 @@ const navGroups: NavGroup[] = [
items: [ items: [
{ path: '/', label: '工作台', icon: LayoutDashboard }, { path: '/', label: '工作台', icon: LayoutDashboard },
{ path: '/calendar', label: '工作日历', icon: CalendarDays }, { path: '/calendar', label: '工作日历', icon: CalendarDays },
{ path: '/esign', label: '电子签署', icon: PenTool },
], ],
}, },
{ {
title: '团队', title: '团队',
items: [ items: [
{ path: '/roster', label: '花名册', icon: Users }, { path: '/roster', label: '花名册', icon: Users },
{ path: '/work-process', label: '用工办理', icon: ClipboardList }, { path: '/org-chart', label: '组织架构', icon: Building2 },
{ path: '/esign', label: '待签合同', icon: PenTool },
{ path: '/termination', label: '离职管理', icon: UserX }, { path: '/termination', label: '离职管理', icon: UserX },
{ path: '/training-records', label: '培训记录', icon: GraduationCap }, { path: '/training-records', label: '培训记录', icon: GraduationCap },
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp }, { path: '/performance-records', label: '绩效考核', icon: TrendingUp },
@@ -64,6 +64,8 @@ const navGroups: NavGroup[] = [
items: [ items: [
{ path: '/money', label: '薪税管理', icon: Calculator }, { path: '/money', label: '薪税管理', icon: Calculator },
{ path: '/social', label: '社保公积金', icon: Shield }, { path: '/social', label: '社保公积金', icon: Shield },
{ path: '/overtime', label: '加班费计算', icon: Clock },
{ path: '/commission-bonus', label: '提成奖金', icon: DollarSign },
{ path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 }, { path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 },
], ],
}, },
@@ -80,6 +82,11 @@ const navGroups: NavGroup[] = [
{ path: '/risk-center', label: '风险中心', icon: ShieldAlert }, { path: '/risk-center', label: '风险中心', icon: ShieldAlert },
{ path: '/evidence', label: '证据链条', icon: FileSearch }, { path: '/evidence', label: '证据链条', icon: FileSearch },
{ path: '/policies', label: '规章制度', icon: FileText }, { path: '/policies', label: '规章制度', icon: FileText },
],
},
{
title: '工具',
items: [
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope }, { path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
{ path: '/tools/medical-period', label: '医疗期', icon: HeartPulse }, { path: '/tools/medical-period', label: '医疗期', icon: HeartPulse },
{ path: '/tools/annual-value', label: '年度价值', icon: Award }, { path: '/tools/annual-value', label: '年度价值', icon: Award },
@@ -147,10 +154,9 @@ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolea
mobileOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0', mobileOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
)} )}
> >
{/* Logo 区 */} {/* 公司名区 */}
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0"> <div className="h-14 flex items-center px-4 border-b border-gray-200 shrink-0">
<Logo className="w-5 h-5 text-primary" /> <span className="font-bold text-sm text-gray-900 truncate">{orgData?.name || ''}</span>
<span className="font-bold text-sm text-gray-900 truncate">{orgData?.name || '企业用工专家'}</span>
</div> </div>
{/* 导航菜单 */} {/* 导航菜单 */}
+2 -8
View File
@@ -1,5 +1,5 @@
/** /**
* Logo favicon.svg * Logo
*/ */
interface LogoProps { interface LogoProps {
@@ -8,12 +8,6 @@ interface LogoProps {
export default function Logo({ className = 'w-5 h-5' }: LogoProps) { export default function Logo({ className = 'w-5 h-5' }: LogoProps) {
return ( return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={className} fill="none"> <img src="/JClog.png" alt="嘉驰英才" className={className} />
<rect width="32" height="32" rx="6" fill="currentColor" />
<path d="M16 6 L24 10 L24 16 L16 20 L8 16 L8 10 Z" stroke="white" strokeWidth="1.5" strokeLinejoin="round" />
<circle cx="16" cy="13" r="2" fill="white" />
<path d="M12 24 L12 20 M20 24 L20 20 M16 22 L16 20" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
<rect x="10" y="24" width="12" height="2" rx="1" fill="white" />
</svg>
) )
} }
@@ -0,0 +1,166 @@
/**
*
* ///
* HR选择是走电子签署还是线下手签
*
* - status=PENDING
* - 线线
* -
*/
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { PenTool, FileCheck, SkipForward, Shield } from 'lucide-react'
import { esignApi } from '../../lib/api-services'
import Modal from './Modal'
import Button from './Button'
interface SignMethodChoiceProps {
open: boolean
onClose: () => void
/** 员工ID */
employeeId: string
/** 员工姓名(用于显示) */
employeeName: string
/** 签署场景 */
scene: 'CONTRACT' | 'RESIGNATION' | 'POLICY' | 'PAYSLIP' | 'ONBOARDING'
/** 文件标题 */
documentTitle: string
/** 关联合同ID(可选) */
contractId?: string
/** 备注 */
remark?: string
/** 操作名称(如"重新入职"、"合同续签" */
actionName: string
}
const SCENE_LABELS: Record<string, string> = {
CONTRACT: '劳动合同',
RESIGNATION: '离职协议',
POLICY: '规章制度',
PAYSLIP: '工资条',
ONBOARDING: '入职文件',
}
export default function SignMethodChoice({
open,
onClose,
employeeId,
employeeName,
scene,
documentTitle,
contractId,
remark,
actionName,
}: SignMethodChoiceProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [creating, setCreating] = useState(false)
const createEsignMutation = useMutation({
mutationFn: (data: any) => esignApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('电子签署已发起,员工可在员工端查看并签署', {
action: { label: '查看签署', onClick: () => navigate('/esign') },
})
onClose()
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
})
/** 选择电子签署 */
const handleEsign = () => {
setCreating(true)
createEsignMutation.mutate({
employeeId,
contractId,
scene,
documentTitle,
remark: remark || `${actionName}时自动发起`,
})
setCreating(false)
}
/** 选择线下手签 → 跳转到签署页面 */
const handlePaperSign = () => {
onClose()
// 通过 URL 参数传递信息,签署页面读取后自动打开线下手签登记
const params = new URLSearchParams({
action: 'paper-sign',
employeeId,
scene,
documentTitle,
})
if (contractId) params.set('contractId', contractId)
navigate(`/esign?${params.toString()}`)
}
/** 跳过 */
const handleSkip = () => {
toast.info('已跳过签署,可稍后在「电子签署」页面手动发起')
onClose()
}
return (
<Modal open={open} onClose={onClose} title={`${actionName}成功 — 选择签署方式`} size="sm">
<div className="space-y-4">
<div className="text-sm text-gray-600 bg-gray-50 rounded-md p-3">
<div className="font-medium text-gray-700">{employeeName} · {SCENE_LABELS[scene] || scene}</div>
<div className="text-xs text-gray-400 mt-1">{documentTitle}</div>
</div>
<div className="text-xs text-gray-500 flex items-start gap-1.5">
<Shield className="w-3.5 h-3.5 mt-0.5 shrink-0 text-primary" />
<div>{SCENE_LABELS[scene] || '文件'}</div>
</div>
<div className="space-y-2">
{/* 电子签署 */}
<button
onClick={handleEsign}
disabled={creating}
className="w-full flex items-center gap-3 p-3 border-2 border-blue-200 bg-blue-50/50 rounded-lg hover:border-blue-400 hover:bg-blue-50 transition text-left disabled:opacity-50"
>
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center shrink-0">
<PenTool className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800"></div>
<div className="text-xs text-gray-500 mt-0.5"></div>
</div>
</button>
{/* 线下手签 */}
<button
onClick={handlePaperSign}
className="w-full flex items-center gap-3 p-3 border-2 border-orange-200 bg-orange-50/50 rounded-lg hover:border-orange-400 hover:bg-orange-50 transition text-left"
>
<div className="w-10 h-10 rounded-lg bg-orange-100 flex items-center justify-center shrink-0">
<FileCheck className="w-5 h-5 text-orange-600" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800">线</div>
<div className="text-xs text-gray-500 mt-0.5"></div>
</div>
</button>
{/* 跳过 */}
<button
onClick={handleSkip}
className="w-full flex items-center gap-3 p-3 border-2 border-gray-200 bg-gray-50/50 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition text-left"
>
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
<SkipForward className="w-5 h-5 text-gray-500" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-600"></div>
<div className="text-xs text-gray-400 mt-0.5"></div>
</div>
</button>
</div>
</div>
</Modal>
)
}
+1 -1
View File
@@ -274,7 +274,7 @@ export const surveyPages: SurveyPage[] = [
{ {
id: 24, name: 'Onboarding(入职信息填报)', file: 'frontend/src/pages/portal/Onboarding.tsx', menu: '员工端 > 入职填报', id: 24, name: 'Onboarding(入职信息填报)', file: 'frontend/src/pages/portal/Onboarding.tsx', menu: '员工端 > 入职填报',
features: [ features: [
{ id: '24.1', name: '入职表单填写', desc: '填写姓名、手机号、身份证号、紧急联系人等' }, { id: '24.1', name: '入职表单填写', desc: '填写姓名、手机号、证件号码、紧急联系人等' },
{ id: '24.2', name: '入职材料上传', desc: '上传身份证、学历证明、银行卡等材料' }, { id: '24.2', name: '入职材料上传', desc: '上传身份证、学历证明、银行卡等材料' },
{ id: '24.3', name: '文件类型选择', desc: '选择上传文件的类型(身份证正反面、学历、银行卡、其他)' }, { id: '24.3', name: '文件类型选择', desc: '选择上传文件的类型(身份证正反面、学历、银行卡、其他)' },
{ id: '24.4', name: '已传文件管理', desc: '查看和删除已上传文件' }, { id: '24.4', name: '已传文件管理', desc: '查看和删除已上传文件' },
+159 -4
View File
@@ -69,6 +69,9 @@ export const employeeApi = {
/** 身份证查重 */ /** 身份证查重 */
checkIdCard: (idCard: string) => checkIdCard: (idCard: string) =>
get('/employees/check-id-card', { params: { idCard } }).then(unwrap<{ exists: boolean; employee?: any }>()), get('/employees/check-id-card', { params: { idCard } }).then(unwrap<{ exists: boolean; employee?: any }>()),
/** 手机号查重 */
checkPhone: (phone: string) =>
get('/employees/check-phone', { params: { phone } }).then(unwrap<{ exists: boolean; employee?: any }>()),
/** 更新员工 */ /** 更新员工 */
update: (id: string, data: Record<string, unknown>) => update: (id: string, data: Record<string, unknown>) =>
put(`/employees/${id}`, data), put(`/employees/${id}`, data),
@@ -78,6 +81,9 @@ export const employeeApi = {
/** 重新入职 */ /** 重新入职 */
rehire: (id: string, data: Record<string, unknown>) => rehire: (id: string, data: Record<string, unknown>) =>
post(`/employees/${id}/rehire`, data), post(`/employees/${id}/rehire`, data),
/** 重置员工密码(管理员,重置为手机号后6位) */
resetPassword: (id: string) =>
post(`/employees/${id}/reset-password`).then(unwrap<any>()),
/** 添加合同 */ /** 添加合同 */
addContract: (data: Record<string, unknown>) => addContract: (data: Record<string, unknown>) =>
post('/employees/contracts', data), post('/employees/contracts', data),
@@ -288,6 +294,12 @@ export const attendanceApi = {
/** 批量确认 */ /** 批量确认 */
batchConfirm: (data: { month: string; all?: boolean; ids?: string[] }) => batchConfirm: (data: { month: string; all?: boolean; ids?: string[] }) =>
post('/attendance/batch-confirm', data).then(unwrap<any>()), post('/attendance/batch-confirm', data).then(unwrap<any>()),
/** 批量创建/更新考勤确认记录 */
batchCreate: (data: { month: string; items: any[] }) =>
post('/attendance/batch', data).then(unwrap<any>()),
/** 更新考勤确认扣款金额 */
updateDeduction: (id: string, deductionAmount: number) =>
put(`/attendance/confirmations/${id}/deduction`, { deductionAmount }).then(unwrap<any>()),
/** 单条确认 */ /** 单条确认 */
confirm: (data: { employeeId: string; month: string }) => confirm: (data: { employeeId: string; month: string }) =>
post('/attendance/confirm', data).then(unwrap<any>()), post('/attendance/confirm', data).then(unwrap<any>()),
@@ -471,6 +483,18 @@ export const payrollApi = {
/** 导入加班费到批次 */ /** 导入加班费到批次 */
importOvertimeToBatch: (batchId: string) => importOvertimeToBatch: (batchId: string) =>
post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap<any>()), post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap<any>()),
/** 获取提成奖金到批次 */
fetchBonusToBatch: (batchId: string) =>
post(`/payroll2/batches/${batchId}/fetch-bonus`).then(unwrap<any>()),
/** 获取绩效工资到批次(按考核系数计算) */
fetchPerformanceToBatch: (batchId: string) =>
post(`/payroll2/batches/${batchId}/fetch-performance`).then(unwrap<any>()),
/** 获取违纪扣款到批次 */
fetchDisciplinaryToBatch: (batchId: string) =>
post(`/payroll2/batches/${batchId}/fetch-disciplinary`).then(unwrap<any>()),
/** 获取考勤扣款到批次 */
fetchAttendanceDeductionToBatch: (batchId: string) =>
post(`/payroll2/batches/${batchId}/fetch-attendance-deduction`).then(unwrap<any>()),
/** 加班费配置 */ /** 加班费配置 */
overtimeConfig: () => overtimeConfig: () =>
get('/payroll/overtime/config').then(unwrap<any>()), get('/payroll/overtime/config').then(unwrap<any>()),
@@ -506,6 +530,55 @@ export const salaryDashboardApi = {
// ========== 社保公积金相关 ========== // ========== 社保公积金相关 ==========
// 账户管理(新)
export const socialAccountApi = {
/** 账户列表 */
list: (type?: string) =>
get('/social/accounts', { params: type ? { type } : {} }).then(unwrap<any[]>()),
/** 新建账户 */
create: (data: { type: string; name: string; city: string; accountNo?: string; bankName?: string; bankAccount?: string; orgName?: string; orgCode?: string; accountType?: string; isDefault?: boolean; remark?: string }) =>
post('/social/accounts', data).then(unwrap<any>()),
/** 编辑账户 */
update: (id: string, data: Partial<{ type: string; name: string; city: string; accountNo: string; bankName: string; bankAccount: string; orgName: string; orgCode: string; accountType: string; isDefault: boolean; remark: string; status: string }>) =>
put(`/social/accounts/${id}`, data).then(unwrap<any>()),
/** 删除账户 */
remove: (id: string) =>
del(`/social/accounts/${id}`).then(unwrap<any>()),
/** 设为默认 */
setDefault: (id: string) =>
put(`/social/accounts/${id}/default`).then(unwrap<any>()),
/** 按账户获取年度标准列表 */
standards: (accountId: string) =>
get(`/social/accounts/${accountId}/standards`).then(unwrap<any[]>()),
/** 按账户获取当前生效标准 */
currentStandard: (accountId: string) =>
get(`/social/accounts/${accountId}/current-standard`).then(unwrap<any>()),
/** 按账户获取继承数据(当前标准→旧配置→null),用于新建年度标准初始值 */
inheritConfig: (accountId: string) =>
get(`/social/accounts/${accountId}/inherit-config`).then(unwrap<any>()),
/** 按账户+月份获取适用标准 */
standardByMonth: (accountId: string, month: string) =>
get(`/social/accounts/${accountId}/standard-by-month/${month}`).then(unwrap<any>()),
/** 新建年度标准 */
createStandard: (accountId: string, data: any) =>
post(`/social/accounts/${accountId}/standards`, data).then(unwrap<any>()),
/** 按员工获取适用账户 */
employeeAccount: (employeeId: string) =>
get(`/social/employee-account/${employeeId}`).then(unwrap<any>()),
/** 获取账户已关联的根部门 */
accountDepartments: (id: string) =>
get(`/social/accounts/${id}/departments`).then(unwrap<any[]>()),
/** 账户关联根部门(批量) */
linkDepartments: (id: string, departmentIds: string[]) =>
put(`/social/accounts/${id}/departments`, { departmentIds }).then(unwrap<any>()),
/** 按部门获取适用账户(选部门时自动带出) */
departmentAccount: (departmentId: string) =>
get(`/social/department-account/${departmentId}`).then(unwrap<any>()),
/** 快速更新当前年度标准的最低工资 */
updateMinWage: (accountId: string, minWage: number) =>
put(`/social/accounts/${accountId}/min-wage`, { minWage }).then(unwrap<any>()),
}
export const socialInsuranceApi = { export const socialInsuranceApi = {
/** 城市列表 */ /** 城市列表 */
cities: () => cities: () =>
@@ -638,12 +711,60 @@ export const benefitApi = {
export const esignApi = { export const esignApi = {
list: (params?: { status?: string; scene?: string }) => list: (params?: { status?: string; scene?: string }) =>
get('/esign', { params: params || {} }).then(unwrap<any[]>()), get('/esign', { params: params || {} }).then(unwrap<any[]>()),
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string }) => /** 待签合同列表(按员工聚合) */
pending: () =>
get('/esign/pending').then(unwrap<any[]>()),
/** 催办(生成自动登录链接) */
remind: (employeeId: string) =>
post('/esign/remind', { employeeId }).then(unwrap<any>()),
/** 登记线下合同签署日期 */
signDate: (contractId: string, signDate: string) =>
post('/esign/sign-date', { contractId, signDate }).then(unwrap<any>()),
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string; templateId?: string; templateVars?: Record<string, string> }) =>
post('/esign/create', data), post('/esign/create', data),
detail: (id: string) =>
get(`/esign/${id}`).then(unwrap<any>()),
status: (id: string) => status: (id: string) =>
get(`/esign/${id}/status`).then(unwrap<any>()), get(`/esign/${id}/status`).then(unwrap<any>()),
evidence: (id: string) =>
get(`/esign/${id}/evidence`).then(unwrap<any[]>()),
cancel: (id: string) => cancel: (id: string) =>
post(`/esign/${id}/cancel`), post(`/esign/${id}/cancel`),
/** 上传线下签署扫描件 */
uploadPaperSign: (files: File[]) => {
const formData = new FormData()
files.forEach(f => formData.append('files', f))
return post('/esign/paper-upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(unwrap<any>())
},
/** 线下手签登记 */
paperSign: (data: Record<string, unknown>) =>
post('/esign/paper-sign', data).then(unwrap<any>()),
}
// ========== 提成奖金 ==========
export const commissionBonusApi = {
/** 按月查询列表 + 汇总 */
list: (month: string) =>
get('/commission-bonus', { params: { month } }).then(unwrap<any>()),
/** 新增单条 */
create: (data: { employeeId: string; month: string; amount: number; remark?: string }) =>
post('/commission-bonus', data).then(unwrap<any>()),
/** 更新单条 */
update: (id: string, data: { amount?: number; remark?: string }) =>
put(`/commission-bonus/${id}`, data).then(unwrap<any>()),
/** 删除单条 */
remove: (id: string) =>
del(`/commission-bonus/${id}`).then(unwrap<any>()),
/** 批量导入 Excel */
import: (file: File, month: string) => {
const formData = new FormData()
formData.append('file', file)
formData.append('month', month)
return post('/commission-bonus/import', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(unwrap<any>())
},
/** 模板下载 URL */
templateUrl: '/api/v1/commission-bonus/template',
} }
// ========== 离职相关 ========== // ========== 离职相关 ==========
@@ -694,6 +815,18 @@ export const terminationApi = {
/** 批量预览 */ /** 批量预览 */
batchPreview: (items: Record<string, unknown>[]) => batchPreview: (items: Record<string, unknown>[]) =>
post('/termination/batch/preview', { items }), post('/termination/batch/preview', { items }),
/** 上传工会回执文件 */
uploadUnionReceipt: (draftId: string, file: File) => {
const formData = new FormData()
formData.append('file', file)
return post(`/termination/draft/${draftId}/union-receipt/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(unwrap<any>())
},
/** 保存工会回执信息 */
saveUnionReceipt: (draftId: string, data: Record<string, unknown>) =>
post(`/termination/draft/${draftId}/union-receipt`, data).then(unwrap<any>()),
/** 获取工会回执信息 */
getUnionReceipt: (draftId: string) =>
get(`/termination/draft/${draftId}/union-receipt`).then(unwrap<any>()),
} }
// ========== 制度相关 ========== // ========== 制度相关 ==========
@@ -977,6 +1110,22 @@ portalAxios.interceptors.request.use((config: any) => {
if (token) config.headers.Authorization = `Bearer ${token}` if (token) config.headers.Authorization = `Bearer ${token}`
return config return config
}) })
// 与管理端 api 实例一致:response interceptor 返回 response.data(后端 JSON body
// 401 时清除 token 并跳转登录页(避免 token 过期后显示空数据)
portalAxios.interceptors.response.use(
(response) => response.data,
(error) => {
if (error?.response?.status === 401) {
localStorage.removeItem('portalToken')
localStorage.removeItem('portalEmployee')
// 避免在登录页重复跳转
if (!window.location.pathname.includes('/portal/login') && !window.location.pathname.includes('/portal/auto-login')) {
window.location.href = '/portal/login'
}
}
return Promise.reject(error)
},
)
const portalGet = ((url: string, config?: any) => portalAxios.get(url, config)) as any const portalGet = ((url: string, config?: any) => portalAxios.get(url, config)) as any
const portalPost = ((url: string, data?: any, config?: any) => portalAxios.post(url, data, config)) as any const portalPost = ((url: string, data?: any, config?: any) => portalAxios.post(url, data, config)) as any
@@ -993,6 +1142,9 @@ export const portalApi = {
/** 自动登录 */ /** 自动登录 */
autoLogin: (token: string) => autoLogin: (token: string) =>
portalGet('/auto-login', { params: { token } }).then(unwrap<any>()), portalGet('/auto-login', { params: { token } }).then(unwrap<any>()),
/** 修改密码(员工自己) */
changePassword: (oldPassword: string, newPassword: string) =>
portalPost('/change-password', { oldPassword, newPassword }).then(unwrap<any>()),
/** 生成自动登录令牌(管理端) */ /** 生成自动登录令牌(管理端) */
generateAutoLoginToken: (employeeId: string) => generateAutoLoginToken: (employeeId: string) =>
post('/portal/auto-login-token', { employeeId }).then(unwrap<any>()), post('/portal/auto-login-token', { employeeId }).then(unwrap<any>()),
@@ -1074,9 +1226,12 @@ export const portalApi = {
/** 电子签署详情 */ /** 电子签署详情 */
esignDetail: (id: string) => esignDetail: (id: string) =>
portalGet(`/esign/${id}`).then(unwrap<any>()), portalGet(`/esign/${id}`).then(unwrap<any>()),
/** 签署操作 */ /** 发送签署验证码 */
signEsign: (id: string) => esignSendCode: (id: string) =>
portalPost(`/esign/${id}/sign`).then(unwrap<any>()), portalPost(`/esign/${id}/send-code`).then(unwrap<any>()),
/** 签署操作(需验证码) */
signEsign: (id: string, verifyCode: string) =>
portalPost(`/esign/${id}/sign`, { verifyCode }).then(unwrap<any>()),
/** 我的培训记录 */ /** 我的培训记录 */
myTraining: () => myTraining: () =>
portalGet('/training').then(unwrap<any[]>()), portalGet('/training').then(unwrap<any[]>()),
+42
View File
@@ -0,0 +1,42 @@
import { lazy, ComponentType } from 'react'
/**
*
*
* chunk hash index.html
* chunk "Failed to fetch dynamically imported module"
* index.html chunk
*/
export function lazyRetry<T extends ComponentType<any>>(
factory: () => Promise<{ default: T } | { [key: string]: T }>,
): React.LazyExoticComponent<T> {
return lazy(async () => {
try {
const m = await factory()
// 统一为 { default: T } 格式
if ('default' in m) return { default: m.default }
// named export 模式:取第一个导出作为 default
const firstKey = Object.keys(m)[0]
return { default: m[firstKey] }
} catch (err: any) {
// 仅对动态导入失败自动刷新,避免其他错误误触发
if (
err &&
(err.message?.includes('Failed to fetch dynamically imported module') ||
err.message?.includes('Importing a module script failed') ||
err.message?.includes('error loading dynamically imported module'))
) {
// 防止无限刷新:用 sessionStorage 标记,仅刷新一次
const key = 'lazyRetry:' + (err.message.match(/[\w-]+\.js/)?.[0] || 'unknown')
if (!sessionStorage.getItem(key)) {
sessionStorage.setItem(key, '1')
window.location.reload()
// 返回一个永不 resolve 的 Promise,等待页面刷新
return new Promise<{ default: T }>(() => {})
}
sessionStorage.removeItem(key)
}
throw err
}
})
}
+10 -7
View File
@@ -1,12 +1,14 @@
import { useState, lazy, Suspense } from 'react' import { useState, Suspense } from 'react'
import { Bot, FileSearch, Scale, Sparkles, BookOpen, TrendingUp, Loader2 } from 'lucide-react' import { Bot, FileSearch, Scale, Sparkles, BookOpen, TrendingUp, Loader2 } from 'lucide-react'
import PageGuide from '../components/ui/PageGuide'
import { lazyRetry } from '../lib/lazyRetry'
const ChatTab = lazy(() => import('./ai-assistant/ChatTab').then(m => ({ default: m.ChatTab }))) const ChatTab = lazyRetry(() => import('./ai-assistant/ChatTab').then(m => ({ default: m.ChatTab })))
const PredictTab = lazy(() => import('./ai-assistant/PredictTab').then(m => ({ default: m.PredictTab }))) const PredictTab = lazyRetry(() => import('./ai-assistant/PredictTab').then(m => ({ default: m.PredictTab })))
const ReviewTab = lazy(() => import('./ai-assistant/ReviewTab').then(m => ({ default: m.ReviewTab }))) const ReviewTab = lazyRetry(() => import('./ai-assistant/ReviewTab').then(m => ({ default: m.ReviewTab })))
const CaseTab = lazy(() => import('./ai-assistant/CaseTab').then(m => ({ default: m.CaseTab }))) const CaseTab = lazyRetry(() => import('./ai-assistant/CaseTab').then(m => ({ default: m.CaseTab })))
const KnowledgeTab = lazy(() => import('./ai-assistant/KnowledgeTab').then(m => ({ default: m.KnowledgeTab }))) const KnowledgeTab = lazyRetry(() => import('./ai-assistant/KnowledgeTab').then(m => ({ default: m.KnowledgeTab })))
const HRReportTab = lazy(() => import('./ai-assistant/HRReportTab').then(m => ({ default: m.HRReportTab }))) const HRReportTab = lazyRetry(() => import('./ai-assistant/HRReportTab').then(m => ({ default: m.HRReportTab })))
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' | 'hr-report' type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' | 'hr-report'
@@ -24,6 +26,7 @@ export default function AIAssistant() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageGuide>AI </PageGuide>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-primary" /> <Bot className="h-5 w-5 text-primary" />
<div> <div>
+25 -18
View File
@@ -54,6 +54,9 @@ export default function Attendance() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageGuide>
</PageGuide>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CalendarCheck className="h-5 w-5 text-primary" /> <CalendarCheck className="h-5 w-5 text-primary" />
@@ -107,7 +110,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [editItem, setEditItem] = useState<any>(null) const [editItem, setEditItem] = useState<any>(null)
const [editForm, setEditForm] = useState({ workDays: 0, lateCount: 0, earlyLeaveCount: 0, absentDays: 0, leaveDays: 0, overtimeHours: 0, overtimePay: 0 }) const [editForm, setEditForm] = useState({ workDays: 0, lateCount: 0, earlyLeaveCount: 0, absentDays: 0, leaveDays: 0, overtimeHours: 0, overtimePay: 0, deductionAmount: 0 })
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const pageSize = usePageSize() const pageSize = usePageSize()
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@@ -190,7 +193,19 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
const editMutation = useMutation({ const editMutation = useMutation({
mutationFn: async (data: any) => { mutationFn: async (data: any) => {
return await attendanceApi.manualCorrect(data) // 通过批量创建接口更新考勤确认记录(upsert 语义)
return await attendanceApi.batchCreate({
month: data.month,
items: [{
employeeId: data.employeeId,
workDays: data.workDays,
weekdayHours: 0,
weekendHours: 0,
holidayHours: 0,
overtimePay: data.overtimePay,
deductionAmount: data.deductionAmount || 0,
}],
})
}, },
onSuccess: () => { onSuccess: () => {
toast.success('考勤记录已修改') toast.success('考勤记录已修改')
@@ -418,6 +433,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
<span> {item.weekendHours}h</span> <span> {item.weekendHours}h</span>
<span> {item.holidayHours}h</span> <span> {item.holidayHours}h</span>
<span className="text-gray-700"> ¥{item.overtimePay?.toFixed(2)}</span> <span className="text-gray-700"> ¥{item.overtimePay?.toFixed(2)}</span>
{item.deductionAmount > 0 && <span className="text-danger"> ¥{item.deductionAmount?.toFixed(2)}</span>}
</div> </div>
{item.disputeNote && ( {item.disputeNote && (
<div className="text-xs text-red-600 mt-1">{item.disputeNote}</div> <div className="text-xs text-red-600 mt-1">{item.disputeNote}</div>
@@ -446,6 +462,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
leaveDays: item.leaveDays || 0, leaveDays: item.leaveDays || 0,
overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0), overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0),
overtimePay: item.overtimePay || 0, overtimePay: item.overtimePay || 0,
deductionAmount: item.deductionAmount || 0,
}) })
}} }}
> >
@@ -507,6 +524,11 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
<input type="number" min="0" step="0.01" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.overtimePay} <input type="number" min="0" step="0.01" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.overtimePay}
onChange={(e) => setEditForm({ ...editForm, overtimePay: Number(e.target.value) })} /> onChange={(e) => setEditForm({ ...editForm, overtimePay: Number(e.target.value) })} />
</div> </div>
<div className="col-span-2">
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" step="0.01" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.deductionAmount}
onChange={(e) => setEditForm({ ...editForm, deductionAmount: Number(e.target.value) })} placeholder="迟到/早退/旷工等扣款金额" />
</div>
</div> </div>
<div className="flex justify-end gap-2 mt-4"> <div className="flex justify-end gap-2 mt-4">
<Button size="sm" variant="secondary" onClick={() => setEditItem(null)}></Button> <Button size="sm" variant="secondary" onClick={() => setEditItem(null)}></Button>
@@ -555,7 +577,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
</div> </div>
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2"> <div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
Sheet 0 Sheet 0
</div> </div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center"> <div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
@@ -670,9 +692,6 @@ function ShiftsTab() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}> <Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}>
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4 mr-1" />
@@ -836,9 +855,6 @@ function ScheduleTab() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
@@ -1029,9 +1045,6 @@ function DailyTab() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
//退/
</PageGuide>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
@@ -1257,9 +1270,6 @@ function MonthlyTab() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
@@ -1409,9 +1419,6 @@ function LeavesTab() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex justify-end items-center gap-3"> <div className="flex justify-end items-center gap-3">
<input <input
type="text" type="text"
+2
View File
@@ -7,6 +7,7 @@ import Card from '../components/ui/Card'
import Button from '../components/ui/Button' import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState' import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination' import Pagination from '../components/ui/Pagination'
import PageGuide from '../components/ui/PageGuide'
const ACTION_LABELS: Record<string, string> = { const ACTION_LABELS: Record<string, string> = {
CREATE: '创建', CREATE: '创建',
@@ -147,6 +148,7 @@ export default function AuditLog() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide></PageGuide>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ScrollText className="h-5 w-5 text-primary" /> <ScrollText className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1> <h1 className="text-base font-semibold"></h1>
+2
View File
@@ -7,6 +7,7 @@ import { dashboardApi, calendarApi } from '../lib/api-services'
import Card from '../components/ui/Card' import Card from '../components/ui/Card'
import Button from '../components/ui/Button' import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input' import { Input, Label } from '../components/ui/Input'
import PageGuide from '../components/ui/PageGuide'
const EVENT_TYPE_COLORS: Record<string, string> = { const EVENT_TYPE_COLORS: Record<string, string> = {
CONTRACT_EXPIRY: 'bg-red-100 text-red-700 border-red-200', CONTRACT_EXPIRY: 'bg-red-100 text-red-700 border-red-200',
@@ -190,6 +191,7 @@ export default function Calendar() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide> birthdays /</PageGuide>
{/* 顶部工具栏 */} {/* 顶部工具栏 */}
<div className="flex items-center justify-between flex-wrap gap-2"> <div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+306
View File
@@ -0,0 +1,306 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Edit2, Trash2, Upload, Download, Search } from 'lucide-react'
import { toast } from 'sonner'
import { commissionBonusApi, employeeApi } from '../lib/api-services'
import { Input, Label, Select } from '../components/ui/Input'
import Button from '../components/ui/Button'
import Modal from '../components/ui/Modal'
import PageGuide from '../components/ui/PageGuide'
import Pagination from '../components/ui/Pagination'
import { usePageSize } from '../hooks/usePageSize'
export default function CommissionBonus() {
const queryClient = useQueryClient()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [search, setSearch] = useState('')
const [showAdd, setShowAdd] = useState(false)
const [editRecord, setEditRecord] = useState<any>(null)
const fileRef = useRef<HTMLInputElement>(null)
// 列表 + 汇总
const { data, isLoading } = useQuery({
queryKey: ['commission-bonus', month],
queryFn: () => commissionBonusApi.list(month),
})
// 在职员工列表(新增用)
const { data: employees } = useQuery({
queryKey: ['employees-active'],
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
enabled: showAdd,
})
const records = data?.records || []
const summary = data?.summary || { count: 0, totalBonus: 0, totalDeduction: 0, netAmount: 0 }
// 搜索过滤
const filtered = records.filter((r: any) =>
!search || r.employee?.name?.includes(search) || r.employee?.department?.includes(search)
)
// 分页
const paged = filtered.slice((page - 1) * pageSize, page * pageSize)
// 新增
const addMutation = useMutation({
mutationFn: (data: { employeeId: string; month: string; amount: number; remark?: string }) =>
commissionBonusApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
setShowAdd(false)
toast.success('已添加')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '添加失败'),
})
// 更新
const updateMutation = useMutation({
mutationFn: (data: { id: string; amount?: number; remark?: string }) =>
commissionBonusApi.update(data.id, { amount: data.amount, remark: data.remark }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
setEditRecord(null)
toast.success('已更新')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
})
// 删除
const deleteMutation = useMutation({
mutationFn: (id: string) => commissionBonusApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
toast.success('已删除')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
// 导入
const importMutation = useMutation({
mutationFn: (file: File) => commissionBonusApi.import(file, month),
onSuccess: (data: any) => {
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
toast.success(`导入完成:新增 ${data.created},更新 ${data.updated},跳过 ${data.skipped}`)
if (data.errors?.length > 0) {
toast.error(`错误明细:${data.errors.slice(0, 3).map((e: any) => `${e.row}行: ${e.message}`).join('')}`)
}
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '导入失败'),
})
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) importMutation.mutate(file)
if (fileRef.current) fileRef.current.value = ''
}
const fmt = (n: number) => `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
return (
<div className="space-y-4">
<PageGuide>
<p>/"获取提成奖金"</p>
</PageGuide>
{/* 筛选栏 */}
<div className="flex items-center gap-3 flex-wrap">
<div>
<Label></Label>
<Input type="month" value={month} onChange={(e) => { setMonth(e.target.value); setPage(1) }} className="w-40" />
</div>
<div className="flex-1 min-w-[200px]">
<Label></Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input value={search} onChange={(e) => { setSearch(e.target.value); setPage(1) }} placeholder="员工姓名/部门" className="pl-9" />
</div>
</div>
<div className="flex items-end gap-2">
<Button variant="secondary" onClick={() => window.open(commissionBonusApi.templateUrl, '_blank')}>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button variant="secondary" onClick={() => fileRef.current?.click()} disabled={importMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />{importMutation.isPending ? '导入中...' : '批量导入'}
</Button>
<input ref={fileRef} type="file" accept=".xlsx,.xls" onChange={handleFileChange} className="hidden" />
<Button onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 汇总卡片 */}
<div className="grid grid-cols-4 gap-4">
<div className="rounded-lg border border-gray-200 p-4">
<div className="text-xs text-gray-500"></div>
<div className="text-2xl font-bold text-gray-900 mt-1">{summary.count}</div>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="text-xs text-gray-500"></div>
<div className="text-2xl font-bold text-safe mt-1">{fmt(summary.totalBonus)}</div>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="text-xs text-gray-500"></div>
<div className="text-2xl font-bold text-danger mt-1">{fmt(summary.totalDeduction)}</div>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="text-xs text-gray-500"></div>
<div className={`text-2xl font-bold mt-1 ${summary.netAmount >= 0 ? 'text-gray-900' : 'text-danger'}`}>{fmt(summary.netAmount)}</div>
</div>
</div>
{/* 列表 */}
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{isLoading ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">...</td></tr>
) : paged.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">{month} </td></tr>
) : paged.map((r: any) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-4 py-2.5 text-sm text-gray-900">{r.employee?.name || '-'}</td>
<td className="px-4 py-2.5 text-sm text-gray-500">{r.employee?.department || '-'}</td>
<td className={`px-4 py-2.5 text-sm text-right font-medium ${r.amount >= 0 ? 'text-safe' : 'text-danger'}`}>
{r.amount >= 0 ? '+' : ''}{fmt(r.amount)}
</td>
<td className="px-4 py-2.5 text-sm text-gray-500">{r.remark || '-'}</td>
<td className="px-4 py-2.5 text-center">
<button onClick={() => setEditRecord(r)} className="p-1 text-gray-400 hover:text-primary" title="编辑">
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => { if (confirm(`确认删除 ${r.employee?.name} 的提成奖金记录?`)) deleteMutation.mutate(r.id) }}
className="p-1 text-gray-400 hover:text-danger ml-1" title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filtered.length > pageSize && (
<Pagination page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
)}
{/* 新增弹窗 */}
{showAdd && (
<AddModal
employees={employees || []}
month={month}
onClose={() => setShowAdd(false)}
onSubmit={(data) => addMutation.mutate(data)}
saving={addMutation.isPending}
/>
)}
{/* 编辑弹窗 */}
{editRecord && (
<EditModal
record={editRecord}
onClose={() => setEditRecord(null)}
onSubmit={(data) => updateMutation.mutate({ id: editRecord.id, ...data })}
saving={updateMutation.isPending}
/>
)}
</div>
)
}
function AddModal({ employees, month, onClose, onSubmit, saving }: {
employees: any[]
month: string
onClose: () => void
onSubmit: (data: { employeeId: string; month: string; amount: number; remark?: string }) => void
saving: boolean
}) {
const [employeeId, setEmployeeId] = useState('')
const [amount, setAmount] = useState('')
const [remark, setRemark] = useState('')
return (
<Modal open onClose={onClose} title="新增提成奖金" size="md">
<div className="space-y-4">
<div>
<Label> *</Label>
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
<option value=""></option>
{employees.map((e: any) => (
<option key={e.id} value={e.id}>{e.name}{e.department}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input type="month" value={month} disabled className="bg-gray-50" />
</div>
<div>
<Label> *==</Label>
<Input type="number" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="如 5000 或 -200" />
</div>
<div>
<Label></Label>
<Input value={remark} onChange={(e) => setRemark(e.target.value)} placeholder="选填" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button
disabled={!employeeId || !amount || saving}
onClick={() => onSubmit({ employeeId, month, amount: parseFloat(amount) || 0, remark: remark || undefined })}
>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Modal>
)
}
function EditModal({ record, onClose, onSubmit, saving }: {
record: any
onClose: () => void
onSubmit: (data: { amount?: number; remark?: string }) => void
saving: boolean
}) {
const [amount, setAmount] = useState(String(record.amount))
const [remark, setRemark] = useState(record.remark || '')
return (
<Modal open onClose={onClose} title={`编辑 - ${record.employee?.name || ''}`} size="md">
<div className="space-y-4">
<div>
<Label> *==</Label>
<Input type="number" value={amount} onChange={(e) => setAmount(e.target.value)} />
</div>
<div>
<Label></Label>
<Input value={remark} onChange={(e) => setRemark(e.target.value)} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button
disabled={!amount || saving}
onClick={() => onSubmit({ amount: parseFloat(amount) || 0, remark: remark || undefined })}
>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Modal>
)
}
+2
View File
@@ -7,6 +7,7 @@ import Card from '../components/ui/Card'
import Button from '../components/ui/Button' import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input' import { Input, Label, Select } from '../components/ui/Input'
import EmptyState from '../components/ui/EmptyState' import EmptyState from '../components/ui/EmptyState'
import PageGuide from '../components/ui/PageGuide'
const FILE_TYPES = [ const FILE_TYPES = [
{ value: 'BUSINESS_LICENSE', label: '营业执照' }, { value: 'BUSINESS_LICENSE', label: '营业执照' },
@@ -82,6 +83,7 @@ export default function CompanyFiles() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageGuide>线</PageGuide>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" /> <Building2 className="h-5 w-5 text-primary" />
<h1 className="text-lg font-semibold"></h1> <h1 className="text-lg font-semibold"></h1>
+68 -7
View File
@@ -1,9 +1,10 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize' import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Paperclip, Trash2, X, FileText, Download } from 'lucide-react' import { Plus, Search, Paperclip, Trash2, X, FileText, Download, AlertTriangle } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { rosterApi, employeeApi, attachmentApi } from '../lib/api-services' import { rosterApi, employeeApi, attachmentApi } from '../lib/api-services'
import api from '../lib/api'
import Card from '../components/ui/Card' import Card from '../components/ui/Card'
import Button from '../components/ui/Button' import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input' import { Input, Label, Select } from '../components/ui/Input'
@@ -78,7 +79,7 @@ export default function Contracts() {
<FileText className="h-5 w-5 text-primary" /> <FileText className="h-5 w-5 text-primary" />
<div> <div>
<h1 className="text-base font-semibold"></h1> <h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p> <p className="mt-1 text-sm text-gray-500">//</p>
</div> </div>
</div> </div>
<Button onClick={() => setShowAddModal(true)}> <Button onClick={() => setShowAddModal(true)}>
@@ -217,12 +218,30 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
loading: boolean loading: boolean
error: any error: any
}) { }) {
// 拉取组织架构部门列表,用于部门下拉选择
const { data: departments = [] } = useQuery({
queryKey: ['departments'],
queryFn: () => api.get('/departments').then(r => r.data),
})
// 构建部门树形下拉选项(带层级缩进)
const deptOptions: { id: string; label: string; level: number }[] = []
const buildDeptOptions = (items: any[], parentId: string | null, level: number) => {
items.filter(d => d.parentId === parentId).sort((a, b) => a.sortOrder - b.sortOrder).forEach(d => {
deptOptions.push({ id: d.id, label: d.name, level })
buildDeptOptions(items, d.id, level + 1)
})
}
buildDeptOptions(departments, null, 0)
// 手机号查重
const [phoneDuplicate, setPhoneDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',
department: '', department: '',
position: '', position: '',
hireDate: '', hireDate: '',
monthlySalary: '', monthlySalary: '',
baseSalary: '',
performanceSalary: '0',
gender: '男' as '男' | '女', gender: '男' as '男' | '女',
phone: '', phone: '',
isPregnant: false, isPregnant: false,
@@ -244,6 +263,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
position: form.position || undefined, position: form.position || undefined,
hireDate: new Date(form.hireDate).toISOString(), hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary, monthlySalary: form.monthlySalary,
baseSalary: form.baseSalary,
performanceSalary: form.performanceSalary || '0',
gender: form.gender, gender: form.gender,
phone: form.phone || undefined, phone: form.phone || undefined,
isPregnant: form.isPregnant, isPregnant: form.isPregnant,
@@ -259,6 +280,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
contractYears: form.contractYears, contractYears: form.contractYears,
probationMonths: form.probationMonths, probationMonths: form.probationMonths,
probationSalary: form.probationSalary, probationSalary: form.probationSalary,
baseSalary: parseFloat(form.baseSalary) || 0,
performanceSalary: parseFloat(form.performanceSalary) || 0,
} }
} }
onSubmit(data) onSubmit(data)
@@ -280,7 +303,12 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
</div> </div>
<div> <div>
<Label> *</Label> <Label> *</Label>
<Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /> <Select value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })}>
<option value=""></option>
{deptOptions.map(d => (
<option key={d.id} value={d.label}>{' '.repeat(d.level)}{d.label}</option>
))}
</Select>
</div> </div>
</div> </div>
@@ -295,9 +323,27 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
</div> </div>
</div> </div>
<div> <div className="grid grid-cols-3 gap-3">
<Label> *</Label> <div>
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" /> <Label> *</Label>
<Input type="number" value={form.baseSalary} onChange={(e) => {
const base = parseFloat(e.target.value) || 0
const perf = parseFloat(form.performanceSalary) || 0
setForm({ ...form, baseSalary: e.target.value, monthlySalary: base + perf > 0 ? String(base + perf) : '' })
}} placeholder="元" />
</div>
<div>
<Label></Label>
<Input type="number" value={form.performanceSalary} onChange={(e) => {
const perf = parseFloat(e.target.value) || 0
const base = parseFloat(form.baseSalary) || 0
setForm({ ...form, performanceSalary: e.target.value, monthlySalary: base + perf > 0 ? String(base + perf) : '' })
}} placeholder="可为0" />
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">¥{(parseFloat(form.baseSalary) || 0) + (parseFloat(form.performanceSalary) || 0)}</div>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -310,9 +356,24 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
</div> </div>
<div> <div>
<Label></Label> <Label></Label>
<Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /> <Input value={form.phone} onChange={(e) => {
const phone = e.target.value.replace(/\D/g, '').slice(0, 11)
setForm({ ...form, phone })
setPhoneDuplicate(null)
if (phone.length === 11) {
employeeApi.checkPhone(phone).then((data: { exists: boolean; employee?: any }) => {
setPhoneDuplicate(data)
}).catch(() => {})
}
}} placeholder="选填" maxLength={11} />
</div> </div>
</div> </div>
{phoneDuplicate?.exists && (
<div className="px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{phoneDuplicate.employee?.name}{phoneDuplicate.employee?.department}</span>
</div>
)}
{/* 特殊状态 */} {/* 特殊状态 */}
<div className="flex gap-4"> <div className="flex gap-4">
+115 -61
View File
@@ -214,10 +214,10 @@ export default function Dashboard() {
const yearCost = data.yearCostSummary const yearCost = data.yearCostSummary
const stats = [ const stats = [
{ label: '在管员工', value: data.stats.employeeCount, icon: Users, color: 'text-primary' }, { label: '在管员工', value: data.stats.employeeCount, icon: Users, color: 'text-primary', to: '/roster' },
{ label: '高风险', value: data.stats.highRiskCount, icon: AlertTriangle, color: 'text-danger' }, { label: '高风险', value: data.stats.highRiskCount, icon: AlertTriangle, color: 'text-danger', to: '/risk-center' },
{ label: '待办事项', value: data.stats.todoCount, icon: CheckSquare, color: 'text-warning' }, { label: '待办事项', value: data.stats.todoCount, icon: CheckSquare, color: 'text-warning', to: '', tab: 'risk' as const },
{ label: '工资条', value: payroll?.payslipCount ?? 0, icon: Receipt, color: 'text-safe' }, { label: '工资条', value: payroll?.payslipCount ?? 0, icon: Receipt, color: 'text-safe', to: '/money' },
] ]
// 当月人力成本 // 当月人力成本
@@ -279,6 +279,9 @@ export default function Dashboard() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -382,16 +385,13 @@ export default function Dashboard() {
{/* 概览 Tab */} {/* 概览 Tab */}
{activeTab === 'overview' && ( {activeTab === 'overview' && (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
/
</PageGuide>
{/* 统计卡片 */} {/* 统计卡片 */}
{isSectionVisible('overview_stats') && ( {isSectionVisible('overview_stats') && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{stats.map((stat) => { {stats.map((stat) => {
const Icon = stat.icon const Icon = stat.icon
return ( const inner = (
<Card key={stat.label} className="flex items-center gap-2.5"> <Card key={stat.label} className="flex items-center gap-2.5 hover:shadow-md transition-shadow cursor-pointer">
<Icon className={`w-6 h-6 ${stat.color}`} /> <Icon className={`w-6 h-6 ${stat.color}`} />
<div> <div>
<div className="text-base font-bold">{stat.value}</div> <div className="text-base font-bold">{stat.value}</div>
@@ -399,6 +399,10 @@ export default function Dashboard() {
</div> </div>
</Card> </Card>
) )
if (stat.tab) {
return <div key={stat.label} onClick={() => setActiveTab(stat.tab)}>{inner}</div>
}
return <Link key={stat.label} to={stat.to}>{inner}</Link>
})} })}
</div> </div>
)} )}
@@ -425,18 +429,46 @@ export default function Dashboard() {
</ResponsiveContainer> </ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center px-2 overflow-hidden"> <div className="absolute inset-0 flex flex-col items-center justify-center px-2 overflow-hidden">
<span className={`text-2xl font-bold leading-none ${complianceScore?.level === 'safe' ? 'text-safe' : complianceScore?.level === 'warning' ? 'text-warning' : 'text-danger'}`}>{complianceScore?.overallScore ?? complianceScore?.totalScore ?? 0}</span> <span className={`text-2xl font-bold leading-none ${complianceScore?.level === 'safe' ? 'text-safe' : complianceScore?.level === 'warning' ? 'text-warning' : 'text-danger'}`}>{complianceScore?.overallScore ?? complianceScore?.totalScore ?? 0}</span>
<span className="text-xs text-gray-500 mt-1 truncate max-w-full">{complianceScore?.levelLabel ?? ''}</span> <span className="text-xs text-gray-500 mt-1 truncate max-w-full">{complianceScore?.levelLabel ?? complianceScore?.summary ?? ''}</span>
</div> </div>
</div> </div>
{/* 维度评分 */} {/* 维度评分 */}
<div className="flex-1 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2"> <div className="flex-1 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2">
{complianceScore?.dimensions?.map((dim: any) => ( {complianceScore?.dimensions?.map((dim: any) => {
<Link key={dim.key} to={dim.key === 'contract' ? '/roster' : dim.key === 'policy' ? '/policies' : dim.key === 'attendance' ? '/attendance' : dim.key === 'salary' ? '/money' : '/social'} className="flex flex-col items-center p-1.5 rounded-lg hover:bg-gray-50 transition-colors"> const dimRouteMap: Record<string, string> = {
<span className={`text-lg font-bold ${dim.score >= 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score}</span> contract: '/roster',
<span className="text-xs text-gray-600">{dim.name}</span> salary: '/money',
{dim.todoCount > 0 && <span className="text-[10px] text-gray-400">{dim.todoCount}</span>} social: '/social',
</Link> attendance: '/attendance',
))} policy: '/policies',
termination: '/termination',
evidence: '/evidence',
}
return (
<Link key={dim.key} to={dimRouteMap[dim.key] || '/roster'} className="flex flex-col items-center p-1.5 rounded-lg hover:bg-gray-50 transition-colors group relative">
<span className={`text-lg font-bold ${dim.score >= 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score}</span>
<span className="text-xs text-gray-600">{dim.name}</span>
{dim.todoCount > 0 && <span className="text-[10px] text-gray-400">{dim.todoCount}</span>}
{/* hover 显示得分依据 */}
{dim.findings && dim.findings.length > 0 && (
<div className="hidden group-hover:block absolute top-full left-1/2 -translate-x-1/2 z-20 mt-1 w-56 bg-gray-900 text-white text-xs rounded-lg p-2.5 shadow-lg whitespace-normal">
<div className="font-medium mb-1"></div>
{dim.findings.map((f: string, i: number) => (
<div key={i} className="text-gray-300 leading-relaxed mb-0.5"> {f}</div>
))}
{dim.recommendations && dim.recommendations.length > 0 && (
<>
<div className="font-medium mt-1.5 mb-0.5 text-amber-300"></div>
{dim.recommendations.map((r: string, i: number) => (
<div key={i} className="text-gray-300 leading-relaxed mb-0.5"> {r}</div>
))}
</>
)}
</div>
)}
</Link>
)
})}
</div> </div>
</div> </div>
{/* 合同到期预警内联 */} {/* 合同到期预警内联 */}
@@ -572,9 +604,6 @@ export default function Dashboard() {
{/* 人力成本 Tab */} {/* 人力成本 Tab */}
{activeTab === 'cost' && ( {activeTab === 'cost' && (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
</PageGuide>
{/* 成本专属指标卡片 */} {/* 成本专属指标卡片 */}
{isSectionVisible('cost_metrics') && ( {isSectionVisible('cost_metrics') && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
@@ -839,9 +868,6 @@ export default function Dashboard() {
{/* 人员分析 Tab */} {/* 人员分析 Tab */}
{activeTab === 'workforce' && ( {activeTab === 'workforce' && (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
</PageGuide>
{/* 本月工作动态 */} {/* 本月工作动态 */}
{isSectionVisible('workforce_activities') && ( {isSectionVisible('workforce_activities') && (
<Card> <Card>
@@ -974,11 +1000,6 @@ export default function Dashboard() {
{/* 风险提醒 Tab */} {/* 风险提醒 Tab */}
{(activeTab === 'risk' || activeTab === 'task') && ( {(activeTab === 'risk' || activeTab === 'task') && (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
{activeTab === 'risk'
? '风险提醒页汇总合同到期、未签合同、离职风险、退休预警等高风险待办事项。可批量处理或逐项解决,处理完成后自动归档至已办事项。'
: '月度任务页汇总社保办理、发薪批次等周期性待办事项。可批量处理或逐项解决,处理完成后自动归档至已办事项。'}
</PageGuide>
{/* 风险分布饼图(仅风险提醒Tab) */} {/* 风险分布饼图(仅风险提醒Tab) */}
{activeTab === 'risk' && isSectionVisible('risk_distribution') && ( {activeTab === 'risk' && isSectionVisible('risk_distribution') && (
<Card> <Card>
@@ -1130,7 +1151,9 @@ export default function Dashboard() {
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => ( {filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => {
const hasValidUrl = todo.actionUrl && todo.actionUrl !== '/' && todo.actionUrl !== ''
return (
<div <div
key={todo.id} key={todo.id}
className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors" className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
@@ -1142,40 +1165,71 @@ export default function Dashboard() {
onChange={() => toggleSelect(todo.id)} onChange={() => toggleSelect(todo.id)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/> />
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1"> {hasValidUrl ? (
<TodoIcon type={todo.type} level={todo.level} /> <Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
<div className="flex flex-col"> <TodoIcon type={todo.type} level={todo.level} />
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex flex-col">
{todo.employeeName && ( <div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-bold text-gray-900">{todo.employeeName}</span> {todo.employeeName && (
)} <span className="text-xs font-bold text-gray-900">{todo.employeeName}</span>
{todo.employeeDepartment && ( )}
<span className="text-xs text-gray-400">{todo.employeeDepartment}</span> {todo.employeeDepartment && (
)} <span className="text-xs text-gray-400">{todo.employeeDepartment}</span>
<span className="text-xs text-gray-700">{todo.title}</span> )}
{todo.priority && priorityConfig[todo.priority] && ( <span className="text-xs text-gray-700">{todo.title}</span>
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}> {todo.priority && priorityConfig[todo.priority] && (
{priorityConfig[todo.priority].label} <span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
</span> {priorityConfig[todo.priority].label}
)} </span>
{todo.estimatedLoss > 0 && ( )}
<span className="text-xs text-red-600 font-medium"> {fmt(todo.estimatedLoss)}</span> {todo.estimatedLoss > 0 && (
)} <span className="text-xs text-red-600 font-medium"> {fmt(todo.estimatedLoss)}</span>
{todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && ( )}
<span className="text-xs text-red-600">{todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}`}</span> {todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && (
)} <span className="text-xs text-red-600">{todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}`}</span>
)}
</div>
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
</div>
</Link>
) : (
<div className="flex items-center gap-2.5 flex-1">
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<div className="flex items-center gap-1.5 flex-wrap">
{todo.employeeName && (
<span className="text-xs font-bold text-gray-900">{todo.employeeName}</span>
)}
{todo.employeeDepartment && (
<span className="text-xs text-gray-400">{todo.employeeDepartment}</span>
)}
<span className="text-xs text-gray-700">{todo.title}</span>
{todo.priority && priorityConfig[todo.priority] && (
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
{priorityConfig[todo.priority].label}
</span>
)}
{todo.estimatedLoss > 0 && (
<span className="text-xs text-red-600 font-medium"> {fmt(todo.estimatedLoss)}</span>
)}
{todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && (
<span className="text-xs text-red-600">{todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}`}</span>
)}
</div>
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
</div> </div>
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
</div> </div>
</Link> )}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Link {hasValidUrl && (
to={todo.actionUrl} <Link
className="px-2 py-1 rounded text-xs font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors whitespace-nowrap" to={todo.actionUrl}
> className="px-2 py-1 rounded text-xs font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors whitespace-nowrap"
>
</Link>
</Link>
)}
<button <button
onClick={() => resolveMutation.mutate(todo.id)} onClick={() => resolveMutation.mutate(todo.id)}
disabled={resolveMutation.isPending} disabled={resolveMutation.isPending}
@@ -1194,7 +1248,7 @@ export default function Dashboard() {
</button> </button>
</div> </div>
</div> </div>
))} )})}
</div> </div>
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={() => setTodoPage(1)} /> <Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={() => setTodoPage(1)} />
</> </>
+801 -37
View File
@@ -1,22 +1,33 @@
import { useState } from 'react' /**
* /
* - Tab1 /
* - Tab2 //
* - + +
* - 线 + +
* -
* -
*/
import { useState, useRef, useEffect, Fragment } from 'react'
import { useSearchParams } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { PenTool, Plus, X, RefreshCw, ExternalLink, FileText, AlertCircle } from 'lucide-react' import { QRCodeSVG } from 'qrcode.react'
import { PenTool, Plus, RefreshCw, ExternalLink, FileText, AlertCircle, Shield, ChevronLeft, Clock, CheckCircle2, XCircle, Eye, Upload, FileCheck, Bell, Copy, Check, ChevronDown, ChevronRight, User } from 'lucide-react'
import { esignApi, employeeApi } from '../lib/api-services' import { esignApi, employeeApi } from '../lib/api-services'
import PageGuide from '../components/ui/PageGuide' import PageGuide from '../components/ui/PageGuide'
import Card from '../components/ui/Card' import Card from '../components/ui/Card'
import Button from '../components/ui/Button' import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input' import { Input, Label, Select } from '../components/ui/Input'
import { InlineAlert } from '../components/ui/InlineAlert' import { InlineAlert } from '../components/ui/InlineAlert'
import Modal from '../components/ui/Modal' import Modal from '../components/ui/Modal'
const STATUS_CONFIG: Record<string, { label: string; color: string }> = { const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700' }, PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700', icon: <Clock className="w-3 h-3" /> },
SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700' }, SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700', icon: <Clock className="w-3 h-3" /> },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' }, COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger' }, REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger', icon: <XCircle className="w-3 h-3" /> },
EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500' }, EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500', icon: <XCircle className="w-3 h-3" /> },
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-500' }, CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-500', icon: <XCircle className="w-3 h-3" /> },
} }
const SCENE_CONFIG: Record<string, { label: string; color: string }> = { const SCENE_CONFIG: Record<string, { label: string; color: string }> = {
@@ -27,17 +38,47 @@ const SCENE_CONFIG: Record<string, { label: string; color: string }> = {
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' }, ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
} }
const SIGN_METHOD_CONFIG: Record<string, { label: string; color: string }> = {
ELECTRONIC: { label: '电子签', color: 'bg-blue-50 text-blue-600' },
PAPER: { label: '线下手签', color: 'bg-orange-50 text-orange-600' },
}
export default function ESign() { export default function ESign() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<'pending' | 'records'>('pending')
const [filterStatus, setFilterStatus] = useState('') const [filterStatus, setFilterStatus] = useState('')
const [filterScene, setFilterScene] = useState('') const [filterScene, setFilterScene] = useState('')
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [showPaperSign, setShowPaperSign] = useState(false)
const [detailId, setDetailId] = useState<string | null>(null)
const [searchParams] = useSearchParams()
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
employeeId: '', employeeId: '',
scene: 'CONTRACT',
documentTitle: '', documentTitle: '',
remark: '', remark: '',
}) })
// 从 URL 参数读取,自动打开线下手签登记
useEffect(() => {
const action = searchParams.get('action')
if (action === 'paper-sign') {
setShowPaperSign(true)
// 预填表单
const employeeId = searchParams.get('employeeId')
const scene = searchParams.get('scene') as string
const documentTitle = searchParams.get('documentTitle') as string
if (employeeId || scene || documentTitle) {
setFormData({
employeeId: employeeId || '',
scene: scene || 'CONTRACT',
documentTitle: documentTitle || '',
remark: '花名册操作跳转登记',
})
}
}
}, [searchParams])
const { data: records = [], isLoading } = useQuery<any[]>({ const { data: records = [], isLoading } = useQuery<any[]>({
queryKey: ['esign-records', filterStatus, filterScene], queryKey: ['esign-records', filterStatus, filterScene],
queryFn: async () => { queryFn: async () => {
@@ -45,6 +86,26 @@ export default function ESign() {
}, },
}) })
/** 待签合同列表(按员工聚合) */
const { data: pendingList = [], isLoading: pendingLoading } = useQuery<any[]>({
queryKey: ['esign-pending'],
queryFn: async () => {
return await esignApi.pending()
},
})
/** 催办(生成自动登录链接) */
const remindMutation = useMutation({
mutationFn: (employeeId: string) => esignApi.remind(employeeId) as any,
onSuccess: (data: any) => {
queryClient.invalidateQueries({ queryKey: ['esign-pending'] })
toast.success(`催办链接已生成,可复制或展示二维码给员工`, {
description: data?.phone ? `员工手机:${data.phone}` : '',
})
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '催办失败'),
})
const { data: rosterData = [] } = useQuery<any[]>({ const { data: rosterData = [] } = useQuery<any[]>({
queryKey: ['employees-for-esign'], queryKey: ['employees-for-esign'],
queryFn: async () => { queryFn: async () => {
@@ -54,15 +115,17 @@ export default function ESign() {
}) })
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (data: { employeeId: string; documentTitle: string; remark?: string }) => mutationFn: async (data: { employeeId: string; scene: string; documentTitle: string; remark?: string }) =>
esignApi.create(data) as any, esignApi.create(data) as any,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] }) queryClient.invalidateQueries({ queryKey: ['esign-records'] })
setShowCreate(false) setShowCreate(false)
setFormData({ employeeId: '', documentTitle: '', remark: '' }) setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' })
toast.success('签署记录已创建,待对接易签宝后将自动发送签署链接') toast.success('签署记录已创建,员工可在员工端查看并签署')
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '创建失败')
}, },
onError: () => toast.error('创建失败'),
}) })
const cancelMutation = useMutation({ const cancelMutation = useMutation({
@@ -87,32 +150,60 @@ export default function ESign() {
createMutation.mutate(formData) createMutation.mutate(formData)
} }
// ===== 签署详情视图 =====
if (detailId) {
return <ESignDetail id={detailId} onBack={() => setDetailId(null)} />
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageGuide>
</PageGuide>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PenTool className="h-5 w-5 text-primary" /> <PenTool className="h-5 w-5 text-primary" />
<div> <div>
<h1 className="text-base font-semibold"></h1> <h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500">线</p> <p className="mt-1 text-sm text-gray-500"> + </p>
</div> </div>
</div> </div>
<PageGuide> {/* Tab 切换 */}
线PDF文件 <div className="flex items-center justify-between">
<span className="text-amber-600"> API后将自动启用在线签署功能</span> <div className="flex gap-1 border-b">
</PageGuide> <button
onClick={() => setActiveTab('pending')}
<InlineAlert type="info" className="flex items-start gap-2"> className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'pending' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> >
<div>
<span className="font-medium">API</span> {pendingList.length > 0 && <span className="ml-1.5 px-1.5 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-700">{pendingList.length}</span>}
<div className="mt-1 text-xs"> </button>
AppIdAppSecret API <button
HR在系统发起签署 PDF onClick={() => setActiveTab('records')}
</div> className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'records' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
{records.length > 0 && <span className="ml-1.5 px-1.5 py-0.5 rounded-full text-xs bg-blue-100 text-blue-700">{records.length}</span>}
</button>
</div> </div>
</InlineAlert> <div className="flex gap-2">
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowPaperSign(true)}>
<FileCheck className="w-4 h-4 mr-1" />线
</Button>
</div>
</div>
{/* ===== Tab1: 待签合同(按员工聚合) ===== */}
{activeTab === 'pending' && (
<PendingTab pendingList={pendingList} loading={pendingLoading} onRemind={(empId) => remindMutation.mutate(empId)} remindLoading={remindMutation.isPending} remindData={remindMutation.data} onDetail={(id) => setDetailId(id)} />
)}
{/* ===== Tab2: 签署记录 ===== */}
{activeTab === 'records' && (
<>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-2"> <div className="flex gap-2">
<select <select
@@ -132,9 +223,6 @@ export default function ESign() {
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)} {Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select> </select>
</div> </div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div> </div>
{/* 签署记录列表 */} {/* 签署记录列表 */}
@@ -151,6 +239,7 @@ export default function ESign() {
<th className="py-2 px-3 text-left"></th> <th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th> <th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th> <th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th> <th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th> <th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th> <th className="py-2 px-3 text-left"></th>
@@ -165,7 +254,9 @@ export default function ESign() {
<td className="py-2 px-3"> <td className="py-2 px-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" /> <FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="font-medium truncate max-w-[200px]">{r.documentTitle}</span> <button className="font-medium truncate max-w-[200px] text-left hover:text-primary" onClick={() => setDetailId(r.id)}>
{r.documentTitle}
</button>
{r.scene && SCENE_CONFIG[r.scene] && ( {r.scene && SCENE_CONFIG[r.scene] && (
<span className={`px-1.5 py-0.5 rounded text-xs shrink-0 ${SCENE_CONFIG[r.scene].color}`}>{SCENE_CONFIG[r.scene].label}</span> <span className={`px-1.5 py-0.5 rounded text-xs shrink-0 ${SCENE_CONFIG[r.scene].color}`}>{SCENE_CONFIG[r.scene].label}</span>
)} )}
@@ -175,16 +266,32 @@ export default function ESign() {
<td className="py-2 px-3">{r.employee?.name || '—'}</td> <td className="py-2 px-3">{r.employee?.name || '—'}</td>
<td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td> <td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td>
<td className="py-2 px-3"> <td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>{statusCfg.label}</span> <span className={`px-1.5 py-0.5 rounded text-xs ${(SIGN_METHOD_CONFIG[r.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
{(SIGN_METHOD_CONFIG[r.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
</span>
</td>
<td className="py-2 px-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>
{statusCfg.icon}{statusCfg.label}
</span>
</td> </td>
<td className="py-2 px-3 text-gray-500 text-xs">{new Date(r.createdAt).toLocaleString('zh-CN')}</td> <td className="py-2 px-3 text-gray-500 text-xs">{new Date(r.createdAt).toLocaleString('zh-CN')}</td>
<td className="py-2 px-3 text-gray-500 text-xs">{r.completedAt ? new Date(r.completedAt).toLocaleString('zh-CN') : '—'}</td> <td className="py-2 px-3 text-gray-500 text-xs">{r.completedAt ? new Date(r.completedAt).toLocaleString('zh-CN') : '—'}</td>
<td className="py-2 px-3 text-right"> <td className="py-2 px-3 text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => setDetailId(r.id)} title="查看详情">
<Eye className="w-3.5 h-3.5" />
</button>
{r.status === 'COMPLETED' && r.signedPdfUrl && ( {r.status === 'COMPLETED' && r.signedPdfUrl && (
<a href={r.signedPdfUrl} target="_blank" rel="noopener noreferrer" <a href={r.signedPdfUrl} target="_blank" rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-0.5"> className="text-xs text-primary hover:underline flex items-center gap-0.5">
<ExternalLink className="w-3 h-3" />PDF <ExternalLink className="w-3 h-3" />PDF
</a>
)}
{r.status === 'COMPLETED' && r.signMethod === 'PAPER' && (r as any).scanFileUrls && (
<a href={(r as any).scanFileUrls[0]?.url} target="_blank" rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-0.5">
<ExternalLink className="w-3 h-3" />
</a> </a>
)} )}
{(r.status === 'PENDING' || r.status === 'SIGNING') && ( {(r.status === 'PENDING' || r.status === 'SIGNING') && (
@@ -208,13 +315,15 @@ export default function ESign() {
</div> </div>
)} )}
</Card> </Card>
</>
)}
{/* 发起签署 Modal */} {/* 发起签署 Modal */}
{showCreate && ( {showCreate && (
<Modal open={true} onClose={() => setShowCreate(false)} title="发起电子签署" size="md"> <Modal open={true} onClose={() => setShowCreate(false)} title="发起电子签署" size="md">
<div className="space-y-3"> <div className="space-y-3">
<InlineAlert type="info"> <InlineAlert type="info">
</InlineAlert> </InlineAlert>
<div> <div>
<Label> *</Label> <Label> *</Label>
@@ -229,6 +338,26 @@ export default function ESign() {
))} ))}
</select> </select>
</div> </div>
<div>
<Label> *</Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={formData.scene}
onChange={(e) => {
const scene = e.target.value
const defaultTitle: Record<string, string> = {
CONTRACT: '劳动合同书',
RESIGNATION: '协商解除劳动合同协议书',
POLICY: '规章制度签收确认书',
PAYSLIP: '工资条确认书',
ONBOARDING: '入职文件签署',
}
setFormData({ ...formData, scene, documentTitle: defaultTitle[scene] || '' })
}}
>
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<div> <div>
<Label> *</Label> <Label> *</Label>
<Input value={formData.documentTitle} onChange={(e) => setFormData({ ...formData, documentTitle: e.target.value })} <Input value={formData.documentTitle} onChange={(e) => setFormData({ ...formData, documentTitle: e.target.value })}
@@ -239,6 +368,13 @@ export default function ESign() {
<Input value={formData.remark} onChange={(e) => setFormData({ ...formData, remark: e.target.value })} <Input value={formData.remark} onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
placeholder="可选" /> placeholder="可选" />
</div> </div>
<div className="text-xs text-gray-400 bg-gray-50 rounded p-2">
<div className="font-medium text-gray-500 mb-1"></div>
<div>1. </div>
<div>2. </div>
<div>3. IP//</div>
{formData.scene === 'CONTRACT' && <div>4. </div>}
</div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)}></Button> <Button variant="secondary" size="sm" onClick={() => setShowCreate(false)}></Button>
<Button size="sm" onClick={handleCreate} disabled={createMutation.isPending}> <Button size="sm" onClick={handleCreate} disabled={createMutation.isPending}>
@@ -248,6 +384,634 @@ export default function ESign() {
</div> </div>
</Modal> </Modal>
)} )}
{/* 线下手签登记 Modal */}
{showPaperSign && (
<PaperSignModal
initialEmployeeId={formData.employeeId}
initialScene={formData.scene as any}
initialDocumentTitle={formData.documentTitle}
initialRemark={formData.remark}
onClose={() => { setShowPaperSign(false); setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' }) }}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
setShowPaperSign(false)
setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' })
}}
/>
)}
</div> </div>
) )
} }
// ===== 线下手签登记 Modal =====
function PaperSignModal({ onClose, onSuccess, initialEmployeeId, initialScene, initialDocumentTitle, initialRemark }: {
onClose: () => void
onSuccess: () => void
initialEmployeeId?: string
initialScene?: string
initialDocumentTitle?: string
initialRemark?: string
}) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [form, setForm] = useState({
employeeId: initialEmployeeId || '',
scene: initialScene || 'CONTRACT',
documentTitle: initialDocumentTitle || '',
signedAt: new Date().toISOString().slice(0, 10),
signedLocation: '',
witnessName: '',
witnessPhone: '',
remark: initialRemark || '',
})
const [scanFiles, setScanFiles] = useState<Array<{ name: string; url: string }>>([])
const [uploading, setUploading] = useState(false)
const { data: rosterData = [] } = useQuery<any[]>({
queryKey: ['employees-for-paper-sign'],
queryFn: () => employeeApi.allLite({ status: 'ACTIVE' }),
})
const paperSignMutation = useMutation({
mutationFn: (data: any) => esignApi.paperSign(data),
onSuccess: () => {
toast.success('线下手签登记成功,证据链已记录')
onSuccess()
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '登记失败'),
})
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
const allowedExts = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp', '.webp', '.tiff', '.tif']
const validFiles: File[] = []
for (const file of Array.from(files)) {
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedExts.includes(ext)) {
toast.error(`不支持的格式: ${file.name}`)
continue
}
if (file.size > 10 * 1024 * 1024) {
toast.error(`文件过大: ${file.name}(最大10MB`)
continue
}
validFiles.push(file)
}
if (validFiles.length === 0) return
setUploading(true)
try {
const res = await esignApi.uploadPaperSign(validFiles)
setScanFiles(prev => [...prev, ...res])
toast.success(`已上传 ${res.length} 个文件`)
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '上传失败')
} finally {
setUploading(false)
}
e.target.value = ''
}
const handleSubmit = () => {
if (!form.employeeId) { toast.error('请选择员工'); return }
if (!form.documentTitle.trim()) { toast.error('请填写文件标题'); return }
if (!form.signedAt) { toast.error('请选择签署日期'); return }
if (scanFiles.length === 0) { toast.error('请至少上传一份签署扫描件'); return }
paperSignMutation.mutate({ ...form, scanFileUrls: scanFiles })
}
return (
<Modal open={true} onClose={onClose} title="线下手签登记" size="md">
<div className="space-y-3">
<InlineAlert type="info">
线/
</InlineAlert>
<div>
<Label> *</Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={form.employeeId}
onChange={(e) => setForm({ ...form, employeeId: e.target.value })}
>
<option value=""></option>
{rosterData.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</select>
</div>
<div>
<Label> *</Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={form.scene}
onChange={(e) => {
const scene = e.target.value
const defaultTitle: Record<string, string> = {
CONTRACT: '劳动合同书',
RESIGNATION: '解除劳动合同协议书',
POLICY: '规章制度签收确认书',
PAYSLIP: '工资条确认书',
ONBOARDING: '入职文件',
}
setForm({ ...form, scene, documentTitle: defaultTitle[scene] || '' })
}}
>
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<div>
<Label> *</Label>
<Input value={form.documentTitle} onChange={(e) => setForm({ ...form, documentTitle: e.target.value })}
placeholder="如:2024年度劳动合同" />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label> *</Label>
<Input type="date" max={new Date().toISOString().slice(0, 10)} value={form.signedAt} onChange={(e) => setForm({ ...form, signedAt: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={form.signedLocation} onChange={(e) => setForm({ ...form, signedLocation: e.target.value })}
placeholder="如:公司会议室" />
</div>
<div>
<Label></Label>
<Input value={form.witnessName} onChange={(e) => setForm({ ...form, witnessName: e.target.value })}
placeholder="可选" />
</div>
<div>
<Label></Label>
<Input value={form.witnessPhone} onChange={(e) => setForm({ ...form, witnessPhone: e.target.value })}
placeholder="可选" />
</div>
</div>
<div>
<Label> *</Label>
<div className="border-2 border-dashed border-gray-200 rounded-md p-4 text-center">
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.bmp,.webp,.tiff,.tif"
onChange={handleFileUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-sm text-primary hover:underline"
>
<Upload className="w-4 h-4 inline mr-1" />
{uploading ? '上传中...' : '点击上传扫描件'}
</button>
<div className="text-xs text-gray-400 mt-1"> JPG/PNG/PDF/BMP/WEBP/TIFF10MB</div>
</div>
{scanFiles.length > 0 && (
<div className="mt-2 space-y-1">
{scanFiles.map((f, i) => (
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded px-2 py-1.5">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<a href={f.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex-1 truncate">{f.name}</a>
<button
onClick={() => setScanFiles(prev => prev.filter((_, idx) => idx !== i))}
className="text-gray-400 hover:text-danger"
></button>
</div>
))}
</div>
)}
</div>
<div>
<Label></Label>
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })}
placeholder="可选" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={handleSubmit} disabled={paperSignMutation.isPending || uploading}>
{paperSignMutation.isPending ? '登记中...' : '确认登记'}
</Button>
</div>
</div>
</Modal>
)
}
// ===== 签署详情组件 =====
function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
const { data: detail, isLoading } = useQuery<any>({
queryKey: ['esign-detail', id],
queryFn: () => esignApi.detail(id),
})
const { data: evidence = [] } = useQuery<any[]>({
queryKey: ['esign-evidence', id],
queryFn: () => esignApi.evidence(id),
})
if (isLoading) {
return (
<div className="space-y-4">
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
<ChevronLeft className="w-4 h-4" />
</button>
<Card className="p-6"><div className="text-center text-gray-400">...</div></Card>
</div>
)
}
if (!detail) {
return (
<div className="space-y-4">
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
<ChevronLeft className="w-4 h-4" />
</button>
<Card className="p-6"><div className="text-center text-gray-400"></div></Card>
</div>
)
}
const statusCfg = STATUS_CONFIG[detail.status] || STATUS_CONFIG.PENDING
const sceneCfg = SCENE_CONFIG[detail.scene] || SCENE_CONFIG.CONTRACT
return (
<div className="space-y-4">
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
<ChevronLeft className="w-4 h-4" />
</button>
{/* 基本信息 */}
<Card className="p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<FileText className="w-5 h-5 text-primary" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold">{detail.documentTitle}</h2>
<span className={`px-1.5 py-0.5 rounded text-xs ${sceneCfg.color}`}>{sceneCfg.label}</span>
<span className={`px-1.5 py-0.5 rounded text-xs ${(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
{(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
</span>
</div>
<div className="text-xs text-gray-400 mt-0.5">{detail.employee?.name} · {detail.employee?.department}</div>
</div>
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs ${statusCfg.color}`}>
{statusCfg.icon}{statusCfg.label}
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{new Date(detail.createdAt).toLocaleString('zh-CN')}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}</div>
</div>
{detail.signMethod === 'PAPER' ? (
<>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.signedAt ? new Date(detail.signedAt).toLocaleDateString('zh-CN') : '—'}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.signedLocation || '—'}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.witnessName || '—'} {detail.witnessPhone ? `· ${detail.witnessPhone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}` : ''}</div>
</div>
</>
) : (
<>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.expiredAt ? new Date(detail.expiredAt).toLocaleString('zh-CN') : '—'}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.employee?.phone ? detail.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '—'}</div>
</div>
</>
)}
{detail.remark && (
<div className="col-span-2">
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.remark}</div>
</div>
)}
</div>
</Card>
{/* 线下手签扫描件 */}
{detail.signMethod === 'PAPER' && detail.scanFileUrls && (detail.scanFileUrls as any[]).length > 0 && (
<Card className="p-5">
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
<FileCheck className="w-4 h-4 text-orange-500" />
<span className="text-xs text-gray-400 font-normal">{(detail.scanFileUrls as any[]).length}</span>
</div>
<div className="space-y-2">
{(detail.scanFileUrls as any[]).map((f: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded px-3 py-2">
<FileText className="w-4 h-4 text-gray-400 shrink-0" />
<a href={f.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex-1 truncate">{f.name}</a>
<ExternalLink className="w-3 h-3 text-gray-400" />
</div>
))}
</div>
</Card>
)}
{/* 文件内容预览 */}
{detail.documentContent && (
<Card className="p-5">
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
<FileText className="w-4 h-4 text-gray-400" />
</div>
<div className="text-xs text-gray-600 max-h-96 overflow-y-auto bg-gray-50 rounded p-3 whitespace-pre-wrap border">
{detail.documentContent}
</div>
</Card>
)}
{/* 证据链 */}
<Card className="p-5">
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
<Shield className="w-4 h-4 text-primary" />
<span className="text-xs text-gray-400 font-normal">{evidence.length}</span>
</div>
{evidence.length === 0 ? (
<div className="text-xs text-gray-400 text-center py-4"></div>
) : (
<div className="space-y-3">
{evidence.map((ev: any, idx: number) => {
const events = ev.events as any[]
return (
<div key={ev.id} className="border rounded-md p-3">
<div className="text-xs text-gray-400 mb-2"> #{idx + 1} · {new Date(ev.createdAt).toLocaleString('zh-CN')}</div>
<div className="space-y-2">
{events?.map((event: any, i: number) => (
<div key={i} className="flex items-start gap-2 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 shrink-0" />
<div className="flex-1">
<div className="text-gray-700 font-medium">{event.action}</div>
<div className="text-gray-400 mt-0.5">
{new Date(event.timestamp).toLocaleString('zh-CN')}
{event.ip && ` · IP: ${event.ip}`}
{event.location && ` · ${event.location}`}
</div>
</div>
</div>
))}
</div>
<div className="text-xs text-gray-300 mt-2 font-mono">hash: {ev.hash?.slice(0, 32)}...</div>
</div>
)
})}
</div>
)}
</Card>
</div>
)
}
/**
* Tab
*/
function PendingTab({ pendingList, loading, onRemind, remindLoading, remindData, onDetail }: {
pendingList: any[]
loading: boolean
onRemind: (employeeId: string) => void
remindLoading: boolean
remindData: any
onDetail: (id: string) => void
}) {
const queryClient = useQueryClient()
const [expandedEmp, setExpandedEmp] = useState<string | null>(null)
const [remindEmpId, setRemindEmpId] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [signDateEditing, setSignDateEditing] = useState<string | null>(null)
const [signDateValue, setSignDateValue] = useState('')
const [signDateSaving, setSignDateSaving] = useState(false)
/** 保存签署日期 */
const handleSaveSignDate = async (contractId: string) => {
if (!signDateValue) { toast.error('请选择签署日期'); return }
setSignDateSaving(true)
try {
await esignApi.signDate(contractId, signDateValue)
toast.success('签署日期已登记')
setSignDateEditing(null)
setSignDateValue('')
queryClient.invalidateQueries({ queryKey: ['esign-pending'] })
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '登记失败')
} finally {
setSignDateSaving(false)
}
}
const handleCopy = (url: string) => {
navigator.clipboard?.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (loading) {
return <Card><div className="text-center py-8 text-gray-400">...</div></Card>
}
if (pendingList.length === 0) {
return (
<Card>
<div className="text-center py-8 text-gray-400 text-sm">
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-green-400" />
</div>
</Card>
)
}
return (
<>
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 px-3 text-left w-8"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-center"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
</thead>
<tbody>
{pendingList.map((emp: any) => {
const expanded = expandedEmp === emp.employeeId
return (
<Fragment key={emp.employeeId}>
<tr className="border-b last:border-0 hover:bg-gray-50 cursor-pointer"
onClick={() => setExpandedEmp(expanded ? null : emp.employeeId)}
>
<td className="py-2 px-3">
{expanded ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
</td>
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<User className="w-3.5 h-3.5 text-primary" />
</div>
<span className="font-medium">{emp.name}</span>
</div>
</td>
<td className="py-2 px-3 text-gray-500">{emp.department || '—'}</td>
<td className="py-2 px-3 text-gray-500">{emp.phone || '—'}</td>
<td className="py-2 px-3">
<div className="flex flex-wrap gap-1">
{emp.pendingItems.map((item: any, idx: number) => {
const sceneCfg = SCENE_CONFIG[item.scene]
const methodCfg = SIGN_METHOD_CONFIG[item.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC
return (
<span key={idx} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs ${sceneCfg ? sceneCfg.color : 'bg-gray-100 text-gray-600'}`} title={item.title}>
{sceneCfg ? sceneCfg.label : item.title}
<span className={`px-1 rounded text-[10px] ${methodCfg.color}`}>{methodCfg.label}</span>
{item.type === 'contract' && <span className="text-[10px] text-gray-400"></span>}
</span>
)
})}
</div>
</td>
<td className="py-2 px-3 text-center">
<span className="inline-flex items-center justify-center px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-700 font-medium">
{emp.pendingItems.length}
</span>
</td>
<td className="py-2 px-3 text-right" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="secondary"
onClick={() => {
setRemindEmpId(emp.employeeId)
onRemind(emp.employeeId)
}}
disabled={remindLoading && remindEmpId === emp.employeeId}
>
<Bell className="w-3.5 h-3.5 mr-1" />
{remindLoading && remindEmpId === emp.employeeId ? '生成中...' : '催办'}
</Button>
</td>
</tr>
{/* 展开行:待签文件列表 */}
{expanded && (
<tr className="bg-gray-50/50">
<td></td>
<td colSpan={5} className="py-2 px-3">
<div className="space-y-1.5">
{emp.pendingItems.map((item: any, idx: number) => (
<div key={idx} className="flex items-center gap-2 px-3 py-2 bg-white rounded-md border text-xs">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="font-medium">{item.title}</span>
{SCENE_CONFIG[item.scene] && (
<span className={`px-1.5 py-0.5 rounded ${SCENE_CONFIG[item.scene].color}`}>{SCENE_CONFIG[item.scene].label}</span>
)}
<span className={`px-1.5 py-0.5 rounded ${(SIGN_METHOD_CONFIG[item.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
{(SIGN_METHOD_CONFIG[item.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
</span>
<span className="text-gray-400">{new Date(item.createdAt).toLocaleDateString('zh-CN')}</span>
{item.type === 'esign' && item.recordId && (
<button className="text-primary hover:underline ml-auto" onClick={() => onDetail(item.recordId)}>
</button>
)}
{item.type === 'contract' && (
<div className="ml-auto flex items-center gap-2">
{signDateEditing === item.contractId ? (
<>
<input
type="date"
max={new Date().toISOString().slice(0, 10)}
value={signDateValue}
onChange={(e) => setSignDateValue(e.target.value)}
className="h-7 px-2 text-xs border rounded"
/>
<button
className="text-xs text-primary hover:underline"
disabled={signDateSaving}
onClick={() => handleSaveSignDate(item.contractId)}
>
{signDateSaving ? '保存中...' : '保存'}
</button>
<button
className="text-xs text-gray-400 hover:text-gray-600"
onClick={() => { setSignDateEditing(null); setSignDateValue('') }}
>
</button>
</>
) : (
<>
<span className="text-gray-400"></span>
<button
className="text-xs text-primary hover:underline"
onClick={() => {
setSignDateEditing(item.contractId)
setSignDateValue(new Date().toISOString().slice(0, 10))
}}
>
</button>
</>
)}
</div>
)}
</div>
))}
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
</Card>
{/* 催办结果弹窗:二维码 + 链接 */}
{remindData && remindEmpId && (
<Modal open onClose={() => { setRemindEmpId(null); }} title="催办 — 发送给员工" size="sm">
<div className="py-4">
<div className="flex flex-col items-center">
<div className="p-4 bg-white rounded-xl border-2 border-gray-100 shadow-sm">
<QRCodeSVG value={remindData.url} size={200} level="M" />
</div>
<p className="mt-3 text-xs text-gray-600 text-center">
<span className="font-medium">{remindData.employeeName}</span>
</p>
<p className="mt-1 text-xs text-gray-400"> 24 </p>
<div className="mt-3 w-full">
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg">
<span className="text-xs text-gray-500 flex-1 truncate">{remindData.url}</span>
<button
onClick={() => handleCopy(remindData.url)}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 flex-shrink-0"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? '已复制' : '复制'}
</button>
</div>
</div>
</div>
</div>
</Modal>
)}
</>
)
}
+2
View File
@@ -10,6 +10,7 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input' import { Input, Label, Select } from '../components/ui/Input'
import EmptyState from '../components/ui/EmptyState' import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination' import Pagination from '../components/ui/Pagination'
import PageGuide from '../components/ui/PageGuide'
const LEAVE_TYPE_MAP: Record<string, string> = { const LEAVE_TYPE_MAP: Record<string, string> = {
SICK: '病假', SICK: '病假',
@@ -75,6 +76,7 @@ export default function LeaveApproval() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageGuide> HR </PageGuide>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CalendarClock className="w-5 h-5 text-primary" /> <CalendarClock className="w-5 h-5 text-primary" />
<div> <div>
+12 -11
View File
@@ -1,33 +1,35 @@
import { useState, lazy, Suspense } from 'react' import { useState, Suspense } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { Layers, Wallet, LayoutTemplate, Clock, Receipt, Loader2 } from 'lucide-react' import { Layers, Wallet, LayoutTemplate, Receipt, Loader2 } from 'lucide-react'
import PageGuide from '../components/ui/PageGuide'
import { lazyRetry } from '../lib/lazyRetry'
const BatchManager = lazy(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager }))) const BatchManager = lazyRetry(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager })))
const TemplateManager = lazy(() => import('./money/TemplateTab').then(m => ({ default: m.TemplateManager }))) const TemplateManager = lazyRetry(() => import('./money/TemplateTab').then(m => ({ default: m.TemplateManager })))
const OvertimeCalculator = lazy(() => import('./money/OvertimeTab').then(m => ({ default: m.OvertimeCalculator }))) const PayslipManager = lazyRetry(() => import('./money/PayslipTab').then(m => ({ default: m.PayslipManager })))
const PayslipManager = lazy(() => import('./money/PayslipTab').then(m => ({ default: m.PayslipManager })))
type Tab = 'batch' | 'template' | 'overtime' | 'payslip' type Tab = 'batch' | 'template' | 'payslip'
export default function Money() { export default function Money() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const initialEmployeeId = searchParams.get('employeeId') || '' const initialEmployeeId = searchParams.get('employeeId') || ''
const [tab, setTab] = useState<Tab>(initialEmployeeId ? 'payslip' : 'batch') const initialTab = (searchParams.get('tab') as Tab) || (initialEmployeeId ? 'payslip' : 'batch')
const [tab, setTab] = useState<Tab>(initialTab)
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [ const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> }, { key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
{ key: 'template', label: '薪酬模版', icon: <LayoutTemplate className="w-4 h-4" /> }, { key: 'template', label: '薪酬模版', icon: <LayoutTemplate className="w-4 h-4" /> },
{ key: 'overtime', label: '加班费计算', icon: <Clock className="w-4 h-4" /> },
{ key: 'payslip', label: '工资条管理', icon: <Receipt className="w-4 h-4" /> }, { key: 'payslip', label: '工资条管理', icon: <Receipt className="w-4 h-4" /> },
] ]
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageGuide> </PageGuide>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Wallet className="w-5 h-5 text-primary" /> <Wallet className="w-5 h-5 text-primary" />
<div> <div>
<h1 className="text-base font-semibold"></h1> <h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p> <p className="mt-1 text-sm text-gray-500"></p>
</div> </div>
</div> </div>
@@ -49,7 +51,6 @@ export default function Money() {
<Suspense fallback={<div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>}> <Suspense fallback={<div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>}>
{tab === 'batch' && <BatchManager />} {tab === 'batch' && <BatchManager />}
{tab === 'template' && <TemplateManager />} {tab === 'template' && <TemplateManager />}
{tab === 'overtime' && <OvertimeCalculator />}
{tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />} {tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />}
</Suspense> </Suspense>
</div> </div>
+2
View File
@@ -8,6 +8,7 @@ import Card from '../components/ui/Card'
import Button from '../components/ui/Button' import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState' import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination' import Pagination from '../components/ui/Pagination'
import PageGuide from '../components/ui/PageGuide'
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
CONTRACT_EXPIRY: '合同到期', CONTRACT_EXPIRY: '合同到期',
@@ -62,6 +63,7 @@ export default function Notifications() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>/</PageGuide>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+319
View File
@@ -0,0 +1,319 @@
/**
*
* +
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Plus, Edit, Trash2, ChevronRight, ChevronDown, Building2, Briefcase, Shield } from 'lucide-react'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import PageGuide from '../components/ui/PageGuide'
import api from '../lib/api'
export default function OrgChart() {
const queryClient = useQueryClient()
const [tab, setTab] = useState<'dept' | 'position'>('dept')
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<any>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [form, setForm] = useState<any>({ name: '', parentId: '', sortOrder: 0, description: '' })
const { data: departments = [], isLoading } = useQuery({
queryKey: ['departments'],
queryFn: () => api.get('/departments').then(r => r.data),
})
const { data: positions = [] } = useQuery({
queryKey: ['positions'],
queryFn: () => api.get('/positions').then(r => r.data),
enabled: tab === 'position',
})
const createMutation = useMutation({
mutationFn: (data: any) => api.post(editing ? `/departments/${editing.id}` : '/departments', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['departments'] })
setShowModal(false)
toast.success(editing ? '部门已更新' : '部门已创建')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/departments/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['departments'] })
toast.success('部门已删除')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
const toggleExpand = (id: string) => {
setExpanded(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const renderTree = (items: any[], parentId: string | null, level: number = 0) => {
return items.filter(d => d.parentId === parentId).map(d => {
// 根部门(level=0 且无父部门)为公司节点,不可编辑/删除
const isRoot = d.level === 0 && !d.parentId
return (
<div key={d.id}>
<div
className={`flex items-center gap-2 py-2 px-3 rounded-md ${isRoot ? 'bg-primary/5 font-medium' : 'hover:bg-gray-50'}`}
style={{ marginLeft: level * 24 }}
>
{items.some(c => c.parentId === d.id) ? (
<button onClick={() => toggleExpand(d.id)} className="text-gray-400 hover:text-gray-600">
{expanded.has(d.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
) : (
<span className="w-4" />
)}
<Building2 className={`w-4 h-4 ${isRoot ? 'text-primary' : 'text-gray-400'}`} />
<span className="flex-1 text-sm">{d.name}</span>
{isRoot && <span className="text-xs text-primary"></span>}
{d.level === 0 && !isRoot && (
<span className="flex items-center gap-0.5 text-xs text-gray-400" title="分公司/子公司可在设置-社保公积金账户中单独关联账户">
<Shield className="w-3 h-3" />
</span>
)}
<span className="text-xs text-gray-400">{d._count?.employees || 0}</span>
{!isRoot && (
<>
<button
onClick={() => {
setEditing(d)
setForm({ name: d.name, parentId: d.parentId || '', sortOrder: d.sortOrder, description: d.description || '' })
setShowModal(true)
}}
className="text-gray-400 hover:text-primary"
>
<Edit className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { if (confirm(`确认删除部门"${d.name}"`)) deleteMutation.mutate(d.id) }}
className="text-gray-400 hover:text-danger"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
{expanded.has(d.id) && renderTree(items, d.id, level + 1)}
</div>
)
})
}
return (
<div className="space-y-4">
<PageGuide>使</PageGuide>
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
</div>
<div className="flex gap-2 border-b">
<button
onClick={() => setTab('dept')}
className={`px-4 py-2 text-sm font-medium border-b-2 ${tab === 'dept' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
>
</button>
<button
onClick={() => setTab('position')}
className={`px-4 py-2 text-sm font-medium border-b-2 ${tab === 'position' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
>
</button>
</div>
{tab === 'dept' ? (
<Card className="p-4">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium"></span>
<Button size="sm" onClick={() => {
setEditing(null)
// 默认父部门为根部门(level=0 且 parentId=null
const root = departments.find((d: any) => d.level === 0 && !d.parentId)
setForm({ name: '', parentId: root?.id || '', sortOrder: 0, description: '' })
setShowModal(true)
}}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400 text-sm">...</div>
) : departments.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="space-y-1">{renderTree(departments, null)}</div>
)}
</Card>
) : (
<PositionTab positions={positions} departments={departments} />
)}
{showModal && (
<Modal open onClose={() => setShowModal(false)} title={editing ? '编辑部门' : '新增部门'}>
<div className="space-y-3">
{editing?.level === 0 && (
<div className="flex items-start gap-2 p-2 bg-blue-50 rounded text-xs text-blue-700">
<Shield className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>/ </span>
</div>
)}
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:技术部" />
</div>
<div>
<Label></Label>
<Select value={form.parentId} onChange={(e) => setForm({ ...form, parentId: e.target.value })}>
{departments.filter((d: any) => d.id !== editing?.id).map((d: any) => (
<option key={d.id} value={d.id}>
{d.level === 0 && !d.parentId ? `${d.name}(公司)` : d.name}
</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input type="number" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="选填" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowModal(false)}></Button>
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name}>
{editing ? '保存' : '创建'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
/** 岗位字典子组件 */
function PositionTab({ positions, departments }: { positions: any[]; departments: any[] }) {
const queryClient = useQueryClient()
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<any>(null)
const [form, setForm] = useState<any>({ name: '', departmentId: '', headcount: 0, level: '', description: '' })
const createMutation = useMutation({
mutationFn: (data: any) => api.post(editing ? `/positions/${editing.id}` : '/positions', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['positions'] })
setShowModal(false)
toast.success(editing ? '岗位已更新' : '岗位已创建')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/positions/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['positions'] })
toast.success('岗位已删除')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
return (
<Card className="p-4">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium"></span>
<Button size="sm" onClick={() => {
setEditing(null)
setForm({ name: '', departmentId: '', headcount: 0, level: '', description: '' })
setShowModal(true)
}}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{positions.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="space-y-1">
{positions.map(p => (
<div key={p.id} className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 rounded-md">
<Briefcase className="w-4 h-4 text-primary" />
<span className="flex-1 text-sm">{p.name}</span>
{p.department && <span className="text-xs text-gray-400">{p.department.name}</span>}
{p.level && <span className="text-xs text-gray-400">{p.level}</span>}
<span className="text-xs text-gray-400">{p.headcount}</span>
<button
onClick={() => {
setEditing(p)
setForm({ name: p.name, departmentId: p.departmentId || '', headcount: p.headcount, level: p.level || '', description: p.description || '' })
setShowModal(true)
}}
className="text-gray-400 hover:text-primary"
>
<Edit className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { if (confirm(`确认删除岗位"${p.name}"`)) deleteMutation.mutate(p.id) }}
className="text-gray-400 hover:text-danger"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{showModal && (
<Modal open onClose={() => setShowModal(false)} title={editing ? '编辑岗位' : '新增岗位'}>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:前端工程师" />
</div>
<div>
<Label></Label>
<Select value={form.departmentId} onChange={(e) => setForm({ ...form, departmentId: e.target.value })}>
<option value=""></option>
{departments.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.headcount} onChange={(e) => setForm({ ...form, headcount: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input value={form.level} onChange={(e) => setForm({ ...form, level: e.target.value })} placeholder="如 P1/M1" />
</div>
</div>
<div>
<Label></Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="选填" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowModal(false)}></Button>
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name}>
{editing ? '保存' : '创建'}
</Button>
</div>
</div>
</Modal>
)}
</Card>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { Suspense } from 'react'
import { Loader2 } from 'lucide-react'
import { lazyRetry } from '../lib/lazyRetry'
const OvertimeCalculator = lazyRetry(() => import('./money/OvertimeTab').then(m => ({ default: m.OvertimeCalculator })))
/**
*
*
*/
export default function Overtime() {
return (
<Suspense fallback={<div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>}>
<OvertimeCalculator />
</Suspense>
)
}
+566 -97
View File
@@ -5,8 +5,9 @@ import { toast } from 'sonner'
import { toastError } from '../lib/errorToast' import { toastError } from '../lib/errorToast'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm' import { useConfirm } from '../hooks/useConfirm'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2 } from 'lucide-react' import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Download, Phone, MapPin, Search, Settings2, CheckCircle, FileText } from 'lucide-react'
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services' import { rosterApi, employeeApi, terminationApi, workProcessApi } from '../lib/api-services'
import api from '../lib/api'
import { copyToClipboard } from '../lib/clipboard' import { copyToClipboard } from '../lib/clipboard'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import { useDebouncedValue } from '../hooks/useDebouncedValue' import { useDebouncedValue } from '../hooks/useDebouncedValue'
@@ -19,7 +20,8 @@ import { fmt, terminateReasonMap } from './roster/shared'
import PageGuide from '../components/ui/PageGuide' import PageGuide from '../components/ui/PageGuide'
import EmployeeProfile from './roster/EmployeeProfile' import EmployeeProfile from './roster/EmployeeProfile'
import { InlineAlert } from '../components/ui/InlineAlert' import { InlineAlert } from '../components/ui/InlineAlert'
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals' import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal, ConfirmModal } from './roster/modals'
import SignMethodChoice from '../components/ui/SignMethodChoice'
import { ImportSettings } from './Settings' import { ImportSettings } from './Settings'
import QueryError from '../components/ui/QueryError' import QueryError from '../components/ui/QueryError'
@@ -77,22 +79,54 @@ export default function Roster() {
const [salaryEmployee, setSalaryEmployee] = useState<any>(null) const [salaryEmployee, setSalaryEmployee] = useState<any>(null)
const [showDeptModal, setShowDeptModal] = useState(false) const [showDeptModal, setShowDeptModal] = useState(false)
const [deptEmployee, setDeptEmployee] = useState<any>(null) const [deptEmployee, setDeptEmployee] = useState<any>(null)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [confirmEmployee, setConfirmEmployee] = useState<any>(null)
// 开具证明弹窗
const [showCertModal, setShowCertModal] = useState(false)
const [certEmployee, setCertEmployee] = useState<any>(null)
const [certPurpose, setCertPurpose] = useState('')
const [certType, setCertType] = useState('INCOME_CERT')
// 续签合同弹窗
const [showRenewModal, setShowRenewModal] = useState(false)
const [renewEmployee, setRenewEmployee] = useState<any>(null)
const [renewNewStartDate, setRenewNewStartDate] = useState('')
const [renewNewEndDate, setRenewNewEndDate] = useState('')
const [renewNewSalary, setRenewNewSalary] = useState('')
const pageSize = usePageSize() const pageSize = usePageSize()
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false) const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
// 签署方式选择弹窗
const [signChoice, setSignChoice] = useState<{
open: boolean
employeeId: string
employeeName: string
scene: 'CONTRACT' | 'RESIGNATION' | 'POLICY' | 'PAYSLIP' | 'ONBOARDING'
documentTitle: string
contractId?: string
remark?: string
actionName: string
} | null>(null)
const [batchRenewYears, setBatchRenewYears] = useState(3) const [batchRenewYears, setBatchRenewYears] = useState(3)
const [previewData, setPreviewData] = useState<any>(null) const [previewData, setPreviewData] = useState<any>(null)
const [filterStatus, setFilterStatus] = useState('') const [filterStatus, setFilterStatus] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('') const [filterContractStatus, setFilterContractStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('') const [filterDepartment, setFilterDepartment] = useState('')
const [filterPosition, setFilterPosition] = useState('')
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false) const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10)) const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED') const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null) const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
// 批量转正弹窗
const [showBatchConfirmModal, setShowBatchConfirmModal] = useState(false)
const [batchConfirmDate, setBatchConfirmDate] = useState(new Date().toISOString().slice(0, 10))
const [batchConfirmSalary, setBatchConfirmSalary] = useState('')
// 批量开具证明弹窗
const [showBatchCertModal, setShowBatchCertModal] = useState(false)
const [batchCertType, setBatchCertType] = useState('INCOME_CERT')
const { data: rosterData, isLoading, isError, error, refetch } = useQuery<any>({ const { data: rosterData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment], queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment, filterPosition],
queryFn: async () => { queryFn: async () => {
const params: any = { page, pageSize } const params: any = { page, pageSize }
if (debouncedSearch) params.search = debouncedSearch if (debouncedSearch) params.search = debouncedSearch
@@ -100,7 +134,8 @@ export default function Roster() {
if (filterStatus === 'PROBATION') params.status = 'ACTIVE' if (filterStatus === 'PROBATION') params.status = 'ACTIVE'
if (filterContractStatus) params.contractStatus = filterContractStatus if (filterContractStatus) params.contractStatus = filterContractStatus
if (filterDepartment) params.department = filterDepartment if (filterDepartment) params.department = filterDepartment
const res = await rosterApi.list({ search: debouncedSearch, page, pageSize, status: filterStatus === 'PROBATION' ? 'ACTIVE' : filterStatus || undefined, contractStatus: filterContractStatus || undefined, department: filterDepartment || undefined } as any) as any if (filterPosition) params.position = filterPosition
const res = await rosterApi.list({ search: debouncedSearch, page, pageSize, status: filterStatus === 'PROBATION' ? 'ACTIVE' : filterStatus || undefined, contractStatus: filterContractStatus || undefined, department: filterDepartment || undefined, position: filterPosition || undefined } as any) as any
return res return res
}, },
}) })
@@ -132,16 +167,17 @@ export default function Roster() {
}, [employeeParam, employeeIdParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams]) }, [employeeParam, employeeIdParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
const addMutation = useMutation({ const addMutation = useMutation({
mutationFn: (data: any) => employeeApi.create(data), mutationFn: async (data: any) => {
const res = await employeeApi.create(data)
return res
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
localStorage.removeItem('add-employee-draft') localStorage.removeItem('add-employee-draft')
setShowAddModal(false) setShowAddModal(false)
toast.success('员工已添加', { toast.success('员工已添加,请前往员工档案签订合同')
action: { label: '前往用工办理', onClick: () => navigate('/work-process') },
})
}, },
onError: (err: any) => toastError(err, '创建失败'), onError: (err: any) => toastError(err, '创建失败'),
}) })
@@ -160,8 +196,9 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] }) queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程', { queryClient.invalidateQueries({ queryKey: ['esign-records'] })
action: { label: '前往处理', onClick: () => navigate('/termination') }, toast.success('已创建离职草稿,离职协议电子签署已自动发起', {
action: { label: '查看签署', onClick: () => navigate('/esign') },
}) })
setShowResignModal(false) setShowResignModal(false)
setResignEmployee(null) setResignEmployee(null)
@@ -184,6 +221,16 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowRehireModal(false) setShowRehireModal(false)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: rehireEmployee?.id || '',
employeeName: rehireEmployee?.name || '',
scene: 'CONTRACT',
documentTitle: `${rehireEmployee?.name || ''}的劳动合同`,
remark: '重新入职时发起',
actionName: '重新入职',
})
setRehireEmployee(null) setRehireEmployee(null)
}, },
}) })
@@ -195,6 +242,16 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowSalaryModal(false) setShowSalaryModal(false)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: salaryEmployee?.id || '',
employeeName: salaryEmployee?.name || '',
scene: 'POLICY',
documentTitle: `${salaryEmployee?.name || ''}的薪酬调整确认书`,
remark: '薪酬变更时发起',
actionName: '薪酬变更',
})
setSalaryEmployee(null) setSalaryEmployee(null)
}, },
}) })
@@ -206,10 +263,50 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowDeptModal(false) setShowDeptModal(false)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: deptEmployee?.id || '',
employeeName: deptEmployee?.name || '',
scene: 'POLICY',
documentTitle: `${deptEmployee?.name || ''}的调岗确认书`,
remark: '部门/岗位调动时发起',
actionName: '调岗调动',
})
setDeptEmployee(null) setDeptEmployee(null)
}, },
}) })
// 转正 mutation
const confirmMutation = useMutation({
mutationFn: (data: { employeeId: string; confirmDate: string; regularSalary?: number }) =>
workProcessApi.create({ type: 'CONFIRM', title: '转正', employeeId: data.employeeId, formData: { employeeId: data.employeeId, confirmDate: data.confirmDate, regularSalary: data.regularSalary }, status: 'DRAFT' }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowConfirmModal(false)
const originalSalary = confirmEmployee?.monthlySalary ? Number(confirmEmployee.monthlySalary) : null
const newSalary = variables.regularSalary
// 薪资有变化时弹出签署方式选择
if (newSalary && newSalary !== originalSalary) {
setSignChoice({
open: true,
employeeId: variables.employeeId,
employeeName: confirmEmployee?.name || '',
scene: 'POLICY',
documentTitle: `${confirmEmployee?.name || ''}的转正薪酬调整确认书`,
remark: `转正薪资调整:¥${originalSalary || 0} → ¥${newSalary}`,
actionName: '转正调薪',
})
} else {
toast.success('转正已提交')
}
setConfirmEmployee(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '转正失败'),
})
const batchRenewMutation = useMutation({ const batchRenewMutation = useMutation({
mutationFn: (data: { contractIds: string[]; years: number }) => employeeApi.batchRenew(data), mutationFn: (data: { contractIds: string[]; years: number }) => employeeApi.batchRenew(data),
onSuccess: () => { onSuccess: () => {
@@ -264,6 +361,142 @@ export default function Roster() {
}, },
}) })
/** 开具证明:直接创建并提交工单 */
const certMutation = useMutation({
mutationFn: async (data: { employeeId: string; formData: Record<string, unknown> }) => {
const certTitleMap: Record<string, string> = {
INCOME_CERT: '开具收入证明',
EMPLOYMENT_CERT: '开具在职证明',
LEAVING_CERT: '开具离职证明',
}
const created: any = await workProcessApi.create({ type: certType, title: certTitleMap[certType] || '开具证明', employeeId: data.employeeId, formData: data.formData, status: 'DRAFT' })
if (created?.id) {
await workProcessApi.submit(created.id)
}
return created
},
onSuccess: () => {
toast.success('证明已开具,可在「证据管理」中查看和下载')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowCertModal(false)
setCertEmployee(null)
setCertPurpose('')
},
onError: (err: any) => toastError(err, '开具证明失败'),
})
/** 续签合同:直接创建并提交工单 */
const renewMutation = useMutation({
mutationFn: async (data: { employeeId: string; formData: Record<string, unknown> }) => {
const created: any = await workProcessApi.create({ type: 'RENEW', title: '合同续签', employeeId: data.employeeId, formData: data.formData, status: 'DRAFT' })
if (created?.id) {
await workProcessApi.submit(created.id)
}
return created
},
onSuccess: () => {
toast.success('合同续签已完成')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
setShowRenewModal(false)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: renewEmployee?.id || '',
employeeName: renewEmployee?.name || '',
scene: 'CONTRACT',
documentTitle: `${renewEmployee?.name || ''}的续签劳动合同`,
remark: '合同续签时发起',
actionName: '合同续签',
})
setRenewEmployee(null)
setRenewNewStartDate('')
setRenewNewEndDate('')
setRenewNewSalary('')
},
onError: (err: any) => toastError(err, '续签合同失败'),
})
/** 批量转正:为选中的试用期员工创建转正草稿 */
const handleBatchConfirm = async () => {
const probationEmployees = employees.filter((e: any) => selectedIds.has(e.id) && e.probationInfo?.isProbation)
if (probationEmployees.length === 0) {
toast.error('选中的员工中没有试用期员工')
return
}
const confirmDate = batchConfirmDate
let success = 0
let failed = 0
await Promise.all(
probationEmployees.map(async (emp: any) => {
try {
await workProcessApi.create({
type: 'CONFIRM',
title: '批量转正',
employeeId: emp.id,
formData: { employeeId: emp.id, confirmDate, salary: batchConfirmSalary || undefined },
status: 'DRAFT',
})
success++
} catch {
failed++
}
})
)
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
if (success > 0) toast.success(`已为 ${success} 名员工创建转正草稿`)
if (failed > 0) toast.error(`${failed} 名员工转正失败`)
setSelectedIds(new Set())
setShowBatchConfirmModal(false)
}
/** 批量开具证明:直接为选中员工创建并提交证明 */
const handleBatchCert = async () => {
if (selectedIds.size === 0) {
toast.error('请至少选择一名员工')
return
}
const certTitleMap: Record<string, string> = {
INCOME_CERT: '开具收入证明',
EMPLOYMENT_CERT: '开具在职证明',
LEAVING_CERT: '开具离职证明',
}
let success = 0
let failed = 0
for (const id of selectedIds) {
const emp = employees.find((e: any) => e.id === id)
if (!emp) continue
try {
const created: any = await workProcessApi.create({
type: batchCertType,
title: certTitleMap[batchCertType] || '开具证明',
employeeId: id,
formData: {
employeeId: id,
employeeName: emp.name,
idCardNumber: emp.idCardMasked || '',
position: emp.position || '',
monthlyIncome: emp.monthlySalary ? `¥${emp.monthlySalary}` : '',
purpose: '批量开具',
},
status: 'DRAFT',
})
if (created?.id) {
await workProcessApi.submit(created.id)
}
success++
} catch {
failed++
}
}
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
if (success > 0) toast.success(`已为 ${success} 名员工开具证明`)
if (failed > 0) toast.error(`${failed} 名员工开具证明失败`)
setSelectedIds(new Set())
setShowBatchCertModal(false)
}
const toggleSelect = (id: string) => { const toggleSelect = (id: string) => {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev) const next = new Set(prev)
@@ -286,10 +519,11 @@ export default function Roster() {
setFilterStatus('') setFilterStatus('')
setFilterContractStatus('') setFilterContractStatus('')
setFilterDepartment('') setFilterDepartment('')
setFilterPosition('')
setPage(1) setPage(1)
} }
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment || filterPosition
/** 全局合同风险统计(来自后端,与风险中心同口径) */ /** 全局合同风险统计(来自后端,与风险中心同口径) */
const globalRiskStats = rosterData?.globalRiskStats const globalRiskStats = rosterData?.globalRiskStats
@@ -318,6 +552,14 @@ export default function Roster() {
}, },
}) })
const { data: positionList } = useQuery<string[]>({
queryKey: ['roster-positions'],
queryFn: async () => {
const res = await api.get('/roster/positions')
return res.data as string[]
},
})
const { isVisible: colVisible, toggle: colToggle } = useRosterColumns() const { isVisible: colVisible, toggle: colToggle } = useRosterColumns()
const [showColSettings, setShowColSettings] = useState(false) const [showColSettings, setShowColSettings] = useState(false)
@@ -382,15 +624,20 @@ export default function Roster() {
<option value=""></option> <option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)} {departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select> </select>
<select
value={filterPosition}
onChange={(e) => { setFilterPosition(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{positionList?.map((p: string) => <option key={p} value={p}>{p}</option>)}
</select>
{hasActiveFilters && ( {hasActiveFilters && (
<button onClick={clearFilters} className="h-9 px-2 text-sm text-gray-400 transition hover:text-gray-700"></button> <button onClick={clearFilters} className="h-9 px-2 text-sm text-gray-400 transition hover:text-gray-700"></button>
)} )}
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0"> <Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> <Plus className="mr-1.5 h-4 w-4" />
</Button> </Button>
<Button variant="secondary" onClick={() => navigate('/work-process')} className="h-9 shrink-0" title="完整的入职办理流程">
<UserPlus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0"> <Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
<Upload className="mr-1.5 h-4 w-4" /> <Upload className="mr-1.5 h-4 w-4" />
</Button> </Button>
@@ -400,6 +647,7 @@ export default function Roster() {
if (debouncedSearch) params.set('search', debouncedSearch) if (debouncedSearch) params.set('search', debouncedSearch)
if (filterStatus) params.set('status', filterStatus) if (filterStatus) params.set('status', filterStatus)
if (filterDepartment) params.set('department', filterDepartment) if (filterDepartment) params.set('department', filterDepartment)
if (filterPosition) params.set('position', filterPosition)
if (filterContractStatus) params.set('contractStatus', filterContractStatus) if (filterContractStatus) params.set('contractStatus', filterContractStatus)
const token = useAuthStore.getState().accessToken const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1' const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
@@ -450,6 +698,12 @@ export default function Roster() {
<Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0"> <Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0">
<Check className="mr-1 h-4 w-4" /> <Check className="mr-1 h-4 w-4" />
</Button> </Button>
<Button variant="secondary" onClick={() => setShowBatchConfirmModal(true)} className="shrink-0">
<CheckCircle className="mr-1 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => setShowBatchCertModal(true)} className="shrink-0">
<FileText className="mr-1 h-4 w-4" />
</Button>
<Button variant="danger" onClick={() => setShowBatchTerminateModal(true)} className="shrink-0"> <Button variant="danger" onClick={() => setShowBatchTerminateModal(true)} className="shrink-0">
<UserX className="mr-1 h-4 w-4" /> <UserX className="mr-1 h-4 w-4" />
</Button> </Button>
@@ -529,26 +783,28 @@ export default function Roster() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{employees.map((e: any) => ( {employees.map((e: any, idx: number) => (
<tr <tr
key={e.id} key={e.id}
className="border-b border-gray-100 last:border-0 cursor-pointer transition-colors hover:bg-primary/[0.03]" className={`border-b border-gray-100 last:border-0 cursor-pointer transition-colors hover:bg-primary/[0.05] group ${idx % 2 === 1 ? 'bg-gray-100/60' : ''}`}
onClick={() => setSelectedId(e.id)} onClick={() => setSelectedId(e.id)}
> >
<td className="px-4 py-3" onClick={(ev) => ev.stopPropagation()}> <td className="px-4 py-2.5" onClick={(ev) => ev.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} /> <input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
</td> </td>
<td className="px-4 py-3 font-medium"> <td className="px-4 py-2.5">
<div>{e.name}</div> <div className="flex flex-col gap-0.5">
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整身份证号" onClick={(ev) => { <span className="font-medium text-gray-900 text-sm leading-tight">{e.name}</span>
ev.stopPropagation() <span className="text-gray-400 text-[11px] font-mono cursor-pointer hover:text-primary transition-colors leading-tight" title="点击复制完整证件号码" onClick={(ev) => {
if (e.idCardNumber) { ev.stopPropagation()
copyToClipboard(e.idCardNumber, '已复制身份证号') if (e.idCardNumber) {
} copyToClipboard(e.idCardNumber, '已复制证件号码')
}}>{e.idCardMasked || '—'}</div> }
}}>{e.idCardMasked || '—'}</span>
</div>
</td> </td>
{colVisible('department') && <td className="px-4 py-3 text-gray-500">{e.department}</td>} {colVisible('department') && <td className="px-4 py-2.5 text-gray-500">{e.department}</td>}
{colVisible('status') && <td className="px-4 py-3"> {colVisible('status') && <td className="px-4 py-2.5">
<span className={`px-2 py-0.5 rounded text-xs ${ <span className={`px-2 py-0.5 rounded text-xs ${
e.status === 'ACTIVE' ? 'bg-green-50 text-safe' e.status === 'ACTIVE' ? 'bg-green-50 text-safe'
: e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600' : e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600'
@@ -566,8 +822,8 @@ export default function Roster() {
</span> </span>
)} )}
</td>} </td>}
{colVisible('hireDate') && <td className="px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>} {colVisible('hireDate') && <td className="px-4 py-2.5 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>}
<td className="hidden px-4 py-3 text-gray-500"> <td className="hidden px-4 py-2.5 text-gray-500">
{e.hasTermination && e.latestTerminationDate && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' ? ( {e.hasTermination && e.latestTerminationDate && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' ? (
<span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}> <span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}>
{e.latestTerminationDate.toString().slice(0, 10)} {e.latestTerminationDate.toString().slice(0, 10)}
@@ -577,10 +833,10 @@ export default function Roster() {
<span className="text-gray-300"></span> <span className="text-gray-300"></span>
)} )}
</td> </td>
{colVisible('position') && <td className="px-4 py-3 text-gray-500">{e.position || '—'}</td>} {colVisible('position') && <td className="px-4 py-2.5 text-gray-500">{e.position || '—'}</td>}
{colVisible('phone') && <td className="px-4 py-3 text-gray-500">{e.phone || '—'}</td>} {colVisible('phone') && <td className="px-4 py-2.5 text-gray-500">{e.phone || '—'}</td>}
{colVisible('gender') && <td className="px-4 py-3 text-center text-gray-500">{e.gender || '—'}</td>} {colVisible('gender') && <td className="px-4 py-2.5 text-center text-gray-500">{e.gender || '—'}</td>}
{colVisible('contractType') && <td className="px-4 py-3"> {colVisible('contractType') && <td className="px-4 py-2.5">
{(() => { {(() => {
const typeConfig: Record<string, { label: string; style: string }> = { const typeConfig: Record<string, { label: string; style: string }> = {
FIXED: { label: '劳动合同-固定期', style: 'bg-blue-50 text-blue-700 border border-blue-200' }, FIXED: { label: '劳动合同-固定期', style: 'bg-blue-50 text-blue-700 border border-blue-200' },
@@ -597,7 +853,7 @@ export default function Roster() {
return <span className={`px-2 py-0.5 rounded text-xs ${cfg.style}`}>{cfg.label}</span> return <span className={`px-2 py-0.5 rounded text-xs ${cfg.style}`}>{cfg.label}</span>
})()} })()}
</td>} </td>}
{colVisible('contractStatus') && <td className="px-4 py-3"> {colVisible('contractStatus') && <td className="px-4 py-2.5">
{(() => { {(() => {
const tagStyles: Record<string, string> = { const tagStyles: Record<string, string> = {
expired: 'bg-red-50 text-danger', expired: 'bg-red-50 text-danger',
@@ -605,6 +861,7 @@ export default function Roster() {
unsigned_over_30: 'bg-red-50 text-danger', unsigned_over_30: 'bg-red-50 text-danger',
unsigned: 'bg-yellow-50 text-yellow-700', unsigned: 'bg-yellow-50 text-yellow-700',
expiring: 'bg-yellow-50 text-yellow-700', expiring: 'bg-yellow-50 text-yellow-700',
pending_sign: 'bg-yellow-50 text-yellow-700',
active: 'bg-green-50 text-safe', active: 'bg-green-50 text-safe',
unfixed: 'bg-green-50 text-safe', unfixed: 'bg-green-50 text-safe',
} }
@@ -614,6 +871,7 @@ export default function Roster() {
unsigned_over_30: '未签署(超30天)', unsigned_over_30: '未签署(超30天)',
unsigned: '未签署', unsigned: '未签署',
expiring: '即将到期', expiring: '即将到期',
pending_sign: '待签署',
active: '正常', active: '正常',
unfixed: '正常', unfixed: '正常',
} }
@@ -622,7 +880,7 @@ export default function Roster() {
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{text}</span> return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{text}</span>
})()} })()}
</td>} </td>}
{colVisible('contractExpiry') && <td className="px-4 py-3 text-gray-500"> {colVisible('contractExpiry') && <td className="px-4 py-2.5 text-gray-500">
{(() => { {(() => {
const endDate = e.latestContract?.endDate const endDate = e.latestContract?.endDate
if (!endDate) { if (!endDate) {
@@ -642,7 +900,7 @@ export default function Roster() {
return <span className="text-xs">{dateStr}</span> return <span className="text-xs">{dateStr}</span>
})()} })()}
</td>} </td>}
{colVisible('socialStatus') && <td className="px-4 py-3"> {colVisible('socialStatus') && <td className="px-4 py-2.5">
{(() => { {(() => {
const status = e.socialInsuranceStatus const status = e.socialInsuranceStatus
if (!status) return <span className="text-gray-300 text-xs"></span> if (!status) return <span className="text-gray-300 text-xs"></span>
@@ -656,12 +914,12 @@ export default function Roster() {
return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span> return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span>
})()} })()}
</td>} </td>}
{colVisible('socialInsBase') && <td className="px-4 py-3 text-right text-xs"> {colVisible('socialInsBase') && <td className="px-4 py-2.5 text-right text-xs">
{e.socialInsBase ? e.socialInsBase.toLocaleString() : <span className="text-gray-300"></span>} {e.socialInsBase ? e.socialInsBase.toLocaleString() : <span className="text-gray-300"></span>}
</td>} </td>}
{colVisible('socialInsAmount') && <td className="px-4 py-3 text-right text-xs"> {colVisible('socialInsAmount') && <td className="px-4 py-2.5 text-right">
{(() => { {(() => {
if (!e.socialInsCalc && !e.housingFundCalc) return <span className="text-gray-300"></span> if (!e.socialInsCalc && !e.housingFundCalc) return <span className="text-gray-300 text-xs"></span>
const socialEmp = e.socialInsCalc?.socialEmp || 0 const socialEmp = e.socialInsCalc?.socialEmp || 0
const socialOrg = e.socialInsCalc?.socialOrg || 0 const socialOrg = e.socialInsCalc?.socialOrg || 0
const housingEmp = e.housingFundCalc?.housingEmp || 0 const housingEmp = e.housingFundCalc?.housingEmp || 0
@@ -669,14 +927,14 @@ export default function Roster() {
const totalEmp = socialEmp + housingEmp const totalEmp = socialEmp + housingEmp
const totalOrg = socialOrg + housingOrg const totalOrg = socialOrg + housingOrg
return ( return (
<div className="flex flex-col"> <div className="flex flex-col gap-0.5 items-end">
<span>: {totalEmp.toFixed(2)}</span> <span className="text-xs text-gray-700 leading-tight"> {totalEmp.toFixed(2)}</span>
<span className="text-gray-400">: {totalOrg.toFixed(2)}</span> <span className="text-[11px] text-gray-400 leading-tight"> {totalOrg.toFixed(2)}</span>
</div> </div>
) )
})()} })()}
</td>} </td>}
{colVisible('records') && <td className="px-4 py-3 text-center"> {colVisible('records') && <td className="px-4 py-2.5 text-center">
<div className="flex items-center justify-center gap-1 flex-wrap"> <div className="flex items-center justify-center gap-1 flex-wrap">
{(() => { {(() => {
const items = [ const items = [
@@ -697,60 +955,109 @@ export default function Roster() {
})()} })()}
</div> </div>
</td>} </td>}
<td className="px-4 py-3 text-center"> <td className="px-3 py-2 text-center">
{e.status === 'ACTIVE' && (!e.hasTermination || e.latestTerminationStatus === 'CANCELLED' || e.latestTerminationStatus === 'COMPLETED') && ( {e.status === 'ACTIVE' && (!e.hasTermination || e.latestTerminationStatus === 'CANCELLED' || e.latestTerminationStatus === 'COMPLETED') && (
<div className="flex items-center justify-center gap-2"> <div className="flex flex-col items-center gap-1.5">
<button <div className="flex items-center justify-center gap-0.5">
type="button" <button
title="调薪" type="button"
aria-label={`${e.name}调薪`} title="调薪"
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary" aria-label={`${e.name}调薪`}
onClick={(ev) => { className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
ev.stopPropagation() onClick={(ev) => {
setSalaryEmployee(e) ev.stopPropagation()
setShowSalaryModal(true) setSalaryEmployee(e)
}} setShowSalaryModal(true)
> }}
<DollarSign className="h-4 w-4" /> >
</button> <DollarSign className="h-4 w-4" />
<button </button>
type="button" {/* 试用期员工显示转正按钮 */}
title="发薪" {e.probationInfo?.isProbation && (
aria-label={`${e.name}发薪`} <button
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary" type="button"
onClick={(ev) => { title="转正"
ev.stopPropagation() aria-label={`${e.name}办理转正`}
navigate('/money') className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
}} onClick={(ev) => {
> ev.stopPropagation()
<Wallet className="h-4 w-4" /> setConfirmEmployee(e)
</button> setShowConfirmModal(true)
<button }}
type="button" >
title="调部门" <CheckCircle className="h-4 w-4" />
aria-label={`${e.name}调整部门`} </button>
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary" )}
onClick={(ev) => { <button
ev.stopPropagation() type="button"
setDeptEmployee(e) title="调动"
setShowDeptModal(true) aria-label={`${e.name}调动`}
}} className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
> onClick={(ev) => {
<Building2 className="h-4 w-4" /> ev.stopPropagation()
</button> setDeptEmployee(e)
<button setShowDeptModal(true)
type="button" }}
title="离职" >
aria-label={`${e.name}办理离职`} <Building2 className="h-4 w-4" />
className="rounded-md p-1.5 text-gray-500 transition hover:bg-danger/10 hover:text-danger" </button>
onClick={(ev) => { </div>
ev.stopPropagation() <div className="flex items-center justify-center gap-0.5 border-t border-gray-100 pt-1.5">
setResignEmployee(e) <button
setShowResignModal(true) type="button"
}} title="开具证明"
> aria-label={`${e.name}开具证明`}
<UserX className="h-4 w-4" /> className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
</button> onClick={(ev) => {
ev.stopPropagation()
setCertEmployee(e)
setCertPurpose('')
setCertType('INCOME_CERT')
setShowCertModal(true)
}}
>
<FileText className="h-4 w-4" />
</button>
{(e.contractStatus === 'expiring' || e.contractStatus === 'expired') && (
<button
type="button"
title="续签合同"
aria-label={`${e.name}续签合同`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
// 自动推导新合同开始日期:原合同结束日 + 1天
const oldEndDate = e.latestContract?.endDate
let newStart = new Date().toISOString().slice(0, 10)
if (oldEndDate) {
const d = new Date(oldEndDate)
d.setDate(d.getDate() + 1)
newStart = d.toISOString().slice(0, 10)
}
setRenewEmployee(e)
setRenewNewStartDate(newStart)
setRenewNewEndDate('')
setRenewNewSalary(e.monthlySalary ? String(e.monthlySalary) : '')
setShowRenewModal(true)
}}
>
<RotateCcw className="h-4 w-4" />
</button>
)}
<button
type="button"
title="离职"
aria-label={`${e.name}办理离职`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-danger/10 hover:text-danger"
onClick={(ev) => {
ev.stopPropagation()
setResignEmployee(e)
setShowResignModal(true)
}}
>
<UserX className="h-4 w-4" />
</button>
</div>
</div> </div>
)} )}
{e.hasTermination && e.status === 'ACTIVE' && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' && ( {e.hasTermination && e.status === 'ACTIVE' && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' && (
@@ -865,6 +1172,153 @@ export default function Roster() {
/> />
)} )}
{showConfirmModal && confirmEmployee && (
<ConfirmModal
employee={confirmEmployee}
onClose={() => { setShowConfirmModal(false); setConfirmEmployee(null) }}
onSubmit={(data) => confirmMutation.mutate(data)}
loading={confirmMutation.isPending}
error={confirmMutation.error as any}
/>
)}
{/* 开具证明弹窗 */}
{showCertModal && certEmployee && (
<Modal open={showCertModal} onClose={() => { setShowCertModal(false); setCertEmployee(null) }} title={`${certEmployee.name} 开具证明`}>
<div className="space-y-3">
<div>
<Label></Label>
<Select value={certType} onChange={(e) => setCertType(e.target.value)}>
<option value="INCOME_CERT"></option>
<option value="EMPLOYMENT_CERT"></option>
<option value="LEAVING_CERT"></option>
</Select>
</div>
<div className="text-xs text-gray-500">
</div>
<div>
<Label></Label>
<Input value={certPurpose} onChange={(e) => setCertPurpose(e.target.value)} placeholder="如:办理签证、租房、贷款等" />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowCertModal(false); setCertEmployee(null) }}></Button>
<Button
disabled={certMutation.isPending}
onClick={() => {
certMutation.mutate({
employeeId: certEmployee.id,
formData: {
employeeId: certEmployee.id,
employeeName: certEmployee.name,
idCardNumber: certEmployee.idCardMasked || '',
position: certEmployee.position || '',
monthlyIncome: certEmployee.monthlySalary ? `¥${certEmployee.monthlySalary}` : '',
purpose: certPurpose || '通用',
},
})
}}
></Button>
</div>
</div>
</Modal>
)}
{/* 续签合同弹窗 */}
{showRenewModal && renewEmployee && (
<Modal open={showRenewModal} onClose={() => { setShowRenewModal(false); setRenewEmployee(null) }} title={`${renewEmployee.name} 续签合同`}>
<div className="space-y-3">
<div className="text-xs text-gray-500">
{renewEmployee.latestContract?.endDate ? new Date(renewEmployee.latestContract.endDate).toISOString().slice(0, 10) : '无'}
</div>
<div>
<Label> *</Label>
<Input type="date" value={renewNewStartDate} onChange={(e) => setRenewNewStartDate(e.target.value)} />
</div>
<div>
<Label></Label>
<Input type="date" value={renewNewEndDate} onChange={(e) => setRenewNewEndDate(e.target.value)} placeholder="留空表示无固定期限" />
</div>
<div>
<Label></Label>
<Input type="number" value={renewNewSalary} onChange={(e) => setRenewNewSalary(e.target.value)} placeholder="续签后月薪" />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowRenewModal(false); setRenewEmployee(null) }}></Button>
<Button
disabled={renewMutation.isPending}
onClick={() => {
if (!renewNewStartDate) {
toast.error('请填写新合同开始日期')
return
}
if (renewNewEndDate && renewNewEndDate < renewNewStartDate) {
toast.error('结束日期不能早于开始日期')
return
}
renewMutation.mutate({
employeeId: renewEmployee.id,
formData: {
employeeId: renewEmployee.id,
oldContractId: renewEmployee.latestContract?.id || '',
newStartDate: renewNewStartDate,
newEndDate: renewNewEndDate || undefined,
newSalary: renewNewSalary || undefined,
},
})
}}
></Button>
</div>
</div>
</Modal>
)}
{/* 批量转正弹窗 */}
{showBatchConfirmModal && (
<Modal open={showBatchConfirmModal} onClose={() => setShowBatchConfirmModal(false)} title="批量转正">
<div className="space-y-3">
<div className="text-xs text-gray-500">
{selectedIds.size} 稿
</div>
<div>
<Label></Label>
<Input type="date" value={batchConfirmDate} onChange={(e) => setBatchConfirmDate(e.target.value)} />
</div>
<div>
<Label></Label>
<Input type="number" value={batchConfirmSalary} onChange={(e) => setBatchConfirmSalary(e.target.value)} placeholder="转正后月薪" />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setShowBatchConfirmModal(false)}></Button>
<Button onClick={handleBatchConfirm}></Button>
</div>
</div>
</Modal>
)}
{/* 批量开具证明弹窗 */}
{showBatchCertModal && (
<Modal open={showBatchCertModal} onClose={() => setShowBatchCertModal(false)} title="批量开具证明">
<div className="space-y-3">
<div className="text-xs text-gray-500">
{selectedIds.size}
</div>
<div>
<Label></Label>
<Select value={batchCertType} onChange={(e) => setBatchCertType(e.target.value)}>
<option value="INCOME_CERT"></option>
<option value="EMPLOYMENT_CERT"></option>
<option value="LEAVING_CERT"></option>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setShowBatchCertModal(false)}></Button>
<Button onClick={handleBatchCert}></Button>
</div>
</div>
</Modal>
)}
{showBatchRenewModal && ( {showBatchRenewModal && (
<Modal open={showBatchRenewModal} onClose={() => { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签"> <Modal open={showBatchRenewModal} onClose={() => { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签">
<div className="space-y-3"> <div className="space-y-3">
@@ -1059,6 +1513,21 @@ export default function Roster() {
</div> </div>
</Modal> </Modal>
)} )}
{/* 签署方式选择弹窗 */}
{signChoice?.open && (
<SignMethodChoice
open={signChoice.open}
onClose={() => setSignChoice(null)}
employeeId={signChoice.employeeId}
employeeName={signChoice.employeeName}
scene={signChoice.scene}
documentTitle={signChoice.documentTitle}
contractId={signChoice.contractId}
remark={signChoice.remark}
actionName={signChoice.actionName}
/>
)}
</div> </div>
) )
} }
+3 -5
View File
@@ -54,6 +54,9 @@ export default function SalaryDashboard() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 页头 */} {/* 页头 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -70,11 +73,6 @@ export default function SalaryDashboard() {
</Select> </Select>
</div> </div>
{/* 操作说明 — 紧跟标题下方 */}
<PageGuide>
</PageGuide>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-gray-400">...</div> <div className="text-center py-8 text-gray-400">...</div>
) : isError ? ( ) : isError ? (
+309 -17
View File
@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2 } from 'lucide-react' import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2, Edit2 } from 'lucide-react'
import { settingsApi, notificationsApi } from '../lib/api-services' import { settingsApi, notificationsApi, socialAccountApi } from '../lib/api-services'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize' import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
import Card from '../components/ui/Card' import Card from '../components/ui/Card'
@@ -10,6 +11,7 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input' import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal' import Modal from '../components/ui/Modal'
import { useConfirm } from '../hooks/useConfirm' import { useConfirm } from '../hooks/useConfirm'
import PageGuide from '../components/ui/PageGuide'
export default function Settings() { export default function Settings() {
@@ -48,6 +50,7 @@ export default function Settings() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide></PageGuide>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" /> <Building2 className="h-5 w-5 text-primary" />
<div> <div>
@@ -653,7 +656,7 @@ function handleAcceptanceExport() {
} }
const resultMap: Record<string, string> = { pass: '✅通过', fail: '❌失败', partial: '⚠️部分通过', untested: '🚫未测试' } const resultMap: Record<string, string> = { pass: '✅通过', fail: '❌失败', partial: '⚠️部分通过', untested: '🚫未测试' }
let md = `# TurboHR 验收测试结果\n\n` let md = `# 安职通 验收测试结果\n\n`
md += `> **导出时间:** ${new Date().toLocaleString('zh-CN')}\n` md += `> **导出时间:** ${new Date().toLocaleString('zh-CN')}\n`
md += `> **验收人数:** ${keys.length}\n\n` md += `> **验收人数:** ${keys.length}\n\n`
@@ -702,7 +705,7 @@ function handleAcceptanceExport() {
md += `\n` md += `\n`
} }
md += `\n---\n*由企业用工专家系统自动生成*\n` md += `\n---\n*由安职通系统自动生成*\n`
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' }) const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
@@ -1148,7 +1151,7 @@ function InitImport() {
<div className="text-amber-600"> {result.skipped} </div> <div className="text-amber-600"> {result.skipped} </div>
)} )}
{result.duplicates > 0 && ( {result.duplicates > 0 && (
<div className="text-amber-600"> {result.duplicates} </div> <div className="text-amber-600"> {result.duplicates} </div>
)} )}
{result.errors?.length > 0 && ( {result.errors?.length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200"> <div className="mt-2 pt-2 border-t border-green-200">
@@ -1214,12 +1217,12 @@ function InitImport() {
<Card> <Card>
<div className="text-xs text-gray-500 space-y-1"> <div className="text-xs text-gray-500 space-y-1">
<div className="font-medium text-gray-700 mb-1">Sheet </div> <div className="font-medium text-gray-700 mb-1">Sheet </div>
<div>- <b></b></div> <div>- <b></b></div>
<div>- <b></b></div> <div>- <b></b></div>
<div>- <b></b></div> <div>- <b></b></div>
<div>- <b></b></div> <div>- <b></b></div>
<div>- <b></b></div> <div>- <b></b></div>
<div className="mt-2 text-gray-500"> Sheet </div> <div className="mt-2 text-gray-500"> Sheet </div>
</div> </div>
</Card> </Card>
</div> </div>
@@ -1377,12 +1380,12 @@ function MonthlyImport() {
<Card> <Card>
<div className="text-xs text-gray-500 space-y-1"> <div className="text-xs text-gray-500 space-y-1">
<div className="font-medium text-gray-700 mb-1">Sheet </div> <div className="font-medium text-gray-700 mb-1">Sheet </div>
<div>- <b></b></div> <div>- <b></b></div>
<div>- <b></b></div> <div>- <b></b></div>
<div>- <b></b></div> <div>- <b></b></div>
<div>- <b></b>//</div> <div>- <b></b>//</div>
<div>- <b></b>//</div> <div>- <b></b>//</div>
<div className="mt-2 text-gray-500"> Sheet </div> <div className="mt-2 text-gray-500"> Sheet </div>
</div> </div>
</Card> </Card>
</div> </div>
@@ -1572,3 +1575,292 @@ function MedicalPolicyForm({ policy, onSave, onClose }: { policy: any; onSave: (
) )
} }
// ========== 社保公积金账户管理 ==========
function SocialAccountSettings() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [accountType, setAccountType] = useState<'SOCIAL' | 'HOUSING'>('SOCIAL')
const [showForm, setShowForm] = useState(false)
const [editAccount, setEditAccount] = useState<any>(null)
const { data: accounts, isLoading } = useQuery({
queryKey: ['social-accounts', accountType],
queryFn: () => socialAccountApi.list(accountType),
})
const createMutation = useMutation({
mutationFn: (data: any) => socialAccountApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
setShowForm(false)
toast.success('账户已创建')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => socialAccountApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
setEditAccount(null)
setShowForm(false)
toast.success('已更新')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => socialAccountApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
toast.success('已删除')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
const setDefaultMutation = useMutation({
mutationFn: (id: string) => socialAccountApi.setDefault(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
toast.success('已设为默认')
},
})
return (
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium"></h2>
<p className="text-xs text-gray-500 mt-0.5">//</p>
</div>
<Button size="sm" onClick={() => { setEditAccount(null); setShowForm(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 类型切换 */}
<div className="flex gap-1 border-b">
{(['SOCIAL', 'HOUSING'] as const).map((t) => (
<button
key={t}
onClick={() => setAccountType(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
accountType === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{t === 'SOCIAL' ? '社保账户' : '公积金账户'}
</button>
))}
</div>
{/* 账户列表 */}
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !accounts || accounts.length === 0 ? (
<div className="text-center py-8 text-gray-400">
{accountType === 'SOCIAL' ? '社保' : '公积金'}"新建账户"
</div>
) : (
<div className="space-y-3">
{accounts.map((a: any) => (
<div key={a.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{a.name}</span>
{a.isDefault && <span className="px-1.5 py-0.5 rounded text-xs bg-green-50 text-safe"></span>}
{a.status === 'SUSPENDED' && <span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-500"></span>}
</div>
<div className="text-xs text-gray-500 space-y-0.5">
<div>{a.city} {a.accountNo && `· 账号:${a.accountNo}`}</div>
{a.orgName && <div>{a.orgName} {a.orgCode && `${a.orgCode}`}</div>}
{a.type === 'HOUSING' && a.bankName && <div>{a.bankName} {a.bankAccount && `· ${a.bankAccount}`}</div>}
{a.accountType && a.type === 'HOUSING' && <div>{a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</div>}
<div>{(a._count?.deptSocialAccounts || 0) + (a._count?.deptHousingAccounts || 0)} · {(a._count?.socialRecords || 0) + (a._count?.housingRecords || 0)} </div>
</div>
</div>
<div className="flex items-center gap-1">
{!a.isDefault && a.status === 'ACTIVE' && (
<button onClick={() => setDefaultMutation.mutate(a.id)} className="text-xs text-primary hover:underline" title="设为默认"></button>
)}
<button onClick={() => { setEditAccount(a); setShowForm(true) }} className="p-1 text-gray-400 hover:text-primary" title="编辑">
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={async () => {
if (await confirm({ title: '删除确认', message: `确认删除账户"${a.name}"?关联的参保记录不会被删除。`, variant: 'danger' })) {
deleteMutation.mutate(a.id)
}
}}
className="p-1 text-gray-400 hover:text-danger" title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* 新建/编辑弹窗 */}
{showForm && (
<AccountFormModal
type={accountType}
account={editAccount}
onClose={() => { setShowForm(false); setEditAccount(null) }}
onSubmit={async (data, departmentIds) => {
if (editAccount) {
updateMutation.mutate({ id: editAccount.id, data })
if (departmentIds) {
await socialAccountApi.linkDepartments(editAccount.id, departmentIds)
toast.success('账户已更新,部门关联已同步')
}
} else {
createMutation.mutate(
{ ...data, type: accountType },
{
onSuccess: async (created: any) => {
if (departmentIds && departmentIds.length > 0) {
await socialAccountApi.linkDepartments(created.id, departmentIds)
}
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
},
}
)
}
}}
saving={createMutation.isPending || updateMutation.isPending}
/>
)}
</Card>
)
}
function AccountFormModal({ type, account, onClose, onSubmit, saving }: {
type: string
account: any
onClose: () => void
onSubmit: (data: any, departmentIds?: string[]) => void
saving: boolean
}) {
const [name, setName] = useState(account?.name || '')
const [city, setCity] = useState(account?.city || '')
const [accountNo, setAccountNo] = useState(account?.accountNo || '')
const [orgName, setOrgName] = useState(account?.orgName || '')
const [orgCode, setOrgCode] = useState(account?.orgCode || '')
const [bankName, setBankName] = useState(account?.bankName || '')
const [bankAccount, setBankAccount] = useState(account?.bankAccount || '')
const [accountTypeVal, setAccountTypeVal] = useState(account?.accountType || 'BASIC')
const [isDefault, setIsDefault] = useState(account?.isDefault || false)
const [remark, setRemark] = useState(account?.remark || '')
const [selectedDeptIds, setSelectedDeptIds] = useState<string[]>([])
// 加载 level=0 根部门列表
const { data: rootDepartments = [] } = useQuery<any[]>({
queryKey: ['departments'],
queryFn: () => api.get('/departments').then(r => r.data),
})
const rootDepts = rootDepartments.filter((d: any) => d.level === 0)
// 编辑时加载已关联部门
useEffect(() => {
if (account?.id) {
socialAccountApi.accountDepartments(account.id).then((depts: any[]) => {
setSelectedDeptIds(depts.map((d: any) => d.id))
}).catch(() => {})
}
}, [account?.id])
const toggleDept = (id: string) => {
setSelectedDeptIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
return (
<Modal open onClose={onClose} title={account ? '编辑账户' : '新建账户'} size="md">
<div className="space-y-4">
<div>
<Label> *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:北京总公司社保账户" />
</div>
<div>
<Label> *</Label>
<Input value={city} onChange={(e) => setCity(e.target.value)} placeholder="如:北京" />
</div>
<div>
<Label>{type === 'SOCIAL' ? '社保登记号' : '公积金单位账号'}</Label>
<Input value={accountNo} onChange={(e) => setAccountNo(e.target.value)} />
</div>
{type === 'HOUSING' && (
<>
<div>
<Label></Label>
<Select value={accountTypeVal} onChange={(e) => setAccountTypeVal(e.target.value)}>
<option value="BASIC"></option>
<option value="SUPPLEMENTARY"></option>
</Select>
</div>
<div>
<Label></Label>
<Input value={bankName} onChange={(e) => setBankName(e.target.value)} placeholder="如:工商银行北京分行" />
</div>
<div>
<Label></Label>
<Input value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
</div>
</>
)}
<div>
<Label></Label>
<Input value={orgName} onChange={(e) => setOrgName(e.target.value)} placeholder="子公司/分公司名称" />
</div>
<div>
<Label></Label>
<Input value={orgCode} onChange={(e) => setOrgCode(e.target.value)} />
</div>
<div>
<Label></Label>
<Input value={remark} onChange={(e) => setRemark(e.target.value)} />
</div>
{/* 关联根部门 */}
<div>
<Label>//</Label>
<p className="text-xs text-gray-400 mb-2">使</p>
{rootDepts.length === 0 ? (
<div className="text-xs text-gray-400"></div>
) : (
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
{rootDepts.map((d: any) => (
<label key={d.id} className="flex items-center gap-2 text-sm py-1">
<input
type="checkbox"
checked={selectedDeptIds.includes(d.id)}
onChange={() => toggleDept(d.id)}
/>
<span>{d.name}</span>
{d._count?.employees > 0 && <span className="text-xs text-gray-400">({d._count.employees})</span>}
</label>
))}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button
disabled={!name || !city || saving}
onClick={() => onSubmit({ name, city, accountNo, orgName, orgCode, bankName, bankAccount, accountType: accountTypeVal, isDefault, remark }, selectedDeptIds)}
>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Modal>
)
}
+95 -768
View File
@@ -1,102 +1,72 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm' import { Calculator, Check, Plus, Download, AlertCircle, Clock } from 'lucide-react'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles } from 'lucide-react'
import { InlineAlert } from '../components/ui/InlineAlert' import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide' import PageGuide from '../components/ui/PageGuide'
import { socialInsuranceApi } from '../lib/api-services' import { socialInsuranceApi, socialAccountApi } from '../lib/api-services'
import Card from '../components/ui/Card' import Card from '../components/ui/Card'
import Button from '../components/ui/Button' import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input' import { Input } from '../components/ui/Input'
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows' import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab' import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
import EmployeeEnrollmentTab from './social-insurance/EmployeeEnrollmentTab' import EmployeeEnrollmentTab from './social-insurance/EmployeeEnrollmentTab'
import { AccountCard } from './social-insurance/AccountCard'
import { AccountFormModal } from './social-insurance/AccountFormModal'
// 金额格式化:保留两位小数 + 千分位 // 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export default function SocialInsurance() { export default function SocialInsurance() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm() const [searchParams] = useSearchParams()
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>('monthly') const initialTab = (searchParams.get('tab') as 'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment') || 'monthly'
const [city, setCity] = useState<string>('北京') const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>(initialTab)
const [showAddCity, setShowAddCity] = useState(false)
const [newCityName, setNewCityName] = useState('')
const [base, setBase] = useState(8000)
const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7)) const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7))
const [showNewVersion, setShowNewVersion] = useState(false)
const [showVersions, setShowVersions] = useState(false)
const [showAdjust, setShowAdjust] = useState(false)
const [adjustData, setAdjustData] = useState<any>(null)
const [editItems, setEditItems] = useState<Record<string, number>>({})
const [editingId, setEditingId] = useState<string | null>(null)
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7)) const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
const [monthlyProcessed, setMonthlyProcessed] = useState(false) const [monthlyProcessed, setMonthlyProcessed] = useState(false)
const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null) const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null)
const [newVersion, setNewVersion] = useState<any>({ // 账户管理相关 state
effectiveFrom: new Date().toISOString().slice(0, 7), const [showAccountForm, setShowAccountForm] = useState(false)
city: '北京', const [editAccount, setEditAccount] = useState<any>(null)
pensionOrg: 16, pensionEmp: 8,
medicalOrg: 9.8, medicalEmp: 2, // 获取账户列表(按当前 tab 类型)
unemploymentOrg: 0.5, unemploymentEmp: 0.5, const accountType = tab === 'housing' ? 'HOUSING' : 'SOCIAL'
injuryOrg: 0.2, maternityOrg: 0.8, const { data: accounts = [] } = useQuery<any[]>({
baseMin: 6326, baseMax: 33891, queryKey: ['social-accounts', accountType],
medicalBaseMin: 0, medicalBaseMax: 0, queryFn: () => socialAccountApi.list(accountType),
extraInsurances: [],
})
const [newHousingVersion, setNewHousingVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
city: '北京',
accountType: 'BASIC',
housingOrg: 12, housingEmp: 12,
baseMin: 6326, baseMax: 33891,
}) })
// 获取城市列表 // 账户 CRUD mutations
const { data: cities = [] } = useQuery<string[]>({ const createAccountMutation = useMutation({
queryKey: ['social-config-cities'], mutationFn: (data: any) => socialAccountApi.create(data),
queryFn: async () => { onSuccess: () => {
return await socialInsuranceApi.cities() queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
setShowAccountForm(false)
toast.success('账户已创建')
}, },
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
}) })
const { data: config, isLoading: configLoading } = useQuery<any>({ const updateAccountMutation = useMutation({
queryKey: ['social-config', city], mutationFn: ({ id, data }: { id: string; data: any }) => socialAccountApi.update(id, data),
queryFn: async () => { onSuccess: () => {
return await socialInsuranceApi.config(city) queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
setEditAccount(null)
setShowAccountForm(false)
toast.success('已更新')
}, },
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
}) })
const { data: housingConfig, isLoading: housingLoading } = useQuery<any>({ const deleteAccountMutation = useMutation({
queryKey: ['housing-config', city], mutationFn: (id: string) => socialAccountApi.remove(id),
queryFn: async () => { onSuccess: () => {
return await socialInsuranceApi.housingConfig(city) queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
toast.success('已删除')
}, },
}) onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
const { data: housingAllAccounts } = useQuery<any[]>({
queryKey: ['housing-config-all-accounts', city],
queryFn: async () => {
const res = await socialInsuranceApi.housingConfigVersions(city) as any
const current = (res || []).filter((v: any) => v.isCurrent)
return current
},
})
const { data: versions } = useQuery<any[]>({
queryKey: ['social-config-versions', city],
queryFn: async () => {
return await socialInsuranceApi.configVersions(city)
},
enabled: showVersions && tab === 'social',
})
const { data: housingVersions } = useQuery<any[]>({
queryKey: ['housing-config-versions'],
queryFn: async () => {
return await socialInsuranceApi.housingConfigVersions()
},
enabled: showVersions && tab === 'housing',
}) })
// 已办理月份列表(进入月度办理Tab时自动加载) // 已办理月份列表(进入月度办理Tab时自动加载)
@@ -169,154 +139,6 @@ export default function SocialInsurance() {
}, },
}) })
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
mutationFn: async () => {
return await socialInsuranceApi.calculate(base, city)
},
})
const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation<any>({
mutationFn: async () => {
return await socialInsuranceApi.housingCalculate(base, city)
},
})
const createVersionMutation = useMutation({
mutationFn: (data: any) => socialInsuranceApi.createConfigVersion(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
setShowNewVersion(false)
toast.success('新版本已创建,旧版本已自动归档')
},
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || '创建失败'
toast.error(msg)
},
})
const createHousingVersionMutation = useMutation({
mutationFn: (data: any) => socialInsuranceApi.createHousingConfigVersion(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
setShowNewVersion(false)
toast.success('公积金新版本已创建,旧版本已自动归档')
},
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || '创建失败'
toast.error(msg)
},
})
const aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
mutationFn: async (vars: { city: string; effectiveFrom: string; type: 'social' | 'housing' }) => {
return await socialInsuranceApi.aiSuggest(vars)
},
onSuccess: (data) => {
if (isHousing) {
activeSetNewVersion({
...activeNewVersion,
baseMin: data.baseMin ?? activeNewVersion.baseMin,
baseMax: data.baseMax ?? activeNewVersion.baseMax,
housingOrg: data.housingOrg ?? activeNewVersion.housingOrg,
housingEmp: data.housingEmp ?? activeNewVersion.housingEmp,
})
} else {
activeSetNewVersion({
...activeNewVersion,
baseMin: data.baseMin ?? activeNewVersion.baseMin,
baseMax: data.baseMax ?? activeNewVersion.baseMax,
medicalBaseMin: data.medicalBaseMin ?? 0,
medicalBaseMax: data.medicalBaseMax ?? 0,
pensionOrg: data.pensionOrg ?? activeNewVersion.pensionOrg,
pensionEmp: data.pensionEmp ?? activeNewVersion.pensionEmp,
medicalOrg: data.medicalOrg ?? activeNewVersion.medicalOrg,
medicalEmp: data.medicalEmp ?? activeNewVersion.medicalEmp,
unemploymentOrg: data.unemploymentOrg ?? activeNewVersion.unemploymentOrg,
unemploymentEmp: data.unemploymentEmp ?? activeNewVersion.unemploymentEmp,
injuryOrg: data.injuryOrg ?? activeNewVersion.injuryOrg,
maternityOrg: data.maternityOrg ?? activeNewVersion.maternityOrg,
extraInsurances: data.extraInsurances ?? [],
})
}
toast.success('AI建议已填入,请核对后保存')
},
onError: () => {
toast.error('AI建议获取失败,请手动填写')
},
})
const previewAdjustMutation = useMutation({
mutationFn: async () => {
return await socialInsuranceApi.adjustPreview(config?.id)
},
onSuccess: (data) => {
setAdjustData(data)
setShowAdjust(true)
},
})
const previewHousingAdjustMutation = useMutation({
mutationFn: async () => {
return await socialInsuranceApi.housingAdjustPreview(housingConfig?.id)
},
onSuccess: (data) => {
setAdjustData(data)
setShowAdjust(true)
},
})
const applyAdjustMutation = useMutation({
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
socialInsuranceApi.applyAdjust(config?.id, data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
setEditingId(null)
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保基数`)
},
})
const applyHousingAdjustMutation = useMutation({
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
socialInsuranceApi.applyHousingAdjust(housingConfig?.id, data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
setEditingId(null)
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的公积金基数`)
},
})
const resetAdjustMutation = useMutation({
mutationFn: () => socialInsuranceApi.resetAdjust(config?.id, city),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-config', city] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions', city] })
toast.success('社保基数调整已重置,可以重新调整')
},
})
const resetHousingAdjustMutation = useMutation({
mutationFn: () => socialInsuranceApi.resetHousingAdjust(housingConfig?.id, city),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['housing-config', city] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions', city] })
toast.success('公积金基数调整已重置,可以重新调整')
},
})
const handleExportCSV = (type: 'social' | 'housing', data: any) => { const handleExportCSV = (type: 'social' | 'housing', data: any) => {
if (!data?.items?.length) return if (!data?.items?.length) return
const headers = type === 'social' const headers = type === 'social'
@@ -339,17 +161,12 @@ export default function SocialInsurance() {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
const isHousing = tab === 'housing'
const activeConfig = isHousing ? housingConfig : config
const activeVersions = isHousing ? housingVersions : versions
const activePreviewMut = isHousing ? previewHousingAdjustMutation : previewAdjustMutation
const activeApplyMut = isHousing ? applyHousingAdjustMutation : applyAdjustMutation
const activeCreateMut = isHousing ? createHousingVersionMutation : createVersionMutation
const activeNewVersion = isHousing ? newHousingVersion : newVersion
const activeSetNewVersion = isHousing ? setNewHousingVersion : setNewVersion
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calculator className="h-5 w-5 text-primary" /> <Calculator className="h-5 w-5 text-primary" />
@@ -358,22 +175,9 @@ export default function SocialInsurance() {
<p className="mt-1 text-sm text-gray-500"></p> <p className="mt-1 text-sm text-gray-500"></p>
</div> </div>
</div> </div>
<div className="flex gap-2">
{(tab === 'social' || tab === 'housing') && (
<>
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
<History className="w-4 h-4 mr-1" />
{showVersions ? '收起历史' : '版本历史'}
</Button>
<Button size="sm" onClick={() => setShowNewVersion(!showNewVersion)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</>
)}
</div>
</div> </div>
{/* Tab 切换 + 城市选择 */} {/* Tab 切换 */}
<div className="flex items-center gap-4 border-b"> <div className="flex items-center gap-4 border-b">
{(['monthly', 'social', 'housing', 'enrollment', 'deduction'] as const).map((t) => ( {(['monthly', 'social', 'housing', 'enrollment', 'deduction'] as const).map((t) => (
<button <button
@@ -381,553 +185,76 @@ export default function SocialInsurance() {
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700' tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`} }`}
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }} onClick={() => { setTab(t); setMonthlyProcessed(false); setProcessStatus(null) }}
> >
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'enrollment' ? '员工参保' : '专项附加扣除'} {t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'enrollment' ? '员工参保' : '专项附加扣除'}
</button> </button>
))} ))}
{(tab === 'social' || tab === 'housing') && (
<div className="flex items-center gap-2 ml-auto">
<label className="text-sm text-gray-500">:</label>
{showAddCity ? (
<div className="flex items-center gap-1">
<input
className="h-9 w-24 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newCityName}
onChange={(e) => setNewCityName(e.target.value)}
placeholder="城市名"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && newCityName.trim()) {
setCity(newCityName.trim())
setNewCityName('')
setShowAddCity(false)
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
}
if (e.key === 'Escape') { setShowAddCity(false); setNewCityName('') }
}}
/>
<button className="h-9 px-2 text-xs text-primary" onClick={() => {
if (newCityName.trim()) {
setCity(newCityName.trim())
setNewCityName('')
setShowAddCity(false)
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
}
}}></button>
<button className="h-9 px-2 text-xs text-gray-400" onClick={() => { setShowAddCity(false); setNewCityName('') }}></button>
</div>
) : (
<div className="flex items-center gap-1">
<select
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={city}
onChange={(e) => setCity(e.target.value)}
>
{cities.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<button
className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-400 hover:text-primary hover:border-primary transition"
title="添加新城市"
onClick={() => setShowAddCity(true)}
>
<MapPin className="w-4 h-4" />
</button>
</div>
)}
</div>
)}
</div> </div>
{/* ========== 社保 / 公积金 Tab ========== */} {/* ========== 社保 / 公积金 Tab:账户卡片列表 ========== */}
{(tab === 'social' || tab === 'housing') && ( {(tab === 'social' || tab === 'housing') && (
(isHousing ? housingLoading : configLoading) ? ( <div className="space-y-3">
<Card><div className="text-center py-8 text-gray-500">...</div></Card> {/* 新建对应类型账户按钮 */}
) : activeConfig ? ( <div className="flex justify-end">
<Card> <Button size="sm" onClick={() => { setEditAccount(null); setShowAccountForm(true) }}>
<div className="flex items-center justify-between mb-3"> <Plus className="w-4 h-4 mr-1" />{tab === 'housing' ? '公积金' : '社保'}
<div className="flex items-center gap-2"> </Button>
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe"></span>
<span className="text-sm text-gray-500">{activeConfig.effectiveFrom}</span>
<span className="text-sm text-gray-500">· {activeConfig.city}</span>
{activeConfig.adjustmentDone && (
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500"></span>
)}
</div>
<div className="flex items-center gap-2">
{activeConfig.adjustmentDone && (
<Button
variant="secondary"
size="sm"
onClick={async () => {
if (await confirm({ title: '重置确认', message: `确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`, variant: 'primary' })) {
isHousing ? resetHousingAdjustMutation.mutate() : resetAdjustMutation.mutate()
}
}}
disabled={isHousing ? resetHousingAdjustMutation.isPending : resetAdjustMutation.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{isHousing ? resetHousingAdjustMutation.isPending ? '重置中...' : '重置调整' : resetAdjustMutation.isPending ? '重置中...' : '重置调整'}
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={() => activePreviewMut.mutate()}
disabled={activeConfig.adjustmentDone || activePreviewMut.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{activeConfig.adjustmentDone ? '已调整' : activePreviewMut.isPending ? '加载中...' : `调整员工${isHousing ? '公积金' : '社保'}基数`}
</Button>
</div>
</div> </div>
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-center gap-2 mb-3"> {accounts.length === 0 ? (
<Info className="w-4 h-4 shrink-0" /> <Card>
<span>7 /</span> <div className="text-center py-8 text-gray-500">
</div> {tab === 'housing' ? '公积金' : '社保'}{tab === 'housing' ? '公积金' : '社保'}
{isHousing ? (
<>
{(housingAllAccounts || []).length > 1 && (
<div className="flex gap-2 mb-3">
{(housingAllAccounts || []).map((a: any) => (
<span key={a.id} className={`px-2 py-0.5 rounded text-xs ${a.accountType === 'SUPPLEMENTARY' ? 'bg-purple-50 text-purple-700' : 'bg-blue-50 text-blue-700'}`}>
{a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'} {a.housingOrg}%/{a.housingEmp}%
</span>
))}
</div>
)}
<div className="grid md:grid-cols-4 gap-3 text-sm">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">{activeConfig.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingEmp}%</span></div>
</div> </div>
</> </Card>
) : ( ) : (
<div className="grid md:grid-cols-4 gap-3 text-sm"> accounts.map((a: any) => (
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div> <AccountCard
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div> key={a.id}
{activeConfig.medicalBaseMin > 0 && ( account={a}
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.medicalBaseMin)}</span></div> isHousing={tab === 'housing'}
)} onEdit={(acc) => { setEditAccount(acc); setShowAccountForm(true) }}
{activeConfig.medicalBaseMax > 0 && ( onDelete={(acc) => deleteAccountMutation.mutate(acc.id)}
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.medicalBaseMax)}</span></div> />
)} ))
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.pensionOrg}% / {activeConfig.pensionEmp}%</span></div> )}
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.medicalOrg}% / {activeConfig.medicalEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div> {/* 账户新建/编辑弹窗 */}
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.injuryOrg}%</span></div> {showAccountForm && (
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div> <AccountFormModal
{Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => ( type={accountType}
<div key={idx} className="flex justify-between border-b pb-1.5"> account={editAccount}
<span className="text-gray-500">{ins.name}(/)</span> onClose={() => { setShowAccountForm(false); setEditAccount(null) }}
<span className="font-medium"> onSubmit={async (data, departmentIds) => {
{ins.baseType === 'fixed' if (editAccount) {
? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}` updateAccountMutation.mutate({ id: editAccount.id, data })
: `${ins.orgRate}% / ${ins.empRate}%` if (departmentIds) {
await socialAccountApi.linkDepartments(editAccount.id, departmentIds)
toast.success('账户已更新,部门关联已同步')
}
} else {
createAccountMutation.mutate(
{ ...data, type: accountType },
{
onSuccess: async (created: any) => {
if (departmentIds && departmentIds.length > 0) {
await socialAccountApi.linkDepartments(created.id, departmentIds)
}
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
},
} }
</span>
</div>
))}
</div>
)}
</Card>
) : (
<Card><div className="text-center py-8 text-gray-500">{isHousing ? '公积金' : '社保'}</div></Card>
)
)}
{/* 调整预览 */}
{(tab === 'social' || tab === 'housing') && showAdjust && adjustData && (
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />{isHousing ? '公积金' : '社保'}
</h3>
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2 mb-3">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>
¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)}{isHousing ? '公积金' : '社保'}
=
</div>
</div>
<div className="flex items-center gap-2 mb-3">
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, number> = {}
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.suggestedBase })
setEditItems(newEdits)
}}>
<Check className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, number> = {}
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.oldBase })
setEditItems(newEdits)
}}>
</Button>
<span className="text-xs text-gray-400"> {adjustData.total} </span>
</div>
<div className="overflow-x-auto mb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}()</th>
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}()</th>
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}()</th>
</tr>
</thead>
<tbody>
{adjustData.items.map((item: any) => {
const edit = editItems[item.employeeId]
const newBase = edit ?? item.suggestedBase
const changed = newBase !== item.oldBase
return (
<tr key={item.employeeId} className="border-b last:border-0">
<td className="py-1.5">{item.name}</td>
<td className="py-1.5 text-gray-500">{item.department}</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.avgSalary)}</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldBase)}</td>
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedBase)}</td>
<td className="py-1.5 text-right">
{editingId === item.employeeId ? (
<Input type="number" step="0.01" min="0" className="!w-28 text-right text-xs" value={newBase}
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })}
onBlur={() => setEditingId(null)}
autoFocus />
) : (
<span className="cursor-text inline-block !w-28 text-right"
onClick={() => setEditingId(item.employeeId)}>
¥{fmt(newBase)}
</span>
)}
{changed && <span className="text-warning ml-1"></span>}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex gap-2">
<Button onClick={() => {
const items = adjustData.items.map((i: any) => ({ employeeId: i.employeeId, newBase: editItems[i.employeeId] ?? i.suggestedBase }))
activeApplyMut.mutate({ items })
}} disabled={activeApplyMut.isPending}>
{activeApplyMut.isPending ? '保存中...' : '确认保存'}
</Button>
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}></Button>
</div>
</Card>
)}
{/* 版本历史 */}
{(tab === 'social' || tab === 'housing') && showVersions && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
{!activeVersions || activeVersions.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-xs"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
{isHousing && <th className="py-2 text-left"></th>}
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
{isHousing ? (
<th className="py-2 text-right">%</th>
) : (
<>
<th className="py-2 text-right">%</th>
<th className="py-2 text-right">%</th>
</>
)}
<th className="py-2 text-center"></th>
</tr>
</thead>
<tbody>
{activeVersions.map((v: any) => (
<tr key={v.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2">{v.effectiveFrom}</td>
<td className="py-2 text-gray-400">{v.effectiveTo || '—'}</td>
<td className="py-2">{v.city}</td>
{isHousing && <td className="py-2">{v.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</td>}
<td className="py-2 text-right">¥{fmt(v.baseMin)}</td>
<td className="py-2 text-right">¥{fmt(v.baseMax)}</td>
{isHousing ? (
<td className="py-2 text-right text-gray-500">{v.housingOrg}/{v.housingEmp}</td>
) : (
<>
<td className="py-2 text-right text-gray-500">{v.pensionOrg}/{v.pensionEmp}</td>
<td className="py-2 text-right text-gray-500">{v.medicalOrg}/{v.medicalEmp}</td>
</>
)}
<td className="py-2 text-center">
{v.isCurrent ? <span className="px-2 py-0.5 rounded bg-green-50 text-safe"></span> : <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-400"></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{/* 新建版本 */}
{(tab === 'social' || tab === 'housing') && showNewVersion && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
<div className="space-y-3">
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>7</div>
</div>
<button
type="button"
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
onClick={() => aiSuggestMut.mutate({ city: activeNewVersion.city, effectiveFrom: activeNewVersion.effectiveFrom, type: isHousing ? 'housing' : 'social' })}
disabled={aiSuggestMut.isPending}
>
<Sparkles className="w-4 h-4" />
{aiSuggestMut.isPending ? 'AI获取中...' : 'AI建议 — 根据城市和年份自动填充最新政策'}
</button>
<div className="grid md:grid-cols-3 gap-3">
<div><Label></Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
<div><Label></Label>
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })}>
{[...new Set([city, ...cities])].map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
</div>
{!isHousing && (
<div className="grid md:grid-cols-2 gap-3">
<div>
<Label>/</Label>
<Input type="number" value={activeNewVersion.medicalBaseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalBaseMin: Number(e.target.value) })} />
<p className="text-xs text-gray-400 mt-1"> 0 使</p>
</div>
<div>
<Label>/</Label>
<Input type="number" value={activeNewVersion.medicalBaseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalBaseMax: Number(e.target.value) })} />
<p className="text-xs text-gray-400 mt-1"> 0 使</p>
</div>
</div>
)}
{isHousing ? (
<>
<div className="grid md:grid-cols-3 gap-3">
<div>
<Label></Label>
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.accountType || 'BASIC'} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, accountType: e.target.value })}>
<option value="BASIC"></option>
<option value="SUPPLEMENTARY"></option>
</select>
</div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingEmp: Number(e.target.value) })} /></div>
</div>
</>
) : (
<div className="grid md:grid-cols-4 gap-3">
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.injuryOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, injuryOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.maternityOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, maternityOrg: Number(e.target.value) })} /></div>
</div>
)}
{!isHousing && (
<div className="border rounded-md p-3 space-y-2 bg-gray-50">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-700">/</span>
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => activeSetNewVersion({
...activeNewVersion,
extraInsurances: [...(activeNewVersion.extraInsurances || []), { name: '', orgRate: 0, empRate: 0, baseType: 'pension', fixedAmount: 0, empFixedAmount: 0 }],
})}
>
+
</button>
</div>
{(activeNewVersion.extraInsurances || []).map((ins: any, idx: number) => (
<div key={idx} className="grid grid-cols-5 gap-2 items-end">
<div><Label></Label><Input value={ins.name} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, name: e.target.value }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
<div>
<Label></Label>
<select className="w-full h-9 rounded-md border border-input px-2 text-sm" value={ins.baseType} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, baseType: e.target.value }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }}>
<option value="pension"></option>
<option value="medical"></option>
<option value="fixed"></option>
</select>
</div>
{ins.baseType === 'fixed' ? (
<>
<div><Label>()</Label><Input type="number" value={ins.fixedAmount} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, fixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
<div><Label>()</Label><Input type="number" value={ins.empFixedAmount} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empFixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
</>
) : (
<>
<div><Label>%</Label><Input type="number" step="0.01" value={ins.orgRate} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, orgRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
<div><Label>%</Label><Input type="number" step="0.01" value={ins.empRate} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
</>
)}
<button type="button" className="text-xs text-danger h-9" onClick={() => { const arr = (activeNewVersion.extraInsurances || []).filter((_: any, i: number) => i !== idx); activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }}></button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Button onClick={() => activeCreateMut.mutate(activeNewVersion)} disabled={activeCreateMut.isPending}>
{activeCreateMut.isPending ? '保存中...' : '创建版本'}
</Button>
<Button variant="secondary" onClick={() => setShowNewVersion(false)}></Button>
</div>
</div>
</Card>
)}
{/* 试算工具 */}
{(tab === 'social' || tab === 'housing') && (
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="text-sm font-medium mb-3">{isHousing ? '公积金' : '社保'}</h2>
<div className="space-y-3">
<div>
<Label></Label>
<Input type="number" value={base} onChange={(e) => setBase(Number(e.target.value) || 0)} />
</div>
<Button onClick={() => isHousing ? calcHousingMutate() : calcMutate()} disabled={isHousing ? housingCalcPending : isPending}>
<Calculator className="w-4 h-4 mr-1" />
{(isHousing ? housingCalcPending : isPending) ? '计算中...' : '开始计算'}
</Button>
{activeConfig && (
<div className="text-sm text-gray-400">
{activeConfig.city} | {fmt(activeConfig.baseMin)}~{fmt(activeConfig.baseMax)}
</div>
)}
</div>
</Card>
<Card>
<h2 className="text-sm font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" /></h2>
{(() => {
const r = isHousing ? housingResult : result
if (!r) return <div className="text-gray-400 text-sm"></div>
if (isHousing) {
return (
<div className="space-y-3">
<div className="text-sm text-gray-500">
<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
{r.capped && <span className="text-warning ml-2"></span>}
{r.floored && <span className="text-warning ml-2"></span>}
{r.configVersion && <span className="text-gray-400 ml-2">| {r.configVersion}</span>}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2 text-sm">
<span className="text-gray-500"></span>
<span className="font-medium text-danger">¥{fmt(r.housingOrg)}</span>
</div>
<div className="flex items-center justify-between border-b pb-2 text-sm">
<span className="text-gray-500"></span>
<span className="font-medium text-warning">¥{fmt(r.housingEmp)}</span>
</div>
</div>
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-lg font-bold text-primary">¥{fmt(r.total)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
¥{fmt(r.housingOrg)} + ¥{fmt(r.housingEmp)}
</div>
</div>
</div>
) )
} }
return ( }}
<div className="space-y-3"> saving={createAccountMutation.isPending || updateAccountMutation.isPending}
<div className="text-sm text-gray-500"> />
<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span> )}
{r.capped && <span className="text-warning ml-2"></span>}
{r.floored && <span className="text-warning ml-2"></span>}
{r.configVersion && <span className="text-gray-400 ml-2">| {r.configVersion}</span>}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-1.5"></th>
<th className="py-1.5 text-right">%</th>
<th className="py-1.5 text-right">%</th>
<th className="py-1.5 text-right"></th>
<th className="py-1.5 text-right"></th>
</tr>
</thead>
<tbody>
{r.items?.map((item: any) => (
<tr key={item.name} className="border-b last:border-0">
<td className="py-1.5">{item.name}</td>
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
<td className="py-1.5 text-right">¥{fmt(item.orgAmount)}</td>
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-bold">
<td className="py-2" colSpan={3}></td>
<td className="py-2 text-right text-danger">¥{fmt(r.totalOrg)}</td>
<td className="py-2 text-right text-warning">¥{fmt(r.totalEmp)}</td>
</tr>
</tfoot>
</table>
</div>
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-lg font-bold text-primary">¥{fmt(r.total)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
¥{fmt(r.totalOrg)} + ¥{fmt(r.totalEmp)}
</div>
</div>
</div>
)
})()}
</Card>
</div> </div>
)} )}
{/* 基数说明(仅社保/公积金Tab显示) */}
{(tab === 'social' || tab === 'housing') && (
<p className="text-sm text-gray-400">
/7
</p>
)}
{/* ========== 月度办理 Tab ========== */} {/* ========== 月度办理 Tab ========== */}
{tab === 'monthly' && ( {tab === 'monthly' && (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
</PageGuide>
<Card> <Card>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"></h2> <h2 className="text-sm font-medium"></h2>
+19 -2
View File
@@ -450,7 +450,12 @@ export default function SpecialStatus() {
{/* 员工选择 */} {/* 员工选择 */}
<div> <div>
<Label></Label> <Label></Label>
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })} disabled={!!editing}> <Select value={form.employeeId} onChange={(e) => {
const emp = employees.find(em => em.id === e.target.value)
// 选择员工后,若当前类型为三期但员工为男性,自动切换为其他类型
const newType = emp?.gender === '男' && form.type === 'PREGNANCY' ? 'WORK_INJURY' : form.type
setForm({ ...form, employeeId: e.target.value, type: newType })
}} disabled={!!editing}>
<option value=""></option> <option value=""></option>
{employees.map((emp) => ( {employees.map((emp) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option> <option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
@@ -463,11 +468,23 @@ export default function SpecialStatus() {
<div> <div>
<Label></Label> <Label></Label>
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}> <Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="PREGNANCY"></option> {/* 三期仅适用于女性员工,男职工不显示该选项 */}
{(() => {
const selectedEmp = employees.find(em => em.id === form.employeeId)
const isMale = selectedEmp?.gender === '男'
return isMale ? null : <option value="PREGNANCY"></option>
})()}
<option value="WORK_INJURY"></option> <option value="WORK_INJURY"></option>
<option value="MEDICAL_PERIOD"></option> <option value="MEDICAL_PERIOD"></option>
<option value="OTHER"></option> <option value="OTHER"></option>
</Select> </Select>
{(() => {
const selectedEmp = employees.find(em => em.id === form.employeeId)
if (selectedEmp?.gender === '男') {
return <p className="text-xs text-gray-400 mt-1"></p>
}
return null
})()}
</div> </div>
<div> <div>
<Label></Label> <Label></Label>
+3 -1
View File
@@ -13,6 +13,7 @@ import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState' import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination' import Pagination from '../components/ui/Pagination'
import { useConfirm } from '../hooks/useConfirm' import { useConfirm } from '../hooks/useConfirm'
import PageGuide from '../components/ui/PageGuide'
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
CONTRACT: '合同', CONTRACT: '合同',
@@ -25,7 +26,7 @@ const CATEGORY_LABELS: Record<string, string> = {
const VARIABLE_LABELS: Record<string, string> = { const VARIABLE_LABELS: Record<string, string> = {
companyName: '公司名称', companyName: '公司名称',
employeeName: '员工姓名', employeeName: '员工姓名',
idCard: '身份证号', idCard: '证件号码',
address: '地址', address: '地址',
startDate: '开始日期', startDate: '开始日期',
position: '岗位', position: '岗位',
@@ -78,6 +79,7 @@ export default function Templates() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>使</PageGuide>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" /> <FileText className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1> <h1 className="text-base font-semibold"></h1>
+388 -36
View File
@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize' import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -7,7 +8,7 @@ import { Stepper } from '../components/ui/Stepper'
import { InlineAlert } from '../components/ui/InlineAlert' import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide' import PageGuide from '../components/ui/PageGuide'
import jsPDF from 'jspdf' import jsPDF from 'jspdf'
import { rosterApi, terminationApi, esignApi } from '../lib/api-services' import { rosterApi, terminationApi, esignApi, settingsApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card' import Card from '../components/ui/Card'
import Button from '../components/ui/Button' import Button from '../components/ui/Button'
@@ -162,12 +163,34 @@ interface EmployeeProfile {
export default function Termination() { export default function Termination() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm() const confirm = useConfirm()
const [searchParams] = useSearchParams()
const [view, setView] = useState<'list' | 'wizard' | 'detail'>('list') const [view, setView] = useState<'list' | 'wizard' | 'detail'>('list')
const [draftId, setDraftId] = useState<string | null>(null) const [draftId, setDraftId] = useState<string | null>(null)
const [step, setStep] = useState(0) const [step, setStep] = useState(0)
const [reason, setReason] = useState('') const [reason, setReason] = useState('')
const [employeeId, setEmployeeId] = useState('') const [employeeId, setEmployeeId] = useState('')
const [terminationDate, setTerminationDate] = useState('') const [terminationDate, setTerminationDate] = useState('')
// 获取组织所在城市,用于地区差异化合规提示(如北京通知工会程序)
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
queryFn: () => settingsApi.org(),
staleTime: 300000,
})
const isBeijing = !orgData?.city || orgData?.city === '北京' || orgData?.city === '北京市' || (orgData?.city as string)?.includes('北京')
// 从 URL 参数预填员工和解聘类型(从花名册操作栏跳转)
useEffect(() => {
const urlEmployeeId = searchParams.get('employeeId')
const urlReason = searchParams.get('reason')
if (urlEmployeeId) {
setEmployeeId(urlEmployeeId)
setView('wizard')
}
if (urlReason) {
setReason(urlReason)
}
}, [searchParams])
const [socialInsEndMonth, setSocialInsEndMonth] = useState('') const [socialInsEndMonth, setSocialInsEndMonth] = useState('')
const [housingFundEndMonth, setHousingFundEndMonth] = useState('') const [housingFundEndMonth, setHousingFundEndMonth] = useState('')
const [checklist, setChecklist] = useState<Record<string, boolean>>({}) const [checklist, setChecklist] = useState<Record<string, boolean>>({})
@@ -177,10 +200,32 @@ export default function Termination() {
const [compAdjustments, setCompAdjustments] = useState<Array<{ field: string; from: number; to: number; reason: string }>>([]) const [compAdjustments, setCompAdjustments] = useState<Array<{ field: string; from: number; to: number; reason: string }>>([])
const [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS) const [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS)
const [checklistOverrides, setChecklistOverrides] = useState<Record<string, { checked: boolean; overrideReason: string }>>({}) const [checklistOverrides, setChecklistOverrides] = useState<Record<string, { checked: boolean; overrideReason: string }>>({})
/** 工会回执上传(北京地区单方解除证据链) */
const [unionReceipt, setUnionReceipt] = useState<{
receiptNo: string
unionName: string
receiptDate: string
fileUrl: string
fileName: string
unionOpinion: string
} | null>(null)
const [uploadingReceipt, setUploadingReceipt] = useState(false)
const [editingCompField, setEditingCompField] = useState<string | null>(null) const [editingCompField, setEditingCompField] = useState<string | null>(null)
const [editCompValue, setEditCompValue] = useState<number>(0) const [editCompValue, setEditCompValue] = useState<number>(0)
const [editCompReason, setEditCompReason] = useState('') const [editCompReason, setEditCompReason] = useState('')
const [approvalComment, setApprovalComment] = useState('') const [approvalComment, setApprovalComment] = useState('')
/** 违法解除且实际补偿金低于法定2N时需确认风险 */
const [illegalRiskAcknowledged, setIllegalRiskAcknowledged] = useState(false)
/** 补偿月数模式:N/N+1/2N/自定义 */
const [compMonthMode, setCompMonthMode] = useState<'N' | 'N+1' | '2N' | 'CUSTOM'>('N')
const [customCompMonths, setCustomCompMonths] = useState<number>(0)
const [customCompReason, setCustomCompReason] = useState('')
/** 计算基数来源:近12月平均/合同工资/自定义 */
const [wageBaseType, setWageBaseType] = useState<'AVG_12' | 'CONTRACT' | 'CUSTOM'>('AVG_12')
const [customWageBase, setCustomWageBase] = useState<number>(0)
const [customWageReason, setCustomWageReason] = useState('')
/** 剩余年假天数及折算金额 */
const [annualLeaveDays, setAnnualLeaveDays] = useState<number>(0)
const [savedItems, setSavedItems] = useState<Array<{ const [savedItems, setSavedItems] = useState<Array<{
id: string id: string
employeeId: string employeeId: string
@@ -479,6 +524,28 @@ export default function Termination() {
capped = true capped = true
} }
// 支持自定义计算基数(近12月平均/合同工资/自定义)
let effectiveWageBase = cappedWage
if (wageBaseType === 'CONTRACT') {
effectiveWageBase = selectedEmployee.latestContract?.monthlySalary || wage
} else if (wageBaseType === 'CUSTOM' && customWageBase > 0) {
effectiveWageBase = customWageBase
// 自定义基数时提示三倍社平封顶但不强制
if (socialAvgWage > 0 && customWageBase > socialAvgWage * 3) {
capped = true // 仅标记提示
}
}
// 支持自定义补偿月数(N/N+1/2N/其他)
let effectiveMonths = cappedMonths
if (compMonthMode === 'N+1') {
effectiveMonths = compMonths + 1
} else if (compMonthMode === '2N') {
effectiveMonths = compMonths * 2
} else if (compMonthMode === 'CUSTOM' && customCompMonths > 0) {
effectiveMonths = customCompMonths
}
const reasonMap: Record<string, { multiplier: number; notice: boolean }> = { const reasonMap: Record<string, { multiplier: number; notice: boolean }> = {
NEGOTIATED: { multiplier: 1, notice: false }, NEGOTIATED: { multiplier: 1, notice: false },
FAULT: { multiplier: 0, notice: false }, FAULT: { multiplier: 0, notice: false },
@@ -488,9 +555,9 @@ export default function Termination() {
ILLEGAL: { multiplier: 2, notice: false }, ILLEGAL: { multiplier: 2, notice: false },
} }
const r = reasonMap[reason] || { multiplier: 1, notice: false } const r = reasonMap[reason] || { multiplier: 1, notice: false }
const basePay = cappedWage * cappedMonths const basePay = effectiveWageBase * effectiveMonths
const severancePay = basePay * r.multiplier const severancePay = basePay * r.multiplier
const noticePay = r.notice ? cappedWage : 0 const noticePay = r.notice ? effectiveWageBase : 0
const totalSeverance = severancePay + noticePay const totalSeverance = severancePay + noticePay
// 双倍工资计算(未签合同) // 双倍工资计算(未签合同)
@@ -525,35 +592,45 @@ export default function Termination() {
isIllegal: r.multiplier === 2, isIllegal: r.multiplier === 2,
grandTotal: totalSeverance + doublePay, grandTotal: totalSeverance + doublePay,
} }
}, [selectedEmployee, terminationDate, socialAvgWage, reason]) }, [selectedEmployee, terminationDate, socialAvgWage, reason, compMonthMode, customCompMonths, wageBaseType, customWageBase])
// 计算调整后的实际补偿金(系统预估 + 手动调整差额 // 年假折算金额:日工资 × 剩余年假天数 × 3(未休年假按300%支付
const annualLeavePay = useMemo(() => {
if (!costResult || annualLeaveDays <= 0) return 0
const dailyWage = (costResult.cappedWage || 0) / 21.75
return Math.round(dailyWage * annualLeaveDays * 3 * 100) / 100
}, [costResult, annualLeaveDays])
// 计算调整后的实际补偿金(系统预估 + 手动调整差额 + 年假折算)
const adjustedTotal = useMemo(() => { const adjustedTotal = useMemo(() => {
if (!costResult) return 0 if (!costResult) return 0
let total = costResult.grandTotal let total = costResult.grandTotal
compAdjustments.forEach(adj => { compAdjustments.forEach(adj => {
total += (adj.to - adj.from) total += (adj.to - adj.from)
}) })
total += annualLeavePay
return total return total
}, [costResult, compAdjustments]) }, [costResult, compAdjustments, annualLeavePay])
const canProceed = () => { const canProceed = () => {
if (step === 0) return !!employeeId && (!!draftId || !selectedEmployee?.hasTermination || selectedEmployee?.latestTerminationStatus === 'CANCELLED' || selectedEmployee?.latestTerminationStatus === 'COMPLETED') if (step === 0) return !!employeeId && (!!draftId || !selectedEmployee?.hasTermination || selectedEmployee?.latestTerminationStatus === 'CANCELLED' || selectedEmployee?.latestTerminationStatus === 'COMPLETED')
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk) if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
// step 2: 合规检查 — required 项必须勾选 // step 2: 合规检查 — 改为软阻断,未勾选 required 项可下一步但会生成待办
if (step === 2) { if (step === 2) {
if (!checklistItems) return true // 法定禁止情形(孕期/工伤/医疗期)仍硬阻断
const requiredItems = checklistItems.filter(item => item.suggestionType === 'required') if (riskAssessment?.warnings.length && !acknowledgeRisk) return false
if (requiredItems.length === 0) return true return true
return requiredItems.every(item => checklist[item.key] === true)
} }
// step 3: 费用结算 — 有补偿金时需确认 // step 3: 费用结算 — 违法解除且实际补偿金低于法定2N时需确认风险
if (step === 3) return true if (step === 3) {
// step 4: 工作交接 — 关键项需完成 if (reason === 'ILLEGAL' && costResult && adjustedTotal < costResult.grandTotal) {
return illegalRiskAcknowledged
}
return true
}
// step 4: 工作交接 — 改为软阻断,未完成可下一步但会生成待办
if (step === 4) { if (step === 4) {
const keyItems = handoverItems.filter(item => item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke') return true
if (keyItems.length === 0) return true
return keyItems.every(item => item.done)
} }
return false return false
} }
@@ -568,6 +645,13 @@ export default function Termination() {
total: adjustedTotal, total: adjustedTotal,
systemTotal: costResult.grandTotal, systemTotal: costResult.grandTotal,
adjustments: compAdjustments, adjustments: compAdjustments,
riskAcknowledged: reason === 'ILLEGAL' && adjustedTotal < costResult.grandTotal ? {
acknowledged: illegalRiskAcknowledged,
legalAmount: costResult.grandTotal,
actualAmount: adjustedTotal,
difference: costResult.grandTotal - adjustedTotal,
timestamp: new Date().toISOString(),
} : null,
} : null } : null
saveDraftMutation.mutate({ saveDraftMutation.mutate({
@@ -583,18 +667,53 @@ export default function Termination() {
compensationBreakdown: breakdown, compensationBreakdown: breakdown,
handoverItems, handoverItems,
checklistOverrides, checklistOverrides,
compMonthMode,
customCompMonths: compMonthMode === 'CUSTOM' ? customCompMonths : undefined,
customCompReason: compMonthMode === 'CUSTOM' ? customCompReason : undefined,
wageBaseType,
customWageBase: wageBaseType === 'CUSTOM' ? customWageBase : undefined,
customWageReason: wageBaseType === 'CUSTOM' ? customWageReason : undefined,
annualLeaveDays: annualLeaveDays > 0 ? annualLeaveDays : undefined,
annualLeavePay: annualLeavePay > 0 ? annualLeavePay : undefined,
socialAvgWage: socialAvgWage > 0 ? socialAvgWage : undefined,
remark: '', remark: '',
}) })
} }
/** 编辑已有草稿 */ /** 编辑已有草稿(拉取完整详情后恢复所有字段) */
const handleEditDraft = (item: any) => { const handleEditDraft = async (item: any) => {
setDraftId(item.id) setDraftId(item.id)
setEmployeeId(item.employeeId) setEmployeeId(item.employeeId)
setReason(item.reason) setReason(item.reason)
setTerminationDate(item.terminationDate) setTerminationDate(item.terminationDate)
setStep(item.currentStep || 0) setStep(item.currentStep || 0)
setView('wizard') setView('wizard')
// 恢复所有已保存的字段
if (item.socialInsEndMonth) setSocialInsEndMonth(item.socialInsEndMonth)
if (item.housingFundEndMonth) setHousingFundEndMonth(item.housingFundEndMonth)
if (item.checklist) setChecklist(item.checklist)
if (item.compensationBreakdown) {
setCompBreakdown(item.compensationBreakdown)
if (item.compensationBreakdown.adjustments) {
setCompAdjustments(item.compensationBreakdown.adjustments)
}
// 恢复违法解除风险确认状态
if (item.compensationBreakdown.riskAcknowledged?.acknowledged) {
setIllegalRiskAcknowledged(true)
}
}
if (item.handoverItems) setHandoverItems(item.handoverItems)
if (item.checklistOverrides) setChecklistOverrides(item.checklistOverrides)
// 恢复补偿月数和计算基数设置
if (item.compMonthMode) setCompMonthMode(item.compMonthMode)
if (item.customCompMonths) setCustomCompMonths(item.customCompMonths)
if (item.customCompReason) setCustomCompReason(item.customCompReason)
if (item.wageBaseType) setWageBaseType(item.wageBaseType)
if (item.customWageBase) setCustomWageBase(item.customWageBase)
if (item.customWageReason) setCustomWageReason(item.customWageReason)
if (item.annualLeaveDays) setAnnualLeaveDays(item.annualLeaveDays)
// 恢复社平工资
if (item.socialAvgWage) setSocialAvgWage(item.socialAvgWage)
} }
/** 查看详情 */ /** 查看详情 */
@@ -675,6 +794,14 @@ export default function Termination() {
setChecklistOverrides({}) setChecklistOverrides({})
setEditingCompField(null) setEditingCompField(null)
setApprovalComment('') setApprovalComment('')
setIllegalRiskAcknowledged(false)
setCompMonthMode('N')
setCustomCompMonths(0)
setCustomCompReason('')
setWageBaseType('AVG_12')
setCustomWageBase(0)
setCustomWageReason('')
setAnnualLeaveDays(0)
} }
const handleReset = resetWizard const handleReset = resetWizard
@@ -686,6 +813,9 @@ export default function Termination() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
稿
</PageGuide>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" /> <Shield className="h-5 w-5 text-primary" />
@@ -694,11 +824,6 @@ export default function Termination() {
<p className="mt-1 text-sm text-gray-500"></p> <p className="mt-1 text-sm text-gray-500"></p>
</div> </div>
</div> </div>
{view === 'list' && (
<Button size="sm" onClick={handleNewTermination}>
<Plus className="w-4 h-4 mr-1" />
</Button>
)}
{view !== 'list' && ( {view !== 'list' && (
<Button variant="secondary" size="sm" onClick={() => { setView('list'); resetWizard() }}> <Button variant="secondary" size="sm" onClick={() => { setView('list'); resetWizard() }}>
<ChevronLeft className="w-4 h-4 mr-1" /> <ChevronLeft className="w-4 h-4 mr-1" />
@@ -709,9 +834,6 @@ export default function Termination() {
{/* 草稿列表视图 */} {/* 草稿列表视图 */}
{view === 'list' && ( {view === 'list' && (
<> <>
<PageGuide>
稿
</PageGuide>
<div className="flex gap-2 flex-wrap items-center"> <div className="flex gap-2 flex-wrap items-center">
<Input <Input
placeholder="搜索员工姓名或部门" placeholder="搜索员工姓名或部门"
@@ -787,7 +909,7 @@ export default function Termination() {
<EmptyState <EmptyState
icon={<List className="w-8 h-8 text-gray-400" />} icon={<List className="w-8 h-8 text-gray-400" />}
title="暂无解聘记录" title="暂无解聘记录"
description="点击「新建解聘」开始创建" description="请在花名册中选择员工发起解聘"
/> />
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -1079,7 +1201,7 @@ body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align:
</style></head> </style></head>
<body> <body>
<div class="title">/</div> <div class="title">/</div>
<div class="body"> ${draftDetail.employeeName}${draftDetail.idCardNumber || '—'} ${draftDetail.department || '—'} ${draftDetail.terminationDate} ${reasonLabel} /</div> <div class="body"> ${draftDetail.employeeName}${draftDetail.idCardNumber || '—'} ${draftDetail.department || '—'} ${draftDetail.terminationDate} ${reasonLabel} /</div>
<div class="body">¥${fmt(draftDetail.compensation)}${draftDetail.socialInsEndMonth || '—'}${draftDetail.housingFundEndMonth || '—'}</div> <div class="body">¥${fmt(draftDetail.compensation)}${draftDetail.socialInsEndMonth || '—'}${draftDetail.housingFundEndMonth || '—'}</div>
<div class="body"></div> <div class="body"></div>
<div class="sign"><br/>${new Date().toISOString().slice(0, 10)}</div> <div class="sign"><br/>${new Date().toISOString().slice(0, 10)}</div>
@@ -1268,6 +1390,17 @@ ${items}
{r.steps.map((s, i) => ( {r.steps.map((s, i) => (
<li key={i} className={`text-xs leading-relaxed ${s.includes('⚠') ? 'text-amber-600 font-medium' : 'text-gray-600'}`}>{s}</li> <li key={i} className={`text-xs leading-relaxed ${s.includes('⚠') ? 'text-amber-600 font-medium' : 'text-gray-600'}`}>{s}</li>
))} ))}
{/* 北京地区单方解除须通知工会 */}
{isBeijing && ['FAULT', 'NONFAULT', 'LAYOFF'].includes(r.value) && (
<>
<li className="text-xs leading-relaxed text-amber-600 font-medium">
5///使
</li>
<li className="text-xs leading-relaxed text-amber-600 font-medium">
</li>
</>
)}
</ol> </ol>
</div> </div>
</div> </div>
@@ -1275,7 +1408,26 @@ ${items}
})()} })()}
<div> <div>
<Label></Label> <Label></Label>
<Input type="date" value={terminationDate} onChange={(e) => setTerminationDate(e.target.value)} /> <Input type="date" value={terminationDate} onChange={(e) => {
const date = e.target.value
setTerminationDate(date)
// 自动推导社保/公积金截止月:15号前(含)截止上月,15号后截止当月
if (date) {
const d = new Date(date)
const day = d.getDate()
const monthStr = date.slice(0, 7)
if (day <= 15) {
// 上月
const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1)
const prevMonth = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
setSocialInsEndMonth(prevMonth)
setHousingFundEndMonth(prevMonth)
} else {
setSocialInsEndMonth(monthStr)
setHousingFundEndMonth(monthStr)
}
}
}} />
</div> </div>
{/* 实时费用预览 - 当有足够数据时在解聘方式下方显示 */} {/* 实时费用预览 - 当有足够数据时在解聘方式下方显示 */}
{step === 1 && costResult && ( {step === 1 && costResult && (
@@ -1406,6 +1558,101 @@ ${items}
</div> </div>
) )
})} })}
{/* 工会回执上传区域(北京地区单方解除,checklist 包含 union_receipt 项时显示) */}
{checklistItems?.some(item => item.key === 'union_receipt') && checklist['notify_union'] && (
<div className="border rounded-md p-3 space-y-3 bg-blue-50/30">
<div className="text-xs font-medium text-gray-700 flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-primary" />
</div>
<div className="text-xs text-gray-500">
稿
</div>
{unionReceipt && unionReceipt.fileUrl ? (
/* 已上传回执展示 */
<div className="space-y-2 bg-white rounded-md p-2.5 border">
<div className="flex items-center gap-2 text-xs">
<CheckCircle className="w-4 h-4 text-safe" />
<span className="font-medium"></span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
<div>{unionReceipt.receiptNo || '—'}</div>
<div>{unionReceipt.unionName || '—'}</div>
<div>{unionReceipt.receiptDate || '—'}</div>
<div><a href={unionReceipt.fileUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{unionReceipt.fileName}</a></div>
</div>
{unionReceipt.unionOpinion && (
<div className="text-xs text-gray-600">{unionReceipt.unionOpinion}</div>
)}
<Button size="sm" variant="secondary" onClick={() => setUnionReceipt(null)}></Button>
</div>
) : (
/* 上传表单 */
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<Label></Label>
<Input value={unionReceipt?.receiptNo || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), receiptNo: e.target.value }))} placeholder="如:2026第001号" />
</div>
<div>
<Label></Label>
<Input value={unionReceipt?.unionName || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), unionName: e.target.value }))} placeholder="如:XX街道总工会" />
</div>
<div>
<Label></Label>
<Input type="date" value={unionReceipt?.receiptDate || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), receiptDate: e.target.value }))} />
</div>
<div>
<Label></Label>
<Input value={unionReceipt?.unionOpinion || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), unionOpinion: e.target.value }))} placeholder="如有意见建议请填写" />
</div>
</div>
<div>
<Label></Label>
<input
type="file"
accept=".jpg,.jpeg,.png,.pdf,.bmp"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file || !draftId) return
setUploadingReceipt(true)
try {
const res = await terminationApi.uploadUnionReceipt(draftId, file)
setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), fileUrl: res.fileUrl, fileName: res.fileName }))
toast.success('文件上传成功')
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '上传失败')
} finally {
setUploadingReceipt(false)
}
}}
className="block w-full text-xs text-gray-500 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:bg-primary file:text-white hover:file:bg-primary/90"
/>
{uploadingReceipt && <div className="text-xs text-gray-400 mt-1">...</div>}
</div>
{draftId && unionReceipt?.fileUrl && (
<Button
size="sm"
onClick={async () => {
try {
await terminationApi.saveUnionReceipt(draftId, unionReceipt)
toast.success('工会回执已保存到证据链')
setChecklist({ ...checklist, union_receipt: true })
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '保存失败')
}
}}
></Button>
)}
{!draftId && (
<div className="text-xs text-amber-600">稿</div>
)}
</div>
)}
</div>
)}
</div> </div>
)} )}
@@ -1428,6 +1675,57 @@ ${items}
)} )}
</div> </div>
{/* 补偿月数和计算基数选择 */}
{!costResult.noComp && (
<div className="border rounded-md p-3 space-y-3 bg-blue-50/30">
<div className="text-xs font-medium text-gray-700"></div>
{/* 补偿月数模式 */}
<div>
<Label></Label>
<div className="flex gap-2 flex-wrap">
{(['N', 'N+1', '2N', 'CUSTOM'] as const).map(mode => (
<button
key={mode}
type="button"
onClick={() => setCompMonthMode(mode)}
className={`px-3 py-1 rounded text-xs border ${compMonthMode === mode ? 'bg-primary text-white border-primary' : 'bg-white text-gray-600 border-gray-300 hover:border-primary'}`}
>
{mode === 'N' ? 'N(法定)' : mode === 'N+1' ? 'N+1(含代通知)' : mode === '2N' ? '2N(违法解除)' : '自定义'}
</button>
))}
</div>
{compMonthMode === 'CUSTOM' && (
<div className="mt-2 space-y-1">
<Input type="number" value={customCompMonths || ''} onChange={(e) => setCustomCompMonths(Number(e.target.value) || 0)} placeholder="自定义补偿月数" />
<Input value={customCompReason} onChange={(e) => setCustomCompReason(e.target.value)} placeholder="自定义原因(必填)" />
</div>
)}
</div>
{/* 计算基数来源 */}
<div>
<Label></Label>
<div className="flex gap-2 flex-wrap">
{(['AVG_12', 'CONTRACT', 'CUSTOM'] as const).map(bt => (
<button
key={bt}
type="button"
onClick={() => setWageBaseType(bt)}
className={`px-3 py-1 rounded text-xs border ${wageBaseType === bt ? 'bg-primary text-white border-primary' : 'bg-white text-gray-600 border-gray-300 hover:border-primary'}`}
>
{bt === 'AVG_12' ? '近12月平均工资' : bt === 'CONTRACT' ? '合同约定工资' : '自定义基数'}
</button>
))}
</div>
{wageBaseType === 'CUSTOM' && (
<div className="mt-2 space-y-1">
<Input type="number" value={customWageBase || ''} onChange={(e) => setCustomWageBase(Number(e.target.value) || 0)} placeholder="自定义月工资基数" />
<Input value={customWageReason} onChange={(e) => setCustomWageReason(e.target.value)} placeholder="自定义原因(必填)" />
</div>
)}
</div>
</div>
)}
{/* 经济补偿金 / 赔偿金 */} {/* 经济补偿金 / 赔偿金 */}
{costResult.noComp ? ( {costResult.noComp ? (
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs"> <div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
@@ -1496,8 +1794,55 @@ ${items}
{/* 合计 */} {/* 合计 */}
<div className="border-t pt-3"> <div className="border-t pt-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium"></span> <span className="font-medium"></span>
<span className="text-lg font-bold text-danger">¥{fmt(costResult.grandTotal)}</span> <span className="text-lg font-bold text-gray-700">¥{fmt(costResult.grandTotal)}</span>
</div>
{compAdjustments.length > 0 && (
<div className="flex items-center justify-between mt-1">
<span className="font-medium text-primary"></span>
<span className="text-lg font-bold text-danger">¥{fmt(adjustedTotal)}</span>
</div>
)}
</div>
{/* 违法解除风险提醒:实际补偿金低于法定2N时需确认 */}
{reason === 'ILLEGAL' && costResult && adjustedTotal < costResult.grandTotal && (
<div className="border border-danger rounded-md p-3 space-y-2 bg-red-50">
<div className="text-xs font-medium text-danger flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4" />
</div>
<p className="text-xs text-danger">
287 ¥{fmt(adjustedTotal)} ¥{fmt(costResult.grandTotal)} ¥{fmt(costResult.grandTotal - adjustedTotal)}
</p>
<p className="text-xs text-danger"></p>
<label className="flex items-center gap-2 text-xs text-danger cursor-pointer">
<input
type="checkbox"
checked={illegalRiskAcknowledged}
onChange={(e) => setIllegalRiskAcknowledged(e.target.checked)}
/>
<span></span>
</label>
</div>
)}
{/* 剩余年假折算 */}
<div className="border rounded-md p-3 space-y-2 bg-green-50/30">
<div className="text-xs font-medium flex items-center gap-1.5">
<Calculator className="w-3.5 h-3.5" />
</div>
<div className="text-xs text-gray-500">300%5</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<Label></Label>
<Input type="number" min="0" step="0.5" value={annualLeaveDays || ''} onChange={(e) => setAnnualLeaveDays(Number(e.target.value) || 0)} placeholder="0" />
</div>
{annualLeaveDays > 0 && costResult && (
<div className="text-xs text-gray-600">
<div>¥{fmt((costResult.cappedWage || 0) / 21.75)}/</div>
<div className="font-medium text-danger">¥{fmt(annualLeavePay)}</div>
</div>
)}
</div> </div>
</div> </div>
@@ -1666,6 +2011,13 @@ ${items}
</> </>
)} )}
<div>{handoverItems.filter(i => i.done).length}/{handoverItems.length} </div> <div>{handoverItems.filter(i => i.done).length}/{handoverItems.length} </div>
{checklistItems?.some(item => item.key === 'notify_union') && (
<div className={checklist['notify_union'] ? 'text-safe' : 'text-danger'}>
{checklist['notify_union'] ? '已通知' : '⚠ 未通知工会(北京地区单方解除必经程序)'}
{checklist['notify_union'] && checklist['union_receipt'] ? ' · 已收到回执' : checklist['notify_union'] ? ' · 未收到回执' : ''}
{unionReceipt?.fileUrl && <a href={unionReceipt.fileUrl} target="_blank" rel="noopener noreferrer" className="ml-1 text-primary hover:underline"></a>}
</div>
)}
</div> </div>
{/* 风险提示 */} {/* 风险提示 */}
@@ -1884,11 +2236,11 @@ ${items}
</Button> </Button>
{step < 4 ? ( {step < 4 ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!canProceed() && step === 2 && checklistItems?.some(item => item.suggestionType === 'required' && !checklist[item.key]) && ( {step === 2 && checklistItems?.some(item => item.suggestionType === 'required' && !checklist[item.key]) && (
<span className="text-xs text-danger"></span> <span className="text-xs text-amber-600"></span>
)} )}
{!canProceed() && step === 4 && handoverItems.some(item => (item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke') && !item.done) && ( {step === 4 && handoverItems.some(item => (item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke') && !item.done) && (
<span className="text-xs text-danger"></span> <span className="text-xs text-amber-600"></span>
)} )}
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}> <Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
<ChevronRight className="w-4 h-4 ml-1" /> <ChevronRight className="w-4 h-4 ml-1" />
-896
View File
@@ -1,896 +0,0 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { useUnsavedChanges } from '../hooks/useUnsavedChanges'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { toastError } from '../lib/errorToast'
import {
UserPlus, LogIn, FileSignature, Edit, CheckCircle, RefreshCw,
Repeat, Pause, FileText, Briefcase,
Loader2, ChevronRight, Trash2, Send, X, Eye, Search, Download, Users,
} from 'lucide-react'
import { workProcessApi, templatesApi, employeeApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import Pagination from '../components/ui/Pagination'
import PageGuide from '../components/ui/PageGuide'
import QueryError from '../components/ui/QueryError'
const PROCESS_ICONS: Record<string, any> = {
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
INFO_SUBMIT: Edit, CONFIRM: CheckCircle, CHANGE: RefreshCw,
RENEW: Repeat, SUSPEND: Pause, INCOME_CERT: FileText,
FLEXIBLE: Briefcase,
}
const PROCESS_TYPES: Record<string, { label: string; description: string }> = {
HIRE: { label: '员工录用', description: '录用新员工并起草劳动合同' },
ONBOARD: { label: '员工入职', description: '办理员工入职手续' },
CUSTOM_CONTRACT: { label: '自定义合同签署', description: '自定义合同内容并签署' },
INFO_SUBMIT: { label: '员工信息提交', description: '提交员工信息变更' },
CONFIRM: { label: '员工转正', description: '试用期员工转正' },
CHANGE: { label: '合同变更', description: '变更合同内容' },
RENEW: { label: '合同续签', description: '到期合同续签' },
SUSPEND: { label: '合同中止', description: '中止履行合同' },
INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明' },
FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署' },
}
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' },
PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' },
APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' },
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' },
EXECUTING: { label: '执行中', color: 'bg-purple-50 text-purple-700' },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
CANCELLED: { label: '已撤销', color: 'bg-gray-100 text-gray-400' },
}
// 各流程类型的表单字段配置(required 标记必填项)
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea' | 'enterprise-template' | 'employee-select' | 'contract-select'; options?: string[]; required?: boolean }[]> = {
HIRE: [
{ key: 'name', label: '员工姓名', type: 'text', required: true },
{ key: 'department', label: '部门', type: 'text', required: true },
{ key: 'hireDate', label: '入职日期', type: 'date', required: true },
{ key: 'monthlySalary', label: '月薪', type: 'number' },
{ key: 'phone', label: '手机号', type: 'text', required: true },
{ key: 'idCardNumber', label: '身份证号', type: 'text', required: true },
{ key: 'gender', label: '性别', type: 'select', options: ['男', '女'] },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
],
ONBOARD: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'hireDate', label: '入职日期', type: 'date', required: true },
],
CUSTOM_CONTRACT: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date', required: true },
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
{ key: 'contractType', label: '合同类型', type: 'select', options: ['FIXED', 'UNFIXED', 'INTERNSHIP'] },
],
INFO_SUBMIT: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'address', label: '地址', type: 'text' },
{ key: 'emergencyContact', label: '紧急联系人', type: 'text' },
{ key: 'emergencyPhone', label: '紧急联系电话', type: 'text' },
],
CONFIRM: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'confirmDate', label: '转正日期', type: 'date', required: true },
{ key: 'regularSalary', label: '转正薪资', type: 'number' },
],
CHANGE: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select', required: true },
{ key: 'newEndDate', label: '新到期日期', type: 'date', required: true },
],
RENEW: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'oldContractId', label: '原合同ID', type: 'text' },
{ key: 'newStartDate', label: '新合同开始日期', type: 'date', required: true },
{ key: 'newEndDate', label: '新合同结束日期', type: 'date' },
{ key: 'newSalary', label: '新薪资', type: 'number' },
],
SUSPEND: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select', required: true },
{ key: 'suspendDate', label: '中止日期', type: 'date', required: true },
],
INCOME_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
{ key: 'monthlyIncome', label: '月收入', type: 'text' },
{ key: 'purpose', label: '用途', type: 'text' },
],
TERMINATE: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
{ key: 'terminateDate', label: '终止日期', type: 'date', required: true },
{ key: 'reason', label: '终止原因', type: 'select', options: ['EXPIRED', 'NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'], required: true },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
RESCIND: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
{ key: 'rescindDate', label: '解除日期', type: 'date', required: true },
{ key: 'reason', label: '解除原因', type: 'select', options: ['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'], required: true },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
LEAVING_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
{ key: 'leaveDate', label: '离职日期', type: 'date', required: true },
],
FLEXIBLE: [
{ key: 'name', label: '姓名', type: 'text', required: true },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text', required: true },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'agreementStartDate', label: '协议开始日期', type: 'date', required: true },
{ key: 'agreementEndDate', label: '协议结束日期', type: 'date' },
{ key: 'payMethod', label: '计酬方式', type: 'text' },
],
}
// 字段 key → 中文 label 映射(用于展示已保存的表单数据)
const FIELD_LABEL_MAP: Record<string, string> = Object.values(FORM_FIELDS).flat().reduce((acc, f) => {
acc[f.key] = f.label
return acc
}, {} as Record<string, string>)
export default function WorkProcess() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedType, setSelectedType] = useState<string>(() => {
try { return localStorage.getItem('workprocess-draft-type') || '' } catch { return '' }
})
const [formData, setFormData] = useState<Record<string, any>>(() => {
try {
const saved = localStorage.getItem('workprocess-draft-data')
return saved ? JSON.parse(saved) : {}
} catch { return {} }
})
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
const [previewContent, setPreviewContent] = useState<string | null>(null)
const [showBatch, setShowBatch] = useState(false)
const [batchType, setBatchType] = useState<string>('INCOME_CERT')
const [batchEmployees, setBatchEmployees] = useState<string[]>([])
const [batchSearch, setBatchSearch] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
// 持久化草稿到 localStorage,防止录入数据丢失
useEffect(() => {
try {
if (selectedType && Object.keys(formData).length > 0) {
localStorage.setItem('workprocess-draft-type', selectedType)
localStorage.setItem('workprocess-draft-data', JSON.stringify(formData))
} else {
localStorage.removeItem('workprocess-draft-type')
localStorage.removeItem('workprocess-draft-data')
}
} catch {}
}, [selectedType, formData])
const isDirty = selectedType && Object.keys(formData).length > 0
useUnsavedChanges(!!isDirty)
const { data: listData, isLoading, isError, error, refetch } = useQuery({
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
queryFn: async () => {
const params: any = { page, pageSize }
if (filterType) params.type = filterType
if (filterStatus) params.status = filterStatus
return await workProcessApi.list({ page, pageSize, type: filterType || undefined, status: filterStatus || undefined } as any)
},
})
const createMutation = useMutation({
mutationFn: async (data: any) => {
return await workProcessApi.create(data)
},
onSuccess: () => {
toast.success('已创建草稿,可在列表中查看详情并提交', {
action: { label: '去提交', onClick: () => {} },
})
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowCreate(false)
setFormData({})
setSelectedType('')
},
onError: (err: any) => toastError(err, '创建失败'),
})
const submitMutation = useMutation({
mutationFn: async (id: string) => {
return await workProcessApi.submit(id)
},
onSuccess: () => {
toast.success('已提交并执行', {
action: { label: '查看文书', onClick: () => navigate('/evidence') },
})
toast.info('文书已生成,可在「证据管理」中查看和下载', { duration: 5000 })
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toastError(err, '提交失败'),
})
const cancelMutation = useMutation({
mutationFn: async (id: string) => {
return await workProcessApi.cancel(id)
},
onSuccess: () => {
toast.success('已撤销')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toastError(err, '撤销失败'),
})
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await workProcessApi.remove(id)
},
onSuccess: () => {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
},
onError: (err: any) => toastError(err, '删除失败'),
})
const previewMutation = useMutation({
mutationFn: async (id: string) => {
return await workProcessApi.preview(id)
},
onSuccess: (data) => {
setPreviewContent(data.content)
},
onError: (err: any) => toastError(err, '预览失败'),
})
const handleCreate = () => {
if (!selectedType) {
toast.error('请选择流程类型')
return
}
// 校验必填项
const requiredFields = (FORM_FIELDS[selectedType] || []).filter(f => f.required)
const missingFields = requiredFields.filter(f => !formData[f.key] || String(formData[f.key]).trim() === '')
if (missingFields.length > 0) {
toast.error(`请填写必填项:${missingFields.map(f => f.label).join('、')}`)
return
}
createMutation.mutate({
type: selectedType,
title: PROCESS_TYPES[selectedType].label,
employeeId: formData.employeeId || undefined,
formData,
status: 'DRAFT',
})
}
const handleCreateAndSubmit = () => {
if (!selectedType) {
toast.error('请选择流程类型')
return
}
createMutation.mutate(
{ type: selectedType, title: PROCESS_TYPES[selectedType].label, employeeId: formData.employeeId || undefined, formData, status: 'DRAFT' },
{
onSuccess: (data: any) => {
const newId = data?.id
if (newId) {
submitMutation.mutate(newId)
} else {
toast.success('草稿已创建,请手动提交')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
}
setShowCreate(false)
setFormData({})
setSelectedType('')
},
}
)
}
const handleFieldChange = (key: string, value: any) => {
setFormData(prev => ({ ...prev, [key]: value }))
// 选择员工后自动填充相关字段
if (key === 'employeeId' && value) {
employeeApi.detail(value).then((emp: any) => {
setFormData(prev => ({
...prev,
employeeName: emp.name || prev.employeeName,
idCardNumber: emp.idCardNumber || prev.idCardNumber,
position: emp.position || prev.position,
monthlyIncome: emp.monthlySalary ? String(emp.monthlySalary) : prev.monthlyIncome,
hireDate: emp.hireDate ? emp.hireDate.slice(0, 10) : prev.hireDate,
department: emp.department || prev.department,
phone: emp.phone || prev.phone,
}))
}).catch(() => {})
}
}
const handleBatchSubmit = () => {
if (batchEmployees.length === 0) {
toast.error('请至少选择一名员工')
return
}
let success = 0
let failed = 0
Promise.all(
batchEmployees.map(async (empId) => {
try {
const emp = allEmployees.find((e: any) => e.id === empId)
if (!emp) return
const data: any = {
type: batchType,
title: PROCESS_TYPES[batchType].label,
employeeId: empId,
formData: {
employeeName: emp.name,
idCardNumber: emp.idCardNumber || '',
position: emp.position || '',
},
status: 'DRAFT',
}
const res: any = await workProcessApi.create(data)
if (res?.id) {
await workProcessApi.submit(res.id)
success++
}
} catch {
failed++
}
})
).then(() => {
toast.success(`批量开具完成:成功 ${success}${failed > 0 ? ',失败 ' + failed + ' 个' : ''}`)
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowBatch(false)
setBatchEmployees([])
setBatchSearch('')
})
}
const { data: allEmployees = [] } = useQuery<any[]>({
queryKey: ['employee-list-batch'],
queryFn: async () => {
return await employeeApi.allLite()
},
})
const filteredEmployees = allEmployees.filter((e: any) => {
if (!batchSearch) return true
return e.name.includes(batchSearch) || (e.department || '').includes(batchSearch)
})
const items = listData?.items || []
const total = listData?.total || 0
return (
<div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 发起办理 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<div className="flex items-center gap-2">
{isDirty && (
<span className="text-xs text-amber-600 flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
稿{PROCESS_TYPES[selectedType]?.label}
<button className="text-primary hover:underline" onClick={() => { setSelectedType(''); setFormData({}) }}></button>
</span>
)}
<Button size="sm" onClick={() => setShowCreate(true)}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowBatch(true)}>
<Users className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 13类流程卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
const Icon = PROCESS_ICONS[key] || FileText
return (
<button
key={key}
onClick={() => {
setSelectedType(key)
setShowCreate(true)
setFormData({})
}}
className="flex items-start gap-2 p-3 rounded-md border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
<Icon className="w-4 h-4 text-primary mt-0.5 shrink-0" />
<div className="min-w-0">
<div className="text-xs font-medium text-gray-900">{config.label}</div>
<div className="text-[10px] text-gray-500 truncate">{config.description}</div>
</div>
</button>
)
})}
</div>
</Card>
{/* 办理记录 */}
<Card>
<div className="flex items-center gap-3 mb-4">
<h3 className="text-sm font-medium"></h3>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="w-32">
<option value=""></option>
{Object.entries(PROCESS_TYPES).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
</Select>
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="w-32">
<option value=""></option>
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
</Select>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
) : isError ? (
<QueryError error={error} onRetry={refetch} />
) : items.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400"></div>
) : (
<div className="space-y-2">
{items.map((item: any) => {
const Icon = PROCESS_ICONS[item.type] || FileText
const statusCfg = STATUS_CONFIG[item.status] || STATUS_CONFIG.DRAFT
return (
<div
key={item.id}
className="flex items-center gap-3 p-3 rounded-md border border-gray-200 hover:bg-gray-50 cursor-pointer"
onClick={() => setDetailId(item.id)}
>
<Icon className="w-4 h-4 text-gray-400 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">{item.title}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{item.employee ? `${item.employee.name} · ${item.employee.department}` : (item.formData?.employeeName || item.formData?.name || '未关联员工')}
{' · '}{new Date(item.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
{(item.status === 'COMPLETED' || item.status === 'EXECUTING') && item.documents && item.documents.length > 0 && (
<button
className="text-xs text-primary hover:underline shrink-0 flex items-center gap-0.5"
onClick={(e) => { e.stopPropagation(); setDetailId(item.id) }}
>
<Eye className="w-3.5 h-3.5" />
</button>
)}
<ChevronRight className="w-4 h-4 text-gray-300" />
</div>
)
})}
</div>
)}
<Pagination
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={() => setPage(1)}
/>
</Card>
{/* 创建/编辑弹窗 */}
<Modal open={showCreate} onClose={() => setShowCreate(false)} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
{!selectedType ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
const Icon = PROCESS_ICONS[key] || FileText
return (
<button
key={key}
onClick={() => setSelectedType(key)}
className="flex items-start gap-2 p-3 rounded-md border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
<Icon className="w-4 h-4 text-primary mt-0.5 shrink-0" />
<div>
<div className="text-xs font-medium">{config.label}</div>
<div className="text-[10px] text-gray-500">{config.description}</div>
</div>
</button>
)
})}
</div>
) : (
<div className="space-y-3">
<div className="text-xs text-gray-500 mb-2">{PROCESS_TYPES[selectedType]?.description}</div>
{(FORM_FIELDS[selectedType] || []).map(field => (
<div key={field.key}>
<Label>{field.label}{field.required && <span className="text-danger ml-0.5">*</span>}</Label>
{field.type === 'select' ? (
<Select value={formData[field.key] || ''} onChange={(e) => handleFieldChange(field.key, e.target.value)}>
<option value=""></option>
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</Select>
) : field.type === 'textarea' ? (
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm min-h-[80px]"
value={formData[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
) : field.type === 'enterprise-template' ? (
<EnterpriseTemplateSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
) : field.type === 'employee-select' ? (
<EmployeeSelect value={formData[field.key] || ''} onChange={(emp) => {
handleFieldChange(field.key, emp.id)
if (emp.name) handleFieldChange('employeeName', emp.name)
if (emp.idCardNumber) handleFieldChange('idCardNumber', emp.idCardNumber)
if (emp.position) handleFieldChange('position', emp.position)
if (emp.monthlySalary) handleFieldChange('monthlyIncome', String(emp.monthlySalary))
if (emp.hireDate) handleFieldChange('hireDate', emp.hireDate?.slice(0, 10))
if (emp.department) handleFieldChange('department', emp.department)
if (emp.phone) handleFieldChange('phone', emp.phone)
}} />
) : field.type === 'contract-select' ? (
<ContractSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} employeeId={formData['employeeId'] || ''} />
) : (
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
value={formData[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
)}
</div>
))}
<div className="flex items-center gap-2 pt-2">
<Button onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
稿
</Button>
<Button onClick={handleCreateAndSubmit} disabled={createMutation.isPending || submitMutation.isPending}>
{(createMutation.isPending || submitMutation.isPending) ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
</Button>
<Button variant="secondary" onClick={() => setSelectedType('')}>
</Button>
</div>
</div>
)}
</Modal>
{/* 详情弹窗 */}
<Modal open={!!detailId} onClose={() => { setDetailId(null); setPreviewContent(null) }} title="办理详情" size="lg">
<DetailContent
id={detailId}
previewContent={previewContent}
onPreview={(id) => previewMutation.mutate(id)}
onSubmit={(id) => submitMutation.mutate(id)}
onCancel={(id) => cancelMutation.mutate(id)}
onDelete={(id) => deleteMutation.mutate(id)}
loading={submitMutation.isPending || cancelMutation.isPending}
/>
</Modal>
{/* 批量开具证明弹窗 */}
<Modal open={showBatch} onClose={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }} title="批量开具证明" size="lg">
<div className="space-y-4">
<div>
<Label></Label>
<Select value={batchType} onChange={(e) => setBatchType(e.target.value)}>
<option value="INCOME_CERT"></option>
<option value="LEAVING_CERT"></option>
</Select>
</div>
<div>
<Label> {batchEmployees.length} </Label>
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={batchSearch}
onChange={(e) => setBatchSearch(e.target.value)}
placeholder="搜索姓名或部门..."
className="pl-9"
/>
</div>
<div className="max-h-[300px] overflow-y-auto border rounded-md">
{filteredEmployees.map((emp: any) => (
<label
key={emp.id}
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer border-b last:border-0"
>
<input
type="checkbox"
checked={batchEmployees.includes(emp.id)}
onChange={() => {
setBatchEmployees(prev =>
prev.includes(emp.id) ? prev.filter(id => id !== emp.id) : [...prev, emp.id]
)
}}
/>
<span className="text-sm flex-1">{emp.name}</span>
<span className="text-xs text-gray-400">{emp.department || '—'}</span>
</label>
))}
{filteredEmployees.length === 0 && (
<div className="text-center py-4 text-xs text-gray-400"></div>
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="secondary" size="sm" onClick={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }}></Button>
<Button size="sm" onClick={handleBatchSubmit} disabled={batchEmployees.length === 0}>
<Send className="w-4 h-4 mr-1" />
{batchEmployees.length}
</Button>
</div>
</div>
</Modal>
</div>
)
}
function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDelete, loading }: {
id: string | null
previewContent: string | null
onPreview: (id: string) => void
onSubmit: (id: string) => void
onCancel: (id: string) => void
onDelete: (id: string) => void
loading: boolean
}) {
const { data, isLoading } = useQuery({
queryKey: ['work-process', id],
queryFn: async () => {
return await workProcessApi.detail(id!)
},
enabled: !!id,
})
if (isLoading || !data) return <div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.DRAFT
const Icon = PROCESS_ICONS[data.type] || FileText
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Icon className="w-5 h-5 text-primary" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{data.title}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{PROCESS_TYPES[data.type]?.label} · {data.employee ? `${data.employee.name}${data.employee.department}` : (data.formData?.employeeName || data.formData?.name || '未关联员工')}
</div>
</div>
</div>
{/* 表单数据 */}
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<div className="bg-gray-50 rounded-md p-3 space-y-1">
{Object.entries(data.formData || {}).map(([key, value]: [string, any]) => (
<div key={key} className="flex text-xs">
<span className="text-gray-500 w-28 shrink-0">{FIELD_LABEL_MAP[key] || key}</span>
<span className="text-gray-900">{key === 'enterpriseTemplateId' && value ? `已关联企业模板` : (value ? String(value) : '-')}</span>
</div>
))}
{Object.keys(data.formData || {}).length === 0 && <span className="text-xs text-gray-400"></span>}
</div>
</div>
{/* 文书预览 */}
{previewContent && (
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<pre className="bg-gray-50 rounded-md p-3 text-xs whitespace-pre-wrap max-h-[300px] overflow-y-auto">{previewContent}</pre>
</div>
)}
{/* 生成的文书 */}
{data.documents && data.documents.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<div className="space-y-2">
{data.documents.map((doc: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded-md p-2">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="flex-1 truncate">{doc.name}</span>
<button
type="button"
className="text-primary hover:underline shrink-0"
onClick={() => onPreview(data.id)}
>
<Eye className="w-3.5 h-3.5 inline mr-0.5" />
</button>
<button
type="button"
className="text-primary hover:underline shrink-0"
onClick={() => {
const content = previewContent || ''
if (!content) {
onPreview(data.id)
return
}
const blob = new Blob(['\ufeff' + content], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = doc.name.endsWith('.doc') ? doc.name : `${doc.name}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-3.5 h-3.5 inline mr-0.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-2 pt-2 border-t">
{data.status === 'DRAFT' && (
<>
<Button size="sm" onClick={() => onPreview(data.id)} variant="secondary">
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" onClick={() => onSubmit(data.id)} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
</Button>
<Button size="sm" variant="danger" onClick={() => onDelete(data.id)}>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
</>
)}
{!['COMPLETED', 'CANCELLED'].includes(data.status) && data.status !== 'DRAFT' && (
<Button size="sm" variant="secondary" onClick={() => onCancel(data.id)} disabled={loading}>
<X className="w-4 h-4 mr-1" />
</Button>
)}
</div>
</div>
)
}
function EnterpriseTemplateSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { data, isLoading } = useQuery<any>({
queryKey: ['enterprise-templates-for-cert'],
queryFn: async () => {
const res = await templatesApi.enterpriseList({ pageSize: 100 } as any)
return res
},
})
const items = data?.items || []
return (
<div>
<Select value={value} onChange={(e) => onChange(e.target.value)} disabled={isLoading}>
<option value="">{isLoading ? '加载中...' : '使用系统默认模板'}</option>
{items.map((t: any) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</Select>
{items.length === 0 && !isLoading && (
<p className="text-xs text-gray-400 mt-1"></p>
)}
</div>
)
}
function EmployeeSelect({ value, onChange }: { value: string; onChange: (employee: any) => void }) {
const [search, setSearch] = useState('')
const [open, setOpen] = useState(false)
const { data: employees = [], isLoading } = useQuery<any[]>({
queryKey: ['employee-list-for-select'],
queryFn: async () => {
return await employeeApi.allLite()
},
})
const filtered = employees.filter((e: any) => {
if (!search) return true
return e.name.includes(search) || (e.department || '').includes(search) || (e.phone || '').includes(search)
})
const selected = employees.find((e: any) => e.id === value)
return (
<div className="relative">
<div
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm cursor-pointer flex items-center justify-between"
onClick={() => setOpen(!open)}
>
{selected ? (
<span>{selected.name} · {selected.department || '未分配部门'}</span>
) : (
<span className="text-gray-400">{isLoading ? '加载中...' : '点击选择员工'}</span>
)}
<Search className="w-3.5 h-3.5 text-gray-400" />
</div>
{open && (
<div className="absolute z-50 mt-1 w-full bg-white rounded-md border border-gray-200 shadow-lg max-h-[240px] overflow-hidden">
<div className="p-2 border-b border-gray-100">
<input
type="text"
autoFocus
placeholder="搜索姓名/部门/手机号"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="overflow-y-auto max-h-[180px]">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-gray-400"></div>
) : (
filtered.map((e: any) => (
<div
key={e.id}
className="px-3 py-2 text-sm hover:bg-primary/5 cursor-pointer flex items-center justify-between"
onClick={() => {
onChange(e)
setOpen(false)
setSearch('')
}}
>
<span>{e.name}</span>
<span className="text-xs text-gray-400">{e.department || ''}</span>
</div>
))
)}
</div>
</div>
)}
</div>
)
}
function ContractSelect({ value, onChange, employeeId }: { value: string; onChange: (v: string) => void; employeeId: string }) {
const { data: contracts = [], isLoading } = useQuery<any[]>({
queryKey: ['employee-contracts', employeeId],
queryFn: async () => {
if (!employeeId) return []
const res = await employeeApi.detail(employeeId)
return res?.contracts || []
},
enabled: !!employeeId,
})
const activeContracts = contracts.filter((c: any) => c.status === 'ACTIVE' || c.status === 'SUSPENDED')
return (
<Select value={value} onChange={(e) => onChange(e.target.value)} disabled={!employeeId}>
<option value="">{!employeeId ? '请先选择员工' : isLoading ? '加载中...' : activeContracts.length === 0 ? '无可用合同' : '请选择合同'}</option>
{activeContracts.map((c: any) => (
<option key={c.id} value={c.id}>
{c.contractType === 'UNFIXED' ? '无固定期限' : `${c.startDate?.slice(0, 10)} ~ ${c.endDate?.slice(0, 10)}`}{c.status === 'SUSPENDED' ? '(已中止)' : ''}
</option>
))}
</Select>
)
}
+1 -1
View File
@@ -238,7 +238,7 @@ export function ChatTab() {
} }
return ( return (
<div className="flex flex-col" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}> <div className="flex flex-col" style={{ height: 'calc(100vh - 250px)', minHeight: '400px' }}>
{/* 顶部操作栏 */} {/* 顶部操作栏 */}
<div className="flex items-center gap-2 pb-2 border-b"> <div className="flex items-center gap-2 pb-2 border-b">
<Button size="sm" variant="secondary" onClick={newConversation}><Plus className="w-4 h-4 mr-1" /></Button> <Button size="sm" variant="secondary" onClick={newConversation}><Plus className="w-4 h-4 mr-1" /></Button>
+2 -2
View File
@@ -60,8 +60,8 @@ export default function ForgotPassword() {
<div className="min-h-screen flex items-center justify-center bg-surface px-4"> <div className="min-h-screen flex items-center justify-center bg-surface px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8"> <div className="flex items-center justify-center gap-2 mb-8">
<Logo className="w-8 h-8 text-primary" /> <Logo className="w-8 h-8 object-contain" />
<span className="text-xl font-bold"></span> <span className="text-xl font-bold"></span>
</div> </div>
<div className="card"> <div className="card">
+8 -12
View File
@@ -89,14 +89,10 @@ export default function Login() {
<div className="relative z-10 flex flex-col justify-between p-12 xl:p-16 text-white w-full"> <div className="relative z-10 flex flex-col justify-between p-12 xl:p-16 text-white w-full">
{/* Logo + 品牌名 */} {/* Logo + 品牌名 */}
<div className="flex items-center gap-3"> <div>
<div className="w-11 h-11 rounded-xl bg-white/15 backdrop-blur flex items-center justify-center"> <Logo className="w-32 h-32 object-contain mb-4" />
<Logo className="w-7 h-7 text-white" /> <div className="text-xl font-bold tracking-tight"></div>
</div> <div className="text-xs text-white/60 mt-0.5"></div>
<div>
<div className="text-xl font-bold tracking-tight"></div>
<div className="text-xs text-white/60 mt-0.5">TurboHR · </div>
</div>
</div> </div>
{/* 标语 */} {/* 标语 */}
@@ -126,7 +122,7 @@ export default function Login() {
{/* 底部版权 */} {/* 底部版权 */}
<div className="text-xs text-white/40"> <div className="text-xs text-white/40">
© 2026 TurboHR · © 2026
</div> </div>
</div> </div>
</div> </div>
@@ -135,9 +131,9 @@ export default function Login() {
<div className="flex-1 flex items-center justify-center px-4 py-12"> <div className="flex-1 flex items-center justify-center px-4 py-12">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
{/* 移动端 Logo */} {/* 移动端 Logo */}
<div className="flex lg:hidden items-center justify-center gap-2 mb-8"> <div className="flex lg:hidden flex-col items-center mb-8">
<Logo className="w-8 h-8 text-primary" /> <Logo className="w-24 h-24 object-contain mb-3" />
<span className="text-xl font-bold"></span> <span className="text-xl font-bold"></span>
</div> </div>
{/* 欢迎语 */} {/* 欢迎语 */}
+2 -2
View File
@@ -51,8 +51,8 @@ export default function Register() {
<div className="min-h-screen flex items-center justify-center bg-surface px-4"> <div className="min-h-screen flex items-center justify-center bg-surface px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8"> <div className="flex items-center justify-center gap-2 mb-8">
<Logo className="w-8 h-8 text-primary" /> <Logo className="w-8 h-8 object-contain" />
<span className="text-xl font-bold"></span> <span className="text-xl font-bold"></span>
</div> </div>
<div className="card"> <div className="card">
+172 -9
View File
@@ -3,7 +3,7 @@ import { usePageSize } from '../../hooks/usePageSize'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm' import { useConfirm } from '../../hooks/useConfirm'
import { Calculator, AlertCircle, Info, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Clock, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react' import { Calculator, AlertCircle, Info, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Clock, Users, TrendingDown, TrendingUp, BadgeCheck, DollarSign, CalendarCheck } from 'lucide-react'
import { Stepper, type Step } from '../../components/ui/Stepper' import { Stepper, type Step } from '../../components/ui/Stepper'
import { InlineAlert } from '../../components/ui/InlineAlert' import { InlineAlert } from '../../components/ui/InlineAlert'
import PageGuide from '../../components/ui/PageGuide' import PageGuide from '../../components/ui/PageGuide'
@@ -107,6 +107,8 @@ export function BatchManager() {
const [filterType, setFilterType] = useState<string>('') const [filterType, setFilterType] = useState<string>('')
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null) const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [createMonth, setCreateMonth] = useState(new Date().toISOString().slice(0, 7))
const [createPayMonth, setCreatePayMonth] = useState('')
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS' | 'SEVERANCE'>('REGULAR') const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS' | 'SEVERANCE'>('REGULAR')
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch' | 'custom'>('copy_last') const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch' | 'custom'>('copy_last')
const [sourceBatchId, setSourceBatchId] = useState<string>('') const [sourceBatchId, setSourceBatchId] = useState<string>('')
@@ -146,7 +148,11 @@ export function BatchManager() {
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: any) => payrollApi.createBatch(data), mutationFn: (data: any) => payrollApi.createBatch(data),
onSuccess: () => { onSuccess: (_data: any, vars: any) => {
// 自动切换列表筛选到批次所属月,确保新批次可见
if (vars.month && vars.month !== month) {
setMonth(vars.month)
}
queryClient.invalidateQueries({ queryKey: ['batches'] }) queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] }) queryClient.invalidateQueries({ queryKey: ['batch-check'] })
setShowCreateModal(false) setShowCreateModal(false)
@@ -199,9 +205,6 @@ export function BatchManager() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<PageGuide>
/// Excel
</PageGuide>
{/* 重复发薪提醒 */} {/* 重复发薪提醒 */}
{checkResult?.hasArchivedBatch && ( {checkResult?.hasArchivedBatch && (
<div className="flex items-center gap-2 p-2 rounded-md bg-amber-50 text-warning text-xs"> <div className="flex items-center gap-2 p-2 rounded-md bg-amber-50 text-warning text-xs">
@@ -260,6 +263,18 @@ export function BatchManager() {
<Card> <Card>
<h2 className="text-xs font-medium mb-3"></h2> <h2 className="text-xs font-medium mb-3"></h2>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>*</Label>
<Input type="month" value={createMonth} onChange={(e) => setCreateMonth(e.target.value)} />
<p className="text-xs text-gray-400 mt-0.5"></p>
</div>
<div>
<Label></Label>
<Input type="month" value={createPayMonth} onChange={(e) => setCreatePayMonth(e.target.value)} />
<p className="text-xs text-gray-400 mt-0.5">==10=9</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label></Label> <Label></Label>
@@ -307,7 +322,7 @@ export function BatchManager() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={() => createMutation.mutate({ month, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined, employeeIds: createMode === 'custom' ? selectedEmployeeIds : undefined })} onClick={() => createMutation.mutate({ month: createMonth, payMonth: createPayMonth || undefined, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined, employeeIds: createMode === 'custom' ? selectedEmployeeIds : undefined })}
disabled={createMutation.isPending || (createMode === 'copy_batch' && !sourceBatchId) || (createMode === 'custom' && selectedEmployeeIds.length === 0)} disabled={createMutation.isPending || (createMode === 'copy_batch' && !sourceBatchId) || (createMode === 'custom' && selectedEmployeeIds.length === 0)}
> >
{createMutation.isPending ? '创建中...' : '确认创建'} {createMutation.isPending ? '创建中...' : '确认创建'}
@@ -330,6 +345,7 @@ export function BatchManager() {
<thead> <thead>
<tr className="border-b text-left text-xs text-gray-500"> <tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-3"></th> <th className="py-2 px-3"></th>
<th className="py-2 px-3">/</th>
<th className="py-2 px-3"></th> <th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th> <th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th> <th className="py-2 px-3 text-right"></th>
@@ -378,6 +394,12 @@ export function BatchManager() {
)} )}
</div> </div>
</td> </td>
<td className="py-2.5 px-3 text-xs text-gray-600">
<div>{batch.month}</div>
{batch.payMonth && batch.payMonth !== batch.month && (
<div className="text-amber-600">{batch.payMonth}</div>
)}
</td>
<td className="py-2.5 px-3 text-xs text-gray-600"> <td className="py-2.5 px-3 text-xs text-gray-600">
{batch.type === 'REGULAR' ? '常规发薪' : batch.type === 'BONUS' ? '年终奖/奖金' : batch.type === 'TERMINATION' ? '离职结算' : batch.type === 'SEVERANCE' ? '补偿金' : batch.type} {batch.type === 'REGULAR' ? '常规发薪' : batch.type === 'BONUS' ? '年终奖/奖金' : batch.type === 'TERMINATION' ? '离职结算' : batch.type === 'SEVERANCE' ? '补偿金' : batch.type}
</td> </td>
@@ -556,6 +578,70 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
}, },
}) })
const fetchBonusMutation = useMutation({
mutationFn: () => payrollApi.fetchBonusToBatch(batchId),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
if (res.data?.filled > 0) {
toast.success(res.data.message)
} else {
toast.info(res.data?.message || '无提成奖金数据')
}
},
onError: () => {
toast.error('获取提成奖金失败')
},
})
const fetchPerformanceMutation = useMutation({
mutationFn: () => payrollApi.fetchPerformanceToBatch(batchId),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
if (res.data?.filled > 0) {
toast.success(res.data.message)
} else {
toast.info(res.data?.message || '无绩效工资数据')
}
},
onError: () => {
toast.error('获取绩效工资失败')
},
})
const fetchDisciplinaryMutation = useMutation({
mutationFn: () => payrollApi.fetchDisciplinaryToBatch(batchId),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
if (res.data?.filled > 0) {
toast.success(res.data.message)
} else {
toast.info(res.data?.message || '无违纪扣款数据')
}
},
onError: () => {
toast.error('获取违纪扣款失败')
},
})
const fetchAttendanceDeductionMutation = useMutation({
mutationFn: () => payrollApi.fetchAttendanceDeductionToBatch(batchId),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
if (res.data?.filled > 0) {
toast.success(res.data.message)
} else {
toast.info(res.data?.message || '无考勤扣款数据')
}
},
onError: () => {
toast.error('获取考勤扣款失败')
},
})
const importPayrollMutation = useMutation({ const importPayrollMutation = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
const token = useAuthStore.getState().accessToken const token = useAuthStore.getState().accessToken
@@ -774,6 +860,46 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
<Calculator className="w-4 h-4 mr-1" /> <Calculator className="w-4 h-4 mr-1" />
{importOvertimeMutation.isPending ? '导入中...' : '导入加班费'} {importOvertimeMutation.isPending ? '导入中...' : '导入加班费'}
</Button> </Button>
<Button
variant="secondary"
size="sm"
onClick={() => fetchBonusMutation.mutate()}
disabled={fetchBonusMutation.isPending || isArchived}
title="从提成奖金模块按月拉取填充奖金字段"
>
<DollarSign className="w-4 h-4 mr-1" />
{fetchBonusMutation.isPending ? '获取中...' : '获取提成奖金'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => fetchPerformanceMutation.mutate()}
disabled={fetchPerformanceMutation.isPending || isArchived}
title="按考核等级系数计算绩效工资(A=1.2/B=1.0/C=0.8/D=0.6),无考核记录按1.0"
>
<TrendingUp className="w-4 h-4 mr-1" />
{fetchPerformanceMutation.isPending ? '获取中...' : '获取绩效工资'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => fetchDisciplinaryMutation.mutate()}
disabled={fetchDisciplinaryMutation.isPending || isArchived}
title="按批次月份从违纪记录中汇总扣款金额(action=DEDUCTION"
>
<AlertTriangle className="w-4 h-4 mr-1" />
{fetchDisciplinaryMutation.isPending ? '获取中...' : '获取违纪扣款'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => fetchAttendanceDeductionMutation.mutate()}
disabled={fetchAttendanceDeductionMutation.isPending || isArchived}
title="按批次月份从考勤确认中获取扣款金额(需先在考勤确认中填写)"
>
<CalendarCheck className="w-4 h-4 mr-1" />
{fetchAttendanceDeductionMutation.isPending ? '获取中...' : '获取考勤扣款'}
</Button>
<Button <Button
size="sm" size="sm"
onClick={async () => { onClick={async () => {
@@ -922,8 +1048,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
<Card className="p-4"> <Card className="p-4">
<Stepper <Stepper
steps={[ steps={[
{ key: 'edit', title: '编辑薪资', description: '填写/导入工资数据', status: isArchived ? 'complete' : 'current' }, { key: 'edit', title: '编辑薪资', description: '填写/导入/核对工资数据', status: isArchived ? 'complete' : 'current' },
{ key: 'review', title: '核对汇总', description: '检查应发/个税/实发', status: isArchived ? 'complete' : 'pending' },
{ key: 'archive', title: '归档锁定', description: '归档后不可编辑', status: isArchived ? 'complete' : 'pending' }, { key: 'archive', title: '归档锁定', description: '归档后不可编辑', status: isArchived ? 'complete' : 'pending' },
{ key: 'publish', title: '发布工资条', description: '员工端可见', status: batch.payslipPublished ? 'complete' : 'pending' }, { key: 'publish', title: '发布工资条', description: '员工端可见', status: batch.payslipPublished ? 'complete' : 'pending' },
] as Step[]} ] as Step[]}
@@ -938,6 +1063,21 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
/ 0 / 0
</InlineAlert> </InlineAlert>
)} )}
{batch.entries.some((e: any) => e.minWage > 0 && e.netPay < e.minWage && (e.minWageApplied || 0) === 0) && (
<InlineAlert type="error" title="存在实发低于最低工资的员工">
¥{Math.min(...batch.entries.filter((e: any) => e.minWage > 0 && e.netPay < e.minWage).map((e: any) => e.minWage))}
</InlineAlert>
)}
{batch.entries.some((e: any) => (e.minWageApplied || 0) > 0) && (
<InlineAlert type="warning" title="最低工资保护已触发">
{batch.entries.filter((e: any) => (e.minWageApplied || 0) > 0).length} /
</InlineAlert>
)}
{batch.entries.some((e: any) => (e.prevDeferredSocialEmp || 0) + (e.prevDeferredHousingEmp || 0) + (e.prevDeferredMinWage || 0) > 0) && (
<InlineAlert type="warning" title="本月补扣上月递延">
{batch.entries.filter((e: any) => (e.prevDeferredSocialEmp || 0) + (e.prevDeferredHousingEmp || 0) + (e.prevDeferredMinWage || 0) > 0).length} //
</InlineAlert>
)}
{batch.entries.some((e: any) => e.baseSalary === 0 && e.bonus === 0 && e.totalPay === 0) && ( {batch.entries.some((e: any) => e.baseSalary === 0 && e.bonus === 0 && e.totalPay === 0) && (
<InlineAlert type="warning" title="存在全零记录"> <InlineAlert type="warning" title="存在全零记录">
0 0
@@ -1045,6 +1185,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
<th className="py-2 px-2 text-right">()</th> <th className="py-2 px-2 text-right">()</th>
<th className="py-2 px-2 text-right text-gray-500"></th> <th className="py-2 px-2 text-right text-gray-500"></th>
<th className="py-2 px-2 text-right text-gray-500"></th> <th className="py-2 px-2 text-right text-gray-500"></th>
<th className="py-2 px-2 text-right text-gray-500"></th>
<th className="py-2 px-2"></th> <th className="py-2 px-2"></th>
{!isArchived && <th className="py-2 px-2"></th>} {!isArchived && <th className="py-2 px-2"></th>}
</tr> </tr>
@@ -1069,12 +1210,34 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
{renderCell(entry, 'socialEmp')} {renderCell(entry, 'socialEmp')}
{renderCell(entry, 'housingEmp')} {renderCell(entry, 'housingEmp')}
<td className="py-2 px-2 text-right text-gray-500 cursor-pointer hover:text-primary hover:underline" onClick={() => handleTaxDetailClick(entry.employeeId, entry.employee.name)} title="点击查看个税计算明细">{fmt(entry.tax)}</td> <td className="py-2 px-2 text-right text-gray-500 cursor-pointer hover:text-primary hover:underline" onClick={() => handleTaxDetailClick(entry.employeeId, entry.employee.name)} title="点击查看个税计算明细">{fmt(entry.tax)}</td>
<td className="py-2 px-2 text-right font-bold text-safe">{fmt(entry.netPay)}</td> {/* 实发:最低工资保护触发时高亮显示 */}
<td className={`py-2 px-2 text-right font-bold ${(entry.minWageApplied || 0) > 0 ? 'text-amber-600' : 'text-safe'}`} title={(entry.minWageApplied || 0) > 0 ? `最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发补齐到 ¥${fmt(entry.minWage)}\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)} + 免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)} + 额外补齐 ¥${fmt(entry.deferredMinWage || 0)} = 递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))}(次月补扣)` : ''}>
{fmt(entry.netPay)}
{(entry.minWageApplied || 0) > 0 && <span className="text-xs text-amber-500 ml-1"></span>}
</td>
{/* 递延扣款明细 */}
<td className="py-2 px-2 text-right text-xs">
{(() => {
const deferred = (entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0)
const prevDeferred = (entry.prevDeferredSocialEmp || 0) + (entry.prevDeferredHousingEmp || 0) + (entry.prevDeferredMinWage || 0)
if (deferred > 0) {
return <span className="text-amber-600 cursor-help" title={`递延到次月补扣:\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)}\n免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)}\n额外补齐 ¥${fmt(entry.deferredMinWage || 0)}\n合计 ¥${fmt(deferred)}`}> ¥{fmt(deferred)}</span>
}
if (prevDeferred > 0) {
return <span className="text-blue-600 cursor-help" title={`本月补扣上月递延:\n社保 ¥${fmt(entry.prevDeferredSocialEmp || 0)}\n公积金 ¥${fmt(entry.prevDeferredHousingEmp || 0)}\n最低工资补齐 ¥${fmt(entry.prevDeferredMinWage || 0)}\n合计 ¥${fmt(prevDeferred)}`}> ¥{fmt(prevDeferred)}</span>
}
return <span className="text-gray-300"></span>
})()}
</td>
<td className="py-2 px-2"> <td className="py-2 px-2">
{entry.riskWarnings && entry.riskWarnings.length > 0 ? ( {entry.riskWarnings && entry.riskWarnings.length > 0 ? (
<span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}> <span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}>
<AlertTriangle className="w-4 h-4" /> <AlertTriangle className="w-4 h-4" />
</span> </span>
) : (entry.minWageApplied || 0) > 0 ? (
<span className="text-amber-500 cursor-help" title={`最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发 ¥${fmt(entry.minWage)}\n递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))} 次月补扣`}>
<AlertTriangle className="w-4 h-4" />
</span>
) : ( ) : (
<span className="text-gray-300"></span> <span className="text-gray-300"></span>
)} )}

Some files were not shown because too many files have changed in this diff Show More