Files
solution-erp/docs/flows/sla-expiry-flow.md
pqhuy1987 49a5f57a50 [CLAUDE] Docs: database-guide + 6 flow diagrams
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>
2026-04-21 11:15:28 +07:00

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