[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

@ -0,0 +1,233 @@
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Office;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Office;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// Phase 11 P11-D (S53 2026-06-08 — Wave 2) — test-after CreateItTicketHandler
// round-robin auto-assign + SLA-due theo Priority.
//
// READ src: WorkflowAppsFeatures.cs CreateItTicketHandler:
// - SlaWindow map (dòng ~336): Urgent=4h / High=8h / Medium=24h / Low=72h
// → e.SlaDueAt = e.CreatedAt + window (default Medium 24h nếu Priority lạ).
// - Round-robin (dòng ~371-388): tìm Department.Code=="IT" (&& !IsDeleted) →
// trong dept đó pick user IsActive ÍT ticket-mở nhất (Status != Closed && != Resolved
// && !IsDeleted) → OrderBy(count).ThenBy(u.Id) → least-loaded + tie-break Id.
// Không có dept IT / 0 user active IT → AssignedToUserId == null (unassigned).
//
// ⚠️ Tie-break ThenBy(u.Id) so theo Guid — CreateUserAsync dùng Guid.NewGuid() nên
// KHÔNG hardcode được "A thắng B". Assert động: tính expected = user có Id nhỏ hơn
// (Guid CompareTo) tại runtime, so với AssignedToUserId thực tế.
//
// GOTCHA Serializable-on-SQLite = NON-ISSUE (confirmed S52 ItTicketCodeGenTests):
// codegen MaTicket dùng BeginTransactionAsync(IsolationLevel.Serializable) chạy SẠCH
// trên SQLite (provider map isolation gracefully, no throw). Mọi Create dưới đây gen
// MaTicket bình thường — Case regression (3) verify lại format không vỡ khi handler đổi.
public class ItTicketAssignSlaTests
{
private static readonly DateTime FixedNow = new(2026, 6, 8, 8, 0, 0, DateTimeKind.Utc);
private static (IdentityFixture fix, TestApplicationDbContext db, FixedDateTime clock) NewCtx()
{
var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var clock = new FixedDateTime(FixedNow);
return (fix, db, clock);
}
private static TestCurrentUser AsUser(User u)
=> new() { UserId = u.Id, FullName = u.FullName, Roles = Array.Empty<string>() };
private static CreateItTicketCommand BuildCmd(int priority = 2, string title = "Máy in hỏng")
=> new(title, "Máy in tầng 3 không nhận lệnh in.", Category: 1, Priority: priority);
// Seed 1 Department với Code chỉ định, trả về Id.
private static async Task<Guid> SeedDeptAsync(TestApplicationDbContext db, string code, string name)
{
var dept = new Department { Id = Guid.NewGuid(), Code = code, Name = name };
db.Departments.Add(dept);
await db.SaveChangesAsync(CancellationToken.None);
return dept.Id;
}
// Seed 1 ticket OPEN đã assign cho user (mô phỏng "load sẵn") — bỏ qua codegen.
private static async Task SeedOpenTicketAsync(TestApplicationDbContext db, Guid assigneeId, DateTime createdAt)
{
db.ItTickets.Add(new ItTicket
{
Id = Guid.NewGuid(),
MaTicket = "IT/2026/900", // mã seed thủ công, không đụng sequence
RequesterUserId = assigneeId,
RequesterFullName = "seed",
Title = "Ticket seed",
Description = "load sẵn",
Category = ItTicketCategory.Hardware,
Priority = ItTicketPriority.Medium,
Status = ItTicketStatus.Open,
AssignedToUserId = assigneeId,
CreatedAt = createdAt,
});
await db.SaveChangesAsync(CancellationToken.None);
}
// ============ Case 1: Round-robin least-loaded + tie-break deterministic ============
[Fact]
public async Task CreateItTicket_RoundRobin_PicksLeastLoadedThenTieBreaksById()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var requester = await fix.CreateUserAsync("req-rr1@test.local", "Người tạo", null, Array.Empty<string>());
// 2 IT staff active, A có sẵn 1 ticket mở → load A=1, B=0.
var staffA = await fix.CreateUserAsync("it-a@test.local", "IT Staff A", itDeptId, Array.Empty<string>());
var staffB = await fix.CreateUserAsync("it-b@test.local", "IT Staff B", itDeptId, Array.Empty<string>());
await SeedOpenTicketAsync(db, staffA.Id, clock.UtcNow);
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
// Create #1 → B (load 0) thắng A (load 1).
var id1 = await handler.Handle(BuildCmd(), CancellationToken.None);
var t1 = await db.ItTickets.FirstAsync(t => t.Id == id1);
t1.AssignedToUserId.Should().Be(staffB.Id, "B load=0 < A load=1 → least-loaded");
t1.AssignedToFullName.Should().Be("IT Staff B", "denorm full name set cùng assignee");
// Create #2 → giờ A=1, B=1 tie → ThenBy(u.Id) chọn user Id nhỏ hơn (deterministic).
var id2 = await handler.Handle(BuildCmd(), CancellationToken.None);
var t2 = await db.ItTickets.FirstAsync(t => t.Id == id2);
var expectedTieWinner = staffA.Id.CompareTo(staffB.Id) < 0 ? staffA : staffB;
t2.AssignedToUserId.Should().Be(expectedTieWinner.Id,
"A=1,B=1 tie → ThenBy(u.Id) → user có Guid Id nhỏ hơn");
}
}
// ============ Case 2a: Không có Department "IT" → unassigned ============
[Fact]
public async Task CreateItTicket_NoItDepartment_LeavesUnassigned()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
// Chỉ có phòng khác (HR), KHÔNG có Code=="IT".
var hrDeptId = await SeedDeptAsync(db, "HR", "Phòng Nhân sự");
var requester = await fix.CreateUserAsync("req-rr2@test.local", "Người tạo", null, Array.Empty<string>());
await fix.CreateUserAsync("hr-1@test.local", "HR Staff", hrDeptId, Array.Empty<string>());
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
var id = await handler.Handle(BuildCmd(), CancellationToken.None);
var t = await db.ItTickets.FirstAsync(t => t.Id == id);
t.AssignedToUserId.Should().BeNull("không có Department.Code==\"IT\" → bỏ qua round-robin");
t.AssignedToFullName.Should().BeNull();
}
}
// ============ Case 2b: Có dept IT nhưng 0 user active trong đó → unassigned ============
[Fact]
public async Task CreateItTicket_ItDepartmentButNoActiveStaff_LeavesUnassigned()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var requester = await fix.CreateUserAsync("req-rr3@test.local", "Người tạo", null, Array.Empty<string>());
// Không seed user nào thuộc IT dept.
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
var id = await handler.Handle(BuildCmd(), CancellationToken.None);
var t = await db.ItTickets.FirstAsync(t => t.Id == id);
t.AssignedToUserId.Should().BeNull("dept IT tồn tại nhưng 0 user → assignee null");
}
}
// ============ Case 3: User ngoài IT / inactive KHÔNG được assign ============
[Fact]
public async Task CreateItTicket_OnlyAssignsActiveStaffInsideItDepartment()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var otherDeptId = await SeedDeptAsync(db, "FIN", "Phòng Tài chính");
var requester = await fix.CreateUserAsync("req-rr4@test.local", "Người tạo", null, Array.Empty<string>());
// (a) user phòng khác — KHÔNG đủ điều kiện.
await fix.CreateUserAsync("fin-1@test.local", "Finance Staff", otherDeptId, Array.Empty<string>());
// (b) user IT nhưng IsActive=false — KHÔNG đủ điều kiện.
var inactiveIt = await fix.CreateUserAsync("it-off@test.local", "IT Inactive", itDeptId, Array.Empty<string>());
inactiveIt.IsActive = false;
await db.SaveChangesAsync(CancellationToken.None);
// (c) đúng 1 IT staff active — phải là người duy nhất được assign.
var activeIt = await fix.CreateUserAsync("it-on@test.local", "IT Active", itDeptId, Array.Empty<string>());
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
var id = await handler.Handle(BuildCmd(), CancellationToken.None);
var t = await db.ItTickets.FirstAsync(t => t.Id == id);
t.AssignedToUserId.Should().Be(activeIt.Id,
"chỉ user IsActive thuộc đúng dept IT mới vào pool round-robin");
}
}
// ============ Case 4: SLA-due = CreatedAt + window theo Priority ============
[Theory]
[InlineData(4, 4)] // Urgent → +4h
[InlineData(3, 8)] // High → +8h
[InlineData(2, 24)] // Medium → +24h
[InlineData(1, 72)] // Low → +72h
public async Task CreateItTicket_SlaDueAt_MatchesPriorityWindow(int priority, int expectedHours)
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync($"req-sla{priority}@test.local", "Người tạo", null, Array.Empty<string>());
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
var id = await handler.Handle(BuildCmd(priority), CancellationToken.None);
var t = await db.ItTickets.FirstAsync(t => t.Id == id);
t.CreatedAt.Should().Be(FixedNow, "clock stub cố định → CreatedAt = clock.UtcNow");
t.SlaDueAt.Should().Be(FixedNow.AddHours(expectedHours),
$"Priority {priority} → SLA window {expectedHours}h từ CreatedAt");
t.SlaBreached.Should().BeFalse("ticket mới chưa breach");
t.SlaWarnedSent.Should().BeFalse("ticket mới chưa cảnh báo");
}
}
// ============ Case 5: Regression P11-F — MaTicket vẫn gen đúng format ============
[Fact]
public async Task CreateItTicket_StillGeneratesMaTicket_FormatUnchanged()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
// Có dept IT + staff để chạy luôn nhánh round-robin → đảm bảo codegen
// vẫn chạy chung với assign logic, không bị nhánh mới phá format.
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
await fix.CreateUserAsync("it-reg@test.local", "IT Staff", itDeptId, Array.Empty<string>());
var requester = await fix.CreateUserAsync("req-reg@test.local", "Người tạo", null, Array.Empty<string>());
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
var id = await handler.Handle(BuildCmd(), CancellationToken.None);
var t = await db.ItTickets.FirstAsync(t => t.Id == id);
t.MaTicket.Should().NotBeNullOrEmpty();
t.MaTicket.Should().MatchRegex(@"^IT/\d{4}/\d{3}$", "P11-D đổi handler nhưng codegen P11-F giữ format");
t.MaTicket.Should().Be("IT/2026/001", "vẫn seq đầu năm 2026");
}
}
}