[CLAUDE] PurchaseEvaluation: PE gắn Hạng mục công việc (Mig 49) + mở quyền Pe all-role + menu Cá nhân + khóa 14 demo user
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m24s

Sếp chốt deadline 15:00 (Zalo 11:02-11:17): flow tạo phiếu chọn quy trình → dự án → HẠNG MỤC → NCC/TP; phiếu dạng «Dự án – Hạng mục»; all-user thấy Duyệt NCC + master config; clear data cũ.

- Mig 49 AddWorkItemToPurchaseEvaluation: PE.WorkItemId Guid? loose-Guid + index (KHÔNG FK vật lý — convention PE, database-agent design). Validator NotEmpty (create) + FK-guard AnyAsync(IsActive) → Conflict + UpdateDraft NULL-SAFE (client không gửi → giữ, chống null-hóa bug-class S42). 3 projection ListItemDto LEFT-join WorkItems.
- FE ×2 app: PeWorkspaceCreateView select «c. Hạng mục *» + PeHeaderForm (load existing + PUT gửi lại, SHA256 IDENTICAL) + PeDetailTabs (header «Dự án – Hạng mục» + FormRow + inline khóa) + types. Route reuse /catalogs/work-items.
- Perm: SeedAllRolesReviewReadPermissionsAsync extend Pe_* 11 key (factory — Pe leaf không nằm All) CanRead+CanCreate upgrade-only mọi role; PeWf_*/AwV2 GIỮ Admin. HRM/Office/Master/Catalogs CanRead (S57). Master write-lock Admin,CatalogManager ×3 controller.
- Menu «Cá nhân» (Personal root 30, mirror Puro) + Chấm công re-parent + HrmConfig→Master + parentBackfill idempotent + admin bỏ ẩn Master (đảo S29).
- LockDemoSampleUsersAsync: khóa 14/16 sample (GIỮ nv.cao+nv.truong IT-pool + catalog.manager) — ungated idempotent, IsActive=0+Lockout+SecurityStamp rotate.
- Tests +12 PeWorkItemGuardTests (validator/FK-guard/null-safe) → 240 PASS. npm ×2 + BE 0W/0E.
- Excel (3) đối chiếu: 62/71/3 identical S55 — no data change.
- Gate: em main evidence-checklist (2 reviewer-spawn die-0-byte — resume-kill; backstop 12 guard-test + authz-key/role-string/Mig-49 evidence-lệnh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-11 12:13:26 +07:00
parent 17b23a418a
commit dd117b749c
26 changed files with 7461 additions and 29 deletions

View File

@ -139,11 +139,14 @@ public class ListApprovedPurchaseEvaluationsQueryHandler(IApplicationDbContext d
from u in uj.DefaultIfEmpty()
join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
join wi in db.WorkItems.AsNoTracking() on e.WorkItemId equals wi.Id into wij
from wi in wij.DefaultIfEmpty()
where e.Phase == PurchaseEvaluationPhase.DaDuyet && e.ContractId == null
orderby e.CreatedAt descending
select new PurchaseEvaluationListItemDto(
e.Id, e.MaPhieu, e.TenGoiThau, e.Type, e.Phase,
e.ProjectId, p.Name,
e.WorkItemId, wi != null ? wi.Name : null, wi != null ? wi.Code : null,
e.SelectedSupplierId, s != null ? s.Name : null,
e.ContractId, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.DrafterUserId, u != null ? u.FullName : null,

View File

@ -12,6 +12,11 @@ public record PurchaseEvaluationListItemDto(
PurchaseEvaluationPhase Phase,
Guid ProjectId,
string ProjectName,
// [Mig 49 S57bis] Hạng mục công việc — loose-Guid resolve LEFT join WorkItems
// (mirror ProjectName). Nullable cho 4 phiếu cũ chưa gắn hạng mục.
Guid? WorkItemId,
string? WorkItemName,
string? WorkItemCode,
Guid? SelectedSupplierId,
string? SelectedSupplierName,
Guid? ContractId,
@ -200,6 +205,10 @@ public record PurchaseEvaluationDetailBundleDto(
string? MoTa,
Guid ProjectId,
string ProjectName,
// [Mig 49 S57bis] Hạng mục công việc — loose-Guid resolve giống ProjectName.
Guid? WorkItemId,
string? WorkItemName,
string? WorkItemCode,
Guid? DepartmentId,
string? DepartmentName,
Guid? DrafterUserId,

View File

@ -7,7 +7,6 @@ using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.PurchaseEvaluations.Dtos;
using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Domain.ApprovalWorkflowsV2; // Plan E V2 strict scope query
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
@ -27,7 +26,8 @@ public record CreatePurchaseEvaluationCommand(
Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount,
Guid? ApprovalWorkflowId = null) : IRequest<Guid>; // [Mig 23] User chọn quy trình duyệt V2 lúc tạo
Guid? ApprovalWorkflowId = null, // [Mig 23] User chọn quy trình duyệt V2 lúc tạo
Guid? WorkItemId = null) : IRequest<Guid>; // [Mig 49 S57bis] Hạng mục công việc — flow create PHẢI chọn (validator NotEmpty)
public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<CreatePurchaseEvaluationCommand>
{
@ -36,6 +36,12 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<Create
RuleFor(x => x.Type).IsInEnum();
RuleFor(x => x.TenGoiThau).NotEmpty().MaximumLength(500);
RuleFor(x => x.ProjectId).NotEmpty();
// [Mig 49 S57bis] Sếp yêu cầu flow create PHẢI chọn hạng mục công việc.
// DB cột nullable chỉ để backward-compat 4 phiếu cũ — create mới bắt buộc.
// FK-exists check trong handler → ConflictException (validator sync, không
// async-db; mirror S43 CreateLeaveRequestHandler FK-invariant guard).
RuleFor(x => x.WorkItemId).NotEmpty()
.WithMessage("Phải chọn hạng mục công việc.");
RuleFor(x => x.DiaDiem).MaximumLength(500);
RuleFor(x => x.MoTa).MaximumLength(2000);
RuleFor(x => x.BudgetManualName).MaximumLength(200);
@ -54,6 +60,17 @@ public class CreatePurchaseEvaluationCommandHandler(
_ = await db.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, ct)
?? throw new NotFoundException("Project", request.ProjectId);
// [Mig 49 S57bis] FK-invariant guard hạng mục công việc (mirror S43
// CreateLeaveRequestHandler). Validator đã NotEmpty → ở đây WorkItemId
// chắc chắn có value; check tồn tại + đang hoạt động.
if (request.WorkItemId is Guid wiId)
{
var wiOk = await db.WorkItems.AsNoTracking()
.AnyAsync(w => w.Id == wiId && w.IsActive, ct);
if (!wiOk)
throw new ConflictException("Hạng mục công việc không tồn tại hoặc ngưng hoạt động.");
}
var activeWfId = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
.Where(w => w.EvaluationType == request.Type && w.IsActive)
.Select(w => (Guid?)w.Id)
@ -97,6 +114,7 @@ public class CreatePurchaseEvaluationCommandHandler(
Phase = PurchaseEvaluationPhase.DangSoanThao,
TenGoiThau = request.TenGoiThau,
ProjectId = request.ProjectId,
WorkItemId = request.WorkItemId, // Mig 49 S57bis
DepartmentId = request.DepartmentId,
DiaDiem = request.DiaDiem,
MoTa = request.MoTa,
@ -175,7 +193,8 @@ public record UpdatePurchaseEvaluationDraftCommand(
Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount,
Guid? ApprovalWorkflowId = null) : IRequest; // [Mig 23] cho User đổi quy trình khi sửa Nháp
Guid? ApprovalWorkflowId = null, // [Mig 23] cho User đổi quy trình khi sửa Nháp
Guid? WorkItemId = null) : IRequest; // [Mig 49 S57bis] cho User đổi hạng mục công việc khi sửa Nháp/Trả lại
public class UpdatePurchaseEvaluationDraftCommandHandler(
IApplicationDbContext db,
@ -218,6 +237,15 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
// [Mig 49 S57bis] FK-invariant guard hạng mục nếu đổi (mirror S43).
if (request.WorkItemId is Guid wiId && wiId != entity.WorkItemId)
{
var wiOk = await db.WorkItems.AsNoTracking()
.AnyAsync(w => w.Id == wiId && w.IsActive, ct);
if (!wiOk)
throw new ConflictException("Hạng mục công việc không tồn tại hoặc ngưng hoạt động.");
}
entity.TenGoiThau = request.TenGoiThau;
entity.DiaDiem = request.DiaDiem;
entity.MoTa = request.MoTa;
@ -226,6 +254,11 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
entity.BudgetManualName = request.BudgetManualName;
entity.BudgetManualAmount = request.BudgetManualAmount;
entity.ApprovalWorkflowId = request.ApprovalWorkflowId; // Mig 23 — User đổi quy trình
// Mig 49 S57bis — null-safe: CHỈ đổi hạng mục khi client gửi giá trị.
// Client cũ / PeDetailTabs inline-edit không gửi field này → GIỮ nguyên
// (tránh null-hóa mất hạng mục vừa chọn lúc create — bug-class S42 picker).
if (request.WorkItemId is not null)
entity.WorkItemId = request.WorkItemId;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
@ -469,6 +502,7 @@ public class ListPurchaseEvaluationsQueryHandler(
ListPurchaseEvaluationsQuery request, CancellationToken ct)
{
// Plan AG4: JOIN Users + Departments LEFT (cả 2 nullable theo PE entity).
// Mig 49 S57bis: LEFT join WorkItems (loose-Guid nullable, mirror Project).
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
@ -477,7 +511,9 @@ public class ListPurchaseEvaluationsQueryHandler(
from u in uj.DefaultIfEmpty()
join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
select new { e, p, s, u, d };
join wi in db.WorkItems.AsNoTracking() on e.WorkItemId equals wi.Id into wij
from wi in wij.DefaultIfEmpty()
select new { e, p, s, u, d, wi };
// IDOR strict (Plan E S22 — Session 21 +1): non-admin chỉ thấy phiếu khi:
// 1. là Drafter (mình tạo)
@ -531,6 +567,7 @@ public class ListPurchaseEvaluationsQueryHandler(
.Select(x => new PurchaseEvaluationListItemDto(
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
x.e.ProjectId, x.p.Name,
x.e.WorkItemId, x.wi != null ? x.wi.Name : null, x.wi != null ? x.wi.Code : null,
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
@ -595,6 +632,7 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
: await ResolveV2InboxIdsAsync(userId, ct);
// Plan AG4: JOIN Users + Departments LEFT (mirror ListPurchaseEvaluations).
// Mig 49 S57bis: LEFT join WorkItems (loose-Guid nullable, mirror List).
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
@ -603,8 +641,10 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
from u in uj.DefaultIfEmpty()
join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
join wi in db.WorkItems.AsNoTracking() on e.WorkItemId equals wi.Id into wij
from wi in wij.DefaultIfEmpty()
where eligiblePhases.Contains(e.Phase) || v2InboxIds.Contains(e.Id)
select new { e, p, s, u, d };
select new { e, p, s, u, d, wi };
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
if (request.ApprovalWorkflowId is not null)
@ -616,6 +656,7 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
.Select(x => new PurchaseEvaluationListItemDto(
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
x.e.ProjectId, x.p.Name,
x.e.WorkItemId, x.wi != null ? x.wi.Name : null, x.wi != null ? x.wi.Code : null,
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
@ -707,6 +748,8 @@ public class GetPurchaseEvaluationQueryHandler(
}
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == e.ProjectId, ct);
// [Mig 49 S57bis] Resolve hạng mục công việc giống Project (loose-Guid).
var workItem = e.WorkItemId is null ? null : await db.WorkItems.AsNoTracking().FirstOrDefaultAsync(w => w.Id == e.WorkItemId, ct);
var department = e.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == e.DepartmentId, ct);
var selectedSupplier = e.SelectedSupplierId is null ? null : await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == e.SelectedSupplierId, ct);
@ -918,6 +961,7 @@ public class GetPurchaseEvaluationQueryHandler(
return new PurchaseEvaluationDetailBundleDto(
e.Id, e.MaPhieu, e.Type, e.Phase, e.TenGoiThau, e.DiaDiem, e.MoTa,
e.ProjectId, project?.Name ?? "",
e.WorkItemId, workItem?.Name, workItem?.Code,
e.DepartmentId, department?.Name,
e.DrafterUserId, e.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
e.SelectedSupplierId, selectedSupplier?.Name,