[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:
@ -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 có ticket nào. Form tạo ticket sẽ kích hoạt Phase 11.
|
Chưa có ticket nào.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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 có ticket nào. Form tạo ticket sẽ kích hoạt Phase 11.
|
Chưa có ticket nào.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -29,5 +29,15 @@ public class ItTicketsController(IMediator mediator) : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public record UpdateItTicketStatusBody(int Status, string? Resolution);
|
// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>();
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user