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>
12 KiB
12 KiB
Contract Approval Flow — State Machine 9 Phase
Status: 📝 Planned (Phase 3) Spec gốc:
../workflow-contract.md(state machine + role matrix) Entry: User click "Chuyển phase tiếp" ở/contracts/{id}detail page
1. Tổng quan state machine
stateDiagram-v2
[*] --> DangChon: Tạo (mặc định)
DangChon --> DangSoanThao: PB chọn NCC
DangSoanThao --> DangGopY: Drafter submit
DangGopY --> DangDamPhan: Nhận xong comment
DangDamPhan --> DangInKy: Thỏa thuận xong
DangInKy --> DangKiemTraCCM: Đã ký nháy, chuyển CCM
DangKiemTraCCM --> DangTrinhKy: CCM duyệt
DangTrinhKy --> DangDongDau: BOD ký (GEN mã HĐ!)
DangDongDau --> DaPhatHanh: HRA đóng dấu
DaPhatHanh --> [*]
DangSoanThao --> TuChoi: Drafter hủy
DangGopY --> DangSoanThao: Revise
DangKiemTraCCM --> DangSoanThao: CCM reject
DangTrinhKy --> DangSoanThao: BOD reject
TuChoi --> [*]
SLA mỗi phase: xem ../workflow-contract.md §4. Tổng ~19 ngày.
2. Transition API — POST /api/contracts/{id}/transitions
Request
{
"targetPhase": "DangGopY",
"decision": "Approve",
"comment": "Đã hoàn thiện draft, chuyển góp ý."
}
Response
{
"contractId": "c5d6-...",
"oldPhase": "DangSoanThao",
"newPhase": "DangGopY",
"slaDeadline": "2026-04-28T10:00:00Z",
"actor": { "id": "u1-...", "fullName": "Nguyen Van A" },
"notifiedUsers": ["u2-...", "u3-...", "u4-..."]
}
3. Handler flow
sequenceDiagram
actor U as User<br/>(role cụ thể)
participant FE as fe-user
participant API as ContractsController<br/>.Transition
participant M as TransitionContractCommandHandler
participant WF as IContractWorkflowService
participant CG as IContractCodeGenerator
participant NS as INotificationService
participant DB
U->>FE: Click "Chuyển phase" + nhập comment
FE->>API: POST /api/contracts/{id}/transitions<br/>{targetPhase, decision, comment}
API->>M: Send(TransitionContractCommand)
M->>DB: SELECT Contract WHERE Id = ?
alt Not found
M-->>API: throw NotFoundException
end
M->>WF: ValidateTransition(contract, targetPhase, userRole)
WF->>WF: Check state rule:<br/>1. currentPhase allows transition to targetPhase?<br/>2. user role đủ quyền ở phase này?<br/>3. bypass rule với Chủ đầu tư?
alt Invalid transition
WF-->>M: throw ForbiddenException("không được phép")
end
alt targetPhase == DangDongDau (BOD ký)
M->>CG: GenerateAsync(project, type, supplier)
CG->>DB: BEGIN TRAN SERIALIZABLE
CG->>DB: SELECT LastSeq WITH UPDLOCK
CG->>DB: UPDATE LastSeq + 1
CG->>DB: COMMIT
CG-->>M: "FLOCK 01/HĐGK/SOL&PVL/03"
M->>DB: UPDATE Contract SET MaHopDong = ?
end
M->>DB: INSERT ContractApproval<br/>(ContractId, Phase=currentPhase,<br/>ApproverUserId, Decision, Comment)
M->>DB: UPDATE Contract SET Phase = targetPhase,<br/>SlaDeadline = UtcNow + PhaseSla
M->>NS: NotifyPhaseChangeAsync(contract, oldPhase, newPhase)
NS->>NS: Query users trong role eligible<br/>for newPhase
NS->>NS: Send email (MailKit) + in-app notify
Note over NS: (Phase 3 Iteration 2) SignalR<br/>push notification real-time
M-->>API: TransitionResultDto
API-->>FE: 200
FE->>FE: Invalidate TanStack query<br/>['contracts', id] + toast success
FE->>FE: Refresh timeline UI
4. Guard rules (IContractWorkflowService)
public class ContractWorkflowService : IContractWorkflowService
{
// Adjacency + role matrix — xem workflow-contract.md
private static readonly Dictionary<(ContractPhase From, ContractPhase To), string[]> _transitions = new()
{
[(ContractPhase.DangChon, ContractPhase.DangSoanThao)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter],
[(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.Admin],
[(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter],
[(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl],
[(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM)] = [AppRoles.Drafter],
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy)] = [AppRoles.CostControl],
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao)] = [AppRoles.CostControl],
[(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin],
};
// Bypass CĐT: HopDongChuDauTu → có thể nhảy từ DangInKy thẳng tới DangTrinhKy (bỏ CCM)
public void ValidateTransition(Contract contract, ContractPhase target, IReadOnlyList<string> userRoles)
{
// 1. Check adjacency
var key = (contract.Phase, target);
if (!_transitions.TryGetValue(key, out var allowedRoles))
throw new ForbiddenException($"Không thể chuyển {contract.Phase} → {target}");
// 2. Check role
if (!userRoles.Any(r => allowedRoles.Contains(r)) && !userRoles.Contains(AppRoles.Admin))
throw new ForbiddenException("Role không đủ quyền duyệt phase này");
// 3. Bypass rule
if (contract.BypassProcurementAndCCM && contract.Phase == ContractPhase.DangInKy && target == ContractPhase.DangTrinhKy)
return; // OK skip CCM
// 4. Business rule: không cho skip phase
// (adjacency table đã enforce — chỉ transition nào khai báo mới pass)
}
}
5. Reject flow (ví dụ CCM reject)
sequenceDiagram
actor CCM
participant FE
participant API
participant M as TransitionHandler
participant NS
participant DB
CCM->>FE: Click "Yêu cầu sửa" + nhập comment
FE->>API: POST /contracts/{id}/transitions<br/>{targetPhase: "DangSoanThao", decision: "Reject", comment: "Điều khoản 5 cần rõ hơn"}
API->>M: Send
M->>DB: INSERT ContractApproval<br/>(Phase=DangKiemTraCCM, Decision=Reject, Comment)
M->>DB: UPDATE Contract.Phase = DangSoanThao<br/>SlaDeadline = UtcNow + 7d
Note over M: KHÔNG xóa lịch sử approval các phase cũ<br/>— giữ history
M->>NS: NotifyRejection(contract, drafter)
NS->>NS: Email drafter + in-app badge
M-->>API: 200
Lưu ý: Drafter nhận thông báo + comment thread tự động có entry mới. Họ fix, rồi submit lại → re-trigger DangGopY (quay lại loop comment → duyệt).
6. Comment thread
Endpoint riêng (không phải transition):
POST /api/contracts/{id}/comments
Body: { content: "NCC này từng trễ giao hàng HĐ trước, cân nhắc thêm điều khoản phạt" }
Ghi nhận:
ContractComments.Phase = contract.Phase hiện tại- Hiển thị cùng timeline với approval history
7. Timeline UI (fe-user /contracts/{id})
┌────────────────────────────────────────────────────────┐
│ HĐ: FLOCK 01/HĐGK/SOL&PVL/03 │
│ NCC: Công ty PVL · Dự án: FLOCK 01 │
│ Giá trị: 150,000,000 VND │
│ Phase hiện tại: 🟡 Đang kiểm tra CCM │
│ SLA: còn 2 ngày 4 giờ │
├────────────────────────────────────────────────────────┤
│ Timeline │
│ │
│ ● DangSoanThao 2026-04-15 10:00 Nguyen Van A │
│ │ Drafter tạo draft │
│ │ │
│ ● DangGopY 2026-04-16 08:30 Tran Thi B (PM) │
│ │ "Scope work cần chi tiết hơn mục 3" │
│ ● Le Van C (CCM) │
│ │ "OK giá" │
│ │ │
│ ● DangDamPhan 2026-04-17 14:20 Nguyen Van A │
│ ● DangInKy 2026-04-18 09:15 NCC đã ký │
│ │
│ 🟡 DangKiemTraCCM (đang ở phase này) │
│ Chờ CCM kiểm tra │
│ │
│ ⚪ DangTrinhKy │
│ ⚪ DangDongDau │
│ ⚪ DaPhatHanh │
├────────────────────────────────────────────────────────┤
│ Action (tùy role): │
│ [✅ Duyệt → TrinhKy] [❌ Yêu cầu sửa] │
│ │
│ Comment: │
│ [Textarea] │
│ [Gửi comment] │
└────────────────────────────────────────────────────────┘
8. Notifications
| Event | Recipient | Channel |
|---|---|---|
DangSoanThao → DangGopY |
Tất cả role góp ý (PD, PM, PRO, CCM, FIN, ACT) | Email + in-app |
Chuyển DangKiemTraCCM |
Mọi user role CostControl |
Email + in-app |
Chuyển DangTrinhKy |
Mọi user role Director + AuthorizedSigner |
Email (high priority) + in-app |
| SLA còn 20% thời gian | Role đang giữ phase | In-app warning |
| SLA hết → auto-approve | Drafter + role giữ phase | Email + in-app |
Reject → quay về DangSoanThao |
Drafter (người tạo) | Email + in-app |
| Mã HĐ được gen khi BOD ký | Drafter | In-app |
9. Audit trail
Mọi transition tạo 1 row trong ContractApprovals. Ngoài ra, nếu cần log granular hơn, Phase 4 thêm AuditLogs table ghi mọi command (không chỉ workflow):
public class AuditBehavior<TReq, TRes> : IPipelineBehavior<TReq, TRes>
{
public async Task<TRes> Handle(TReq request, RequestHandlerDelegate<TRes> next, CancellationToken ct)
{
var response = await next();
// Log: command name, user, payload, timestamp
_db.AuditLogs.Add(new AuditLog { ... });
await _db.SaveChangesAsync(ct);
return response;
}
}
10. Edge cases
| Case | Xử lý |
|---|---|
| User A và User B cùng role CCM click "Duyệt" đồng thời | Optimistic concurrency với RowVersion — người sau nhận 409 Conflict |
Drafter cố submit từ DangSoanThao → DangKiemTraCCM (skip phase) |
Adjacency table reject → 403 |
BOD reject ở phase DangTrinhKy sau khi mã HĐ đã gen |
Giữ mã HĐ (không revert seq) + update Phase=DangSoanThao. Khi BOD duyệt lại → vẫn dùng mã cũ (không gen lại) |
User role không map đúng phase (vd Finance cố duyệt DangKiemTraCCM) |
Guard reject 403 |
HĐ với Chủ đầu tư (BypassProcurementAndCCM=true) |
Từ DangInKy → cho phép nhảy DangTrinhKy bỏ qua CCM |
Xóa HĐ ở phase > DangInKy |
Block — soft delete cũng không cho (business rule) |
11. Liên quan
../workflow-contract.md— spec gốcsla-expiry-flow.md— auto-approve jobcontract-creation-flow.md— tạo HĐ trước khi vào flow nàyform-render-flow.md— render file khi chuyển phase