[CLAUDE] PurchaseEvaluation: Mig 57 ghi chu gia de xuat PRO/CCM + so phan cach VND + sua chinh ta + guard #70
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m52s

Theo Tra Sol + anh Kiet FDC (Zalo): A) o nhap tien VndInlineEdit + BudgetCell nhay phan cach vi-VN (300.000.000) on-keystroke (o dialog da co san). B) them o ghi chu PRO + CCM canh nut Luu trong khoi Gia de xuat (giai thich vi sao chon Min/Max) — Mig 57 AddPeSuggestedPriceNotes (+ProSuggestedPriceNote +CcmSuggestedPriceNote nvarchar(1000) null, additive no-backfill no-table); 2 setter command +Note absolute-set rides role-gate PRO/CCM/Admin; DTO +2 field; controller body +note. C) sua chinh ta 'd. Ban so sanh' -> 'd. Bang so sanh gia' (2 app). GUARD gotcha #70: o gia + ghi chu echo nhau absolute-set -> them peFetching khoa nut Luu toi khi pe-detail refetch land, tranh stale-echo mat du lieu (mirror bang ngan sach S76; em-main review bat impl-frontend sot guard). BE slnx 0-warn 0-err; FE build PASS x2; test 344->351 (+7); PeDetailTabs/PeWorkspaceCreateView 2 app SHA256-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-19 14:08:45 +07:00
parent 3b98845976
commit 94e0e12f77
16 changed files with 6714 additions and 36 deletions

View File

@ -266,4 +266,171 @@ public class PeSuggestedPriceSetterAuthzTests
await act.Should().ThrowAsync<NotFoundException>();
}
// ====================================================================
// ===== NEW [Mig 57 2026-06-19] suggested-price NOTE (ProSuggestedPriceNote /
// CcmSuggestedPriceNote) — mirror PeWorkItemBudgetTests §4b CcmNote shape.
// - PRO/CCM command +Note (string?) absolute-set (overwrite always, null = clear).
// - Note rides the SAME role-gate as the price (PRO/Admin; CCM/Admin) fail-closed.
// - Validator: Note MaximumLength(1000).
// ====================================================================
// 11. PRO note set + persist — note rides alongside Min/Max (doesn't clobber price).
[Fact]
public async Task ProSetter_Procurement_SetsNote_AlongsideMinMax()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-001");
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 100_000_000m, MaxPrice: 200_000_000m,
Note: "Min theo NCC A, Max theo NCC B (đã gồm VAT)"),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedPriceNote.Should().Be("Min theo NCC A, Max theo NCC B (đã gồm VAT)",
"PRO ghi chú giải thích Min vs Max");
reloaded.ProSuggestedMinPrice.Should().Be(100_000_000m, "note đi kèm KHÔNG đè giá Min");
reloaded.ProSuggestedMaxPrice.Should().Be(200_000_000m, "note đi kèm KHÔNG đè giá Max");
}
// 11b. Admin set PRO note (allow-list thứ 2 của PRO command).
[Fact]
public async Task ProSetter_Admin_SetsNote()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-001B");
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 50_000_000m, MaxPrice: null, Note: "admin ghi chú giá PRO"),
CancellationToken.None);
(await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id))
.ProSuggestedPriceNote.Should().Be("admin ghi chú giá PRO", "Admin được ghi chú giá PRO");
}
// 12. CCM note set + persist — note rides alongside CcmPrice.
[Fact]
public async Task CcmSetter_CostControl_SetsNote_AlongsidePrice()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-002");
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 333_000_000m, Note: "giá CCM khuyến nghị cho CEO duyệt"),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmSuggestedPriceNote.Should().Be("giá CCM khuyến nghị cho CEO duyệt", "CCM ghi chú giá CCM");
reloaded.CcmSuggestedPrice.Should().Be(333_000_000m, "note đi kèm KHÔNG đè giá CCM");
}
// 13. Absolute-set null-clear — bắt đầu từ note CÓ SẴN rồi gửi Note=null → CLEAR
// (chứng minh absolute-set, KHÔNG skip-if-null giữ giá trị cũ). Cả PRO + CCM.
[Fact]
public async Task ProSetter_NullNote_ClearsExistingNote_AbsoluteSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-003");
// Pre-seed note có sẵn để chứng minh bị xoá (không phải vốn-dĩ-null).
pe.ProSuggestedPriceNote = "ghi chú PRO cũ cần xoá";
pe.ProSuggestedMinPrice = 10_000_000m;
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 10_000_000m, MaxPrice: null, Note: null),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedPriceNote.Should().BeNull("absolute-set: Note=null CLEAR field, KHÔNG giữ ghi chú cũ");
}
[Fact]
public async Task CcmSetter_NullNote_ClearsExistingNote_AbsoluteSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-003C");
pe.CcmSuggestedPriceNote = "ghi chú CCM cũ cần xoá";
pe.CcmSuggestedPrice = 20_000_000m;
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 20_000_000m, Note: null),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmSuggestedPriceNote.Should().BeNull("absolute-set: Note=null CLEAR field, KHÔNG giữ ghi chú cũ");
}
// 14. Non-privileged role → ForbiddenException + note (và giá) GIỮ NGUYÊN trong DB.
// Fail-closed: role-gate TRƯỚC mọi side-effect (NotFound → gate → mutate), nên PE
// tồn tại + role sai = Forbidden trước khi ghi → KHÔNG partial-write.
[Fact]
public async Task ProSetter_NonPrivilegedRole_WithNote_ThrowsForbidden_AndDoesNotMutate()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-004");
// Pre-seed note + giá để chứng minh KHÔNG bị mutate khi Forbidden.
pe.ProSuggestedPriceNote = "ghi chú PRO giữ nguyên";
pe.ProSuggestedMinPrice = 700_000_000m;
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 1m, MaxPrice: 2m, Note: "không được ghi"),
CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("chỉ PRO | Admin được nhập giá đề xuất PRO + ghi chú");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedPriceNote.Should().Be("ghi chú PRO giữ nguyên",
"Forbidden TRƯỚC side-effect → note KHÔNG bị mutate");
reloaded.ProSuggestedMinPrice.Should().Be(700_000_000m, "giá cũng giữ nguyên (no partial-write)");
}
// 15. Độc lập PRO note ↔ CCM note — set CCM note KHÔNG đụng PRO note (và ngược lại
// ngầm định: 2 lệnh, 2 field riêng). Cũng kiểm Note MaximumLength(1000) ở validator.
[Fact]
public async Task ProAndCcmNotes_AreIndependent_AndValidatorEnforcesMaxLength()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-005");
// Đặt PRO note trước (Admin set được cả 2 command).
await new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin))
.Handle(new UpdatePeSuggestedPriceProCommand(pe.Id, 1m, 2m, Note: "PRO note"), CancellationToken.None);
// Rồi đặt CCM note — phải KHÔNG làm mất PRO note.
await new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.Admin))
.Handle(new UpdatePeSuggestedPriceCcmCommand(pe.Id, 3m, Note: "CCM note"), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedPriceNote.Should().Be("PRO note", "set CCM note KHÔNG đụng PRO note (field độc lập)");
reloaded.CcmSuggestedPriceNote.Should().Be("CCM note");
// Validator: Note > 1000 ký tự → invalid; đúng 1000 → valid.
var proValidator = new UpdatePeSuggestedPriceProCommandValidator();
proValidator.Validate(new UpdatePeSuggestedPriceProCommand(pe.Id, null, null, Note: new string('x', 1001)))
.IsValid.Should().BeFalse("Note > 1000 ký tự vi phạm MaximumLength(1000)");
proValidator.Validate(new UpdatePeSuggestedPriceProCommand(pe.Id, null, null, Note: new string('x', 1000)))
.IsValid.Should().BeTrue("Note đúng 1000 ký tự hợp lệ");
var ccmValidator = new UpdatePeSuggestedPriceCcmCommandValidator();
ccmValidator.Validate(new UpdatePeSuggestedPriceCcmCommand(pe.Id, null, Note: new string('x', 1001)))
.IsValid.Should().BeFalse("Note > 1000 ký tự vi phạm MaximumLength(1000)");
}
}