[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

@ -1,5 +1,5 @@
// Ticket CNTT — Phase 10.3 G-O6 (S38 2026-05-28). // Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52).
// SKELETON Phase 1: read-only kanban list. Auto-assign + SLA timer DEFER Phase 11. // Read-only kanban list + MaTicket + người xử lý (auto-assign dept IT) + SLA badge (đỏ khi quá hạn).
// File MIRROR SHA256 identical fe-user counterpart. // File MIRROR SHA256 identical fe-user counterpart.
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Ticket } from 'lucide-react' import { Ticket } from 'lucide-react'
@ -11,6 +11,12 @@ import {
IT_TICKET_STATUS_LABELS, type ItTicketDto, type PagedResult, IT_TICKET_STATUS_LABELS, type ItTicketDto, type PagedResult,
} from '@/types/workflowApps' } from '@/types/workflowApps'
function formatSlaDue(iso: string): string {
return new Date(iso).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
})
}
export function ItTicketsPage() { export function ItTicketsPage() {
const list = useQuery({ const list = useQuery({
queryKey: ['it-tickets'], queryKey: ['it-tickets'],
@ -29,10 +35,6 @@ export function ItTicketsPage() {
<div className="space-y-4"> <div className="space-y-4">
<PageHeader title="Ticket CNTT" description="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật" /> <PageHeader title="Ticket CNTT" description="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật" />
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900">
<strong>Skeleton Phase 1 (S38):</strong> Read-only list. Form tạo ticket + Auto-assign round-robin + SLA timer defer Phase 11 polish.
</div>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3">
{[1, 2, 3, 5, 4].map((statusKey) => ( {[1, 2, 3, 5, 4].map((statusKey) => (
<div key={statusKey} className="rounded-lg border bg-card p-3"> <div key={statusKey} className="rounded-lg border bg-card p-3">
@ -56,6 +58,22 @@ export function ItTicketsPage() {
<div className="text-muted-foreground"> <div className="text-muted-foreground">
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName} {IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
</div> </div>
<div className="flex items-center justify-between gap-1 pt-0.5">
<span className="text-muted-foreground truncate" title={t.assignedToFullName ?? undefined}>
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
</span>
{t.slaDueAt && (
<span
className={cn(
'rounded px-1.5 py-0.5 text-[10px] whitespace-nowrap',
t.slaBreached ? 'bg-red-100 text-red-700 font-medium' : 'bg-slate-100 text-slate-600',
)}
title={`Hạn xử lý SLA: ${formatSlaDue(t.slaDueAt)}`}
>
{t.slaBreached ? 'Quá hạn SLA' : `SLA ${formatSlaDue(t.slaDueAt)}`}
</span>
)}
</div>
</div> </div>
))} ))}
</div> </div>
@ -66,7 +84,7 @@ export function ItTicketsPage() {
{!list.isLoading && items.length === 0 && ( {!list.isLoading && items.length === 0 && (
<div className="rounded-lg border bg-card p-8 text-center text-muted-foreground"> <div className="rounded-lg border bg-card p-8 text-center text-muted-foreground">
<Ticket className="mx-auto h-10 w-10 mb-3 opacity-50" /> <Ticket className="mx-auto h-10 w-10 mb-3 opacity-50" />
Chưa ticket nào. Form tạo ticket sẽ kích hoạt Phase 11. Chưa ticket nào.
</div> </div>
)} )}
</div> </div>

View File

@ -114,7 +114,7 @@ export interface LeaveRequestDto { id: string; maDonTu: string | null; requester
export interface OtRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; otDate: string; startTime: string; endTime: string; hours: number; reason: string; otPolicyId: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string } export interface OtRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; otDate: string; startTime: string; endTime: string; hours: number; reason: string; otPolicyId: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string }
export interface TravelRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; destination: string; startDate: string; endDate: string; numDays: number; purpose: string; estimatedCost: number | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string } export interface TravelRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; destination: string; startDate: string; endDate: string; numDays: number; purpose: string; estimatedCost: number | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string }
export interface VehicleBookingDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; vehicleLicense: string; vehicleName: string | null; startAt: string; endAt: string; destination: string; purpose: string; driverName: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string } export interface VehicleBookingDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; vehicleLicense: string; vehicleName: string | null; startAt: string; endAt: string; destination: string; purpose: string; driverName: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string }
export interface ItTicketDto { id: string; maTicket: string | null; requesterUserId: string; requesterFullName: string; title: string; description: string; category: number; priority: number; status: number; assignedToUserId: string | null; assignedToFullName: string | null; resolvedAt: string | null; resolution: string | null; createdAt: string } export interface ItTicketDto { id: string; maTicket: string | null; requesterUserId: string; requesterFullName: string; title: string; description: string; category: number; priority: number; status: number; assignedToUserId: string | null; assignedToFullName: string | null; resolvedAt: string | null; resolution: string | null; createdAt: string; slaDueAt: string | null; slaBreached: boolean }
export interface AttendanceDto { id: string; userId: string; userFullName: string; attendanceDate: string; checkInAt: string | null; checkOutAt: string | null; sourceIn: number; sourceOut: number; checkInLatitude: number | null; checkInLongitude: number | null; workHours: number | null; otHours: number | null; note: string | null } export interface AttendanceDto { id: string; userId: string; userFullName: string; attendanceDate: string; checkInAt: string | null; checkOutAt: string | null; sourceIn: number; sourceOut: number; checkInLatitude: number | null; checkInLongitude: number | null; workHours: number | null; otHours: number | null; note: string | null }
// P11-E (S?? 2026-06-08) — Báo cáo chấm công tháng + OT quy đổi (admin-only). Mirror BE AttendanceReportDto/RowDto (decimal → number). // P11-E (S?? 2026-06-08) — Báo cáo chấm công tháng + OT quy đổi (admin-only). Mirror BE AttendanceReportDto/RowDto (decimal → number).

View File

@ -1,5 +1,5 @@
// Ticket CNTT — Phase 10.3 G-O6 (S38 2026-05-28). // Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52).
// SKELETON Phase 1: read-only kanban list. Auto-assign + SLA timer DEFER Phase 11. // Read-only kanban list + MaTicket + người xử lý (auto-assign dept IT) + SLA badge (đỏ khi quá hạn).
// File MIRROR SHA256 identical fe-user counterpart. // File MIRROR SHA256 identical fe-user counterpart.
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Ticket } from 'lucide-react' import { Ticket } from 'lucide-react'
@ -11,6 +11,12 @@ import {
IT_TICKET_STATUS_LABELS, type ItTicketDto, type PagedResult, IT_TICKET_STATUS_LABELS, type ItTicketDto, type PagedResult,
} from '@/types/workflowApps' } from '@/types/workflowApps'
function formatSlaDue(iso: string): string {
return new Date(iso).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
})
}
export function ItTicketsPage() { export function ItTicketsPage() {
const list = useQuery({ const list = useQuery({
queryKey: ['it-tickets'], queryKey: ['it-tickets'],
@ -29,10 +35,6 @@ export function ItTicketsPage() {
<div className="space-y-4"> <div className="space-y-4">
<PageHeader title="Ticket CNTT" description="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật" /> <PageHeader title="Ticket CNTT" description="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật" />
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900">
<strong>Skeleton Phase 1 (S38):</strong> Read-only list. Form tạo ticket + Auto-assign round-robin + SLA timer defer Phase 11 polish.
</div>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3">
{[1, 2, 3, 5, 4].map((statusKey) => ( {[1, 2, 3, 5, 4].map((statusKey) => (
<div key={statusKey} className="rounded-lg border bg-card p-3"> <div key={statusKey} className="rounded-lg border bg-card p-3">
@ -56,6 +58,22 @@ export function ItTicketsPage() {
<div className="text-muted-foreground"> <div className="text-muted-foreground">
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName} {IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
</div> </div>
<div className="flex items-center justify-between gap-1 pt-0.5">
<span className="text-muted-foreground truncate" title={t.assignedToFullName ?? undefined}>
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
</span>
{t.slaDueAt && (
<span
className={cn(
'rounded px-1.5 py-0.5 text-[10px] whitespace-nowrap',
t.slaBreached ? 'bg-red-100 text-red-700 font-medium' : 'bg-slate-100 text-slate-600',
)}
title={`Hạn xử lý SLA: ${formatSlaDue(t.slaDueAt)}`}
>
{t.slaBreached ? 'Quá hạn SLA' : `SLA ${formatSlaDue(t.slaDueAt)}`}
</span>
)}
</div>
</div> </div>
))} ))}
</div> </div>
@ -66,7 +84,7 @@ export function ItTicketsPage() {
{!list.isLoading && items.length === 0 && ( {!list.isLoading && items.length === 0 && (
<div className="rounded-lg border bg-card p-8 text-center text-muted-foreground"> <div className="rounded-lg border bg-card p-8 text-center text-muted-foreground">
<Ticket className="mx-auto h-10 w-10 mb-3 opacity-50" /> <Ticket className="mx-auto h-10 w-10 mb-3 opacity-50" />
Chưa ticket nào. Form tạo ticket sẽ kích hoạt Phase 11. Chưa ticket nào.
</div> </div>
)} )}
</div> </div>

View File

@ -114,6 +114,6 @@ export interface LeaveRequestDto { id: string; maDonTu: string | null; requester
export interface OtRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; otDate: string; startTime: string; endTime: string; hours: number; reason: string; otPolicyId: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string } export interface OtRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; otDate: string; startTime: string; endTime: string; hours: number; reason: string; otPolicyId: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string }
export interface TravelRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; destination: string; startDate: string; endDate: string; numDays: number; purpose: string; estimatedCost: number | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string } export interface TravelRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; destination: string; startDate: string; endDate: string; numDays: number; purpose: string; estimatedCost: number | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string }
export interface VehicleBookingDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; vehicleLicense: string; vehicleName: string | null; startAt: string; endAt: string; destination: string; purpose: string; driverName: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string } export interface VehicleBookingDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; vehicleLicense: string; vehicleName: string | null; startAt: string; endAt: string; destination: string; purpose: string; driverName: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string }
export interface ItTicketDto { id: string; maTicket: string | null; requesterUserId: string; requesterFullName: string; title: string; description: string; category: number; priority: number; status: number; assignedToUserId: string | null; assignedToFullName: string | null; resolvedAt: string | null; resolution: string | null; createdAt: string } export interface ItTicketDto { id: string; maTicket: string | null; requesterUserId: string; requesterFullName: string; title: string; description: string; category: number; priority: number; status: number; assignedToUserId: string | null; assignedToFullName: string | null; resolvedAt: string | null; resolution: string | null; createdAt: string; slaDueAt: string | null; slaBreached: boolean }
export interface AttendanceDto { id: string; userId: string; userFullName: string; attendanceDate: string; checkInAt: string | null; checkOutAt: string | null; sourceIn: number; sourceOut: number; checkInLatitude: number | null; checkInLongitude: number | null; workHours: number | null; otHours: number | null; note: string | null } export interface AttendanceDto { id: string; userId: string; userFullName: string; attendanceDate: string; checkInAt: string | null; checkOutAt: string | null; sourceIn: number; sourceOut: number; checkInLatitude: number | null; checkInLongitude: number | null; workHours: number | null; otHours: number | null; note: string | null }
export interface HrDashboardDto { totalEmployees: number; activeEmployees: number; onLeaveEmployees: number; resignedEmployees: number; maleCount: number; femaleCount: number; birthdaysThisWeek: number; newHiresThisMonth: number } export interface HrDashboardDto { totalEmployees: number; activeEmployees: number; onLeaveEmployees: number; resignedEmployees: number; maleCount: number; femaleCount: number; birthdaysThisWeek: number; newHiresThisMonth: number }

View File

@ -29,5 +29,15 @@ public class ItTicketsController(IMediator mediator) : ControllerBase
return NoContent(); 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 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, public record ItTicketDto(Guid Id, string? MaTicket, Guid RequesterUserId, string RequesterFullName,
string Title, string Description, int Category, int Priority, int Status, 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>; 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) public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<CreateItTicketCommand, Guid> : 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) public async Task<Guid> Handle(CreateItTicketCommand req, CancellationToken ct)
{ {
if (cu.UserId is null) throw new UnauthorizedException(); if (cu.UserId is null) throw new UnauthorizedException();
var priority = (ItTicketPriority)req.Priority;
var e = new ItTicket var e = new ItTicket
{ {
RequesterUserId = cu.UserId.Value, RequesterUserId = cu.UserId.Value,
@ -340,7 +353,7 @@ public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
Title = req.Title.Trim(), Title = req.Title.Trim(),
Description = req.Description.Trim(), Description = req.Description.Trim(),
Category = (ItTicketCategory)req.Category, Category = (ItTicketCategory)req.Category,
Priority = (ItTicketPriority)req.Priority, Priority = priority,
Status = ItTicketStatus.Open, Status = ItTicketStatus.Open,
CreatedAt = clock.UtcNow, CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId, 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, // 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). // 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); 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); db.ItTickets.Add(e);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return e.Id; 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) 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, .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.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); .ToListAsync(ct);
return new PagedResult<ItTicketDto>(items, total, page, pageSize); 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) // REGION 6: Attendance (G-P1)
// ========================================================================= // =========================================================================

View File

@ -23,4 +23,12 @@ public class ItTicket : AuditableEntity
public DateTime? ResolvedAt { get; set; } public DateTime? ResolvedAt { get; set; }
public string? Resolution { get; set; } // ghi chú giải pháp (free text replace ItTicketComments thread defer Phase 11) 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 // Phase 3 iteration 2 — SLA auto-approve background service
services.AddHostedService<SlaExpiryJob>(); 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<AuditingInterceptor>();
services.AddScoped<NotificationPushInterceptor>(); 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 SeedAdminAsync(userManager, logger);
await SeedDepartmentsAsync(db, logger); await SeedDepartmentsAsync(db, logger);
await SeedDemoUsersAsync(db, userManager, 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 // 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 // mọi user @solutions.com.vn. Idempotent. NOT gated DemoSeed flag
// (infrastructure data, mirror Mig 32 SeedSampleContractWorkflowV2 // (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"), ("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ý"), ("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Đ"), ("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(); 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ó, // 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 // 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. // 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") b.Property<DateTime?>("ResolvedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<bool>("SlaBreached")
.HasColumnType("bit");
b.Property<DateTime?>("SlaDueAt")
.HasColumnType("datetime2");
b.Property<bool>("SlaWarnedSent")
.HasColumnType("bit");
b.Property<int>("Status") b.Property<int>("Status")
.HasColumnType("int"); .HasColumnType("int");

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");
}
}
}