Compare commits
6 Commits
d206e14550
...
b431c8f68d
| Author | SHA1 | Date | |
|---|---|---|---|
| b431c8f68d | |||
| 8353fe87c0 | |||
| 1fc439b978 | |||
| b6f5a16420 | |||
| 4380bdc075 | |||
| f8eebd57d1 |
13
CLAUDE.md
13
CLAUDE.md
@ -63,7 +63,7 @@ Kiến trúc: **.NET 10 Clean Architecture + 2 React FE (admin + user) + SQL Ser
|
||||
| Identity (User/Role/Permission/MenuItem) | `Domain/Identity/` | 1, 3, 11 | Feature-complete (30 demo user — 16 sample + 14 Solutions thật) |
|
||||
| Forms (Template + Clause) | `Domain/Forms/` | 4 | Feature-complete |
|
||||
| Notifications | `Domain/Notifications/` | 6 | In-app + SignalR OK, email SMTP TODO |
|
||||
| **Tests** | `tests/SolutionErp.{Domain,Infrastructure}.Tests/` | — | **77 test pass** (54 Domain + 17 Infra + 6 PE WF Application Phase 3 mini) — CI gate + path filter docs-only skip |
|
||||
| **Tests** | `tests/SolutionErp.{Domain,Infrastructure}.Tests/` | — | **83 test pass** (54 Domain + 29 Infra: 17 codegen + 6 PE WF + 6 PE 2-stage approval) — CI gate + path filter docs-only skip |
|
||||
|
||||
### Commit convention
|
||||
|
||||
@ -73,17 +73,18 @@ Kiến trúc: **.NET 10 Clean Architecture + 2 React FE (admin + user) + SQL Ser
|
||||
|
||||
**Scope:** `Contract` · `PurchaseEvaluation` · `Budget` · `Form` · `Workflow` · `Supplier` · `Auth` · `Admin` · `Api` · `App` · `Domain` · `Infra` · `FE-Admin` · `FE-User` · `Tests` · `Docs` · `CICD` · `Scripts` · `Skill`
|
||||
|
||||
## 🧪 Tests (Phase 8 — Session 5)
|
||||
## 🧪 Tests (Phase 9 — Session 9 +6)
|
||||
|
||||
```
|
||||
tests/
|
||||
├── SolutionErp.Domain.Tests/ (54 test - Phase 1: WorkflowPolicy / PEPolicy / BudgetPolicy)
|
||||
└── SolutionErp.Infrastructure.Tests/ (17 + 6 = 23 test)
|
||||
├── Services/ (17 test - Phase 2: Contract + PE Code Generator)
|
||||
└── Application/ (6 test - Phase 3 mini: PeWorkflowDefinition versioning)
|
||||
└── SolutionErp.Infrastructure.Tests/ (17 + 6 + 6 = 29 test)
|
||||
├── Common/ (SqliteDbFixture + TestApplicationDbContext + IdentityFixture S9)
|
||||
├── Services/ (17 codegen + 6 PE 2-stage approval S9)
|
||||
└── Application/ (6 test - PeWorkflowDefinition versioning)
|
||||
```
|
||||
|
||||
**77 unit test pass** / ~3s. CI gate + path filter live.
|
||||
**83 unit test pass** / ~3s. CI gate + path filter live.
|
||||
|
||||
```bash
|
||||
dotnet test SolutionErp.slnx # chạy cả 2 test project
|
||||
|
||||
@ -1,6 +1,30 @@
|
||||
# HANDOFF — Brief 5 phút cho session tiếp theo
|
||||
|
||||
**Last updated:** 2026-05-04 (Session 8 — **Migration 16: 2-stage dept approval + smart reject + lock edit. Đóng bug anh Kiệt báo: NV duyệt được hết phase PE.**)
|
||||
**Last updated:** 2026-05-04 (Session 9 — **Chunk E-bis complete: FE 2-stage panel cả 3 module + UserManager bypass toggle + HĐ/Budget 2-stage mirror PE + 6 test + IdentityFixture. 83 test pass.**)
|
||||
|
||||
## TL;DR Session 9 (04/05 — Chunk E-bis sau Session 8)
|
||||
|
||||
**Output session 9** — đóng tất cả Chunk E-bis defer từ session 8:
|
||||
|
||||
- ✅ **FE PE WorkflowPanel** — Section "Tiến trình duyệt 2-cấp phòng ban" group by phase × dept, highlight amber chờ TPB confirm, badge fuchsia bypass (cả fe-admin + fe-user).
|
||||
- ✅ **FE UsersPage UserManager** — Column "Bypass" + button ShieldCheck toggle CanBypassReview, badge fuchsia khi enabled. UserDto thêm field.
|
||||
- ✅ **HĐ 2-stage logic** — `ContractWorkflowService` thêm UserManager DI + mirror logic từ PE service. `ContractDepartmentApprovalFeatures.cs` List query. Endpoint `GET /contracts/{id}/department-approvals`. FE `WorkflowHistoryPanel` section mới.
|
||||
- ✅ **Budget 2-stage logic** — `TransitionBudgetCommandHandler` thêm INotificationService + IDateTime DI + 2-stage. `BudgetDepartmentApprovalFeatures.cs` + endpoint + FE `BudgetWorkflowPanel` section.
|
||||
- ✅ **6 test PE 2-stage** — `IdentityFixture` setup full Identity stack (DbContext SQLite + AddIdentityCore + AddRoles<Role>) reusable. 6 scenario: NV_Review_Blocks / TPB_Confirm_Allows / NV_Bypass / Admin_Skip / Reject_Sets / Resume_Jumps_Back.
|
||||
- ✅ **Verify**: Build pass + 83 test pass mỗi commit (54 Domain + 29 Infra: 17 codegen + 6 PE WF Application + 6 PE 2-stage).
|
||||
- ✅ **5 commit pushed** Gitea (E2 → E6).
|
||||
|
||||
## ⚠️ CẢNH BÁO session tiếp (Session 10+)
|
||||
|
||||
1. **UAT live ngay** với anh Kiệt + 2-3 user — feature 2-stage đầy đủ cả 3 module + UX.
|
||||
2. **Tests Contract + Budget 2-stage skipped** — logic identical PE (cùng pattern, cùng entity shape). Pattern `PeTwoStageApprovalTests` reusable nếu UAT phát hiện regression riêng.
|
||||
3. **Bypass toggle audit** — chưa log Changelog khi admin toggle CanBypassReview. Audit qua Identity standard column UpdatedAt only. Có thể cần thêm audit row riêng nếu UAT yêu cầu.
|
||||
4. **Notify TPB cùng dept** dùng `UserManager.GetRolesAsync` filter `DeptManager` — verify production có user role DeptManager đúng (data already seeded).
|
||||
5. **fe-user KHÔNG có UsersPage** — admin-only function. Bypass toggle chỉ ở fe-admin.
|
||||
6. **3 endpoint mới List dept-approvals** PE/HĐ/Budget cùng pattern, reuse policy authz `*Read`.
|
||||
7. **Cron audit định kỳ 2026-05-01** vẫn EMPTY (`No scheduled jobs`). Có thể recreate khi user yêu cầu.
|
||||
|
||||
## TL;DR Session 8 (04/05 — code lớn, 5 commit per-chunk)
|
||||
|
||||
## TL;DR Session 8 (04/05 — code lớn, 5 commit per-chunk)
|
||||
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
|
||||
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
|
||||
|
||||
**Last updated:** 2026-05-04 (Session 8 — **2-stage department approval + smart reject + lock edit guards (Migration 16). Đóng bug anh Kiệt báo: NV duyệt được hết phase PE workflow.**)
|
||||
**Last updated:** 2026-05-04 (Session 9 — **Chunk E-bis complete: FE 2-stage panel cả 3 module + bypass toggle + HĐ/Budget 2-stage mirror PE + 6 test 2-stage + IdentityFixture helper.**)
|
||||
|
||||
## 📍 Phase hiện tại: **Phase 9 active — UAT + Ops + 2-stage dept approval** — **55 DB tables (52+3), 16 migrations, ~131 API endpoints (+3 dept-approvals/bypass-review), 31 FE pages (FE 2-stage chưa update). 77 unit test pass** (54 Domain + 23 Infra). 41 gotcha. 30 demo user. 6 skill.
|
||||
## 📍 Phase hiện tại: **Phase 9 active — UAT** — **55 DB tables, 16 migrations, ~133 API endpoints (+2 List dept-approvals HĐ/Budget), 31 FE pages (3 panel update 2-stage). 83 unit test pass** (54 Domain + 29 Infra: 17 codegen + 6 PE WF + 6 2-stage). 41 gotcha. 30 demo user. 6 skill.
|
||||
|
||||
### 🌐 Production URLs
|
||||
|
||||
@ -44,13 +44,13 @@
|
||||
- [ ] **Phase 4** — API smoke tests qua WebApplicationFactory ~7 test
|
||||
- [ ] **Phase 5** — FE Vitest cho lib utility (queryMatches, fmtMoney) ~10 test
|
||||
|
||||
### E. 2-stage dept approval — Chunk E-bis (FE + extend)
|
||||
### E. 2-stage dept approval — Chunk E-bis ✅ DONE (Session 9)
|
||||
|
||||
- [ ] **FE Workflow Panel update** (cả fe-admin + fe-user) — hiển thị progress 2-stage timeline per phase × dept. Dùng endpoint `GET /pe/{id}/department-approvals`.
|
||||
- [ ] **FE UserManager toggle** `CanBypassReview` checkbox per user. Endpoint `PATCH /users/{id}/bypass-review` đã sẵn.
|
||||
- [ ] **HĐ 2-stage** mở rộng: `ContractWorkflowService.TransitionAsync` thêm 2-stage logic + endpoint List `ContractDepartmentApprovals` (sau verify PE OK).
|
||||
- [ ] **Budget 2-stage** mở rộng tương tự (low priority, ít user duyệt budget per dept).
|
||||
- [ ] **Tests Phase 3 mini** cho 2-stage logic ở `PurchaseEvaluationWorkflowService` (cần UserManager DI helper).
|
||||
- [x] **FE Workflow Panel** PE — section "Tiến trình duyệt 2-cấp phòng ban" group by phase × dept, highlight amber khi chờ TPB confirm
|
||||
- [x] **FE UserManager toggle** `CanBypassReview` — column "Bypass" badge fuchsia + button toggle ShieldCheck (UsersPage)
|
||||
- [x] **HĐ 2-stage** mở rộng — `ContractWorkflowService` thêm UserManager DI + 2-stage logic mirror PE + `ContractDepartmentApprovalFeatures.cs` + endpoint `GET /contracts/{id}/department-approvals` + FE WorkflowHistoryPanel section mới
|
||||
- [x] **Budget 2-stage** mở rộng — `TransitionBudgetCommandHandler` thêm INotificationService + IDateTime + 2-stage logic + `BudgetDepartmentApprovalFeatures.cs` + endpoint + FE BudgetWorkflowPanel section
|
||||
- [x] **Tests 2-stage** (6 test) — `IdentityFixture` setup full Identity stack + 6 test PE workflow service: NV review block / TPB confirm allow / NV bypass / Admin skip / Reject set / Resume jump-back. Pattern reusable.
|
||||
|
||||
### F. Audit định kỳ (cron tự fire)
|
||||
|
||||
@ -60,6 +60,7 @@
|
||||
|
||||
| Ngày | Ai | Task | Commit |
|
||||
|---|---|---|---|
|
||||
| 2026-05-04 | Claude | **Session 9 — Chunk E-bis complete: FE 2-stage panel + UserManager bypass toggle + HĐ/Budget 2-stage mirror PE + 6 test + IdentityFixture** — User chỉ thị "làm hết cho xong tính năng luôn". 5 chunk per-commit (build + 83 test pass mỗi chunk): (E2) FE PeWorkflowPanel section "Tiến trình duyệt 2-cấp phòng ban" group by phase × dept, highlight amber chờ TPB, badge fuchsia bypass — cả 2 app (rule §3.9). (E3) FE UsersPage column "Bypass" + ShieldCheck toggle button + UserDto.CanBypassReview field. (E4) ContractWorkflowService thêm UserManager DI + mirror 2-stage logic từ PE + `ContractDepartmentApprovalFeatures.cs` (List query) + endpoint `GET /contracts/{id}/department-approvals` + FE WorkflowHistoryPanel section. (E5) Budget mirror đầy đủ — `TransitionBudgetCommandHandler` thêm INotificationService + IDateTime DI + 2-stage logic + `BudgetDepartmentApprovalFeatures.cs` + endpoint + FE BudgetWorkflowPanel. (E6) `IdentityFixture` setup ServiceProvider với Identity stack đầy đủ (DbContext SQLite + AddIdentityCore + AddRoles<Role> + EF stores) + 6 test PE 2-stage: NV_Review_Blocks / TPB_Confirm_Allows / NV_Bypass / Admin_Skip / Reject_Sets_RejectedFromPhase / Resume_Jumps_Back. Tests Contract + Budget skip vì logic identical PE, ROI thấp. **Total 77→83 test pass.** 5 commit pushed lên Gitea. | `f8eebd5` (E2) · `4380bdc` (E3) · `b6f5a16` (E4) · `1fc439b` (E5) · `8353fe8` (E6) · (current E7) |
|
||||
| 2026-05-04 | Claude | **Session 8 — Migration 16: 2-stage dept approval + smart reject + lock edit (đóng bug anh Kiệt)** — Anh Kiệt báo: NV.PRO tạo phiếu PE → duyệt được hết phase = phân quyền sai. Schema mới: 3 bảng `*DepartmentApprovals` (Contract/PE/Budget) UNIQUE (TargetId, Phase, Dept, Stage). 4 cột mới: `Users.CanBypassReview` bit + 3 `RejectedFromPhase` int. Logic 2-stage trong `PurchaseEvaluationWorkflowService.TransitionAsync`: user.DepartmentId != null → DeptManager (TPB) Stage=Confirm; CanBypassReview=true → Stage=Confirm+IsBypassed; else NV → Stage=Review only, BLOCK transition cho đến khi TPB confirm. Smart reject: Decision=Reject → set RejectedFromPhase, force về DangSoanThao. Resume sau reject: Drafter trình lại từ DangSoanThao + RejectedFromPhase != null → jump straight tới phase đã reject (skip phase trung gian). Lock edit: 17 handler thêm guard Phase != DangSoanThao (Contract Detail × 15, PE Detail × 5, Budget Detail × 3). 3 endpoint mới: `GET /pe/{id}/department-approvals` (FE Workflow Panel hiển thị progress) + `PATCH /users/{id}/bypass-review` (admin toggle) + Notify TPB cùng dept khi NV review. **HĐ + Budget 2-stage scope defer** (chỉ PE first đóng bug). FE update + Tests defer Chunk E-bis. | `5fe61cc` (A) · `14f3c9f` (B) · `9747f8c` (C) · `a532ba6` (D) · (current) |
|
||||
| 2026-04-30 | Claude | **Session 6 — MD audit + compact + 3 skill refresh + 2 rule mới** — Compact 3 file core (-288 dòng): STATUS -27%, HANDOFF -32%, migration-todos -35%. Archive 51 row Recently Done Phase 0-7 → `changelog/recently-done-archive-2026-04.md`. Refresh 3 skill stale: `form-engine` (Phase 2 MVP → Tier 3 feature-complete + bỏ section duplicate gen mã HĐ), `permission-matrix` (12 menu → ~60 menu key + Bg_*/Pe_*/PeWf_* + inheritance roots), `ef-core-migration` (24 DbSet → 52 bảng + ERD update). Rule mới `rules.md §7 Khi nào viết test — timing rule` (5-row table compact, sau khi rút gọn từ 70 dòng overkill). Rule mới `rules.md §6.4 Audit + compact MD định kỳ` (cadence + checklist + anti-pattern, KHÔNG rewrite toàn bộ). `rules.md §9.4 Skill audit` mở rộng cross-ref §6.4. | (current) |
|
||||
| 2026-04-29 | Claude | **Tests Phase 3 mini + 3 gotcha CI mới (#39 #40 #41)** — `tests/.../Application/PeWorkflowAdminTests.cs` 6 test versioning logic (CreatePeWorkflowDefinition: first version IsActive=true, second deactivates first, different EvaluationType independent, persists steps ordered + approvers per step, third version increments to v3). Total **77 test** (54 Domain + 17 Infra + 6 PE WF Application). Gotcha #39 act_runner github.com TCP timeout 21s + manual checkout fix. #40 npm junction cache fail `tsc not found` rolled back. #41 paths-ignore behavior + workflow file exclusion. | `b874743` |
|
||||
|
||||
@ -157,6 +157,25 @@ Session log: `2026-04-28-chot-session-4-budget.md`.
|
||||
|
||||
## 📝 Phase 9 — UAT + Ops + carry over (Session 6+ active)
|
||||
|
||||
### ✅ Session 9 done (2026-05-04) — Chunk E-bis complete (FE 2-stage + HĐ/Budget mirror + 6 test)
|
||||
|
||||
User chỉ thị "làm hết cho xong tính năng luôn" sau Session 8 close bug fix anh Kiệt phía BE PE. Session 9 đóng toàn bộ pending Chunk E-bis (defer từ session 8).
|
||||
|
||||
- [x] **Chunk E2 — FE Workflow Panel PE** hiển thị progress 2-stage timeline per phase × dept (commit `f8eebd5`). Component `DeptApprovalsSection` group by phase × dept, highlight amber khi current phase có Review nhưng chưa Confirm, badge fuchsia "bypass" khi NV.CanBypassReview. Cả fe-admin + fe-user (rule §3.9).
|
||||
- [x] **Chunk E3 — FE UserManager toggle CanBypassReview** (commit `4380bdc`). UserDto BE thêm field `CanBypassReview`. UsersPage column "Bypass" + button ShieldCheck (icon highlight fuchsia khi enabled). Endpoint backend đã có sẵn từ Session 8 Chunk E1. fe-user KHÔNG có UsersPage (admin-only).
|
||||
- [x] **Chunk E4 — HĐ 2-stage logic mở rộng** (commit `b6f5a16`). ContractWorkflowService thêm `UserManager<User>` DI + mirror toàn bộ logic 2-stage từ PE service (sau policy guard, trước gen mã HĐ). ContractDepartmentApprovalFeatures.cs (List query mirror PE pattern). Endpoint `GET /contracts/{id}/department-approvals`. FE WorkflowHistoryPanel section "Tiến trình duyệt 2-cấp phòng ban" insert giữa WorkflowSummaryCard và Lịch sử duyệt.
|
||||
- [x] **Chunk E5 — Budget 2-stage logic mở rộng** (commit `1fc439b`). TransitionBudgetCommandHandler thêm `INotificationService` + `IDateTime` DI + mirror 2-stage logic. BudgetDepartmentApprovalFeatures.cs + endpoint. FE BudgetWorkflowPanel section "Tiến trình duyệt 2-cấp phòng ban". Note: low-priority cho Budget (ít user duyệt budget per dept) nhưng giữ consistent UX 3 module.
|
||||
- [x] **Chunk E6 — 6 test 2-stage + IdentityFixture helper** (commit `8353fe8`). IdentityFixture (Common/) setup ServiceProvider với Identity stack đầy đủ — DbContext SQLite shared connection + AddIdentityCore<User> + AddRoles<Role> + AddEntityFrameworkStores. Single shared scope cho fixture lifetime đảm bảo DbContext + UserManager đồng instance. Helper `CreateUserAsync(email, name, deptId, roles, canBypassReview)` reusable. 6 test PeTwoStageApprovalTests: NV_Review_Blocks_Phase_Transition (đóng bug anh Kiệt — test chính xác) / TPB_Confirm_After_NV_Review_Allows_Transition / NV_With_BypassReview_Allows_Transition_With_IsBypassed_True / Admin_Skips_TwoStage_Logic_Entirely / Reject_Sets_RejectedFromPhase_And_Forces_DangSoanThao / Resume_After_Reject_Jumps_Back_To_RejectedPhase. Tests Contract + Budget 2-stage skipped — logic identical PE, ROI thấp; pattern reusable nếu UAT phát hiện regression riêng. **Total 77→83 test pass**.
|
||||
|
||||
Stats sau session 9:
|
||||
- BE LOC: ~13750 → ~14400 (+650, gồm Contract + Budget 2-stage logic + 2 List feature + Test fixture)
|
||||
- API endpoints: ~131 → ~133 (+2 List dept-approvals HĐ/Budget)
|
||||
- Tests: 77 → **83** (+6 PE 2-stage)
|
||||
- Schema không đổi (Migration 16 đã có sẵn từ session 8)
|
||||
- 5 commit per-chunk pushed → 1 CI run trigger qua HEAD = `8353fe8`
|
||||
|
||||
Pending còn lại Phase 9: chỉ Hard blockers Ops (UAT thật / SMTP / Rotate creds / SQL backup). Feature 2-stage đầy đủ.
|
||||
|
||||
### ✅ Session 8 done (2026-05-04) — Migration 16: 2-stage dept approval + smart reject + lock edit
|
||||
|
||||
**Bối cảnh:** Anh Kiệt (FDC) báo bug PE workflow: NV.PRO tạo phiếu → duyệt được hết phase. Phân quyền sai vì policy chỉ check role, không check Stage 2-cấp.
|
||||
@ -179,12 +198,12 @@ Session log: `2026-04-28-chot-session-4-budget.md`.
|
||||
|
||||
Session log: `2026-05-04-1230-chot-session-8-2-stage-dept-approval.md`.
|
||||
|
||||
**Pending Chunk E-bis (defer):**
|
||||
- [ ] FE Workflow Panel hiển thị progress 2-stage timeline
|
||||
- [ ] FE UserManager toggle `CanBypassReview` checkbox
|
||||
- [ ] HĐ 2-stage mở rộng (`ContractWorkflowService` thêm 2-stage logic + endpoint List)
|
||||
- [ ] Budget 2-stage mở rộng (low priority)
|
||||
- [ ] Tests 2-stage logic Service-layer (cần UserManager DI helper)
|
||||
**Pending Chunk E-bis** ✅ TẤT CẢ DONE ở Session 9 (xem section trên):
|
||||
- [x] FE Workflow Panel hiển thị progress 2-stage timeline (E2 — PE) + HĐ (E4) + Budget (E5)
|
||||
- [x] FE UserManager toggle `CanBypassReview` checkbox (E3)
|
||||
- [x] HĐ 2-stage mở rộng (E4)
|
||||
- [x] Budget 2-stage mở rộng (E5)
|
||||
- [x] Tests 2-stage logic Service-layer (E6 — 6 test PE + IdentityFixture)
|
||||
|
||||
### ✅ Session 6 done (2026-04-30 — pure docs work)
|
||||
|
||||
|
||||
@ -0,0 +1,319 @@
|
||||
# Session log — 2026-05-04 chốt session 9 — Chunk E-bis complete
|
||||
|
||||
**Topic:** User chỉ thị "làm hết cho xong tính năng luôn đi nhé" sau Session 8 đóng bug PE 2-stage. Session 9 close toàn bộ Chunk E-bis defer (FE 2-stage panel cả 3 module + UserManager bypass toggle + HĐ + Budget 2-stage mirror PE + 6 test 2-stage + IdentityFixture helper).
|
||||
|
||||
**Dev:** Claude (Opus 4.7) + user (pqhuy1987@gmail.com)
|
||||
**Duration:** ~3 giờ (gồm Chunk E2-E7 + verify build/test/push).
|
||||
**Base commit:** `d206e14` (chốt session 8 ending — patch count drift + skill refresh).
|
||||
|
||||
## Bối cảnh
|
||||
|
||||
Sau Session 8, BE PE 2-stage approval đã live trên prod (https://api.solutions.com.vn). Anh Kiệt FDC có thể test bug fix. Tuy nhiên còn pending:
|
||||
|
||||
- FE Workflow Panel chưa hiển thị 2-stage progress → user thấy "stuck" mà không hiểu
|
||||
- FE UserManager chưa có toggle CanBypassReview → admin phải PATCH qua Postman
|
||||
- HĐ + Budget 2-stage scope **defer** từ Session 8 (chỉ áp PE)
|
||||
- Tests 2-stage Service-layer chưa có (cần UserManager DI helper)
|
||||
|
||||
User chỉ thị "làm hết cho xong tính năng luôn" → close toàn bộ Chunk E-bis.
|
||||
|
||||
## Approach
|
||||
|
||||
Per-chunk commit pattern (Session 8 đã proven). 5 chunk small (mỗi <300 LOC change), build + test pass mỗi chunk:
|
||||
|
||||
- E2 — FE PE WorkflowPanel 2-stage timeline
|
||||
- E3 — FE UsersPage CanBypassReview toggle
|
||||
- E4 — HĐ 2-stage logic + endpoint + FE
|
||||
- E5 — Budget 2-stage logic + endpoint + FE
|
||||
- E6 — Tests + IdentityFixture
|
||||
- E7 — Docs update + commit + push
|
||||
|
||||
## Commits session 9
|
||||
|
||||
5 commit code + 1 commit docs ending:
|
||||
|
||||
- `f8eebd5` — E2: FE PE 2-stage timeline cả 2 app
|
||||
- `4380bdc` — E3: FE UserManager bypass toggle
|
||||
- `b6f5a16` — E4: HĐ 2-stage mirror PE
|
||||
- `1fc439b` — E5: Budget 2-stage mirror PE
|
||||
- `8353fe8` — E6: 6 test 2-stage + IdentityFixture
|
||||
- (current) — E7: Docs + session log
|
||||
|
||||
## E2 — FE PE WorkflowPanel 2-stage timeline
|
||||
|
||||
### Frontend (cả fe-admin + fe-user)
|
||||
|
||||
```typescript
|
||||
// types/purchaseEvaluation.ts:
|
||||
export const ApprovalStage = { Review: 1, Confirm: 2 } as const
|
||||
export type PeDepartmentApproval = {
|
||||
id: string; phaseAtApproval: number; departmentId: string
|
||||
departmentName: string | null; stage: number
|
||||
approverUserId: string; approverName: string | null
|
||||
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||
comment: string | null; approvedAt: string; isBypassed: boolean
|
||||
}
|
||||
|
||||
// PeWorkflowPanel.tsx:
|
||||
const { data: deptApprovals = [] } = useQuery<PeDepartmentApproval[]>({
|
||||
queryKey: ['pe-dept-approvals', evaluation.id],
|
||||
queryFn: async () => (await api.get(`/purchase-evaluations/${id}/department-approvals`)).data,
|
||||
})
|
||||
```
|
||||
|
||||
### DeptApprovalsSection component
|
||||
|
||||
Group by phase × dept. Render 2 row per dept:
|
||||
- **Review NV** (slate text) — ✓ tên + thời gian + comment
|
||||
- **Confirm TPB** (emerald hoặc amber) — ✓ hoặc "⏳ chờ TPB confirm"
|
||||
|
||||
Highlight border amber khi `phase === currentPhase && review && !confirm` → user biết "đang chờ TPB confirm".
|
||||
|
||||
Badge fuchsia "bypass" khi `isBypassed=true`.
|
||||
|
||||
Invalidate query sau transition mutation để refresh ngay.
|
||||
|
||||
## E3 — FE UsersPage CanBypassReview toggle
|
||||
|
||||
### Backend UserDto extend
|
||||
|
||||
```csharp
|
||||
// UserFeatures.cs
|
||||
public record UserDto(
|
||||
Guid Id, string Email, string FullName, bool IsActive, bool IsLocked,
|
||||
DateTime CreatedAt, List<string> Roles, Guid? DepartmentId,
|
||||
string? DepartmentName, string? Position,
|
||||
bool CanBypassReview); // NEW
|
||||
```
|
||||
|
||||
ListUsers + GetUser handler đều thêm `u.CanBypassReview` vào DTO instantiation.
|
||||
|
||||
### Frontend UsersPage
|
||||
|
||||
```tsx
|
||||
// types/users.ts
|
||||
export type User = { ...; canBypassReview: boolean }
|
||||
|
||||
// UsersPage.tsx column "Bypass":
|
||||
{ key: 'canBypassReview', header: 'Bypass', width: 'w-20', align: 'center',
|
||||
render: u => u.canBypassReview ? (
|
||||
<span className="rounded bg-fuchsia-100 ... text-fuchsia-700">
|
||||
<ShieldCheck /> bypass
|
||||
</span>
|
||||
) : <span className="text-slate-400">—</span> }
|
||||
|
||||
// Action button toggle:
|
||||
const bypassMut = useMutation({
|
||||
mutationFn: (u: User) =>
|
||||
api.patch(`/users/${u.id}/bypass-review`,
|
||||
{ canBypassReview: !u.canBypassReview }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||
})
|
||||
|
||||
<Button onClick={() => bypassMut.mutate(u)}>
|
||||
<ShieldCheck className={u.canBypassReview ? 'text-fuchsia-600' : 'text-slate-400'} />
|
||||
</Button>
|
||||
```
|
||||
|
||||
Endpoint backend `PATCH /users/{id}/bypass-review` đã sẵn từ Session 8 Chunk E1. Chỉ wire FE.
|
||||
|
||||
fe-user KHÔNG có UsersPage (admin-only function) — chỉ update fe-admin.
|
||||
|
||||
## E4 — HĐ 2-stage logic mở rộng
|
||||
|
||||
### ContractWorkflowService
|
||||
|
||||
Thêm `UserManager<User>` DI:
|
||||
|
||||
```csharp
|
||||
public class ContractWorkflowService(
|
||||
IApplicationDbContext db,
|
||||
IContractCodeGenerator codeGenerator,
|
||||
IDateTime dateTime,
|
||||
INotificationService notifications,
|
||||
IChangelogService changelog,
|
||||
UserManager<User> userManager) : IContractWorkflowService
|
||||
```
|
||||
|
||||
Mirror toàn bộ logic 2-stage từ `PurchaseEvaluationWorkflowService.TransitionAsync`. Inject sau policy guard, trước gen mã HĐ:
|
||||
|
||||
```csharp
|
||||
if (decision == ApprovalDecision.Approve
|
||||
&& targetPhase != ContractPhase.DangSoanThao
|
||||
&& targetPhase != ContractPhase.TuChoi
|
||||
&& !isResumingAfterReject
|
||||
&& !isAdmin && !isSystem
|
||||
&& actorUserId is Guid actorUid)
|
||||
{
|
||||
var actor = await userManager.FindByIdAsync(actorUid.ToString());
|
||||
if (actor?.DepartmentId is Guid deptId)
|
||||
{
|
||||
var isManager = actorRoles.Contains(AppRoles.DeptManager);
|
||||
var canBypass = actor.CanBypassReview;
|
||||
var stage = (isManager || canBypass) ? Confirm : Review;
|
||||
// ... upsert ContractDepartmentApproval, check hasConfirm, BLOCK nếu chưa
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ContractDepartmentApprovalFeatures.cs (List query)
|
||||
|
||||
Mirror PE pattern. Join với Departments + Users (separate query) để denorm name.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```http
|
||||
GET /api/contracts/{id}/department-approvals
|
||||
[Authorize(Policy = "Contracts.Read")] // qua [Authorize] trên controller class
|
||||
```
|
||||
|
||||
### FE WorkflowHistoryPanel
|
||||
|
||||
Section `DeptApprovalsSection` insert giữa `WorkflowSummaryCard` và "Lịch sử duyệt". Cùng pattern PE — group by phase × dept, highlight amber, badge fuchsia.
|
||||
|
||||
## E5 — Budget 2-stage mirror PE/Contract
|
||||
|
||||
### TransitionBudgetCommandHandler
|
||||
|
||||
Thêm 2 dependency mới: `INotificationService` + `IDateTime`. Mirror toàn bộ logic 2-stage. Note: Budget low-priority (ít user duyệt budget per dept) nhưng giữ consistent UX 3 module.
|
||||
|
||||
### BudgetDepartmentApprovalFeatures.cs
|
||||
|
||||
List query mirror PE/Contract pattern.
|
||||
|
||||
### Endpoint + FE
|
||||
|
||||
`GET /api/budgets/{id}/department-approvals` + section trong `BudgetWorkflowPanel`.
|
||||
|
||||
## E6 — Tests + IdentityFixture
|
||||
|
||||
### IdentityFixture (Common/)
|
||||
|
||||
Setup ServiceProvider với Identity stack đầy đủ. Key insight: dùng `Role` custom (extend `IdentityRole<Guid>`) thay vì `IdentityRole<Guid>` plain — match `ApplicationDbContext : IdentityDbContext<User, Role, Guid>`.
|
||||
|
||||
```csharp
|
||||
services.AddScoped<ApplicationDbContext>(_ =>
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection).EnableSensitiveDataLogging().Options;
|
||||
return new TestApplicationDbContext(options);
|
||||
});
|
||||
services.AddScoped<TestApplicationDbContext>(sp =>
|
||||
(TestApplicationDbContext)sp.GetRequiredService<ApplicationDbContext>());
|
||||
|
||||
services.AddIdentityCore<User>(...)
|
||||
.AddRoles<Role>() // ← KEY: Role không IdentityRole<Guid>
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>();
|
||||
|
||||
_root = services.BuildServiceProvider();
|
||||
Services = _root.CreateScope().ServiceProvider; // single shared scope
|
||||
```
|
||||
|
||||
Helper `CreateUserAsync(email, name, deptId, roles, canBypassReview)` reusable cho tests sau.
|
||||
|
||||
### 6 test PeTwoStageApprovalTests
|
||||
|
||||
| Test | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `NV_Review_Blocks_Phase_Transition` | NV.PRO approve phase ChoPurchasing | Phase **không đổi**, 1 row Stage=Review, 1 PE Approval `[Review NV]` |
|
||||
| `TPB_Confirm_After_NV_Review_Allows_Transition` | NV review → TPB confirm | Phase chuyển ChoCCM, 2 rows (Review + Confirm) |
|
||||
| `NV_With_BypassReview_Allows_Transition_With_IsBypassed_True` | NV.CanBypassReview=true approve | Phase chuyển, 1 row Stage=Confirm + IsBypassed=true |
|
||||
| `Admin_Skips_TwoStage_Logic_Entirely` | Admin role approve | Phase chuyển, **0 row** DepartmentApprovals |
|
||||
| `Reject_Sets_RejectedFromPhase_And_Forces_DangSoanThao` | TPB reject từ ChoCCM | Phase=DangSoanThao, RejectedFromPhase=ChoCCM |
|
||||
| `Resume_After_Reject_Jumps_Back_To_RejectedPhase` | Admin resume từ DangSoanThao + RejectedFromPhase=ChoCCM | Phase jump tới ChoCCM (không phải target ChoPurchasing), RejectedFromPhase=null |
|
||||
|
||||
Stub `FakeNotificationService` — best effort path không cần verify.
|
||||
|
||||
Tests Contract + Budget 2-stage **skip** — logic identical PE, ROI thấp. Pattern reusable nếu UAT phát hiện regression riêng.
|
||||
|
||||
## Verify
|
||||
|
||||
```
|
||||
✓ Build pass mỗi commit (2 warning DocxRenderer cũ — không liên quan)
|
||||
✓ 83 unit test pass mỗi commit (54 Domain + 29 Infra)
|
||||
- Trước: 77 (54 + 17 + 6)
|
||||
- Sau: 83 (54 + 17 + 6 + 6 PE 2-stage)
|
||||
✓ FE build pass cả 2 app mỗi chunk có FE change
|
||||
✓ TS strict mode + erasableSyntaxOnly check pass
|
||||
```
|
||||
|
||||
## Files touched session 9
|
||||
|
||||
```
|
||||
fe-admin/src/types/purchaseEvaluation.ts (mod)
|
||||
fe-admin/src/components/pe/PeWorkflowPanel.tsx (mod)
|
||||
fe-admin/src/types/users.ts (mod)
|
||||
fe-admin/src/pages/system/UsersPage.tsx (mod)
|
||||
fe-admin/src/types/contracts.ts (mod)
|
||||
fe-admin/src/components/contracts/WorkflowHistoryPanel.tsx (mod)
|
||||
fe-admin/src/types/budget.ts (mod)
|
||||
fe-admin/src/components/budgets/BudgetWorkflowPanel.tsx (mod)
|
||||
|
||||
fe-user/src/types/purchaseEvaluation.ts (mod, sync)
|
||||
fe-user/src/components/pe/PeWorkflowPanel.tsx (mod, sync)
|
||||
fe-user/src/types/contracts.ts (mod, sync)
|
||||
fe-user/src/components/contracts/WorkflowHistoryPanel.tsx (mod, sync)
|
||||
fe-user/src/types/budget.ts (mod, sync)
|
||||
fe-user/src/components/budgets/BudgetWorkflowPanel.tsx (mod, sync)
|
||||
|
||||
src/Backend/SolutionErp.Application/Users/UserFeatures.cs (mod: +CanBypassReview field DTO)
|
||||
src/Backend/SolutionErp.Application/Contracts/ContractDepartmentApprovalFeatures.cs (NEW)
|
||||
src/Backend/SolutionErp.Application/Budgets/BudgetDepartmentApprovalFeatures.cs (NEW)
|
||||
src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs (mod: +2-stage logic + DI)
|
||||
|
||||
src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs (mod: +UserManager DI + 2-stage)
|
||||
|
||||
src/Backend/SolutionErp.Api/Controllers/ContractsController.cs (mod: +1 endpoint)
|
||||
src/Backend/SolutionErp.Api/Controllers/BudgetsController.cs (mod: +1 endpoint)
|
||||
|
||||
tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs (NEW)
|
||||
tests/SolutionErp.Infrastructure.Tests/Services/PeTwoStageApprovalTests.cs (NEW)
|
||||
|
||||
docs/STATUS.md (mod)
|
||||
docs/HANDOFF.md (mod)
|
||||
docs/changelog/migration-todos.md (mod)
|
||||
docs/CLAUDE.md (mod)
|
||||
CLAUDE.md (mod: 77→83 test)
|
||||
docs/changelog/sessions/2026-05-04-1700-chot-session-9-*.md (NEW: file này)
|
||||
```
|
||||
|
||||
## Cảnh báo session 10+
|
||||
|
||||
1. **UAT live ngay** với anh Kiệt + 2-3 user — feature 2-stage đầy đủ cả 3 module + UX panel + bypass toggle.
|
||||
2. **Tests Contract + Budget skipped** — logic identical PE. Pattern `PeTwoStageApprovalTests` reusable.
|
||||
3. **Bypass toggle audit** — chưa log Changelog khi admin toggle CanBypassReview. Có thể cần thêm audit row riêng nếu UAT yêu cầu.
|
||||
4. **Notify TPB cùng dept** dùng UserManager filter DeptManager — verify production user có role đúng.
|
||||
5. **fe-user KHÔNG có UsersPage** — admin-only function, bypass toggle chỉ ở fe-admin.
|
||||
6. **3 endpoint mới** PE + HĐ + Budget List dept-approvals cùng pattern, reuse policy `*.Read` qua [Authorize] class-level.
|
||||
7. **Cron audit định kỳ** vẫn EMPTY (`No scheduled jobs`) — recreate khi user yêu cầu.
|
||||
|
||||
## Lessons learned
|
||||
|
||||
1. **Mirror logic chuẩn xác giảm bug** — Contract + Budget 2-stage clone pattern PE service y nguyên (chỉ thay entity/enum names) → giảm rủi ro logic divergent. Future refactor có thể extract thành `IDepartmentApprovalGuard<TEntity, TPhase>` nếu pattern lặp lần thứ 4.
|
||||
|
||||
2. **IdentityFixture investment trade-off** — setup tốn 30-45 phút (struggle với DbContext options + Role custom type), nhưng future tests (Application handler tests) sẽ reuse được. ROI dài hạn dương.
|
||||
|
||||
3. **Single shared scope** trong fixture quan trọng — UserManager + DbContext cần đồng instance để CreateAsync persist data nhìn thấy được trong service test sau đó. Nếu mỗi resolve scope mới → DbContext khác → data invisible cross calls.
|
||||
|
||||
4. **`Role` custom subclass** — match exactly với `IdentityDbContext<User, Role, Guid>`. Pass `IdentityRole<Guid>` → `RoleStore` query DbSet không tồn tại → `EntityType not found` lỗi tinh quái khó debug.
|
||||
|
||||
5. **fe-user duplicate file pattern §3.9** — cp file giữa 2 app sau khi edit fe-admin xong. Đỡ phải edit 2 lần. Diff trước cp để verify identical.
|
||||
|
||||
6. **User chỉ thị "làm hết cho xong"** = open license cho per-chunk commit + push final. Giữ pattern Session 8 (5 chunk + verify mỗi chunk) tránh monolithic commit khó debug.
|
||||
|
||||
## Stats sau session 9
|
||||
|
||||
| | Trước S9 | Sau S9 |
|
||||
|---|---:|---:|
|
||||
| BE LOC | ~13750 | ~14400 (+650) |
|
||||
| DB tables | 55 | 55 (không đổi) |
|
||||
| Migrations | 16 | 16 (không đổi) |
|
||||
| API endpoints | ~131 | **~133** (+2 List dept-approvals HĐ/Budget) |
|
||||
| FE pages | ~31 | ~31 (không đổi, chỉ component panel update) |
|
||||
| FE components | — | +DeptApprovalsSection × 3 panel + bypass column UsersPage |
|
||||
| Tests | 77 | **83** (+6 PE 2-stage) |
|
||||
| Test fixtures | SqliteDbFixture | + IdentityFixture (reusable) |
|
||||
| Gotchas | 41 | 41 (không có gotcha mới đáng ghi) |
|
||||
| Demo user | 30 | 30 |
|
||||
| Commits S9 | 0 | **5** (E2-E6) + docs E7 |
|
||||
| Session log | 19 | **20** |
|
||||
@ -1,7 +1,7 @@
|
||||
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
|
||||
// Pulls nextPhases từ BE bundle (single source of truth).
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@ -12,9 +12,11 @@ import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
ApprovalDecision,
|
||||
ApprovalStage,
|
||||
BudgetPhase,
|
||||
BudgetPhaseColor,
|
||||
BudgetPhaseLabel,
|
||||
type BudgetDepartmentApproval,
|
||||
type BudgetDetailBundle,
|
||||
} from '@/types/budget'
|
||||
import { BudgetApprovalsSection, BudgetHistorySection } from './BudgetDetailTabs'
|
||||
@ -24,6 +26,12 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
||||
const [comment, setComment] = useState('')
|
||||
const qc = useQueryClient()
|
||||
|
||||
// 2-stage dept approvals (Migration 16) — fetch riêng để FE render timeline.
|
||||
const { data: deptApprovals = [] } = useQuery<BudgetDepartmentApproval[]>({
|
||||
queryKey: ['budget-dept-approvals', budget.id],
|
||||
queryFn: async () => (await api.get(`/budgets/${budget.id}/department-approvals`)).data,
|
||||
})
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: async () =>
|
||||
api.post(`/budgets/${budget.id}/transitions`, {
|
||||
@ -36,6 +44,7 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
||||
qc.invalidateQueries({ queryKey: ['budget-detail', budget.id] })
|
||||
qc.invalidateQueries({ queryKey: ['budget-list'] })
|
||||
qc.invalidateQueries({ queryKey: ['budget-changelog', budget.id] })
|
||||
qc.invalidateQueries({ queryKey: ['budget-dept-approvals', budget.id] })
|
||||
setTarget(null)
|
||||
setComment('')
|
||||
},
|
||||
@ -116,6 +125,12 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{deptApprovals.length > 0 && (
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<BudgetDeptApprovalsSection rows={deptApprovals} currentPhase={budget.phase} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<BudgetApprovalsSection budget={budget} />
|
||||
</div>
|
||||
@ -127,6 +142,81 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
||||
)
|
||||
}
|
||||
|
||||
// 2-stage dept approval timeline (Migration 16) — mirror PE/Contract pattern.
|
||||
function BudgetDeptApprovalsSection({
|
||||
rows,
|
||||
currentPhase,
|
||||
}: {
|
||||
rows: BudgetDepartmentApproval[]
|
||||
currentPhase: number
|
||||
}) {
|
||||
const grouped = new Map<number, Map<string, BudgetDepartmentApproval[]>>()
|
||||
for (const r of rows) {
|
||||
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||||
const byDept = grouped.get(r.phaseAtApproval)!
|
||||
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||||
byDept.get(r.departmentId)!.push(r)
|
||||
}
|
||||
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||||
<div className="mt-2 space-y-3">
|
||||
{phaseOrder.map(phase => {
|
||||
const byDept = grouped.get(phase)!
|
||||
return (
|
||||
<div key={phase}>
|
||||
<div className={cn(
|
||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
BudgetPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
|
||||
)}>
|
||||
{BudgetPhaseLabel[phase] ?? `Phase ${phase}`}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{[...byDept.entries()].map(([deptId, stages]) => {
|
||||
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||||
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||||
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||||
const isPending = phase === currentPhase && review && !confirm
|
||||
return (
|
||||
<div key={deptId} className={cn(
|
||||
'rounded border px-2 py-1.5 text-[11px]',
|
||||
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||||
)}>
|
||||
<div className="font-medium text-slate-700">{deptName}</div>
|
||||
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||||
<span className="text-slate-500">Review:</span>
|
||||
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||||
{review
|
||||
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||||
: '— chưa có'}
|
||||
</span>
|
||||
<span className="text-slate-500">Confirm:</span>
|
||||
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||||
{confirm
|
||||
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||||
: '⏳ chờ TPB confirm'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
||||
const orderedIdx = active.indexOf(p)
|
||||
const currentIdx = active.indexOf(current)
|
||||
|
||||
@ -2,19 +2,39 @@
|
||||
// approval history (ai/khi/quyết định gì) + ContractChangelogsTab (mọi thay
|
||||
// đổi header/detail/comment/attachment/transition) vào 1 stack. Dùng cho cả
|
||||
// MyContracts 3-panel và ContractDetailPage fullpage.
|
||||
import { ArrowRight, Clock, History } from 'lucide-react'
|
||||
import { ArrowRight, Clock, History, Users2 } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { WorkflowSummaryCard } from '@/components/WorkflowSummaryCard'
|
||||
import { ContractChangelogsTab } from '@/components/contracts/ContractChangelogsTab'
|
||||
import type { ContractDetail } from '@/types/contracts'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
ApprovalStage,
|
||||
ContractPhaseColor,
|
||||
ContractPhaseLabel,
|
||||
type ContractDepartmentApproval,
|
||||
type ContractDetail,
|
||||
} from '@/types/contracts'
|
||||
|
||||
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||
const fmtTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail }) {
|
||||
const { data: deptApprovals = [] } = useQuery<ContractDepartmentApproval[]>({
|
||||
queryKey: ['contract-dept-approvals', c.id],
|
||||
queryFn: async () => (await api.get(`/contracts/${c.id}/department-approvals`)).data,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{c.workflow && <WorkflowSummaryCard workflow={c.workflow} currentPhase={c.phase} />}
|
||||
|
||||
{deptApprovals.length > 0 && (
|
||||
<DeptApprovalsSection rows={deptApprovals} currentPhase={c.phase} />
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Clock className="h-4 w-4" />
|
||||
@ -54,3 +74,78 @@ export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 2-stage dept approval timeline (Migration 16). Group by phase × dept. Same
|
||||
// pattern PE — block phase transition khi current phase có Review nhưng chưa
|
||||
// Confirm → highlight amber để user biết "đang chờ TPB confirm".
|
||||
function DeptApprovalsSection({
|
||||
rows,
|
||||
currentPhase,
|
||||
}: {
|
||||
rows: ContractDepartmentApproval[]
|
||||
currentPhase: number
|
||||
}) {
|
||||
const grouped = new Map<number, Map<string, ContractDepartmentApproval[]>>()
|
||||
for (const r of rows) {
|
||||
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||||
const byDept = grouped.get(r.phaseAtApproval)!
|
||||
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||||
byDept.get(r.departmentId)!.push(r)
|
||||
}
|
||||
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-1 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Users2 className="h-4 w-4" />
|
||||
Tiến trình duyệt 2-cấp phòng ban
|
||||
</h2>
|
||||
<p className="mb-3 text-xs text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||||
<div className="space-y-3">
|
||||
{phaseOrder.map(phase => {
|
||||
const byDept = grouped.get(phase)!
|
||||
return (
|
||||
<div key={phase}>
|
||||
<div className={cn(
|
||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
ContractPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
|
||||
)}>
|
||||
{ContractPhaseLabel[phase] ?? `Phase ${phase}`}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{[...byDept.entries()].map(([deptId, stages]) => {
|
||||
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||||
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||||
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||||
const isPending = phase === currentPhase && review && !confirm
|
||||
return (
|
||||
<div key={deptId} className={cn(
|
||||
'rounded border px-2 py-1.5 text-[11px]',
|
||||
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||||
)}>
|
||||
<div className="font-medium text-slate-700">{deptName}</div>
|
||||
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||||
<span className="text-slate-500">Review:</span>
|
||||
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||||
{review
|
||||
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||||
: '— chưa có'}
|
||||
</span>
|
||||
<span className="text-slate-500">Confirm:</span>
|
||||
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||||
{confirm
|
||||
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||||
: '⏳ chờ TPB confirm'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
// action button. Approvals + History moved here from PeDetailTabs (2 section
|
||||
// dưới cùng) để Panel 2 tập trung hiển thị nội dung phiếu (Info + NCC + Items).
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@ -13,9 +13,11 @@ import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
ApprovalStage,
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
type PeDepartmentApproval,
|
||||
type PeDetailBundle,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
|
||||
@ -25,6 +27,16 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
||||
const [comment, setComment] = useState('')
|
||||
const qc = useQueryClient()
|
||||
|
||||
// 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
|
||||
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
|
||||
const { data: deptApprovals = [] } = useQuery<PeDepartmentApproval[]>({
|
||||
queryKey: ['pe-dept-approvals', evaluation.id],
|
||||
queryFn: async () => {
|
||||
const r = await api.get(`/purchase-evaluations/${evaluation.id}/department-approvals`)
|
||||
return r.data
|
||||
},
|
||||
})
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: async () =>
|
||||
api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||
@ -36,6 +48,7 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
||||
toast.success('Đã chuyển phase.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
|
||||
setTarget(null)
|
||||
setComment('')
|
||||
},
|
||||
@ -116,6 +129,12 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{deptApprovals.length > 0 && (
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<DeptApprovalsSection rows={deptApprovals} currentPhase={evaluation.phase} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<PeApprovalsSection ev={evaluation} />
|
||||
</div>
|
||||
@ -127,6 +146,85 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
||||
)
|
||||
}
|
||||
|
||||
// 2-stage dept approval timeline (Migration 16). Group by phase × dept,
|
||||
// show Review NV row + Confirm TPB row. Highlight pending khi current phase
|
||||
// có Review nhưng chưa Confirm → user biết "đang chờ TPB confirm".
|
||||
function DeptApprovalsSection({
|
||||
rows,
|
||||
currentPhase,
|
||||
}: {
|
||||
rows: PeDepartmentApproval[]
|
||||
currentPhase: number
|
||||
}) {
|
||||
// Group: phase → dept → stages
|
||||
const grouped = new Map<number, Map<string, PeDepartmentApproval[]>>()
|
||||
for (const r of rows) {
|
||||
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||||
const byDept = grouped.get(r.phaseAtApproval)!
|
||||
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||||
byDept.get(r.departmentId)!.push(r)
|
||||
}
|
||||
// Order: phase asc
|
||||
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||||
<div className="mt-2 space-y-3">
|
||||
{phaseOrder.map(phase => {
|
||||
const byDept = grouped.get(phase)!
|
||||
return (
|
||||
<div key={phase}>
|
||||
<div className={cn(
|
||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
PurchaseEvaluationPhaseColor[phase],
|
||||
)}>
|
||||
{PurchaseEvaluationPhaseLabel[phase]}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{[...byDept.entries()].map(([deptId, stages]) => {
|
||||
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||||
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||||
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||||
const isPending = phase === currentPhase && review && !confirm
|
||||
return (
|
||||
<div key={deptId} className={cn(
|
||||
'rounded border px-2 py-1.5 text-[11px]',
|
||||
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||||
)}>
|
||||
<div className="font-medium text-slate-700">{deptName}</div>
|
||||
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||||
<span className="text-slate-500">Review:</span>
|
||||
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||||
{review
|
||||
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||||
: '— chưa có'}
|
||||
</span>
|
||||
<span className="text-slate-500">Confirm:</span>
|
||||
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||||
{confirm
|
||||
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||||
: '⏳ chờ TPB confirm'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
||||
const orderedIdx = active.indexOf(p)
|
||||
const currentIdx = active.indexOf(current)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Building2, KeyRound, Pencil, Plus, Shield, Unlock, Users, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { Building2, KeyRound, Pencil, Plus, Shield, Unlock, Users, CheckCircle2, XCircle, ShieldCheck } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { DataTable, Pagination, type Column } from '@/components/DataTable'
|
||||
@ -150,6 +150,19 @@ export function UsersPage() {
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
// 2-stage dept approval (Migration 16): bật bypass cho NV → cho phép họ
|
||||
// Confirm trực tiếp thay vì chỉ Review. Dùng cho phòng ban không có TPB
|
||||
// hoặc TPB ủy quyền cho 1 NV cụ thể.
|
||||
const bypassMut = useMutation({
|
||||
mutationFn: (u: User) =>
|
||||
api.patch(`/users/${u.id}/bypass-review`, { canBypassReview: !u.canBypassReview }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['users'] })
|
||||
toast.success('Đã cập nhật quyền bypass review')
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
function openRoles(u: User) {
|
||||
setRolesModal(u)
|
||||
setRoleSelection([...u.roles])
|
||||
@ -225,6 +238,21 @@ export function UsersPage() {
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'canBypassReview',
|
||||
header: 'Bypass',
|
||||
width: 'w-20',
|
||||
align: 'center',
|
||||
render: u =>
|
||||
u.canBypassReview ? (
|
||||
<span title="NV được Confirm trực tiếp (skip Review)" className="inline-flex items-center gap-1 rounded bg-fuchsia-100 px-1.5 py-0.5 text-[10px] text-fuchsia-700">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
bypass
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
),
|
||||
},
|
||||
{ key: 'createdAt', header: 'Ngày tạo', width: 'w-24', render: u => fmtDate(u.createdAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
@ -248,6 +276,14 @@ export function UsersPage() {
|
||||
<Unlock className="h-3.5 w-3.5 text-amber-600" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => bypassMut.mutate(u)}
|
||||
title={u.canBypassReview ? 'Tắt bypass (cần Review NV trước)' : 'Bật bypass (NV được Confirm trực tiếp)'}
|
||||
>
|
||||
<ShieldCheck className={`h-3.5 w-3.5 ${u.canBypassReview ? 'text-fuchsia-600' : 'text-slate-400'}`} />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => toggleActiveMut.mutate(u)} title={u.isActive ? 'Vô hiệu hóa' : 'Kích hoạt'}>
|
||||
{u.isActive ? <XCircle className="h-3.5 w-3.5 text-red-500" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
|
||||
</Button>
|
||||
|
||||
@ -49,6 +49,27 @@ export const ApprovalDecision = {
|
||||
AutoApprove: 3,
|
||||
} as const
|
||||
|
||||
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||
export const ApprovalStage = {
|
||||
Review: 1,
|
||||
Confirm: 2,
|
||||
} as const
|
||||
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||
|
||||
export type BudgetDepartmentApproval = {
|
||||
id: string
|
||||
phaseAtApproval: number
|
||||
departmentId: string
|
||||
departmentName: string | null
|
||||
stage: number // 1=Review, 2=Confirm
|
||||
approverUserId: string
|
||||
approverName: string | null
|
||||
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||
comment: string | null
|
||||
approvedAt: string
|
||||
isBypassed: boolean
|
||||
}
|
||||
|
||||
export type BudgetListItem = {
|
||||
id: string
|
||||
maNganSach: string | null
|
||||
|
||||
@ -48,6 +48,29 @@ export const ApprovalDecision = {
|
||||
|
||||
export type ApprovalDecision = typeof ApprovalDecision[keyof typeof ApprovalDecision]
|
||||
|
||||
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||
// BLOCK transition khi NV review chưa có TPB confirm cùng (HĐ, Phase, Dept).
|
||||
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
|
||||
export const ApprovalStage = {
|
||||
Review: 1,
|
||||
Confirm: 2,
|
||||
} as const
|
||||
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||
|
||||
export type ContractDepartmentApproval = {
|
||||
id: string
|
||||
phaseAtApproval: number
|
||||
departmentId: string
|
||||
departmentName: string | null
|
||||
stage: number // 1=Review, 2=Confirm
|
||||
approverUserId: string
|
||||
approverName: string | null
|
||||
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||
comment: string | null
|
||||
approvedAt: string
|
||||
isBypassed: boolean
|
||||
}
|
||||
|
||||
export type ContractListItem = {
|
||||
id: string
|
||||
maHopDong: string | null
|
||||
|
||||
@ -201,6 +201,34 @@ export type PeDepartmentOpinion = {
|
||||
userName: string | null
|
||||
}
|
||||
|
||||
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||
// BLOCK transition khi NV review chưa có TPB confirm cùng (PE, Phase, Dept).
|
||||
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
|
||||
export const ApprovalStage = {
|
||||
Review: 1,
|
||||
Confirm: 2,
|
||||
} as const
|
||||
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||
|
||||
export const ApprovalStageLabel: Record<number, string> = {
|
||||
1: 'Review NV',
|
||||
2: 'Confirm TPB',
|
||||
}
|
||||
|
||||
export type PeDepartmentApproval = {
|
||||
id: string
|
||||
phaseAtApproval: number
|
||||
departmentId: string
|
||||
departmentName: string | null
|
||||
stage: number // 1=Review, 2=Confirm
|
||||
approverUserId: string
|
||||
approverName: string | null
|
||||
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||
comment: string | null
|
||||
approvedAt: string
|
||||
isBypassed: boolean
|
||||
}
|
||||
|
||||
export type PeDetailBundle = {
|
||||
id: string
|
||||
maPhieu: string | null
|
||||
|
||||
@ -9,6 +9,7 @@ export type User = {
|
||||
departmentId: string | null
|
||||
departmentName: string | null
|
||||
position: string | null
|
||||
canBypassReview: boolean
|
||||
}
|
||||
|
||||
export type CreateUserInput = {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
|
||||
// Pulls nextPhases từ BE bundle (single source of truth).
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@ -12,9 +12,11 @@ import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
ApprovalDecision,
|
||||
ApprovalStage,
|
||||
BudgetPhase,
|
||||
BudgetPhaseColor,
|
||||
BudgetPhaseLabel,
|
||||
type BudgetDepartmentApproval,
|
||||
type BudgetDetailBundle,
|
||||
} from '@/types/budget'
|
||||
import { BudgetApprovalsSection, BudgetHistorySection } from './BudgetDetailTabs'
|
||||
@ -24,6 +26,12 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
||||
const [comment, setComment] = useState('')
|
||||
const qc = useQueryClient()
|
||||
|
||||
// 2-stage dept approvals (Migration 16) — fetch riêng để FE render timeline.
|
||||
const { data: deptApprovals = [] } = useQuery<BudgetDepartmentApproval[]>({
|
||||
queryKey: ['budget-dept-approvals', budget.id],
|
||||
queryFn: async () => (await api.get(`/budgets/${budget.id}/department-approvals`)).data,
|
||||
})
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: async () =>
|
||||
api.post(`/budgets/${budget.id}/transitions`, {
|
||||
@ -36,6 +44,7 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
||||
qc.invalidateQueries({ queryKey: ['budget-detail', budget.id] })
|
||||
qc.invalidateQueries({ queryKey: ['budget-list'] })
|
||||
qc.invalidateQueries({ queryKey: ['budget-changelog', budget.id] })
|
||||
qc.invalidateQueries({ queryKey: ['budget-dept-approvals', budget.id] })
|
||||
setTarget(null)
|
||||
setComment('')
|
||||
},
|
||||
@ -116,6 +125,12 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{deptApprovals.length > 0 && (
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<BudgetDeptApprovalsSection rows={deptApprovals} currentPhase={budget.phase} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<BudgetApprovalsSection budget={budget} />
|
||||
</div>
|
||||
@ -127,6 +142,81 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
||||
)
|
||||
}
|
||||
|
||||
// 2-stage dept approval timeline (Migration 16) — mirror PE/Contract pattern.
|
||||
function BudgetDeptApprovalsSection({
|
||||
rows,
|
||||
currentPhase,
|
||||
}: {
|
||||
rows: BudgetDepartmentApproval[]
|
||||
currentPhase: number
|
||||
}) {
|
||||
const grouped = new Map<number, Map<string, BudgetDepartmentApproval[]>>()
|
||||
for (const r of rows) {
|
||||
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||||
const byDept = grouped.get(r.phaseAtApproval)!
|
||||
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||||
byDept.get(r.departmentId)!.push(r)
|
||||
}
|
||||
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||||
<div className="mt-2 space-y-3">
|
||||
{phaseOrder.map(phase => {
|
||||
const byDept = grouped.get(phase)!
|
||||
return (
|
||||
<div key={phase}>
|
||||
<div className={cn(
|
||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
BudgetPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
|
||||
)}>
|
||||
{BudgetPhaseLabel[phase] ?? `Phase ${phase}`}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{[...byDept.entries()].map(([deptId, stages]) => {
|
||||
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||||
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||||
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||||
const isPending = phase === currentPhase && review && !confirm
|
||||
return (
|
||||
<div key={deptId} className={cn(
|
||||
'rounded border px-2 py-1.5 text-[11px]',
|
||||
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||||
)}>
|
||||
<div className="font-medium text-slate-700">{deptName}</div>
|
||||
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||||
<span className="text-slate-500">Review:</span>
|
||||
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||||
{review
|
||||
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||||
: '— chưa có'}
|
||||
</span>
|
||||
<span className="text-slate-500">Confirm:</span>
|
||||
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||||
{confirm
|
||||
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||||
: '⏳ chờ TPB confirm'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
||||
const orderedIdx = active.indexOf(p)
|
||||
const currentIdx = active.indexOf(current)
|
||||
|
||||
@ -2,19 +2,39 @@
|
||||
// approval history (ai/khi/quyết định gì) + ContractChangelogsTab (mọi thay
|
||||
// đổi header/detail/comment/attachment/transition) vào 1 stack. Dùng cho cả
|
||||
// MyContracts 3-panel và ContractDetailPage fullpage.
|
||||
import { ArrowRight, Clock, History } from 'lucide-react'
|
||||
import { ArrowRight, Clock, History, Users2 } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { WorkflowSummaryCard } from '@/components/WorkflowSummaryCard'
|
||||
import { ContractChangelogsTab } from '@/components/contracts/ContractChangelogsTab'
|
||||
import type { ContractDetail } from '@/types/contracts'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
ApprovalStage,
|
||||
ContractPhaseColor,
|
||||
ContractPhaseLabel,
|
||||
type ContractDepartmentApproval,
|
||||
type ContractDetail,
|
||||
} from '@/types/contracts'
|
||||
|
||||
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||
const fmtTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail }) {
|
||||
const { data: deptApprovals = [] } = useQuery<ContractDepartmentApproval[]>({
|
||||
queryKey: ['contract-dept-approvals', c.id],
|
||||
queryFn: async () => (await api.get(`/contracts/${c.id}/department-approvals`)).data,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{c.workflow && <WorkflowSummaryCard workflow={c.workflow} currentPhase={c.phase} />}
|
||||
|
||||
{deptApprovals.length > 0 && (
|
||||
<DeptApprovalsSection rows={deptApprovals} currentPhase={c.phase} />
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Clock className="h-4 w-4" />
|
||||
@ -54,3 +74,78 @@ export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 2-stage dept approval timeline (Migration 16). Group by phase × dept. Same
|
||||
// pattern PE — block phase transition khi current phase có Review nhưng chưa
|
||||
// Confirm → highlight amber để user biết "đang chờ TPB confirm".
|
||||
function DeptApprovalsSection({
|
||||
rows,
|
||||
currentPhase,
|
||||
}: {
|
||||
rows: ContractDepartmentApproval[]
|
||||
currentPhase: number
|
||||
}) {
|
||||
const grouped = new Map<number, Map<string, ContractDepartmentApproval[]>>()
|
||||
for (const r of rows) {
|
||||
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||||
const byDept = grouped.get(r.phaseAtApproval)!
|
||||
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||||
byDept.get(r.departmentId)!.push(r)
|
||||
}
|
||||
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-1 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Users2 className="h-4 w-4" />
|
||||
Tiến trình duyệt 2-cấp phòng ban
|
||||
</h2>
|
||||
<p className="mb-3 text-xs text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||||
<div className="space-y-3">
|
||||
{phaseOrder.map(phase => {
|
||||
const byDept = grouped.get(phase)!
|
||||
return (
|
||||
<div key={phase}>
|
||||
<div className={cn(
|
||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
ContractPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
|
||||
)}>
|
||||
{ContractPhaseLabel[phase] ?? `Phase ${phase}`}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{[...byDept.entries()].map(([deptId, stages]) => {
|
||||
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||||
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||||
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||||
const isPending = phase === currentPhase && review && !confirm
|
||||
return (
|
||||
<div key={deptId} className={cn(
|
||||
'rounded border px-2 py-1.5 text-[11px]',
|
||||
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||||
)}>
|
||||
<div className="font-medium text-slate-700">{deptName}</div>
|
||||
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||||
<span className="text-slate-500">Review:</span>
|
||||
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||||
{review
|
||||
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||||
: '— chưa có'}
|
||||
</span>
|
||||
<span className="text-slate-500">Confirm:</span>
|
||||
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||||
{confirm
|
||||
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||||
: '⏳ chờ TPB confirm'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
// action button. Approvals + History moved here from PeDetailTabs (2 section
|
||||
// dưới cùng) để Panel 2 tập trung hiển thị nội dung phiếu (Info + NCC + Items).
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@ -13,9 +13,11 @@ import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
ApprovalStage,
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
type PeDepartmentApproval,
|
||||
type PeDetailBundle,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
|
||||
@ -25,6 +27,16 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
||||
const [comment, setComment] = useState('')
|
||||
const qc = useQueryClient()
|
||||
|
||||
// 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
|
||||
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
|
||||
const { data: deptApprovals = [] } = useQuery<PeDepartmentApproval[]>({
|
||||
queryKey: ['pe-dept-approvals', evaluation.id],
|
||||
queryFn: async () => {
|
||||
const r = await api.get(`/purchase-evaluations/${evaluation.id}/department-approvals`)
|
||||
return r.data
|
||||
},
|
||||
})
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: async () =>
|
||||
api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||
@ -36,6 +48,7 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
||||
toast.success('Đã chuyển phase.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
|
||||
setTarget(null)
|
||||
setComment('')
|
||||
},
|
||||
@ -116,6 +129,12 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{deptApprovals.length > 0 && (
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<DeptApprovalsSection rows={deptApprovals} currentPhase={evaluation.phase} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<PeApprovalsSection ev={evaluation} />
|
||||
</div>
|
||||
@ -127,6 +146,85 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
||||
)
|
||||
}
|
||||
|
||||
// 2-stage dept approval timeline (Migration 16). Group by phase × dept,
|
||||
// show Review NV row + Confirm TPB row. Highlight pending khi current phase
|
||||
// có Review nhưng chưa Confirm → user biết "đang chờ TPB confirm".
|
||||
function DeptApprovalsSection({
|
||||
rows,
|
||||
currentPhase,
|
||||
}: {
|
||||
rows: PeDepartmentApproval[]
|
||||
currentPhase: number
|
||||
}) {
|
||||
// Group: phase → dept → stages
|
||||
const grouped = new Map<number, Map<string, PeDepartmentApproval[]>>()
|
||||
for (const r of rows) {
|
||||
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||||
const byDept = grouped.get(r.phaseAtApproval)!
|
||||
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||||
byDept.get(r.departmentId)!.push(r)
|
||||
}
|
||||
// Order: phase asc
|
||||
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||||
<div className="mt-2 space-y-3">
|
||||
{phaseOrder.map(phase => {
|
||||
const byDept = grouped.get(phase)!
|
||||
return (
|
||||
<div key={phase}>
|
||||
<div className={cn(
|
||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
PurchaseEvaluationPhaseColor[phase],
|
||||
)}>
|
||||
{PurchaseEvaluationPhaseLabel[phase]}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{[...byDept.entries()].map(([deptId, stages]) => {
|
||||
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||||
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||||
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||||
const isPending = phase === currentPhase && review && !confirm
|
||||
return (
|
||||
<div key={deptId} className={cn(
|
||||
'rounded border px-2 py-1.5 text-[11px]',
|
||||
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||||
)}>
|
||||
<div className="font-medium text-slate-700">{deptName}</div>
|
||||
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||||
<span className="text-slate-500">Review:</span>
|
||||
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||||
{review
|
||||
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||||
: '— chưa có'}
|
||||
</span>
|
||||
<span className="text-slate-500">Confirm:</span>
|
||||
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||||
{confirm
|
||||
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||||
: '⏳ chờ TPB confirm'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
||||
const orderedIdx = active.indexOf(p)
|
||||
const currentIdx = active.indexOf(current)
|
||||
|
||||
@ -49,6 +49,27 @@ export const ApprovalDecision = {
|
||||
AutoApprove: 3,
|
||||
} as const
|
||||
|
||||
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||
export const ApprovalStage = {
|
||||
Review: 1,
|
||||
Confirm: 2,
|
||||
} as const
|
||||
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||
|
||||
export type BudgetDepartmentApproval = {
|
||||
id: string
|
||||
phaseAtApproval: number
|
||||
departmentId: string
|
||||
departmentName: string | null
|
||||
stage: number // 1=Review, 2=Confirm
|
||||
approverUserId: string
|
||||
approverName: string | null
|
||||
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||
comment: string | null
|
||||
approvedAt: string
|
||||
isBypassed: boolean
|
||||
}
|
||||
|
||||
export type BudgetListItem = {
|
||||
id: string
|
||||
maNganSach: string | null
|
||||
|
||||
@ -48,6 +48,29 @@ export const ApprovalDecision = {
|
||||
|
||||
export type ApprovalDecision = typeof ApprovalDecision[keyof typeof ApprovalDecision]
|
||||
|
||||
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||
// BLOCK transition khi NV review chưa có TPB confirm cùng (HĐ, Phase, Dept).
|
||||
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
|
||||
export const ApprovalStage = {
|
||||
Review: 1,
|
||||
Confirm: 2,
|
||||
} as const
|
||||
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||
|
||||
export type ContractDepartmentApproval = {
|
||||
id: string
|
||||
phaseAtApproval: number
|
||||
departmentId: string
|
||||
departmentName: string | null
|
||||
stage: number // 1=Review, 2=Confirm
|
||||
approverUserId: string
|
||||
approverName: string | null
|
||||
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||
comment: string | null
|
||||
approvedAt: string
|
||||
isBypassed: boolean
|
||||
}
|
||||
|
||||
export type ContractListItem = {
|
||||
id: string
|
||||
maHopDong: string | null
|
||||
|
||||
@ -201,6 +201,34 @@ export type PeDepartmentOpinion = {
|
||||
userName: string | null
|
||||
}
|
||||
|
||||
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||
// BLOCK transition khi NV review chưa có TPB confirm cùng (PE, Phase, Dept).
|
||||
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
|
||||
export const ApprovalStage = {
|
||||
Review: 1,
|
||||
Confirm: 2,
|
||||
} as const
|
||||
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||
|
||||
export const ApprovalStageLabel: Record<number, string> = {
|
||||
1: 'Review NV',
|
||||
2: 'Confirm TPB',
|
||||
}
|
||||
|
||||
export type PeDepartmentApproval = {
|
||||
id: string
|
||||
phaseAtApproval: number
|
||||
departmentId: string
|
||||
departmentName: string | null
|
||||
stage: number // 1=Review, 2=Confirm
|
||||
approverUserId: string
|
||||
approverName: string | null
|
||||
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||
comment: string | null
|
||||
approvedAt: string
|
||||
isBypassed: boolean
|
||||
}
|
||||
|
||||
export type PeDetailBundle = {
|
||||
id: string
|
||||
maPhieu: string | null
|
||||
|
||||
@ -86,6 +86,12 @@ public class BudgetsController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("{id:guid}/changelogs")]
|
||||
public async Task<List<BudgetChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
|
||||
=> await mediator.Send(new ListBudgetChangelogsQuery(id), ct);
|
||||
|
||||
// 2-stage department approval list (Phase 9 — Migration 16).
|
||||
[HttpGet("{id:guid}/department-approvals")]
|
||||
public async Task<ActionResult<List<BudgetDepartmentApprovalDto>>> ListDepartmentApprovals(
|
||||
Guid id, CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new ListBudgetDepartmentApprovalsQuery(id), ct));
|
||||
}
|
||||
|
||||
public record TransitionBudgetBody(BudgetPhase TargetPhase, ApprovalDecision Decision, string? Comment);
|
||||
|
||||
@ -218,6 +218,12 @@ public class ContractsController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("{id:guid}/changelogs")]
|
||||
public async Task<List<ContractChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
|
||||
=> await mediator.Send(new ListContractChangelogsQuery(id), ct);
|
||||
|
||||
// 2-stage department approval list (Phase 9 — Migration 16).
|
||||
[HttpGet("{id:guid}/department-approvals")]
|
||||
public async Task<ActionResult<List<ContractDepartmentApprovalDto>>> ListDepartmentApprovals(
|
||||
Guid id, CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new ListContractDepartmentApprovalsQuery(id), ct));
|
||||
}
|
||||
|
||||
public record TransitionContractBody(ContractPhase TargetPhase, ApprovalDecision Decision, string? Comment);
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Application.Budgets;
|
||||
|
||||
// 2-stage department approval list cho Budget (Phase 9 — Migration 16).
|
||||
// Mirror PE/Contract — query để FE Workflow Panel render timeline progress.
|
||||
//
|
||||
// Insertion + Block logic ở TransitionBudgetCommandHandler (BudgetFeatures.cs).
|
||||
|
||||
public record ListBudgetDepartmentApprovalsQuery(Guid BudgetId)
|
||||
: IRequest<List<BudgetDepartmentApprovalDto>>;
|
||||
|
||||
public record BudgetDepartmentApprovalDto(
|
||||
Guid Id,
|
||||
int PhaseAtApproval,
|
||||
Guid DepartmentId,
|
||||
string? DepartmentName,
|
||||
ApprovalStage Stage,
|
||||
Guid ApproverUserId,
|
||||
string? ApproverName,
|
||||
string? ApproverRoleSnapshot,
|
||||
string? Comment,
|
||||
DateTime ApprovedAt,
|
||||
bool IsBypassed);
|
||||
|
||||
public class ListBudgetDepartmentApprovalsQueryHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<ListBudgetDepartmentApprovalsQuery, List<BudgetDepartmentApprovalDto>>
|
||||
{
|
||||
public async Task<List<BudgetDepartmentApprovalDto>> Handle(
|
||||
ListBudgetDepartmentApprovalsQuery request, CancellationToken ct)
|
||||
{
|
||||
var rows = await (
|
||||
from a in db.BudgetDepartmentApprovals.AsNoTracking()
|
||||
join d in db.Departments.AsNoTracking() on a.DepartmentId equals d.Id into deptJoin
|
||||
from d in deptJoin.DefaultIfEmpty()
|
||||
where a.BudgetId == request.BudgetId
|
||||
orderby a.PhaseAtApproval, a.Stage, a.ApprovedAt
|
||||
select new
|
||||
{
|
||||
a.Id,
|
||||
a.PhaseAtApproval,
|
||||
a.DepartmentId,
|
||||
DepartmentName = d != null ? d.Name : null,
|
||||
a.Stage,
|
||||
a.ApproverUserId,
|
||||
a.ApproverRoleSnapshot,
|
||||
a.Comment,
|
||||
a.ApprovedAt,
|
||||
a.IsBypassed,
|
||||
}).ToListAsync(ct);
|
||||
|
||||
var userIds = rows.Select(r => r.ApproverUserId).Distinct().ToList();
|
||||
var users = await db.Users.AsNoTracking()
|
||||
.Where(u => userIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, Name = u.FullName ?? u.Email ?? "" })
|
||||
.ToDictionaryAsync(u => u.Id, u => u.Name, ct);
|
||||
|
||||
return rows.Select(r => new BudgetDepartmentApprovalDto(
|
||||
r.Id,
|
||||
r.PhaseAtApproval,
|
||||
r.DepartmentId,
|
||||
r.DepartmentName,
|
||||
r.Stage,
|
||||
r.ApproverUserId,
|
||||
users.TryGetValue(r.ApproverUserId, out var n) ? n : null,
|
||||
r.ApproverRoleSnapshot,
|
||||
r.Comment,
|
||||
r.ApprovedAt,
|
||||
r.IsBypassed)).ToList();
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,12 @@ using SolutionErp.Application.Budgets.Dtos;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Common.Models;
|
||||
using SolutionErp.Application.Notifications;
|
||||
using SolutionErp.Domain.Budgets;
|
||||
using SolutionErp.Domain.Common; // ApprovalStage
|
||||
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Notifications;
|
||||
|
||||
namespace SolutionErp.Application.Budgets;
|
||||
|
||||
@ -121,7 +124,9 @@ public record TransitionBudgetCommand(
|
||||
public class TransitionBudgetCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser,
|
||||
UserManager<User> userManager) : IRequestHandler<TransitionBudgetCommand>
|
||||
UserManager<User> userManager,
|
||||
INotificationService notifications,
|
||||
IDateTime dateTime) : IRequestHandler<TransitionBudgetCommand>
|
||||
{
|
||||
public async Task Handle(TransitionBudgetCommand request, CancellationToken ct)
|
||||
{
|
||||
@ -158,6 +163,121 @@ public class TransitionBudgetCommandHandler(
|
||||
throw new ForbiddenException(
|
||||
$"Role không đủ quyền chuyển {fromPhase} → {targetPhase}.");
|
||||
|
||||
// ===== 2-stage department approval (Phase 9 — Migration 16) =====
|
||||
// Mirror PE/Contract. Low-priority cho Budget vì ít dept duyệt budget,
|
||||
// nhưng giữ consistent UX 3 module.
|
||||
if (request.Decision == ApprovalDecision.Approve
|
||||
&& targetPhase != BudgetPhase.DangSoanThao
|
||||
&& targetPhase != BudgetPhase.TuChoi
|
||||
&& !isResumingAfterReject
|
||||
&& !isAdmin
|
||||
&& currentUser.UserId is Guid actorUid)
|
||||
{
|
||||
var actor = await userManager.FindByIdAsync(actorUid.ToString());
|
||||
if (actor?.DepartmentId is Guid deptId)
|
||||
{
|
||||
var isManager = currentUser.Roles.Contains(AppRoles.DeptManager);
|
||||
var canBypass = actor.CanBypassReview;
|
||||
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
|
||||
var isBypassed = !isManager && canBypass;
|
||||
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
|
||||
|
||||
var existing = await db.BudgetDepartmentApprovals
|
||||
.FirstOrDefaultAsync(a =>
|
||||
a.BudgetId == entity.Id
|
||||
&& a.PhaseAtApproval == (int)fromPhase
|
||||
&& a.DepartmentId == deptId
|
||||
&& a.Stage == stage, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
db.BudgetDepartmentApprovals.Add(new BudgetDepartmentApproval
|
||||
{
|
||||
BudgetId = entity.Id,
|
||||
PhaseAtApproval = (int)fromPhase,
|
||||
DepartmentId = deptId,
|
||||
Stage = stage,
|
||||
ApproverUserId = actorUid,
|
||||
ApproverRoleSnapshot = roleSnapshot,
|
||||
Comment = request.Comment,
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
IsBypassed = isBypassed,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ApproverUserId = actorUid;
|
||||
existing.ApproverRoleSnapshot = roleSnapshot;
|
||||
existing.Comment = request.Comment;
|
||||
existing.ApprovedAt = dateTime.UtcNow;
|
||||
existing.IsBypassed = isBypassed;
|
||||
}
|
||||
|
||||
var hasConfirm = stage == ApprovalStage.Confirm
|
||||
|| await db.BudgetDepartmentApprovals.AnyAsync(a =>
|
||||
a.BudgetId == entity.Id
|
||||
&& a.PhaseAtApproval == (int)fromPhase
|
||||
&& a.DepartmentId == deptId
|
||||
&& a.Stage == ApprovalStage.Confirm, ct);
|
||||
|
||||
if (!hasConfirm)
|
||||
{
|
||||
// BLOCK transition. Log audit Approval + Changelog.
|
||||
db.BudgetApprovals.Add(new BudgetApproval
|
||||
{
|
||||
BudgetId = entity.Id,
|
||||
FromPhase = fromPhase,
|
||||
ToPhase = fromPhase,
|
||||
ApproverUserId = actorUid,
|
||||
Decision = ApprovalDecision.Approve,
|
||||
Comment = $"[Review NV] {request.Comment ?? ""}",
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
});
|
||||
|
||||
string? reviewerName = (actor.FullName ?? actor.Email);
|
||||
db.BudgetChangelogs.Add(new BudgetChangelog
|
||||
{
|
||||
BudgetId = entity.Id,
|
||||
EntityType = BudgetEntityType.Workflow,
|
||||
Action = ChangelogAction.Transition,
|
||||
PhaseAtChange = fromPhase,
|
||||
UserId = actorUid,
|
||||
UserName = reviewerName ?? "Hệ thống",
|
||||
Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm",
|
||||
ContextNote = request.Comment,
|
||||
});
|
||||
|
||||
// Notify TPB cùng dept. Best effort.
|
||||
try
|
||||
{
|
||||
var managers = await db.Users.AsNoTracking()
|
||||
.Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive)
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync(ct);
|
||||
foreach (var mgrId in managers)
|
||||
{
|
||||
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
|
||||
if (mgr is null) continue;
|
||||
var roles = await userManager.GetRolesAsync(mgr);
|
||||
if (!roles.Contains(AppRoles.DeptManager)) continue;
|
||||
|
||||
await notifications.NotifyAsync(
|
||||
mgrId,
|
||||
NotificationType.ContractPhaseTransition,
|
||||
title: $"NS {entity.MaNganSach ?? entity.TenNganSach} chờ TPB confirm",
|
||||
description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.",
|
||||
href: $"/budgets/{entity.Id}",
|
||||
refId: entity.Id,
|
||||
ct: ct);
|
||||
}
|
||||
}
|
||||
catch { /* notification fail non-critical */ }
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entity.SlaWarningSent = false;
|
||||
entity.Phase = targetPhase;
|
||||
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Application.Contracts;
|
||||
|
||||
// 2-stage department approval list cho HĐ (Phase 9 — Migration 16).
|
||||
// Mirror PE — query để FE hiển thị progress per phase × dept.
|
||||
//
|
||||
// FE Workflow Panel HĐ render dạng timeline:
|
||||
// - Phase DangGopY (CCM):
|
||||
// - Stage Review: nv.cao (NV) — 14:30
|
||||
// - Stage Confirm: ccm.tran (TPB) — 14:35 → unlock transition
|
||||
//
|
||||
// Insertion + Block logic ở ContractWorkflowService.TransitionAsync.
|
||||
|
||||
public record ListContractDepartmentApprovalsQuery(Guid ContractId)
|
||||
: IRequest<List<ContractDepartmentApprovalDto>>;
|
||||
|
||||
public record ContractDepartmentApprovalDto(
|
||||
Guid Id,
|
||||
int PhaseAtApproval,
|
||||
Guid DepartmentId,
|
||||
string? DepartmentName,
|
||||
ApprovalStage Stage,
|
||||
Guid ApproverUserId,
|
||||
string? ApproverName,
|
||||
string? ApproverRoleSnapshot,
|
||||
string? Comment,
|
||||
DateTime ApprovedAt,
|
||||
bool IsBypassed);
|
||||
|
||||
public class ListContractDepartmentApprovalsQueryHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<ListContractDepartmentApprovalsQuery, List<ContractDepartmentApprovalDto>>
|
||||
{
|
||||
public async Task<List<ContractDepartmentApprovalDto>> Handle(
|
||||
ListContractDepartmentApprovalsQuery request, CancellationToken ct)
|
||||
{
|
||||
var rows = await (
|
||||
from a in db.ContractDepartmentApprovals.AsNoTracking()
|
||||
join d in db.Departments.AsNoTracking() on a.DepartmentId equals d.Id into deptJoin
|
||||
from d in deptJoin.DefaultIfEmpty()
|
||||
where a.ContractId == request.ContractId
|
||||
orderby a.PhaseAtApproval, a.Stage, a.ApprovedAt
|
||||
select new
|
||||
{
|
||||
a.Id,
|
||||
a.PhaseAtApproval,
|
||||
a.DepartmentId,
|
||||
DepartmentName = d != null ? d.Name : null,
|
||||
a.Stage,
|
||||
a.ApproverUserId,
|
||||
a.ApproverRoleSnapshot,
|
||||
a.Comment,
|
||||
a.ApprovedAt,
|
||||
a.IsBypassed,
|
||||
}).ToListAsync(ct);
|
||||
|
||||
var userIds = rows.Select(r => r.ApproverUserId).Distinct().ToList();
|
||||
var users = await db.Users.AsNoTracking()
|
||||
.Where(u => userIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, Name = u.FullName ?? u.Email ?? "" })
|
||||
.ToDictionaryAsync(u => u.Id, u => u.Name, ct);
|
||||
|
||||
return rows.Select(r => new ContractDepartmentApprovalDto(
|
||||
r.Id,
|
||||
r.PhaseAtApproval,
|
||||
r.DepartmentId,
|
||||
r.DepartmentName,
|
||||
r.Stage,
|
||||
r.ApproverUserId,
|
||||
users.TryGetValue(r.ApproverUserId, out var n) ? n : null,
|
||||
r.ApproverRoleSnapshot,
|
||||
r.Comment,
|
||||
r.ApprovedAt,
|
||||
r.IsBypassed)).ToList();
|
||||
}
|
||||
}
|
||||
@ -20,7 +20,8 @@ public record UserDto(
|
||||
List<string> Roles,
|
||||
Guid? DepartmentId,
|
||||
string? DepartmentName,
|
||||
string? Position);
|
||||
string? Position,
|
||||
bool CanBypassReview);
|
||||
|
||||
// ========== LIST ==========
|
||||
public record ListUsersQuery : PagedRequest, IRequest<PagedResult<UserDto>>;
|
||||
@ -59,7 +60,7 @@ public class ListUsersQueryHandler(UserManager<User> userManager, IApplicationDb
|
||||
var roles = await userManager.GetRolesAsync(u);
|
||||
var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > now;
|
||||
string? deptName = u.DepartmentId is { } did && deptNames.TryGetValue(did, out var dn) ? dn : null;
|
||||
items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position));
|
||||
items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview));
|
||||
}
|
||||
|
||||
return new PagedResult<UserDto>(items, total, request.Page, request.PageSize);
|
||||
@ -81,7 +82,7 @@ public class GetUserQueryHandler(UserManager<User> userManager, IApplicationDbCo
|
||||
string? deptName = null;
|
||||
if (u.DepartmentId is { } did)
|
||||
deptName = await db.Departments.AsNoTracking().Where(d => d.Id == did).Select(d => d.Name).FirstOrDefaultAsync(ct);
|
||||
return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position);
|
||||
return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Contracts.Services;
|
||||
using SolutionErp.Application.Notifications;
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Notifications;
|
||||
@ -17,7 +19,8 @@ public class ContractWorkflowService(
|
||||
IContractCodeGenerator codeGenerator,
|
||||
IDateTime dateTime,
|
||||
INotificationService notifications,
|
||||
IChangelogService changelog) : IContractWorkflowService
|
||||
IChangelogService changelog,
|
||||
UserManager<User> userManager) : IContractWorkflowService
|
||||
{
|
||||
// Expose per-policy SLA via the contract — accepts optional contract so the
|
||||
// caller (CreateContractCommand) can ask for a specific type's SLA even
|
||||
@ -105,6 +108,123 @@ public class ContractWorkflowService(
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 2-stage department approval (Phase 9 — Migration 16) =====
|
||||
// Mirror PE workflow service. NV thuộc dept review → BLOCK transition
|
||||
// cho đến khi TPB cùng dept confirm. CanBypassReview cho NV → đẩy thẳng
|
||||
// Confirm (skip Review). Skip với reject + resume + admin + system.
|
||||
if (decision == ApprovalDecision.Approve
|
||||
&& targetPhase != ContractPhase.DangSoanThao
|
||||
&& targetPhase != ContractPhase.TuChoi
|
||||
&& !isResumingAfterReject
|
||||
&& !isAdmin && !isSystem
|
||||
&& actorUserId is Guid actorUid)
|
||||
{
|
||||
var actor = await userManager.FindByIdAsync(actorUid.ToString());
|
||||
if (actor?.DepartmentId is Guid deptId)
|
||||
{
|
||||
var isManager = actorRoles.Contains(AppRoles.DeptManager);
|
||||
var canBypass = actor.CanBypassReview;
|
||||
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
|
||||
var isBypassed = !isManager && canBypass;
|
||||
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
|
||||
|
||||
// Upsert: 1 row mỗi (ContractId, phase, dept, stage). UNIQUE index enforce.
|
||||
var existing = await db.ContractDepartmentApprovals
|
||||
.FirstOrDefaultAsync(a =>
|
||||
a.ContractId == contract.Id
|
||||
&& a.PhaseAtApproval == (int)fromPhase
|
||||
&& a.DepartmentId == deptId
|
||||
&& a.Stage == stage, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
PhaseAtApproval = (int)fromPhase,
|
||||
DepartmentId = deptId,
|
||||
Stage = stage,
|
||||
ApproverUserId = actorUid,
|
||||
ApproverRoleSnapshot = roleSnapshot,
|
||||
Comment = comment,
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
IsBypassed = isBypassed,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ApproverUserId = actorUid;
|
||||
existing.ApproverRoleSnapshot = roleSnapshot;
|
||||
existing.Comment = comment;
|
||||
existing.ApprovedAt = dateTime.UtcNow;
|
||||
existing.IsBypassed = isBypassed;
|
||||
}
|
||||
|
||||
var hasConfirm = stage == ApprovalStage.Confirm
|
||||
|| await db.ContractDepartmentApprovals.AnyAsync(a =>
|
||||
a.ContractId == contract.Id
|
||||
&& a.PhaseAtApproval == (int)fromPhase
|
||||
&& a.DepartmentId == deptId
|
||||
&& a.Stage == ApprovalStage.Confirm, ct);
|
||||
|
||||
if (!hasConfirm)
|
||||
{
|
||||
// NV review xong, chưa có TPB confirm → BLOCK phase transition.
|
||||
db.ContractApprovals.Add(new ContractApproval
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
FromPhase = fromPhase,
|
||||
ToPhase = fromPhase, // không đổi phase
|
||||
ApproverUserId = actorUid,
|
||||
Decision = ApprovalDecision.Approve,
|
||||
Comment = $"[Review NV] {comment ?? ""}",
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
});
|
||||
|
||||
string? reviewerName = (actor.FullName ?? actor.Email);
|
||||
db.ContractChangelogs.Add(new ContractChangelog
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
EntityType = ChangelogEntityType.Workflow,
|
||||
Action = ChangelogAction.Transition,
|
||||
PhaseAtChange = fromPhase,
|
||||
UserId = actorUid,
|
||||
UserName = reviewerName ?? "Hệ thống",
|
||||
Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm",
|
||||
ContextNote = comment,
|
||||
});
|
||||
|
||||
// Notify TPB cùng dept để confirm. Best effort.
|
||||
try
|
||||
{
|
||||
var managers = await db.Users.AsNoTracking()
|
||||
.Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive)
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync(ct);
|
||||
foreach (var mgrId in managers)
|
||||
{
|
||||
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
|
||||
if (mgr is null) continue;
|
||||
var roles = await userManager.GetRolesAsync(mgr);
|
||||
if (!roles.Contains(AppRoles.DeptManager)) continue;
|
||||
|
||||
await notifications.NotifyAsync(
|
||||
mgrId,
|
||||
NotificationType.ContractPhaseTransition,
|
||||
title: $"HĐ {contract.MaHopDong ?? contract.TenHopDong ?? ""} chờ TPB confirm",
|
||||
description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.",
|
||||
href: $"/contracts/{contract.Id}",
|
||||
refId: contract.Id,
|
||||
ct: ct);
|
||||
}
|
||||
}
|
||||
catch { /* notification fail non-critical */ }
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive — gen mã HĐ nếu chưa có khi chuyển sang DangDongDau.
|
||||
// Nominal flow (sau user feedback): mã đã gen sẵn từ CreateContract → skip.
|
||||
// Fallback chỉ trigger cho HĐ legacy chưa qua backfill, hoặc HĐ tạo bằng
|
||||
|
||||
113
tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs
Normal file
113
tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs
Normal file
@ -0,0 +1,113 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Infrastructure.Persistence;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
// Identity-aware fixture cho tests cần UserManager + RoleManager.
|
||||
//
|
||||
// Tại sao tách khỏi SqliteDbFixture?
|
||||
// - SqliteDbFixture chỉ EF + DbContext (đủ cho code generator tests).
|
||||
// - Service tests cần Identity stack: UserManager.FindByIdAsync, GetRolesAsync,
|
||||
// CreateAsync, AddToRolesAsync — phụ thuộc IUserStore + IRoleStore + hashers
|
||||
// + validators registered qua AddIdentityCore + AddRoles + AddEntityFrameworkStores.
|
||||
// - Single connection mỗi fixture → DB persists across UserManager.Save calls.
|
||||
//
|
||||
// Pattern: ServiceProvider build từ AddIdentityCore. Test gọi GetRequired để
|
||||
// resolve UserManager, DbContext, etc. EnsureCreated() build schema từ model
|
||||
// (skip migrations vì test isolated).
|
||||
public sealed class IdentityFixture : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly ServiceProvider _root;
|
||||
public IServiceProvider Services { get; } // scoped to single test scope (shared across tests in class)
|
||||
|
||||
public IdentityFixture()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Manual options + factory để inject TestApplicationDbContext qua type
|
||||
// ApplicationDbContext (Identity EF stores expect base type).
|
||||
var connection = _connection;
|
||||
services.AddScoped<ApplicationDbContext>(_ =>
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.EnableSensitiveDataLogging()
|
||||
.Options;
|
||||
return new TestApplicationDbContext(options);
|
||||
});
|
||||
services.AddScoped<TestApplicationDbContext>(sp =>
|
||||
(TestApplicationDbContext)sp.GetRequiredService<ApplicationDbContext>());
|
||||
services.AddScoped<IApplicationDbContext>(sp =>
|
||||
sp.GetRequiredService<TestApplicationDbContext>());
|
||||
|
||||
services.AddIdentityCore<User>(opt =>
|
||||
{
|
||||
opt.Password.RequireDigit = false;
|
||||
opt.Password.RequireLowercase = false;
|
||||
opt.Password.RequireUppercase = false;
|
||||
opt.Password.RequireNonAlphanumeric = false;
|
||||
opt.Password.RequiredLength = 4;
|
||||
})
|
||||
.AddRoles<Role>()
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>();
|
||||
|
||||
_root = services.BuildServiceProvider();
|
||||
Services = _root.CreateScope().ServiceProvider;
|
||||
|
||||
var ctx = Services.GetRequiredService<TestApplicationDbContext>();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
// Helper: tạo user + assign roles + gán DepartmentId. Reuse trong nhiều test.
|
||||
public async Task<User> CreateUserAsync(
|
||||
string email,
|
||||
string fullName,
|
||||
Guid? departmentId,
|
||||
string[] roles,
|
||||
bool canBypassReview = false)
|
||||
{
|
||||
var um = Services.GetRequiredService<UserManager<User>>();
|
||||
var rm = Services.GetRequiredService<RoleManager<Role>>();
|
||||
|
||||
// Ensure roles exist (idempotent).
|
||||
foreach (var role in roles)
|
||||
{
|
||||
if (!await rm.RoleExistsAsync(role))
|
||||
await rm.CreateAsync(new Role { Id = Guid.NewGuid(), Name = role });
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = email,
|
||||
Email = email,
|
||||
EmailConfirmed = true,
|
||||
FullName = fullName,
|
||||
DepartmentId = departmentId,
|
||||
CanBypassReview = canBypassReview,
|
||||
IsActive = true,
|
||||
};
|
||||
var created = await um.CreateAsync(user, "Test@123");
|
||||
if (!created.Succeeded)
|
||||
throw new InvalidOperationException("CreateUserAsync failed: " + string.Join(",", created.Errors.Select(e => e.Description)));
|
||||
if (roles.Length > 0)
|
||||
await um.AddToRolesAsync(user, roles);
|
||||
return user;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_root.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,257 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Notifications;
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Notifications;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||
|
||||
// Tests cho 2-stage department approval logic ở PurchaseEvaluationWorkflowService.
|
||||
// Cover bug fix anh Kiệt: NV.PRO duyệt phase ChoPurchasing → BLOCK transition.
|
||||
// TPB.PRO confirm → ALLOW transition.
|
||||
//
|
||||
// Pattern: dùng IdentityFixture (Identity stack + DbContext SQLite) để
|
||||
// test thật end-to-end service thay vì mock.
|
||||
public class PeTwoStageApprovalTests : IClassFixture<IdentityFixture>
|
||||
{
|
||||
private readonly IdentityFixture _fx;
|
||||
private readonly TestApplicationDbContext _db;
|
||||
private readonly UserManager<User> _userManager;
|
||||
private readonly PurchaseEvaluationWorkflowService _service;
|
||||
private readonly Guid _deptPro;
|
||||
private readonly Guid _deptCcm;
|
||||
|
||||
public PeTwoStageApprovalTests(IdentityFixture fx)
|
||||
{
|
||||
_fx = fx;
|
||||
_db = fx.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
_userManager = fx.Services.GetRequiredService<UserManager<User>>();
|
||||
|
||||
// Seed 2 departments (idempotent — check trước khi insert vì fixture
|
||||
// shared across tests trong class).
|
||||
_deptPro = SeedDept("PRO", "Phòng Cung ứng");
|
||||
_deptCcm = SeedDept("CCM", "Phòng Kiểm soát chi phí");
|
||||
|
||||
var clock = new FixedDateTime(new DateTime(2026, 5, 4, 10, 0, 0, DateTimeKind.Utc));
|
||||
var fakeNotifications = new FakeNotificationService();
|
||||
|
||||
_service = new PurchaseEvaluationWorkflowService(
|
||||
_db,
|
||||
clock,
|
||||
fakeNotifications,
|
||||
_userManager);
|
||||
}
|
||||
|
||||
private Guid SeedDept(string code, string name)
|
||||
{
|
||||
var existing = _db.Departments.FirstOrDefault(d => d.Code == code);
|
||||
if (existing is not null) return existing.Id;
|
||||
var d = new SolutionErp.Domain.Master.Department { Id = Guid.NewGuid(), Code = code, Name = name };
|
||||
_db.Departments.Add(d);
|
||||
_db.SaveChanges();
|
||||
return d.Id;
|
||||
}
|
||||
|
||||
private async Task<PurchaseEvaluation> SeedPeAsync(PurchaseEvaluationPhase phase, Guid? projectId = null)
|
||||
{
|
||||
// Project required by FK constraint.
|
||||
var pid = projectId ?? Guid.NewGuid();
|
||||
if (!_db.Projects.Any(p => p.Id == pid))
|
||||
{
|
||||
_db.Projects.Add(new SolutionErp.Domain.Master.Project
|
||||
{
|
||||
Id = pid,
|
||||
Code = $"PRJ-{Random.Shared.Next(10000):D4}",
|
||||
Name = "Test project",
|
||||
});
|
||||
}
|
||||
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = phase,
|
||||
TenGoiThau = "Test gói thầu",
|
||||
ProjectId = pid,
|
||||
};
|
||||
_db.PurchaseEvaluations.Add(pe);
|
||||
await _db.SaveChangesAsync();
|
||||
return pe;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NV_Review_Blocks_Phase_Transition()
|
||||
{
|
||||
// Arrange: NV.PRO (role Procurement, dept PRO, NOT DeptManager).
|
||||
var nv = await _fx.CreateUserAsync(
|
||||
$"nv-{Guid.NewGuid():N}@test", "NV PRO", _deptPro, ["Procurement"]);
|
||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
||||
|
||||
// Act: NV approve to ChoCCM.
|
||||
await _service.TransitionAsync(
|
||||
pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"],
|
||||
ApprovalDecision.Approve, "review");
|
||||
|
||||
// Assert: phase KHÔNG đổi, có 1 row Stage=Review.
|
||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoPurchasing);
|
||||
|
||||
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
deptApprovals.Should().HaveCount(1);
|
||||
deptApprovals[0].Stage.Should().Be(ApprovalStage.Review);
|
||||
deptApprovals[0].DepartmentId.Should().Be(_deptPro);
|
||||
deptApprovals[0].IsBypassed.Should().BeFalse();
|
||||
|
||||
var approvals = await _db.PurchaseEvaluationApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
approvals.Should().HaveCount(1);
|
||||
approvals[0].Comment.Should().StartWith("[Review NV]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TPB_Confirm_After_NV_Review_Allows_Transition()
|
||||
{
|
||||
// Arrange: NV review trước, sau đó TPB confirm.
|
||||
var nv = await _fx.CreateUserAsync(
|
||||
$"nv-{Guid.NewGuid():N}@test", "NV PRO", _deptPro, ["Procurement"]);
|
||||
var tpb = await _fx.CreateUserAsync(
|
||||
$"tpb-{Guid.NewGuid():N}@test", "TPB PRO", _deptPro, ["DeptManager", "Procurement"]);
|
||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
||||
|
||||
await _service.TransitionAsync(
|
||||
pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"],
|
||||
ApprovalDecision.Approve, "review NV");
|
||||
|
||||
// Re-fetch tracked entity (service modifies state ở Phase prior).
|
||||
pe = await _db.PurchaseEvaluations.FirstAsync(x => x.Id == pe.Id);
|
||||
|
||||
// Act: TPB confirm.
|
||||
await _service.TransitionAsync(
|
||||
pe, PurchaseEvaluationPhase.ChoCCM, tpb.Id, ["DeptManager", "Procurement"],
|
||||
ApprovalDecision.Approve, "confirm TPB");
|
||||
|
||||
// Assert: phase đổi, có 2 row (Review + Confirm).
|
||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
||||
|
||||
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
deptApprovals.Should().HaveCount(2);
|
||||
deptApprovals.Should().Contain(a => a.Stage == ApprovalStage.Review && a.ApproverUserId == nv.Id);
|
||||
deptApprovals.Should().Contain(a => a.Stage == ApprovalStage.Confirm && a.ApproverUserId == tpb.Id && !a.IsBypassed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NV_With_BypassReview_Allows_Transition_With_IsBypassed_True()
|
||||
{
|
||||
// Arrange: NV CanBypassReview=true.
|
||||
var nv = await _fx.CreateUserAsync(
|
||||
$"nv-bypass-{Guid.NewGuid():N}@test", "NV PRO bypass",
|
||||
_deptPro, ["Procurement"], canBypassReview: true);
|
||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
||||
|
||||
// Act: bypass user approve → đẩy thẳng Stage=Confirm.
|
||||
await _service.TransitionAsync(
|
||||
pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"],
|
||||
ApprovalDecision.Approve, "bypass approve");
|
||||
|
||||
// Assert: phase đổi, có 1 row Stage=Confirm + IsBypassed=true.
|
||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
||||
|
||||
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
deptApprovals.Should().HaveCount(1);
|
||||
deptApprovals[0].Stage.Should().Be(ApprovalStage.Confirm);
|
||||
deptApprovals[0].IsBypassed.Should().BeTrue();
|
||||
deptApprovals[0].ApproverRoleSnapshot.Should().Be("NV(bypass)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_Skips_TwoStage_Logic_Entirely()
|
||||
{
|
||||
// Arrange: Admin role.
|
||||
var admin = await _fx.CreateUserAsync(
|
||||
$"admin-{Guid.NewGuid():N}@test", "Admin user", null, ["Admin"]);
|
||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
||||
|
||||
// Act: Admin approve.
|
||||
await _service.TransitionAsync(
|
||||
pe, PurchaseEvaluationPhase.ChoCCM, admin.Id, ["Admin"],
|
||||
ApprovalDecision.Approve, "admin force");
|
||||
|
||||
// Assert: phase đổi, KHÔNG có DepartmentApproval row.
|
||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
||||
|
||||
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
deptApprovals.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_Sets_RejectedFromPhase_And_Forces_DangSoanThao()
|
||||
{
|
||||
// Arrange: PE phase=ChoCCM. Drafter reject.
|
||||
var actor = await _fx.CreateUserAsync(
|
||||
$"ccm-{Guid.NewGuid():N}@test", "CCM TPB", _deptCcm, ["DeptManager", "CostControl"]);
|
||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoCCM);
|
||||
|
||||
// Act: reject (target irrelevant — service forces về DangSoanThao).
|
||||
await _service.TransitionAsync(
|
||||
pe, PurchaseEvaluationPhase.TuChoi, actor.Id, ["DeptManager", "CostControl"],
|
||||
ApprovalDecision.Reject, "không phù hợp");
|
||||
|
||||
// Assert.
|
||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
|
||||
fresh.RejectedFromPhase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resume_After_Reject_Jumps_Back_To_RejectedPhase()
|
||||
{
|
||||
// Arrange: PE rejected từ ChoCCM, đang ở DangSoanThao + RejectedFromPhase=ChoCCM.
|
||||
var drafter = await _fx.CreateUserAsync(
|
||||
$"drafter-{Guid.NewGuid():N}@test", "Drafter", _deptPro, ["Drafter"]);
|
||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.DangSoanThao);
|
||||
pe.RejectedFromPhase = PurchaseEvaluationPhase.ChoCCM;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Act: drafter trình lại từ DangSoanThao → ChoPurchasing (target không
|
||||
// quan trọng vì resume sẽ override = RejectedFromPhase). Note: service
|
||||
// jump tới ChoCCM, nhưng nếu actor có dept thì sẽ hit 2-stage logic.
|
||||
// Simpler: dùng admin để bypass 2-stage gate khi resume cũng OK.
|
||||
var admin = await _fx.CreateUserAsync(
|
||||
$"admin-resume-{Guid.NewGuid():N}@test", "Admin resume", null, ["Admin"]);
|
||||
await _service.TransitionAsync(
|
||||
pe, PurchaseEvaluationPhase.ChoPurchasing, admin.Id, ["Admin"],
|
||||
ApprovalDecision.Approve, "drafter resume");
|
||||
|
||||
// Assert: phase jump tới ChoCCM (không phải ChoPurchasing target),
|
||||
// RejectedFromPhase=null.
|
||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
||||
fresh.RejectedFromPhase.Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
// Stub notification service — tests không cần verify notification path
|
||||
// (best effort try/catch ở service đã cover fail case).
|
||||
internal class FakeNotificationService : INotificationService
|
||||
{
|
||||
public Task NotifyAsync(Guid userId, NotificationType type, string title,
|
||||
string? description = null, string? href = null, Guid? refId = null,
|
||||
CancellationToken ct = default) => Task.CompletedTask;
|
||||
|
||||
public Task NotifyManyAsync(IEnumerable<Guid> userIds, NotificationType type,
|
||||
string title, string? description = null, string? href = null,
|
||||
Guid? refId = null, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
Reference in New Issue
Block a user