[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>
This commit is contained in:
pqhuy1987
2026-05-15 01:39:03 +07:00
parent 409a9676e8
commit f3db9e6cc0
3 changed files with 50 additions and 26 deletions

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

@ -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)