Sprint 4-5: 员工自助+考勤+合规+AI+搜索

This commit is contained in:
selfrelease
2026-08-01 06:45:59 +08:00
parent 9946197d20
commit a88c96299c
16 changed files with 1919 additions and 35 deletions
+21
View File
@@ -105,6 +105,27 @@ router.post('/confirm', authMiddleware, async (req: AuthRequest, res: Response,
}
})
/** HR 批量确认考勤 */
router.post('/batch-confirm', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
ids: z.array(z.string()).optional(),
all: z.boolean().optional(),
})
const { month, ids, all } = schema.parse(req.body)
const where: any = { orgId: req.user!.orgId, month, status: 'PENDING' }
if (!all && ids?.length) {
where.id = { in: ids }
}
const result = await prisma.attendanceConfirmation.updateMany({
where,
data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmedBy: req.user!.id },
})
res.json({ success: true, data: { count: result.count } })
} catch (err) { next(err) }
})
// ========== 班次管理 ==========
router.get('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
+195
View File
@@ -640,4 +640,199 @@ router.get('/attendance', portalAuth, async (req: any, res, next) => {
}
})
// ========== 员工端:首页概览 ==========
router.get('/home/overview', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
if (!employee) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
// 最新工资条
const latestPayslip = await prisma.payslip.findFirst({
where: { employeeId, orgId, publishStatus: 'PUBLISHED' },
orderBy: { month: 'desc' },
select: { month: true, totalPay: true, netPay: true },
})
// 合同信息
const contract = await prisma.laborContract.findFirst({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
select: { contractType: true, startDate: true, endDate: true },
})
const typeLabels: Record<string, string> = { FIXED: '劳动合同-固定期', UNFIXED: '劳动合同-无固定期', LABOR: '劳务协议', INTERNSHIP: '实习协议', DISPATCH: '劳务派遣', OUTSOURCING: '业务外包', PARTTIME: '兼职协议', UNSIGNED: '未签合同' }
let daysToExpire: number | null = null
if (contract?.endDate) {
const diff = Math.ceil((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
daysToExpire = diff
}
// 本月考勤概览
const month = new Date().toISOString().slice(0, 7)
const startDate = new Date(`${month}-01`)
const endDate = new Date(startDate)
endDate.setMonth(endDate.getMonth() + 1)
const attendanceRecords = await prisma.attendanceRecord.findMany({
where: { employeeId, orgId, date: { gte: startDate, lt: endDate } },
})
const attendanceSummary = {
normalDays: attendanceRecords.filter((r: any) => r.status === 'NORMAL').length,
lateCount: attendanceRecords.filter((r: any) => r.status === 'LATE').length,
leaveDays: attendanceRecords.filter((r: any) => r.status === 'LEAVE').length,
absentDays: attendanceRecords.filter((r: any) => r.status === 'ABSENT').length,
}
// 待办事项
const pendingTasks: any[] = []
if (contract && daysToExpire !== null && daysToExpire < 30 && daysToExpire >= 0) {
pendingTasks.push({ severity: 'high', message: `合同将在 ${daysToExpire} 天后到期,请联系HR确认续签事宜` })
}
if (contract && daysToExpire !== null && daysToExpire < 0) {
pendingTasks.push({ severity: 'high', message: '合同已到期,请尽快联系HR办理续签或离职手续' })
}
// 查找未阅读的制度:取所有制度ID,排除已阅读的
const allPolicies = await prisma.policyDocument.findMany({ where: { orgId }, select: { id: true } })
const readRecords = await prisma.policyReadRecord.findMany({
where: { employeeId, orgId },
select: { policyId: true },
})
const readPolicyIds = new Set(readRecords.map(r => r.policyId))
const unreadPolicies = allPolicies.filter(p => !readPolicyIds.has(p.id))
if (unreadPolicies.length > 0) {
pendingTasks.push({ severity: 'medium', message: `您有 ${unreadPolicies.length} 份制度待阅读确认` })
}
res.json({
success: true,
data: {
latestPayslip,
contract: contract ? {
typeLabel: typeLabels[contract.contractType] || contract.contractType,
startDate: contract.startDate?.toISOString().slice(0, 10),
endDate: contract.endDate?.toISOString().slice(0, 10),
daysToExpire,
} : null,
attendance: attendanceSummary,
pendingTasks,
announcements: [],
},
})
} catch (err) { next(err) }
})
// ========== 员工端:入职进度 ==========
router.get('/onboarding/progress', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
if (!employee) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
const contract = await prisma.laborContract.findFirst({ where: { employeeId, orgId } })
const files = await prisma.employeeAttachment.findMany({ where: { employeeId, orgId } })
const steps: any = {
profile: {
completed: !!(employee.name && employee.idCardNumber && employee.phone),
description: employee.name ? '基本信息已填写' : '请完善基本信息',
completedAt: employee.hireDate,
},
documents: {
completed: files.length > 0,
description: files.length > 0 ? `已上传 ${files.length} 份材料` : '请上传入职材料',
},
contract: {
completed: !!contract,
description: contract ? '合同已签署' : '等待合同签署',
completedAt: contract?.createdAt,
},
bankcard: {
completed: !!(employee as any).bankCard,
description: (employee as any).bankCard ? '银行卡已登记' : '请登记银行卡信息',
},
complete: {
completed: employee.status === 'ACTIVE',
description: employee.status === 'ACTIVE' ? '入职流程已完成' : '入职流程进行中',
},
}
const completedCount = Object.values(steps).filter((s: any) => s.completed).length
const completionRate = Math.round((completedCount / 5) * 100)
const currentStepIndex = Object.values(steps).findIndex((s: any) => !s.completed)
const pendingItems: any[] = []
Object.entries(steps).forEach(([key, s]: any) => {
if (!s.completed) pendingItems.push({ message: s.description })
})
res.json({ success: true, data: { steps, completionRate, currentStepIndex, pendingItems } })
} catch (err) { next(err) }
})
// ========== 员工端:离职申请 ==========
// 提交离职申请
router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const { reason, expectedDate, remark } = req.body
if (!reason || !expectedDate) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请填写离职原因和预计离职日期' } })
}
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
if (!employee) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
if (employee.status === 'RESIGNED' || employee.status === 'TERMINATED') {
return res.status(400).json({ success: false, error: { code: 'ALREADY_RESIGNED', message: '您已离职,无法重复申请' } })
}
// 检查是否已有待审批的离职申请
const existing = await (prisma as any).terminationRecord.findFirst({
where: { employeeId, orgId, status: { in: ['DRAFT', 'PENDING_APPROVAL'] } },
})
if (existing) {
return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '您已有一个待处理的离职申请' } })
}
const record = await (prisma as any).terminationRecord.create({
data: {
employeeId, orgId,
reason: 'RESIGNATION',
terminationDate: new Date(expectedDate),
status: 'PENDING_APPROVAL',
remark: `员工自主申请:${reason}${remark ? ';备注:' + remark : ''}`,
createdBy: employeeId,
},
})
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 查询自己的离职申请状态
router.get('/resignation/status', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const records = await (prisma as any).terminationRecord.findMany({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
take: 5,
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 撤回离职申请(仅 DRAFT/PENDING_APPROVAL 可撤回)
router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await (prisma as any).terminationRecord.findFirst({
where: { id: req.params.id, employeeId, orgId },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '离职申请不存在' } })
if (record.status !== 'DRAFT' && record.status !== 'PENDING_APPROVAL') {
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '当前状态无法撤回' } })
}
await (prisma as any).terminationRecord.update({
where: { id: record.id },
data: { status: 'CANCELLED' },
})
res.json({ success: true, data: { id: record.id, status: 'CANCELLED' } })
} catch (err) { next(err) }
})
export default router
+51
View File
@@ -0,0 +1,51 @@
/**
* 全局搜索路由 — 员工、页面、功能搜索
*/
import { Router } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
/**
* GET /search?q=keyword
* 全局搜索:员工、部门等
*/
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const q = (req.query.q as string || '').trim()
if (!q || q.length < 1) {
return res.json({ success: true, data: { employees: [] } })
}
if (!req.user) {
return res.status(401).json({ success: false, message: '未授权' })
}
const orgId = req.user.orgId
// 搜索员工(按姓名、工号、手机号)
const employees = await prisma.employee.findMany({
where: {
orgId,
OR: [
{ name: { contains: q } },
{ employeeNo: { contains: q } },
{ phone: { contains: q } },
],
status: { notIn: ['TERMINATED'] },
},
select: {
id: true,
name: true,
department: true,
position: true,
employeeNo: true,
},
take: 10,
})
res.json({ success: true, data: { employees } })
} catch (err) { next(err) }
})
export default router