[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

@ -24,6 +24,7 @@ public record PermissionDto(
public record RoleDto(
Guid Id,
string Name,
string? ShortName,
string? Description,
DateTime CreatedAt);

View File

@ -115,7 +115,7 @@ public class ListRolesQueryHandler(RoleManager<Role> roleManager) : IRequestHand
{
return await roleManager.Roles
.OrderBy(r => r.Name)
.Select(r => new RoleDto(r.Id, r.Name!, r.Description, r.CreatedAt))
.Select(r => new RoleDto(r.Id, r.Name!, r.ShortName, r.Description, r.CreatedAt))
.ToListAsync(ct);
}
}

View File

@ -17,12 +17,15 @@ public record UserDto(
bool IsActive,
bool IsLocked,
DateTime CreatedAt,
List<string> Roles);
List<string> Roles,
Guid? DepartmentId,
string? DepartmentName,
string? Position);
// ========== LIST ==========
public record ListUsersQuery : PagedRequest, IRequest<PagedResult<UserDto>>;
public class ListUsersQueryHandler(UserManager<User> userManager, IDateTime dateTime)
public class ListUsersQueryHandler(UserManager<User> userManager, IApplicationDbContext db, IDateTime dateTime)
: IRequestHandler<ListUsersQuery, PagedResult<UserDto>>
{
public async Task<PagedResult<UserDto>> Handle(ListUsersQuery request, CancellationToken ct)
@ -43,13 +46,20 @@ public class ListUsersQueryHandler(UserManager<User> userManager, IDateTime date
.Take(request.PageSize)
.ToListAsync(ct);
// Lookup department names cho denormalize trong DTO
var deptIds = users.Where(u => u.DepartmentId is not null).Select(u => u.DepartmentId!.Value).Distinct().ToList();
var deptNames = await db.Departments.AsNoTracking()
.Where(d => deptIds.Contains(d.Id))
.ToDictionaryAsync(d => d.Id, d => d.Name, ct);
var items = new List<UserDto>(users.Count);
var now = dateTime.UtcNow;
foreach (var u in users)
{
var roles = await userManager.GetRolesAsync(u);
var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > now;
items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList()));
string? deptName = u.DepartmentId is { } did && deptNames.TryGetValue(did, out var dn) ? dn : null;
items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position));
}
return new PagedResult<UserDto>(items, total, request.Page, request.PageSize);
@ -59,7 +69,7 @@ public class ListUsersQueryHandler(UserManager<User> userManager, IDateTime date
// ========== GET ==========
public record GetUserQuery(Guid Id) : IRequest<UserDto>;
public class GetUserQueryHandler(UserManager<User> userManager, IDateTime dateTime)
public class GetUserQueryHandler(UserManager<User> userManager, IApplicationDbContext db, IDateTime dateTime)
: IRequestHandler<GetUserQuery, UserDto>
{
public async Task<UserDto> Handle(GetUserQuery request, CancellationToken ct)
@ -68,13 +78,17 @@ public class GetUserQueryHandler(UserManager<User> userManager, IDateTime dateTi
?? throw new NotFoundException("User", request.Id);
var roles = await userManager.GetRolesAsync(u);
var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > dateTime.UtcNow;
return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList());
string? deptName = null;
if (u.DepartmentId is { } did)
deptName = await db.Departments.AsNoTracking().Where(d => d.Id == did).Select(d => d.Name).FirstOrDefaultAsync(ct);
return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position);
}
}
// ========== CREATE ==========
public record CreateUserCommand(string Email, string FullName, string Password, List<string> Roles)
: IRequest<Guid>;
public record CreateUserCommand(
string Email, string FullName, string Password, List<string> Roles,
Guid? DepartmentId = null, string? Position = null) : IRequest<Guid>;
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
@ -104,6 +118,8 @@ public class CreateUserCommandHandler(
FullName = request.FullName,
EmailConfirmed = true,
IsActive = true,
DepartmentId = request.DepartmentId,
Position = request.Position,
CreatedAt = dateTime.UtcNow,
};
var result = await userManager.CreateAsync(user, request.Password);
@ -122,7 +138,9 @@ public class CreateUserCommandHandler(
}
// ========== UPDATE ==========
public record UpdateUserCommand(Guid Id, string FullName, bool IsActive) : IRequest;
public record UpdateUserCommand(
Guid Id, string FullName, bool IsActive,
Guid? DepartmentId = null, string? Position = null) : IRequest;
public class UpdateUserCommandValidator : AbstractValidator<UpdateUserCommand>
{
@ -147,6 +165,9 @@ public class UpdateUserCommandHandler(UserManager<User> userManager, ICurrentUse
user.FullName = request.FullName;
user.IsActive = request.IsActive;
user.DepartmentId = request.DepartmentId;
user.Position = request.Position;
user.UpdatedAt = DateTime.UtcNow;
await userManager.UpdateAsync(user);
}
}