[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_Holidays: '/hrm/configs/holidays',
Hrm_Config_Shifts: '/hrm/configs/shifts', Hrm_Config_Shifts: '/hrm/configs/shifts',
Hrm_Config_OtPolicies: '/hrm/configs/ot-policies', 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ộ. // [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. // 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
Off_DanhBa: '/directory', Off_DanhBa: '/directory',

View File

@ -40,6 +40,9 @@ export const MenuKeys = {
HrmConfigHolidays: 'Hrm_Config_Holidays', HrmConfigHolidays: 'Hrm_Config_Holidays',
HrmConfigShifts: 'Hrm_Config_Shifts', HrmConfigShifts: 'Hrm_Config_Shifts',
HrmConfigOtPolicies: 'Hrm_Config_OtPolicies', 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) // Module Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1 Session 34, 2026-05-27)
Off: 'Off', Off: 'Off',
OffDanhBa: 'Off_DanhBa', OffDanhBa: 'Off_DanhBa',

View File

@ -6,7 +6,7 @@
import { useState, type ComponentType, type FormEvent } from 'react' import { useState, type ComponentType, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom' 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 { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button' 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'], 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 } 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 // ot-policies
const mw = Number(row.multiplierWeekday ?? 0) const mw = Number(row.multiplierWeekday ?? 0)
const me = Number(row.multiplierWeekend ?? 0) const me = Number(row.multiplierWeekend ?? 0)

View File

@ -1,7 +1,7 @@
// Types cho HRM Config module — mirror BE Domain.Hrm.* entities. // 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). // 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) ========== // ========== DTOs (mirror BE DTOs HrmConfigFeatures.cs records) ==========
@ -53,6 +53,28 @@ export type OtPolicyDto = {
description: string | null 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) ========== // ========== Create/Update Input Commands (mirror BE record Command shape) ==========
export type CreateLeaveTypeInput = { export type CreateLeaveTypeInput = {
@ -102,3 +124,24 @@ export type CreateOtPolicyInput = {
} }
export type UpdateOtPolicyInput = CreateOtPolicyInput & { id: string; isActive: boolean } 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_Holidays: '/hrm/configs/holidays',
Hrm_Config_Shifts: '/hrm/configs/shifts', Hrm_Config_Shifts: '/hrm/configs/shifts',
Hrm_Config_OtPolicies: '/hrm/configs/ot-policies', 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ộ. // [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. // 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
Off_DanhBa: '/directory', Off_DanhBa: '/directory',

View File

@ -5,7 +5,7 @@ export const MenuKeys = {
Suppliers: 'Suppliers', Suppliers: 'Suppliers',
Projects: 'Projects', Projects: 'Projects',
Departments: 'Departments', 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', Catalogs: 'Catalogs',
CatalogUnits: 'CatalogUnits', CatalogUnits: 'CatalogUnits',
CatalogMaterials: 'CatalogMaterials', CatalogMaterials: 'CatalogMaterials',
@ -40,6 +40,9 @@ export const MenuKeys = {
HrmConfigHolidays: 'Hrm_Config_Holidays', HrmConfigHolidays: 'Hrm_Config_Holidays',
HrmConfigShifts: 'Hrm_Config_Shifts', HrmConfigShifts: 'Hrm_Config_Shifts',
HrmConfigOtPolicies: 'Hrm_Config_OtPolicies', 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) // Module Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1 Session 34, 2026-05-27)
Off: 'Off', Off: 'Off',
OffDanhBa: 'Off_DanhBa', OffDanhBa: 'Off_DanhBa',

View File

@ -6,7 +6,7 @@
import { useState, type ComponentType, type FormEvent } from 'react' import { useState, type ComponentType, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom' 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 { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button' 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'], 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 } 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 // ot-policies
const mw = Number(row.multiplierWeekday ?? 0) const mw = Number(row.multiplierWeekday ?? 0)
const me = Number(row.multiplierWeekend ?? 0) const me = Number(row.multiplierWeekend ?? 0)

View File

@ -1,7 +1,7 @@
// Types cho HRM Config module — mirror BE Domain.Hrm.* entities. // 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). // 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) ========== // ========== DTOs (mirror BE DTOs HrmConfigFeatures.cs records) ==========
@ -53,6 +53,28 @@ export type OtPolicyDto = {
description: string | null 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) ========== // ========== Create/Update Input Commands (mirror BE record Command shape) ==========
export type CreateLeaveTypeInput = { export type CreateLeaveTypeInput = {
@ -102,3 +124,24 @@ export type CreateOtPolicyInput = {
} }
export type UpdateOtPolicyInput = CreateOtPolicyInput & { id: string; isActive: boolean } 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); await mediator.Send(new DeleteOtPolicyCommand(id), ct);
return NoContent(); 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<ShiftPattern> ShiftPatterns { get; }
DbSet<OtPolicy> OtPolicies { 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. // 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, // Overlap check qua SERIALIZABLE tx + EXISTS handler (clean-room SOL,
// mirror NamGroup TblBookingResource S50 pattern). FullCalendar v6 MIT FE. // 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 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 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 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 ===== // ===== Region 1: LeaveType =====
@ -437,3 +439,201 @@ public class DeleteOtPolicyHandler(IApplicationDbContext db) : IRequestHandler<D
await db.SaveChangesAsync(ct); 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 HrmConfigHolidays = "Hrm_Config_Holidays"; // Ngày lễ
public const string HrmConfigShifts = "Hrm_Config_Shifts"; // Ca làm việc public const string HrmConfigShifts = "Hrm_Config_Shifts"; // Ca làm việc
public const string HrmConfigOtPolicies = "Hrm_Config_OtPolicies"; // Chính sách OT 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). // 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, Budgets, BudgetList, BudgetCreate, BudgetPending,
Hrm, HrmHoSo, // Mig 34 — Phase 10.1 Hrm, HrmHoSo, // Mig 34 — Phase 10.1
HrmConfig, HrmConfigLeaveTypes, HrmConfigHolidays, HrmConfigShifts, HrmConfigOtPolicies, // Mig 35 — Phase 10.2 G-H2 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ố 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 OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất 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<ShiftPattern> ShiftPatterns => Set<ShiftPattern>();
public DbSet<OtPolicy> OtPolicies => Set<OtPolicy>(); 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. // Phase 10.2 G-O2 (Mig 36 — S36) — Phòng họp + Booking + Attendee join.
public DbSet<MeetingRoom> MeetingRooms => Set<MeetingRoom>(); public DbSet<MeetingRoom> MeetingRooms => Set<MeetingRoom>();
public DbSet<MeetingBooking> MeetingBookings => Set<MeetingBooking>(); 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.DaysPerYear).HasColumnType("decimal(5,2)");
e.Property(x => x.Description).HasMaxLength(500); 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.MultiplierHoliday).HasColumnType("decimal(4,2)");
e.Property(x => x.Description).HasMaxLength(500); 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.WorkDays).HasMaxLength(100).IsRequired(); // "Mon,Tue,Wed,Thu,Fri"
e.Property(x => x.Description).HasMaxLength(500); 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.HrmConfigHolidays, "Ngày lễ", MenuKeys.HrmConfig, 2, "PartyPopper"),
(MenuKeys.HrmConfigShifts, "Ca làm việc", MenuKeys.HrmConfig, 3, "Clock"), (MenuKeys.HrmConfigShifts, "Ca làm việc", MenuKeys.HrmConfig, 3, "Clock"),
(MenuKeys.HrmConfigOtPolicies, "Chính sách OT", MenuKeys.HrmConfig, 4, "TimerReset"), (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ạ. // 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. // 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() if (await db.LeaveTypes.AnyAsync()
&& await db.Holidays.AnyAsync() && await db.Holidays.AnyAsync()
&& await db.ShiftPatterns.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; 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(); 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. // 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); 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 => modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeCodeSequence", b =>
{ {
b.Property<string>("Prefix") b.Property<string>("Prefix")
@ -2670,7 +2738,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Code") b.HasIndex("Code")
.IsUnique(); .IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("LeaveTypes", (string)null); b.ToTable("LeaveTypes", (string)null);
}); });
@ -2740,7 +2809,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Code") b.HasIndex("Code")
.IsUnique(); .IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("OtPolicies", (string)null); b.ToTable("OtPolicies", (string)null);
}); });
@ -2806,11 +2876,73 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Code") b.HasIndex("Code")
.IsUnique(); .IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("ShiftPatterns", (string)null); 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 => modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{ {
b.Property<string>("Key") 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");
}
}