diff --git a/src/Backend/SolutionErp.Application/Master/Projects/ProjectFeatures.cs b/src/Backend/SolutionErp.Application/Master/Projects/ProjectFeatures.cs index 4c95368..d66d56e 100644 --- a/src/Backend/SolutionErp.Application/Master/Projects/ProjectFeatures.cs +++ b/src/Backend/SolutionErp.Application/Master/Projects/ProjectFeatures.cs @@ -92,11 +92,17 @@ public class CreateProjectCommandHandler(IApplicationDbContext db) : IRequestHan { public async Task Handle(CreateProjectCommand request, CancellationToken ct) { - if (await db.Projects.AnyAsync(x => x.Code == request.Code, ct)) - throw new ConflictException($"Mã dự án '{request.Code}' đã tồn tại."); + // [Fix UAT — dự án trùng do khác dấu-cách trong mã: "FLOCK 01" vs "FLOCK01"] + // Owner (chốt): LƯU chỉ cắt đầu/cuối (Trim) — giữ mã như người dùng gõ; nhưng + // KIỂM-TRÙNG bỏ-qua-dấu-cách để chặn tạo bản trùng chỉ khác khoảng trắng. + // (Collation SQL mặc định CI → đã bỏ-qua hoa/thường; đây thêm bỏ-qua dấu-cách.) + var code = request.Code.Trim(); + var codeKey = code.Replace(" ", ""); + if (await db.Projects.AnyAsync(x => x.Code.Replace(" ", "") == codeKey, ct)) + throw new ConflictException($"Mã dự án '{code}' đã tồn tại (trùng mã sau khi bỏ dấu cách)."); var entity = new Project { - Code = request.Code, Name = request.Name, + Code = code, Name = request.Name, StartDate = request.StartDate, EndDate = request.EndDate, ManagerUserId = request.ManagerUserId, BudgetTotal = request.BudgetTotal, Note = request.Note, Year = request.Year, Investor = request.Investor, Location = request.Location, Package = request.Package, @@ -135,9 +141,12 @@ public class UpdateProjectCommandHandler(IApplicationDbContext db) : IRequestHan { var entity = await db.Projects.FirstOrDefaultAsync(x => x.Id == request.Id, ct) ?? throw new NotFoundException("Project", request.Id); - if (entity.Code != request.Code && await db.Projects.AnyAsync(x => x.Code == request.Code && x.Id != request.Id, ct)) - throw new ConflictException($"Mã dự án '{request.Code}' đã tồn tại."); - entity.Code = request.Code; + // Chuẩn hóa mã như CREATE: Trim khi lưu + kiểm-trùng bỏ-qua-dấu-cách. + var code = request.Code.Trim(); + var codeKey = code.Replace(" ", ""); + if (entity.Code != code && await db.Projects.AnyAsync(x => x.Id != request.Id && x.Code.Replace(" ", "") == codeKey, ct)) + throw new ConflictException($"Mã dự án '{code}' đã tồn tại (trùng mã sau khi bỏ dấu cách)."); + entity.Code = code; entity.Name = request.Name; entity.StartDate = request.StartDate; entity.EndDate = request.EndDate; diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ProjectCodeNormalizationTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ProjectCodeNormalizationTests.cs new file mode 100644 index 0000000..d56659e --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ProjectCodeNormalizationTests.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Master.Projects; +using SolutionErp.Domain.Master; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// [Fix UAT 2026-07-23 — dự án trùng do khác dấu-cách trong mã: "FLOCK 01" vs "FLOCK01"] +// Owner (chốt): LƯU chỉ cắt đầu/cuối (Trim) — giữ mã như người dùng gõ; nhưng KIỂM-TRÙNG +// bỏ-qua-dấu-cách để chặn tạo/sửa thành bản trùng chỉ khác khoảng trắng (chống tái phát +// sau khi gom dữ liệu). Dupe-check là bug-class guard → test-before (docs/rules.md §7). +// Handlers chỉ cần IApplicationDbContext → new trực tiếp với fix.Db. +public class ProjectCodeNormalizationTests +{ + private static CreateProjectCommand NewProject(string code, string name = "Dự án") => + new(code, name, null, null, null, null, null, null, null, null, null); + + [Fact] + public async Task CreateProject_SpaceVariantOfExistingCode_ThrowsConflict() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + db.Projects.Add(new Project { Id = Guid.NewGuid(), Code = "FLOCK01", Name = "FLOCK01" }); + await db.SaveChangesAsync(CancellationToken.None); + + // "FLOCK 01" (dấu cách giữa) == "FLOCK01" sau khi bỏ dấu cách → phải chặn. + var act = async () => await new CreateProjectCommandHandler(db) + .Handle(NewProject("FLOCK 01"), CancellationToken.None); + + await act.Should().ThrowAsync( + "kiểm-trùng bỏ-qua-dấu-cách phải bắt bản trùng chỉ khác khoảng trắng"); + (await db.Projects.CountAsync()).Should().Be(1, "không tạo thêm bản trùng dấu-cách"); + } + + [Fact] + public async Task CreateProject_TrimsLeadingTrailingWhitespace_ButKeepsInternalSpace() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + var id = await new CreateProjectCommandHandler(db) + .Handle(NewProject(" CAL 02 "), CancellationToken.None); + + var saved = await db.Projects.FirstAsync(x => x.Id == id); + // Owner chọn "chỉ cắt đầu/cuối": Trim bỏ khoảng trắng đầu/cuối, GIỮ dấu cách giữa. + saved.Code.Should().Be("CAL 02"); + } + + [Fact] + public async Task CreateProject_GenuinelyNewCode_Succeeds() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + db.Projects.Add(new Project { Id = Guid.NewGuid(), Code = "FLOCK01", Name = "FLOCK01" }); + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await new CreateProjectCommandHandler(db) + .Handle(NewProject("SOV01"), CancellationToken.None); + + await act.Should().NotThrowAsync("mã mới hoàn toàn không đụng bản nào"); + (await db.Projects.CountAsync()).Should().Be(2); + } + + [Fact] + public async Task UpdateProject_ToSpaceVariantOfAnotherCode_ThrowsConflict() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + db.Projects.Add(new Project { Id = Guid.NewGuid(), Code = "FLOCK01", Name = "FLOCK01" }); + var otherId = Guid.NewGuid(); + db.Projects.Add(new Project { Id = otherId, Code = "TARGET", Name = "Khác" }); + await db.SaveChangesAsync(CancellationToken.None); + + // Đổi mã "TARGET" → "FLOCK 01" (trùng "FLOCK01" bỏ dấu cách) → phải chặn. + var act = async () => await new UpdateProjectCommandHandler(db) + .Handle(new UpdateProjectCommand(otherId, "FLOCK 01", "Khác", + null, null, null, null, null, null, null, null, null), CancellationToken.None); + + await act.Should().ThrowAsync( + "sửa sang biến thể dấu-cách của mã đã tồn tại cũng phải bị chặn"); + } +}