Compare commits

...

2 Commits

Author SHA1 Message Date
10ddc8761b [CLAUDE] FE-Admin FE-User: Chunk L2 — Fix F4 BudgetAdjustSection bypass readOnly khi Approver scope (menu Duyệt)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m1s
Bro UAT S23 t2 catch: "Đã stick cho edit trong luồng duyệt nhưng trong menu
duyệt -> vẫn không edit đc ngân sách". Investigator audit root cause:
- BudgetAdjustSection canAdjust = !readOnly && (...) — `!readOnly` short-circuit
  block F4 logic
- Menu Duyệt route truyền readOnly=true xuống PeDetailTabs → button "Điều chỉnh"
  hidden dù admin đã tick AllowApproverEditBudget cho slot + actor match
- F3 wire ItemsTab ĐÚNG via `itemsReadOnly = readOnly && !approverEditMode`
  pattern bypass — F4 không follow same pattern

Refactor canAdjust × 2 app (rule §3.9 mirror):
```
- canAdjust = !readOnly && (isAdmin || (isDrafter && isDrafterPhase) || isApproverChoDuyet)
+ canAdjust = isAdmin
+   || (!readOnly && isDrafter && isDrafterPhase)
+   || isApproverChoDuyet
```

→ F4 Approver scope (Mig 30) BYPASS readOnly:
- Admin: bypass readOnly (full quyền)
- Drafter (Nháp/TraLai): chỉ Workspace (readOnly=false)
- Approver ChoDuyet + flag tick + actor match: bypass readOnly → button "Điều chỉnh"
  visible trong menu Duyệt

Mirror F3 pattern (itemsReadOnly line 118). F4 wire S22+5 ban đầu miss BYPASS
pattern — fixed S23 t2.

Verify:
- npm run build × 2 app pass (0 TS err, bundle hash rotated)
- Bro UAT verify: tick F4 → vào menu Duyệt → click "Điều chỉnh ngân sách"
  → modal open editable

Pattern lesson saved memory: per-NV admin opt-in flag wire RULE — FE bypass
readOnly khi flag tick + actor match + phase match (mirror F3 itemsReadOnly).
F4 BudgetAdjustSection retroactive fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:39:21 +07:00
f3db9e6cc0 [CLAUDE] PurchaseEvaluation: Chunk L1 — Fix F2 skipToFinal semantic: skip pointer tới NV cuối (KHÔNG terminate DaDuyet)
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>
2026-05-15 01:39:03 +07:00
5 changed files with 64 additions and 28 deletions

View File

@ -966,7 +966,13 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
&& actorInCurrentLevel && actorInCurrentLevel
&& approverEditBudgetAllowed && approverEditBudgetAllowed
const canAdjust = !readOnly && (isAdmin || (isDrafter && isDrafterPhase) || isApproverChoDuyet) // S23 t2 bug fix: F4 Approver scope BYPASS readOnly (mirror F3 itemsReadOnly
// pattern). Khi admin tick AllowApproverEditBudget cho slot + actor match +
// Phase=ChoDuyet → button "Điều chỉnh" enable trong menu Duyệt (readOnly=true)
// dù chế độ chỉ-đọc. Drafter + Admin vẫn cần !readOnly (chỉ active từ Workspace).
const canAdjust = isAdmin
|| (!readOnly && isDrafter && isDrafterPhase)
|| isApproverChoDuyet
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
const [manualMode, setManualMode] = useState(initialManual) const [manualMode, setManualMode] = useState(initialManual)

View File

@ -418,7 +418,7 @@ export function PeWorkflowPanel({
<span> <span>
<span className="font-medium">Duyệt thẳng Cấp cuối (skip Bước/Cấp trung gian)</span> <span className="font-medium">Duyệt thẳng Cấp cuối (skip Bước/Cấp trung gian)</span>
<span className="mt-0.5 block text-[11px] text-violet-700/80"> <span className="mt-0.5 block text-[11px] text-violet-700/80">
Phiếu sẽ tiến thẳng tới "Đã duyệt" (terminal) bỏ qua mọi Cấp/Bước n lại. Phiếu sẽ skip tới NV cuối (CEO/cấp cuối) NV cuối vẫn cần duyệt thật đ hoàn tất.
</span> </span>
</span> </span>
</label> </label>
@ -426,8 +426,8 @@ export function PeWorkflowPanel({
)} )}
{!isCancel && !isSendBack && skipToFinalApprover && ( {!isCancel && !isSendBack && skipToFinalApprover && (
<div className="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800"> <div className="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
Hành đng KHÔNG quay lại đưc (trừ khi Drafter reset toàn bộ). Phiếu sẽ Bỏ qua mọi Cấp/Bước trung gian, phiếu chuyển thẳng tới NV cuối. NV cuối
skip qua tất cả Cấp/Bước còn lại chuyển thẳng "Đã duyệt". vẫn phải duyệt thật đ phiếu thành "Đã duyệt".
</div> </div>
)} )}
<Label>Ghi chú (tùy chọn)</Label> <Label>Ghi chú (tùy chọn)</Label>

View File

@ -970,7 +970,13 @@ function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: b
&& actorInCurrentLevel && actorInCurrentLevel
&& approverEditBudgetAllowed && approverEditBudgetAllowed
const canAdjust = !readOnly && (isAdmin || (isDrafter && isDrafterPhase) || isApproverChoDuyet) // S23 t2 bug fix: F4 Approver scope BYPASS readOnly (mirror F3 itemsReadOnly
// pattern line 118). Khi admin tick AllowApproverEditBudget cho slot + actor
// match + Phase=ChoDuyet → button "Điều chỉnh" enable trong menu Duyệt (readOnly=true)
// dù chế độ chỉ-đọc. Drafter + Admin vẫn cần !readOnly (chỉ active từ Workspace).
const canAdjust = isAdmin
|| (!readOnly && isDrafter && isDrafterPhase)
|| isApproverChoDuyet
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
const [manualMode, setManualMode] = useState(initialManual) const [manualMode, setManualMode] = useState(initialManual)

View File

@ -415,7 +415,7 @@ export function PeWorkflowPanel({
<span> <span>
<span className="font-medium">Duyệt thẳng Cấp cuối (skip Bước/Cấp trung gian)</span> <span className="font-medium">Duyệt thẳng Cấp cuối (skip Bước/Cấp trung gian)</span>
<span className="mt-0.5 block text-[11px] text-violet-700/80"> <span className="mt-0.5 block text-[11px] text-violet-700/80">
Phiếu sẽ tiến thẳng tới "Đã duyệt" (terminal) bỏ qua mọi Cấp/Bước n lại. Phiếu sẽ skip tới NV cuối (CEO/cấp cuối) NV cuối vẫn cần duyệt thật đ hoàn tất.
</span> </span>
</span> </span>
</label> </label>
@ -423,8 +423,8 @@ export function PeWorkflowPanel({
)} )}
{!isCancel && !isSendBack && skipToFinalApprover && ( {!isCancel && !isSendBack && skipToFinalApprover && (
<div className="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800"> <div className="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
Hành đng KHÔNG quay lại đưc (trừ khi Drafter reset toàn bộ). Phiếu sẽ Bỏ qua mọi Cấp/Bước trung gian, phiếu chuyển thẳng tới NV cuối. NV cuối
skip qua tất cả Cấp/Bước còn lại chuyển thẳng "Đã duyệt". vẫn phải duyệt thật đ phiếu thành "Đã duyệt".
</div> </div>
)} )}
<Label>Ghi chú (tùy chọn)</Label> <Label>Ghi chú (tùy chọn)</Label>

View File

@ -467,13 +467,19 @@ public class PurchaseEvaluationWorkflowService(
existingOpinion.SignedByFullName = actorFullName; existingOpinion.SignedByFullName = actorFullName;
} }
// Mig 31 (S23 t1 Plan K) — F2 Approver scope ChoDuyet: duyệt thẳng Cấp cuối. // Mig 31 (S23 t1 Plan K) — F2 Approver scope ChoDuyet: skip thẳng tới
// Admin opt-in per slot tại matchingLevel.AllowApproverSkipToFinal. Khi // NV cuối (CEO / last approver). Admin opt-in per slot tại
// Approver tick checkbox "Duyệt thẳng Cấp cuối" trong Workspace + admin // matchingLevel.AllowApproverSkipToFinal. Khi Approver tick checkbox
// đã enable flag cho slot này → bỏ qua mọi Bước/Cấp trung gian còn lại, // "Duyệt thẳng Cấp cuối" + admin enable flag → bỏ qua mọi Bước/Cấp
// set Phase=DaDuyet terminal trực tiếp. Mirror F3+F4 admin opt-in per- // TRUNG GIAN còn lại, advance pointer tới Bước cuối + Cấp cuối (max).
// Approver-slot pattern (Mig 29 + Mig 30) reinforced 3× cumulative. // Phase GIỮ NGUYÊN ChoDuyet — NV cuối vẫn cần ký thật để tiến DaDuyet
// Non-admin + flag off → ConflictException. Admin bypass flag. // (KHÔNG auto-approve terminal). Mirror F3+F4 admin opt-in per-slot
// pattern (Mig 29 + Mig 30) reinforced 3× cumulative. Non-admin + flag
// off → ConflictException. Admin bypass flag.
//
// S23 t2 spec fix: bro UAT feedback Plan K K2 implement SAI semantic
// (set Phase=DaDuyet terminal auto-approve), refactor sang advance
// pointer tới Cấp cuối (CEO duyệt thật).
if (skipToFinal) if (skipToFinal)
{ {
if (!isAdmin && !isSystem && !matchingLevel.AllowApproverSkipToFinal) if (!isAdmin && !isSystem && !matchingLevel.AllowApproverSkipToFinal)
@ -484,20 +490,38 @@ public class PurchaseEvaluationWorkflowService(
"'Duyệt thẳng Cấp cuối' trong Workflow Designer cho slot này."); "'Duyệt thẳng Cấp cuối' trong Workflow Designer cho slot này.");
} }
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet; // Resolve last Step + last Level (max LevelOrder trong Step cuối)
evaluation.CurrentWorkflowStepIndex = null; var lastStepIdx = steps.Count - 1;
evaluation.CurrentApprovalLevelOrder = null; var lastStep = steps[lastStepIdx];
evaluation.SlaDeadline = null; var lastLevelGroups = lastStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
var lastLevelMaxOrder = lastLevelGroups.Count == 0 ? 1 : lastLevelGroups.Max(g => g.Key);
// Guard: nếu actor đã ở Cấp cuối Bước cuối thì skipToFinal = no-op
// → fall through advance logic bên dưới (normal advance → DaDuyet).
if (currentIdx == lastStepIdx && currentLevelOrder == lastLevelMaxOrder)
{
// No-op skip: actor đã ở slot cuối — fall through normal advance
// (sẽ hit branch `nextIdx >= steps.Count` → DaDuyet đúng).
}
else
{
// Advance pointer tới Bước cuối + Cấp cuối. Phase giữ ChoDuyet.
// CEO/NV cuối thấy phiếu đang chờ duyệt + opinion của actor vừa
// ghi sẵn → duyệt cuối để approve DaDuyet thật.
evaluation.CurrentWorkflowStepIndex = lastStepIdx;
evaluation.CurrentApprovalLevelOrder = lastLevelMaxOrder;
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync( await LogTransitionAsync(
evaluation, evaluation,
PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet,
PurchaseEvaluationPhase.DaDuyet, PurchaseEvaluationPhase.ChoDuyet,
actorUserId, actorUserId,
ApprovalDecision.Approve, ApprovalDecision.Approve,
$"[Approver duyệt thẳng Cấp cuối — Bước {currentIdx + 1} Cấp {currentLevelOrder} → DaDuyet] {comment ?? ""}".Trim(), $"[Approver skip thẳng tới Bước {lastStepIdx + 1} Cấp {lastLevelMaxOrder} (NV cuối) — bỏ qua các Bước/Cấp trung gian] {comment ?? ""}".Trim(),
ct); ct);
return; return;
} }
}
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1 // Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
if (currentLevelOrder < maxLevelOrder) if (currentLevelOrder < maxLevelOrder)