[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
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:
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user