Files
solution-erp/.claude/skills/contract-workflow/SKILL.md
pqhuy1987 8680f4c849 [CLAUDE] Docs: Session 17 wrap-up — PE Workflow V2 end-to-end consolidation
User chốt MD wrap-up Session 17 (13 commit từ c847dc0de0f38d) — PE
Workflow V2 schema + Service wire + UX iteration đầy đủ.

MD updates:
- STATUS.md — header counts (24 mig, 58 tables, 81 test, 43 gotcha) +
  Recently Done row consolidate Session 17 wrap-up (gộp 4 row iter cũ
  thành 1 row tổng, không cắt narrative quan trọng)
- HANDOFF.md — TL;DR Session 17 + Chunk E (Designer iter + State
  machine + Service wire + UX) + Pending Session 18+
- CLAUDE.md — count (22→24 mig, 77→81 test) + V2 schema overview
- changelog/migration-todos.md — section  Session 17 done với 7 task
  ticked + Defer Session 18+ explicit
- database/schema-diagram.md — §14 ApprovalWorkflow V2 schema (3 bảng
  + 2 column PE + state transitions + Service branch + Designer
  constraints + match approver V2 vs V1 table)
- gotchas.md — +#42 Dual schema branch + #43 Step.Order ≠ index 0-based
- skill contract-workflow — +section "Phase 9+ done Mig 22-24" với V2
  spec + Service branch + match logic table + Designer constraints +
  UX V2-aware + Defer Session 18+
- changelog/sessions/2026-05-08-1100-pe-workflow-v2-end-to-end.md (NEW)
  — full session log với 13 commit timeline + stats + gotchas + pending

Memory:
- project_solution_erp.md — entry Session 17 wrap-up đầy đủ (KHÔNG tạo
  file feedback mới — decisions specific cho project, không reusable
  cross-project per §9.5 anti-pattern)

Verify:
- 81 test pass (58 Domain + 23 Infra) — không thay đổi sau wrap-up
- 0 BE error
- 2 FE builds OK (đã verify ở các commit trước)
- Quy tắc consolidate §6.5: KHÔNG cắt narrative, chỉ phân tầng + remove
  duplicate. Session 17 row dài cố ý — cover full 13 commit context cho
  Session 18+ đọc lại.
2026-05-08 16:40:49 +07:00

459 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: contract-workflow
description: State machine 9 phase cho hợp đồng TP/NCC/Tổ đội — transition guard, role check, SLA deadline, auto-gen mã HĐ RG-001, versioned workflow admin-configurable per ContractType. Dùng khi debug chuyển phase, 403 forbidden, code sai format, bypass Chủ đầu tư, workflow policy resolution.
when-to-use:
- "transition contract"
- "chuyển phase hợp đồng"
- "HĐ quá hạn auto-approve"
- "role không duyệt được"
- "reject contract về draft"
- "mã HĐ sai format"
- "bypass CCM chủ đầu tư"
- "versioned workflow"
- "quy trình mới HĐ cũ giữ cũ"
- "WorkflowDefinition pin"
- "admin workflow designer"
---
# Contract Workflow Skill
> **Status:** Tier 3 FEATURE-COMPLETE — State transitions + code gen + SLA job + attachment + realtime notify + versioned workflow admin-configurable. Còn thiếu: email outbox (SMTP), warning 20% SLA.
>
> **Phase 6 cross-ref (2026-04-23):** Module `PurchaseEvaluation` (tiền-HĐ) có
> **workflow SEPARATE** — `PurchaseEvaluationPolicy` + registry +
> `FromDefinition` mirror pattern này nhưng tách table riêng (3 bảng
> `PurchaseEvaluationWorkflow*`) vì Phase enum khác. Xem:
> - `Domain/PurchaseEvaluations/PurchaseEvaluationPolicy.cs`
> - `Infrastructure/Services/PurchaseEvaluationWorkflowService.cs`
> - Kế thừa HĐ từ phiếu `DaDuyet` qua `CreateContractFromEvaluationCommand` — pin `Contract.WorkflowDefinitionId` theo ContractType user chọn.
>
> **Phase 9 cross-ref (Migration 16 — Session 8/9):** 3 mở rộng cross-cut 3 module
> (Contract + PurchaseEvaluation + Budget):
> - **2-stage dept approval** (đóng bug anh Kiệt FDC): NV Review → TPB Confirm.
> `*DepartmentApprovals` table UNIQUE (TargetId, Phase, Dept, Stage). Service
> inject UserManager → check role DeptManager / CanBypassReview → upsert row +
> block transition cho đến khi Stage=Confirm. **6 test PE 2-stage** ở
> `tests/.../Services/PeTwoStageApprovalTests.cs` (`IdentityFixture` reusable).
> - **Smart reject + Resume jump-back**: `Reject` → set `RejectedFromPhase` snapshot
> + force `targetPhase=DangSoanThao`. `Resume` (Drafter trình lại từ DangSoanThao
> với RejectedFromPhase != null) → jump straight tới phase đã reject, bypass
> policy guard. Áp dụng 3 module.
> - **Lock edit guards** 17 handler: Phase != DangSoanThao → throw 409 ConflictException.
> KHÔNG lock Comment + Attachment + Opinion (workflow design intent).
## Domain entities (implemented)
```
Contract ─────< ContractApproval (lịch sử mỗi transition)
─────< ContractComment (thread góp ý)
─────< ContractAttachment (scan signed/sealed)
─────> WorkflowDefinition (PINNED at create-time, nullable FK)
ContractCodeSequence (Prefix PK, LastSeq) — gen mã HĐ atomic
WorkflowDefinition ─────< WorkflowStep ─────< WorkflowStepApprover
(Code + Version + IsActive + ContractType) (Kind=Role|User + AssignmentValue)
WorkflowTypeAssignment (admin override legacy, fall back khi Contract.WorkflowDefinitionId == null)
```
## 9 phase state machine
```
DangChon(1) → DangSoanThao(2) → DangGopY(3) → DangDamPhan(4) → DangInKy(5) →
DangKiemTraCCM(6) → DangTrinhKy(7) → DangDongDau(8) → DaPhatHanh(9)
Alternates:
DangSoanThao → TuChoi(99)
DangGopY → DangSoanThao (revise)
DangKiemTraCCM → DangSoanThao (CCM reject)
DangTrinhKy → DangSoanThao (BOD reject)
Bypass (HĐ Chủ đầu tư, BypassProcurementAndCCM=true):
DangInKy → DangTrinhKy (skip CCM)
Policy variants (hardcoded fallback, dùng khi không có WorkflowDefinition pin):
- Standard (8 phase full CCM) — Thầu phụ, Giao khoán, NCC
- SkipCcm (7 phase bỏ CCM) — Dịch vụ, Mua bán, Nguyên tắc NCC, Nguyên tắc DV
```
## Versioned workflow (Tier 3) — policy resolution runtime
```csharp
// ContractWorkflowService.LoadPolicyAsync(contractId):
// 1. Load contract
var c = await db.Contracts.FindAsync(contractId);
// 2. If pinned — nạp từ DB
if (c.WorkflowDefinitionId != null) {
var def = await db.WorkflowDefinitions
.Include(d => d.Steps).ThenInclude(s => s.Approvers)
.FirstAsync(d => d.Id == c.WorkflowDefinitionId);
return WorkflowPolicyRegistry.FromDefinition(def);
// → xây policy runtime từ Steps.Approvers
// → Role-kind → allowedRoles
// → User-kind (data model ready, iter sau enable guard)
}
// 3. Nếu không pin — check admin override WorkflowTypeAssignments
var assignment = await db.WorkflowTypeAssignments.FirstOrDefaultAsync(a => a.ContractType == c.Type);
if (assignment != null) return WorkflowPolicyRegistry.ByName(assignment.PolicyName);
// 4. Fallback hardcoded
return WorkflowPolicyRegistry.For(c.Type); // Standard or SkipCcm
```
## Admin designer flow (Tạo version mới)
```
Admin → /system/workflows → grid 7 type card
→ click "HĐ Mua bán" → /system/workflows/MuaBan
→ thấy QT-MB-v01 active + history
→ click "Tạo phiên bản mới" (có thể Clone từ v01)
→ Designer modal:
Code: QT-MB (auto-fill)
Version: v02 (auto-compute max+1)
Name + Description
Steps (repeatable, reorderable):
Step 1: Phase=2 (DangSoanThao) SLA=7d
Approvers: +Role Drafter, +Role DeptManager
Step 2: Phase=3 (DangGopY) SLA=7d
Approvers: +Role ProjectManager, +User {userId alice}
...
→ Save → POST /api/workflows
BE atomically:
UPDATE WorkflowDefinitions SET IsActive=0 WHERE ContractType=5 AND IsActive=1;
INSERT WorkflowDefinitions (Id, Code='QT-MB', Version=2, IsActive=1, ...);
INSERT WorkflowSteps / WorkflowStepApprovers batch;
→ trở về /system/workflows/MuaBan → v02 active badge, v01 archived "N HĐ còn chạy"
→ HĐ cũ pin v01 KHÔNG BỊ ẢNH HƯỞNG (Contract.WorkflowDefinitionId = v01.Id)
→ HĐ mới tạo sau đó pick active → pin v02
```
## SLA mặc định (khi pinned def không có SlaDays → fallback)
| Phase | SLA fallback |
|---|---|
| DangSoanThao | 7d |
| DangGopY | 7d |
| DangDamPhan | 7d |
| DangInKy | 1d |
| DangKiemTraCCM | 3d |
| DangTrinhKy | 1d |
| DangDongDau | none |
| DaPhatHanh | none |
Nếu pinned WorkflowStep có `SlaDays > 0` → ưu tiên value của step đó.
## Role × Phase guard matrix (hardcoded Standard)
Xem `WorkflowPolicies.Standard.Transitions`. Tóm tắt:
| Phase hiện tại → target | Roles được phép |
|---|---|
| DangSoanThao → DangGopY | Drafter, DeptManager |
| DangSoanThao → TuChoi | Drafter, DeptManager |
| DangGopY → DangDamPhan | Drafter, DeptManager |
| DangGopY → DangSoanThao | ProjectManager, Procurement, CostControl, Finance, Accounting, Equipment |
| DangDamPhan → DangInKy | Drafter, DeptManager, ProjectManager |
| DangInKy → DangKiemTraCCM | Drafter, DeptManager, ProjectManager |
| DangInKy → DangTrinhKy (bypass) | Drafter, DeptManager, ProjectManager (chỉ khi `BypassProcurementAndCCM=true`) |
| DangKiemTraCCM → DangTrinhKy | CostControl |
| DangKiemTraCCM → DangSoanThao | CostControl |
| DangTrinhKy → DangDongDau | Director, AuthorizedSigner |
| DangTrinhKy → DangSoanThao | Director, AuthorizedSigner |
| DangDongDau → DaPhatHanh | HrAdmin |
**Admin bypass:** user có role `Admin` → pass mọi guard. Dùng để test flow nhanh.
**Versioned override:** Nếu HĐ có `WorkflowDefinitionId` pin → allowed roles sẽ lấy từ `WorkflowStep.Approvers` (Role-kind) thay vì hardcoded. Admin có thể config 2 role bất kỳ cho step 3, guard theo đó.
## Mã HĐ gen (RG-001)
Xem `ContractCodeGenerator.GenerateAsync()`. Format theo loại HĐ:
| Type | Format |
|---|---|
| HopDongThauPhu | `{ProjectCode}/HĐTP/SOL&{SupplierCode}/{Seq:D2}` |
| HopDongGiaoKhoan | `{ProjectCode}/HĐGK/SOL&{SupplierCode}/{Seq:D2}` |
| HopDongNhaCungCap | `{ProjectCode}/NCC/SOL&{SupplierCode}/{Seq:D2}` |
| HopDongDichVu | `{ProjectCode}/HĐDV/SOL&{SupplierCode}/{Seq:D2}` |
| HopDongMuaBan | `{ProjectCode}/MB/SOL&{SupplierCode}/{Seq:D2}` |
| HopDongNguyenTacNCC | `{Year}/NCC/SOL&{SupplierCode}/{Seq:D2}` ← framework |
| HopDongNguyenTacDichVu | `{Year}/HĐDV/SOL&{SupplierCode}/{Seq:D2}` ← framework |
**Transactional:** `BeginTransactionAsync(IsolationLevel.Serializable)` + `ContractCodeSequences` row UPSERT. Tránh race condition khi 2 HĐ cùng prefix gen song song.
**Gen khi nào:** transition sang `DangDongDau`. Nếu `MaHopDong` đã có (reject rồi approve lại) → giữ nguyên, không gen lại.
## Code pointers (Tier 3 updated)
**Backend Domain:**
- `Domain/Contracts/Contract.cs` — aggregate root (+ `WorkflowDefinitionId?`)
- `Domain/Contracts/ContractApproval.cs` — history
- `Domain/Contracts/ContractComment.cs` — thread
- `Domain/Contracts/ContractAttachment.cs` — files
- `Domain/Contracts/ContractCodeSequence.cs` — seq table
- `Domain/Contracts/WorkflowPolicy.cs` — record + `WorkflowPolicies.Standard/SkipCcm` + `WorkflowPolicyRegistry.{For, FromDefinition, ByName}`
- **`Domain/Contracts/WorkflowDefinition.cs`** — versioned policy header
- **`Domain/Contracts/WorkflowStep.cs`** — step trong definition
- **`Domain/Contracts/WorkflowStepApprover.cs`** — Role/User approver (+ `ApproverKind` enum)
- `Domain/Contracts/WorkflowTypeAssignment.cs` — legacy admin override
**Backend Application:**
- `Application/Contracts/Services/IContractWorkflowService.cs` + `IContractCodeGenerator.cs`
- `Application/Contracts/ContractFeatures.cs` — CQRS (Create pin WorkflowDefId, Update draft, Transition, AddComment, List, Inbox, GetDetail, Delete)
- `Application/Contracts/ContractAttachmentFeatures.cs` — Upload/Download/Delete CQRS
- **`Application/Contracts/WorkflowAdminFeatures.cs`** — `GetWorkflowAdminOverviewQuery` + `CreateWorkflowDefinitionCommand`
**Backend Infrastructure:**
- `Infrastructure/Services/ContractWorkflowService.cs` — resolve policy (pinned → override → fallback), state + role guard
- `Infrastructure/Services/ContractCodeGenerator.cs` — transactional gen
- `Infrastructure/Services/NotificationService.cs` — write to DbContext (caller atomicity)
- `Infrastructure/Persistence/Interceptors/NotificationPushInterceptor.cs` — auto-push via SignalR
**Backend Api:**
- `Api/Controllers/ContractsController.cs` — REST endpoints
- **`Api/Controllers/WorkflowsController.cs`** — admin overview + create new version
- `Api/Hubs/NotificationHub.cs` + `Api/Realtime/SignalRNotifier.cs`
**Frontend Admin:**
- `fe-admin/src/pages/contracts/{ContractsListPage, ContractCreatePage, ContractDetailPage}.tsx`
- `fe-admin/src/pages/system/WorkflowsPage.tsx` — URL-driven landing + per-type
- `fe-admin/src/components/workflow/{WorkflowDesigner, DefinitionCard}.tsx`
- `fe-admin/src/components/{PhaseBadge, WorkflowSummaryCard, ContractAttachmentsSection, DynamicForm, SlaTimer}.tsx`
**Frontend User:**
- `fe-user/src/pages/InboxPage.tsx` — filter `?type=X`
- `fe-user/src/pages/contracts/{ContractCreatePage, ContractDetailPage, MyContractsPage}.tsx`
- `fe-user/src/components/{Layout, ContractAttachmentsSection, SlaTimer, NotificationBell}.tsx`
## API endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/contracts` | List với filter phase/supplier/project/type + paging + pendingMe |
| GET | `/api/contracts/inbox` | HĐ chờ role của user xử lý |
| GET | `/api/contracts/{id}` | Detail + approvals + comments + attachments + pinned workflow |
| POST | `/api/contracts` | Tạo draft — pin `WorkflowDefinitionId = active version for type` |
| PUT | `/api/contracts/{id}` | Update draft (chỉ khi Phase = DangSoanThao) |
| POST | `/api/contracts/{id}/transitions` | Chuyển phase (body: `{targetPhase, decision, comment}`) |
| POST | `/api/contracts/{id}/comments` | Thêm comment vào thread |
| POST | `/api/contracts/{id}/attachments` | Upload file (multipart, 20MB, MIME whitelist) |
| GET | `/api/contracts/{id}/attachments/{aid}` | Download stream |
| DELETE | `/api/contracts/{id}/attachments/{aid}` | Delete (+ cleanup file) |
| DELETE | `/api/contracts/{id}` | Soft delete (chỉ < DangInKy) |
| **GET** | **`/api/workflows`** | Admin: overview per ContractType (active + history + "N còn chạy") |
| **GET** | **`/api/workflows/{type}`** | Per-type definitions + steps + approvers |
| **POST** | **`/api/workflows`** | Create new version (auto-increment + deactivate old) |
## Guard Rules đã implement
- **State adjacency:** chỉ cho chuyển giữa các (from, to) đã khai báo trong `policy.Transitions` (pinned def hoặc hardcoded)
- **Role check:** role của actor phải allowed roles của transition đó (từ Role-kind approvers hoặc hardcoded)
- **Admin bypass:** role `Admin` pass mọi check
- **System bypass:** `actorUserId == null` + `Decision = AutoApprove` cho phép (dành cho SLA job)
- **Bypass CCM:** `Contract.BypassProcurementAndCCM=true` cho phép `DangInKy → DangTrinhKy` (skip CCM)
- **Self-delete:** không cho xóa đã qua `DangInKy`
- **Versioned pin:** `Contract.WorkflowDefinitionId` pinned at create không update sau đó. FK restrict admin không xóa được def nếu còn tham chiếu
## Workflow tạo HĐ end-to-end (testable, Tier 3)
```bash
# 1. Setup master data (auto-seeded: 5 supplier + 3 project + 9 dept)
# 2. Admin tạo version mới cho HĐ Mua bán
POST /api/workflows
{
"code": "QT-MB",
"name": "Quy trình Mua bán v02",
"contractType": 5,
"steps": [
{ "order": 1, "phase": 2, "name": "Soạn thảo", "slaDays": 7,
"approvers": [{ "kind": 1, "assignmentValue": "Drafter" }, { "kind": 1, "assignmentValue": "DeptManager" }] },
{ "order": 2, "phase": 3, "name": "Góp ý", "slaDays": 7,
"approvers": [{ "kind": 1, "assignmentValue": "ProjectManager" }, { "kind": 2, "assignmentValue": "{userId}" }] },
...
]
}
# → Version=02, IsActive=1, v01 deactivated
# 3. User tạo HĐ Mua bán → pin WorkflowDefinitionId = v02.Id
POST /api/contracts { type: 5, supplierId, projectId, giaTri: ..., tenHopDong: "..." }
# → Phase=DangSoanThao, SlaDeadline=+7d, WorkflowDefinitionId=v02
# 4. Transition — guard load từ v02.Steps.Approvers
POST /api/contracts/{id}/transitions { targetPhase: 3, decision: 1, comment: "..." }
# 5. Chuyển qua các phase
4 DangDamPhan → 5 DangInKy → (skip CCM nếu SkipCcm policy)7 DangTrinhKy
# 6. BOD ký → gen mã HĐ
8 DangDongDau
# contract.MaHopDong = "FLOCK 01/MB/SOL&PVL/01"
# 7. HRA đóng dấu + phát hành
9 DaPhatHanh
```
## Common pitfalls (xem gotchas.md)
- **Admin check mọi phase** đôi khi không catch role-scope bug. Test với user không phải Admin.
- ** gen 2 lần** (sau reject rồi approve) generator check `if (MaHopDong is null)` trước khi gen.
- **Race condition gen song song** dùng `IsolationLevel.Serializable`, không skip.
- **SLA Deadline không reset khi reject** `TransitionAsync` luôn reset theo target phase, kể cả reject.
- **Comment phase sai** `AddCommentCommand` luôn lấy phase hiện tại tại thời điểm comment.
- **FE hiển thị next phase button** RESOLVED Tier 3. FE dùng `contract.workflow.nextPhases` từ BE (pinned policy single source of truth).
- **WorkflowDefinition cascade delete** NÊN restrict FK. Nếu cascade sẽ xóa Contract data loss. Đã fix trong migration.
- **User-kind approver không enforce runtime** designer cho chọn nhưng guard v1 chỉ check Role. Iter 2 cần wire `step.Approvers.Where(a => a.Kind == User)` vào check.
## Phase 9 done (Mig 16 — Session 8/9)
- [x] **2-stage dept approval** xuyên 3 module NV Review BLOCK / TPB Confirm ALLOW
- [x] **Smart reject + Resume jump-back** RejectedFromPhase snapshot + bypass policy
- [x] **Lock edit guards** 17 handler khi Phase != DangSoanThao
- [x] **CanBypassReview toggle** per User admin UI + audit IsBypassed=true
## Phase 9+ done (Mig 18-20 — Session 12/13/14)
### N-stage workflow (PE Mig 18-19 + Contract Mig 20)
- [x] **N-stage approval** Phòng × PositionLevel (NV/PP/TP) cấu hình động per
WorkflowStep cha. Mỗi inner step = 1 cấp duyệt (Order asc, sequential).
- [x] **PositionLevel enum** Domain/Identity (NV=1, PP=2, TP=3) + `User.PositionLevel int?`
- [x] **InnerStep entity** + ALTER `*DepartmentApproval.InnerStepId Guid?` per module
- [x] **Filtered unique split** (Mig 19 cho PE / gộp Mig 20 cho Contract):
legacy `WHERE InnerStepId IS NULL` (Stage Review/Confirm) + N-stage
`WHERE InnerStepId IS NOT NULL` (per inner step)
- [x] **Service refactor** TransitionAsync load InnerSteps eager + reject
branch clear N-stage rows + dept approval block split: hasInnerStepsN-stage
logic / elselegacy 2-stage. Match firstPending Order asc + (exact level
OR canBypass + level≥). Bypass batch upsert NV+PP+TP cùng dept actor.
- [x] **6 test PE N-stage** + **6 test Contract N-stage** (`PeNStageApprovalTests`,
`ContractNStageApprovalTests`). Pattern reusable.
- [x] **FE Designer** (PeWorkflowsPage + WorkflowsPage) sub-section "Cấp duyệt nhỏ
trong phòng" drag-list { Phòng × Cấp + required }
- [x] **UsersPage cột Cấp** + cycle button null123null
- [x] **API** `PATCH /users/{id}/position-level`
**Backward compat 100%:** workflow no InnerSteps service fallback legacy 2-stage
Mig 16. Data legacy InnerStepId=null vẫn enforce unique qua filtered index.
### PE 3-button approval (Session 14 — `0d77698`)
UI distinguishment 3 hành động cho approver:
- **Duyệt** = forward (decision=Approve)
- **Trả lại** = về DangSoanThao + Drafter sửa (decision=Reject + target=DangSoanThao
smart reject pattern Mig 16 + clear N-stage rows + jump-back)
- **Từ chối** = Phase=TuChoi (decision=Reject + target=TuChoi phiếu khoá
vĩnh viễn 17 handler Mig 16 lock edit, Drafter phải tạo phiếu mới)
**Domain policy expand:** NccOnly + NccWithPlan + FromDefinition thêm `(X → TuChoi)`
transition cho mọi phase trung gian (trước chỉ DangSoanThaoTuChoi).
**Service Reject branch tách 2 case:**
- target=TuChoi giữ nguyên (KHÔNG override + KHÔNG set RejectedFromPhase + KHÔNG clear N-stage)
- target khác (DangSoanThao) smart reject (force DangSoanThao + RejectedFromPhase + clear N-stage)
### Defer
- Budget N-stage cần migration `AddBudgetVersionedWorkflow` trước (Budget
hardcoded `BudgetPolicy.Default`, chưa versioned WorkflowDefinition).
- Phase TraLai = 98 (Domain enum từ S11+++++++) orphan, KHÔNG wire (user
Session 14 chốt không cần phase trung gian).
## Phase 9+ done (Mig 21 — Session 16) — Drastic refactor flat workflow
Bỏ phase enum legacy 2-9. Dùng `ChoDuyet=10` đơn nhất + `CurrentWorkflowStepIndex` tracking flat. WorkflowStep + DepartmentId/PositionLevel. Service iterate steps `OrderBy Order` advance pointer per approve. Match approver: actor.Dept+PositionLevel OR Approvers Role/User. 9677 test pass (drop 19 N-stage/2-stage legacy). Phase legacy 2-6 + 98 deprecated giữ enum cho data . Reject về DangSoanThao + RejectedAtStepIndex jump-back resume.
## Phase 9+ done (Mig 22-24 — Session 17) — V2 schema riêng + state machine 5 trạng thái
**Spec change (Session 17):** schema riêng `ApprovalWorkflowsV2` (3 bảng) song song V1 (Mig 21 vẫn live). Cấu trúc: Quy trình > Bước (Phòng) > Cấp (N NV cụ thể qua `ApproverUserId`). PE Service wire xong, Contract V2 chưa (defer Session 18+).
### State machine 5 trạng thái (mirror Contract sẽ áp dụng tương tự)
```
Nháp (DangSoanThao) ──Drafter trình──► Đã gửi duyệt (ChoDuyet)
Trả lại (TraLai=98) ──Drafter sửa+gửi lại──► Đã gửi duyệt (chạy LẠI từ Cấp 1 Bước 1)
Đã gửi duyệt ──advance level/step──► Đã gửi duyệt
──last cấp last bước done──► Đã duyệt (DaDuyet, terminal)
──Approver Trả lại──► Trả lại
──Approver Từ chối──► Từ chối (TuChoi, terminal)
```
Khác Mig 21 (Session 16): Trả lại = Phase RIÊNG, KHÔNG revert DangSoanThao + KHÔNG jump-back. Drafter từ TraLai gửi lại = entry point thứ 2 mirror DangSoanThao. `RejectedAtStepIndex/RejectedFromPhase` deprecated (giữ DB column data cũ).
### Service branch theo schema pin
```csharp
// PurchaseEvaluationWorkflowService.TransitionAsync (gotcha #42)
if (evaluation.ApprovalWorkflowId is Guid awId)
await ApproveV2Async(...) // V2: iterate AW.Steps + Levels match ApproverUserId
else
await ApproveV1LegacyAsync(...) // V1: iterate WorkflowSteps match Dept+PositionLevel
```
### Match approver V2 — KHÁC V1
| Schema | Approver type | Match logic |
|---|---|---|
| V1 (Mig 21) | Group qua Dept+PositionLevel | actor.Dept == step.Dept AND actor.PositionLevel >= step.PositionLevel |
| V2 (Mig 22-24) | NV cụ thể 1-1 | actor.Id == any level.ApproverUserId where level.Order == currentLevelOrder |
| Cả 2 | Role-based fallback | Approvers explicit Kind=Role match actor.Roles |
| Cả 2 | Admin bypass | actorRoles.Contains("Admin") → skip mọi check |
### Pin V2 (Mig 23-24)
```csharp
public class PurchaseEvaluation : AuditableEntity
{
public Guid? WorkflowDefinitionId { get; set; } // V1 legacy (Mig 21)
public Guid? ApprovalWorkflowId { get; set; } // V2 mới (Mig 23) — pin lúc create
public int? CurrentWorkflowStepIndex { get; set; } // 0-based index Step (post-sort by Order)
public int? CurrentApprovalLevelOrder { get; set; } // 1/2/3 Cấp đang chờ (Mig 24)
// ...
}
```
### Designer V2 constraints
- Tối đa 3 Cấp/Bước (Order ∈ {1,2,3})
- Sequential gating: 1 / 1+2 / 1+2+3 (chặn 2 hoặc 1+3)
- Mỗi Cấp có N NV (multiple Level rows cùng Order = same Cấp, OR-of-N)
- 3 section cố định C1/C2/C3 trong UI, button "+ Thêm NV" mỗi cấp
- C2 disabled khi C1 empty, C3 disabled khi C2 empty
- Filter NV theo Phòng đã chọn (đổi Phòng → clear approvers)
- Validator BE: `HaveSequentialOrders` + `HaveNoDuplicateApproverInSameLevel`
### FE UX V2-aware
- Banner emerald "Đến lượt bạn" / amber "Không phải lượt bạn — chỉ {NV X / Y} duyệt được"
- Button "Duyệt forward" disabled khi V2 + actor không trong Cấp + tooltip
- Button "Trả lại" + "Từ chối" enabled cho mọi user (BE không gating reject theo cấp)
- Inbox V2-aware (`ResolveV2InboxIdsAsync` precompute IDs khớp actor.Id ∈ Cấp hiện tại)
- 2 dropdown filter "Quy trình" + "Trạng thái" (chỉ ở Duyệt, không Danh sách)
- Panel 3 thay 4 phase cards bằng flow workflow thực tế: Bước (icon ✓/●/○) → Cấp với label "đang chờ"/"đã duyệt"
### DTO mới Session 17
- `PurchaseEvaluationCurrentApprovalDto` — Cấp hiện tại + N approvers (cho banner + button gating)
- `PurchaseEvaluationApprovalFlowDto` — full Steps/Levels tree với Status Done/Current/Pending (cho Panel 3 render)
### Defer Session 18+
- Contract V2 wire (Mig 25) — mirror PE Mig 23-24 pattern
- Phân quyền strict V2 (hiện loose UAT — mọi authenticated user thấy phiếu V2)
- Drop legacy V1 sau khi không còn phiếu pin → drop tables WorkflowDefinitions/Steps/Approvers + drop deprecated columns RejectedAtStepIndex/RejectedFromPhase
- Test Domain ApproveV2Async + match logic (defer khi có sample data UAT)
## Tier 4+ (còn thiếu / future)
- [ ] Warning notification 20% SLA (`SlaWarningSent` flag đã có)
- [ ] User-kind approver runtime guard (data model ready)
- [ ] Email notification (MailKit) khi chuyển phase — BLOCKED SMTP
- [ ] RowVersion optimistic concurrency (2 user cùng duyệt → 409)
- [ ] ContractClause appendix attach khi export HĐ trọn gói
- [ ] Audit log riêng (`AuditLogs` table) ngoài `ContractApprovals`
- [ ] MediatR `AuditBehavior` — log mọi command
- [ ] E-signature integration (VNPT/FPT CA)