From ef6d4591be2a0e843c784524d9dc79878d1988f5 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sun, 16 Aug 2026 12:17:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=99=BB=E8=AE=B0=E7=BA=BF=E4=B8=8B?= =?UTF-8?q?=E7=AD=BE=E7=BD=B2=E6=97=A5=E6=9C=9F=E6=97=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=20EsignRecord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登记签署日期后,签署记录 Tab 中也能看到这条记录。 创建一条 status=COMPLETED 的线下手签 EsignRecord, 避免已签合同在签署记录中无痕可查。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- 20260816-优化.md | 74 ++++++++++++++++++++++ backend/src/routes/esign.routes.ts | 27 +++++++- backend/src/routes/roster.routes.ts | 8 ++- frontend/src/pages/Dashboard.tsx | 97 +++++++++++++++++++---------- frontend/src/pages/Roster.tsx | 2 + 5 files changed, 174 insertions(+), 34 deletions(-) diff --git a/20260816-优化.md b/20260816-优化.md index 66c60a0..d0a85fe 100644 --- a/20260816-优化.md +++ b/20260816-优化.md @@ -272,3 +272,77 @@ - `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 行) + diff --git a/backend/src/routes/esign.routes.ts b/backend/src/routes/esign.routes.ts index bb1d126..a1f2e25 100644 --- a/backend/src/routes/esign.routes.ts +++ b/backend/src/routes/esign.routes.ts @@ -206,7 +206,7 @@ router.post('/sign-date', async (req: AuthRequest, res: Response, next: NextFunc } const contract = await prisma.laborContract.findFirst({ where: { id: contractId, orgId: req.user!.orgId }, - select: { id: true, signMethod: true, employee: { select: { name: true } } }, + 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: '合同不存在' } }) @@ -218,10 +218,35 @@ router.post('/sign-date', async (req: AuthRequest, res: Response, next: NextFunc if (isNaN(parsedDate.getTime())) { 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) { + await prisma.eSignRecord.create({ + data: { + orgId: req.user!.orgId, + contractId, + employeeId: contract.employeeId, + scene: 'CONTRACT', + signMethod: 'PAPER', + documentTitle: `${contract.contractType}合同线下签署登记`, + status: 'COMPLETED', + completedAt: parsedDate, + signedAt: parsedDate, + 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) } diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 1531da6..8722c16 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -242,7 +242,13 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { riskLevel: contractInfo.riskLevel, socialInsuranceStatus: (() => { 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 表示在保,否则已停保 if (sr.endMonth) return 'SUSPENDED' return 'ACTIVE' diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 64dc26a..4942ed3 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1133,7 +1133,9 @@ export default function Dashboard() { )}
- {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 (
toggleSelect(todo.id)} className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" /> - - -
-
- {todo.employeeName && ( - {todo.employeeName} - )} - {todo.employeeDepartment && ( - {todo.employeeDepartment} - )} - {todo.title} - {todo.priority && priorityConfig[todo.priority] && ( - - {priorityConfig[todo.priority].label} - - )} - {todo.estimatedLoss > 0 && ( - 损失 {fmt(todo.estimatedLoss)} - )} - {todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && ( - {todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}天`} - )} + {hasValidUrl ? ( + + +
+
+ {todo.employeeName && ( + {todo.employeeName} + )} + {todo.employeeDepartment && ( + {todo.employeeDepartment} + )} + {todo.title} + {todo.priority && priorityConfig[todo.priority] && ( + + {priorityConfig[todo.priority].label} + + )} + {todo.estimatedLoss > 0 && ( + 损失 {fmt(todo.estimatedLoss)} + )} + {todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && ( + {todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}天`} + )} +
+ {todo.description} +
+ + ) : ( +
+ +
+
+ {todo.employeeName && ( + {todo.employeeName} + )} + {todo.employeeDepartment && ( + {todo.employeeDepartment} + )} + {todo.title} + {todo.priority && priorityConfig[todo.priority] && ( + + {priorityConfig[todo.priority].label} + + )} + {todo.estimatedLoss > 0 && ( + 损失 {fmt(todo.estimatedLoss)} + )} + {todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && ( + {todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}天`} + )} +
+ {todo.description}
- {todo.description}
- + )}
- - 立刻办理 → - + {hasValidUrl && ( + + 立刻办理 → + + )}
- ))} + )})}
setTodoPage(1)} /> diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index 48bd126..b6e9ef8 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -861,6 +861,7 @@ export default function Roster() { unsigned_over_30: 'bg-red-50 text-danger', unsigned: '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', unfixed: 'bg-green-50 text-safe', } @@ -870,6 +871,7 @@ export default function Roster() { unsigned_over_30: '未签署(超30天)', unsigned: '未签署', expiring: '即将到期', + pending_sign: '待签署', active: '正常', unfixed: '正常', }