[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>
This commit is contained in:
pqhuy1987
2026-04-21 11:15:28 +07:00
parent 702411fcc8
commit 49a5f57a50
12 changed files with 1982 additions and 2 deletions

30
docs/flows/README.md Normal file
View File

@ -0,0 +1,30 @@
# Flows — SOLUTION_ERP
> Document các luồng (process / sequence) chính của hệ thống. Mỗi flow có mermaid sequence diagram + API calls + side effects + edge case.
## Index
| Flow | Phase | Trạng thái | Doc |
|---|---|---|---|
| **Authentication** — login, refresh, logout, /me | 1 | ✅ Implemented | [`auth-flow.md`](auth-flow.md) |
| **Permission resolution** — resolve menu + CRUD cho user | 1 đợt 2 | 📝 Planned | [`permission-flow.md`](permission-flow.md) |
| **Contract creation** — tạo HĐ draft + fill form template | 2 | 📝 Planned | [`contract-creation-flow.md`](contract-creation-flow.md) |
| **Contract approval** — state machine 9 phase | 3 | 📝 Planned | [`contract-approval-flow.md`](contract-approval-flow.md) |
| **Form render** — template engine xuất docx/xlsx | 2 | 📝 Planned | [`form-render-flow.md`](form-render-flow.md) |
| **SLA expiry auto-approve** — hosted service | 3 | 📝 Planned | [`sla-expiry-flow.md`](sla-expiry-flow.md) |
## Quy ước đọc
- **Actor:** vai trò khi thực hiện (user role hoặc system)
- **Entry point:** ai/gì trigger
- **API calls:** controller + endpoint (hoặc internal service)
- **Side effects:** DB writes, file writes, notifications
- **Edge cases:** các path lỗi / alternate
Tất cả mermaid sequence/state/flowchart có thể render ở VS Code (extension Markdown Preview Mermaid), GitHub, Gitea, Gitea MD preview.
## Liên quan
- [`../workflow-contract.md`](../workflow-contract.md) — spec 9 phase + role matrix (domain-centric)
- [`../forms-spec.md`](../forms-spec.md) — 8 form + mã HĐ format
- [`../database/database-guide.md`](../database/database-guide.md) — schema chi tiết

212
docs/flows/auth-flow.md Normal file
View File

@ -0,0 +1,212 @@
# Auth Flow — Login / Refresh / Logout / Me
> **Status:** ✅ Implemented (Phase 1 foundation, commit `702411f`)
> **Actors:** Anyone (login) | Authenticated user (refresh, me, logout)
## 1. Tổng quan
- **Schema:** JWT Bearer (HS256) + refresh token (random 64-byte base64)
- **Access token:** 1 giờ (config `Jwt:AccessTokenExpiryMinutes`)
- **Refresh token:** 7 ngày (config `Jwt:RefreshTokenExpiryDays`)
- **Storage FE:** `localStorage` — key `solution-erp-admin-token` (fe-admin) / `solution-erp-user-token` (fe-user)
- **Truyền:** header `Authorization: Bearer <token>` (axios interceptor auto)
## 2. Login
```mermaid
sequenceDiagram
actor U as User
participant FE as Frontend<br/>(LoginPage)
participant API as SolutionErp.Api<br/>AuthController
participant M as MediatR<br/>LoginCommandHandler
participant UM as UserManager<User>
participant J as IJwtTokenService
participant DB as SQL Server
U->>FE: Nhập email + password → Submit
FE->>API: POST /api/auth/login<br/>{email, password}
API->>M: Send(LoginCommand)
M->>M: Validate (FluentValidation)<br/>email format + password min 6
alt Validation fail
M-->>API: throw ValidationException
API-->>FE: 400 ProblemDetails + errors{}
FE->>U: Toast error
end
M->>UM: FindByEmailAsync(email)
UM->>DB: SELECT FROM Users WHERE Email = ?
alt User not found OR IsActive = false
M-->>API: throw UnauthorizedException
API-->>FE: 401 "Email hoặc mật khẩu không đúng"
end
M->>UM: CheckPasswordAsync(user, password)
alt Password sai
M-->>API: throw UnauthorizedException
API-->>FE: 401
end
M->>UM: GetRolesAsync(user)
UM->>DB: SELECT Roles JOIN UserRoles
UM-->>M: roles[]
M->>J: GenerateTokensAsync(user, roles)
J->>J: Build claims (sub, email, jti, fullName, roles)
J->>J: Sign HS256 + random refresh token
J-->>M: (accessToken, refreshToken, refreshTokenExpiresAt)
M->>UM: UpdateAsync(user) — save refresh token + expiry
UM->>DB: UPDATE Users SET RefreshToken=?, RefreshTokenExpiresAt=?
M-->>API: AuthResponseDto
API-->>FE: 200 {accessToken, refreshToken, user: {id, email, fullName, roles}}
FE->>FE: localStorage.setItem('solution-erp-*-token', accessToken)<br/>+ lưu user JSON
FE->>FE: setUser(user) via AuthContext
FE->>U: Redirect /dashboard hoặc /inbox + toast success
```
**Code pointers:**
- FE: [`fe-admin/src/pages/LoginPage.tsx`](../../fe-admin/src/pages/LoginPage.tsx), [`fe-admin/src/contexts/AuthContext.tsx`](../../fe-admin/src/contexts/AuthContext.tsx)
- BE controller: [`AuthController.Login`](../../src/Backend/SolutionErp.Api/Controllers/AuthController.cs)
- Handler: [`LoginCommandHandler`](../../src/Backend/SolutionErp.Application/Auth/Commands/Login/LoginCommand.cs)
- Token gen: [`JwtTokenService`](../../src/Backend/SolutionErp.Infrastructure/Identity/JwtTokenService.cs)
## 3. Authenticated request (mọi API khác)
```mermaid
sequenceDiagram
participant FE as Frontend
participant AX as axios interceptor
participant API as API
participant JWT as JwtBearerMiddleware
participant CTRL as Controller
FE->>AX: api.get('/suppliers')
AX->>AX: Đọc localStorage token
AX->>API: GET /api/suppliers<br/>Authorization: Bearer <token>
API->>JWT: ValidateToken
alt Token invalid / expired
JWT-->>API: 401
API-->>AX: 401
AX->>AX: Clear localStorage + redirect /login
end
JWT->>API: HttpContext.User = ClaimsPrincipal
API->>CTRL: Invoke action (có [Authorize])
CTRL->>CTRL: ICurrentUser.UserId (từ claim "sub")
CTRL-->>FE: 200 data
```
**401 handling:** [`fe-admin/src/lib/api.ts`](../../fe-admin/src/lib/api.ts) interceptor catches 401 → clear localStorage → `window.location.href = '/login'`.
## 4. Refresh token
```mermaid
sequenceDiagram
participant FE as Frontend
participant API as AuthController.Refresh
participant M as RefreshTokenCommandHandler
participant UM as UserManager
participant J as IJwtTokenService
participant DB
Note over FE: Access token expired<br/>(server trả 401 từ /api/whatever)
FE->>API: POST /api/auth/refresh<br/>{refreshToken}
API->>M: Send(RefreshTokenCommand)
M->>M: Validate not empty
M->>UM: Users.First(RefreshToken == ?)
UM->>DB: SELECT
alt Not found OR RefreshTokenExpiresAt < UtcNow
M-->>API: throw UnauthorizedException
API-->>FE: 401 "Refresh token không hợp lệ hoặc đã hết hạn"
FE->>FE: Clear localStorage + redirect /login
end
M->>UM: GetRolesAsync(user)
M->>J: GenerateTokensAsync(user, roles)
J-->>M: new (access, refresh, expiry)
M->>UM: UpdateAsync — rotate refresh token
DB-->>UM: OK
M-->>API: AuthResponseDto
API-->>FE: 200 new tokens
FE->>FE: Update localStorage + retry original request
```
**⚠️ Chưa implement ở FE:** hiện tại 401 → logout luôn. Phase 1 đợt 2 sẽ thêm logic auto-refresh trong axios interceptor (retry failed request sau khi refresh thành công, queue các request song song).
## 5. /me (lấy user hiện tại)
```mermaid
sequenceDiagram
actor U
participant FE
participant API as AuthController.Me
participant M as GetMeQueryHandler
participant CU as ICurrentUser
participant UM as UserManager
U->>FE: App bootstrap (sau login)
FE->>API: GET /api/auth/me<br/>Bearer <token>
API->>M: Send(GetMeQuery)
M->>CU: UserId (from claim "sub")
alt Not authenticated
M-->>API: throw UnauthorizedException
end
M->>UM: FindByIdAsync(userId.ToString())
alt Not found
M-->>API: throw UnauthorizedException
end
M->>UM: GetRolesAsync(user)
M-->>API: UserInfoDto{id, email, fullName, roles}
API-->>FE: 200
```
**Use case:** FE dùng để refresh user info sau page reload (nếu localStorage cũ nhưng role có thể thay đổi). Hiện Phase 1 FE lấy user từ localStorage trực tiếp — Phase 1 đợt 2 sẽ gọi `/me` ở mount AuthContext để verify token + sync role.
## 6. Logout
```mermaid
sequenceDiagram
actor U
participant FE
participant API as AuthController.Logout
U->>FE: Click "Đăng xuất"
FE->>FE: logout() in AuthContext:<br/>localStorage.removeItem(token + user)<br/>setUser(null)
FE->>API: POST /api/auth/logout (optional)
API-->>FE: 204 No Content
FE->>FE: Redirect /login (via ProtectedRoute)
```
**Lưu ý:** Hiện endpoint logout chỉ trả 204. Phase 3 sẽ upgrade: invalidate refresh token trong DB + log audit event.
## 7. Edge cases
| Case | Hiện tại handle | Phase nào fix |
|---|---|---|
| Access token expired mid-request | FE 401 → logout | Phase 1 đợt 2: auto refresh |
| Refresh token expired | FE 401 → logout | OK — đúng hành vi |
| User bị disable (`IsActive=false`) sau khi đã login | Token vẫn valid tới khi expire | Phase 4: check `IsActive` trong middleware |
| Admin reset password | Refresh token cũ vẫn valid | Phase 4: invalidate refresh token khi reset password |
| Multi-device login | Ghi đè refresh token → device cũ bị logout khi cần refresh | OK cho Phase 1; Phase 4 optional: bảng `UserRefreshTokens` riêng |
| JWT key leak | Toàn bộ user có thể forge token | Phase 5: dùng user secrets prod + rotate annually |
| Clock skew giữa API + client | `ClockSkew = 1 minute` đã config | OK |
## 8. Security checklist (Phase 5 review)
- [ ] `Jwt:Secret` trong user-secrets / env var prod (không trong appsettings)
- [ ] HTTPS enforce (`RequireHttpsMetadata = true` production)
- [ ] Rate limit `/api/auth/login` (5 attempts/min/IP) — prevent brute force
- [ ] Account lockout sau N lần sai password (config `UserLockout` trong Identity)
- [ ] Password policy production (min 12 chars, unique, etc.)
- [ ] Audit log cho login success/fail
- [ ] Refresh token rotation detection (nếu dùng refresh cũ sau khi đã rotate → compromise alert)

View File

@ -0,0 +1,278 @@
# Contract Approval Flow — State Machine 9 Phase
> **Status:** 📝 Planned (Phase 3)
> **Spec gốc:** [`../workflow-contract.md`](../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
```mermaid
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`](../workflow-contract.md). Tổng ~19 ngày.
## 2. Transition API — `POST /api/contracts/{id}/transitions`
### Request
```json
{
"targetPhase": "DangGopY",
"decision": "Approve",
"comment": "Đã hoàn thiện draft, chuyển góp ý."
}
```
### Response
```json
{
"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
```mermaid
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)
```csharp
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)
```mermaid
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):
```csharp
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`](../workflow-contract.md) — spec gốc
- [`sla-expiry-flow.md`](sla-expiry-flow.md) — auto-approve job
- [`contract-creation-flow.md`](contract-creation-flow.md) — tạo HĐ trước khi vào flow này
- [`form-render-flow.md`](form-render-flow.md) — render file khi chuyển phase

View File

@ -0,0 +1,235 @@
# Contract Creation Flow
> **Status:** 📝 Planned (Phase 2)
> **Actors:** Drafter (QS/NV.PB)
> **Entry:** User mở `/contracts/new` ở fe-user
## 1. Tổng quan
Drafter chọn loại HĐ → chọn template form → điền field động → preview → lưu draft. Sau đó có thể tiếp tục soạn hoặc submit lên phase `DangGopY` (xem [`contract-approval-flow.md`](contract-approval-flow.md)).
## 2. Sequence
```mermaid
sequenceDiagram
actor D as Drafter
participant FE as fe-user<br/>/contracts/new
participant API as ContractsController
participant CMD as CreateContractCommandHandler
participant TMPL as FormsController<br/>.GetTemplate
participant REN as IFormRenderer
participant DB
participant FS as File Storage<br/>(wwwroot/uploads)
D->>FE: Click "Tạo HĐ mới"
D->>FE: Step 1 — Chọn loại HĐ<br/>(ContractType dropdown)
FE->>API: GET /api/forms/templates?type={contractType}
API->>DB: SELECT ContractTemplates WHERE Type = ? AND IsActive = 1
API-->>FE: Template list<br/>[{id, formCode, name, fieldSpec}]
D->>FE: Step 2 — Chọn template (vd FO-002.05 Giao khoán)
FE->>TMPL: GET /api/forms/templates/{id}
TMPL->>DB: SELECT template + FieldSpec JSON
TMPL-->>FE: fieldSpec (array of {name, type, required, ...})
FE->>FE: Dynamic form builder render form<br/>theo fieldSpec
D->>FE: Step 3 — Điền field (NCC, dự án,<br/>giá trị, hạng mục, nghiệm thu…)
D->>FE: Click "Preview"
FE->>API: POST /api/forms/render<br/>{templateId, data}
API->>REN: DocxRenderer.Render(template, data)
REN->>REN: Load .docx template<br/>replace placeholder {{field}}<br/>with values
REN->>FS: Write temp file uploads/preview/{guid}.docx
REN-->>API: (bytes + temp path)
API-->>FE: 200 file blob (inline hoặc Content-Disposition: inline)
FE->>FE: Display PDF preview<br/>(convert server-side via LibreOffice)
D->>FE: Step 4 — Click "Lưu draft"
FE->>API: POST /api/contracts<br/>{type, supplierId, projectId,<br/>templateId, draftData}
API->>CMD: Send(CreateContractCommand)
CMD->>CMD: Validate (FluentValidation)<br/>supplier exists, project active...
CMD->>DB: INSERT Contracts (Phase=DangSoanThao,<br/>DraftData=JSON.serialize)
CMD-->>API: ContractId
API-->>FE: 201 Created + Location header
FE->>D: Redirect /contracts/{id}<br/>toast "Đã lưu draft"
```
## 3. API chi tiết
### `GET /api/forms/templates?type=HopDongGiaoKhoan`
Response:
```json
[
{
"id": "a0b1-...",
"formCode": "FO-002.05",
"name": "Hợp đồng Giao khoán",
"contractType": 2
}
]
```
### `GET /api/forms/templates/{id}`
Response:
```json
{
"id": "a0b1-...",
"formCode": "FO-002.05",
"name": "Hợp đồng Giao khoán",
"fieldSpec": {
"sections": [
{
"title": "Thông tin hai bên",
"fields": [
{ "name": "benA_tenCongTy", "label": "Tên Bên A", "type": "string", "required": true },
{ "name": "benA_diaChi", "label": "Địa chỉ Bên A", "type": "text" },
{ "name": "benA_maSoThue", "label": "MST Bên A", "type": "string", "pattern": "^[0-9]{10,13}$" },
{ "name": "benB_supplierId", "label": "NCC (Bên B)", "type": "reference", "table": "Suppliers" }
]
},
{
"title": "Giá trị hợp đồng",
"fields": [
{ "name": "giaTri", "label": "Giá trị (VND)", "type": "decimal", "required": true, "min": 0 },
{ "name": "ngayKy", "label": "Ngày ký dự kiến", "type": "date" }
]
}
]
}
}
```
### `POST /api/forms/render`
Request:
```json
{
"templateId": "a0b1-...",
"data": {
"benA_tenCongTy": "Công ty TNHH Xây dựng Solutions",
"benA_diaChi": "123 Đường ABC, TP.HCM",
"benB_supplierId": "d9e2-...",
"giaTri": 150000000,
"ngayKy": "2026-05-10"
}
}
```
Response: binary stream `.docx` (or `.pdf` nếu convert).
### `POST /api/contracts`
Request:
```json
{
"type": 2,
"supplierId": "d9e2-...",
"projectId": "p7a1-...",
"departmentId": "dp3b-...",
"templateId": "a0b1-...",
"giaTri": 150000000,
"draftData": { /* full field values */ }
}
```
Response:
```json
{
"id": "c5d6-...",
"phase": "DangSoanThao",
"createdAt": "2026-04-21T10:00:00Z"
}
```
## 4. Validation rules (FluentValidation)
```csharp
public class CreateContractCommandValidator : AbstractValidator<CreateContractCommand>
{
public CreateContractCommandValidator(IApplicationDbContext db)
{
RuleFor(x => x.Type).IsInEnum();
RuleFor(x => x.SupplierId).NotEmpty().MustAsync(SupplierExists);
RuleFor(x => x.ProjectId).NotEmpty().MustAsync(ProjectActive);
RuleFor(x => x.TemplateId).NotEmpty().MustAsync(TemplateActive);
RuleFor(x => x.GiaTri).GreaterThan(0);
RuleFor(x => x.DraftData).NotNull();
// Template field validation — đọc FieldSpec + validate draftData match
RuleFor(x => x).CustomAsync(async (cmd, ctx, ct) =>
{
var tpl = await db.ContractTemplates.FindAsync(cmd.TemplateId, ct);
var spec = JsonSerializer.Deserialize<FormFieldSpec>(tpl.FieldSpec);
// check required fields present in draftData
});
}
}
```
## 5. Side effects
| Action | Side effect |
|---|---|
| `POST /contracts` | INSERT `Contracts` (Phase=`DangSoanThao`) |
| | AuditInterceptor: set `CreatedAt`, `CreatedBy` = Drafter.Id |
| | (Phase 3) Emit `ContractCreatedEvent` domain event |
| `POST /forms/render` preview | Write temp file `wwwroot/uploads/preview/{guid}.docx` (auto cleanup sau 1h) |
| `POST /forms/render` với `persist=true` | Save final file vào `ContractAttachments` với `Purpose='Export'` |
## 6. UI wireframe (fe-user)
```
/contracts/new
┌────────────────────────────────────────────────────┐
│ Tạo hợp đồng mới │
├────────────────────────────────────────────────────┤
│ Bước 1/4: Chọn loại hợp đồng │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Trọn gói │ │ Giao │ │ Mua bán │ ... │
│ │ NC+VT │ │ khoán │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
├────────────────────────────────────────────────────┤
│ Bước 2/4: Chọn template │
│ ○ FO-002.02 Trọn gói nhân công + vật tư │
│ ● FO-002.05 Giao khoán ← đã chọn │
├────────────────────────────────────────────────────┤
│ Bước 3/4: Điền thông tin │
│ [Thông tin 2 bên] │
│ Tên Bên A: [Công ty TNHH ...] │
│ NCC (Bên B): [Autocomplete → chọn NCC] │
│ [Giá trị HĐ] │
│ Giá trị: [150,000,000 VND] │
│ ... │
├────────────────────────────────────────────────────┤
│ Bước 4/4: Preview + Lưu │
│ [PDF preview inline] │
│ [← Quay lại] [💾 Lưu draft] [🚀 Submit góp ý] │
└────────────────────────────────────────────────────┘
```
## 7. Edge cases
| Case | Xử lý |
|---|---|
| NCC chưa có trong DB | FE modal "Tạo NCC mới" inline → callback back vào form |
| Template bị deactivate khi đang soạn | Warning + cho phép tiếp tục với template cũ |
| Draft bị trùng (user nhấn Save 2 lần) | Idempotency key header — return existing contract |
| User mất kết nối khi preview | FE retry + show spinner; nếu fail 3 lần → disable button + show cached data |
| Giá trị HĐ vượt ngân sách dự án | Warning (không block) — sẽ check lại ở phase CCM Review |
| Template thiếu field bắt buộc | FormValidator block Save + highlight field đỏ |
## 8. Performance
- Template load 1 lần + cache TanStack Query `{queryKey: ['template', id], staleTime: 10min}`
- Preview render: có thể tốn 1-3s cho .docx lớn → show spinner + allow cancel
- Save draft < 500ms (chỉ INSERT 1 row)
## 9. Liên quan
- [`form-render-flow.md`](form-render-flow.md) chi tiết render engine
- [`contract-approval-flow.md`](contract-approval-flow.md) sau Save, phase tiếp theo
- [`../forms-spec.md`](../forms-spec.md) spec 8 form

View File

@ -0,0 +1,219 @@
# Form Render Flow — Template Engine
> **Status:** 📝 Planned (Phase 2)
> **Mục tiêu:** render .docx/.xlsx giống 100% mẫu gốc với field động đã điền
> **Spec 8 form:** [`../forms-spec.md`](../forms-spec.md)
## 1. Stack lựa chọn
| Format | Library | Status | Lý do |
|---|---|---|---|
| `.docx` render | **DocumentFormat.OpenXml** 3.x | Chọn mặc định | Free, maintained by MS, dùng đủ cho template placeholder |
| `.docx` render (alternative) | Aspose.Words | Option phí | Dễ dùng hơn, render PDF in-process, nhưng cần license |
| `.xlsx` render | **ClosedXML** 0.105+ | Chọn mặc định | Free, LGPL, API dễ; support formula, merged cells, style |
| `.xlsx` render (alternative) | EPPlus 7+ | Không chọn | License commercial sau v5; non-commercial license risky |
| PDF convert | LibreOffice headless (`soffice --convert-to pdf`) | Production path | Free, chất lượng OK |
| PDF convert (dev only) | Aspose.Words `SaveAs PDF` | Option nếu mua Aspose | In-process, không cần external binary |
**Quyết định Phase 2:** OpenXml + ClosedXML + LibreOffice. Nếu render DOCX phức tạp quá (table lồng, heading numbering) → đánh giá lại Aspose.
## 2. Template placeholder syntax
Sử dụng `{{fieldName}}` cho text đơn giản và `{{#loop}}...{{/loop}}` cho bảng lặp.
Ví dụ trong file .docx (FO-002.05 Giao khoán):
```
Hợp đồng số: {{maHopDong}}
Ngày: {{ngayKy}}
Bên A: {{benA_tenCongTy}}
Địa chỉ: {{benA_diaChi}}
MST: {{benA_maSoThue}}
Bên B: {{benB_tenNCC}}
...
Danh mục công việc:
| STT | Hạng mục | Đơn giá | Khối lượng | Thành tiền |
| --- | -------- | ------- | ---------- | ---------- |
{{#hangMuc}}
| {{stt}} | {{tenHangMuc}} | {{donGia}} | {{khoiLuong}} | {{thanhTien}} |
{{/hangMuc}}
Tổng giá trị: {{giaTri}} VND
```
Field không có trong data → replace bằng rỗng hoặc `—` (config).
## 3. Flow render .docx
```mermaid
sequenceDiagram
participant Caller as API / Workflow
participant REN as IFormRenderer
participant DOCX as DocxRenderer
participant OX as OpenXml
participant FS as File Storage
Caller->>REN: Render(templateId, dataDict)
REN->>REN: Load ContractTemplate from DB<br/>(get TemplatePath + FieldSpec)
REN->>DOCX: RenderDocx(templatePath, dataDict)
DOCX->>FS: Copy template → temp/{guid}.docx
DOCX->>OX: Open WordprocessingDocument
DOCX->>DOCX: Find all Text elements<br/>(w:t nodes)
loop Each w:t
DOCX->>DOCX: Regex replace {{field}} → value
end
DOCX->>DOCX: Process {{#loop}} blocks:<br/>1. Find row/paragraph có placeholder loop<br/>2. Clone n-1 lần cho n items<br/>3. Replace field trong từng clone
DOCX->>OX: Save + Close
OX-->>DOCX: byte[] hoặc path
DOCX-->>REN: file bytes + metadata
REN-->>Caller: RenderResult{bytes, fileName, contentType}
```
## 4. Flow render .xlsx (FO-002.07 PO)
```mermaid
sequenceDiagram
participant Caller
participant REN as XlsxRenderer
participant CX as ClosedXML
participant FS
Caller->>REN: RenderXlsx(templateId, dataDict)
REN->>FS: Copy template xlsx → temp
REN->>CX: Open XLWorkbook
loop Each sheet
REN->>CX: Iterate cells
alt Cell value contains {{field}}
REN->>CX: Replace value với data[field]<br/>(giữ style, format)
end
end
loop Each named range "LoopHangMuc"
REN->>REN: Insert n-1 rows, copy style, fill data
REN->>REN: Adjust formula references (=SUM(...))
end
REN->>CX: Recalculate formulas
REN->>CX: SaveAs byte[]
CX-->>REN: xlsx bytes
REN-->>Caller: RenderResult
```
**ClosedXML edge:** phải gọi `workbook.CalculateMode = XLCalculateMode.Auto` + `workbook.FormulaParser.Calculate()` trước khi save, nếu không formula tổng vẫn giữ giá trị cũ.
## 5. PDF convert flow
```mermaid
flowchart TD
Start([Input: .docx bytes]) --> Save[Lưu temp file .docx]
Save --> Run[Run: soffice --headless --convert-to pdf<br/>--outdir /tmp in.docx]
Run -->|exit 0| Read[Read /tmp/in.pdf bytes]
Run -->|exit != 0| Fail[Log error<br/>return docx thay vì pdf]
Read --> Cleanup[Cleanup temp files]
Cleanup --> Return([Return PDF bytes])
Fail --> Return
```
**Deploy prod:**
- Windows IIS app pool cần LibreOffice portable ở `C:\Apps\LibreOffice` + `PATH` env var
- Subprocess timeout 30s (nếu > 30s → fail, user download docx thay)
- Pool soffice instance (1 instance / 10 requests) — tránh spawn process mỗi request
## 6. API endpoints
### Preview (không lưu)
```
POST /api/forms/render
Body: { templateId, data, outputFormat: "docx" | "pdf" }
Response: binary stream, Content-Disposition: inline (nếu pdf), attachment (nếu docx)
```
### Finalize (lưu vào ContractAttachments)
```
POST /api/contracts/{id}/render-final
Body: { outputFormat: "docx" }
Response: { attachmentId, url }
Side effect: INSERT ContractAttachments (Purpose='Export')
```
## 7. Template management (admin)
```mermaid
sequenceDiagram
actor Admin
participant FE as fe-admin<br/>/master/contract-templates
participant API as FormsController.UploadTemplate
participant VAL as TemplateValidator
participant DB
participant FS
Admin->>FE: Upload file .docx + metadata<br/>(formCode, name, contractType, fieldSpec JSON)
FE->>API: POST /api/forms/templates<br/>multipart: file + metadata
API->>VAL: ParsePlaceholders(file)
VAL->>VAL: Extract all {{field}} tokens<br/>→ compare với fieldSpec
alt Token không match spec
VAL-->>API: warning "Field X trong template không có trong spec"
end
API->>FS: Save file to wwwroot/templates/{guid}.docx
API->>DB: INSERT ContractTemplate<br/>(FormCode, Name, TemplatePath, FieldSpec, IsActive=true)
API-->>FE: 201
```
## 8. Field types supported
| Type | Render behavior | Example |
|---|---|---|
| `string` | Replace direct | `"Công ty ABC"` |
| `text` | Replace + preserve line breaks (`<w:br/>`) | `"Dòng 1\nDòng 2"` |
| `number` | Format theo culture vi-VN | `150000000``"150.000.000"` |
| `decimal` | Giá tiền: thêm `VND` suffix | `150000000``"150.000.000 VND"` |
| `date` | Format `dd/MM/yyyy` | `2026-04-21``"21/04/2026"` |
| `boolean` | Checkbox symbol | `true``"☑"`, `false``"☐"` |
| `reference` | Lookup entity name | `supplierId → "Công ty NCC PVL"` |
| `array` | Với `{{#loop}}` | danh mục hạng mục |
## 9. Edge cases
| Case | Xử lý |
|---|---|
| Template bị rename/xóa file vật lý | Render throw `FileNotFoundException` → API 500, log error |
| Template có macro (`.docm`) | Reject ở upload — chỉ accept `.docx` |
| Placeholder bị split giữa 2 `<w:t>` elements (Word quirk) | DocxRenderer normalize trước: merge adjacent `<w:t>` trong cùng run |
| Field có ký tự đặc biệt (`&`, `<`, `>`) | OpenXml auto XML-escape khi set Text value |
| Loop template có 0 items | Remove template row hoàn toàn (không để lại placeholder row rỗng) |
| File size > 10MB | Chunk upload hoặc reject (Phase 4 optimize) |
| Render concurrent (2 user render cùng template) | Mỗi request lock copy riêng vào temp file → không conflict |
## 10. Performance
| Operation | Budget | Note |
|---|---|---|
| Load template from DB + FS | < 100ms | Cache FileBytes in IMemoryCache 1h |
| Render .docx (5 pages, 1 table 20 rows) | < 500ms | Stream-based, không full DOM |
| Convert PDF via LibreOffice | 1-3s | Bottleneck, consider pool |
| Full request (render + PDF + save attachment) | < 5s | Show spinner FE |
## 11. Testing checklist (Phase 2)
- [ ] FO-002.05 Giao khoán: render với 10 hạng mục output giống mẫu 100%
- [ ] FO-002.07 PO xlsx: formula `=SUM(E5:E30)` recalculate đúng
- [ ] Template với tự Unicode (đ, ư, ă) không lỗi encoding
- [ ] Template với 0 items loop không còn dòng trống
- [ ] PDF convert với 50-page docx timeout setting OK
- [ ] Concurrent 10 request cùng 1 template mỗi file output độc lập
## 12. Liên quan
- [`../forms-spec.md`](../forms-spec.md) spec 8 form + field types
- [`contract-creation-flow.md`](contract-creation-flow.md) nơi gọi render
- [`contract-approval-flow.md`](contract-approval-flow.md) render lại khi

View File

@ -0,0 +1,289 @@
# Permission Flow — Resolution Menu + CRUD
> **Status:** 📝 Planned (Phase 1 đợt 2)
> **Actors:** System (resolution) | Admin (configure matrix) | Any user (guard apply)
## 1. Mô hình
3 layer resolution:
```
User
└─ có nhiều Role (UserRoles table)
└─ có Permission (Permissions table, row per MenuKey × CRUD)
└─ gắn với MenuItem (tree Key → ParentKey)
```
Quyết định cuối cùng: **union** của tất cả permission từ mọi role của user. Nếu bất kỳ role nào `CanRead=true` cho `MenuKey=Contracts` → user được đọc.
## 2. Ma trận Permission
```
MenuKey Role.Admin Role.Drafter Role.CCM Role.BOD
─────────────────────────────────────────────────────────────
Dashboard R R R R
Contracts CRUD CR (self) RU RU
Suppliers CRUD R R R
Projects CRUD R R R
Users CRUD — — —
Roles CRUD — — —
Permissions CRUD — — —
Reports R — R R
```
(Chi tiết đầy đủ role × menu mapping ở [`../workflow-contract.md §5`](../workflow-contract.md))
## 3. Flow — user login → FE resolve menu
```mermaid
sequenceDiagram
actor U as User
participant FE as Frontend
participant API as MenusController.GetMyTree
participant Q as GetMyMenuTreeQueryHandler
participant CU as ICurrentUser
participant DB
U->>FE: Login thành công (có token)
FE->>API: GET /api/menus/me
API->>Q: Send(GetMyMenuTreeQuery)
Q->>CU: UserId + Roles
CU-->>Q: userId, roles[]
Q->>DB: SELECT Permissions<br/>JOIN Roles ON Permissions.RoleId = Roles.Id<br/>JOIN MenuItems ON Permissions.MenuKey = MenuItems.Key<br/>WHERE Roles.Id IN (user's roles)
DB-->>Q: raw rows
Q->>Q: Group by MenuKey<br/>UNION CRUD flags (OR)<br/>Build tree (Key → ParentKey)
Q-->>API: MenuTreeDto[] with resolved CRUD per node
API-->>FE: 200
FE->>FE: Cache in AuthContext + localStorage
FE->>FE: Render sidebar (filter nodes có CanRead=true)
```
**Example response:**
```json
[
{
"key": "Dashboard",
"label": "Tổng quan",
"icon": "LayoutDashboard",
"order": 1,
"parentKey": null,
"canRead": true,
"canCreate": false,
"canUpdate": false,
"canDelete": false,
"children": []
},
{
"key": "Master",
"label": "Danh mục",
"icon": "Database",
"order": 2,
"parentKey": null,
"children": [
{
"key": "Suppliers",
"label": "Nhà cung cấp",
"parentKey": "Master",
"canRead": true, "canCreate": true, "canUpdate": true, "canDelete": false,
"children": []
}
]
}
]
```
## 4. Flow — FE guard render
```mermaid
flowchart TD
Start([Component mount]) --> CheckMenu{usePermission<br/>can 'Contracts', 'Update'?}
CheckMenu -->|no permission| Hide[Không render nút 'Sửa']
CheckMenu -->|has permission| Show[Render nút 'Sửa']
Show --> ClickEdit[User click 'Sửa']
ClickEdit --> CallAPI[PATCH /api/contracts/:id]
CallAPI --> BEGuard{Controller<br/>Authorize 'Contracts.Update'?}
BEGuard -->|no| Reject[403 Forbidden]
BEGuard -->|yes| Proceed[Update DB + return 200]
```
**FE pattern (sẽ implement Phase 1 đợt 2):**
```tsx
// usePermission.ts
export function usePermission() {
const { menu } = useAuth() // menu cached từ login
return {
can: (menuKey: string, action: 'Read' | 'Create' | 'Update' | 'Delete') => {
const node = findInTree(menu, menuKey)
return node?.[`can${action}`] ?? false
},
}
}
// PermissionGuard.tsx
<PermissionGuard menuKey="Contracts" action="Update">
<Button>Sửa</Button>
</PermissionGuard>
// Route guard
<Route
path="/admin/permissions"
element={
<PermissionGuard menuKey="Permissions" action="Read" fallback={<Forbidden />}>
<PermissionMatrixPage />
</PermissionGuard>
}
/>
```
## 5. Flow — Admin configure permission matrix
```mermaid
sequenceDiagram
actor A as Admin
participant FE as Permission Matrix Page
participant API as PermissionsController
participant M as UpsertPermissionCommandHandler
participant DB
A->>FE: Mở /admin/permissions
FE->>API: GET /api/permissions?roleId={id}
API-->>FE: Array permissions (row per menuKey)
A->>FE: Tick checkbox "Contracts.CanUpdate = true" cho role Drafter
FE->>FE: Optimistic update UI
FE->>API: PUT /api/permissions<br/>{roleId, menuKey: "Contracts", canRead, canCreate, canUpdate, canDelete}
API->>M: Send(UpsertPermissionCommand)
M->>DB: SELECT existing Permission<br/>WHERE RoleId=? AND MenuKey=?
alt Exists
M->>DB: UPDATE flags
else Not exists
M->>DB: INSERT new row
end
M-->>API: 204
API-->>FE: 204
Note over FE,A: User với role Drafter<br/>sẽ thấy nút Update HĐ<br/>sau khi họ refresh/re-login<br/>(hoặc SignalR notify Phase 3)
```
**Ghi chú quan trọng:**
- Permission update **KHÔNG** real-time đến user đang online ở Phase 1 đợt 2 — họ phải logout/login để thấy.
- Phase 3 có thể thêm SignalR notify "permission changed" → FE auto refetch `/api/menus/me`.
- Phase 4 có thể invalidate JWT khi permission đổi (rare change, nhưng secure).
## 6. Backend guard
```csharp
// Api/Controllers/ContractsController.cs
[HttpPut("{id}")]
[Authorize(Policy = "Contracts.Update")] // custom policy
public async Task<IActionResult> Update(Guid id, UpdateContractCommand cmd)
{
// ...
}
```
**Custom policy registration (Program.cs):**
```csharp
services.AddAuthorization(opts =>
{
foreach (var menu in MenuKeys.All)
{
foreach (var action in new[] { "Read", "Create", "Update", "Delete" })
{
opts.AddPolicy($"{menu}.{action}", p =>
p.Requirements.Add(new MenuPermissionRequirement(menu, action)));
}
}
});
services.AddSingleton<IAuthorizationHandler, MenuPermissionHandler>();
```
**Handler:**
```csharp
public class MenuPermissionHandler : AuthorizationHandler<MenuPermissionRequirement>
{
private readonly IApplicationDbContext _db;
// ...
protected override async Task HandleRequirementAsync(...)
{
var userId = context.User.GetUserId();
var hasPermission = await _db.Permissions
.Where(p => p.Role.Users.Any(u => u.Id == userId))
.Where(p => p.MenuKey == req.MenuKey)
.AnyAsync(p => req.Action switch
{
"Read" => p.CanRead,
"Create" => p.CanCreate,
"Update" => p.CanUpdate,
"Delete" => p.CanDelete,
_ => false,
});
if (hasPermission) context.Succeed(req);
}
}
```
## 7. Seed mặc định (Phase 1 đợt 2)
Seed trong `DbInitializer.InitializeAsync`:
1. **Menu tree seed** — từ `MenuKeys.cs` const class (Phase 1 đợt 2):
```
Dashboard
Master
├── Suppliers
├── Projects
└── Departments
Contracts
Forms
Approvals
Reports
System
├── Users
├── Roles
└── Permissions
```
2. **Default permissions:**
- `Admin` role → full CRUD mọi menu
- Các role khác → chỉ `Read` mặc định, admin config thêm sau qua UI
## 8. Edge cases
| Case | Xử lý |
|---|---|
| User có 0 role | Chỉ thấy `Dashboard` (nếu mở cho anonymous), không vào được menu khác |
| Role bị xóa | `FK ON DELETE CASCADE` xóa permissions liên quan |
| Menu bị remove khỏi `MenuKeys.cs` | Seed job cảnh báo + giữ orphan permissions (dev fix manual) |
| Admin tự xóa quyền admin của mình | Check trong UpsertPermissionCommand — nếu target role là Admin + current user đang ở role Admin → `throw ForbiddenException("Không thể tự xóa quyền admin")` |
| User có nhiều role conflict (1 role cho, 1 role cấm) | Union (OR) — có ít nhất 1 role cho là được |
## 9. Performance
- Menu tree cache trong `AuthContext` sau login → không hit API mỗi navigate
- `/api/menus/me` response size ~5KB gzipped (với ~30 menu nodes)
- Authorization handler cache scope request (1 query / request) — EF Core auto cache
- Phase 4 optimize: Redis distributed cache cho permission matrix (nếu >100 concurrent users)
## 10. Testing checklist (Phase 1 đợt 2)
- [ ] Admin login → thấy tất cả menu
- [ ] Tạo user role Drafter only → chỉ thấy menu Contracts + self HĐ
- [ ] Tạo role tùy chỉnh "CCM Reviewer" chỉ read Contracts + read Reports → verify không thấy Master menu
- [ ] User có 2 role (Drafter + Finance) → thấy union của cả 2
- [ ] Admin xóa quyền Update của role Drafter → user Drafter refresh → không thấy nút Sửa
- [ ] Backend 403 khi FE bypass (dev tools unhide nút) → gọi API trực tiếp bị chặn

View File

@ -0,0 +1,240 @@
# 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<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
```csharp
// 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
```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<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
```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