[CLAUDE] PurchaseEvaluation: rang buoc du 4 thong tin muc 3 moi gui duyet + bypass nguoi soan trong chuoi duyet (UAT anh Kiet S60)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m38s

- 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>
This commit is contained in:
pqhuy1987
2026-06-12 11:53:26 +07:00
parent 6bf28bfdb4
commit 37122f0f64
7 changed files with 949 additions and 28 deletions

View File

@ -142,6 +142,61 @@ public class PurchaseEvaluationWorkflowService(
throw new ForbiddenException(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu.");
}
// ===== UAT S60 (anh Kiệt) — Section 3 completeness guard =====
// "Mục chọn thầu phải có thông tin mới cho gửi duyệt" — đủ CẢ 4
// mọi trường hợp (anh chốt S60): (1) Đơn vị NCC/TP được chọn
// (2) Giá chào thầu > 0 của đơn vị đó (3) Ngân sách — link Budget
// HOẶC nhập tay (4) Bảng so sánh đính kèm.
// Message gộp MỌI mục thiếu 1 lần (UX — không bắt thử lại từng lỗi).
// Áp CẢ Admin/system (data-quality ≠ authz — phiếu thiếu thông tin
// thì không ai được trình, kể cả gửi hộ).
// Predicate "Bảng so sánh" = attachment chung (SupplierRowId null)
// — mirror ĐÚNG FE PeDetailTabs banSoSanhAttachments + pre-check
// missingForApproval (FE KHÔNG check Purpose enum → BE cũng không,
// tránh mismatch 2 tầng).
var missing = new List<string>();
if (evaluation.SelectedSupplierId is not Guid winnerId)
{
missing.Add("chưa chọn Đơn vị NCC/TP");
}
else
{
var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.SupplierId == winnerId)
.Select(s => s.Id)
.ToListAsync(ct);
if (winnerRowIds.Count == 0)
{
missing.Add("Đơn vị được chọn không còn trong danh sách NCC tham gia");
}
else
{
var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
if (winnerQuoteTotal <= 0)
missing.Add("Đơn vị được chọn chưa có giá chào thầu");
}
}
if (evaluation.BudgetId is null
&& (evaluation.BudgetManualAmount is null || evaluation.BudgetManualAmount <= 0))
{
missing.Add("chưa nhập Ngân sách");
}
var hasComparisonFile = await db.PurchaseEvaluationAttachments.AsNoTracking()
.AnyAsync(a => a.PurchaseEvaluationId == evaluation.Id
&& a.PurchaseEvaluationSupplierId == null, ct);
if (!hasComparisonFile)
missing.Add("chưa đính kèm Bảng so sánh");
if (missing.Count > 0)
{
throw new ConflictException(
"Chưa đủ thông tin mục 3 \"Đơn vị NCC/TP được chọn\" để gửi duyệt: "
+ string.Join(" · ", missing) + ".");
}
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
// Mig 31 (S23 t1 Plan K) — F2 Drafter-skip-from-Nháp semantic deprecated.
@ -153,6 +208,15 @@ public class PurchaseEvaluationWorkflowService(
evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null;
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
// UAT S60 (anh Kiệt) — bypass khi NGƯỜI SOẠN nằm trong chuỗi duyệt
// bước đầu (vd Trưởng phòng tự tạo phiếu → không bắt NV duyệt lại
// + không bắt TP tự bấm duyệt phiếu mình). V2-only. Chạy SAU
// LogTransition để Changelog đúng thứ tự: "Chuyển phase" → "Bỏ qua
// Cấp...". TraLai-resubmit đi cùng branch (reset Cấp 1) → tự áp lại.
if (evaluation.ApprovalWorkflowId is Guid submitAwId)
await ApplyDrafterBypassOnSubmitAsync(evaluation, submitAwId, ct);
await db.SaveChangesAsync(ct);
return;
}
@ -440,6 +504,139 @@ 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.
//
// 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).
private async Task ApplyDrafterBypassOnSubmitAsync(
PurchaseEvaluation evaluation, Guid awId, CancellationToken ct)
{
if (evaluation.DrafterUserId is not Guid drafterId) return;
var aw = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.FirstOrDefaultAsync(w => w.Id == awId, ct);
if (aw is null) return;
var steps = aw.Steps.OrderBy(s => s.Order).ToList();
if (steps.Count == 0) return;
var firstStep = steps[0];
var levelGroups = firstStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
if (levelGroups.Count == 0) return;
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);
var drafterFullName = await ResolveActorFullNameAsync(drafterId, isSystem: false, ct);
var bypassedOrders = levelGroups.Select(g => g.Key).Where(o => o <= k).OrderBy(o => o).ToList();
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,
FromPhase = PurchaseEvaluationPhase.ChoDuyet,
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)",
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)
{
var nextOrder = levelGroups.Select(g => g.Key).Where(o => o > k).Min();
evaluation.CurrentApprovalLevelOrder = nextOrder;
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)",
ct);
}
}
// ===== V2 schema (Mig 22-24) — iterate ApprovalWorkflowSteps + Levels =====
// Mig 31 (S23 t1 Plan K) — `skipToFinal` 8th param: F2 Approver scope ChoDuyet.
// Admin opt-in flag per slot tại matchingLevel.AllowApproverSkipToFinal.