feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全

- 面包屑导航组件,集成至TopNav header
- 侧边栏菜单分组间距增大,分组间分隔线
- 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计
- 修复Policies.tsx民主程序推进bug(字段名/API路径/参数)
- 用工文本模板变量名英文转中文显示
- 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY)
- 通知示例数据补充
- h2标题统一为text-sm font-medium
- 新增run.md
This commit is contained in:
selfrelease
2026-07-26 20:32:38 +08:00
parent 9cb0d1f63b
commit d79e3baa34
71 changed files with 18561 additions and 3230 deletions
+150
View File
@@ -0,0 +1,150 @@
import prisma from '../lib/prisma'
/**
* 考勤确认服务
*/
/**
* 创建月度考勤确认记录
*/
export async function createAttendanceConfirmation(orgId: string, userId: string, data: {
employeeId: string
month: string
workDays: number
weekdayHours: number
weekendHours: number
holidayHours: number
overtimePay: number
}) {
const existing = await prisma.attendanceConfirmation.findUnique({
where: { orgId_employeeId_month: { orgId, employeeId: data.employeeId, month: data.month } },
})
if (existing) {
throw { code: 'CONFLICT', message: '该月考勤确认记录已存在' }
}
return prisma.attendanceConfirmation.create({
data: {
orgId,
employeeId: data.employeeId,
month: data.month,
workDays: data.workDays,
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
overtimePay: data.overtimePay,
createdBy: userId,
},
})
}
/**
* 批量创建月度考勤确认记录
*/
export async function batchCreateAttendanceConfirmations(orgId: string, userId: string, month: string, items: Array<{
employeeId: string
workDays: number
weekdayHours: number
weekendHours: number
holidayHours: number
overtimePay: number
}>) {
const results: Array<{ employeeId: string; success: boolean; error?: string }> = []
for (const item of items) {
try {
const existing = await prisma.attendanceConfirmation.findUnique({
where: { orgId_employeeId_month: { orgId, employeeId: item.employeeId, month } },
})
if (existing) {
// 更新已有记录
await prisma.attendanceConfirmation.update({
where: { id: existing.id },
data: {
workDays: item.workDays,
weekdayHours: item.weekdayHours,
weekendHours: item.weekendHours,
holidayHours: item.holidayHours,
overtimePay: item.overtimePay,
status: 'PENDING',
},
})
} else {
await prisma.attendanceConfirmation.create({
data: {
orgId,
employeeId: item.employeeId,
month,
workDays: item.workDays,
weekdayHours: item.weekdayHours,
weekendHours: item.weekendHours,
holidayHours: item.holidayHours,
overtimePay: item.overtimePay,
createdBy: userId,
},
})
}
results.push({ employeeId: item.employeeId, success: true })
} catch (err: any) {
results.push({ employeeId: item.employeeId, success: false, error: err.message })
}
}
return { total: items.length, success: results.filter(r => r.success).length, results }
}
/**
* 获取月度考勤确认列表
*/
export async function getAttendanceConfirmations(orgId: string, month: string, status?: string) {
const where: any = { orgId, month }
if (status) where.status = status
return prisma.attendanceConfirmation.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { employee: { name: 'asc' } },
})
}
/**
* 员工确认考勤(员工端)
*/
export async function confirmAttendance(orgId: string, employeeId: string, month: string, ip: string, disputeNote?: string) {
const record = await prisma.attendanceConfirmation.findUnique({
where: { orgId_employeeId_month: { orgId, employeeId, month } },
})
if (!record) {
throw { code: 'NOT_FOUND', message: '考勤确认记录不存在' }
}
if (disputeNote) {
// 有异议
return prisma.attendanceConfirmation.update({
where: { id: record.id },
data: { status: 'DISPUTED', disputeNote, confirmIp: ip },
})
}
// 确认无误
return prisma.attendanceConfirmation.update({
where: { id: record.id },
data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmIp: ip },
})
}
/**
* 获取考勤确认统计
*/
export async function getAttendanceStats(orgId: string, month: string) {
const records = await prisma.attendanceConfirmation.findMany({
where: { orgId, month },
})
return {
total: records.length,
pending: records.filter(r => r.status === 'PENDING').length,
confirmed: records.filter(r => r.status === 'CONFIRMED').length,
disputed: records.filter(r => r.status === 'DISPUTED').length,
}
}
+174
View File
@@ -0,0 +1,174 @@
import prisma from '../lib/prisma'
import { sha256 } from '../lib/crypto'
/**
* 证据链服务 — 管理操作证据链,用于劳动仲裁举证
*/
export type EvidenceCategory =
| 'CONTRACT_SIGN'
| 'ONBOARD'
| 'PAYSLIP_CONFIRM'
| 'DISCIPLINARY'
| 'ATTENDANCE'
| 'TERMINATION'
/**
* 创建证据链记录
*/
export async function createEvidence(params: {
orgId: string
category: EvidenceCategory
refId?: string
employeeId?: string
events: Array<{ action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }>
createdBy: string
}) {
const eventsJson = JSON.stringify(params.events)
const hash = sha256(eventsJson + params.orgId + params.category + (params.refId || ''))
return prisma.evidenceChain.create({
data: {
orgId: params.orgId,
category: params.category,
refId: params.refId || null,
employeeId: params.employeeId || null,
events: params.events,
hash,
createdBy: params.createdBy,
},
})
}
/**
* 追加证据事件到已有证据链
*/
export async function appendEvidence(orgId: string, evidenceId: string, event: { action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }) {
const existing = await prisma.evidenceChain.findFirst({ where: { id: evidenceId, orgId } })
if (!existing) {
throw { code: 'NOT_FOUND', message: '证据链不存在' }
}
const events = [...(existing.events as any[]), event]
const eventsJson = JSON.stringify(events)
const hash = sha256(eventsJson + orgId + existing.category + (existing.refId || ''))
return prisma.evidenceChain.update({
where: { id: evidenceId },
data: { events, hash },
})
}
/**
* 获取某操作的完整证据链
*/
export async function getEvidenceByRef(orgId: string, category: string, refId: string) {
return prisma.evidenceChain.findMany({
where: { orgId, category, refId },
orderBy: { createdAt: 'asc' },
})
}
/**
* 获取某员工所有证据链
*/
export async function getEvidenceByEmployee(orgId: string, employeeId: string) {
return prisma.evidenceChain.findMany({
where: { orgId, employeeId },
orderBy: { createdAt: 'desc' },
})
}
/**
* 获取证据链详情
*/
export async function getEvidenceDetail(orgId: string, id: string) {
const evidence = await prisma.evidenceChain.findFirst({ where: { id, orgId } })
if (!evidence) {
throw { code: 'NOT_FOUND', message: '证据链不存在' }
}
return evidence
}
/**
* 验证证据链完整性(重新计算哈希对比)
*/
export async function verifyEvidence(orgId: string, id: string): Promise<{ valid: boolean; expectedHash: string; actualHash: string }> {
const evidence = await prisma.evidenceChain.findFirst({ where: { id, orgId } })
if (!evidence) {
throw { code: 'NOT_FOUND', message: '证据链不存在' }
}
const eventsJson = JSON.stringify(evidence.events)
const expectedHash = sha256(eventsJson + orgId + evidence.category + (evidence.refId || ''))
return {
valid: expectedHash === evidence.hash,
expectedHash,
actualHash: evidence.hash,
}
}
/**
* 获取组织级证据链列表(支持按类别筛选)
*/
export async function getEvidenceList(orgId: string, category?: string, page: number = 1, pageSize: number = 20) {
const where: any = { orgId }
if (category && category !== 'ALL') {
where.category = category
}
const [total, records] = await Promise.all([
prisma.evidenceChain.count({ where }),
prisma.evidenceChain.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
])
return {
total,
page,
pageSize,
records: records.map((r: any) => {
const events = Array.isArray(r.events) ? r.events : []
const firstEvent = events.length > 0 ? events[0] : null
const lastEvent = events.length > 0 ? events[events.length - 1] : null
return {
id: r.id,
category: r.category,
refId: r.refId,
employeeId: r.employeeId,
employeeName: r.employee?.name || null,
employeeDept: r.employee?.department || null,
eventCount: events.length,
firstAction: firstEvent?.action || null,
lastAction: lastEvent?.action || null,
firstTimestamp: firstEvent?.timestamp || null,
lastTimestamp: lastEvent?.timestamp || null,
hash: r.hash,
hashShort: r.hash ? r.hash.substring(0, 16) : null,
createdAt: r.createdAt.toISOString(),
}
}),
}
}
/**
* 验证全部证据链完整性
*/
export async function verifyAllEvidence(orgId: string) {
const records = await prisma.evidenceChain.findMany({ where: { orgId } })
let valid = 0
let invalid = 0
for (const r of records) {
const eventsJson = JSON.stringify(r.events)
const expectedHash = sha256(eventsJson + orgId + r.category + (r.refId || ''))
if (expectedHash === r.hash) valid++
else invalid++
}
return { total: records.length, valid, invalid }
}
+256
View File
@@ -351,3 +351,259 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
return { generated }
}
// ========== 算薪前 AI 校验 ==========
export interface PayrollCheckItem {
code: string
name: string
status: 'PASS' | 'FAIL' | 'WARNING'
message: string
employeeId?: string
employeeName?: string
detail?: any
}
export interface PayrollCheckResult {
checks: PayrollCheckItem[]
passedCount: number
failedCount: number
warningCount: number
}
/**
* 算薪前校验:检查 10+ 项异常,把错误拦截在发放前
*/
export async function prePayrollCheck(orgId: string, batchId: string): Promise<PayrollCheckResult> {
const batch = await prisma.payrollBatch.findFirst({
where: { id: batchId, orgId },
include: { entries: { include: { employee: true } } },
})
if (!batch) {
throw { code: 'NOT_FOUND', message: '工资批次不存在' }
}
const month = batch.month
const entries = batch.entries
const checks: PayrollCheckItem[] = []
// 获取社保公积金配置
const [socialConfig, housingConfig] = await Promise.all([
prisma.socialInsuranceConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
])
// 1. 社保基数是否在上下限范围内
if (socialConfig) {
for (const entry of entries) {
const base = entry.employee.socialInsBase || entry.baseSalary
if (base < socialConfig.baseMin || base > socialConfig.baseMax) {
checks.push({
code: 'SOCIAL_BASE_OUT_OF_RANGE',
name: '社保基数超出范围',
status: 'FAIL',
message: `${entry.employee.name} 的社保基数 ¥${base} 不在范围内(${socialConfig.baseMin} ~ ${socialConfig.baseMax}`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { base, min: socialConfig.baseMin, max: socialConfig.baseMax },
})
}
}
}
// 2. 公积金基数是否在上下限范围内
if (housingConfig) {
for (const entry of entries) {
const base = entry.employee.housingFundBase || entry.baseSalary
if (base < housingConfig.baseMin || base > housingConfig.baseMax) {
checks.push({
code: 'HOUSING_BASE_OUT_OF_RANGE',
name: '公积金基数超出范围',
status: 'FAIL',
message: `${entry.employee.name} 的公积金基数 ¥${base} 不在范围内(${housingConfig.baseMin} ~ ${housingConfig.baseMax}`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { base, min: housingConfig.baseMin, max: housingConfig.baseMax },
})
}
}
}
// 3. 加班时数是否超过法定上限(月 36 小时)
for (const entry of entries) {
const overtimeRecords = await prisma.overtimeRecord.findMany({
where: { orgId, employeeId: entry.employeeId, month },
})
for (const ot of overtimeRecords) {
const totalOT = ot.weekdayHours + ot.weekendHours + ot.holidayHours
if (totalOT > 36) {
checks.push({
code: 'OVERTIME_EXCEED_LIMIT',
name: '加班超法定上限',
status: 'WARNING',
message: `${entry.employee.name} 本月加班 ${totalOT} 小时,超过法定月上限 36 小时`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { totalHours: totalOT, limit: 36 },
})
}
}
}
// 4. 个税累计预扣跳档检测
const year = month.slice(0, 4)
for (const entry of entries) {
const prevPayslips = await prisma.payslip.findMany({
where: { orgId, employeeId: entry.employeeId, month: { startsWith: year, lt: month } },
select: { tax: true, totalPay: true },
})
if (prevPayslips.length >= 2) {
const avgTax = prevPayslips.reduce((s, p) => s + p.tax, 0) / prevPayslips.length
if (entry.tax > avgTax * 3 && entry.tax > 1000) {
checks.push({
code: 'TAX_BRACKET_JUMP',
name: '个税跳档警告',
status: 'WARNING',
message: `${entry.employee.name} 本月个税 ¥${entry.tax} 明显高于往月均值 ¥${avgTax.toFixed(0)},可能存在累计预扣跳档`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { currentTax: entry.tax, avgTax },
})
}
}
}
// 5. 试用期工资是否低于合同工资 80%
for (const entry of entries) {
const latestContract = await prisma.laborContract.findFirst({
where: { employeeId: entry.employeeId, orgId },
orderBy: { createdAt: 'desc' },
})
if (latestContract && latestContract.probationMonths > 0 && latestContract.probationSalary > 0) {
const probationEnd = new Date(latestContract.startDate)
probationEnd.setMonth(probationEnd.getMonth() + latestContract.probationMonths)
if (probationEnd > new Date() && entry.baseSalary < latestContract.probationSalary * 0.8) {
checks.push({
code: 'PROBATION_SALARY_TOO_LOW',
name: '试用期工资低于法定下限',
status: 'FAIL',
message: `${entry.employee.name} 试用期工资 ¥${entry.baseSalary} 低于合同工资的 80%(¥${latestContract.probationSalary * 0.8}`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { actualSalary: entry.baseSalary, minSalary: latestContract.probationSalary * 0.8 },
})
}
}
}
// 6. 离职员工是否多算了一个月
for (const entry of entries) {
if (entry.employee.status === 'RESIGNED') {
const termination = await prisma.terminationRecord.findFirst({
where: { employeeId: entry.employeeId, status: { notIn: ['CANCELLED', 'DRAFT'] } },
orderBy: { terminationDate: 'desc' },
})
if (termination) {
const termMonth = termination.terminationDate.toISOString().slice(0, 7)
if (month > termMonth) {
checks.push({
code: 'RESIGNED_OVERPAY',
name: '离职员工多算工资',
status: 'FAIL',
message: `${entry.employee.name} 已于 ${termination.terminationDate.toISOString().slice(0, 10)} 离职,但 ${month} 仍有工资记录`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { terminationDate: termination.terminationDate, payrollMonth: month },
})
}
}
}
}
// 7. 新入职员工是否按实际入职日折算
for (const entry of entries) {
const hireDate = entry.employee.hireDate
const hireMonth = hireDate.toISOString().slice(0, 7)
if (hireMonth === month) {
const daysInMonth = new Date(hireDate.getFullYear(), hireDate.getMonth() + 1, 0).getDate()
const actualWorkDays = daysInMonth - hireDate.getDate() + 1
// 如果基本工资等于整月工资,提示可能未折算
const fullMonthSalary = Number(entry.employee.monthlySalary) || entry.baseSalary
if (Math.abs(entry.baseSalary - fullMonthSalary) < 1 && actualWorkDays < daysInMonth) {
checks.push({
code: 'NEW_HIRE_NO_PRORATE',
name: '新入职未折算工资',
status: 'WARNING',
message: `${entry.employee.name} 本月 ${hireDate.getDate()} 日入职,工资可能未按实际天数折算(实际工作 ${actualWorkDays} 天)`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { hireDate: hireDate.toISOString().slice(0, 10), actualWorkDays, daysInMonth },
})
}
}
}
// 8. 社保是否在入职 30 天内参保
for (const entry of entries) {
const hireDate = entry.employee.hireDate
const socialStart = entry.employee.socialInsStartMonth
if (socialStart) {
const socialStartDate = new Date(socialStart + '-01')
const daysDiff = Math.floor((socialStartDate.getTime() - hireDate.getTime()) / 86400000)
if (daysDiff > 30) {
checks.push({
code: 'SOCIAL_INS_LATE_ENROLL',
name: '社保参保延迟',
status: 'WARNING',
message: `${entry.employee.name} 入职 ${daysDiff} 天后才参保社保,超过 30 天法定期限`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { hireDate: hireDate.toISOString().slice(0, 10), socialStart, daysDiff },
})
}
}
}
// 9. 离职当月社保是否已停保
for (const entry of entries) {
if (entry.employee.status === 'RESIGNED' && entry.employee.socialInsEndMonth) {
// 正常,已停保
} else if (entry.employee.status === 'RESIGNED' && !entry.employee.socialInsEndMonth) {
checks.push({
code: 'SOCIAL_INS_NOT_STOPPED',
name: '离职未停保',
status: 'WARNING',
message: `${entry.employee.name} 已离职但社保未停保,可能产生多缴费用`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
})
}
}
// 10. 基本工资为 0
for (const entry of entries) {
if (entry.baseSalary === 0 && batch.type === 'REGULAR') {
checks.push({
code: 'ZERO_BASE_SALARY',
name: '基本工资为 0',
status: 'WARNING',
message: `${entry.employee.name} 的基本工资为 0,请确认是否正确`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
})
}
}
// 汇总
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 warningCount = checks.filter(c => c.status === 'WARNING').length
return { checks, passedCount, failedCount, warningCount }
}
+164
View File
@@ -0,0 +1,164 @@
import prisma from '../lib/prisma'
/**
* 规章制度民主程序服务
*/
const DEMOCRACY_STEPS = [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论,提出方案和意见' },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定' },
{ step: 4, name: '公示告知', description: '向全体员工公示告知(公告栏/邮件/培训签收等)' },
]
/**
* 初始化民主程序进度
*/
export function initDemocracyProgress() {
return {
currentStep: 1,
steps: DEMOCRACY_STEPS.map(s => ({
...s,
status: s.step === 1 ? 'IN_PROGRESS' : 'PENDING',
date: s.step === 1 ? new Date().toISOString().slice(0, 10) : null,
note: null,
})),
}
}
/**
* 更新民主程序步骤
*/
export function updateDemocracyStep(progress: any, targetStep: number, note?: string) {
const steps = progress.steps.map((s: any) => {
if (s.step < targetStep) {
return { ...s, status: 'COMPLETED' }
} else if (s.step === targetStep) {
return { ...s, status: 'COMPLETED', date: new Date().toISOString().slice(0, 10), note: note || s.note }
}
return s
})
// 设置下一步为进行中
if (targetStep < DEMOCRACY_STEPS.length) {
const nextIdx = steps.findIndex((s: any) => s.step === targetStep + 1)
if (nextIdx >= 0) {
steps[nextIdx] = { ...steps[nextIdx], status: 'IN_PROGRESS', date: new Date().toISOString().slice(0, 10) }
}
}
return {
currentStep: targetStep >= DEMOCRACY_STEPS.length ? DEMOCRACY_STEPS.length : targetStep + 1,
steps,
}
}
/**
* 创建制度文档
*/
export async function createPolicy(orgId: string, userId: string, data: { title: string; content: string; type?: string }) {
return prisma.policyDocument.create({
data: {
orgId,
title: data.title,
content: data.content,
type: data.type || 'RULES',
status: 'DRAFT',
democracyProgress: initDemocracyProgress(),
createdBy: userId,
},
})
}
/**
* 获取制度列表(含阅读签收统计)
*/
export async function getPolicies(orgId: string, status?: string) {
const where: any = { orgId }
if (status) where.status = status
const [policies, totalEmployees] = await Promise.all([
prisma.policyDocument.findMany({
where,
orderBy: { updatedAt: 'desc' },
include: {
_count: { select: { readRecords: true } },
},
}),
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
])
return policies.map(p => ({
...p,
readCount: p._count?.readRecords || 0,
totalEmployees,
}))
}
/**
* 获取制度详情
*/
export async function getPolicyDetail(orgId: string, id: string) {
const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } })
if (!policy) {
throw { code: 'NOT_FOUND', message: '制度文档不存在' }
}
return policy
}
/**
* 更新制度
*/
export async function updatePolicy(orgId: string, id: string, data: { title?: string; content?: string; type?: string }) {
const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } })
if (!policy) {
throw { code: 'NOT_FOUND', message: '制度文档不存在' }
}
if (policy.status === 'PUBLISHED') {
throw { code: 'CONFLICT', message: '已公示的制度不可编辑' }
}
const updateData: any = {}
if (data.title !== undefined) updateData.title = data.title
if (data.content !== undefined) updateData.content = data.content
if (data.type !== undefined) updateData.type = data.type
return prisma.policyDocument.update({ where: { id }, data: updateData })
}
/**
* 推进民主程序步骤
*/
export async function advanceDemocracyStep(orgId: string, id: string, step: number, note?: string) {
const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } })
if (!policy) {
throw { code: 'NOT_FOUND', message: '制度文档不存在' }
}
if (policy.status === 'PUBLISHED') {
throw { code: 'CONFLICT', message: '已公示的制度不可修改' }
}
const progress = updateDemocracyStep(policy.democracyProgress, step, note)
const status = step >= 4 ? 'PUBLISHED' : step >= 3 ? 'CONSULTING' : step >= 2 ? 'DISCUSSING' : 'DRAFT'
return prisma.policyDocument.update({
where: { id },
data: {
democracyProgress: progress,
status,
publishedAt: step >= 4 ? new Date() : null,
},
})
}
/**
* 删除制度
*/
export async function deletePolicy(orgId: string, id: string) {
const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } })
if (!policy) {
throw { code: 'NOT_FOUND', message: '制度文档不存在' }
}
if (policy.status === 'PUBLISHED') {
throw { code: 'CONFLICT', message: '已公示的制度不可删除' }
}
return prisma.policyDocument.delete({ where: { id } })
}
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
/**
* 用工文本模板库
* 提供合同、制度、通知等常用文本模板,HR 可基于模板快速生成文档
*/
export interface DocumentTemplate {
id: string
name: string
category: 'CONTRACT' | 'RULES' | 'NOTICE' | 'AGREEMENT' | 'OTHER'
description: string
content: string
variables: string[] // 模板变量列表,如 ['employeeName', 'startDate', 'salary']
}
export const documentTemplates: DocumentTemplate[] = [
{
id: 'tpl_fixed_term_contract',
name: '固定期限劳动合同',
category: 'CONTRACT',
description: '标准固定期限劳动合同模板,适用于大多数正式员工',
variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'monthlySalary', 'socialInsBase'],
content: `劳动合同书
甲方(用人单位):{{companyName}}
乙方(劳动者):{{employeeName}}
根据《中华人民共和国劳动合同法》及相关法律法规,甲乙双方在平等自愿、协商一致的基础上,签订本劳动合同。
第一条 合同期限
本合同为固定期限劳动合同,自{{startDate}}起至{{endDate}}止,其中试用期{{probationMonths}}个月。
第二条 工作内容和工作地点
乙方担任{{position}}岗位,工作地点为{{workplace}}。
第三条 工作时间和休息休假
甲方执行标准工时制度,乙方每日工作时间不超过8小时,每周不超过40小时。
第四条 劳动报酬
乙方试用期月工资为人民币{{monthlySalary}}元,转正后月工资为人民币{{monthlySalary}}元,甲方于每月15日前支付上月工资。
第五条 社会保险和福利
甲方依法为乙方缴纳社会保险,缴费基数为{{socialInsBase}}元。
第六条 劳动保护、劳动条件和职业危害防护
甲方为乙方提供符合国家规定的劳动保护条件。
第七条 合同解除和终止
双方解除和终止劳动合同,应严格按照《劳动合同法》的规定执行。
第八条 违约责任
任何一方违反本合同约定,应承担相应的违约责任。
第九条 争议解决
因履行本合同发生的争议,双方应协商解决;协商不成的,可向劳动争议仲裁委员会申请仲裁。
第十条 其他
本合同一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。
甲方(盖章):____________ 乙方(签字):____________
日期:____年__月__日 日期:____年__月__日`,
},
{
id: 'tpl_open_ended_contract',
name: '无固定期限劳动合同',
category: 'CONTRACT',
description: '无固定期限劳动合同模板,适用于符合签订条件的情况',
variables: ['companyName', 'employeeName', 'startDate', 'position', 'monthlySalary'],
content: `无固定期限劳动合同书
甲方(用人单位):{{companyName}}
乙方(劳动者):{{employeeName}}
根据《中华人民共和国劳动合同法》及相关法律法规,甲乙双方在平等自愿、协商一致的基础上,签订无固定期限劳动合同。
第一条 合同期限
本合同为无固定期限劳动合同,自{{startDate}}起生效,至法定终止条件出现时终止。
第二条 工作内容
乙方担任{{position}}岗位。
第三条 劳动报酬
乙方月工资为人民币{{monthlySalary}}元。
(其余条款参照固定期限劳动合同模板)
甲方(盖章):____________ 乙方(签字):____________
日期:____年__月__日 日期:____年__月__日`,
},
{
id: 'tpl_termination_agreement',
name: '解除劳动合同协议书',
category: 'AGREEMENT',
description: '协商一致解除劳动合同协议书模板',
variables: ['companyName', 'employeeName', 'idCard', 'terminationDate', 'compensation', 'lastWorkDay', 'socialInsEndMonth', 'housingFundEndMonth'],
content: `解除劳动合同协议书
甲方(用人单位):{{companyName}}
乙方(劳动者):{{employeeName}},身份证号:{{idCard}}
甲乙双方经协商一致,就解除劳动合同事宜达成如下协议:
一、解除日期
双方同意于{{terminationDate}}解除劳动合同,乙方最后工作日为{{lastWorkDay}}。
二、经济补偿
甲方同意向乙方支付经济补偿金人民币{{compensation}}元,于乙方办理完工作交接手续后__个工作日内一次性支付。
三、工资结算
甲方结清乙方截至解除日的所有工资、加班费等劳动报酬。
四、社会保险和公积金
甲方为乙方缴纳社会保险至{{socialInsEndMonth}}月,住房公积金缴存至{{housingFundEndMonth}}月。
五、工作交接
乙方应在解除日前完成工作交接,归还甲方所有财物和资料。
六、保密义务
乙方解除劳动合同后,仍应遵守保密义务,不得泄露甲方商业秘密。
七、争议解决
本协议履行过程中如发生争议,双方应协商解决;协商不成的,可向劳动争议仲裁委员会申请仲裁。
八、其他
本协议一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。
甲方(盖章):____________ 乙方(签字):____________
日期:____年__月__日 日期:____年__月__日`,
},
{
id: 'tpl_employee_handbook_notice',
name: '员工手册公示通知',
category: 'NOTICE',
description: '员工手册公示通知模板,用于民主程序第四步公示',
variables: ['companyName', 'publishDate', 'effectiveDate'],
content: `关于发布《员工手册》的通知
致全体员工:
经职工代表大会讨论通过,并经与工会平等协商,{{companyName}}现正式发布《员工手册》({{publishDate}}版),自{{effectiveDate}}起施行。
请全体员工认真阅读并遵守《员工手册》的各项规定。如有疑问,请联系人力资源部。
特此通知。
{{companyName}}
{{publishDate}}`,
},
{
id: 'tpl_rules_discussion_notice',
name: '规章制度讨论通知',
category: 'NOTICE',
description: '规章制度职工讨论通知,用于民主程序第二步',
variables: ['companyName', 'meetingDate', 'meetingLocation', 'policyTitle'],
content: `关于召开职工代表大会讨论{{policyTitle}}的通知
致全体职工代表:
根据《劳动合同法》第四条规定,用人单位制定规章制度应当经职工代表大会或全体职工讨论。现定于{{meetingDate}}在{{meetingLocation}}召开职工代表大会,讨论《{{policyTitle}}》草案。
请各位职工代表准时出席,充分发表意见和建议。
特此通知。
{{companyName}}
____年__月__日`,
},
{
id: 'tpl_disciplinary_notice',
name: '违纪处分通知书',
category: 'NOTICE',
description: '员工违纪处分通知书模板',
variables: ['employeeName', 'department', 'violationDate', 'violationDescription', 'disciplineType', 'policyBasis', 'companyName'],
content: `违纪处分通知书
{{employeeName}}{{department}}):
经查,你于{{violationDate}}存在以下违纪行为:
{{violationDescription}}
上述行为违反了公司《{{policyBasis}}》的相关规定。根据公司规章制度,决定给予你以下处分:
{{disciplineType}}
如对本处分决定有异议,你可以在收到本通知之日起__日内向人力资源部提出书面申诉。
特此通知。
{{companyName}}
____年__月__日
签收人:____________ 日期:____年__月__日`,
},
{
id: 'tpl_probation_notice',
name: '试用期转正通知书',
category: 'NOTICE',
description: '试用期考核合格转正通知',
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'monthlySalary', 'companyName'],
content: `试用期转正通知书
{{employeeName}}
经考核,你在试用期内表现合格,符合岗位要求。现通知你:
一、自{{regularDate}}起正式转正,担任{{position}}岗位。
二、转正后月工资为人民币{{monthlySalary}}元。
三、试用期至{{probationEndDate}}结束。
请继续遵守公司各项规章制度,努力工作。
{{companyName}}
____年__月__日`,
},
{
id: 'tpl_contract_expiry_notice',
name: '合同到期不续签通知书',
category: 'NOTICE',
description: '合同到期公司决定不续签的通知',
variables: ['employeeName', 'contractEndDate', 'companyName', 'compensation', 'lastWorkDay'],
content: `劳动合同到期不续签通知书
{{employeeName}}
你与公司签订的劳动合同将于{{contractEndDate}}到期。经公司研究决定,合同到期后不再与你续签劳动合同。
请你于{{lastWorkDay}}前完成工作交接手续。公司将在你完成交接后,依法支付经济补偿金人民币{{compensation}}元。
特此通知。
{{companyName}}
____年__月__日
签收人:____________ 日期:____年__月__日`,
},
]
/**
* 获取所有模板
*/
export function getAllTemplates() {
return documentTemplates.map(({ content, ...rest }) => rest)
}
/**
* 获取模板详情(含内容)
*/
export function getTemplateById(id: string) {
return documentTemplates.find(t => t.id === id) || null
}
/**
* 按分类获取模板
*/
export function getTemplatesByCategory(category: string) {
return documentTemplates.filter(t => t.category === category)
}
/**
* 渲染模板(替换变量)
*/
export function renderTemplate(id: string, variables: Record<string, string>): string | null {
const template = getTemplateById(id)
if (!template) return null
let content = template.content
for (const [key, value] of Object.entries(variables)) {
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
}
return content
}
+107
View File
@@ -808,3 +808,110 @@ export async function getTerminationDetail(orgId: string, recordId: string) {
updatedAt: record.updatedAt.toISOString().slice(0, 10),
}
}
/**
* 解聘流程步骤前置校验
* 返回是否可以继续、阻断原因、警告信息
*/
export async function validateTerminationStep(orgId: string, recordId: string, step: number) {
const record = await prisma.terminationRecord.findFirst({
where: { id: recordId, orgId },
include: { employee: true },
})
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
const employee = record.employee
const reason = record.reason as string
const result: {
step: number
canProceed: boolean
blockReason?: string
warning?: string
legalBasis?: string
} = { step, canProceed: true }
// Step 1: 选择员工 — 检查特殊群体
if (step === 1) {
// 三期女职工 + 非过错解除 → 阻止
if (employee.isPregnant && reason !== 'FAULT' && reason !== 'RESIGNATION') {
result.canProceed = false
result.blockReason = '该员工处于孕期/哺乳期,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)'
result.legalBasis = '《劳动合同法》第四十二条:女职工在孕期、产期、哺乳期内,用人单位不得依照第四十条、第四十一条的规定解除劳动合同'
}
// 工伤期间 + 非过错解除 → 阻止
if (employee.isWorkInjured && reason !== 'FAULT' && reason !== 'RESIGNATION') {
result.canProceed = false
result.blockReason = '该员工工伤期间,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)'
result.legalBasis = '《劳动合同法》第四十二条:在本单位患职业病或者因工负伤并被确认丧失或者部分丧失劳动能力的,用人单位不得依照第四十条、第四十一条的规定解除劳动合同'
}
// 医疗期内 + 非过错解除 → 阻止
if (employee.isInMedicalPeriod && reason !== 'FAULT' && reason !== 'RESIGNATION') {
result.canProceed = false
result.blockReason = '该员工处于医疗期内,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)'
result.legalBasis = '《劳动合同法》第四十二条:患病或者非因工负伤,在规定的医疗期内的,用人单位不得依照第四十条、第四十一条的规定解除劳动合同'
}
}
// Step 3: 合规检查 — 检查关键合规项
if (step === 3) {
const checklist = record.checklist as any
const overrides = record.checklistOverrides as any
// 过错解除:必须通知工会
if (reason === 'FAULT') {
const notifyUnion = checklist?.notify_union
const override = overrides?.notify_union
if (!notifyUnion && !override?.checked) {
result.warning = '未确认"已通知工会"。如未通知工会即解除,可能被认定为违法解除程序(2N 赔偿风险)'
result.legalBasis = '《劳动合同法》第四十三条:用人单位单方解除劳动合同,应当事先将理由通知工会'
}
}
// 非过错解除:必须支付补偿金
if (reason === 'NONFAULT' || reason === 'NEGOTIATED' || reason === 'LAYOFF') {
const compPaid = checklist?.compensation_paid
const override = overrides?.compensation_paid
if (!compPaid && !override?.checked) {
result.warning = '未确认"已支付经济补偿金"。非过错解除必须支付经济补偿金(N),未支付将面临劳动监察处罚和仲裁风险'
result.legalBasis = '《劳动合同法》第四十六条:用人单位依照本法第三十六条、第四十条、第四十一条规定解除劳动合同的,应当向劳动者支付经济补偿'
}
}
}
// Step 4: 费用结算 — 必须先计算补偿金
if (step === 4) {
if (reason !== 'RESIGNATION' && record.compensation === 0) {
const compPaid = (record.checklist as any)?.compensation_paid
if (!compPaid) {
result.canProceed = false
result.blockReason = '补偿金尚未计算。请先在 Step 4 费用结算中计算经济补偿金,再继续后续流程'
}
}
}
// Step 5: 工作交接 — 检查交接清单
if (step === 5) {
const handoverItems = record.handoverItems as any[]
if (handoverItems && handoverItems.length > 0) {
const incomplete = handoverItems.filter(h => !h.done)
if (incomplete.length > 0) {
result.warning = `还有 ${incomplete.length} 项工作交接未完成:${incomplete.map(h => h.label).join('、')}。建议完成后再执行解聘`
}
}
}
// 合同已到期 + 选择"解除"而非"终止" → 警告
if (step === 2 && reason !== 'EXPIRED' && reason !== 'RESIGNATION') {
const latestContract = await prisma.laborContract.findFirst({
where: { employeeId: employee.id, orgId },
orderBy: { createdAt: 'desc' },
})
if (latestContract?.endDate && new Date(latestContract.endDate) < new Date()) {
result.warning = '该员工合同已到期。建议使用"到期终止"(EXPIRED)而非解除,流程更简单且法律风险更低'
}
}
return result
}