[CLAUDE] Infra: gotcha #57 EXT Master filtered-unique Department/Supplier/Project (Mig 47)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m15s

Extend gotcha #57 filtered-unique fix to 3 Master catalogs (4th/5th/6th cumulative after Holiday Mig 43 S45 + HRM x3 Mig 45 S51).

Root cause: app-level dup-check db.X.AnyAsync(Code==req) runs through HasQueryFilter(!IsDeleted) so it ignores soft-deleted rows, but the bare .IsUnique() DB index counted them -> admin delete+re-add same Code = reachable 500. Fix aligns index with query filter via .HasFilter("[IsDeleted] = 0").

- Department/Project/Supplier Configuration: unique Code index + .HasFilter (Supplier Type index untouched)
- Mig 47 FilterMasterCatalogUniqueIndexesByIsDeleted (Up: 3x DropIndex+CreateIndex filtered; Down reversible)
- test-before MasterCatalogFilteredUniqueTests (3 RED->GREEN, delete+re-add same Code)
- Tests 200 -> 203 (58 Domain + 145 Infra)

Pipeline: test-specialist -> implementer-backend -> reviewer (PASS, 0 issues).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-08 14:28:04 +07:00
parent f440c194a8
commit 44b9e542fb
7 changed files with 6745 additions and 6 deletions

View File

@ -15,7 +15,7 @@ public class DepartmentConfiguration : IEntityTypeConfiguration<Department>
b.Property(x => x.Name).HasMaxLength(200).IsRequired(); b.Property(x => x.Name).HasMaxLength(200).IsRequired();
b.Property(x => x.Note).HasMaxLength(1000); b.Property(x => x.Note).HasMaxLength(1000);
b.HasIndex(x => x.Code).IsUnique(); b.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0"); // Mig 47 (gotcha #57 EXT) — soft-deleted slot reusable, khớp HasQueryFilter !IsDeleted app-check
b.HasQueryFilter(x => !x.IsDeleted); b.HasQueryFilter(x => !x.IsDeleted);
} }

View File

@ -16,7 +16,7 @@ public class ProjectConfiguration : IEntityTypeConfiguration<Project>
b.Property(x => x.BudgetTotal).HasPrecision(18, 2); b.Property(x => x.BudgetTotal).HasPrecision(18, 2);
b.Property(x => x.Note).HasMaxLength(1000); b.Property(x => x.Note).HasMaxLength(1000);
b.HasIndex(x => x.Code).IsUnique(); b.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0"); // Mig 47 (gotcha #57 EXT) — soft-deleted slot reusable, khớp HasQueryFilter !IsDeleted app-check
b.HasQueryFilter(x => !x.IsDeleted); b.HasQueryFilter(x => !x.IsDeleted);
} }

View File

@ -21,7 +21,7 @@ public class SupplierConfiguration : IEntityTypeConfiguration<Supplier>
b.Property(x => x.ContactPerson).HasMaxLength(200); b.Property(x => x.ContactPerson).HasMaxLength(200);
b.Property(x => x.Note).HasMaxLength(1000); b.Property(x => x.Note).HasMaxLength(1000);
b.HasIndex(x => x.Code).IsUnique(); b.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0"); // Mig 47 (gotcha #57 EXT) — soft-deleted slot reusable, khớp HasQueryFilter !IsDeleted app-check
b.HasIndex(x => x.Type); b.HasIndex(x => x.Type);
b.HasQueryFilter(x => !x.IsDeleted); b.HasQueryFilter(x => !x.IsDeleted);

View File

@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class FilterMasterCatalogUniqueIndexesByIsDeleted : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Suppliers_Code",
table: "Suppliers");
migrationBuilder.DropIndex(
name: "IX_Projects_Code",
table: "Projects");
migrationBuilder.DropIndex(
name: "IX_Departments_Code",
table: "Departments");
migrationBuilder.CreateIndex(
name: "IX_Suppliers_Code",
table: "Suppliers",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0");
migrationBuilder.CreateIndex(
name: "IX_Projects_Code",
table: "Projects",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0");
migrationBuilder.CreateIndex(
name: "IX_Departments_Code",
table: "Departments",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Suppliers_Code",
table: "Suppliers");
migrationBuilder.DropIndex(
name: "IX_Projects_Code",
table: "Projects");
migrationBuilder.DropIndex(
name: "IX_Departments_Code",
table: "Departments");
migrationBuilder.CreateIndex(
name: "IX_Suppliers_Code",
table: "Suppliers",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Projects_Code",
table: "Projects",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Departments_Code",
table: "Departments",
column: "Code",
unique: true);
}
}
}

View File

@ -3448,7 +3448,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("Departments", (string)null); b.ToTable("Departments", (string)null);
}); });
@ -3510,7 +3511,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("Projects", (string)null); b.ToTable("Projects", (string)null);
}); });
@ -3582,7 +3584,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Code") b.HasIndex("Code")
.IsUnique(); .IsUnique()
.HasFilter("[IsDeleted] = 0");
b.HasIndex("Type"); b.HasIndex("Type");

View File

@ -0,0 +1,137 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Master.Departments;
using SolutionErp.Application.Master.Projects;
using SolutionErp.Application.Master.Suppliers.Commands.CreateSupplier;
using SolutionErp.Domain.Master;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// P11-D HMW Wave 2 (S52 2026-06-08) — gotcha #57 filtered-unique guard cho 3 Master catalog
// có UNIQUE Code hiện đang BARE `.IsUnique()` KHÔNG `.HasFilter("[IsDeleted]=0")`:
// - Department (DepartmentConfiguration.cs:18, soft-delete :20)
// - Project (ProjectConfiguration.cs:19, soft-delete :21)
// - Supplier (SupplierConfiguration.cs:24, soft-delete :27)
//
// Mirror EXACT GROUP B của HrmConfigFilteredUniqueTests (S51 LeaveType/Shift/OtPolicy):
// seed 1 row soft-deleted (IsDeleted=true) trong slot Code X →
// gọi Create*CommandHandler tạo row mới CÙNG Code X →
// MONG MUỐN: filtered index `[IsDeleted]=0` bỏ qua row đã xoá → SaveChanges thành công,
// query default-filtered trả ĐÚNG 1 row active.
//
// gotcha #57 = BUG-CLASS → test-before BẮT BUỘC (docs/rules.md §7). Assert behavior FIXED.
//
// DỰ KIẾN RED TRÊN CODE HIỆN TẠI:
// Handler app-check `db.X.AnyAsync(x => x.Code == request.Code)` chạy QUA HasQueryFilter
// (!IsDeleted) → row soft-deleted bị loại → app-check PASS (slot trông trống ở mức app) →
// db.X.Add + SaveChanges → DB UNIQUE bare tính CẢ row đã xoá → ném DbUpdateException
// (SQLite Error 19 UNIQUE constraint failed). 3 test RED = REPRODUCE gotcha #57.
// Sau khi em main fix migration (.HasFilter cho 3 config) → 3 test này GREEN.
// (KHÔNG sửa Configuration.cs / Domain / migration ở đây — implementer làm.)
//
// LƯU Ý SOFT-DELETE TRONG TEST (giống HrmConfigFilteredUniqueTests):
// 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 SEED row 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 MasterCatalogFilteredUniqueTests
{
// ---- Department (gotcha #57 — DỰ KIẾN RED) ----
[Fact]
public async Task CreateDepartment_OnSoftDeletedCodeSlot_Succeeds_FilteredUniqueAllowsReuse()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
// Seed 1 phòng ban Code="DUP1" đã soft-delete (IsDeleted=true).
db.Departments.Add(new Department
{
Id = Guid.NewGuid(),
Code = "DUP1",
Name = "Phòng ban cũ",
IsDeleted = true,
});
await db.SaveChangesAsync(CancellationToken.None);
// Tạo phòng ban mới CÙNG Code="DUP1". 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
// migration chưa filter index. Đây là gotcha #57 confirmed.
var act = async () => await new CreateDepartmentCommandHandler(db)
.Handle(new CreateDepartmentCommand("DUP1", "Phòng ban mới", null, 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 fix migration)");
(await db.Departments.CountAsync(x => x.Code == "DUP1"))
.Should().Be(1, "default query filter (!IsDeleted) chỉ trả 1 row active chiếm slot Code");
(await db.Departments.IgnoreQueryFilters().CountAsync(x => x.Code == "DUP1"))
.Should().Be(2, "soft-deleted gốc giữ lại cho audit + active mới");
}
// ---- Project (gotcha #57 — DỰ KIẾN RED) ----
[Fact]
public async Task CreateProject_OnSoftDeletedCodeSlot_Succeeds_FilteredUniqueAllowsReuse()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
// Seed 1 dự án Code="DUP1" đã soft-delete.
db.Projects.Add(new Project
{
Id = Guid.NewGuid(),
Code = "DUP1",
Name = "Dự án cũ",
IsDeleted = true,
});
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new CreateProjectCommandHandler(db)
.Handle(new CreateProjectCommand("DUP1", "Dự án mới", null, null, null, null, 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 fix migration)");
(await db.Projects.CountAsync(x => x.Code == "DUP1"))
.Should().Be(1, "default query filter (!IsDeleted) chỉ trả 1 row active chiếm slot Code");
(await db.Projects.IgnoreQueryFilters().CountAsync(x => x.Code == "DUP1"))
.Should().Be(2, "soft-deleted gốc giữ lại cho audit + active mới");
}
// ---- Supplier (gotcha #57 — DỰ KIẾN RED) ----
[Fact]
public async Task CreateSupplier_OnSoftDeletedCodeSlot_Succeeds_FilteredUniqueAllowsReuse()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
// Seed 1 NCC Code="DUP1" đã soft-delete.
db.Suppliers.Add(new Supplier
{
Id = Guid.NewGuid(),
Code = "DUP1",
Name = "NCC cũ",
Type = SupplierType.NhaCungCap,
IsDeleted = true,
});
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new CreateSupplierCommandHandler(db)
.Handle(new CreateSupplierCommand("DUP1", "NCC mới", SupplierType.NhaThauPhu,
null, null, null, null, null, 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 fix migration)");
(await db.Suppliers.CountAsync(x => x.Code == "DUP1"))
.Should().Be(1, "default query filter (!IsDeleted) chỉ trả 1 row active chiếm slot Code");
(await db.Suppliers.IgnoreQueryFilters().CountAsync(x => x.Code == "DUP1"))
.Should().Be(2, "soft-deleted gốc giữ lại cho audit + active mới");
}
}