[CLAUDE] PE: V2-aware Inbox/List + 2 dropdown filter quy trình + trạng thái
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m12s

User báo: "Phiếu chưa thấy lên trong danh sách duyệt — chắc do chưa ăn
vào flow. Tách thành 2 cái dropdown là list quy trình duyệt và list
trạng thái. Debug trước, phân quyền rút gọn lại sau."

BE — V2-aware permission + filter (Application/PurchaseEvaluations/
PurchaseEvaluationFeatures.cs):
- ListPurchaseEvaluationsQuery +ApprovalWorkflowId? Guid? param +
  IDOR loose: phiếu pin V2 → mọi authenticated user thấy được (UAT)
- GetMyPurchaseEvaluationInbox V2-aware: ResolveV2InboxIdsAsync helper
  precompute Set<Guid> phiếu Phase=ChoDuyet pin V2 + actor ∈ Cấp hiện
  tại approvers (CurrentWorkflowStepIndex + CurrentApprovalLevelOrder
  match Step.Order + Level.Order). Inbox where = eligiblePhases.Contains
  || v2InboxIds.Contains. eligiblePhases admin +ChoDuyet.
- GetById Detail loose: V2 pin → cho non-Drafter xem (skip
  eligiblePhases check).

API Controller:
- PurchaseEvaluationsController.List +approvalWorkflowId query param

FE — 2 dropdown filter (cả 2 app mirror):
- PurchaseEvaluationsListPage: +URL param `awId` filter quy trình
- useQuery `approval-workflows-v2-filter` load list V2 active+history
  theo applicableType=typeFilter (chỉ enabled khi có type)
- Render Select riêng "Tất cả quy trình duyệt" (chỉ show !pendingMe vì
  Inbox dùng API endpoint khác) + Select "Tất cả trạng thái" giữ
- Display option: "QT-DN-V2-001 v01 — Tên quy trình"

Verify: BE build 0 error · 2 FE builds OK.

Test luồng eoffice:
1. Drafter trình phiếu V2 → Phase=ChoDuyet
2. Login NV X (approver Cấp 1) vào "Duyệt NCC > Duyệt"
   (?pendingMe=1) → phiếu hiện trong list
3. Login NV Y (không phải approver) → list rỗng (đúng spec)
4. Vào "Duyệt NCC > Danh sách" (không pendingMe) → 2 dropdown:
   - Quy trình duyệt: filter theo workflow specific
   - Trạng thái: filter theo Phase
This commit is contained in:
pqhuy1987
2026-05-08 15:18:22 +07:00
parent d814429cee
commit 9e63e2da10
4 changed files with 120 additions and 9 deletions

View File

@ -254,7 +254,8 @@ public class TransitionPurchaseEvaluationCommandHandler(
public record ListPurchaseEvaluationsQuery(
PurchaseEvaluationType? Type = null,
PurchaseEvaluationPhase? Phase = null,
Guid? ProjectId = null) : PagedRequest, IRequest<PagedResult<PurchaseEvaluationListItemDto>>;
Guid? ProjectId = null,
Guid? ApprovalWorkflowId = null) : PagedRequest, IRequest<PagedResult<PurchaseEvaluationListItemDto>>;
public class ListPurchaseEvaluationsQueryHandler(
IApplicationDbContext db,
@ -269,17 +270,24 @@ public class ListPurchaseEvaluationsQueryHandler(
from s in sj.DefaultIfEmpty()
select new { e, p, s };
// IDOR: non-admin chỉ thấy phiếu mình là Drafter hoặc role eligible phase
// IDOR: non-admin chỉ thấy phiếu mình là Drafter hoặc role eligible phase.
// [Session 17 UAT loose] Phiếu pin V2 (ApprovalWorkflowId != null) →
// tạm cho mọi authenticated user thấy để UAT (sẽ thắt chặt sau khi
// hoàn thiện logic permission V2-aware).
if (!currentUser.Roles.Contains(AppRoles.Admin))
{
var userId = currentUser.UserId;
var eligiblePhases = GetEligiblePhases(currentUser.Roles);
q = q.Where(x => x.e.DrafterUserId == userId || eligiblePhases.Contains(x.e.Phase));
q = q.Where(x =>
x.e.DrafterUserId == userId
|| eligiblePhases.Contains(x.e.Phase)
|| x.e.ApprovalWorkflowId != null); // V2 loose UAT
}
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
if (request.Phase is not null) q = q.Where(x => x.e.Phase == request.Phase);
if (request.ProjectId is not null) q = q.Where(x => x.e.ProjectId == request.ProjectId);
if (request.ApprovalWorkflowId is not null) q = q.Where(x => x.e.ApprovalWorkflowId == request.ApprovalWorkflowId);
if (!string.IsNullOrWhiteSpace(request.Search))
{
@ -338,6 +346,7 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
if (!currentUser.IsAuthenticated) throw new UnauthorizedException();
var userRoles = currentUser.Roles;
var userId = currentUser.UserId;
var isAdmin = userRoles.Contains(AppRoles.Admin);
var eligiblePhases = isAdmin
? [
@ -347,16 +356,21 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
PurchaseEvaluationPhase.ChoCCM,
PurchaseEvaluationPhase.ChoCEODuyetPA,
PurchaseEvaluationPhase.ChoCEODuyetNCC,
PurchaseEvaluationPhase.ChoDuyet,
]
: ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(userRoles);
if (eligiblePhases.Count == 0) return [];
// V2-aware (Mig 22-24): tìm phiếu pin V2 + Phase=ChoDuyet + actor là
// approver Cấp hiện tại (CurrentWorkflowStepIndex + CurrentApprovalLevelOrder).
var v2InboxIds = isAdmin
? new HashSet<Guid>()
: await ResolveV2InboxIdsAsync(userId, ct);
var q = from e in db.PurchaseEvaluations.AsNoTracking()
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
from s in sj.DefaultIfEmpty()
where eligiblePhases.Contains(e.Phase)
where eligiblePhases.Contains(e.Phase) || v2InboxIds.Contains(e.Id)
select new { e, p, s };
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
@ -371,6 +385,45 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
.Take(100)
.ToListAsync(ct);
}
// Helper: precompute V2 phiếu IDs nơi actor là approver Cấp hiện tại.
// In-memory join vì Step.Order vs Index 0-based không thẳng EF được.
private async Task<HashSet<Guid>> ResolveV2InboxIdsAsync(Guid? userId, CancellationToken ct)
{
if (userId is null) return new HashSet<Guid>();
var candidates = await (
from e in db.PurchaseEvaluations.AsNoTracking()
where e.Phase == PurchaseEvaluationPhase.ChoDuyet
&& e.ApprovalWorkflowId != null
&& e.CurrentWorkflowStepIndex != null
&& e.CurrentApprovalLevelOrder != null
select new { e.Id, e.ApprovalWorkflowId, e.CurrentWorkflowStepIndex, e.CurrentApprovalLevelOrder }
).ToListAsync(ct);
if (candidates.Count == 0) return new HashSet<Guid>();
var wfIds = candidates.Select(c => c.ApprovalWorkflowId!.Value).Distinct().ToList();
var workflows = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => wfIds.Contains(w.Id))
.Include(w => w.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.ToDictionaryAsync(w => w.Id, ct);
var result = new HashSet<Guid>();
foreach (var c in candidates)
{
if (!workflows.TryGetValue(c.ApprovalWorkflowId!.Value, out var wf)) continue;
var steps = wf.Steps.OrderBy(s => s.Order).ToList();
var idx = c.CurrentWorkflowStepIndex!.Value;
if (idx < 0 || idx >= steps.Count) continue;
var step = steps[idx];
var match = step.Levels.Any(l =>
l.Order == c.CurrentApprovalLevelOrder!.Value
&& l.ApproverUserId == userId.Value);
if (match) result.Add(c.Id);
}
return result;
}
}
// ========== GET detail bundle ==========
@ -399,7 +452,10 @@ public class GetPurchaseEvaluationQueryHandler(
{
var isDrafter = e.DrafterUserId == currentUser.UserId;
var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles);
if (!isDrafter && !eligiblePhases.Contains(e.Phase))
// [Session 17 UAT loose] Phiếu pin V2 → cho mọi authenticated user
// xem được (sẽ thắt chặt sau khi user UAT confirm flow).
var isPinnedV2 = e.ApprovalWorkflowId is not null;
if (!isDrafter && !eligiblePhases.Contains(e.Phase) && !isPinnedV2)
throw new ForbiddenException("Bạn không có quyền xem phiếu này.");
}