[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
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:
@ -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)
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user