[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
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:
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user