[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:
@ -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)
|
||||
{
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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] Cá nhân (Puro grouping — Chấm công re-parent)
|
||||
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
|
||||
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
||||
];
|
||||
|
||||
@ -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...
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
Reference in New Issue
Block a user