[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

@ -33,10 +33,25 @@ export function PurchaseEvaluationsListPage() {
const pendingMe = sp.get('pendingMe') === '1'
const search = sp.get('q') ?? ''
const phase = sp.get('phase') ?? ''
const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình
const selectedId = sp.get('id')
// Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen)
const approvalWorkflows = useQuery({
queryKey: ['approval-workflows-v2-filter', typeFilter],
queryFn: async () => {
if (!typeFilter) return []
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean }[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: typeFilter } },
)
return res.data.types.find(t => t.applicableType === typeFilter)?.history ?? []
},
enabled: !!typeFilter,
})
const list = useQuery({
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId }],
queryFn: async () => {
if (pendingMe) {
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
@ -50,6 +65,7 @@ export function PurchaseEvaluationsListPage() {
search: search || undefined,
type: typeFilter ?? undefined,
phase: phase || undefined,
approvalWorkflowId: approvalWorkflowId || undefined,
},
})
return res.data
@ -119,6 +135,17 @@ export function PurchaseEvaluationsListPage() {
className="pl-8"
/>
</div>
{/* Mig 23 — 2 dropdown tách: Quy trình duyệt + Trạng thái */}
{!pendingMe && (
<Select value={approvalWorkflowId} onChange={e => setParam('awId', e.target.value)}>
<option value="">Tất cả quy trình duyệt</option>
{approvalWorkflows.data?.map(w => (
<option key={w.id} value={w.id}>
{w.code} v{String(w.version).padStart(2, '0')} {w.name}
</option>
))}
</Select>
)}
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
<option value="">Tất cả trạng thái</option>
{Object.values(PeDisplayStatus).map(s => {

View File

@ -33,10 +33,25 @@ export function PurchaseEvaluationsListPage() {
const pendingMe = sp.get('pendingMe') === '1'
const search = sp.get('q') ?? ''
const phase = sp.get('phase') ?? ''
const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình
const selectedId = sp.get('id')
// Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen)
const approvalWorkflows = useQuery({
queryKey: ['approval-workflows-v2-filter', typeFilter],
queryFn: async () => {
if (!typeFilter) return []
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean }[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: typeFilter } },
)
return res.data.types.find(t => t.applicableType === typeFilter)?.history ?? []
},
enabled: !!typeFilter,
})
const list = useQuery({
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId }],
queryFn: async () => {
if (pendingMe) {
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
@ -50,6 +65,7 @@ export function PurchaseEvaluationsListPage() {
search: search || undefined,
type: typeFilter ?? undefined,
phase: phase || undefined,
approvalWorkflowId: approvalWorkflowId || undefined,
},
})
return res.data
@ -119,6 +135,17 @@ export function PurchaseEvaluationsListPage() {
className="pl-8"
/>
</div>
{/* Mig 23 — 2 dropdown tách: Quy trình duyệt + Trạng thái */}
{!pendingMe && (
<Select value={approvalWorkflowId} onChange={e => setParam('awId', e.target.value)}>
<option value="">Tất cả quy trình duyệt</option>
{approvalWorkflows.data?.map(w => (
<option key={w.id} value={w.id}>
{w.code} v{String(w.version).padStart(2, '0')} {w.name}
</option>
))}
</Select>
)}
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
<option value="">Tất cả trạng thái</option>
{Object.values(PeDisplayStatus).map(s => {

View File

@ -21,8 +21,9 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
[FromQuery] PurchaseEvaluationType? type = null,
[FromQuery] PurchaseEvaluationPhase? phase = null,
[FromQuery] Guid? projectId = null,
[FromQuery] Guid? approvalWorkflowId = null,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListPurchaseEvaluationsQuery(type, phase, projectId)
=> Ok(await mediator.Send(new ListPurchaseEvaluationsQuery(type, phase, projectId, approvalWorkflowId)
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
[HttpGet("inbox")]

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.");
}