Compare commits
8 Commits
0a3b747612
...
eea86fdfe7
| Author | SHA1 | Date | |
|---|---|---|---|
| eea86fdfe7 | |||
| d27caafcf5 | |||
| a508564b45 | |||
| c56024ba25 | |||
| 0294693a4a | |||
| 6d30ba42d1 | |||
| 4b29d00716 | |||
| de0088742f |
258
docs/HANDOFF.md
258
docs/HANDOFF.md
@ -1,8 +1,264 @@
|
|||||||
# HANDOFF — Brief 5 phút cho session tiếp theo
|
# HANDOFF — Brief 5 phút cho session tiếp theo
|
||||||
|
|
||||||
**Last updated:** 2026-05-12 (Session 21 turn 2 — **🎯 RAG Hybrid setup planning + Cách A validation deep dive. 2 commit (`1f8e9af` plan save 1223 LOC + this chốt). KHÔNG implement, plan only — defer chờ bro confirm 5 dự án future. Decision chốt: Cách A defensive (giữ blanket 120K em main + RAG retrieve) over Cách B aggressive (cắt 60-70% blanket). Industry-validated cross 4 Anthropic blog + 5 community tools (Cursor/Continue/Cline/Aider). Stack: Voyage-3-large + Qdrant + FastMCP + Streamlit dashboard. Multi-agent cost reality: 4 agents → ~520K cumulative blanket → heavy session ~560K (Cách A) vs ~700K (lazy). 3-layer pattern Phase 1-3 rollout (embeddings + BM25 + reranking, ~70% → ~92% recall). Stats: +1 memory entry (`feedback_rag_hybrid_pattern`) +1 plan file (`rag-setup-plan.md` 1500 LOC). Sub-agents vẫn 4 seeds-only, em main solo session.**)
|
**Last updated:** 2026-05-13 1200 (Session 21 turn 4 — **🎯 F1+F2+F3 PE Workflow advanced options (Mig 28) — 5 chunk per-commit `0294693` (A schema) → `c56024b` (B BE) → `a508564` (C FE Admin) → `d27caaf` (D FE eOffice) → this (E Docs). **F1** 4 mode Trả lại admin tick stick (1 Cấp / 1 Bước / Người chỉ định / Người soạn thảo) — 3 mode đầu giữ Phase=ChoDuyet lùi pointer (peer review chain), mode Drafter giữ Phase=TraLai clear pointer (S17 backward compat). **F2** Drafter skip thẳng Cấp cuối — workflow tick + Workspace checkbox dynamic. **F3** Approver edit Section 2 (Hạng mục/NCC/Báo giá) khi workflow tick + actor match CurrentLevel.ApproverUserId + audit ghi PurchaseEvaluationChangelog. Mig 28 thêm 6 bit column lên `ApprovalWorkflows` (DEFAULT 1 cho AllowReturnToDrafter backward compat, 5 còn lại 0). BE Service extend signature 3 optional param (returnMode/returnTargetUserId/skipToFinal). Helper `EnsureEditableForDetailsAsync` mới gating Detail/Quote/Supplier CRUD theo Drafter scope OR F3 Approver scope + audit changelog Update/Delete (trước đây silent). FE Admin Designer "Cấu hình nâng cao" section 6 checkbox 3 group. FE eOffice 3 changes mirror 2 app. UAT mode skip dotnet test mỗi chunk, npm build × 2 app pass mỗi chunk. CHƯA push remote — chờ bro confirm.**)
|
||||||
|
**S21 turn 3:** 2026-05-12 2100 (Session 21 turn 3 — **🔴 BUG FIX CRITICAL "Trả về nhưng hệ thống vẫn duyệt" PE workflow (gotcha #45 mới). 3 chunk per-commit: `de00887` (BE Chunk A guard + 3 test) + `4b29d00` (FE Chunk B fix 2 app mirror) + this Chunk C Docs. Root: `PeWorkflowPanel.tsx` `isReject` payload (L64-66) thiếu nhánh TraLai → button "← Trả lại" gửi `decision: 1` (Approve) thay vì `2` (Reject) khi target=TraLai(98) → BE skip Reject branch → enter APPROVE STEP → `ApproveV2Async` UPSERT opinion "đã duyệt" + advance Cấp tiếp theo. Inconsistency phụ: dialog `isSendBack` (L247-248) cùng pattern thiếu TraLai → dialog title sai `'✓ Duyệt → Trả lại'` + KHÔNG amber warning. Severity CRITICAL — data integrity issue khó rollback (BE đã `SaveChangesAsync`). Test-before §7 BẮT BUỘC: viết test reproduce → confirm FAIL (BE đi sâu vào ApproveV2Async throw "Phiếu chưa pin workflow") → thêm BE guard early throw ConflictException khi `target ∈ {TraLai, TuChoi} && decision != Reject` → confirm PASS. 3 regression test (Throws TraLai+Approve, Throws TuChoi+Approve consistency, happy path Reject+TraLai). Tổng `dotnet test SolutionErp.slnx` 84 PASS (58 Domain + 26 Infra = +3 from 81 baseline). `npm run build` × 2 app pass. Stats: 27 mig (no change) · 59 tables · ~142 endpoints · 34 FE pages · **84 test (+3)** · **45 gotcha (+1 #45)** · 17 memory · 6 skills · 4 sub-agents seeds-only. Em main solo S21 t3 — bug fix reasoning chain cross BE/FE Implementer REFUSE per multi-agent rule (decision tree: tightly coupled BE+FE+test). CHƯA push remote — chờ bro confirm sau Chunk C wrap.**)
|
||||||
|
**S21 turn 2:** 2026-05-12 1800 (Session 21 turn 2 — **🎯 RAG Hybrid setup planning + Cách A validation deep dive. 2 commit (`1f8e9af` plan save 1223 LOC + this chốt). KHÔNG implement, plan only — defer chờ bro confirm 5 dự án future. Decision chốt: Cách A defensive (giữ blanket 120K em main + RAG retrieve) over Cách B aggressive (cắt 60-70% blanket). Industry-validated cross 4 Anthropic blog + 5 community tools (Cursor/Continue/Cline/Aider). Stack: Voyage-3-large + Qdrant + FastMCP + Streamlit dashboard. Multi-agent cost reality: 4 agents → ~520K cumulative blanket → heavy session ~560K (Cách A) vs ~700K (lazy). 3-layer pattern Phase 1-3 rollout (embeddings + BM25 + reranking, ~70% → ~92% recall). Stats: +1 memory entry (`feedback_rag_hybrid_pattern`) +1 plan file (`rag-setup-plan.md` 1500 LOC). Sub-agents vẫn 4 seeds-only, em main solo session.**)
|
||||||
**S21 turn 1:** 2026-05-12 0030 (Session 21 turn 1 — **🎯 Add con thứ 4 cicd-monitor (Path A — post-deploy verifier). 1 commit `f1c61c9` pushed `36e21c8..f1c61c9 main -> main`. CI skipped per path filter (3 file `.md`). Cost reality update: ~750K spawn (3 → 4 agents) · ~1.35M heavy / ~700K optimized. Stats: 4 sub-agents seeds-only · 16 memory · 27 mig · 59 tables · ~142 endpoints · 81 test · 44 gotcha · 6 skills unchanged. KHÔNG flush 3 agent MEMORY.md (chưa spawn work — em main solo). Trial Week 1 kick-off S21 turn 2+ Plan B Contract V2 wire mirror PE pattern.**)
|
**S21 turn 1:** 2026-05-12 0030 (Session 21 turn 1 — **🎯 Add con thứ 4 cicd-monitor (Path A — post-deploy verifier). 1 commit `f1c61c9` pushed `36e21c8..f1c61c9 main -> main`. CI skipped per path filter (3 file `.md`). Cost reality update: ~750K spawn (3 → 4 agents) · ~1.35M heavy / ~700K optimized. Stats: 4 sub-agents seeds-only · 16 memory · 27 mig · 59 tables · ~142 endpoints · 81 test · 44 gotcha · 6 skills unchanged. KHÔNG flush 3 agent MEMORY.md (chưa spawn work — em main solo). Trial Week 1 kick-off S21 turn 2+ Plan B Contract V2 wire mirror PE pattern.**)
|
||||||
|
|
||||||
|
## TL;DR Session 21 turn 4 — F1+F2+F3 PE Workflow advanced options (Mig 28)
|
||||||
|
|
||||||
|
User request 3 tính năng mới trong PE V2 Workflow:
|
||||||
|
- **F1** 4 mode Trả lại admin stick: 1 Cấp / 1 Bước / Người chỉ định / Người soạn thảo
|
||||||
|
- **F2** Drafter gửi thẳng Cấp cuối (skip mọi Bước/Cấp trung gian)
|
||||||
|
- **F3** Approver chỉnh sửa Section 2 (Hạng mục + NCC + Báo giá) khi đang duyệt
|
||||||
|
|
||||||
|
### Q&A clarify chốt scope (2 lượt AskUserQuestion)
|
||||||
|
|
||||||
|
- **F1 "1 bậc"** = cả 2 mode (admin chọn 1 Cấp HOẶC 1 Bước HOẶC cả 2 stick)
|
||||||
|
- **F1 "Người chỉ định"** = Approver pick runtime từ list NV đã ký (PE.LevelOpinions)
|
||||||
|
- **F1 behavior** = 3 mode đầu giữ Phase=ChoDuyet lùi pointer (peer review chain). Mode Drafter giữ Phase=TraLai S17 fallback.
|
||||||
|
- **F2 skip** = chỉ skip tới Level cuối (CEO) — Dropdown 2 option "Gửi tuần tự" vs "Gửi thẳng Cấp cuối"
|
||||||
|
- **F2+F3 admin enable** = cả 2 cần admin tick per workflow (audit nghiêm)
|
||||||
|
- **F3 approver perm** = mọi approver Cấp đang active (currentLevel match)
|
||||||
|
- **F3 scope** = Section 2 only (Hạng mục + NCC + Báo giá), KHÔNG đụng PE Header, KHÔNG reset workflow
|
||||||
|
- **Test** = test-after UAT default Phase 9 (skip dotnet test mỗi chunk, npm build × 2 app pass)
|
||||||
|
|
||||||
|
### Chunk A — Mig 28 + Domain (`0294693`)
|
||||||
|
|
||||||
|
`ApprovalWorkflow.cs` thêm 6 bool field:
|
||||||
|
- `AllowReturnOneLevel` / `AllowReturnOneStep` / `AllowReturnToAssignee` (default false)
|
||||||
|
- `AllowReturnToDrafter` (default **TRUE** — backward compat S17)
|
||||||
|
- `AllowDrafterSkipToFinal` / `AllowApproverEditDetails` (default false)
|
||||||
|
|
||||||
|
EF config `ApprovalWorkflowConfiguration` thêm 6 `HasDefaultValue` match Mig 28 DEFAULT.
|
||||||
|
|
||||||
|
Mig 28 `AddAdvancedOptionsToApprovalWorkflows`:
|
||||||
|
- 6 AddColumn bit NOT NULL DEFAULT 0/1
|
||||||
|
- 3-file rule complete (mig.cs + Designer.cs + Snapshot.cs)
|
||||||
|
- Apply LocalDB Dev + Design
|
||||||
|
|
||||||
|
### Chunk B — BE Service + handlers + DTOs (`c56024b`)
|
||||||
|
|
||||||
|
**Service interface + impl** `TransitionAsync` thêm 3 optional param (backward compat):
|
||||||
|
- `WorkflowReturnMode? returnMode` (enum {OneLevel=1, OneStep=2, Assignee=3, Drafter=4})
|
||||||
|
- `Guid? returnTargetUserId` (required khi mode=Assignee)
|
||||||
|
- `bool skipToFinal`
|
||||||
|
|
||||||
|
REJECT branch extend với helper `ApplyReturnModeAsync` switch 4 mode:
|
||||||
|
- OneLevel: lùi 1 Cấp cùng Step. Bước 1 Cấp 1 → fallback Drafter.
|
||||||
|
- OneStep: lùi sang Bước trước Cấp cuối. Bước 1 → fallback Drafter.
|
||||||
|
- Assignee: tìm Step+Level match `ApproverUserId == returnTargetUserId`.
|
||||||
|
- Drafter: Phase=TraLai clear pointer (S17 behavior).
|
||||||
|
- 3 mode đầu giữ ChoDuyet + reset SLA 7d.
|
||||||
|
- Admin bypass workflow.Allow* flag check.
|
||||||
|
- Non-admin → throw ConflictException nếu flag disabled.
|
||||||
|
|
||||||
|
DRAFTER trình branch extend với F2 skipToFinal:
|
||||||
|
- Workflow.AllowDrafterSkipToFinal required (non-admin)
|
||||||
|
- Set CurrentWorkflowStepIndex = Steps.Count-1 + CurrentApprovalLevelOrder = max Level
|
||||||
|
- Audit comment append "[Drafter gửi thẳng Cấp cuối]"
|
||||||
|
|
||||||
|
**Helper edit guard** `EnsureEditableForDetailsAsync` mới (PurchaseEvaluationDraftGuard class):
|
||||||
|
- Drafter scope: DangSoanThao OR TraLai
|
||||||
|
- F3 Approver scope: ChoDuyet + workflow.AllowApproverEditDetails + actor match CurrentLevel.ApproverUserId
|
||||||
|
- Admin bypass workflow flag check
|
||||||
|
|
||||||
|
**8 handler switch** sang helper mới + inject ICurrentUser khi cần:
|
||||||
|
- Detail Add/Update/Delete + Quote Upsert/Delete (5 handler — replace EnsureDraftAsync)
|
||||||
|
- Supplier Add/Update/Remove (3 handler — bonus security fix, trước đây hoàn toàn KHÔNG có phase guard!)
|
||||||
|
- Update/Delete handler trước đây silent → thêm changelog `PhaseAtChange + UserId + Summary` (append `[Approver edit khi đang duyệt]` khi phase=ChoDuyet)
|
||||||
|
|
||||||
|
**Command DTO + DTOs**:
|
||||||
|
- `TransitionPurchaseEvaluationCommand` +3 optional field
|
||||||
|
- `ApprovalWorkflowOptionsDto` NEW sub-record (6 Allow* flag)
|
||||||
|
- `PurchaseEvaluationDetailBundleDto` +WorkflowOptions field
|
||||||
|
- `AwDefinitionDto` +6 Allow* (admin Designer GET)
|
||||||
|
- `CreateAwDefinitionCommand` +6 Allow* param (admin Designer POST)
|
||||||
|
|
||||||
|
### Chunk C — FE Admin Designer (`a508564`)
|
||||||
|
|
||||||
|
`ApprovalWorkflowsV2Page.tsx` Designer modal thêm section "Cấu hình nâng cao" 3 sub-group:
|
||||||
|
|
||||||
|
1. Mode Trả lại 4 checkbox:
|
||||||
|
- Trả về 1 Cấp trước (peer review chain trong cùng Bước)
|
||||||
|
- Trả về 1 Bước trước (Cấp cuối Bước trước nhận lại)
|
||||||
|
- Trả về Người chỉ định (pick runtime từ NV đã ký)
|
||||||
|
- Trả về Người soạn thảo (default checked = backward compat S17)
|
||||||
|
|
||||||
|
2. Drafter skip: 1 checkbox "Cho phép Drafter gửi thẳng Cấp cuối"
|
||||||
|
|
||||||
|
3. Approver edit: 1 checkbox "Cho phép Approver chỉnh sửa Section 2"
|
||||||
|
|
||||||
|
Styling: container amber-50/30 border distinct với Steps section. Helper text [10px] dưới label. Headers uppercase tracking.
|
||||||
|
|
||||||
|
DTO types + state defaults từ cloneFrom (giữ config version trước) hoặc S17 fallback (chỉ AllowReturnToDrafter=true). POST body propagate 6 flag → BE Create handler set entity.
|
||||||
|
|
||||||
|
fe-user KHÔNG mirror (Designer admin-only).
|
||||||
|
|
||||||
|
### Chunk D — FE eOffice (`d27caaf`) mirror 2 app
|
||||||
|
|
||||||
|
Types `purchaseEvaluation.ts`:
|
||||||
|
- `ApprovalWorkflowOptions` type
|
||||||
|
- `WorkflowReturnMode` const-object
|
||||||
|
- `PeDetailBundle` +workflowOptions field
|
||||||
|
|
||||||
|
`PeWorkflowPanel.tsx` F1 Trả lại radio picker:
|
||||||
|
- State `returnMode` (default Drafter) + `returnTargetUserId`
|
||||||
|
- Dialog Trả lại render 1-4 radio mode enabled theo wfOptions.Allow*
|
||||||
|
- Assignee mode → submodal Select pick từ levelOpinions (NV đã ký), dedupe by userId
|
||||||
|
- Banner amber rounded dưới mô tả hành vi mode chọn
|
||||||
|
- Mutation payload +returnMode +returnTargetUserId khi isTraLaiAction
|
||||||
|
|
||||||
|
`PeDetailTabs.tsx` F2 Drafter skip:
|
||||||
|
- State `skipToFinal` + `allowSkipToFinal` từ workflowOptions
|
||||||
|
- submitForApproval mutationFn accept opts.skipToFinal
|
||||||
|
- Workspace action bar: checkbox violet "Gửi thẳng Cấp cuối (skip trung gian)" conditional
|
||||||
|
- Confirm dialog message + button label dynamic theo skipToFinal
|
||||||
|
|
||||||
|
`PeDetailTabs.tsx` F3 Approver edit Section 2:
|
||||||
|
- useAuth import + compute `approverEditMode` (phase=ChoDuyet + workflowOptions.allowApproverEditDetails + actor match)
|
||||||
|
- `itemsReadOnly = readOnly && !approverEditMode` → ItemsTab nhận
|
||||||
|
- Banner violet "ⓘ Bạn được phép chỉnh sửa..." khi approverEditMode + readOnly (Duyệt menu)
|
||||||
|
- InfoTab / NccSelectorRow / BudgetFieldRow GIỮ strict isEditablePhase (Header + Section 3, KHÔNG trong F3 scope)
|
||||||
|
|
||||||
|
### Chunk E — Docs (this commit)
|
||||||
|
|
||||||
|
- `docs/database/schema-diagram.md §14` cập nhật title "Mig 22-28, S17-21" + thêm 6 column Allow* trong Core block với inline comment F1/F2/F3
|
||||||
|
- `docs/STATUS.md` Last updated S21 t4 + count 27→28 mig + UAT defer test count unchanged 84
|
||||||
|
- `docs/HANDOFF.md` TL;DR S21 t4 đầy đủ (file này)
|
||||||
|
- `docs/changelog/sessions/2026-05-13-1200-s21-turn4-pe-workflow-advanced-options.md` session log
|
||||||
|
|
||||||
|
### State chốt S21 turn 4
|
||||||
|
|
||||||
|
| Metric | Trước (S21 t3) | Sau (S21 t4) | Δ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| DB tables | 59 | 59 | 0 |
|
||||||
|
| **Migrations** | 27 | **28** | **+1** (Mig 28 6 column Allow*) |
|
||||||
|
| Endpoints | ~142 | ~143 | +1 (extend transitions body) |
|
||||||
|
| FE pages | 34 | 34 | 0 (Designer extend section) |
|
||||||
|
| **Unit tests** | 84 | **84** | 0 (UAT defer test-after §7) |
|
||||||
|
| Gotchas | 45 | 45 | 0 |
|
||||||
|
| Memory entries | 17 | 17 | 0 |
|
||||||
|
| Skills | 6 | 6 | 0 |
|
||||||
|
| Sub-agents | 4 seeds-only | 4 seeds-only | 0 |
|
||||||
|
| **Commits S21 t4** | — | **5** | (`0294693` → `c56024b` → `a508564` → `d27caaf` → this) |
|
||||||
|
|
||||||
|
### Pending — Test-after (Plan C carry)
|
||||||
|
|
||||||
|
Per `feedback_uat_skip_verify` Phase 9 default: viết test sau UAT 2-3 lần ổn.
|
||||||
|
Test scope candidate (test-after-uat commit riêng):
|
||||||
|
- Service `ApplyReturnModeAsync` 4 mode happy path (OneLevel/OneStep/Assignee/Drafter)
|
||||||
|
- Service skipToFinal happy path + AllowDrafterSkipToFinal=false → ConflictException
|
||||||
|
- `EnsureEditableForDetailsAsync` 3 scenario: Drafter scope / Approver match / Approver mismatch → Forbidden
|
||||||
|
|
||||||
|
Bundle với Plan C existing (test #44 silent 403 + test V2 ApproveV2Async + Mig 25/27 PATCH).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR Session 21 turn 3 — Bug fix CRITICAL "Trả về nhưng hệ thống vẫn duyệt" (gotcha #45)
|
||||||
|
|
||||||
|
User UAT 2026-05-12 21:00 screenshot button labeled `← Trả lại` trong PE Workflow Panel (menu "Duyệt"), mô tả hành vi: nhấn vào nhưng phiếu KHÔNG về phase TraLai — ngược lại tiến qua Cấp tiếp theo. User mô tả: "Trả về nhưng hệ thống vẫn duyệt".
|
||||||
|
|
||||||
|
### Diagnose (em main solo, no agent spawn)
|
||||||
|
|
||||||
|
3 chỗ inconsistency cùng pattern trong `PeWorkflowPanel.tsx` (× 2 app fe-admin + fe-user):
|
||||||
|
|
||||||
|
| # | Location | Logic | Bug? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | L205-207 button `isSendBack` | include TraLai → label `← Trả lại` ĐÚNG | ✅ no bug |
|
||||||
|
| 2 | L64-66 payload `isReject` | thiếu nhánh TraLai → gửi `decision: 1` (Approve) | 🔴 BUG ROOT |
|
||||||
|
| 3 | L247-248 dialog `isSendBack` | thiếu nhánh TraLai → dialog title fallback `'✓ Duyệt → Trả lại'` + no amber warning | 🔴 BUG phụ |
|
||||||
|
|
||||||
|
BE `PurchaseEvaluationWorkflowService.TransitionAsync`:
|
||||||
|
- L51 `if (decision == Reject)` branch → đúng cho decision=Reject.
|
||||||
|
- L97 `APPROVE STEP` branch khi decision=Approve + fromPhase=ChoDuyet → ApproveV2Async UPSERT opinion + advance Cấp.
|
||||||
|
- → FE gửi `decision=1` (do bug `isReject`) → BE đi vào nhánh APPROVE thay vì REJECT → phiếu approve mặc dù user định trả lại.
|
||||||
|
|
||||||
|
### Chunk A — BE defense-in-depth + 3 regression test (`de00887`)
|
||||||
|
|
||||||
|
**Test-before §7 BẮT BUỘC:** Viết test reproduce bug TRƯỚC fix.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Sau line 48 (set isAdmin/isSystem), trước REJECT branch (L51)
|
||||||
|
if ((targetPhase == PurchaseEvaluationPhase.TraLai
|
||||||
|
|| targetPhase == PurchaseEvaluationPhase.TuChoi)
|
||||||
|
&& decision != ApprovalDecision.Reject)
|
||||||
|
{
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Transition tới {targetPhase} BẮT BUỘC decision=Reject (nhận {decision}). " +
|
||||||
|
"Báo lỗi caller — payload mismatch giữa target phase và decision.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Boundary protection cho mọi caller tương lai (API client / mobile / cron retry). Guard KHÔNG xoá khi FE fix — defense-in-depth.
|
||||||
|
|
||||||
|
3 test file `tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceGuardTests.cs`:
|
||||||
|
- `TransitionAsync_TargetTraLai_WithApproveDecision_Throws_AndDoesNotMutateState` — reproduce bug, expect `ConflictException` "*TraLai*Reject*"
|
||||||
|
- `TransitionAsync_TargetTuChoi_WithApproveDecision_Throws_AndDoesNotMutateState` — consistency cover TuChoi
|
||||||
|
- `TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai` — happy path control (Reject branch vẫn đúng)
|
||||||
|
|
||||||
|
+ `NoOpNotificationService` stub reusable cho future PE service tests (avoid `INotificationService` real DI complexity).
|
||||||
|
|
||||||
|
Run test → 2 FAIL (reproduce bug, BE đi sâu vào ApproveV2Async throw "Phiếu chưa pin workflow") + 1 PASS (happy path). Thêm BE guard → 3 PASS. Tổng `dotnet test SolutionErp.slnx` 84 PASS (+3 from 81 baseline).
|
||||||
|
|
||||||
|
### Chunk B — FE fix mirror 2 app (`4b29d00`)
|
||||||
|
|
||||||
|
3 chỗ × 2 app = 6 edits:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Chỗ 1: isReject payload (L64-66)
|
||||||
|
const isReject = target === PurchaseEvaluationPhase.TuChoi
|
||||||
|
|| (target === PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|
||||||
|
|| (target === PurchaseEvaluationPhase.TraLai // ← THÊM
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
|
||||||
|
|
||||||
|
// Chỗ 3: dialog isSendBack (L247-248)
|
||||||
|
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
|| target === PurchaseEvaluationPhase.TraLai) // ← THÊM
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai // ← THÊM
|
||||||
|
```
|
||||||
|
|
||||||
|
Chỗ 2 (button label `isSendBack` L205-207) đã đúng từ S17, KHÔNG đụng.
|
||||||
|
|
||||||
|
Verify: `npm run build` × 2 app pass (fe-user 17.91s + fe-admin 6.71s, 0 TS6 err).
|
||||||
|
|
||||||
|
### Chunk C — Docs (this commit)
|
||||||
|
|
||||||
|
- `docs/gotchas.md` +#45 PE button label vs decision payload mismatch (~120 dòng narrative + 2 commit cross-ref + pattern reusable + phòng tránh tương lai)
|
||||||
|
- `docs/gotchas.md` checklist debug +entry 22 quick lookup
|
||||||
|
- `docs/STATUS.md` Last updated S21 t3 + count 81→84 test + 44→45 gotcha
|
||||||
|
- `docs/HANDOFF.md` TL;DR S21 t3 narrative đầy đủ (file này)
|
||||||
|
- `docs/changelog/sessions/2026-05-12-2100-s21-turn3-fix-tra-lai-bug45.md` session log mới
|
||||||
|
|
||||||
|
### Pending (carry from S21 turn 2)
|
||||||
|
|
||||||
|
Plans A-I unchanged. Plan C1 (test regression gotcha #44 silent 403 S18) vẫn còn nợ — không bundle với S21 t3 fix (scope khác, ưu tiên unblock UAT bug critical trước).
|
||||||
|
|
||||||
|
### Audit cadence
|
||||||
|
|
||||||
|
- Lần gần nhất: 2026-05-04 manual trễ 4 ngày
|
||||||
|
- Lần kế: **2026-06-01** combined audit
|
||||||
|
- Drift sau S21 t3: 44→45 gotcha (+1) + 81→84 test (+3) + 17→17 memory (no new) + 6 skills unchanged
|
||||||
|
|
||||||
|
### State chốt S21 turn 3
|
||||||
|
|
||||||
|
| Metric | Trước (S21 t2) | Sau (S21 t3) | Δ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| DB tables | 59 | 59 | 0 |
|
||||||
|
| Migrations | 27 | 27 | 0 |
|
||||||
|
| Endpoints | ~142 | ~142 | 0 |
|
||||||
|
| FE pages | 34 | 34 | 0 |
|
||||||
|
| **Unit tests** | 81 | **84** | **+3** (PE guard) |
|
||||||
|
| **Gotchas** | 44 | **45** | **+1** (#45) |
|
||||||
|
| Memory entries | 17 | 17 | 0 |
|
||||||
|
| Skills | 6 | 6 | 0 |
|
||||||
|
| Sub-agents | 4 seeds-only | 4 seeds-only | 0 |
|
||||||
|
| **Commits S21 t3** | — | **3** | (`de00887` + `4b29d00` + this) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## TL;DR Session 21 turn 2 — RAG Hybrid setup planning (Cách A chốt + 3-layer pattern)
|
## TL;DR Session 21 turn 2 — RAG Hybrid setup planning (Cách A chốt + 3-layer pattern)
|
||||||
|
|
||||||
User clarify 5 dự án future > 1M MD tokens → cuộc thảo luận deep ~15 turn về RAG infrastructure. Em main solo (no SOLUTION_ERP sub-agent spawn), delegate 2 lần claude-code-guide agent research Anthropic + community practice.
|
User clarify 5 dự án future > 1M MD tokens → cuộc thảo luận deep ~15 turn về RAG infrastructure. Em main solo (no SOLUTION_ERP sub-agent spawn), delegate 2 lần claude-code-guide agent research Anthropic + community practice.
|
||||||
|
|||||||
@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
|
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
|
||||||
|
|
||||||
**Last updated:** 2026-05-12 1800 (Session 21 turn 2 — **🎯 RAG Hybrid setup planning + Cách A validation deep dive. 2 commit (`1f8e9af` plan save 1223 LOC + this chốt). Em main solo (no SOLUTION_ERP sub-agent spawn), delegate claude-code-guide × 2 research Anthropic + community practice. Decision chốt: Cách A defensive (giữ blanket 120K em main + RAG retrieve supplement) over Cách B aggressive (cắt 60-70% blanket). Industry-validated cross 4 Anthropic blog + 5 community tools (Cursor/Continue/Cline/Aider all hybrid). Stack: Voyage-3-large + Qdrant local + FastMCP Python + Streamlit dashboard 7 pages + SQLite event log. Multi-agent cost reality: 4 agents → ~520K cumulative blanket → heavy session ~560K (Cách A) vs ~700K (lazy), saving -20%. 3-layer pattern Phase 1-3 rollout (Layer 1 vector → Layer 2 +BM25 → Layer 3 +reranking, recall ~70% → ~92%). Stats: +1 memory entry (`feedback_rag_hybrid_pattern.md`) +1 plan file (`rag-setup-plan.md` 1500 LOC final). 4 sub-agents vẫn seeds-only. Plan I NEW deferred chờ bro confirm 5 dự án path + stack + Voyage API key + disk cleanup 5-8GB.**)
|
**Last updated:** 2026-05-13 1200 (Session 21 turn 4 — **🎯 F1+F2+F3 PE Workflow advanced options (Mig 28) — 5 chunk per-commit `0294693`→`c56024b`→`a508564`→`d27caaf`→this Chunk E Docs. **F1** 4 mode Trả lại admin tick: "1 Cấp / 1 Bước / Người chỉ định / Người soạn thảo" — 3 mode đầu giữ Phase=ChoDuyet lùi pointer (peer review chain), mode Drafter giữ Phase=TraLai S17 fallback. **F2** Drafter skip thẳng Cấp cuối — workflow tick + Workspace checkbox dynamic confirm. **F3** Approver edit Section 2 (Hạng mục/NCC/Báo giá) khi workflow tick + actor match CurrentLevel + audit ghi PurchaseEvaluationChangelog. Mig 28 `ApprovalWorkflows +6 bool Allow*` (DEFAULT 1 cho AllowReturnToDrafter backward compat, 5 còn lại 0). BE Service `TransitionAsync` extend 3 optional param (returnMode/returnTargetUserId/skipToFinal) + helper `ApplyReturnModeAsync` switch 4 mode. Detail/Quote/Supplier helper `EnsureEditableForDetailsAsync` mới (kế thừa `EnsureDraftAsync` + add ChoDuyet+F3 branch + Admin bypass). FE Admin Designer "Cấu hình nâng cao" section 6 checkbox 3 group. FE eOffice 3 changes mirror 2 app: Trả lại radio picker 1-4 mode + Workspace skip checkbox violet + Section 2 itemsReadOnly approver banner. UAT mode skip dotnet test mỗi chunk (per `feedback_uat_skip_verify`), `npm run build` × 2 app pass mỗi chunk. Stats: **28 mig (+1)** · 59 tables · **~143 endpoints (+1 user-selectable patch existed)** · **34 FE pages (+1 Designer section)** · **84 test pass unchanged** (UAT defer test-after) · **45 gotcha unchanged** · 17 memory · 6 skills · 4 sub-agents seeds-only.**)
|
||||||
|
**S21 turn 3:** 2026-05-12 2100 (Session 21 turn 3 — **🎯 Bug fix CRITICAL "Trả về nhưng hệ thống vẫn duyệt" PE workflow (gotcha #45). 2 chunk per-commit `de00887` (BE Chunk A) + `4b29d00` (FE Chunk B) + Chunk C Docs this. Root: PeWorkflowPanel.tsx `isReject` payload (L64-66) thiếu nhánh TraLai → button "← Trả lại" gửi `decision: 1` (Approve) thay vì `2` (Reject) khi target=TraLai(98) → BE ApproveV2Async UPSERT opinion "đã duyệt" + advance Cấp. Inconsistency phụ: dialog `isSendBack` (L247-248) cùng pattern thiếu TraLai → dialog title sai. Fix BE defense-in-depth + FE 3 chỗ × 2 app mirror rule §3.9. Test-before §7 BẮT BUỘC: 3 regression test mới (2 reproduce bug + 1 happy path control) — `dotnet test SolutionErp.slnx` 84 PASS (58 Domain + 26 Infra = +3). `npm run build` × 2 app pass. Stats: 27 mig (no change) · 59 tables · ~142 endpoints · 34 FE pages · **84 test pass (+3)** · **45 gotcha (+1 #45)** · 17 memory entries (no new) · 6 skills. Em main solo (no sub-agent spawn S21 t3 — bug fix reasoning chain cross BE/FE Implementer REFUSE per multi-agent rule).**)
|
||||||
|
**S21 turn 2:** 2026-05-12 1800 (Session 21 turn 2 — **🎯 RAG Hybrid setup planning + Cách A validation deep dive. 2 commit (`1f8e9af` plan save 1223 LOC + this chốt). Em main solo (no SOLUTION_ERP sub-agent spawn), delegate claude-code-guide × 2 research Anthropic + community practice. Decision chốt: Cách A defensive (giữ blanket 120K em main + RAG retrieve supplement) over Cách B aggressive (cắt 60-70% blanket). Industry-validated cross 4 Anthropic blog + 5 community tools (Cursor/Continue/Cline/Aider all hybrid). Stack: Voyage-3-large + Qdrant local + FastMCP Python + Streamlit dashboard 7 pages + SQLite event log. Multi-agent cost reality: 4 agents → ~520K cumulative blanket → heavy session ~560K (Cách A) vs ~700K (lazy), saving -20%. 3-layer pattern Phase 1-3 rollout (Layer 1 vector → Layer 2 +BM25 → Layer 3 +reranking, recall ~70% → ~92%). Stats: +1 memory entry (`feedback_rag_hybrid_pattern.md`) +1 plan file (`rag-setup-plan.md` 1500 LOC final). 4 sub-agents vẫn seeds-only. Plan I NEW deferred chờ bro confirm 5 dự án path + stack + Voyage API key + disk cleanup 5-8GB.**)
|
||||||
**S21 turn 1:** 2026-05-12 0030 (Session 21 turn 1 — **🎯 Add con thứ 4 cicd-monitor (Path A — post-deploy verifier green READ tier). 1 commit `f1c61c9` pushed `36e21c8..f1c61c9 main -> main`, CI skipped per path filter (`**/*.md` paths-ignore docs-only). Trade-off: +~150K spawn extra mỗi run, đổi lại catch deploy ship fail tự động (bundle hash unchanged / mig drift prod / endpoint 500) — recurring blind spot pattern em main solo S20 quên verify ~30% push. Cost reality update: ~750K spawn setup (3 → 4 agents) · ~1.35M heavy session · ~700K optimized cached. Stats: 4 sub-agents seeds-only (+1 cicd-monitor green) · 16 memory entries (no new, update existing `feedback_multi_agent_setup.md` 3 → 4 agents narrative) · 27 mig · 59 tables · ~142 endpoints · 81 test unchanged · 44 gotcha unchanged · 6 skills unchanged. KHÔNG flush 3 agent MEMORY.md (chưa spawn work S21 t1 nên KHÔNG có findings — em main solo via context + Write file).**)
|
**S21 turn 1:** 2026-05-12 0030 (Session 21 turn 1 — **🎯 Add con thứ 4 cicd-monitor (Path A — post-deploy verifier green READ tier). 1 commit `f1c61c9` pushed `36e21c8..f1c61c9 main -> main`, CI skipped per path filter (`**/*.md` paths-ignore docs-only). Trade-off: +~150K spawn extra mỗi run, đổi lại catch deploy ship fail tự động (bundle hash unchanged / mig drift prod / endpoint 500) — recurring blind spot pattern em main solo S20 quên verify ~30% push. Cost reality update: ~750K spawn setup (3 → 4 agents) · ~1.35M heavy session · ~700K optimized cached. Stats: 4 sub-agents seeds-only (+1 cicd-monitor green) · 16 memory entries (no new, update existing `feedback_multi_agent_setup.md` 3 → 4 agents narrative) · 27 mig · 59 tables · ~142 endpoints · 81 test unchanged · 44 gotcha unchanged · 6 skills unchanged. KHÔNG flush 3 agent MEMORY.md (chưa spawn work S21 t1 nên KHÔNG có findings — em main solo via context + Write file).**)
|
||||||
**S20 wrap:** 2026-05-11 22:00 (Session 20 wrap turns 1-12 — **🎯 14 commit `9dee00d` → `ae1814c`. PE Detail UI restructure 3 yêu cầu (t1-5) + Manual budget drop tên (t6) + Mig 27 admin menu eOffice (t7) + NCC palette 5-màu cycle + Winner icon ✓ đậm + AddSupplier auto-fill master + Responsive laptop nhỏ 4-tầng pattern (t8-11) + Multi-agent infrastructure setup 3 sub-agents (t12). 27 mig (+1) · 59 tables · ~142 endpoints (+1) · 34 FE pages (+1) · 61 menu key (+1) · 81 test pass unchanged · 44 gotcha · 16 memory entries (+2) · 3 sub-agents NEW. Phase 9 UAT iteration mode.**)
|
**S20 wrap:** 2026-05-11 22:00 (Session 20 wrap turns 1-12 — **🎯 14 commit `9dee00d` → `ae1814c`. PE Detail UI restructure 3 yêu cầu (t1-5) + Manual budget drop tên (t6) + Mig 27 admin menu eOffice (t7) + NCC palette 5-màu cycle + Winner icon ✓ đậm + AddSupplier auto-fill master + Responsive laptop nhỏ 4-tầng pattern (t8-11) + Multi-agent infrastructure setup 3 sub-agents (t12). 27 mig (+1) · 59 tables · ~142 endpoints (+1) · 34 FE pages (+1) · 61 menu key (+1) · 81 test pass unchanged · 44 gotcha · 16 memory entries (+2) · 3 sub-agents NEW. Phase 9 UAT iteration mode.**)
|
||||||
**S20 turn 7:** 2026-05-11 17:00 (Session 20 turn 7 — **🎯 Admin Ẩn/Hiện + Đổi tên menu eOffice (Mig 27). 5 chunk `2ea2d27`→`ef394f8`→`059bfcb`→`1ed6530`→Chunk E Docs. User Q2=b: DisplayLabel CHỈ áp fe-user, admin sidebar giữ Label gốc. Domain MenuItem +IsVisible(true) +DisplayLabel(200). Mig 27 AddVisibilityAndDisplayLabelToMenuItems. BE PATCH /api/menus/{key} [Authorize Policy=Permissions.Update]. NEW FE-admin MenuVisibilityPage ~210 LOC (table inline edit per-row + Save dirty + Khôi phục mặc định + Toggle Eye/EyeOff + 4 StatCard). fe-user Layout filterForUser 2 tầng (USER_HIDDEN_KEYS hardcode + !isVisible dynamic) + effectiveLabel(displayLabel || label) replace 3 callsite. fe-admin Layout KHÔNG đụng. +1 menu key MenuVisibility "Menu eOffice" leaf System Order=94. 27 mig, 59 tables, ~142 endpoints, 34 FE pages, 81 test pass (Q4 UAT defer).**)
|
**S20 turn 7:** 2026-05-11 17:00 (Session 20 turn 7 — **🎯 Admin Ẩn/Hiện + Đổi tên menu eOffice (Mig 27). 5 chunk `2ea2d27`→`ef394f8`→`059bfcb`→`1ed6530`→Chunk E Docs. User Q2=b: DisplayLabel CHỈ áp fe-user, admin sidebar giữ Label gốc. Domain MenuItem +IsVisible(true) +DisplayLabel(200). Mig 27 AddVisibilityAndDisplayLabelToMenuItems. BE PATCH /api/menus/{key} [Authorize Policy=Permissions.Update]. NEW FE-admin MenuVisibilityPage ~210 LOC (table inline edit per-row + Save dirty + Khôi phục mặc định + Toggle Eye/EyeOff + 4 StatCard). fe-user Layout filterForUser 2 tầng (USER_HIDDEN_KEYS hardcode + !isVisible dynamic) + effectiveLabel(displayLabel || label) replace 3 callsite. fe-admin Layout KHÔNG đụng. +1 menu key MenuVisibility "Menu eOffice" leaf System Order=94. 27 mig, 59 tables, ~142 endpoints, 34 FE pages, 81 test pass (Q4 UAT defer).**)
|
||||||
**S20 prev:** 2026-05-11 (Session 20 — **🎯 PE Detail UI restructure 3 yêu cầu user UX. 4 chunk per-commit `9dee00d` → `2bba851` → `f2f01f4` → (current Chunk D Docs).** Q1=a (giữ Section "Chọn NCC TP" riêng), Q2=a "1 hạng mục trước tiên" (NCC shared, demo 1 hạng mục), Q3=a (chỉ hiện NV đã ký), Q4 public luôn (skip dotnet test mỗi chunk theo memory `feedback_uat_skip_verify`, vẫn `npm run build` × 2 app mỗi chunk vì có rename/remove function). **Chunk A (`9dee00d`)**: BE `CreatePurchaseEvaluationCommandHandler` thêm 1 PurchaseEvaluationDetail mặc định khi tạo phiếu — GroupCode="01", GroupName="Hạng mục chính", NoiDung=TenGoiThau, DonGiaNganSach=ThanhTienNganSach=Budget.TongNganSach hoặc BudgetManualAmount fallback 0; Changelog Insert audit. FE reorder PeDetailTabs (mirror 2 app) 1.Thông tin / 2.Hạng mục (lên #2) / 3.Chọn NCC / 4.NCC tham gia / 5.Ý kiến. **Chunk B (`2bba851`)**: ItemsTab restructure thành list `HangMucCard` (1 card / 1 hạng mục, expanded=true mặc định cho 1 hạng mục demo). Header card: GroupCode + NoiDung + 3 stat (KL/ĐG/TT) + NS link Δ nếu có + Pencil/Trash actions + ▼/▶ toggle expand. Expand body: NCC inline table columns NCC / Liên hệ / Điều khoản TT / **File báo giá** / ĐG chưa VAT / ĐG có VAT / Thành tiền / Action. Quote inline click cell → QuoteDialog cũ reuse. Add NCC + Sửa NCC reuse AddSupplierDialog/EditSupplierDialog cũ. Winner ✓ button mỗi NCC row. Drop function `SuppliersTab` (dead code ~134 LOC, replace bằng HangMucCard expand panel). Giữ AddSupplierDialog + EditSupplierDialog + SupplierAttachmentsCell (HangMucCard call lại). Section layout cuối: 1.Thông tin / 2.Hạng mục + Báo giá NCC (nested) / 3.Chọn NCC TP thắng thầu / 4.Ý kiến cấp duyệt — 4 section. **Chunk C (`f2f01f4`)**: Section Ý kiến restructure render layer (KHÔNG đụng Mig 26 schema — vẫn UPSERT 1 row / Level). LevelOpinionsSectionV2 forEach step → 1 `StepOpinionsBox` (replace grid-cols-2 cho N approver). Box header: "Bước N — Tên" + dept badge emerald + "X/Y đã duyệt" counter. Body: filter opinions theo step.order → sort levelOrder asc, signedAt asc → render `StepOpinionEntry` per signed opinion (tên NV + Cấp badge slate + admin override badge amber nếu có + emerald rounded-full timestamp + comment text). NV chưa duyệt KHÔNG hiển thị (Q3=a). Drop function `LevelOpinionBox` (replaced). Mirror fe-admin + fe-user. Verify build pass cả 2 app sau khi catch TS6133 `SuppliersTab` + `SupplierAttachmentsCell` unused (đã giải quyết: drop SuppliersTab, restore SupplierAttachmentsCell vào HangMucCard cột "File báo giá"). 81 test pass (no change — UAT defer)**)
|
**S20 prev:** 2026-05-11 (Session 20 — **🎯 PE Detail UI restructure 3 yêu cầu user UX. 4 chunk per-commit `9dee00d` → `2bba851` → `f2f01f4` → (current Chunk D Docs).** Q1=a (giữ Section "Chọn NCC TP" riêng), Q2=a "1 hạng mục trước tiên" (NCC shared, demo 1 hạng mục), Q3=a (chỉ hiện NV đã ký), Q4 public luôn (skip dotnet test mỗi chunk theo memory `feedback_uat_skip_verify`, vẫn `npm run build` × 2 app mỗi chunk vì có rename/remove function). **Chunk A (`9dee00d`)**: BE `CreatePurchaseEvaluationCommandHandler` thêm 1 PurchaseEvaluationDetail mặc định khi tạo phiếu — GroupCode="01", GroupName="Hạng mục chính", NoiDung=TenGoiThau, DonGiaNganSach=ThanhTienNganSach=Budget.TongNganSach hoặc BudgetManualAmount fallback 0; Changelog Insert audit. FE reorder PeDetailTabs (mirror 2 app) 1.Thông tin / 2.Hạng mục (lên #2) / 3.Chọn NCC / 4.NCC tham gia / 5.Ý kiến. **Chunk B (`2bba851`)**: ItemsTab restructure thành list `HangMucCard` (1 card / 1 hạng mục, expanded=true mặc định cho 1 hạng mục demo). Header card: GroupCode + NoiDung + 3 stat (KL/ĐG/TT) + NS link Δ nếu có + Pencil/Trash actions + ▼/▶ toggle expand. Expand body: NCC inline table columns NCC / Liên hệ / Điều khoản TT / **File báo giá** / ĐG chưa VAT / ĐG có VAT / Thành tiền / Action. Quote inline click cell → QuoteDialog cũ reuse. Add NCC + Sửa NCC reuse AddSupplierDialog/EditSupplierDialog cũ. Winner ✓ button mỗi NCC row. Drop function `SuppliersTab` (dead code ~134 LOC, replace bằng HangMucCard expand panel). Giữ AddSupplierDialog + EditSupplierDialog + SupplierAttachmentsCell (HangMucCard call lại). Section layout cuối: 1.Thông tin / 2.Hạng mục + Báo giá NCC (nested) / 3.Chọn NCC TP thắng thầu / 4.Ý kiến cấp duyệt — 4 section. **Chunk C (`f2f01f4`)**: Section Ý kiến restructure render layer (KHÔNG đụng Mig 26 schema — vẫn UPSERT 1 row / Level). LevelOpinionsSectionV2 forEach step → 1 `StepOpinionsBox` (replace grid-cols-2 cho N approver). Box header: "Bước N — Tên" + dept badge emerald + "X/Y đã duyệt" counter. Body: filter opinions theo step.order → sort levelOrder asc, signedAt asc → render `StepOpinionEntry` per signed opinion (tên NV + Cấp badge slate + admin override badge amber nếu có + emerald rounded-full timestamp + comment text). NV chưa duyệt KHÔNG hiển thị (Q3=a). Drop function `LevelOpinionBox` (replaced). Mirror fe-admin + fe-user. Verify build pass cả 2 app sau khi catch TS6133 `SuppliersTab` + `SupplierAttachmentsCell` unused (đã giải quyết: drop SuppliersTab, restore SupplierAttachmentsCell vào HangMucCard cột "File báo giá"). 81 test pass (no change — UAT defer)**)
|
||||||
|
|
||||||
## 📍 Phase hiện tại: **Phase 9 active — UAT V2 testing với user thật** — **59 DB tables (+1 PurchaseEvaluationLevelOpinions Mig 26), 26 migrations (+1 Mig 26), ~141 API endpoints (no new — UPSERT auto qua Service hook không endpoint riêng, Q1=1B), 33 FE pages. 81 unit test pass** (58 Domain + 23 Infra — no change S19, feature UAT defer test theo §7). 44 gotcha. 30 demo user + 1 test user UAT. 6 skill. **5 trạng thái phiếu** (Nháp/Đã gửi duyệt/Trả lại/Từ chối/Đã duyệt). **2 Workflow schemas đồng tồn tại** post-Session 17: (1) Mig 21 `WorkflowDefinition` flat (V1) — pin với PE/Contract cũ + match Dept+PositionLevel. (2) Mig 22-26 `ApprovalWorkflow` (V2) — pin với PE mới + match ApproverUserId 1-1, Steps/Levels group by Order, Bước (Phòng) > Cấp (N NV OR-of-N), Mig 25 +IsUserSelectable admin pin per version, **Mig 26 +PeLevelOpinions sign-off dynamic theo Level**. Service PE branch theo `ApprovalWorkflowId` set or null. Sau UAT chốt → migrate + drop V1 + Contract V2 wire.
|
## 📍 Phase hiện tại: **Phase 9 active — UAT V2 testing với user thật** — **59 DB tables (+1 PurchaseEvaluationLevelOpinions Mig 26), 28 migrations (+1 Mig 28 advanced options S21 t4 — 6 bool column trên ApprovalWorkflows), ~143 API endpoints, 34 FE pages. 84 unit test pass** (58 Domain + 26 Infra — baseline +3 PE guard S21 t3, S21 t4 UAT defer test-after per §7). **45 gotcha**. 30 demo user + 1 test user UAT. 6 skill. **5 trạng thái phiếu** (Nháp/Đã gửi duyệt/Trả lại/Từ chối/Đã duyệt). **2 Workflow schemas đồng tồn tại** post-Session 17: (1) Mig 21 `WorkflowDefinition` flat (V1) — pin với PE/Contract cũ + match Dept+PositionLevel. (2) Mig 22-26 `ApprovalWorkflow` (V2) — pin với PE mới + match ApproverUserId 1-1, Steps/Levels group by Order, Bước (Phòng) > Cấp (N NV OR-of-N), Mig 25 +IsUserSelectable admin pin per version, **Mig 26 +PeLevelOpinions sign-off dynamic theo Level**. Service PE branch theo `ApprovalWorkflowId` set or null. Sau UAT chốt → migrate + drop V1 + Contract V2 wire.
|
||||||
|
|
||||||
### 🌐 Production URLs
|
### 🌐 Production URLs
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,182 @@
|
|||||||
|
# Session 21 turn 3 — 2026-05-12 21:00 — Bug fix CRITICAL "Trả về nhưng hệ thống vẫn duyệt" PE workflow (gotcha #45)
|
||||||
|
|
||||||
|
**Dev:** Claude Opus 4.7 1M Max (em main solo, no SOLUTION_ERP sub-agent spawn)
|
||||||
|
**Duration:** ~1.5h diagnose + fix + test + docs
|
||||||
|
**Base commit:** `0a3b747` (S21 t2 RAG planning chốt)
|
||||||
|
**Commits này turn:** `de00887` (BE Chunk A) → `4b29d00` (FE Chunk B) → this (Chunk C Docs)
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
User UAT 2026-05-12 21:00 screenshot button labeled `← Trả lại` trong PE Workflow Panel (menu "Duyệt") với caption thắc mắc "Trả về nhưng hệ thống vẫn duyệt" + yêu cầu "check lại nhé chỗ Duyệt NCC".
|
||||||
|
|
||||||
|
Đây là bug CRITICAL data integrity — NV nhấn "Trả lại" vô tình "duyệt" phiếu sang Cấp tiếp theo + UPSERT opinion vĩnh viễn vào `PurchaseEvaluationLevelOpinions` (Mig 26). Khó rollback vì BE đã `SaveChangesAsync`.
|
||||||
|
|
||||||
|
## Diagnose
|
||||||
|
|
||||||
|
Em main solo (Implementer REFUSE per multi-agent rule — reasoning chain cross BE/FE+test tightly coupled). Grep `Trả lại|isReject|TraLai` trong fe-user/fe-admin → tìm 3 chỗ inconsistency trong `PeWorkflowPanel.tsx`:
|
||||||
|
|
||||||
|
| # | Location | Logic | Bug? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | L205-207 button `isSendBack` | include cả `DangSoanThao` lẫn `TraLai` → label `← Trả lại` đúng | ✅ no bug |
|
||||||
|
| 2 | L64-66 payload `isReject` | CHỈ check `DangSoanThao`, thiếu `TraLai` → gửi `decision: 1` (Approve) thay vì `2` | 🔴 BUG ROOT |
|
||||||
|
| 3 | L247-248 dialog `isSendBack` | CHỈ check `DangSoanThao`, thiếu `TraLai` → title fallback `'✓ Duyệt → Trả lại'` (sai semantic) + no amber warning | 🔴 BUG phụ |
|
||||||
|
|
||||||
|
**BE side audit** `PurchaseEvaluationWorkflowService.TransitionAsync`:
|
||||||
|
- L51 `if (decision == Reject)` branch → handle BOTH TuChoi (set Phase=TuChoi) + TraLai (set Phase=TraLai, clear pointer). Correct.
|
||||||
|
- L97 `APPROVE STEP` branch khi `decision=Approve && fromPhase=ChoDuyet` → `ApproveV2Async` UPSERT opinion + advance Cấp pointer.
|
||||||
|
- → Khi FE gửi `decision=1` do bug `isReject` thiếu nhánh TraLai, BE entry → APPROVE branch thay vì REJECT branch → phiếu approve mặc dù user định trả lại.
|
||||||
|
|
||||||
|
## Chunk A — BE defense-in-depth + 3 regression test (`de00887`)
|
||||||
|
|
||||||
|
### Test-before §7 BẮT BUỘC strict flow
|
||||||
|
|
||||||
|
**Step 1:** Write test file `tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceGuardTests.cs` 3 test KHÔNG có BE guard → run → expect FAIL.
|
||||||
|
|
||||||
|
**Step 2:** Confirm 2 test FAIL (reproduce bug — BE đi sâu vào ApproveV2Async throw "Phiếu chưa pin workflow definition hoặc workflow không có step") + 1 test PASS (happy path Reject branch — đã pass vì BE đã đúng cho decision=Reject từ Session 17).
|
||||||
|
|
||||||
|
**Step 3:** Add BE guard sau L48, trước L51:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ===== GUARD: targetPhase TraLai/TuChoi BẮT BUỘC decision=Reject =====
|
||||||
|
// Defense-in-depth chặn FE inconsistency (gotcha #45 — Session 21 turn 3).
|
||||||
|
// Bug: button "← Trả lại" gửi decision=Approve khi target=TraLai → BE skip
|
||||||
|
// Reject branch → enter APPROVE STEP → ApproveV2Async UPSERT opinion.
|
||||||
|
if ((targetPhase == PurchaseEvaluationPhase.TraLai
|
||||||
|
|| targetPhase == PurchaseEvaluationPhase.TuChoi)
|
||||||
|
&& decision != ApprovalDecision.Reject)
|
||||||
|
{
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Transition tới {targetPhase} BẮT BUỘC decision=Reject (nhận {decision}). " +
|
||||||
|
"Báo lỗi caller — payload mismatch giữa target phase và decision " +
|
||||||
|
"(xem gotcha #45 + docs/workflow-contract.md).");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4:** Re-run test → 3/3 PASS. Run full suite → 84/84 PASS (58 Domain + 26 Infra = +3 from 81 baseline).
|
||||||
|
|
||||||
|
### 3 test cases
|
||||||
|
|
||||||
|
1. **`TransitionAsync_TargetTraLai_WithApproveDecision_Throws_AndDoesNotMutateState`** — Bug reproduce. Setup PE ở Phase=ChoDuyet, CurrentApprovalLevelOrder=1. Act: gửi target=TraLai + decision=Approve. Assert: throw `ConflictException` "*TraLai*Reject*" + Phase KHÔNG đổi + CurrentApprovalLevelOrder=1 (no advance).
|
||||||
|
2. **`TransitionAsync_TargetTuChoi_WithApproveDecision_Throws_AndDoesNotMutateState`** — Consistency cover. Cùng pattern với TuChoi.
|
||||||
|
3. **`TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai`** — Happy path control. decision=Reject + target=TraLai → BE đi vào Reject branch, set Phase=TraLai, clear pointer (CurrentApprovalLevelOrder=null + CurrentWorkflowStepIndex=null + SlaDeadline=null).
|
||||||
|
|
||||||
|
+ `NoOpNotificationService internal sealed` stub trong cùng file (Tests scope) — reusable cho future PE service tests, avoid `INotificationService` real DI complexity.
|
||||||
|
|
||||||
|
### Pattern reusable
|
||||||
|
- **Boundary guard semantic invariant.** Bất kỳ BE service nào nhận payload từ FE → audit invariant `(domain state X) ⇔ (input parameter Y)` → throw early nếu mismatch. Defense-in-depth thay vì trust FE đúng.
|
||||||
|
- **Test-before flow strict:** Write test → confirm FAIL với exception KHÁC expected (proves bug reproduce) → add fix → confirm PASS với exception ĐÚNG expected. KHÔNG bỏ qua bước "confirm FAIL" — đảm bảo test actually catches bug.
|
||||||
|
|
||||||
|
## Chunk B — FE fix mirror 2 app (`4b29d00`)
|
||||||
|
|
||||||
|
3 chỗ × 2 app = 6 edits.
|
||||||
|
|
||||||
|
### fe-user/src/components/pe/PeWorkflowPanel.tsx
|
||||||
|
|
||||||
|
**Edit #1 (L64-66 `isReject`):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const isReject = target === PurchaseEvaluationPhase.TuChoi
|
||||||
|
|| (target === PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|
||||||
|
|| (target === PurchaseEvaluationPhase.TraLai // ← THÊM nhánh TraLai
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edit #2 (L247-248 dialog `isSendBack`):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
|| target === PurchaseEvaluationPhase.TraLai) // ← THÊM TraLai
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai // ← THÊM guard
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comment update:** Thêm context bug + cross-ref BE guard Chunk A trong comment.
|
||||||
|
|
||||||
|
### fe-admin/src/components/pe/PeWorkflowPanel.tsx
|
||||||
|
|
||||||
|
Mirror y hệt (rule §3.9 mirror 2 app — duplicate có chủ đích).
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# fe-user
|
||||||
|
cd fe-user && npm run build
|
||||||
|
✓ built in 17.91s
|
||||||
|
|
||||||
|
# fe-admin
|
||||||
|
cd fe-admin && npm run build
|
||||||
|
✓ built in 6.71s
|
||||||
|
```
|
||||||
|
|
||||||
|
0 TS6 err, 0 new warnings. Warning chunk size pre-existing (KHÔNG introduced).
|
||||||
|
|
||||||
|
## Chunk C — Docs (this commit)
|
||||||
|
|
||||||
|
### `docs/gotchas.md` +#45
|
||||||
|
|
||||||
|
~120 dòng narrative đầy đủ KEEP rule §6.5:
|
||||||
|
- **Triệu chứng** — UAT screenshot user mô tả hành vi
|
||||||
|
- **Root cause** — 3 chỗ inconsistency table + BE service path narrative
|
||||||
|
- **Severity** CRITICAL — data integrity issue khó rollback
|
||||||
|
- **Fix Chunk A** BE code block + 3 test list
|
||||||
|
- **Fix Chunk B** FE code block diff
|
||||||
|
- **Pattern reusable** — boundary guard semantic invariant + button label ↔ payload sync
|
||||||
|
- **Phòng tránh tương lai** — grep audit khi spec mới thêm phase + test-before §7 strict flow
|
||||||
|
- **References** — 2 commit + Session 17 spec
|
||||||
|
|
||||||
|
`docs/gotchas.md` checklist debug +entry 22 quick lookup.
|
||||||
|
|
||||||
|
### `docs/STATUS.md`
|
||||||
|
|
||||||
|
Edit Last updated header thêm S21 t3 + count 81→84 test + 44→45 gotcha. Giữ nguyên S21 t2/t1/S20 narrative cũ (rule §6.5 KEEP).
|
||||||
|
|
||||||
|
### `docs/HANDOFF.md`
|
||||||
|
|
||||||
|
Insert TL;DR S21 t3 section trên cùng (trước S21 t2). Header Last updated mới + narrative đầy đủ Chunk A/B/C + state table cumulative. Giữ S21 t2/t1/S20 narrative cũ.
|
||||||
|
|
||||||
|
### Session log
|
||||||
|
|
||||||
|
File này — `docs/changelog/sessions/2026-05-12-2100-s21-turn3-fix-tra-lai-bug45.md`.
|
||||||
|
|
||||||
|
## Stats cumulative
|
||||||
|
|
||||||
|
| Metric | Trước (S21 t2) | Sau (S21 t3) | Δ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| DB tables | 59 | 59 | 0 |
|
||||||
|
| Migrations | 27 | 27 | 0 |
|
||||||
|
| Endpoints | ~142 | ~142 | 0 |
|
||||||
|
| FE pages | 34 | 34 | 0 |
|
||||||
|
| **Unit tests** | 81 | **84** | **+3** (PE guard) |
|
||||||
|
| **Gotchas** | 44 | **45** | **+1** (#45) |
|
||||||
|
| Memory entries | 17 | 17 | 0 |
|
||||||
|
| Skills | 6 | 6 | 0 |
|
||||||
|
| Sub-agents | 4 seeds-only | 4 seeds-only | 0 |
|
||||||
|
| Commits S21 t3 | — | **3** | (`de00887` + `4b29d00` + this) |
|
||||||
|
|
||||||
|
## Lessons learned
|
||||||
|
|
||||||
|
1. **Mảng inconsistency 3 chỗ cùng pattern** — khi spec mới thêm value vào set check (vd Session 17 thêm `TraLai` làm Phase RIÊNG thay vì DangSoanThao revert), DỄ QUÊN grep TOÀN BỘ logic check `=== OldValue` để thêm `|| === NewValue`. Tốt nhất extract helper function `isReject(target, currentPhase): boolean` share 1 nơi thay vì duplicate 3 chỗ.
|
||||||
|
2. **BE guard defense-in-depth có giá trị thực** — trong S21 t3, nếu BE chỉ trust FE đúng → bug ship prod, user UAT report, mất data integrity. BE guard early catch payload mismatch + ConflictException rõ ràng → fix nhanh + safe.
|
||||||
|
3. **Test-before flow strict không chỉ là "viết test" — còn confirm FAIL** — em main đầu tiên định viết test + fix cùng commit (cho gọn). Nhưng test-before §7 BẮT BUỘC confirm test FAIL trước fix. Bước này quan trọng — confirm test actually reproduce bug (assert đúng exception type/message), không chỉ là "test xanh sau fix".
|
||||||
|
4. **Multi-agent decision tree áp đúng** — Bug fix tightly coupled BE+FE+test reasoning chain → Implementer REFUSE per rule (Cognition "writes single-threaded"). Em main solo correct decision, KHÔNG cố split → tránh agent thrash.
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- ✅ Chunk A `de00887` committed local — chưa push
|
||||||
|
- ✅ Chunk B `4b29d00` committed local — chưa push
|
||||||
|
- ✅ Chunk C (this) — committed sau khi save session log
|
||||||
|
- ⏭ **PENDING bro confirm push remote** — `git push origin main` 3 commit ahead `0a3b747..HEAD`
|
||||||
|
- ⏭ Sau push: CI sẽ trigger (NOT docs-only — có file `.cs` + `.tsx`) → 🟩 CICD Monitor spawn smoke verify (per plan G Trial Week 1)
|
||||||
|
|
||||||
|
User next action expected: "fix đi rồi tao giao thêm task" → sau Chunk C wrap → bro chốt task tiếp theo (có thể là Plan B Contract V2 wire hoặc fix khác phát sinh UAT).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Gotcha #45: `docs/gotchas.md`
|
||||||
|
- BE service: `src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs`
|
||||||
|
- FE component (× 2 app): `{fe-admin,fe-user}/src/components/pe/PeWorkflowPanel.tsx`
|
||||||
|
- Test: `tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceGuardTests.cs`
|
||||||
|
- Workflow spec Session 17: `PurchaseEvaluationPhase.cs` enum doc + Service comment L15-19
|
||||||
|
- Rule §7 test-before: `docs/rules.md`
|
||||||
|
- Rule §3.9 mirror 2 FE: `docs/rules.md`
|
||||||
@ -0,0 +1,316 @@
|
|||||||
|
# Session 21 turn 4 — 2026-05-13 12:00 — PE Workflow advanced options (F1+F2+F3, Mig 28)
|
||||||
|
|
||||||
|
**Dev:** Claude Opus 4.7 1M Max (em main solo — 3 feature multi-layer Implementer REFUSE per cross-stack reasoning chain rule)
|
||||||
|
**Duration:** ~3h (clarify Q&A 2 lượt + 5 chunk implement + verify build cả 2 app mỗi chunk)
|
||||||
|
**Base commit:** `6d30ba4` (S21 t3 fix gotcha #45 Chunk C Docs)
|
||||||
|
**Commits này turn:** `0294693` (A schema) → `c56024b` (B BE) → `a508564` (C FE Admin) → `d27caaf` (D FE eOffice) → this (E Docs)
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
User chốt 3 tính năng mới trong "Quy trình duyệt NCC":
|
||||||
|
|
||||||
|
1. **F1** — 4 mode Trả lại trong workflow (admin stick per workflow):
|
||||||
|
- Cho trả về 1 bậc trước đó
|
||||||
|
- Cho trả về người chỉ định
|
||||||
|
- Trả về người soạn thảo
|
||||||
|
- Workflow tick stick mode nào enabled → user eOffice dropdown chỉ hiện mode đó
|
||||||
|
|
||||||
|
2. **F2** — Drafter chọn "Gửi duyệt thẳng cấp" (vd skip → CEO):
|
||||||
|
- "Các bước này đều ghi nhận vào quy trình duyệt phiếu"
|
||||||
|
|
||||||
|
3. **F3** — Approver chỉnh sửa phiếu (Section 2 chi tiết):
|
||||||
|
- "lưu vào lịch sử chỉnh sửa luôn"
|
||||||
|
|
||||||
|
## Clarify Q&A (2 lượt AskUserQuestion)
|
||||||
|
|
||||||
|
### Lượt 1 — Schema + UX scope:
|
||||||
|
|
||||||
|
| Câu | User chốt |
|
||||||
|
|---|---|
|
||||||
|
| F1 "Trả về 1 bậc trước đó" nghĩa là gì? | **Cả 2 mode (admin chọn)** — 1 Cấp + 1 Bước stick độc lập |
|
||||||
|
| F1 "Người chỉ định" nguồn từ đâu? | **Approver pick runtime** — dropdown từ list NV đã ký (PeLevelOpinions) |
|
||||||
|
| F2 skip scope | **Chỉ skip tới Level cuối (CEO)** — Dropdown 2 option |
|
||||||
|
| F3 edit scope | **Section 2 (Hạng mục + NCC + Báo giá)** — KHÔNG đụng Header, KHÔNG reset workflow |
|
||||||
|
|
||||||
|
### Lượt 2 — Behavior + admin enable:
|
||||||
|
|
||||||
|
| Câu | User chốt |
|
||||||
|
|---|---|
|
||||||
|
| 3 mode Trả lại behavior | **Giữ ChoDuyet, lùi pointer** (peer review chain). Mode Drafter giữ Phase=TraLai S17 |
|
||||||
|
| F2+F3 admin enable | **Cả 2 cần admin tick** per workflow (audit nghiêm) |
|
||||||
|
| F3 approver perm | **Mọi approver Cấp đang active** (currentLevel match) |
|
||||||
|
| Test timing | **Test-after UAT default Phase 9** (skip dotnet test mỗi chunk, npm build × 2 app) |
|
||||||
|
|
||||||
|
## Chunk A — Schema + Migration 28 (`0294693`)
|
||||||
|
|
||||||
|
### Domain `ApprovalWorkflow.cs` +6 bool
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool AllowReturnOneLevel { get; set; } // F1 mode 1
|
||||||
|
public bool AllowReturnOneStep { get; set; } // F1 mode 2
|
||||||
|
public bool AllowReturnToAssignee { get; set; } // F1 mode 3
|
||||||
|
public bool AllowReturnToDrafter { get; set; } = true; // F1 mode 4 (backward compat S17)
|
||||||
|
public bool AllowDrafterSkipToFinal { get; set; } // F2
|
||||||
|
public bool AllowApproverEditDetails { get; set; } // F3
|
||||||
|
```
|
||||||
|
|
||||||
|
### EF config `ApprovalWorkflowConfiguration`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
e.Property(x => x.AllowReturnOneLevel).HasDefaultValue(false);
|
||||||
|
// ... 4 more false ...
|
||||||
|
e.Property(x => x.AllowReturnToDrafter).HasDefaultValue(true); // backfill rows cũ
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration 28 `AddAdvancedOptionsToApprovalWorkflows`
|
||||||
|
|
||||||
|
- 6 AddColumn bit NOT NULL DEFAULT 0/1
|
||||||
|
- 3-file rule complete (mig.cs + Designer.cs + Snapshot.cs)
|
||||||
|
- Apply LocalDB Dev + Design success
|
||||||
|
|
||||||
|
## Chunk B — BE Service + handlers + DTOs (`c56024b`)
|
||||||
|
|
||||||
|
### Service signature extend (backward compat)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task TransitionAsync(
|
||||||
|
PurchaseEvaluation evaluation,
|
||||||
|
PurchaseEvaluationPhase targetPhase,
|
||||||
|
Guid? actorUserId,
|
||||||
|
IReadOnlyList<string> actorRoles,
|
||||||
|
ApprovalDecision decision,
|
||||||
|
string? comment,
|
||||||
|
WorkflowReturnMode? returnMode = null, // ← NEW
|
||||||
|
Guid? returnTargetUserId = null, // ← NEW
|
||||||
|
bool skipToFinal = false, // ← NEW
|
||||||
|
CancellationToken ct = default)
|
||||||
|
```
|
||||||
|
|
||||||
|
`WorkflowReturnMode` enum: OneLevel=1, OneStep=2, Assignee=3, Drafter=4.
|
||||||
|
|
||||||
|
### REJECT branch extend với `ApplyReturnModeAsync`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Inside REJECT branch (line 51+)
|
||||||
|
var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter;
|
||||||
|
var returnSummary = await ApplyReturnModeAsync(
|
||||||
|
evaluation, effectiveMode, returnTargetUserId, isAdmin, ct);
|
||||||
|
comment = $"{comment} [{returnSummary}]";
|
||||||
|
```
|
||||||
|
|
||||||
|
Helper `ApplyReturnModeAsync` switch 4 mode:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// OneLevel — lùi 1 Cấp trong cùng Step. Bước 1 Cấp 1 → fallback Drafter.
|
||||||
|
if (curLevel > 1) {
|
||||||
|
evaluation.CurrentApprovalLevelOrder = curLevel - 1;
|
||||||
|
summary = $"Trả về Cấp {curLevel - 1}";
|
||||||
|
}
|
||||||
|
else if (curStepIdx > 0) {
|
||||||
|
var prevStep = stepsOrdered[curStepIdx - 1];
|
||||||
|
evaluation.CurrentWorkflowStepIndex = curStepIdx - 1;
|
||||||
|
evaluation.CurrentApprovalLevelOrder = prevStep.Levels.Max(l => l.Order);
|
||||||
|
summary = $"Trả về Bước {prevStep.Order} Cấp {maxLevel} (Bước trước)";
|
||||||
|
}
|
||||||
|
else { /* fallback Drafter */ }
|
||||||
|
|
||||||
|
// OneStep — lùi sang Bước trước, set Level = max của Bước đó. Bước 1 → fallback.
|
||||||
|
// Assignee — tìm Step+Level match returnTargetUserId trong workflow.
|
||||||
|
// Drafter — Phase=TraLai clear pointer (S17 backward compat).
|
||||||
|
```
|
||||||
|
|
||||||
|
3 mode đầu giữ Phase=ChoDuyet + reset SLA 7d. Admin bypass workflow.Allow* flag check.
|
||||||
|
|
||||||
|
### DRAFTER trình branch extend với F2
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (skipToFinal && evaluation.ApprovalWorkflowId is Guid skipAwId) {
|
||||||
|
// Workflow.AllowDrafterSkipToFinal required (non-admin)
|
||||||
|
if (!wfSkip.AllowDrafterSkipToFinal)
|
||||||
|
throw new ConflictException("Workflow không bật mode 'Gửi thẳng Cấp cuối'.");
|
||||||
|
evaluation.CurrentWorkflowStepIndex = wfSkip.Steps.Count - 1; // 0-based last
|
||||||
|
evaluation.CurrentApprovalLevelOrder = finalStep.Levels.Max(l => l.Order);
|
||||||
|
comment = $"{comment} [Drafter gửi thẳng Cấp cuối — skip Bước/Cấp trung gian]";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helper edit guard `EnsureEditableForDetailsAsync` (PurchaseEvaluationDraftGuard class)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static async Task<PurchaseEvaluation> EnsureEditableForDetailsAsync(
|
||||||
|
IApplicationDbContext db, Guid id, ICurrentUser currentUser, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(...);
|
||||||
|
|
||||||
|
// Drafter scope — bypass current Controller [Authorize] handles role
|
||||||
|
if (pe.Phase == DangSoanThao || pe.Phase == TraLai) return pe;
|
||||||
|
|
||||||
|
// F3 Approver scope (Mig 28) — chỉ ChoDuyet
|
||||||
|
if (pe.Phase == ChoDuyet && currentUser.UserId is Guid actorUserId) {
|
||||||
|
if (currentUser.Roles.Contains(Admin)) return pe; // bypass
|
||||||
|
|
||||||
|
var workflow = await db.ApprovalWorkflows.Include(w => w.Steps)...
|
||||||
|
if (!workflow.AllowApproverEditDetails)
|
||||||
|
throw new ConflictException("Workflow không bật mode...");
|
||||||
|
|
||||||
|
var level = step.Levels.FirstOrDefault(lv => lv.Order == levelOrder);
|
||||||
|
if (level.ApproverUserId != actorUserId)
|
||||||
|
throw new ForbiddenException("Chỉ NV phụ trách Bước X / Cấp Y...");
|
||||||
|
|
||||||
|
return pe;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ConflictException($"Phiếu PE ở Phase={pe.Phase}, không thể chỉnh sửa.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8 handler switch + audit changelog
|
||||||
|
|
||||||
|
- `PurchaseEvaluationDetailFeatures.cs`: Add/Update/Delete + Quote Upsert/Delete (5)
|
||||||
|
- `PurchaseEvaluationSupplierFeatures.cs`: Add/Update/Remove (3) — **bonus security fix**: trước đây Supplier handlers HOÀN TOÀN KHÔNG có phase guard!
|
||||||
|
|
||||||
|
Update/Delete handlers trước đây silent → thêm changelog `PhaseAtChange + UserId + Summary` (append `[Approver edit khi đang duyệt]` khi phase=ChoDuyet).
|
||||||
|
|
||||||
|
### Command DTO + DTOs extend
|
||||||
|
|
||||||
|
- `TransitionPurchaseEvaluationCommand` +3 optional field + Validator
|
||||||
|
- `ApprovalWorkflowOptionsDto` NEW sub-record (6 Allow* flag)
|
||||||
|
- `PurchaseEvaluationDetailBundleDto` +WorkflowOptions field (null nếu V1 legacy)
|
||||||
|
- `AwDefinitionDto` +6 Allow* (admin Designer GET)
|
||||||
|
- `CreateAwDefinitionCommand` +6 Allow* param (admin Designer POST)
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
- `dotnet build SolutionErp.slnx` → 0 err, 2 warn pre-existing DocxRenderer
|
||||||
|
- 3 regression test gotcha #45 vẫn PASS (signature backward compat)
|
||||||
|
|
||||||
|
## Chunk C — FE Admin Designer (`a508564`)
|
||||||
|
|
||||||
|
### `ApprovalWorkflowsV2Page.tsx` Section "Cấu hình nâng cao" 6 checkbox
|
||||||
|
|
||||||
|
Container amber-50/30 + border distinct với Steps. 3 sub-group:
|
||||||
|
|
||||||
|
1. **Mode Trả lại** (4 checkbox):
|
||||||
|
- ☐ Trả về 1 Cấp trước (peer review chain trong cùng Bước)
|
||||||
|
- ☐ Trả về 1 Bước trước (Cấp cuối Bước trước nhận lại)
|
||||||
|
- ☐ Trả về Người chỉ định (pick runtime từ NV đã ký)
|
||||||
|
- ☑ Trả về Người soạn thảo (default checked = backward compat S17)
|
||||||
|
|
||||||
|
2. **Drafter gửi duyệt** (1 checkbox):
|
||||||
|
- ☐ Cho phép Drafter gửi thẳng Cấp cuối
|
||||||
|
|
||||||
|
3. **Approver chỉnh sửa phiếu** (1 checkbox):
|
||||||
|
- ☐ Cho phép Approver chỉnh sửa Section 2 (Hạng mục + NCC + Báo giá)
|
||||||
|
|
||||||
|
DTO types + state defaults từ cloneFrom (giữ config version trước) hoặc S17 fallback. POST body propagate.
|
||||||
|
|
||||||
|
fe-user KHÔNG mirror (Designer admin-only).
|
||||||
|
|
||||||
|
## Chunk D — FE eOffice (`d27caaf`) mirror 2 app
|
||||||
|
|
||||||
|
### Types `purchaseEvaluation.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ApprovalWorkflowOptions = {
|
||||||
|
allowReturnOneLevel: boolean
|
||||||
|
// ... 5 more
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkflowReturnMode = {
|
||||||
|
OneLevel: 1, OneStep: 2, Assignee: 3, Drafter: 4,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PeDetailBundle = {
|
||||||
|
// ...
|
||||||
|
workflowOptions: ApprovalWorkflowOptions | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PeWorkflowPanel.tsx` F1 Trả lại radio picker
|
||||||
|
|
||||||
|
State `returnMode` + `returnTargetUserId`. Dialog render 1-4 radio mode enabled theo `wfOptions.Allow*`. Assignee mode → submodal Select pick từ `evaluation.levelOpinions` (NV đã ký, dedupe by userId).
|
||||||
|
|
||||||
|
Banner amber rounded dưới mô tả hành vi mode chọn:
|
||||||
|
- Drafter: "Phiếu sẽ về 'Trả lại'. Drafter có thể sửa rồi trình lại từ Cấp 1 Bước 1."
|
||||||
|
- Assignee: "Phiếu sẽ về Cấp/Bước của NV đã chọn..."
|
||||||
|
- OneLevel/OneStep: "Phiếu sẽ lùi pointer (vẫn 'Đã gửi duyệt')..."
|
||||||
|
|
||||||
|
Mutation payload +`returnMode` +`returnTargetUserId` khi `isTraLaiAction`.
|
||||||
|
|
||||||
|
### `PeDetailTabs.tsx` F2 Drafter skip
|
||||||
|
|
||||||
|
State `skipToFinal` + `allowSkipToFinal` từ workflowOptions. submitForApproval mutationFn accept `opts.skipToFinal`. Workspace action bar: checkbox violet conditional. Confirm dialog message + button label dynamic.
|
||||||
|
|
||||||
|
### `PeDetailTabs.tsx` F3 Approver edit Section 2
|
||||||
|
|
||||||
|
useAuth import + compute `approverEditMode`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const approverEditMode = evaluation.phase === ChoDuyet
|
||||||
|
&& (evaluation.workflowOptions?.allowApproverEditDetails ?? false)
|
||||||
|
&& actorMatchesLevel // isAdmin || actor.id in currentApproval.approvers
|
||||||
|
const itemsReadOnly = readOnly && !approverEditMode
|
||||||
|
```
|
||||||
|
|
||||||
|
Banner violet "ⓘ Bạn được phép chỉnh sửa..." khi approverEditMode + readOnly (Duyệt menu).
|
||||||
|
|
||||||
|
InfoTab / NccSelectorRow / BudgetFieldRow GIỮ strict isEditablePhase (Header + Section 3 KHÔNG trong F3 scope).
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
- `npm run build × 2 app` pass (fe-user 7.52s + fe-admin 499ms cached)
|
||||||
|
- 0 TS6 err, warning chunk size pre-existing
|
||||||
|
|
||||||
|
## Chunk E — Docs (this commit)
|
||||||
|
|
||||||
|
- `docs/database/schema-diagram.md §14` title "Mig 22-28, S17-21" + thêm 6 column Allow* trong Core block với inline comment F1/F2/F3
|
||||||
|
- `docs/STATUS.md` Last updated S21 t4 + count 27→28 mig + UAT defer test count unchanged
|
||||||
|
- `docs/HANDOFF.md` TL;DR S21 t4 đầy đủ (5 chunk narrative + Q&A + pattern reusable)
|
||||||
|
- Session log file này
|
||||||
|
|
||||||
|
## Stats cumulative S21 t4
|
||||||
|
|
||||||
|
| Metric | Trước (S21 t3) | Sau (S21 t4) | Δ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| DB tables | 59 | 59 | 0 |
|
||||||
|
| **Migrations** | 27 | **28** | **+1** (Mig 28) |
|
||||||
|
| Endpoints | ~142 | ~143 | +1 (transitions body extend) |
|
||||||
|
| FE pages | 34 | 34 | 0 (Designer extend) |
|
||||||
|
| **Unit tests** | 84 | **84** | 0 (UAT defer test-after §7) |
|
||||||
|
| Gotchas | 45 | 45 | 0 |
|
||||||
|
| Memory entries | 17 | 17 | 0 |
|
||||||
|
| Skills | 6 | 6 | 0 |
|
||||||
|
| Sub-agents | 4 seeds-only | 4 seeds-only | 0 |
|
||||||
|
| Commits S21 t4 | — | **5** | `0294693` → `c56024b` → `a508564` → `d27caaf` → this |
|
||||||
|
|
||||||
|
## Lessons learned
|
||||||
|
|
||||||
|
1. **Backward-compat option pattern.** Thêm 6 cột mới với 1 cột default TRUE (AllowReturnToDrafter S17 fallback) + 5 cột default FALSE (admin opt-in) → workflow cũ chạy đúng sau deploy, không breaking change. Pattern reusable cho future feature flags.
|
||||||
|
|
||||||
|
2. **Boundary helper pattern** (extension thay vì rewrite). Helper cũ `EnsureDraftAsync` strict DangSoanThao only → thêm helper mới `EnsureEditableForDetailsAsync` accept thêm Approver scope. KHÔNG rename + KHÔNG break cũ → coexistence safe. 8 handler switch cleanly.
|
||||||
|
|
||||||
|
3. **Multi-agent decision tree áp đúng**. 3 feature multi-layer (Domain + Mig + Service + 8 handler + 2 FE app + 4 DTO file) — tightly coupled reasoning chain → Implementer REFUSE per Cognition "writes single-threaded". Em main solo decision đúng, KHÔNG split tránh agent thrash.
|
||||||
|
|
||||||
|
4. **Per-chunk discipline (5 chunk A-E)** mặc dù big-feature multi-layer >1000 LOC. Mỗi chunk verify build pass trước commit → rollback dễ nếu fail. Audit trail commit history rõ ràng cho future debug.
|
||||||
|
|
||||||
|
5. **Test-after UAT default Phase 9** chấp nhận được khi feature nhánh enable mới (admin opt-in). Test-before BẮT BUỘC chỉ cho bug fix (gotcha #45 S21 t3 đã làm). Cảnh báo carry: Plan C test-after candidate cumulative ngày càng nhiều — cần dedicated commit "Test catch-up S21 t1-t4" sau UAT ổn.
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- ✅ Chunk A `0294693` BE schema + Mig 28 committed local
|
||||||
|
- ✅ Chunk B `c56024b` BE Service + handlers + DTOs committed local
|
||||||
|
- ✅ Chunk C `a508564` FE Admin Designer committed local
|
||||||
|
- ✅ Chunk D `d27caaf` FE eOffice mirror 2 app committed local
|
||||||
|
- ✅ Chunk E (this) — Docs commit sau khi save session log
|
||||||
|
- ⏭ **PENDING bro confirm push remote** — `git push origin main` 8 commit ahead `0a3b747..HEAD` (S21 t3 fix gotcha #45 + S21 t4 F1+F2+F3)
|
||||||
|
|
||||||
|
User next action expected: UAT test 3 feature mới trong env Prod hoặc local. Sau UAT 2-3 lần ổn → Plan C bundle test-after catch up.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- BE Service: `src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs`
|
||||||
|
- BE entity: `src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs`
|
||||||
|
- BE handlers: `src/Backend/SolutionErp.Application/PurchaseEvaluations/{PurchaseEvaluationFeatures, PurchaseEvaluationDetailFeatures, PurchaseEvaluationSupplierFeatures}.cs`
|
||||||
|
- Mig 28: `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260513114505_AddAdvancedOptionsToApprovalWorkflows.cs`
|
||||||
|
- FE Admin Designer: `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx`
|
||||||
|
- FE eOffice × 2 app: `{fe-admin,fe-user}/src/components/pe/PeWorkflowPanel.tsx` + `PeDetailTabs.tsx` + `types/purchaseEvaluation.ts`
|
||||||
|
- Spec context: gotcha #45 S21 t3 BE guard payload mismatch + Session 17 spec 5 trạng thái
|
||||||
|
- Rules: §3.9 mirror 2 FE, §6.5 KEEP narrative, §7 test timing, `feedback_uat_skip_verify`, `feedback_per_chunk_commit`
|
||||||
@ -715,7 +715,7 @@ CREATE TABLE PurchaseEvaluationDepartmentOpinions (
|
|||||||
CREATE UNIQUE INDEX IX_PEDeptOpinions_PEId_Kind ON PurchaseEvaluationDepartmentOpinions (PurchaseEvaluationId, Kind);
|
CREATE UNIQUE INDEX IX_PEDeptOpinions_PEId_Kind ON PurchaseEvaluationDepartmentOpinions (PurchaseEvaluationId, Kind);
|
||||||
```
|
```
|
||||||
|
|
||||||
## 14. ApprovalWorkflow V2 schema (Migration 22-25, Session 17-18 — 3 bảng mới + 3 column)
|
## 14. ApprovalWorkflow V2 schema (Migration 22-28, Session 17-21 — 3 bảng mới + 9 column)
|
||||||
|
|
||||||
Schema riêng song song WorkflowDefinition V1 (Mig 21) — pin per phiếu PE.
|
Schema riêng song song WorkflowDefinition V1 (Mig 21) — pin per phiếu PE.
|
||||||
V1 vẫn giữ cho phiếu cũ; V2 mới là active cho phiếu tạo từ Session 17 trở đi.
|
V1 vẫn giữ cho phiếu cũ; V2 mới là active cho phiếu tạo từ Session 17 trở đi.
|
||||||
@ -728,6 +728,15 @@ ApprovalWorkflows
|
|||||||
├── ApplicableType (1=DuyetNcc, 2=DuyetNccPhuongAn, 3=Contract)
|
├── ApplicableType (1=DuyetNcc, 2=DuyetNccPhuongAn, 3=Contract)
|
||||||
├── Name, Description, IsActive, ActivatedAt
|
├── Name, Description, IsActive, ActivatedAt
|
||||||
├── IsUserSelectable (Mig 25, S18) — admin pin/unpin cho user pick lúc create phiếu
|
├── IsUserSelectable (Mig 25, S18) — admin pin/unpin cho user pick lúc create phiếu
|
||||||
|
│
|
||||||
|
├── Mig 28 (S21 t4) — 6 advanced options "Cấu hình nâng cao" per workflow:
|
||||||
|
├── AllowReturnOneLevel bit DEFAULT 0 — F1 mode: Trả về 1 Cấp trước (peer review)
|
||||||
|
├── AllowReturnOneStep bit DEFAULT 0 — F1 mode: Trả về 1 Bước trước
|
||||||
|
├── AllowReturnToAssignee bit DEFAULT 0 — F1 mode: Trả về Người chỉ định (pick runtime)
|
||||||
|
├── AllowReturnToDrafter bit DEFAULT 1 — F1 mode: Trả về Drafter (S17 backward compat)
|
||||||
|
├── AllowDrafterSkipToFinal bit DEFAULT 0 — F2: Drafter trình thẳng Cấp cuối, skip trung gian
|
||||||
|
├── AllowApproverEditDetails bit DEFAULT 0 — F3: Approver chỉnh Section 2 lúc đang duyệt
|
||||||
|
│
|
||||||
└── (audit) CreatedAt, UpdatedAt, CreatedBy, UpdatedBy
|
└── (audit) CreatedAt, UpdatedAt, CreatedBy, UpdatedBy
|
||||||
|
|
||||||
ApprovalWorkflowSteps (FK Cascade ApprovalWorkflowId, FK Restrict DepartmentId)
|
ApprovalWorkflowSteps (FK Cascade ApprovalWorkflowId, FK Restrict DepartmentId)
|
||||||
|
|||||||
@ -637,6 +637,76 @@ public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
|
|||||||
|
|
||||||
**FE diagnostic improvement:** TanStack Query error nên hiển thị warning UI (toast hoặc banner) thay vì silent. Hiện tại `useQuery` catch silent → debug khó. Future: wire `onError` handler global show generic error toast.
|
**FE diagnostic improvement:** TanStack Query error nên hiển thị warning UI (toast hoặc banner) thay vì silent. Hiện tại `useQuery` catch silent → debug khó. Future: wire `onError` handler global show generic error toast.
|
||||||
|
|
||||||
|
### 45. PE "Trả về nhưng hệ thống vẫn duyệt" — FE button label vs decision payload mismatch (Session 21 turn 3)
|
||||||
|
|
||||||
|
**Triệu chứng:** UAT 2026-05-12 — User bro screenshot button labeled `← Trả lại` trong PE Workflow Panel (menu "Duyệt"), nhấn vào nhưng phiếu KHÔNG về phase TraLai — ngược lại tiến qua Cấp tiếp theo (hệ thống ghi nhận approve). User mô tả hành vi: "Trả về nhưng hệ thống vẫn duyệt".
|
||||||
|
|
||||||
|
**Root cause:** `PeWorkflowPanel.tsx` có 3 chỗ check transition type với logic KHÔNG sync giữa nhau:
|
||||||
|
|
||||||
|
- **L205-207** `isSendBack` (button label color): include cả `DangSoanThao` lẫn `TraLai` từ phase trung gian → label hiển thị `← Trả lại` đúng.
|
||||||
|
- **L64-66** `isReject` (payload `decision` gửi BE): CHỈ check `DangSoanThao`, **thiếu `TraLai`** → khi target=TraLai (98), `isReject=false` → payload `decision: 1` (Approve) thay vì `2` (Reject).
|
||||||
|
- **L247-248** dialog `isSendBack` (title + warning): CHỈ check `DangSoanThao`, **thiếu `TraLai`** → dialog title fallback `'✓ Duyệt → Trả lại'` (sai semantic) + KHÔNG hiển thị amber warning "Phiếu sẽ về Đang soạn thảo".
|
||||||
|
|
||||||
|
BE `PurchaseEvaluationWorkflowService.TransitionAsync`:
|
||||||
|
- L51 `if (decision == Reject)` branch → set Phase=TraLai correctly khi decision=Reject.
|
||||||
|
- L97 `APPROVE STEP` branch khi decision=Approve + fromPhase=ChoDuyet → `ApproveV2Async` UPSERT opinion = "đã duyệt" + advance Cấp.
|
||||||
|
- → Khi FE gửi `decision=1` (do bug `isReject`), BE đi vào nhánh APPROVE thay vì REJECT → phiếu được ghi nhận approve dù user định trả lại.
|
||||||
|
|
||||||
|
**Severity:** 🔴 CRITICAL — data integrity issue. NV nhấn "Trả lại" sẽ vô tình "duyệt" phiếu sang Cấp tiếp theo + UPSERT opinion vĩnh viễn vào `PurchaseEvaluationLevelOpinions` (Mig 26). Khó rollback vì BE đã `SaveChangesAsync`.
|
||||||
|
|
||||||
|
**Fix Chunk A (`de00887` BE defense-in-depth):**
|
||||||
|
```csharp
|
||||||
|
// PurchaseEvaluationWorkflowService.cs sau set isAdmin/isSystem (L48), trước REJECT branch (L51)
|
||||||
|
if ((targetPhase == PurchaseEvaluationPhase.TraLai
|
||||||
|
|| targetPhase == PurchaseEvaluationPhase.TuChoi)
|
||||||
|
&& decision != ApprovalDecision.Reject)
|
||||||
|
{
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Transition tới {targetPhase} BẮT BUỘC decision=Reject (nhận {decision}). " +
|
||||||
|
"Báo lỗi caller — payload mismatch giữa target phase và decision.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Boundary protection cho mọi caller tương lai (API client / mobile / cron retry). 3 regression test:
|
||||||
|
- `TransitionAsync_TargetTraLai_WithApproveDecision_Throws_AndDoesNotMutateState` (bug reproduce)
|
||||||
|
- `TransitionAsync_TargetTuChoi_WithApproveDecision_Throws_AndDoesNotMutateState` (consistency cover)
|
||||||
|
- `TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai` (happy path control)
|
||||||
|
|
||||||
|
**Fix Chunk B (`4b29d00` FE mirror 2 app):**
|
||||||
|
```typescript
|
||||||
|
// PeWorkflowPanel.tsx (fe-user + fe-admin) — 3 chỗ × 2 app
|
||||||
|
|
||||||
|
// Chỗ 1: isReject payload (line 64-66)
|
||||||
|
const isReject = target === PurchaseEvaluationPhase.TuChoi
|
||||||
|
|| (target === PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|
||||||
|
|| (target === PurchaseEvaluationPhase.TraLai // ← THÊM
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
|
||||||
|
|
||||||
|
// Chỗ 2: dialog isSendBack (line 247-248)
|
||||||
|
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
|| target === PurchaseEvaluationPhase.TraLai) // ← THÊM
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai // ← THÊM
|
||||||
|
```
|
||||||
|
|
||||||
|
Chỗ 3 (button label `isSendBack` L205-207) đã đúng từ S17, KHÔNG đụng.
|
||||||
|
|
||||||
|
**Pattern reusable — invariant check khi viết FE workflow transition:**
|
||||||
|
1. Button label condition (visual) phải SYNC với payload decision (semantic).
|
||||||
|
2. Dialog title/warning condition phải SYNC với button label + payload.
|
||||||
|
3. Tốt nhất: extract `isReject(target, currentPhase)` thành 1 helper FE + BE share semantic — KHÔNG duplicate logic giữa 3 chỗ.
|
||||||
|
|
||||||
|
**Phòng tránh tương lai:**
|
||||||
|
- Khi spec mới có thêm phase terminal/intermediate (vd Session 17 thêm TraLai làm Phase RIÊNG thay vì DangSoanThao revert), audit grep TOÀN BỘ logic check `=== DangSoanThao` để xem chỗ nào cần thêm `|| === NewPhase`.
|
||||||
|
- BE guard early invariant `(targetPhase ∈ terminalSet) ⇔ (decision == Reject)` thay vì trust FE payload.
|
||||||
|
- Test-before bug fix BẮT BUỘC §7 — 3 test cover bug reproduce + consistency + happy path.
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
- Commit fix: `de00887` (BE Chunk A) + `4b29d00` (FE Chunk B)
|
||||||
|
- Spec Session 17: `feedback_n_stage_workflow_pattern` DEPRECATED + spec mới trong `PurchaseEvaluationWorkflowService.cs` comment L15-19
|
||||||
|
- State machine 5 trạng thái: Nháp / Đã gửi duyệt / **Trả lại (98) — Phase RIÊNG** / Từ chối / Đã duyệt
|
||||||
|
|
||||||
## Checklist debug bug mới
|
## Checklist debug bug mới
|
||||||
|
|
||||||
1. Build pass không? → fail → check using + package version compat
|
1. Build pass không? → fail → check using + package version compat
|
||||||
@ -660,3 +730,4 @@ public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
|
|||||||
19. Nếu npm install caching fail `tsc not found` → KHÔNG dùng junction Move-Item, thử robocopy/Copy-Item (#40)
|
19. Nếu npm install caching fail `tsc not found` → KHÔNG dùng junction Move-Item, thử robocopy/Copy-Item (#40)
|
||||||
20. Nếu CI vẫn trigger khi commit MD-only → paths-ignore trong on:push không match patterns đúng (#41)
|
20. Nếu CI vẫn trigger khi commit MD-only → paths-ignore trong on:push không match patterns đúng (#41)
|
||||||
21. Nếu user phàn nàn "feature work cho admin nhưng user empty/403 silent" → check class-level Authorize policy có over-restrict cho non-admin không, split per action (#44)
|
21. Nếu user phàn nàn "feature work cho admin nhưng user empty/403 silent" → check class-level Authorize policy có over-restrict cho non-admin không, split per action (#44)
|
||||||
|
22. Nếu button workflow label nói "Trả lại" nhưng phiếu vẫn tiến approve → audit FE `isReject` payload condition vs button `isSendBack` label condition vs dialog `isSendBack` warning condition — phải sync 3 chỗ với CÙNG set target phase. BE thêm guard `(target ∈ terminalSet) ⇔ (decision=Reject)` chặn caller mismatch (#45)
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { Select } from '@/components/ui/Select'
|
|||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import {
|
import {
|
||||||
PeAttachmentPurpose,
|
PeAttachmentPurpose,
|
||||||
PeAttachmentPurposeLabel,
|
PeAttachmentPurposeLabel,
|
||||||
@ -100,17 +101,33 @@ export function PeDetailTabs({
|
|||||||
const canEditPhase = isEditablePhase(evaluation.phase)
|
const canEditPhase = isEditablePhase(evaluation.phase)
|
||||||
const opinionsReadOnly = readOnly || mode === 'workspace'
|
const opinionsReadOnly = readOnly || mode === 'workspace'
|
||||||
|
|
||||||
|
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
|
||||||
|
const { user: currentUser } = useAuth()
|
||||||
|
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||||
|
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
||||||
|
const actorMatchesLevel = isAdmin
|
||||||
|
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
|
||||||
|
const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
|
||||||
|
&& (evaluation.workflowOptions?.allowApproverEditDetails ?? false)
|
||||||
|
&& actorMatchesLevel
|
||||||
|
const itemsReadOnly = readOnly && !approverEditMode
|
||||||
|
|
||||||
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
|
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
|
||||||
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
|
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
|
||||||
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
|
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
|
||||||
|
// Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối. Workflow phải bật flag.
|
||||||
|
const [skipToFinal, setSkipToFinal] = useState(false)
|
||||||
|
const allowSkipToFinal = evaluation.workflowOptions?.allowDrafterSkipToFinal ?? false
|
||||||
|
|
||||||
const submitForApproval = useMutation({
|
const submitForApproval = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (opts: { skipToFinal: boolean }) => {
|
||||||
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||||||
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
|
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
|
||||||
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||||
targetPhase: next,
|
targetPhase: next,
|
||||||
decision: 1,
|
decision: 1,
|
||||||
comment: null,
|
comment: null,
|
||||||
|
skipToFinal: opts.skipToFinal,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -192,7 +209,14 @@ export function PeDetailTabs({
|
|||||||
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
|
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
|
||||||
</Section>
|
</Section>
|
||||||
<Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
|
<Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
|
||||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
{/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2 */}
|
||||||
|
{approverEditMode && readOnly && (
|
||||||
|
<div className="mx-5 mt-2 rounded border border-violet-200 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
|
||||||
|
ⓘ Bạn được phép chỉnh sửa Hạng mục / NCC / Báo giá (workflow bật mode Approver edit).
|
||||||
|
Mọi thay đổi sẽ được ghi vào Lịch sử chỉnh sửa.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ItemsTab ev={evaluation} readOnly={itemsReadOnly} />
|
||||||
</Section>
|
</Section>
|
||||||
<Section title="3. Chọn NCC / TP thắng thầu">
|
<Section title="3. Chọn NCC / TP thắng thầu">
|
||||||
<ChonNccSection ev={evaluation} readOnly={readOnly} />
|
<ChonNccSection ev={evaluation} readOnly={readOnly} />
|
||||||
@ -251,18 +275,33 @@ export function PeDetailTabs({
|
|||||||
>
|
>
|
||||||
Lưu
|
Lưu
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Mig 28 (S21 t4) — F2: Drafter skip checkbox */}
|
||||||
|
{allowSkipToFinal && canSubmitForApproval && (
|
||||||
|
<label className="flex cursor-pointer items-center gap-1.5 rounded border border-violet-200 bg-violet-50 px-2 py-1 text-[11px] text-violet-800">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-3 w-3"
|
||||||
|
checked={skipToFinal}
|
||||||
|
onChange={e => setSkipToFinal(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Gửi thẳng Cấp cuối (skip trung gian)</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!forwardPhase) return
|
if (!forwardPhase) return
|
||||||
if (confirm(`Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`)) {
|
const confirmMsg = skipToFinal
|
||||||
submitForApproval.mutate()
|
? `Gửi THẲNG CẤP CUỐI bỏ qua các Cấp trung gian? Hệ thống sẽ ghi audit "Drafter skip" — không quay lại được trừ khi approver Trả lại.`
|
||||||
|
: `Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`
|
||||||
|
if (confirm(confirmMsg)) {
|
||||||
|
submitForApproval.mutate({ skipToFinal })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!canSubmitForApproval || submitForApproval.isPending}
|
disabled={!canSubmitForApproval || submitForApproval.isPending}
|
||||||
title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`}
|
title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{submitForApproval.isPending ? 'Đang gửi…' : 'Lưu & Gửi Duyệt →'}
|
{submitForApproval.isPending ? 'Đang gửi…' : skipToFinal ? 'Lưu & Gửi thẳng CẤP CUỐI →' : 'Lưu & Gửi Duyệt →'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
PurchaseEvaluationPhase,
|
PurchaseEvaluationPhase,
|
||||||
PurchaseEvaluationPhaseColor,
|
PurchaseEvaluationPhaseColor,
|
||||||
PurchaseEvaluationPhaseLabel,
|
PurchaseEvaluationPhaseLabel,
|
||||||
|
WorkflowReturnMode,
|
||||||
type PeDepartmentApproval,
|
type PeDepartmentApproval,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
} from '@/types/purchaseEvaluation'
|
} from '@/types/purchaseEvaluation'
|
||||||
@ -33,10 +34,20 @@ export function PeWorkflowPanel({
|
|||||||
}) {
|
}) {
|
||||||
const [target, setTarget] = useState<number | null>(null)
|
const [target, setTarget] = useState<number | null>(null)
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
|
// Mig 28 (S21 t4) — F1 mode Trả lại. Default Drafter (backward compat S17).
|
||||||
|
const [returnMode, setReturnMode] = useState<WorkflowReturnMode>(WorkflowReturnMode.Drafter)
|
||||||
|
const [returnTargetUserId, setReturnTargetUserId] = useState<string | null>(null)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||||
|
|
||||||
|
// Mig 28 — F1 workflow options. Null nếu V1 legacy → fallback chỉ "Trả về Drafter".
|
||||||
|
const wfOptions = evaluation.workflowOptions
|
||||||
|
// List approvers đã ký (cho mode Assignee dropdown pick)
|
||||||
|
const signedApprovers = (evaluation.levelOpinions ?? [])
|
||||||
|
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
|
||||||
|
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
|
||||||
|
|
||||||
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
|
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
|
||||||
// duyệt cấp hiện tại. Nếu actor không khớp → disable nút "Duyệt forward"
|
// duyệt cấp hiện tại. Nếu actor không khớp → disable nút "Duyệt forward"
|
||||||
// (Trả lại / Từ chối vẫn enabled vì Service không kiểm Bước/Cấp với 2
|
// (Trả lại / Từ chối vẫn enabled vì Service không kiểm Bước/Cấp với 2
|
||||||
@ -63,15 +74,28 @@ export function PeWorkflowPanel({
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
// Decision = Reject (2) khi:
|
// Decision = Reject (2) khi:
|
||||||
// - target = TuChoi (huỷ phiếu)
|
// - target = TuChoi (huỷ phiếu)
|
||||||
// - target = DangSoanThao từ phase trung gian (= Trả lại — smart reject Mig 16
|
// - target = DangSoanThao từ phase trung gian (= Trả lại legacy Mig 16
|
||||||
// set RejectedFromPhase + clear N-stage rows + Drafter resume jump-back)
|
// set RejectedFromPhase + clear N-stage rows + Drafter resume jump-back)
|
||||||
|
// - target = TraLai (98) từ phase trung gian — Session 17 spec mới: Trả
|
||||||
|
// lại là Phase RIÊNG (gotcha #45 — thiếu nhánh này gây "Trả về nhưng
|
||||||
|
// hệ thống vẫn duyệt" do BE nhận decision=Approve → ApproveV2Async).
|
||||||
|
// BE có guard mirror trong PurchaseEvaluationWorkflowService.TransitionAsync
|
||||||
|
// throw ConflictException nếu payload mismatch — phải sync 2 phía.
|
||||||
const isReject = target === PurchaseEvaluationPhase.TuChoi
|
const isReject = target === PurchaseEvaluationPhase.TuChoi
|
||||||
|| (target === PurchaseEvaluationPhase.DangSoanThao
|
|| (target === PurchaseEvaluationPhase.DangSoanThao
|
||||||
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|
||||||
|
|| (target === PurchaseEvaluationPhase.TraLai
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
|
||||||
|
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
|
||||||
|
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||||
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||||
targetPhase: target,
|
targetPhase: target,
|
||||||
decision: isReject ? 2 : 1,
|
decision: isReject ? 2 : 1,
|
||||||
comment: comment || null,
|
comment: comment || null,
|
||||||
|
returnMode: isTraLaiAction ? returnMode : null,
|
||||||
|
returnTargetUserId: isTraLaiAction && returnMode === WorkflowReturnMode.Assignee
|
||||||
|
? returnTargetUserId : null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -81,6 +105,8 @@ export function PeWorkflowPanel({
|
|||||||
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
|
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
|
||||||
setTarget(null)
|
setTarget(null)
|
||||||
setComment('')
|
setComment('')
|
||||||
|
setReturnMode(WorkflowReturnMode.Drafter)
|
||||||
|
setReturnTargetUserId(null)
|
||||||
},
|
},
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
@ -250,8 +276,13 @@ export function PeWorkflowPanel({
|
|||||||
|
|
||||||
{target !== null && (() => {
|
{target !== null && (() => {
|
||||||
const isCancel = target === PurchaseEvaluationPhase.TuChoi
|
const isCancel = target === PurchaseEvaluationPhase.TuChoi
|
||||||
const isSendBack = target === PurchaseEvaluationPhase.DangSoanThao
|
// isSendBack sync với button label + payload isReject (gotcha #45).
|
||||||
|
// Include cả DangSoanThao (legacy Mig 16) lẫn TraLai (Session 17 spec)
|
||||||
|
// — cả 2 là Trả lại Drafter sửa.
|
||||||
|
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
|| target === PurchaseEvaluationPhase.TraLai)
|
||||||
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
|
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||||
const dialogTitle = isCancel
|
const dialogTitle = isCancel
|
||||||
? '✗ Từ chối phiếu (khoá hoàn toàn)'
|
? '✗ Từ chối phiếu (khoá hoàn toàn)'
|
||||||
: isSendBack
|
: isSendBack
|
||||||
@ -273,9 +304,96 @@ export function PeWorkflowPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSendBack && (
|
{isSendBack && (
|
||||||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
<>
|
||||||
Phiếu sẽ về “Đang soạn thảo”. Drafter có thể sửa rồi trình lại — workflow tự jump tới phase này.
|
{/* Mig 28 (S21 t4) — F1 mode picker khi Trả lại. Show modes
|
||||||
|
enabled per workflow.options. Default Drafter (S17 fallback). */}
|
||||||
|
{(wfOptions?.allowReturnOneLevel
|
||||||
|
|| wfOptions?.allowReturnOneStep
|
||||||
|
|| wfOptions?.allowReturnToAssignee
|
||||||
|
|| wfOptions?.allowReturnToDrafter
|
||||||
|
|| !wfOptions) && (
|
||||||
|
<div className="mb-3 space-y-1.5">
|
||||||
|
<Label className="text-[12px]">Chọn cách Trả lại</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(wfOptions?.allowReturnOneLevel) && (
|
||||||
|
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={returnMode === WorkflowReturnMode.OneLevel}
|
||||||
|
onChange={() => setReturnMode(WorkflowReturnMode.OneLevel)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Trả về 1 Cấp trước</span>
|
||||||
|
<span className="block text-[10px] text-slate-500">Lùi 1 Cấp trong cùng Bước. NV cấp trước nhận lại.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{(wfOptions?.allowReturnOneStep) && (
|
||||||
|
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={returnMode === WorkflowReturnMode.OneStep}
|
||||||
|
onChange={() => setReturnMode(WorkflowReturnMode.OneStep)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Trả về 1 Bước trước</span>
|
||||||
|
<span className="block text-[10px] text-slate-500">Lùi sang Bước trước, NV Cấp cuối Bước đó nhận lại.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{(wfOptions?.allowReturnToAssignee) && (
|
||||||
|
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={returnMode === WorkflowReturnMode.Assignee}
|
||||||
|
onChange={() => setReturnMode(WorkflowReturnMode.Assignee)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="font-medium">Trả về Người chỉ định</span>
|
||||||
|
<span className="block text-[10px] text-slate-500">Pick từ list NV đã duyệt trước đó. Workflow set Cấp/Bước của NV.</span>
|
||||||
|
{returnMode === WorkflowReturnMode.Assignee && (
|
||||||
|
<select
|
||||||
|
className="mt-1.5 w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||||||
|
value={returnTargetUserId ?? ''}
|
||||||
|
onChange={e => setReturnTargetUserId(e.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">— Chọn NV —</option>
|
||||||
|
{signedApprovers.map(a => (
|
||||||
|
<option key={a.userId} value={a.userId}>{a.fullName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{(wfOptions?.allowReturnToDrafter !== false) && (
|
||||||
|
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={returnMode === WorkflowReturnMode.Drafter}
|
||||||
|
onChange={() => setReturnMode(WorkflowReturnMode.Drafter)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Trả về Người soạn thảo (mặc định)</span>
|
||||||
|
<span className="block text-[10px] text-slate-500">Phase → "Trả lại". Drafter sửa rồi gửi lại chạy từ Cấp 1 Bước 1.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||||
|
{returnMode === WorkflowReturnMode.Drafter
|
||||||
|
? 'Phiếu sẽ về "Trả lại". Drafter có thể sửa rồi trình lại từ Cấp 1 Bước 1.'
|
||||||
|
: returnMode === WorkflowReturnMode.Assignee
|
||||||
|
? 'Phiếu sẽ về Cấp/Bước của NV đã chọn (vẫn "Đã gửi duyệt"). NV nhận lại để duyệt tiếp.'
|
||||||
|
: 'Phiếu sẽ lùi pointer (vẫn "Đã gửi duyệt"). NV trước nhận lại để duyệt tiếp.'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Label>Ghi chú (tùy chọn)</Label>
|
<Label>Ghi chú (tùy chọn)</Label>
|
||||||
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
||||||
|
|||||||
@ -60,6 +60,13 @@ type DefinitionDto = {
|
|||||||
description: string | null
|
description: string | null
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
isUserSelectable: boolean // Mig 25 — admin toggle cho user pick
|
isUserSelectable: boolean // Mig 25 — admin toggle cho user pick
|
||||||
|
// Mig 28 (S21 t4) — 6 advanced options per workflow version
|
||||||
|
allowReturnOneLevel: boolean
|
||||||
|
allowReturnOneStep: boolean
|
||||||
|
allowReturnToAssignee: boolean
|
||||||
|
allowReturnToDrafter: boolean // default true backward compat S17
|
||||||
|
allowDrafterSkipToFinal: boolean
|
||||||
|
allowApproverEditDetails: boolean
|
||||||
activatedAt: string | null
|
activatedAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
steps: StepDto[]
|
steps: StepDto[]
|
||||||
@ -445,6 +452,15 @@ function Designer({
|
|||||||
const [description, setDescription] = useState(cloneFrom?.description ?? '')
|
const [description, setDescription] = useState(cloneFrom?.description ?? '')
|
||||||
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
|
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
|
||||||
|
|
||||||
|
// Mig 28 (S21 t4) — 6 advanced options. Default clone từ cloneFrom (giữ
|
||||||
|
// config version trước) hoặc backward compat S17 (chỉ Drafter mode).
|
||||||
|
const [allowReturnOneLevel, setAllowReturnOneLevel] = useState(cloneFrom?.allowReturnOneLevel ?? false)
|
||||||
|
const [allowReturnOneStep, setAllowReturnOneStep] = useState(cloneFrom?.allowReturnOneStep ?? false)
|
||||||
|
const [allowReturnToAssignee, setAllowReturnToAssignee] = useState(cloneFrom?.allowReturnToAssignee ?? false)
|
||||||
|
const [allowReturnToDrafter, setAllowReturnToDrafter] = useState(cloneFrom?.allowReturnToDrafter ?? true)
|
||||||
|
const [allowDrafterSkipToFinal, setAllowDrafterSkipToFinal] = useState(cloneFrom?.allowDrafterSkipToFinal ?? false)
|
||||||
|
const [allowApproverEditDetails, setAllowApproverEditDetails] = useState(cloneFrom?.allowApproverEditDetails ?? false)
|
||||||
|
|
||||||
const usersList = useQuery({
|
const usersList = useQuery({
|
||||||
queryKey: ['users-for-approver-v2'],
|
queryKey: ['users-for-approver-v2'],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
@ -503,6 +519,13 @@ function Designer({
|
|||||||
approverUserId: e.approverUserId,
|
approverUserId: e.approverUserId,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
|
// Mig 28 (S21 t4) — 6 advanced options
|
||||||
|
allowReturnOneLevel,
|
||||||
|
allowReturnOneStep,
|
||||||
|
allowReturnToAssignee,
|
||||||
|
allowReturnToDrafter,
|
||||||
|
allowDrafterSkipToFinal,
|
||||||
|
allowApproverEditDetails,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -561,6 +584,118 @@ function Designer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mig 28 (S21 t4) — Section Cấu hình nâng cao (F1+F2+F3 advanced options).
|
||||||
|
6 checkbox per workflow: 4 mode Trả lại + 1 Skip CEO + 1 Approver edit. */}
|
||||||
|
<div className="space-y-2 rounded-lg border border-amber-200 bg-amber-50/30 p-3">
|
||||||
|
<Label className="text-amber-900">
|
||||||
|
Cấu hình nâng cao — quyền duyệt mở rộng
|
||||||
|
</Label>
|
||||||
|
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||||
|
Bật/tắt mode duyệt mở rộng cho workflow này. Mặc định chỉ "Trả về Người soạn thảo" enabled
|
||||||
|
(tương thích quy trình cũ). Cá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 & Gửi Duyệt")
|
||||||
|
</div>
|
||||||
|
<label className="flex items-start gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-3.5 w-3.5"
|
||||||
|
checked={allowDrafterSkipToFinal}
|
||||||
|
onChange={e => setAllowDrafterSkipToFinal(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Cho phép Drafter gửi thẳng Cấp cuối</span>
|
||||||
|
<span className="block text-[10px] text-slate-500">
|
||||||
|
Skip mọi Bước/Cấp trung gian → đi thẳng NV Cấp cuối (vd CEO).
|
||||||
|
Workspace hiện dropdown 2 option "Gửi tuần tự" vs "Gửi thẳng Cấp cuối".
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[11px] font-semibold uppercase text-slate-500">
|
||||||
|
Approver chỉnh sửa phiếu
|
||||||
|
</div>
|
||||||
|
<label className="flex items-start gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-3.5 w-3.5"
|
||||||
|
checked={allowApproverEditDetails}
|
||||||
|
onChange={e => setAllowApproverEditDetails(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Cho phép Approver chỉnh sửa Section 2 (Hạng mục + NCC + Báo giá)</span>
|
||||||
|
<span className="block text-[10px] text-slate-500">
|
||||||
|
NV Cấp đang duyệt được edit chi tiết phiếu (không reset workflow,
|
||||||
|
giữ Cấp hiện tại). Mọi thay đổi log vào Lịch sử chỉnh sửa.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>
|
<Label>
|
||||||
|
|||||||
@ -347,6 +347,25 @@ export type PeDepartmentApproval = {
|
|||||||
isBypassed: boolean
|
isBypassed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mig 28 (S21 t4) — 6 advanced options của workflow pin.
|
||||||
|
export type ApprovalWorkflowOptions = {
|
||||||
|
allowReturnOneLevel: boolean
|
||||||
|
allowReturnOneStep: boolean
|
||||||
|
allowReturnToAssignee: boolean
|
||||||
|
allowReturnToDrafter: boolean
|
||||||
|
allowDrafterSkipToFinal: boolean
|
||||||
|
allowApproverEditDetails: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE
|
||||||
|
export const WorkflowReturnMode = {
|
||||||
|
OneLevel: 1,
|
||||||
|
OneStep: 2,
|
||||||
|
Assignee: 3,
|
||||||
|
Drafter: 4,
|
||||||
|
} as const
|
||||||
|
export type WorkflowReturnMode = typeof WorkflowReturnMode[keyof typeof WorkflowReturnMode]
|
||||||
|
|
||||||
export type PeDetailBundle = {
|
export type PeDetailBundle = {
|
||||||
id: string
|
id: string
|
||||||
maPhieu: string | null
|
maPhieu: string | null
|
||||||
@ -378,6 +397,8 @@ export type PeDetailBundle = {
|
|||||||
approvalWorkflowCode: string | null
|
approvalWorkflowCode: string | null
|
||||||
approvalWorkflowName: string | null
|
approvalWorkflowName: string | null
|
||||||
approvalWorkflowVersion: number | null
|
approvalWorkflowVersion: number | null
|
||||||
|
// Mig 28 (S21 t4) — 6 advanced options của workflow pin. Null nếu V1 legacy.
|
||||||
|
workflowOptions: ApprovalWorkflowOptions | null
|
||||||
// Mig 24 — info Bước/Cấp đang chờ duyệt (chỉ populate khi pin V2 + Phase=ChoDuyet)
|
// Mig 24 — info Bước/Cấp đang chờ duyệt (chỉ populate khi pin V2 + Phase=ChoDuyet)
|
||||||
currentApproval: PeCurrentApproval | null
|
currentApproval: PeCurrentApproval | null
|
||||||
// Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level
|
// Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { Select } from '@/components/ui/Select'
|
|||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import {
|
import {
|
||||||
PeAttachmentPurpose,
|
PeAttachmentPurpose,
|
||||||
PeAttachmentPurposeLabel,
|
PeAttachmentPurposeLabel,
|
||||||
@ -100,17 +101,38 @@ export function PeDetailTabs({
|
|||||||
const canEditPhase = isEditablePhase(evaluation.phase)
|
const canEditPhase = isEditablePhase(evaluation.phase)
|
||||||
const opinionsReadOnly = readOnly || mode === 'workspace'
|
const opinionsReadOnly = readOnly || mode === 'workspace'
|
||||||
|
|
||||||
|
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
|
||||||
|
// Khi phase=ChoDuyet + workflow.AllowApproverEditDetails + actor match
|
||||||
|
// CurrentLevel.ApproverUserId → cho phép edit Section 2 dù readOnly=true.
|
||||||
|
const { user: currentUser } = useAuth()
|
||||||
|
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||||
|
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
||||||
|
const actorMatchesLevel = isAdmin
|
||||||
|
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
|
||||||
|
const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
|
||||||
|
&& (evaluation.workflowOptions?.allowApproverEditDetails ?? false)
|
||||||
|
&& actorMatchesLevel
|
||||||
|
// itemsReadOnly = readOnly trừ khi approver mode F3 mở
|
||||||
|
const itemsReadOnly = readOnly && !approverEditMode
|
||||||
|
|
||||||
|
// Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối. Workflow phải bật flag.
|
||||||
|
// Default false (gửi tuần tự như cũ). Sync state với confirm dialog handler.
|
||||||
|
const [skipToFinal, setSkipToFinal] = useState(false)
|
||||||
|
const allowSkipToFinal = evaluation.workflowOptions?.allowDrafterSkipToFinal ?? false
|
||||||
|
|
||||||
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
|
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
|
||||||
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
|
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
|
||||||
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
|
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
|
||||||
const submitForApproval = useMutation({
|
const submitForApproval = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (opts: { skipToFinal: boolean }) => {
|
||||||
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||||||
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
|
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
|
||||||
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||||
targetPhase: next,
|
targetPhase: next,
|
||||||
decision: 1,
|
decision: 1,
|
||||||
comment: null,
|
comment: null,
|
||||||
|
// Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối
|
||||||
|
skipToFinal: opts.skipToFinal,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -192,7 +214,15 @@ export function PeDetailTabs({
|
|||||||
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
|
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
|
||||||
</Section>
|
</Section>
|
||||||
<Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
|
<Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
|
||||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
{/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2.
|
||||||
|
Banner cảnh báo "Bạn đang chỉnh sửa khi đang duyệt" khi approverEditMode. */}
|
||||||
|
{approverEditMode && readOnly && (
|
||||||
|
<div className="mx-5 mt-2 rounded border border-violet-200 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
|
||||||
|
ⓘ Bạn được phép chỉnh sửa Hạng mục / NCC / Báo giá (workflow bật mode Approver edit).
|
||||||
|
Mọi thay đổi sẽ được ghi vào Lịch sử chỉnh sửa.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ItemsTab ev={evaluation} readOnly={itemsReadOnly} />
|
||||||
</Section>
|
</Section>
|
||||||
<Section title="3. Chọn NCC / TP thắng thầu">
|
<Section title="3. Chọn NCC / TP thắng thầu">
|
||||||
<ChonNccSection ev={evaluation} readOnly={readOnly} />
|
<ChonNccSection ev={evaluation} readOnly={readOnly} />
|
||||||
@ -251,18 +281,34 @@ export function PeDetailTabs({
|
|||||||
>
|
>
|
||||||
Lưu
|
Lưu
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối checkbox.
|
||||||
|
Chỉ hiện khi workflow.AllowDrafterSkipToFinal=true. */}
|
||||||
|
{allowSkipToFinal && canSubmitForApproval && (
|
||||||
|
<label className="flex cursor-pointer items-center gap-1.5 rounded border border-violet-200 bg-violet-50 px-2 py-1 text-[11px] text-violet-800">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-3 w-3"
|
||||||
|
checked={skipToFinal}
|
||||||
|
onChange={e => setSkipToFinal(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Gửi thẳng Cấp cuối (skip trung gian)</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!forwardPhase) return
|
if (!forwardPhase) return
|
||||||
if (confirm(`Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`)) {
|
const confirmMsg = skipToFinal
|
||||||
submitForApproval.mutate()
|
? `Gửi THẲNG CẤP CUỐI bỏ qua các Cấp trung gian? Hệ thống sẽ ghi audit "Drafter skip" — không quay lại được trừ khi approver Trả lại.`
|
||||||
|
: `Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`
|
||||||
|
if (confirm(confirmMsg)) {
|
||||||
|
submitForApproval.mutate({ skipToFinal })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!canSubmitForApproval || submitForApproval.isPending}
|
disabled={!canSubmitForApproval || submitForApproval.isPending}
|
||||||
title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`}
|
title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{submitForApproval.isPending ? 'Đang gửi…' : 'Lưu & Gửi Duyệt →'}
|
{submitForApproval.isPending ? 'Đang gửi…' : skipToFinal ? 'Lưu & Gửi thẳng CẤP CUỐI →' : 'Lưu & Gửi Duyệt →'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
PurchaseEvaluationPhase,
|
PurchaseEvaluationPhase,
|
||||||
PurchaseEvaluationPhaseColor,
|
PurchaseEvaluationPhaseColor,
|
||||||
PurchaseEvaluationPhaseLabel,
|
PurchaseEvaluationPhaseLabel,
|
||||||
|
WorkflowReturnMode,
|
||||||
type PeDepartmentApproval,
|
type PeDepartmentApproval,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
} from '@/types/purchaseEvaluation'
|
} from '@/types/purchaseEvaluation'
|
||||||
@ -33,10 +34,21 @@ export function PeWorkflowPanel({
|
|||||||
}) {
|
}) {
|
||||||
const [target, setTarget] = useState<number | null>(null)
|
const [target, setTarget] = useState<number | null>(null)
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
|
// Mig 28 (S21 t4) — F1 mode Trả lại. Default Drafter (backward compat S17).
|
||||||
|
const [returnMode, setReturnMode] = useState<WorkflowReturnMode>(WorkflowReturnMode.Drafter)
|
||||||
|
const [returnTargetUserId, setReturnTargetUserId] = useState<string | null>(null)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||||
|
|
||||||
|
// Mig 28 — F1 workflow options. Null nếu V1 legacy → fallback chỉ "Trả về Drafter".
|
||||||
|
const wfOptions = evaluation.workflowOptions
|
||||||
|
// List approvers đã ký (cho mode Assignee dropdown pick)
|
||||||
|
const signedApprovers = (evaluation.levelOpinions ?? [])
|
||||||
|
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
|
||||||
|
// Dedupe by userId (1 NV có thể ký nhiều cấp nếu workflow đặt như vậy)
|
||||||
|
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
|
||||||
|
|
||||||
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
|
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
|
||||||
// duyệt cấp hiện tại. Admin bypass.
|
// duyệt cấp hiện tại. Admin bypass.
|
||||||
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
||||||
@ -59,15 +71,28 @@ export function PeWorkflowPanel({
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
// Decision = Reject (2) khi:
|
// Decision = Reject (2) khi:
|
||||||
// - target = TuChoi (huỷ phiếu)
|
// - target = TuChoi (huỷ phiếu)
|
||||||
// - target = DangSoanThao từ phase trung gian (= Trả lại — smart reject Mig 16
|
// - target = DangSoanThao từ phase trung gian (= Trả lại legacy Mig 16
|
||||||
// set RejectedFromPhase + clear N-stage rows + Drafter resume jump-back)
|
// set RejectedFromPhase + clear N-stage rows + Drafter resume jump-back)
|
||||||
|
// - target = TraLai (98) từ phase trung gian — Session 17 spec mới: Trả
|
||||||
|
// lại là Phase RIÊNG (gotcha #45 — thiếu nhánh này gây "Trả về nhưng
|
||||||
|
// hệ thống vẫn duyệt" do BE nhận decision=Approve → ApproveV2Async).
|
||||||
|
// BE có guard mirror trong PurchaseEvaluationWorkflowService.TransitionAsync
|
||||||
|
// throw ConflictException nếu payload mismatch — phải sync 2 phía.
|
||||||
const isReject = target === PurchaseEvaluationPhase.TuChoi
|
const isReject = target === PurchaseEvaluationPhase.TuChoi
|
||||||
|| (target === PurchaseEvaluationPhase.DangSoanThao
|
|| (target === PurchaseEvaluationPhase.DangSoanThao
|
||||||
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|
||||||
|
|| (target === PurchaseEvaluationPhase.TraLai
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
|
||||||
|
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
|
||||||
|
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||||
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||||
targetPhase: target,
|
targetPhase: target,
|
||||||
decision: isReject ? 2 : 1,
|
decision: isReject ? 2 : 1,
|
||||||
comment: comment || null,
|
comment: comment || null,
|
||||||
|
returnMode: isTraLaiAction ? returnMode : null,
|
||||||
|
returnTargetUserId: isTraLaiAction && returnMode === WorkflowReturnMode.Assignee
|
||||||
|
? returnTargetUserId : null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -77,6 +102,8 @@ export function PeWorkflowPanel({
|
|||||||
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
|
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
|
||||||
setTarget(null)
|
setTarget(null)
|
||||||
setComment('')
|
setComment('')
|
||||||
|
setReturnMode(WorkflowReturnMode.Drafter)
|
||||||
|
setReturnTargetUserId(null)
|
||||||
},
|
},
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
@ -244,8 +271,13 @@ export function PeWorkflowPanel({
|
|||||||
|
|
||||||
{target !== null && (() => {
|
{target !== null && (() => {
|
||||||
const isCancel = target === PurchaseEvaluationPhase.TuChoi
|
const isCancel = target === PurchaseEvaluationPhase.TuChoi
|
||||||
const isSendBack = target === PurchaseEvaluationPhase.DangSoanThao
|
// isSendBack sync với button label L205-207 + payload isReject L64-68
|
||||||
|
// (gotcha #45). Include cả DangSoanThao (legacy Mig 16) lẫn TraLai
|
||||||
|
// (Session 17 spec) — cả 2 là Trả lại Drafter sửa.
|
||||||
|
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
|| target === PurchaseEvaluationPhase.TraLai)
|
||||||
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
|
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||||
const dialogTitle = isCancel
|
const dialogTitle = isCancel
|
||||||
? '✗ Từ chối phiếu (khoá hoàn toàn)'
|
? '✗ Từ chối phiếu (khoá hoàn toàn)'
|
||||||
: isSendBack
|
: isSendBack
|
||||||
@ -267,9 +299,96 @@ export function PeWorkflowPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSendBack && (
|
{isSendBack && (
|
||||||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
<>
|
||||||
Phiếu sẽ về “Đang soạn thảo”. Drafter có thể sửa rồi trình lại — workflow tự jump tới phase này.
|
{/* Mig 28 (S21 t4) — F1 mode picker khi Trả lại. Show modes
|
||||||
|
enabled per workflow.options. Default Drafter (S17 fallback). */}
|
||||||
|
{(wfOptions?.allowReturnOneLevel
|
||||||
|
|| wfOptions?.allowReturnOneStep
|
||||||
|
|| wfOptions?.allowReturnToAssignee
|
||||||
|
|| wfOptions?.allowReturnToDrafter
|
||||||
|
|| !wfOptions) && (
|
||||||
|
<div className="mb-3 space-y-1.5">
|
||||||
|
<Label className="text-[12px]">Chọn cách Trả lại</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(wfOptions?.allowReturnOneLevel) && (
|
||||||
|
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={returnMode === WorkflowReturnMode.OneLevel}
|
||||||
|
onChange={() => setReturnMode(WorkflowReturnMode.OneLevel)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Trả về 1 Cấp trước</span>
|
||||||
|
<span className="block text-[10px] text-slate-500">Lùi 1 Cấp trong cùng Bước. NV cấp trước nhận lại.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{(wfOptions?.allowReturnOneStep) && (
|
||||||
|
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={returnMode === WorkflowReturnMode.OneStep}
|
||||||
|
onChange={() => setReturnMode(WorkflowReturnMode.OneStep)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Trả về 1 Bước trước</span>
|
||||||
|
<span className="block text-[10px] text-slate-500">Lùi sang Bước trước, NV Cấp cuối Bước đó nhận lại.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{(wfOptions?.allowReturnToAssignee) && (
|
||||||
|
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={returnMode === WorkflowReturnMode.Assignee}
|
||||||
|
onChange={() => setReturnMode(WorkflowReturnMode.Assignee)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="font-medium">Trả về Người chỉ định</span>
|
||||||
|
<span className="block text-[10px] text-slate-500">Pick từ list NV đã duyệt trước đó. Workflow set Cấp/Bước của NV.</span>
|
||||||
|
{returnMode === WorkflowReturnMode.Assignee && (
|
||||||
|
<select
|
||||||
|
className="mt-1.5 w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||||||
|
value={returnTargetUserId ?? ''}
|
||||||
|
onChange={e => setReturnTargetUserId(e.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">— Chọn NV —</option>
|
||||||
|
{signedApprovers.map(a => (
|
||||||
|
<option key={a.userId} value={a.userId}>{a.fullName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{(wfOptions?.allowReturnToDrafter !== false) && (
|
||||||
|
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={returnMode === WorkflowReturnMode.Drafter}
|
||||||
|
onChange={() => setReturnMode(WorkflowReturnMode.Drafter)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Trả về Người soạn thảo (mặc định)</span>
|
||||||
|
<span className="block text-[10px] text-slate-500">Phase → "Trả lại". Drafter sửa rồi gửi lại chạy từ Cấp 1 Bước 1.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||||
|
{returnMode === WorkflowReturnMode.Drafter
|
||||||
|
? 'Phiếu sẽ về "Trả lại". Drafter có thể sửa rồi trình lại từ Cấp 1 Bước 1.'
|
||||||
|
: returnMode === WorkflowReturnMode.Assignee
|
||||||
|
? 'Phiếu sẽ về Cấp/Bước của NV đã chọn (vẫn "Đã gửi duyệt"). NV nhận lại để duyệt tiếp.'
|
||||||
|
: 'Phiếu sẽ lùi pointer (vẫn "Đã gửi duyệt"). NV trước nhận lại để duyệt tiếp.'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Label>Ghi chú (tùy chọn)</Label>
|
<Label>Ghi chú (tùy chọn)</Label>
|
||||||
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
||||||
|
|||||||
@ -344,6 +344,25 @@ export type PeDepartmentApproval = {
|
|||||||
isBypassed: boolean
|
isBypassed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mig 28 (S21 t4) — 6 advanced options của workflow pin.
|
||||||
|
export type ApprovalWorkflowOptions = {
|
||||||
|
allowReturnOneLevel: boolean
|
||||||
|
allowReturnOneStep: boolean
|
||||||
|
allowReturnToAssignee: boolean
|
||||||
|
allowReturnToDrafter: boolean
|
||||||
|
allowDrafterSkipToFinal: boolean
|
||||||
|
allowApproverEditDetails: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE
|
||||||
|
export const WorkflowReturnMode = {
|
||||||
|
OneLevel: 1,
|
||||||
|
OneStep: 2,
|
||||||
|
Assignee: 3,
|
||||||
|
Drafter: 4,
|
||||||
|
} as const
|
||||||
|
export type WorkflowReturnMode = typeof WorkflowReturnMode[keyof typeof WorkflowReturnMode]
|
||||||
|
|
||||||
export type PeDetailBundle = {
|
export type PeDetailBundle = {
|
||||||
id: string
|
id: string
|
||||||
maPhieu: string | null
|
maPhieu: string | null
|
||||||
@ -375,6 +394,9 @@ export type PeDetailBundle = {
|
|||||||
approvalWorkflowCode: string | null
|
approvalWorkflowCode: string | null
|
||||||
approvalWorkflowName: string | null
|
approvalWorkflowName: string | null
|
||||||
approvalWorkflowVersion: number | null
|
approvalWorkflowVersion: number | null
|
||||||
|
// Mig 28 (S21 t4) — 6 advanced options của workflow pin. Null nếu V1 legacy.
|
||||||
|
// FE filter Trả lại dropdown + Skip submit + Edit Section 2 conditional.
|
||||||
|
workflowOptions: ApprovalWorkflowOptions | null
|
||||||
// Mig 24 — info Bước/Cấp đang chờ duyệt (chỉ populate khi pin V2 + Phase=ChoDuyet)
|
// Mig 24 — info Bước/Cấp đang chờ duyệt (chỉ populate khi pin V2 + Phase=ChoDuyet)
|
||||||
currentApproval: PeCurrentApproval | null
|
currentApproval: PeCurrentApproval | null
|
||||||
// Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level
|
// Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level
|
||||||
|
|||||||
@ -46,6 +46,15 @@ public record AwDefinitionDto(
|
|||||||
string? Description,
|
string? Description,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
bool IsUserSelectable,
|
bool IsUserSelectable,
|
||||||
|
// Mig 28 (S21 t4) — 6 advanced options của workflow per version. Admin
|
||||||
|
// Designer tick stick → checkbox. FE eOffice render dropdown / Skip / Edit
|
||||||
|
// conditional theo flag tương ứng.
|
||||||
|
bool AllowReturnOneLevel,
|
||||||
|
bool AllowReturnOneStep,
|
||||||
|
bool AllowReturnToAssignee,
|
||||||
|
bool AllowReturnToDrafter,
|
||||||
|
bool AllowDrafterSkipToFinal,
|
||||||
|
bool AllowApproverEditDetails,
|
||||||
DateTime? ActivatedAt,
|
DateTime? ActivatedAt,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
List<AwStepDto> Steps);
|
List<AwStepDto> Steps);
|
||||||
@ -128,6 +137,13 @@ public class GetAwAdminOverviewQueryHandler(
|
|||||||
d.Description,
|
d.Description,
|
||||||
d.IsActive,
|
d.IsActive,
|
||||||
d.IsUserSelectable,
|
d.IsUserSelectable,
|
||||||
|
// Mig 28 — 6 Allow* flag
|
||||||
|
d.AllowReturnOneLevel,
|
||||||
|
d.AllowReturnOneStep,
|
||||||
|
d.AllowReturnToAssignee,
|
||||||
|
d.AllowReturnToDrafter,
|
||||||
|
d.AllowDrafterSkipToFinal,
|
||||||
|
d.AllowApproverEditDetails,
|
||||||
d.ActivatedAt,
|
d.ActivatedAt,
|
||||||
d.CreatedAt,
|
d.CreatedAt,
|
||||||
d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto(
|
d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto(
|
||||||
@ -178,7 +194,15 @@ public record CreateAwDefinitionCommand(
|
|||||||
string Code,
|
string Code,
|
||||||
string Name,
|
string Name,
|
||||||
string? Description,
|
string? Description,
|
||||||
List<CreateAwStepInput> Steps) : IRequest<Guid>;
|
List<CreateAwStepInput> Steps,
|
||||||
|
// Mig 28 (S21 t4) — 6 Allow* options. Default = backward compat S17
|
||||||
|
// (chỉ Trả về Drafter enabled). Admin tick stick để mở mode khác.
|
||||||
|
bool AllowReturnOneLevel = false,
|
||||||
|
bool AllowReturnOneStep = false,
|
||||||
|
bool AllowReturnToAssignee = false,
|
||||||
|
bool AllowReturnToDrafter = true,
|
||||||
|
bool AllowDrafterSkipToFinal = false,
|
||||||
|
bool AllowApproverEditDetails = false) : IRequest<Guid>;
|
||||||
|
|
||||||
public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
|
public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
|
||||||
{
|
{
|
||||||
@ -271,6 +295,13 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
|
|||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
IsUserSelectable = true, // Mig 25 — version mới mặc định cho user pick
|
IsUserSelectable = true, // Mig 25 — version mới mặc định cho user pick
|
||||||
|
// Mig 28 (S21 t4) — 6 Allow* options
|
||||||
|
AllowReturnOneLevel = request.AllowReturnOneLevel,
|
||||||
|
AllowReturnOneStep = request.AllowReturnOneStep,
|
||||||
|
AllowReturnToAssignee = request.AllowReturnToAssignee,
|
||||||
|
AllowReturnToDrafter = request.AllowReturnToDrafter,
|
||||||
|
AllowDrafterSkipToFinal = request.AllowDrafterSkipToFinal,
|
||||||
|
AllowApproverEditDetails = request.AllowApproverEditDetails,
|
||||||
ActivatedAt = DateTime.UtcNow,
|
ActivatedAt = DateTime.UtcNow,
|
||||||
Steps = request.Steps.OrderBy(s => s.Order)
|
Steps = request.Steps.OrderBy(s => s.Order)
|
||||||
.Select(s => new ApprovalWorkflowStep
|
.Select(s => new ApprovalWorkflowStep
|
||||||
|
|||||||
@ -78,6 +78,16 @@ public record PurchaseEvaluationChangelogDto(
|
|||||||
string? ContextNote,
|
string? ContextNote,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
// Mig 28 (S21 t4) — 6 advanced options của workflow pin. FE filter Trả lại
|
||||||
|
// dropdown / Skip submit / Edit Section 2 enabled theo flag tương ứng.
|
||||||
|
public record ApprovalWorkflowOptionsDto(
|
||||||
|
bool AllowReturnOneLevel,
|
||||||
|
bool AllowReturnOneStep,
|
||||||
|
bool AllowReturnToAssignee,
|
||||||
|
bool AllowReturnToDrafter,
|
||||||
|
bool AllowDrafterSkipToFinal,
|
||||||
|
bool AllowApproverEditDetails);
|
||||||
|
|
||||||
public record PurchaseEvaluationWorkflowSummaryDto(
|
public record PurchaseEvaluationWorkflowSummaryDto(
|
||||||
string PolicyName,
|
string PolicyName,
|
||||||
string PolicyDescription,
|
string PolicyDescription,
|
||||||
@ -194,6 +204,9 @@ public record PurchaseEvaluationDetailBundleDto(
|
|||||||
string? ApprovalWorkflowCode,
|
string? ApprovalWorkflowCode,
|
||||||
string? ApprovalWorkflowName,
|
string? ApprovalWorkflowName,
|
||||||
int? ApprovalWorkflowVersion,
|
int? ApprovalWorkflowVersion,
|
||||||
|
// Mig 28 (S21 t4) — 6 Allow* options của workflow pin. Null nếu phiếu V1
|
||||||
|
// legacy. FE render Trả lại dropdown + Skip + Edit Section 2 conditional.
|
||||||
|
ApprovalWorkflowOptionsDto? WorkflowOptions,
|
||||||
PurchaseEvaluationCurrentApprovalDto? CurrentApproval,
|
PurchaseEvaluationCurrentApprovalDto? CurrentApproval,
|
||||||
PurchaseEvaluationApprovalFlowDto? ApprovalFlow,
|
PurchaseEvaluationApprovalFlowDto? ApprovalFlow,
|
||||||
List<PurchaseEvaluationSupplierDto> Suppliers,
|
List<PurchaseEvaluationSupplierDto> Suppliers,
|
||||||
|
|||||||
@ -4,23 +4,96 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using SolutionErp.Application.Common.Exceptions;
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
using SolutionErp.Application.Common.Interfaces;
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
using SolutionErp.Domain.Contracts;
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
using SolutionErp.Domain.PurchaseEvaluations;
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
namespace SolutionErp.Application.PurchaseEvaluations;
|
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
|
||||||
// ========== Helper: Lock edit guard (Phase 9 — Migration 16) ==========
|
// ========== Helper: Lock edit guard (Phase 9 — Migration 16) ==========
|
||||||
// Chỉ Phase=DangSoanThao mới được CRUD chi tiết / báo giá / NCC tham gia.
|
// Original: Chỉ Phase=DangSoanThao mới được CRUD chi tiết / báo giá / NCC tham gia.
|
||||||
// Đã trình duyệt → KHÔNG sửa được. Phải reject về DangSoanThao trước.
|
// Đã trình duyệt → KHÔNG sửa được. Phải reject về DangSoanThao trước.
|
||||||
|
//
|
||||||
|
// Mig 28 (S21 t4 — F3): Extend Section 2 (Detail + NCC + Báo giá) cho phép
|
||||||
|
// Approver edit khi phase=ChoDuyet + workflow.AllowApproverEditDetails=true +
|
||||||
|
// actor.Id == currentLevel.ApproverUserId. KHÔNG đụng PE Header / Attachment /
|
||||||
|
// DepartmentOpinion — vẫn dùng EnsureDraftAsync strict.
|
||||||
internal static class PurchaseEvaluationDraftGuard
|
internal static class PurchaseEvaluationDraftGuard
|
||||||
{
|
{
|
||||||
|
/// Strict guard cho PE Header / Attachment / DepartmentOpinion / Winner select —
|
||||||
|
/// chỉ Drafter scope (DangSoanThao OR TraLai để Drafter sửa rồi gửi lại).
|
||||||
public static async Task<PurchaseEvaluation> EnsureDraftAsync(IApplicationDbContext db, Guid id, CancellationToken ct)
|
public static async Task<PurchaseEvaluation> EnsureDraftAsync(IApplicationDbContext db, Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct)
|
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluation", id);
|
?? throw new NotFoundException("PurchaseEvaluation", id);
|
||||||
if (pe.Phase != PurchaseEvaluationPhase.DangSoanThao)
|
if (pe.Phase != PurchaseEvaluationPhase.DangSoanThao
|
||||||
throw new ConflictException($"Phiếu PE đã trình duyệt (Phase={pe.Phase}), không thể chỉnh sửa chi tiết. Phải reject để Drafter sửa lại.");
|
&& pe.Phase != PurchaseEvaluationPhase.TraLai)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Phiếu PE đã trình duyệt (Phase={pe.Phase}), không thể chỉnh sửa. " +
|
||||||
|
"Phải Trả lại Drafter sửa lại.");
|
||||||
return pe;
|
return pe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F3 (Mig 28 — S21 t4) — Edit guard cho Section 2 (Detail + NCC + Báo giá).
|
||||||
|
/// 2 trường hợp accepted:
|
||||||
|
/// 1. Drafter scope: DangSoanThao OR TraLai — Controller [Authorize] handle role.
|
||||||
|
/// 2. Approver scope: ChoDuyet + workflow.AllowApproverEditDetails=true +
|
||||||
|
/// actor.Id match CurrentLevel.ApproverUserId. KHÔNG reset workflow,
|
||||||
|
/// giữ Cấp hiện tại. Admin bypass workflow flag check.
|
||||||
|
public static async Task<PurchaseEvaluation> EnsureEditableForDetailsAsync(
|
||||||
|
IApplicationDbContext db, Guid id, ICurrentUser currentUser, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluation", id);
|
||||||
|
|
||||||
|
// Drafter scope — any authenticated, Controller [Authorize(Policy)] gates role
|
||||||
|
if (pe.Phase == PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
|| pe.Phase == PurchaseEvaluationPhase.TraLai)
|
||||||
|
return pe;
|
||||||
|
|
||||||
|
// F3 Approver scope (Mig 28) — chỉ ChoDuyet với V2 schema
|
||||||
|
if (pe.Phase == PurchaseEvaluationPhase.ChoDuyet
|
||||||
|
&& currentUser.IsAuthenticated
|
||||||
|
&& currentUser.UserId is Guid actorUserId)
|
||||||
|
{
|
||||||
|
// Admin bypass — admin có thể edit bất chấp Allow* flag
|
||||||
|
if (currentUser.Roles.Contains(AppRoles.Admin)) return pe;
|
||||||
|
|
||||||
|
// V2 schema required
|
||||||
|
if (pe.ApprovalWorkflowId is Guid awId
|
||||||
|
&& pe.CurrentWorkflowStepIndex is int stepIdx
|
||||||
|
&& pe.CurrentApprovalLevelOrder is int levelOrder)
|
||||||
|
{
|
||||||
|
var workflow = await db.ApprovalWorkflows
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == awId, ct)
|
||||||
|
?? throw new ConflictException("Workflow không tồn tại.");
|
||||||
|
|
||||||
|
if (!workflow.AllowApproverEditDetails)
|
||||||
|
throw new ConflictException(
|
||||||
|
"Workflow không bật mode 'Approver chỉnh sửa Section 2'. " +
|
||||||
|
"Phải Trả lại Drafter sửa hoặc liên hệ Admin Designer.");
|
||||||
|
|
||||||
|
var step = workflow.Steps.OrderBy(s => s.Order).Skip(stepIdx).FirstOrDefault();
|
||||||
|
var level = step?.Levels.FirstOrDefault(lv => lv.Order == levelOrder);
|
||||||
|
if (level is null)
|
||||||
|
throw new ConflictException("Workflow Bước/Cấp không tìm thấy — schema lỗi.");
|
||||||
|
|
||||||
|
if (level.ApproverUserId != actorUserId)
|
||||||
|
throw new ForbiddenException(
|
||||||
|
$"Chỉ NV phụ trách Bước {step!.Order} / Cấp {levelOrder} " +
|
||||||
|
"mới được chỉnh sửa Section 2 lúc đang duyệt.");
|
||||||
|
|
||||||
|
return pe;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ConflictException(
|
||||||
|
"Phiếu chưa pin workflow V2 hoặc chưa init Bước/Cấp — không thể chỉnh sửa.");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Phiếu PE ở Phase={pe.Phase}, không thể chỉnh sửa Section 2. " +
|
||||||
|
"Phải Trả lại Drafter sửa.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Detail (hạng mục + ngân sách) ==========
|
// ========== Detail (hạng mục + ngân sách) ==========
|
||||||
@ -55,7 +128,8 @@ public class AddPurchaseEvaluationDetailCommandHandler(
|
|||||||
{
|
{
|
||||||
public async Task<Guid> Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct)
|
public async Task<Guid> Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var evaluation = await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
|
var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||||
|
db, request.PurchaseEvaluationId, currentUser, ct);
|
||||||
|
|
||||||
var maxOrder = await db.PurchaseEvaluationDetails
|
var maxOrder = await db.PurchaseEvaluationDetails
|
||||||
.Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId)
|
.Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId)
|
||||||
@ -110,11 +184,13 @@ public record UpdatePurchaseEvaluationDetailCommand(
|
|||||||
string? GhiChu) : IRequest;
|
string? GhiChu) : IRequest;
|
||||||
|
|
||||||
public class UpdatePurchaseEvaluationDetailCommandHandler(
|
public class UpdatePurchaseEvaluationDetailCommandHandler(
|
||||||
IApplicationDbContext db) : IRequestHandler<UpdatePurchaseEvaluationDetailCommand>
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationDetailCommand>
|
||||||
{
|
{
|
||||||
public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct)
|
public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
|
var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||||
|
db, request.PurchaseEvaluationId, currentUser, ct);
|
||||||
var entity = await db.PurchaseEvaluationDetails
|
var entity = await db.PurchaseEvaluationDetails
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
|
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
|
||||||
@ -130,6 +206,21 @@ public class UpdatePurchaseEvaluationDetailCommandHandler(
|
|||||||
entity.ThanhTienNganSach = request.ThanhTienNganSach;
|
entity.ThanhTienNganSach = request.ThanhTienNganSach;
|
||||||
entity.GhiChu = request.GhiChu;
|
entity.GhiChu = request.GhiChu;
|
||||||
|
|
||||||
|
// F3 audit (Mig 28) — log Approver edit Section 2. Drafter edit cũng log
|
||||||
|
// để audit trail consistent. Phase ChoDuyet → flag "Approver" trong summary.
|
||||||
|
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
|
||||||
|
? " [Approver edit khi đang duyệt]" : string.Empty;
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Detail,
|
||||||
|
EntityId = entity.Id,
|
||||||
|
Action = ChangelogAction.Update,
|
||||||
|
PhaseAtChange = evaluation.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Cập nhật hạng mục {request.GroupCode} — {request.NoiDung}{approverNote}",
|
||||||
|
});
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -137,15 +228,30 @@ public class UpdatePurchaseEvaluationDetailCommandHandler(
|
|||||||
public record DeletePurchaseEvaluationDetailCommand(Guid PurchaseEvaluationId, Guid DetailId) : IRequest;
|
public record DeletePurchaseEvaluationDetailCommand(Guid PurchaseEvaluationId, Guid DetailId) : IRequest;
|
||||||
|
|
||||||
public class DeletePurchaseEvaluationDetailCommandHandler(
|
public class DeletePurchaseEvaluationDetailCommandHandler(
|
||||||
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationDetailCommand>
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationDetailCommand>
|
||||||
{
|
{
|
||||||
public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct)
|
public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
|
var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||||
|
db, request.PurchaseEvaluationId, currentUser, ct);
|
||||||
var entity = await db.PurchaseEvaluationDetails
|
var entity = await db.PurchaseEvaluationDetails
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
|
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
|
||||||
|
|
||||||
|
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
|
||||||
|
? " [Approver edit khi đang duyệt]" : string.Empty;
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Detail,
|
||||||
|
EntityId = entity.Id,
|
||||||
|
Action = ChangelogAction.Delete,
|
||||||
|
PhaseAtChange = evaluation.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Xóa hạng mục {entity.GroupCode} — {entity.NoiDung}{approverNote}",
|
||||||
|
});
|
||||||
|
|
||||||
db.PurchaseEvaluationDetails.Remove(entity);
|
db.PurchaseEvaluationDetails.Remove(entity);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
@ -164,11 +270,13 @@ public record UpsertPurchaseEvaluationQuoteCommand(
|
|||||||
string? Note) : IRequest<Guid>;
|
string? Note) : IRequest<Guid>;
|
||||||
|
|
||||||
public class UpsertPurchaseEvaluationQuoteCommandHandler(
|
public class UpsertPurchaseEvaluationQuoteCommandHandler(
|
||||||
IApplicationDbContext db) : IRequestHandler<UpsertPurchaseEvaluationQuoteCommand, Guid>
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<UpsertPurchaseEvaluationQuoteCommand, Guid>
|
||||||
{
|
{
|
||||||
public async Task<Guid> Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct)
|
public async Task<Guid> Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
|
var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||||
|
db, request.PurchaseEvaluationId, currentUser, ct);
|
||||||
// Verify parents exist + same phiếu
|
// Verify parents exist + same phiếu
|
||||||
var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync(
|
var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync(
|
||||||
d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
@ -182,6 +290,9 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
|
|||||||
q => q.PurchaseEvaluationDetailId == request.PurchaseEvaluationDetailId
|
q => q.PurchaseEvaluationDetailId == request.PurchaseEvaluationDetailId
|
||||||
&& q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId, ct);
|
&& q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId, ct);
|
||||||
|
|
||||||
|
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
|
||||||
|
? " [Approver edit khi đang duyệt]" : string.Empty;
|
||||||
|
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
existing.BgVat = request.BgVat;
|
existing.BgVat = request.BgVat;
|
||||||
@ -189,6 +300,16 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
|
|||||||
existing.ThanhTien = request.ThanhTien;
|
existing.ThanhTien = request.ThanhTien;
|
||||||
existing.IsSelected = request.IsSelected;
|
existing.IsSelected = request.IsSelected;
|
||||||
existing.Note = request.Note;
|
existing.Note = request.Note;
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Quote,
|
||||||
|
EntityId = existing.Id,
|
||||||
|
Action = ChangelogAction.Update,
|
||||||
|
PhaseAtChange = evaluation.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Cập nhật báo giá cho hạng mục {detail.GroupCode}{approverNote}",
|
||||||
|
});
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return existing.Id;
|
return existing.Id;
|
||||||
}
|
}
|
||||||
@ -204,6 +325,16 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
|
|||||||
Note = request.Note,
|
Note = request.Note,
|
||||||
};
|
};
|
||||||
db.PurchaseEvaluationQuotes.Add(entity);
|
db.PurchaseEvaluationQuotes.Add(entity);
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Quote,
|
||||||
|
EntityId = entity.Id,
|
||||||
|
Action = ChangelogAction.Insert,
|
||||||
|
PhaseAtChange = evaluation.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Thêm báo giá cho hạng mục {detail.GroupCode}{approverNote}",
|
||||||
|
});
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return entity.Id;
|
return entity.Id;
|
||||||
}
|
}
|
||||||
@ -212,11 +343,13 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
|
|||||||
public record DeletePurchaseEvaluationQuoteCommand(Guid PurchaseEvaluationId, Guid QuoteId) : IRequest;
|
public record DeletePurchaseEvaluationQuoteCommand(Guid PurchaseEvaluationId, Guid QuoteId) : IRequest;
|
||||||
|
|
||||||
public class DeletePurchaseEvaluationQuoteCommandHandler(
|
public class DeletePurchaseEvaluationQuoteCommandHandler(
|
||||||
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationQuoteCommand>
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationQuoteCommand>
|
||||||
{
|
{
|
||||||
public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct)
|
public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
|
var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||||
|
db, request.PurchaseEvaluationId, currentUser, ct);
|
||||||
var quote = await (
|
var quote = await (
|
||||||
from q in db.PurchaseEvaluationQuotes
|
from q in db.PurchaseEvaluationQuotes
|
||||||
join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id
|
join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id
|
||||||
@ -224,6 +357,19 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
|
|||||||
select q).FirstOrDefaultAsync(ct)
|
select q).FirstOrDefaultAsync(ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluationQuote", request.QuoteId);
|
?? throw new NotFoundException("PurchaseEvaluationQuote", request.QuoteId);
|
||||||
|
|
||||||
|
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
|
||||||
|
? " [Approver edit khi đang duyệt]" : string.Empty;
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Quote,
|
||||||
|
EntityId = quote.Id,
|
||||||
|
Action = ChangelogAction.Delete,
|
||||||
|
PhaseAtChange = evaluation.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Xóa báo giá{approverNote}",
|
||||||
|
});
|
||||||
|
|
||||||
db.PurchaseEvaluationQuotes.Remove(quote);
|
db.PurchaseEvaluationQuotes.Remove(quote);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -244,7 +244,12 @@ public record TransitionPurchaseEvaluationCommand(
|
|||||||
Guid Id,
|
Guid Id,
|
||||||
PurchaseEvaluationPhase TargetPhase,
|
PurchaseEvaluationPhase TargetPhase,
|
||||||
ApprovalDecision Decision,
|
ApprovalDecision Decision,
|
||||||
string? Comment) : IRequest;
|
string? Comment,
|
||||||
|
// Mig 28 (S21 t4) — F1 mode Trả lại (optional, null = default Drafter)
|
||||||
|
WorkflowReturnMode? ReturnMode = null,
|
||||||
|
Guid? ReturnTargetUserId = null,
|
||||||
|
// F2 — Drafter skip thẳng Cấp cuối khi trình duyệt (optional, default false)
|
||||||
|
bool SkipToFinal = false) : IRequest;
|
||||||
|
|
||||||
public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<TransitionPurchaseEvaluationCommand>
|
public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<TransitionPurchaseEvaluationCommand>
|
||||||
{
|
{
|
||||||
@ -254,6 +259,11 @@ public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<Tr
|
|||||||
RuleFor(x => x.TargetPhase).IsInEnum();
|
RuleFor(x => x.TargetPhase).IsInEnum();
|
||||||
RuleFor(x => x.Decision).IsInEnum();
|
RuleFor(x => x.Decision).IsInEnum();
|
||||||
RuleFor(x => x.Comment).MaximumLength(1000);
|
RuleFor(x => x.Comment).MaximumLength(1000);
|
||||||
|
RuleFor(x => x.ReturnMode!.Value).IsInEnum().When(x => x.ReturnMode.HasValue);
|
||||||
|
// Assignee mode → returnTargetUserId required
|
||||||
|
RuleFor(x => x.ReturnTargetUserId).NotEmpty()
|
||||||
|
.When(x => x.ReturnMode == WorkflowReturnMode.Assignee)
|
||||||
|
.WithMessage("ReturnTargetUserId yêu cầu khi mode=Assignee.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,6 +287,9 @@ public class TransitionPurchaseEvaluationCommandHandler(
|
|||||||
currentUser.Roles,
|
currentUser.Roles,
|
||||||
request.Decision,
|
request.Decision,
|
||||||
request.Comment,
|
request.Comment,
|
||||||
|
request.ReturnMode,
|
||||||
|
request.ReturnTargetUserId,
|
||||||
|
request.SkipToFinal,
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -549,6 +562,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
// Bước/Cấp tree với Status) cho FE render flow vertical thay phase cards.
|
// Bước/Cấp tree với Status) cho FE render flow vertical thay phase cards.
|
||||||
string? awCode = null, awName = null;
|
string? awCode = null, awName = null;
|
||||||
int? awVersion = null;
|
int? awVersion = null;
|
||||||
|
ApprovalWorkflowOptionsDto? awOptions = null;
|
||||||
PurchaseEvaluationCurrentApprovalDto? currentApproval = null;
|
PurchaseEvaluationCurrentApprovalDto? currentApproval = null;
|
||||||
PurchaseEvaluationApprovalFlowDto? approvalFlow = null;
|
PurchaseEvaluationApprovalFlowDto? approvalFlow = null;
|
||||||
if (e.ApprovalWorkflowId is Guid awId)
|
if (e.ApprovalWorkflowId is Guid awId)
|
||||||
@ -562,6 +576,14 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
awCode = aw.Code;
|
awCode = aw.Code;
|
||||||
awName = aw.Name;
|
awName = aw.Name;
|
||||||
awVersion = aw.Version;
|
awVersion = aw.Version;
|
||||||
|
// Mig 28 — 6 Allow* options pin lúc PE create
|
||||||
|
awOptions = new ApprovalWorkflowOptionsDto(
|
||||||
|
aw.AllowReturnOneLevel,
|
||||||
|
aw.AllowReturnOneStep,
|
||||||
|
aw.AllowReturnToAssignee,
|
||||||
|
aw.AllowReturnToDrafter,
|
||||||
|
aw.AllowDrafterSkipToFinal,
|
||||||
|
aw.AllowApproverEditDetails);
|
||||||
|
|
||||||
var steps = aw.Steps.OrderBy(s => s.Order).ToList();
|
var steps = aw.Steps.OrderBy(s => s.Order).ToList();
|
||||||
// Resolve dept names cho Steps
|
// Resolve dept names cho Steps
|
||||||
@ -681,7 +703,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
|
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
|
||||||
e.BudgetId, budgetSummary,
|
e.BudgetId, budgetSummary,
|
||||||
e.BudgetManualName, e.BudgetManualAmount,
|
e.BudgetManualName, e.BudgetManualAmount,
|
||||||
e.ApprovalWorkflowId, awCode, awName, awVersion,
|
e.ApprovalWorkflowId, awCode, awName, awVersion, awOptions,
|
||||||
currentApproval, approvalFlow,
|
currentApproval, approvalFlow,
|
||||||
e.Suppliers
|
e.Suppliers
|
||||||
.OrderBy(s => s.Order)
|
.OrderBy(s => s.Order)
|
||||||
|
|||||||
@ -41,8 +41,10 @@ public class AddPurchaseEvaluationSupplierCommandHandler(
|
|||||||
{
|
{
|
||||||
public async Task<Guid> Handle(AddPurchaseEvaluationSupplierCommand request, CancellationToken ct)
|
public async Task<Guid> Handle(AddPurchaseEvaluationSupplierCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
|
// Mig 28 (S21 t4 F3) — Section 2 edit guard: Drafter (DangSoanThao/TraLai)
|
||||||
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
// OR Approver (ChoDuyet + workflow.AllowApproverEditDetails + actor match).
|
||||||
|
var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||||
|
db, request.PurchaseEvaluationId, currentUser, ct);
|
||||||
|
|
||||||
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct)
|
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct)
|
||||||
?? throw new NotFoundException("Supplier", request.SupplierId);
|
?? throw new NotFoundException("Supplier", request.SupplierId);
|
||||||
@ -97,10 +99,14 @@ public record UpdatePurchaseEvaluationSupplierCommand(
|
|||||||
string? Note) : IRequest;
|
string? Note) : IRequest;
|
||||||
|
|
||||||
public class UpdatePurchaseEvaluationSupplierCommandHandler(
|
public class UpdatePurchaseEvaluationSupplierCommandHandler(
|
||||||
IApplicationDbContext db) : IRequestHandler<UpdatePurchaseEvaluationSupplierCommand>
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationSupplierCommand>
|
||||||
{
|
{
|
||||||
public async Task Handle(UpdatePurchaseEvaluationSupplierCommand request, CancellationToken ct)
|
public async Task Handle(UpdatePurchaseEvaluationSupplierCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Mig 28 (S21 t4 F3) — Section 2 edit guard.
|
||||||
|
var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||||
|
db, request.PurchaseEvaluationId, currentUser, ct);
|
||||||
var row = await db.PurchaseEvaluationSuppliers
|
var row = await db.PurchaseEvaluationSuppliers
|
||||||
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId);
|
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId);
|
||||||
@ -112,6 +118,19 @@ public class UpdatePurchaseEvaluationSupplierCommandHandler(
|
|||||||
row.PaymentTermText = request.PaymentTermText;
|
row.PaymentTermText = request.PaymentTermText;
|
||||||
row.Note = request.Note;
|
row.Note = request.Note;
|
||||||
|
|
||||||
|
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
|
||||||
|
? " [Approver edit khi đang duyệt]" : string.Empty;
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Supplier,
|
||||||
|
EntityId = row.Id,
|
||||||
|
Action = ChangelogAction.Update,
|
||||||
|
PhaseAtChange = evaluation.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Cập nhật NCC {request.DisplayName ?? "#" + row.SupplierId.ToString()[..8]}{approverNote}",
|
||||||
|
});
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,10 +138,14 @@ public class UpdatePurchaseEvaluationSupplierCommandHandler(
|
|||||||
public record RemovePurchaseEvaluationSupplierCommand(Guid PurchaseEvaluationId, Guid SupplierRowId) : IRequest;
|
public record RemovePurchaseEvaluationSupplierCommand(Guid PurchaseEvaluationId, Guid SupplierRowId) : IRequest;
|
||||||
|
|
||||||
public class RemovePurchaseEvaluationSupplierCommandHandler(
|
public class RemovePurchaseEvaluationSupplierCommandHandler(
|
||||||
IApplicationDbContext db) : IRequestHandler<RemovePurchaseEvaluationSupplierCommand>
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<RemovePurchaseEvaluationSupplierCommand>
|
||||||
{
|
{
|
||||||
public async Task Handle(RemovePurchaseEvaluationSupplierCommand request, CancellationToken ct)
|
public async Task Handle(RemovePurchaseEvaluationSupplierCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Mig 28 (S21 t4 F3) — Section 2 edit guard.
|
||||||
|
var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||||
|
db, request.PurchaseEvaluationId, currentUser, ct);
|
||||||
var row = await db.PurchaseEvaluationSuppliers
|
var row = await db.PurchaseEvaluationSuppliers
|
||||||
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId);
|
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId);
|
||||||
@ -131,6 +154,19 @@ public class RemovePurchaseEvaluationSupplierCommandHandler(
|
|||||||
var hasQuotes = await db.PurchaseEvaluationQuotes.AnyAsync(q => q.PurchaseEvaluationSupplierId == row.Id, ct);
|
var hasQuotes = await db.PurchaseEvaluationQuotes.AnyAsync(q => q.PurchaseEvaluationSupplierId == row.Id, ct);
|
||||||
if (hasQuotes) throw new ConflictException("Không thể xóa NCC khi còn báo giá. Xóa báo giá trước.");
|
if (hasQuotes) throw new ConflictException("Không thể xóa NCC khi còn báo giá. Xóa báo giá trước.");
|
||||||
|
|
||||||
|
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
|
||||||
|
? " [Approver edit khi đang duyệt]" : string.Empty;
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Supplier,
|
||||||
|
EntityId = row.Id,
|
||||||
|
Action = ChangelogAction.Delete,
|
||||||
|
PhaseAtChange = evaluation.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Xóa NCC {row.DisplayName ?? "#" + row.SupplierId.ToString()[..8]}{approverNote}",
|
||||||
|
});
|
||||||
|
|
||||||
db.PurchaseEvaluationSuppliers.Remove(row);
|
db.PurchaseEvaluationSuppliers.Remove(row);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,14 @@ public interface IPurchaseEvaluationWorkflowService
|
|||||||
{
|
{
|
||||||
// Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
|
// Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
|
||||||
// Tự tạo PurchaseEvaluationApproval + update Phase + SlaDeadline.
|
// Tự tạo PurchaseEvaluationApproval + update Phase + SlaDeadline.
|
||||||
|
//
|
||||||
|
// Optional params Mig 28 (S21 t4 — F1+F2 advanced workflow options):
|
||||||
|
// - returnMode: mode Trả lại (F1). Null = default Drafter behavior khi Reject+TraLai.
|
||||||
|
// OneLevel/OneStep/Assignee → giữ Phase=ChoDuyet, lùi pointer (peer review).
|
||||||
|
// Drafter → Phase=TraLai clear pointer như S17.
|
||||||
|
// - returnTargetUserId: required khi returnMode=Assignee — pick từ list NV đã duyệt.
|
||||||
|
// - skipToFinal: F2 Drafter trình duyệt → skip mọi Bước/Cấp trung gian, set pointer
|
||||||
|
// = max Step + max Level. Workflow phải AllowDrafterSkipToFinal=true.
|
||||||
Task TransitionAsync(
|
Task TransitionAsync(
|
||||||
PurchaseEvaluation evaluation,
|
PurchaseEvaluation evaluation,
|
||||||
PurchaseEvaluationPhase targetPhase,
|
PurchaseEvaluationPhase targetPhase,
|
||||||
@ -14,11 +22,23 @@ public interface IPurchaseEvaluationWorkflowService
|
|||||||
IReadOnlyList<string> actorRoles,
|
IReadOnlyList<string> actorRoles,
|
||||||
ApprovalDecision decision,
|
ApprovalDecision decision,
|
||||||
string? comment,
|
string? comment,
|
||||||
|
WorkflowReturnMode? returnMode = null,
|
||||||
|
Guid? returnTargetUserId = null,
|
||||||
|
bool skipToFinal = false,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase);
|
TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mig 28 (S21 t4) — F1 mode Trả lại. Mapping với ApprovalWorkflow.Allow* flag.
|
||||||
|
public enum WorkflowReturnMode
|
||||||
|
{
|
||||||
|
OneLevel = 1, // Lùi 1 Cấp trong cùng Step (peer review)
|
||||||
|
OneStep = 2, // Lùi sang Bước trước, level = max của bước đó
|
||||||
|
Assignee = 3, // Pick runtime từ list NV đã duyệt
|
||||||
|
Drafter = 4, // Trả về Drafter, Phase=TraLai clear pointer (S17 default fallback)
|
||||||
|
}
|
||||||
|
|
||||||
// Atomic sequence generator cho mã PE (MaPhieu) — mirror IContractCodeGenerator.
|
// Atomic sequence generator cho mã PE (MaPhieu) — mirror IContractCodeGenerator.
|
||||||
// Format: PE/{YYYY}/{TypeLetter}/{Seq:D3}
|
// Format: PE/{YYYY}/{TypeLetter}/{Seq:D3}
|
||||||
// - YYYY = năm hiện tại (UTC)
|
// - YYYY = năm hiện tại (UTC)
|
||||||
|
|||||||
@ -34,6 +34,46 @@ public class ApprovalWorkflow : BaseEntity
|
|||||||
// khi tạo version mới (mirror IsActive default), admin có thể unstick.
|
// khi tạo version mới (mirror IsActive default), admin có thể unstick.
|
||||||
public bool IsUserSelectable { get; set; }
|
public bool IsUserSelectable { get; set; }
|
||||||
|
|
||||||
|
// ===== Mig 28 (Session 21 turn 4) — 6 advanced options per workflow =====
|
||||||
|
// Cấu hình "Cấu hình nâng cao" trong Admin Designer. User eOffice render
|
||||||
|
// dropdown/checkbox theo flag enabled. 4 flag Return* = mode Trả lại (F1).
|
||||||
|
// 1 flag Skip = Drafter trình thẳng Cấp cuối (F2). 1 flag EditDetails =
|
||||||
|
// Approver chỉnh Section 2 (F3).
|
||||||
|
//
|
||||||
|
// Default backward compat S17: AllowReturnToDrafter=true (mọi workflow cũ
|
||||||
|
// chạy đúng — fallback "Trả về Drafter" như Session 17 spec). 5 flag còn
|
||||||
|
// lại default false — admin opt-in per workflow để audit nghiêm.
|
||||||
|
|
||||||
|
/// F1 mode 1 — Cho phép Approver Trả lại 1 Cấp trước (lùi pointer trong
|
||||||
|
/// cùng Step). Phiếu GIỮ Phase=ChoDuyet (peer review chain).
|
||||||
|
public bool AllowReturnOneLevel { get; set; }
|
||||||
|
|
||||||
|
/// F1 mode 2 — Cho phép Approver Trả lại 1 Bước trước (lùi sang Step trước,
|
||||||
|
/// set level = max của step đó). Phiếu GIỮ Phase=ChoDuyet.
|
||||||
|
public bool AllowReturnOneStep { get; set; }
|
||||||
|
|
||||||
|
/// F1 mode 3 — Cho phép Approver Trả lại Người chỉ định (pick runtime từ
|
||||||
|
/// list NV ĐÃ DUYỆT trong PeLevelOpinions). Phiếu GIỮ Phase=ChoDuyet, set
|
||||||
|
/// Step/Level = vị trí của user pick trong workflow.
|
||||||
|
public bool AllowReturnToAssignee { get; set; }
|
||||||
|
|
||||||
|
/// F1 mode 4 — Cho phép Approver Trả lại Người soạn thảo (Drafter). Phiếu
|
||||||
|
/// đi vào Phase=TraLai, clear pointer (như Session 17 spec). Default TRUE
|
||||||
|
/// để backward compat — admin có thể unstick force peer review only.
|
||||||
|
public bool AllowReturnToDrafter { get; set; } = true;
|
||||||
|
|
||||||
|
/// F2 — Cho phép Drafter gửi thẳng Cấp cuối (skip mọi Bước/Cấp trung gian).
|
||||||
|
/// UI eOffice trình duyệt thêm dropdown 2 option ("Gửi tuần tự" default vs
|
||||||
|
/// "Gửi thẳng Cấp cuối"). BE set CurrentWorkflowStepIndex=maxStep,
|
||||||
|
/// CurrentApprovalLevelOrder=maxLevel. Audit changelog "Drafter skip C1..N".
|
||||||
|
public bool AllowDrafterSkipToFinal { get; set; }
|
||||||
|
|
||||||
|
/// F3 — Cho phép Approver chỉnh sửa Section 2 (Hạng mục + NCC + Báo giá)
|
||||||
|
/// khi phase=ChoDuyet + actor match CurrentLevel.ApproverUserId. KHÔNG đụng
|
||||||
|
/// PE Header (TenGoiThau/Project/Budget). KHÔNG reset workflow. Audit ghi
|
||||||
|
/// PurchaseEvaluationChangelog cho mỗi field/row thay đổi.
|
||||||
|
public bool AllowApproverEditDetails { get; set; }
|
||||||
|
|
||||||
public List<ApprovalWorkflowStep> Steps { get; set; } = new();
|
public List<ApprovalWorkflowStep> Steps { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,15 @@ public class ApprovalWorkflowConfiguration : IEntityTypeConfiguration<ApprovalWo
|
|||||||
|
|
||||||
e.HasIndex(x => new { x.Code, x.Version }).IsUnique();
|
e.HasIndex(x => new { x.Code, x.Version }).IsUnique();
|
||||||
e.HasIndex(x => new { x.ApplicableType, x.IsActive });
|
e.HasIndex(x => new { x.ApplicableType, x.IsActive });
|
||||||
|
|
||||||
|
// Mig 28 — 6 advanced options. 5 default false (admin opt-in). 1
|
||||||
|
// AllowReturnToDrafter default true (backward compat S17 fallback).
|
||||||
|
e.Property(x => x.AllowReturnOneLevel).HasDefaultValue(false);
|
||||||
|
e.Property(x => x.AllowReturnOneStep).HasDefaultValue(false);
|
||||||
|
e.Property(x => x.AllowReturnToAssignee).HasDefaultValue(false);
|
||||||
|
e.Property(x => x.AllowReturnToDrafter).HasDefaultValue(true);
|
||||||
|
e.Property(x => x.AllowDrafterSkipToFinal).HasDefaultValue(false);
|
||||||
|
e.Property(x => x.AllowApproverEditDetails).HasDefaultValue(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,84 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAdvancedOptionsToApprovalWorkflows : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowApproverEditDetails",
|
||||||
|
table: "ApprovalWorkflows",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowDrafterSkipToFinal",
|
||||||
|
table: "ApprovalWorkflows",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowReturnOneLevel",
|
||||||
|
table: "ApprovalWorkflows",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowReturnOneStep",
|
||||||
|
table: "ApprovalWorkflows",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowReturnToAssignee",
|
||||||
|
table: "ApprovalWorkflows",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowReturnToDrafter",
|
||||||
|
table: "ApprovalWorkflows",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowApproverEditDetails",
|
||||||
|
table: "ApprovalWorkflows");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowDrafterSkipToFinal",
|
||||||
|
table: "ApprovalWorkflows");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowReturnOneLevel",
|
||||||
|
table: "ApprovalWorkflows");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowReturnOneStep",
|
||||||
|
table: "ApprovalWorkflows");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowReturnToAssignee",
|
||||||
|
table: "ApprovalWorkflows");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowReturnToDrafter",
|
||||||
|
table: "ApprovalWorkflows");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -134,6 +134,36 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<DateTime?>("ActivatedAt")
|
b.Property<DateTime?>("ActivatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowApproverEditDetails")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowDrafterSkipToFinal")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowReturnOneLevel")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowReturnOneStep")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowReturnToAssignee")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowReturnToDrafter")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
b.Property<int>("ApplicableType")
|
b.Property<int>("ApplicableType")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
|||||||
@ -41,29 +41,55 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
IReadOnlyList<string> actorRoles,
|
IReadOnlyList<string> actorRoles,
|
||||||
ApprovalDecision decision,
|
ApprovalDecision decision,
|
||||||
string? comment,
|
string? comment,
|
||||||
|
WorkflowReturnMode? returnMode = null,
|
||||||
|
Guid? returnTargetUserId = null,
|
||||||
|
bool skipToFinal = false,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var fromPhase = evaluation.Phase;
|
var fromPhase = evaluation.Phase;
|
||||||
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||||
|
|
||||||
// ===== REJECT BRANCH =====
|
// ===== GUARD: targetPhase TraLai/TuChoi BẮT BUỘC decision=Reject =====
|
||||||
|
// Defense-in-depth chặn FE inconsistency (gotcha #45 — Session 21 turn 3):
|
||||||
|
// Bug: button "← Trả lại" trong PeWorkflowPanel.tsx gửi decision=Approve
|
||||||
|
// khi target=TraLai do `isReject` local var thiếu nhánh TraLai. BE nhận
|
||||||
|
// payload sẽ skip Reject branch → enter APPROVE STEP → ApproveV2Async
|
||||||
|
// UPSERT opinion = "đã duyệt" + advance Cấp. User UAT thấy: "Trả về
|
||||||
|
// nhưng hệ thống vẫn duyệt".
|
||||||
|
// FE fix song song trong fe-admin + fe-user (rule §3.9 mirror 2 app).
|
||||||
|
// Guard này KHÔNG xoá khi FE fix — boundary protection cho mọi caller
|
||||||
|
// tương lai (API client / mobile app / cron retry).
|
||||||
|
if ((targetPhase == PurchaseEvaluationPhase.TraLai
|
||||||
|
|| targetPhase == PurchaseEvaluationPhase.TuChoi)
|
||||||
|
&& decision != ApprovalDecision.Reject)
|
||||||
|
{
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Transition tới {targetPhase} BẮT BUỘC decision=Reject (nhận {decision}). " +
|
||||||
|
"Báo lỗi caller — payload mismatch giữa target phase và decision " +
|
||||||
|
"(xem gotcha #45 + docs/workflow-contract.md).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) =====
|
||||||
if (decision == ApprovalDecision.Reject)
|
if (decision == ApprovalDecision.Reject)
|
||||||
{
|
{
|
||||||
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
|
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
|
||||||
{
|
{
|
||||||
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
|
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
|
||||||
evaluation.Phase = PurchaseEvaluationPhase.TuChoi;
|
evaluation.Phase = PurchaseEvaluationPhase.TuChoi;
|
||||||
|
evaluation.SlaDeadline = null;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Trả lại — Phase=TraLai RIÊNG (không revert về DangSoanThao).
|
// F1 (S21 t4) — 4 mode Trả lại theo workflow.Allow* flag.
|
||||||
// Drafter sửa từ TraLai rồi gửi lại sẽ chạy lại từ Cấp 1 Bước 1.
|
// Default fallback (returnMode=null) = Drafter mode = S17 behavior.
|
||||||
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
|
var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter;
|
||||||
evaluation.CurrentWorkflowStepIndex = null;
|
var returnSummary = await ApplyReturnModeAsync(
|
||||||
evaluation.CurrentApprovalLevelOrder = null;
|
evaluation, effectiveMode, returnTargetUserId, isAdmin, ct);
|
||||||
|
comment = string.IsNullOrWhiteSpace(comment)
|
||||||
|
? returnSummary
|
||||||
|
: $"{comment} [{returnSummary}]";
|
||||||
}
|
}
|
||||||
evaluation.SlaDeadline = null;
|
|
||||||
await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct);
|
await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return;
|
return;
|
||||||
@ -84,9 +110,36 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu.");
|
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu.");
|
||||||
}
|
}
|
||||||
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
|
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
|
||||||
|
|
||||||
|
// F2 (Mig 28 — S21 t4) — Drafter skip thẳng Cấp cuối. Workflow phải
|
||||||
|
// AllowDrafterSkipToFinal=true. Set pointer = max Step + max Level.
|
||||||
|
// Audit changelog ghi rõ "Drafter skip" để approver Cấp cuối biết.
|
||||||
|
if (skipToFinal && evaluation.ApprovalWorkflowId is Guid skipAwId)
|
||||||
|
{
|
||||||
|
var wfSkip = await db.ApprovalWorkflows
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == skipAwId, ct)
|
||||||
|
?? throw new ConflictException("Workflow không tồn tại.");
|
||||||
|
if (!wfSkip.AllowDrafterSkipToFinal)
|
||||||
|
throw new ConflictException(
|
||||||
|
"Workflow không bật mode 'Gửi thẳng Cấp cuối'. " +
|
||||||
|
"Liên hệ Admin để config Designer.");
|
||||||
|
var finalStep = wfSkip.Steps.OrderBy(s => s.Order).LastOrDefault()
|
||||||
|
?? throw new ConflictException("Workflow chưa có Bước nào.");
|
||||||
|
var finalLevelOrder = finalStep.Levels.OrderBy(l => l.Order).LastOrDefault()?.Order
|
||||||
|
?? throw new ConflictException($"Bước {finalStep.Order} chưa có Cấp nào.");
|
||||||
|
evaluation.CurrentWorkflowStepIndex = wfSkip.Steps.Count - 1; // 0-based last step
|
||||||
|
evaluation.CurrentApprovalLevelOrder = finalLevelOrder;
|
||||||
|
comment = string.IsNullOrWhiteSpace(comment)
|
||||||
|
? "[Drafter gửi thẳng Cấp cuối — skip Bước/Cấp trung gian]"
|
||||||
|
: $"{comment} [Drafter gửi thẳng Cấp cuối — skip Bước/Cấp trung gian]";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
evaluation.CurrentWorkflowStepIndex = 0;
|
evaluation.CurrentWorkflowStepIndex = 0;
|
||||||
// Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set).
|
// Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set).
|
||||||
evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null;
|
evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null;
|
||||||
|
}
|
||||||
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||||
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
|
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
@ -124,6 +177,154 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
|
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== F1 (Mig 28 — S21 t4) — Apply Return Mode =====
|
||||||
|
// Switch theo effectiveMode → set Phase + pointer. 3 mode đầu giữ ChoDuyet
|
||||||
|
// (peer review chain). Mode Drafter set Phase=TraLai như S17.
|
||||||
|
// Validate workflow.Allow* flag match mode → throw nếu disabled.
|
||||||
|
// Return summary text để chèn vào comment changelog (audit trail).
|
||||||
|
private async Task<string> ApplyReturnModeAsync(
|
||||||
|
PurchaseEvaluation evaluation,
|
||||||
|
WorkflowReturnMode mode,
|
||||||
|
Guid? returnTargetUserId,
|
||||||
|
bool isAdmin,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Mode Drafter — Session 17 default (always allowed for backward compat,
|
||||||
|
// workflow.AllowReturnToDrafter default true).
|
||||||
|
if (mode == WorkflowReturnMode.Drafter)
|
||||||
|
{
|
||||||
|
// Validate workflow flag (admin có thể disable mode này force peer review)
|
||||||
|
if (evaluation.ApprovalWorkflowId is Guid awId0 && !isAdmin)
|
||||||
|
{
|
||||||
|
var wf0 = await db.ApprovalWorkflows.FirstOrDefaultAsync(w => w.Id == awId0, ct);
|
||||||
|
if (wf0 is not null && !wf0.AllowReturnToDrafter)
|
||||||
|
throw new ConflictException(
|
||||||
|
"Workflow không bật mode 'Trả về Drafter'. Phải dùng mode khác.");
|
||||||
|
}
|
||||||
|
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
|
||||||
|
evaluation.CurrentWorkflowStepIndex = null;
|
||||||
|
evaluation.CurrentApprovalLevelOrder = null;
|
||||||
|
evaluation.SlaDeadline = null;
|
||||||
|
return "Trả về Người soạn thảo";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 mode còn lại (OneLevel / OneStep / Assignee) — yêu cầu V2 schema +
|
||||||
|
// pointer hợp lệ.
|
||||||
|
if (evaluation.ApprovalWorkflowId is not Guid awId)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Mode '{mode}' yêu cầu phiếu pin V2 workflow (ApprovalWorkflowId).");
|
||||||
|
if (evaluation.CurrentWorkflowStepIndex is not int curStepIdx
|
||||||
|
|| evaluation.CurrentApprovalLevelOrder is not int curLevel)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Mode '{mode}' yêu cầu phiếu đang ChoDuyet + pointer init. " +
|
||||||
|
$"State hiện tại: Step={evaluation.CurrentWorkflowStepIndex}, Level={evaluation.CurrentApprovalLevelOrder}.");
|
||||||
|
|
||||||
|
var workflow = await db.ApprovalWorkflows
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == awId, ct)
|
||||||
|
?? throw new ConflictException("Workflow không tồn tại.");
|
||||||
|
|
||||||
|
// Validate Allow* flag (Admin bypass — admin có thể trả lại bất chấp config)
|
||||||
|
if (!isAdmin)
|
||||||
|
{
|
||||||
|
var allowed = mode switch
|
||||||
|
{
|
||||||
|
WorkflowReturnMode.OneLevel => workflow.AllowReturnOneLevel,
|
||||||
|
WorkflowReturnMode.OneStep => workflow.AllowReturnOneStep,
|
||||||
|
WorkflowReturnMode.Assignee => workflow.AllowReturnToAssignee,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if (!allowed)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Workflow không bật mode '{mode}'. Liên hệ Admin Designer để config.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
|
||||||
|
var summary = string.Empty;
|
||||||
|
|
||||||
|
switch (mode)
|
||||||
|
{
|
||||||
|
case WorkflowReturnMode.OneLevel:
|
||||||
|
// Lùi 1 Cấp trong cùng Step. Nếu đang Cấp 1 → lùi sang Bước trước
|
||||||
|
// Cấp cuối. Nếu đang Bước 1 Cấp 1 → fallback Drafter (no further).
|
||||||
|
if (curLevel > 1)
|
||||||
|
{
|
||||||
|
evaluation.CurrentApprovalLevelOrder = curLevel - 1;
|
||||||
|
summary = $"Trả về Cấp {curLevel - 1} (cùng Bước {stepsOrdered[curStepIdx].Order})";
|
||||||
|
}
|
||||||
|
else if (curStepIdx > 0)
|
||||||
|
{
|
||||||
|
var prevStep = stepsOrdered[curStepIdx - 1];
|
||||||
|
var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order;
|
||||||
|
evaluation.CurrentWorkflowStepIndex = curStepIdx - 1;
|
||||||
|
evaluation.CurrentApprovalLevelOrder = prevMaxLevel;
|
||||||
|
summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel} (Bước trước)";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Bước 1 Cấp 1 — no further back. Fallback Drafter.
|
||||||
|
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
|
||||||
|
evaluation.CurrentWorkflowStepIndex = null;
|
||||||
|
evaluation.CurrentApprovalLevelOrder = null;
|
||||||
|
evaluation.SlaDeadline = null;
|
||||||
|
return "Trả về Người soạn thảo (fallback — đang Bước 1 Cấp 1)";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WorkflowReturnMode.OneStep:
|
||||||
|
// Lùi sang Bước trước, set Level = max của Bước đó.
|
||||||
|
if (curStepIdx > 0)
|
||||||
|
{
|
||||||
|
var prevStep = stepsOrdered[curStepIdx - 1];
|
||||||
|
var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order;
|
||||||
|
evaluation.CurrentWorkflowStepIndex = curStepIdx - 1;
|
||||||
|
evaluation.CurrentApprovalLevelOrder = prevMaxLevel;
|
||||||
|
summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Đang Bước 1 → fallback Drafter
|
||||||
|
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
|
||||||
|
evaluation.CurrentWorkflowStepIndex = null;
|
||||||
|
evaluation.CurrentApprovalLevelOrder = null;
|
||||||
|
evaluation.SlaDeadline = null;
|
||||||
|
return "Trả về Người soạn thảo (fallback — đang Bước đầu)";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WorkflowReturnMode.Assignee:
|
||||||
|
if (returnTargetUserId is not Guid targetUid)
|
||||||
|
throw new ConflictException("returnTargetUserId yêu cầu khi mode=Assignee.");
|
||||||
|
var foundStepIdx = -1;
|
||||||
|
int foundLevel = -1;
|
||||||
|
string? foundStepName = null;
|
||||||
|
for (int si = 0; si < stepsOrdered.Count; si++)
|
||||||
|
{
|
||||||
|
var match = stepsOrdered[si].Levels
|
||||||
|
.FirstOrDefault(l => l.ApproverUserId == targetUid);
|
||||||
|
if (match is not null)
|
||||||
|
{
|
||||||
|
foundStepIdx = si;
|
||||||
|
foundLevel = match.Order;
|
||||||
|
foundStepName = stepsOrdered[si].Name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundStepIdx < 0)
|
||||||
|
throw new ConflictException(
|
||||||
|
"Không tìm thấy người chỉ định trong workflow. " +
|
||||||
|
"Chỉ pick từ list NV đã duyệt trước đó (PeLevelOpinions).");
|
||||||
|
evaluation.CurrentWorkflowStepIndex = foundStepIdx;
|
||||||
|
evaluation.CurrentApprovalLevelOrder = foundLevel;
|
||||||
|
summary = $"Trả về Người chỉ định — Bước {stepsOrdered[foundStepIdx].Order} ({foundStepName}) Cấp {foundLevel}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 mode trên đều giữ Phase=ChoDuyet — reset SLA cho approver mới.
|
||||||
|
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== V2 schema (Mig 22-24) — iterate ApprovalWorkflowSteps + Levels =====
|
// ===== V2 schema (Mig 22-24) — iterate ApprovalWorkflowSteps + Levels =====
|
||||||
private async Task ApproveV2Async(
|
private async Task ApproveV2Async(
|
||||||
PurchaseEvaluation evaluation,
|
PurchaseEvaluation evaluation,
|
||||||
|
|||||||
@ -0,0 +1,176 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Infrastructure.Services;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
// Regression test for Session 21 turn 3 bug — gotcha #45:
|
||||||
|
// FE button "← Trả lại" trong PeWorkflowPanel gửi `decision: 1` (Approve) thay
|
||||||
|
// vì `2` (Reject) khi target = TraLai (98). Root: `isReject` local variable
|
||||||
|
// trong FE thiếu nhánh TraLai → payload mismatch giữa button label hiển thị
|
||||||
|
// "Trả lại" và decision gửi BE.
|
||||||
|
//
|
||||||
|
// Hiệu ứng cũ trước fix: BE TransitionAsync nhận decision=Approve → skip Reject
|
||||||
|
// branch (L51) → enter APPROVE STEP branch (L97) → ApproveV2Async UPSERT
|
||||||
|
// opinion đánh dấu "đã duyệt" cho NV đang nhấn nút → tiến qua Cấp tiếp theo.
|
||||||
|
// User UAT thấy: "Trả về nhưng hệ thống vẫn duyệt".
|
||||||
|
//
|
||||||
|
// Fix BE defense-in-depth: guard early throw ConflictException khi targetPhase
|
||||||
|
// ∈ {TraLai, TuChoi} mà decision != Reject — chặn FE inconsistency tại
|
||||||
|
// boundary BE thay vì depend FE đúng.
|
||||||
|
//
|
||||||
|
// FE fix song song trong fe-admin + fe-user PeWorkflowPanel.tsx (rule §3.9
|
||||||
|
// mirror 2 app) — sync `isReject` + dialog `isSendBack` include TraLai.
|
||||||
|
public class PurchaseEvaluationWorkflowServiceGuardTests
|
||||||
|
{
|
||||||
|
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db)
|
||||||
|
CreateService()
|
||||||
|
{
|
||||||
|
var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var dt = new FixedDateTime(new DateTime(2026, 5, 12, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
var notify = new NoOpNotificationService();
|
||||||
|
var svc = new PurchaseEvaluationWorkflowService(db, dt, notify, um);
|
||||||
|
return (svc, fix, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PurchaseEvaluation BuildPeInChoDuyet(string code = "PE-GUARD-001")
|
||||||
|
{
|
||||||
|
return new PurchaseEvaluation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = PurchaseEvaluationType.DuyetNcc,
|
||||||
|
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
MaPhieu = code,
|
||||||
|
TenGoiThau = "Test guard bug Trả lại",
|
||||||
|
ProjectId = Guid.NewGuid(),
|
||||||
|
DrafterUserId = Guid.NewGuid(),
|
||||||
|
CurrentApprovalLevelOrder = 1,
|
||||||
|
CurrentWorkflowStepIndex = 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TransitionAsync_TargetTraLai_WithApproveDecision_Throws_AndDoesNotMutateState()
|
||||||
|
{
|
||||||
|
// Arrange: phiếu PE ở ChoDuyet (typical intermediate state khi approver duyệt)
|
||||||
|
var (svc, fix, db) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var pe = BuildPeInChoDuyet();
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Act: simulate FE bug payload — button "← Trả lại" gửi decision=Approve
|
||||||
|
// thay vì Reject (gotcha #45 root cause).
|
||||||
|
var act = async () => await svc.TransitionAsync(
|
||||||
|
evaluation: pe,
|
||||||
|
targetPhase: PurchaseEvaluationPhase.TraLai,
|
||||||
|
actorUserId: Guid.NewGuid(),
|
||||||
|
actorRoles: new[] { AppRoles.CostControl },
|
||||||
|
decision: ApprovalDecision.Approve,
|
||||||
|
comment: "test guard mismatch",
|
||||||
|
ct: CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert: BE chặn payload mismatch sớm + state phiếu KHÔNG đổi
|
||||||
|
await act.Should().ThrowAsync<ConflictException>()
|
||||||
|
.WithMessage("*TraLai*Reject*");
|
||||||
|
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
"Guard chặn trước khi mutate phase");
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().Be(1,
|
||||||
|
"Guard chặn trước khi advance level pointer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TransitionAsync_TargetTuChoi_WithApproveDecision_Throws_AndDoesNotMutateState()
|
||||||
|
{
|
||||||
|
// Tương tự TraLai — TuChoi cũng BẮT BUỘC decision=Reject. Defense
|
||||||
|
// double-cover invariant (FE chỉ bug TraLai branch nhưng guard nên cover
|
||||||
|
// luôn TuChoi cho consistency).
|
||||||
|
var (svc, fix, db) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var pe = BuildPeInChoDuyet("PE-GUARD-002");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var act = async () => await svc.TransitionAsync(
|
||||||
|
evaluation: pe,
|
||||||
|
targetPhase: PurchaseEvaluationPhase.TuChoi,
|
||||||
|
actorUserId: Guid.NewGuid(),
|
||||||
|
actorRoles: new[] { AppRoles.CostControl },
|
||||||
|
decision: ApprovalDecision.Approve,
|
||||||
|
comment: "test guard tu choi",
|
||||||
|
ct: CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ConflictException>()
|
||||||
|
.WithMessage("*TuChoi*Reject*");
|
||||||
|
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai()
|
||||||
|
{
|
||||||
|
// Happy path control test: decision=Reject + target=TraLai → BE đi vào
|
||||||
|
// Reject branch (L51), set Phase=TraLai, clear pointer. Verify fix
|
||||||
|
// KHÔNG break flow Trả lại đúng.
|
||||||
|
var (svc, fix, db) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var pe = BuildPeInChoDuyet("PE-GUARD-003");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await svc.TransitionAsync(
|
||||||
|
evaluation: pe,
|
||||||
|
targetPhase: PurchaseEvaluationPhase.TraLai,
|
||||||
|
actorUserId: Guid.NewGuid(),
|
||||||
|
actorRoles: new[] { AppRoles.CostControl },
|
||||||
|
decision: ApprovalDecision.Reject,
|
||||||
|
comment: "trả lại sửa lại đi",
|
||||||
|
ct: CancellationToken.None);
|
||||||
|
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.TraLai,
|
||||||
|
"Reject branch set Phase=TraLai");
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().BeNull("Trả lại clear level pointer");
|
||||||
|
pe.CurrentWorkflowStepIndex.Should().BeNull("Trả lại clear step pointer");
|
||||||
|
pe.SlaDeadline.Should().BeNull("Trả lại clear SLA");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub: not assert side effects of notify (out-of-scope cho guard test).
|
||||||
|
// Pattern reuse cho future PE service tests.
|
||||||
|
internal sealed class NoOpNotificationService : INotificationService
|
||||||
|
{
|
||||||
|
public Task NotifyAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationType type,
|
||||||
|
string title,
|
||||||
|
string? description = null,
|
||||||
|
string? href = null,
|
||||||
|
Guid? refId = null,
|
||||||
|
CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task NotifyManyAsync(
|
||||||
|
IEnumerable<Guid> userIds,
|
||||||
|
NotificationType type,
|
||||||
|
string title,
|
||||||
|
string? description = null,
|
||||||
|
string? href = null,
|
||||||
|
Guid? refId = null,
|
||||||
|
CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user