[CLAUDE] FE-User: K4c SP-002 WfView loc theo nhom + test MAJOR-A /deleted lọc server (chot cum-2)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m17s

Gate PASS 0-blocker (sub-reviewer-gate-k4c.md): WorkflowMatrixViewPage doc ?group= (guard
/^[1-8]$/ chan ca "03") -> loc wf.code === KHKK-N{n} client-side co-chu-dich (payload da
tai tron theo type — khong ro cache, BE khong nhan truc group) + badge nhom + link xem-tat-ca
+ rong-vi-loc tach cau; type≠10 = 0 doi hanh vi (PE matrix regression chung-la-khong).
fe-admin 0-diff (lech co-y 2026-07-27). +1 Fact ListDeleted_FiltersByApprovalGroup (5 ve +
nhanh-Admin-cung-loc, assert Total, RED->GREEN 2 chieu do that — gate-K4b MAJOR-A dong).

Suite 622/0 (45D+577I) do tuoi ca gate lan lead. Ghi mo: 1 test cu
DeleteContractSigningPlan_SoftDeletes flaky 1/12 luot quan sat (gate truy 3 ve khong ra
duong diff — moc cho CI neu tai). No con: gate-K4c 3-MINOR (helper-dispose · link-mat-query
· highlight) + K5-MINOR-3 chu SAP + MAJOR-C da dong o wave nay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-01 11:08:09 +07:00
parent fa999d8fd7
commit e202393256
2 changed files with 174 additions and 7 deletions

View File

@ -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() {
<span className="flex items-center gap-2">
<Network className="h-5 w-5 text-slate-500" />
Luồng duyệt {typeLabel}
{khkkGroup !== null && (
<span className="inline-flex items-center gap-1 rounded-md border border-violet-300 bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-800">
<Layers className="h-3.5 w-3.5" />
Nhóm N{khkkGroup}
</span>
)}
</span>
}
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.
<Link
to={`${pathname}?type=${typeInt}`}
className="rounded-md border border-slate-300 px-2.5 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-50"
>
Xem tất cả nhóm
</Link>
) : undefined
}
/>
{isLoading && (
@ -99,7 +151,18 @@ export function WorkflowMatrixViewPage() {
{!isLoading && !isError && workflows.length === 0 && (
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50/40 p-8 text-center text-sm text-slate-500">
Chưa 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 quy trình nào đưc Admin ghim cho nhóm{' '}
<span className="font-medium text-slate-700">N{khkkGroup}</span>
{allWorkflows.length > 0 && <> (loại phiếu này {allWorkflows.length} quy trình nhóm khác)</>}. Liên hệ Admin.
</>
) : (
<>Chưa quy trình nào đưc Admin ghim cho loại phiếu này. Liên hệ Admin.</>
)}
</div>
)}

View File

@ -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<T> — 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 ----
/// <param name="approvalGroup">
/// [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á".
/// </param>
private static async Task<CreateContractSigningPlanResult> 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<PagedResult<ContractSigningPlanListItemDto>> 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);
}
}