diff --git a/fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx b/fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx index f43e0e6..1538fde 100644 --- a/fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx +++ b/fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx @@ -15,10 +15,11 @@ // Mirror layout admin `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` // line 853-949 cho 7 checkbox label (read-only — drop input onChange). import { useQuery } from '@tanstack/react-query' -import { useSearchParams } from 'react-router-dom' -import { Network, CheckCircle2, Pin } from 'lucide-react' +import { Link, useLocation, useSearchParams } from 'react-router-dom' +import { Network, CheckCircle2, Pin, Layers } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { api } from '@/lib/api' +import { khkkGroupMenuLabel, khkkGroupWorkflowCode } from '@/types/khkk' import type { AwAdminOverviewDto, AwDefinitionDto, AwLevelDto } from '@/types/approvalWorkflowV2' // Mig 23 ApplicableType enum mirror (BE Domain/ApprovalWorkflowsV2) @@ -50,6 +51,9 @@ const LEVEL_PALETTE = [ export function WorkflowMatrixViewPage() { const [searchParams] = useSearchParams() + // Trang này gắn ở 3 route (/purchase-evaluations|/contracts|/khkk + /workflow-matrix) + // ⇒ link "xem tất cả" phải bám đường dẫn ĐANG đứng, không hardcode một cái. + const { pathname } = useLocation() const rawType = Number(searchParams.get('type')) // [S159-đợt5] +3 = Contract (ApplicableType.Contract — HĐ dùng CHUNG 1 bộ workflow V2 // cho mọi ContractType ⇒ 7 leaf 'Luồng duyệt' của 7 nhóm HĐ đều trỏ về matrix này). @@ -68,9 +72,32 @@ export function WorkflowMatrixViewPage() { // Em chỉ render type được chọn — BE đã filter applicableType. const summary = data?.types.find(t => t.applicableType === typeInt) - const workflows: AwDefinitionDto[] = summary?.history ?? [] + const allWorkflows: AwDefinitionDto[] = summary?.history ?? [] const typeLabel = summary?.applicableTypeLabel ?? TYPE_LABEL[typeInt] ?? 'Quy trình duyệt' + // ── [K4c S166] Lọc theo NHÓM DUYỆT KHKK ──────────────────────────────────── + // 8 leaf "Luồng duyệt" của 8 nhóm (K4b) đều trỏ về CÙNG trang này, chỉ khác + // `?group=n`. Không lọc thì cả 8 leaf hiện y hệt nhau — mục menu nói dối tên nó. + // 🔴 Chỉ áp cho type 10 (Kế hoạch ký kết): `group` là khái niệm của riêng KHKK. + // Type khác, hoặc `group` rác/thiếu ⇒ BỎ QUA hoàn toàn, hành vi cũ giữ nguyên + // (thà hiện đủ còn hơn lọc theo một con số vô nghĩa rồi ra trang trắng). + const rawGroup = searchParams.get('group') + const khkkGroup = + typeInt === 10 && rawGroup !== null && /^[1-8]$/.test(rawGroup) ? Number(rawGroup) : null + + // 🔴 LỌC CLIENT-SIDE, CÓ CHỦ ĐÍCH: query trên kia đã tải TRỌN bộ quy trình của + // type này rồi (`/approval-workflows-v2?applicableType=10`), nhóm chỉ là lát cắt + // của đúng bộ đó ⇒ KHÔNG thêm trục `group` vào `queryKey`. Thêm vào sẽ đẻ 8 lần + // gọi mạng cho cùng một payload, và 8 ô cache cùng nội dung. + // Khớp bằng `code` (`AwDefinitionDto.code`) chứ không bằng tên/thứ tự: BE seed + // `KHKK-N{n}` (`DbInitializer.cs:609`) và cũng chính `code` này là thứ BE dùng để + // gác phiếu-pin-đúng-nhóm (`ContractSigningPlanFeatures.cs:420` regex `^KHKK-N([1-8])$`). + // Một quy trình có thể có nhiều VERSION cùng `code` ⇒ lọc giữ đủ các version. + const workflows = + khkkGroup !== null + ? allWorkflows.filter(wf => wf.code === khkkGroupWorkflowCode(khkkGroup)) + : allWorkflows + return ( // [Plan AA hotfix S24 t1] Bro UAT request: dịch hết content sang trái. // px-6 → px-2 (24px → 8px) cho title + table sát sidebar. @@ -80,9 +107,34 @@ export function WorkflowMatrixViewPage() { Luồng duyệt — {typeLabel} + {khkkGroup !== null && ( + + + Nhóm N{khkkGroup} + + )} } - description="Cấu hình do Admin quản trị. Có thắc mắc liên hệ Admin." + description={ + // Đang xem lát cắt của một nhóm thì phải nói RÕ nhóm nào — nếu không, + // 8 leaf menu mở ra 8 trang trông hệt nhau và người dùng không biết + // mình đang đứng ở đâu. + khkkGroup !== null + ? `${khkkGroupMenuLabel(khkkGroup)} — cấu hình do Admin quản trị. Có thắc mắc liên hệ Admin.` + : 'Cấu hình do Admin quản trị. Có thắc mắc liên hệ Admin.' + } + actions={ + khkkGroup !== null ? ( + // Giữ nguyên đường dẫn hiện tại (trang này gắn ở 3 route), chỉ bỏ + // `group` ⇒ về lại chế độ xem cả 8 nhóm. + + Xem tất cả nhóm + + ) : undefined + } /> {isLoading && ( @@ -99,7 +151,18 @@ export function WorkflowMatrixViewPage() { {!isLoading && !isError && workflows.length === 0 && (
- Chưa có quy trình nào được Admin ghim cho loại phiếu này. Liên hệ Admin. + {/* 🔴 Rỗng-vì-lọc ⟂ rỗng-vì-chưa-có-gì là HAI chuyện khác nhau. Dùng + chung một câu thì người dùng nhóm N5 tưởng cả module chưa cấu hình, + trong khi 7 nhóm kia đang chạy bình thường. */} + {khkkGroup !== null ? ( + <> + Chưa có quy trình nào được Admin ghim cho nhóm{' '} + N{khkkGroup} + {allWorkflows.length > 0 && <> (loại phiếu này có {allWorkflows.length} quy trình ở nhóm khác)}. Liên hệ Admin. + + ) : ( + <>Chưa có quy trình nào được Admin ghim cho loại phiếu này. Liên hệ Admin. + )}
)} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanCrudTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanCrudTests.cs index 4b2185e..bde7de9 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanCrudTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanCrudTests.cs @@ -2,6 +2,7 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor +using SolutionErp.Application.Common.Models; // PagedResult — kết quả màn "Đã xoá" using SolutionErp.Application.ContractSigningPlans; using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ContractSigningPlans; @@ -202,14 +203,28 @@ public class ContractSigningPlanCrudTests // ---- Chạy lệnh qua handler THẬT, mỗi lệnh 1 context mới ---- + /// + /// [K4b S166] null ⇒ handler tự lấy nhóm mặc định 1 (`ContractSigningPlanScope.DefaultApprovalGroup`). + /// Truyền số để lập phiếu ở nhóm duyệt khác — dùng cho test lọc nhóm màn "Đã xoá". + /// private static async Task CreateAsync( - KhkkFixture f, Guid peId, Guid workflowId, string? ghiChu = null) + KhkkFixture f, Guid peId, Guid workflowId, string? ghiChu = null, int? approvalGroup = null) { await using var db = f.NewDb(); var handler = new CreateContractSigningPlanCommandHandler( db, f.Actor, new ContractSigningPlanCodeGenerator(db, f.Clock)); return await handler.Handle( - new CreateContractSigningPlanCommand(peId, workflowId, ghiChu), CancellationToken.None); + new CreateContractSigningPlanCommand(peId, workflowId, ghiChu, approvalGroup), + CancellationToken.None); + } + + private static Task> ListDeletedAsync( + KhkkFixture f, ICurrentUser actor, int? approvalGroup) + { + var db = f.NewDb(actor); + var handler = new ListDeletedContractSigningPlansQueryHandler(db, actor); + return handler.Handle( + new ListDeletedContractSigningPlansQuery(approvalGroup), CancellationToken.None); } private static async Task DeleteAsync(KhkkFixture f, Guid planId, string? reason, TestCurrentUser actor) @@ -560,4 +575,93 @@ public class ContractSigningPlanCrudTests .Single(p => p.Id == created.Id).DeletedBy .Should().Be(admin.UserId, "người xoá thật được ghi lại là Admin, không phải chủ phiếu"); } + + // =================================================================== + // (9) [gate-K4b MAJOR-A @S166] Màn "Đã xoá" phải LỌC NHÓM ở SERVER + // (`ListDeletedContractSigningPlansQueryHandler` :896-897, vá @K4b). + // + // Vì sao đáng 1 test riêng: sidebar KHKK có 8 leaf "Đã xóa" per-nhóm, mỗi leaf trỏ + // `?group=n`. Trước bản vá, endpoint KHÔNG đọc param ⇒ 8 URL khác nhau trả CÙNG một + // tập mọi-nhóm — hỏng IM LẶNG (màn vẫn có dữ liệu, chỉ là dữ liệu của nhóm khác). + // + // Đo 4 điều, và 3 trong 4 là để phép đo KHÔNG rỗng: + // (a) `Total` chứ không chỉ `Items` — `Total` là DẤU VÂN TAY của lọc-server: + // `CountAsync` chạy SAU mọi `Where`. Ai "lọc" bằng cách lấy trang to rồi lọc + // trong bộ nhớ sẽ cho `Items` Y HỆT nhưng `Total` = cả thế giới ⇒ số trang nói dối. + // (b) group=1 ⇒ phiếu nhóm 1 · group=3 ⇒ phiếu nhóm 3 (KHÔNG phải "luôn trả cái đầu"); + // (c) group=7 (không có phiếu) ⇒ RỖNG — lọc không khớp phải ra rỗng, không phải + // "bỏ qua lọc" (đây chính là hình dạng của lỗi trước bản vá); + // (d) đo bằng CẢ actor thường LẪN Admin: vế lọc nhóm nằm NGOÀI khối IDOR + // (`:884-893`), nên chỉ đo 1 vai thì đẩy vế lọc vào trong `if (!admin)` vẫn xanh. + // + // 🔴 CHỨNG CÓ RĂNG (không cần chạm production): ở thế giới KHÔNG có vế + // `.Where(p => p.ApprovalGroup == request.ApprovalGroup)`, tham số bị bỏ qua hoàn toàn + // ⇒ `Handle(group:1)` chạy Y HỆT `Handle(group:null)`. Mà `Handle(null)` được đo NGAY + // TRONG test này = 2 phiếu ⇒ vế `group:1` phải là 1 sẽ ĐỎ. Nói cách khác cặp + // (null ⇒ 2) ∧ (1 ⇒ 1) chính là 2 thế giới đặt cạnh nhau, đo trên MÃ THẬT. + // =================================================================== + [Fact] + public async Task ListDeletedContractSigningPlans_FiltersByApprovalGroup_ServerSide() + { + using var f = new KhkkFixture(); + var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]); + + // Cùng 1 phiếu PE nhưng 2 NHÓM DUYỆT khác nhau — rào "1 PE = 1 kế hoạch sống" khoá + // theo cặp (PeId, ApprovalGroup) (`:401-404`) nên đây là đường hợp lệ. + // Workflow seed có Code "QT-KHKK-V2" (ngoài họ `KHKK-N{n}`) ⇒ rào (v) `:420-424` cho qua. + var planG1 = await CreateAsync(f, seeded.PeId, seeded.WorkflowId, approvalGroup: 1); + var planG3 = await CreateAsync(f, seeded.PeId, seeded.WorkflowId, approvalGroup: 3); + + await DeleteAsync(f, planG1.Id, "xoá phiếu nhóm 1", f.Actor); + await DeleteAsync(f, planG3.Id, "xoá phiếu nhóm 3", f.Actor); + + // ── Sanity TRƯỚC phép đo chính: 2 row THẬT SỰ đã xoá mềm và THẬT SỰ khác nhóm. + // Thiếu 2 vế này thì "lọc ra 1 phiếu" có thể đúng vì lý do hoàn toàn khác. + await using (var db = f.NewDb()) + { + var raws = db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking() + .Where(p => p.PurchaseEvaluationId == seeded.PeId) + .Select(p => new { p.Id, p.ApprovalGroup, p.IsDeleted }).ToList(); + raws.Should().HaveCount(2); + raws.Should().OnlyContain(r => r.IsDeleted, "cả 2 phải là xoá MỀM, nếu không màn Đã xoá rỗng"); + raws.Single(r => r.Id == planG1.Id).ApprovalGroup.Should().Be(1); + raws.Single(r => r.Id == planG3.Id).ApprovalGroup.Should().Be(3, "2 phiếu phải KHÁC nhóm — cùng nhóm thì phép lọc vô nghĩa"); + } + + // ── (1) KHÔNG truyền nhóm ⇒ thấy CẢ HAI. Đây cũng chính là ảnh của thế-giới-hỏng. + var all = await ListDeletedAsync(f, f.Actor, approvalGroup: null); + all.Items.Should().HaveCount(2); + all.Total.Should().Be(2, "không lọc thì tổng là cả 2 phiếu đã xoá"); + + // ── (2) group=1 ⇒ ĐÚNG 1 phiếu nhóm 1. + var g1 = await ListDeletedAsync(f, f.Actor, approvalGroup: 1); + g1.Items.Should().HaveCount(1); + g1.Total.Should().Be(1, + "Total đếm SAU vế Where — nếu vẫn là 2 thì handler đang trả trang đã lọc trên tập chưa lọc (số trang nói dối)"); + g1.Items[0].Id.Should().Be(planG1.Id); + g1.Items[0].ApprovalGroup.Should().Be(1); + + // ── (3) group=3 ⇒ ĐÚNG phiếu nhóm 3 (chứng không phải "luôn trả phiếu đầu tiên"). + var g3 = await ListDeletedAsync(f, f.Actor, approvalGroup: 3); + g3.Items.Should().HaveCount(1); + g3.Total.Should().Be(1); + g3.Items[0].Id.Should().Be(planG3.Id); + g3.Items[0].ApprovalGroup.Should().Be(3); + + // ── (4) group=7 (không phiếu nào) ⇒ RỖNG, không phải "bỏ qua lọc". + var g7 = await ListDeletedAsync(f, f.Actor, approvalGroup: 7); + g7.Items.Should().BeEmpty("nhóm không có phiếu nào thì màn Đã xoá của nhóm đó phải trống"); + g7.Total.Should().Be(0); + + // ── (5) NHÁNH ADMIN: lọc nhóm nằm NGOÀI khối IDOR nên phải đúng ở cả 2 vai. + var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", "admin@test.local", AppRoles.Admin); + var adminAll = await ListDeletedAsync(f, admin, approvalGroup: null); + adminAll.Total.Should().Be(2, "Admin bỏ qua IDOR nên thấy cả 2 phiếu đã xoá"); + + var adminG3 = await ListDeletedAsync(f, admin, approvalGroup: 3); + adminG3.Items.Should().HaveCount(1); + adminG3.Total.Should().Be(1, + "đẩy vế lọc nhóm vào trong `if (!admin)` sẽ làm vế này ĐỎ trong khi 4 vế trên vẫn xanh"); + adminG3.Items[0].Id.Should().Be(planG3.Id); + } }