docs/database/database-guide.md: - Conventions (naming, data types, audit fields, soft delete) - Schema hien tai (Identity tables sau migration Init) + seed 12 role + admin - Schema planned: Phase 1 dot 2 (Supplier/Project/Department + Permission Matrix) - Schema planned: Phase 3 (Contract + Approval + Comment + Attachment + Template + Clause + CodeSequence) - Mermaid ERD cho tung phase - Migration workflow (create/apply/revert) - Index strategy + unique indexes - Backup/restore SQL - Common pitfalls + SQL cheatsheet docs/flows/ — 6 flow documentation: - README.md: index - auth-flow.md: login/refresh/me/logout (IMPLEMENTED, sequence + edge cases + security checklist) - permission-flow.md: Phase 1 dot 2 - Role x MenuKey x CRUD resolution + FE guard + BE policy - contract-creation-flow.md: Phase 2 - Drafter flow chon template -> fill -> preview -> save draft - contract-approval-flow.md: Phase 3 - state machine 9 phase chi tiet + reject flow + timeline UI - form-render-flow.md: Phase 2 - OpenXml + ClosedXML + LibreOffice PDF convert - sla-expiry-flow.md: Phase 3 - BackgroundService auto-approve qua SLA + warning notify Update references: - CLAUDE.md (root): them 2 row Tai lieu quan trong - docs/CLAUDE.md: update project layout voi flows/ + database/ - docs/STATUS.md: log docs addition - docs/changelog/migration-todos.md: tick Phase 0 docs items Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.7 KiB
SLA Expiry Auto-Approve Flow
Status: 📝 Planned (Phase 3) Business rule nguồn:
../workflow-contract.md §4— "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
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
flowchart TD
Start([BackgroundService<br/>ExecuteAsync]) --> Loop{Loop}
Loop --> Wait[Wait 15 minutes<br/>Task.Delay with CancellationToken]
Wait --> Query[Query Contracts<br/>WHERE SlaDeadline IS NOT NULL<br/>AND SlaDeadline < UtcNow<br/>AND Phase NOT IN finished_phases]
Query -->|count > 0| Process[Process each contract]
Query -->|count = 0| Loop
Process --> Decide{Có role<br/>next để<br/>auto approve?}
Decide -->|yes| AutoApprove[Transition → nextPhase<br/>với system actor<br/>Decision=AutoApprove]
Decide -->|no<br/>(HĐ đang ở final phase)| Skip[Skip]
AutoApprove --> Log[Log audit + send notify]
Log --> Loop
Skip --> Loop
4. Implementation skeleton
// Infrastructure/HostedServices/SlaExpiryJob.cs
public class SlaExpiryJob : BackgroundService
{
private readonly IServiceProvider _sp;
private readonly ILogger<SlaExpiryJob> _logger;
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(15);
public SlaExpiryJob(IServiceProvider sp, ILogger<SlaExpiryJob> 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<IApplicationDbContext>();
var workflow = scope.ServiceProvider.GetRequiredService<IContractWorkflowService>();
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
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):
// 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)
builder.Services.AddHostedService<SlaExpiryJob>();
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
[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
// 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— manual transition (ngược lại với auto)../workflow-contract.md— SLA rule gốc