[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

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