[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
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:
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user