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
+158 -17
View File
@@ -159,6 +159,12 @@ model Organization {
performanceRecords PerformanceRecord[]
retirementPolicies RetirementPolicy[]
socialMonthlyProcesses SocialMonthlyProcess[]
evidenceChains EvidenceChain[]
attendanceConfirmations AttendanceConfirmation[]
policyDocuments PolicyDocument[]
policyReadRecords PolicyReadRecord[]
healthCheckReports HealthCheckReport[]
annualValueReports AnnualValueReport[]
}
model User {
@@ -231,6 +237,9 @@ model Employee {
housingFundRecords EmployeeHousingFundRecord[]
departmentRecords EmployeeDepartmentRecord[]
aiReviewRecords AIReviewRecord[]
evidenceChains EvidenceChain[]
attendanceConfirmations AttendanceConfirmation[]
policyReadRecords PolicyReadRecord[]
@@unique([orgId, idCardHash])
}
@@ -320,22 +329,28 @@ model TerminationRecord {
}
model RiskItem {
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: SetNull)
type RiskType
level RiskLevel
status RiskStatus @default(PENDING)
title String
description String
actionUrl String?
resolvedAt DateTime?
resolvedBy String?
remark String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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: SetNull)
type RiskType
level RiskLevel
status RiskStatus @default(PENDING)
title String
description String
actionUrl String?
// 风险量化:预估损失金额(元)
estimatedLoss Float?
// 风险量化:损失区间 [最低, 最高]
lossRange Json?
// 建议处理截止日期
deadline DateTime?
resolvedAt DateTime?
resolvedBy String?
remark String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([orgId, type])
@@ -460,13 +475,16 @@ model EmployeeAttachment {
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
fileName String
fileType String // ID_CARD / BANK_CARD / CONTRACT_SCAN / EDUCATION / OTHER
fileType String // ID_CARD / BANK_CARD / CONTRACT_SCAN / EDUCATION / DISCIPLINARY / OTHER
fileUrl String
fileSize Int @default(0)
// 关联违纪记录(可选)
disciplinaryRecordId String?
uploadedBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([disciplinaryRecordId])
}
// ========== 仲裁证据链 ==========
@@ -876,3 +894,126 @@ model RetirementPolicy {
@@index([orgId, createdAt])
}
// ========== 证据链管理 ==========
model EvidenceChain {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
category String // CONTRACT_SIGN / ONBOARD / PAYSLIP_CONFIRM / DISCIPLINARY / ATTENDANCE / TERMINATION
refId String? // 关联记录 ID(合同ID/工资条ID等)
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
events Json // [{ action, timestamp, ip, userAgent, smsCode?, location? }]
hash String // 全链路 SHA-256 哈希(防篡改)
createdBy String
createdAt DateTime @default(now())
@@index([orgId, category, refId])
@@index([orgId, employeeId])
}
// ========== 考勤确认 ==========
model AttendanceConfirmation {
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
workDays Int @default(0)
weekdayHours Float @default(0)
weekendHours Float @default(0)
holidayHours Float @default(0)
overtimePay Float @default(0)
confirmedAt DateTime?
confirmIp String?
status String @default("PENDING") // PENDING / CONFIRMED / DISPUTED
disputeNote String? // 员工有异议时的说明
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, employeeId, month])
@@index([orgId, month])
@@index([orgId, status])
}
// ========== 规章制度民主程序 ==========
model PolicyDocument {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
title String
content String
type String @default("RULES") // RULES=规章制度 / NOTICE=通知
status String @default("DRAFT") // DRAFT / DISCUSSING / CONSULTING / PUBLISHED
democracyProgress Json? // { currentStep: 1-4, steps: [{ name, status, date, note }] }
publishedAt DateTime?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
readRecords PolicyReadRecord[]
@@index([orgId, status])
}
// ========== 规章制度阅读签收 ==========
model PolicyReadRecord {
id String @id @default(cuid())
policyId String
policy PolicyDocument @relation(fields: [policyId], references: [id], onDelete: Cascade)
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
readAt DateTime @default(now())
ip String?
userAgent String?
@@unique([policyId, employeeId])
@@index([orgId, employeeId])
}
// ========== 用工体检诊断报告 ==========
model HealthCheckReport {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
year Int // 诊断年度
totalScore Int // 综合评分 0-100
level String // safe / warning / danger
dimensions Json // [{ key, name, score, findings: [], recommendations: [] }]
summary String // 诊断总结
createdBy String
createdAt DateTime @default(now())
@@index([orgId, year])
@@index([orgId, createdAt])
}
// ========== 年度价值报告 ==========
model AnnualValueReport {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
year Int // 报告年度
totalValue Int // 总价值(元)
roi Float // ROI 百分比
metrics Json // { risksResolved, lossAvoided, aiQueries, contractsReviewed, payrollProcessed, employeesManaged }
timeline Json // [{ month, event, value }] 月度事件时间轴
costSaved Int // 节约成本(元)
timeSaved Int // 节约工时(小时)
summary String // 年度总结
createdBy String
createdAt DateTime @default(now())
@@index([orgId, year])
@@index([orgId, createdAt])
}
+89
View File
@@ -0,0 +1,89 @@
/**
* 批量补齐现有示例数据的证据链
* 运行: npx tsx scripts/seed-evidence.ts
*/
import prisma from '../src/lib/prisma'
import { sha256 } from '../src/lib/crypto'
async function main() {
const orgId = 'cmry97x7l0000trrp5f9jgng5'
const systemUserId = 'system-seed'
let count = 0
// 清除旧的 seed 数据
await prisma.evidenceChain.deleteMany({ where: { createdBy: systemUserId } })
console.log('Cleared old seed evidence chains')
// 1. 员工入职证据链
const employees = await prisma.employee.findMany({ select: { id: true, name: true, hireDate: true, createdAt: true } })
for (const emp of employees) {
const events = [{ action: '员工入职登记', timestamp: emp.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }]
const hash = sha256(JSON.stringify(events) + orgId + 'ONBOARD' + emp.id)
await prisma.evidenceChain.create({
data: { orgId, category: 'ONBOARD', refId: emp.id, employeeId: emp.id, events: events as any, hash, createdBy: systemUserId, createdAt: emp.createdAt },
}).catch(() => {})
count++
}
console.log('ONBOARD:', employees.length)
// 2. 合同签订证据链
const contracts = await prisma.laborContract.findMany({ select: { id: true, employeeId: true, createdAt: true } })
for (const c of contracts) {
const events = [{ action: '合同签订', timestamp: c.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }]
const hash = sha256(JSON.stringify(events) + orgId + 'CONTRACT_SIGN' + c.id)
await prisma.evidenceChain.create({
data: { orgId, category: 'CONTRACT_SIGN', refId: c.id, employeeId: c.employeeId, events: events as any, hash, createdBy: systemUserId, createdAt: c.createdAt },
}).catch(() => {})
count++
}
console.log('CONTRACT_SIGN:', contracts.length)
// 3. 工资条确认证据链
const payslips = await prisma.payslip.findMany({ where: { confirmedAt: { not: null } }, select: { id: true, employeeId: true, confirmedAt: true } })
for (const p of payslips) {
const events = [{ action: '工资条确认', timestamp: p.confirmedAt!.toISOString(), ip: '127.0.0.1', userAgent: 'employee-portal' }]
const hash = sha256(JSON.stringify(events) + orgId + 'PAYSLIP_CONFIRM' + p.id)
await prisma.evidenceChain.create({
data: { orgId, category: 'PAYSLIP_CONFIRM', refId: p.id, employeeId: p.employeeId, events: events as any, hash, createdBy: p.employeeId, createdAt: p.confirmedAt! },
}).catch(() => {})
count++
}
console.log('PAYSLIP_CONFIRM:', payslips.length)
// 4. 违纪记录证据链
const disciplinaries = await prisma.disciplinaryRecord.findMany({ select: { id: true, employeeId: true, createdAt: true, violationType: true, severity: true } })
const violationMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', OTHER: '其他违规' }
for (const d of disciplinaries) {
const desc = violationMap[d.violationType] || d.violationType
const events = [{ action: `违纪记录创建(${desc}`, timestamp: d.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }]
const hash = sha256(JSON.stringify(events) + orgId + 'DISCIPLINARY' + d.id)
await prisma.evidenceChain.create({
data: { orgId, category: 'DISCIPLINARY', refId: d.id, employeeId: d.employeeId, events: events as any, hash, createdBy: systemUserId, createdAt: d.createdAt },
}).catch(() => {})
count++
}
console.log('DISCIPLINARY:', disciplinaries.length)
// 5. 解聘证据链
const terminations = await prisma.terminationRecord.findMany({ select: { id: true, employeeId: true, createdAt: true, reason: true, status: true } })
const reasonMap: Record<string, string> = { NEGOTIATED: '协商解除', UNILATERAL: '单方解除', EXPIRY: '合同到期', RESIGNATION: '员工辞职', OTHER: '其他' }
const statusMap: Record<string, string> = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTED: '已执行', CANCELLED: '已撤销' }
for (const t of terminations) {
const reasonText = reasonMap[t.reason] || t.reason
const statusText = statusMap[t.status] || t.status
const events = [{ action: `解聘流程(${reasonText}·${statusText}`, timestamp: t.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }]
const hash = sha256(JSON.stringify(events) + orgId + 'TERMINATION' + t.id)
await prisma.evidenceChain.create({
data: { orgId, category: 'TERMINATION', refId: t.id, employeeId: t.employeeId, events: events as any, hash, createdBy: systemUserId, createdAt: t.createdAt },
}).catch(() => {})
count++
}
console.log('TERMINATION:', terminations.length)
const total = await prisma.evidenceChain.count()
console.log(`\n总计插入 ${count} 条,数据库现有 ${total} 条证据链`)
await prisma.$disconnect()
}
main().catch(console.error)
+227
View File
@@ -0,0 +1,227 @@
/**
* 补充规章制度示例数据
* 运行: npx tsx scripts/seed-policies.ts
*/
import prisma from '../src/lib/prisma'
async function main() {
const orgId = 'cmry97x7l0000trrp5f9jgng5'
const userId = 'system-seed'
const policies = [
{
id: 'pol_seed_1',
title: '考勤与加班管理制度',
content: `第一章 总则
第一条 为规范公司考勤管理,维护正常工作秩序,根据《劳动法》《劳动合同法》及相关法规,制定本制度。
第二条 本制度适用于公司全体员工。
第二章 工作时间
第三条 公司实行标准工时制,每日工作时间不超过8小时,每周不超过40小时。
第四条 工作时间为周一至周五 9:00-18:00,午休时间 12:00-13:00。
第三章 加班管理
第五条 加班需经部门负责人书面审批,未经审批的自行延长工作时间不视为加班。
第六条 工作日加班按不低于工资的150%支付加班费;休息日加班按不低于工资的200%支付加班费;法定节假日加班按不低于工资的300%支付加班费。
第七条 每月加班时间不得超过36小时,怀孕女职工及哺乳期女职工不得安排加班。
第四章 考勤记录
第八条 公司采用指纹打卡方式记录考勤,员工应按时上下班打卡。
第九条 迟到、早退每次扣罚50元;当月累计迟到早退超过5次,按旷工一天处理。
第五章 请假制度
第十条 员工请假需提前填写请假单,经主管审批后交人事部备案。
第十一条 病假需提供医院证明,按国家规定支付病假工资。
第六章 附则
第十二条 本制度经职工代表大会讨论通过后公示执行。
第十三条 本制度自公示之日起施行。`,
type: 'RULES',
status: 'PUBLISHED',
currentStep: 4,
steps: [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-06-01', note: '完成初稿' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'COMPLETED', date: '2026-06-10', note: '职工代表大会讨论通过' },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'COMPLETED', date: '2026-06-15', note: '与工会代表协商一致' },
{ step: 4, name: '公示', description: '向全体员工公示', status: 'COMPLETED', date: '2026-06-20', note: '全员邮件公示并签收' },
],
publishedAt: new Date('2026-06-20'),
createdAt: new Date('2026-06-01'),
},
{
id: 'pol_seed_2',
title: '员工奖惩管理制度',
content: `第一章 总则
第一条 为维护公司正常经营管理秩序,激励员工遵纪守法,制定本制度。
第二条 本制度适用于公司全体员工。
第二章 奖励
第三条 奖励分为:通报表扬、嘉奖、记功、记大功四种。
第四条 有下列情形之一的,给予奖励:
(一)在工作中有重大创新,为公司创造显著经济效益的;
(二)为公司挽回重大经济损失的;
(三)见义勇为,保护公司财产和员工安全的;
(四)连续年度考核优秀的。
第三章 惩戒
第五条 违纪行为分为:轻微违纪、一般违纪、严重违纪。
第六条 严重违纪包括但不限于:
(一)旷工连续3天以上或年内累计5天以上的;
(二)严重违反工作纪律,影响生产经营的;
(三)泄露公司商业秘密的;
(四)在工作场所打架斗殴的;
(五)利用职务便利索取、收受贿赂的。
第七条 严重违纪可以解除劳动合同,且无需支付经济补偿。
第四章 程序
第八条 对员工的奖惩应当事实清楚、证据确凿,并告知员工本人。
第九条 员工对惩戒决定有异议的,可以自收到通知之日起5个工作日内向人事部申诉。
第五章 附则
第十条 本制度经民主程序制定并公示后执行。`,
type: 'RULES',
status: 'PUBLISHED',
currentStep: 4,
steps: [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-06-05', note: '完成初稿' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'COMPLETED', date: '2026-06-12', note: '职工讨论收集意见' },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'COMPLETED', date: '2026-06-18', note: '协商修改后定稿' },
{ step: 4, name: '公示', description: '向全体员工公示', status: 'COMPLETED', date: '2026-06-22', note: '公告栏及邮件公示' },
],
publishedAt: new Date('2026-06-22'),
createdAt: new Date('2026-06-05'),
},
{
id: 'pol_seed_3',
title: '薪酬与绩效考核制度',
content: `第一章 总则
第一条 为建立公平、公正、公开的薪酬体系,激励员工提升绩效,制定本制度。
第二条 薪酬结构包括:基本工资、岗位工资、绩效奖金、津贴补贴。
第二章 薪酬构成
第三条 基本工资不低于当地最低工资标准。
第四条 岗位工资根据岗位等级确定,具体标准见附件。
第五条 绩效奖金根据月度/季度/年度考核结果发放,考核等级分为A(优秀)、B(良好)、C(合格)、D(不合格)四档。
第三章 绩效考核
第六条 考核周期分为月度考核、季度考核和年度考核。
第七条 月度考核于次月5日前完成,季度考核于次季首月10日前完成,年度考核于次年1月15日前完成。
第八条 考核结果作为晋升、调薪、奖金发放的依据。
第九条 连续两次年度考核为D的,公司可以调整其岗位或依法解除劳动合同。
第四章 薪酬调整
第十条 公司每年根据经营状况和市场水平进行薪酬回顾,适时调整薪酬标准。
第十一条 员工岗位变动时,薪酬自变动次月起相应调整。
第五章 附则
第十二条 本制度经民主程序制定并公示后执行。`,
type: 'RULES',
status: 'CONSULTING',
currentStep: 3,
steps: [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-07-01', note: '完成初稿' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'COMPLETED', date: '2026-07-08', note: '收集到15条修改建议' },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'IN_PROGRESS', date: '2026-07-15', note: '正在与工会代表协商中' },
{ step: 4, name: '公示', description: '向全体员工公示', status: 'PENDING', date: null, note: null },
],
publishedAt: null,
createdAt: new Date('2026-07-01'),
},
{
id: 'pol_seed_4',
title: '员工出差与费用报销制度',
content: `第一章 总则
第一条 为规范出差管理和费用报销流程,制定本制度。
第二章 出差审批
第二条 员工出差需提前填写出差申请单,经部门负责人审批。
第三条 出差天数3天以上的需分管副总审批,7天以上的需总经理审批。
第三章 费用标准
第四条 交通费标准:
(一)飞机:总监及以上职级可乘坐商务舱,其他职级乘坐经济舱;
(二)火车:总监及以上职级可乘坐一等座,其他职级乘坐二等座;
(三)市内交通:实报实销,每日不超过100元。
第五条 住宿费标准(元/天):
(一)一线城市:总监级800元,经理级500元,员工级350元;
(二)其他城市:总监级600元,经理级400元,员工级300元。
第六条 餐费补贴:出差期间每人每天补贴80元,不再另行报销餐费。
第四章 报销流程
第七条 出差结束后5个工作日内提交报销单据,逾期不予报销。
第八条 报销单据需附发票原件,经部门负责人和财务部审核后支付。
第五章 附则
第九条 本制度经民主程序制定并公示后执行。`,
type: 'RULES',
status: 'DISCUSSING',
currentStep: 2,
steps: [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-07-10', note: '完成初稿' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'IN_PROGRESS', date: '2026-07-18', note: '正在征求各部门意见' },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'PENDING', date: null, note: null },
{ step: 4, name: '公示', description: '向全体员工公示', status: 'PENDING', date: null, note: null },
],
publishedAt: null,
createdAt: new Date('2026-07-10'),
},
{
id: 'pol_seed_5',
title: '保密与竞业限制制度',
content: `第一章 总则
第一条 为保护公司商业秘密和知识产权,制定本制度。
第二条 商业秘密包括:客户名单、技术方案、财务数据、经营策略、薪酬信息等。
第二章 保密义务
第三条 员工在职期间及离职后均负有保密义务,不得泄露、使用或允许他人使用公司商业秘密。
第四条 员工应妥善保管涉密文件,离开工作岗位时应将涉密材料锁好或交还相关部门。
第五条 违反保密义务的,公司可以解除劳动合同并要求赔偿损失。
第三章 竞业限制
第六条 对负有保密义务的高级管理人员、高级技术人员和其他知悉商业秘密的员工,可以约定竞业限制条款。
第七条 竞业限制期限不超过两年,竞业限制期间公司按月支付竞业限制补偿金,标准为离职前12个月平均工资的30%。
第八条 违反竞业限制的,应向公司支付竞业限制违约金,并继续履行竞业限制义务。
第四章 附则
第九条 本制度经民主程序制定并公示后执行。`,
type: 'RULES',
status: 'DRAFT',
currentStep: 1,
steps: [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'IN_PROGRESS', date: '2026-07-20', note: '初稿起草中' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'PENDING', date: null, note: null },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'PENDING', date: null, note: null },
{ step: 4, name: '公示', description: '向全体员工公示', status: 'PENDING', date: null, note: null },
],
publishedAt: null,
createdAt: new Date('2026-07-20'),
},
]
for (const p of policies) {
await prisma.policyDocument.upsert({
where: { id: p.id },
create: {
id: p.id,
orgId,
title: p.title,
content: p.content,
type: p.type,
status: p.status,
democracyProgress: { currentStep: p.currentStep, steps: p.steps } as any,
publishedAt: p.publishedAt,
createdBy: userId,
createdAt: p.createdAt,
},
update: {},
})
console.log(`Inserted: ${p.title} (${p.status})`)
}
const total = await prisma.policyDocument.count()
console.log(`\n总计 ${total} 条规章制度`)
await prisma.$disconnect()
}
main().catch(console.error)
+10
View File
@@ -49,6 +49,11 @@ import attachmentRoutes from './routes/attachment.routes'
import rosterRoutes from './routes/roster.routes'
import exportRoutes from './routes/export.routes'
import importRoutes from './routes/import.routes'
import evidenceRoutes from './routes/evidence.routes'
import policyRoutes from './routes/policy.routes'
import attendanceRoutes from './routes/attendance.routes'
import templateRoutes from './routes/template.routes'
import auditRoutes from './routes/audit.routes'
app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes)
@@ -64,6 +69,11 @@ app.use('/api/v1/attachments', attachmentRoutes)
app.use('/api/v1/roster', rosterRoutes)
app.use('/api/v1/export', exportRoutes)
app.use('/api/v1/import', importRoutes)
app.use('/api/v1/evidence', evidenceRoutes)
app.use('/api/v1/policies', policyRoutes)
app.use('/api/v1/attendance', attendanceRoutes)
app.use('/api/v1/templates', templateRoutes)
app.use('/api/v1/audit', auditRoutes)
app.use(errorHandler)
+146
View File
@@ -0,0 +1,146 @@
/**
* 验证码存储服务
* 支持两种模式:
* 1. Redis(生产环境,REDIS_URL 环境变量存在时自动启用)
* 2. 内存 Map(开发环境 fallback
*
* 功能:存储、验证、过期清理、失败次数限制
*/
interface CodeEntry {
code: string
expiresAt: number
failCount: number
lastSentAt: number
}
// 内存存储 fallback
const memoryStore = new Map<string, CodeEntry>()
// 是否启用 Redis
const REDIS_URL = process.env.REDIS_URL || ''
const useRedis = !!REDIS_URL
// Redis 客户端(懒加载)
let redisClient: any = null
async function getRedis() {
if (!redisClient && useRedis) {
try {
const { createClient } = await import('redis')
redisClient = createClient({ url: REDIS_URL })
redisClient.on('error', (err: any) => console.error('[Redis] error:', err))
await redisClient.connect()
console.log('[Redis] 验证码存储已连接')
} catch (err) {
console.warn('[Redis] 连接失败,降级为内存存储:', err)
return null
}
}
return redisClient
}
const KEY_PREFIX = 'vcode:'
const TTL_SECONDS = 300 // 5 分钟
/**
* 存储验证码
*/
export async function setCode(key: string, code: string, ttlMs: number = 5 * 60 * 1000): Promise<void> {
const entry: CodeEntry = {
code,
expiresAt: Date.now() + ttlMs,
failCount: 0,
lastSentAt: Date.now(),
}
const redis = await getRedis()
if (redis) {
try {
await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: ttlMs })
return
} catch (err) {
console.warn('[Redis] set 失败,降级内存:', err)
}
}
memoryStore.set(key, entry)
}
/**
* 获取验证码条目
*/
export async function getCode(key: string): Promise<CodeEntry | null> {
const redis = await getRedis()
if (redis) {
try {
const raw = await redis.get(`${KEY_PREFIX}${key}`)
if (!raw) return null
const entry = JSON.parse(raw) as CodeEntry
if (entry.expiresAt < Date.now()) {
await redis.del(`${KEY_PREFIX}${key}`)
return null
}
return entry
} catch (err) {
console.warn('[Redis] get 失败,降级内存:', err)
}
}
const entry = memoryStore.get(key)
if (!entry) return null
if (entry.expiresAt < Date.now()) {
memoryStore.delete(key)
return null
}
return entry
}
/**
* 更新验证码条目(如递增失败次数)
*/
export async function updateCode(key: string, updates: Partial<CodeEntry>): Promise<void> {
const redis = await getRedis()
if (redis) {
try {
const raw = await redis.get(`${KEY_PREFIX}${key}`)
if (raw) {
const entry = { ...JSON.parse(raw), ...updates }
await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: TTL_SECONDS * 1000 })
}
return
} catch (err) {
console.warn('[Redis] update 失败,降级内存:', err)
}
}
const entry = memoryStore.get(key)
if (entry) {
Object.assign(entry, updates)
}
}
/**
* 删除验证码
*/
export async function deleteCode(key: string): Promise<void> {
const redis = await getRedis()
if (redis) {
try {
await redis.del(`${KEY_PREFIX}${key}`)
return
} catch (err) {
console.warn('[Redis] del 失败,降级内存:', err)
}
}
memoryStore.delete(key)
}
/**
* 检查发送频率限制(60秒内不可重复发送)
*/
export async function checkRateLimit(key: string, intervalMs: number = 60 * 1000): Promise<boolean> {
const entry = await getCode(key)
if (entry && entry.lastSentAt && Date.now() - entry.lastSentAt < intervalMs) {
return false // 被限流
}
return true // 允许发送
}
+137
View File
@@ -4,6 +4,7 @@ import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisks
import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service'
import prisma from '../lib/prisma'
import { z } from 'zod'
import { decrypt } from '../lib/crypto'
const router = Router()
@@ -542,4 +543,140 @@ router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) =>
}
})
// ========== 合同到期决策助手 ==========
router.post('/contract-decision', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { employeeId } = req.body as { employeeId: string }
if (!employeeId) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } })
}
const emp = await prisma.employee.findFirst({
where: { id: employeeId, orgId: req.user!.orgId },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
if (!emp) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const contract = emp.contracts[0]
if (!contract || !contract.endDate) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '该员工无固定期限合同或未签合同,不适用到期决策' } })
}
// 解密薪资
let salary = 0
try { salary = Number(decrypt(emp.monthlySalary)) || 0 } catch { salary = Number(emp.monthlySalary) || 0 }
const today = new Date()
const hireDate = emp.hireDate
const endDate = contract.endDate
const totalMonths = (endDate.getFullYear() - hireDate.getFullYear()) * 12 + (endDate.getMonth() - hireDate.getMonth())
const years = Math.floor(totalMonths / 12)
const remainingMonths = totalMonths % 12
let n = years
if (remainingMonths >= 6) n = years + 1
else if (remainingMonths > 0) n = years + 0.5
if (n <= 0) n = 0.5
const daysToExpiry = Math.ceil((endDate.getTime() - today.getTime()) / 86400000)
// 三选项成本对比
const options = [
{
key: 'RENEW',
title: '续签合同',
cost: 0,
description: '与员工续签劳动合同,保持劳动关系延续',
legalRisk: '低',
details: {
compensation: 0,
noticePeriod: '无需通知',
notes: '续签时如维持或提高条件,员工拒绝则无需补偿;降低条件员工拒绝需支付 N',
},
},
{
key: 'EXPIRE_NO_RENEW',
title: '到期不续签',
cost: salary * n,
description: '合同到期后公司决定不续签,需支付经济补偿金 N',
legalRisk: '中',
details: {
compensation: salary * n,
n,
monthlySalary: salary,
noticePeriod: '建议提前30天书面通知',
notes: '公司提出不续签需支付经济补偿金(N);员工主动提出不续签则无需支付',
},
},
{
key: 'EXPIRE_WAIT',
title: '逾期不处理(风险最高)',
cost: salary * (n + 1),
description: '合同到期后继续用工但不签新合同,可能被认定为事实劳动关系',
legalRisk: '高',
details: {
compensation: salary * (n + 1),
n,
monthlySalary: salary,
notes: '逾期超过1个月未续签,员工可主张双倍工资;满1年视为已订立无固定期限劳动合同',
},
},
]
// 构建 AI 建议请求
const decisionContext = `员工合同到期决策分析:
- 姓名:${emp.name}
- 部门:${emp.department}
- 入职日期:${hireDate.toISOString().slice(0, 10)}
- 合同到期日:${endDate.toISOString().slice(0, 10)}(距今${daysToExpiry}天)
- 月薪:¥${salary.toFixed(2)}
- 工龄:${years}${remainingMonths}月(经济补偿月数 N=${n}
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
三选项成本对比:
1. 续签:成本 ¥0
2. 到期不续签:成本 ¥${(salary * n).toFixed(2)}(经济补偿 N=${n}
3. 逾期不处理:风险成本 ¥${(salary * (n + 1)).toFixed(2)}(双倍工资风险)
请给出专业建议,分析每个选项的法律风险和实际影响,推荐最优方案。`
let aiAdvice = ''
try {
await checkUsageLimit(req.user!.orgId, 'chat')
const result = await chat([{ role: 'user', content: decisionContext }], '')
aiAdvice = result
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
} catch {
aiAdvice = 'AI 建议生成失败,请参考以上成本对比数据自行判断。'
}
res.json({
success: true,
data: {
employee: {
id: emp.id,
name: emp.name,
department: emp.department,
hireDate: hireDate.toISOString().slice(0, 10),
contractEndDate: endDate.toISOString().slice(0, 10),
daysToExpiry,
monthlySalary: salary,
workYears: years,
workMonths: remainingMonths,
n,
isPregnant: emp.isPregnant,
isInMedicalPeriod: emp.isInMedicalPeriod,
isWorkInjured: emp.isWorkInjured,
},
options,
aiAdvice,
},
})
} catch (err) {
next(err)
}
})
export default router
+94
View File
@@ -0,0 +1,94 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
import {
createAttendanceConfirmation,
batchCreateAttendanceConfirmations,
getAttendanceConfirmations,
confirmAttendance,
getAttendanceStats,
} from '../services/attendance.service'
import { createEvidence } from '../services/evidence.service'
const router = Router()
/** 获取月度考勤确认列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = req.query.month as string
if (!month) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } })
}
const status = req.query.status as string | undefined
const data = await getAttendanceConfirmations(req.user!.orgId, month, status)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
/** 获取考勤确认统计 */
router.get('/stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = req.query.month as string
if (!month) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } })
}
const data = await getAttendanceStats(req.user!.orgId, month)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
/** 批量创建考勤确认记录 */
router.post('/batch', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
items: z.array(z.object({
employeeId: z.string(),
workDays: z.number().int().min(0),
weekdayHours: z.number().min(0),
weekendHours: z.number().min(0),
holidayHours: z.number().min(0),
overtimePay: z.number().min(0),
})),
})
const { month, items } = schema.parse(req.body)
const result = await batchCreateAttendanceConfirmations(req.user!.orgId, req.user!.id, month, items)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
/** 员工确认考勤(员工端) */
router.post('/confirm', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
employeeId: z.string(),
month: z.string().regex(/^\d{4}-\d{2}$/),
disputeNote: z.string().optional(),
})
const { employeeId, month, disputeNote } = schema.parse(req.body)
const ip = req.ip || req.socket.remoteAddress || ''
const result = await confirmAttendance(req.user!.orgId, employeeId, month, ip, disputeNote)
await createEvidence({
orgId: req.user!.orgId,
category: 'ATTENDANCE',
refId: result.id,
employeeId,
events: [{ action: '考勤确认', timestamp: new Date().toISOString(), ip, userAgent: req.headers['user-agent'], ...(disputeNote ? { location: disputeNote } : {}) }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
export default router
+80
View File
@@ -0,0 +1,80 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
/**
* 查询审计日志
* 支持按操作类型、实体类型、时间范围筛选
*/
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const action = req.query.action as string | undefined
const entity = req.query.entity as string | undefined
const userId = req.query.userId as string | undefined
const dateFrom = req.query.dateFrom as string | undefined
const dateTo = req.query.dateTo as string | undefined
const where: any = { orgId: req.user!.orgId }
if (action) where.action = { contains: action, mode: 'insensitive' }
if (entity) where.entity = entity
if (userId) where.userId = userId
if (dateFrom || dateTo) {
where.createdAt = {}
if (dateFrom) where.createdAt.gte = new Date(dateFrom)
if (dateTo) where.createdAt.lte = new Date(`${dateTo}T23:59:59`)
}
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.auditLog.count({ where }),
])
res.json({
success: true,
data: { items: logs, total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
})
} catch (err) {
next(err)
}
})
/**
* 获取审计日志统计(按操作类型分组)
*/
router.get('/stats', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const dateFrom = req.query.dateFrom as string | undefined
const dateTo = req.query.dateTo as string | undefined
const where: any = { orgId: req.user!.orgId }
if (dateFrom || dateTo) {
where.createdAt = {}
if (dateFrom) where.createdAt.gte = new Date(dateFrom)
if (dateTo) where.createdAt.lte = new Date(`${dateTo}T23:59:59`)
}
const stats = await prisma.auditLog.groupBy({
by: ['action'],
where,
_count: { action: true },
orderBy: { _count: { action: 'desc' } },
take: 20,
})
res.json({ success: true, data: stats })
} catch (err) {
next(err)
}
})
export default router
+8 -9
View File
@@ -4,11 +4,10 @@ import { register, login, refresh, resetPassword } from '../services/auth.servic
import { authLimiter, loginLimiter } from '../middleware/rateLimit'
import prisma from '../lib/prisma'
import bcrypt from 'bcryptjs'
import { setCode, getCode, deleteCode } from '../lib/codeStore'
const router = Router()
const codeStore = new Map<string, { code: string; expiresAt: number }>()
router.post('/register', authLimiter, async (req, res, next) => {
try {
const data = registerSchema.parse(req.body)
@@ -48,7 +47,7 @@ router.post('/forgot-password/send-code', authLimiter, async (req, res, next) =>
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未注册' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
await setCode(data.phone, code)
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} catch (err) {
next(err)
@@ -59,14 +58,14 @@ router.post('/forgot-password/send-code', authLimiter, async (req, res, next) =>
router.post('/forgot-password/verify', authLimiter, async (req, res, next) => {
try {
const data = verifyCodeSchema.parse(req.body)
const stored = codeStore.get(data.phone)
if (!stored || stored.expiresAt < Date.now()) {
const stored = await getCode(data.phone)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.code !== data.code) {
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: '验证码错误' } })
}
codeStore.delete(data.phone)
await deleteCode(data.phone)
const passwordHash = await bcrypt.hash(data.newPassword, 10)
await prisma.user.updateMany({
where: { phone: data.phone },
@@ -81,11 +80,11 @@ router.post('/forgot-password/verify', authLimiter, async (req, res, next) => {
router.post('/reset-password', authLimiter, async (req, res, next) => {
try {
const data = resetPasswordSchema.parse(req.body)
const stored = codeStore.get(data.phone)
if (!stored || stored.expiresAt < Date.now()) {
const stored = await getCode(data.phone)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
codeStore.delete(data.phone)
await deleteCode(data.phone)
const result = await resetPassword(data.phone, data.newPassword)
res.json({ success: true, data: result })
} catch (err) {
+95 -1
View File
@@ -1,7 +1,7 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { getDashboardData } from '../services/risk.service'
import { getDashboardData, getMonthlyCalendar, getCostAnalysis, getComplianceScore, getHealthCheck, saveHealthCheckReport, getHealthCheckHistory, getAnnualValueReport, saveAnnualValueReport, getAnnualValueReportHistory } from '../services/risk.service'
import { z } from 'zod'
const router = Router()
@@ -77,4 +77,98 @@ router.patch('/todos/batch-ignore', authMiddleware, async (req: AuthRequest, res
}
})
// HR 月度日历
router.get('/calendar', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const data = await getMonthlyCalendar(req.user!.orgId, month)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 人力成本深度分析
router.get('/cost-analysis', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const data = await getCostAnalysis(req.user!.orgId, month)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 合规健康度评分 + AI 建议卡片流
router.get('/compliance-score', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getComplianceScore(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 用工体检诊断 — 获取当前诊断
router.get('/health-check', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getHealthCheck(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 用工体检诊断 — 保存报告
router.post('/health-check/save', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const report = await saveHealthCheckReport(req.user!.orgId, req.user!.id)
res.json({ success: true, data: { id: (report as any).id } })
} catch (err) {
next(err)
}
})
// 用工体检诊断 — 历史报告
router.get('/health-check/history', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getHealthCheckHistory(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 年度价值报告 — 获取当年报告
router.get('/annual-value', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const year = parseInt(req.query.year as string) || new Date().getFullYear()
const data = await getAnnualValueReport(req.user!.orgId, year)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 年度价值报告 — 保存
router.post('/annual-value/save', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const year = parseInt(req.body.year) || new Date().getFullYear()
const report = await saveAnnualValueReport(req.user!.orgId, req.user!.id, year)
res.json({ success: true, data: { id: (report as any).id } })
} catch (err) {
next(err)
}
})
// 年度价值报告 — 历史
router.get('/annual-value/history', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getAnnualValueReportHistory(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
export default router
+17
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
import {
createEmployeeSchema,
@@ -49,6 +50,14 @@ router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
const data = createEmployeeSchema.parse(req.body)
const result = await createEmployee(req.user!.orgId, req.user!.id, data)
await auditLog(req, 'CREATE', 'EMPLOYEE', result.id, { name: data.name })
await createEvidence({
orgId: req.user!.orgId,
category: 'ONBOARD',
refId: result.id,
employeeId: result.id,
events: [{ action: '员工入职登记', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: result })
} catch (err) {
next(err)
@@ -199,6 +208,14 @@ router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) =>
const data = addContractSchema.parse(req.body)
const result = await addContract(req.user!.orgId, req.user!.id, data)
await auditLog(req, 'ADD_CONTRACT', 'CONTRACT', result.id, { employeeId: data.employeeId })
await createEvidence({
orgId: req.user!.orgId,
category: 'CONTRACT_SIGN',
refId: result.id,
employeeId: data.employeeId,
events: [{ action: '合同签订', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: result })
} catch (err) {
next(err)
+88
View File
@@ -0,0 +1,88 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { getEvidenceByRef, getEvidenceByEmployee, getEvidenceDetail, verifyEvidence, getEvidenceList, verifyAllEvidence } from '../services/evidence.service'
const router = Router()
/**
* 获取组织级证据链列表
* GET /api/v1/evidence?category=ALL&page=1&pageSize=20
*/
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const category = (req.query.category as string) || 'ALL'
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const data = await getEvidenceList(req.user!.orgId, category, page, pageSize)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
/**
* 验证全部证据链完整性
* GET /api/v1/evidence/verify-all
*/
router.get('/verify-all', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await verifyAllEvidence(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
/**
* 获取某操作的完整证据链
* GET /api/v1/evidence/:category/:refId
*/
router.get('/:category/:refId', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const evidence = await getEvidenceByRef(req.user!.orgId, req.params.category, req.params.refId)
res.json({ success: true, data: evidence })
} catch (err) {
next(err)
}
})
/**
* 获取某员工所有证据链
* GET /api/v1/evidence/employee/:id
*/
router.get('/employee/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const evidence = await getEvidenceByEmployee(req.user!.orgId, req.params.id)
res.json({ success: true, data: evidence })
} catch (err) {
next(err)
}
})
/**
* 获取证据链详情
* GET /api/v1/evidence/detail/:id
*/
router.get('/detail/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const evidence = await getEvidenceDetail(req.user!.orgId, req.params.id)
res.json({ success: true, data: evidence })
} catch (err) {
next(err)
}
})
/**
* 验证证据链完整性
* GET /api/v1/evidence/verify/:id
*/
router.get('/verify/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const result = await verifyEvidence(req.user!.orgId, req.params.id)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
export default router
+14
View File
@@ -8,6 +8,7 @@ import {
calcBatchEntry,
getPayrollRiskWarnings,
generatePayslipFromBatches,
prePayrollCheck,
} from '../services/payroll.service'
const router = Router()
@@ -670,4 +671,17 @@ router.get('/batches/:batchId/export', async (req: AuthRequest, res: Response, n
}
})
// 算薪前 AI 校验
router.get('/batches/:batchId/pre-check', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const result = await prePayrollCheck(req.user!.orgId, req.params.batchId)
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
export default router
+123
View File
@@ -0,0 +1,123 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
import prisma from '../lib/prisma'
import {
createPolicy, getPolicies, getPolicyDetail, updatePolicy,
advanceDemocracyStep, deletePolicy,
} from '../services/policy.service'
const router = Router()
/** 获取制度列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const status = req.query.status as string | undefined
const policies = await getPolicies(req.user!.orgId, status)
res.json({ success: true, data: policies })
} catch (err) {
next(err)
}
})
/** 获取制度详情 */
router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const policy = await getPolicyDetail(req.user!.orgId, req.params.id)
res.json({ success: true, data: policy })
} catch (err) {
next(err)
}
})
/** 创建制度 */
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
title: z.string().min(1),
content: z.string().min(1),
type: z.string().optional(),
})
const data = schema.parse(req.body)
const policy = await createPolicy(req.user!.orgId, req.user!.id, data)
res.json({ success: true, data: { id: policy.id } })
} catch (err) {
next(err)
}
})
/** 更新制度 */
router.put('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
title: z.string().optional(),
content: z.string().optional(),
type: z.string().optional(),
})
const data = schema.parse(req.body)
await updatePolicy(req.user!.orgId, req.params.id, data)
res.json({ success: true })
} catch (err) {
next(err)
}
})
/** 推进民主程序步骤 */
router.post('/:id/advance-step', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({ step: z.number().int().min(1).max(4), note: z.string().optional() })
const { step, note } = schema.parse(req.body)
await advanceDemocracyStep(req.user!.orgId, req.params.id, step, note)
res.json({ success: true })
} catch (err) {
next(err)
}
})
/** 删除制度 */
router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await deletePolicy(req.user!.orgId, req.params.id)
res.json({ success: true })
} catch (err) {
next(err)
}
})
/** 获取制度的阅读签收统计 */
router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true } })
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } })
}
const [totalEmployees, readRecords] = await Promise.all([
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
prisma.policyReadRecord.findMany({
where: { policyId: req.params.id, orgId },
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { readAt: 'desc' },
}),
])
res.json({
success: true,
data: {
total: totalEmployees,
readCount: readRecords.length,
unreadCount: totalEmployees - readRecords.length,
records: readRecords.map(r => ({
employeeId: r.employeeId,
employeeName: r.employee.name,
department: r.employee.department,
readAt: r.readAt.toISOString(),
ip: r.ip,
})),
},
})
} catch (err) {
next(err)
}
})
export default router
+141 -19
View File
@@ -6,12 +6,11 @@ import fs from 'fs'
import prisma from '../lib/prisma'
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
import { createEvidence } from '../services/evidence.service'
const router = Router()
// 验证码临时存储(生产环境应使用 Redis)
const codeStore = new Map<string, { code: string; expiresAt: number; failCount: number; lastSentAt: number }>()
// 员工端认证中间件
function portalAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
@@ -63,12 +62,12 @@ router.post('/send-code', async (req, res, next) => {
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未在系统中登记' } })
}
// 频率限制:60秒内不可重复发送
const existing = codeStore.get(data.phone)
if (existing && existing.lastSentAt && Date.now() - existing.lastSentAt < 60 * 1000) {
const allowed = await checkRateLimit(data.phone)
if (!allowed) {
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '验证码发送过于频繁,请60秒后重试' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
await setCode(data.phone, code)
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} catch (err) {
next(err)
@@ -79,20 +78,20 @@ router.post('/send-code', async (req, res, next) => {
router.post('/verify-code', async (req, res, next) => {
try {
const data = portalVerifyCodeSchema.parse(req.body)
const stored = codeStore.get(data.phone)
if (!stored || stored.expiresAt < Date.now()) {
const stored = await getCode(data.phone)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
// 错误次数限制:5次后锁定
if (stored.failCount >= 5) {
codeStore.delete(data.phone)
await deleteCode(data.phone)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== data.code) {
stored.failCount++
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
await updateCode(data.phone, { failCount: stored.failCount + 1 })
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
}
codeStore.delete(data.phone)
await deleteCode(data.phone)
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } })
if (!employee) {
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
@@ -158,6 +157,14 @@ router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
channel: 'IN_APP',
},
})
await createEvidence({
orgId: req.employee.orgId,
category: 'PAYSLIP_CONFIRM',
refId: payslip.id,
employeeId: req.employee.id,
events: [{ action: '工资条确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.employee.id,
}).catch(() => {})
res.json({ success: true })
} catch (err) {
next(err)
@@ -231,7 +238,7 @@ router.post('/contract-confirm/send-code', async (req, res, next) => {
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '员工手机号未登记,无法发送验证码' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(`contract-${data.token}`, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
await setCode(`contract-${data.token}`, code)
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} catch (err) {
next(err)
@@ -250,19 +257,19 @@ router.post('/contract-confirm', async (req, res, next) => {
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
}
// 验证码校验
const stored = codeStore.get(`contract-${data.token}`)
if (!stored || stored.expiresAt < Date.now()) {
const stored = await getCode(`contract-${data.token}`)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.failCount >= 5) {
codeStore.delete(`contract-${data.token}`)
await deleteCode(`contract-${data.token}`)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== data.verifyCode) {
stored.failCount++
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
await updateCode(`contract-${data.token}`, { failCount: stored.failCount + 1 })
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
}
codeStore.delete(`contract-${data.token}`)
await deleteCode(`contract-${data.token}`)
const userAgent = req.headers['user-agent'] || ''
const signEvidence = JSON.stringify({
@@ -278,6 +285,14 @@ router.post('/contract-confirm', async (req, res, next) => {
where: { id: link.contractId },
data: { attachmentName: `confirmed:${new Date().toISOString()}|evidence:${signEvidence}` },
})
await createEvidence({
orgId: link.contract.orgId,
category: 'CONTRACT_SIGN',
refId: link.contractId,
employeeId: link.contract.employeeId,
events: [{ action: '合同签署确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent, smsCode: data.verifyCode }],
createdBy: link.contract.employeeId,
}).catch(() => {})
res.json({ success: true, data: { message: '合同签署确认成功' } })
} catch (err) {
next(err)
@@ -422,4 +437,111 @@ router.post('/onboarding/:token/upload', onboardingUpload.single('file'), async
}
})
// ========== 员工端:规章制度公示 ==========
/** 获取已公示制度列表(含当前员工阅读状态) */
router.get('/policies', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const { orgId, id: employeeId } = (req as any).employee
const policies = await prisma.policyDocument.findMany({
where: { orgId, status: 'PUBLISHED' },
orderBy: { publishedAt: 'desc' },
select: {
id: true,
title: true,
type: true,
publishedAt: true,
readRecords: {
where: { employeeId },
select: { id: true, readAt: true },
},
},
})
const result = policies.map(p => ({
id: p.id,
title: p.title,
type: p.type,
publishedAt: p.publishedAt?.toISOString() || null,
hasRead: p.readRecords.length > 0,
readAt: p.readRecords[0]?.readAt?.toISOString() || null,
}))
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
/** 获取制度详情(已公示) */
router.get('/policies/:id', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const { orgId, id: employeeId } = (req as any).employee
const policy = await prisma.policyDocument.findFirst({
where: { id: req.params.id, orgId, status: 'PUBLISHED' },
select: {
id: true,
title: true,
content: true,
type: true,
publishedAt: true,
readRecords: {
where: { employeeId },
select: { id: true, readAt: true },
},
},
})
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } })
}
res.json({
success: true,
data: {
id: policy.id,
title: policy.title,
content: policy.content,
type: policy.type,
publishedAt: policy.publishedAt?.toISOString() || null,
hasRead: policy.readRecords.length > 0,
readAt: policy.readRecords[0]?.readAt?.toISOString() || null,
},
})
} catch (err) {
next(err)
}
})
/** 提交阅读确认(签收) */
router.post('/policies/:id/read', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const { orgId, id: employeeId } = (req as any).employee
const policy = await prisma.policyDocument.findFirst({
where: { id: req.params.id, orgId, status: 'PUBLISHED' },
select: { id: true },
})
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } })
}
// 幂等:已存在则返回已有记录
const existing = await prisma.policyReadRecord.findUnique({
where: { policyId_employeeId: { policyId: req.params.id, employeeId } },
})
if (existing) {
return res.json({ success: true, data: { readAt: existing.readAt.toISOString() } })
}
const record = await prisma.policyReadRecord.create({
data: {
policyId: req.params.id,
orgId,
employeeId,
ip: req.ip || req.socket.remoteAddress,
userAgent: req.headers['user-agent'] || null,
},
})
res.json({ success: true, data: { readAt: record.readAt.toISOString() } })
} catch (err) {
next(err)
}
})
export default router
+9
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
import { decrypt, encrypt } from '../lib/crypto'
import { getContractStatus } from '../services/contract.service'
@@ -523,6 +524,14 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
},
})
await auditLog(req, 'CREATE', 'DISCIPLINARY', record.id, { employeeId: req.params.employeeId })
await createEvidence({
orgId: req.user!.orgId,
category: 'DISCIPLINARY',
refId: record.id,
employeeId: req.params.employeeId,
events: [{ action: '违纪记录创建', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
+45
View File
@@ -0,0 +1,45 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { getAllTemplates, getTemplateById, getTemplatesByCategory, renderTemplate } from '../services/template.service'
const router = Router()
/** 获取所有模板列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const category = req.query.category as string | undefined
const templates = category ? getTemplatesByCategory(category) : getAllTemplates()
res.json({ success: true, data: templates })
} catch (err) {
next(err)
}
})
/** 获取模板详情 */
router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const template = getTemplateById(req.params.id)
if (!template) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
res.json({ success: true, data: template })
} catch (err) {
next(err)
}
})
/** 渲染模板 */
router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { variables } = req.body as { variables: Record<string, string> }
const content = renderTemplate(req.params.id, variables || {})
if (!content) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
res.json({ success: true, data: { content } })
} catch (err) {
next(err)
}
})
export default router
+32 -1
View File
@@ -2,8 +2,9 @@ import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { terminationChecklistSchema } from '../schemas/termination.schema'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems } 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 { createEvidence } from '../services/evidence.service'
const router = Router()
@@ -63,6 +64,14 @@ router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
const data = terminationChecklistSchema.parse(req.body)
const result = await createTermination(req.user!.orgId, req.user!.id, data)
await auditLog(req, 'TERMINATE', 'EMPLOYEE', data.employeeId, { reason: data.reason })
await createEvidence({
orgId: req.user!.orgId,
category: 'TERMINATION',
refId: result.id,
employeeId: data.employeeId,
events: [{ action: '解聘流程启动', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: result })
} catch (err) {
next(err)
@@ -246,6 +255,14 @@ router.post('/draft/:id/execute', authMiddleware, async (req: AuthRequest, res,
try {
const result = await executeTermination(req.user!.orgId, req.params.id, req.user!.id)
await auditLog(req, 'EXECUTE_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
await createEvidence({
orgId: req.user!.orgId,
category: 'TERMINATION',
refId: req.params.id,
employeeId: (result as any)?.employeeId,
events: [{ action: '解聘执行', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
@@ -269,4 +286,18 @@ router.post('/draft/:id/cancel', authMiddleware, async (req: AuthRequest, res, n
}
})
// 步骤前置校验
router.get('/draft/:id/validate-step', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const step = parseInt(req.query.step as string) || 1
const result = await validateTerminationStep(req.user!.orgId, req.params.id, step)
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
export default router
+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
}