Nen cho tinh nang "xoa phieu PE o man duyet" (UAT: phieu bam sai goi thau dang
an luy ke). Dot 1 lam NEN, dot 2 moi lam nut xoa.
Vi sao phai lam nen truoc: phieu dang chay pin ApprovalWorkflowId va doc workflow
SONG theo Id do. Moi POST tao workflow moi = Id MOI, nen tick co tren ban moi
KHONG BAO GIO toi duoc phieu dang treo. Phai co lenh sua-tai-cho giu nguyen Id.
- Domain: ApprovalWorkflowLevel + AllowApproverDelete (F6, per-NGUOI) + Mig
20260727033522_AddPeAllowApproverDelete (AddColumn bit default false, no new table)
- Application: UpdateAwDefinitionCommand + Validator + Handler
- 2 phep thu: (T1) khung khong doi - Order tung row bat bien
(T2) luat choi khong doi - cam CeoApprovalThreshold, AllowApproverFinalize,
Code, ApplicableType => 409 bao tao ban moi
- diff theo Level.Id (CAM delete-then-insert: no FK Restrict + mo coi LevelOpinion)
- guard 2 tang chu ky: tang 1 chu ky tren ho so CON SONG; tang 2 chu ky CON LUU
(cha xoa mem hoac opinion xoa mem) - ca hai deu CHAN, khong xoa cung
- purge chi khi ho so cha KHONG CON ROW; boc transaction (ExecuteDelete tu commit)
- KHONG tang Version, KHONG dung IsActive, KHONG doi Id
- Api: PUT /api/approval-workflows-v2/{id} + [Authorize(Policy="Workflows.Create")]
- Infra: message 403 in TEN NGUOI thay GUID tran
- FE: Designer tach "Sua tai cho" (PUT) vs "Tao phien ban moi" (POST) + o tick F6
- Tests: +21 (551 tong, 0 fail) - gom F12 chung minh phieu dang chay nhan thay doi ngay
Review: 14 FLAG, 3 HIGH da va (ExecuteDelete ngoai transaction / purge xoa cung
chu ky phieu xoa mem / endpoint khong co day FE goi toi).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hai chỉnh từ buổi UAT anh Kiệt FDC (S117):
1) Fix hiển thị lũy kế "duyệt rồi không bắt" — root cause là DISPLAY-LIE, không phải
lũy kế bug. endsBeforeCeo cho phiếu CHƯA DaDuyet nay POINTER-AWARE: chỉ báo "kết
thúc trước CEO" khi con-trỏ còn ở/trước cấp finalize. Phiếu đã đi QUA cấp finalize
(approver bỏ tick "Cấp này KẾT THÚC" → trình tiếp CEO) → false: sơ đồ hết làm mờ
Bước CEO + hết nói dối "Kết thúc tại Cấp X" khi phiếu đang chờ CEO. 3 site (detail
in-memory + list/inbox EF-subquery). Filter lũy kế GIỮ nguyên (đúng nguyên tắc
"duyệt rồi mới bắc"). BE-only — FE là consumer thuần nên tự render đúng.
2) Feature "duyệt theo KHOẢNG Min–Max": khi PRO có cả Min & Max, người duyệt cấp cuối
chọn CẢ HAI (radio "PRO — cả Min và Max", source=ProMinMax) → Min ở ApprovedPriceAmount
+ Max ở ApprovedPriceMaxAmount (Mig 66 AddColumn nullable, no backfill, phiếu cũ
nguyên vẹn). Bất-biến Max⟺ProMinMax, Min≤Max (guard ở service + validator). HĐ.GiaTri
KHÔNG đọc giá chốt (= SUM báo giá NCC được chọn) → range thuần record-of-decision
hiển thị, 0 tác động HĐ. FE 2-app SHA-mirror (Panel + DetailTabs).
Test 495 → 501 (+1 pointer-aware repro, +5 ProMinMax). Cả 2 điều tra qua /fable-real
engine-đắt (investigator-codebase deep-pass) theo yêu cầu anh Kiệt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Bug: phiếu có cấp AllowApproverFinalize nhưng approver opt-out → lên CEO thật + CEO duyệt, nhưng sơ đồ báo sai "kết thúc tại CCM, không qua CEO" + làm mờ Bước CEO (endsBeforeCeo tính theo CONFIG workflow)
- Fix: +field runtime EndedByLevelFinalize (set true CHỈ ở nhánh level-finalize service) → EndsBeforeCeo cho phiếu DaDuyet đọc runtime thay config; ChoDuyet giữ heads-up config
- BE: entity + EF config + service + 4 projection (detail/List/Inbox/ListApproved) runtime-aware
- Mig 60 AddPeEndedByLevelFinalize + backfill (dò marker "Duyệt KẾT THÚC tại" set phiếu finalize-sớm cũ; ccm-delegation không match → giữ false)
- FE: gate làm-mờ finalizeFlowStep theo endsBeforeCeo (2 app SHA-mirror b7abbc2f)
- Test 440 PASS (repro peFinButWentCeo: config-finalize + EndedByLevelFinalize=false → false) + reviewer PASS 0-issue
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
anh Kiệt "chuông không bỏ ra được" — noti "Phiếu cần bạn duyệt" của approver (kể cả CEO) KHÔNG tự xóa khi phiếu đã xong/chuyển bởi người khác (duyệt-thay tại Cấp CEO, co-approver OR-of-N, finalize) → chuông đầy phiếu thừa.
Fix: trong LogTransitionAsync (chạy MỌI transition), TRƯỚC block notify cấp mới → mark-read (ReadAt) tất cả noti Generic + RefId=phiếu còn unread. Phiếu rời cấp (finalize/advance/return/reject) → noti cũ tự sạch; approver cấp MỚI nhận noti fresh (tạo sau mark-read). Best-effort try/catch, V2-only, ExecuteUpdateAsync. KHÔNG đổi target (noti vẫn báo đúng lúc), chỉ thu-hồi khi stale.
Test: +test #7 (finalize → noti CEO cũ mark-read). 435→436 PASS. BE-only, no migration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CCM (Lưu Trần / anh Kiệt FDC) — làm 3/4 mục (mục 3 "CCM sửa ngân sách" để riêng):
1. Multi-winner: chọn NHIỀU NTP/NCC/ĐVDV cùng trúng → winnerQuoteTotal = SUM
ThanhTien MỌI đơn vị isWinner (vd 3 ông 37.8+9+10 = 56.8tr). Cột mới
PurchaseEvaluationSupplier.IsWinner (Mig 58, backfill từ SelectedSupplierId).
SelectWinner → multi-toggle SupplierIds[]. FE: bảng so sánh tick nhiều ô +
dòng "Tổng chi phí đã chọn"; mọi guard/checklist/computeGiaChaoThau/tạo-HĐ
đổi sang isWinner (selectedSupplierId = null khi liên-danh ≥2).
2. Ngân sách = 0 hợp lệ: nới submit-guard + 3 validator (chỉ chặn khi NULL,
số 0 cố ý đi qua; fallback ProInitial chỉ khi null). Mirror BE + FE.
3. Cấp KẾT THÚC quy trình: ApprovalWorkflowLevel.AllowApproverFinalize (Mig 58)
— admin bật per-cấp trong Workflow Designer; duyệt tới cấp đó TỰ DaDuyet,
bỏ qua CEO (AUTO theo cấu hình quy trình, không ô-tích runtime — anh Kiệt
chốt). FE: banner cảnh báo + bắt chọn giá chốt.
Mig 58 AddPeMultiWinnerAndLevelFinalize (2 AddColumn bool + backfill IsWinner,
additive/reversible). Test 377→395 (+PeMultiWinnerTests +PeApproverFinalizeTests
+budget=0; auto-model gọn bớt 2 test bypass/opt-out). FE 2 app SHA-mirror.
Build sạch BE + 2 FE local.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Người TẠO phiếu (drafter) không còn nhận chuông "Phiếu cần bạn duyệt" cho
phiếu mình tạo — kể cả khi được cấu hình làm approver ở một Cấp. Chỉ approver
KHÁC người tạo ở Cấp hiện tại mới nhận. Drafter vẫn nhận chuông KẾT-QUẢ
(đã duyệt / trả lại) như cũ (block :1060 giữ nguyên).
- PurchaseEvaluationWorkflowService.LogTransitionAsync: thêm filter
`l.ApproverUserId != evaluation.DrafterUserId` (in-memory LINQ, nullable-safe).
- test-specialist: +3 regression PeApproverNotifyExcludesDrafterTests
(drafter-excluded · co-approver-vẫn-nhận · result-notify-intact; RED-on-old
đã chứng minh). Test 374 -> 377.
- docs/STATUS.md: re-ground canonical (S85-closeout sót: test 356->377,
gotcha 71->73, bundle #341) + dòng S86 +3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
D2 — Người KHAI phiếu tự GỠ cờ GẤP của mình (nới luật S77 "GỠ=chỉ TP"). BE handler
restructure SET/UNSET: GẮN giữ role-only (PRO/CCM/Admin); GỠ +drafter (DrafterUserId==actor)
→ drafter/Admin clear CẢ 2 cờ, TP-vai clear cờ vai mình. FE canToggle +isDrafter.
D3 — "Ngân sách kỳ này" (row3) tự nhảy từ "Ngân sách Ban hành lần đầu"
(PeWorkItemBudget.ProInitialAmount) khi chưa nhập tay. Submit-guard BE + FE missingForApproval
dùng effective = budgetPeriodAmount ?? proInitialAmount (mirror 2 tầng, chống bypass gotcha #44).
BE 2 file (Urgent handler + Workflow submit-guard). FE PeDetailTabs ×2 app SHA-identical.
Test +5 PeUrgentToggleAuthzTests (drafter-self-unset authz matrix) → 371 PASS. BE slnx + 2 FE build PASS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tra Sol (Zalo): 'khong thay chuong bao - viec co ho so can duyet'. PE workflow truoc chi notify DRAFTER luc terminal; nguoi DUYET khong duoc bao khi phieu toi cap cua ho. Fix: block trong LogTransitionAsync (sau drafter-notify) - khi toPhase==ChoDuyet, resolve approver cap hien tai (workflow.Steps[CurrentWorkflowStepIndex].Levels Order==CurrentApprovalLevelOrder -> ApproverUserId OR-of-N, exclude actor) -> NotifyManyAsync Generic 'Phieu can ban duyet'. Fire moi luc phieu vao cap ChoDuyet: submit (cap 1) + moi approve-advance (cap ke) - 5 call-site advance deu log toPhase=ChoDuyet sau khi set pointer (verified). Best-effort try/catch (notify fail KHONG rollback transition). V2-only guard. Bell render Generic san (urgent->CEO da chung minh). BE-only, no-mig. Build slnx 0/0, test 354 PASS (khong vo transition cu). Notify = test-after (UAT).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Theo note anh Kiệt FDC (go-live so-sánh-giá thứ Hai):
- (1) Giá chào thầu thêm giá đề xuất NGOÀI giá NCC: PRO nhập dải Min/Max +
CCM nhập 1 giá (2 lệnh role-gate Procurement/CostControl, fail-closed).
Khi duyệt cấp cuối, người duyệt CHỌN 1 giá chốt (Ncc/ProMin/ProMax/Ccm)
-> luu ApprovedPriceAmount/Source (bind tai moi nhanh DaDuyet, bat buoc
chon; auto-approve he thong mien).
- (3) CCM duyet-done mien CEO: DOI tu AUTO-threshold (S69) sang O-TICH-TAY
(finalizeByCcmDelegation) -- CCM chu dong tich, fail-closed theo nguong
+ role + gia goi. An toan hon (khong vo tinh bo CEO).
- Mig 54 additive-nullable (5 cot PE) - FE 2 app SHA-mirror - test 306->334
(+28: opt-in 6->11, +10 gia chot, +13 setter authz).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Yêu cầu anh Kiệt FDC (sau họp sếp). Mig 53 AddPeUrgentAndCeoApprovalThreshold — 3 AddColumn, no new table (Mig 52→53). Rollout an toàn: cột nullable, ngưỡng null = giữ luồng duyệt cũ 100% cho tới khi admin set.
B — CCM duyệt-final theo NGƯỠNG GIÁ TRỊ ("gói CEO phân quyền theo giá trị"):
- ApprovalWorkflow += CeoApprovalThreshold (decimal?, admin nhập trong Workflow Designer).
- ApproveV2Async: actor role CostControl (CCM) + winnerQuoteTotal (tổng giá NCC được chọn) < ngưỡng → DaDuyet luôn (bỏ CEO); ≥ ngưỡng → đẩy lên CEO như cũ. Ngưỡng null = luồng tuyến tính cũ. Q4 chốt nhận diện theo ROLE người duyệt.
- reviewer PASS 0 blocker: cascade-safe (Off/role không lan), tested load-bearing (CCM dưới ngưỡng → DaDuyet skip CEO).
A — cờ gấp per-vai (visibility-only, Q3 KHÔNG đổi luồng):
- PE += IsUrgentByPro (PRO đỏ) / IsUrgentByCcm (CCM xanh).
- Endpoint PUT /purchase-evaluations/{id}/urgent role-gated (Procurement→ByPro, CostControl→ByCcm, Admin→cả 2, khác→Forbidden) + notify CEO (Director) khi MỚI bật (best-effort).
FE ×2 app: Workflow Designer ô "Ngưỡng giá trị gói CEO" (fe-admin) + PE detail nút bật/tắt cờ gấp đỏ/xanh theo role + badge GẤP + hint "giá trị gói vs ngưỡng → CCM duyệt-final/cần CEO" + PE list badge gấp.
DTO: PE detail += isUrgentByPro/Ccm + winnerQuoteTotal + ceoApprovalThreshold; list += isUrgentByPro/Ccm; workflow V2 += ceoApprovalThreshold.
+14 test (292→306): PeCcmThresholdFinalizeTests 5 (B routing) + PeUrgentToggleAuthzTests 9 (A authz). Build slnx 0/0 · npm build ×2 0 err · dotnet test 306 PASS.
C (sau duyệt xong chuyển phiếu đến dự án) — chờ anh Kiệt làm chi tiết form, CHƯA làm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Mig 50 ReplaceBudgetModuleWithPeWorkItemBudgets: bang moi PeWorkItemBudgets (1 record/cap Du an x Hang muc, UNIQUE filtered [IsDeleted]=0) + drop 5 bang Budget cu + PE/Contracts drop BudgetId + backfill BudgetManualAmount->BudgetPeriodAmount TRUOC DropColumn (phieu UAT giu so) + DELETE menu/permission Bg_* IN-list children-first
- BE: PUT {id}/budget/pro (role Procurement) + {id}/budget/ccm (role CostControl, Adjustment cho phep AM) fail-closed Forbidden-truoc-side-effect + EnsureTrackedAsync race-safe (catch unique -> re-fetch winner, loi khac rethrow) + auto-create record khi tao phieu + budgetSummary DTO (luy ke trinh-truoc/chon-thau-truoc/de-xuat-ky-nay + full fallback du-tru-PRO + canEdit flags) + submit-guard (3) doi predicate BudgetPeriodAmount -> "chua nhap Ngan sach ky nay" + PATCH budget-adjust absolute-set 2 field moi + Contract GIU BudgetManual* (HD nhap tay khong doi) + ke thua HD map BudgetPeriodAmount
- FE x2 app SHA256 identical: bang "TONG HOP NGAN SACH TRINH KY" block A (full dam + ban hanh + V0 hieu chinh + du tru PRO + ghi chu, editable theo canEditPro/canEditCcm) + block B 9 dong cong thuc Excel (5=1+3, 6=2+4, 7=full-5, 8 tu nhap default 7, 9=4+8) + to mau vuot ngan sach #C00000 / am do / red-soft row8>row7 + "Chua chon" khi count=0 + banner phieu chua gan Hang muc + o "Ngan sach ky nay" o create/header + XOA pages/components/types budgets + routes + menuKeys + Layout staticMap 4-place
- Tests: +22 PeWorkItemBudgetTests (auto-create x3, ensure/race x2, authz matrix PRO x5 + CCM x3, budgetSummary aggregates x5, adjust x4) - 14 BudgetPolicyTests xoa theo module - 1 test via-BudgetId -> 263 PASS (45 Domain + 218 Infra, 0 fail)
- database-agent advise adopted: khong FK vat ly PE/Contracts->Budgets (DropColumn khong can DropForeignKey) + DropIndex truoc DropColumn (SQL 5074) + IN-list thay LIKE Bg_% (underscore wildcard + miss root) + khong Serializable wrap (nested-tx conflict codegen)
- Reviewer PASS-with-minor 0 blocker (verdict-first survived); 2 minor da sua truoc commit (comment adjustMut absolute-set + dead key budgetId); note: F4 approver-edit-budget UI entry tam drafter-only, BE van cho approver scope - cho UAT anh Kiet
- Scaffold-bug caught: EF tu sinh RenameColumn BudgetManualAmount->ExpectedRemainingAmount (SAI semantics) -> thay bang Add+UPDATE+Drop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Domain policy: xoa MOI transition -> TuChoi o ca 4 policy (NccOnly + NccWithPlan + ForV2Schema + FromDefinition) -> NextPhases het tra TuChoi, nut FE tu bien mat
- Service guard S60: chan targetPhase=TuChoi moi caller ke ca Admin (dung truoc moi branch — spec bo han, khong escape hatch); message huong dan dung Tra lai / Xoa nhap
- FE x2 app: filter phong thu next.filter(p != TuChoi) PeWorkflowPanel (SHA256 identical); dialog/isCancel giu dead-safe de flip lai de
- Enum TuChoi + phieu TuChoi cu + tab filter "Tu choi" GIU display (data cu render binh thuong)
- SlaExpiryJob chi dung Contract — PE khong auto-TuChoi, khong anh huong
- Tests spec-change cung commit: Domain flip BothPolicies_TuChoi_RemovedFromAllTransitions_S60 + NEW V2SchemaPolicy fact; Infra NEW TargetTuChoi_WithRejectDecision_Throws_TuChoiRemoved_S60 (guard #45 test cu giu nguyen PASS — van dung truoc)
- Test 254 -> 256 PASS (59 Domain + 197 Infra)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Rename muc 3: "Chon NCC / TP thang thau" -> "Don vi NCC/TP duoc chon" (anh Kiet chot chu) x2 app + wording phu nhat quan
- Guard gui duyet du CA 4 (anh chot): don vi duoc chon + gia chao thau >0 + ngan sach (Budget link HOAC nhap tay) + bang so sanh dinh kem
+ BE ConflictException gop moi muc thieu 1 lan, ap ca Admin (TransitionAsync submit branch)
+ FE pre-check missingForApproval cung predicate -> disable nut + tooltip liet ke du (computeGiaChaoThau extract single-source)
- Bypass drafter-in-chain (luat GENERIC theo cap, anh chot): V2-only, BUOC DAU only - nguoi soan la approver cap k -> auto qua Cap 1..k khi gui
+ Audit 3 tang: Approval row AutoApprove per cap + LevelOpinion CHI slot chinh chu (khong gan chu ky NV bi skip) + Changelog
+ Pointer: k<max -> Cap k+1; het buoc -> Buoc 2 Cap 1; workflow 1 buoc -> terminal DaDuyet
+ TraLai resubmit ap lai idempotent (opinion UPSERT)
- Tests: +14 PeSubmitGuardAndBypassTests (240 -> 254 PASS)
- Reviewer die mid-run (gotcha #53 class) -> em main self-gate evidence-checklist PASS 0 blocker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bro UAT 2026-05-19 screenshot: panel "Lịch sử duyệt" KHÔNG show Return mode
events (Bro Trả lại từ Phan Văn Chương → Trà missing) + KHÔNG distinct
event Duyệt vượt cấp (skipToFinal F2).
Root cause:
- PurchaseEvaluationApprovals.Add() chỉ ở Approve branch (line 472 V2 + 660 V1)
- Reject branch line 75-103 NEVER adds Approval row — chỉ log Changelog
- skipToFinal advance branch line 532-572 dùng existing line 472 row nhưng
comment KHÔNG distinct "vượt cấp" semantic vs approve thường
Fix Plan AC:
1. BE Service.cs Reject branch (line 75-103): capture pre-call Step/Level
trước ApplyReturnModeAsync mutate pointer, add Approval row sau khi mutate:
Decision=Reject + FromPhase + ToPhase=evaluation.Phase + Comment carry
from-position + mode summary. Cover cả Trả lại (TraLai+pointer-mode) +
Từ chối (TuChoi terminal).
2. BE Service.cs line 472 Approve branch: enrich Comment với prefix
"[Duyệt vượt cấp tới Cấp cuối]" khi skipToFinal=true để Lịch sử duyệt
distinguish vượt cấp với approve thường.
3. FE PeDetailTabs.tsx × 2 app ApprovalsTab: add Decision badge phân biệt
Approve (emerald) / Trả lại (amber) / Từ chối (rose). Vì 3/4 mode Trả
lại (OneLevel/OneStep/Assignee) giữ Phase=ChoDuyet → fromPhase→toPhase
badge giống Approve. Decision badge bù visual phân biệt.
Verify:
- dotnet build clean 0 err 2 warn (pre-existing DocxRenderer)
- dotnet test 111/111 PASS
- npm build × fe-user + fe-admin PASS 0 TS err
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bro UAT 2026-05-15 sau Plan N deploy phát hiện 2 bug mới:
1. Actor NV Test trong OR-of-N slot click "Trả lại Người chỉ định" → toast
"Không phải lượt bạn — chỉ NV Cấp duyệt hiện tại mới được Trả lại / Từ
chối phiếu" mặc dù NV Test đúng trong slot.
2. F2 Duyệt thẳng Cấp cuối → trỏ đến Phan Văn Chương Bước 2 Cấp 2 thay vì
Nguyễn Văn Trường Bước 3 Cấp 1 (BOD) — defer follow-up vì F2 logic line
483-524 đã đúng (lastStepIdx + lastLevelMaxOrder), cần verify workflow
v14 DB structure.
Audit em main: Plan N chỉ fix 1/5 lookup sites — còn 4 sites cùng bug pattern:
1. Service.cs:201 EnsureCanRejectV2Async — bug bro UAT 1 ROOT CAUSE
2. Service.cs:248 ApplyReturnModeAsync — read Allow flag từ row đầu
3. DetailFeatures.cs:72 F3 EnsureEditableForDetailsAsync — cùng bug
4. Features.cs:311 F4 AdjustBudgetCommand — cùng bug
4 fix surgical (~30 LOC BE total):
**Site 1** (`PurchaseEvaluationWorkflowService.cs:201`):
```diff
- var currentLevel = step.Levels.FirstOrDefault(l => l.Order == curLvl);
- if (currentLevel?.ApproverUserId != actorId)
+ var currentLevel = step.Levels.FirstOrDefault(l =>
+ l.Order == curLvl && l.ApproverUserId == actorId);
+ if (currentLevel is null)
throw new ForbiddenException("Không phải lượt bạn — ...");
```
**Site 2** (`PurchaseEvaluationWorkflowService.cs:248`): ApplyReturnModeAsync
+`Guid? actorUserId` param 4th + caller TransitionAsync:94 update. Filter
`l.ApproverUserId == actorUserId` trong FirstOrDefault. Non-admin actor
KHÔNG match slot → currentLevel=null → validation skip (mode logic switch
KHÔNG dùng currentLevel object — chỉ dùng curStepIdx + curLevel int values).
Admin bypass validation existing line 252.
**Site 3** (`PurchaseEvaluationDetailFeatures.cs:72`):
```diff
- var level = step?.Levels.FirstOrDefault(lv => lv.Order == levelOrder);
- if (level is null) throw ConflictException("schema lỗi");
- if (!level.AllowApproverEditDetails) throw ConflictException(...);
- if (level.ApproverUserId != actorUserId) throw ForbiddenException(...);
+ var level = step?.Levels.FirstOrDefault(lv =>
+ lv.Order == levelOrder && lv.ApproverUserId == actorUserId);
+ if (level is null) throw ForbiddenException(...);
+ if (!level.AllowApproverEditDetails) throw ConflictException(...);
```
**Site 4** (`PurchaseEvaluationFeatures.cs:311`):
```diff
- var level = step.Levels.FirstOrDefault(l => l.Order == curLvl);
- if (level is null) throw ConflictException("schema lỗi");
- if (!level.AllowApproverEditBudget) throw ConflictException(...);
- if (level.ApproverUserId != actorId) throw ForbiddenException(...);
+ var level = step.Levels.FirstOrDefault(l =>
+ l.Order == curLvl && l.ApproverUserId == actorId);
+ if (level is null) throw ForbiddenException(...);
+ if (!level.AllowApproverEditBudget) throw ConflictException(...);
```
**Regression test** (`PurchaseEvaluationPerNvLookupRegressionTests.cs` 3 test):
1. `TransitionReject_ActorD_LastInSlot_AllowsRejectViaDrafterMode` —
Actor D (non-first-row trong OR-of-N) trả lại Drafter mode → no throw.
Pre-fix: throw "Không phải lượt bạn" vì handler check row đầu A.
2. `TransitionReject_Outsider_NotInSlot_ThrowsForbidden` — Outsider không
trong slot → throw đúng intent (verify fix KHÔNG over-permissive).
3. `TransitionRejectOneLevel_ActorC_HasFlagWhileOthersDont_AllowsMode` —
Actor C only tick AllowReturnOneLevel, 3 NV khác KHÔNG. Actor C click
"Trả lại 1 Cấp" → mode allowed. Pre-fix: read flag từ row A (false) →
throw ConflictException "không bật mode OneLevel".
Pattern reinforced: per-NV admin opt-in flag wire **5 lookup sites** đều
phải discriminate ApproverUserId. Plan N chỉ catch 1/5. Plan O catch 4/5
còn lại. Memory user-level cần update danh sách 5 sites cho future audit.
Verify:
- dotnet build SolutionErp.slnx clean (0 err, 2 warn pre-existing DocxRenderer)
- dotnet test SolutionErp.slnx **111/111 PASS** (+3 từ 108 baseline Plan N)
Pending Chunk O7: docs + memory update commit + push.
Pending Chunk O8: CICD Monitor post-deploy verify.
Pending follow-up Bug 2 F2 đến Phan Văn Chương: verify workflow v14 DB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bro UAT S23 t3: "Các tính năng trả lại 1 cấp hoặc chỉ định hoặc edit cho
xử lý ở trạng thái đang gửi duyệt luôn, không về draft."
Investigator audit confirm 4 mode F1.OneLevel/Assignee + F2 + F3+F4 main
path đã giữ Phase=ChoDuyet (Mig 28-31 cumulative). Edge case duy nhất còn
fallback Drafter (Phase=TraLai):
- F1.OneLevel khi đang Bước 1 Cấp 1 (curStepIdx=0, curLevel=1) — no further back
- F1.OneStep khi đang Bước 1 (curStepIdx=0)
Logic cũ (line 303-310 OneLevel + 325-332 OneStep):
```
evaluation.Phase = PurchaseEvaluationPhase.TraLai; // 98
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)";
```
Logic mới — reset (0, 1) giữ ChoDuyet:
```
evaluation.CurrentWorkflowStepIndex = 0;
evaluation.CurrentApprovalLevelOrder = 1;
summary = "Action 'Trả lại 1 Cấp/Bước' không lùi được — phiếu reset về Approver Bước 1 Cấp 1";
// SLA reset 7d ở cuối hàm cho 3 mode còn lại
```
Semantic mới (per bro chốt AskUserQuestion Plan M):
- Phase giữ ChoDuyet (KHÔNG TraLai=98)
- Pointer reset về (0, 1) = chính Approver A hiện tại (effectively no-op)
- SLA reset 7d (cuối hàm switch áp dụng cho cả 3 mode F1 non-Drafter)
- Audit log rõ "không lùi được" để Drafter/Admin biết action không hiệu lực
KHÔNG đụng:
- F1.Drafter (line 268-275) giữ nguyên semantic Phase=TraLai
- F1.Assignee (line 335-360) giữ nguyên throw nếu không match
- F2 ApproveV2Async skipToFinal (line 483-524 Plan K L1 vừa fix)
- F3 EnsureEditableForDetailsAsync (PurchaseEvaluationDetailFeatures.cs:42)
- F4 AdjustBudgetCommand handler (PurchaseEvaluationFeatures.cs:272-329)
Verify:
- dotnet build src/Backend/SolutionErp.Infrastructure clean (0 err, 2 warn
pre-existing DocxRenderer)
- Service.cs 13+/13- LOC change (1 file, surgical edit)
- Pending Chunk M2: 2 edge case test (Implementer Case 3 spawn) + verify
K7 Approver F2 không cascade
- Pending Chunk M3: FE label rename Phase=TraLai "Trả lại" → "Cần chỉnh sửa lại" (Implementer Case 2 spawn × 2 app)
- Pending Chunk M4: docs + memory update + push + CICD verify
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bro UAT S23 t2 catch: Plan K K2 implement F2 SAI semantic — set
Phase=DaDuyet terminal auto-approve. Bro intent: "Duyệt thẳng đến CEO,
bỏ qua các bước khác chứ ko phải chuyển sang đã duyệt."
Refactor Service.cs ApproveV2Async F2 branch:
- Resolve lastStepIdx = steps.Count - 1, lastLevelMaxOrder = max(LevelOrder)
trong Step cuối
- Advance pointer: CurrentWorkflowStepIndex = lastStepIdx + CurrentApprovalLevelOrder = lastLevelMaxOrder
- Phase GIỮ NGUYÊN ChoDuyet — NV cuối (CEO/last approver) vẫn cần ký thật
để tiến DaDuyet
- Audit log "Approver skip thẳng tới Bước X Cấp Y (NV cuối) — bỏ qua các Bước/Cấp trung gian"
- Guard no-op: actor đã ở slot cuối → fall through advance logic (normal → DaDuyet)
(KHÔNG double-advance khi skipToFinal=true ngay slot cuối)
- Reset SLA 7d cho NV cuối nhận lại
FE × 2 app PeWorkflowPanel.tsx (mirror rule §3.9):
- Description text update: "Phiếu sẽ skip tới NV cuối (CEO/cấp ký cuối) —
NV cuối vẫn cần duyệt thật để hoàn tất."
- Amber warning update: "Bỏ qua mọi Cấp/Bước trung gian, phiếu chuyển thẳng
tới NV cuối. NV cuối vẫn phải ký duyệt thật để phiếu thành 'Đã duyệt'."
Verify:
- dotnet build production projects clean (0 err, 2 pre-existing warn)
- npm run build × 2 app pass
Pattern lesson saved memory: Service skipToFinal semantic = advance pointer
NOT terminate. K7 tests TODO update: 3 Approver F2 tests assert pointer
moved to last slot, NOT Phase=DaDuyet. Defer test fix sau UAT confirm UX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User UAT feedback: "Nếu đã không được quyền thao tác thì ko được quyền thao tác
hết tất cả các hành động" — trước đây chỉ "Duyệt" disabled, "Trả lại" + "Từ chối"
vẫn enabled (design intent S17 cũ).
FE 2 app mirror (PeWorkflowPanel.tsx):
- `isDisabled = blockedByV2Level` (drop `isForwardApprove &&` qualifier)
- Tooltip update "mới thao tác được (Duyệt / Trả lại / Từ chối)"
- Comment refresh ghi UAT S22+1 spec + cross-ref BE EnsureCanRejectV2Async
BE defense-in-depth (PurchaseEvaluationWorkflowService.cs):
- Helper mới `EnsureCanRejectV2Async` mirror FE actorInV2Level logic:
Skip silent khi admin/V1/non-ChoDuyet/no actor/no pointer. Throw
ForbiddenException khi V2 + ChoDuyet + actor != currentLevel.ApproverUserId.
- Invoke ở top Reject branch (cover cả TuChoi + Trả lại sub-branches).
- Chặn request forge: non-approver gọi PATCH /transitions direct sẽ 403.
Test (test-before §7 — security guard critical algorithm):
- ReturnMode tests existing 7/7 vẫn PASS (a2.Id = currentLevel approver, guard accept)
- +1 NEW test `Reject_NonApprover_V2_Throws_ForbiddenException` — outsider
Drafter role gọi Reject phiếu V2 → throw + Phase không mutate
Verify:
- dotnet test SolutionErp.slnx — 104/104 PASS (+1 guard regression)
Δ: 103 → 104
- npm run build × 2 app — pass (482ms + 583ms)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Service `ApproveV2Async` sau khi log approval (Decision=Approve) → UPSERT
row `PurchaseEvaluationLevelOpinions` cho Cấp hiện tại (auto sync ý kiến
từ comment khi duyệt). Reject KHÔNG sync.
Match level theo ApproverUserId của actor (multi-NV cùng Cấp OR-of-N).
Admin override (actor.Id KHÔNG match) → fallback first level — FE detect
SignedByUserId !== Level.ApproverUserId hiển thị "Admin duyệt thay".
Empty/whitespace comment → "(duyệt — không ý kiến)" placeholder (Q4 bonus).
Helper `ResolveActorFullNameAsync(actorUserId, isSystem, ct)` lookup
denorm SignedByFullName từ Users (fallback "(System)" / "(unknown)").
DTO `PurchaseEvaluationLevelOpinionDto` (15 fields):
- StepOrder/StepName/StepDepartmentId/StepDepartmentName (Bước Phòng)
- LevelOrder/LevelName/ApproverUserId/ApproverFullName (Cấp NV)
- Comment/SignedAt/SignedByUserId/SignedByFullName (sign-off)
GetPurchaseEvaluationQueryHandler:
- Include LevelOpinions
- helper BuildLevelOpinionsAsync JOIN ApprovalWorkflows.Steps.Levels +
Departments + Users → denorm DTO. Empty list cho phiếu V1 / V2 chưa
có cấp nào duyệt → FE fallback message.
Verify: dotnet build pass + dotnet test 81 pass (no regression).
Chunk C kế tiếp: FE Section 5 dynamic mirror 2 app.
User báo bug eoffice: phiếu tạo mới không duyệt được + không bắt đc quy
trình mới. Root cause: Mig 23 pin ApprovalWorkflowId vào entity nhưng
Service vẫn đọc WorkflowDefinitionId legacy → match approver theo schema
cũ (Dept+PositionLevel/Role/User) thay vì ApproverUserId V2.
BE Domain — Migration 24 `AddCurrentApprovalLevelOrderToPe`:
- PurchaseEvaluation +CurrentApprovalLevelOrder int? (track Cấp 1/2/3
đang chờ duyệt trong Step hiện tại khi pin V2). Null khi terminal/V1.
- RejectedAtStepIndex giữ deprecated DB column cho data cũ.
BE Service PE — branch theo schema pin:
- V2 (`ApprovalWorkflowId` set): ApproveV2Async() — load
ApprovalWorkflows.Steps.Levels Include 3-level. Group Levels by Order
= Cấp (OR-of-N approvers). Match `actor.Id ∈ levelGroup.ApproverUserId`
(KHÔNG match Dept+Level/Role/User như V1). Advance:
Còn cấp tiếp trong Step → levelOrder++
Hết cấp → idx++, levelOrder=1
Hết Step → DaDuyet
- V1 legacy (chỉ `WorkflowDefinitionId` set): ApproveV1LegacyAsync() —
giữ nguyên logic Mig 21 (Dept+PositionLevel match)
- Drafter trình từ Nháp/Trả lại: init CurrentWorkflowStepIndex=0 +
CurrentApprovalLevelOrder=1 (chỉ khi V2 pin)
- Reject (Trả lại): clear CurrentApprovalLevelOrder=null
- Reject (Từ chối): clear all tracking
BE Synthetic Policy V2:
- `PurchaseEvaluationPolicyRegistry.ForV2Schema()` — simple state machine
policy (DangSoanThao/TraLai → ChoDuyet/TuChoi; ChoDuyet → ChoDuyet/
TraLai/TuChoi). Roles="*" cho ChoDuyet branch — Service tự enforce
ApproverUserId, Policy chỉ expose 3 nút FE.
- GetPurchaseEvaluationByIdQuery handler: ưu tiên ForV2Schema() khi pin
V2 (FE đọc workflow.nextPhases để show button).
Verify: 81 test pass · BE 0 error · Mig 24 applied cả 2 LocalDB.
Test thử (Drafter eoffice):
1. Designer V2 tạo quy trình QT-DN-V2-001: Bước 1 (Phòng A), Cấp 1 (NV X)
2. Workspace tạo phiếu mới, Select QT-DN-V2-001 → Lưu phiếu + Gửi duyệt
3. Phiếu Phase=ChoDuyet, idx=0, levelOrder=1. NV X login → thấy phiếu
trong Inbox + duyệt được. Sau approve → idx++, levelOrder reset 1.
4. Cấu hình level mismatch: NV Y khác → thấy ForbiddenException rõ tên.
Logic Contract V2 chưa wire (chỉ PE), defer Session sau khi user UAT PE OK.
User chỉ thị thay 2-button hiện tại bằng 3 hành động rõ ràng:
- Duyệt = forward phase tiếp theo
- Trả lại = về DangSoanThao + Drafter sửa → workflow tự jump tới phase
đã reject (smart reject Mig 16 pattern + clear N-stage rows)
- Từ chối = phiếu khoá hoàn toàn (Phase=TuChoi → 17 handler Mig 16 lock
edit). Drafter phải tạo phiếu mới.
Domain (PurchaseEvaluationPolicy.cs):
- NccOnly + NccWithPlan: thêm (X → TuChoi) transition cho mọi phase
trung gian (ChoPurchasing/ChoCCM/ChoCEODuyetNCC/ChoDuAn/ChoCEODuyetPA)
với roles của phase đó. Trước đây chỉ DangSoanThao → TuChoi (Drafter).
- FromDefinition expand: mỗi step (trừ DangSoanThao) thêm
(step.Phase → TuChoi) với roles của step.
Service (PurchaseEvaluationWorkflowService.cs):
- Reject branch tách 2 case:
* target=TuChoi → giữ nguyên (KHÔNG override + KHÔNG set
RejectedFromPhase + KHÔNG clear N-stage rows). Phiếu khoá vĩnh viễn.
* target khác (thường DangSoanThao) → smart reject (set
RejectedFromPhase + force DangSoanThao + clear N-stage rows).
FE (PeWorkflowPanel.tsx, fe-admin + fe-user mirror):
- next.phases render 3 button rõ ràng:
* "✓ Duyệt → <label>" brand (forward)
* "← Trả lại (về Drafter sửa)" red (target=DangSoanThao + isSendBack)
* "✗ Hủy / Từ chối" red (target=TuChoi)
- Decision logic: target=TuChoi || isSendBack → Reject (2), else Approve (1)
- Dialog confirm:
* Title rõ theo loại hành động
* Cancel case: warning red "Phiếu sẽ bị khoá hoàn toàn"
* SendBack case: hint amber "Phiếu sẽ về Đang soạn thảo, Drafter sửa
rồi trình lại — workflow tự jump tới phase này"
Tests update + add 1 test mới:
- Reject_Sets_RejectedFromPhase_And_Forces_DangSoanThao →
Reject_To_DangSoanThao_Sets_RejectedFromPhase_TraLai (rename + change
target từ TuChoi → DangSoanThao để test Trả lại pattern)
- + Reject_To_TuChoi_Locks_Permanently_No_RejectedFromPhase (NEW test
Từ chối — phase=TuChoi + RejectedFromPhase null)
- NStage_Reject_Clears_InnerStep_Rows_At_Phase: target TuChoi →
DangSoanThao (test Trả lại + clear N-stage rows pattern)
Verify:
- dotnet build 0 error
- dotnet test 95 → **96 pass** (+1 test mới Từ chối)
- npm build fe-admin + fe-user pass
Pending Task 2: Sample data seed N-stage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>