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
+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