[CLAUDE] Domain+App+Infra: Role ShortName + User Department/Position + 13 demo users (migration 11)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m53s

User feedback: chi tiết hóa Users/Phòng ban + gán roles. Roles label
tiếng Việt có Mã (ShortName) + Tên đầy đủ (Description).

## Entity changes

### Role (Domain/Identity/Role.cs)
+ ShortName (max 50)  — Mã viết tắt VN: QTV/BOD/CCM/PRO/FIN/...
+ Description (đã có) — Tên đầy đủ VN
- Identity Name = code English giữ nguyên (FK + [Authorize] attr)

### User (Domain/Identity/User.cs)
+ DepartmentId Guid? FK Departments (Restrict — không xóa dept nếu user reference)
+ Position string? max 200 — chức vụ free text

## Migration 11: AddRoleShortNameAndUserDepartment

3-file rule. Apply LocalDB OK. DB total: 36 tables (không tăng — chỉ
thêm cột vào existing).

## Seed VN labels (12 roles)

| Code | ShortName | Description |
|---|---|---|
| Admin | QTV | Quản trị viên hệ thống |
| Drafter | NV.PB | Nhân viên phòng ban (soạn thảo HĐ) |
| DeptManager | TPB | Trưởng phòng ban |
| ProjectManager | PM | Giám đốc dự án |
| Procurement | PRO | Phòng Cung ứng |
| CostControl | CCM | Phòng Kiểm soát chi phí |
| Finance | FIN | Phòng Tài chính |
| Accounting | ACT | Phòng Kế toán |
| Equipment | EQU | Phòng Thiết bị |
| Director | BOD | Ban Giám đốc |
| AuthorizedSigner | NĐUQ | Người được Ủy quyền ký HĐ |
| HrAdmin | HRA | Phòng Nhân sự - Hành chính |

SeedRolesAsync idempotent + backfill (existing role thiếu ShortName/
Description → update).

## Seed 13 demo users

Default password: User@123456 (warn log để rotate prod). Coverage full
org chart:
- bod.huynh (Tổng GĐ — Director, BOD dept)
- bod.le (Phó GĐ NĐUQ — AuthorizedSigner, BOD dept)
- pm.nguyen (PM FLOCK 01 — ProjectManager, PM dept)
- ccm.tran (TPB CCM — CostControl + DeptManager, CCM dept)
- pro.pham (TPB PRO — Procurement + DeptManager, PRO dept)
- fin.do, act.vu, equ.bui, hra.dang (TPB respective dept)
- qs.hoang, qs.ngo (Drafter — QS dept)
- nv.cao, nv.dinh (Drafter — PRO/FIN dept)

Idempotent (skip nếu email đã tồn tại).

## DTOs + Commands updated

- RoleDto + ShortName field
- UserDto + DepartmentId/DepartmentName/Position
- CreateUserCommand + DepartmentId/Position params (defaults null)
- UpdateUserCommand + DepartmentId/Position
- ListUsersQueryHandler load dept names denormalize per page
- UpdateUserCommandHandler set UpdatedAt

## Note

FE updates (UsersPage dept dropdown + role label VN) ở commit kế tiếp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 14:24:12 +07:00
parent 39031ca33c
commit 330d529c92
10 changed files with 2439 additions and 14 deletions

View File

@ -56,10 +56,18 @@ public class ApplicationDbContext
e.ToTable("Users");
e.Property(u => u.FullName).HasMaxLength(200).IsRequired();
e.Property(u => u.RefreshToken).HasMaxLength(512);
e.Property(u => u.Position).HasMaxLength(200);
e.HasIndex(u => u.DepartmentId);
// FK Department — Restrict (không xóa dept nếu còn user assigned)
e.HasOne<Department>()
.WithMany()
.HasForeignKey(u => u.DepartmentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<Role>(e =>
{
e.ToTable("Roles");
e.Property(r => r.ShortName).HasMaxLength(50);
e.Property(r => r.Description).HasMaxLength(500);
});
builder.Entity<Microsoft.AspNetCore.Identity.IdentityUserRole<Guid>>(e => e.ToTable("UserRoles"));

View File

@ -30,9 +30,10 @@ public static class DbInitializer
await SeedRolesAsync(roleManager, logger);
await SeedAdminAsync(userManager, logger);
await SeedDepartmentsAsync(db, logger);
await SeedDemoUsersAsync(db, userManager, logger);
await SeedMenuTreeAsync(db, logger);
await SeedAdminPermissionsAsync(db, roleManager, logger);
await SeedDepartmentsAsync(db, logger);
await SeedDemoMasterDataAsync(db, logger);
await SeedContractTemplatesAsync(db, logger);
await SeedWorkflowDefinitionsAsync(db, logger);
@ -280,14 +281,51 @@ public static class DbInitializer
}
}
// 12 role với ShortName (Vietnamese abbreviation) + Description (Vietnamese
// full name). Identity Name = code English (kept cho FK + [Authorize] attr).
// Idempotent: tạo role mới nếu chưa có, backfill ShortName/Description nếu null.
private static readonly Dictionary<string, (string ShortName, string Description)> RoleLabels = new()
{
[AppRoles.Admin] = ("QTV", "Quản trị viên hệ thống"),
[AppRoles.Drafter] = ("NV.PB", "Nhân viên phòng ban (soạn thảo HĐ)"),
[AppRoles.DeptManager] = ("TPB", "Trưởng phòng ban"),
[AppRoles.ProjectManager] = ("PM", "Giám đốc dự án"),
[AppRoles.Procurement] = ("PRO", "Phòng Cung ứng"),
[AppRoles.CostControl] = ("CCM", "Phòng Kiểm soát chi phí"),
[AppRoles.Finance] = ("FIN", "Phòng Tài chính"),
[AppRoles.Accounting] = ("ACT", "Phòng Kế toán"),
[AppRoles.Equipment] = ("EQU", "Phòng Thiết bị"),
[AppRoles.Director] = ("BOD", "Ban Giám đốc"),
[AppRoles.AuthorizedSigner] = ("NĐUQ", "Người được Ủy quyền ký HĐ"),
[AppRoles.HrAdmin] = ("HRA", "Phòng Nhân sự - Hành chính"),
};
private static async Task SeedRolesAsync(RoleManager<Role> roleManager, ILogger logger)
{
foreach (var roleName in AppRoles.All)
{
if (!await roleManager.RoleExistsAsync(roleName))
var (shortName, desc) = RoleLabels.TryGetValue(roleName, out var v)
? v : (roleName, roleName);
var existing = await roleManager.FindByNameAsync(roleName);
if (existing is null)
{
await roleManager.CreateAsync(new Role { Name = roleName, CreatedAt = DateTime.UtcNow });
logger.LogInformation("Created role {Role}", roleName);
await roleManager.CreateAsync(new Role
{
Name = roleName,
ShortName = shortName,
Description = desc,
CreatedAt = DateTime.UtcNow,
});
logger.LogInformation("Created role {Role} ({Short})", roleName, shortName);
}
else if (existing.ShortName != shortName || existing.Description != desc)
{
// Backfill labels for existing roles (post-migration)
existing.ShortName = shortName;
existing.Description = desc;
await roleManager.UpdateAsync(existing);
logger.LogInformation("Backfilled role {Role} labels", roleName);
}
}
}
@ -317,6 +355,76 @@ public static class DbInitializer
}
}
// Seed ~13 demo users covering full org chart (1+ user per role, distributed
// across 9 departments). Idempotent: skip nếu user đã tồn tại theo email.
// Default password: User@123456 (warn ở log để rotate).
private const string DemoUserPassword = "User@123456";
private static async Task SeedDemoUsersAsync(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
// Lookup department Id theo Code
var depts = await db.Departments.ToDictionaryAsync(d => d.Code, d => d.Id);
if (depts.Count == 0)
{
logger.LogWarning("Skipping SeedDemoUsersAsync — no departments seeded yet.");
return;
}
Guid? deptId(string code) => depts.TryGetValue(code, out var id) ? id : null;
var demoUsers = new[]
{
// (Email, FullName, DeptCode, Position, RoleNames[])
("bod.huynh@solutionerp.local", "Huỳnh Văn Hùng", "BOD", "Tổng Giám đốc", new[] { AppRoles.Director }),
("bod.le@solutionerp.local", "Lê Thị Mai", "BOD", "Phó Giám đốc (NĐUQ)", new[] { AppRoles.AuthorizedSigner }),
("pm.nguyen@solutionerp.local", "Nguyễn Quốc Cường", "PM", "Giám đốc Dự án FLOCK 01", new[] { AppRoles.ProjectManager }),
("ccm.tran@solutionerp.local", "Trần Văn Bình", "CCM", "Trưởng phòng Kiểm soát chi phí", new[] { AppRoles.CostControl, AppRoles.DeptManager }),
("pro.pham@solutionerp.local", "Phạm Thị Hồng", "PRO", "Trưởng phòng Cung ứng", new[] { AppRoles.Procurement, AppRoles.DeptManager }),
("fin.do@solutionerp.local", "Đỗ Minh Tuấn", "FIN", "Trưởng phòng Tài chính", new[] { AppRoles.Finance, AppRoles.DeptManager }),
("act.vu@solutionerp.local", "Vũ Thị Lan", "ACT", "Kế toán trưởng", new[] { AppRoles.Accounting, AppRoles.DeptManager }),
("equ.bui@solutionerp.local", "Bùi Văn Khánh", "EQU", "Trưởng phòng Thiết bị", new[] { AppRoles.Equipment, AppRoles.DeptManager }),
("hra.dang@solutionerp.local", "Đặng Thị Thanh", "HRA", "Trưởng phòng Nhân sự - Hành chính", new[] { AppRoles.HrAdmin, AppRoles.DeptManager }),
("qs.hoang@solutionerp.local", "Hoàng Văn Đức", "QS", "QS công trường — soạn thảo HĐ", new[] { AppRoles.Drafter }),
("qs.ngo@solutionerp.local", "Ngô Thị Hà", "QS", "QS dự án FLOCK 01", new[] { AppRoles.Drafter }),
("nv.cao@solutionerp.local", "Cao Văn Long", "PRO", "Nhân viên Cung ứng", new[] { AppRoles.Drafter }),
("nv.dinh@solutionerp.local", "Đinh Thị Yến", "FIN", "Nhân viên Tài chính", new[] { AppRoles.Drafter }),
};
int created = 0;
foreach (var (email, fullName, deptCode, position, roles) in demoUsers)
{
if (await userManager.FindByEmailAsync(email) is not null) continue;
var user = new User
{
UserName = email,
Email = email,
FullName = fullName,
EmailConfirmed = true,
IsActive = true,
DepartmentId = deptId(deptCode),
Position = position,
CreatedAt = DateTime.UtcNow,
};
var result = await userManager.CreateAsync(user, DemoUserPassword);
if (!result.Succeeded)
{
logger.LogWarning("Demo user {Email} create fail: {Err}",
email, string.Join("; ", result.Errors.Select(e => e.Description)));
continue;
}
foreach (var role in roles) await userManager.AddToRoleAsync(user, role);
created++;
}
if (created > 0)
{
logger.LogInformation(
"Seed {Count} demo users (password={Pwd} — ⚠ rotate in prod).",
created, DemoUserPassword);
}
}
private static async Task SeedMenuTreeAsync(ApplicationDbContext db, ILogger logger)
{
var typeLabels = new Dictionary<string, string>

View File

@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddRoleShortNameAndUserDepartment : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "DepartmentId",
table: "Users",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Position",
table: "Users",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ShortName",
table: "Roles",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Users_DepartmentId",
table: "Users",
column: "DepartmentId");
migrationBuilder.AddForeignKey(
name: "FK_Users_Departments_DepartmentId",
table: "Users",
column: "DepartmentId",
principalTable: "Departments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Users_Departments_DepartmentId",
table: "Users");
migrationBuilder.DropIndex(
name: "IX_Users_DepartmentId",
table: "Users");
migrationBuilder.DropColumn(
name: "DepartmentId",
table: "Users");
migrationBuilder.DropColumn(
name: "Position",
table: "Users");
migrationBuilder.DropColumn(
name: "ShortName",
table: "Roles");
}
}
}

View File

@ -1311,6 +1311,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("ShortName")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
@ -1337,6 +1341,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
@ -1375,6 +1382,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("Position")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("RefreshToken")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
@ -1397,6 +1408,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("DepartmentId");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
@ -2114,6 +2127,14 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Role");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.User", b =>
{
b.HasOne("SolutionErp.Domain.Master.Department", null)
.WithMany()
.HasForeignKey("DepartmentId")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.Navigation("Approvals");