Compare commits

..

8 Commits

Author SHA1 Message Date
eea86fdfe7 [CLAUDE] Docs: Chunk E — chốt Session 21 turn 4 F1+F2+F3 PE Workflow advanced options (Mig 28)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m31s
Update docs theo rule §6.5 KEEP narrative:

- `docs/database/schema-diagram.md §14` title "Mig 22-28, S17-21" + thêm 6
  column Allow* inline trong Core ApprovalWorkflows block (inline comment
  F1/F2/F3 mapping). Bonus: 3 bảng (Steps + Levels) unchanged.

- `docs/STATUS.md` Last updated S21 t4 + count 27→28 mig (Mig 28 advanced
  options 6 column). UAT defer test count unchanged 84 (test-after-uat candidate
  bundle Plan C carry).

- `docs/HANDOFF.md` Insert TL;DR S21 t4 đầy đủ (trước S21 t3):
  - Q&A clarify 2 lượt chốt scope (F1 cả 2 mode admin, F1 Assignee runtime,
    F2 chỉ Cấp cuối, F3 Section 2 only, F2+F3 admin tick, F3 mọi approver
    active, test-after UAT)
  - 5 chunk narrative đầy đủ A schema → B BE → C FE Admin → D FE eOffice → E Docs
  - Pattern reusable: backward-compat option flags, boundary helper extension
  - State table cumulative + pending Plan C test-after catch-up

- NEW session log `docs/changelog/sessions/2026-05-13-1200-s21-turn4-pe-workflow-advanced-options.md`:
  - Trigger + Q&A 2 lượt
  - 5 chunk narrative chi tiết với code snippets
  - Pattern reusable 5 lessons learned
  - References file paths + spec context

Stats cumulative S21 t4:
- 28 mig (+1) · 59 tables · ~143 endpoints (+1) · 34 FE pages · 84 test pass
  (UAT defer test-after §7) · 45 gotcha unchanged · 17 memory · 6 skills
- 5 commits S21 t4 cumulative ready push remote

Pending: bro confirm push `0a3b747..HEAD` 8 commits ahead (S21 t3 + S21 t4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:11:14 +07:00
d27caafcf5 [CLAUDE] FE-PE: Chunk D — eOffice Trả lại modes + Skip CEO + Approver edit Section 2 (F1+F2+F3) mirror 2 app
Types (fe-{admin,user}/src/types/purchaseEvaluation.ts):
- ApprovalWorkflowOptions type (6 boolean Allow* flag)
- WorkflowReturnMode const-object {OneLevel,OneStep,Assignee,Drafter}
- PeDetailBundle +workflowOptions field (null nếu V1 legacy)

PeWorkflowPanel.tsx F1 (mirror 2 app):
- State returnMode + returnTargetUserId thêm vào transition mutation payload
- Dialog Trả lại render radio list 1-4 mode enabled theo workflowOptions:
  • Trả về 1 Cấp trước (lùi pointer trong cùng Bước, peer review)
  • Trả về 1 Bước trước (Cấp cuối Bước trước nhận lại)
  • Trả về Người chỉ định (pick từ dropdown NV đã ký levelOpinions)
  • Trả về Người soạn thảo (default Drafter S17 fallback)
- Banner amber rounded box dưới radio list mô tả hành vi mode chọn
- onSuccess reset returnMode về Drafter + returnTargetUserId null

PeDetailTabs.tsx F2 (mirror 2 app):
- State skipToFinal + allowSkipToFinal (từ workflowOptions)
- submitForApproval mutationFn accept opts.skipToFinal → POST body
- Workspace action bar: thêm checkbox violet "Gửi thẳng Cấp cuối (skip trung gian)"
  hiển thị conditional theo allowSkipToFinal + canSubmitForApproval
- Confirm dialog message dynamic: "Gửi thẳng" warning vs default tuần tự
- Button label dynamic: "Lưu & Gửi thẳng CẤP CUỐI →" vs "Lưu & Gửi Duyệt →"

PeDetailTabs.tsx F3 (mirror 2 app):
- useAuth import + compute approverEditMode (phase=ChoDuyet +
  workflow.AllowApproverEditDetails + actor match currentApproval.approvers)
- itemsReadOnly = readOnly && !approverEditMode → ItemsTab nhận
- Banner violet "ⓘ Bạn được phép chỉnh sửa Hạng mục/NCC/Báo giá" khi
  approverEditMode + readOnly (Duyệt menu) — UX nhắc về quyền extended

InfoTab + NccSelectorRow + BudgetFieldRow GIỮ strict isEditablePhase (KHÔNG
trong F3 scope — Header section + Section 3 winner KHÔNG cho Approver edit).

Verify:
- npm run build × 2 app pass (fe-user 7.52s, fe-admin 499ms cached)
- 0 TS6 err, warning chunk size pre-existing
- BE Chunk B đã accept skipToFinal + returnMode + returnTargetUserId trong
  TransitionPurchaseEvaluationCommand → wire E2E complete

Pending Chunk E: Docs schema-diagram §14 update + STATUS + HANDOFF + session log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:08:08 +07:00
a508564b45 [CLAUDE] FE-Admin: Chunk C — ApprovalWorkflowDesigner section "Cấu hình nâng cao" 6 checkbox (F1+F2+F3)
Thêm section "Cấu hình nâng cao" trong Designer modal (giữa Description và
Steps), 3 sub-group 6 checkbox per workflow version:

1. Mode Trả lại (Approver chọn khi nhấn ← Trả lại):
   - Trả về 1 Cấp trước (peer review chain trong cùng Bước)
   - Trả về 1 Bước trước (Cấp cuối Bước trước nhận lại)
   - Trả về Người chỉ định (pick runtime từ list NV đã duyệt)
   - Trả về Người soạn thảo (default checked = backward compat S17)

2. Drafter gửi duyệt:
   - Cho phép Drafter gửi thẳng Cấp cuối (F2 skip mọi Bước/Cấp trung gian)

3. Approver chỉnh sửa phiếu:
   - Cho phép Approver chỉnh sửa Section 2 Hạng mục/NCC/Báo giá (F3, giữ Cấp)

DTO types update:
- DefinitionDto +6 boolean field (mirror BE AwDefinitionDto)
- 6 useState cho 6 flag, default từ cloneFrom (giữ config version trước) hoặc
  S17 backward compat (chỉ AllowReturnToDrafter=true)
- POST body extend 6 field gửi BE

Styling:
- Container amber-50/30 + border amber-200 (visual distinction với Steps section)
- Mỗi checkbox: card border-slate-200 bg-white, hover bg-amber-50/40
- Helper text [10px] text-slate-500 dưới label giải thích mode
- Headers [11px] uppercase text-slate-500 group sub-section

fe-user KHÔNG mirror — ApprovalWorkflowsV2Page admin-only. PeWorkspaceCreateView
chỉ filter IsUserSelectable, không cần Allow* flag lúc create phiếu.

Verify:
- npm run build fe-admin pass (8.72s, 0 TS6 err)
- Warning chunk size pre-existing

Pending Chunk D: FE eOffice (Trả lại modal dropdown + Skip submit + Edit
Section 2 enable conditional theo workflow.options) mirror 2 app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:59:45 +07:00
c56024ba25 [CLAUDE] PE-Workflow: Chunk B — BE Service + handlers + DTOs (F1+F2+F3)
F1 — 4 mode Trả lại (Service.ApplyReturnModeAsync helper):
- WorkflowReturnMode enum (OneLevel / OneStep / Assignee / Drafter)
- OneLevel: lùi 1 Cấp trong cùng Step (peer review). Bước 1 Cấp 1 → fallback Drafter.
- OneStep: lùi sang Bước trước Cấp cuối. Bước 1 → fallback Drafter.
- Assignee: pick runtime → tìm Step+Level match ApproverUserId trong workflow.
- Drafter: Phase=TraLai clear pointer như S17 (backward compat).
- 3 mode đầu giữ Phase=ChoDuyet, reset SLA 7d. Mode Drafter clear SLA.
- Admin bypass workflow.Allow* flag check. Non-admin → throw ConflictException
  với message rõ "Workflow không bật mode X".

F2 — Drafter skipToFinal (extend DRAFTER trình branch):
- Workflow.AllowDrafterSkipToFinal=true required (non-admin)
- Set CurrentWorkflowStepIndex = Steps.Count-1 + CurrentApprovalLevelOrder = max Level
- Audit comment append "[Drafter gửi thẳng Cấp cuối]"

F3 — Approver edit Section 2 (Detail + NCC + Báo giá):
- New helper `EnsureEditableForDetailsAsync` (extend pattern PurchaseEvaluationDraftGuard):
  - Drafter scope: DangSoanThao OR TraLai (any role, Controller [Authorize] handles)
  - F3 Approver scope: ChoDuyet + workflow.AllowApproverEditDetails=true +
    actor.Id match CurrentLevel.ApproverUserId. Admin bypass flag check.
  - Throw ForbiddenException nếu approver Cấp khác nhau (rõ Bước/Cấp trong message).
- 8 handler switch helper + inject ICurrentUser khi cần:
  - Detail: Add (existing ICurrentUser) / Update + Delete (inject new)
  - Quote: Upsert + Delete (inject new)
  - Supplier: Add (existing) / Update + Delete (inject new + add guard, trước
    đây hoàn toàn KHÔNG có phase guard — bonus security fix)
- Audit: thêm changelog Update/Delete handler (trước đây silent). Khi phase=
  ChoDuyet append " [Approver edit khi đang duyệt]" cho lịch sử rõ ai sửa.

Extension Service `TransitionAsync` signature (backward compat — 3 optional
param thêm cuối + default null/false):
- WorkflowReturnMode? returnMode = null
- Guid? returnTargetUserId = null
- bool skipToFinal = false

TransitionPurchaseEvaluationCommand DTO + Validator + Handler — mirror signature.

DTO extensions:
- ApprovalWorkflowOptionsDto NEW sub-record (6 Allow* flag) cho FE filter
- PurchaseEvaluationDetailBundleDto + WorkflowOptions field (null nếu V1 legacy)
- GetPe handler populate awOptions từ ApprovalWorkflow entity load (Mig 23 path)
- AwDefinitionDto + 6 Allow* field (admin Designer GET overview)
- CreateAwDefinitionCommand + 6 Allow* param (admin Designer POST new version)
- Handler ToDto + entity new() — propagate Allow* end-to-end

Default backward compat: workflow cũ → AllowReturnToDrafter=true (Mig 28 DB
default), 5 flag còn lại false. Phiếu cũ V2 vẫn Trả lại Drafter như S17 sau
deploy — no breaking change.

Verify:
- dotnet build SolutionErp.slnx → 0 err, 2 warn pre-existing DocxRenderer
- 3 regression test gotcha #45 vẫn PASS (backward compat signature change)
- LocalDB Dev + Design đã apply Mig 28 (Chunk A)

Pending Chunk C: FE Admin Designer mirror 2 app (6 checkbox + DTO types).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:57:09 +07:00
0294693a4a [CLAUDE] PE-Workflow: Chunk A — Mig 28 +6 Allow* column ApprovalWorkflow (F1+F2+F3 advanced options)
Domain `ApprovalWorkflow` (Mig 22 — Session 17) thêm 6 boolean cấu hình "Cấu
hình nâng cao" cho admin Designer (F1 trả lại modes + F2 skip cấp cuối + F3
approver edit Section 2):

- AllowReturnOneLevel       (default false) — F1 mode 1 lùi 1 Cấp peer review
- AllowReturnOneStep        (default false) — F1 mode 2 lùi 1 Bước
- AllowReturnToAssignee     (default false) — F1 mode 3 pick runtime từ NV đã duyệt
- AllowReturnToDrafter      (default TRUE)  — F1 mode 4 backward compat S17 fallback
- AllowDrafterSkipToFinal   (default false) — F2 Drafter trình thẳng Cấp cuối
- AllowApproverEditDetails  (default false) — F3 Approver edit HangMuc/NCC/Báo giá

Default backward compat S17: AllowReturnToDrafter=true → mọi workflow cũ chạy
đúng "Trả về Drafter" Phase=TraLai. 5 flag còn lại default false → admin
opt-in per workflow để audit nghiêm.

Mig 28 `AddAdvancedOptionsToApprovalWorkflows`:
- AddColumn × 6 bit NOT NULL DEFAULT 0/1 (3-file rule complete + Designer + Snapshot)
- Apply LocalDB SolutionErp_Dev (runtime) + SolutionErp_Design (ef tooling)

EF config ApprovalWorkflowConfiguration thêm 6 HasDefaultValue match Mig 28
default (backfill rows cũ + ef snapshot consistency).

3 mode Trả lại mới giữ Phase=ChoDuyet, chỉ lùi pointer (peer review chain
sequential). Mode Drafter giữ Phase=TraLai + clear pointer như S17. Behavior
implement trong Chunk B (Service.TransitionAsync extend branches).

Verify:
- dotnet ef migrations add success (no compile error)
- 3-file rule complete: 28 mig × 2 + Snapshot = 57 file Migrations dir
- LocalDB Dev + Design both apply success

Pending Chunk B: BE Service branches + handlers + Controller body extend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:46:01 +07:00
6d30ba42d1 [CLAUDE] Docs: Chunk C — chốt Session 21 turn 3 fix gotcha #45 PE button "Trả lại" mismatch
Add gotcha #45 narrative đầy đủ ~120 dòng KEEP rule §6.5:
- Triệu chứng UAT screenshot + user mô tả "Trả về nhưng hệ thống vẫn duyệt"
- Root cause 3 chỗ inconsistency table + BE service path
- Severity CRITICAL data integrity
- Fix Chunk A BE code + 3 test list
- Fix Chunk B FE code diff × 2 app
- Pattern reusable (boundary guard semantic invariant) + phòng tránh tương lai
- References 2 commit + Session 17 spec

+ gotchas.md checklist debug entry 22 quick lookup.

Update STATUS.md Last updated header + count 81→84 test + 44→45 gotcha.
Insert HANDOFF.md TL;DR S21 t3 đầy đủ Chunk A/B/C + state cumulative.
New session log docs/changelog/sessions/2026-05-12-2100-s21-turn3-fix-tra-lai-bug45.md.

Verify:
- 84 test PASS (dotnet test SolutionErp.slnx — Chunk A persisted)
- npm run build × 2 app pass (Chunk B persisted)
- KHÔNG paraphrase / KHÔNG cắt narrative cũ S21 t1/t2/S20 (rule §6.5 KEEP)

Pending: bro confirm push remote `0a3b747..HEAD` 3 commits ahead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:46:52 +07:00
4b29d00716 [CLAUDE] FE-PE: Chunk B — Fix button "Trả lại" gửi decision=Approve thay vì Reject (gotcha #45) mirror 2 app
Bug pattern: button "← Trả lại" trong PeWorkflowPanel.tsx hiển thị đúng label
(L205-207 isSendBack include TraLai) NHƯNG payload `isReject` (L64-66) thiếu
nhánh TraLai → gửi decision=1 (Approve) thay vì 2 (Reject) khi target=TraLai
(98). BE Service vào APPROVE STEP → ApproveV2Async UPSERT opinion "đã duyệt"
+ advance Cấp tiếp theo. User UAT thấy: "Trả về nhưng hệ thống vẫn duyệt".

Inconsistency thứ 2: dialog `isSendBack` (L247-248) cũng thiếu nhánh TraLai
→ dialog title fallback `✓ Duyệt → Trả lại` + KHÔNG hiển thị amber warning.

Fix 3 chỗ × 2 app (fe-user + fe-admin, rule §3.9 mirror):
1. `isReject` payload — thêm nhánh `target=TraLai && phase!=TraLai`
2. dialog `isSendBack` — thêm nhánh TraLai + guard phase != TraLai
3. Comments document context bug + cross-ref BE guard Chunk A

Sync với BE guard (Chunk A `de00887` `PurchaseEvaluationWorkflowService.cs`):
- BE throw ConflictException khi target ∈ {TraLai, TuChoi} && decision != Reject
- 2 phía cùng đúng → no payload mismatch

Verify:
- npm run build × 2 app pass (fe-user 17.91s, fe-admin 6.71s, 0 TS6 err)
- Warning chunk size pre-existing (NOT introduced)

Pending Chunk C: docs gotcha #45 + STATUS + HANDOFF + session log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:43:20 +07:00
de0088742f [CLAUDE] PurchaseEvaluation: Chunk A — BE guard target TraLai/TuChoi BẮT BUỘC decision=Reject + 3 regression test
Defense-in-depth chặn FE inconsistency (gotcha #45 — Session 21 turn 3).
Bug pattern: button "← Trả lại" trong PeWorkflowPanel.tsx gửi decision=Approve
khi target=TraLai do `isReject` local var thiếu nhánh TraLai → BE skip Reject
branch → enter APPROVE STEP → ApproveV2Async UPSERT opinion = "đã duyệt" +
advance Cấp. User UAT thấy: "Trả về nhưng hệ thống vẫn duyệt".

BE guard:
- Service `TransitionAsync` thêm early check sau set isAdmin/isSystem
- targetPhase ∈ {TraLai, TuChoi} && decision != Reject → throw ConflictException
- Boundary protection cho mọi caller tương lai (API client / mobile / cron)

Tests (Infra suite +3):
- TransitionAsync_TargetTraLai_WithApproveDecision_Throws_AndDoesNotMutateState
- TransitionAsync_TargetTuChoi_WithApproveDecision_Throws_AndDoesNotMutateState
- TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai (happy path)
+ NoOpNotificationService stub reusable cho future PE service tests

Verify:
- dotnet test SolutionErp.slnx → 84 PASS (58 Domain + 26 Infra = +3 from 81 baseline)
- Build pass (0 err, 2 warn CS8602 pre-existing DocxRenderer)

Pending Chunk B: FE fix PeWorkflowPanel.tsx isReject + dialog isSendBack
mirror 2 app (fe-admin + fe-user) — sync với BE guard rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:41:14 +07:00
26 changed files with 6130 additions and 53 deletions

View File

@ -1,8 +1,264 @@
# HANDOFF — Brief 5 phút cho session tiếp theo # HANDOFF — Brief 5 phút cho session tiếp theo
**Last updated:** 2026-05-12 (Session 21 turn 2**🎯 RAG Hybrid setup planning + Cách A validation deep dive. 2 commit (`1f8e9af` plan save 1223 LOC + this chốt). KHÔNG implement, plan only — defer chờ bro confirm 5 dự án future. Decision chốt: Cách A defensive (giữ blanket 120K em main + RAG retrieve) over Cách B aggressive (cắt 60-70% blanket). Industry-validated cross 4 Anthropic blog + 5 community tools (Cursor/Continue/Cline/Aider). Stack: Voyage-3-large + Qdrant + FastMCP + Streamlit dashboard. Multi-agent cost reality: 4 agents → ~520K cumulative blanket → heavy session ~560K (Cách A) vs ~700K (lazy). 3-layer pattern Phase 1-3 rollout (embeddings + BM25 + reranking, ~70% → ~92% recall). Stats: +1 memory entry (`feedback_rag_hybrid_pattern`) +1 plan file (`rag-setup-plan.md` 1500 LOC). Sub-agents vẫn 4 seeds-only, em main solo session.**) **Last updated:** 2026-05-13 1200 (Session 21 turn 4**🎯 F1+F2+F3 PE Workflow advanced options (Mig 28) — 5 chunk per-commit `0294693` (A schema) → `c56024b` (B BE) → `a508564` (C FE Admin) → `d27caaf` (D FE eOffice) → this (E Docs). **F1** 4 mode Trả lại admin tick stick (1 Cấp / 1 Bước / Người chỉ định / Người soạn thảo) — 3 mode đầu giữ Phase=ChoDuyet lùi pointer (peer review chain), mode Drafter giữ Phase=TraLai clear pointer (S17 backward compat). **F2** Drafter skip thẳng Cấp cuối — workflow tick + Workspace checkbox dynamic. **F3** Approver edit Section 2 (Hạng mục/NCC/Báo giá) khi workflow tick + actor match CurrentLevel.ApproverUserId + audit ghi PurchaseEvaluationChangelog. Mig 28 thêm 6 bit column lên `ApprovalWorkflows` (DEFAULT 1 cho AllowReturnToDrafter backward compat, 5 còn lại 0). BE Service extend signature 3 optional param (returnMode/returnTargetUserId/skipToFinal). Helper `EnsureEditableForDetailsAsync` mới gating Detail/Quote/Supplier CRUD theo Drafter scope OR F3 Approver scope + audit changelog Update/Delete (trước đây silent). FE Admin Designer "Cấu hình nâng cao" section 6 checkbox 3 group. FE eOffice 3 changes mirror 2 app. UAT mode skip dotnet test mỗi chunk, npm build × 2 app pass mỗi chunk. CHƯA push remote — chờ bro confirm.**)
**S21 turn 3:** 2026-05-12 2100 (Session 21 turn 3 — **🔴 BUG FIX CRITICAL "Trả về nhưng hệ thống vẫn duyệt" PE workflow (gotcha #45 mới). 3 chunk per-commit: `de00887` (BE Chunk A guard + 3 test) + `4b29d00` (FE Chunk B fix 2 app mirror) + this Chunk C Docs. Root: `PeWorkflowPanel.tsx` `isReject` payload (L64-66) thiếu nhánh TraLai → button "← Trả lại" gửi `decision: 1` (Approve) thay vì `2` (Reject) khi target=TraLai(98) → BE skip Reject branch → enter APPROVE STEP → `ApproveV2Async` UPSERT opinion "đã duyệt" + advance Cấp tiếp theo. Inconsistency phụ: dialog `isSendBack` (L247-248) cùng pattern thiếu TraLai → dialog title sai `'✓ Duyệt → Trả lại'` + KHÔNG amber warning. Severity CRITICAL — data integrity issue khó rollback (BE đã `SaveChangesAsync`). Test-before §7 BẮT BUỘC: viết test reproduce → confirm FAIL (BE đi sâu vào ApproveV2Async throw "Phiếu chưa pin workflow") → thêm BE guard early throw ConflictException khi `target ∈ {TraLai, TuChoi} && decision != Reject` → confirm PASS. 3 regression test (Throws TraLai+Approve, Throws TuChoi+Approve consistency, happy path Reject+TraLai). Tổng `dotnet test SolutionErp.slnx` 84 PASS (58 Domain + 26 Infra = +3 from 81 baseline). `npm run build` × 2 app pass. Stats: 27 mig (no change) · 59 tables · ~142 endpoints · 34 FE pages · **84 test (+3)** · **45 gotcha (+1 #45)** · 17 memory · 6 skills · 4 sub-agents seeds-only. Em main solo S21 t3 — bug fix reasoning chain cross BE/FE Implementer REFUSE per multi-agent rule (decision tree: tightly coupled BE+FE+test). CHƯA push remote — chờ bro confirm sau Chunk C wrap.**)
**S21 turn 2:** 2026-05-12 1800 (Session 21 turn 2 — **🎯 RAG Hybrid setup planning + Cách A validation deep dive. 2 commit (`1f8e9af` plan save 1223 LOC + this chốt). KHÔNG implement, plan only — defer chờ bro confirm 5 dự án future. Decision chốt: Cách A defensive (giữ blanket 120K em main + RAG retrieve) over Cách B aggressive (cắt 60-70% blanket). Industry-validated cross 4 Anthropic blog + 5 community tools (Cursor/Continue/Cline/Aider). Stack: Voyage-3-large + Qdrant + FastMCP + Streamlit dashboard. Multi-agent cost reality: 4 agents → ~520K cumulative blanket → heavy session ~560K (Cách A) vs ~700K (lazy). 3-layer pattern Phase 1-3 rollout (embeddings + BM25 + reranking, ~70% → ~92% recall). Stats: +1 memory entry (`feedback_rag_hybrid_pattern`) +1 plan file (`rag-setup-plan.md` 1500 LOC). Sub-agents vẫn 4 seeds-only, em main solo session.**)
**S21 turn 1:** 2026-05-12 0030 (Session 21 turn 1 — **🎯 Add con thứ 4 cicd-monitor (Path A — post-deploy verifier). 1 commit `f1c61c9` pushed `36e21c8..f1c61c9 main -> main`. CI skipped per path filter (3 file `.md`). Cost reality update: ~750K spawn (3 → 4 agents) · ~1.35M heavy / ~700K optimized. Stats: 4 sub-agents seeds-only · 16 memory · 27 mig · 59 tables · ~142 endpoints · 81 test · 44 gotcha · 6 skills unchanged. KHÔNG flush 3 agent MEMORY.md (chưa spawn work — em main solo). Trial Week 1 kick-off S21 turn 2+ Plan B Contract V2 wire mirror PE pattern.**) **S21 turn 1:** 2026-05-12 0030 (Session 21 turn 1 — **🎯 Add con thứ 4 cicd-monitor (Path A — post-deploy verifier). 1 commit `f1c61c9` pushed `36e21c8..f1c61c9 main -> main`. CI skipped per path filter (3 file `.md`). Cost reality update: ~750K spawn (3 → 4 agents) · ~1.35M heavy / ~700K optimized. Stats: 4 sub-agents seeds-only · 16 memory · 27 mig · 59 tables · ~142 endpoints · 81 test · 44 gotcha · 6 skills unchanged. KHÔNG flush 3 agent MEMORY.md (chưa spawn work — em main solo). Trial Week 1 kick-off S21 turn 2+ Plan B Contract V2 wire mirror PE pattern.**)
## TL;DR Session 21 turn 4 — F1+F2+F3 PE Workflow advanced options (Mig 28)
User request 3 tính năng mới trong PE V2 Workflow:
- **F1** 4 mode Trả lại admin stick: 1 Cấp / 1 Bước / Người chỉ định / Người soạn thảo
- **F2** Drafter gửi thẳng Cấp cuối (skip mọi Bước/Cấp trung gian)
- **F3** Approver chỉnh sửa Section 2 (Hạng mục + NCC + Báo giá) khi đang duyệt
### Q&A clarify chốt scope (2 lượt AskUserQuestion)
- **F1 "1 bậc"** = cả 2 mode (admin chọn 1 Cấp HOẶC 1 Bước HOẶC cả 2 stick)
- **F1 "Người chỉ định"** = Approver pick runtime từ list NV đã ký (PE.LevelOpinions)
- **F1 behavior** = 3 mode đầu giữ Phase=ChoDuyet lùi pointer (peer review chain). Mode Drafter giữ Phase=TraLai S17 fallback.
- **F2 skip** = chỉ skip tới Level cuối (CEO) — Dropdown 2 option "Gửi tuần tự" vs "Gửi thẳng Cấp cuối"
- **F2+F3 admin enable** = cả 2 cần admin tick per workflow (audit nghiêm)
- **F3 approver perm** = mọi approver Cấp đang active (currentLevel match)
- **F3 scope** = Section 2 only (Hạng mục + NCC + Báo giá), KHÔNG đụng PE Header, KHÔNG reset workflow
- **Test** = test-after UAT default Phase 9 (skip dotnet test mỗi chunk, npm build × 2 app pass)
### Chunk A — Mig 28 + Domain (`0294693`)
`ApprovalWorkflow.cs` thêm 6 bool field:
- `AllowReturnOneLevel` / `AllowReturnOneStep` / `AllowReturnToAssignee` (default false)
- `AllowReturnToDrafter` (default **TRUE** — backward compat S17)
- `AllowDrafterSkipToFinal` / `AllowApproverEditDetails` (default false)
EF config `ApprovalWorkflowConfiguration` thêm 6 `HasDefaultValue` match Mig 28 DEFAULT.
Mig 28 `AddAdvancedOptionsToApprovalWorkflows`:
- 6 AddColumn bit NOT NULL DEFAULT 0/1
- 3-file rule complete (mig.cs + Designer.cs + Snapshot.cs)
- Apply LocalDB Dev + Design
### Chunk B — BE Service + handlers + DTOs (`c56024b`)
**Service interface + impl** `TransitionAsync` thêm 3 optional param (backward compat):
- `WorkflowReturnMode? returnMode` (enum {OneLevel=1, OneStep=2, Assignee=3, Drafter=4})
- `Guid? returnTargetUserId` (required khi mode=Assignee)
- `bool skipToFinal`
REJECT branch extend với helper `ApplyReturnModeAsync` switch 4 mode:
- OneLevel: lùi 1 Cấp cùng Step. Bước 1 Cấp 1 → fallback Drafter.
- OneStep: lùi sang Bước trước Cấp cuối. Bước 1 → fallback Drafter.
- Assignee: tìm Step+Level match `ApproverUserId == returnTargetUserId`.
- Drafter: Phase=TraLai clear pointer (S17 behavior).
- 3 mode đầu giữ ChoDuyet + reset SLA 7d.
- Admin bypass workflow.Allow* flag check.
- Non-admin → throw ConflictException nếu flag disabled.
DRAFTER trình branch extend với F2 skipToFinal:
- Workflow.AllowDrafterSkipToFinal required (non-admin)
- Set CurrentWorkflowStepIndex = Steps.Count-1 + CurrentApprovalLevelOrder = max Level
- Audit comment append "[Drafter gửi thẳng Cấp cuối]"
**Helper edit guard** `EnsureEditableForDetailsAsync` mới (PurchaseEvaluationDraftGuard class):
- Drafter scope: DangSoanThao OR TraLai
- F3 Approver scope: ChoDuyet + workflow.AllowApproverEditDetails + actor match CurrentLevel.ApproverUserId
- Admin bypass workflow flag check
**8 handler switch** sang helper mới + inject ICurrentUser khi cần:
- Detail Add/Update/Delete + Quote Upsert/Delete (5 handler — replace EnsureDraftAsync)
- Supplier Add/Update/Remove (3 handler — bonus security fix, trước đây hoàn toàn KHÔNG có phase guard!)
- Update/Delete handler trước đây silent → thêm changelog `PhaseAtChange + UserId + Summary` (append `[Approver edit khi đang duyệt]` khi phase=ChoDuyet)
**Command DTO + DTOs**:
- `TransitionPurchaseEvaluationCommand` +3 optional field
- `ApprovalWorkflowOptionsDto` NEW sub-record (6 Allow* flag)
- `PurchaseEvaluationDetailBundleDto` +WorkflowOptions field
- `AwDefinitionDto` +6 Allow* (admin Designer GET)
- `CreateAwDefinitionCommand` +6 Allow* param (admin Designer POST)
### Chunk C — FE Admin Designer (`a508564`)
`ApprovalWorkflowsV2Page.tsx` Designer modal thêm section "Cấu hình nâng cao" 3 sub-group:
1. Mode Trả lại 4 checkbox:
- Trả về 1 Cấp trước (peer review chain trong cùng Bước)
- Trả về 1 Bước trước (Cấp cuối Bước trước nhận lại)
- Trả về Người chỉ định (pick runtime từ NV đã ký)
- Trả về Người soạn thảo (default checked = backward compat S17)
2. Drafter skip: 1 checkbox "Cho phép Drafter gửi thẳng Cấp cuối"
3. Approver edit: 1 checkbox "Cho phép Approver chỉnh sửa Section 2"
Styling: container amber-50/30 border distinct với Steps section. Helper text [10px] dưới label. Headers uppercase tracking.
DTO types + state defaults từ cloneFrom (giữ config version trước) hoặc S17 fallback (chỉ AllowReturnToDrafter=true). POST body propagate 6 flag → BE Create handler set entity.
fe-user KHÔNG mirror (Designer admin-only).
### Chunk D — FE eOffice (`d27caaf`) mirror 2 app
Types `purchaseEvaluation.ts`:
- `ApprovalWorkflowOptions` type
- `WorkflowReturnMode` const-object
- `PeDetailBundle` +workflowOptions field
`PeWorkflowPanel.tsx` F1 Trả lại radio picker:
- State `returnMode` (default Drafter) + `returnTargetUserId`
- Dialog Trả lại render 1-4 radio mode enabled theo wfOptions.Allow*
- Assignee mode → submodal Select pick từ levelOpinions (NV đã ký), dedupe by userId
- Banner amber rounded dưới mô tả hành vi mode chọn
- Mutation payload +returnMode +returnTargetUserId khi isTraLaiAction
`PeDetailTabs.tsx` F2 Drafter skip:
- State `skipToFinal` + `allowSkipToFinal` từ workflowOptions
- submitForApproval mutationFn accept opts.skipToFinal
- Workspace action bar: checkbox violet "Gửi thẳng Cấp cuối (skip trung gian)" conditional
- Confirm dialog message + button label dynamic theo skipToFinal
`PeDetailTabs.tsx` F3 Approver edit Section 2:
- useAuth import + compute `approverEditMode` (phase=ChoDuyet + workflowOptions.allowApproverEditDetails + actor match)
- `itemsReadOnly = readOnly && !approverEditMode` → ItemsTab nhận
- Banner violet "ⓘ Bạn được phép chỉnh sửa..." khi approverEditMode + readOnly (Duyệt menu)
- InfoTab / NccSelectorRow / BudgetFieldRow GIỮ strict isEditablePhase (Header + Section 3, KHÔNG trong F3 scope)
### Chunk E — Docs (this commit)
- `docs/database/schema-diagram.md §14` cập nhật title "Mig 22-28, S17-21" + thêm 6 column Allow* trong Core block với inline comment F1/F2/F3
- `docs/STATUS.md` Last updated S21 t4 + count 27→28 mig + UAT defer test count unchanged 84
- `docs/HANDOFF.md` TL;DR S21 t4 đầy đủ (file này)
- `docs/changelog/sessions/2026-05-13-1200-s21-turn4-pe-workflow-advanced-options.md` session log
### State chốt S21 turn 4
| Metric | Trước (S21 t3) | Sau (S21 t4) | Δ |
|---|---|---|---|
| DB tables | 59 | 59 | 0 |
| **Migrations** | 27 | **28** | **+1** (Mig 28 6 column Allow*) |
| Endpoints | ~142 | ~143 | +1 (extend transitions body) |
| FE pages | 34 | 34 | 0 (Designer extend section) |
| **Unit tests** | 84 | **84** | 0 (UAT defer test-after §7) |
| Gotchas | 45 | 45 | 0 |
| Memory entries | 17 | 17 | 0 |
| Skills | 6 | 6 | 0 |
| Sub-agents | 4 seeds-only | 4 seeds-only | 0 |
| **Commits S21 t4** | — | **5** | (`0294693``c56024b``a508564``d27caaf` → this) |
### Pending — Test-after (Plan C carry)
Per `feedback_uat_skip_verify` Phase 9 default: viết test sau UAT 2-3 lần ổn.
Test scope candidate (test-after-uat commit riêng):
- Service `ApplyReturnModeAsync` 4 mode happy path (OneLevel/OneStep/Assignee/Drafter)
- Service skipToFinal happy path + AllowDrafterSkipToFinal=false → ConflictException
- `EnsureEditableForDetailsAsync` 3 scenario: Drafter scope / Approver match / Approver mismatch → Forbidden
Bundle với Plan C existing (test #44 silent 403 + test V2 ApproveV2Async + Mig 25/27 PATCH).
---
## TL;DR Session 21 turn 3 — Bug fix CRITICAL "Trả về nhưng hệ thống vẫn duyệt" (gotcha #45)
User UAT 2026-05-12 21:00 screenshot button labeled `← Trả lại` trong PE Workflow Panel (menu "Duyệt"), mô tả hành vi: nhấn vào nhưng phiếu KHÔNG về phase TraLai — ngược lại tiến qua Cấp tiếp theo. User mô tả: "Trả về nhưng hệ thống vẫn duyệt".
### Diagnose (em main solo, no agent spawn)
3 chỗ inconsistency cùng pattern trong `PeWorkflowPanel.tsx` (× 2 app fe-admin + fe-user):
| # | Location | Logic | Bug? |
|---|---|---|---|
| 1 | L205-207 button `isSendBack` | include TraLai → label `← Trả lại` ĐÚNG | ✅ no bug |
| 2 | L64-66 payload `isReject` | thiếu nhánh TraLai → gửi `decision: 1` (Approve) | 🔴 BUG ROOT |
| 3 | L247-248 dialog `isSendBack` | thiếu nhánh TraLai → dialog title fallback `'✓ Duyệt → Trả lại'` + no amber warning | 🔴 BUG phụ |
BE `PurchaseEvaluationWorkflowService.TransitionAsync`:
- L51 `if (decision == Reject)` branch → đúng cho decision=Reject.
- L97 `APPROVE STEP` branch khi decision=Approve + fromPhase=ChoDuyet → ApproveV2Async UPSERT opinion + advance Cấp.
- → FE gửi `decision=1` (do bug `isReject`) → BE đi vào nhánh APPROVE thay vì REJECT → phiếu approve mặc dù user định trả lại.
### Chunk A — BE defense-in-depth + 3 regression test (`de00887`)
**Test-before §7 BẮT BUỘC:** Viết test reproduce bug TRƯỚC fix.
```csharp
// Sau line 48 (set isAdmin/isSystem), trước REJECT branch (L51)
if ((targetPhase == PurchaseEvaluationPhase.TraLai
|| targetPhase == PurchaseEvaluationPhase.TuChoi)
&& decision != ApprovalDecision.Reject)
{
throw new ConflictException(
$"Transition tới {targetPhase} BẮT BUỘC decision=Reject (nhận {decision}). " +
"Báo lỗi caller — payload mismatch giữa target phase và decision.");
}
```
Boundary protection cho mọi caller tương lai (API client / mobile / cron retry). Guard KHÔNG xoá khi FE fix — defense-in-depth.
3 test file `tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceGuardTests.cs`:
- `TransitionAsync_TargetTraLai_WithApproveDecision_Throws_AndDoesNotMutateState` — reproduce bug, expect `ConflictException` "*TraLai*Reject*"
- `TransitionAsync_TargetTuChoi_WithApproveDecision_Throws_AndDoesNotMutateState` — consistency cover TuChoi
- `TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai` — happy path control (Reject branch vẫn đúng)
+ `NoOpNotificationService` stub reusable cho future PE service tests (avoid `INotificationService` real DI complexity).
Run test → 2 FAIL (reproduce bug, BE đi sâu vào ApproveV2Async throw "Phiếu chưa pin workflow") + 1 PASS (happy path). Thêm BE guard → 3 PASS. Tổng `dotnet test SolutionErp.slnx` 84 PASS (+3 from 81 baseline).
### Chunk B — FE fix mirror 2 app (`4b29d00`)
3 chỗ × 2 app = 6 edits:
```typescript
// Chỗ 1: isReject payload (L64-66)
const isReject = target === PurchaseEvaluationPhase.TuChoi
|| (target === PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|| (target === PurchaseEvaluationPhase.TraLai // ← THÊM
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
// Chỗ 3: dialog isSendBack (L247-248)
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|| target === PurchaseEvaluationPhase.TraLai) // ← THÊM
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai // ← THÊM
```
Chỗ 2 (button label `isSendBack` L205-207) đã đúng từ S17, KHÔNG đụng.
Verify: `npm run build` × 2 app pass (fe-user 17.91s + fe-admin 6.71s, 0 TS6 err).
### Chunk C — Docs (this commit)
- `docs/gotchas.md` +#45 PE button label vs decision payload mismatch (~120 dòng narrative + 2 commit cross-ref + pattern reusable + phòng tránh tương lai)
- `docs/gotchas.md` checklist debug +entry 22 quick lookup
- `docs/STATUS.md` Last updated S21 t3 + count 81→84 test + 44→45 gotcha
- `docs/HANDOFF.md` TL;DR S21 t3 narrative đầy đủ (file này)
- `docs/changelog/sessions/2026-05-12-2100-s21-turn3-fix-tra-lai-bug45.md` session log mới
### Pending (carry from S21 turn 2)
Plans A-I unchanged. Plan C1 (test regression gotcha #44 silent 403 S18) vẫn còn nợ — không bundle với S21 t3 fix (scope khác, ưu tiên unblock UAT bug critical trước).
### Audit cadence
- Lần gần nhất: 2026-05-04 manual trễ 4 ngày
- Lần kế: **2026-06-01** combined audit
- Drift sau S21 t3: 44→45 gotcha (+1) + 81→84 test (+3) + 17→17 memory (no new) + 6 skills unchanged
### State chốt S21 turn 3
| Metric | Trước (S21 t2) | Sau (S21 t3) | Δ |
|---|---|---|---|
| DB tables | 59 | 59 | 0 |
| Migrations | 27 | 27 | 0 |
| Endpoints | ~142 | ~142 | 0 |
| FE pages | 34 | 34 | 0 |
| **Unit tests** | 81 | **84** | **+3** (PE guard) |
| **Gotchas** | 44 | **45** | **+1** (#45) |
| Memory entries | 17 | 17 | 0 |
| Skills | 6 | 6 | 0 |
| Sub-agents | 4 seeds-only | 4 seeds-only | 0 |
| **Commits S21 t3** | — | **3** | (`de00887` + `4b29d00` + this) |
---
## TL;DR Session 21 turn 2 — RAG Hybrid setup planning (Cách A chốt + 3-layer pattern) ## TL;DR Session 21 turn 2 — RAG Hybrid setup planning (Cách A chốt + 3-layer pattern)
User clarify 5 dự án future > 1M MD tokens → cuộc thảo luận deep ~15 turn về RAG infrastructure. Em main solo (no SOLUTION_ERP sub-agent spawn), delegate 2 lần claude-code-guide agent research Anthropic + community practice. User clarify 5 dự án future > 1M MD tokens → cuộc thảo luận deep ~15 turn về RAG infrastructure. Em main solo (no SOLUTION_ERP sub-agent spawn), delegate 2 lần claude-code-guide agent research Anthropic + community practice.

View File

@ -2,13 +2,15 @@
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`. > **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-12 1800 (Session 21 turn 2**🎯 RAG Hybrid setup planning + Cách A validation deep dive. 2 commit (`1f8e9af` plan save 1223 LOC + this chốt). Em main solo (no SOLUTION_ERP sub-agent spawn), delegate claude-code-guide × 2 research Anthropic + community practice. Decision chốt: Cách A defensive (giữ blanket 120K em main + RAG retrieve supplement) over Cách B aggressive (cắt 60-70% blanket). Industry-validated cross 4 Anthropic blog + 5 community tools (Cursor/Continue/Cline/Aider all hybrid). Stack: Voyage-3-large + Qdrant local + FastMCP Python + Streamlit dashboard 7 pages + SQLite event log. Multi-agent cost reality: 4 agents → ~520K cumulative blanket → heavy session ~560K (Cách A) vs ~700K (lazy), saving -20%. 3-layer pattern Phase 1-3 rollout (Layer 1 vector → Layer 2 +BM25 → Layer 3 +reranking, recall ~70% → ~92%). Stats: +1 memory entry (`feedback_rag_hybrid_pattern.md`) +1 plan file (`rag-setup-plan.md` 1500 LOC final). 4 sub-agents vẫn seeds-only. Plan I NEW deferred chờ bro confirm 5 dự án path + stack + Voyage API key + disk cleanup 5-8GB.**) **Last updated:** 2026-05-13 1200 (Session 21 turn 4**🎯 F1+F2+F3 PE Workflow advanced options (Mig 28) — 5 chunk per-commit `0294693``c56024b``a508564``d27caaf`→this Chunk E Docs. **F1** 4 mode Trả lại admin tick: "1 Cấp / 1 Bước / Người chỉ định / Người soạn thảo" — 3 mode đầu giữ Phase=ChoDuyet lùi pointer (peer review chain), mode Drafter giữ Phase=TraLai S17 fallback. **F2** Drafter skip thẳng Cấp cuối — workflow tick + Workspace checkbox dynamic confirm. **F3** Approver edit Section 2 (Hạng mục/NCC/Báo giá) khi workflow tick + actor match CurrentLevel + audit ghi PurchaseEvaluationChangelog. Mig 28 `ApprovalWorkflows +6 bool Allow*` (DEFAULT 1 cho AllowReturnToDrafter backward compat, 5 còn lại 0). BE Service `TransitionAsync` extend 3 optional param (returnMode/returnTargetUserId/skipToFinal) + helper `ApplyReturnModeAsync` switch 4 mode. Detail/Quote/Supplier helper `EnsureEditableForDetailsAsync` mới (kế thừa `EnsureDraftAsync` + add ChoDuyet+F3 branch + Admin bypass). FE Admin Designer "Cấu hình nâng cao" section 6 checkbox 3 group. FE eOffice 3 changes mirror 2 app: Trả lại radio picker 1-4 mode + Workspace skip checkbox violet + Section 2 itemsReadOnly approver banner. UAT mode skip dotnet test mỗi chunk (per `feedback_uat_skip_verify`), `npm run build` × 2 app pass mỗi chunk. Stats: **28 mig (+1)** · 59 tables · **~143 endpoints (+1 user-selectable patch existed)** · **34 FE pages (+1 Designer section)** · **84 test pass unchanged** (UAT defer test-after) · **45 gotcha unchanged** · 17 memory · 6 skills · 4 sub-agents seeds-only.**)
**S21 turn 3:** 2026-05-12 2100 (Session 21 turn 3 — **🎯 Bug fix CRITICAL "Trả về nhưng hệ thống vẫn duyệt" PE workflow (gotcha #45). 2 chunk per-commit `de00887` (BE Chunk A) + `4b29d00` (FE Chunk B) + Chunk C Docs this. Root: PeWorkflowPanel.tsx `isReject` payload (L64-66) thiếu nhánh TraLai → button "← Trả lại" gửi `decision: 1` (Approve) thay vì `2` (Reject) khi target=TraLai(98) → BE ApproveV2Async UPSERT opinion "đã duyệt" + advance Cấp. Inconsistency phụ: dialog `isSendBack` (L247-248) cùng pattern thiếu TraLai → dialog title sai. Fix BE defense-in-depth + FE 3 chỗ × 2 app mirror rule §3.9. Test-before §7 BẮT BUỘC: 3 regression test mới (2 reproduce bug + 1 happy path control) — `dotnet test SolutionErp.slnx` 84 PASS (58 Domain + 26 Infra = +3). `npm run build` × 2 app pass. Stats: 27 mig (no change) · 59 tables · ~142 endpoints · 34 FE pages · **84 test pass (+3)** · **45 gotcha (+1 #45)** · 17 memory entries (no new) · 6 skills. Em main solo (no sub-agent spawn S21 t3 — bug fix reasoning chain cross BE/FE Implementer REFUSE per multi-agent rule).**)
**S21 turn 2:** 2026-05-12 1800 (Session 21 turn 2 — **🎯 RAG Hybrid setup planning + Cách A validation deep dive. 2 commit (`1f8e9af` plan save 1223 LOC + this chốt). Em main solo (no SOLUTION_ERP sub-agent spawn), delegate claude-code-guide × 2 research Anthropic + community practice. Decision chốt: Cách A defensive (giữ blanket 120K em main + RAG retrieve supplement) over Cách B aggressive (cắt 60-70% blanket). Industry-validated cross 4 Anthropic blog + 5 community tools (Cursor/Continue/Cline/Aider all hybrid). Stack: Voyage-3-large + Qdrant local + FastMCP Python + Streamlit dashboard 7 pages + SQLite event log. Multi-agent cost reality: 4 agents → ~520K cumulative blanket → heavy session ~560K (Cách A) vs ~700K (lazy), saving -20%. 3-layer pattern Phase 1-3 rollout (Layer 1 vector → Layer 2 +BM25 → Layer 3 +reranking, recall ~70% → ~92%). Stats: +1 memory entry (`feedback_rag_hybrid_pattern.md`) +1 plan file (`rag-setup-plan.md` 1500 LOC final). 4 sub-agents vẫn seeds-only. Plan I NEW deferred chờ bro confirm 5 dự án path + stack + Voyage API key + disk cleanup 5-8GB.**)
**S21 turn 1:** 2026-05-12 0030 (Session 21 turn 1 — **🎯 Add con thứ 4 cicd-monitor (Path A — post-deploy verifier green READ tier). 1 commit `f1c61c9` pushed `36e21c8..f1c61c9 main -> main`, CI skipped per path filter (`**/*.md` paths-ignore docs-only). Trade-off: +~150K spawn extra mỗi run, đổi lại catch deploy ship fail tự động (bundle hash unchanged / mig drift prod / endpoint 500) — recurring blind spot pattern em main solo S20 quên verify ~30% push. Cost reality update: ~750K spawn setup (3 → 4 agents) · ~1.35M heavy session · ~700K optimized cached. Stats: 4 sub-agents seeds-only (+1 cicd-monitor green) · 16 memory entries (no new, update existing `feedback_multi_agent_setup.md` 3 → 4 agents narrative) · 27 mig · 59 tables · ~142 endpoints · 81 test unchanged · 44 gotcha unchanged · 6 skills unchanged. KHÔNG flush 3 agent MEMORY.md (chưa spawn work S21 t1 nên KHÔNG có findings — em main solo via context + Write file).**) **S21 turn 1:** 2026-05-12 0030 (Session 21 turn 1 — **🎯 Add con thứ 4 cicd-monitor (Path A — post-deploy verifier green READ tier). 1 commit `f1c61c9` pushed `36e21c8..f1c61c9 main -> main`, CI skipped per path filter (`**/*.md` paths-ignore docs-only). Trade-off: +~150K spawn extra mỗi run, đổi lại catch deploy ship fail tự động (bundle hash unchanged / mig drift prod / endpoint 500) — recurring blind spot pattern em main solo S20 quên verify ~30% push. Cost reality update: ~750K spawn setup (3 → 4 agents) · ~1.35M heavy session · ~700K optimized cached. Stats: 4 sub-agents seeds-only (+1 cicd-monitor green) · 16 memory entries (no new, update existing `feedback_multi_agent_setup.md` 3 → 4 agents narrative) · 27 mig · 59 tables · ~142 endpoints · 81 test unchanged · 44 gotcha unchanged · 6 skills unchanged. KHÔNG flush 3 agent MEMORY.md (chưa spawn work S21 t1 nên KHÔNG có findings — em main solo via context + Write file).**)
**S20 wrap:** 2026-05-11 22:00 (Session 20 wrap turns 1-12 — **🎯 14 commit `9dee00d``ae1814c`. PE Detail UI restructure 3 yêu cầu (t1-5) + Manual budget drop tên (t6) + Mig 27 admin menu eOffice (t7) + NCC palette 5-màu cycle + Winner icon ✓ đậm + AddSupplier auto-fill master + Responsive laptop nhỏ 4-tầng pattern (t8-11) + Multi-agent infrastructure setup 3 sub-agents (t12). 27 mig (+1) · 59 tables · ~142 endpoints (+1) · 34 FE pages (+1) · 61 menu key (+1) · 81 test pass unchanged · 44 gotcha · 16 memory entries (+2) · 3 sub-agents NEW. Phase 9 UAT iteration mode.**) **S20 wrap:** 2026-05-11 22:00 (Session 20 wrap turns 1-12 — **🎯 14 commit `9dee00d``ae1814c`. PE Detail UI restructure 3 yêu cầu (t1-5) + Manual budget drop tên (t6) + Mig 27 admin menu eOffice (t7) + NCC palette 5-màu cycle + Winner icon ✓ đậm + AddSupplier auto-fill master + Responsive laptop nhỏ 4-tầng pattern (t8-11) + Multi-agent infrastructure setup 3 sub-agents (t12). 27 mig (+1) · 59 tables · ~142 endpoints (+1) · 34 FE pages (+1) · 61 menu key (+1) · 81 test pass unchanged · 44 gotcha · 16 memory entries (+2) · 3 sub-agents NEW. Phase 9 UAT iteration mode.**)
**S20 turn 7:** 2026-05-11 17:00 (Session 20 turn 7 — **🎯 Admin Ẩn/Hiện + Đổi tên menu eOffice (Mig 27). 5 chunk `2ea2d27``ef394f8``059bfcb``1ed6530`→Chunk E Docs. User Q2=b: DisplayLabel CHỈ áp fe-user, admin sidebar giữ Label gốc. Domain MenuItem +IsVisible(true) +DisplayLabel(200). Mig 27 AddVisibilityAndDisplayLabelToMenuItems. BE PATCH /api/menus/{key} [Authorize Policy=Permissions.Update]. NEW FE-admin MenuVisibilityPage ~210 LOC (table inline edit per-row + Save dirty + Khôi phục mặc định + Toggle Eye/EyeOff + 4 StatCard). fe-user Layout filterForUser 2 tầng (USER_HIDDEN_KEYS hardcode + !isVisible dynamic) + effectiveLabel(displayLabel || label) replace 3 callsite. fe-admin Layout KHÔNG đụng. +1 menu key MenuVisibility "Menu eOffice" leaf System Order=94. 27 mig, 59 tables, ~142 endpoints, 34 FE pages, 81 test pass (Q4 UAT defer).**) **S20 turn 7:** 2026-05-11 17:00 (Session 20 turn 7 — **🎯 Admin Ẩn/Hiện + Đổi tên menu eOffice (Mig 27). 5 chunk `2ea2d27``ef394f8``059bfcb``1ed6530`→Chunk E Docs. User Q2=b: DisplayLabel CHỈ áp fe-user, admin sidebar giữ Label gốc. Domain MenuItem +IsVisible(true) +DisplayLabel(200). Mig 27 AddVisibilityAndDisplayLabelToMenuItems. BE PATCH /api/menus/{key} [Authorize Policy=Permissions.Update]. NEW FE-admin MenuVisibilityPage ~210 LOC (table inline edit per-row + Save dirty + Khôi phục mặc định + Toggle Eye/EyeOff + 4 StatCard). fe-user Layout filterForUser 2 tầng (USER_HIDDEN_KEYS hardcode + !isVisible dynamic) + effectiveLabel(displayLabel || label) replace 3 callsite. fe-admin Layout KHÔNG đụng. +1 menu key MenuVisibility "Menu eOffice" leaf System Order=94. 27 mig, 59 tables, ~142 endpoints, 34 FE pages, 81 test pass (Q4 UAT defer).**)
**S20 prev:** 2026-05-11 (Session 20 — **🎯 PE Detail UI restructure 3 yêu cầu user UX. 4 chunk per-commit `9dee00d``2bba851``f2f01f4` → (current Chunk D Docs).** Q1=a (giữ Section "Chọn NCC TP" riêng), Q2=a "1 hạng mục trước tiên" (NCC shared, demo 1 hạng mục), Q3=a (chỉ hiện NV đã ký), Q4 public luôn (skip dotnet test mỗi chunk theo memory `feedback_uat_skip_verify`, vẫn `npm run build` × 2 app mỗi chunk vì có rename/remove function). **Chunk A (`9dee00d`)**: BE `CreatePurchaseEvaluationCommandHandler` thêm 1 PurchaseEvaluationDetail mặc định khi tạo phiếu — GroupCode="01", GroupName="Hạng mục chính", NoiDung=TenGoiThau, DonGiaNganSach=ThanhTienNganSach=Budget.TongNganSach hoặc BudgetManualAmount fallback 0; Changelog Insert audit. FE reorder PeDetailTabs (mirror 2 app) 1.Thông tin / 2.Hạng mục (lên #2) / 3.Chọn NCC / 4.NCC tham gia / 5.Ý kiến. **Chunk B (`2bba851`)**: ItemsTab restructure thành list `HangMucCard` (1 card / 1 hạng mục, expanded=true mặc định cho 1 hạng mục demo). Header card: GroupCode + NoiDung + 3 stat (KL/ĐG/TT) + NS link Δ nếu có + Pencil/Trash actions + ▼/▶ toggle expand. Expand body: NCC inline table columns NCC / Liên hệ / Điều khoản TT / **File báo giá** / ĐG chưa VAT / ĐG có VAT / Thành tiền / Action. Quote inline click cell → QuoteDialog cũ reuse. Add NCC + Sửa NCC reuse AddSupplierDialog/EditSupplierDialog cũ. Winner ✓ button mỗi NCC row. Drop function `SuppliersTab` (dead code ~134 LOC, replace bằng HangMucCard expand panel). Giữ AddSupplierDialog + EditSupplierDialog + SupplierAttachmentsCell (HangMucCard call lại). Section layout cuối: 1.Thông tin / 2.Hạng mục + Báo giá NCC (nested) / 3.Chọn NCC TP thắng thầu / 4.Ý kiến cấp duyệt — 4 section. **Chunk C (`f2f01f4`)**: Section Ý kiến restructure render layer (KHÔNG đụng Mig 26 schema — vẫn UPSERT 1 row / Level). LevelOpinionsSectionV2 forEach step → 1 `StepOpinionsBox` (replace grid-cols-2 cho N approver). Box header: "Bước N — Tên" + dept badge emerald + "X/Y đã duyệt" counter. Body: filter opinions theo step.order → sort levelOrder asc, signedAt asc → render `StepOpinionEntry` per signed opinion (tên NV + Cấp badge slate + admin override badge amber nếu có + emerald rounded-full timestamp + comment text). NV chưa duyệt KHÔNG hiển thị (Q3=a). Drop function `LevelOpinionBox` (replaced). Mirror fe-admin + fe-user. Verify build pass cả 2 app sau khi catch TS6133 `SuppliersTab` + `SupplierAttachmentsCell` unused (đã giải quyết: drop SuppliersTab, restore SupplierAttachmentsCell vào HangMucCard cột "File báo giá"). 81 test pass (no change — UAT defer)**) **S20 prev:** 2026-05-11 (Session 20 — **🎯 PE Detail UI restructure 3 yêu cầu user UX. 4 chunk per-commit `9dee00d``2bba851``f2f01f4` → (current Chunk D Docs).** Q1=a (giữ Section "Chọn NCC TP" riêng), Q2=a "1 hạng mục trước tiên" (NCC shared, demo 1 hạng mục), Q3=a (chỉ hiện NV đã ký), Q4 public luôn (skip dotnet test mỗi chunk theo memory `feedback_uat_skip_verify`, vẫn `npm run build` × 2 app mỗi chunk vì có rename/remove function). **Chunk A (`9dee00d`)**: BE `CreatePurchaseEvaluationCommandHandler` thêm 1 PurchaseEvaluationDetail mặc định khi tạo phiếu — GroupCode="01", GroupName="Hạng mục chính", NoiDung=TenGoiThau, DonGiaNganSach=ThanhTienNganSach=Budget.TongNganSach hoặc BudgetManualAmount fallback 0; Changelog Insert audit. FE reorder PeDetailTabs (mirror 2 app) 1.Thông tin / 2.Hạng mục (lên #2) / 3.Chọn NCC / 4.NCC tham gia / 5.Ý kiến. **Chunk B (`2bba851`)**: ItemsTab restructure thành list `HangMucCard` (1 card / 1 hạng mục, expanded=true mặc định cho 1 hạng mục demo). Header card: GroupCode + NoiDung + 3 stat (KL/ĐG/TT) + NS link Δ nếu có + Pencil/Trash actions + ▼/▶ toggle expand. Expand body: NCC inline table columns NCC / Liên hệ / Điều khoản TT / **File báo giá** / ĐG chưa VAT / ĐG có VAT / Thành tiền / Action. Quote inline click cell → QuoteDialog cũ reuse. Add NCC + Sửa NCC reuse AddSupplierDialog/EditSupplierDialog cũ. Winner ✓ button mỗi NCC row. Drop function `SuppliersTab` (dead code ~134 LOC, replace bằng HangMucCard expand panel). Giữ AddSupplierDialog + EditSupplierDialog + SupplierAttachmentsCell (HangMucCard call lại). Section layout cuối: 1.Thông tin / 2.Hạng mục + Báo giá NCC (nested) / 3.Chọn NCC TP thắng thầu / 4.Ý kiến cấp duyệt — 4 section. **Chunk C (`f2f01f4`)**: Section Ý kiến restructure render layer (KHÔNG đụng Mig 26 schema — vẫn UPSERT 1 row / Level). LevelOpinionsSectionV2 forEach step → 1 `StepOpinionsBox` (replace grid-cols-2 cho N approver). Box header: "Bước N — Tên" + dept badge emerald + "X/Y đã duyệt" counter. Body: filter opinions theo step.order → sort levelOrder asc, signedAt asc → render `StepOpinionEntry` per signed opinion (tên NV + Cấp badge slate + admin override badge amber nếu có + emerald rounded-full timestamp + comment text). NV chưa duyệt KHÔNG hiển thị (Q3=a). Drop function `LevelOpinionBox` (replaced). Mirror fe-admin + fe-user. Verify build pass cả 2 app sau khi catch TS6133 `SuppliersTab` + `SupplierAttachmentsCell` unused (đã giải quyết: drop SuppliersTab, restore SupplierAttachmentsCell vào HangMucCard cột "File báo giá"). 81 test pass (no change — UAT defer)**)
## 📍 Phase hiện tại: **Phase 9 active — UAT V2 testing với user thật** — **59 DB tables (+1 PurchaseEvaluationLevelOpinions Mig 26), 26 migrations (+1 Mig 26), ~141 API endpoints (no new — UPSERT auto qua Service hook không endpoint riêng, Q1=1B), 33 FE pages. 81 unit test pass** (58 Domain + 23 Infra — no change S19, feature UAT defer test theo §7). 44 gotcha. 30 demo user + 1 test user UAT. 6 skill. **5 trạng thái phiếu** (Nháp/Đã gửi duyệt/Trả lại/Từ chối/Đã duyệt). **2 Workflow schemas đồng tồn tại** post-Session 17: (1) Mig 21 `WorkflowDefinition` flat (V1) — pin với PE/Contract cũ + match Dept+PositionLevel. (2) Mig 22-26 `ApprovalWorkflow` (V2) — pin với PE mới + match ApproverUserId 1-1, Steps/Levels group by Order, Bước (Phòng) > Cấp (N NV OR-of-N), Mig 25 +IsUserSelectable admin pin per version, **Mig 26 +PeLevelOpinions sign-off dynamic theo Level**. Service PE branch theo `ApprovalWorkflowId` set or null. Sau UAT chốt → migrate + drop V1 + Contract V2 wire. ## 📍 Phase hiện tại: **Phase 9 active — UAT V2 testing với user thật** — **59 DB tables (+1 PurchaseEvaluationLevelOpinions Mig 26), 28 migrations (+1 Mig 28 advanced options S21 t4 — 6 bool column trên ApprovalWorkflows), ~143 API endpoints, 34 FE pages. 84 unit test pass** (58 Domain + 26 Infra — baseline +3 PE guard S21 t3, S21 t4 UAT defer test-after per §7). **45 gotcha**. 30 demo user + 1 test user UAT. 6 skill. **5 trạng thái phiếu** (Nháp/Đã gửi duyệt/Trả lại/Từ chối/Đã duyệt). **2 Workflow schemas đồng tồn tại** post-Session 17: (1) Mig 21 `WorkflowDefinition` flat (V1) — pin với PE/Contract cũ + match Dept+PositionLevel. (2) Mig 22-26 `ApprovalWorkflow` (V2) — pin với PE mới + match ApproverUserId 1-1, Steps/Levels group by Order, Bước (Phòng) > Cấp (N NV OR-of-N), Mig 25 +IsUserSelectable admin pin per version, **Mig 26 +PeLevelOpinions sign-off dynamic theo Level**. Service PE branch theo `ApprovalWorkflowId` set or null. Sau UAT chốt → migrate + drop V1 + Contract V2 wire.
### 🌐 Production URLs ### 🌐 Production URLs

View File

@ -0,0 +1,182 @@
# Session 21 turn 3 — 2026-05-12 21:00 — Bug fix CRITICAL "Trả về nhưng hệ thống vẫn duyệt" PE workflow (gotcha #45)
**Dev:** Claude Opus 4.7 1M Max (em main solo, no SOLUTION_ERP sub-agent spawn)
**Duration:** ~1.5h diagnose + fix + test + docs
**Base commit:** `0a3b747` (S21 t2 RAG planning chốt)
**Commits này turn:** `de00887` (BE Chunk A) → `4b29d00` (FE Chunk B) → this (Chunk C Docs)
## Trigger
User UAT 2026-05-12 21:00 screenshot button labeled `← Trả lại` trong PE Workflow Panel (menu "Duyệt") với caption thắc mắc "Trả về nhưng hệ thống vẫn duyệt" + yêu cầu "check lại nhé chỗ Duyệt NCC".
Đây là bug CRITICAL data integrity — NV nhấn "Trả lại" vô tình "duyệt" phiếu sang Cấp tiếp theo + UPSERT opinion vĩnh viễn vào `PurchaseEvaluationLevelOpinions` (Mig 26). Khó rollback vì BE đã `SaveChangesAsync`.
## Diagnose
Em main solo (Implementer REFUSE per multi-agent rule — reasoning chain cross BE/FE+test tightly coupled). Grep `Trả lại|isReject|TraLai` trong fe-user/fe-admin → tìm 3 chỗ inconsistency trong `PeWorkflowPanel.tsx`:
| # | Location | Logic | Bug? |
|---|---|---|---|
| 1 | L205-207 button `isSendBack` | include cả `DangSoanThao` lẫn `TraLai` → label `← Trả lại` đúng | ✅ no bug |
| 2 | L64-66 payload `isReject` | CHỈ check `DangSoanThao`, thiếu `TraLai` → gửi `decision: 1` (Approve) thay vì `2` | 🔴 BUG ROOT |
| 3 | L247-248 dialog `isSendBack` | CHỈ check `DangSoanThao`, thiếu `TraLai` → title fallback `'✓ Duyệt → Trả lại'` (sai semantic) + no amber warning | 🔴 BUG phụ |
**BE side audit** `PurchaseEvaluationWorkflowService.TransitionAsync`:
- L51 `if (decision == Reject)` branch → handle BOTH TuChoi (set Phase=TuChoi) + TraLai (set Phase=TraLai, clear pointer). Correct.
- L97 `APPROVE STEP` branch khi `decision=Approve && fromPhase=ChoDuyet``ApproveV2Async` UPSERT opinion + advance Cấp pointer.
- → Khi FE gửi `decision=1` do bug `isReject` thiếu nhánh TraLai, BE entry → APPROVE branch thay vì REJECT branch → phiếu approve mặc dù user định trả lại.
## Chunk A — BE defense-in-depth + 3 regression test (`de00887`)
### Test-before §7 BẮT BUỘC strict flow
**Step 1:** Write test file `tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceGuardTests.cs` 3 test KHÔNG có BE guard → run → expect FAIL.
**Step 2:** Confirm 2 test FAIL (reproduce bug — BE đi sâu vào ApproveV2Async throw "Phiếu chưa pin workflow definition hoặc workflow không có step") + 1 test PASS (happy path Reject branch — đã pass vì BE đã đúng cho decision=Reject từ Session 17).
**Step 3:** Add BE guard sau L48, trước L51:
```csharp
// ===== GUARD: targetPhase TraLai/TuChoi BẮT BUỘC decision=Reject =====
// Defense-in-depth chặn FE inconsistency (gotcha #45 — Session 21 turn 3).
// Bug: button "← Trả lại" gửi decision=Approve khi target=TraLai → BE skip
// Reject branch → enter APPROVE STEP → ApproveV2Async UPSERT opinion.
if ((targetPhase == PurchaseEvaluationPhase.TraLai
|| targetPhase == PurchaseEvaluationPhase.TuChoi)
&& decision != ApprovalDecision.Reject)
{
throw new ConflictException(
$"Transition tới {targetPhase} BẮT BUỘC decision=Reject (nhận {decision}). " +
"Báo lỗi caller — payload mismatch giữa target phase và decision " +
"(xem gotcha #45 + docs/workflow-contract.md).");
}
```
**Step 4:** Re-run test → 3/3 PASS. Run full suite → 84/84 PASS (58 Domain + 26 Infra = +3 from 81 baseline).
### 3 test cases
1. **`TransitionAsync_TargetTraLai_WithApproveDecision_Throws_AndDoesNotMutateState`** — Bug reproduce. Setup PE ở Phase=ChoDuyet, CurrentApprovalLevelOrder=1. Act: gửi target=TraLai + decision=Approve. Assert: throw `ConflictException` "*TraLai*Reject*" + Phase KHÔNG đổi + CurrentApprovalLevelOrder=1 (no advance).
2. **`TransitionAsync_TargetTuChoi_WithApproveDecision_Throws_AndDoesNotMutateState`** — Consistency cover. Cùng pattern với TuChoi.
3. **`TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai`** — Happy path control. decision=Reject + target=TraLai → BE đi vào Reject branch, set Phase=TraLai, clear pointer (CurrentApprovalLevelOrder=null + CurrentWorkflowStepIndex=null + SlaDeadline=null).
+ `NoOpNotificationService internal sealed` stub trong cùng file (Tests scope) — reusable cho future PE service tests, avoid `INotificationService` real DI complexity.
### Pattern reusable
- **Boundary guard semantic invariant.** Bất kỳ BE service nào nhận payload từ FE → audit invariant `(domain state X) ⇔ (input parameter Y)` → throw early nếu mismatch. Defense-in-depth thay vì trust FE đúng.
- **Test-before flow strict:** Write test → confirm FAIL với exception KHÁC expected (proves bug reproduce) → add fix → confirm PASS với exception ĐÚNG expected. KHÔNG bỏ qua bước "confirm FAIL" — đảm bảo test actually catches bug.
## Chunk B — FE fix mirror 2 app (`4b29d00`)
3 chỗ × 2 app = 6 edits.
### fe-user/src/components/pe/PeWorkflowPanel.tsx
**Edit #1 (L64-66 `isReject`):**
```typescript
const isReject = target === PurchaseEvaluationPhase.TuChoi
|| (target === PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|| (target === PurchaseEvaluationPhase.TraLai // ← THÊM nhánh TraLai
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
```
**Edit #2 (L247-248 dialog `isSendBack`):**
```typescript
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|| target === PurchaseEvaluationPhase.TraLai) // ← THÊM TraLai
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai // ← THÊM guard
```
**Comment update:** Thêm context bug + cross-ref BE guard Chunk A trong comment.
### fe-admin/src/components/pe/PeWorkflowPanel.tsx
Mirror y hệt (rule §3.9 mirror 2 app — duplicate có chủ đích).
### Verify
```bash
# fe-user
cd fe-user && npm run build
✓ built in 17.91s
# fe-admin
cd fe-admin && npm run build
✓ built in 6.71s
```
0 TS6 err, 0 new warnings. Warning chunk size pre-existing (KHÔNG introduced).
## Chunk C — Docs (this commit)
### `docs/gotchas.md` +#45
~120 dòng narrative đầy đủ KEEP rule §6.5:
- **Triệu chứng** — UAT screenshot user mô tả hành vi
- **Root cause** — 3 chỗ inconsistency table + BE service path narrative
- **Severity** CRITICAL — data integrity issue khó rollback
- **Fix Chunk A** BE code block + 3 test list
- **Fix Chunk B** FE code block diff
- **Pattern reusable** — boundary guard semantic invariant + button label ↔ payload sync
- **Phòng tránh tương lai** — grep audit khi spec mới thêm phase + test-before §7 strict flow
- **References** — 2 commit + Session 17 spec
`docs/gotchas.md` checklist debug +entry 22 quick lookup.
### `docs/STATUS.md`
Edit Last updated header thêm S21 t3 + count 81→84 test + 44→45 gotcha. Giữ nguyên S21 t2/t1/S20 narrative cũ (rule §6.5 KEEP).
### `docs/HANDOFF.md`
Insert TL;DR S21 t3 section trên cùng (trước S21 t2). Header Last updated mới + narrative đầy đủ Chunk A/B/C + state table cumulative. Giữ S21 t2/t1/S20 narrative cũ.
### Session log
File này — `docs/changelog/sessions/2026-05-12-2100-s21-turn3-fix-tra-lai-bug45.md`.
## Stats cumulative
| Metric | Trước (S21 t2) | Sau (S21 t3) | Δ |
|---|---|---|---|
| DB tables | 59 | 59 | 0 |
| Migrations | 27 | 27 | 0 |
| Endpoints | ~142 | ~142 | 0 |
| FE pages | 34 | 34 | 0 |
| **Unit tests** | 81 | **84** | **+3** (PE guard) |
| **Gotchas** | 44 | **45** | **+1** (#45) |
| Memory entries | 17 | 17 | 0 |
| Skills | 6 | 6 | 0 |
| Sub-agents | 4 seeds-only | 4 seeds-only | 0 |
| Commits S21 t3 | — | **3** | (`de00887` + `4b29d00` + this) |
## Lessons learned
1. **Mảng inconsistency 3 chỗ cùng pattern** — khi spec mới thêm value vào set check (vd Session 17 thêm `TraLai` làm Phase RIÊNG thay vì DangSoanThao revert), DỄ QUÊN grep TOÀN BỘ logic check `=== OldValue` để thêm `|| === NewValue`. Tốt nhất extract helper function `isReject(target, currentPhase): boolean` share 1 nơi thay vì duplicate 3 chỗ.
2. **BE guard defense-in-depth có giá trị thực** — trong S21 t3, nếu BE chỉ trust FE đúng → bug ship prod, user UAT report, mất data integrity. BE guard early catch payload mismatch + ConflictException rõ ràng → fix nhanh + safe.
3. **Test-before flow strict không chỉ là "viết test" — còn confirm FAIL** — em main đầu tiên định viết test + fix cùng commit (cho gọn). Nhưng test-before §7 BẮT BUỘC confirm test FAIL trước fix. Bước này quan trọng — confirm test actually reproduce bug (assert đúng exception type/message), không chỉ là "test xanh sau fix".
4. **Multi-agent decision tree áp đúng** — Bug fix tightly coupled BE+FE+test reasoning chain → Implementer REFUSE per rule (Cognition "writes single-threaded"). Em main solo correct decision, KHÔNG cố split → tránh agent thrash.
## Handoff
- ✅ Chunk A `de00887` committed local — chưa push
- ✅ Chunk B `4b29d00` committed local — chưa push
- ✅ Chunk C (this) — committed sau khi save session log
-**PENDING bro confirm push remote**`git push origin main` 3 commit ahead `0a3b747..HEAD`
- ⏭ Sau push: CI sẽ trigger (NOT docs-only — có file `.cs` + `.tsx`) → 🟩 CICD Monitor spawn smoke verify (per plan G Trial Week 1)
User next action expected: "fix đi rồi tao giao thêm task" → sau Chunk C wrap → bro chốt task tiếp theo (có thể là Plan B Contract V2 wire hoặc fix khác phát sinh UAT).
## References
- Gotcha #45: `docs/gotchas.md`
- BE service: `src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs`
- FE component (× 2 app): `{fe-admin,fe-user}/src/components/pe/PeWorkflowPanel.tsx`
- Test: `tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceGuardTests.cs`
- Workflow spec Session 17: `PurchaseEvaluationPhase.cs` enum doc + Service comment L15-19
- Rule §7 test-before: `docs/rules.md`
- Rule §3.9 mirror 2 FE: `docs/rules.md`

View File

@ -0,0 +1,316 @@
# Session 21 turn 4 — 2026-05-13 12:00 — PE Workflow advanced options (F1+F2+F3, Mig 28)
**Dev:** Claude Opus 4.7 1M Max (em main solo — 3 feature multi-layer Implementer REFUSE per cross-stack reasoning chain rule)
**Duration:** ~3h (clarify Q&A 2 lượt + 5 chunk implement + verify build cả 2 app mỗi chunk)
**Base commit:** `6d30ba4` (S21 t3 fix gotcha #45 Chunk C Docs)
**Commits này turn:** `0294693` (A schema) → `c56024b` (B BE) → `a508564` (C FE Admin) → `d27caaf` (D FE eOffice) → this (E Docs)
## Trigger
User chốt 3 tính năng mới trong "Quy trình duyệt NCC":
1. **F1** — 4 mode Trả lại trong workflow (admin stick per workflow):
- Cho trả về 1 bậc trước đó
- Cho trả về người chỉ định
- Trả về người soạn thảo
- Workflow tick stick mode nào enabled → user eOffice dropdown chỉ hiện mode đó
2. **F2** — Drafter chọn "Gửi duyệt thẳng cấp" (vd skip → CEO):
- "Các bước này đều ghi nhận vào quy trình duyệt phiếu"
3. **F3** — Approver chỉnh sửa phiếu (Section 2 chi tiết):
- "lưu vào lịch sử chỉnh sửa luôn"
## Clarify Q&A (2 lượt AskUserQuestion)
### Lượt 1 — Schema + UX scope:
| Câu | User chốt |
|---|---|
| F1 "Trả về 1 bậc trước đó" nghĩa là gì? | **Cả 2 mode (admin chọn)** — 1 Cấp + 1 Bước stick độc lập |
| F1 "Người chỉ định" nguồn từ đâu? | **Approver pick runtime** — dropdown từ list NV đã ký (PeLevelOpinions) |
| F2 skip scope | **Chỉ skip tới Level cuối (CEO)** — Dropdown 2 option |
| F3 edit scope | **Section 2 (Hạng mục + NCC + Báo giá)** — KHÔNG đụng Header, KHÔNG reset workflow |
### Lượt 2 — Behavior + admin enable:
| Câu | User chốt |
|---|---|
| 3 mode Trả lại behavior | **Giữ ChoDuyet, lùi pointer** (peer review chain). Mode Drafter giữ Phase=TraLai S17 |
| F2+F3 admin enable | **Cả 2 cần admin tick** per workflow (audit nghiêm) |
| F3 approver perm | **Mọi approver Cấp đang active** (currentLevel match) |
| Test timing | **Test-after UAT default Phase 9** (skip dotnet test mỗi chunk, npm build × 2 app) |
## Chunk A — Schema + Migration 28 (`0294693`)
### Domain `ApprovalWorkflow.cs` +6 bool
```csharp
public bool AllowReturnOneLevel { get; set; } // F1 mode 1
public bool AllowReturnOneStep { get; set; } // F1 mode 2
public bool AllowReturnToAssignee { get; set; } // F1 mode 3
public bool AllowReturnToDrafter { get; set; } = true; // F1 mode 4 (backward compat S17)
public bool AllowDrafterSkipToFinal { get; set; } // F2
public bool AllowApproverEditDetails { get; set; } // F3
```
### EF config `ApprovalWorkflowConfiguration`
```csharp
e.Property(x => x.AllowReturnOneLevel).HasDefaultValue(false);
// ... 4 more false ...
e.Property(x => x.AllowReturnToDrafter).HasDefaultValue(true); // backfill rows cũ
```
### Migration 28 `AddAdvancedOptionsToApprovalWorkflows`
- 6 AddColumn bit NOT NULL DEFAULT 0/1
- 3-file rule complete (mig.cs + Designer.cs + Snapshot.cs)
- Apply LocalDB Dev + Design success
## Chunk B — BE Service + handlers + DTOs (`c56024b`)
### Service signature extend (backward compat)
```csharp
public async Task TransitionAsync(
PurchaseEvaluation evaluation,
PurchaseEvaluationPhase targetPhase,
Guid? actorUserId,
IReadOnlyList<string> actorRoles,
ApprovalDecision decision,
string? comment,
WorkflowReturnMode? returnMode = null, // ← NEW
Guid? returnTargetUserId = null, // ← NEW
bool skipToFinal = false, // ← NEW
CancellationToken ct = default)
```
`WorkflowReturnMode` enum: OneLevel=1, OneStep=2, Assignee=3, Drafter=4.
### REJECT branch extend với `ApplyReturnModeAsync`
```csharp
// Inside REJECT branch (line 51+)
var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter;
var returnSummary = await ApplyReturnModeAsync(
evaluation, effectiveMode, returnTargetUserId, isAdmin, ct);
comment = $"{comment} [{returnSummary}]";
```
Helper `ApplyReturnModeAsync` switch 4 mode:
```csharp
// OneLevel — lùi 1 Cấp trong cùng Step. Bước 1 Cấp 1 → fallback Drafter.
if (curLevel > 1) {
evaluation.CurrentApprovalLevelOrder = curLevel - 1;
summary = $"Trả về Cấp {curLevel - 1}";
}
else if (curStepIdx > 0) {
var prevStep = stepsOrdered[curStepIdx - 1];
evaluation.CurrentWorkflowStepIndex = curStepIdx - 1;
evaluation.CurrentApprovalLevelOrder = prevStep.Levels.Max(l => l.Order);
summary = $"Trả về Bước {prevStep.Order} Cấp {maxLevel} (Bước trước)";
}
else { /* fallback Drafter */ }
// OneStep — lùi sang Bước trước, set Level = max của Bước đó. Bước 1 → fallback.
// Assignee — tìm Step+Level match returnTargetUserId trong workflow.
// Drafter — Phase=TraLai clear pointer (S17 backward compat).
```
3 mode đầu giữ Phase=ChoDuyet + reset SLA 7d. Admin bypass workflow.Allow* flag check.
### DRAFTER trình branch extend với F2
```csharp
if (skipToFinal && evaluation.ApprovalWorkflowId is Guid skipAwId) {
// Workflow.AllowDrafterSkipToFinal required (non-admin)
if (!wfSkip.AllowDrafterSkipToFinal)
throw new ConflictException("Workflow không bật mode 'Gửi thẳng Cấp cuối'.");
evaluation.CurrentWorkflowStepIndex = wfSkip.Steps.Count - 1; // 0-based last
evaluation.CurrentApprovalLevelOrder = finalStep.Levels.Max(l => l.Order);
comment = $"{comment} [Drafter gửi thẳng Cấp cuối — skip Bước/Cấp trung gian]";
}
```
### Helper edit guard `EnsureEditableForDetailsAsync` (PurchaseEvaluationDraftGuard class)
```csharp
public static async Task<PurchaseEvaluation> EnsureEditableForDetailsAsync(
IApplicationDbContext db, Guid id, ICurrentUser currentUser, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(...);
// Drafter scope — bypass current Controller [Authorize] handles role
if (pe.Phase == DangSoanThao || pe.Phase == TraLai) return pe;
// F3 Approver scope (Mig 28) — chỉ ChoDuyet
if (pe.Phase == ChoDuyet && currentUser.UserId is Guid actorUserId) {
if (currentUser.Roles.Contains(Admin)) return pe; // bypass
var workflow = await db.ApprovalWorkflows.Include(w => w.Steps)...
if (!workflow.AllowApproverEditDetails)
throw new ConflictException("Workflow không bật mode...");
var level = step.Levels.FirstOrDefault(lv => lv.Order == levelOrder);
if (level.ApproverUserId != actorUserId)
throw new ForbiddenException("Chỉ NV phụ trách Bước X / Cấp Y...");
return pe;
}
throw new ConflictException($"Phiếu PE ở Phase={pe.Phase}, không thể chỉnh sửa.");
}
```
### 8 handler switch + audit changelog
- `PurchaseEvaluationDetailFeatures.cs`: Add/Update/Delete + Quote Upsert/Delete (5)
- `PurchaseEvaluationSupplierFeatures.cs`: Add/Update/Remove (3) — **bonus security fix**: trước đây Supplier handlers HOÀN TOÀN KHÔNG có phase guard!
Update/Delete handlers trước đây silent → thêm changelog `PhaseAtChange + UserId + Summary` (append `[Approver edit khi đang duyệt]` khi phase=ChoDuyet).
### Command DTO + DTOs extend
- `TransitionPurchaseEvaluationCommand` +3 optional field + Validator
- `ApprovalWorkflowOptionsDto` NEW sub-record (6 Allow* flag)
- `PurchaseEvaluationDetailBundleDto` +WorkflowOptions field (null nếu V1 legacy)
- `AwDefinitionDto` +6 Allow* (admin Designer GET)
- `CreateAwDefinitionCommand` +6 Allow* param (admin Designer POST)
### Verify
- `dotnet build SolutionErp.slnx` → 0 err, 2 warn pre-existing DocxRenderer
- 3 regression test gotcha #45 vẫn PASS (signature backward compat)
## Chunk C — FE Admin Designer (`a508564`)
### `ApprovalWorkflowsV2Page.tsx` Section "Cấu hình nâng cao" 6 checkbox
Container amber-50/30 + border distinct với Steps. 3 sub-group:
1. **Mode Trả lại** (4 checkbox):
- ☐ Trả về 1 Cấp trước (peer review chain trong cùng Bước)
- ☐ Trả về 1 Bước trước (Cấp cuối Bước trước nhận lại)
- ☐ Trả về Người chỉ định (pick runtime từ NV đã ký)
- ☑ Trả về Người soạn thảo (default checked = backward compat S17)
2. **Drafter gửi duyệt** (1 checkbox):
- ☐ Cho phép Drafter gửi thẳng Cấp cuối
3. **Approver chỉnh sửa phiếu** (1 checkbox):
- ☐ Cho phép Approver chỉnh sửa Section 2 (Hạng mục + NCC + Báo giá)
DTO types + state defaults từ cloneFrom (giữ config version trước) hoặc S17 fallback. POST body propagate.
fe-user KHÔNG mirror (Designer admin-only).
## Chunk D — FE eOffice (`d27caaf`) mirror 2 app
### Types `purchaseEvaluation.ts`
```typescript
export type ApprovalWorkflowOptions = {
allowReturnOneLevel: boolean
// ... 5 more
}
export const WorkflowReturnMode = {
OneLevel: 1, OneStep: 2, Assignee: 3, Drafter: 4,
} as const
export type PeDetailBundle = {
// ...
workflowOptions: ApprovalWorkflowOptions | null
}
```
### `PeWorkflowPanel.tsx` F1 Trả lại radio picker
State `returnMode` + `returnTargetUserId`. Dialog render 1-4 radio mode enabled theo `wfOptions.Allow*`. Assignee mode → submodal Select pick từ `evaluation.levelOpinions` (NV đã ký, dedupe by userId).
Banner amber rounded dưới mô tả hành vi mode chọn:
- Drafter: "Phiếu sẽ về 'Trả lại'. Drafter có thể sửa rồi trình lại từ Cấp 1 Bước 1."
- Assignee: "Phiếu sẽ về Cấp/Bước của NV đã chọn..."
- OneLevel/OneStep: "Phiếu sẽ lùi pointer (vẫn 'Đã gửi duyệt')..."
Mutation payload +`returnMode` +`returnTargetUserId` khi `isTraLaiAction`.
### `PeDetailTabs.tsx` F2 Drafter skip
State `skipToFinal` + `allowSkipToFinal` từ workflowOptions. submitForApproval mutationFn accept `opts.skipToFinal`. Workspace action bar: checkbox violet conditional. Confirm dialog message + button label dynamic.
### `PeDetailTabs.tsx` F3 Approver edit Section 2
useAuth import + compute `approverEditMode`:
```typescript
const approverEditMode = evaluation.phase === ChoDuyet
&& (evaluation.workflowOptions?.allowApproverEditDetails ?? false)
&& actorMatchesLevel // isAdmin || actor.id in currentApproval.approvers
const itemsReadOnly = readOnly && !approverEditMode
```
Banner violet "ⓘ Bạn được phép chỉnh sửa..." khi approverEditMode + readOnly (Duyệt menu).
InfoTab / NccSelectorRow / BudgetFieldRow GIỮ strict isEditablePhase (Header + Section 3 KHÔNG trong F3 scope).
### Verify
- `npm run build × 2 app` pass (fe-user 7.52s + fe-admin 499ms cached)
- 0 TS6 err, warning chunk size pre-existing
## Chunk E — Docs (this commit)
- `docs/database/schema-diagram.md §14` title "Mig 22-28, S17-21" + thêm 6 column Allow* trong Core block với inline comment F1/F2/F3
- `docs/STATUS.md` Last updated S21 t4 + count 27→28 mig + UAT defer test count unchanged
- `docs/HANDOFF.md` TL;DR S21 t4 đầy đủ (5 chunk narrative + Q&A + pattern reusable)
- Session log file này
## Stats cumulative S21 t4
| Metric | Trước (S21 t3) | Sau (S21 t4) | Δ |
|---|---|---|---|
| DB tables | 59 | 59 | 0 |
| **Migrations** | 27 | **28** | **+1** (Mig 28) |
| Endpoints | ~142 | ~143 | +1 (transitions body extend) |
| FE pages | 34 | 34 | 0 (Designer extend) |
| **Unit tests** | 84 | **84** | 0 (UAT defer test-after §7) |
| Gotchas | 45 | 45 | 0 |
| Memory entries | 17 | 17 | 0 |
| Skills | 6 | 6 | 0 |
| Sub-agents | 4 seeds-only | 4 seeds-only | 0 |
| Commits S21 t4 | — | **5** | `0294693``c56024b``a508564``d27caaf` → this |
## Lessons learned
1. **Backward-compat option pattern.** Thêm 6 cột mới với 1 cột default TRUE (AllowReturnToDrafter S17 fallback) + 5 cột default FALSE (admin opt-in) → workflow cũ chạy đúng sau deploy, không breaking change. Pattern reusable cho future feature flags.
2. **Boundary helper pattern** (extension thay vì rewrite). Helper cũ `EnsureDraftAsync` strict DangSoanThao only → thêm helper mới `EnsureEditableForDetailsAsync` accept thêm Approver scope. KHÔNG rename + KHÔNG break cũ → coexistence safe. 8 handler switch cleanly.
3. **Multi-agent decision tree áp đúng**. 3 feature multi-layer (Domain + Mig + Service + 8 handler + 2 FE app + 4 DTO file) — tightly coupled reasoning chain → Implementer REFUSE per Cognition "writes single-threaded". Em main solo decision đúng, KHÔNG split tránh agent thrash.
4. **Per-chunk discipline (5 chunk A-E)** mặc dù big-feature multi-layer >1000 LOC. Mỗi chunk verify build pass trước commit → rollback dễ nếu fail. Audit trail commit history rõ ràng cho future debug.
5. **Test-after UAT default Phase 9** chấp nhận được khi feature nhánh enable mới (admin opt-in). Test-before BẮT BUỘC chỉ cho bug fix (gotcha #45 S21 t3 đã làm). Cảnh báo carry: Plan C test-after candidate cumulative ngày càng nhiều — cần dedicated commit "Test catch-up S21 t1-t4" sau UAT ổn.
## Handoff
- ✅ Chunk A `0294693` BE schema + Mig 28 committed local
- ✅ Chunk B `c56024b` BE Service + handlers + DTOs committed local
- ✅ Chunk C `a508564` FE Admin Designer committed local
- ✅ Chunk D `d27caaf` FE eOffice mirror 2 app committed local
- ✅ Chunk E (this) — Docs commit sau khi save session log
-**PENDING bro confirm push remote**`git push origin main` 8 commit ahead `0a3b747..HEAD` (S21 t3 fix gotcha #45 + S21 t4 F1+F2+F3)
User next action expected: UAT test 3 feature mới trong env Prod hoặc local. Sau UAT 2-3 lần ổn → Plan C bundle test-after catch up.
## References
- BE Service: `src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs`
- BE entity: `src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs`
- BE handlers: `src/Backend/SolutionErp.Application/PurchaseEvaluations/{PurchaseEvaluationFeatures, PurchaseEvaluationDetailFeatures, PurchaseEvaluationSupplierFeatures}.cs`
- Mig 28: `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260513114505_AddAdvancedOptionsToApprovalWorkflows.cs`
- FE Admin Designer: `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx`
- FE eOffice × 2 app: `{fe-admin,fe-user}/src/components/pe/PeWorkflowPanel.tsx` + `PeDetailTabs.tsx` + `types/purchaseEvaluation.ts`
- Spec context: gotcha #45 S21 t3 BE guard payload mismatch + Session 17 spec 5 trạng thái
- Rules: §3.9 mirror 2 FE, §6.5 KEEP narrative, §7 test timing, `feedback_uat_skip_verify`, `feedback_per_chunk_commit`

View File

@ -715,7 +715,7 @@ CREATE TABLE PurchaseEvaluationDepartmentOpinions (
CREATE UNIQUE INDEX IX_PEDeptOpinions_PEId_Kind ON PurchaseEvaluationDepartmentOpinions (PurchaseEvaluationId, Kind); CREATE UNIQUE INDEX IX_PEDeptOpinions_PEId_Kind ON PurchaseEvaluationDepartmentOpinions (PurchaseEvaluationId, Kind);
``` ```
## 14. ApprovalWorkflow V2 schema (Migration 22-25, Session 17-18 — 3 bảng mới + 3 column) ## 14. ApprovalWorkflow V2 schema (Migration 22-28, Session 17-21 — 3 bảng mới + 9 column)
Schema riêng song song WorkflowDefinition V1 (Mig 21) — pin per phiếu PE. Schema riêng song song WorkflowDefinition V1 (Mig 21) — pin per phiếu PE.
V1 vẫn giữ cho phiếu cũ; V2 mới là active cho phiếu tạo từ Session 17 trở đi. V1 vẫn giữ cho phiếu cũ; V2 mới là active cho phiếu tạo từ Session 17 trở đi.
@ -728,6 +728,15 @@ ApprovalWorkflows
├── ApplicableType (1=DuyetNcc, 2=DuyetNccPhuongAn, 3=Contract) ├── ApplicableType (1=DuyetNcc, 2=DuyetNccPhuongAn, 3=Contract)
├── Name, Description, IsActive, ActivatedAt ├── Name, Description, IsActive, ActivatedAt
├── IsUserSelectable (Mig 25, S18) — admin pin/unpin cho user pick lúc create phiếu ├── IsUserSelectable (Mig 25, S18) — admin pin/unpin cho user pick lúc create phiếu
├── Mig 28 (S21 t4) — 6 advanced options "Cấu hình nâng cao" per workflow:
├── AllowReturnOneLevel bit DEFAULT 0 — F1 mode: Trả về 1 Cấp trước (peer review)
├── AllowReturnOneStep bit DEFAULT 0 — F1 mode: Trả về 1 Bước trước
├── AllowReturnToAssignee bit DEFAULT 0 — F1 mode: Trả về Người chỉ định (pick runtime)
├── AllowReturnToDrafter bit DEFAULT 1 — F1 mode: Trả về Drafter (S17 backward compat)
├── AllowDrafterSkipToFinal bit DEFAULT 0 — F2: Drafter trình thẳng Cấp cuối, skip trung gian
├── AllowApproverEditDetails bit DEFAULT 0 — F3: Approver chỉnh Section 2 lúc đang duyệt
└── (audit) CreatedAt, UpdatedAt, CreatedBy, UpdatedBy └── (audit) CreatedAt, UpdatedAt, CreatedBy, UpdatedBy
ApprovalWorkflowSteps (FK Cascade ApprovalWorkflowId, FK Restrict DepartmentId) ApprovalWorkflowSteps (FK Cascade ApprovalWorkflowId, FK Restrict DepartmentId)

View File

@ -637,6 +637,76 @@ public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
**FE diagnostic improvement:** TanStack Query error nên hiển thị warning UI (toast hoặc banner) thay vì silent. Hiện tại `useQuery` catch silent → debug khó. Future: wire `onError` handler global show generic error toast. **FE diagnostic improvement:** TanStack Query error nên hiển thị warning UI (toast hoặc banner) thay vì silent. Hiện tại `useQuery` catch silent → debug khó. Future: wire `onError` handler global show generic error toast.
### 45. PE "Trả về nhưng hệ thống vẫn duyệt" — FE button label vs decision payload mismatch (Session 21 turn 3)
**Triệu chứng:** UAT 2026-05-12 — User bro screenshot button labeled `← Trả lại` trong PE Workflow Panel (menu "Duyệt"), nhấn vào nhưng phiếu KHÔNG về phase TraLai — ngược lại tiến qua Cấp tiếp theo (hệ thống ghi nhận approve). User mô tả hành vi: "Trả về nhưng hệ thống vẫn duyệt".
**Root cause:** `PeWorkflowPanel.tsx` có 3 chỗ check transition type với logic KHÔNG sync giữa nhau:
- **L205-207** `isSendBack` (button label color): include cả `DangSoanThao` lẫn `TraLai` từ phase trung gian → label hiển thị `← Trả lại` đúng.
- **L64-66** `isReject` (payload `decision` gửi BE): CHỈ check `DangSoanThao`, **thiếu `TraLai`** → khi target=TraLai (98), `isReject=false` → payload `decision: 1` (Approve) thay vì `2` (Reject).
- **L247-248** dialog `isSendBack` (title + warning): CHỈ check `DangSoanThao`, **thiếu `TraLai`** → dialog title fallback `'✓ Duyệt → Trả lại'` (sai semantic) + KHÔNG hiển thị amber warning "Phiếu sẽ về Đang soạn thảo".
BE `PurchaseEvaluationWorkflowService.TransitionAsync`:
- L51 `if (decision == Reject)` branch → set Phase=TraLai correctly khi decision=Reject.
- L97 `APPROVE STEP` branch khi decision=Approve + fromPhase=ChoDuyet → `ApproveV2Async` UPSERT opinion = "đã duyệt" + advance Cấp.
- → Khi FE gửi `decision=1` (do bug `isReject`), BE đi vào nhánh APPROVE thay vì REJECT → phiếu được ghi nhận approve dù user định trả lại.
**Severity:** 🔴 CRITICAL — data integrity issue. NV nhấn "Trả lại" sẽ vô tình "duyệt" phiếu sang Cấp tiếp theo + UPSERT opinion vĩnh viễn vào `PurchaseEvaluationLevelOpinions` (Mig 26). Khó rollback vì BE đã `SaveChangesAsync`.
**Fix Chunk A (`de00887` BE defense-in-depth):**
```csharp
// PurchaseEvaluationWorkflowService.cs sau set isAdmin/isSystem (L48), trước REJECT branch (L51)
if ((targetPhase == PurchaseEvaluationPhase.TraLai
|| targetPhase == PurchaseEvaluationPhase.TuChoi)
&& decision != ApprovalDecision.Reject)
{
throw new ConflictException(
$"Transition tới {targetPhase} BẮT BUỘC decision=Reject (nhận {decision}). " +
"Báo lỗi caller — payload mismatch giữa target phase và decision.");
}
```
Boundary protection cho mọi caller tương lai (API client / mobile / cron retry). 3 regression test:
- `TransitionAsync_TargetTraLai_WithApproveDecision_Throws_AndDoesNotMutateState` (bug reproduce)
- `TransitionAsync_TargetTuChoi_WithApproveDecision_Throws_AndDoesNotMutateState` (consistency cover)
- `TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai` (happy path control)
**Fix Chunk B (`4b29d00` FE mirror 2 app):**
```typescript
// PeWorkflowPanel.tsx (fe-user + fe-admin) — 3 chỗ × 2 app
// Chỗ 1: isReject payload (line 64-66)
const isReject = target === PurchaseEvaluationPhase.TuChoi
|| (target === PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|| (target === PurchaseEvaluationPhase.TraLai // ← THÊM
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
// Chỗ 2: dialog isSendBack (line 247-248)
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|| target === PurchaseEvaluationPhase.TraLai) // ← THÊM
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai // ← THÊM
```
Chỗ 3 (button label `isSendBack` L205-207) đã đúng từ S17, KHÔNG đụng.
**Pattern reusable — invariant check khi viết FE workflow transition:**
1. Button label condition (visual) phải SYNC với payload decision (semantic).
2. Dialog title/warning condition phải SYNC với button label + payload.
3. Tốt nhất: extract `isReject(target, currentPhase)` thành 1 helper FE + BE share semantic — KHÔNG duplicate logic giữa 3 chỗ.
**Phòng tránh tương lai:**
- Khi spec mới có thêm phase terminal/intermediate (vd Session 17 thêm TraLai làm Phase RIÊNG thay vì DangSoanThao revert), audit grep TOÀN BỘ logic check `=== DangSoanThao` để xem chỗ nào cần thêm `|| === NewPhase`.
- BE guard early invariant `(targetPhase ∈ terminalSet) ⇔ (decision == Reject)` thay vì trust FE payload.
- Test-before bug fix BẮT BUỘC §7 — 3 test cover bug reproduce + consistency + happy path.
**References:**
- Commit fix: `de00887` (BE Chunk A) + `4b29d00` (FE Chunk B)
- Spec Session 17: `feedback_n_stage_workflow_pattern` DEPRECATED + spec mới trong `PurchaseEvaluationWorkflowService.cs` comment L15-19
- State machine 5 trạng thái: Nháp / Đã gửi duyệt / **Trả lại (98) — Phase RIÊNG** / Từ chối / Đã duyệt
## Checklist debug bug mới ## Checklist debug bug mới
1. Build pass không? → fail → check using + package version compat 1. Build pass không? → fail → check using + package version compat
@ -660,3 +730,4 @@ public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
19. Nếu npm install caching fail `tsc not found` → KHÔNG dùng junction Move-Item, thử robocopy/Copy-Item (#40) 19. Nếu npm install caching fail `tsc not found` → KHÔNG dùng junction Move-Item, thử robocopy/Copy-Item (#40)
20. Nếu CI vẫn trigger khi commit MD-only → paths-ignore trong on:push không match patterns đúng (#41) 20. Nếu CI vẫn trigger khi commit MD-only → paths-ignore trong on:push không match patterns đúng (#41)
21. Nếu user phàn nàn "feature work cho admin nhưng user empty/403 silent" → check class-level Authorize policy có over-restrict cho non-admin không, split per action (#44) 21. Nếu user phàn nàn "feature work cho admin nhưng user empty/403 silent" → check class-level Authorize policy có over-restrict cho non-admin không, split per action (#44)
22. Nếu button workflow label nói "Trả lại" nhưng phiếu vẫn tiến approve → audit FE `isReject` payload condition vs button `isSendBack` label condition vs dialog `isSendBack` warning condition — phải sync 3 chỗ với CÙNG set target phase. BE thêm guard `(target ∈ terminalSet) ⇔ (decision=Reject)` chặn caller mismatch (#45)

View File

@ -15,6 +15,7 @@ import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import { import {
PeAttachmentPurpose, PeAttachmentPurpose,
PeAttachmentPurposeLabel, PeAttachmentPurposeLabel,
@ -100,17 +101,33 @@ export function PeDetailTabs({
const canEditPhase = isEditablePhase(evaluation.phase) const canEditPhase = isEditablePhase(evaluation.phase)
const opinionsReadOnly = readOnly || mode === 'workspace' const opinionsReadOnly = readOnly || mode === 'workspace'
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorMatchesLevel = isAdmin
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
&& (evaluation.workflowOptions?.allowApproverEditDetails ?? false)
&& actorMatchesLevel
const itemsReadOnly = readOnly && !approverEditMode
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition // "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing // sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace. // (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
// Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối. Workflow phải bật flag.
const [skipToFinal, setSkipToFinal] = useState(false)
const allowSkipToFinal = evaluation.workflowOptions?.allowDrafterSkipToFinal ?? false
const submitForApproval = useMutation({ const submitForApproval = useMutation({
mutationFn: async () => { mutationFn: async (opts: { skipToFinal: boolean }) => {
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai) const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt') if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, { return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: next, targetPhase: next,
decision: 1, decision: 1,
comment: null, comment: null,
skipToFinal: opts.skipToFinal,
}) })
}, },
onSuccess: () => { onSuccess: () => {
@ -192,7 +209,14 @@ export function PeDetailTabs({
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} /> <InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
</Section> </Section>
<Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}> <Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
<ItemsTab ev={evaluation} readOnly={readOnly} /> {/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2 */}
{approverEditMode && readOnly && (
<div className="mx-5 mt-2 rounded border border-violet-200 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
Bạn đưc phép chỉnh sửa Hạng mục / NCC / Báo giá (workflow bật mode Approver edit).
Mọi thay đi sẽ đưc ghi vào Lịch sử chỉnh sửa.
</div>
)}
<ItemsTab ev={evaluation} readOnly={itemsReadOnly} />
</Section> </Section>
<Section title="3. Chọn NCC / TP thắng thầu"> <Section title="3. Chọn NCC / TP thắng thầu">
<ChonNccSection ev={evaluation} readOnly={readOnly} /> <ChonNccSection ev={evaluation} readOnly={readOnly} />
@ -251,18 +275,33 @@ export function PeDetailTabs({
> >
Lưu Lưu
</Button> </Button>
{/* Mig 28 (S21 t4) — F2: Drafter skip checkbox */}
{allowSkipToFinal && canSubmitForApproval && (
<label className="flex cursor-pointer items-center gap-1.5 rounded border border-violet-200 bg-violet-50 px-2 py-1 text-[11px] text-violet-800">
<input
type="checkbox"
className="h-3 w-3"
checked={skipToFinal}
onChange={e => setSkipToFinal(e.target.checked)}
/>
<span>Gửi thẳng Cấp cuối (skip trung gian)</span>
</label>
)}
<Button <Button
onClick={() => { onClick={() => {
if (!forwardPhase) return if (!forwardPhase) return
if (confirm(`Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`)) { const confirmMsg = skipToFinal
submitForApproval.mutate() ? `Gửi THẲNG CẤP CUỐI bỏ qua các Cấp trung gian? Hệ thống sẽ ghi audit "Drafter skip" — không quay lại được trừ khi approver Trả lại.`
: `Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`
if (confirm(confirmMsg)) {
submitForApproval.mutate({ skipToFinal })
} }
}} }}
disabled={!canSubmitForApproval || submitForApproval.isPending} disabled={!canSubmitForApproval || submitForApproval.isPending}
title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`} title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`}
className="text-xs" className="text-xs"
> >
{submitForApproval.isPending ? 'Đang gửi…' : 'Lưu & Gửi Duyệt →'} {submitForApproval.isPending ? 'Đang gửi…' : skipToFinal ? 'Lưu & Gửi thẳng CẤP CUỐI →' : 'Lưu & Gửi Duyệt →'}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -18,6 +18,7 @@ import {
PurchaseEvaluationPhase, PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor, PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel, PurchaseEvaluationPhaseLabel,
WorkflowReturnMode,
type PeDepartmentApproval, type PeDepartmentApproval,
type PeDetailBundle, type PeDetailBundle,
} from '@/types/purchaseEvaluation' } from '@/types/purchaseEvaluation'
@ -33,10 +34,20 @@ export function PeWorkflowPanel({
}) { }) {
const [target, setTarget] = useState<number | null>(null) const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
// Mig 28 (S21 t4) — F1 mode Trả lại. Default Drafter (backward compat S17).
const [returnMode, setReturnMode] = useState<WorkflowReturnMode>(WorkflowReturnMode.Drafter)
const [returnTargetUserId, setReturnTargetUserId] = useState<string | null>(null)
const qc = useQueryClient() const qc = useQueryClient()
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false const isAdmin = currentUser?.roles?.includes('Admin') ?? false
// Mig 28 — F1 workflow options. Null nếu V1 legacy → fallback chỉ "Trả về Drafter".
const wfOptions = evaluation.workflowOptions
// List approvers đã ký (cho mode Assignee dropdown pick)
const signedApprovers = (evaluation.levelOpinions ?? [])
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers // Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
// duyệt cấp hiện tại. Nếu actor không khớp → disable nút "Duyệt forward" // duyệt cấp hiện tại. Nếu actor không khớp → disable nút "Duyệt forward"
// (Trả lại / Từ chối vẫn enabled vì Service không kiểm Bước/Cấp với 2 // (Trả lại / Từ chối vẫn enabled vì Service không kiểm Bước/Cấp với 2
@ -63,15 +74,28 @@ export function PeWorkflowPanel({
mutationFn: async () => { mutationFn: async () => {
// Decision = Reject (2) khi: // Decision = Reject (2) khi:
// - target = TuChoi (huỷ phiếu) // - target = TuChoi (huỷ phiếu)
// - target = DangSoanThao từ phase trung gian (= Trả lại — smart reject Mig 16 // - target = DangSoanThao từ phase trung gian (= Trả lại legacy Mig 16
// set RejectedFromPhase + clear N-stage rows + Drafter resume jump-back) // set RejectedFromPhase + clear N-stage rows + Drafter resume jump-back)
// - target = TraLai (98) từ phase trung gian — Session 17 spec mới: Trả
// lại là Phase RIÊNG (gotcha #45 — thiếu nhánh này gây "Trả về nhưng
// hệ thống vẫn duyệt" do BE nhận decision=Approve → ApproveV2Async).
// BE có guard mirror trong PurchaseEvaluationWorkflowService.TransitionAsync
// throw ConflictException nếu payload mismatch — phải sync 2 phía.
const isReject = target === PurchaseEvaluationPhase.TuChoi const isReject = target === PurchaseEvaluationPhase.TuChoi
|| (target === PurchaseEvaluationPhase.DangSoanThao || (target === PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao) && evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|| (target === PurchaseEvaluationPhase.TraLai
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, { return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: target, targetPhase: target,
decision: isReject ? 2 : 1, decision: isReject ? 2 : 1,
comment: comment || null, comment: comment || null,
returnMode: isTraLaiAction ? returnMode : null,
returnTargetUserId: isTraLaiAction && returnMode === WorkflowReturnMode.Assignee
? returnTargetUserId : null,
}) })
}, },
onSuccess: () => { onSuccess: () => {
@ -81,6 +105,8 @@ export function PeWorkflowPanel({
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] }) qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
setTarget(null) setTarget(null)
setComment('') setComment('')
setReturnMode(WorkflowReturnMode.Drafter)
setReturnTargetUserId(null)
}, },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
@ -250,8 +276,13 @@ export function PeWorkflowPanel({
{target !== null && (() => { {target !== null && (() => {
const isCancel = target === PurchaseEvaluationPhase.TuChoi const isCancel = target === PurchaseEvaluationPhase.TuChoi
const isSendBack = target === PurchaseEvaluationPhase.DangSoanThao // isSendBack sync với button label + payload isReject (gotcha #45).
// Include cả DangSoanThao (legacy Mig 16) lẫn TraLai (Session 17 spec)
// — cả 2 là Trả lại Drafter sửa.
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|| target === PurchaseEvaluationPhase.TraLai)
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao && evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
const dialogTitle = isCancel const dialogTitle = isCancel
? '✗ Từ chối phiếu (khoá hoàn toàn)' ? '✗ Từ chối phiếu (khoá hoàn toàn)'
: isSendBack : isSendBack
@ -273,9 +304,96 @@ export function PeWorkflowPanel({
</div> </div>
)} )}
{isSendBack && ( {isSendBack && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800"> <>
Phiếu sẽ về &ldquo;Đang soạn thảo&rdquo;. Drafter thể sửa rồi trình lại workflow tự jump tới phase này. {/* Mig 28 (S21 t4) — F1 mode picker khi Trả lại. Show modes
</div> enabled per workflow.options. Default Drafter (S17 fallback). */}
{(wfOptions?.allowReturnOneLevel
|| wfOptions?.allowReturnOneStep
|| wfOptions?.allowReturnToAssignee
|| wfOptions?.allowReturnToDrafter
|| !wfOptions) && (
<div className="mb-3 space-y-1.5">
<Label className="text-[12px]">Chọn cách Trả lại</Label>
<div className="space-y-1">
{(wfOptions?.allowReturnOneLevel) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.OneLevel}
onChange={() => setReturnMode(WorkflowReturnMode.OneLevel)}
/>
<span>
<span className="font-medium">Trả về 1 Cấp trước</span>
<span className="block text-[10px] text-slate-500">Lùi 1 Cấp trong cùng Bước. NV cấp trước nhận lại.</span>
</span>
</label>
)}
{(wfOptions?.allowReturnOneStep) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.OneStep}
onChange={() => setReturnMode(WorkflowReturnMode.OneStep)}
/>
<span>
<span className="font-medium">Trả về 1 Bước trước</span>
<span className="block text-[10px] text-slate-500">Lùi sang Bước trước, NV Cấp cuối Bước đó nhận lại.</span>
</span>
</label>
)}
{(wfOptions?.allowReturnToAssignee) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.Assignee}
onChange={() => setReturnMode(WorkflowReturnMode.Assignee)}
/>
<span className="flex-1">
<span className="font-medium">Trả về Người chỉ đnh</span>
<span className="block text-[10px] text-slate-500">Pick từ list NV đã duyệt trước đó. Workflow set Cấp/Bước của NV.</span>
{returnMode === WorkflowReturnMode.Assignee && (
<select
className="mt-1.5 w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
value={returnTargetUserId ?? ''}
onChange={e => setReturnTargetUserId(e.target.value || null)}
>
<option value=""> Chọn NV </option>
{signedApprovers.map(a => (
<option key={a.userId} value={a.userId}>{a.fullName}</option>
))}
</select>
)}
</span>
</label>
)}
{(wfOptions?.allowReturnToDrafter !== false) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.Drafter}
onChange={() => setReturnMode(WorkflowReturnMode.Drafter)}
/>
<span>
<span className="font-medium">Trả về Người soạn thảo (mặc đnh)</span>
<span className="block text-[10px] text-slate-500">Phase "Trả lại". Drafter sửa rồi gửi lại chạy từ Cấp 1 Bước 1.</span>
</span>
</label>
)}
</div>
</div>
)}
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
{returnMode === WorkflowReturnMode.Drafter
? 'Phiếu sẽ về "Trả lại". Drafter có thể sửa rồi trình lại từ Cấp 1 Bước 1.'
: returnMode === WorkflowReturnMode.Assignee
? 'Phiếu sẽ về Cấp/Bước của NV đã chọn (vẫn "Đã gửi duyệt"). NV nhận lại để duyệt tiếp.'
: 'Phiếu sẽ lùi pointer (vẫn "Đã gửi duyệt"). NV trước nhận lại để duyệt tiếp.'}
</div>
</>
)} )}
<Label>Ghi chú (tùy chọn)</Label> <Label>Ghi chú (tùy chọn)</Label>
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} /> <Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />

View File

@ -60,6 +60,13 @@ type DefinitionDto = {
description: string | null description: string | null
isActive: boolean isActive: boolean
isUserSelectable: boolean // Mig 25 — admin toggle cho user pick isUserSelectable: boolean // Mig 25 — admin toggle cho user pick
// Mig 28 (S21 t4) — 6 advanced options per workflow version
allowReturnOneLevel: boolean
allowReturnOneStep: boolean
allowReturnToAssignee: boolean
allowReturnToDrafter: boolean // default true backward compat S17
allowDrafterSkipToFinal: boolean
allowApproverEditDetails: boolean
activatedAt: string | null activatedAt: string | null
createdAt: string createdAt: string
steps: StepDto[] steps: StepDto[]
@ -445,6 +452,15 @@ function Designer({
const [description, setDescription] = useState(cloneFrom?.description ?? '') const [description, setDescription] = useState(cloneFrom?.description ?? '')
const [steps, setSteps] = useState<EditStep[]>(initialSteps) const [steps, setSteps] = useState<EditStep[]>(initialSteps)
// Mig 28 (S21 t4) — 6 advanced options. Default clone từ cloneFrom (giữ
// config version trước) hoặc backward compat S17 (chỉ Drafter mode).
const [allowReturnOneLevel, setAllowReturnOneLevel] = useState(cloneFrom?.allowReturnOneLevel ?? false)
const [allowReturnOneStep, setAllowReturnOneStep] = useState(cloneFrom?.allowReturnOneStep ?? false)
const [allowReturnToAssignee, setAllowReturnToAssignee] = useState(cloneFrom?.allowReturnToAssignee ?? false)
const [allowReturnToDrafter, setAllowReturnToDrafter] = useState(cloneFrom?.allowReturnToDrafter ?? true)
const [allowDrafterSkipToFinal, setAllowDrafterSkipToFinal] = useState(cloneFrom?.allowDrafterSkipToFinal ?? false)
const [allowApproverEditDetails, setAllowApproverEditDetails] = useState(cloneFrom?.allowApproverEditDetails ?? false)
const usersList = useQuery({ const usersList = useQuery({
queryKey: ['users-for-approver-v2'], queryKey: ['users-for-approver-v2'],
queryFn: async () => queryFn: async () =>
@ -503,6 +519,13 @@ function Designer({
approverUserId: e.approverUserId, approverUserId: e.approverUserId,
})), })),
})), })),
// Mig 28 (S21 t4) — 6 advanced options
allowReturnOneLevel,
allowReturnOneStep,
allowReturnToAssignee,
allowReturnToDrafter,
allowDrafterSkipToFinal,
allowApproverEditDetails,
}) })
}, },
onSuccess: () => { onSuccess: () => {
@ -561,6 +584,118 @@ function Designer({
</div> </div>
</div> </div>
{/* Mig 28 (S21 t4) — Section Cấu hình nâng cao (F1+F2+F3 advanced options).
6 checkbox per workflow: 4 mode Trả lại + 1 Skip CEO + 1 Approver edit. */}
<div className="space-y-2 rounded-lg border border-amber-200 bg-amber-50/30 p-3">
<Label className="text-amber-900">
Cấu hình nâng cao quyền duyệt mở rộng
</Label>
<p className="text-[11px] leading-relaxed text-slate-600">
Bật/tắt mode duyệt mở rộng cho workflow này. Mặc đnh chỉ "Trả về Người soạn thảo" enabled
(tương thích quy trình ). Các mode khác opt-in đ audit nghiêm.
</p>
<div className="mt-2 space-y-3">
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-slate-500">
Mode Trả lại (Approver chọn khi nhấn Trả lại)
</div>
<div className="grid grid-cols-2 gap-1.5">
<label className="flex items-start gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="checkbox"
className="mt-0.5 h-3.5 w-3.5"
checked={allowReturnOneLevel}
onChange={e => setAllowReturnOneLevel(e.target.checked)}
/>
<span>
<span className="font-medium">Trả về 1 Cấp trước</span>
<span className="block text-[10px] text-slate-500">Lùi 1 Cấp trong cùng Bước, peer review chain</span>
</span>
</label>
<label className="flex items-start gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="checkbox"
className="mt-0.5 h-3.5 w-3.5"
checked={allowReturnOneStep}
onChange={e => setAllowReturnOneStep(e.target.checked)}
/>
<span>
<span className="font-medium">Trả về 1 Bước trước</span>
<span className="block text-[10px] text-slate-500">Lùi sang Bước trước, Cấp cuối nhận lại</span>
</span>
</label>
<label className="flex items-start gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="checkbox"
className="mt-0.5 h-3.5 w-3.5"
checked={allowReturnToAssignee}
onChange={e => setAllowReturnToAssignee(e.target.checked)}
/>
<span>
<span className="font-medium">Trả về Người chỉ đnh</span>
<span className="block text-[10px] text-slate-500">Pick runtime từ list NV đã duyệt</span>
</span>
</label>
<label className="flex items-start gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="checkbox"
className="mt-0.5 h-3.5 w-3.5"
checked={allowReturnToDrafter}
onChange={e => setAllowReturnToDrafter(e.target.checked)}
/>
<span>
<span className="font-medium">Trả về Người soạn thảo</span>
<span className="block text-[10px] text-slate-500">Phase=TraLai, Drafter sửa rồi gửi lại (mặc đnh)</span>
</span>
</label>
</div>
</div>
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-slate-500">
Drafter gửi duyệt (Workspace "Lưu &amp; Gửi Duyệt")
</div>
<label className="flex items-start gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="checkbox"
className="mt-0.5 h-3.5 w-3.5"
checked={allowDrafterSkipToFinal}
onChange={e => setAllowDrafterSkipToFinal(e.target.checked)}
/>
<span>
<span className="font-medium">Cho phép Drafter gửi thẳng Cấp cuối</span>
<span className="block text-[10px] text-slate-500">
Skip mọi Bước/Cấp trung gian đi thẳng NV Cấp cuối (vd CEO).
Workspace hiện dropdown 2 option "Gửi tuần tự" vs "Gửi thẳng Cấp cuối".
</span>
</span>
</label>
</div>
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-slate-500">
Approver chỉnh sửa phiếu
</div>
<label className="flex items-start gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="checkbox"
className="mt-0.5 h-3.5 w-3.5"
checked={allowApproverEditDetails}
onChange={e => setAllowApproverEditDetails(e.target.checked)}
/>
<span>
<span className="font-medium">Cho phép Approver chỉnh sửa Section 2 (Hạng mục + NCC + Báo giá)</span>
<span className="block text-[10px] text-slate-500">
NV Cấp đang duyệt đưc edit chi tiết phiếu (không reset workflow,
giữ Cấp hiện tại). Mọi thay đi log vào Lịch sử chỉnh sửa.
</span>
</span>
</label>
</div>
</div>
</div>
<div className="space-y-2 rounded-lg border border-slate-200 p-3"> <div className="space-y-2 rounded-lg border border-slate-200 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label> <Label>

View File

@ -347,6 +347,25 @@ export type PeDepartmentApproval = {
isBypassed: boolean isBypassed: boolean
} }
// Mig 28 (S21 t4) — 6 advanced options của workflow pin.
export type ApprovalWorkflowOptions = {
allowReturnOneLevel: boolean
allowReturnOneStep: boolean
allowReturnToAssignee: boolean
allowReturnToDrafter: boolean
allowDrafterSkipToFinal: boolean
allowApproverEditDetails: boolean
}
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE
export const WorkflowReturnMode = {
OneLevel: 1,
OneStep: 2,
Assignee: 3,
Drafter: 4,
} as const
export type WorkflowReturnMode = typeof WorkflowReturnMode[keyof typeof WorkflowReturnMode]
export type PeDetailBundle = { export type PeDetailBundle = {
id: string id: string
maPhieu: string | null maPhieu: string | null
@ -378,6 +397,8 @@ export type PeDetailBundle = {
approvalWorkflowCode: string | null approvalWorkflowCode: string | null
approvalWorkflowName: string | null approvalWorkflowName: string | null
approvalWorkflowVersion: number | null approvalWorkflowVersion: number | null
// Mig 28 (S21 t4) — 6 advanced options của workflow pin. Null nếu V1 legacy.
workflowOptions: ApprovalWorkflowOptions | null
// Mig 24 — info Bước/Cấp đang chờ duyệt (chỉ populate khi pin V2 + Phase=ChoDuyet) // Mig 24 — info Bước/Cấp đang chờ duyệt (chỉ populate khi pin V2 + Phase=ChoDuyet)
currentApproval: PeCurrentApproval | null currentApproval: PeCurrentApproval | null
// Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level // Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level

View File

@ -15,6 +15,7 @@ import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import { import {
PeAttachmentPurpose, PeAttachmentPurpose,
PeAttachmentPurposeLabel, PeAttachmentPurposeLabel,
@ -100,17 +101,38 @@ export function PeDetailTabs({
const canEditPhase = isEditablePhase(evaluation.phase) const canEditPhase = isEditablePhase(evaluation.phase)
const opinionsReadOnly = readOnly || mode === 'workspace' const opinionsReadOnly = readOnly || mode === 'workspace'
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
// Khi phase=ChoDuyet + workflow.AllowApproverEditDetails + actor match
// CurrentLevel.ApproverUserId → cho phép edit Section 2 dù readOnly=true.
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorMatchesLevel = isAdmin
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
&& (evaluation.workflowOptions?.allowApproverEditDetails ?? false)
&& actorMatchesLevel
// itemsReadOnly = readOnly trừ khi approver mode F3 mở
const itemsReadOnly = readOnly && !approverEditMode
// Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối. Workflow phải bật flag.
// Default false (gửi tuần tự như cũ). Sync state với confirm dialog handler.
const [skipToFinal, setSkipToFinal] = useState(false)
const allowSkipToFinal = evaluation.workflowOptions?.allowDrafterSkipToFinal ?? false
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition // "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing // sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace. // (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
const submitForApproval = useMutation({ const submitForApproval = useMutation({
mutationFn: async () => { mutationFn: async (opts: { skipToFinal: boolean }) => {
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai) const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt') if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, { return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: next, targetPhase: next,
decision: 1, decision: 1,
comment: null, comment: null,
// Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối
skipToFinal: opts.skipToFinal,
}) })
}, },
onSuccess: () => { onSuccess: () => {
@ -192,7 +214,15 @@ export function PeDetailTabs({
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} /> <InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
</Section> </Section>
<Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}> <Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
<ItemsTab ev={evaluation} readOnly={readOnly} /> {/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2.
Banner cảnh báo "Bạn đang chỉnh sửa khi đang duyệt" khi approverEditMode. */}
{approverEditMode && readOnly && (
<div className="mx-5 mt-2 rounded border border-violet-200 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
Bạn đưc phép chỉnh sửa Hạng mục / NCC / Báo giá (workflow bật mode Approver edit).
Mọi thay đi sẽ đưc ghi vào Lịch sử chỉnh sửa.
</div>
)}
<ItemsTab ev={evaluation} readOnly={itemsReadOnly} />
</Section> </Section>
<Section title="3. Chọn NCC / TP thắng thầu"> <Section title="3. Chọn NCC / TP thắng thầu">
<ChonNccSection ev={evaluation} readOnly={readOnly} /> <ChonNccSection ev={evaluation} readOnly={readOnly} />
@ -251,18 +281,34 @@ export function PeDetailTabs({
> >
Lưu Lưu
</Button> </Button>
{/* Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối checkbox.
Chỉ hiện khi workflow.AllowDrafterSkipToFinal=true. */}
{allowSkipToFinal && canSubmitForApproval && (
<label className="flex cursor-pointer items-center gap-1.5 rounded border border-violet-200 bg-violet-50 px-2 py-1 text-[11px] text-violet-800">
<input
type="checkbox"
className="h-3 w-3"
checked={skipToFinal}
onChange={e => setSkipToFinal(e.target.checked)}
/>
<span>Gửi thẳng Cấp cuối (skip trung gian)</span>
</label>
)}
<Button <Button
onClick={() => { onClick={() => {
if (!forwardPhase) return if (!forwardPhase) return
if (confirm(`Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`)) { const confirmMsg = skipToFinal
submitForApproval.mutate() ? `Gửi THẲNG CẤP CUỐI bỏ qua các Cấp trung gian? Hệ thống sẽ ghi audit "Drafter skip" — không quay lại được trừ khi approver Trả lại.`
: `Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`
if (confirm(confirmMsg)) {
submitForApproval.mutate({ skipToFinal })
} }
}} }}
disabled={!canSubmitForApproval || submitForApproval.isPending} disabled={!canSubmitForApproval || submitForApproval.isPending}
title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`} title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`}
className="text-xs" className="text-xs"
> >
{submitForApproval.isPending ? 'Đang gửi…' : 'Lưu & Gửi Duyệt →'} {submitForApproval.isPending ? 'Đang gửi…' : skipToFinal ? 'Lưu & Gửi thẳng CẤP CUỐI →' : 'Lưu & Gửi Duyệt →'}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -18,6 +18,7 @@ import {
PurchaseEvaluationPhase, PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor, PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel, PurchaseEvaluationPhaseLabel,
WorkflowReturnMode,
type PeDepartmentApproval, type PeDepartmentApproval,
type PeDetailBundle, type PeDetailBundle,
} from '@/types/purchaseEvaluation' } from '@/types/purchaseEvaluation'
@ -33,10 +34,21 @@ export function PeWorkflowPanel({
}) { }) {
const [target, setTarget] = useState<number | null>(null) const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
// Mig 28 (S21 t4) — F1 mode Trả lại. Default Drafter (backward compat S17).
const [returnMode, setReturnMode] = useState<WorkflowReturnMode>(WorkflowReturnMode.Drafter)
const [returnTargetUserId, setReturnTargetUserId] = useState<string | null>(null)
const qc = useQueryClient() const qc = useQueryClient()
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false const isAdmin = currentUser?.roles?.includes('Admin') ?? false
// Mig 28 — F1 workflow options. Null nếu V1 legacy → fallback chỉ "Trả về Drafter".
const wfOptions = evaluation.workflowOptions
// List approvers đã ký (cho mode Assignee dropdown pick)
const signedApprovers = (evaluation.levelOpinions ?? [])
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
// Dedupe by userId (1 NV có thể ký nhiều cấp nếu workflow đặt như vậy)
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers // Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
// duyệt cấp hiện tại. Admin bypass. // duyệt cấp hiện tại. Admin bypass.
const v2Approvers = evaluation.currentApproval?.approvers ?? [] const v2Approvers = evaluation.currentApproval?.approvers ?? []
@ -59,15 +71,28 @@ export function PeWorkflowPanel({
mutationFn: async () => { mutationFn: async () => {
// Decision = Reject (2) khi: // Decision = Reject (2) khi:
// - target = TuChoi (huỷ phiếu) // - target = TuChoi (huỷ phiếu)
// - target = DangSoanThao từ phase trung gian (= Trả lại — smart reject Mig 16 // - target = DangSoanThao từ phase trung gian (= Trả lại legacy Mig 16
// set RejectedFromPhase + clear N-stage rows + Drafter resume jump-back) // set RejectedFromPhase + clear N-stage rows + Drafter resume jump-back)
// - target = TraLai (98) từ phase trung gian — Session 17 spec mới: Trả
// lại là Phase RIÊNG (gotcha #45 — thiếu nhánh này gây "Trả về nhưng
// hệ thống vẫn duyệt" do BE nhận decision=Approve → ApproveV2Async).
// BE có guard mirror trong PurchaseEvaluationWorkflowService.TransitionAsync
// throw ConflictException nếu payload mismatch — phải sync 2 phía.
const isReject = target === PurchaseEvaluationPhase.TuChoi const isReject = target === PurchaseEvaluationPhase.TuChoi
|| (target === PurchaseEvaluationPhase.DangSoanThao || (target === PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao) && evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|| (target === PurchaseEvaluationPhase.TraLai
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, { return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: target, targetPhase: target,
decision: isReject ? 2 : 1, decision: isReject ? 2 : 1,
comment: comment || null, comment: comment || null,
returnMode: isTraLaiAction ? returnMode : null,
returnTargetUserId: isTraLaiAction && returnMode === WorkflowReturnMode.Assignee
? returnTargetUserId : null,
}) })
}, },
onSuccess: () => { onSuccess: () => {
@ -77,6 +102,8 @@ export function PeWorkflowPanel({
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] }) qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
setTarget(null) setTarget(null)
setComment('') setComment('')
setReturnMode(WorkflowReturnMode.Drafter)
setReturnTargetUserId(null)
}, },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
@ -244,8 +271,13 @@ export function PeWorkflowPanel({
{target !== null && (() => { {target !== null && (() => {
const isCancel = target === PurchaseEvaluationPhase.TuChoi const isCancel = target === PurchaseEvaluationPhase.TuChoi
const isSendBack = target === PurchaseEvaluationPhase.DangSoanThao // isSendBack sync với button label L205-207 + payload isReject L64-68
// (gotcha #45). Include cả DangSoanThao (legacy Mig 16) lẫn TraLai
// (Session 17 spec) — cả 2 là Trả lại Drafter sửa.
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|| target === PurchaseEvaluationPhase.TraLai)
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao && evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
const dialogTitle = isCancel const dialogTitle = isCancel
? '✗ Từ chối phiếu (khoá hoàn toàn)' ? '✗ Từ chối phiếu (khoá hoàn toàn)'
: isSendBack : isSendBack
@ -267,9 +299,96 @@ export function PeWorkflowPanel({
</div> </div>
)} )}
{isSendBack && ( {isSendBack && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800"> <>
Phiếu sẽ về &ldquo;Đang soạn thảo&rdquo;. Drafter thể sửa rồi trình lại workflow tự jump tới phase này. {/* Mig 28 (S21 t4) — F1 mode picker khi Trả lại. Show modes
</div> enabled per workflow.options. Default Drafter (S17 fallback). */}
{(wfOptions?.allowReturnOneLevel
|| wfOptions?.allowReturnOneStep
|| wfOptions?.allowReturnToAssignee
|| wfOptions?.allowReturnToDrafter
|| !wfOptions) && (
<div className="mb-3 space-y-1.5">
<Label className="text-[12px]">Chọn cách Trả lại</Label>
<div className="space-y-1">
{(wfOptions?.allowReturnOneLevel) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.OneLevel}
onChange={() => setReturnMode(WorkflowReturnMode.OneLevel)}
/>
<span>
<span className="font-medium">Trả về 1 Cấp trước</span>
<span className="block text-[10px] text-slate-500">Lùi 1 Cấp trong cùng Bước. NV cấp trước nhận lại.</span>
</span>
</label>
)}
{(wfOptions?.allowReturnOneStep) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.OneStep}
onChange={() => setReturnMode(WorkflowReturnMode.OneStep)}
/>
<span>
<span className="font-medium">Trả về 1 Bước trước</span>
<span className="block text-[10px] text-slate-500">Lùi sang Bước trước, NV Cấp cuối Bước đó nhận lại.</span>
</span>
</label>
)}
{(wfOptions?.allowReturnToAssignee) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.Assignee}
onChange={() => setReturnMode(WorkflowReturnMode.Assignee)}
/>
<span className="flex-1">
<span className="font-medium">Trả về Người chỉ đnh</span>
<span className="block text-[10px] text-slate-500">Pick từ list NV đã duyệt trước đó. Workflow set Cấp/Bước của NV.</span>
{returnMode === WorkflowReturnMode.Assignee && (
<select
className="mt-1.5 w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
value={returnTargetUserId ?? ''}
onChange={e => setReturnTargetUserId(e.target.value || null)}
>
<option value=""> Chọn NV </option>
{signedApprovers.map(a => (
<option key={a.userId} value={a.userId}>{a.fullName}</option>
))}
</select>
)}
</span>
</label>
)}
{(wfOptions?.allowReturnToDrafter !== false) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.Drafter}
onChange={() => setReturnMode(WorkflowReturnMode.Drafter)}
/>
<span>
<span className="font-medium">Trả về Người soạn thảo (mặc đnh)</span>
<span className="block text-[10px] text-slate-500">Phase "Trả lại". Drafter sửa rồi gửi lại chạy từ Cấp 1 Bước 1.</span>
</span>
</label>
)}
</div>
</div>
)}
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
{returnMode === WorkflowReturnMode.Drafter
? 'Phiếu sẽ về "Trả lại". Drafter có thể sửa rồi trình lại từ Cấp 1 Bước 1.'
: returnMode === WorkflowReturnMode.Assignee
? 'Phiếu sẽ về Cấp/Bước của NV đã chọn (vẫn "Đã gửi duyệt"). NV nhận lại để duyệt tiếp.'
: 'Phiếu sẽ lùi pointer (vẫn "Đã gửi duyệt"). NV trước nhận lại để duyệt tiếp.'}
</div>
</>
)} )}
<Label>Ghi chú (tùy chọn)</Label> <Label>Ghi chú (tùy chọn)</Label>
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} /> <Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />

View File

@ -344,6 +344,25 @@ export type PeDepartmentApproval = {
isBypassed: boolean isBypassed: boolean
} }
// Mig 28 (S21 t4) — 6 advanced options của workflow pin.
export type ApprovalWorkflowOptions = {
allowReturnOneLevel: boolean
allowReturnOneStep: boolean
allowReturnToAssignee: boolean
allowReturnToDrafter: boolean
allowDrafterSkipToFinal: boolean
allowApproverEditDetails: boolean
}
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE
export const WorkflowReturnMode = {
OneLevel: 1,
OneStep: 2,
Assignee: 3,
Drafter: 4,
} as const
export type WorkflowReturnMode = typeof WorkflowReturnMode[keyof typeof WorkflowReturnMode]
export type PeDetailBundle = { export type PeDetailBundle = {
id: string id: string
maPhieu: string | null maPhieu: string | null
@ -375,6 +394,9 @@ export type PeDetailBundle = {
approvalWorkflowCode: string | null approvalWorkflowCode: string | null
approvalWorkflowName: string | null approvalWorkflowName: string | null
approvalWorkflowVersion: number | null approvalWorkflowVersion: number | null
// Mig 28 (S21 t4) — 6 advanced options của workflow pin. Null nếu V1 legacy.
// FE filter Trả lại dropdown + Skip submit + Edit Section 2 conditional.
workflowOptions: ApprovalWorkflowOptions | null
// Mig 24 — info Bước/Cấp đang chờ duyệt (chỉ populate khi pin V2 + Phase=ChoDuyet) // Mig 24 — info Bước/Cấp đang chờ duyệt (chỉ populate khi pin V2 + Phase=ChoDuyet)
currentApproval: PeCurrentApproval | null currentApproval: PeCurrentApproval | null
// Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level // Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level

View File

@ -46,6 +46,15 @@ public record AwDefinitionDto(
string? Description, string? Description,
bool IsActive, bool IsActive,
bool IsUserSelectable, bool IsUserSelectable,
// Mig 28 (S21 t4) — 6 advanced options của workflow per version. Admin
// Designer tick stick → checkbox. FE eOffice render dropdown / Skip / Edit
// conditional theo flag tương ứng.
bool AllowReturnOneLevel,
bool AllowReturnOneStep,
bool AllowReturnToAssignee,
bool AllowReturnToDrafter,
bool AllowDrafterSkipToFinal,
bool AllowApproverEditDetails,
DateTime? ActivatedAt, DateTime? ActivatedAt,
DateTime CreatedAt, DateTime CreatedAt,
List<AwStepDto> Steps); List<AwStepDto> Steps);
@ -128,6 +137,13 @@ public class GetAwAdminOverviewQueryHandler(
d.Description, d.Description,
d.IsActive, d.IsActive,
d.IsUserSelectable, d.IsUserSelectable,
// Mig 28 — 6 Allow* flag
d.AllowReturnOneLevel,
d.AllowReturnOneStep,
d.AllowReturnToAssignee,
d.AllowReturnToDrafter,
d.AllowDrafterSkipToFinal,
d.AllowApproverEditDetails,
d.ActivatedAt, d.ActivatedAt,
d.CreatedAt, d.CreatedAt,
d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto( d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto(
@ -178,7 +194,15 @@ public record CreateAwDefinitionCommand(
string Code, string Code,
string Name, string Name,
string? Description, string? Description,
List<CreateAwStepInput> Steps) : IRequest<Guid>; List<CreateAwStepInput> Steps,
// Mig 28 (S21 t4) — 6 Allow* options. Default = backward compat S17
// (chỉ Trả về Drafter enabled). Admin tick stick để mở mode khác.
bool AllowReturnOneLevel = false,
bool AllowReturnOneStep = false,
bool AllowReturnToAssignee = false,
bool AllowReturnToDrafter = true,
bool AllowDrafterSkipToFinal = false,
bool AllowApproverEditDetails = false) : IRequest<Guid>;
public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand> public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
{ {
@ -271,6 +295,13 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
Description = request.Description, Description = request.Description,
IsActive = true, IsActive = true,
IsUserSelectable = true, // Mig 25 — version mới mặc định cho user pick IsUserSelectable = true, // Mig 25 — version mới mặc định cho user pick
// Mig 28 (S21 t4) — 6 Allow* options
AllowReturnOneLevel = request.AllowReturnOneLevel,
AllowReturnOneStep = request.AllowReturnOneStep,
AllowReturnToAssignee = request.AllowReturnToAssignee,
AllowReturnToDrafter = request.AllowReturnToDrafter,
AllowDrafterSkipToFinal = request.AllowDrafterSkipToFinal,
AllowApproverEditDetails = request.AllowApproverEditDetails,
ActivatedAt = DateTime.UtcNow, ActivatedAt = DateTime.UtcNow,
Steps = request.Steps.OrderBy(s => s.Order) Steps = request.Steps.OrderBy(s => s.Order)
.Select(s => new ApprovalWorkflowStep .Select(s => new ApprovalWorkflowStep

View File

@ -78,6 +78,16 @@ public record PurchaseEvaluationChangelogDto(
string? ContextNote, string? ContextNote,
DateTime CreatedAt); DateTime CreatedAt);
// Mig 28 (S21 t4) — 6 advanced options của workflow pin. FE filter Trả lại
// dropdown / Skip submit / Edit Section 2 enabled theo flag tương ứng.
public record ApprovalWorkflowOptionsDto(
bool AllowReturnOneLevel,
bool AllowReturnOneStep,
bool AllowReturnToAssignee,
bool AllowReturnToDrafter,
bool AllowDrafterSkipToFinal,
bool AllowApproverEditDetails);
public record PurchaseEvaluationWorkflowSummaryDto( public record PurchaseEvaluationWorkflowSummaryDto(
string PolicyName, string PolicyName,
string PolicyDescription, string PolicyDescription,
@ -194,6 +204,9 @@ public record PurchaseEvaluationDetailBundleDto(
string? ApprovalWorkflowCode, string? ApprovalWorkflowCode,
string? ApprovalWorkflowName, string? ApprovalWorkflowName,
int? ApprovalWorkflowVersion, int? ApprovalWorkflowVersion,
// Mig 28 (S21 t4) — 6 Allow* options của workflow pin. Null nếu phiếu V1
// legacy. FE render Trả lại dropdown + Skip + Edit Section 2 conditional.
ApprovalWorkflowOptionsDto? WorkflowOptions,
PurchaseEvaluationCurrentApprovalDto? CurrentApproval, PurchaseEvaluationCurrentApprovalDto? CurrentApproval,
PurchaseEvaluationApprovalFlowDto? ApprovalFlow, PurchaseEvaluationApprovalFlowDto? ApprovalFlow,
List<PurchaseEvaluationSupplierDto> Suppliers, List<PurchaseEvaluationSupplierDto> Suppliers,

View File

@ -4,23 +4,96 @@ using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations; using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations; namespace SolutionErp.Application.PurchaseEvaluations;
// ========== Helper: Lock edit guard (Phase 9 — Migration 16) ========== // ========== Helper: Lock edit guard (Phase 9 — Migration 16) ==========
// Chỉ Phase=DangSoanThao mới được CRUD chi tiết / báo giá / NCC tham gia. // Original: Chỉ Phase=DangSoanThao mới được CRUD chi tiết / báo giá / NCC tham gia.
// Đã trình duyệt → KHÔNG sửa được. Phải reject về DangSoanThao trước. // Đã trình duyệt → KHÔNG sửa được. Phải reject về DangSoanThao trước.
//
// Mig 28 (S21 t4 — F3): Extend Section 2 (Detail + NCC + Báo giá) cho phép
// Approver edit khi phase=ChoDuyet + workflow.AllowApproverEditDetails=true +
// actor.Id == currentLevel.ApproverUserId. KHÔNG đụng PE Header / Attachment /
// DepartmentOpinion — vẫn dùng EnsureDraftAsync strict.
internal static class PurchaseEvaluationDraftGuard internal static class PurchaseEvaluationDraftGuard
{ {
/// Strict guard cho PE Header / Attachment / DepartmentOpinion / Winner select —
/// chỉ Drafter scope (DangSoanThao OR TraLai để Drafter sửa rồi gửi lại).
public static async Task<PurchaseEvaluation> EnsureDraftAsync(IApplicationDbContext db, Guid id, CancellationToken ct) public static async Task<PurchaseEvaluation> EnsureDraftAsync(IApplicationDbContext db, Guid id, CancellationToken ct)
{ {
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct) var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct)
?? throw new NotFoundException("PurchaseEvaluation", id); ?? throw new NotFoundException("PurchaseEvaluation", id);
if (pe.Phase != PurchaseEvaluationPhase.DangSoanThao) if (pe.Phase != PurchaseEvaluationPhase.DangSoanThao
throw new ConflictException($"Phiếu PE đã trình duyệt (Phase={pe.Phase}), không thể chỉnh sửa chi tiết. Phải reject để Drafter sửa lại."); && pe.Phase != PurchaseEvaluationPhase.TraLai)
throw new ConflictException(
$"Phiếu PE đã trình duyệt (Phase={pe.Phase}), không thể chỉnh sửa. " +
"Phải Trả lại Drafter sửa lại.");
return pe; return pe;
} }
/// F3 (Mig 28 — S21 t4) — Edit guard cho Section 2 (Detail + NCC + Báo giá).
/// 2 trường hợp accepted:
/// 1. Drafter scope: DangSoanThao OR TraLai — Controller [Authorize] handle role.
/// 2. Approver scope: ChoDuyet + workflow.AllowApproverEditDetails=true +
/// actor.Id match CurrentLevel.ApproverUserId. KHÔNG reset workflow,
/// giữ Cấp hiện tại. Admin bypass workflow flag check.
public static async Task<PurchaseEvaluation> EnsureEditableForDetailsAsync(
IApplicationDbContext db, Guid id, ICurrentUser currentUser, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct)
?? throw new NotFoundException("PurchaseEvaluation", id);
// Drafter scope — any authenticated, Controller [Authorize(Policy)] gates role
if (pe.Phase == PurchaseEvaluationPhase.DangSoanThao
|| pe.Phase == PurchaseEvaluationPhase.TraLai)
return pe;
// F3 Approver scope (Mig 28) — chỉ ChoDuyet với V2 schema
if (pe.Phase == PurchaseEvaluationPhase.ChoDuyet
&& currentUser.IsAuthenticated
&& currentUser.UserId is Guid actorUserId)
{
// Admin bypass — admin có thể edit bất chấp Allow* flag
if (currentUser.Roles.Contains(AppRoles.Admin)) return pe;
// V2 schema required
if (pe.ApprovalWorkflowId is Guid awId
&& pe.CurrentWorkflowStepIndex is int stepIdx
&& pe.CurrentApprovalLevelOrder is int levelOrder)
{
var workflow = await db.ApprovalWorkflows
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == awId, ct)
?? throw new ConflictException("Workflow không tồn tại.");
if (!workflow.AllowApproverEditDetails)
throw new ConflictException(
"Workflow không bật mode 'Approver chỉnh sửa Section 2'. " +
"Phải Trả lại Drafter sửa hoặc liên hệ Admin Designer.");
var step = workflow.Steps.OrderBy(s => s.Order).Skip(stepIdx).FirstOrDefault();
var level = step?.Levels.FirstOrDefault(lv => lv.Order == levelOrder);
if (level is null)
throw new ConflictException("Workflow Bước/Cấp không tìm thấy — schema lỗi.");
if (level.ApproverUserId != actorUserId)
throw new ForbiddenException(
$"Chỉ NV phụ trách Bước {step!.Order} / Cấp {levelOrder} " +
"mới được chỉnh sửa Section 2 lúc đang duyệt.");
return pe;
}
throw new ConflictException(
"Phiếu chưa pin workflow V2 hoặc chưa init Bước/Cấp — không thể chỉnh sửa.");
}
throw new ConflictException(
$"Phiếu PE ở Phase={pe.Phase}, không thể chỉnh sửa Section 2. " +
"Phải Trả lại Drafter sửa.");
}
} }
// ========== Detail (hạng mục + ngân sách) ========== // ========== Detail (hạng mục + ngân sách) ==========
@ -55,7 +128,8 @@ public class AddPurchaseEvaluationDetailCommandHandler(
{ {
public async Task<Guid> Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct) public async Task<Guid> Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct)
{ {
var evaluation = await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
db, request.PurchaseEvaluationId, currentUser, ct);
var maxOrder = await db.PurchaseEvaluationDetails var maxOrder = await db.PurchaseEvaluationDetails
.Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId) .Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId)
@ -110,11 +184,13 @@ public record UpdatePurchaseEvaluationDetailCommand(
string? GhiChu) : IRequest; string? GhiChu) : IRequest;
public class UpdatePurchaseEvaluationDetailCommandHandler( public class UpdatePurchaseEvaluationDetailCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpdatePurchaseEvaluationDetailCommand> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationDetailCommand>
{ {
public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct) public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct)
{ {
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
db, request.PurchaseEvaluationId, currentUser, ct);
var entity = await db.PurchaseEvaluationDetails var entity = await db.PurchaseEvaluationDetails
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) .FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId); ?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
@ -130,6 +206,21 @@ public class UpdatePurchaseEvaluationDetailCommandHandler(
entity.ThanhTienNganSach = request.ThanhTienNganSach; entity.ThanhTienNganSach = request.ThanhTienNganSach;
entity.GhiChu = request.GhiChu; entity.GhiChu = request.GhiChu;
// F3 audit (Mig 28) — log Approver edit Section 2. Drafter edit cũng log
// để audit trail consistent. Phase ChoDuyet → flag "Approver" trong summary.
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
? " [Approver edit khi đang duyệt]" : string.Empty;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
EntityType = PurchaseEvaluationEntityType.Detail,
EntityId = entity.Id,
Action = ChangelogAction.Update,
PhaseAtChange = evaluation.Phase,
UserId = currentUser.UserId,
Summary = $"Cập nhật hạng mục {request.GroupCode} — {request.NoiDung}{approverNote}",
});
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }
@ -137,15 +228,30 @@ public class UpdatePurchaseEvaluationDetailCommandHandler(
public record DeletePurchaseEvaluationDetailCommand(Guid PurchaseEvaluationId, Guid DetailId) : IRequest; public record DeletePurchaseEvaluationDetailCommand(Guid PurchaseEvaluationId, Guid DetailId) : IRequest;
public class DeletePurchaseEvaluationDetailCommandHandler( public class DeletePurchaseEvaluationDetailCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationDetailCommand> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationDetailCommand>
{ {
public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct) public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct)
{ {
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
db, request.PurchaseEvaluationId, currentUser, ct);
var entity = await db.PurchaseEvaluationDetails var entity = await db.PurchaseEvaluationDetails
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) .FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId); ?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
? " [Approver edit khi đang duyệt]" : string.Empty;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
EntityType = PurchaseEvaluationEntityType.Detail,
EntityId = entity.Id,
Action = ChangelogAction.Delete,
PhaseAtChange = evaluation.Phase,
UserId = currentUser.UserId,
Summary = $"Xóa hạng mục {entity.GroupCode} — {entity.NoiDung}{approverNote}",
});
db.PurchaseEvaluationDetails.Remove(entity); db.PurchaseEvaluationDetails.Remove(entity);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
@ -164,11 +270,13 @@ public record UpsertPurchaseEvaluationQuoteCommand(
string? Note) : IRequest<Guid>; string? Note) : IRequest<Guid>;
public class UpsertPurchaseEvaluationQuoteCommandHandler( public class UpsertPurchaseEvaluationQuoteCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpsertPurchaseEvaluationQuoteCommand, Guid> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpsertPurchaseEvaluationQuoteCommand, Guid>
{ {
public async Task<Guid> Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct) public async Task<Guid> Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct)
{ {
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
db, request.PurchaseEvaluationId, currentUser, ct);
// Verify parents exist + same phiếu // Verify parents exist + same phiếu
var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync( var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync(
d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
@ -182,6 +290,9 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
q => q.PurchaseEvaluationDetailId == request.PurchaseEvaluationDetailId q => q.PurchaseEvaluationDetailId == request.PurchaseEvaluationDetailId
&& q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId, ct); && q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId, ct);
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
? " [Approver edit khi đang duyệt]" : string.Empty;
if (existing is not null) if (existing is not null)
{ {
existing.BgVat = request.BgVat; existing.BgVat = request.BgVat;
@ -189,6 +300,16 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
existing.ThanhTien = request.ThanhTien; existing.ThanhTien = request.ThanhTien;
existing.IsSelected = request.IsSelected; existing.IsSelected = request.IsSelected;
existing.Note = request.Note; existing.Note = request.Note;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
EntityType = PurchaseEvaluationEntityType.Quote,
EntityId = existing.Id,
Action = ChangelogAction.Update,
PhaseAtChange = evaluation.Phase,
UserId = currentUser.UserId,
Summary = $"Cập nhật báo giá cho hạng mục {detail.GroupCode}{approverNote}",
});
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return existing.Id; return existing.Id;
} }
@ -204,6 +325,16 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
Note = request.Note, Note = request.Note,
}; };
db.PurchaseEvaluationQuotes.Add(entity); db.PurchaseEvaluationQuotes.Add(entity);
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
EntityType = PurchaseEvaluationEntityType.Quote,
EntityId = entity.Id,
Action = ChangelogAction.Insert,
PhaseAtChange = evaluation.Phase,
UserId = currentUser.UserId,
Summary = $"Thêm báo giá cho hạng mục {detail.GroupCode}{approverNote}",
});
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return entity.Id; return entity.Id;
} }
@ -212,11 +343,13 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
public record DeletePurchaseEvaluationQuoteCommand(Guid PurchaseEvaluationId, Guid QuoteId) : IRequest; public record DeletePurchaseEvaluationQuoteCommand(Guid PurchaseEvaluationId, Guid QuoteId) : IRequest;
public class DeletePurchaseEvaluationQuoteCommandHandler( public class DeletePurchaseEvaluationQuoteCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationQuoteCommand> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationQuoteCommand>
{ {
public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct) public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct)
{ {
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
db, request.PurchaseEvaluationId, currentUser, ct);
var quote = await ( var quote = await (
from q in db.PurchaseEvaluationQuotes from q in db.PurchaseEvaluationQuotes
join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id
@ -224,6 +357,19 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
select q).FirstOrDefaultAsync(ct) select q).FirstOrDefaultAsync(ct)
?? throw new NotFoundException("PurchaseEvaluationQuote", request.QuoteId); ?? throw new NotFoundException("PurchaseEvaluationQuote", request.QuoteId);
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
? " [Approver edit khi đang duyệt]" : string.Empty;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
EntityType = PurchaseEvaluationEntityType.Quote,
EntityId = quote.Id,
Action = ChangelogAction.Delete,
PhaseAtChange = evaluation.Phase,
UserId = currentUser.UserId,
Summary = $"Xóa báo giá{approverNote}",
});
db.PurchaseEvaluationQuotes.Remove(quote); db.PurchaseEvaluationQuotes.Remove(quote);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }

View File

@ -244,7 +244,12 @@ public record TransitionPurchaseEvaluationCommand(
Guid Id, Guid Id,
PurchaseEvaluationPhase TargetPhase, PurchaseEvaluationPhase TargetPhase,
ApprovalDecision Decision, ApprovalDecision Decision,
string? Comment) : IRequest; string? Comment,
// Mig 28 (S21 t4) — F1 mode Trả lại (optional, null = default Drafter)
WorkflowReturnMode? ReturnMode = null,
Guid? ReturnTargetUserId = null,
// F2 — Drafter skip thẳng Cấp cuối khi trình duyệt (optional, default false)
bool SkipToFinal = false) : IRequest;
public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<TransitionPurchaseEvaluationCommand> public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<TransitionPurchaseEvaluationCommand>
{ {
@ -254,6 +259,11 @@ public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<Tr
RuleFor(x => x.TargetPhase).IsInEnum(); RuleFor(x => x.TargetPhase).IsInEnum();
RuleFor(x => x.Decision).IsInEnum(); RuleFor(x => x.Decision).IsInEnum();
RuleFor(x => x.Comment).MaximumLength(1000); RuleFor(x => x.Comment).MaximumLength(1000);
RuleFor(x => x.ReturnMode!.Value).IsInEnum().When(x => x.ReturnMode.HasValue);
// Assignee mode → returnTargetUserId required
RuleFor(x => x.ReturnTargetUserId).NotEmpty()
.When(x => x.ReturnMode == WorkflowReturnMode.Assignee)
.WithMessage("ReturnTargetUserId yêu cầu khi mode=Assignee.");
} }
} }
@ -277,6 +287,9 @@ public class TransitionPurchaseEvaluationCommandHandler(
currentUser.Roles, currentUser.Roles,
request.Decision, request.Decision,
request.Comment, request.Comment,
request.ReturnMode,
request.ReturnTargetUserId,
request.SkipToFinal,
ct); ct);
} }
} }
@ -549,6 +562,7 @@ public class GetPurchaseEvaluationQueryHandler(
// Bước/Cấp tree với Status) cho FE render flow vertical thay phase cards. // Bước/Cấp tree với Status) cho FE render flow vertical thay phase cards.
string? awCode = null, awName = null; string? awCode = null, awName = null;
int? awVersion = null; int? awVersion = null;
ApprovalWorkflowOptionsDto? awOptions = null;
PurchaseEvaluationCurrentApprovalDto? currentApproval = null; PurchaseEvaluationCurrentApprovalDto? currentApproval = null;
PurchaseEvaluationApprovalFlowDto? approvalFlow = null; PurchaseEvaluationApprovalFlowDto? approvalFlow = null;
if (e.ApprovalWorkflowId is Guid awId) if (e.ApprovalWorkflowId is Guid awId)
@ -562,6 +576,14 @@ public class GetPurchaseEvaluationQueryHandler(
awCode = aw.Code; awCode = aw.Code;
awName = aw.Name; awName = aw.Name;
awVersion = aw.Version; awVersion = aw.Version;
// Mig 28 — 6 Allow* options pin lúc PE create
awOptions = new ApprovalWorkflowOptionsDto(
aw.AllowReturnOneLevel,
aw.AllowReturnOneStep,
aw.AllowReturnToAssignee,
aw.AllowReturnToDrafter,
aw.AllowDrafterSkipToFinal,
aw.AllowApproverEditDetails);
var steps = aw.Steps.OrderBy(s => s.Order).ToList(); var steps = aw.Steps.OrderBy(s => s.Order).ToList();
// Resolve dept names cho Steps // Resolve dept names cho Steps
@ -681,7 +703,7 @@ public class GetPurchaseEvaluationQueryHandler(
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt, e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.BudgetId, budgetSummary, e.BudgetId, budgetSummary,
e.BudgetManualName, e.BudgetManualAmount, e.BudgetManualName, e.BudgetManualAmount,
e.ApprovalWorkflowId, awCode, awName, awVersion, e.ApprovalWorkflowId, awCode, awName, awVersion, awOptions,
currentApproval, approvalFlow, currentApproval, approvalFlow,
e.Suppliers e.Suppliers
.OrderBy(s => s.Order) .OrderBy(s => s.Order)

View File

@ -41,8 +41,10 @@ public class AddPurchaseEvaluationSupplierCommandHandler(
{ {
public async Task<Guid> Handle(AddPurchaseEvaluationSupplierCommand request, CancellationToken ct) public async Task<Guid> Handle(AddPurchaseEvaluationSupplierCommand request, CancellationToken ct)
{ {
var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct) // Mig 28 (S21 t4 F3) — Section 2 edit guard: Drafter (DangSoanThao/TraLai)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); // OR Approver (ChoDuyet + workflow.AllowApproverEditDetails + actor match).
var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
db, request.PurchaseEvaluationId, currentUser, ct);
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct) _ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct)
?? throw new NotFoundException("Supplier", request.SupplierId); ?? throw new NotFoundException("Supplier", request.SupplierId);
@ -97,10 +99,14 @@ public record UpdatePurchaseEvaluationSupplierCommand(
string? Note) : IRequest; string? Note) : IRequest;
public class UpdatePurchaseEvaluationSupplierCommandHandler( public class UpdatePurchaseEvaluationSupplierCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpdatePurchaseEvaluationSupplierCommand> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationSupplierCommand>
{ {
public async Task Handle(UpdatePurchaseEvaluationSupplierCommand request, CancellationToken ct) public async Task Handle(UpdatePurchaseEvaluationSupplierCommand request, CancellationToken ct)
{ {
// Mig 28 (S21 t4 F3) — Section 2 edit guard.
var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
db, request.PurchaseEvaluationId, currentUser, ct);
var row = await db.PurchaseEvaluationSuppliers var row = await db.PurchaseEvaluationSuppliers
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) .FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId); ?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId);
@ -112,6 +118,19 @@ public class UpdatePurchaseEvaluationSupplierCommandHandler(
row.PaymentTermText = request.PaymentTermText; row.PaymentTermText = request.PaymentTermText;
row.Note = request.Note; row.Note = request.Note;
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
? " [Approver edit khi đang duyệt]" : string.Empty;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
EntityType = PurchaseEvaluationEntityType.Supplier,
EntityId = row.Id,
Action = ChangelogAction.Update,
PhaseAtChange = evaluation.Phase,
UserId = currentUser.UserId,
Summary = $"Cập nhật NCC {request.DisplayName ?? "#" + row.SupplierId.ToString()[..8]}{approverNote}",
});
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }
@ -119,10 +138,14 @@ public class UpdatePurchaseEvaluationSupplierCommandHandler(
public record RemovePurchaseEvaluationSupplierCommand(Guid PurchaseEvaluationId, Guid SupplierRowId) : IRequest; public record RemovePurchaseEvaluationSupplierCommand(Guid PurchaseEvaluationId, Guid SupplierRowId) : IRequest;
public class RemovePurchaseEvaluationSupplierCommandHandler( public class RemovePurchaseEvaluationSupplierCommandHandler(
IApplicationDbContext db) : IRequestHandler<RemovePurchaseEvaluationSupplierCommand> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<RemovePurchaseEvaluationSupplierCommand>
{ {
public async Task Handle(RemovePurchaseEvaluationSupplierCommand request, CancellationToken ct) public async Task Handle(RemovePurchaseEvaluationSupplierCommand request, CancellationToken ct)
{ {
// Mig 28 (S21 t4 F3) — Section 2 edit guard.
var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
db, request.PurchaseEvaluationId, currentUser, ct);
var row = await db.PurchaseEvaluationSuppliers var row = await db.PurchaseEvaluationSuppliers
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) .FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId); ?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId);
@ -131,6 +154,19 @@ public class RemovePurchaseEvaluationSupplierCommandHandler(
var hasQuotes = await db.PurchaseEvaluationQuotes.AnyAsync(q => q.PurchaseEvaluationSupplierId == row.Id, ct); var hasQuotes = await db.PurchaseEvaluationQuotes.AnyAsync(q => q.PurchaseEvaluationSupplierId == row.Id, ct);
if (hasQuotes) throw new ConflictException("Không thể xóa NCC khi còn báo giá. Xóa báo giá trước."); if (hasQuotes) throw new ConflictException("Không thể xóa NCC khi còn báo giá. Xóa báo giá trước.");
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
? " [Approver edit khi đang duyệt]" : string.Empty;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
EntityType = PurchaseEvaluationEntityType.Supplier,
EntityId = row.Id,
Action = ChangelogAction.Delete,
PhaseAtChange = evaluation.Phase,
UserId = currentUser.UserId,
Summary = $"Xóa NCC {row.DisplayName ?? "#" + row.SupplierId.ToString()[..8]}{approverNote}",
});
db.PurchaseEvaluationSuppliers.Remove(row); db.PurchaseEvaluationSuppliers.Remove(row);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }

View File

@ -7,6 +7,14 @@ public interface IPurchaseEvaluationWorkflowService
{ {
// Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ. // Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
// Tự tạo PurchaseEvaluationApproval + update Phase + SlaDeadline. // Tự tạo PurchaseEvaluationApproval + update Phase + SlaDeadline.
//
// Optional params Mig 28 (S21 t4 — F1+F2 advanced workflow options):
// - returnMode: mode Trả lại (F1). Null = default Drafter behavior khi Reject+TraLai.
// OneLevel/OneStep/Assignee → giữ Phase=ChoDuyet, lùi pointer (peer review).
// Drafter → Phase=TraLai clear pointer như S17.
// - returnTargetUserId: required khi returnMode=Assignee — pick từ list NV đã duyệt.
// - skipToFinal: F2 Drafter trình duyệt → skip mọi Bước/Cấp trung gian, set pointer
// = max Step + max Level. Workflow phải AllowDrafterSkipToFinal=true.
Task TransitionAsync( Task TransitionAsync(
PurchaseEvaluation evaluation, PurchaseEvaluation evaluation,
PurchaseEvaluationPhase targetPhase, PurchaseEvaluationPhase targetPhase,
@ -14,11 +22,23 @@ public interface IPurchaseEvaluationWorkflowService
IReadOnlyList<string> actorRoles, IReadOnlyList<string> actorRoles,
ApprovalDecision decision, ApprovalDecision decision,
string? comment, string? comment,
WorkflowReturnMode? returnMode = null,
Guid? returnTargetUserId = null,
bool skipToFinal = false,
CancellationToken ct = default); CancellationToken ct = default);
TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase); TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase);
} }
/// Mig 28 (S21 t4) — F1 mode Trả lại. Mapping với ApprovalWorkflow.Allow* flag.
public enum WorkflowReturnMode
{
OneLevel = 1, // Lùi 1 Cấp trong cùng Step (peer review)
OneStep = 2, // Lùi sang Bước trước, level = max của bước đó
Assignee = 3, // Pick runtime từ list NV đã duyệt
Drafter = 4, // Trả về Drafter, Phase=TraLai clear pointer (S17 default fallback)
}
// Atomic sequence generator cho mã PE (MaPhieu) — mirror IContractCodeGenerator. // Atomic sequence generator cho mã PE (MaPhieu) — mirror IContractCodeGenerator.
// Format: PE/{YYYY}/{TypeLetter}/{Seq:D3} // Format: PE/{YYYY}/{TypeLetter}/{Seq:D3}
// - YYYY = năm hiện tại (UTC) // - YYYY = năm hiện tại (UTC)

View File

@ -34,6 +34,46 @@ public class ApprovalWorkflow : BaseEntity
// khi tạo version mới (mirror IsActive default), admin có thể unstick. // khi tạo version mới (mirror IsActive default), admin có thể unstick.
public bool IsUserSelectable { get; set; } public bool IsUserSelectable { get; set; }
// ===== Mig 28 (Session 21 turn 4) — 6 advanced options per workflow =====
// Cấu hình "Cấu hình nâng cao" trong Admin Designer. User eOffice render
// dropdown/checkbox theo flag enabled. 4 flag Return* = mode Trả lại (F1).
// 1 flag Skip = Drafter trình thẳng Cấp cuối (F2). 1 flag EditDetails =
// Approver chỉnh Section 2 (F3).
//
// Default backward compat S17: AllowReturnToDrafter=true (mọi workflow cũ
// chạy đúng — fallback "Trả về Drafter" như Session 17 spec). 5 flag còn
// lại default false — admin opt-in per workflow để audit nghiêm.
/// F1 mode 1 — Cho phép Approver Trả lại 1 Cấp trước (lùi pointer trong
/// cùng Step). Phiếu GIỮ Phase=ChoDuyet (peer review chain).
public bool AllowReturnOneLevel { get; set; }
/// F1 mode 2 — Cho phép Approver Trả lại 1 Bước trước (lùi sang Step trước,
/// set level = max của step đó). Phiếu GIỮ Phase=ChoDuyet.
public bool AllowReturnOneStep { get; set; }
/// F1 mode 3 — Cho phép Approver Trả lại Người chỉ định (pick runtime từ
/// list NV ĐÃ DUYỆT trong PeLevelOpinions). Phiếu GIỮ Phase=ChoDuyet, set
/// Step/Level = vị trí của user pick trong workflow.
public bool AllowReturnToAssignee { get; set; }
/// F1 mode 4 — Cho phép Approver Trả lại Người soạn thảo (Drafter). Phiếu
/// đi vào Phase=TraLai, clear pointer (như Session 17 spec). Default TRUE
/// để backward compat — admin có thể unstick force peer review only.
public bool AllowReturnToDrafter { get; set; } = true;
/// F2 — Cho phép Drafter gửi thẳng Cấp cuối (skip mọi Bước/Cấp trung gian).
/// UI eOffice trình duyệt thêm dropdown 2 option ("Gửi tuần tự" default vs
/// "Gửi thẳng Cấp cuối"). BE set CurrentWorkflowStepIndex=maxStep,
/// CurrentApprovalLevelOrder=maxLevel. Audit changelog "Drafter skip C1..N".
public bool AllowDrafterSkipToFinal { get; set; }
/// F3 — Cho phép Approver chỉnh sửa Section 2 (Hạng mục + NCC + Báo giá)
/// khi phase=ChoDuyet + actor match CurrentLevel.ApproverUserId. KHÔNG đụng
/// PE Header (TenGoiThau/Project/Budget). KHÔNG reset workflow. Audit ghi
/// PurchaseEvaluationChangelog cho mỗi field/row thay đổi.
public bool AllowApproverEditDetails { get; set; }
public List<ApprovalWorkflowStep> Steps { get; set; } = new(); public List<ApprovalWorkflowStep> Steps { get; set; } = new();
} }

View File

@ -18,6 +18,15 @@ public class ApprovalWorkflowConfiguration : IEntityTypeConfiguration<ApprovalWo
e.HasIndex(x => new { x.Code, x.Version }).IsUnique(); e.HasIndex(x => new { x.Code, x.Version }).IsUnique();
e.HasIndex(x => new { x.ApplicableType, x.IsActive }); e.HasIndex(x => new { x.ApplicableType, x.IsActive });
// Mig 28 — 6 advanced options. 5 default false (admin opt-in). 1
// AllowReturnToDrafter default true (backward compat S17 fallback).
e.Property(x => x.AllowReturnOneLevel).HasDefaultValue(false);
e.Property(x => x.AllowReturnOneStep).HasDefaultValue(false);
e.Property(x => x.AllowReturnToAssignee).HasDefaultValue(false);
e.Property(x => x.AllowReturnToDrafter).HasDefaultValue(true);
e.Property(x => x.AllowDrafterSkipToFinal).HasDefaultValue(false);
e.Property(x => x.AllowApproverEditDetails).HasDefaultValue(false);
} }
} }

View File

@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAdvancedOptionsToApprovalWorkflows : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowApproverEditDetails",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowDrafterSkipToFinal",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnOneLevel",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnOneStep",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnToAssignee",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnToDrafter",
table: "ApprovalWorkflows",
type: "bit",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowApproverEditDetails",
table: "ApprovalWorkflows");
migrationBuilder.DropColumn(
name: "AllowDrafterSkipToFinal",
table: "ApprovalWorkflows");
migrationBuilder.DropColumn(
name: "AllowReturnOneLevel",
table: "ApprovalWorkflows");
migrationBuilder.DropColumn(
name: "AllowReturnOneStep",
table: "ApprovalWorkflows");
migrationBuilder.DropColumn(
name: "AllowReturnToAssignee",
table: "ApprovalWorkflows");
migrationBuilder.DropColumn(
name: "AllowReturnToDrafter",
table: "ApprovalWorkflows");
}
}
}

View File

@ -134,6 +134,36 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<DateTime?>("ActivatedAt") b.Property<DateTime?>("ActivatedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<bool>("AllowApproverEditDetails")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowDrafterSkipToFinal")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnOneLevel")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnOneStep")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnToAssignee")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowReturnToDrafter")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<int>("ApplicableType") b.Property<int>("ApplicableType")
.HasColumnType("int"); .HasColumnType("int");

View File

@ -41,29 +41,55 @@ public class PurchaseEvaluationWorkflowService(
IReadOnlyList<string> actorRoles, IReadOnlyList<string> actorRoles,
ApprovalDecision decision, ApprovalDecision decision,
string? comment, string? comment,
WorkflowReturnMode? returnMode = null,
Guid? returnTargetUserId = null,
bool skipToFinal = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var fromPhase = evaluation.Phase; var fromPhase = evaluation.Phase;
var isAdmin = actorRoles.Contains(AppRoles.Admin); var isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove; var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
// ===== REJECT BRANCH ===== // ===== GUARD: targetPhase TraLai/TuChoi BẮT BUỘC decision=Reject =====
// Defense-in-depth chặn FE inconsistency (gotcha #45 — Session 21 turn 3):
// Bug: button "← Trả lại" trong PeWorkflowPanel.tsx gửi decision=Approve
// khi target=TraLai do `isReject` local var thiếu nhánh TraLai. BE nhận
// payload sẽ skip Reject branch → enter APPROVE STEP → ApproveV2Async
// UPSERT opinion = "đã duyệt" + advance Cấp. User UAT thấy: "Trả về
// nhưng hệ thống vẫn duyệt".
// FE fix song song trong fe-admin + fe-user (rule §3.9 mirror 2 app).
// Guard này KHÔNG xoá khi FE fix — boundary protection cho mọi caller
// tương lai (API client / mobile app / cron retry).
if ((targetPhase == PurchaseEvaluationPhase.TraLai
|| targetPhase == PurchaseEvaluationPhase.TuChoi)
&& decision != ApprovalDecision.Reject)
{
throw new ConflictException(
$"Transition tới {targetPhase} BẮT BUỘC decision=Reject (nhận {decision}). " +
"Báo lỗi caller — payload mismatch giữa target phase và decision " +
"(xem gotcha #45 + docs/workflow-contract.md).");
}
// ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) =====
if (decision == ApprovalDecision.Reject) if (decision == ApprovalDecision.Reject)
{ {
if (targetPhase == PurchaseEvaluationPhase.TuChoi) if (targetPhase == PurchaseEvaluationPhase.TuChoi)
{ {
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16). // Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
evaluation.Phase = PurchaseEvaluationPhase.TuChoi; evaluation.Phase = PurchaseEvaluationPhase.TuChoi;
evaluation.SlaDeadline = null;
} }
else else
{ {
// Trả lại — Phase=TraLai RIÊNG (không revert về DangSoanThao). // F1 (S21 t4) — 4 mode Trả lại theo workflow.Allow* flag.
// Drafter sửa từ TraLai rồi gửi lại sẽ chạy lại từ Cấp 1 Bước 1. // Default fallback (returnMode=null) = Drafter mode = S17 behavior.
evaluation.Phase = PurchaseEvaluationPhase.TraLai; var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter;
evaluation.CurrentWorkflowStepIndex = null; var returnSummary = await ApplyReturnModeAsync(
evaluation.CurrentApprovalLevelOrder = null; evaluation, effectiveMode, returnTargetUserId, isAdmin, ct);
comment = string.IsNullOrWhiteSpace(comment)
? returnSummary
: $"{comment} [{returnSummary}]";
} }
evaluation.SlaDeadline = null;
await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct); await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return; return;
@ -84,9 +110,36 @@ public class PurchaseEvaluationWorkflowService(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu."); $"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu.");
} }
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet; evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
evaluation.CurrentWorkflowStepIndex = 0;
// Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set). // F2 (Mig 28 — S21 t4) — Drafter skip thẳng Cấp cuối. Workflow phải
evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null; // AllowDrafterSkipToFinal=true. Set pointer = max Step + max Level.
// Audit changelog ghi rõ "Drafter skip" để approver Cấp cuối biết.
if (skipToFinal && evaluation.ApprovalWorkflowId is Guid skipAwId)
{
var wfSkip = await db.ApprovalWorkflows
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == skipAwId, ct)
?? throw new ConflictException("Workflow không tồn tại.");
if (!wfSkip.AllowDrafterSkipToFinal)
throw new ConflictException(
"Workflow không bật mode 'Gửi thẳng Cấp cuối'. " +
"Liên hệ Admin để config Designer.");
var finalStep = wfSkip.Steps.OrderBy(s => s.Order).LastOrDefault()
?? throw new ConflictException("Workflow chưa có Bước nào.");
var finalLevelOrder = finalStep.Levels.OrderBy(l => l.Order).LastOrDefault()?.Order
?? throw new ConflictException($"Bước {finalStep.Order} chưa có Cấp nào.");
evaluation.CurrentWorkflowStepIndex = wfSkip.Steps.Count - 1; // 0-based last step
evaluation.CurrentApprovalLevelOrder = finalLevelOrder;
comment = string.IsNullOrWhiteSpace(comment)
? "[Drafter gửi thẳng Cấp cuối — skip Bước/Cấp trung gian]"
: $"{comment} [Drafter gửi thẳng Cấp cuối — skip Bước/Cấp trung gian]";
}
else
{
evaluation.CurrentWorkflowStepIndex = 0;
// Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set).
evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null;
}
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7); evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct); await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@ -124,6 +177,154 @@ public class PurchaseEvaluationWorkflowService(
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ."); throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
} }
// ===== F1 (Mig 28 — S21 t4) — Apply Return Mode =====
// Switch theo effectiveMode → set Phase + pointer. 3 mode đầu giữ ChoDuyet
// (peer review chain). Mode Drafter set Phase=TraLai như S17.
// Validate workflow.Allow* flag match mode → throw nếu disabled.
// Return summary text để chèn vào comment changelog (audit trail).
private async Task<string> ApplyReturnModeAsync(
PurchaseEvaluation evaluation,
WorkflowReturnMode mode,
Guid? returnTargetUserId,
bool isAdmin,
CancellationToken ct)
{
// Mode Drafter — Session 17 default (always allowed for backward compat,
// workflow.AllowReturnToDrafter default true).
if (mode == WorkflowReturnMode.Drafter)
{
// Validate workflow flag (admin có thể disable mode này force peer review)
if (evaluation.ApprovalWorkflowId is Guid awId0 && !isAdmin)
{
var wf0 = await db.ApprovalWorkflows.FirstOrDefaultAsync(w => w.Id == awId0, ct);
if (wf0 is not null && !wf0.AllowReturnToDrafter)
throw new ConflictException(
"Workflow không bật mode 'Trả về Drafter'. Phải dùng mode khác.");
}
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
return "Trả về Người soạn thảo";
}
// 3 mode còn lại (OneLevel / OneStep / Assignee) — yêu cầu V2 schema +
// pointer hợp lệ.
if (evaluation.ApprovalWorkflowId is not Guid awId)
throw new ConflictException(
$"Mode '{mode}' yêu cầu phiếu pin V2 workflow (ApprovalWorkflowId).");
if (evaluation.CurrentWorkflowStepIndex is not int curStepIdx
|| evaluation.CurrentApprovalLevelOrder is not int curLevel)
throw new ConflictException(
$"Mode '{mode}' yêu cầu phiếu đang ChoDuyet + pointer init. " +
$"State hiện tại: Step={evaluation.CurrentWorkflowStepIndex}, Level={evaluation.CurrentApprovalLevelOrder}.");
var workflow = await db.ApprovalWorkflows
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == awId, ct)
?? throw new ConflictException("Workflow không tồn tại.");
// Validate Allow* flag (Admin bypass — admin có thể trả lại bất chấp config)
if (!isAdmin)
{
var allowed = mode switch
{
WorkflowReturnMode.OneLevel => workflow.AllowReturnOneLevel,
WorkflowReturnMode.OneStep => workflow.AllowReturnOneStep,
WorkflowReturnMode.Assignee => workflow.AllowReturnToAssignee,
_ => false,
};
if (!allowed)
throw new ConflictException(
$"Workflow không bật mode '{mode}'. Liên hệ Admin Designer để config.");
}
var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
var summary = string.Empty;
switch (mode)
{
case WorkflowReturnMode.OneLevel:
// Lùi 1 Cấp trong cùng Step. Nếu đang Cấp 1 → lùi sang Bước trước
// Cấp cuối. Nếu đang Bước 1 Cấp 1 → fallback Drafter (no further).
if (curLevel > 1)
{
evaluation.CurrentApprovalLevelOrder = curLevel - 1;
summary = $"Trả về Cấp {curLevel - 1} (cùng Bước {stepsOrdered[curStepIdx].Order})";
}
else if (curStepIdx > 0)
{
var prevStep = stepsOrdered[curStepIdx - 1];
var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order;
evaluation.CurrentWorkflowStepIndex = curStepIdx - 1;
evaluation.CurrentApprovalLevelOrder = prevMaxLevel;
summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel} (Bước trước)";
}
else
{
// Bước 1 Cấp 1 — no further back. Fallback Drafter.
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
return "Trả về Người soạn thảo (fallback — đang Bước 1 Cấp 1)";
}
break;
case WorkflowReturnMode.OneStep:
// Lùi sang Bước trước, set Level = max của Bước đó.
if (curStepIdx > 0)
{
var prevStep = stepsOrdered[curStepIdx - 1];
var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order;
evaluation.CurrentWorkflowStepIndex = curStepIdx - 1;
evaluation.CurrentApprovalLevelOrder = prevMaxLevel;
summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel}";
}
else
{
// Đang Bước 1 → fallback Drafter
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
return "Trả về Người soạn thảo (fallback — đang Bước đầu)";
}
break;
case WorkflowReturnMode.Assignee:
if (returnTargetUserId is not Guid targetUid)
throw new ConflictException("returnTargetUserId yêu cầu khi mode=Assignee.");
var foundStepIdx = -1;
int foundLevel = -1;
string? foundStepName = null;
for (int si = 0; si < stepsOrdered.Count; si++)
{
var match = stepsOrdered[si].Levels
.FirstOrDefault(l => l.ApproverUserId == targetUid);
if (match is not null)
{
foundStepIdx = si;
foundLevel = match.Order;
foundStepName = stepsOrdered[si].Name;
break;
}
}
if (foundStepIdx < 0)
throw new ConflictException(
"Không tìm thấy người chỉ định trong workflow. " +
"Chỉ pick từ list NV đã duyệt trước đó (PeLevelOpinions).");
evaluation.CurrentWorkflowStepIndex = foundStepIdx;
evaluation.CurrentApprovalLevelOrder = foundLevel;
summary = $"Trả về Người chỉ định — Bước {stepsOrdered[foundStepIdx].Order} ({foundStepName}) Cấp {foundLevel}";
break;
}
// 3 mode trên đều giữ Phase=ChoDuyet — reset SLA cho approver mới.
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
return summary;
}
// ===== V2 schema (Mig 22-24) — iterate ApprovalWorkflowSteps + Levels ===== // ===== V2 schema (Mig 22-24) — iterate ApprovalWorkflowSteps + Levels =====
private async Task ApproveV2Async( private async Task ApproveV2Async(
PurchaseEvaluation evaluation, PurchaseEvaluation evaluation,

View File

@ -0,0 +1,176 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
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;
// Regression test for Session 21 turn 3 bug — gotcha #45:
// FE button "← Trả lại" trong PeWorkflowPanel gửi `decision: 1` (Approve) thay
// vì `2` (Reject) khi target = TraLai (98). Root: `isReject` local variable
// trong FE thiếu nhánh TraLai → payload mismatch giữa button label hiển thị
// "Trả lại" và decision gửi BE.
//
// Hiệu ứng cũ trước fix: BE TransitionAsync nhận decision=Approve → skip Reject
// branch (L51) → enter APPROVE STEP branch (L97) → ApproveV2Async UPSERT
// opinion đánh dấu "đã duyệt" cho NV đang nhấn nút → tiến qua Cấp tiếp theo.
// User UAT thấy: "Trả về nhưng hệ thống vẫn duyệt".
//
// Fix BE defense-in-depth: guard early throw ConflictException khi targetPhase
// ∈ {TraLai, TuChoi} mà decision != Reject — chặn FE inconsistency tại
// boundary BE thay vì depend FE đúng.
//
// FE fix song song trong fe-admin + fe-user PeWorkflowPanel.tsx (rule §3.9
// mirror 2 app) — sync `isReject` + dialog `isSendBack` include TraLai.
public class PurchaseEvaluationWorkflowServiceGuardTests
{
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db)
CreateService()
{
var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var dt = new FixedDateTime(new DateTime(2026, 5, 12, 0, 0, 0, DateTimeKind.Utc));
var notify = new NoOpNotificationService();
var svc = new PurchaseEvaluationWorkflowService(db, dt, notify, um);
return (svc, fix, db);
}
private static PurchaseEvaluation BuildPeInChoDuyet(string code = "PE-GUARD-001")
{
return new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet,
MaPhieu = code,
TenGoiThau = "Test guard bug Trả lại",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
CurrentApprovalLevelOrder = 1,
CurrentWorkflowStepIndex = 0,
};
}
[Fact]
public async Task TransitionAsync_TargetTraLai_WithApproveDecision_Throws_AndDoesNotMutateState()
{
// Arrange: phiếu PE ở ChoDuyet (typical intermediate state khi approver duyệt)
var (svc, fix, db) = CreateService();
using (fix)
{
var pe = BuildPeInChoDuyet();
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
// Act: simulate FE bug payload — button "← Trả lại" gửi decision=Approve
// thay vì Reject (gotcha #45 root cause).
var act = async () => await svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.TraLai,
actorUserId: Guid.NewGuid(),
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Approve,
comment: "test guard mismatch",
ct: CancellationToken.None);
// Assert: BE chặn payload mismatch sớm + state phiếu KHÔNG đổi
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*TraLai*Reject*");
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"Guard chặn trước khi mutate phase");
pe.CurrentApprovalLevelOrder.Should().Be(1,
"Guard chặn trước khi advance level pointer");
}
}
[Fact]
public async Task TransitionAsync_TargetTuChoi_WithApproveDecision_Throws_AndDoesNotMutateState()
{
// Tương tự TraLai — TuChoi cũng BẮT BUỘC decision=Reject. Defense
// double-cover invariant (FE chỉ bug TraLai branch nhưng guard nên cover
// luôn TuChoi cho consistency).
var (svc, fix, db) = CreateService();
using (fix)
{
var pe = BuildPeInChoDuyet("PE-GUARD-002");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.TuChoi,
actorUserId: Guid.NewGuid(),
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Approve,
comment: "test guard tu choi",
ct: CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*TuChoi*Reject*");
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
}
}
[Fact]
public async Task TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai()
{
// Happy path control test: decision=Reject + target=TraLai → BE đi vào
// Reject branch (L51), set Phase=TraLai, clear pointer. Verify fix
// KHÔNG break flow Trả lại đúng.
var (svc, fix, db) = CreateService();
using (fix)
{
var pe = BuildPeInChoDuyet("PE-GUARD-003");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
await svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.TraLai,
actorUserId: Guid.NewGuid(),
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Reject,
comment: "trả lại sửa lại đi",
ct: CancellationToken.None);
pe.Phase.Should().Be(PurchaseEvaluationPhase.TraLai,
"Reject branch set Phase=TraLai");
pe.CurrentApprovalLevelOrder.Should().BeNull("Trả lại clear level pointer");
pe.CurrentWorkflowStepIndex.Should().BeNull("Trả lại clear step pointer");
pe.SlaDeadline.Should().BeNull("Trả lại clear SLA");
}
}
}
// Stub: not assert side effects of notify (out-of-scope cho guard test).
// Pattern reuse cho future PE service tests.
internal sealed class NoOpNotificationService : 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;
}