[CLAUDE] App+Api: S35 Plan G-H2 Task 3 — HrmConfig BE CRUD 16 endpoint cookie-cutter
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m43s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m43s
Pattern 12-bis cumulative 3× (S29 Plan B + Chunk C + S35 G-H2). Mega scaffold mirror
Master/Catalogs/CatalogsFeatures.cs 334 LOC pattern → SolutionErp.Application/Hrm/
HrmConfigFeatures.cs 439 LOC. 4 region cookie-cutter cho 4 HRM catalog (LeaveTypes +
Holidays + ShiftPatterns + OtPolicies). 16 endpoint qua HrmConfigsController.cs 137 LOC.
## Scope (Implementer Case 2 ~25K spawn)
- HrmConfigFeatures.cs 4 region × (DTO + List + Create cmd/validator/handler + Update cmd/validator/handler + Delete cmd/handler) = 4 DTO + 4 List query + 4 Create + 4 Update + 4 Delete handler
- HrmConfigsController.cs URL `/api/hrm-configs/{kind}` × {leave-types, holidays, shifts, ot-policies} × 4 verb = 16 endpoint
- Authorization: Class `[Authorize]` + write `[Authorize(Roles="Admin")]` 12 attribute (4 × 3 write verb) mirror Catalogs precedent
- Conflict check: 8 AnyAsync IsDeleted (4 Create + 4 Update với composite Year+Date Holiday exclude-self via `x.Id != req.Id`)
- Validator: 8 AbstractValidator + 44 RuleFor — MaxLength match EF source-of-truth (Smart Friend Implementer caught spec drift, aligned)
- HRM entities NO HasQueryFilter (4 vs 9 file Master have it) — explicit `.Where(!IsDeleted)` in List/Conflict-check queries
## Build + Test verify
- dotnet build PASS 0 err (2 pre-existing DocxRenderer warning unrelated)
- dotnet test 130/130 PASS preserve (BE-only, no FE/test touch)
- FE files NOT touched (defer Task 4 G-H2 FE Admin next chunk)
- EF Config + DbInitializer + IApplicationDbContext: S34 done, no re-touch
## Pre-commit Reviewer Smart Friend 8× clean cumulative
S22+S25+S29×2+S33×2+S35×2 (FE forms + BE CRUD)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
137
src/Backend/SolutionErp.Api/Controllers/HrmConfigsController.cs
Normal file
137
src/Backend/SolutionErp.Api/Controllers/HrmConfigsController.cs
Normal file
@ -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<List<LeaveTypeDto>> 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<IActionResult> 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<IActionResult> 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<IActionResult> DeleteLeaveType(Guid id, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeleteLeaveTypeCommand(id), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ===== Holidays =====
|
||||
[HttpGet("holidays")]
|
||||
public async Task<List<HolidayDto>> 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<IActionResult> 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<IActionResult> 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<IActionResult> DeleteHoliday(Guid id, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeleteHolidayCommand(id), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ===== Shifts =====
|
||||
[HttpGet("shifts")]
|
||||
public async Task<List<ShiftPatternDto>> 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<IActionResult> 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<IActionResult> 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<IActionResult> DeleteShift(Guid id, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeleteShiftCommand(id), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ===== OT Policies =====
|
||||
[HttpGet("ot-policies")]
|
||||
public async Task<List<OtPolicyDto>> 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<IActionResult> 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<IActionResult> 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<IActionResult> DeleteOtPolicy(Guid id, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeleteOtPolicyCommand(id), ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
439
src/Backend/SolutionErp.Application/Hrm/HrmConfigFeatures.cs
Normal file
439
src/Backend/SolutionErp.Application/Hrm/HrmConfigFeatures.cs
Normal file
@ -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<List<LeaveTypeDto>>;
|
||||
public class ListLeaveTypesHandler(IApplicationDbContext db) : IRequestHandler<ListLeaveTypesQuery, List<LeaveTypeDto>>
|
||||
{
|
||||
public async Task<List<LeaveTypeDto>> 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<Guid>;
|
||||
public class CreateLeaveTypeValidator : AbstractValidator<CreateLeaveTypeCommand>
|
||||
{
|
||||
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<CreateLeaveTypeCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> 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<UpdateLeaveTypeCommand>
|
||||
{
|
||||
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<UpdateLeaveTypeCommand>
|
||||
{
|
||||
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<DeleteLeaveTypeCommand>
|
||||
{
|
||||
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<List<HolidayDto>>;
|
||||
public class ListHolidaysHandler(IApplicationDbContext db) : IRequestHandler<ListHolidaysQuery, List<HolidayDto>>
|
||||
{
|
||||
public async Task<List<HolidayDto>> 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<Guid>;
|
||||
public class CreateHolidayValidator : AbstractValidator<CreateHolidayCommand>
|
||||
{
|
||||
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<CreateHolidayCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> 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<UpdateHolidayCommand>
|
||||
{
|
||||
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<UpdateHolidayCommand>
|
||||
{
|
||||
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<DeleteHolidayCommand>
|
||||
{
|
||||
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<List<ShiftPatternDto>>;
|
||||
public class ListShiftsHandler(IApplicationDbContext db) : IRequestHandler<ListShiftsQuery, List<ShiftPatternDto>>
|
||||
{
|
||||
public async Task<List<ShiftPatternDto>> 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<Guid>;
|
||||
public class CreateShiftValidator : AbstractValidator<CreateShiftCommand>
|
||||
{
|
||||
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<CreateShiftCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> 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<UpdateShiftCommand>
|
||||
{
|
||||
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<UpdateShiftCommand>
|
||||
{
|
||||
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<DeleteShiftCommand>
|
||||
{
|
||||
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<List<OtPolicyDto>>;
|
||||
public class ListOtPoliciesHandler(IApplicationDbContext db) : IRequestHandler<ListOtPoliciesQuery, List<OtPolicyDto>>
|
||||
{
|
||||
public async Task<List<OtPolicyDto>> 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<Guid>;
|
||||
public class CreateOtPolicyValidator : AbstractValidator<CreateOtPolicyCommand>
|
||||
{
|
||||
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<CreateOtPolicyCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> 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<UpdateOtPolicyCommand>
|
||||
{
|
||||
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<UpdateOtPolicyCommand>
|
||||
{
|
||||
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<DeleteOtPolicyCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user