feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user