[CLAUDE] PurchaseEvaluation: A1 người-tạo ký ô cấp mình + EDGE-5 khóa terminal + opinion-history
A1: ApplyDrafterBypassOnSubmitAsync bypass CHỈ NV cấp dưới creator (order<minOwn), creator ký tay ô mình qua ApproveV2Async (bỏ auto-ký-hộ), giữ skip-NV S60. EDGE-5: guard đầu TransitionAsync chặn non-admin transition từ DaDuyet/TuChoi (admin+system exempt). History: log ý-kiến cũ→PurchaseEvaluationChangelog trước ghi đè (no migration). Tests: 4 bypass→A1 + 8 mới (happy/non-contiguous/EDGE-5/history), 458 PASS. UAT: phiếu 1-bước creator-là-cấp-cuối nay cần bấm Duyệt (bỏ auto-terminal-on-submit, đúng bám-quy-trình). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -54,6 +54,22 @@ public class PurchaseEvaluationWorkflowService(
|
||||
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||
|
||||
// ===== EDGE-5 hard-lock (close-review wf_7df56b89-7bb — owner anh Kiệt oQ2) =====
|
||||
// Phiếu ĐÃ ở trạng thái KẾT THÚC (DaDuyet / TuChoi) → user THƯỜNG không được trả
|
||||
// lại, từ chối hay chuyển trạng thái. Chặn exploit EDGE-5: drafter non-admin forge
|
||||
// Reject trên chính phiếu DaDuyet của mình → lật phiếu đã duyệt về "đang duyệt"
|
||||
// (un-terminal). ADMIN + SYSTEM MIỄN (owner oQ2 "Admin đầy đủ nhất" — giữ override
|
||||
// :290 + khả năng un-terminal + cron/SLA auto-job decision=AutoApprove). Đặt TRƯỚC
|
||||
// MỌI branch → chặn trên CẢ 5 đường (reject / drafter-submit / approve-step /
|
||||
// admin-override / fall-through) với non-admin. KHÔNG đụng EnsureCanRejectV2Async
|
||||
// (early-return của nó phục vụ system/cron hợp lệ — guard top-method này là fix duy nhất).
|
||||
if ((fromPhase is PurchaseEvaluationPhase.DaDuyet or PurchaseEvaluationPhase.TuChoi)
|
||||
&& !isAdmin && !isSystem)
|
||||
{
|
||||
throw new ConflictException(
|
||||
$"Phiếu đã ở trạng thái kết thúc ({fromPhase}) — không thể trả lại, từ chối hay chuyển trạng thái.");
|
||||
}
|
||||
|
||||
// ===== 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
|
||||
@ -547,27 +563,23 @@ public class PurchaseEvaluationWorkflowService(
|
||||
return summary;
|
||||
}
|
||||
|
||||
// ===== UAT S60 — Drafter-in-chain bypass khi gửi duyệt (V2-only) =====
|
||||
// Anh Kiệt: "Trưởng phòng tạo thì bypass — không cần nhân viên duyệt lại."
|
||||
// Luật GENERIC theo cấp (anh chốt S60): chỉ xét BƯỚC ĐẦU (= phòng soạn).
|
||||
// Người soạn (DrafterUserId — KHÔNG phải actor submit, Admin gửi hộ vẫn
|
||||
// tính theo người soạn) là approver ở cấp nào trong bước đầu → auto qua
|
||||
// Cấp 1..k (k = MAX Order có slot drafter — drafter có thể ở nhiều cấp,
|
||||
// Designer chỉ chặn duplicate cùng cấp). Phiếu bắt đầu chờ Cấp k+1 / Bước 2
|
||||
// Cấp 1 / terminal DaDuyet (quy trình 1 bước mà drafter là cấp cuối).
|
||||
// Các bước SAU + BOD duyệt đầy đủ bình thường.
|
||||
// ===== Drafter-in-chain bypass khi gửi duyệt (V2-only) =====
|
||||
// Anh Kiệt: "Trưởng phòng tạo thì bypass — không cần nhân viên cấp dưới duyệt lại."
|
||||
// A1 (close-review wf_7df56b89-7bb — owner anh Kiệt): chỉ xét BƯỚC ĐẦU (= phòng
|
||||
// soạn). Người soạn (DrafterUserId — KHÔNG phải actor submit; Admin gửi hộ vẫn
|
||||
// tính theo người soạn) KÝ TAY ô cấp mình; chỉ bypass các cấp NV DƯỚI THUẦN
|
||||
// (Order < minOwn, minOwn = cấp THẤP NHẤT người soạn có slot). Pointer DỪNG tại
|
||||
// minOwn → người soạn tự duyệt Cấp minOwn qua ApproveV2Async (KHÔNG auto-terminal,
|
||||
// KHÔNG auto nhảy Bước — người soạn phải ký thật để "bám đúng quy trình").
|
||||
// Non-contiguous (người soạn Cấp 1 + Cấp 3, NV khác ở Cấp 2): minOwn=1 → bypass
|
||||
// rỗng, toàn bộ chuỗi ký tay đúng thứ tự (KHÔNG auto qua Cấp 2 của người khác).
|
||||
//
|
||||
// Audit trail 3 tầng:
|
||||
// - LevelOpinion: ghi CHỈ slot CHÍNH CHỦ (Level.ApproverUserId == drafter)
|
||||
// — KHÔNG ghi hộ cấp NV bị skip (không gán chữ ký người không duyệt +
|
||||
// không trigger FE badge "duyệt thay" sai nghĩa). Cấp skip để trống
|
||||
// Section 5 (đúng sự thật); vết nằm ở Approval row + Changelog.
|
||||
// - Approval row per cấp (Decision=AutoApprove) → ApprovalsTab có vết.
|
||||
// - Changelog 1 dòng tóm tắt pointer nhảy.
|
||||
// Idempotent khi TraLai-resubmit: opinion UPSERT, approval row add thêm
|
||||
// (lịch sử 2 lần gửi = 2 vết — đúng semantics history).
|
||||
// Fail-soft: workflow/step/level lỗi cấu trúc → return im lặng (submit vẫn
|
||||
// hợp lệ pointer Cấp 1, ApproveV2Async sẽ báo lỗi cấu trúc khi duyệt).
|
||||
// Audit trail: mỗi cấp NV bị bypass → 1 Approval row (Decision=AutoApprove, "bỏ
|
||||
// qua") + 1 dòng Changelog tóm tắt pointer nhảy. KHÔNG tự ký hộ opinion Section 5
|
||||
// cho bất kỳ ai (kể cả người soạn — họ ký tay). Idempotent khi TraLai-resubmit
|
||||
// (approval row add thêm = lịch sử 2 lần gửi — đúng semantics history).
|
||||
// Fail-soft: workflow/step/level lỗi cấu trúc → return im lặng (submit vẫn hợp lệ
|
||||
// pointer Cấp 1, ApproveV2Async sẽ báo lỗi cấu trúc khi duyệt).
|
||||
private async Task ApplyDrafterBypassOnSubmitAsync(
|
||||
PurchaseEvaluation evaluation, Guid awId, CancellationToken ct)
|
||||
{
|
||||
@ -589,17 +601,18 @@ public class PurchaseEvaluationWorkflowService(
|
||||
var drafterSlots = firstStep.Levels.Where(l => l.ApproverUserId == drafterId).ToList();
|
||||
if (drafterSlots.Count == 0) return; // drafter ngoài chuỗi bước đầu — flow thường
|
||||
|
||||
var k = drafterSlots.Max(l => l.Order);
|
||||
var maxLevelOrder = levelGroups.Max(g => g.Key);
|
||||
// A1 (close-review wf_7df56b89-7bb — owner anh Kiệt): người soạn KÝ TAY ô cấp
|
||||
// mình, chỉ bypass các cấp NV DƯỚI THUẦN (Order < minOwn). minOwn = cấp THẤP
|
||||
// NHẤT người soạn có slot trong bước đầu.
|
||||
var minOwn = drafterSlots.Min(l => l.Order);
|
||||
var drafterFullName = await ResolveActorFullNameAsync(drafterId, isSystem: false, ct);
|
||||
var bypassedOrders = levelGroups.Select(g => g.Key).Where(o => o <= k).OrderBy(o => o).ToList();
|
||||
var bypassedOrders = levelGroups.Select(g => g.Key).Where(o => o < minOwn).OrderBy(o => o).ToList();
|
||||
|
||||
// Mọi order < minOwn là cấp NV cấp dưới THUẦN (người soạn KHÔNG có slot ở đây)
|
||||
// → chỉ ghi vết audit "bỏ qua", KHÔNG tự ký hộ opinion (A1 — không tự ký hộ ai;
|
||||
// người soạn tự ký tay ô cấp mình qua ApproveV2Async).
|
||||
foreach (var order in bypassedOrders)
|
||||
{
|
||||
var ownSlot = drafterSlots.FirstOrDefault(l => l.Order == order);
|
||||
|
||||
// Approval row vết audit per cấp — Decision=AutoApprove phân biệt
|
||||
// rõ với duyệt tay (mirror style ApproveV2Async :501-510).
|
||||
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
||||
{
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
@ -607,75 +620,27 @@ public class PurchaseEvaluationWorkflowService(
|
||||
ToPhase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
ApproverUserId = drafterId,
|
||||
Decision = ApprovalDecision.AutoApprove,
|
||||
Comment = ownSlot is not null
|
||||
? $"[Bước 1 — Cấp {order}] (duyệt tự động khi gửi — người soạn phiếu là người duyệt cấp này)"
|
||||
: $"[Bước 1 — Cấp {order}] (bỏ qua — phiếu do người duyệt cấp cao hơn cùng phòng soạn)",
|
||||
Comment = $"[Bước 1 — Cấp {order}] (bỏ qua — phiếu do người duyệt cấp cao hơn cùng phòng soạn)",
|
||||
ApprovedAt = dateTime.UtcNow,
|
||||
});
|
||||
|
||||
// Opinion CHỈ slot chính chủ (UPSERT — mirror ApproveV2Async Mig 26).
|
||||
if (ownSlot is not null)
|
||||
{
|
||||
const string autoComment = "(duyệt tự động — người soạn phiếu là người duyệt cấp này)";
|
||||
var existingOpinion = await db.PurchaseEvaluationLevelOpinions
|
||||
.FirstOrDefaultAsync(o => o.PurchaseEvaluationId == evaluation.Id
|
||||
&& o.ApprovalWorkflowLevelId == ownSlot.Id, ct);
|
||||
if (existingOpinion is null)
|
||||
{
|
||||
db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
|
||||
{
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
ApprovalWorkflowLevelId = ownSlot.Id,
|
||||
Comment = autoComment,
|
||||
SignedAt = dateTime.UtcNow,
|
||||
SignedByUserId = drafterId,
|
||||
SignedByFullName = drafterFullName,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existingOpinion.Comment = autoComment;
|
||||
existingOpinion.SignedAt = dateTime.UtcNow;
|
||||
existingOpinion.SignedByUserId = drafterId;
|
||||
existingOpinion.SignedByFullName = drafterFullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance pointer qua Cấp k (next = min Order > k — robust khi Order
|
||||
// không liên tục; engine gốc +1 giả định liên tục, đây superset).
|
||||
if (k < maxLevelOrder)
|
||||
// A1 — pointer DỪNG tại minOwn (cấp thấp nhất của người soạn) → người soạn TỰ KÝ
|
||||
// Cấp minOwn qua ApproveV2Async (gate match-approver pass vì họ ∈ ApproverUserId).
|
||||
// KHÔNG advance sang Bước sau, KHÔNG terminal DaDuyet khi gửi — người soạn phải ký
|
||||
// thật để "bám đúng quy trình".
|
||||
// - minOwn == 1 (người soạn là Cấp 1): bypassedOrders rỗng + block này skip →
|
||||
// pointer giữ (0,1) đã set ở nhánh submit → người soạn ký Cấp 1 bình thường.
|
||||
// - Non-contiguous (người soạn ở Cấp 1 + Cấp 3, NV khác ở Cấp 2): minOwn=1 →
|
||||
// bypass rỗng; người soạn ký Cấp 1 tay → NV Cấp 2 ký bình thường → người soạn
|
||||
// ký Cấp 3 tay. BÁM ĐÚNG quy trình, KHÔNG auto nhảy qua Cấp 2 của người khác.
|
||||
if (minOwn > 1)
|
||||
{
|
||||
var nextOrder = levelGroups.Select(g => g.Key).Where(o => o > k).Min();
|
||||
evaluation.CurrentApprovalLevelOrder = nextOrder;
|
||||
evaluation.CurrentApprovalLevelOrder = minOwn;
|
||||
await LogTransitionAsync(evaluation,
|
||||
PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet,
|
||||
drafterId, ApprovalDecision.AutoApprove,
|
||||
$"Bỏ qua Cấp 1..{k} Bước 1 (người soạn {drafterFullName} là người duyệt Cấp {k}) — phiếu chờ từ Cấp {nextOrder}",
|
||||
ct);
|
||||
}
|
||||
else if (steps.Count > 1)
|
||||
{
|
||||
evaluation.CurrentWorkflowStepIndex = 1;
|
||||
evaluation.CurrentApprovalLevelOrder = 1;
|
||||
await LogTransitionAsync(evaluation,
|
||||
PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet,
|
||||
drafterId, ApprovalDecision.AutoApprove,
|
||||
$"Bỏ qua toàn bộ Bước 1 (người soạn {drafterFullName} là người duyệt cấp cuối của bước) — phiếu chờ Bước 2 (Cấp 1)",
|
||||
ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Quy trình chỉ 1 bước + drafter là cấp cuối → terminal DaDuyet
|
||||
// (mirror ApproveV2Async terminal :617-624).
|
||||
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
|
||||
evaluation.CurrentWorkflowStepIndex = null;
|
||||
evaluation.CurrentApprovalLevelOrder = null;
|
||||
evaluation.SlaDeadline = null;
|
||||
await LogTransitionAsync(evaluation,
|
||||
PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.DaDuyet,
|
||||
drafterId, ApprovalDecision.AutoApprove,
|
||||
"(duyệt tự động toàn bộ — quy trình 1 bước, người soạn là người duyệt cấp cuối)",
|
||||
$"Bỏ qua Cấp 1..{minOwn - 1} Bước 1 (cấp NV dưới) — chờ người soạn {drafterFullName} ký Cấp {minOwn}",
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@ -783,6 +748,28 @@ public class PurchaseEvaluationWorkflowService(
|
||||
}
|
||||
else
|
||||
{
|
||||
// CHANGE 4 (close-review wf_7df56b89-7bb) — Opinion history: phiếu bị Trả lại
|
||||
// rồi NV cùng Cấp KÝ LẠI → ghi ý kiến CŨ vào Changelog TRƯỚC khi ghi đè (bảo
|
||||
// toàn lịch sử; KHÔNG migration / bảng / endpoint mới — edit diễn ra in-flow
|
||||
// qua re-sign). Chỉ log khi ý kiến cũ non-empty (lần ký ĐẦU đi nhánh Add ở
|
||||
// trên vì existingOpinion null). Interpolation đọc snapshot CŨ ngay tại đây,
|
||||
// TRƯỚC 4 dòng ghi đè bên dưới → giá trị đã "đóng băng" vào ContextNote.
|
||||
if (!string.IsNullOrWhiteSpace(existingOpinion.Comment))
|
||||
{
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
PurchaseEvaluationId = evaluation.Id,
|
||||
EntityType = PurchaseEvaluationEntityType.Workflow,
|
||||
EntityId = matchingLevel.Id,
|
||||
Action = ChangelogAction.Update,
|
||||
PhaseAtChange = evaluation.Phase,
|
||||
UserId = actorUserId,
|
||||
UserName = actorFullName,
|
||||
Summary = $"Ghi đè ý kiến duyệt (Bước {currentIdx + 1} — Cấp {currentLevelOrder}) — lưu bản cũ vào lịch sử",
|
||||
ContextNote = $"[Ý kiến cũ — {existingOpinion.SignedByFullName} lúc {existingOpinion.SignedAt:dd/MM/yyyy HH:mm}] {existingOpinion.Comment}",
|
||||
});
|
||||
}
|
||||
|
||||
existingOpinion.Comment = normalizedComment;
|
||||
existingOpinion.SignedAt = dateTime.UtcNow;
|
||||
existingOpinion.SignedByUserId = actorUserId ?? Guid.Empty;
|
||||
|
||||
Reference in New Issue
Block a user