Compare commits
2 Commits
8b6df0a396
...
4510cea1e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4510cea1e3 | |||
| 7d39d8d61b |
@ -1977,6 +1977,17 @@ function ThongTinChonThauSection({ ev, readOnly = false }: { ev: PeDetailBundle;
|
||||
a => a.purchaseEvaluationSupplierId === null
|
||||
&& a.purpose !== PeAttachmentPurpose.ApprovalAttachment,
|
||||
)
|
||||
// [Fix UAT — phiếu bị Trả lại: không xóa/tải lại được file "Bảng so sánh"]
|
||||
// Nút xóa/tải file so sánh phải theo NGƯỜI-SOẠN (hoặc Admin) + TRẠNG-THÁI-được-sửa,
|
||||
// KHÔNG theo readOnly của MÀN HÌNH: màn "Danh sách" mở phiếu readOnly=true nên ẩn
|
||||
// nút ở MỌI phase — kể cả Trả lại, vốn nằm trong isEditablePhase. Chỉ tách RIÊNG
|
||||
// khối đính kèm này; HoSoLink + winner bên dưới vẫn theo readOnly màn hình (không
|
||||
// mở nhầm). Chỉ áp cho "Bảng so sánh" (GeneralAttachmentsSection) — file per-NCC
|
||||
// (SupplierAttachmentsCell) giữ nguyên carve-out approverEditMode (Mig 28 F3).
|
||||
const { user: currentUser } = useAuth()
|
||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
|
||||
const attachEditable = (isAdmin || isDrafter) && isEditablePhase(ev.phase)
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-300">
|
||||
<div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
|
||||
@ -2010,7 +2021,7 @@ function ThongTinChonThauSection({ ev, readOnly = false }: { ev: PeDetailBundle;
|
||||
{/* 4. Bảng so sánh giá */}
|
||||
<div>
|
||||
<div className="mb-1 text-[12px] font-medium text-slate-600">4. Bảng so sánh giá</div>
|
||||
<GeneralAttachmentsSection evaluationId={ev.id} attachments={banSoSanhAttachments} readOnly={readOnly} />
|
||||
<GeneralAttachmentsSection evaluationId={ev.id} attachments={banSoSanhAttachments} readOnly={!attachEditable} />
|
||||
</div>
|
||||
|
||||
{/* 5. Link hồ sơ */}
|
||||
|
||||
@ -1977,6 +1977,17 @@ function ThongTinChonThauSection({ ev, readOnly = false }: { ev: PeDetailBundle;
|
||||
a => a.purchaseEvaluationSupplierId === null
|
||||
&& a.purpose !== PeAttachmentPurpose.ApprovalAttachment,
|
||||
)
|
||||
// [Fix UAT — phiếu bị Trả lại: không xóa/tải lại được file "Bảng so sánh"]
|
||||
// Nút xóa/tải file so sánh phải theo NGƯỜI-SOẠN (hoặc Admin) + TRẠNG-THÁI-được-sửa,
|
||||
// KHÔNG theo readOnly của MÀN HÌNH: màn "Danh sách" mở phiếu readOnly=true nên ẩn
|
||||
// nút ở MỌI phase — kể cả Trả lại, vốn nằm trong isEditablePhase. Chỉ tách RIÊNG
|
||||
// khối đính kèm này; HoSoLink + winner bên dưới vẫn theo readOnly màn hình (không
|
||||
// mở nhầm). Chỉ áp cho "Bảng so sánh" (GeneralAttachmentsSection) — file per-NCC
|
||||
// (SupplierAttachmentsCell) giữ nguyên carve-out approverEditMode (Mig 28 F3).
|
||||
const { user: currentUser } = useAuth()
|
||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
|
||||
const attachEditable = (isAdmin || isDrafter) && isEditablePhase(ev.phase)
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-300">
|
||||
<div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
|
||||
@ -2010,7 +2021,7 @@ function ThongTinChonThauSection({ ev, readOnly = false }: { ev: PeDetailBundle;
|
||||
{/* 4. Bảng so sánh giá */}
|
||||
<div>
|
||||
<div className="mb-1 text-[12px] font-medium text-slate-600">4. Bảng so sánh giá</div>
|
||||
<GeneralAttachmentsSection evaluationId={ev.id} attachments={banSoSanhAttachments} readOnly={readOnly} />
|
||||
<GeneralAttachmentsSection evaluationId={ev.id} attachments={banSoSanhAttachments} readOnly={!attachEditable} />
|
||||
</div>
|
||||
|
||||
{/* 5. Link hồ sơ */}
|
||||
|
||||
@ -92,11 +92,17 @@ public class CreateProjectCommandHandler(IApplicationDbContext db) : IRequestHan
|
||||
{
|
||||
public async Task<Guid> 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;
|
||||
|
||||
@ -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<ConflictException>(
|
||||
"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<ConflictException>(
|
||||
"sửa sang biến thể dấu-cách của mã đã tồn tại cũng phải bị chặn");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user