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/pages/ESign.tsx`(Tab 切换 + PendingTab 组件 + 催办弹窗)
|
||||||
- `frontend/src/lib/api-services.ts`(pending / remind 接口调用)
|
- `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({
|
const contract = await prisma.laborContract.findFirst({
|
||||||
where: { id: contractId, orgId: req.user!.orgId },
|
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) {
|
if (!contract) {
|
||||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
|
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())) {
|
if (isNaN(parsedDate.getTime())) {
|
||||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期格式无效' } })
|
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期格式无效' } })
|
||||||
}
|
}
|
||||||
|
// 更新合同签署日期
|
||||||
await prisma.laborContract.update({
|
await prisma.laborContract.update({
|
||||||
where: { id: contractId },
|
where: { id: contractId },
|
||||||
data: { signDate: parsedDate },
|
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() })
|
await auditLog(req, 'SIGN_DATE', 'CONTRACT', contractId, { employeeName: contract.employee.name, signDate: parsedDate.toISOString() })
|
||||||
res.json({ success: true, data: { message: '签署日期已登记', signDate: parsedDate.toISOString() } })
|
res.json({ success: true, data: { message: '签署日期已登记', signDate: parsedDate.toISOString() } })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
|
|||||||
@@ -242,7 +242,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'
|
||||||
|
|||||||
@@ -1133,7 +1133,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"
|
||||||
@@ -1145,6 +1147,7 @@ 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"
|
||||||
/>
|
/>
|
||||||
|
{hasValidUrl ? (
|
||||||
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||||
<TodoIcon type={todo.type} level={todo.level} />
|
<TodoIcon type={todo.type} level={todo.level} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -1171,14 +1174,44 @@ export default function Dashboard() {
|
|||||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{hasValidUrl && (
|
||||||
<Link
|
<Link
|
||||||
to={todo.actionUrl}
|
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"
|
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}
|
||||||
@@ -1197,7 +1230,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)} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -861,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',
|
||||||
}
|
}
|
||||||
@@ -870,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: '正常',
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user