[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

@ -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");