337 lines
13 KiB
C#
337 lines
13 KiB
C#
|
|
using Meezi.API.Models.Hr;
|
||
|
|
using Meezi.Core.Entities;
|
||
|
|
using Meezi.Core.Enums;
|
||
|
|
using Meezi.Core.Interfaces;
|
||
|
|
using Meezi.Infrastructure.Data;
|
||
|
|
using Microsoft.EntityFrameworkCore;
|
||
|
|
|
||
|
|
namespace Meezi.API.Services;
|
||
|
|
|
||
|
|
public interface IHrService
|
||
|
|
{
|
||
|
|
Task<IReadOnlyList<EmployeeSummaryDto>> GetEmployeesAsync(
|
||
|
|
string cafeId,
|
||
|
|
string? branchId = null,
|
||
|
|
CancellationToken cancellationToken = default);
|
||
|
|
Task<EmployeeSummaryDto?> GetEmployeeAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||
|
|
Task<bool> EmployeeBelongsToCafeAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||
|
|
|
||
|
|
Task<TodayShiftDto?> GetTodayShiftAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||
|
|
Task<AttendanceDto?> ClockInAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||
|
|
Task<AttendanceDto?> ClockOutAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||
|
|
Task<IReadOnlyList<AttendanceDto>> GetAttendanceAsync(string cafeId, string? employeeId, DateOnly? from, DateOnly? to, CancellationToken cancellationToken = default);
|
||
|
|
|
||
|
|
Task<IReadOnlyList<ShiftDto>> GetShiftsAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||
|
|
Task<IReadOnlyList<ShiftDto>> UpsertShiftsAsync(string cafeId, string employeeId, UpsertShiftsRequest request, CancellationToken cancellationToken = default);
|
||
|
|
|
||
|
|
Task<IReadOnlyList<LeaveRequestDto>> GetLeaveRequestsAsync(string cafeId, LeaveStatus? status, CancellationToken cancellationToken = default);
|
||
|
|
Task<LeaveRequestDto?> CreateLeaveRequestAsync(string cafeId, string employeeId, CreateLeaveRequest request, CancellationToken cancellationToken = default);
|
||
|
|
Task<LeaveRequestDto?> ReviewLeaveRequestAsync(string cafeId, string leaveId, string reviewerId, ReviewLeaveRequest request, CancellationToken cancellationToken = default);
|
||
|
|
|
||
|
|
Task<IReadOnlyList<EmployeeSalaryDto>> GetSalariesAsync(string cafeId, string? monthYear, CancellationToken cancellationToken = default);
|
||
|
|
Task<EmployeeSalaryDto?> CreateSalaryAsync(string cafeId, CreateSalaryRequest request, CancellationToken cancellationToken = default);
|
||
|
|
Task<EmployeeSalaryDto?> MarkSalaryPaidAsync(string cafeId, string salaryId, CancellationToken cancellationToken = default);
|
||
|
|
}
|
||
|
|
|
||
|
|
public class HrService : IHrService
|
||
|
|
{
|
||
|
|
private readonly AppDbContext _db;
|
||
|
|
|
||
|
|
public HrService(AppDbContext db)
|
||
|
|
{
|
||
|
|
_db = db;
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<IReadOnlyList<EmployeeSummaryDto>> GetEmployeesAsync(
|
||
|
|
string cafeId,
|
||
|
|
string? branchId = null,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var query = _db.Employees.Where(e => e.CafeId == cafeId);
|
||
|
|
if (!string.IsNullOrEmpty(branchId))
|
||
|
|
query = query.Where(e => e.BranchId == branchId);
|
||
|
|
var list = await query.OrderBy(e => e.Name).ToListAsync(cancellationToken);
|
||
|
|
return list.Select(e => new EmployeeSummaryDto(e.Id, e.Name, e.Phone, e.Role, e.BaseSalary)).ToList();
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<EmployeeSummaryDto?> GetEmployeeAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == employeeId && x.CafeId == cafeId, cancellationToken);
|
||
|
|
return e is null ? null : new EmployeeSummaryDto(e.Id, e.Name, e.Phone, e.Role, e.BaseSalary);
|
||
|
|
}
|
||
|
|
|
||
|
|
public Task<bool> EmployeeBelongsToCafeAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default) =>
|
||
|
|
_db.Employees.AnyAsync(e => e.Id == employeeId && e.CafeId == cafeId, cancellationToken);
|
||
|
|
|
||
|
|
public async Task<TodayShiftDto?> GetTodayShiftAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
if (!await EmployeeBelongsToCafeAsync(cafeId, employeeId, cancellationToken))
|
||
|
|
return null;
|
||
|
|
|
||
|
|
var day = (int)DateTime.UtcNow.DayOfWeek;
|
||
|
|
var shift = await _db.EmployeeSchedules
|
||
|
|
.FirstOrDefaultAsync(s => s.EmployeeId == employeeId && s.DayOfWeek == day, cancellationToken);
|
||
|
|
|
||
|
|
var type = shift?.ShiftType ?? ShiftType.DayOff;
|
||
|
|
return new TodayShiftDto(type, ShiftLabel(type));
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<AttendanceDto?> ClockInAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var employee = await _db.Employees
|
||
|
|
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId, cancellationToken);
|
||
|
|
if (employee is null) return null;
|
||
|
|
|
||
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||
|
|
var attendance = await _db.Attendances
|
||
|
|
.FirstOrDefaultAsync(a => a.EmployeeId == employeeId && a.Date == today, cancellationToken);
|
||
|
|
|
||
|
|
if (attendance is null)
|
||
|
|
{
|
||
|
|
attendance = new Attendance
|
||
|
|
{
|
||
|
|
EmployeeId = employeeId,
|
||
|
|
Date = today,
|
||
|
|
ClockIn = DateTime.UtcNow
|
||
|
|
};
|
||
|
|
_db.Attendances.Add(attendance);
|
||
|
|
}
|
||
|
|
else if (attendance.ClockIn is null)
|
||
|
|
{
|
||
|
|
attendance.ClockIn = DateTime.UtcNow;
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
return ToAttendanceDto(attendance, employee.Name);
|
||
|
|
}
|
||
|
|
|
||
|
|
await _db.SaveChangesAsync(cancellationToken);
|
||
|
|
return ToAttendanceDto(attendance, employee.Name);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<AttendanceDto?> ClockOutAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var employee = await _db.Employees
|
||
|
|
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId, cancellationToken);
|
||
|
|
if (employee is null) return null;
|
||
|
|
|
||
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||
|
|
var attendance = await _db.Attendances
|
||
|
|
.FirstOrDefaultAsync(a => a.EmployeeId == employeeId && a.Date == today, cancellationToken);
|
||
|
|
|
||
|
|
if (attendance?.ClockIn is null) return null;
|
||
|
|
|
||
|
|
attendance.ClockOut = DateTime.UtcNow;
|
||
|
|
await _db.SaveChangesAsync(cancellationToken);
|
||
|
|
return ToAttendanceDto(attendance, employee.Name);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<IReadOnlyList<AttendanceDto>> GetAttendanceAsync(
|
||
|
|
string cafeId,
|
||
|
|
string? employeeId,
|
||
|
|
DateOnly? from,
|
||
|
|
DateOnly? to,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var query = _db.Attendances
|
||
|
|
.Include(a => a.Employee)
|
||
|
|
.Where(a => a.Employee.CafeId == cafeId);
|
||
|
|
|
||
|
|
if (!string.IsNullOrEmpty(employeeId))
|
||
|
|
query = query.Where(a => a.EmployeeId == employeeId);
|
||
|
|
|
||
|
|
if (from.HasValue)
|
||
|
|
query = query.Where(a => a.Date >= from.Value);
|
||
|
|
if (to.HasValue)
|
||
|
|
query = query.Where(a => a.Date <= to.Value);
|
||
|
|
|
||
|
|
var list = await query.OrderByDescending(a => a.Date).Take(100).ToListAsync(cancellationToken);
|
||
|
|
return list.Select(a => ToAttendanceDto(a, a.Employee.Name)).ToList();
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<IReadOnlyList<ShiftDto>> GetShiftsAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
if (!await EmployeeBelongsToCafeAsync(cafeId, employeeId, cancellationToken))
|
||
|
|
return [];
|
||
|
|
|
||
|
|
var shifts = await _db.EmployeeSchedules
|
||
|
|
.Where(s => s.EmployeeId == employeeId)
|
||
|
|
.OrderBy(s => s.DayOfWeek)
|
||
|
|
.ToListAsync(cancellationToken);
|
||
|
|
return shifts.Select(s => new ShiftDto(s.DayOfWeek, s.ShiftType)).ToList();
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<IReadOnlyList<ShiftDto>> UpsertShiftsAsync(
|
||
|
|
string cafeId,
|
||
|
|
string employeeId,
|
||
|
|
UpsertShiftsRequest request,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
if (!await EmployeeBelongsToCafeAsync(cafeId, employeeId, cancellationToken))
|
||
|
|
return [];
|
||
|
|
|
||
|
|
var existing = await _db.EmployeeSchedules.Where(s => s.EmployeeId == employeeId).ToListAsync(cancellationToken);
|
||
|
|
_db.EmployeeSchedules.RemoveRange(existing);
|
||
|
|
|
||
|
|
foreach (var s in request.Shifts)
|
||
|
|
{
|
||
|
|
_db.EmployeeSchedules.Add(new EmployeeSchedule
|
||
|
|
{
|
||
|
|
EmployeeId = employeeId,
|
||
|
|
DayOfWeek = s.DayOfWeek,
|
||
|
|
ShiftType = s.ShiftType
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
await _db.SaveChangesAsync(cancellationToken);
|
||
|
|
return request.Shifts.ToList();
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<IReadOnlyList<LeaveRequestDto>> GetLeaveRequestsAsync(
|
||
|
|
string cafeId,
|
||
|
|
LeaveStatus? status,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var query = _db.LeaveRequests
|
||
|
|
.Include(l => l.Employee)
|
||
|
|
.Where(l => l.Employee.CafeId == cafeId);
|
||
|
|
|
||
|
|
if (status.HasValue)
|
||
|
|
query = query.Where(l => l.Status == status.Value);
|
||
|
|
|
||
|
|
var list = await query.OrderByDescending(l => l.CreatedAt).ToListAsync(cancellationToken);
|
||
|
|
return list.Select(l => ToLeaveDto(l)).ToList();
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<LeaveRequestDto?> CreateLeaveRequestAsync(
|
||
|
|
string cafeId,
|
||
|
|
string employeeId,
|
||
|
|
CreateLeaveRequest request,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
if (!await EmployeeBelongsToCafeAsync(cafeId, employeeId, cancellationToken))
|
||
|
|
return null;
|
||
|
|
|
||
|
|
var employee = await _db.Employees.FindAsync([employeeId], cancellationToken);
|
||
|
|
var entity = new LeaveRequest
|
||
|
|
{
|
||
|
|
EmployeeId = employeeId,
|
||
|
|
StartDate = request.StartDate,
|
||
|
|
EndDate = request.EndDate,
|
||
|
|
Reason = request.Reason,
|
||
|
|
Status = LeaveStatus.Pending
|
||
|
|
};
|
||
|
|
|
||
|
|
_db.LeaveRequests.Add(entity);
|
||
|
|
await _db.SaveChangesAsync(cancellationToken);
|
||
|
|
return ToLeaveDto(entity, employee!.Name);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<LeaveRequestDto?> ReviewLeaveRequestAsync(
|
||
|
|
string cafeId,
|
||
|
|
string leaveId,
|
||
|
|
string reviewerId,
|
||
|
|
ReviewLeaveRequest request,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var entity = await _db.LeaveRequests
|
||
|
|
.Include(l => l.Employee)
|
||
|
|
.FirstOrDefaultAsync(l => l.Id == leaveId && l.Employee.CafeId == cafeId, cancellationToken);
|
||
|
|
|
||
|
|
if (entity is null) return null;
|
||
|
|
|
||
|
|
entity.Status = request.Status;
|
||
|
|
entity.ReviewedBy = reviewerId;
|
||
|
|
await _db.SaveChangesAsync(cancellationToken);
|
||
|
|
return ToLeaveDto(entity);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<IReadOnlyList<EmployeeSalaryDto>> GetSalariesAsync(
|
||
|
|
string cafeId,
|
||
|
|
string? monthYear,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var query = _db.EmployeeSalaries
|
||
|
|
.Include(s => s.Employee)
|
||
|
|
.Where(s => s.Employee.CafeId == cafeId);
|
||
|
|
|
||
|
|
if (!string.IsNullOrEmpty(monthYear))
|
||
|
|
query = query.Where(s => s.MonthYear == monthYear);
|
||
|
|
|
||
|
|
var list = await query.OrderByDescending(s => s.MonthYear).ToListAsync(cancellationToken);
|
||
|
|
return list.Select(s => ToSalaryDto(s)).ToList();
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<EmployeeSalaryDto?> CreateSalaryAsync(
|
||
|
|
string cafeId,
|
||
|
|
CreateSalaryRequest request,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
if (!await EmployeeBelongsToCafeAsync(cafeId, request.EmployeeId, cancellationToken))
|
||
|
|
return null;
|
||
|
|
|
||
|
|
var employee = await _db.Employees.FindAsync([request.EmployeeId], cancellationToken);
|
||
|
|
var net = request.BaseSalary + request.OvertimePay - request.Deductions;
|
||
|
|
|
||
|
|
var entity = new EmployeeSalary
|
||
|
|
{
|
||
|
|
EmployeeId = request.EmployeeId,
|
||
|
|
MonthYear = request.MonthYear,
|
||
|
|
BaseSalary = request.BaseSalary,
|
||
|
|
OvertimePay = request.OvertimePay,
|
||
|
|
Deductions = request.Deductions,
|
||
|
|
NetSalary = net,
|
||
|
|
IsPaid = false
|
||
|
|
};
|
||
|
|
|
||
|
|
_db.EmployeeSalaries.Add(entity);
|
||
|
|
await _db.SaveChangesAsync(cancellationToken);
|
||
|
|
return ToSalaryDto(entity, employee!.Name);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<EmployeeSalaryDto?> MarkSalaryPaidAsync(string cafeId, string salaryId, CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var entity = await _db.EmployeeSalaries
|
||
|
|
.Include(s => s.Employee)
|
||
|
|
.FirstOrDefaultAsync(s => s.Id == salaryId && s.Employee.CafeId == cafeId, cancellationToken);
|
||
|
|
|
||
|
|
if (entity is null) return null;
|
||
|
|
|
||
|
|
entity.IsPaid = true;
|
||
|
|
await _db.SaveChangesAsync(cancellationToken);
|
||
|
|
return ToSalaryDto(entity);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string ShiftLabel(ShiftType type) => type switch
|
||
|
|
{
|
||
|
|
ShiftType.Morning => "08:00 - 16:00",
|
||
|
|
ShiftType.Evening => "16:00 - 00:00",
|
||
|
|
_ => "Day off"
|
||
|
|
};
|
||
|
|
|
||
|
|
private static AttendanceDto ToAttendanceDto(Attendance a, string name) => new(
|
||
|
|
a.Id, a.EmployeeId, name, a.Date, a.ClockIn, a.ClockOut, a.Notes);
|
||
|
|
|
||
|
|
private static LeaveRequestDto ToLeaveDto(LeaveRequest l, string? name = null) => new(
|
||
|
|
l.Id,
|
||
|
|
l.EmployeeId,
|
||
|
|
name ?? l.Employee?.Name ?? "",
|
||
|
|
l.StartDate,
|
||
|
|
l.EndDate,
|
||
|
|
l.Reason,
|
||
|
|
l.Status,
|
||
|
|
l.ReviewedBy,
|
||
|
|
l.CreatedAt);
|
||
|
|
|
||
|
|
private static EmployeeSalaryDto ToSalaryDto(EmployeeSalary s, string? name = null) => new(
|
||
|
|
s.Id,
|
||
|
|
s.EmployeeId,
|
||
|
|
name ?? s.Employee?.Name ?? "",
|
||
|
|
s.MonthYear,
|
||
|
|
s.BaseSalary,
|
||
|
|
s.OvertimePay,
|
||
|
|
s.Deductions,
|
||
|
|
s.NetSalary,
|
||
|
|
s.IsPaid);
|
||
|
|
}
|