diff --git a/src/Backend/SolutionErp.Api/Controllers/HrmConfigsController.cs b/src/Backend/SolutionErp.Api/Controllers/HrmConfigsController.cs new file mode 100644 index 0000000..59588b6 --- /dev/null +++ b/src/Backend/SolutionErp.Api/Controllers/HrmConfigsController.cs @@ -0,0 +1,137 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SolutionErp.Application.Hrm; + +namespace SolutionErp.Api.Controllers; + +// HRM Config API — 4 sub-resource (leave-types, holidays, shifts, ot-policies) +// dùng Mediator dispatch theo command/query type. Authorize = đăng nhập đủ +// (mọi role được đọc cho dropdown; ghi cần Admin). +// Pattern 12-bis mirror Master/Catalogs/CatalogsController.cs (S29 cumulative 2×). + +[ApiController] +[Route("api/hrm-configs")] +[Authorize] +public class HrmConfigsController(IMediator mediator) : ControllerBase +{ + // ===== Leave Types ===== + [HttpGet("leave-types")] + public async Task> ListLeaveTypes([FromQuery] string? q, [FromQuery] bool? isActive, CancellationToken ct) + => await mediator.Send(new ListLeaveTypesQuery(q, isActive), ct); + + [HttpPost("leave-types")] + [Authorize(Roles = "Admin")] + public async Task CreateLeaveType([FromBody] CreateLeaveTypeCommand body, CancellationToken ct) + { + var id = await mediator.Send(body, ct); + return CreatedAtAction(nameof(ListLeaveTypes), new { id }, new { id }); + } + + [HttpPut("leave-types/{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task UpdateLeaveType(Guid id, [FromBody] UpdateLeaveTypeCommand body, CancellationToken ct) + { + if (id != body.Id) return BadRequest("Id mismatch"); + await mediator.Send(body, ct); + return NoContent(); + } + + [HttpDelete("leave-types/{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task DeleteLeaveType(Guid id, CancellationToken ct) + { + await mediator.Send(new DeleteLeaveTypeCommand(id), ct); + return NoContent(); + } + + // ===== Holidays ===== + [HttpGet("holidays")] + public async Task> ListHolidays([FromQuery] string? q, [FromQuery] int? year, CancellationToken ct) + => await mediator.Send(new ListHolidaysQuery(q, year), ct); + + [HttpPost("holidays")] + [Authorize(Roles = "Admin")] + public async Task CreateHoliday([FromBody] CreateHolidayCommand body, CancellationToken ct) + { + var id = await mediator.Send(body, ct); + return CreatedAtAction(nameof(ListHolidays), new { id }, new { id }); + } + + [HttpPut("holidays/{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task UpdateHoliday(Guid id, [FromBody] UpdateHolidayCommand body, CancellationToken ct) + { + if (id != body.Id) return BadRequest("Id mismatch"); + await mediator.Send(body, ct); + return NoContent(); + } + + [HttpDelete("holidays/{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task DeleteHoliday(Guid id, CancellationToken ct) + { + await mediator.Send(new DeleteHolidayCommand(id), ct); + return NoContent(); + } + + // ===== Shifts ===== + [HttpGet("shifts")] + public async Task> ListShifts([FromQuery] string? q, [FromQuery] bool? isActive, CancellationToken ct) + => await mediator.Send(new ListShiftsQuery(q, isActive), ct); + + [HttpPost("shifts")] + [Authorize(Roles = "Admin")] + public async Task CreateShift([FromBody] CreateShiftCommand body, CancellationToken ct) + { + var id = await mediator.Send(body, ct); + return CreatedAtAction(nameof(ListShifts), new { id }, new { id }); + } + + [HttpPut("shifts/{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task UpdateShift(Guid id, [FromBody] UpdateShiftCommand body, CancellationToken ct) + { + if (id != body.Id) return BadRequest("Id mismatch"); + await mediator.Send(body, ct); + return NoContent(); + } + + [HttpDelete("shifts/{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task DeleteShift(Guid id, CancellationToken ct) + { + await mediator.Send(new DeleteShiftCommand(id), ct); + return NoContent(); + } + + // ===== OT Policies ===== + [HttpGet("ot-policies")] + public async Task> ListOtPolicies([FromQuery] string? q, [FromQuery] bool? isActive, CancellationToken ct) + => await mediator.Send(new ListOtPoliciesQuery(q, isActive), ct); + + [HttpPost("ot-policies")] + [Authorize(Roles = "Admin")] + public async Task CreateOtPolicy([FromBody] CreateOtPolicyCommand body, CancellationToken ct) + { + var id = await mediator.Send(body, ct); + return CreatedAtAction(nameof(ListOtPolicies), new { id }, new { id }); + } + + [HttpPut("ot-policies/{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task UpdateOtPolicy(Guid id, [FromBody] UpdateOtPolicyCommand body, CancellationToken ct) + { + if (id != body.Id) return BadRequest("Id mismatch"); + await mediator.Send(body, ct); + return NoContent(); + } + + [HttpDelete("ot-policies/{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task DeleteOtPolicy(Guid id, CancellationToken ct) + { + await mediator.Send(new DeleteOtPolicyCommand(id), ct); + return NoContent(); + } +} diff --git a/src/Backend/SolutionErp.Application/Hrm/HrmConfigFeatures.cs b/src/Backend/SolutionErp.Application/Hrm/HrmConfigFeatures.cs new file mode 100644 index 0000000..741e5de --- /dev/null +++ b/src/Backend/SolutionErp.Application/Hrm/HrmConfigFeatures.cs @@ -0,0 +1,439 @@ +using FluentValidation; +using MediatR; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.Hrm; + +namespace SolutionErp.Application.Hrm; + +// CRUD CQRS cho 4 HRM catalog (LeaveTypes/Holidays/Shifts/OtPolicies). +// Pattern 12-bis mega mirror Master/Catalogs/CatalogsFeatures.cs (S29 cumulative 2×). +// Endpoints expose qua HrmConfigsController: +// GET /api/hrm-configs/{kind} — list (filter q + isActive / year) +// POST /api/hrm-configs/{kind} — create +// PUT /api/hrm-configs/{kind}/{id} — update +// DELETE /api/hrm-configs/{kind}/{id} — soft delete via AuditingInterceptor +// kind ∈ {leave-types, holidays, shifts, ot-policies} +// +// MaxLength validator MATCH EF config exact (LeaveTypeConfiguration etc) — avoid runtime +// truncation. HRM entities KHÔNG có global HasQueryFilter (khác Master/Catalogs) → list +// query phải Where(!IsDeleted) thủ công. + +// ===== DTOs ===== + +public record LeaveTypeDto(Guid Id, string Code, string Name, decimal DaysPerYear, bool IsPaid, bool RequiresAttachment, bool IsActive, string? Description); +public record HolidayDto(Guid Id, int Year, DateOnly Date, string Name, bool IsRecurring, bool IsPaid, bool IsActive, string? Description); +public record ShiftPatternDto(Guid Id, string Code, string Name, TimeOnly StartTime, TimeOnly EndTime, int BreakMinutes, string WorkDays, bool IsActive, string? Description); +public record OtPolicyDto(Guid Id, string Code, string Name, decimal MultiplierWeekday, decimal MultiplierWeekend, decimal MultiplierHoliday, int MaxHoursPerDay, int MaxHoursPerMonth, int MaxHoursPerYear, bool IsActive, string? Description); + +// ===== Region 1: LeaveType ===== + +public record ListLeaveTypesQuery(string? Q = null, bool? IsActive = null) : IRequest>; +public class ListLeaveTypesHandler(IApplicationDbContext db) : IRequestHandler> +{ + public async Task> Handle(ListLeaveTypesQuery req, CancellationToken ct) + { + var q = db.LeaveTypes.AsNoTracking().Where(x => !x.IsDeleted); + if (!string.IsNullOrWhiteSpace(req.Q)) + { + var s = req.Q.ToLower(); + q = q.Where(x => x.Code.ToLower().Contains(s) || x.Name.ToLower().Contains(s)); + } + if (req.IsActive.HasValue) q = q.Where(x => x.IsActive == req.IsActive.Value); + return await q.OrderBy(x => x.Code) + .Select(x => new LeaveTypeDto(x.Id, x.Code, x.Name, x.DaysPerYear, x.IsPaid, x.RequiresAttachment, x.IsActive, x.Description)) + .ToListAsync(ct); + } +} + +public record CreateLeaveTypeCommand(string Code, string Name, decimal DaysPerYear, bool IsPaid, bool RequiresAttachment, string? Description) : IRequest; +public class CreateLeaveTypeValidator : AbstractValidator +{ + public CreateLeaveTypeValidator() + { + RuleFor(x => x.Code).NotEmpty().MaximumLength(50); + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.DaysPerYear).GreaterThanOrEqualTo(0); + RuleFor(x => x.Description).MaximumLength(500); + } +} +public class CreateLeaveTypeHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(CreateLeaveTypeCommand req, CancellationToken ct) + { + if (await db.LeaveTypes.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct)) + throw new ConflictException($"Mã loại phép '{req.Code}' đã tồn tại."); + var entity = new LeaveType + { + Code = req.Code, + Name = req.Name, + DaysPerYear = req.DaysPerYear, + IsPaid = req.IsPaid, + RequiresAttachment = req.RequiresAttachment, + Description = req.Description, + IsActive = true, + }; + db.LeaveTypes.Add(entity); + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record UpdateLeaveTypeCommand(Guid Id, string Code, string Name, decimal DaysPerYear, bool IsPaid, bool RequiresAttachment, bool IsActive, string? Description) : IRequest; +public class UpdateLeaveTypeValidator : AbstractValidator +{ + public UpdateLeaveTypeValidator() + { + RuleFor(x => x.Code).NotEmpty().MaximumLength(50); + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.DaysPerYear).GreaterThanOrEqualTo(0); + RuleFor(x => x.Description).MaximumLength(500); + } +} +public class UpdateLeaveTypeHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpdateLeaveTypeCommand req, CancellationToken ct) + { + var entity = await db.LeaveTypes.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("LeaveType", req.Id); + if (entity.Code != req.Code && await db.LeaveTypes.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct)) + throw new ConflictException($"Mã loại phép '{req.Code}' đã tồn tại."); + entity.Code = req.Code; + entity.Name = req.Name; + entity.DaysPerYear = req.DaysPerYear; + entity.IsPaid = req.IsPaid; + entity.RequiresAttachment = req.RequiresAttachment; + entity.IsActive = req.IsActive; + entity.Description = req.Description; + await db.SaveChangesAsync(ct); + } +} + +public record DeleteLeaveTypeCommand(Guid Id) : IRequest; +public class DeleteLeaveTypeHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(DeleteLeaveTypeCommand req, CancellationToken ct) + { + var entity = await db.LeaveTypes.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("LeaveType", req.Id); + db.LeaveTypes.Remove(entity); // Soft delete via AuditingInterceptor + await db.SaveChangesAsync(ct); + } +} + +// ===== Region 2: Holiday ===== +// UNIQUE composite (Year, Date) — Code field không có. +// List filter Year nullable: null = trả về all year, có value = chỉ year đó. + +public record ListHolidaysQuery(string? Q = null, int? Year = null) : IRequest>; +public class ListHolidaysHandler(IApplicationDbContext db) : IRequestHandler> +{ + public async Task> Handle(ListHolidaysQuery req, CancellationToken ct) + { + var q = db.Holidays.AsNoTracking().Where(x => !x.IsDeleted); + if (req.Year.HasValue) q = q.Where(x => x.Year == req.Year.Value); + if (!string.IsNullOrWhiteSpace(req.Q)) + { + var s = req.Q.ToLower(); + q = q.Where(x => x.Name.ToLower().Contains(s)); + } + return await q.OrderBy(x => x.Year).ThenBy(x => x.Date) + .Select(x => new HolidayDto(x.Id, x.Year, x.Date, x.Name, x.IsRecurring, x.IsPaid, x.IsActive, x.Description)) + .ToListAsync(ct); + } +} + +public record CreateHolidayCommand(int Year, DateOnly Date, string Name, bool IsRecurring, bool IsPaid, string? Description) : IRequest; +public class CreateHolidayValidator : AbstractValidator +{ + public CreateHolidayValidator() + { + RuleFor(x => x.Year).InclusiveBetween(2000, 2100); + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).MaximumLength(500); + } +} +public class CreateHolidayHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(CreateHolidayCommand req, CancellationToken ct) + { + if (await db.Holidays.AnyAsync(x => x.Year == req.Year && x.Date == req.Date && !x.IsDeleted, ct)) + throw new ConflictException($"Ngày lễ năm {req.Year} ngày {req.Date:dd/MM/yyyy} đã tồn tại."); + var entity = new Holiday + { + Year = req.Year, + Date = req.Date, + Name = req.Name, + IsRecurring = req.IsRecurring, + IsPaid = req.IsPaid, + Description = req.Description, + IsActive = true, + }; + db.Holidays.Add(entity); + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record UpdateHolidayCommand(Guid Id, int Year, DateOnly Date, string Name, bool IsRecurring, bool IsPaid, bool IsActive, string? Description) : IRequest; +public class UpdateHolidayValidator : AbstractValidator +{ + public UpdateHolidayValidator() + { + RuleFor(x => x.Year).InclusiveBetween(2000, 2100); + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).MaximumLength(500); + } +} +public class UpdateHolidayHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpdateHolidayCommand req, CancellationToken ct) + { + var entity = await db.Holidays.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("Holiday", req.Id); + // Check conflict composite (Year, Date) khi 1 trong 2 đổi + if ((entity.Year != req.Year || entity.Date != req.Date) + && await db.Holidays.AnyAsync(x => x.Year == req.Year && x.Date == req.Date && !x.IsDeleted && x.Id != req.Id, ct)) + throw new ConflictException($"Ngày lễ năm {req.Year} ngày {req.Date:dd/MM/yyyy} đã tồn tại."); + entity.Year = req.Year; + entity.Date = req.Date; + entity.Name = req.Name; + entity.IsRecurring = req.IsRecurring; + entity.IsPaid = req.IsPaid; + entity.IsActive = req.IsActive; + entity.Description = req.Description; + await db.SaveChangesAsync(ct); + } +} + +public record DeleteHolidayCommand(Guid Id) : IRequest; +public class DeleteHolidayHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(DeleteHolidayCommand req, CancellationToken ct) + { + var entity = await db.Holidays.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("Holiday", req.Id); + db.Holidays.Remove(entity); + await db.SaveChangesAsync(ct); + } +} + +// ===== Region 3: ShiftPattern ===== +// Code UNIQUE (entity MaxLen 20 — khác LeaveType/OtPolicy 50). +// Validator extra: StartTime != EndTime (denote ca làm việc khác giờ thật). +// WorkDays NotEmpty đủ — FE Designer multi-select sẽ format "Mon,Tue,..." + +public record ListShiftsQuery(string? Q = null, bool? IsActive = null) : IRequest>; +public class ListShiftsHandler(IApplicationDbContext db) : IRequestHandler> +{ + public async Task> Handle(ListShiftsQuery req, CancellationToken ct) + { + var q = db.ShiftPatterns.AsNoTracking().Where(x => !x.IsDeleted); + if (!string.IsNullOrWhiteSpace(req.Q)) + { + var s = req.Q.ToLower(); + q = q.Where(x => x.Code.ToLower().Contains(s) || x.Name.ToLower().Contains(s)); + } + if (req.IsActive.HasValue) q = q.Where(x => x.IsActive == req.IsActive.Value); + return await q.OrderBy(x => x.Code) + .Select(x => new ShiftPatternDto(x.Id, x.Code, x.Name, x.StartTime, x.EndTime, x.BreakMinutes, x.WorkDays, x.IsActive, x.Description)) + .ToListAsync(ct); + } +} + +public record CreateShiftCommand(string Code, string Name, TimeOnly StartTime, TimeOnly EndTime, int BreakMinutes, string WorkDays, string? Description) : IRequest; +public class CreateShiftValidator : AbstractValidator +{ + public CreateShiftValidator() + { + RuleFor(x => x.Code).NotEmpty().MaximumLength(20); + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.WorkDays).NotEmpty().MaximumLength(100); + RuleFor(x => x.BreakMinutes).GreaterThanOrEqualTo(0); + RuleFor(x => x.Description).MaximumLength(500); + RuleFor(x => x).Must(c => c.StartTime != c.EndTime) + .WithMessage("Giờ bắt đầu và giờ kết thúc không được trùng nhau."); + } +} +public class CreateShiftHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(CreateShiftCommand req, CancellationToken ct) + { + if (await db.ShiftPatterns.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct)) + throw new ConflictException($"Mã ca làm việc '{req.Code}' đã tồn tại."); + var entity = new ShiftPattern + { + Code = req.Code, + Name = req.Name, + StartTime = req.StartTime, + EndTime = req.EndTime, + BreakMinutes = req.BreakMinutes, + WorkDays = req.WorkDays, + Description = req.Description, + IsActive = true, + }; + db.ShiftPatterns.Add(entity); + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record UpdateShiftCommand(Guid Id, string Code, string Name, TimeOnly StartTime, TimeOnly EndTime, int BreakMinutes, string WorkDays, bool IsActive, string? Description) : IRequest; +public class UpdateShiftValidator : AbstractValidator +{ + public UpdateShiftValidator() + { + RuleFor(x => x.Code).NotEmpty().MaximumLength(20); + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.WorkDays).NotEmpty().MaximumLength(100); + RuleFor(x => x.BreakMinutes).GreaterThanOrEqualTo(0); + RuleFor(x => x.Description).MaximumLength(500); + RuleFor(x => x).Must(c => c.StartTime != c.EndTime) + .WithMessage("Giờ bắt đầu và giờ kết thúc không được trùng nhau."); + } +} +public class UpdateShiftHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpdateShiftCommand req, CancellationToken ct) + { + var entity = await db.ShiftPatterns.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("ShiftPattern", req.Id); + if (entity.Code != req.Code && await db.ShiftPatterns.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct)) + throw new ConflictException($"Mã ca làm việc '{req.Code}' đã tồn tại."); + entity.Code = req.Code; + entity.Name = req.Name; + entity.StartTime = req.StartTime; + entity.EndTime = req.EndTime; + entity.BreakMinutes = req.BreakMinutes; + entity.WorkDays = req.WorkDays; + entity.IsActive = req.IsActive; + entity.Description = req.Description; + await db.SaveChangesAsync(ct); + } +} + +public record DeleteShiftCommand(Guid Id) : IRequest; +public class DeleteShiftHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(DeleteShiftCommand req, CancellationToken ct) + { + var entity = await db.ShiftPatterns.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("ShiftPattern", req.Id); + db.ShiftPatterns.Remove(entity); + await db.SaveChangesAsync(ct); + } +} + +// ===== Region 4: OtPolicy ===== +// Code UNIQUE. KHÔNG auto-deactivate existing IsActive=true (admin manually toggle qua Update). +// Validator: Multipliers >= 1.0 (không thể nhỏ hơn giờ bình thường) + MaxHours > 0. + +public record ListOtPoliciesQuery(string? Q = null, bool? IsActive = null) : IRequest>; +public class ListOtPoliciesHandler(IApplicationDbContext db) : IRequestHandler> +{ + public async Task> Handle(ListOtPoliciesQuery req, CancellationToken ct) + { + var q = db.OtPolicies.AsNoTracking().Where(x => !x.IsDeleted); + if (!string.IsNullOrWhiteSpace(req.Q)) + { + var s = req.Q.ToLower(); + q = q.Where(x => x.Code.ToLower().Contains(s) || x.Name.ToLower().Contains(s)); + } + if (req.IsActive.HasValue) q = q.Where(x => x.IsActive == req.IsActive.Value); + return await q.OrderBy(x => x.Code) + .Select(x => new OtPolicyDto(x.Id, x.Code, x.Name, x.MultiplierWeekday, x.MultiplierWeekend, x.MultiplierHoliday, x.MaxHoursPerDay, x.MaxHoursPerMonth, x.MaxHoursPerYear, x.IsActive, x.Description)) + .ToListAsync(ct); + } +} + +public record CreateOtPolicyCommand(string Code, string Name, decimal MultiplierWeekday, decimal MultiplierWeekend, decimal MultiplierHoliday, int MaxHoursPerDay, int MaxHoursPerMonth, int MaxHoursPerYear, string? Description) : IRequest; +public class CreateOtPolicyValidator : AbstractValidator +{ + public CreateOtPolicyValidator() + { + RuleFor(x => x.Code).NotEmpty().MaximumLength(50); + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.MultiplierWeekday).GreaterThanOrEqualTo(1.0m); + RuleFor(x => x.MultiplierWeekend).GreaterThanOrEqualTo(1.0m); + RuleFor(x => x.MultiplierHoliday).GreaterThanOrEqualTo(1.0m); + RuleFor(x => x.MaxHoursPerDay).GreaterThan(0); + RuleFor(x => x.MaxHoursPerMonth).GreaterThan(0); + RuleFor(x => x.MaxHoursPerYear).GreaterThan(0); + RuleFor(x => x.Description).MaximumLength(500); + } +} +public class CreateOtPolicyHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(CreateOtPolicyCommand req, CancellationToken ct) + { + if (await db.OtPolicies.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct)) + throw new ConflictException($"Mã chính sách OT '{req.Code}' đã tồn tại."); + var entity = new OtPolicy + { + Code = req.Code, + Name = req.Name, + MultiplierWeekday = req.MultiplierWeekday, + MultiplierWeekend = req.MultiplierWeekend, + MultiplierHoliday = req.MultiplierHoliday, + MaxHoursPerDay = req.MaxHoursPerDay, + MaxHoursPerMonth = req.MaxHoursPerMonth, + MaxHoursPerYear = req.MaxHoursPerYear, + Description = req.Description, + IsActive = true, + }; + db.OtPolicies.Add(entity); + await db.SaveChangesAsync(ct); + return entity.Id; + } +} + +public record UpdateOtPolicyCommand(Guid Id, string Code, string Name, decimal MultiplierWeekday, decimal MultiplierWeekend, decimal MultiplierHoliday, int MaxHoursPerDay, int MaxHoursPerMonth, int MaxHoursPerYear, bool IsActive, string? Description) : IRequest; +public class UpdateOtPolicyValidator : AbstractValidator +{ + public UpdateOtPolicyValidator() + { + RuleFor(x => x.Code).NotEmpty().MaximumLength(50); + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.MultiplierWeekday).GreaterThanOrEqualTo(1.0m); + RuleFor(x => x.MultiplierWeekend).GreaterThanOrEqualTo(1.0m); + RuleFor(x => x.MultiplierHoliday).GreaterThanOrEqualTo(1.0m); + RuleFor(x => x.MaxHoursPerDay).GreaterThan(0); + RuleFor(x => x.MaxHoursPerMonth).GreaterThan(0); + RuleFor(x => x.MaxHoursPerYear).GreaterThan(0); + RuleFor(x => x.Description).MaximumLength(500); + } +} +public class UpdateOtPolicyHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(UpdateOtPolicyCommand req, CancellationToken ct) + { + var entity = await db.OtPolicies.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("OtPolicy", req.Id); + if (entity.Code != req.Code && await db.OtPolicies.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct)) + throw new ConflictException($"Mã chính sách OT '{req.Code}' đã tồn tại."); + entity.Code = req.Code; + entity.Name = req.Name; + entity.MultiplierWeekday = req.MultiplierWeekday; + entity.MultiplierWeekend = req.MultiplierWeekend; + entity.MultiplierHoliday = req.MultiplierHoliday; + entity.MaxHoursPerDay = req.MaxHoursPerDay; + entity.MaxHoursPerMonth = req.MaxHoursPerMonth; + entity.MaxHoursPerYear = req.MaxHoursPerYear; + entity.IsActive = req.IsActive; + entity.Description = req.Description; + await db.SaveChangesAsync(ct); + } +} + +public record DeleteOtPolicyCommand(Guid Id) : IRequest; +public class DeleteOtPolicyHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task Handle(DeleteOtPolicyCommand req, CancellationToken ct) + { + var entity = await db.OtPolicies.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct) + ?? throw new NotFoundException("OtPolicy", req.Id); + db.OtPolicies.Remove(entity); + await db.SaveChangesAsync(ct); + } +}