feat(staff-portal): implement staff attendance and leave request management with dedicated portal UI and backend services
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
// EN: Check-in command for staff attendance.
|
||||
// VI: Command cham cong vao cho nhan vien.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Commands.Attendance;
|
||||
|
||||
public record CheckInCommand(Guid StaffId, Guid ShopId) : IRequest<CheckInResult>;
|
||||
public record CheckInResult(Guid AttendanceId, DateTime CheckIn);
|
||||
|
||||
public class CheckInCommandHandler : IRequestHandler<CheckInCommand, CheckInResult>
|
||||
{
|
||||
private readonly IAttendanceRepository _repo;
|
||||
private readonly ILogger<CheckInCommandHandler> _logger;
|
||||
|
||||
public CheckInCommandHandler(IAttendanceRepository repo, ILogger<CheckInCommandHandler> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CheckInResult> Handle(CheckInCommand request, CancellationToken ct)
|
||||
{
|
||||
var existing = await _repo.GetTodayRecordAsync(request.StaffId, ct);
|
||||
if (existing != null)
|
||||
return new CheckInResult(existing.Id, existing.CheckIn ?? DateTime.UtcNow);
|
||||
|
||||
var record = AttendanceRecord.CheckInNow(request.StaffId, request.ShopId);
|
||||
_repo.Add(record);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
|
||||
_logger.LogInformation("EN: Staff checked in / VI: Nhan vien da check-in: StaffId={StaffId}", request.StaffId);
|
||||
return new CheckInResult(record.Id, record.CheckIn ?? DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// EN: Check-out command for staff attendance.
|
||||
// VI: Command cham cong ra cho nhan vien.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Commands.Attendance;
|
||||
|
||||
public record CheckOutCommand(Guid StaffId) : IRequest<CheckOutResult>;
|
||||
public record CheckOutResult(Guid AttendanceId, DateTime CheckOut, decimal HoursWorked);
|
||||
|
||||
public class CheckOutCommandHandler : IRequestHandler<CheckOutCommand, CheckOutResult>
|
||||
{
|
||||
private readonly IAttendanceRepository _repo;
|
||||
private readonly ILogger<CheckOutCommandHandler> _logger;
|
||||
|
||||
public CheckOutCommandHandler(IAttendanceRepository repo, ILogger<CheckOutCommandHandler> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CheckOutResult> Handle(CheckOutCommand request, CancellationToken ct)
|
||||
{
|
||||
var record = await _repo.GetTodayRecordAsync(request.StaffId, ct)
|
||||
?? throw new InvalidOperationException("No check-in record found for today / Chua check-in hom nay");
|
||||
|
||||
record.DoCheckOut();
|
||||
_repo.Update(record);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
|
||||
_logger.LogInformation("EN: Staff checked out / VI: Nhan vien da check-out: StaffId={StaffId}, Hours={Hours}",
|
||||
request.StaffId, record.HoursWorked);
|
||||
return new CheckOutResult(record.Id, record.CheckOut ?? DateTime.UtcNow, record.HoursWorked ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// EN: Command to approve/reject a leave request.
|
||||
// VI: Command duyet/tu choi yeu cau nghi phep.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Commands.LeaveRequests;
|
||||
|
||||
public record ApproveLeaveRequestCommand(Guid LeaveRequestId, Guid ApprovedBy) : IRequest<bool>;
|
||||
public record RejectLeaveRequestCommand(Guid LeaveRequestId, Guid RejectedBy, string? Reason) : IRequest<bool>;
|
||||
|
||||
public class ApproveLeaveRequestCommandHandler : IRequestHandler<ApproveLeaveRequestCommand, bool>
|
||||
{
|
||||
private readonly ILeaveRequestRepository _repo;
|
||||
|
||||
public ApproveLeaveRequestCommandHandler(ILeaveRequestRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<bool> Handle(ApproveLeaveRequestCommand request, CancellationToken ct)
|
||||
{
|
||||
var leaveRequest = await _repo.GetByIdAsync(request.LeaveRequestId, ct);
|
||||
if (leaveRequest == null) return false;
|
||||
|
||||
leaveRequest.Approve(request.ApprovedBy);
|
||||
_repo.Update(leaveRequest);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class RejectLeaveRequestCommandHandler : IRequestHandler<RejectLeaveRequestCommand, bool>
|
||||
{
|
||||
private readonly ILeaveRequestRepository _repo;
|
||||
|
||||
public RejectLeaveRequestCommandHandler(ILeaveRequestRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<bool> Handle(RejectLeaveRequestCommand request, CancellationToken ct)
|
||||
{
|
||||
var leaveRequest = await _repo.GetByIdAsync(request.LeaveRequestId, ct);
|
||||
if (leaveRequest == null) return false;
|
||||
|
||||
leaveRequest.Reject(request.RejectedBy, request.Reason);
|
||||
_repo.Update(leaveRequest);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// EN: Command to create a leave request.
|
||||
// VI: Command tao yeu cau nghi phep.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Commands.LeaveRequests;
|
||||
|
||||
public record CreateLeaveRequestCommand(
|
||||
Guid StaffId, Guid ShopId, string LeaveType,
|
||||
DateTime StartDate, DateTime EndDate, string? Reason) : IRequest<Guid>;
|
||||
|
||||
public class CreateLeaveRequestCommandHandler : IRequestHandler<CreateLeaveRequestCommand, Guid>
|
||||
{
|
||||
private readonly ILeaveRequestRepository _repo;
|
||||
private readonly ILogger<CreateLeaveRequestCommandHandler> _logger;
|
||||
|
||||
public CreateLeaveRequestCommandHandler(ILeaveRequestRepository repo, ILogger<CreateLeaveRequestCommandHandler> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Guid> Handle(CreateLeaveRequestCommand request, CancellationToken ct)
|
||||
{
|
||||
var leaveRequest = LeaveRequest.Create(
|
||||
request.StaffId, request.ShopId, request.LeaveType,
|
||||
request.StartDate, request.EndDate, request.Reason);
|
||||
|
||||
_repo.Add(leaveRequest);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
|
||||
_logger.LogInformation("EN: Leave request created / VI: Yeu cau nghi phep da tao: StaffId={StaffId}, Type={Type}",
|
||||
request.StaffId, request.LeaveType);
|
||||
return leaveRequest.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// EN: Query to get attendance records for a staff member.
|
||||
// VI: Query lay ban ghi cham cong cho nhan vien.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Queries.Attendance;
|
||||
|
||||
public record GetAttendanceByStaffQuery(Guid StaffId, int Month, int Year) : IRequest<List<AttendanceDto>>;
|
||||
public record GetAttendanceByShopQuery(Guid ShopId, int Month, int Year) : IRequest<List<AttendanceDto>>;
|
||||
|
||||
public record AttendanceDto(Guid Id, Guid StaffId, DateTime Date, DateTime? CheckIn, DateTime? CheckOut,
|
||||
decimal? HoursWorked, string Status, string? Notes);
|
||||
|
||||
public class GetAttendanceByStaffQueryHandler : IRequestHandler<GetAttendanceByStaffQuery, List<AttendanceDto>>
|
||||
{
|
||||
private readonly IAttendanceRepository _repo;
|
||||
|
||||
public GetAttendanceByStaffQueryHandler(IAttendanceRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<List<AttendanceDto>> Handle(GetAttendanceByStaffQuery request, CancellationToken ct)
|
||||
{
|
||||
var records = await _repo.GetByStaffAndMonthAsync(request.StaffId, request.Month, request.Year, ct);
|
||||
return records.Select(r => new AttendanceDto(r.Id, r.StaffId, r.Date, r.CheckIn, r.CheckOut,
|
||||
r.HoursWorked, r.Status, r.Notes)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public class GetAttendanceByShopQueryHandler : IRequestHandler<GetAttendanceByShopQuery, List<AttendanceDto>>
|
||||
{
|
||||
private readonly IAttendanceRepository _repo;
|
||||
|
||||
public GetAttendanceByShopQueryHandler(IAttendanceRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<List<AttendanceDto>> Handle(GetAttendanceByShopQuery request, CancellationToken ct)
|
||||
{
|
||||
var records = await _repo.GetByShopAndMonthAsync(request.ShopId, request.Month, request.Year, ct);
|
||||
return records.Select(r => new AttendanceDto(r.Id, r.StaffId, r.Date, r.CheckIn, r.CheckOut,
|
||||
r.HoursWorked, r.Status, r.Notes)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// EN: Query to get leave requests.
|
||||
// VI: Query lay yeu cau nghi phep.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Queries.LeaveRequests;
|
||||
|
||||
public record GetLeaveRequestsByStaffQuery(Guid StaffId) : IRequest<List<LeaveRequestDto>>;
|
||||
public record GetLeaveRequestsByShopQuery(Guid ShopId) : IRequest<List<LeaveRequestDto>>;
|
||||
|
||||
public record LeaveRequestDto(Guid Id, Guid StaffId, string LeaveType, DateTime StartDate, DateTime EndDate,
|
||||
string? Reason, string Status, string? ApprovedBy, DateTime CreatedAt);
|
||||
|
||||
public class GetLeaveRequestsByStaffHandler : IRequestHandler<GetLeaveRequestsByStaffQuery, List<LeaveRequestDto>>
|
||||
{
|
||||
private readonly ILeaveRequestRepository _repo;
|
||||
|
||||
public GetLeaveRequestsByStaffHandler(ILeaveRequestRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<List<LeaveRequestDto>> Handle(GetLeaveRequestsByStaffQuery request, CancellationToken ct)
|
||||
{
|
||||
var items = await _repo.GetByStaffAsync(request.StaffId, ct);
|
||||
return items.Select(l => new LeaveRequestDto(l.Id, l.StaffId, l.LeaveType, l.StartDate, l.EndDate,
|
||||
l.Reason, l.Status, l.ApprovedBy?.ToString(), l.CreatedAt)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public class GetLeaveRequestsByShopHandler : IRequestHandler<GetLeaveRequestsByShopQuery, List<LeaveRequestDto>>
|
||||
{
|
||||
private readonly ILeaveRequestRepository _repo;
|
||||
|
||||
public GetLeaveRequestsByShopHandler(ILeaveRequestRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<List<LeaveRequestDto>> Handle(GetLeaveRequestsByShopQuery request, CancellationToken ct)
|
||||
{
|
||||
var items = await _repo.GetByShopAsync(request.ShopId, ct);
|
||||
return items.Select(l => new LeaveRequestDto(l.Id, l.StaffId, l.LeaveType, l.StartDate, l.EndDate,
|
||||
l.Reason, l.Status, l.ApprovedBy?.ToString(), l.CreatedAt)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// EN: Controller for staff attendance operations.
|
||||
// VI: Controller cho thao tac cham cong nhan vien.
|
||||
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MerchantService.API.Application.Commands.Attendance;
|
||||
using MerchantService.API.Application.Queries.Attendance;
|
||||
|
||||
namespace MerchantService.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/attendance")]
|
||||
public class AttendanceController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<AttendanceController> _logger;
|
||||
|
||||
public AttendanceController(IMediator mediator, ILogger<AttendanceController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get attendance records by staff and month.
|
||||
/// VI: Lay ban ghi cham cong theo nhan vien va thang.
|
||||
/// </summary>
|
||||
[HttpGet("staff/{staffId:guid}")]
|
||||
public async Task<IActionResult> GetByStaff(Guid staffId, [FromQuery] int month = 0, [FromQuery] int year = 0, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (month <= 0) month = now.Month;
|
||||
if (year <= 0) year = now.Year;
|
||||
|
||||
var result = await _mediator.Send(new GetAttendanceByStaffQuery(staffId, month, year), ct);
|
||||
return Ok(new { success = true, data = new { items = result } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get attendance records by shop and month.
|
||||
/// VI: Lay ban ghi cham cong theo cua hang va thang.
|
||||
/// </summary>
|
||||
[HttpGet("shop/{shopId:guid}")]
|
||||
public async Task<IActionResult> GetByShop(Guid shopId, [FromQuery] int month = 0, [FromQuery] int year = 0, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (month <= 0) month = now.Month;
|
||||
if (year <= 0) year = now.Year;
|
||||
|
||||
var result = await _mediator.Send(new GetAttendanceByShopQuery(shopId, month, year), ct);
|
||||
return Ok(new { success = true, data = new { items = result } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check in for a staff member.
|
||||
/// VI: Cham cong vao cho nhan vien.
|
||||
/// </summary>
|
||||
[HttpPost("check-in")]
|
||||
public async Task<IActionResult> CheckIn([FromBody] CheckInRequest request, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _mediator.Send(new CheckInCommand(request.StaffId, request.ShopId), ct);
|
||||
return Ok(new { success = true, data = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking in");
|
||||
return BadRequest(new { success = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check out for a staff member.
|
||||
/// VI: Cham cong ra cho nhan vien.
|
||||
/// </summary>
|
||||
[HttpPost("check-out")]
|
||||
public async Task<IActionResult> CheckOut([FromBody] CheckOutRequest request, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _mediator.Send(new CheckOutCommand(request.StaffId), ct);
|
||||
return Ok(new { success = true, data = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking out");
|
||||
return BadRequest(new { success = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record CheckInRequest(Guid StaffId, Guid ShopId);
|
||||
public record CheckOutRequest(Guid StaffId);
|
||||
@@ -0,0 +1,94 @@
|
||||
// EN: Controller for leave request operations.
|
||||
// VI: Controller cho thao tac yeu cau nghi phep.
|
||||
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MerchantService.API.Application.Commands.LeaveRequests;
|
||||
using MerchantService.API.Application.Queries.LeaveRequests;
|
||||
|
||||
namespace MerchantService.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/leave-requests")]
|
||||
public class LeaveRequestsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<LeaveRequestsController> _logger;
|
||||
|
||||
public LeaveRequestsController(IMediator mediator, ILogger<LeaveRequestsController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get leave requests by staff.
|
||||
/// VI: Lay yeu cau nghi phep theo nhan vien.
|
||||
/// </summary>
|
||||
[HttpGet("staff/{staffId:guid}")]
|
||||
public async Task<IActionResult> GetByStaff(Guid staffId, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _mediator.Send(new GetLeaveRequestsByStaffQuery(staffId), ct);
|
||||
return Ok(new { success = true, data = new { items = result } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get leave requests by shop (for manager).
|
||||
/// VI: Lay yeu cau nghi phep theo cua hang (cho quan ly).
|
||||
/// </summary>
|
||||
[HttpGet("shop/{shopId:guid}")]
|
||||
public async Task<IActionResult> GetByShop(Guid shopId, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _mediator.Send(new GetLeaveRequestsByShopQuery(shopId), ct);
|
||||
return Ok(new { success = true, data = new { items = result } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a leave request.
|
||||
/// VI: Tao yeu cau nghi phep.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateLeaveRequestRequest request, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = await _mediator.Send(new CreateLeaveRequestCommand(
|
||||
request.StaffId, request.ShopId, request.LeaveType,
|
||||
request.StartDate, request.EndDate, request.Reason), ct);
|
||||
return Created($"/api/v1/leave-requests/{id}", new { success = true, data = new { id } });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating leave request");
|
||||
return BadRequest(new { success = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Approve a leave request.
|
||||
/// VI: Duyet yeu cau nghi phep.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/approve")]
|
||||
public async Task<IActionResult> Approve(Guid id, [FromBody] ApproveRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _mediator.Send(new ApproveLeaveRequestCommand(id, request.ApprovedBy), ct);
|
||||
if (!result) return NotFound(new { success = false, message = "Leave request not found" });
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reject a leave request.
|
||||
/// VI: Tu choi yeu cau nghi phep.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/reject")]
|
||||
public async Task<IActionResult> Reject(Guid id, [FromBody] RejectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _mediator.Send(new RejectLeaveRequestCommand(id, request.RejectedBy, request.Reason), ct);
|
||||
if (!result) return NotFound(new { success = false, message = "Leave request not found" });
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateLeaveRequestRequest(Guid StaffId, Guid ShopId, string LeaveType, DateTime StartDate, DateTime EndDate, string? Reason);
|
||||
public record ApproveRequest(Guid ApprovedBy);
|
||||
public record RejectRequest(Guid RejectedBy, string? Reason);
|
||||
@@ -0,0 +1,93 @@
|
||||
// EN: Attendance record aggregate — tracks daily check-in/check-out for staff.
|
||||
// VI: Aggregate cham cong — theo doi check-in/check-out hang ngay cua nhan vien.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Daily attendance record for a staff member.
|
||||
/// VI: Ban ghi cham cong hang ngay cho nhan vien.
|
||||
/// </summary>
|
||||
public class AttendanceRecord : Entity, IAggregateRoot
|
||||
{
|
||||
private Guid _staffId;
|
||||
private Guid _shopId;
|
||||
private DateTime _date;
|
||||
private DateTime? _checkIn;
|
||||
private DateTime? _checkOut;
|
||||
private decimal? _hoursWorked;
|
||||
private string _status; // Working, Completed, Late, Absent
|
||||
private string? _notes;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
public Guid StaffId => _staffId;
|
||||
public Guid ShopId => _shopId;
|
||||
public DateTime Date => _date;
|
||||
public DateTime? CheckIn => _checkIn;
|
||||
public DateTime? CheckOut => _checkOut;
|
||||
public decimal? HoursWorked => _hoursWorked;
|
||||
public string Status => _status;
|
||||
public string? Notes => _notes;
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
protected AttendanceRecord() { _status = "Working"; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create attendance record with check-in.
|
||||
/// VI: Tao ban ghi cham cong voi check-in.
|
||||
/// </summary>
|
||||
public static AttendanceRecord CheckInNow(Guid staffId, Guid shopId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var record = new AttendanceRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
_staffId = staffId,
|
||||
_shopId = shopId,
|
||||
_date = now.Date,
|
||||
_checkIn = now,
|
||||
_status = "Working",
|
||||
_createdAt = now
|
||||
};
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Record check-out and calculate hours worked.
|
||||
/// VI: Ghi nhan check-out va tinh gio lam.
|
||||
/// </summary>
|
||||
public void DoCheckOut()
|
||||
{
|
||||
if (_checkOut.HasValue)
|
||||
throw new InvalidOperationException("Already checked out / Da check-out roi");
|
||||
|
||||
_checkOut = DateTime.UtcNow;
|
||||
if (_checkIn.HasValue)
|
||||
_hoursWorked = (decimal)(_checkOut.Value - _checkIn.Value).TotalHours;
|
||||
_status = "Completed";
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark as absent (admin action).
|
||||
/// VI: Danh dau vang mat (thao tac admin).
|
||||
/// </summary>
|
||||
public void MarkAbsent(string? notes = null)
|
||||
{
|
||||
_status = "Absent";
|
||||
_notes = notes;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark as late.
|
||||
/// VI: Danh dau di muon.
|
||||
/// </summary>
|
||||
public void MarkLate()
|
||||
{
|
||||
_status = "Late";
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// EN: Repository interface for attendance records.
|
||||
// VI: Interface repository cho ban ghi cham cong.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
|
||||
public interface IAttendanceRepository : IRepository<AttendanceRecord>
|
||||
{
|
||||
Task<AttendanceRecord?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<AttendanceRecord?> GetTodayRecordAsync(Guid staffId, CancellationToken ct = default);
|
||||
Task<List<AttendanceRecord>> GetByStaffAndMonthAsync(Guid staffId, int month, int year, CancellationToken ct = default);
|
||||
Task<List<AttendanceRecord>> GetByShopAndMonthAsync(Guid shopId, int month, int year, CancellationToken ct = default);
|
||||
AttendanceRecord Add(AttendanceRecord record);
|
||||
void Update(AttendanceRecord record);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// EN: Repository interface for leave requests.
|
||||
// VI: Interface repository cho yeu cau nghi phep.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
|
||||
public interface ILeaveRequestRepository : IRepository<LeaveRequest>
|
||||
{
|
||||
Task<LeaveRequest?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<List<LeaveRequest>> GetByStaffAsync(Guid staffId, CancellationToken ct = default);
|
||||
Task<List<LeaveRequest>> GetByShopAsync(Guid shopId, CancellationToken ct = default);
|
||||
LeaveRequest Add(LeaveRequest request);
|
||||
void Update(LeaveRequest request);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// EN: Leave request aggregate — staff leave/time-off requests.
|
||||
// VI: Aggregate nghi phep — yeu cau nghi phep cua nhan vien.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Leave request from a staff member.
|
||||
/// VI: Yeu cau nghi phep tu nhan vien.
|
||||
/// </summary>
|
||||
public class LeaveRequest : Entity, IAggregateRoot
|
||||
{
|
||||
private Guid _staffId;
|
||||
private Guid _shopId;
|
||||
private string _leaveType; // Annual, Sick, Personal, Maternity, Other
|
||||
private DateTime _startDate;
|
||||
private DateTime _endDate;
|
||||
private string? _reason;
|
||||
private string _status; // Pending, Approved, Rejected
|
||||
private Guid? _approvedBy;
|
||||
private DateTime? _approvedAt;
|
||||
private string? _rejectionReason;
|
||||
private DateTime _createdAt;
|
||||
|
||||
public Guid StaffId => _staffId;
|
||||
public Guid ShopId => _shopId;
|
||||
public string LeaveType => _leaveType;
|
||||
public DateTime StartDate => _startDate;
|
||||
public DateTime EndDate => _endDate;
|
||||
public string? Reason => _reason;
|
||||
public string Status => _status;
|
||||
public Guid? ApprovedBy => _approvedBy;
|
||||
public DateTime? ApprovedAt => _approvedAt;
|
||||
public string? RejectionReason => _rejectionReason;
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Number of days requested.
|
||||
/// VI: So ngay nghi phep yeu cau.
|
||||
/// </summary>
|
||||
public int Days => (_endDate - _startDate).Days + 1;
|
||||
|
||||
protected LeaveRequest() { _leaveType = "Annual"; _status = "Pending"; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new leave request.
|
||||
/// VI: Tao yeu cau nghi phep moi.
|
||||
/// </summary>
|
||||
public static LeaveRequest Create(Guid staffId, Guid shopId, string leaveType, DateTime startDate, DateTime endDate, string? reason)
|
||||
{
|
||||
if (endDate < startDate)
|
||||
throw new InvalidOperationException("End date must be after start date / Ngay ket thuc phai sau ngay bat dau");
|
||||
|
||||
return new LeaveRequest
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
_staffId = staffId,
|
||||
_shopId = shopId,
|
||||
_leaveType = leaveType,
|
||||
_startDate = startDate.Date,
|
||||
_endDate = endDate.Date,
|
||||
_reason = reason,
|
||||
_status = "Pending",
|
||||
_createdAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Approve the leave request.
|
||||
/// VI: Duyet yeu cau nghi phep.
|
||||
/// </summary>
|
||||
public void Approve(Guid approvedBy)
|
||||
{
|
||||
if (_status != "Pending")
|
||||
throw new InvalidOperationException("Only pending requests can be approved / Chi duyet duoc yeu cau dang cho");
|
||||
|
||||
_status = "Approved";
|
||||
_approvedBy = approvedBy;
|
||||
_approvedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reject the leave request.
|
||||
/// VI: Tu choi yeu cau nghi phep.
|
||||
/// </summary>
|
||||
public void Reject(Guid rejectedBy, string? reason = null)
|
||||
{
|
||||
if (_status != "Pending")
|
||||
throw new InvalidOperationException("Only pending requests can be rejected / Chi tu choi duoc yeu cau dang cho");
|
||||
|
||||
_status = "Rejected";
|
||||
_approvedBy = rejectedBy;
|
||||
_approvedAt = DateTime.UtcNow;
|
||||
_rejectionReason = reason;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
using MerchantService.Infrastructure.Idempotency;
|
||||
using MerchantService.Infrastructure.Repositories;
|
||||
|
||||
@@ -57,6 +59,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<IMerchantRepository, MerchantRepository>();
|
||||
services.AddScoped<IShopRepository, ShopRepository>();
|
||||
services.AddScoped<IMerchantStaffRepository, MerchantStaffRepository>();
|
||||
services.AddScoped<IAttendanceRepository, AttendanceRepository>();
|
||||
services.AddScoped<ILeaveRequestRepository, LeaveRequestRepository>();
|
||||
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// EN: EF Core configuration for attendance_records table.
|
||||
// VI: Cau hinh EF Core cho bang attendance_records.
|
||||
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace MerchantService.Infrastructure.EntityConfigurations;
|
||||
|
||||
public class AttendanceRecordEntityTypeConfiguration : IEntityTypeConfiguration<AttendanceRecord>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AttendanceRecord> builder)
|
||||
{
|
||||
builder.ToTable("attendance_records");
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property<Guid>("_staffId").HasColumnName("staff_id").IsRequired();
|
||||
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
|
||||
builder.Property<DateTime>("_date").HasColumnName("date").IsRequired();
|
||||
builder.Property<DateTime?>("_checkIn").HasColumnName("check_in");
|
||||
builder.Property<DateTime?>("_checkOut").HasColumnName("check_out");
|
||||
builder.Property<decimal?>("_hoursWorked").HasColumnName("hours_worked").HasPrecision(5, 2);
|
||||
builder.Property<string>("_status").HasColumnName("status").HasMaxLength(20).IsRequired();
|
||||
builder.Property<string?>("_notes").HasColumnName("notes").HasMaxLength(500);
|
||||
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
|
||||
builder.Property<DateTime?>("_updatedAt").HasColumnName("updated_at");
|
||||
|
||||
builder.Ignore(a => a.DomainEvents);
|
||||
builder.Ignore(a => a.StaffId);
|
||||
builder.Ignore(a => a.ShopId);
|
||||
builder.Ignore(a => a.Date);
|
||||
builder.Ignore(a => a.CheckIn);
|
||||
builder.Ignore(a => a.CheckOut);
|
||||
builder.Ignore(a => a.HoursWorked);
|
||||
builder.Ignore(a => a.Status);
|
||||
builder.Ignore(a => a.Notes);
|
||||
builder.Ignore(a => a.CreatedAt);
|
||||
|
||||
builder.HasIndex("_staffId", "_date").IsUnique().HasDatabaseName("ix_attendance_staff_date");
|
||||
builder.HasIndex("_shopId", "_date").HasDatabaseName("ix_attendance_shop_date");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// EN: EF Core configuration for leave_requests table.
|
||||
// VI: Cau hinh EF Core cho bang leave_requests.
|
||||
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace MerchantService.Infrastructure.EntityConfigurations;
|
||||
|
||||
public class LeaveRequestEntityTypeConfiguration : IEntityTypeConfiguration<LeaveRequest>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LeaveRequest> builder)
|
||||
{
|
||||
builder.ToTable("leave_requests");
|
||||
builder.HasKey(l => l.Id);
|
||||
|
||||
builder.Property<Guid>("_staffId").HasColumnName("staff_id").IsRequired();
|
||||
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
|
||||
builder.Property<string>("_leaveType").HasColumnName("leave_type").HasMaxLength(20).IsRequired();
|
||||
builder.Property<DateTime>("_startDate").HasColumnName("start_date").IsRequired();
|
||||
builder.Property<DateTime>("_endDate").HasColumnName("end_date").IsRequired();
|
||||
builder.Property<string?>("_reason").HasColumnName("reason").HasMaxLength(500);
|
||||
builder.Property<string>("_status").HasColumnName("status").HasMaxLength(20).IsRequired();
|
||||
builder.Property<Guid?>("_approvedBy").HasColumnName("approved_by");
|
||||
builder.Property<DateTime?>("_approvedAt").HasColumnName("approved_at");
|
||||
builder.Property<string?>("_rejectionReason").HasColumnName("rejection_reason").HasMaxLength(500);
|
||||
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
|
||||
|
||||
builder.Ignore(l => l.DomainEvents);
|
||||
builder.Ignore(l => l.StaffId);
|
||||
builder.Ignore(l => l.ShopId);
|
||||
builder.Ignore(l => l.LeaveType);
|
||||
builder.Ignore(l => l.StartDate);
|
||||
builder.Ignore(l => l.EndDate);
|
||||
builder.Ignore(l => l.Reason);
|
||||
builder.Ignore(l => l.Status);
|
||||
builder.Ignore(l => l.ApprovedBy);
|
||||
builder.Ignore(l => l.ApprovedAt);
|
||||
builder.Ignore(l => l.RejectionReason);
|
||||
builder.Ignore(l => l.CreatedAt);
|
||||
builder.Ignore(l => l.Days);
|
||||
|
||||
builder.HasIndex("_staffId").HasDatabaseName("ix_leave_requests_staff_id");
|
||||
builder.HasIndex("_shopId").HasDatabaseName("ix_leave_requests_shop_id");
|
||||
builder.HasIndex("_status").HasDatabaseName("ix_leave_requests_status");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore.Storage;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Infrastructure;
|
||||
@@ -55,6 +57,18 @@ public class MerchantServiceContext : DbContext, IUnitOfWork
|
||||
/// </summary>
|
||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Attendance records table.
|
||||
/// VI: Bang cham cong.
|
||||
/// </summary>
|
||||
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Leave requests table.
|
||||
/// VI: Bang yeu cau nghi phep.
|
||||
/// </summary>
|
||||
public DbSet<LeaveRequest> LeaveRequests => Set<LeaveRequest>();
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// EN: Repository implementation for attendance records.
|
||||
// VI: Implementation repository cho ban ghi cham cong.
|
||||
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MerchantService.Infrastructure.Repositories;
|
||||
|
||||
public class AttendanceRepository : IAttendanceRepository
|
||||
{
|
||||
private readonly MerchantServiceContext _context;
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public AttendanceRepository(MerchantServiceContext context) => _context = context;
|
||||
|
||||
public async Task<AttendanceRecord?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> await _context.AttendanceRecords.FirstOrDefaultAsync(a => a.Id == id, ct);
|
||||
|
||||
public async Task<AttendanceRecord?> GetTodayRecordAsync(Guid staffId, CancellationToken ct = default)
|
||||
{
|
||||
var today = DateTime.UtcNow.Date;
|
||||
return await _context.AttendanceRecords
|
||||
.FirstOrDefaultAsync(a => a.StaffId == staffId && a.Date == today, ct);
|
||||
}
|
||||
|
||||
public async Task<List<AttendanceRecord>> GetByStaffAndMonthAsync(Guid staffId, int month, int year, CancellationToken ct = default)
|
||||
{
|
||||
var startDate = new DateTime(year, month, 1);
|
||||
var endDate = startDate.AddMonths(1);
|
||||
return await _context.AttendanceRecords
|
||||
.Where(a => a.StaffId == staffId && a.Date >= startDate && a.Date < endDate)
|
||||
.OrderByDescending(a => a.Date)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<AttendanceRecord>> GetByShopAndMonthAsync(Guid shopId, int month, int year, CancellationToken ct = default)
|
||||
{
|
||||
var startDate = new DateTime(year, month, 1);
|
||||
var endDate = startDate.AddMonths(1);
|
||||
return await _context.AttendanceRecords
|
||||
.Where(a => a.ShopId == shopId && a.Date >= startDate && a.Date < endDate)
|
||||
.OrderByDescending(a => a.Date)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public AttendanceRecord Add(AttendanceRecord record) => _context.AttendanceRecords.Add(record).Entity;
|
||||
public void Update(AttendanceRecord record) => _context.Entry(record).State = EntityState.Modified;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// EN: Repository implementation for leave requests.
|
||||
// VI: Implementation repository cho yeu cau nghi phep.
|
||||
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MerchantService.Infrastructure.Repositories;
|
||||
|
||||
public class LeaveRequestRepository : ILeaveRequestRepository
|
||||
{
|
||||
private readonly MerchantServiceContext _context;
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public LeaveRequestRepository(MerchantServiceContext context) => _context = context;
|
||||
|
||||
public async Task<LeaveRequest?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> await _context.LeaveRequests.FirstOrDefaultAsync(l => l.Id == id, ct);
|
||||
|
||||
public async Task<List<LeaveRequest>> GetByStaffAsync(Guid staffId, CancellationToken ct = default)
|
||||
=> await _context.LeaveRequests
|
||||
.Where(l => l.StaffId == staffId)
|
||||
.OrderByDescending(l => l.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<List<LeaveRequest>> GetByShopAsync(Guid shopId, CancellationToken ct = default)
|
||||
=> await _context.LeaveRequests
|
||||
.Where(l => l.ShopId == shopId)
|
||||
.OrderByDescending(l => l.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public LeaveRequest Add(LeaveRequest request) => _context.LeaveRequests.Add(request).Entity;
|
||||
public void Update(LeaveRequest request) => _context.Entry(request).State = EntityState.Modified;
|
||||
}
|
||||
Reference in New Issue
Block a user