diff --git a/fe-admin/src/pages/office/ItTicketsPage.tsx b/fe-admin/src/pages/office/ItTicketsPage.tsx
index cb65219..4e0a653 100644
--- a/fe-admin/src/pages/office/ItTicketsPage.tsx
+++ b/fe-admin/src/pages/office/ItTicketsPage.tsx
@@ -1,5 +1,5 @@
-// Ticket CNTT — Phase 10.3 G-O6 (S38 2026-05-28).
-// SKELETON Phase 1: read-only kanban list. Auto-assign + SLA timer DEFER Phase 11.
+// Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52).
+// 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.
import { useQuery } from '@tanstack/react-query'
import { Ticket } from 'lucide-react'
@@ -11,6 +11,12 @@ import {
IT_TICKET_STATUS_LABELS, type ItTicketDto, type PagedResult,
} 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() {
const list = useQuery({
queryKey: ['it-tickets'],
@@ -29,10 +35,6 @@ export function ItTicketsPage() {
-
- ⚠️ Skeleton Phase 1 (S38): Read-only list. Form tạo ticket + Auto-assign round-robin + SLA timer defer Phase 11 polish.
-
-
{[1, 2, 3, 5, 4].map((statusKey) => (
@@ -56,6 +58,22 @@ export function ItTicketsPage() {
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
+
+
+ 👤 {t.assignedToFullName ?? Chưa giao}
+
+ {t.slaDueAt && (
+
+ {t.slaBreached ? 'Quá hạn SLA' : `SLA ${formatSlaDue(t.slaDueAt)}`}
+
+ )}
+
))}
@@ -66,7 +84,7 @@ export function ItTicketsPage() {
{!list.isLoading && items.length === 0 && (
- Chưa có ticket nào. Form tạo ticket sẽ kích hoạt Phase 11.
+ Chưa có ticket nào.
)}
diff --git a/fe-admin/src/types/workflowApps.ts b/fe-admin/src/types/workflowApps.ts
index 1bbf2d1..715c03b 100644
--- a/fe-admin/src/types/workflowApps.ts
+++ b/fe-admin/src/types/workflowApps.ts
@@ -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 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 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 }
// 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).
diff --git a/fe-user/src/pages/office/ItTicketsPage.tsx b/fe-user/src/pages/office/ItTicketsPage.tsx
index cb65219..4e0a653 100644
--- a/fe-user/src/pages/office/ItTicketsPage.tsx
+++ b/fe-user/src/pages/office/ItTicketsPage.tsx
@@ -1,5 +1,5 @@
-// Ticket CNTT — Phase 10.3 G-O6 (S38 2026-05-28).
-// SKELETON Phase 1: read-only kanban list. Auto-assign + SLA timer DEFER Phase 11.
+// Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52).
+// 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.
import { useQuery } from '@tanstack/react-query'
import { Ticket } from 'lucide-react'
@@ -11,6 +11,12 @@ import {
IT_TICKET_STATUS_LABELS, type ItTicketDto, type PagedResult,
} 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() {
const list = useQuery({
queryKey: ['it-tickets'],
@@ -29,10 +35,6 @@ export function ItTicketsPage() {
-
- ⚠️ Skeleton Phase 1 (S38): Read-only list. Form tạo ticket + Auto-assign round-robin + SLA timer defer Phase 11 polish.
-
-
{[1, 2, 3, 5, 4].map((statusKey) => (
@@ -56,6 +58,22 @@ export function ItTicketsPage() {
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
+
+
+ 👤 {t.assignedToFullName ?? Chưa giao}
+
+ {t.slaDueAt && (
+
+ {t.slaBreached ? 'Quá hạn SLA' : `SLA ${formatSlaDue(t.slaDueAt)}`}
+
+ )}
+
))}
@@ -66,7 +84,7 @@ export function ItTicketsPage() {
{!list.isLoading && items.length === 0 && (
- Chưa có ticket nào. Form tạo ticket sẽ kích hoạt Phase 11.
+ Chưa có ticket nào.
)}
diff --git a/fe-user/src/types/workflowApps.ts b/fe-user/src/types/workflowApps.ts
index cdd78a1..de16792 100644
--- a/fe-user/src/types/workflowApps.ts
+++ b/fe-user/src/types/workflowApps.ts
@@ -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 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 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 HrDashboardDto { totalEmployees: number; activeEmployees: number; onLeaveEmployees: number; resignedEmployees: number; maleCount: number; femaleCount: number; birthdaysThisWeek: number; newHiresThisMonth: number }
diff --git a/src/Backend/SolutionErp.Api/Controllers/ItTicketsController.cs b/src/Backend/SolutionErp.Api/Controllers/ItTicketsController.cs
index 0949488..0f7b143 100644
--- a/src/Backend/SolutionErp.Api/Controllers/ItTicketsController.cs
+++ b/src/Backend/SolutionErp.Api/Controllers/ItTicketsController.cs
@@ -29,5 +29,15 @@ public class ItTicketsController(IMediator mediator) : ControllerBase
return NoContent();
}
+ // P11-D: admin re-assign ticket cho IT staff (override round-robin auto-assign).
+ [HttpPut("{id:guid}/assign")]
+ [Authorize(Roles = "Admin")]
+ public async Task 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);
}
diff --git a/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs b/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs
index f5eb722..88a4812 100644
--- a/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs
+++ b/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs
@@ -312,7 +312,8 @@ public class GetVehicleBookingsHandler(IApplicationDbContext db)
public record ItTicketDto(Guid Id, string? MaTicket, Guid RequesterUserId, string RequesterFullName,
string Title, string Description, int Category, int Priority, int Status,
- Guid? AssignedToUserId, string? AssignedToFullName, DateTime? ResolvedAt, string? Resolution, DateTime CreatedAt);
+ Guid? AssignedToUserId, string? AssignedToFullName, DateTime? ResolvedAt, string? Resolution, DateTime CreatedAt,
+ DateTime? SlaDueAt, bool SlaBreached);
public record CreateItTicketCommand(string Title, string Description, int Category, int Priority) : IRequest;
@@ -330,9 +331,21 @@ public class CreateItTicketValidator : AbstractValidator
public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler
{
+ // 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 SlaWindow =
+ new Dictionary
+ {
+ [ItTicketPriority.Urgent] = TimeSpan.FromHours(4),
+ [ItTicketPriority.High] = TimeSpan.FromHours(8),
+ [ItTicketPriority.Medium] = TimeSpan.FromHours(24),
+ [ItTicketPriority.Low] = TimeSpan.FromHours(72),
+ };
+
public async Task Handle(CreateItTicketCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
+ var priority = (ItTicketPriority)req.Priority;
var e = new ItTicket
{
RequesterUserId = cu.UserId.Value,
@@ -340,7 +353,7 @@ public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
Title = req.Title.Trim(),
Description = req.Description.Trim(),
Category = (ItTicketCategory)req.Category,
- Priority = (ItTicketPriority)req.Priority,
+ Priority = priority,
Status = ItTicketStatus.Open,
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
@@ -348,6 +361,32 @@ public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
// P11-F: gen mã ticket lúc Create (ItTicket = kanban KHÔNG workflow,
// khác Leave/OT gen lúc Submit). Format "IT/2026/001" (Serializable tx atomic).
e.MaTicket = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "IT", clock.Now.Year, clock, ct);
+
+ // P11-D: SLA due = CreatedAt + window theo Priority (default Medium 24h nếu lạ).
+ e.SlaDueAt = e.CreatedAt + (SlaWindow.TryGetValue(priority, out var w) ? w : TimeSpan.FromHours(24));
+
+ // P11-D: round-robin least-loaded — assign cho IT staff (Department.Code=="IT")
+ // ít ticket-mở nhất. Tie-break theo Id (deterministic). Không có ai trong IT
+ // → để unassigned (admin assign tay sau qua /assign).
+ var itDeptId = await db.Departments.AsNoTracking()
+ .Where(d => d.Code == "IT" && !d.IsDeleted)
+ .Select(d => (Guid?)d.Id)
+ .FirstOrDefaultAsync(ct);
+ if (itDeptId is Guid deptId)
+ {
+ var assignee = await db.Users
+ .Where(u => u.DepartmentId == deptId && u.IsActive)
+ .OrderBy(u => db.ItTickets.Count(t => t.AssignedToUserId == u.Id
+ && t.Status != ItTicketStatus.Closed && t.Status != ItTicketStatus.Resolved && !t.IsDeleted))
+ .ThenBy(u => u.Id)
+ .FirstOrDefaultAsync(ct);
+ if (assignee is not null)
+ {
+ e.AssignedToUserId = assignee.Id;
+ e.AssignedToFullName = assignee.FullName;
+ }
+ }
+
db.ItTickets.Add(e);
await db.SaveChangesAsync(ct);
return e.Id;
@@ -374,7 +413,8 @@ public class GetItTicketsHandler(IApplicationDbContext db)
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new ItTicketDto(x.Id, x.MaTicket, x.RequesterUserId, x.RequesterFullName,
x.Title, x.Description, (int)x.Category, (int)x.Priority, (int)x.Status,
- x.AssignedToUserId, x.AssignedToFullName, x.ResolvedAt, x.Resolution, x.CreatedAt))
+ x.AssignedToUserId, x.AssignedToFullName, x.ResolvedAt, x.Resolution, x.CreatedAt,
+ x.SlaDueAt, x.SlaBreached))
.ToListAsync(ct);
return new PagedResult(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
+{
+ public AssignItTicketValidator()
+ {
+ RuleFor(x => x.Id).NotEmpty();
+ RuleFor(x => x.AssignedToUserId).NotEmpty();
+ }
+}
+
+public class AssignItTicketHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
+ : IRequestHandler
+{
+ 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)
// =========================================================================
diff --git a/src/Backend/SolutionErp.Domain/Office/ItTicket.cs b/src/Backend/SolutionErp.Domain/Office/ItTicket.cs
index 8d09a45..72f025a 100644
--- a/src/Backend/SolutionErp.Domain/Office/ItTicket.cs
+++ b/src/Backend/SolutionErp.Domain/Office/ItTicket.cs
@@ -23,4 +23,12 @@ public class ItTicket : AuditableEntity
public DateTime? ResolvedAt { get; set; }
public string? Resolution { get; set; } // ghi chú giải pháp (free text replace ItTicketComments thread defer Phase 11)
+
+ // P11-D (Mig 46 — Wave 2) — SLA timer. SlaDueAt = CreatedAt + window theo Priority
+ // (Urgent=4h / High=8h / Medium=24h / Low=72h). ItTicketSlaJob (BackgroundService
+ // 15 phút) set SlaWarnedSent khi còn ≤20% window + SlaBreached khi quá hạn (notify
+ // assignee). NO auto-transition status (khác SlaExpiryJob của HĐ — ticket chỉ cảnh báo).
+ public DateTime? SlaDueAt { get; set; }
+ public bool SlaWarnedSent { get; set; }
+ public bool SlaBreached { get; set; }
}
diff --git a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs
index 398a749..295dae9 100644
--- a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs
+++ b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs
@@ -45,6 +45,8 @@ public static class DependencyInjection
// Phase 3 iteration 2 — SLA auto-approve background service
services.AddHostedService();
+ // Phase 11 P11-D — IT ticket SLA timer (warning ≤20% + breach, no auto-transition)
+ services.AddHostedService();
services.AddScoped();
services.AddScoped();
diff --git a/src/Backend/SolutionErp.Infrastructure/HostedServices/ItTicketSlaJob.cs b/src/Backend/SolutionErp.Infrastructure/HostedServices/ItTicketSlaJob.cs
new file mode 100644
index 0000000..f617f74
--- /dev/null
+++ b/src/Backend/SolutionErp.Infrastructure/HostedServices/ItTicketSlaJob.cs
@@ -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 _logger;
+ private static readonly TimeSpan Interval = TimeSpan.FromMinutes(15);
+
+ public ItTicketSlaJob(IServiceProvider sp, ILogger 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();
+ var dateTime = scope.ServiceProvider.GetRequiredService();
+ var notifications = scope.ServiceProvider.GetRequiredService();
+
+ 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);
+ }
+ }
+}
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
index 4c02abc..4facd6b 100644
--- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
@@ -88,6 +88,10 @@ public static class DbInitializer
await SeedAdminAsync(userManager, logger);
await SeedDepartmentsAsync(db, logger);
await SeedDemoUsersAsync(db, userManager, logger);
+ // Phase 11 P11-D (Mig 46 — Wave 2) — gán 2 sample user vào Phòng CNTT (IT staff)
+ // cho round-robin auto-assign ticket. PHẢI sau SeedDemoUsersAsync (reconcile dept
+ // trước → method này override về IT). Infrastructure data (NOT gated DemoSeed).
+ await SeedItDepartmentStaffAsync(db, userManager, logger);
// Plan B G-H1 (Mig 34 S33 2026-05-26) — seed EmployeeProfile 1-1 với
// mọi user @solutions.com.vn. Idempotent. NOT gated DemoSeed flag
// (infrastructure data, mirror Mig 32 SeedSampleContractWorkflowV2
@@ -2074,6 +2078,7 @@ public static class DbInitializer
("EQU", "Phòng Thiết bị", "Equipment — thuê/mua máy móc"),
("HRA", "Phòng Nhân sự - Hành chính", "HRA/ISO — đóng dấu HĐ sau BOD ký"),
("BOD", "Ban Giám đốc", "Board of Directors — ký duyệt HĐ"),
+ ("IT", "Phòng CNTT", "IT helpdesk — tiếp nhận + xử lý ticket CNTT (P11-D round-robin assign)"),
};
var existingCodes = await db.Departments.Select(d => d.Code).ToListAsync();
@@ -2091,6 +2096,52 @@ public static class DbInitializer
}
}
+ // P11-D (Wave 2) — gán 2 SAMPLE demo user vào Phòng CNTT (IT) làm IT staff
+ // để round-robin auto-assign ticket có người nhận ngày 1. CHỈ đụng 2 sample
+ // user (nv.cao + nv.truong — prefix nv.* = sample, KHÔNG phải 14 Solutions
+ // real user). Idempotent: chỉ set DepartmentId nếu user CHƯA thuộc IT.
+ //
+ // ⚠️ PHẢI chạy SAU SeedDemoUsersAsync: method đó reconcile DepartmentId 2 user
+ // này về PRO/CCM (hardcoded deptCode) mỗi lần boot. Chạy sau → override về IT.
+ // End-state sau mỗi full SeedAsync = deterministic (IT). Infrastructure data
+ // (NOT gated DemoSeed flag, gotcha #51).
+ private static async Task SeedItDepartmentStaffAsync(
+ ApplicationDbContext db, UserManager userManager, ILogger logger)
+ {
+ var itDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "IT" && !d.IsDeleted);
+ if (itDept is null)
+ {
+ logger.LogWarning("SeedItDepartmentStaffAsync: skip — phòng IT chưa seed (chạy SeedDepartmentsAsync trước).");
+ return;
+ }
+
+ // 2 sample user (Drafter-only) chuyển sang IT staff. KHÔNG đụng real user.
+ var sampleItStaffEmails = new[]
+ {
+ "nv.cao@solutions.com.vn", // Cao Văn Long
+ "nv.truong@solutions.com.vn", // Trương Minh Quân
+ };
+
+ int assigned = 0;
+ foreach (var email in sampleItStaffEmails)
+ {
+ var user = await userManager.FindByEmailAsync(email);
+ if (user is null)
+ {
+ logger.LogWarning("SeedItDepartmentStaffAsync: sample user {Email} not found — skip.", email);
+ continue;
+ }
+ if (user.DepartmentId == itDept.Id) continue; // đã thuộc IT → idempotent skip
+ user.DepartmentId = itDept.Id;
+ user.UpdatedAt = DateTime.UtcNow;
+ await userManager.UpdateAsync(user);
+ assigned++;
+ }
+
+ if (assigned > 0)
+ logger.LogInformation("SeedItDepartmentStaffAsync: gán {Count} sample user vào Phòng CNTT (IT staff).", assigned);
+ }
+
// Sample master data for UAT/demo — per-code idempotent (skip Code đã có,
// add Code mới). Cho phép admin add/sửa supplier/project mà restart không
// clobber, đồng thời cho phép expand seed list theo thời gian.
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260608054312_AddSlaFieldsToItTicket.Designer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260608054312_AddSlaFieldsToItTicket.Designer.cs
new file mode 100644
index 0000000..8c52336
--- /dev/null
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260608054312_AddSlaFieldsToItTicket.Designer.cs
@@ -0,0 +1,6515 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SolutionErp.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace SolutionErp.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260608054312_AddSlaFieldsToItTicket")]
+ partial class AddSlaFieldsToItTicket
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.6")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("RoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("UserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderKey")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("UserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("RoleId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("UserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Value")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("UserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ActivatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ApplicableType")
+ .HasColumnType("int");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsUserSelectable")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Version")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicableType", "IsActive");
+
+ b.HasIndex("Code", "Version")
+ .IsUnique();
+
+ b.ToTable("ApprovalWorkflows", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("AllowApproverEditBudget")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowApproverEditDetails")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowApproverSkipToFinal")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowReturnOneLevel")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowReturnOneStep")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowReturnToAssignee")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("AllowReturnToDrafter")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(true);
+
+ b.Property("ApprovalWorkflowStepId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApproverUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Name")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApproverUserId");
+
+ b.HasIndex("ApprovalWorkflowStepId", "Order");
+
+ b.ToTable("ApprovalWorkflowLevels", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovalWorkflowId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DepartmentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DepartmentId");
+
+ b.HasIndex("ApprovalWorkflowId", "Order");
+
+ b.ToTable("ApprovalWorkflowSteps", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DepartmentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("DrafterUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("MaNganSach")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("NamNganSach")
+ .HasColumnType("int");
+
+ b.Property("Phase")
+ .HasColumnType("int");
+
+ b.Property("ProjectId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("RejectedFromPhase")
+ .HasColumnType("int");
+
+ b.Property("SlaDeadline")
+ .HasColumnType("datetime2");
+
+ b.Property("SlaWarningSent")
+ .HasColumnType("bit");
+
+ b.Property("TenNganSach")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("TongNganSach")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MaNganSach")
+ .IsUnique()
+ .HasFilter("[MaNganSach] IS NOT NULL");
+
+ b.HasIndex("NamNganSach");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("SlaDeadline");
+
+ b.HasIndex("Phase", "IsDeleted");
+
+ b.ToTable("Budgets", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ApproverUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Comment")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Decision")
+ .HasColumnType("int");
+
+ b.Property("FromPhase")
+ .HasColumnType("int");
+
+ b.Property("ToPhase")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BudgetId", "ApprovedAt");
+
+ b.ToTable("BudgetApprovals", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetChangelog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Action")
+ .HasColumnType("int");
+
+ b.Property("BudgetId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContextNote")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityType")
+ .HasColumnType("int");
+
+ b.Property("FieldChangesJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhaseAtChange")
+ .HasColumnType("int");
+
+ b.Property("Summary")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserName")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BudgetId", "CreatedAt");
+
+ b.HasIndex("BudgetId", "EntityType");
+
+ b.ToTable("BudgetChangelogs", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDepartmentApproval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ApproverRoleSnapshot")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ApproverUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Comment")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DepartmentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsBypassed")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("PhaseAtApproval")
+ .HasColumnType("int");
+
+ b.Property("Stage")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApproverUserId");
+
+ b.HasIndex("BudgetId");
+
+ b.HasIndex("DepartmentId");
+
+ b.HasIndex("BudgetId", "PhaseAtApproval", "DepartmentId", "Stage")
+ .IsUnique()
+ .HasDatabaseName("UX_BudgetDeptApprovals_Budget_Phase_Dept_Stage");
+
+ b.ToTable("BudgetDepartmentApprovals", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDetail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DonGia")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("DonViTinh")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("GhiChu")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("GroupCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("GroupName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ItemCode")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("KhoiLuong")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("NoiDung")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("ThanhTien")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BudgetId", "Order");
+
+ b.ToTable("BudgetDetails", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovalWorkflowId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetManualAmount")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("BudgetManualName")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("BypassProcurementAndCCM")
+ .HasColumnType("bit");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CurrentApprovalLevelOrder")
+ .HasColumnType("int");
+
+ b.Property("CurrentWorkflowStepIndex")
+ .HasColumnType("int");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DepartmentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DraftData")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DrafterUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("GiaTri")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("MaHopDong")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("NoiDung")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("Phase")
+ .HasColumnType("int");
+
+ b.Property("ProjectId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("RejectedAtStepIndex")
+ .HasColumnType("int");
+
+ b.Property("RejectedFromPhase")
+ .HasColumnType("int");
+
+ b.Property("SlaDeadline")
+ .HasColumnType("datetime2");
+
+ b.Property("SlaWarningSent")
+ .HasColumnType("bit");
+
+ b.Property("SupplierId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("TemplateId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("TenHopDong")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("WorkflowDefinitionId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApprovalWorkflowId");
+
+ b.HasIndex("BudgetId");
+
+ b.HasIndex("MaHopDong")
+ .IsUnique()
+ .HasFilter("[MaHopDong] IS NOT NULL");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("SlaDeadline");
+
+ b.HasIndex("SupplierId");
+
+ b.HasIndex("Phase", "IsDeleted");
+
+ b.ToTable("Contracts", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ApproverUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Comment")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Decision")
+ .HasColumnType("int");
+
+ b.Property("FromPhase")
+ .HasColumnType("int");
+
+ b.Property("ToPhase")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "ApprovedAt");
+
+ b.ToTable("ContractApprovals", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("FileSize")
+ .HasColumnType("bigint");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Purpose")
+ .HasColumnType("int");
+
+ b.Property("StoragePath")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId");
+
+ b.ToTable("ContractAttachments", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractChangelog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Action")
+ .HasColumnType("int");
+
+ b.Property("ContextNote")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityType")
+ .HasColumnType("int");
+
+ b.Property("FieldChangesJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhaseAtChange")
+ .HasColumnType("int");
+
+ b.Property("Summary")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserName")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "CreatedAt");
+
+ b.HasIndex("ContractId", "EntityType");
+
+ b.ToTable("ContractChangelogs", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractCodeSequence", b =>
+ {
+ b.Property("Prefix")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("LastSeq")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Prefix");
+
+ b.ToTable("ContractCodeSequences", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Phase")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "CreatedAt");
+
+ b.ToTable("ContractComments", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractDepartmentApproval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ApproverRoleSnapshot")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ApproverUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Comment")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property