Files
TurboHR/backend/src/services/attendance.service.ts
T
freedakgmail a2e9ba55c2 feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除
P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量
P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
2026-08-09 11:59:02 +08:00

464 lines
14 KiB
TypeScript

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, department?: string) {
const where: any = { orgId, month }
if (status) where.status = status
if (department) where.employee = { department }
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,
}
}
// ========== 班次管理 ==========
export async function getShifts(orgId: string) {
return prisma.shift.findMany({
where: { orgId },
orderBy: { startTime: 'asc' },
})
}
export async function createShift(orgId: string, userId: string, data: {
name: string
startTime: string
endTime: string
flexibleMinutes?: number
restMinutes?: number
color?: string
}) {
return prisma.shift.create({
data: {
orgId,
name: data.name,
startTime: data.startTime,
endTime: data.endTime,
flexibleMinutes: data.flexibleMinutes || 0,
restMinutes: data.restMinutes || 0,
color: data.color || '#3b82f6',
createdBy: userId,
},
})
}
export async function updateShift(orgId: string, id: string, data: {
name?: string
startTime?: string
endTime?: string
flexibleMinutes?: number
restMinutes?: number
color?: string
}) {
return prisma.shift.update({ where: { id }, data })
}
export async function deleteShift(orgId: string, id: string) {
return prisma.shift.delete({ where: { id } })
}
// ========== 排班管理 ==========
export async function getShiftAssignments(orgId: string, date: string) {
const day = new Date(date)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
return prisma.shiftAssignment.findMany({
where: { orgId, date: { gte: day, lt: nextDay } },
include: {
employee: { select: { id: true, name: true, department: true } },
shift: true,
},
orderBy: { employee: { name: 'asc' } },
})
}
export async function batchAssignShifts(orgId: string, userId: string, items: Array<{
employeeId: string
shiftId: string
date: string
}>) {
const results: Array<{ employeeId: string; date: string; success: boolean; error?: string }> = []
for (const item of items) {
try {
const date = new Date(item.date)
date.setHours(0, 0, 0, 0)
const existing = await prisma.shiftAssignment.findUnique({
where: { employeeId_date: { employeeId: item.employeeId, date } },
})
if (existing) {
await prisma.shiftAssignment.update({
where: { id: existing.id },
data: { shiftId: item.shiftId },
})
} else {
await prisma.shiftAssignment.create({
data: {
orgId,
employeeId: item.employeeId,
shiftId: item.shiftId,
date,
createdBy: userId,
},
})
}
results.push({ employeeId: item.employeeId, date: item.date, success: true })
} catch (err: any) {
results.push({ employeeId: item.employeeId, date: item.date, success: false, error: err.message })
}
}
return { total: items.length, success: results.filter(r => r.success).length, results }
}
export async function deleteShiftAssignment(orgId: string, id: string) {
return prisma.shiftAssignment.delete({ where: { id } })
}
// ========== 每日出勤 ==========
export async function manualCorrectAttendance(orgId: string, data: {
employeeId: string
date: string
checkInTime?: string
checkOutTime?: string
status?: string
remark?: string
createdBy?: string
}) {
const day = new Date(data.date)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
const existing = await prisma.attendanceRecord.findFirst({
where: { orgId, employeeId: data.employeeId, date: { gte: day, lt: nextDay } },
})
const checkInTime = data.checkInTime ? new Date(`${data.date}T${data.checkInTime}:00Z`).toISOString() : null
const checkOutTime = data.checkOutTime ? new Date(`${data.date}T${data.checkOutTime}:00Z`).toISOString() : null
let workHours = 0
if (checkInTime && checkOutTime) {
workHours = Math.round((new Date(checkOutTime).getTime() - new Date(checkInTime).getTime()) / 3600000 * 100) / 100
}
if (existing) {
return prisma.attendanceRecord.update({
where: { id: existing.id },
data: {
checkInTime,
checkOutTime,
status: data.status || 'NORMAL',
workHours,
remark: data.remark || existing.remark,
},
})
} else {
return prisma.attendanceRecord.create({
data: {
orgId,
employeeId: data.employeeId,
date: day,
checkInTime,
checkOutTime,
status: data.status || 'NORMAL',
workHours,
remark: data.remark || null,
createdBy: data.createdBy || 'system',
},
})
}
}
export async function getDailyAttendance(orgId: string, date: string) {
const day = new Date(date)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
const [records, assignments, employees] = await Promise.all([
prisma.attendanceRecord.findMany({
where: { orgId, date: { gte: day, lt: nextDay } },
}),
prisma.shiftAssignment.findMany({
where: { orgId, date: { gte: day, lt: nextDay } },
include: { shift: true },
}),
prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true, name: true, department: true },
orderBy: { name: 'asc' },
}),
])
const recordMap = new Map(records.map(r => [r.employeeId, r]))
const shiftMap = new Map(assignments.map(a => [a.employeeId, a.shift]))
return employees.map(emp => {
const record = recordMap.get(emp.id)
const shift = shiftMap.get(emp.id)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
shift: shift ? { name: shift.name, startTime: shift.startTime, endTime: shift.endTime, color: shift.color } : null,
checkInTime: record?.checkInTime || null,
checkOutTime: record?.checkOutTime || null,
status: record?.status || 'UNREGISTERED',
lateMinutes: record?.lateMinutes || 0,
earlyMinutes: record?.earlyMinutes || 0,
workHours: record?.workHours || 0,
overtimeHours: record?.overtimeHours || 0,
remark: record?.remark || null,
}
})
}
// ========== 月度出勤报表 ==========
export async function getMonthlyReport(orgId: string, month: string) {
const monthStart = new Date(month + '-01')
const monthEnd = new Date(monthStart)
monthEnd.setMonth(monthEnd.getMonth() + 1)
const [records, confirmations, overtimes, leaves] = await Promise.all([
prisma.attendanceRecord.findMany({
where: { orgId, date: { gte: monthStart, lt: monthEnd } },
}),
prisma.attendanceConfirmation.findMany({
where: { orgId, month },
include: { employee: { select: { id: true, name: true, department: true } } },
}),
prisma.overtimeRecord.findMany({
where: { orgId, month },
}),
prisma.leaveRecord.findMany({
where: { orgId, startDate: { lt: monthEnd }, endDate: { gte: monthStart } },
}),
])
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true, name: true, department: true },
orderBy: { name: 'asc' },
})
const otMap = new Map<string, { hours: number; pay: number }>()
for (const ot of overtimes) {
const totalHours = (ot.weekdayHours || 0) + (ot.weekendHours || 0) + (ot.holidayHours || 0)
const prev = otMap.get(ot.employeeId) || { hours: 0, pay: 0 }
otMap.set(ot.employeeId, { hours: prev.hours + totalHours, pay: prev.pay + (ot.totalPay || 0) })
}
const leaveMap = new Map<string, number>()
for (const lv of leaves) {
leaveMap.set(lv.employeeId, (leaveMap.get(lv.employeeId) || 0) + lv.days)
}
return employees.map(emp => {
const empRecords = records.filter(r => r.employeeId === emp.id)
const confirmation = confirmations.find(c => c.employeeId === emp.id)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
workDays: confirmation?.workDays || empRecords.filter(r => r.status === 'NORMAL').length,
lateCount: empRecords.filter(r => r.status === 'LATE').length,
earlyLeaveCount: empRecords.filter(r => r.status === 'EARLY_LEAVE').length,
absentDays: empRecords.filter(r => r.status === 'ABSENT').length,
leaveDays: leaveMap.get(emp.id) || 0,
overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id)?.hours || 0),
overtimePay: confirmation?.overtimePay || otMap.get(emp.id)?.pay || 0,
confirmationStatus: confirmation?.status || null,
}
})
}
// ========== 休假记录 ==========
export async function getLeaveRecords(orgId: string, employeeId?: string) {
const where: any = { orgId }
if (employeeId) where.employeeId = employeeId
return prisma.leaveRecord.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { startDate: 'desc' },
})
}
export async function createLeaveRecord(orgId: string, userId: string, data: {
employeeId: string
leaveType: string
startDate: string
endDate: string
days: number
reason?: string
remark?: string
}) {
return prisma.leaveRecord.create({
data: {
orgId,
employeeId: data.employeeId,
leaveType: data.leaveType,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
days: data.days,
reason: data.reason || null,
remark: data.remark || null,
createdBy: userId,
},
include: { employee: { select: { id: true, name: true, department: true } } },
})
}
export async function deleteLeaveRecord(orgId: string, id: string) {
return prisma.leaveRecord.delete({ where: { id } })
}