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

24 KiB
Raw Blame History

name, description, when-to-use
name description when-to-use
contract-workflow 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.
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 SEPARATEPurchaseEvaluationPolicy + 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-stagetests/.../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

// 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.csGetWorkflowAdminOverviewQuery + 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 HĐ 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 HĐ đã qua DangInKy
  • Versioned pin: Contract.WorkflowDefinitionId pinned at create → không update sau đó. FK restrict → admin không xóa được def nếu HĐ còn tham chiếu

Workflow tạo HĐ end-to-end (testable, Tier 3)

# 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 phase4 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ành9 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.
  • Mã HĐ gen 2 lần (sau reject rồi approve) → generator check if (MaHopDong is null) trước khi gen.
  • Race condition gen mã song song → dùng IsolationLevel.Serializable, không skip.
  • SLA Deadline không reset khi rejectTransitionAsync luôn reset theo target phase, kể cả reject.
  • Comment ở phase saiAddCommentCommand 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 cũ → 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)

  • 2-stage dept approval xuyên 3 module — NV Review BLOCK / TPB Confirm ALLOW
  • Smart reject + Resume jump-back — RejectedFromPhase snapshot + bypass policy
  • Lock edit guards 17 handler khi Phase != DangSoanThao
  • 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)

  • 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).
  • PositionLevel enum Domain/Identity (NV=1, PP=2, TP=3) + User.PositionLevel int?
  • InnerStep entity + ALTER *DepartmentApproval.InnerStepId Guid? per module
  • 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)
  • Service refactor TransitionAsync — load InnerSteps eager + reject branch clear N-stage rows + dept approval block split: hasInnerSteps→N-stage logic / else→legacy 2-stage. Match firstPending Order asc + (exact level OR canBypass + level≥). Bypass batch upsert NV+PP+TP cùng dept ≤ actor.
  • 6 test PE N-stage + 6 test Contract N-stage (PeNStageApprovalTests, ContractNStageApprovalTests). Pattern reusable.
  • FE Designer (PeWorkflowsPage + WorkflowsPage) sub-section "Cấp duyệt nhỏ trong phòng" drag-list { Phòng × Cấp + required }
  • UsersPage cột Cấp + cycle button null→1→2→3→null
  • 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 cũ 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ỉ DangSoanThao→TuChoi).

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 có 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. 96→77 test pass (drop 19 N-stage/2-stage legacy). Phase legacy 2-6 + 98 deprecated giữ enum cho data cũ. 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

// 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)

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)