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>
This commit is contained in:
@@ -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 行)
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1133,7 +1133,9 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
|
||||
@@ -1145,40 +1147,71 @@ export default function Dashboard() {
|
||||
onChange={() => toggleSelect(todo.id)}
|
||||
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">
|
||||
<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>
|
||||
)}
|
||||
{hasValidUrl ? (
|
||||
<Link to={todo.actionUrl} 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>
|
||||
</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>
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
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>
|
||||
{hasValidUrl && (
|
||||
<Link
|
||||
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>
|
||||
)}
|
||||
<button
|
||||
onClick={() => resolveMutation.mutate(todo.id)}
|
||||
disabled={resolveMutation.isPending}
|
||||
@@ -1197,7 +1230,7 @@ export default function Dashboard() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)})}
|
||||
</div>
|
||||
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={() => setTodoPage(1)} />
|
||||
</>
|
||||
|
||||
@@ -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: '正常',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user