[CLAUDE] Hrm: P11-C Vehicle+Driver catalogs (Mig 44) + gotcha #57 filtered-unique 3 HRM catalog (Mig 45)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m18s

P11-C: extend HrmConfigs +2 kind (Vehicle/Driver) declarative. Mig 44 AddVehicleAndDriverCatalogs (2 table filtered-unique Code, tables 91->93). Domain entity + EF config (filtered day-1) + 2 DbSet + HrmConfigFeatures Region5/6 CRUD + Controller +2 route-group (GET public / write Roles=Admin) + MenuKeys +2 +All (auto Admin perm) + DbInitializer 2 menu leaf + idempotent seed 2 veh/2 drv. FE declarative KIND_CONFIG +2 kind x2 app (SHA256 mirror) + 4-place (types/page/menuKeys/Layout staticMap), :kind-driven no new route.

gotcha #57 (bundled; OtPolicy missed in backlog, caught via grep) - Mig 45 FilterHrmCatalogUniqueIndexesByIsDeleted: LeaveType+ShiftPattern+OtPolicy bare .IsUnique() -> .HasFilter([IsDeleted]=0) (recreate-on-soft-deleted-slot 500 fix, mirror Holiday Mig 43). Tests +5 HrmConfigFilteredUniqueTests (181->186 PASS) test-before RED->GREEN. Reviewer caught FE<->BE Driver required-field mismatch -> fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-08 10:32:28 +07:00
parent f8179c5fbd
commit 30a99aa03f
27 changed files with 14144 additions and 16 deletions

View File

@ -61,6 +61,9 @@ function resolvePath(key: string): string | null {
Hrm_Config_Holidays: '/hrm/configs/holidays',
Hrm_Config_Shifts: '/hrm/configs/shifts',
Hrm_Config_OtPolicies: '/hrm/configs/ot-policies',
// [P11-C S51] danh mục xe công + tài xế — cùng page :kind-driven.
Hrm_Config_Vehicles: '/hrm/configs/vehicles',
Hrm_Config_Drivers: '/hrm/configs/drivers',
// [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ.
// 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
Off_DanhBa: '/directory',

View File

@ -40,6 +40,9 @@ export const MenuKeys = {
HrmConfigHolidays: 'Hrm_Config_Holidays',
HrmConfigShifts: 'Hrm_Config_Shifts',
HrmConfigOtPolicies: 'Hrm_Config_OtPolicies',
// P11-C (S51) — danh mục xe công + tài xế (dùng khi đặt xe)
HrmConfigVehicles: 'Hrm_Config_Vehicles',
HrmConfigDrivers: 'Hrm_Config_Drivers',
// Module Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1 Session 34, 2026-05-27)
Off: 'Off',
OffDanhBa: 'Off_DanhBa',

View File

@ -6,7 +6,7 @@
import { useState, type ComponentType, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom'
import { Settings2, Calendar, Clock, Pencil, Plus, Plane, Repeat, Trash2, Search } from 'lucide-react'
import { Settings2, Calendar, Clock, Pencil, Plus, Plane, Repeat, Trash2, Search, Car, IdCard } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
@ -109,9 +109,36 @@ const KIND_CONFIG: Record<Kind, {
],
columns: ['Mã', 'Tên', 'Hệ số WD/WE/HL', 'Max h/year', 'Trạng thái'],
},
'vehicles': {
label: 'Xe công',
description: 'Danh mục xe công ty — dùng khi đăng ký đặt xe.',
icon: Car,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'XE-01...' },
{ key: 'name', label: 'Tên xe *', type: 'text', required: true, placeholder: 'Toyota Innova 7 chỗ' },
{ key: 'licensePlate', label: 'Biển số *', type: 'text', required: true, placeholder: '30A-12345' },
{ key: 'seatCount', label: 'Số chỗ', type: 'number', placeholder: '7' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Mã', 'Tên xe', 'Biển số', 'Số chỗ', 'Trạng thái'],
},
'drivers': {
label: 'Tài xế',
description: 'Danh mục tài xế — dùng khi đăng ký đặt xe.',
icon: IdCard,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'TX-01...' },
{ key: 'name', label: 'Họ tên *', type: 'text', required: true, placeholder: 'Nguyễn Văn Tài' },
{ key: 'phoneNumber', label: 'SĐT *', type: 'text', required: true, placeholder: '0901xxxxxx' },
{ key: 'licenseNumber', label: 'Số GPLX *', type: 'text', required: true, placeholder: '012345678' },
{ key: 'licenseClass', label: 'Hạng *', type: 'text', required: true, placeholder: 'B2, C, D, E' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Mã', 'Họ tên', 'SĐT', 'Số GPLX', 'Hạng', 'Trạng thái'],
},
}
const KINDS: Kind[] = ['leave-types', 'holidays', 'shifts', 'ot-policies']
const KINDS: Kind[] = ['leave-types', 'holidays', 'shifts', 'ot-policies', 'vehicles', 'drivers']
type ConfigRow = Record<string, unknown> & { id: string; isActive?: boolean }
@ -427,6 +454,29 @@ function renderCells(kind: Kind, row: ConfigRow) {
</>
)
}
if (kind === 'vehicles') {
return (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code as string}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-600">{row.licensePlate as string}</td>
<td className="px-3 py-2 text-xs text-slate-600">{(row.seatCount as number) ?? ''}</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
if (kind === 'drivers') {
return (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code as string}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2 text-xs text-slate-600">{(row.phoneNumber as string) || ''}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-600">{(row.licenseNumber as string) || ''}</td>
<td className="px-3 py-2 text-xs text-slate-600">{(row.licenseClass as string) || ''}</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
// ot-policies
const mw = Number(row.multiplierWeekday ?? 0)
const me = Number(row.multiplierWeekend ?? 0)

View File

@ -1,7 +1,7 @@
// Types cho HRM Config module — mirror BE Domain.Hrm.* entities.
// Phase 10.2 G-H2 (Mig 35 — S34 schema + S35 BE CRUD wire + FE this file).
export type HrmConfigKind = 'leave-types' | 'holidays' | 'shifts' | 'ot-policies'
export type HrmConfigKind = 'leave-types' | 'holidays' | 'shifts' | 'ot-policies' | 'vehicles' | 'drivers'
// ========== DTOs (mirror BE DTOs HrmConfigFeatures.cs records) ==========
@ -53,6 +53,28 @@ export type OtPolicyDto = {
description: string | null
}
// Phase 11 P11-C (S51) — danh mục xe công + tài xế, dùng khi đăng ký đặt xe.
export type VehicleDto = {
id: string
code: string
name: string
licensePlate: string
seatCount: number | null
isActive: boolean
description: string | null
}
export type DriverDto = {
id: string
code: string
name: string
phoneNumber: string | null
licenseNumber: string | null
licenseClass: string | null
isActive: boolean
description: string | null
}
// ========== Create/Update Input Commands (mirror BE record Command shape) ==========
export type CreateLeaveTypeInput = {
@ -102,3 +124,24 @@ export type CreateOtPolicyInput = {
}
export type UpdateOtPolicyInput = CreateOtPolicyInput & { id: string; isActive: boolean }
export type CreateVehicleInput = {
code: string
name: string
licensePlate: string
seatCount: number | null
description: string | null
}
export type UpdateVehicleInput = CreateVehicleInput & { id: string; isActive: boolean }
export type CreateDriverInput = {
code: string
name: string
phoneNumber: string | null
licenseNumber: string | null
licenseClass: string | null
description: string | null
}
export type UpdateDriverInput = CreateDriverInput & { id: string; isActive: boolean }

View File

@ -83,6 +83,9 @@ function resolvePath(key: string): string | null {
Hrm_Config_Holidays: '/hrm/configs/holidays',
Hrm_Config_Shifts: '/hrm/configs/shifts',
Hrm_Config_OtPolicies: '/hrm/configs/ot-policies',
// [P11-C S51] danh mục xe công + tài xế — cùng page :kind-driven.
Hrm_Config_Vehicles: '/hrm/configs/vehicles',
Hrm_Config_Drivers: '/hrm/configs/drivers',
// [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ.
// 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
Off_DanhBa: '/directory',

View File

@ -5,7 +5,7 @@ export const MenuKeys = {
Suppliers: 'Suppliers',
Projects: 'Projects',
Departments: 'Departments',
// 4 master catalogs cho Details add form autocomplete (Plan CA S29 — UI fe-user)
// 4 master catalogs cho Details add form autocomplete (Plan CA S29 — UI sang fe-user)
Catalogs: 'Catalogs',
CatalogUnits: 'CatalogUnits',
CatalogMaterials: 'CatalogMaterials',
@ -40,6 +40,9 @@ export const MenuKeys = {
HrmConfigHolidays: 'Hrm_Config_Holidays',
HrmConfigShifts: 'Hrm_Config_Shifts',
HrmConfigOtPolicies: 'Hrm_Config_OtPolicies',
// P11-C (S51) — danh mục xe công + tài xế (dùng khi đặt xe)
HrmConfigVehicles: 'Hrm_Config_Vehicles',
HrmConfigDrivers: 'Hrm_Config_Drivers',
// Module Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1 Session 34, 2026-05-27)
Off: 'Off',
OffDanhBa: 'Off_DanhBa',

View File

@ -6,7 +6,7 @@
import { useState, type ComponentType, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom'
import { Settings2, Calendar, Clock, Pencil, Plus, Plane, Repeat, Trash2, Search } from 'lucide-react'
import { Settings2, Calendar, Clock, Pencil, Plus, Plane, Repeat, Trash2, Search, Car, IdCard } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
@ -109,9 +109,36 @@ const KIND_CONFIG: Record<Kind, {
],
columns: ['Mã', 'Tên', 'Hệ số WD/WE/HL', 'Max h/year', 'Trạng thái'],
},
'vehicles': {
label: 'Xe công',
description: 'Danh mục xe công ty — dùng khi đăng ký đặt xe.',
icon: Car,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'XE-01...' },
{ key: 'name', label: 'Tên xe *', type: 'text', required: true, placeholder: 'Toyota Innova 7 chỗ' },
{ key: 'licensePlate', label: 'Biển số *', type: 'text', required: true, placeholder: '30A-12345' },
{ key: 'seatCount', label: 'Số chỗ', type: 'number', placeholder: '7' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Mã', 'Tên xe', 'Biển số', 'Số chỗ', 'Trạng thái'],
},
'drivers': {
label: 'Tài xế',
description: 'Danh mục tài xế — dùng khi đăng ký đặt xe.',
icon: IdCard,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'TX-01...' },
{ key: 'name', label: 'Họ tên *', type: 'text', required: true, placeholder: 'Nguyễn Văn Tài' },
{ key: 'phoneNumber', label: 'SĐT *', type: 'text', required: true, placeholder: '0901xxxxxx' },
{ key: 'licenseNumber', label: 'Số GPLX *', type: 'text', required: true, placeholder: '012345678' },
{ key: 'licenseClass', label: 'Hạng *', type: 'text', required: true, placeholder: 'B2, C, D, E' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Mã', 'Họ tên', 'SĐT', 'Số GPLX', 'Hạng', 'Trạng thái'],
},
}
const KINDS: Kind[] = ['leave-types', 'holidays', 'shifts', 'ot-policies']
const KINDS: Kind[] = ['leave-types', 'holidays', 'shifts', 'ot-policies', 'vehicles', 'drivers']
type ConfigRow = Record<string, unknown> & { id: string; isActive?: boolean }
@ -427,6 +454,29 @@ function renderCells(kind: Kind, row: ConfigRow) {
</>
)
}
if (kind === 'vehicles') {
return (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code as string}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-600">{row.licensePlate as string}</td>
<td className="px-3 py-2 text-xs text-slate-600">{(row.seatCount as number) ?? ''}</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
if (kind === 'drivers') {
return (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code as string}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2 text-xs text-slate-600">{(row.phoneNumber as string) || ''}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-600">{(row.licenseNumber as string) || ''}</td>
<td className="px-3 py-2 text-xs text-slate-600">{(row.licenseClass as string) || ''}</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
// ot-policies
const mw = Number(row.multiplierWeekday ?? 0)
const me = Number(row.multiplierWeekend ?? 0)

View File

@ -1,7 +1,7 @@
// Types cho HRM Config module — mirror BE Domain.Hrm.* entities.
// Phase 10.2 G-H2 (Mig 35 — S34 schema + S35 BE CRUD wire + FE this file).
export type HrmConfigKind = 'leave-types' | 'holidays' | 'shifts' | 'ot-policies'
export type HrmConfigKind = 'leave-types' | 'holidays' | 'shifts' | 'ot-policies' | 'vehicles' | 'drivers'
// ========== DTOs (mirror BE DTOs HrmConfigFeatures.cs records) ==========
@ -53,6 +53,28 @@ export type OtPolicyDto = {
description: string | null
}
// Phase 11 P11-C (S51) — danh mục xe công + tài xế, dùng khi đăng ký đặt xe.
export type VehicleDto = {
id: string
code: string
name: string
licensePlate: string
seatCount: number | null
isActive: boolean
description: string | null
}
export type DriverDto = {
id: string
code: string
name: string
phoneNumber: string | null
licenseNumber: string | null
licenseClass: string | null
isActive: boolean
description: string | null
}
// ========== Create/Update Input Commands (mirror BE record Command shape) ==========
export type CreateLeaveTypeInput = {
@ -102,3 +124,24 @@ export type CreateOtPolicyInput = {
}
export type UpdateOtPolicyInput = CreateOtPolicyInput & { id: string; isActive: boolean }
export type CreateVehicleInput = {
code: string
name: string
licensePlate: string
seatCount: number | null
description: string | null
}
export type UpdateVehicleInput = CreateVehicleInput & { id: string; isActive: boolean }
export type CreateDriverInput = {
code: string
name: string
phoneNumber: string | null
licenseNumber: string | null
licenseClass: string | null
description: string | null
}
export type UpdateDriverInput = CreateDriverInput & { id: string; isActive: boolean }

View File

@ -134,4 +134,64 @@ public class HrmConfigsController(IMediator mediator) : ControllerBase
await mediator.Send(new DeleteOtPolicyCommand(id), ct);
return NoContent();
}
// ===== Vehicles (Mig 44 P11-C — S51) =====
[HttpGet("vehicles")]
public async Task<List<VehicleDto>> ListVehicles([FromQuery] string? q, [FromQuery] bool? isActive, CancellationToken ct)
=> await mediator.Send(new ListVehiclesQuery(q, isActive), ct);
[HttpPost("vehicles")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> CreateVehicle([FromBody] CreateVehicleCommand body, CancellationToken ct)
{
var id = await mediator.Send(body, ct);
return CreatedAtAction(nameof(ListVehicles), new { id }, new { id });
}
[HttpPut("vehicles/{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateVehicle(Guid id, [FromBody] UpdateVehicleCommand body, CancellationToken ct)
{
if (id != body.Id) return BadRequest("Id mismatch");
await mediator.Send(body, ct);
return NoContent();
}
[HttpDelete("vehicles/{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteVehicle(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteVehicleCommand(id), ct);
return NoContent();
}
// ===== Drivers (Mig 44 P11-C — S51) =====
[HttpGet("drivers")]
public async Task<List<DriverDto>> ListDrivers([FromQuery] string? q, [FromQuery] bool? isActive, CancellationToken ct)
=> await mediator.Send(new ListDriversQuery(q, isActive), ct);
[HttpPost("drivers")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> CreateDriver([FromBody] CreateDriverCommand body, CancellationToken ct)
{
var id = await mediator.Send(body, ct);
return CreatedAtAction(nameof(ListDrivers), new { id }, new { id });
}
[HttpPut("drivers/{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateDriver(Guid id, [FromBody] UpdateDriverCommand body, CancellationToken ct)
{
if (id != body.Id) return BadRequest("Id mismatch");
await mediator.Send(body, ct);
return NoContent();
}
[HttpDelete("drivers/{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteDriver(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteDriverCommand(id), ct);
return NoContent();
}
}

View File

@ -104,6 +104,10 @@ public interface IApplicationDbContext
DbSet<ShiftPattern> ShiftPatterns { get; }
DbSet<OtPolicy> OtPolicies { get; }
// Phase 11 P11-C (Mig 44 — S51) — Danh mục xe công + tài xế reference VehicleBooking.
DbSet<Vehicle> Vehicles { get; }
DbSet<Driver> Drivers { get; }
// Phase 10.2 G-O2 (Mig 36 — S36) — Phòng họp + Booking + Attendee join.
// Overlap check qua SERIALIZABLE tx + EXISTS handler (clean-room SOL,
// mirror NamGroup TblBookingResource S50 pattern). FullCalendar v6 MIT FE.

View File

@ -26,6 +26,8 @@ public record LeaveTypeDto(Guid Id, string Code, string Name, decimal DaysPerYea
public record HolidayDto(Guid Id, int Year, DateOnly Date, string Name, bool IsRecurring, bool IsPaid, bool IsActive, string? Description);
public record ShiftPatternDto(Guid Id, string Code, string Name, TimeOnly StartTime, TimeOnly EndTime, int BreakMinutes, string WorkDays, bool IsActive, string? Description);
public record OtPolicyDto(Guid Id, string Code, string Name, decimal MultiplierWeekday, decimal MultiplierWeekend, decimal MultiplierHoliday, int MaxHoursPerDay, int MaxHoursPerMonth, int MaxHoursPerYear, bool IsActive, string? Description);
public record VehicleDto(Guid Id, string Code, string Name, string LicensePlate, int SeatCount, bool IsActive, string? Description);
public record DriverDto(Guid Id, string Code, string Name, string PhoneNumber, string LicenseNumber, string LicenseClass, bool IsActive, string? Description);
// ===== Region 1: LeaveType =====
@ -437,3 +439,201 @@ public class DeleteOtPolicyHandler(IApplicationDbContext db) : IRequestHandler<D
await db.SaveChangesAsync(ct);
}
}
// ===== Region 5: Vehicle (Mig 44 P11-C — S51) =====
// Code UNIQUE filtered. Catalog xe công reference VehicleBooking workflow app.
// Mirror Region 1 LeaveType EXACT (list Where !IsDeleted + filter q Code/Name + IsActive).
public record ListVehiclesQuery(string? Q = null, bool? IsActive = null) : IRequest<List<VehicleDto>>;
public class ListVehiclesHandler(IApplicationDbContext db) : IRequestHandler<ListVehiclesQuery, List<VehicleDto>>
{
public async Task<List<VehicleDto>> Handle(ListVehiclesQuery req, CancellationToken ct)
{
var q = db.Vehicles.AsNoTracking().Where(x => !x.IsDeleted);
if (!string.IsNullOrWhiteSpace(req.Q))
{
var s = req.Q.ToLower();
q = q.Where(x => x.Code.ToLower().Contains(s) || x.Name.ToLower().Contains(s));
}
if (req.IsActive.HasValue) q = q.Where(x => x.IsActive == req.IsActive.Value);
return await q.OrderBy(x => x.Code)
.Select(x => new VehicleDto(x.Id, x.Code, x.Name, x.LicensePlate, x.SeatCount, x.IsActive, x.Description))
.ToListAsync(ct);
}
}
public record CreateVehicleCommand(string Code, string Name, string LicensePlate, int SeatCount, string? Description) : IRequest<Guid>;
public class CreateVehicleValidator : AbstractValidator<CreateVehicleCommand>
{
public CreateVehicleValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.LicensePlate).NotEmpty().MaximumLength(20);
RuleFor(x => x.SeatCount).GreaterThanOrEqualTo(0);
RuleFor(x => x.Description).MaximumLength(500);
}
}
public class CreateVehicleHandler(IApplicationDbContext db) : IRequestHandler<CreateVehicleCommand, Guid>
{
public async Task<Guid> Handle(CreateVehicleCommand req, CancellationToken ct)
{
if (await db.Vehicles.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct))
throw new ConflictException($"Mã xe '{req.Code}' đã tồn tại.");
var entity = new Vehicle
{
Code = req.Code,
Name = req.Name,
LicensePlate = req.LicensePlate,
SeatCount = req.SeatCount,
Description = req.Description,
IsActive = true,
};
db.Vehicles.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateVehicleCommand(Guid Id, string Code, string Name, string LicensePlate, int SeatCount, bool IsActive, string? Description) : IRequest;
public class UpdateVehicleValidator : AbstractValidator<UpdateVehicleCommand>
{
public UpdateVehicleValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.LicensePlate).NotEmpty().MaximumLength(20);
RuleFor(x => x.SeatCount).GreaterThanOrEqualTo(0);
RuleFor(x => x.Description).MaximumLength(500);
}
}
public class UpdateVehicleHandler(IApplicationDbContext db) : IRequestHandler<UpdateVehicleCommand>
{
public async Task Handle(UpdateVehicleCommand req, CancellationToken ct)
{
var entity = await db.Vehicles.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("Vehicle", req.Id);
if (entity.Code != req.Code && await db.Vehicles.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct))
throw new ConflictException($"Mã xe '{req.Code}' đã tồn tại.");
entity.Code = req.Code;
entity.Name = req.Name;
entity.LicensePlate = req.LicensePlate;
entity.SeatCount = req.SeatCount;
entity.IsActive = req.IsActive;
entity.Description = req.Description;
await db.SaveChangesAsync(ct);
}
}
public record DeleteVehicleCommand(Guid Id) : IRequest;
public class DeleteVehicleHandler(IApplicationDbContext db) : IRequestHandler<DeleteVehicleCommand>
{
public async Task Handle(DeleteVehicleCommand req, CancellationToken ct)
{
var entity = await db.Vehicles.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("Vehicle", req.Id);
db.Vehicles.Remove(entity); // Soft delete via AuditingInterceptor
await db.SaveChangesAsync(ct);
}
}
// ===== Region 6: Driver (Mig 44 P11-C — S51) =====
// Code UNIQUE filtered. Catalog tài xế reference VehicleBooking workflow app.
// Mirror Region 5 Vehicle EXACT. Validator: PhoneNumber Max20 + LicenseNumber Max50 + LicenseClass Max20.
public record ListDriversQuery(string? Q = null, bool? IsActive = null) : IRequest<List<DriverDto>>;
public class ListDriversHandler(IApplicationDbContext db) : IRequestHandler<ListDriversQuery, List<DriverDto>>
{
public async Task<List<DriverDto>> Handle(ListDriversQuery req, CancellationToken ct)
{
var q = db.Drivers.AsNoTracking().Where(x => !x.IsDeleted);
if (!string.IsNullOrWhiteSpace(req.Q))
{
var s = req.Q.ToLower();
q = q.Where(x => x.Code.ToLower().Contains(s) || x.Name.ToLower().Contains(s));
}
if (req.IsActive.HasValue) q = q.Where(x => x.IsActive == req.IsActive.Value);
return await q.OrderBy(x => x.Code)
.Select(x => new DriverDto(x.Id, x.Code, x.Name, x.PhoneNumber, x.LicenseNumber, x.LicenseClass, x.IsActive, x.Description))
.ToListAsync(ct);
}
}
public record CreateDriverCommand(string Code, string Name, string PhoneNumber, string LicenseNumber, string LicenseClass, string? Description) : IRequest<Guid>;
public class CreateDriverValidator : AbstractValidator<CreateDriverCommand>
{
public CreateDriverValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.PhoneNumber).NotEmpty().MaximumLength(20);
RuleFor(x => x.LicenseNumber).NotEmpty().MaximumLength(50);
RuleFor(x => x.LicenseClass).NotEmpty().MaximumLength(20);
RuleFor(x => x.Description).MaximumLength(500);
}
}
public class CreateDriverHandler(IApplicationDbContext db) : IRequestHandler<CreateDriverCommand, Guid>
{
public async Task<Guid> Handle(CreateDriverCommand req, CancellationToken ct)
{
if (await db.Drivers.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct))
throw new ConflictException($"Mã tài xế '{req.Code}' đã tồn tại.");
var entity = new Driver
{
Code = req.Code,
Name = req.Name,
PhoneNumber = req.PhoneNumber,
LicenseNumber = req.LicenseNumber,
LicenseClass = req.LicenseClass,
Description = req.Description,
IsActive = true,
};
db.Drivers.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateDriverCommand(Guid Id, string Code, string Name, string PhoneNumber, string LicenseNumber, string LicenseClass, bool IsActive, string? Description) : IRequest;
public class UpdateDriverValidator : AbstractValidator<UpdateDriverCommand>
{
public UpdateDriverValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.PhoneNumber).NotEmpty().MaximumLength(20);
RuleFor(x => x.LicenseNumber).NotEmpty().MaximumLength(50);
RuleFor(x => x.LicenseClass).NotEmpty().MaximumLength(20);
RuleFor(x => x.Description).MaximumLength(500);
}
}
public class UpdateDriverHandler(IApplicationDbContext db) : IRequestHandler<UpdateDriverCommand>
{
public async Task Handle(UpdateDriverCommand req, CancellationToken ct)
{
var entity = await db.Drivers.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("Driver", req.Id);
if (entity.Code != req.Code && await db.Drivers.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct))
throw new ConflictException($"Mã tài xế '{req.Code}' đã tồn tại.");
entity.Code = req.Code;
entity.Name = req.Name;
entity.PhoneNumber = req.PhoneNumber;
entity.LicenseNumber = req.LicenseNumber;
entity.LicenseClass = req.LicenseClass;
entity.IsActive = req.IsActive;
entity.Description = req.Description;
await db.SaveChangesAsync(ct);
}
}
public record DeleteDriverCommand(Guid Id) : IRequest;
public class DeleteDriverHandler(IApplicationDbContext db) : IRequestHandler<DeleteDriverCommand>
{
public async Task Handle(DeleteDriverCommand req, CancellationToken ct)
{
var entity = await db.Drivers.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("Driver", req.Id);
db.Drivers.Remove(entity); // Soft delete via AuditingInterceptor
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,17 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Hrm;
// Phase 11 P11-C (Mig 44 — S51) — Danh mục tài xế.
// Catalog lookup table reference cho Workflow Apps VehicleBooking (đặt xe công).
// Sample seed 2 tài xế: "TX-01" + "TX-02".
public class Driver : AuditableEntity
{
public string Code { get; set; } = string.Empty; // UNIQUE — "TX-01", "TX-02", ...
public string Name { get; set; } = string.Empty; // Họ tên "Nguyễn Văn Tài"
public string PhoneNumber { get; set; } = string.Empty; // SĐT liên hệ
public string LicenseNumber { get; set; } = string.Empty; // Số GPLX
public string LicenseClass { get; set; } = string.Empty; // Hạng GPLX "B2", "C", "D", "E"
public bool IsActive { get; set; } = true;
public string? Description { get; set; }
}

View File

@ -0,0 +1,16 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Hrm;
// Phase 11 P11-C (Mig 44 — S51) — Danh mục xe công.
// Catalog lookup table reference cho Workflow Apps VehicleBooking (đặt xe công).
// Sample seed 2 xe: "XE-01" Toyota Innova 7 chỗ + "XE-02" Ford Transit 16 chỗ.
public class Vehicle : AuditableEntity
{
public string Code { get; set; } = string.Empty; // UNIQUE — "XE-01", "XE-02", ...
public string Name { get; set; } = string.Empty; // "Toyota Innova", "Ford Transit"
public string LicensePlate { get; set; } = string.Empty; // Biển số "30A-12345"
public int SeatCount { get; set; } // Số chỗ ngồi
public bool IsActive { get; set; } = true;
public string? Description { get; set; }
}

View File

@ -90,6 +90,9 @@ public static class MenuKeys
public const string HrmConfigHolidays = "Hrm_Config_Holidays"; // Ngày lễ
public const string HrmConfigShifts = "Hrm_Config_Shifts"; // Ca làm việc
public const string HrmConfigOtPolicies = "Hrm_Config_OtPolicies"; // Chính sách OT
// Phase 11 P11-C (Mig 44 — S51) — Danh mục xe công + tài xế (reference VehicleBooking).
public const string HrmConfigVehicles = "Hrm_Config_Vehicles"; // Xe công
public const string HrmConfigDrivers = "Hrm_Config_Drivers"; // Tài xế
// ============================================================
// Module Văn phòng số (Phase 10.2 G-O1+ S34 2026-05-27).
@ -147,6 +150,7 @@ public static class MenuKeys
Budgets, BudgetList, BudgetCreate, BudgetPending,
Hrm, HrmHoSo, // Mig 34 — Phase 10.1
HrmConfig, HrmConfigLeaveTypes, HrmConfigHolidays, HrmConfigShifts, HrmConfigOtPolicies, // Mig 35 — Phase 10.2 G-H2
HrmConfigVehicles, HrmConfigDrivers, // Mig 44 — Phase 11 P11-C
Off, OffDanhBa, // Phase 10.2 G-O1 — Văn phòng số
OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất

View File

@ -97,6 +97,10 @@ public class ApplicationDbContext
public DbSet<ShiftPattern> ShiftPatterns => Set<ShiftPattern>();
public DbSet<OtPolicy> OtPolicies => Set<OtPolicy>();
// Phase 11 P11-C (Mig 44 — S51) — Danh mục xe công + tài xế.
public DbSet<Vehicle> Vehicles => Set<Vehicle>();
public DbSet<Driver> Drivers => Set<Driver>();
// Phase 10.2 G-O2 (Mig 36 — S36) — Phòng họp + Booking + Attendee join.
public DbSet<MeetingRoom> MeetingRooms => Set<MeetingRoom>();
public DbSet<MeetingBooking> MeetingBookings => Set<MeetingBooking>();

View File

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Hrm;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 44 P11-C (S51) — Danh mục tài xế. Catalog standalone, no FK.
// UNIQUE Code filtered WHERE IsDeleted=0 (gotcha #57 — soft-deleted slot reusable,
// khớp app-level !IsDeleted check + pattern HolidayConfiguration/Catalogs/Contract/PE).
public class DriverConfiguration : IEntityTypeConfiguration<Driver>
{
public void Configure(EntityTypeBuilder<Driver> e)
{
e.ToTable("Drivers");
e.Property(x => x.Code).HasMaxLength(50).IsRequired();
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.PhoneNumber).HasMaxLength(20).IsRequired();
e.Property(x => x.LicenseNumber).HasMaxLength(50).IsRequired();
e.Property(x => x.LicenseClass).HasMaxLength(20).IsRequired();
e.Property(x => x.Description).HasMaxLength(500);
e.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0");
}
}

View File

@ -16,6 +16,6 @@ public class LeaveTypeConfiguration : IEntityTypeConfiguration<LeaveType>
e.Property(x => x.DaysPerYear).HasColumnType("decimal(5,2)");
e.Property(x => x.Description).HasMaxLength(500);
e.HasIndex(x => x.Code).IsUnique();
e.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0"); // Mig 45 (S51 gotcha #57) — soft-deleted slot reusable, khớp handler !IsDeleted
}
}

View File

@ -19,6 +19,6 @@ public class OtPolicyConfiguration : IEntityTypeConfiguration<OtPolicy>
e.Property(x => x.MultiplierHoliday).HasColumnType("decimal(4,2)");
e.Property(x => x.Description).HasMaxLength(500);
e.HasIndex(x => x.Code).IsUnique();
e.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0"); // Mig 45 (S51 gotcha #57) — soft-deleted slot reusable, khớp handler !IsDeleted
}
}

View File

@ -16,6 +16,6 @@ public class ShiftPatternConfiguration : IEntityTypeConfiguration<ShiftPattern>
e.Property(x => x.WorkDays).HasMaxLength(100).IsRequired(); // "Mon,Tue,Wed,Thu,Fri"
e.Property(x => x.Description).HasMaxLength(500);
e.HasIndex(x => x.Code).IsUnique();
e.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0"); // Mig 45 (S51 gotcha #57) — soft-deleted slot reusable, khớp handler !IsDeleted
}
}

View File

@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Hrm;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 44 P11-C (S51) — Danh mục xe công. Catalog standalone, no FK.
// UNIQUE Code filtered WHERE IsDeleted=0 (gotcha #57 — soft-deleted slot reusable,
// khớp app-level !IsDeleted check + pattern HolidayConfiguration/Catalogs/Contract/PE).
public class VehicleConfiguration : IEntityTypeConfiguration<Vehicle>
{
public void Configure(EntityTypeBuilder<Vehicle> e)
{
e.ToTable("Vehicles");
e.Property(x => x.Code).HasMaxLength(50).IsRequired();
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.LicensePlate).HasMaxLength(20).IsRequired();
e.Property(x => x.Description).HasMaxLength(500);
e.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0");
}
}

View File

@ -1755,6 +1755,9 @@ public static class DbInitializer
(MenuKeys.HrmConfigHolidays, "Ngày lễ", MenuKeys.HrmConfig, 2, "PartyPopper"),
(MenuKeys.HrmConfigShifts, "Ca làm việc", MenuKeys.HrmConfig, 3, "Clock"),
(MenuKeys.HrmConfigOtPolicies, "Chính sách OT", MenuKeys.HrmConfig, 4, "TimerReset"),
// Phase 11 P11-C (Mig 44 — S51). 2 catalog leaf xe công + tài xế.
(MenuKeys.HrmConfigVehicles, "Xe công", MenuKeys.HrmConfig, 5, "Car"),
(MenuKeys.HrmConfigDrivers, "Tài xế", MenuKeys.HrmConfig, 6, "UserCog"),
// Module Văn phòng số (Phase 10.2 G-O1+ S34). 1 root + leaf Danh bạ.
// Future leaf: Off_PhongHop (G-O2) + workflow apps Off_DeXuat/DonTu/DatXe/ItTicket.
@ -2331,9 +2334,11 @@ public static class DbInitializer
if (await db.LeaveTypes.AnyAsync()
&& await db.Holidays.AnyAsync()
&& await db.ShiftPatterns.AnyAsync()
&& await db.OtPolicies.AnyAsync())
&& await db.OtPolicies.AnyAsync()
&& await db.Vehicles.AnyAsync()
&& await db.Drivers.AnyAsync())
{
logger.LogInformation("SeedHrmConfigsAsync: skip — đã có 4 catalog.");
logger.LogInformation("SeedHrmConfigsAsync: skip — đã có 6 catalog.");
return;
}
@ -2435,8 +2440,44 @@ public static class DbInitializer
});
}
// 2 Vehicle sample (Mig 44 P11-C — reference VehicleBooking dropdown ngày 1).
if (!await db.Vehicles.AnyAsync())
{
db.Vehicles.AddRange(
new Vehicle
{
Code = "XE-01", Name = "Toyota Innova", LicensePlate = "30A-12345",
SeatCount = 7,
Description = "Xe 7 chỗ đưa đón công tác nội thành.",
},
new Vehicle
{
Code = "XE-02", Name = "Ford Transit", LicensePlate = "30A-67890",
SeatCount = 16,
Description = "Xe 16 chỗ đưa đón đoàn / công tác tỉnh.",
});
}
// 2 Driver sample (Mig 44 P11-C — reference VehicleBooking dropdown ngày 1).
if (!await db.Drivers.AnyAsync())
{
db.Drivers.AddRange(
new Driver
{
Code = "TX-01", Name = "Nguyễn Văn Tài", PhoneNumber = "0901234567",
LicenseNumber = "012345678901", LicenseClass = "D",
Description = "Tài xế xe 7-16 chỗ, GPLX hạng D.",
},
new Driver
{
Code = "TX-02", Name = "Trần Văn Lái", PhoneNumber = "0907654321",
LicenseNumber = "098765432109", LicenseClass = "E",
Description = "Tài xế xe khách lớn, GPLX hạng E.",
});
}
await db.SaveChangesAsync();
logger.LogInformation("SeedHrmConfigsAsync: seeded 5 LeaveTypes + 10 Holidays 2026 + 3 ShiftPatterns + 1 OtPolicy default.");
logger.LogInformation("SeedHrmConfigsAsync: seeded 5 LeaveTypes + 10 Holidays 2026 + 3 ShiftPatterns + 1 OtPolicy + 2 Vehicles + 2 Drivers.");
}
// Plan G-O2 (Mig 36 — S36 2026-05-28). 4 sample MeetingRoom seed.

View File

@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddVehicleAndDriverCatalogs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Drivers",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
PhoneNumber = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
LicenseNumber = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
LicenseClass = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Drivers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Vehicles",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
LicensePlate = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
SeatCount = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Vehicles", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Drivers_Code",
table: "Drivers",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0");
migrationBuilder.CreateIndex(
name: "IX_Vehicles_Code",
table: "Vehicles",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Drivers");
migrationBuilder.DropTable(
name: "Vehicles");
}
}
}

View File

@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class FilterHrmCatalogUniqueIndexesByIsDeleted : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ShiftPatterns_Code",
table: "ShiftPatterns");
migrationBuilder.DropIndex(
name: "IX_OtPolicies_Code",
table: "OtPolicies");
migrationBuilder.DropIndex(
name: "IX_LeaveTypes_Code",
table: "LeaveTypes");
migrationBuilder.CreateIndex(
name: "IX_ShiftPatterns_Code",
table: "ShiftPatterns",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0");
migrationBuilder.CreateIndex(
name: "IX_OtPolicies_Code",
table: "OtPolicies",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0");
migrationBuilder.CreateIndex(
name: "IX_LeaveTypes_Code",
table: "LeaveTypes",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ShiftPatterns_Code",
table: "ShiftPatterns");
migrationBuilder.DropIndex(
name: "IX_OtPolicies_Code",
table: "OtPolicies");
migrationBuilder.DropIndex(
name: "IX_LeaveTypes_Code",
table: "LeaveTypes");
migrationBuilder.CreateIndex(
name: "IX_ShiftPatterns_Code",
table: "ShiftPatterns",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OtPolicies_Code",
table: "OtPolicies",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LeaveTypes_Code",
table: "LeaveTypes",
column: "Code",
unique: true);
}
}
}

View File

@ -1894,6 +1894,74 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("ContractTemplates", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Hrm.Driver", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("LicenseClass")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("LicenseNumber")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("Drivers", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeCodeSequence", b =>
{
b.Property<string>("Prefix")
@ -2670,7 +2738,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("LeaveTypes", (string)null);
});
@ -2740,7 +2809,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("OtPolicies", (string)null);
});
@ -2806,11 +2876,73 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("ShiftPatterns", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Hrm.Vehicle", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("LicensePlate")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("SeatCount")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("Vehicles", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.Property<string>("Key")

View File

@ -0,0 +1,226 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Hrm;
using SolutionErp.Domain.Hrm;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// P11-C HMW Wave 2 (S51 2026-06-08) — gotcha #57 filtered-unique guard cho 4 HRM catalog
// có UNIQUE Code. Mirror EXACT HrmConfigHolidayTests Case 7 (filtered-unique-allows-reuse):
// seed 1 row soft-deleted (IsDeleted=true) trong slot Code X →
// gọi Create*Handler tạo row mới cùng Code X →
// filtered index `[IsDeleted]=0` KHÔNG tính row đã xoá → SaveChanges thành công.
//
// CHỐT theo CODE (single source of truth) — 2 nhóm hành vi KHÁC nhau theo config:
//
// ✅ Vehicle + Driver (Mig 44 — config ĐÃ filtered `.HasFilter("[IsDeleted] = 0")`)
// VehicleConfiguration.cs:21 / DriverConfiguration.cs:23.
// → recreate-on-soft-deleted-slot PHẢI GREEN (chứng minh 2 catalog mới đúng).
//
// ❌ LeaveType + ShiftPattern (config BARE `.IsUnique()` KHÔNG filter = gotcha #57)
// LeaveTypeConfiguration.cs:19 / ShiftPatternConfiguration.cs:19.
// → DB UNIQUE tính CẢ row soft-deleted → handler app-check `!IsDeleted` PASS (slot
// trông trống) → Add + SaveChanges → DB ném DbUpdateException (cùng bug class
// Holiday từng RED trước Mig 43).
// Test viết theo behavior MONG MUỐN (recreate SUCCESS, mirror Vehicle/Driver) →
// DỰ KIẾN RED bây giờ = test-before REPRODUCE bug. Sau khi em main fix Mig 45
// (.HasFilter cho 2 config) → 2 test này GREEN.
//
// LƯU Ý SOFT-DELETE TRONG TEST (giống HolidayTests):
// AuditingInterceptor (production soft-delete: Deleted→Modified + IsDeleted=true) KHÔNG
// wire trong SqliteDbFixture → `Remove + SaveChanges` ở fixture = HARD delete (xoá vật lý,
// không còn row → không test được filtered-unique). Vì vậy ta SEED row với IsDeleted=true
// thủ công để mô phỏng đúng trạng thái hậu-soft-delete (slot bị row đã xoá chiếm chỗ DB).
//
// Handlers chỉ cần IApplicationDbContext (no ICurrentUser/IDateTime) → new trực tiếp với fix.Db.
public class HrmConfigFilteredUniqueTests
{
// ============================================================================
// GROUP A — Vehicle + Driver: filtered config ĐÃ ĐÚNG → PHẢI GREEN
// ============================================================================
// ---- Vehicle ----
[Fact]
public async Task CreateVehicle_OnSoftDeletedCodeSlot_Succeeds_FilteredUniqueAllowsReuse()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
// Seed 1 xe Code="XE-01" đã soft-delete (IsDeleted=true).
db.Vehicles.Add(new Vehicle
{
Id = Guid.NewGuid(),
Code = "XE-01",
Name = "Toyota Innova cũ",
LicensePlate = "30A-00001",
SeatCount = 7,
IsActive = true,
IsDeleted = true,
});
await db.SaveChangesAsync(CancellationToken.None);
// Tạo xe mới CÙNG Code="XE-01" → filtered index bỏ qua row đã xoá → OK.
var id = await new CreateVehicleHandler(db)
.Handle(new CreateVehicleCommand("XE-01", "Toyota Innova mới", "30A-99999", 7, null),
CancellationToken.None);
id.Should().NotBeEmpty("filtered unique index không tính row đã soft-delete → slot reusable");
(await db.Vehicles.CountAsync(x => x.Code == "XE-01" && !x.IsDeleted))
.Should().Be(1, "chỉ 1 row active chiếm slot Code");
(await db.Vehicles.CountAsync(x => x.Code == "XE-01"))
.Should().Be(2, "soft-deleted gốc giữ lại cho audit + active mới");
}
// ---- Driver ----
[Fact]
public async Task CreateDriver_OnSoftDeletedCodeSlot_Succeeds_FilteredUniqueAllowsReuse()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
// Seed 1 tài xế Code="TX-01" đã soft-delete.
db.Drivers.Add(new Driver
{
Id = Guid.NewGuid(),
Code = "TX-01",
Name = "Nguyễn Văn Tài (cũ)",
PhoneNumber = "0900000001",
LicenseNumber = "GPLX-OLD-01",
LicenseClass = "B2",
IsActive = true,
IsDeleted = true,
});
await db.SaveChangesAsync(CancellationToken.None);
var id = await new CreateDriverHandler(db)
.Handle(new CreateDriverCommand("TX-01", "Trần Văn Mới", "0900099999", "GPLX-NEW-01", "C", null),
CancellationToken.None);
id.Should().NotBeEmpty("filtered unique index không tính row đã soft-delete → slot reusable");
(await db.Drivers.CountAsync(x => x.Code == "TX-01" && !x.IsDeleted))
.Should().Be(1, "chỉ 1 row active chiếm slot Code");
(await db.Drivers.CountAsync(x => x.Code == "TX-01"))
.Should().Be(2, "soft-deleted gốc giữ lại cho audit + active mới");
}
// ============================================================================
// GROUP B — LeaveType + ShiftPattern + OtPolicy: BARE .IsUnique() = gotcha #57 → DỰ KIẾN RED
// (test-before REPRODUCE; assert behavior mong muốn = recreate SUCCESS).
// Sau Mig 45 (em main thêm .HasFilter cho 3 config) → 3 test này GREEN.
// (OtPolicy bị BỎ SÓT khỏi backlog gotcha #57 — em main bắt được S51 khi grep toàn bộ HRM catalog.)
// ============================================================================
// ---- LeaveType (gotcha #57 — DỰ KIẾN RED) ----
[Fact]
public async Task CreateLeaveType_OnSoftDeletedCodeSlot_Succeeds_FilteredUniqueAllowsReuse()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
// Seed 1 loại phép Code="ANNUAL" đã soft-delete.
db.LeaveTypes.Add(new LeaveType
{
Id = Guid.NewGuid(),
Code = "ANNUAL",
Name = "Phép năm (cũ)",
DaysPerYear = 12m,
IsPaid = true,
RequiresAttachment = false,
IsActive = true,
IsDeleted = true,
});
await db.SaveChangesAsync(CancellationToken.None);
// Tạo loại phép mới CÙNG Code="ANNUAL". Handler app-check `!IsDeleted` PASS (slot trống
// ở mức app), nhưng DB UNIQUE bare tính cả row đã xoá → SaveChanges sẽ ném
// DbUpdateException CHỪNG NÀO Mig 45 chưa filter index. Đây là gotcha #57 confirmed.
var act = async () => await new CreateLeaveTypeHandler(db)
.Handle(new CreateLeaveTypeCommand("ANNUAL", "Phép năm (mới)", 12m, true, false, null),
CancellationToken.None);
await act.Should().NotThrowAsync(
"MONG MUỐN: filtered unique cho phép tái dùng slot đã soft-delete (gotcha #57 — RED đến khi Mig 45 fix)");
(await db.LeaveTypes.CountAsync(x => x.Code == "ANNUAL" && !x.IsDeleted))
.Should().Be(1, "chỉ 1 row active chiếm slot Code");
(await db.LeaveTypes.CountAsync(x => x.Code == "ANNUAL"))
.Should().Be(2, "soft-deleted gốc giữ lại cho audit + active mới");
}
// ---- ShiftPattern (gotcha #57 — DỰ KIẾN RED) ----
[Fact]
public async Task CreateShift_OnSoftDeletedCodeSlot_Succeeds_FilteredUniqueAllowsReuse()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
// Seed 1 ca làm Code="HC" đã soft-delete.
db.ShiftPatterns.Add(new ShiftPattern
{
Id = Guid.NewGuid(),
Code = "HC",
Name = "Hành chính (cũ)",
StartTime = new TimeOnly(8, 0),
EndTime = new TimeOnly(17, 0),
BreakMinutes = 60,
WorkDays = "Mon,Tue,Wed,Thu,Fri",
IsActive = true,
IsDeleted = true,
});
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new CreateShiftHandler(db)
.Handle(new CreateShiftCommand("HC", "Hành chính (mới)", new TimeOnly(8, 0), new TimeOnly(17, 30),
60, "Mon,Tue,Wed,Thu,Fri", null), CancellationToken.None);
await act.Should().NotThrowAsync(
"MONG MUỐN: filtered unique cho phép tái dùng slot đã soft-delete (gotcha #57 — RED đến khi Mig 45 fix)");
(await db.ShiftPatterns.CountAsync(x => x.Code == "HC" && !x.IsDeleted))
.Should().Be(1, "chỉ 1 row active chiếm slot Code");
(await db.ShiftPatterns.CountAsync(x => x.Code == "HC"))
.Should().Be(2, "soft-deleted gốc giữ lại cho audit + active mới");
}
// ---- OtPolicy (gotcha #57 — bare .IsUnique() BỊ BỎ SÓT khỏi backlog, em main bắt được S51) ----
[Fact]
public async Task CreateOtPolicy_OnSoftDeletedCodeSlot_Succeeds_FilteredUniqueAllowsReuse()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
// Seed 1 chính sách OT Code="STANDARD" đã soft-delete.
db.OtPolicies.Add(new OtPolicy
{
Id = Guid.NewGuid(),
Code = "STANDARD",
Name = "Chính sách OT chuẩn (cũ)",
MultiplierWeekday = 1.5m,
MultiplierWeekend = 2.0m,
MultiplierHoliday = 3.0m,
MaxHoursPerDay = 4,
MaxHoursPerMonth = 40,
MaxHoursPerYear = 200,
IsActive = true,
IsDeleted = true,
});
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new CreateOtPolicyHandler(db)
.Handle(new CreateOtPolicyCommand("STANDARD", "Chính sách OT chuẩn (mới)", 1.5m, 2.0m, 3.0m, 4, 40, 200, null),
CancellationToken.None);
await act.Should().NotThrowAsync(
"MONG MUỐN: filtered unique cho phép tái dùng slot đã soft-delete (gotcha #57 — OtPolicy bị bỏ sót, fix chung Mig 45)");
(await db.OtPolicies.CountAsync(x => x.Code == "STANDARD" && !x.IsDeleted))
.Should().Be(1, "chỉ 1 row active chiếm slot Code");
(await db.OtPolicies.CountAsync(x => x.Code == "STANDARD"))
.Should().Be(2, "soft-deleted gốc giữ lại cho audit + active mới");
}
}