Sprint 4-5: 员工自助+考勤+合规+AI+搜索
This commit is contained in:
@@ -25,6 +25,7 @@ enum Role {
|
||||
enum EmployeeStatus {
|
||||
ACTIVE
|
||||
RESIGNED
|
||||
TERMINATED
|
||||
}
|
||||
|
||||
enum FemaleWorkerType {
|
||||
@@ -202,7 +203,9 @@ model Employee {
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
employeeNo String? // 工号
|
||||
department String
|
||||
position String? // 岗位
|
||||
hireDate DateTime
|
||||
monthlySalary String // AES-256 加密存储
|
||||
status EmployeeStatus @default(ACTIVE)
|
||||
@@ -965,6 +968,7 @@ model AttendanceConfirmation {
|
||||
holidayHours Float @default(0)
|
||||
overtimePay Float @default(0)
|
||||
confirmedAt DateTime?
|
||||
confirmedBy String?
|
||||
confirmIp String?
|
||||
status String @default("PENDING") // PENDING / CONFIRMED / DISPUTED
|
||||
disputeNote String? // 员工有异议时的说明
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user