From 4510cea1e3220082b77421071fcb892748a162d5 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 23 Jul 2026 17:11:36 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20App:=20chu=E1=BA=A9n=20h=C3=B3a=20m?= =?UTF-8?q?=C3=A3=20d=E1=BB=B1=20=C3=A1n=20=E2=80=94=20trim=20+=20ki?= =?UTF-8?q?=E1=BB=83m-tr=C3=B9ng=20b=E1=BB=8F=20d=E1=BA=A5u-c=C3=A1ch=20ch?= =?UTF-8?q?=E1=BB=91ng=20tr=C3=B9ng=20do=20kho=E1=BA=A3ng=20tr=E1=BA=AFng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project Create/Update: LƯU chỉ Trim (giữ mã như người dùng gõ — owner chốt), nhưng kiểm-trùng so trên dạng bỏ-dấu-cách (Code.Replace(' ','')) để chặn tạo/sửa thành bản trùng chỉ khác khoảng trắng ('FLOCK 01' vs 'FLOCK01'). +4 test guard ProjectCodeNormalizationTests. Đây là vá gốc cho vụ dự án trùng vừa gom trên prod (5 phiếu + 5 ngân sách trỏ lại, 3 bản dấu-cách ẩn mềm). Co-Authored-By: Claude Opus 4.8 --- .../Master/Projects/ProjectFeatures.cs | 21 +++-- .../ProjectCodeNormalizationTests.cs | 83 +++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/ProjectCodeNormalizationTests.cs 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"); + } +}