Compare commits

...

4 Commits

Author SHA1 Message Date
c0af9e05ec [CLAUDE] Docs: S21 t5 Chunk D — chốt refactor Allow* per-NV (Mig 29)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m18s
Update docs theo rule §6.5 KEEP narrative:

- `docs/database/schema-diagram.md §14` title "Mig 22-29, S17-21":
  - Update ApprovalWorkflows block: note Mig 28 cũ 6 column DROP, refactor per-NV
  - Add 5 column Allow* trong ApprovalWorkflowLevels block (inline comment F1+F3)
  - Add Users block với F2 AllowDrafterSkipToFinal Mig 29

- `docs/STATUS.md` Last updated S21 t5 + count 28→29 mig. UAT defer test count
  unchanged 84.

- `docs/HANDOFF.md` Insert TL;DR S21 t5 đầy đủ (trước S21 t4):
  - Trigger UAT feedback "cấu hình cho từng người"
  - Q&A 2 lượt chốt scope
  - 4 chunk narrative: A BE+Mig 29 + Service refactor → B FE Admin Designer
    per-Level → C FE eOffice rename → D Docs
  - Pattern reusable: EF migration reorder cho BACKFILL preserve data,
    per-NV scope split theo role (Approver Level vs Drafter User)
  - State table + Pending User Mgmt F2 UI defer

- NEW session log `docs/changelog/sessions/2026-05-13-1400-s21-turn5-refactor-allow-to-per-nv.md`:
  - Code snippets BE/FE refactor
  - 5 lessons learned (incl EF reorder pattern + backward compat backfill discipline)
  - References file paths

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

Pending: bro confirm push `eea86fd..HEAD` 4 commits ahead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:12:21 +07:00
5ccb2a7057 [CLAUDE] FE-PE: S21 t5 Chunk C — eOffice read currentLevelOptions + drafterAllowSkipToFinal (per-NV) mirror 2 app
Types refactor `fe-{admin,user}/src/types/purchaseEvaluation.ts`:
- `ApprovalWorkflowOptions` REMOVE allowDrafterSkipToFinal (F2 đã move per-User).
  Còn 5 flag (F1 4 mode + F3 EditDetails).
- `PeDetailBundle`:
  - RENAME `workflowOptions` → `currentLevelOptions` (clearer semantic per-slot)
  - ADD `drafterAllowSkipToFinal: boolean` (BE resolve từ DrafterUserId → User entity)

PeWorkflowPanel.tsx (mirror 2 app):
- RENAME local var `wfOptions` → `levelOptions`
- READ `evaluation.currentLevelOptions` (Cấp hiện tại)
- 4 mode radio render conditional theo levelOptions.allowReturnXxx (unchanged
  logic, just rename source)

PeDetailTabs.tsx (mirror 2 app):
- F3 approverEditMode: READ `evaluation.currentLevelOptions?.allowApproverEditDetails`
  thay vì workflowOptions.allowApproverEditDetails (semantic per-NV slot)
- F2 allowSkipToFinal: READ `evaluation.drafterAllowSkipToFinal` thay vì
  workflowOptions.allowDrafterSkipToFinal (semantic per-Drafter user)

Backward compat verified:
- Phiếu cũ trước Mig 29 vẫn return currentLevelOptions populated (BE backfill
  Mig 29 đã copy 5 Allow* per Level)
- drafterAllowSkipToFinal: BE backfill chỉ TRUE cho user từng Drafter PE link
  workflow.AllowDrafterSkipToFinal=true (preserve admin config S21 t4)
- Phiếu V1 legacy: currentLevelOptions=null → FE fallback chỉ Drafter mode

Verify:
- npm run build × 2 app pass (fe-user 450ms + fe-admin 439ms, cache hot)
- 0 TS6 err, warning chunk size pre-existing

Pending Chunk D: Docs (schema-diagram §14 update + STATUS + HANDOFF + session log).
Note: User Management page chưa có F2 checkbox UX (defer commit sau khi admin
UAT request — BE field đã có, FE chỉ cần thêm 1 toggle vào UserEdit dialog).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:09:31 +07:00
63234b2cce [CLAUDE] FE-Admin: S21 t5 Chunk B — Designer move 5 checkbox xuống per-Level slot
ApprovalWorkflowsV2Page.tsx refactor Designer modal theo Mig 29 per-NV:

Types update:
- `LevelDto` +5 Allow* (mirror BE AwLevelDto)
- `DefinitionDto` REMOVE 6 workflow-level Allow* (no longer used)
- `EditLevelEntry` +5 Allow* (form state per slot entry)
- `makeDefaultLevelEntry(order, userId)` helper — 4 false + AllowReturnToDrafter
  true (S17 backward compat)
- `copyFromDefinition` propagate 5 Allow* từ existing Levels

Form state:
- REMOVE 6 useState workflow-level (allowReturnOneLevel...allowApproverEditDetails)
- POST body remove 6 workflow-level field
- POST body levels[].* propagate 5 Allow* per slot

UI refactor:
- REMOVE entire section "Cấu hình nâng cao" workflow-level (amber bg 6 checkbox)
- REPLACE với info banner violet ngắn "ⓘ Cấu hình quyền duyệt riêng cho từng NV
  ở mỗi Cấp dưới đây. F2 cấu hình ở User Management."
- Mỗi Level entry (NV row) ADD inline panel amber-50/30 5 checkbox grid-cols-2:
  - Trả về 1 Cấp trước
  - Trả về 1 Bước trước
  - Trả về Người chỉ định
  - Trả về Drafter (mặc định checked)
  - Cho phép chỉnh sửa Section 2 (col-span-2, full row)
- Header "Quyền duyệt NV #N" [10px] uppercase amber-700
- `updateField()` helper inline update per entry index

F2 (AllowDrafterSkipToFinal) cần UX riêng ở User Management page (per-Drafter
user global). Defer Chunk B Plus hoặc commit sau khi user UAT request.

Verify:
- npm run build fe-admin pass 498ms cached
- 0 TS6 err, warning chunk size pre-existing

Pending Chunk C: FE eOffice (PeWorkflowPanel + PeDetailTabs) read
`evaluation.currentLevelOptions` + `evaluation.drafterAllowSkipToFinal` thay vì
`workflowOptions`. Mirror 2 app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:07:10 +07:00
036694638e [CLAUDE] PE-Workflow: S21 t5 Chunk A — Mig 29 refactor Allow* sang per-NV (per-Level + per-Drafter)
Refactor 6 Allow* options từ workflow-level (Mig 28 S21 t4) sang per-NV scope:
- F1 (4 mode Trả lại) + F3 (Edit Section 2) → 5 flag MOVE xuống
  `ApprovalWorkflowLevels` (per slot Approver, cùng table với ApproverUserId).
- F2 (AllowDrafterSkipToFinal) → MOVE xuống `Users` (per-Drafter user, User Mgmt).

Mig 29 `RefactorAdvancedOptionsToPerLevelAndDrafterUser` — 4-stage migration
(EF auto-generated drop-then-add đã được REORDER manual):
1. ADD 5 column trên `ApprovalWorkflowLevels` (AllowReturnOneLevel/OneStep/
   ToAssignee/ToDrafter[default true]/AllowApproverEditDetails)
2. ADD 1 column trên `Users` (AllowDrafterSkipToFinal default false)
3. BACKFILL bulk SQL (preserve admin config Mig 28):
   - Levels: copy workflow.Allow* → all Levels của workflow (JOIN Steps)
   - Users: SET TRUE cho user nào từng Drafter PE link workflow Allow=true
4. DROP 6 column workflow-level (Mig 28 cleanup)
3-file rule complete. Apply LocalDB Dev + Design success.

Domain entity refactor:
- `ApprovalWorkflow.cs` — REMOVE 6 Allow* field (S21 t4 Mig 28 cũ)
- `ApprovalWorkflowLevel.cs` — ADD 5 Allow* field (F1 + F3)
- `User.cs` — ADD 1 Allow* field (F2 AllowDrafterSkipToFinal)

EF config update:
- `ApprovalWorkflowConfiguration.cs` — remove 6 HasDefaultValue workflow-level,
  add 5 HasDefaultValue per-Level (4 false + 1 AllowReturnToDrafter true S17)

Service refactor `ApplyReturnModeAsync` (`PurchaseEvaluationWorkflowService.cs`):
- Resolve currentLevel slot (CurrentWorkflowStepIndex + CurrentApprovalLevelOrder)
- Read 5 Allow* từ `currentLevel.AllowXxx` thay vì workflow.Allow*
- Admin bypass per-Level flag check (unchanged behavior)
- Drafter mode đặc biệt: check AllowReturnToDrafter của currentLevel (vẫn validate)
- V1 legacy (no V2 schema) → fallback Drafter behavior tự động

DRAFTER trình refactor (`TransitionAsync` skipToFinal branch):
- Permission check moved from workflow-level → `drafterUser.AllowDrafterSkipToFinal`
- Use `userManager.FindByIdAsync(actorUserId)` để get current Drafter user entity
- Admin bypass user flag check (unchanged)

Helper `EnsureEditableForDetailsAsync` refactor:
- Read `level.AllowApproverEditDetails` thay vì workflow.AllowApproverEditDetails
- Error message rõ "Cấp Approver hiện tại (Bước X / Cấp Y)" thay vì "Workflow"

DTO refactor:
- `AwLevelDto` ADD 5 Allow* field (admin Designer GET per-Level)
- `AwDefinitionDto` REMOVE 6 Allow* (no longer workflow-level)
- `CreateAwLevelInput` ADD 5 Allow* param (admin Designer POST per-Level)
- `CreateAwDefinitionCommand` REMOVE 6 Allow* (Steps[].Levels[] now has them)
- `ApprovalWorkflowOptionsDto` chỉ còn 5 flag (F2 removed — separate field)
- `PurchaseEvaluationDetailBundleDto`:
  - rename `WorkflowOptions` → `CurrentLevelOptions` (clearer semantic per-slot)
  - ADD `DrafterAllowSkipToFinal bool` (resolve từ DrafterUserId → User entity)

GetPurchaseEvaluationQueryHandler populate:
- `currentLevelOptions` = 5 Allow* của Cấp hiện tại (null nếu V1 legacy / no pointer)
- `drafterAllowSkipToFinal` = User.AllowDrafterSkipToFinal lookup từ DrafterUserId

Backward compat verified:
- Mig 29 backfill preserve admin config S21 t4 — workflow cũ vẫn chạy đúng
  sau deploy. User chưa từng làm Drafter F2 phải opt-in lần đầu (no auto-set).
- 84 test PASS (58 Domain + 26 Infra unchanged, 3 gotcha #45 guard test backward
  compat signature).

Pending Chunk B/C: FE Admin Designer move 5 checkbox xuống per-Level slot + FE
eOffice read currentLevelOptions + drafterAllowSkipToFinal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:03:28 +07:00
22 changed files with 4945 additions and 363 deletions

View File

@ -1,10 +1,152 @@
# HANDOFF — Brief 5 phút cho session tiếp theo # HANDOFF — Brief 5 phút cho session tiếp theo
**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.**) **Last updated:** 2026-05-13 1400 (Session 21 turn 5**🎯 Refactor Allow* sang PER-NV (Mig 29). 4 chunk per-commit `0366946` (A BE+Mig 29) → `63234b2` (B FE Admin Designer per-Level 5 checkbox) → `5ccb2a7` (C FE eOffice mirror 2 app rename) → this Chunk D Docs. **F1+F3** 5 flag MOVED xuống `ApprovalWorkflowLevels` (per slot Approver). **F2** MOVED xuống `Users` (per-Drafter). Mig 29 4-stage: ADD 5 Levels + 1 Users + BACKFILL bulk SQL preserve admin config S21 t4 + DROP 6 workflow column. Service refactor đọc `currentLevel.Allow*` + `drafterUser.AllowDrafterSkipToFinal`. DTO `AwLevelDto +5`, `PeDetailBundle.workflowOptions → currentLevelOptions + drafterAllowSkipToFinal`. FE Admin Designer 5 checkbox per Level slot inline (drop section workflow-level). 84 test PASS. CHƯA push remote — chờ bro confirm.**)
**S21 turn 4:** 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 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 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 5 — Refactor Allow* sang PER-NV (Mig 29 drop Mig 28)
User feedback sau UAT S21 t4 deploy: "Cấu hình cho từng người nhé (chứ ko phải là cho toàn bộ quy trình duyệt), thêm table vào SQL luôn để cấu hình cho dễ."
→ Refactor 6 Allow* options từ workflow-level (Mig 28) sang per-NV scope:
- **F1 (4 mode Trả lại) + F3 (Edit Section 2)** = 5 flag MOVE xuống `ApprovalWorkflowLevels` (per slot Approver — cùng table với ApproverUserId).
- **F2 (AllowDrafterSkipToFinal)** MOVE xuống `Users` (per-Drafter user, admin config ở User Management page).
### Q&A clarify (2 lượt)
| Câu | User chốt |
|---|---|
| Scope "từng người" | **Per-Level**: 5 flag (4 F1 + 1 F3) gắn slot Designer. F2 per-Drafter user. |
| Mig 28 cũ xử lý sao | **Migrate bốc → per-NV bulk + drop**: copy workflow → all Levels của workflow, set TRUE cho Drafter user từng dùng workflow F2, rồi drop 6 column workflow. |
### Chunk A — BE schema + Service refactor (`0366946`)
Mig 29 `RefactorAdvancedOptionsToPerLevelAndDrafterUser` — 4-stage (EF
auto-generated drop-then-add đã REORDER manual):
1. **ADD 5 column** trên `ApprovalWorkflowLevels` (AllowReturnOneLevel/OneStep/
ToAssignee/ToDrafter[default true]/AllowApproverEditDetails)
2. **ADD 1 column** trên `Users` (AllowDrafterSkipToFinal default false)
3. **BACKFILL bulk SQL** (preserve admin config Mig 28):
```sql
-- Levels: copy workflow.Allow* → all Levels của workflow
UPDATE l SET l.AllowReturnOneLevel = w.AllowReturnOneLevel, ...
FROM ApprovalWorkflowLevels l
INNER JOIN ApprovalWorkflowSteps s ON s.Id = l.ApprovalWorkflowStepId
INNER JOIN ApprovalWorkflows w ON w.Id = s.ApprovalWorkflowId;
-- Users: SET TRUE cho user từng Drafter PE link workflow Allow=true
UPDATE u SET u.AllowDrafterSkipToFinal = 1
FROM Users u WHERE EXISTS (
SELECT 1 FROM PurchaseEvaluations pe
INNER JOIN ApprovalWorkflows w ON w.Id = pe.ApprovalWorkflowId
WHERE pe.DrafterUserId = u.Id AND w.AllowDrafterSkipToFinal = 1
);
```
4. **DROP 6 column** workflow-level (Mig 28 cleanup)
Domain entity refactor:
- `ApprovalWorkflow.cs` — REMOVE 6 Allow* (S21 t4 Mig 28 cũ)
- `ApprovalWorkflowLevel.cs` — ADD 5 Allow* (F1 + F3)
- `User.cs` — ADD 1 Allow* (F2 AllowDrafterSkipToFinal)
Service refactor `ApplyReturnModeAsync`:
- Resolve currentLevel slot (CurrentWorkflowStepIndex + CurrentApprovalLevelOrder)
- Read 5 Allow* từ `currentLevel.AllowXxx` thay vì workflow
- Drafter mode validate AllowReturnToDrafter của currentLevel
- V1 legacy phiếu → fallback Drafter tự động
DRAFTER trình refactor:
- Permission check moved → `drafterUser.AllowDrafterSkipToFinal`
- `userManager.FindByIdAsync(actorUserId)` get Drafter user entity
- Admin bypass unchanged
Helper `EnsureEditableForDetailsAsync`:
- Read `level.AllowApproverEditDetails` thay vì workflow
- Error message rõ "Cấp Approver hiện tại (Bước X / Cấp Y)"
DTO refactor:
- `AwLevelDto +5 Allow*` (admin Designer GET per-Level)
- `AwDefinitionDto -6 Allow*` (no longer workflow-level)
- `CreateAwLevelInput +5 Allow*` (admin Designer POST per-Level)
- `ApprovalWorkflowOptionsDto` chỉ còn 5 flag (F2 separate field)
- `PurchaseEvaluationDetailBundleDto`:
- RENAME `WorkflowOptions` → `CurrentLevelOptions`
- ADD `DrafterAllowSkipToFinal bool`
GetPe handler populate:
- currentLevelOptions = 5 Allow* của Cấp hiện tại (null nếu V1 / no pointer)
- drafterAllowSkipToFinal = User.AllowDrafterSkipToFinal từ DrafterUserId
### Chunk B — FE Admin Designer (`63234b2`)
ApprovalWorkflowsV2Page.tsx:
- Types: `LevelDto +5 Allow*`, `DefinitionDto -6 Allow*`, `EditLevelEntry +5 Allow*`
- Helper `makeDefaultLevelEntry(order, userId)` factory với 4 false + AllowReturnToDrafter=true
- `copyFromDefinition` propagate 5 Allow* từ Levels cũ
- REMOVE section "Cấu hình nâng cao" workflow-level (amber bg 6 checkbox)
- REPLACE với info banner violet ngắn "ⓘ Cấu hình quyền duyệt riêng cho từng NV..."
- Mỗi Level entry (NV row) ADD inline panel amber-50/30 5 checkbox grid-cols-2
fe-user KHÔNG mirror (Designer admin-only).
F2 cần UX riêng ở User Management page → defer commit sau (BE field đã sẵn,
FE chỉ thêm 1 toggle UserEdit dialog khi admin UAT request).
### Chunk C — FE eOffice (`5ccb2a7`) mirror 2 app
Types:
- `ApprovalWorkflowOptions` REMOVE allowDrafterSkipToFinal (5 flag)
- `PeDetailBundle`:
- RENAME `workflowOptions → currentLevelOptions`
- ADD `drafterAllowSkipToFinal: boolean`
PeWorkflowPanel.tsx:
- RENAME local `wfOptions → levelOptions`, source `evaluation.currentLevelOptions`
PeDetailTabs.tsx:
- F3 approverEditMode: read `currentLevelOptions?.allowApproverEditDetails`
- F2 allowSkipToFinal: read `drafterAllowSkipToFinal` (per-user)
Backward compat:
- Backfill Mig 29 preserve admin config S21 t4
- Phiếu V1 legacy → currentLevelOptions=null → fallback chỉ Drafter mode
- drafterAllowSkipToFinal TRUE chỉ cho user từng Drafter PE link workflow Allow=true
### Chunk D — Docs (this commit)
- `docs/database/schema-diagram.md §14`: title "Mig 22-29, S17-21" + add 5 column
Level inline comment + add 1 column User Mig 29 block
- `docs/STATUS.md` Last updated S21 t5 + 28→29 mig
- `docs/HANDOFF.md` TL;DR S21 t5 đầy đủ (file này)
- Session log riêng
### State chốt S21 turn 5
| Metric | Trước (S21 t4) | Sau (S21 t5) | Δ |
|---|---|---|---|
| DB tables | 59 | 59 | 0 |
| **Migrations** | 28 | **29** | **+1** (Mig 29 refactor per-NV) |
| Endpoints | ~143 | ~143 | 0 (same body, different schema) |
| FE pages | 34 | 34 | 0 |
| Unit tests | 84 | 84 | 0 (UAT defer test-after §7) |
| Gotchas | 45 | 45 | 0 |
| Memory | 17 | 17 | 0 |
| Skills | 6 | 6 | 0 |
| Sub-agents | 4 seeds-only | 4 seeds-only | 0 |
| **Commits S21 t5** | — | **4** | `0366946` → `63234b2` → `5ccb2a7` → this |
### Pending — defer / next session
- **User Management page F2 toggle**: thêm checkbox "Cho phép gửi PE thẳng Cấp cuối"
vào UserEdit dialog (BE column sẵn, FE 1 toggle nhỏ — defer khi admin UAT request)
- **Test-after carry** (Plan C bundle): Service ApplyReturnModeAsync 4 mode +
EnsureEditableForDetailsAsync 3 scenario read-from-level + skipToFinal read-from-user
---
## TL;DR Session 21 turn 4 — F1+F2+F3 PE Workflow advanced options (Mig 28) ## 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: User request 3 tính năng mới trong PE V2 Workflow:

View File

@ -2,7 +2,8 @@
> **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-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.**) **Last updated:** 2026-05-13 1400 (Session 21 turn 5**🎯 Refactor F1+F2+F3 sang PER-NV (Mig 29 drop Mig 28 column workflow-level). 4 chunk per-commit `0366946` (A BE schema+Service refactor) → `63234b2` (B FE Admin Designer 5 checkbox per-Level row) → `5ccb2a7` (C FE eOffice rename currentLevelOptions + drafterAllowSkipToFinal) → this Chunk D Docs. **F1+F3** 5 flag MOVED xuống `ApprovalWorkflowLevels` (per slot Approver, mỗi NV có riêng quyền duyệt). **F2** AllowDrafterSkipToFinal MOVED xuống `Users` (per-Drafter user, admin config ở User Management). Mig 29 4-stage: ADD 5 column Levels + 1 column Users + BACKFILL bulk SQL (copy workflow → all Levels, set TRUE cho Drafter user nào từng dùng workflow Allow) + DROP 6 column workflow `ApprovalWorkflows`. Service `ApplyReturnModeAsync` refactor đọc `currentLevel.AllowXxx` thay vì `workflow.AllowXxx`. Helper `EnsureEditableForDetailsAsync` read `level.AllowApproverEditDetails`. DRAFTER trình branch read `userManager.FindByIdAsync(actorUserId).AllowDrafterSkipToFinal`. DTO refactor: `AwLevelDto +5 Allow*`, `AwDefinitionDto -6 Allow*`, `CreateAwLevelInput +5 Allow*`, `PeDetailBundle.workflowOptions → currentLevelOptions + drafterAllowSkipToFinal`. FE Admin Designer drop section "Cấu hình nâng cao" workflow-level, replace 5 checkbox grid-cols-2 inline mỗi Level entry row (5 flag per slot). FE eOffice rename `wfOptions → levelOptions` đọc `currentLevelOptions`. Backward compat: backfill preserve admin config S21 t4. Test 84/84 PASS unchanged. Stats: **29 mig (+1) · 59 tables · ~143 endpoints · 34 FE pages · 84 test pass · 45 gotcha · 17 memory · 6 skills · 4 sub-agents seeds-only.**)
**S21 turn 4:** 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 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 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).**)
@ -10,7 +11,7 @@
**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), 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. ## 📍 Phase hiện tại: **Phase 9 active — UAT V2 testing với user thật** — **59 DB tables, 29 migrations (+1 Mig 29 refactor S21 t5 per-NV — 5 column trên ApprovalWorkflowLevels + 1 column trên Users), ~143 API endpoints, 34 FE pages. 84 unit test pass** (58 Domain + 26 Infra, 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,234 @@
# Session 21 turn 5 — 2026-05-13 14:00 — Refactor Allow* sang PER-NV (Mig 29 drop Mig 28)
**Dev:** Claude Opus 4.7 1M Max (em main solo — Implementer REFUSE per cross-stack reasoning chain rule)
**Duration:** ~2h
**Base commit:** `eea86fd` (S21 t4 Chunk E Docs)
**Commits này turn:** `0366946` (A BE+Mig 29) → `63234b2` (B FE Admin) → `5ccb2a7` (C FE eOffice) → this (D Docs)
## Trigger
User feedback sau UAT S21 t4 deploy: "à cấu hình cho từng người nhé (chứ ko phải là cho toàn bộ quy trình duyệt), thêm table vào SQL luôn để cấu hình cho dễ."
→ Workflow-level Allow* (Mig 28 S21 t4) **chưa fit UX request** — admin muốn config quyền duyệt RIÊNG cho TỪNG NV (per-Level slot trong workflow).
## Q&A clarify (2 lượt AskUserQuestion)
### Lượt 1 — Scope per-NV + xử lý Mig 28 cũ:
| Câu | User chốt |
|---|---|
| Scope "từng người" | **Per-Level**: 5 flag (4 F1 + 1 F3) gắn slot Designer. F2 per-Drafter user. |
| Mig 28 xử lý | **Migrate bốc → per-NV bulk + drop**: copy workflow → Levels của workflow, backfill Users F2 từ PE link, drop 6 column workflow. |
→ 6 flag split scope theo role natural:
- **F1 (4 mode) + F3 (1 flag)** = Approver permission → gắn `ApprovalWorkflowLevels` (1 slot Approver = 1 row Level)
- **F2 (1 flag)** = Drafter permission → gắn `Users` (per-Drafter user global)
## Chunk A — BE schema + Service refactor (`0366946`)
### Mig 29 `RefactorAdvancedOptionsToPerLevelAndDrafterUser` (4-stage)
EF auto-generated drop-then-add order WRONG (data loss khi DROP trước BACKFILL).
Phải REORDER manual:
```csharp
public override void Up(MigrationBuilder migrationBuilder)
{
// Stage 1: ADD 5 column ApprovalWorkflowLevels (per slot)
AddColumn × 5 (AllowReturn*+AllowApproverEditDetails)
// Stage 2: ADD 1 column Users (per-Drafter F2)
AddColumn AllowDrafterSkipToFinal
// Stage 3: BACKFILL bulk SQL
migrationBuilder.Sql(@"
UPDATE l SET l.AllowReturnOneLevel = w.AllowReturnOneLevel, ...
FROM ApprovalWorkflowLevels l
INNER JOIN ApprovalWorkflowSteps s ON s.Id = l.ApprovalWorkflowStepId
INNER JOIN ApprovalWorkflows w ON w.Id = s.ApprovalWorkflowId;
");
migrationBuilder.Sql(@"
UPDATE u SET u.AllowDrafterSkipToFinal = 1
FROM Users u WHERE EXISTS (
SELECT 1 FROM PurchaseEvaluations pe
INNER JOIN ApprovalWorkflows w ON w.Id = pe.ApprovalWorkflowId
WHERE pe.DrafterUserId = u.Id AND w.AllowDrafterSkipToFinal = 1
);
");
// Stage 4: DROP 6 column workflow-level (Mig 28 cleanup)
DropColumn × 6 (Mig 28 fields)
}
```
3-file rule complete. Apply LocalDB Dev + Design success.
### Domain entity
```csharp
// Mig 28 cũ ApprovalWorkflow.cs — REMOVE 6 Allow* field
// Mig 29 — entity nguyên thuỷ workflow-level cũ giảm về Code/Name/Version/Active/Selectable only.
// ApprovalWorkflowLevel.cs — ADD 5 Allow* field
public bool AllowReturnOneLevel { get; set; }
public bool AllowReturnOneStep { get; set; }
public bool AllowReturnToAssignee { get; set; }
public bool AllowReturnToDrafter { get; set; } = true; // S17 backward compat
public bool AllowApproverEditDetails { get; set; }
// User.cs — ADD 1 Allow* field
public bool AllowDrafterSkipToFinal { get; set; }
```
### Service refactor `ApplyReturnModeAsync`
```csharp
// Resolve currentLevel slot từ pointer
ApprovalWorkflowLevel? currentLevel = null;
if (evaluation.CurrentWorkflowStepIndex is int csi && csi < stepsOrdered.Count) {
var step = stepsOrdered[csi];
currentLevel = step.Levels.FirstOrDefault(l => l.Order == evaluation.CurrentApprovalLevelOrder);
}
// Validate Allow* từ Level slot (Admin bypass)
if (!isAdmin && currentLevel is not null) {
var allowed = mode switch {
WorkflowReturnMode.OneLevel => currentLevel.AllowReturnOneLevel,
WorkflowReturnMode.OneStep => currentLevel.AllowReturnOneStep,
WorkflowReturnMode.Assignee => currentLevel.AllowReturnToAssignee,
WorkflowReturnMode.Drafter => currentLevel.AllowReturnToDrafter,
_ => false,
};
if (!allowed) throw new ConflictException($"Cấp Approver hiện tại không bật mode '{mode}'.");
}
```
V1 legacy phiếu (no ApprovalWorkflowId) → fallback Drafter behavior tự động.
### DRAFTER trình refactor
```csharp
if (skipToFinal && evaluation.ApprovalWorkflowId is Guid skipAwId) {
if (!isAdmin) {
var drafterUser = await userManager.FindByIdAsync(actorUserId.Value.ToString());
if (!drafterUser.AllowDrafterSkipToFinal)
throw new ConflictException($"User '{drafterUser.FullName}' không được phép gửi thẳng Cấp cuối.");
}
// ... set pointer = max Step + max Level
}
```
### Helper `EnsureEditableForDetailsAsync` refactor
```csharp
// Read level.AllowApproverEditDetails thay vì workflow
if (!level.AllowApproverEditDetails)
throw new ConflictException($"Cấp Approver hiện tại (Bước {step.Order} / Cấp {levelOrder}) " +
"không được cấp quyền chỉnh sửa Section 2.");
```
### DTO refactor
- `AwLevelDto +5 Allow*`, `AwDefinitionDto -6 Allow*`
- `CreateAwLevelInput +5 Allow*`, `CreateAwDefinitionCommand -6 Allow*`
- `ApprovalWorkflowOptionsDto`: 5 flag (F2 separate field)
- `PurchaseEvaluationDetailBundleDto`:
- RENAME `WorkflowOptions → CurrentLevelOptions`
- ADD `DrafterAllowSkipToFinal bool`
GetPe handler populate:
- `currentLevelOptions` = 5 Allow* của Cấp hiện tại
- `drafterAllowSkipToFinal` = lookup User.AllowDrafterSkipToFinal từ DrafterUserId
## Chunk B — FE Admin Designer (`63234b2`)
`ApprovalWorkflowsV2Page.tsx`:
- Types: `LevelDto +5 Allow*`, `DefinitionDto -6 Allow*`, `EditLevelEntry +5 Allow*`
- Factory `makeDefaultLevelEntry(order, userId)` — 4 false + AllowReturnToDrafter=true
- `copyFromDefinition` propagate 5 Allow* từ Levels
UI refactor:
- **REMOVE** entire section "Cấu hình nâng cao" workflow-level (amber bg 6 checkbox)
- **REPLACE** với info banner violet ngắn:
> ⓘ Cấu hình quyền duyệt (Trả lại modes + Edit Section 2) đặt RIÊNG cho từng NV ở mỗi Cấp dưới đây. F2 "Gửi thẳng Cấp cuối" (Drafter) cấu hình ở User Management (mỗi NV global).
- **ADD** inline panel mỗi Level entry (NV row) — 5 checkbox grid-cols-2:
- Trả về 1 Cấp trước
- Trả về 1 Bước trước
- Trả về Người chỉ định
- Trả về Drafter (mặc định checked)
- Cho phép chỉnh sửa Section 2 (col-span-2)
- Header "Quyền duyệt NV #N" [10px] uppercase amber-700
POST body propagate 5 Allow* per slot trong `steps[].levels[].*`.
F2 UI defer — User Management page sẽ thêm 1 toggle khi admin UAT request.
## Chunk C — FE eOffice (`5ccb2a7`) mirror 2 app
Types:
- `ApprovalWorkflowOptions` REMOVE allowDrafterSkipToFinal (still 5 flag)
- `PeDetailBundle`:
- RENAME `workflowOptions → currentLevelOptions`
- ADD `drafterAllowSkipToFinal: boolean`
PeWorkflowPanel.tsx:
- RENAME `wfOptions → levelOptions`, read `evaluation.currentLevelOptions`
- 4 mode radio render conditional theo levelOptions.allowReturnXxx
PeDetailTabs.tsx:
- F3 approverEditMode: read `currentLevelOptions?.allowApproverEditDetails`
- F2 allowSkipToFinal: read `drafterAllowSkipToFinal` (per-user)
## Chunk D — Docs (this commit)
- `docs/database/schema-diagram.md §14`: title Mig 22-29 S17-21 + add 5 column Level + 1 column User block
- `docs/STATUS.md` S21 t5 + 28→29 mig
- `docs/HANDOFF.md` TL;DR đầy đủ trên cùng
- Session log (file này)
## Stats cumulative S21 t5
| Metric | Trước (S21 t4) | Sau (S21 t5) | Δ |
|---|---|---|---|
| DB tables | 59 | 59 | 0 |
| **Migrations** | 28 | **29** | **+1** (Mig 29 refactor per-NV) |
| Endpoints | ~143 | ~143 | 0 (body unchanged, schema-source different) |
| FE pages | 34 | 34 | 0 |
| Unit tests | 84 | 84 | 0 (UAT defer test-after §7) |
| Gotchas | 45 | 45 | 0 |
| Memory | 17 | 17 | 0 |
| Skills | 6 | 6 | 0 |
| Sub-agents | 4 seeds-only | 4 seeds-only | 0 |
| **Commits S21 t5** | — | **4** | `0366946``63234b2``5ccb2a7` → this |
## Lessons learned
1. **Reorder EF migration manual khi cần BACKFILL.** EF auto-generate drop-then-add order — fail nếu cần preserve data. Pattern reusable: ADD → BACKFILL SQL → DROP. Test backfill SQL với realistic data trước commit.
2. **Per-NV permission pattern**. Khi cần config role/permission theo slot user (vd workflow approver level), gắn flag vào table chứa user FK (ApprovalWorkflowLevels có ApproverUserId), KHÔNG gắn parent table (ApprovalWorkflows). Pattern reusable cho future N-stage hoặc HĐ V2.
3. **Split per-Role vs per-User scope**. F1+F3 thuộc Approver role → per-slot (Level table). F2 thuộc Drafter role → per-User (Users table). Cấu hình natural theo role context, không hardcode 1 scope cho mọi flag.
4. **Backward compat backfill discipline**. Mig 29 bulk copy preserve admin config Mig 28 → workflow cũ chạy đúng ngay sau deploy. KHÔNG yêu cầu admin reconfig lần đầu (UAT pain point avoided).
5. **Iteration speed S21 cumulative**: t3 fix bug → t4 add feature workflow-level → t5 refactor per-NV theo UAT feedback. 3 iteration cùng day, mỗi turn dedicated session, no scope creep. Per `feedback_drastic_refactor_scope`.
## Handoff
- ✅ All 4 chunk committed local (4 commits)
-**PENDING bro confirm push remote**`git push origin main` 4 commits ahead `eea86fd..HEAD`
- ⏭ Sau push: CI sẽ trigger (.cs + .tsx + Mig) → 🟩 CICD Monitor spawn smoke verify Mig 29 prod apply + currentLevelOptions returned correctly + drafterAllowSkipToFinal populated cho user backfill
User next action expected: UAT verify 5 checkbox per Level slot trong Designer. UAT Drafter F2 (BE field sẵn, nhưng User Mgmt UI defer — admin có thể test qua SQL UPDATE Users.AllowDrafterSkipToFinal=1 cho test user nếu cần verify F2 immediate).
## References
- BE Mig 29: `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260513130144_RefactorAdvancedOptionsToPerLevelAndDrafterUser.cs`
- Domain: `src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs` + `Identity/User.cs`
- BE Service: `src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs`
- BE handlers: `src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs`
- DTO: `src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs`
- FE Admin: `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: S21 t4 HANDOFF + user UAT feedback "cấu hình cho từng người"

View File

@ -715,11 +715,15 @@ 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-28, Session 17-21 — 3 bảng mới + 9 column) ## 14. ApprovalWorkflow V2 schema (Migration 22-29, Session 17-21 — 3 bảng mới + 5 column Level + 1 column User)
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.
Mig 29 (S21 t5) — Refactor 6 Allow* options từ workflow-level (Mig 28 S21 t4)
sang PER-NV: 5 flag F1+F3 xuống Level slot (Approver), 1 flag F2 xuống User
(per-Drafter). Backfill bulk SQL preserve admin config S21 t4.
### Core (3 bảng): ### Core (3 bảng):
``` ```
@ -728,15 +732,7 @@ 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 cũ 6 column Allow* đã DROP trong Mig 29 — refactor sang per-NV)
├── 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)
@ -747,7 +743,20 @@ ApprovalWorkflowSteps (FK Cascade ApprovalWorkflowId, FK Restrict DepartmentId)
ApprovalWorkflowLevels (FK Cascade ApprovalWorkflowStepId, FK Restrict ApproverUserId) ApprovalWorkflowLevels (FK Cascade ApprovalWorkflowStepId, FK Restrict ApproverUserId)
├── Id (PK), ApprovalWorkflowStepId, Order (1/2/3 trong Step) ├── Id (PK), ApprovalWorkflowStepId, Order (1/2/3 trong Step)
├── Name? (vd "Cấp 1"), ApproverUserId (1 NV cụ thể) ├── Name? (vd "Cấp 1"), ApproverUserId (1 NV cụ thể)
├── Mig 29 (S21 t5) — 5 advanced options per slot Approver (F1+F3):
├── 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
├── AllowReturnToDrafter bit DEFAULT 1 — F1 mode: Trả về Drafter (S17 backward compat)
├── AllowApproverEditDetails bit DEFAULT 0 — F3: NV này chỉnh Section 2 lúc đang duyệt
└── INDEX (ApprovalWorkflowStepId, Order) + INDEX ApproverUserId └── INDEX (ApprovalWorkflowStepId, Order) + INDEX ApproverUserId
Users (AspNetUsers extension)
├── ... existing columns (FullName, DepartmentId, PositionLevel, CanBypassReview, etc)
└── Mig 29 (S21 t5) — F2 per-Drafter:
AllowDrafterSkipToFinal bit DEFAULT 0 — User được Drafter gửi PE thẳng Cấp cuối
``` ```
**Convention quan trọng:** nhiều `ApprovalWorkflowLevel` rows cùng `Order` trong cùng Step = **same Cấp với N approvers** (OR-of-N). Ví dụ Cấp 1 có 2 NV: 2 row Level cùng `Order=1` khác `ApproverUserId`. **Convention quan trọng:** nhiều `ApprovalWorkflowLevel` rows cùng `Order` trong cùng Step = **same Cấp với N approvers** (OR-of-N). Ví dụ Cấp 1 có 2 NV: 2 row Level cùng `Order=1` khác `ApproverUserId`.

View File

@ -108,16 +108,17 @@ export function PeDetailTabs({
const actorMatchesLevel = isAdmin const actorMatchesLevel = isAdmin
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id)) || (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
&& (evaluation.workflowOptions?.allowApproverEditDetails ?? false) // Mig 29 (S21 t5) — read F3 từ currentLevelOptions (per-NV slot)
&& (evaluation.currentLevelOptions?.allowApproverEditDetails ?? false)
&& actorMatchesLevel && actorMatchesLevel
const itemsReadOnly = readOnly && !approverEditMode 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. // Mig 29 (S21 t5) — F2: per-Drafter user flag (User Management page).
const [skipToFinal, setSkipToFinal] = useState(false) const [skipToFinal, setSkipToFinal] = useState(false)
const allowSkipToFinal = evaluation.workflowOptions?.allowDrafterSkipToFinal ?? false const allowSkipToFinal = evaluation.drafterAllowSkipToFinal ?? false
const submitForApproval = useMutation({ const submitForApproval = useMutation({
mutationFn: async (opts: { skipToFinal: boolean }) => { mutationFn: async (opts: { skipToFinal: boolean }) => {

View File

@ -41,8 +41,8 @@ export function PeWorkflowPanel({
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". // Mig 29 (S21 t5) — F1 options per-Level (Cấp Approver hiện tại).
const wfOptions = evaluation.workflowOptions const levelOptions = evaluation.currentLevelOptions
// List approvers đã ký (cho mode Assignee dropdown pick) // List approvers đã ký (cho mode Assignee dropdown pick)
const signedApprovers = (evaluation.levelOpinions ?? []) const signedApprovers = (evaluation.levelOpinions ?? [])
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' })) .map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
@ -307,15 +307,15 @@ export function PeWorkflowPanel({
<> <>
{/* Mig 28 (S21 t4) — F1 mode picker khi Trả lại. Show modes {/* Mig 28 (S21 t4) — F1 mode picker khi Trả lại. Show modes
enabled per workflow.options. Default Drafter (S17 fallback). */} enabled per workflow.options. Default Drafter (S17 fallback). */}
{(wfOptions?.allowReturnOneLevel {(levelOptions?.allowReturnOneLevel
|| wfOptions?.allowReturnOneStep || levelOptions?.allowReturnOneStep
|| wfOptions?.allowReturnToAssignee || levelOptions?.allowReturnToAssignee
|| wfOptions?.allowReturnToDrafter || levelOptions?.allowReturnToDrafter
|| !wfOptions) && ( || !levelOptions) && (
<div className="mb-3 space-y-1.5"> <div className="mb-3 space-y-1.5">
<Label className="text-[12px]">Chọn cách Trả lại</Label> <Label className="text-[12px]">Chọn cách Trả lại</Label>
<div className="space-y-1"> <div className="space-y-1">
{(wfOptions?.allowReturnOneLevel) && ( {(levelOptions?.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"> <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 <input
type="radio" type="radio"
@ -329,7 +329,7 @@ export function PeWorkflowPanel({
</span> </span>
</label> </label>
)} )}
{(wfOptions?.allowReturnOneStep) && ( {(levelOptions?.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"> <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 <input
type="radio" type="radio"
@ -343,7 +343,7 @@ export function PeWorkflowPanel({
</span> </span>
</label> </label>
)} )}
{(wfOptions?.allowReturnToAssignee) && ( {(levelOptions?.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"> <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 <input
type="radio" type="radio"
@ -369,7 +369,7 @@ export function PeWorkflowPanel({
</span> </span>
</label> </label>
)} )}
{(wfOptions?.allowReturnToDrafter !== false) && ( {(levelOptions?.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"> <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 <input
type="radio" type="radio"

View File

@ -41,6 +41,12 @@ type LevelDto = {
approverUserId: string approverUserId: string
approverUserName: string | null approverUserName: string | null
approverEmail: string | null approverEmail: string | null
// Mig 29 (S21 t5) — 5 Allow* options per slot Approver
allowReturnOneLevel: boolean
allowReturnOneStep: boolean
allowReturnToAssignee: boolean
allowReturnToDrafter: boolean
allowApproverEditDetails: boolean
} }
type StepDto = { type StepDto = {
id: string id: string
@ -60,13 +66,9 @@ 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 // Mig 29 (S21 t5) — 6 Allow* options MOVED:
allowReturnOneLevel: boolean // - 5 flag F1+F3 xuống per slot Level (xem LevelDto)
allowReturnOneStep: boolean // - 1 flag F2 AllowDrafterSkipToFinal xuống per User (User Management)
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[]
@ -79,7 +81,17 @@ type TypeSummaryDto = {
} }
type LevelOrder = 1 | 2 | 3 type LevelOrder = 1 | 2 | 3
type EditLevelEntry = { order: LevelOrder; approverUserId: string } type EditLevelEntry = {
order: LevelOrder
approverUserId: string
// Mig 29 (S21 t5) — 5 Allow* per slot (default backward compat S17: chỉ
// AllowReturnToDrafter=true, 4 còn lại false).
allowReturnOneLevel: boolean
allowReturnOneStep: boolean
allowReturnToAssignee: boolean
allowReturnToDrafter: boolean
allowApproverEditDetails: boolean
}
type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] } type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] }
type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null } type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null }
@ -110,16 +122,39 @@ function makeEmptyStep(stepNo: number, deptId: string | null = null): EditStep {
} }
// Clone existing definition: filter Order ∈ {1,2,3}, drop entries vượt giới hạn. // Clone existing definition: filter Order ∈ {1,2,3}, drop entries vượt giới hạn.
// Mig 29 (S21 t5) — clone 5 Allow* per slot từ existing Level.
function copyFromDefinition(d: DefinitionDto): EditStep[] { function copyFromDefinition(d: DefinitionDto): EditStep[] {
return d.steps.map(s => ({ return d.steps.map(s => ({
name: s.name, name: s.name,
departmentId: s.departmentId, departmentId: s.departmentId,
levelEntries: s.levels levelEntries: s.levels
.filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP) .filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP)
.map(l => ({ order: l.order as LevelOrder, approverUserId: l.approverUserId })), .map(l => ({
order: l.order as LevelOrder,
approverUserId: l.approverUserId,
allowReturnOneLevel: l.allowReturnOneLevel ?? false,
allowReturnOneStep: l.allowReturnOneStep ?? false,
allowReturnToAssignee: l.allowReturnToAssignee ?? false,
allowReturnToDrafter: l.allowReturnToDrafter ?? true,
allowApproverEditDetails: l.allowApproverEditDetails ?? false,
})),
})) }))
} }
// Mig 29 — Factory default cho entry mới (admin click "+ Thêm NV"). 5 flag
// default backward compat S17: chỉ AllowReturnToDrafter=true.
function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditLevelEntry {
return {
order,
approverUserId,
allowReturnOneLevel: false,
allowReturnOneStep: false,
allowReturnToAssignee: false,
allowReturnToDrafter: true,
allowApproverEditDetails: false,
}
}
// Filter NV theo Phòng. Nếu Phòng = null → fallback all (chưa chọn phòng). // Filter NV theo Phòng. Nếu Phòng = null → fallback all (chưa chọn phòng).
function usersForDept(all: ApproverUser[] | undefined, deptId: string | null): ApproverUser[] { function usersForDept(all: ApproverUser[] | undefined, deptId: string | null): ApproverUser[] {
if (!all) return [] if (!all) return []
@ -452,14 +487,9 @@ 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ữ // Mig 29 (S21 t5) — 6 Allow* options MOVED:
// config version trước) hoặc backward compat S17 (chỉ Drafter mode). // - 5 flag F1+F3 xuống per Level slot (xem EditLevelEntry, render mỗi Level row)
const [allowReturnOneLevel, setAllowReturnOneLevel] = useState(cloneFrom?.allowReturnOneLevel ?? false) // - 1 flag F2 AllowDrafterSkipToFinal xuống per User (User Management page)
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'],
@ -513,19 +543,18 @@ function Designer({
departmentId: s.departmentId, departmentId: s.departmentId,
// Mỗi entry → 1 Level row. Multiple rows cùng Order = same Cấp với // Mỗi entry → 1 Level row. Multiple rows cùng Order = same Cấp với
// N approvers (BE iterate group by Order). // N approvers (BE iterate group by Order).
// Mig 29 (S21 t5) — 5 Allow* options per slot Approver.
levels: s.levelEntries.map(e => ({ levels: s.levelEntries.map(e => ({
order: e.order, order: e.order,
name: `Cấp ${e.order}`, name: `Cấp ${e.order}`,
approverUserId: e.approverUserId, approverUserId: e.approverUserId,
allowReturnOneLevel: e.allowReturnOneLevel,
allowReturnOneStep: e.allowReturnOneStep,
allowReturnToAssignee: e.allowReturnToAssignee,
allowReturnToDrafter: e.allowReturnToDrafter,
allowApproverEditDetails: e.allowApproverEditDetails,
})), })),
})), })),
// Mig 28 (S21 t4) — 6 advanced options
allowReturnOneLevel,
allowReturnOneStep,
allowReturnToAssignee,
allowReturnToDrafter,
allowDrafterSkipToFinal,
allowApproverEditDetails,
}) })
}, },
onSuccess: () => { onSuccess: () => {
@ -584,116 +613,14 @@ function Designer({
</div> </div>
</div> </div>
{/* Mig 28 (S21 t4) — Section Cấu hình nâng cao (F1+F2+F3 advanced options). {/* Mig 29 (S21 t5) — 6 Allow* options MOVED per-NV:
6 checkbox per workflow: 4 mode Trả lại + 1 Skip CEO + 1 Approver edit. */} - 5 flag F1+F3 xuống mỗi Level row (xem level entry inline below).
<div className="space-y-2 rounded-lg border border-amber-200 bg-amber-50/30 p-3"> - 1 flag F2 AllowDrafterSkipToFinal xuống Users page (System → Users).
<Label className="text-amber-900"> Section "Cấu hình nâng cao" workflow-level cũ Mig 28 đã DROP. */}
Cấu hình nâng cao quyền duyệt mở rộng <div className="rounded-lg border border-violet-200 bg-violet-50/30 px-3 py-2 text-[11px] leading-relaxed text-violet-800">
</Label> Cấu hình quyền duyệt (Trả lại modes + Edit Section 2) đt RIÊNG cho từng NV mỗi
<p className="text-[11px] leading-relaxed text-slate-600"> Cấp dưới đây. F2 "Gửi thẳng Cấp cuối" (Drafter) cấu hình
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 <span className="font-medium"> User Management</span> (mỗi NV global).
(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>
<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">
@ -830,7 +757,7 @@ function Designer({
const firstUser = availableUsers[0] const firstUser = availableUsers[0]
setSteps(steps.map((x, i) => setSteps(steps.map((x, i) =>
i === idx i === idx
? { ...x, levelEntries: [...x.levelEntries, { order, approverUserId: firstUser.id }] } ? { ...x, levelEntries: [...x.levelEntries, makeDefaultLevelEntry(order, firstUser.id)] }
: x, : x,
)) ))
}} }}
@ -913,6 +840,81 @@ function Designer({
</div> </div>
) )
})} })}
{/* Mig 29 (S21 t5) — 5 Allow* checkbox inline cho mỗi
NV entry. Mặc định AllowReturnToDrafter=true (S17
backward compat). Admin tick mở mode khác per slot. */}
{entries.map((entry, ei) => {
const globalIdx = s.levelEntries.findIndex(x => x === entry)
const updateField = (field: keyof EditLevelEntry, value: boolean) => {
setSteps(steps.map((x, i) =>
i === idx
? {
...x,
levelEntries: x.levelEntries.map((y, j) =>
j === globalIdx ? { ...y, [field]: value } : y,
),
}
: x,
))
}
return (
<div
key={`opts-${ei}`}
className="ml-4 mt-1 rounded border border-amber-100 bg-amber-50/30 px-2 py-1.5"
>
<div className="mb-1 text-[10px] font-medium uppercase text-amber-700">
Quyền duyệt NV #{ei + 1}
</div>
<div className="grid grid-cols-2 gap-1">
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnOneLevel}
onChange={e => updateField('allowReturnOneLevel', e.target.checked)}
/>
<span>Trả về 1 Cấp trước</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnOneStep}
onChange={e => updateField('allowReturnOneStep', e.target.checked)}
/>
<span>Trả về 1 Bước trước</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnToAssignee}
onChange={e => updateField('allowReturnToAssignee', e.target.checked)}
/>
<span>Trả về Người chỉ đnh</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnToDrafter}
onChange={e => updateField('allowReturnToDrafter', e.target.checked)}
/>
<span>Trả về Drafter (mặc đnh)</span>
</label>
<label className="col-span-2 flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverEditDetails}
onChange={e => updateField('allowApproverEditDetails', e.target.checked)}
/>
<span>Cho phép chỉnh sửa Section 2 (Hạng mục/NCC/Báo giá) lúc đang duyệt</span>
</label>
</div>
</div>
)
})}
</div> </div>
)} )}
</div> </div>

View File

@ -347,13 +347,13 @@ export type PeDepartmentApproval = {
isBypassed: boolean isBypassed: boolean
} }
// Mig 28 (S21 t4) — 6 advanced options của workflow pin. // Mig 29 (S21 t5) — 5 Allow* options của Cấp Approver hiện tại (per-NV slot).
// F2 (Drafter skip) MOVED sang per-User (xem PeDetailBundle.drafterAllowSkipToFinal).
export type ApprovalWorkflowOptions = { export type ApprovalWorkflowOptions = {
allowReturnOneLevel: boolean allowReturnOneLevel: boolean
allowReturnOneStep: boolean allowReturnOneStep: boolean
allowReturnToAssignee: boolean allowReturnToAssignee: boolean
allowReturnToDrafter: boolean allowReturnToDrafter: boolean
allowDrafterSkipToFinal: boolean
allowApproverEditDetails: boolean allowApproverEditDetails: boolean
} }
@ -397,8 +397,10 @@ 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. // Mig 29 (S21 t5) — 5 Allow* options của Cấp hiện tại (per-NV slot).
workflowOptions: ApprovalWorkflowOptions | null currentLevelOptions: ApprovalWorkflowOptions | null
// Mig 29 — F2 per-Drafter flag
drafterAllowSkipToFinal: boolean
// 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

@ -110,15 +110,16 @@ export function PeDetailTabs({
const actorMatchesLevel = isAdmin const actorMatchesLevel = isAdmin
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id)) || (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
&& (evaluation.workflowOptions?.allowApproverEditDetails ?? false) // Mig 29 (S21 t5) — read F3 từ currentLevelOptions (per-NV slot)
&& (evaluation.currentLevelOptions?.allowApproverEditDetails ?? false)
&& actorMatchesLevel && actorMatchesLevel
// itemsReadOnly = readOnly trừ khi approver mode F3 mở // itemsReadOnly = readOnly trừ khi approver mode F3 mở
const itemsReadOnly = readOnly && !approverEditMode const itemsReadOnly = readOnly && !approverEditMode
// Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối. Workflow phải bật flag. // Mig 29 (S21 t5) — F2: per-Drafter user flag (KHÔNG còn workflow-level).
// Default false (gửi tuần tự như cũ). Sync state với confirm dialog handler. // Admin cấu hình ở User Management page → BE resolve qua DrafterUserId.
const [skipToFinal, setSkipToFinal] = useState(false) const [skipToFinal, setSkipToFinal] = useState(false)
const allowSkipToFinal = evaluation.workflowOptions?.allowDrafterSkipToFinal ?? false const allowSkipToFinal = evaluation.drafterAllowSkipToFinal ?? 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

View File

@ -41,8 +41,9 @@ export function PeWorkflowPanel({
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". // Mig 29 (S21 t5) — F1 options per-Level (Cấp Approver hiện tại). Null nếu
const wfOptions = evaluation.workflowOptions // V1 legacy hoặc pointer chưa init → fallback chỉ "Trả về Drafter".
const levelOptions = evaluation.currentLevelOptions
// List approvers đã ký (cho mode Assignee dropdown pick) // List approvers đã ký (cho mode Assignee dropdown pick)
const signedApprovers = (evaluation.levelOpinions ?? []) const signedApprovers = (evaluation.levelOpinions ?? [])
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' })) .map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
@ -302,15 +303,15 @@ export function PeWorkflowPanel({
<> <>
{/* Mig 28 (S21 t4) — F1 mode picker khi Trả lại. Show modes {/* Mig 28 (S21 t4) — F1 mode picker khi Trả lại. Show modes
enabled per workflow.options. Default Drafter (S17 fallback). */} enabled per workflow.options. Default Drafter (S17 fallback). */}
{(wfOptions?.allowReturnOneLevel {(levelOptions?.allowReturnOneLevel
|| wfOptions?.allowReturnOneStep || levelOptions?.allowReturnOneStep
|| wfOptions?.allowReturnToAssignee || levelOptions?.allowReturnToAssignee
|| wfOptions?.allowReturnToDrafter || levelOptions?.allowReturnToDrafter
|| !wfOptions) && ( || !levelOptions) && (
<div className="mb-3 space-y-1.5"> <div className="mb-3 space-y-1.5">
<Label className="text-[12px]">Chọn cách Trả lại</Label> <Label className="text-[12px]">Chọn cách Trả lại</Label>
<div className="space-y-1"> <div className="space-y-1">
{(wfOptions?.allowReturnOneLevel) && ( {(levelOptions?.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"> <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 <input
type="radio" type="radio"
@ -324,7 +325,7 @@ export function PeWorkflowPanel({
</span> </span>
</label> </label>
)} )}
{(wfOptions?.allowReturnOneStep) && ( {(levelOptions?.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"> <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 <input
type="radio" type="radio"
@ -338,7 +339,7 @@ export function PeWorkflowPanel({
</span> </span>
</label> </label>
)} )}
{(wfOptions?.allowReturnToAssignee) && ( {(levelOptions?.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"> <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 <input
type="radio" type="radio"
@ -364,7 +365,7 @@ export function PeWorkflowPanel({
</span> </span>
</label> </label>
)} )}
{(wfOptions?.allowReturnToDrafter !== false) && ( {(levelOptions?.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"> <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 <input
type="radio" type="radio"

View File

@ -344,13 +344,14 @@ export type PeDepartmentApproval = {
isBypassed: boolean isBypassed: boolean
} }
// Mig 28 (S21 t4) — 6 advanced options của workflow pin. // Mig 29 (S21 t5) — 5 Allow* options của Cấp Approver hiện tại (per-NV slot).
// FE filter Trả lại dropdown + Edit Section 2 enabled theo flag của slot này.
// F2 (Drafter skip) MOVED sang per-User (xem PeDetailBundle.drafterAllowSkipToFinal).
export type ApprovalWorkflowOptions = { export type ApprovalWorkflowOptions = {
allowReturnOneLevel: boolean allowReturnOneLevel: boolean
allowReturnOneStep: boolean allowReturnOneStep: boolean
allowReturnToAssignee: boolean allowReturnToAssignee: boolean
allowReturnToDrafter: boolean allowReturnToDrafter: boolean
allowDrafterSkipToFinal: boolean
allowApproverEditDetails: boolean allowApproverEditDetails: boolean
} }
@ -394,9 +395,13 @@ 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. // Mig 29 (S21 t5) — 5 Allow* options của Cấp hiện tại (per-NV slot). Null
// FE filter Trả lại dropdown + Skip submit + Edit Section 2 conditional. // nếu V1 legacy hoặc pointer chưa init. FE render Trả lại dropdown + Edit
workflowOptions: ApprovalWorkflowOptions | null // Section 2 conditional theo flag của slot Approver đang duyệt.
currentLevelOptions: ApprovalWorkflowOptions | null
// Mig 29 — F2 per-Drafter: cờ AllowDrafterSkipToFinal của Drafter user pin
// phiếu. Workspace conditional render checkbox "Gửi thẳng Cấp cuối".
drafterAllowSkipToFinal: boolean
// 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

@ -26,7 +26,14 @@ public record AwLevelDto(
string? Name, string? Name,
Guid ApproverUserId, Guid ApproverUserId,
string? ApproverUserName, string? ApproverUserName,
string? ApproverEmail); string? ApproverEmail,
// Mig 29 (S21 t5) — 5 advanced options per slot Approver (F1 mode Trả lại
// + F3 Edit Section 2). Mỗi NV trong workflow có quyền riêng.
bool AllowReturnOneLevel,
bool AllowReturnOneStep,
bool AllowReturnToAssignee,
bool AllowReturnToDrafter,
bool AllowApproverEditDetails);
public record AwStepDto( public record AwStepDto(
Guid Id, Guid Id,
@ -46,15 +53,9 @@ 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 // Mig 29 (S21 t5) — 6 advanced options đã MOVE per-NV: 5 flag (F1+F3) xuống
// Designer tick stick → checkbox. FE eOffice render dropdown / Skip / Edit // AwLevelDto (per slot Approver), F2 AllowDrafterSkipToFinal xuống User table
// conditional theo flag tương ứng. // (per-Drafter user). Workflow-level Mig 28 dropped.
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);
@ -137,13 +138,6 @@ 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(
@ -155,7 +149,10 @@ public class GetAwAdminOverviewQueryHandler(
s.Levels.OrderBy(l => l.Order).Select(l => s.Levels.OrderBy(l => l.Order).Select(l =>
{ {
users.TryGetValue(l.ApproverUserId, out var info); users.TryGetValue(l.ApproverUserId, out var info);
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email); // Mig 29 (S21 t5) — 5 Allow* flag per slot Level
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
l.AllowReturnToDrafter, l.AllowApproverEditDetails);
}).ToList() }).ToList()
)).ToList()); )).ToList());
@ -181,7 +178,18 @@ public class GetAwAdminOverviewQueryHandler(
// ========== POST new version ========== // ========== POST new version ==========
public record CreateAwLevelInput(int Order, string? Name, Guid ApproverUserId); public record CreateAwLevelInput(
int Order,
string? Name,
Guid ApproverUserId,
// Mig 29 (S21 t5) — 5 Allow* options per slot. Admin Designer tick per
// Level row. Default backward compat: AllowReturnToDrafter=true, 4 còn lại
// false (admin opt-in từng slot).
bool AllowReturnOneLevel = false,
bool AllowReturnOneStep = false,
bool AllowReturnToAssignee = false,
bool AllowReturnToDrafter = true,
bool AllowApproverEditDetails = false);
public record CreateAwStepInput( public record CreateAwStepInput(
int Order, int Order,
@ -194,15 +202,7 @@ public record CreateAwDefinitionCommand(
string Code, string Code,
string Name, string Name,
string? Description, string? Description,
List<CreateAwStepInput> Steps, List<CreateAwStepInput> Steps) : IRequest<Guid>;
// 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>
{ {
@ -295,13 +295,7 @@ 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 // Mig 29 (S21 t5) — Allow* options đã move xuống Level slot (per-NV)
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
@ -315,6 +309,12 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
Order = l.Order, Order = l.Order,
Name = l.Name, Name = l.Name,
ApproverUserId = l.ApproverUserId, ApproverUserId = l.ApproverUserId,
// Mig 29 (S21 t5) — 5 Allow* per slot
AllowReturnOneLevel = l.AllowReturnOneLevel,
AllowReturnOneStep = l.AllowReturnOneStep,
AllowReturnToAssignee = l.AllowReturnToAssignee,
AllowReturnToDrafter = l.AllowReturnToDrafter,
AllowApproverEditDetails = l.AllowApproverEditDetails,
}).ToList(), }).ToList(),
}) })
.ToList(), .ToList(),

View File

@ -78,14 +78,15 @@ 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 // Mig 29 (S21 t5) — Approver options của slot Level hiện tại (per-NV).
// dropdown / Skip submit / Edit Section 2 enabled theo flag tương ứng. // FE eOffice filter Trả lại dropdown + Edit Section 2 enabled theo flag của
// Cấp hiện tại NV đang duyệt. Null nếu phiếu V1 legacy hoặc không ChoDuyet.
// F2 (Drafter skip) đã move sang `PeDetailBundleDto.DrafterAllowSkipToFinal`.
public record ApprovalWorkflowOptionsDto( public record ApprovalWorkflowOptionsDto(
bool AllowReturnOneLevel, bool AllowReturnOneLevel,
bool AllowReturnOneStep, bool AllowReturnOneStep,
bool AllowReturnToAssignee, bool AllowReturnToAssignee,
bool AllowReturnToDrafter, bool AllowReturnToDrafter,
bool AllowDrafterSkipToFinal,
bool AllowApproverEditDetails); bool AllowApproverEditDetails);
public record PurchaseEvaluationWorkflowSummaryDto( public record PurchaseEvaluationWorkflowSummaryDto(
@ -204,9 +205,14 @@ 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 // Mig 29 (S21 t5) — 5 Allow* options của Cấp hiện tại (per-NV slot). Null
// legacy. FE render Trả lại dropdown + Skip + Edit Section 2 conditional. // nếu V1 legacy hoặc không ChoDuyet. FE render Trả lại dropdown + Edit
ApprovalWorkflowOptionsDto? WorkflowOptions, // Section 2 conditional. Field rename "WorkflowOptions" → "CurrentLevelOptions"
// để rõ semantic per-slot không phải workflow-wide.
ApprovalWorkflowOptionsDto? CurrentLevelOptions,
// Mig 29 — F2 per-Drafter: cờ AllowDrafterSkipToFinal của Drafter user pin
// phiếu. Workspace conditional render checkbox "Gửi thẳng Cấp cuối".
bool DrafterAllowSkipToFinal,
PurchaseEvaluationCurrentApprovalDto? CurrentApproval, PurchaseEvaluationCurrentApprovalDto? CurrentApproval,
PurchaseEvaluationApprovalFlowDto? ApprovalFlow, PurchaseEvaluationApprovalFlowDto? ApprovalFlow,
List<PurchaseEvaluationSupplierDto> Suppliers, List<PurchaseEvaluationSupplierDto> Suppliers,

View File

@ -68,16 +68,19 @@ internal static class PurchaseEvaluationDraftGuard
.FirstOrDefaultAsync(w => w.Id == awId, ct) .FirstOrDefaultAsync(w => w.Id == awId, ct)
?? throw new ConflictException("Workflow không tồn tại."); ?? 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 step = workflow.Steps.OrderBy(s => s.Order).Skip(stepIdx).FirstOrDefault();
var level = step?.Levels.FirstOrDefault(lv => lv.Order == levelOrder); var level = step?.Levels.FirstOrDefault(lv => lv.Order == levelOrder);
if (level is null) if (level is null)
throw new ConflictException("Workflow Bước/Cấp không tìm thấy — schema lỗi."); throw new ConflictException("Workflow Bước/Cấp không tìm thấy — schema lỗi.");
// Mig 29 (S21 t5) — F3 flag move xuống Level slot (per-NV). Đọc
// từ level.AllowApproverEditDetails thay vì workflow-level cũ.
if (!level.AllowApproverEditDetails)
throw new ConflictException(
$"Cấp Approver hiện tại (Bước {step!.Order} / Cấp {levelOrder}) " +
"không được cấp quyền chỉnh sửa Section 2. " +
"Phải Trả lại Drafter sửa hoặc liên hệ Admin Designer cấp quyền slot.");
if (level.ApproverUserId != actorUserId) if (level.ApproverUserId != actorUserId)
throw new ForbiddenException( throw new ForbiddenException(
$"Chỉ NV phụ trách Bước {step!.Order} / Cấp {levelOrder} " + $"Chỉ NV phụ trách Bước {step!.Order} / Cấp {levelOrder} " +

View File

@ -560,9 +560,19 @@ public class GetPurchaseEvaluationQueryHandler(
// hiển thị FE detail card "QT-DN-V2-001 - Tên (v01)"). // hiển thị FE detail card "QT-DN-V2-001 - Tên (v01)").
// Mig 24 — populate CurrentApproval (cấp hiện tại) + ApprovalFlow (full // Mig 24 — populate CurrentApproval (cấp hiện tại) + ApprovalFlow (full
// 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.
// Mig 29 (S21 t5) — F2 drafter flag từ User entity (per-Drafter user
// AllowDrafterSkipToFinal). Default false nếu DrafterUserId null.
var drafterAllowSkipToFinal = false;
if (e.DrafterUserId is Guid drafterId)
{
var drafterUser = await userManager.Users.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == drafterId, ct);
drafterAllowSkipToFinal = drafterUser?.AllowDrafterSkipToFinal ?? false;
}
string? awCode = null, awName = null; string? awCode = null, awName = null;
int? awVersion = null; int? awVersion = null;
ApprovalWorkflowOptionsDto? awOptions = null; ApprovalWorkflowOptionsDto? currentLevelOptions = 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)
@ -576,14 +586,25 @@ 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( // Mig 29 (S21 t5) — Resolve Cấp hiện tại + populate 5 Allow* flag
aw.AllowReturnOneLevel, // của slot Approver đang duyệt. Null nếu pointer chưa init.
aw.AllowReturnOneStep, if (e.CurrentWorkflowStepIndex is int curStepIdx
aw.AllowReturnToAssignee, && curStepIdx >= 0 && curStepIdx < aw.Steps.Count
aw.AllowReturnToDrafter, && e.CurrentApprovalLevelOrder is int curLevelOrder)
aw.AllowDrafterSkipToFinal, {
aw.AllowApproverEditDetails); var curStep = aw.Steps.OrderBy(s => s.Order).Skip(curStepIdx).FirstOrDefault();
var curLevel = curStep?.Levels.FirstOrDefault(l => l.Order == curLevelOrder);
if (curLevel is not null)
{
currentLevelOptions = new ApprovalWorkflowOptionsDto(
curLevel.AllowReturnOneLevel,
curLevel.AllowReturnOneStep,
curLevel.AllowReturnToAssignee,
curLevel.AllowReturnToDrafter,
curLevel.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
@ -703,7 +724,9 @@ 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, awOptions, e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions,
// Mig 29 (S21 t5) — F2 drafter flag từ User entity
drafterAllowSkipToFinal,
currentApproval, approvalFlow, currentApproval, approvalFlow,
e.Suppliers e.Suppliers
.OrderBy(s => s.Order) .OrderBy(s => s.Order)

View File

@ -34,45 +34,10 @@ 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 ===== // Mig 28 cũ 6 column workflow-level Allow* đã DROP trong Mig 29 (S21 t5).
// Cấu hình "Cấu hình nâng cao" trong Admin Designer. User eOffice render // Refactor sang per-NV (per ApprovalWorkflowLevel slot + Users F2). Backfill
// dropdown/checkbox theo flag enabled. 4 flag Return* = mode Trả lại (F1). // bulk SQL copy workflow → all Levels của workflow trước khi DROP — preserve
// 1 flag Skip = Drafter trình thẳng Cấp cuối (F2). 1 flag EditDetails = // admin config từ S21 t4.
// 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();
} }
@ -105,5 +70,33 @@ public class ApprovalWorkflowLevel : BaseEntity
public string? Name { get; set; } // "Cấp 1" — display optional public string? Name { get; set; } // "Cấp 1" — display optional
public Guid ApproverUserId { get; set; } // 1 NV cụ thể duyệt cấp này public Guid ApproverUserId { get; set; } // 1 NV cụ thể duyệt cấp này
// ===== Mig 29 (Session 21 turn 5) — 5 advanced options per slot =====
// Cấu hình quyền duyệt riêng cho TỪNG NV trong slot này. Admin Designer
// tick stick per Level row (KHÔNG còn workflow-level cũ Mig 28).
//
// F1 (4 mode Trả lại) + F3 (Edit Section 2) = quyền của Approver Level.
// F2 (Drafter skip) đã move sang Users.AllowDrafterSkipToFinal (per-Drafter
// user — không liên quan slot Approver).
//
// Backfill Mig 29: copy từ workflow-level Allow* cũ → all Levels của workflow.
// Default backward compat: AllowReturnToDrafter=true (S17 fallback). 4 flag
// còn lại default false (admin opt-in per Level).
/// F1 mode 1 — Lùi 1 Cấp trong cùng Bước. Phase giữ ChoDuyet (peer review).
public bool AllowReturnOneLevel { get; set; }
/// F1 mode 2 — Lùi sang Bước trước Cấp cuối. Phase giữ ChoDuyet.
public bool AllowReturnOneStep { get; set; }
/// F1 mode 3 — Pick runtime từ NV đã ký (PeLevelOpinions). Phase giữ ChoDuyet.
public bool AllowReturnToAssignee { get; set; }
/// F1 mode 4 — Phase=TraLai, clear pointer (S17 fallback). Default TRUE.
public bool AllowReturnToDrafter { get; set; } = true;
/// F3 — Cho phép NV này edit Section 2 (Hạng mục + NCC + Báo giá) lúc đang
/// duyệt. KHÔNG đụng PE Header, KHÔNG reset workflow.
public bool AllowApproverEditDetails { get; set; }
public ApprovalWorkflowStep? Step { get; set; } public ApprovalWorkflowStep? Step { get; set; }
} }

View File

@ -27,4 +27,13 @@ public class User : IdentityUser<Guid>
// Mỗi inner step yêu cầu user khớp DepartmentId + PositionLevel mới duyệt // Mỗi inner step yêu cầu user khớp DepartmentId + PositionLevel mới duyệt
// được sub-step đó. Null cho admin/system/external user. // được sub-step đó. Null cho admin/system/external user.
public PositionLevel? PositionLevel { get; set; } public PositionLevel? PositionLevel { get; set; }
// Mig 29 (Session 21 turn 5) — F2 per-Drafter: cho phép user này (khi đóng
// vai Drafter) gửi PE thẳng Cấp cuối, skip mọi Bước/Cấp trung gian. Workspace
// hiện checkbox "Gửi thẳng Cấp cuối" conditional theo flag này.
//
// Mặc định false (an toàn). Admin set ở User Management page. Backfill
// Mig 29: bulk set TRUE cho user nào từng Drafter PE link workflow có
// workflow.AllowDrafterSkipToFinal=true (preserve admin config S21 t4).
public bool AllowDrafterSkipToFinal { get; set; }
} }

View File

@ -19,14 +19,9 @@ 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 // Mig 28 6 column Allow* đã DROP trong Mig 29 (S21 t5) — refactor sang
// AllowReturnToDrafter default true (backward compat S17 fallback). // per-NV (Level table cho F1+F3, Users table cho F2). Backfill bulk SQL
e.Property(x => x.AllowReturnOneLevel).HasDefaultValue(false); // preserve config admin từ S21 t4 trước khi drop.
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);
} }
} }
@ -74,5 +69,13 @@ public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration<Appro
e.HasIndex(x => new { x.ApprovalWorkflowStepId, x.Order }); e.HasIndex(x => new { x.ApprovalWorkflowStepId, x.Order });
e.HasIndex(x => x.ApproverUserId); e.HasIndex(x => x.ApproverUserId);
// Mig 29 (S21 t5) — 5 per-NV advanced options. 4 default false (admin
// opt-in). 1 AllowReturnToDrafter default true (backward compat S17).
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.AllowApproverEditDetails).HasDefaultValue(false);
} }
} }

View File

@ -0,0 +1,196 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RefactorAdvancedOptionsToPerLevelAndDrafterUser : Migration
{
// Mig 29 (S21 t5) — Refactor Allow* options từ workflow-level (Mig 28
// S21 t4) sang per-NV:
// - F1 (4 mode Trả lại) + F3 (Edit Section 2) = 5 flag move xuống
// ApprovalWorkflowLevels (per slot Approver).
// - F2 (AllowDrafterSkipToFinal) move xuống Users (per-Drafter user).
//
// Migration order: ADD columns mới TRƯỚC → BACKFILL bulk SQL từ workflow
// → DROP columns workflow-level. Preserve admin config S21 t4 (Mig 28).
//
// EF auto-generated order (drop-then-add) đã được reorder manual.
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// ===== Stage 1: ADD 5 column trên ApprovalWorkflowLevels (per slot) =====
migrationBuilder.AddColumn<bool>(
name: "AllowReturnOneLevel",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnOneStep",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnToAssignee",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowReturnToDrafter",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "AllowApproverEditDetails",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
// ===== Stage 2: ADD 1 column trên Users (per-Drafter F2) =====
migrationBuilder.AddColumn<bool>(
name: "AllowDrafterSkipToFinal",
table: "Users",
type: "bit",
nullable: false,
defaultValue: false);
// ===== Stage 3: BACKFILL bulk từ workflow-level (Mig 28) =====
// Copy 5 F1+F3 flag từ workflow → all Levels của workflow đó.
// SQL Server compatible (UPDATE ... FROM ... JOIN ...).
migrationBuilder.Sql(@"
UPDATE l SET
l.AllowReturnOneLevel = w.AllowReturnOneLevel,
l.AllowReturnOneStep = w.AllowReturnOneStep,
l.AllowReturnToAssignee = w.AllowReturnToAssignee,
l.AllowReturnToDrafter = w.AllowReturnToDrafter,
l.AllowApproverEditDetails = w.AllowApproverEditDetails
FROM ApprovalWorkflowLevels l
INNER JOIN ApprovalWorkflowSteps s ON s.Id = l.ApprovalWorkflowStepId
INNER JOIN ApprovalWorkflows w ON w.Id = s.ApprovalWorkflowId;
");
// Backfill Users.AllowDrafterSkipToFinal: set TRUE cho user nào
// từng làm Drafter PE pin workflow có AllowDrafterSkipToFinal=true.
// Conservative: preserve admin config Mig 28 cho user thực tế dùng,
// các user khác giữ false (admin opt-in lần đầu).
migrationBuilder.Sql(@"
UPDATE u SET u.AllowDrafterSkipToFinal = 1
FROM Users u
WHERE EXISTS (
SELECT 1
FROM PurchaseEvaluations pe
INNER JOIN ApprovalWorkflows w ON w.Id = pe.ApprovalWorkflowId
WHERE pe.DrafterUserId = u.Id
AND w.AllowDrafterSkipToFinal = 1
);
");
// ===== Stage 4: DROP 6 column workflow-level (Mig 28 cleanup) =====
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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Rollback: re-add 6 column workflow-level + drop 5 Level + 1 User.
// No reverse backfill (data loss accepted khi rollback).
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);
migrationBuilder.DropColumn(
name: "AllowDrafterSkipToFinal",
table: "Users");
migrationBuilder.DropColumn(
name: "AllowApproverEditDetails",
table: "ApprovalWorkflowLevels");
migrationBuilder.DropColumn(
name: "AllowReturnOneLevel",
table: "ApprovalWorkflowLevels");
migrationBuilder.DropColumn(
name: "AllowReturnOneStep",
table: "ApprovalWorkflowLevels");
migrationBuilder.DropColumn(
name: "AllowReturnToAssignee",
table: "ApprovalWorkflowLevels");
migrationBuilder.DropColumn(
name: "AllowReturnToDrafter",
table: "ApprovalWorkflowLevels");
}
}
}

View File

@ -134,36 +134,6 @@ 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");
@ -218,6 +188,31 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<bool>("AllowApproverEditDetails")
.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<Guid>("ApprovalWorkflowStepId") b.Property<Guid>("ApprovalWorkflowStepId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
@ -1945,6 +1940,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<int>("AccessFailedCount") b.Property<int>("AccessFailedCount")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool>("AllowDrafterSkipToFinal")
.HasColumnType("bit");
b.Property<bool>("CanBypassReview") b.Property<bool>("CanBypassReview")
.HasColumnType("bit"); .HasColumnType("bit");

View File

@ -4,6 +4,7 @@ using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Notifications; using SolutionErp.Application.Notifications;
using SolutionErp.Application.PurchaseEvaluations.Services; using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common; using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
@ -111,19 +112,27 @@ public class PurchaseEvaluationWorkflowService(
} }
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet; evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
// F2 (Mig 28 — S21 t4) — Drafter skip thẳng Cấp cuối. Workflow phải // F2 (Mig 29 — S21 t5) — Drafter skip thẳng Cấp cuối. Permission
// AllowDrafterSkipToFinal=true. Set pointer = max Step + max Level. // check moved sang `User.AllowDrafterSkipToFinal` (per-Drafter user,
// Audit changelog ghi rõ "Drafter skip" để approver Cấp cuối biết. // không còn workflow-level Mig 28).
// Admin bypass user flag check.
if (skipToFinal && evaluation.ApprovalWorkflowId is Guid skipAwId) if (skipToFinal && evaluation.ApprovalWorkflowId is Guid skipAwId)
{ {
if (!isAdmin)
{
if (actorUserId is null)
throw new ConflictException("skipToFinal yêu cầu authenticated user.");
var drafterUser = await userManager.FindByIdAsync(actorUserId.Value.ToString())
?? throw new ConflictException("User không tồn tại.");
if (!drafterUser.AllowDrafterSkipToFinal)
throw new ConflictException(
$"User '{drafterUser.FullName}' không được phép gửi thẳng Cấp cuối. " +
"Liên hệ Admin để cấp quyền ở User Management.");
}
var wfSkip = await db.ApprovalWorkflows var wfSkip = await db.ApprovalWorkflows
.Include(w => w.Steps).ThenInclude(s => s.Levels) .Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == skipAwId, ct) .FirstOrDefaultAsync(w => w.Id == skipAwId, ct)
?? throw new ConflictException("Workflow không tồn tại."); ?? 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() var finalStep = wfSkip.Steps.OrderBy(s => s.Order).LastOrDefault()
?? throw new ConflictException("Workflow chưa có Bước nào."); ?? throw new ConflictException("Workflow chưa có Bước nào.");
var finalLevelOrder = finalStep.Levels.OrderBy(l => l.Order).LastOrDefault()?.Order var finalLevelOrder = finalStep.Levels.OrderBy(l => l.Order).LastOrDefault()?.Order
@ -189,18 +198,57 @@ public class PurchaseEvaluationWorkflowService(
bool isAdmin, bool isAdmin,
CancellationToken ct) CancellationToken ct)
{ {
// Mode Drafter — Session 17 default (always allowed for backward compat, // Mig 29 (S21 t5) refactor: Allow* flag đã move xuống ApprovalWorkflowLevel
// workflow.AllowReturnToDrafter default true). // (per-slot Approver). Cần load workflow Steps+Levels để lấy Level hiện
// tại (curStepIdx + curLevel).
if (evaluation.ApprovalWorkflowId is not Guid awId)
{
// Phiếu V1 legacy không có Allow* → fallback Drafter (S17 behavior).
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
return mode == WorkflowReturnMode.Drafter
? "Trả về Người soạn thảo"
: $"Trả về Người soạn thảo (fallback — phiếu V1 không hỗ trợ mode '{mode}')";
}
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.");
var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
// Resolve Level hiện tại (slot Approver đang duyệt) — đọc Allow* từ slot
// này. Required cho mọi mode (kể cả Drafter — Approver hiện tại quyết
// định mode Trả lại theo flag riêng của slot).
ApprovalWorkflowLevel? currentLevel = null;
if (evaluation.CurrentWorkflowStepIndex is int csi && csi >= 0 && csi < stepsOrdered.Count)
{
var step = stepsOrdered[csi];
currentLevel = step.Levels.FirstOrDefault(l => l.Order == evaluation.CurrentApprovalLevelOrder);
}
// Validate Allow* flag từ Level slot hiện tại (Admin bypass)
if (!isAdmin && currentLevel is not null)
{
var allowed = mode switch
{
WorkflowReturnMode.OneLevel => currentLevel.AllowReturnOneLevel,
WorkflowReturnMode.OneStep => currentLevel.AllowReturnOneStep,
WorkflowReturnMode.Assignee => currentLevel.AllowReturnToAssignee,
WorkflowReturnMode.Drafter => currentLevel.AllowReturnToDrafter,
_ => false,
};
if (!allowed)
throw new ConflictException(
$"Cấp Approver hiện tại không bật mode '{mode}'. Liên hệ Admin Designer để config Level slot.");
}
// Mode Drafter — Session 17 default (Phase=TraLai clear pointer)
if (mode == WorkflowReturnMode.Drafter) 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.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null; evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null; evaluation.CurrentApprovalLevelOrder = null;
@ -208,38 +256,12 @@ public class PurchaseEvaluationWorkflowService(
return "Trả về Người soạn thảo"; return "Trả về Người soạn thảo";
} }
// 3 mode còn lại (OneLevel / OneStep / Assignee) — yêu cầu V2 schema + // 3 mode còn lại — yêu cầu pointer hợp lệ
// 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 if (evaluation.CurrentWorkflowStepIndex is not int curStepIdx
|| evaluation.CurrentApprovalLevelOrder is not int curLevel) || evaluation.CurrentApprovalLevelOrder is not int curLevel)
throw new ConflictException( throw new ConflictException(
$"Mode '{mode}' yêu cầu phiếu đang ChoDuyet + pointer init. " + $"Mode '{mode}' yêu cầu phiếu đang ChoDuyet + pointer init. " +
$"State hiện tại: Step={evaluation.CurrentWorkflowStepIndex}, Level={evaluation.CurrentApprovalLevelOrder}."); $"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; var summary = string.Empty;
switch (mode) switch (mode)