[CLAUDE] Office: P11-D ItTicket auto-assign round-robin + SLA timer (Wave 2, Mig 46)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m17s

Mig 46 AddSlaFieldsToItTicket (SlaDueAt/SlaWarnedSent/SlaBreached). CreateItTicketHandler: round-robin least-loaded assign cho IT staff (dept Code=IT, tie-break Id) + SlaDueAt theo Priority (Urgent 4h/High 8h/Medium 24h/Low 72h). ItTicketSlaJob background (breach+warning notify, KHONG auto-transition). PUT /{id}/assign admin override. DbInitializer seed dept IT + 2 sample staff (nv.cao/nv.truong). FE ItTicketsPage +MaTicket+assignee+SLA badge (2 app SHA256 mirror). +9 test (191->200). Self-review PASS (seed<->query dept-code verified; em main solo review do session-limit kill reviewer-spawn).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-08 13:23:45 +07:00
parent 6a664298fa
commit dcf76f8a9f
14 changed files with 7149 additions and 19 deletions

View File

@ -312,7 +312,8 @@ public class GetVehicleBookingsHandler(IApplicationDbContext db)
public record ItTicketDto(Guid Id, string? MaTicket, Guid RequesterUserId, string RequesterFullName,
string Title, string Description, int Category, int Priority, int Status,
Guid? AssignedToUserId, string? AssignedToFullName, DateTime? ResolvedAt, string? Resolution, DateTime CreatedAt);
Guid? AssignedToUserId, string? AssignedToFullName, DateTime? ResolvedAt, string? Resolution, DateTime CreatedAt,
DateTime? SlaDueAt, bool SlaBreached);
public record CreateItTicketCommand(string Title, string Description, int Category, int Priority) : IRequest<Guid>;
@ -330,9 +331,21 @@ public class CreateItTicketValidator : AbstractValidator<CreateItTicketCommand>
public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<CreateItTicketCommand, Guid>
{
// P11-D (Mig 46) — SLA window theo Priority. Source-of-truth shared với
// ItTicketSlaJob (warning/breach tính cùng map). Urgent gấp nhất → 4h.
public static readonly IReadOnlyDictionary<ItTicketPriority, TimeSpan> SlaWindow =
new Dictionary<ItTicketPriority, TimeSpan>
{
[ItTicketPriority.Urgent] = TimeSpan.FromHours(4),
[ItTicketPriority.High] = TimeSpan.FromHours(8),
[ItTicketPriority.Medium] = TimeSpan.FromHours(24),
[ItTicketPriority.Low] = TimeSpan.FromHours(72),
};
public async Task<Guid> Handle(CreateItTicketCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var priority = (ItTicketPriority)req.Priority;
var e = new ItTicket
{
RequesterUserId = cu.UserId.Value,
@ -340,7 +353,7 @@ public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
Title = req.Title.Trim(),
Description = req.Description.Trim(),
Category = (ItTicketCategory)req.Category,
Priority = (ItTicketPriority)req.Priority,
Priority = priority,
Status = ItTicketStatus.Open,
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
@ -348,6 +361,32 @@ public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
// P11-F: gen mã ticket lúc Create (ItTicket = kanban KHÔNG workflow,
// khác Leave/OT gen lúc Submit). Format "IT/2026/001" (Serializable tx atomic).
e.MaTicket = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "IT", clock.Now.Year, clock, ct);
// P11-D: SLA due = CreatedAt + window theo Priority (default Medium 24h nếu lạ).
e.SlaDueAt = e.CreatedAt + (SlaWindow.TryGetValue(priority, out var w) ? w : TimeSpan.FromHours(24));
// P11-D: round-robin least-loaded — assign cho IT staff (Department.Code=="IT")
// ít ticket-mở nhất. Tie-break theo Id (deterministic). Không có ai trong IT
// → để unassigned (admin assign tay sau qua /assign).
var itDeptId = await db.Departments.AsNoTracking()
.Where(d => d.Code == "IT" && !d.IsDeleted)
.Select(d => (Guid?)d.Id)
.FirstOrDefaultAsync(ct);
if (itDeptId is Guid deptId)
{
var assignee = await db.Users
.Where(u => u.DepartmentId == deptId && u.IsActive)
.OrderBy(u => db.ItTickets.Count(t => t.AssignedToUserId == u.Id
&& t.Status != ItTicketStatus.Closed && t.Status != ItTicketStatus.Resolved && !t.IsDeleted))
.ThenBy(u => u.Id)
.FirstOrDefaultAsync(ct);
if (assignee is not null)
{
e.AssignedToUserId = assignee.Id;
e.AssignedToFullName = assignee.FullName;
}
}
db.ItTickets.Add(e);
await db.SaveChangesAsync(ct);
return e.Id;
@ -374,7 +413,8 @@ public class GetItTicketsHandler(IApplicationDbContext db)
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new ItTicketDto(x.Id, x.MaTicket, x.RequesterUserId, x.RequesterFullName,
x.Title, x.Description, (int)x.Category, (int)x.Priority, (int)x.Status,
x.AssignedToUserId, x.AssignedToFullName, x.ResolvedAt, x.Resolution, x.CreatedAt))
x.AssignedToUserId, x.AssignedToFullName, x.ResolvedAt, x.Resolution, x.CreatedAt,
x.SlaDueAt, x.SlaBreached))
.ToListAsync(ct);
return new PagedResult<ItTicketDto>(items, total, page, pageSize);
}
@ -400,6 +440,38 @@ public class UpdateItTicketStatusHandler(IApplicationDbContext db, ICurrentUser
}
}
// P11-D: admin re-assign ticket cho IT staff cụ thể (override round-robin auto-assign
// lúc Create). Denorm AssignedToFullName từ User.FullName tại thời điểm gán.
public record AssignItTicketCommand(Guid Id, Guid AssignedToUserId) : IRequest;
public class AssignItTicketValidator : AbstractValidator<AssignItTicketCommand>
{
public AssignItTicketValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.AssignedToUserId).NotEmpty();
}
}
public class AssignItTicketHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<AssignItTicketCommand>
{
public async Task Handle(AssignItTicketCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (t is null) throw new NotFoundException("ItTicket", req.Id);
var assignee = await db.Users.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == req.AssignedToUserId && u.IsActive, ct);
if (assignee is null) throw new NotFoundException("User", req.AssignedToUserId);
t.AssignedToUserId = assignee.Id;
t.AssignedToFullName = assignee.FullName;
t.UpdatedAt = clock.UtcNow;
t.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
// =========================================================================
// REGION 6: Attendance (G-P1)
// =========================================================================