[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

View File

@ -68,6 +68,8 @@ Kiến trúc: **.NET 10 Clean Architecture + 2 React FE (admin + user) + SQL Ser
| [`docs/CLAUDE.md`](docs/CLAUDE.md) | Full context — tech stack, architecture | | [`docs/CLAUDE.md`](docs/CLAUDE.md) | Full context — tech stack, architecture |
| [`docs/workflow-contract.md`](docs/workflow-contract.md) | State machine 9 phase + role matrix | | [`docs/workflow-contract.md`](docs/workflow-contract.md) | State machine 9 phase + role matrix |
| [`docs/forms-spec.md`](docs/forms-spec.md) | Catalog 8 form + quy định mã HĐ | | [`docs/forms-spec.md`](docs/forms-spec.md) | Catalog 8 form + quy định mã HĐ |
| [`docs/database/database-guide.md`](docs/database/database-guide.md) | DB conventions + schema hiện tại + planned + ERD |
| [`docs/flows/README.md`](docs/flows/README.md) | Index 6 flow (auth, permission, contract create/approve, form render, SLA) |
## ⚠️ Kết thúc session ## ⚠️ Kết thúc session

View File

@ -60,8 +60,17 @@ SOLUTION_ERP/
│ ├── PROJECT-MAP.md │ ├── PROJECT-MAP.md
│ ├── forms-spec.md ⭐ 8 form catalog + RG-001 code format │ ├── forms-spec.md ⭐ 8 form catalog + RG-001 code format
│ ├── workflow-contract.md ⭐ 9 phase state machine + role matrix │ ├── workflow-contract.md ⭐ 9 phase state machine + role matrix
│ ├── database/
│ │ └── database-guide.md ⭐ Conventions, schema hiện tại + planned, ERD, migration workflow
│ ├── flows/ ⭐ Sequence/state diagram mỗi feature
│ │ ├── README.md (index)
│ │ ├── auth-flow.md ✅ implemented
│ │ ├── permission-flow.md 📝 Phase 1 đợt 2
│ │ ├── contract-creation-flow.md 📝 Phase 2
│ │ ├── contract-approval-flow.md 📝 Phase 3
│ │ ├── form-render-flow.md 📝 Phase 2
│ │ └── sla-expiry-flow.md 📝 Phase 3
│ ├── guides/ setup, cicd, code-rules... │ ├── guides/ setup, cicd, code-rules...
│ ├── database/ schema docs
│ └── changelog/ │ └── changelog/
│ ├── migration-todos.md Roadmap 5 phase │ ├── migration-todos.md Roadmap 5 phase
│ └── sessions/ YYYY-MM-DD session logs │ └── sessions/ YYYY-MM-DD session logs

View File

@ -14,7 +14,8 @@ _(không có — Phase 1 foundation xong, chờ quyết định bước tiếp)_
| Ngày | Ai | Task | Commit | | Ngày | Ai | Task | Commit |
|---|---|---|---| |---|---|---|---|
| 2026-04-21 | Claude | **Phase 1 foundation HOÀN TẤT** — BE (Clean Arch + Identity + JWT + migration) + FE (2 app, Tailwind 4, Router, AuthContext, Login) — E2E login pass qua Vite proxy | (chưa commit, sắp xong) | | 2026-04-21 | Claude | **Docs addition**`database-guide.md` (conventions + schema + ERD + migration workflow) + `flows/` 6 doc (auth implemented + permission/contract-create/contract-approve/form-render/sla-expiry planned) | (sắp commit) |
| 2026-04-21 | Claude | **Phase 1 foundation HOÀN TẤT** — BE (Clean Arch + Identity + JWT + migration) + FE (2 app, Tailwind 4, Router, AuthContext, Login) — E2E login pass qua Vite proxy | `702411f` |
| 2026-04-21 | Claude | **Phase 0 HOÀN TẤT** — scaffold + parse FORM/QUY_TRINH + docs + skills + git init | `25dad7f` | | 2026-04-21 | Claude | **Phase 0 HOÀN TẤT** — scaffold + parse FORM/QUY_TRINH + docs + skills + git init | `25dad7f` |
Session logs: Session logs:

View File

@ -15,6 +15,8 @@
- [x] Parse 8 form → `docs/forms-spec.md` - [x] Parse 8 form → `docs/forms-spec.md`
- [x] Parse quy trình → `docs/workflow-contract.md` - [x] Parse quy trình → `docs/workflow-contract.md`
- [x] Viết `docs/{CLAUDE,STATUS,PROJECT-MAP}.md` - [x] Viết `docs/{CLAUDE,STATUS,PROJECT-MAP}.md`
- [x] Viết `docs/database/database-guide.md` (conventions + schema + ERD + migration workflow)
- [x] Viết `docs/flows/` — README + 6 flow doc (auth, permission, contract-create, contract-approve, form-render, sla-expiry)
- [x] Viết `.gitignore`, `README.md`, `global.json`, `docker-compose.yml` - [x] Viết `.gitignore`, `README.md`, `global.json`, `docker-compose.yml`
- [x] Tạo placeholder skill folders: `contract-workflow`, `form-engine`, `permission-matrix` - [x] Tạo placeholder skill folders: `contract-workflow`, `form-engine`, `permission-matrix`
- [x] `git init` + commit đầu (`25dad7f`) - [x] `git init` + commit đầu (`25dad7f`)

View File

@ -0,0 +1,463 @@
# Database Guide — SOLUTION_ERP
> **DB engine:** SQL Server 2022 (LocalDB dev, full instance prod)
> **ORM:** EF Core 10 Code-First migrations
> **DbContext:** [`ApplicationDbContext`](../../src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs) extends `IdentityDbContext<User, Role, Guid>`
## 1. Connection strings
| Env | ConnectionString | DB name |
|---|---|---|
| Dev (default) | `Server=(localdb)\MSSQLLocalDB;Database=SolutionErp_Dev;Trusted_Connection=True` | `SolutionErp_Dev` |
| Design-time (EF CLI) | same server, `Database=SolutionErp_Design` | `SolutionErp_Design` |
| Prod | `Server=<prod>;Database=SolutionErp;User=<>;Password=<>;TrustServerCertificate=true` | `SolutionErp` |
Cả 3 DB dùng chung schema, khác tên → có thể drop `SolutionErp_Design` không mất dữ liệu.
## 2. Conventions
### Naming
| Item | Rule | Ví dụ |
|---|---|---|
| Schema | 1 schema duy nhất: `dbo` | — |
| Table | PascalCase tiếng Anh, số nhiều | `Contracts`, `Suppliers`, `ContractApprovals` |
| Column | PascalCase | `FullName`, `CreatedAt`, `SupplierId` |
| PK | `Id` | `Guid` (không auto-increment) |
| FK | `{EntityName}Id` | `SupplierId`, `ContractId` |
| Index | `IX_{Table}_{Col1}_{Col2}` | `IX_Contracts_Phase_SupplierId` |
| Unique | `UX_{Table}_{Col}` | `UX_Suppliers_Code` |
| Migration | `YYYYMMDDHHMMSS_{Name}` (auto) | `20260421034520_Init` |
### Data types
| C# type | SQL type | Notes |
|---|---|---|
| `Guid` | `uniqueidentifier` | PK + FK default |
| `string` | `nvarchar(max)` default | Luôn set `HasMaxLength(n)` trong config |
| `DateTime` | `datetime2` | UTC — luôn qua `IDateTime.UtcNow` |
| `decimal` (tiền) | `decimal(18,2)` | `HasPrecision(18, 2)` |
| `int` enum | `int` | `HasConversion<int>()` |
| `bool` | `bit` | — |
| JSON blob | `nvarchar(max)` | `HasColumnType("nvarchar(max)")` — dùng `HasConversion` serialize |
### Audit fields (mọi entity extend `BaseEntity`)
```csharp
public Guid Id { get; set; } = Guid.NewGuid();
public DateTime CreatedAt { get; set; } // Set by AuditingInterceptor
public DateTime? UpdatedAt { get; set; } // Set by AuditingInterceptor
public Guid? CreatedBy { get; set; } // From ICurrentUser.UserId
public Guid? UpdatedBy { get; set; } // From ICurrentUser.UserId
```
### Soft delete (entity extend `AuditableEntity`)
```csharp
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
```
[`AuditingInterceptor`](../../src/Backend/SolutionErp.Infrastructure/Persistence/Interceptors/AuditingInterceptor.cs) tự động:
- `Added` entity → set `CreatedAt`, `CreatedBy`
- `Modified` entity → set `UpdatedAt`, `UpdatedBy`
- `Deleted` entity kiểu `AuditableEntity` → convert sang `Modified` + set `IsDeleted=true`, `DeletedAt`, `DeletedBy` (soft delete)
**Query filter soft delete:** config `modelBuilder.Entity<T>().HasQueryFilter(e => !e.IsDeleted)` cho mọi `AuditableEntity`. Sẽ làm Phase 1 đợt 2.
## 3. Schema hiện tại (sau migration `Init`)
### Identity tables (đã có sau migration 1)
```mermaid
erDiagram
Users ||--o{ UserRoles : has
Roles ||--o{ UserRoles : has
Users ||--o{ UserClaims : has
Users ||--o{ UserLogins : has
Users ||--o{ UserTokens : has
Roles ||--o{ RoleClaims : has
Users {
uniqueidentifier Id PK
nvarchar FullName "200"
nvarchar Email
nvarchar UserName
nvarchar PasswordHash
nvarchar RefreshToken "512"
datetime2 RefreshTokenExpiresAt
bit IsActive
datetime2 CreatedAt
datetime2 UpdatedAt
bit EmailConfirmed
bit LockoutEnabled
datetime2 LockoutEnd
int AccessFailedCount
}
Roles {
uniqueidentifier Id PK
nvarchar Name
nvarchar NormalizedName
nvarchar Description "500"
datetime2 CreatedAt
}
UserRoles {
uniqueidentifier UserId PK,FK
uniqueidentifier RoleId PK,FK
}
UserClaims {
int Id PK
uniqueidentifier UserId FK
nvarchar ClaimType
nvarchar ClaimValue
}
UserLogins {
nvarchar LoginProvider PK
nvarchar ProviderKey PK
uniqueidentifier UserId FK
}
UserTokens {
uniqueidentifier UserId PK,FK
nvarchar LoginProvider PK
nvarchar Name PK
nvarchar Value
}
RoleClaims {
int Id PK
uniqueidentifier RoleId FK
nvarchar ClaimType
nvarchar ClaimValue
}
```
### Seed data hiện tại
[`DbInitializer`](../../src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs) auto-run khi API start:
**12 role:**
| Role | Ý nghĩa (theo workflow-contract.md) |
|---|---|
| `Admin` | Quản trị hệ thống |
| `Drafter` | Người soạn thảo (QS/NV.PB) |
| `DeptManager` | Trưởng Phòng ban (TPB) |
| `ProjectManager` | Giám đốc Dự án (PM) |
| `Procurement` | Phòng Cung ứng (PRO) |
| `CostControl` | Phòng Kiểm soát Chi phí (CCM) |
| `Finance` | Phòng Tài chính (FIN) |
| `Accounting` | Phòng Kế toán (ACT) |
| `Equipment` | Phòng Thiết bị (EQU) |
| `Director` | Ban Giám đốc (BOD) |
| `AuthorizedSigner` | Người được ủy quyền ký (NĐUQ) |
| `HrAdmin` | Nhân sự - Hành chính (HRA) |
**1 admin user:**
| Field | Value |
|---|---|
| Email | `admin@solutionerp.local` |
| Password | `Admin@123456` |
| FullName | `Administrator` |
| Roles | `[Admin]` |
## 4. Schema dự kiến
### Phase 1 đợt 2 — Master data
```mermaid
erDiagram
Suppliers {
uniqueidentifier Id PK
nvarchar Code "50 UNIQUE"
nvarchar Name "200"
nvarchar TaxCode "20"
nvarchar Phone "20"
nvarchar Email "100"
nvarchar Address "500"
int Type "NCC/NTP/TD/DVDV"
datetime2 CreatedAt
datetime2 UpdatedAt
uniqueidentifier CreatedBy FK
uniqueidentifier UpdatedBy FK
bit IsDeleted
}
Projects {
uniqueidentifier Id PK
nvarchar Code "50 UNIQUE"
nvarchar Name "200"
date StartDate
date EndDate
uniqueidentifier ManagerUserId FK
}
Departments {
uniqueidentifier Id PK
nvarchar Code "50 UNIQUE"
nvarchar Name "200"
uniqueidentifier ManagerUserId FK
}
Users ||--o{ Projects : manages
Users ||--o{ Departments : manages
```
### Phase 1 đợt 2 — Permission Matrix
```mermaid
erDiagram
MenuItems ||--o{ Permissions : has
Roles ||--o{ Permissions : has
MenuItems {
nvarchar Key PK "PascalCase, vd Contracts"
nvarchar Label "200 tieng Viet"
nvarchar ParentKey FK "NULL neu root"
int Order
nvarchar Icon "50 lucide icon name"
}
Permissions {
uniqueidentifier Id PK
uniqueidentifier RoleId FK
nvarchar MenuKey FK
bit CanRead
bit CanCreate
bit CanUpdate
bit CanDelete
}
```
Unique key: `(RoleId, MenuKey)`.
### Phase 3 — Contract + Workflow
```mermaid
erDiagram
Contracts ||--o{ ContractApprovals : has
Contracts ||--o{ ContractComments : has
Contracts ||--o{ ContractAttachments : has
Contracts }o--|| Suppliers : references
Contracts }o--|| Projects : references
Contracts }o--|| Departments : drafted_in
Contracts }o--|| ContractTemplates : uses
Contracts {
uniqueidentifier Id PK
nvarchar MaHopDong "100 nullable until Phase5"
int Type "ContractType enum"
int Phase "ContractPhase enum 9 state"
uniqueidentifier SupplierId FK
uniqueidentifier ProjectId FK
uniqueidentifier DepartmentId FK
uniqueidentifier DrafterUserId FK
uniqueidentifier TemplateId FK
decimal GiaTri "18 2"
bit BypassProcurementAndCCM
datetime2 SlaDeadline "khi nao phase hien tai het han"
nvarchar DraftData "nvarchar max JSON field values"
}
ContractApprovals {
uniqueidentifier Id PK
uniqueidentifier ContractId FK
int Phase
uniqueidentifier ApproverUserId FK
datetime2 ApprovedAt
int Decision "Pending/Approve/Reject/AutoApprove"
nvarchar Comment "1000"
}
ContractComments {
uniqueidentifier Id PK
uniqueidentifier ContractId FK
uniqueidentifier UserId FK
int Phase "phase luc comment"
nvarchar Content "2000"
datetime2 CreatedAt
}
ContractAttachments {
uniqueidentifier Id PK
uniqueidentifier ContractId FK
nvarchar FileName "255"
nvarchar StoragePath "500"
bigint FileSize
nvarchar ContentType "100"
nvarchar Purpose "50 Scan/Export/Template"
datetime2 UploadedAt
}
ContractTemplates {
uniqueidentifier Id PK
nvarchar FormCode "20 UNIQUE vd FO-002.05"
nvarchar Name "200"
int ContractType
nvarchar TemplatePath "500"
nvarchar FieldSpec "nvarchar max JSON"
bit IsActive
}
ContractClauses {
uniqueidentifier Id PK
nvarchar Code "20 UNIQUE vd FO-002.04"
nvarchar Name "200"
nvarchar Content "nvarchar max rich text"
int Version
bit IsActive
}
```
### Phase 3 — Contract Code Generator
`ContractCodeSequence` bảng đơn giản để tránh race condition khi gen seq:
```sql
CREATE TABLE dbo.ContractCodeSequences (
Prefix NVARCHAR(100) NOT NULL PRIMARY KEY, -- "FLOCK 01/HĐTP/SOL&HD" hoặc "2026/NCC/SOL&AKATI"
LastSeq INT NOT NULL,
UpdatedAt DATETIME2 NOT NULL
);
```
Logic trong `ContractCodeGenerator`:
1. Begin transaction `SERIALIZABLE`
2. `SELECT LastSeq FROM ContractCodeSequences WITH (UPDLOCK, HOLDLOCK) WHERE Prefix = @prefix`
3. Nếu không có → INSERT với `LastSeq = 1`
4. Nếu có → `UPDATE ContractCodeSequences SET LastSeq = LastSeq + 1`
5. Return `{prefix}/{LastSeq:D2}`
6. Commit
## 5. Migration workflow
### Tạo migration mới
```powershell
cd D:\Dropbox\CONG_VIEC\SOLUTION\SOLUTION_ERP
dotnet ef migrations add <Name> `
--project src\Backend\SolutionErp.Infrastructure `
--startup-project src\Backend\SolutionErp.Api `
--output-dir Persistence\Migrations
```
Ví dụ Phase 1 đợt 2:
```
dotnet ef migrations add AddMasterData
dotnet ef migrations add AddPermissions
dotnet ef migrations add AddContractDraft
```
Phase 3:
```
dotnet ef migrations add AddContractWorkflow
dotnet ef migrations add AddContractCodeSequence
```
### Apply migration
```powershell
dotnet ef database update `
--project src\Backend\SolutionErp.Infrastructure `
--startup-project src\Backend\SolutionErp.Api
```
Hoặc tự động khi API start (đã config trong `DbInitializer.InitializeAsync``db.Database.MigrateAsync()`).
### Revert migration
```powershell
# Rollback 1 bước
dotnet ef database update <PreviousMigrationName>
# Xóa file migration chưa apply
dotnet ef migrations remove
```
### Revert DB về trạng thái clean
```sql
-- Chạy trong SSMS hoặc sqlcmd
DROP DATABASE SolutionErp_Dev;
-- Hoặc giữ DB, chỉ xóa data:
EXEC sp_MSforeachtable 'DELETE FROM ?';
```
Rồi chạy lại API → tự migrate + seed.
## 6. Index strategy (Phase 3-4 optimize)
### Hot queries cần index
| Query | Index đề xuất |
|---|---|
| Inbox — HĐ theo phase + role của user | `IX_Contracts_Phase_IsDeleted` |
| List HĐ theo NCC | `IX_Contracts_SupplierId_IsDeleted` |
| List HĐ theo dự án | `IX_Contracts_ProjectId_IsDeleted` |
| Approval của 1 HĐ | `IX_ContractApprovals_ContractId_Phase` |
| Comment thread | `IX_ContractComments_ContractId_CreatedAt` |
| Audit log | `IX_AuditLogs_EntityId_CreatedAt` |
### Index unique
| Unique | Cột |
|---|---|
| `UX_Suppliers_Code` | `Code` |
| `UX_Projects_Code` | `Code` |
| `UX_Departments_Code` | `Code` |
| `UX_Contracts_MaHopDong` | `MaHopDong` (WHERE `MaHopDong IS NOT NULL` — filtered) |
| `UX_Users_Email` | đã có qua Identity |
| `UX_MenuItems_Key` | PK |
| `UX_Permissions_Role_Menu` | `(RoleId, MenuKey)` |
## 7. Backup & restore (prod)
### Backup daily (SQL Agent job)
```sql
BACKUP DATABASE SolutionErp
TO DISK = 'D:\Backups\SolutionErp_full.bak'
WITH INIT, COMPRESSION, CHECKSUM;
```
### Backup log mỗi 15 phút (nếu full recovery model)
```sql
BACKUP LOG SolutionErp
TO DISK = 'D:\Backups\SolutionErp_log.trn'
WITH INIT;
```
### Restore
```sql
RESTORE DATABASE SolutionErp
FROM DISK = 'D:\Backups\SolutionErp_full.bak'
WITH REPLACE, RECOVERY;
```
Chi tiết runbook ở Phase 5 `docs/guides/cicd.md`.
## 8. Common pitfalls
- **Quên `HasMaxLength`** → `nvarchar(max)`, gây bloat + index lỗi. Luôn set.
- **Lưu DateTime local thay vì UTC** → sai timezone khi deploy server khác múi giờ. Dùng `IDateTime.UtcNow` (tiêm qua DI), KHÔNG `DateTime.Now`.
- **Soft delete quên filter** → user thấy data đã xóa. Luôn `HasQueryFilter(e => !e.IsDeleted)` ở config.
- **FK không có index** → slow join. EF Core tự tạo index cho FK, nhưng vẫn phải verify sau migration.
- **Migration bị commit thiếu `.Designer.cs`** → team khác không apply được. Commit cả 3 file (`{name}.cs`, `{name}.Designer.cs`, `ApplicationDbContextModelSnapshot.cs`).
- **Chạy `dotnet ef` khi API đang chạy** → lock conflict. Dừng API trước khi migrate ở dev.
- **Gen mã HĐ ngoài transaction** → race condition. Luôn dùng `ContractCodeGenerator` với lock.
## 9. Quick SQL cheatsheet
```sql
-- Xem 10 HĐ mới nhất
SELECT TOP 10 Id, MaHopDong, Phase, CreatedAt FROM Contracts ORDER BY CreatedAt DESC;
-- Đếm HĐ theo phase
SELECT Phase, COUNT(*) FROM Contracts WHERE IsDeleted = 0 GROUP BY Phase;
-- List user + roles
SELECT u.Email, u.FullName, r.Name AS Role
FROM Users u
LEFT JOIN UserRoles ur ON u.Id = ur.UserId
LEFT JOIN Roles r ON ur.RoleId = r.Id
ORDER BY u.Email;
-- Reset admin password (emergency)
-- Cách an toàn: không edit PasswordHash trực tiếp, dùng UserManager.ResetPasswordAsync qua API
-- Clear toàn bộ dev data (giữ migrations)
EXEC sp_MSforeachtable 'DELETE FROM ?';
```

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