feat(staff-portal): implement staff attendance and leave request management with dedicated portal UI and backend services

This commit is contained in:
Ho Ngoc Hai
2026-03-06 04:29:00 +07:00
parent a51ecacfac
commit 30b3f9a37c
41 changed files with 3635 additions and 2 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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>();

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
}