[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

@ -29,5 +29,15 @@ public class ItTicketsController(IMediator mediator) : ControllerBase
return NoContent();
}
// P11-D: admin re-assign ticket cho IT staff (override round-robin auto-assign).
[HttpPut("{id:guid}/assign")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Assign(Guid id, [FromBody] AssignItTicketBody body)
{
await mediator.Send(new AssignItTicketCommand(id, body.AssignedToUserId));
return NoContent();
}
public record UpdateItTicketStatusBody(int Status, string? Resolution);
public record AssignItTicketBody(Guid AssignedToUserId);
}

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)
// =========================================================================

View File

@ -23,4 +23,12 @@ public class ItTicket : AuditableEntity
public DateTime? ResolvedAt { get; set; }
public string? Resolution { get; set; } // ghi chú giải pháp (free text replace ItTicketComments thread defer Phase 11)
// P11-D (Mig 46 — Wave 2) — SLA timer. SlaDueAt = CreatedAt + window theo Priority
// (Urgent=4h / High=8h / Medium=24h / Low=72h). ItTicketSlaJob (BackgroundService
// 15 phút) set SlaWarnedSent khi còn ≤20% window + SlaBreached khi quá hạn (notify
// assignee). NO auto-transition status (khác SlaExpiryJob của HĐ — ticket chỉ cảnh báo).
public DateTime? SlaDueAt { get; set; }
public bool SlaWarnedSent { get; set; }
public bool SlaBreached { get; set; }
}

View File

@ -45,6 +45,8 @@ public static class DependencyInjection
// Phase 3 iteration 2 — SLA auto-approve background service
services.AddHostedService<SlaExpiryJob>();
// Phase 11 P11-D — IT ticket SLA timer (warning ≤20% + breach, no auto-transition)
services.AddHostedService<ItTicketSlaJob>();
services.AddScoped<AuditingInterceptor>();
services.AddScoped<NotificationPushInterceptor>();

View File

@ -0,0 +1,143 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Notifications;
using SolutionErp.Application.Office;
using SolutionErp.Domain.Notifications;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.HostedServices;
// P11-D (Mig 46 — Phase 11 Wave 2) — SLA timer cho IT helpdesk ticket.
// Mirror pattern SlaExpiryJob (HĐ) NHƯNG KHÔNG auto-transition status — ticket
// chỉ CẢNH BÁO (warning ≤20% window + breach quá hạn) gửi notification cho assignee.
// Status flow (Open → InProgress → Resolved → Closed) do IT staff điều khiển tay.
// Chạy mỗi 15 phút, warmup 30s tránh race DbInitializer migrate.
// SLA window theo Priority = source-of-truth CreateItTicketHandler.SlaWindow (shared).
public class ItTicketSlaJob : BackgroundService
{
private readonly IServiceProvider _sp;
private readonly ILogger<ItTicketSlaJob> _logger;
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(15);
public ItTicketSlaJob(IServiceProvider sp, ILogger<ItTicketSlaJob> logger)
{
_sp = sp;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "ItTicketSlaJob iteration failed");
}
await Task.Delay(Interval, stoppingToken);
}
}
private async Task ProcessAsync(CancellationToken ct)
{
await using var scope = _sp.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IApplicationDbContext>();
var dateTime = scope.ServiceProvider.GetRequiredService<IDateTime>();
var notifications = scope.ServiceProvider.GetRequiredService<INotificationService>();
var now = dateTime.UtcNow;
await ProcessBreachesAsync(db, notifications, now, ct);
await ProcessWarningsAsync(db, notifications, now, ct);
}
// Breach: ticket quá hạn SLA mà chưa đánh dấu breach + còn open (chưa Resolved/Closed).
// Set SlaBreached=true + notify assignee (nếu có). Idempotent qua !SlaBreached guard.
private async Task ProcessBreachesAsync(
IApplicationDbContext db, INotificationService notifications,
DateTime now, CancellationToken ct)
{
var breached = await db.ItTickets
.Where(t => t.SlaDueAt != null && t.SlaDueAt < now
&& !t.SlaBreached
&& t.Status != ItTicketStatus.Resolved && t.Status != ItTicketStatus.Closed
&& !t.IsDeleted)
.ToListAsync(ct);
if (breached.Count == 0) return;
foreach (var t in breached)
{
if (t.AssignedToUserId is Guid assignee)
{
await notifications.NotifyAsync(
assignee,
NotificationType.SlaWarning,
title: $"⚠ Ticket {t.MaTicket ?? t.Title} quá hạn SLA",
description: $"Ticket \"{t.Title}\" đã quá hạn xử lý SLA. Vui lòng ưu tiên xử lý.",
href: $"/it-tickets/{t.Id}",
refId: t.Id,
ct: ct);
}
t.SlaBreached = true;
}
await db.SaveChangesAsync(ct);
_logger.LogInformation("ItTicketSlaJob: {Count} tickets breached SLA.", breached.Count);
}
// Warning: ticket chưa warning + còn open + còn ≤20% window (theo Priority) trước hạn.
// Notify assignee + set SlaWarnedSent=true. Idempotent qua !SlaWarnedSent guard.
private async Task ProcessWarningsAsync(
IApplicationDbContext db, INotificationService notifications,
DateTime now, CancellationToken ct)
{
var candidates = await db.ItTickets
.Where(t => !t.SlaWarnedSent
&& t.SlaDueAt != null && t.SlaDueAt > now
&& t.Status != ItTicketStatus.Resolved && t.Status != ItTicketStatus.Closed
&& !t.IsDeleted)
.ToListAsync(ct);
if (candidates.Count == 0) return;
int warned = 0;
foreach (var t in candidates)
{
var window = CreateItTicketHandler.SlaWindow.TryGetValue(t.Priority, out var w)
? w : TimeSpan.FromHours(24);
var threshold = TimeSpan.FromTicks((long)(window.Ticks * 0.2));
var remaining = t.SlaDueAt!.Value - now;
if (remaining > threshold) continue; // còn nhiều SLA → skip
if (t.AssignedToUserId is Guid assignee)
{
var hoursLeft = Math.Max(1, (int)remaining.TotalHours);
await notifications.NotifyAsync(
assignee,
NotificationType.SlaWarning,
title: $"⚠ Ticket {t.MaTicket ?? t.Title} sắp quá hạn ({hoursLeft}h)",
description: $"Ticket \"{t.Title}\" còn ~{hoursLeft}h trước hạn xử lý SLA.",
href: $"/it-tickets/{t.Id}",
refId: t.Id,
ct: ct);
}
t.SlaWarnedSent = true;
warned++;
}
if (warned > 0)
{
await db.SaveChangesAsync(ct);
_logger.LogInformation("ItTicketSlaJob: {Count} warnings dispatched (≤20% SLA).", warned);
}
}
}

View File

@ -88,6 +88,10 @@ public static class DbInitializer
await SeedAdminAsync(userManager, logger);
await SeedDepartmentsAsync(db, logger);
await SeedDemoUsersAsync(db, userManager, logger);
// Phase 11 P11-D (Mig 46 — Wave 2) — gán 2 sample user vào Phòng CNTT (IT staff)
// cho round-robin auto-assign ticket. PHẢI sau SeedDemoUsersAsync (reconcile dept
// trước → method này override về IT). Infrastructure data (NOT gated DemoSeed).
await SeedItDepartmentStaffAsync(db, userManager, logger);
// Plan B G-H1 (Mig 34 S33 2026-05-26) — seed EmployeeProfile 1-1 với
// mọi user @solutions.com.vn. Idempotent. NOT gated DemoSeed flag
// (infrastructure data, mirror Mig 32 SeedSampleContractWorkflowV2
@ -2074,6 +2078,7 @@ public static class DbInitializer
("EQU", "Phòng Thiết bị", "Equipment — thuê/mua máy móc"),
("HRA", "Phòng Nhân sự - Hành chính", "HRA/ISO — đóng dấu HĐ sau BOD ký"),
("BOD", "Ban Giám đốc", "Board of Directors — ký duyệt HĐ"),
("IT", "Phòng CNTT", "IT helpdesk — tiếp nhận + xử lý ticket CNTT (P11-D round-robin assign)"),
};
var existingCodes = await db.Departments.Select(d => d.Code).ToListAsync();
@ -2091,6 +2096,52 @@ public static class DbInitializer
}
}
// P11-D (Wave 2) — gán 2 SAMPLE demo user vào Phòng CNTT (IT) làm IT staff
// để round-robin auto-assign ticket có người nhận ngày 1. CHỈ đụng 2 sample
// user (nv.cao + nv.truong — prefix nv.* = sample, KHÔNG phải 14 Solutions
// real user). Idempotent: chỉ set DepartmentId nếu user CHƯA thuộc IT.
//
// ⚠️ PHẢI chạy SAU SeedDemoUsersAsync: method đó reconcile DepartmentId 2 user
// này về PRO/CCM (hardcoded deptCode) mỗi lần boot. Chạy sau → override về IT.
// End-state sau mỗi full SeedAsync = deterministic (IT). Infrastructure data
// (NOT gated DemoSeed flag, gotcha #51).
private static async Task SeedItDepartmentStaffAsync(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
var itDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "IT" && !d.IsDeleted);
if (itDept is null)
{
logger.LogWarning("SeedItDepartmentStaffAsync: skip — phòng IT chưa seed (chạy SeedDepartmentsAsync trước).");
return;
}
// 2 sample user (Drafter-only) chuyển sang IT staff. KHÔNG đụng real user.
var sampleItStaffEmails = new[]
{
"nv.cao@solutions.com.vn", // Cao Văn Long
"nv.truong@solutions.com.vn", // Trương Minh Quân
};
int assigned = 0;
foreach (var email in sampleItStaffEmails)
{
var user = await userManager.FindByEmailAsync(email);
if (user is null)
{
logger.LogWarning("SeedItDepartmentStaffAsync: sample user {Email} not found — skip.", email);
continue;
}
if (user.DepartmentId == itDept.Id) continue; // đã thuộc IT → idempotent skip
user.DepartmentId = itDept.Id;
user.UpdatedAt = DateTime.UtcNow;
await userManager.UpdateAsync(user);
assigned++;
}
if (assigned > 0)
logger.LogInformation("SeedItDepartmentStaffAsync: gán {Count} sample user vào Phòng CNTT (IT staff).", assigned);
}
// Sample master data for UAT/demo — per-code idempotent (skip Code đã có,
// add Code mới). Cho phép admin add/sửa supplier/project mà restart không
// clobber, đồng thời cho phép expand seed list theo thời gian.

View File

@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddSlaFieldsToItTicket : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "SlaBreached",
table: "ItTickets",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "SlaDueAt",
table: "ItTickets",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "SlaWarnedSent",
table: "ItTickets",
type: "bit",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SlaBreached",
table: "ItTickets");
migrationBuilder.DropColumn(
name: "SlaDueAt",
table: "ItTickets");
migrationBuilder.DropColumn(
name: "SlaWarnedSent",
table: "ItTickets");
}
}
}

View File

@ -3793,6 +3793,15 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("datetime2");
b.Property<bool>("SlaBreached")
.HasColumnType("bit");
b.Property<DateTime?>("SlaDueAt")
.HasColumnType("datetime2");
b.Property<bool>("SlaWarnedSent")
.HasColumnType("bit");
b.Property<int>("Status")
.HasColumnType("int");