[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
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:
@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user