[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
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:
@ -33,10 +33,25 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
const pendingMe = sp.get('pendingMe') === '1'
|
const pendingMe = sp.get('pendingMe') === '1'
|
||||||
const search = sp.get('q') ?? ''
|
const search = sp.get('q') ?? ''
|
||||||
const phase = sp.get('phase') ?? ''
|
const phase = sp.get('phase') ?? ''
|
||||||
|
const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình
|
||||||
const selectedId = sp.get('id')
|
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({
|
const list = useQuery({
|
||||||
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
|
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId }],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (pendingMe) {
|
if (pendingMe) {
|
||||||
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
|
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
|
||||||
@ -50,6 +65,7 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
type: typeFilter ?? undefined,
|
type: typeFilter ?? undefined,
|
||||||
phase: phase || undefined,
|
phase: phase || undefined,
|
||||||
|
approvalWorkflowId: approvalWorkflowId || undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
@ -119,6 +135,17 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
className="pl-8"
|
className="pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)}>
|
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
|
||||||
<option value="">Tất cả trạng thái</option>
|
<option value="">Tất cả trạng thái</option>
|
||||||
{Object.values(PeDisplayStatus).map(s => {
|
{Object.values(PeDisplayStatus).map(s => {
|
||||||
|
|||||||
@ -33,10 +33,25 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
const pendingMe = sp.get('pendingMe') === '1'
|
const pendingMe = sp.get('pendingMe') === '1'
|
||||||
const search = sp.get('q') ?? ''
|
const search = sp.get('q') ?? ''
|
||||||
const phase = sp.get('phase') ?? ''
|
const phase = sp.get('phase') ?? ''
|
||||||
|
const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình
|
||||||
const selectedId = sp.get('id')
|
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({
|
const list = useQuery({
|
||||||
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
|
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId }],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (pendingMe) {
|
if (pendingMe) {
|
||||||
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
|
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
|
||||||
@ -50,6 +65,7 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
type: typeFilter ?? undefined,
|
type: typeFilter ?? undefined,
|
||||||
phase: phase || undefined,
|
phase: phase || undefined,
|
||||||
|
approvalWorkflowId: approvalWorkflowId || undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
@ -119,6 +135,17 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
className="pl-8"
|
className="pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)}>
|
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
|
||||||
<option value="">Tất cả trạng thái</option>
|
<option value="">Tất cả trạng thái</option>
|
||||||
{Object.values(PeDisplayStatus).map(s => {
|
{Object.values(PeDisplayStatus).map(s => {
|
||||||
|
|||||||
@ -21,8 +21,9 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
|||||||
[FromQuery] PurchaseEvaluationType? type = null,
|
[FromQuery] PurchaseEvaluationType? type = null,
|
||||||
[FromQuery] PurchaseEvaluationPhase? phase = null,
|
[FromQuery] PurchaseEvaluationPhase? phase = null,
|
||||||
[FromQuery] Guid? projectId = null,
|
[FromQuery] Guid? projectId = null,
|
||||||
|
[FromQuery] Guid? approvalWorkflowId = null,
|
||||||
CancellationToken ct = default)
|
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));
|
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
|
||||||
|
|
||||||
[HttpGet("inbox")]
|
[HttpGet("inbox")]
|
||||||
|
|||||||
@ -254,7 +254,8 @@ public class TransitionPurchaseEvaluationCommandHandler(
|
|||||||
public record ListPurchaseEvaluationsQuery(
|
public record ListPurchaseEvaluationsQuery(
|
||||||
PurchaseEvaluationType? Type = null,
|
PurchaseEvaluationType? Type = null,
|
||||||
PurchaseEvaluationPhase? Phase = null,
|
PurchaseEvaluationPhase? Phase = null,
|
||||||
Guid? ProjectId = null) : PagedRequest, IRequest<PagedResult<PurchaseEvaluationListItemDto>>;
|
Guid? ProjectId = null,
|
||||||
|
Guid? ApprovalWorkflowId = null) : PagedRequest, IRequest<PagedResult<PurchaseEvaluationListItemDto>>;
|
||||||
|
|
||||||
public class ListPurchaseEvaluationsQueryHandler(
|
public class ListPurchaseEvaluationsQueryHandler(
|
||||||
IApplicationDbContext db,
|
IApplicationDbContext db,
|
||||||
@ -269,17 +270,24 @@ public class ListPurchaseEvaluationsQueryHandler(
|
|||||||
from s in sj.DefaultIfEmpty()
|
from s in sj.DefaultIfEmpty()
|
||||||
select new { e, p, s };
|
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))
|
if (!currentUser.Roles.Contains(AppRoles.Admin))
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId;
|
var userId = currentUser.UserId;
|
||||||
var eligiblePhases = GetEligiblePhases(currentUser.Roles);
|
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.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.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.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))
|
if (!string.IsNullOrWhiteSpace(request.Search))
|
||||||
{
|
{
|
||||||
@ -338,6 +346,7 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
|
|||||||
if (!currentUser.IsAuthenticated) throw new UnauthorizedException();
|
if (!currentUser.IsAuthenticated) throw new UnauthorizedException();
|
||||||
|
|
||||||
var userRoles = currentUser.Roles;
|
var userRoles = currentUser.Roles;
|
||||||
|
var userId = currentUser.UserId;
|
||||||
var isAdmin = userRoles.Contains(AppRoles.Admin);
|
var isAdmin = userRoles.Contains(AppRoles.Admin);
|
||||||
var eligiblePhases = isAdmin
|
var eligiblePhases = isAdmin
|
||||||
? [
|
? [
|
||||||
@ -347,16 +356,21 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
|
|||||||
PurchaseEvaluationPhase.ChoCCM,
|
PurchaseEvaluationPhase.ChoCCM,
|
||||||
PurchaseEvaluationPhase.ChoCEODuyetPA,
|
PurchaseEvaluationPhase.ChoCEODuyetPA,
|
||||||
PurchaseEvaluationPhase.ChoCEODuyetNCC,
|
PurchaseEvaluationPhase.ChoCEODuyetNCC,
|
||||||
|
PurchaseEvaluationPhase.ChoDuyet,
|
||||||
]
|
]
|
||||||
: ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(userRoles);
|
: 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()
|
var q = from e in db.PurchaseEvaluations.AsNoTracking()
|
||||||
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
|
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
|
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
|
||||||
from s in sj.DefaultIfEmpty()
|
from s in sj.DefaultIfEmpty()
|
||||||
where eligiblePhases.Contains(e.Phase)
|
where eligiblePhases.Contains(e.Phase) || v2InboxIds.Contains(e.Id)
|
||||||
select new { e, p, s };
|
select new { e, p, s };
|
||||||
|
|
||||||
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
|
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
|
||||||
@ -371,6 +385,45 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
|
|||||||
.Take(100)
|
.Take(100)
|
||||||
.ToListAsync(ct);
|
.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 ==========
|
// ========== GET detail bundle ==========
|
||||||
@ -399,7 +452,10 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
{
|
{
|
||||||
var isDrafter = e.DrafterUserId == currentUser.UserId;
|
var isDrafter = e.DrafterUserId == currentUser.UserId;
|
||||||
var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles);
|
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.");
|
throw new ForbiddenException("Bạn không có quyền xem phiếu này.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user