[CLAUDE] PurchaseEvaluation Tests: Chunk O1-O5 — HOTFIX 4 lookup sites cùng pattern per-NV (Plan N point 9 cascade)

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>
This commit is contained in:
pqhuy1987
2026-05-15 13:05:02 +07:00
parent fb3c22c90f
commit ae01ca56f2
4 changed files with 259 additions and 17 deletions

View File

@ -92,7 +92,7 @@ public class PurchaseEvaluationWorkflowService(
// Default fallback (returnMode=null) = Drafter mode = S17 behavior.
var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter;
var returnSummary = await ApplyReturnModeAsync(
evaluation, effectiveMode, returnTargetUserId, isAdmin, ct);
evaluation, effectiveMode, returnTargetUserId, actorUserId, isAdmin, ct);
comment = string.IsNullOrWhiteSpace(comment)
? returnSummary
: $"{comment} [{returnSummary}]";
@ -198,8 +198,11 @@ public class PurchaseEvaluationWorkflowService(
var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
if (csi < 0 || csi >= stepsOrdered.Count) return; // pointer corrupt
var step = stepsOrdered[csi];
var currentLevel = step.Levels.FirstOrDefault(l => l.Order == curLvl);
if (currentLevel?.ApproverUserId != actorId)
// Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId.
// Schema Mig 29 OR-of-N: N rows cùng Order, mỗi row 1 NV. Filter ngay
// trong FirstOrDefault thay vì compare sau (tránh chọn nhầm row đầu DB).
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 — chỉ NV Cấp duyệt hiện tại mới được Trả lại / Từ chối phiếu.");
}
@ -213,6 +216,7 @@ public class PurchaseEvaluationWorkflowService(
PurchaseEvaluation evaluation,
WorkflowReturnMode mode,
Guid? returnTargetUserId,
Guid? actorUserId,
bool isAdmin,
CancellationToken ct)
{
@ -241,11 +245,19 @@ public class PurchaseEvaluationWorkflowService(
// Resolve Level hiện tại (slot Approver đang duyệt) — đọc Allow* từ slot
// này. Required cho mọi mode (kể cả Drafter — Approver hiện tại quyết
// định mode Trả lại theo flag riêng của slot).
//
// Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId.
// Schema Mig 29 OR-of-N: N rows cùng Order. Non-admin actor BẮT BUỘC
// match slot để đọc Allow flag riêng. Admin bypass validation line 252
// bất kể currentLevel null (mode logic switch phía dưới không dùng
// currentLevel object — chỉ dùng curStepIdx + curLevel int values).
ApprovalWorkflowLevel? currentLevel = null;
if (evaluation.CurrentWorkflowStepIndex is int csi && csi >= 0 && csi < stepsOrdered.Count)
{
var step = stepsOrdered[csi];
currentLevel = step.Levels.FirstOrDefault(l => l.Order == evaluation.CurrentApprovalLevelOrder);
currentLevel = step.Levels.FirstOrDefault(l =>
l.Order == evaluation.CurrentApprovalLevelOrder
&& l.ApproverUserId == actorUserId);
}
// Validate Allow* flag từ Level slot hiện tại (Admin bypass)