# SLA Expiry Auto-Approve Flow > **Status:** 📝 Planned (Phase 3) > **Business rule nguồn:** [`../workflow-contract.md §4`](../workflow-contract.md) — *"Mỗi bộ phận chỉ có 01 ngày để xử lý. Nếu kéo dài hơn 01 ngày mà vẫn chưa xử lý xong, thì xem như đã thông qua."* ## 1. Mục đích Tránh bottleneck khi 1 role giữ HĐ quá SLA. Hệ thống auto-approve để HĐ tiếp tục chạy. Mọi auto-approve được log rõ ràng (`Decision=AutoApprove`) để audit. ## 2. Data model liên quan ```csharp public class Contract : AuditableEntity { public ContractPhase Phase { get; set; } public DateTime? SlaDeadline { get; set; } // khi nào phase hiện tại hết hạn // ... } ``` `SlaDeadline = UtcNow + PhaseSla` được set mỗi khi transition. ### SLA mỗi phase | Phase | SLA | Từ workflow spec | |---|---|---| | DangSoanThao | 7 ngày | Drafter có 7d soạn thảo | | DangGopY | 7 ngày | Các phòng góp ý | | DangDamPhan | 7 ngày | Đàm phán | | DangInKy | 1 ngày | In + ký nháy | | DangKiemTraCCM | 3 ngày | CCM review | | DangTrinhKy | 1 ngày | BOD ký | | DangDongDau | (không SLA) | Phụ thuộc HRA | | DaPhatHanh | (final) | — | ## 3. Hosted service `SlaExpiryJob` ```mermaid flowchart TD Start([BackgroundService
ExecuteAsync]) --> Loop{Loop} Loop --> Wait[Wait 15 minutes
Task.Delay with CancellationToken] Wait --> Query[Query Contracts
WHERE SlaDeadline IS NOT NULL
AND SlaDeadline < UtcNow
AND Phase NOT IN finished_phases] Query -->|count > 0| Process[Process each contract] Query -->|count = 0| Loop Process --> Decide{Có role
next để
auto approve?} Decide -->|yes| AutoApprove[Transition → nextPhase
với system actor
Decision=AutoApprove] Decide -->|no
(HĐ đang ở final phase)| Skip[Skip] AutoApprove --> Log[Log audit + send notify] Log --> Loop Skip --> Loop ``` ## 4. Implementation skeleton ```csharp // Infrastructure/HostedServices/SlaExpiryJob.cs public class SlaExpiryJob : BackgroundService { private readonly IServiceProvider _sp; private readonly ILogger _logger; private static readonly TimeSpan Interval = TimeSpan.FromMinutes(15); public SlaExpiryJob(IServiceProvider sp, ILogger logger) { _sp = sp; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { await using var scope = _sp.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var workflow = scope.ServiceProvider.GetRequiredService(); var now = DateTime.UtcNow; var expired = await db.Contracts .Where(c => c.SlaDeadline != null && c.SlaDeadline < now) .Where(c => c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi) .ToListAsync(stoppingToken); _logger.LogInformation("SlaExpiryJob: {Count} contracts expired", expired.Count); foreach (var contract in expired) { try { await workflow.AutoApproveExpiredAsync(contract, stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "SlaExpiryJob: failed to auto-approve {ContractId}", contract.Id); } } } catch (Exception ex) { _logger.LogError(ex, "SlaExpiryJob: iteration failed"); } await Task.Delay(Interval, stoppingToken); } } } ``` ## 5. Auto-approve logic ```csharp public async Task AutoApproveExpiredAsync(Contract contract, CancellationToken ct) { var nextPhase = DetermineNextPhase(contract); if (nextPhase is null) return; // no next phase → stay (rare) // Record approval với system actor var approval = new ContractApproval { ContractId = contract.Id, Phase = contract.Phase, ApproverUserId = null, // system (hoặc Guid Empty) Decision = ApprovalDecision.AutoApprove, Comment = $"Tự động duyệt do quá SLA {contract.Phase} (deadline {contract.SlaDeadline:yyyy-MM-dd HH:mm})", ApprovedAt = DateTime.UtcNow, }; _db.ContractApprovals.Add(approval); // Update phase + reset deadline cho phase mới contract.Phase = nextPhase.Value; contract.SlaDeadline = DateTime.UtcNow.Add(GetSlaForPhase(nextPhase.Value)); // Gen mã HĐ nếu transition tới DangDongDau if (nextPhase == ContractPhase.DangDongDau && string.IsNullOrEmpty(contract.MaHopDong)) { contract.MaHopDong = await _codeGen.GenerateAsync(contract, ct); } await _db.SaveChangesAsync(ct); // Notify drafter + role next await _notifications.NotifyAutoApproveAsync(contract, approval); } private ContractPhase? DetermineNextPhase(Contract contract) => contract.Phase switch { ContractPhase.DangSoanThao => ContractPhase.DangGopY, ContractPhase.DangGopY => ContractPhase.DangDamPhan, ContractPhase.DangDamPhan => ContractPhase.DangInKy, ContractPhase.DangInKy => ContractPhase.DangKiemTraCCM, ContractPhase.DangKiemTraCCM => ContractPhase.DangTrinhKy, ContractPhase.DangTrinhKy => ContractPhase.DangDongDau, ContractPhase.DangDongDau => ContractPhase.DaPhatHanh, _ => null, }; ``` ## 6. Warning notification (còn 20% SLA) Thêm 1 job warning tách biệt (hoặc merge vào cùng `SlaExpiryJob`): ```csharp // Query contracts với SlaDeadline sắp hết (80% đã trôi qua) var warning = await db.Contracts .Where(c => c.SlaDeadline != null) .Where(c => c.SlaDeadline > now && c.SlaDeadline - now < FractionRemaining(c.Phase, 0.2)) .Where(c => !c.SlaWarningSent) // boolean flag để không gửi 2 lần .ToListAsync(); foreach (var c in warning) { await _notifications.NotifySlaWarningAsync(c); c.SlaWarningSent = true; } await db.SaveChangesAsync(); ``` Reset `SlaWarningSent = false` khi chuyển phase. ## 7. Registration (Program.cs) ```csharp builder.Services.AddHostedService(); ``` Hosted service chạy cùng vòng đời app. Trên IIS với multiple worker processes → mỗi worker chạy 1 instance → có thể duplicate auto-approve 1 HĐ. **Fix:** single worker process prod (IIS app pool `maxProcesses = 1`) hoặc distributed lock qua Redis (Phase 4). ## 8. Monitoring | Metric | Alert threshold | |---|---| | Contracts auto-approved / day | > 20% tổng transition → review quy trình | | Job iteration duration | > 5s → DB query slow, cần index `SlaDeadline` | | Job errors in last hour | > 3 → page oncall | | HĐ có SLA quá hạn > 24h (job không process) | > 0 → system down / deadlock | Log structured với Serilog: ``` [SlaExpiryJob] Auto-approved contract {ContractId} from {OldPhase} → {NewPhase} (expired {Hours}h ago) ``` ## 9. Edge cases | Case | Xử lý | |---|---| | Job đang chạy → app crash giữa chừng | Không save changes → next iteration xử lý lại | | Contract đã được user duyệt ngay lúc job chạy | Optimistic concurrency (RowVersion) — job fail → skip, next iteration sẽ thấy phase đã đổi | | Contract đã transition tới final phase (`DaPhatHanh`) nhưng `SlaDeadline` không được clear | Query filter loại luôn → không xử lý | | Timezone server khác UTC | Luôn compare bằng UTC (`DateTime.UtcNow`, `SlaDeadline` lưu UTC) | | Admin pause auto-approve (ví dụ kỳ lễ không ai làm việc) | Thêm config `Sla:Enabled = false` → job skip loop | | SLA config thay đổi (ví dụ từ 7 → 10 ngày) | Chỉ áp dụng cho transition mới, HĐ đang dở vẫn dùng deadline cũ | ## 10. Testing ### Unit test ```csharp [Fact] public async Task AutoApprove_transitions_to_next_phase() { var contract = new Contract { Phase = ContractPhase.DangGopY, SlaDeadline = DateTime.UtcNow.AddHours(-1) }; await _service.AutoApproveExpiredAsync(contract, default); Assert.Equal(ContractPhase.DangDamPhan, contract.Phase); } ``` ### Integration test ```csharp // Seed HĐ với SlaDeadline = 5 phút trước // Start SlaExpiryJob với interval 1s // Wait 2s // Query DB → phase phải đã advance + có ContractApproval với Decision=AutoApprove ``` ## 11. Liên quan - [`contract-approval-flow.md`](contract-approval-flow.md) — manual transition (ngược lại với auto) - [`../workflow-contract.md`](../workflow-contract.md) — SLA rule gốc