[CLAUDE] PurchaseEvaluation: CV PRO refinement batch (anh Kiệt FDC) — live budget recompute + clear winner + fullscreen preview + hide create c/d
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m59s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m59s
C1 ẩn "c. Giá chào thầu" + "d. Bảng so sánh giá" khỏi form TẠO (vẫn hiện ở Detail tabs) C3 ô 8 "Giá trị TH dự kiến còn lại" pre-fill từ dòng 7 (Ngân sách còn lại) C4a live-recompute: VndInlineEdit +onLiveChange, PeBudgetSummaryTable draftRow3/8 → dòng 5/6/7/9 + So sánh + % nhảy NGAY khi gõ ô 3/8 (chưa cần Lưu) C4b So sánh = 0 → "Bằng ngân sách" / "—" thay "0 đ" (chỉ khi base>0) C5 hủy/đổi NCC winner: BE SelectWinnerBody(Guid?) + Command/Handler 2 nhánh (null=clear bỏ-qua-validate-list / non-null=giữ logic cũ); FE dropdown ""→clear + nút ✓ toggle. KHÔNG migration (SelectedSupplierId đã Guid?) C7 nút "Toàn màn hình" preview file báo giá (overlay inset-0 + Esc thoát) BE 2 file (Api controller record + Application command/handler). FE 3 file × 2 app SHA-identical. Test +10 PeSelectWinnerClearTests (4 clear + 4 select-regression + 2 PE-existence) → 366 PASS. Both FE build PASS, slnx build 0 err. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,282 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.PurchaseEvaluations;
|
||||
using SolutionErp.Domain.Contracts; // ChangelogAction enum (reuse — PurchaseEvaluationChangelog dùng chung)
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [anh Kiệt FDC C5 — test-after] SelectPurchaseEvaluationWinnerCommand: NCC winner
|
||||
// nullable hoá. Command record `(Guid PurchaseEvaluationId, Guid? SupplierId)`.
|
||||
// Handler (PurchaseEvaluationDetailFeatures.cs ~388-438) 2 nhánh theo SupplierId:
|
||||
// • `SupplierId is Guid x` → CHỌN/ĐỔI (logic cũ giữ 100%): NotFound nếu supplier
|
||||
// không có ở master + Conflict nếu supplier KHÔNG thuộc participating-list của
|
||||
// phiếu (PurchaseEvaluationSuppliers) + set entity.SelectedSupplierId = x.
|
||||
// • `SupplierId == null` → BỎ CHỌN (clear về none): set SelectedSupplierId = null,
|
||||
// BỎ QUA validate participating-list (nhánh MỚI — trục test quan trọng nhất).
|
||||
// Mọi nhánh ghi 1 Changelog (Header/Update) — Summary phân theo null vs có-giá-trị.
|
||||
//
|
||||
// Handler 2-dep (db + ICurrentUser), KHÔNG cần UserManager → SqliteDbFixture đủ nhẹ
|
||||
// (mirror DepartmentTreeTests). ICurrentUser fake cứng để Changelog ghi UserId/Name.
|
||||
public class PeSelectWinnerClearTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid? UserId { get; init; } = Guid.NewGuid();
|
||||
public string? Email { get; init; } = "procurement@test.local";
|
||||
public string? FullName { get; init; } = "Procurement Test";
|
||||
public IReadOnlyList<string> Roles { get; init; } = new[] { AppRoles.Procurement };
|
||||
public bool IsAuthenticated => UserId is not null;
|
||||
}
|
||||
|
||||
private static SelectPurchaseEvaluationWinnerCommandHandler BuildHandler(
|
||||
TestApplicationDbContext db)
|
||||
=> new(db, new FakeCurrentUser());
|
||||
|
||||
private static async Task<Supplier> SeedSupplierAsync(
|
||||
TestApplicationDbContext db, string code)
|
||||
{
|
||||
var s = new Supplier { Id = Guid.NewGuid(), Code = code, Name = "NCC " + code };
|
||||
db.Suppliers.Add(s);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Phiếu PE + (tùy chọn) SelectedSupplierId pre-set. KHÔNG tự thêm participating
|
||||
// row — caller quyết định để cô lập nhánh clear (skip-list) vs select (need-list).
|
||||
private static async Task<PurchaseEvaluation> SeedPeAsync(
|
||||
TestApplicationDbContext db, Guid? selectedSupplierId, string code = "PE-SW")
|
||||
{
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.DangSoanThao,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Gói thầu chọn NCC",
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
SelectedSupplierId = selectedSupplierId,
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return pe;
|
||||
}
|
||||
|
||||
// Gắn supplier vào participating-list (PurchaseEvaluationSuppliers) của phiếu.
|
||||
private static async Task AddParticipatingAsync(
|
||||
TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0)
|
||||
{
|
||||
db.PurchaseEvaluationSuppliers.Add(new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = peId,
|
||||
SupplierId = supplierId,
|
||||
Order = order,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1. CLEAR branch (MỚI — trục quan trọng nhất)
|
||||
// ============================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull()
|
||||
{
|
||||
// ⭐ Phiếu đang có SelectedSupplierId = winner (s1) + s1 VẪN nằm trong
|
||||
// participating-list → gọi SupplierId=null → clear về null, KHÔNG ném.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.SelectedSupplierId.Should().BeNull("bỏ chọn = clear winner về none");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_WinnerNotInParticipatingList_StillClears_NoConflict()
|
||||
{
|
||||
// Trục cốt lõi nhánh clear: SKIP validate participating-list. Phiếu có
|
||||
// SelectedSupplierId = s1 (winner cũ) nhưng s1 KHÔNG còn trong list (vd bị
|
||||
// gỡ khỏi bảng so sánh). Clear vẫn phải thành công, KHÔNG ném Conflict/NotFound.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-GONE");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
// CỐ Ý không AddParticipating → s1 không thuộc list của phiếu.
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().NotThrowAsync("clear KHÔNG validate participating-list");
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.SelectedSupplierId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_PhieuAlreadyNull_IsIdempotent_StaysNull_NoThrow()
|
||||
{
|
||||
// Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null) → clear lần nữa =
|
||||
// idempotent, vẫn null, không ném. Empty participating-list cũng không cản.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().NotThrowAsync();
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.SelectedSupplierId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_WritesChangelog_WithBoChonSummary()
|
||||
{
|
||||
// Nhánh clear vẫn ghi 1 Changelog Header/Update với Summary "Bỏ chọn NCC
|
||||
// trúng thầu" (phân biệt với "Chọn NCC trúng thầu" của nhánh select).
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
|
||||
.Where(c => c.PurchaseEvaluationId == pe.Id)
|
||||
.SingleAsync();
|
||||
log.Summary.Should().Be("Bỏ chọn NCC trúng thầu");
|
||||
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
|
||||
log.Action.Should().Be(ChangelogAction.Update);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. SELECT branch (regression — logic cũ giữ nguyên 100%)
|
||||
// ============================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Select_SupplierInParticipatingList_SetsSelectedSupplierId()
|
||||
{
|
||||
// Happy-path cũ: supplier hợp lệ (tồn tại master + thuộc participating-list)
|
||||
// → set SelectedSupplierId = đúng id.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-WIN");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s1.Id);
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.SelectedSupplierId.Should().Be(s1.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2()
|
||||
{
|
||||
// Đổi winner: phiếu đang chọn s1 → chọn s2 (cả 2 trong list) → SelectedSupplierId
|
||||
// chuyển s2 (chứng minh nhánh select overwrite, không chỉ set-from-null).
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-1");
|
||||
var s2 = await SeedSupplierAsync(db, "NCC-2");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s2.Id);
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.SelectedSupplierId.Should().Be(s2.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate()
|
||||
{
|
||||
// Supplier tồn tại ở master NHƯNG chưa được thêm vào phiếu → Conflict.
|
||||
// entity.SelectedSupplierId giữ nguyên (throw TRƯỚC khi gán + SaveChanges).
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-IN"); // trong list
|
||||
var outsider = await SeedSupplierAsync(db, "NCC-OUT"); // không trong list
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: outsider.Id);
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*NCC chưa được thêm vào phiếu*");
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.SelectedSupplierId.Should().Be(s1.Id, "Conflict TRƯỚC gán → winner cũ s1 giữ nguyên");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Select_SupplierNotInMaster_ThrowsNotFound()
|
||||
{
|
||||
// Supplier Guid không tồn tại trong Suppliers master → NotFound (check master
|
||||
// existence TRƯỚC participating-list).
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: Guid.NewGuid());
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. PE existence — chung cả 2 nhánh (check TRƯỚC khi rẽ nhánh SupplierId)
|
||||
// ============================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownPe_Clear_ThrowsNotFound_BeforeBranch()
|
||||
{
|
||||
// PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler, TRƯỚC
|
||||
// khi xét SupplierId (kể cả nhánh clear null cũng không bỏ qua existence).
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: null);
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownPe_Select_ThrowsNotFound_BeforeBranch()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: s1.Id);
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user