[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

@ -22,6 +22,9 @@ public class DepartmentsController(IMediator mediator) : ControllerBase
public async Task<ActionResult<DepartmentDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetDepartmentQuery(id), ct));
// [S57] Master write khóa Admin+CatalogManager (đọc mở cho mọi role; chống sửa/xóa
// Phòng ban qua API khi mở quyền xem cho toàn bộ nhân viên).
[Authorize(Roles = "Admin,CatalogManager")]
[HttpPost]
public async Task<ActionResult<Guid>> Create([FromBody] CreateDepartmentCommand cmd, CancellationToken ct)
{
@ -29,6 +32,7 @@ public class DepartmentsController(IMediator mediator) : ControllerBase
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[Authorize(Roles = "Admin,CatalogManager")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateDepartmentCommand cmd, CancellationToken ct)
{
@ -37,6 +41,7 @@ public class DepartmentsController(IMediator mediator) : ControllerBase
return NoContent();
}
[Authorize(Roles = "Admin,CatalogManager")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{

View File

@ -22,6 +22,9 @@ public class ProjectsController(IMediator mediator) : ControllerBase
public async Task<ActionResult<ProjectDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetProjectQuery(id), ct));
// [S57] Master write khóa Admin+CatalogManager (đọc mở cho mọi role; chống sửa/xóa
// 62 dự án production qua API khi mở quyền xem cho toàn bộ nhân viên).
[Authorize(Roles = "Admin,CatalogManager")]
[HttpPost]
public async Task<ActionResult<Guid>> Create([FromBody] CreateProjectCommand cmd, CancellationToken ct)
{
@ -29,6 +32,7 @@ public class ProjectsController(IMediator mediator) : ControllerBase
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[Authorize(Roles = "Admin,CatalogManager")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateProjectCommand cmd, CancellationToken ct)
{
@ -37,6 +41,7 @@ public class ProjectsController(IMediator mediator) : ControllerBase
return NoContent();
}
[Authorize(Roles = "Admin,CatalogManager")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{

View File

@ -29,6 +29,9 @@ public class SuppliersController(IMediator mediator) : ControllerBase
public async Task<ActionResult<SupplierDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetSupplierQuery(id), ct));
// [S57] Master write khóa Admin+CatalogManager (đọc mở cho mọi role review/test;
// chống nhân viên sửa/xóa NCC production qua API khi menu hiện cho toàn bộ phận).
[Authorize(Roles = "Admin,CatalogManager")]
[HttpPost]
public async Task<ActionResult<Guid>> Create([FromBody] CreateSupplierCommand cmd, CancellationToken ct)
{
@ -36,6 +39,7 @@ public class SuppliersController(IMediator mediator) : ControllerBase
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[Authorize(Roles = "Admin,CatalogManager")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateSupplierCommand cmd, CancellationToken ct)
{
@ -44,6 +48,7 @@ public class SuppliersController(IMediator mediator) : ControllerBase
return NoContent();
}
[Authorize(Roles = "Admin,CatalogManager")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{

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,

View File

@ -121,10 +121,16 @@ public static class MenuKeys
public const string OffDonTuTravel = "Off_DonTu_Travel"; // Đơn công tác
public const string OffDatXe = "Off_DatXe"; // Đặt xe công
public const string OffItTicket = "Off_ItTicket"; // Ticket CNTT helpdesk
public const string OffChamCong = "Off_ChamCong"; // Chấm công GPS (G-P1)
public const string OffChamCong = "Off_ChamCong"; // Chấm công GPS (G-P1) — [S57] re-parent Off → Personal
public const string OffAttendanceReport = "Off_AttendanceReport"; // Báo cáo chấm công (P11-E, admin)
public const string HrmDashboard = "Hrm_Dashboard"; // Dashboard HRM (G-H3)
// ============================================================
// [S57] Nhóm "Cá nhân" — mirror layout Puro (NAMGROUP). Root group cho mục
// cá nhân nhân viên. "Chấm công" (Off_ChamCong) re-parent Off → Personal.
// ============================================================
public const string Personal = "Personal"; // root group "Cá nhân"
public static readonly string[] PurchaseEvaluationTypeCodes =
["DuyetNcc", "DuyetNccPhuongAn"];
@ -157,6 +163,7 @@ public static class MenuKeys
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất
OffDonTu, OffDonTuLeave, OffDonTuOt, OffDonTuTravel, // Phase 10.3 G-O4 — Đơn từ
OffDatXe, OffItTicket, OffChamCong, OffAttendanceReport, HrmDashboard, // Phase 10.3-10.4 — G-O5/G-O6/G-P1/G-H3 + P11-E report
Personal, // [S57] nhân (Puro grouping Chấm công re-parent)
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];

View File

@ -13,6 +13,7 @@ public class PurchaseEvaluation : AuditableEntity
public string TenGoiThau { get; set; } = string.Empty; // "Cung cấp bê tông"
public Guid ProjectId { get; set; } // Dự án (FK Projects)
public Guid? WorkItemId { get; set; } // [Mig 49 S57bis] Hạng mục công việc — scalar loose-Guid (KHÔNG navigation, KHÔNG FK vật lý — convention PE giống ProjectId/SelectedSupplierId). DB nullable cho 4 phiếu cũ; flow create mới validator NotEmpty.
public Guid? DepartmentId { get; set; }
public Guid? DrafterUserId { get; set; } // QS/NV.PB soạn
public string? DiaDiem { get; set; } // Lô K, KCN Lộc An...

View File

@ -25,6 +25,9 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted });
b.HasIndex(x => x.ProjectId);
// [Mig 49 S57bis] WorkItemId scalar loose-Guid — index lọc query, KHÔNG
// HasOne/FK vật lý (convention PE: chỉ ApprovalWorkflowId có FK).
b.HasIndex(x => x.WorkItemId);
b.HasIndex(x => x.SlaDeadline);
b.HasIndex(x => x.WorkflowDefinitionId);
b.HasIndex(x => x.ApprovalWorkflowId);

View File

@ -92,6 +92,10 @@ public static class DbInitializer
// cho round-robin auto-assign ticket. PHẢI sau SeedDemoUsersAsync (reconcile dept
// trước → method này override về IT). Infrastructure data (NOT gated DemoSeed).
await SeedItDepartmentStaffAsync(db, userManager, logger);
// [S57bis 2026-06-11] Khóa 14 demo sample user (sếp yêu cầu clear dữ liệu cũ —
// anh chốt scope CHỈ user). PHẢI sau SeedDemoUsers + SeedItDepartmentStaff
// (chạy sau cùng mỗi startup → khóa BỀN, seed fix-drift không resurrect được).
await LockDemoSampleUsersAsync(userManager, logger);
// Plan B G-H1 (Mig 34 S33 2026-05-26) — seed EmployeeProfile 1-1 với
// mọi user @solutions.com.vn. Idempotent. NOT gated DemoSeed flag
// (infrastructure data, mirror Mig 32 SeedSampleContractWorkflowV2
@ -1537,6 +1541,47 @@ public static class DbInitializer
// Default password: User@123456 (warn log để rotate prod).
private const string DemoUserPassword = "User@123456";
// [S57bis 2026-06-11] Khóa 14/16 demo sample user — sếp yêu cầu "clear dữ liệu cũ",
// anh chốt scope: CHỈ khóa user demo (GIỮ phiếu/HĐ/NCC/dự án demo). Ungated idempotent
// (mirror SeedRealMasterDataAsync philosophy): chạy mọi startup → khóa BỀN kể cả ai
// re-activate nhầm. GIỮ ACTIVE có chủ đích:
// - nv.cao + nv.truong : IT helpdesk round-robin pool (S52 P11-D) — khóa nốt SAU KHI
// anh gán ≥1 user thật vào Phòng CNTT (ops-pending S56), tránh helpdesk chết hẳn.
// - catalog.manager : account chức năng quản danh mục dùng chung (Plan CA S29).
// Muốn mở lại 1 user có chủ đích → gỡ email khỏi list này + admin re-activate.
private static async Task LockDemoSampleUsersAsync(
UserManager<User> userManager, ILogger logger)
{
string[] emails =
[
"bod.huynh@solutions.com.vn", "bod.le@solutions.com.vn", "bod.tran@solutions.com.vn",
"pm.nguyen@solutions.com.vn", "pm.le@solutions.com.vn",
"ccm.tran@solutions.com.vn", "pro.pham@solutions.com.vn", "fin.do@solutions.com.vn",
"act.vu@solutions.com.vn", "equ.bui@solutions.com.vn", "hra.dang@solutions.com.vn",
"qs.hoang@solutions.com.vn", "qs.ngo@solutions.com.vn", "nv.dinh@solutions.com.vn",
];
var locked = 0;
foreach (var email in emails)
{
var user = await userManager.FindByEmailAsync(email);
if (user is null) continue;
if (!user.IsActive && user.LockoutEnd == DateTimeOffset.MaxValue) continue; // đã khóa — idempotent skip
user.IsActive = false;
user.LockoutEnabled = true;
user.LockoutEnd = DateTimeOffset.MaxValue;
await userManager.UpdateAsync(user);
// Rotate SecurityStamp → vô hiệu refresh-token flow của account bị khóa
// (JWT đang sống tự hết hạn ≤1h theo expiry).
await userManager.UpdateSecurityStampAsync(user);
locked++;
}
if (locked > 0)
logger.LogInformation("Locked {Count} demo sample users (S57bis clear-old-data)", locked);
}
private static async Task SeedDemoUsersAsync(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
@ -1729,7 +1774,8 @@ public static class DbInitializer
(MenuKeys.CatalogMaterials,"Vật tư / SP", MenuKeys.Catalogs, 2, "Package"),
(MenuKeys.CatalogServices, "Dịch vụ", MenuKeys.Catalogs, 3, "Wrench"),
(MenuKeys.CatalogWorkItems,"Hạng mục công việc", MenuKeys.Catalogs, 4, "ListChecks"),
(MenuKeys.Contracts, "Hợp đồng", null, 30, "FileText"),
// [S57] Order 30→31: nhường slot 30 cho nhóm "Cá nhân" (đứng ngay sau Văn phòng số = 29, mirror Puro).
(MenuKeys.Contracts, "Hợp đồng", null, 31, "FileText"),
(MenuKeys.Forms, "Biểu mẫu", null, 40, "FileSpreadsheet"),
(MenuKeys.Reports, "Báo cáo", null, 50, "BarChart3"),
(MenuKeys.System, "Hệ thống", null, 90, "Settings"),
@ -1751,13 +1797,17 @@ public static class DbInitializer
(MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"),
(MenuKeys.BudgetCreate, "Thao tác", MenuKeys.Budgets, 2, "Plus"),
(MenuKeys.BudgetPending, "Duyệt", MenuKeys.Budgets, 3, "CheckCircle2"),
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). 1 root + 1 leaf
// Phase 1 minimal. Phase 1.5 + G-H2/G-H3 thêm Config/Dashboard.
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). Root operational HR.
// [S57] "Cấu hình HRM" re-parent sang "Danh mục" (Master) — gom config 1 chỗ.
// Hrm còn: Dashboard(1) → Hồ sơ(2), Dashboard đầu nhóm (khớp Puro).
(MenuKeys.Hrm, "Nhân sự", null, 28, "UserCircle"),
(MenuKeys.HrmHoSo, "Hồ sơ Nhân sự", MenuKeys.Hrm, 1, "ContactRound"),
(MenuKeys.HrmHoSo, "Hồ sơ Nhân sự", MenuKeys.Hrm, 2, "ContactRound"),
// Phase 10.2 G-H2 (Mig 35 — S34). Sub-group "Cấu hình HRM" + 4 catalog leaf.
(MenuKeys.HrmConfig, "Cấu hình HRM", MenuKeys.Hrm, 2, "Settings2"),
// Phase 10.2 G-H2 (Mig 35 — S34). Sub-group "Cấu hình HRM" + 6 catalog leaf.
// [S57] parent Hrm → Master: nằm dưới "Danh mục" (order 25, sau Catalogs) để gom
// toàn bộ config/catalog 1 chỗ. 6 leaf bên dưới giữ parent=HrmConfig nên theo cùng.
// DB cũ propagate qua parentBackfill bên dưới (main upsert chỉ re-set Order).
(MenuKeys.HrmConfig, "Cấu hình HRM", MenuKeys.Master, 25, "Settings2"),
(MenuKeys.HrmConfigLeaveTypes, "Loại phép", MenuKeys.HrmConfig, 1, "CalendarOff"),
(MenuKeys.HrmConfigHolidays, "Ngày lễ", MenuKeys.HrmConfig, 2, "PartyPopper"),
(MenuKeys.HrmConfigShifts, "Ca làm việc", MenuKeys.HrmConfig, 3, "Clock"),
@ -1787,10 +1837,15 @@ public static class DbInitializer
(MenuKeys.OffDonTuTravel, "Công tác", MenuKeys.OffDonTu, 3, "Plane"),
(MenuKeys.OffDatXe, "Đặt xe công", MenuKeys.Off, 5, "Car"),
(MenuKeys.OffItTicket, "Ticket CNTT", MenuKeys.Off, 6, "Ticket"),
(MenuKeys.OffChamCong, "Chấm công", MenuKeys.Off, 7, "Fingerprint"),
(MenuKeys.OffAttendanceReport, "Báo cáo chấm công", MenuKeys.Off, 8, "FileBarChart"),
// Phase 10.4 G-H3 — Dashboard NS dưới root Hrm.
(MenuKeys.HrmDashboard, "Dashboard NS", MenuKeys.Hrm, 3, "BarChart3"),
// [S57] "Báo cáo chấm công" giữ ở Văn phòng số (báo cáo admin, order 7 — lấp chỗ Chấm công rời đi).
(MenuKeys.OffAttendanceReport, "Báo cáo chấm công", MenuKeys.Off, 7, "FileBarChart"),
// [S57] Nhóm "Cá nhân" (mirror Puro). Root order 30 = ngay sau Văn phòng số (29).
// "Chấm công" re-parent Off → Personal; với DB cũ propagate qua parentBackfill bên dưới
// (main upsert chỉ re-set Order, KHÔNG đụng ParentKey).
(MenuKeys.Personal, "Cá nhân", null, 30, "UserRound"),
(MenuKeys.OffChamCong, "Chấm công", MenuKeys.Personal, 1, "Fingerprint"),
// Phase 10.4 G-H3 — Dashboard NS dưới root Hrm. [S57] Order 1 = đầu nhóm (khớp Puro).
(MenuKeys.HrmDashboard, "Dashboard NS", MenuKeys.Hrm, 1, "BarChart3"),
};
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
@ -1895,6 +1950,30 @@ public static class DbInitializer
logger.LogInformation("Backfilled {Count} menu labels", updatedLabels);
}
// [S57] Re-parent backfill — chuyển node sang group khác trên DB cũ. Main
// upsert phía trên CHỈ re-set Order, KHÔNG đụng ParentKey (xem comment trên),
// nên đổi nhóm phải update ParentKey riêng. Idempotent. "Chấm công" Off → Cá nhân.
var parentBackfill = new Dictionary<string, string?>
{
[MenuKeys.OffChamCong] = MenuKeys.Personal, // [S57] Chấm công → Cá nhân
[MenuKeys.HrmConfig] = MenuKeys.Master, // [S57] Cấu hình HRM → Danh mục (gom config 1 chỗ)
};
var reparented = 0;
foreach (var (key, expectedParent) in parentBackfill)
{
var item = await db.MenuItems.FirstOrDefaultAsync(m => m.Key == key);
if (item != null && item.ParentKey != expectedParent)
{
item.ParentKey = expectedParent;
reparented++;
}
}
if (reparented > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Re-parented {Count} menu items", reparented);
}
// Backfill WorkflowDefinition name cho B (Phương Án → Giải pháp rename).
var wfB = await db.PurchaseEvaluationWorkflowDefinitions
.FirstOrDefaultAsync(w => w.Code == "QT-DN-B" && w.Version == 1);
@ -1944,6 +2023,109 @@ public static class DbInitializer
// (Master/Suppliers/Projects/Departments + 4 Catalogs leaf). Admin gán role
// cho user nào cần CRUD danh mục sau khi move FE từ admin → eoffice.
await SeedCatalogManagerPermissionsAsync(db, roleManager, logger);
// [S57] Mở quyền XEM (Read-only) cho TẤT CẢ role để mọi bộ phận review/góp ý
// các module HRM + Văn phòng số + Danh mục (master). KHÔNG đụng Duyệt NCC
// (Pe_*/PeWf_*/AwV2 — sắp go-live, giữ phân quyền cũ), Contracts/Budgets/System.
await SeedAllRolesReviewReadPermissionsAsync(db, roleManager, logger);
}
// [S57] Cấp CanRead (CHỈ xem) cho MỌI role trên menu HRM + Office + Master để mọi
// bộ phận nhân viên thấy + review/góp ý. Additive idempotent → KHÔNG xóa quyền
// sẵn có. Write vẫn khóa ở controller (Master: Admin+CatalogManager;
// HRM-config/Catalogs/MeetingRoom: Admin).
//
// [S57bis] Mở Duyệt NCC (Pe_*) cho MỌI role: anh chốt "Xem + Tạo".
// - Key HRM/Office/Master/Catalogs : CanRead-only, skip-existing (giữ nguyên).
// - Key Pe_* : CanRead=true + CanCreate=true.
// Idempotent UPGRADE-ONLY: row Pe_* đã tồn tại (Pe defaults cũ seed 7 role)
// mà CanRead HOẶC CanCreate=false → NÂNG đúng 2 cờ đó lên true. KHÔNG hạ +
// KHÔNG đụng CanUpdate/CanDelete (additive — không phá quyền admin đã chỉnh
// cao hơn). Row chưa có → tạo mới CanRead+CanCreate=true, Update/Delete=false.
private static async Task SeedAllRolesReviewReadPermissionsAsync(
ApplicationDbContext db, RoleManager<Role> roleManager, ILogger logger)
{
// Scope read-only = HRM (Hrm*) + Office (Off*) + Personal + Master + Catalogs.
// [S57bis] +Pe_* (Duyệt NCC) — semantics riêng read+create xử lý bên dưới.
// Loại trừ tự nhiên (không match prefix): PeWf_* (4th char 'W' ≠ '_'),
// AwV2_*, Ct_*, Bg_*, Wf_*, System keys.
static bool InReviewScope(string key) =>
key.StartsWith("Hrm") || key.StartsWith("Off") || key == MenuKeys.Personal ||
key.StartsWith("Catalog") || key == MenuKeys.Master ||
key == MenuKeys.Suppliers || key == MenuKeys.Projects || key == MenuKeys.Departments ||
key.StartsWith("Pe_");
// Phân biệt key Pe_* (read+create) vs read-only. Pe_* match cờ thứ-3 '_'
// → "PeWf_*"/"PeWorkflows" KHÔNG match (loại admin Designer).
static bool IsPeKey(string key) => key.StartsWith("Pe_");
// MenuKeys.All chứa root PurchaseEvaluations nhưng KHÔNG chứa Pe_* leaf
// (sinh động qua factory). Build leaf giống SeedPurchaseEvaluationPermissionDefaultsAsync
// để upgrade đúng row Pe_* thật trong DB (1 root + 5 leaf × 2 type).
var peKeys = new List<string> { MenuKeys.PurchaseEvaluations };
foreach (var typeCode in MenuKeys.PurchaseEvaluationTypeCodes)
{
peKeys.Add(MenuKeys.PurchaseEvaluationGroup(typeCode));
peKeys.Add(MenuKeys.PurchaseEvaluationWorkflowView(typeCode));
peKeys.Add(MenuKeys.PurchaseEvaluationList(typeCode));
peKeys.Add(MenuKeys.PurchaseEvaluationCreate(typeCode));
peKeys.Add(MenuKeys.PurchaseEvaluationPending(typeCode));
}
var reviewKeys = MenuKeys.All.Where(InReviewScope)
.Concat(peKeys)
.Distinct()
.ToArray();
var roles = await roleManager.Roles.ToListAsync();
// Load full rows (cần mutate CanRead/CanCreate cho Pe_* upgrade path).
var existingRows = (await db.Permissions
.Where(p => reviewKeys.Contains(p.MenuKey))
.ToListAsync())
.ToDictionary(p => (p.RoleId, p.MenuKey));
var added = 0;
var upgraded = 0;
foreach (var role in roles)
{
foreach (var key in reviewKeys)
{
var isPe = IsPeKey(key);
if (existingRows.TryGetValue((role.Id, key), out var row))
{
// [S57bis] Pe_* upgrade-only: nâng CanRead/CanCreate nếu đang false.
if (isPe)
{
var changed = false;
if (!row.CanRead) { row.CanRead = true; changed = true; }
if (!row.CanCreate) { row.CanCreate = true; changed = true; }
if (changed) upgraded++;
}
// Key non-Pe: skip-existing (giữ nguyên như cũ).
continue;
}
db.Permissions.Add(new Permission
{
RoleId = role.Id,
MenuKey = key,
CanRead = true,
CanCreate = isPe, // [S57bis] Pe_* được Tạo; còn lại read-only
CanUpdate = false,
CanDelete = false,
});
added++;
}
}
if (added > 0 || upgraded > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Seeded all-roles review perms: {Added} added + {Upgraded} upgraded (Pe_* read+create) " +
"({Keys} keys × {Roles} roles)",
added, upgraded, reviewKeys.Length, roles.Count);
}
}
// [Plan CA S29 2026-05-22] Permission defaults cho role CatalogManager.

View File

@ -0,0 +1,38 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddWorkItemToPurchaseEvaluation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "WorkItemId",
table: "PurchaseEvaluations",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluations_WorkItemId",
table: "PurchaseEvaluations",
column: "WorkItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_PurchaseEvaluations_WorkItemId",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "WorkItemId",
table: "PurchaseEvaluations");
}
}
}

View File

@ -4942,6 +4942,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("WorkItemId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("WorkflowDefinitionId")
.HasColumnType("uniqueidentifier");
@ -4961,6 +4964,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasIndex("SlaDeadline");
b.HasIndex("WorkItemId");
b.HasIndex("WorkflowDefinitionId");
b.HasIndex("Phase", "IsDeleted");