[CLAUDE] PurchaseEvaluation: cho nhập giá ÂM ô báo giá hạng mục + submit-guard all-zero (S116)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m28s

Feature (anh Kiệt FDC ⇄ Duy Nguyen 2026-07-13): cho nhập SỐ ÂM ô "Số tiền báo giá
cho hạng mục" (Quote.ThanhTien) — âm = phát-sinh-giảm / hoàn-tiền của NCC = giá THẬT.
Nguyên-tắc "có giá là được": ≥1 giá thật (âm/dương) → gửi được; chỉ chưa-nhập-gì mới chặn.

FE (2 app byte-identical PeDetailTabs.tsx):
- Nút ± toggle 2 input quote (AddSupplierDialog + QuoteDialog), ghi giá-CÓ-DẤU thẳng vào
  form.thanhTien (2 dialog POST commit trực tiếp, không nút Lưu riêng → tránh mất-dấu thầm lặng).
- Submit gate + toast >0 → !==0. Ô ngân sách giữ ≥0 (không toggle).
- 2 FE submit-mirror (:201 missingForApproval + :235 submitChecklist) đổi từ SUM
  computeGiaChaoThau → helper hasRealSelectedQuote (iterate details[].quotes IsSelected≠0;
  SUM không phân-biệt all-zero vs net-zero).

BE (1 điều-kiện): PurchaseEvaluationWorkflowService.cs:206 selectedThanhTien.Sum()<=0
  → All(x=>x==0) — chặn chỉ khi TẤT CẢ selected=0. BE+DB nhận âm sẵn (0 validator,
  decimal(18,2), no CHECK, KHÔNG migration).

Owner-decisions: R1 cho Contract.GiaTri âm (no clamp) · R2 nới submit-guard · R-CEO
  giữ NET threshold (dòng âm có thể kéo dưới ngưỡng → CCM finalize, owner chấp nhận).

Tests +9 (486→495): ngân-sách-bất-biến · winnerQuoteTotal signed-sum · submit-guard
  all-zero/negative/net-zero · CCM-threshold net.

Pipeline S116: /fable-clone invest (4-lane) → owner-gate → /fable-clone review (3-lane)
  → /fable-real (Fable, #53 garble → em-main-solo gate GO-WITH-FIXES) → hmw implement (Opus MAX).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-13 17:08:34 +07:00
parent 2ef3f445bf
commit 88368fd87e
7 changed files with 571 additions and 67 deletions

View File

@ -266,4 +266,76 @@ public class PeMultiWinnerTests
detailDto.Quotes.Should().HaveCount(3, "cả 3 NCC đều có báo giá cho hạng mục");
detailDto.Quotes.Count(q => q.IsSelected).Should().Be(2, "đúng 2 báo giá được chọn");
}
// ===== [S116 anh Kiệt — SỐ ÂM báo giá] winnerQuoteTotal = SUM CÓ DẤU =====
// Báo giá ÂM (phát-sinh-giảm / hoàn tiền NCC) là giá-trị THẬT → cộng dồn CÓ DẤU,
// KHÔNG clamp về 0 / KHÔNG Abs / KHÔNG loại. winnerQuoteTotal (DTO :1188-1191
// `.Where(IsSelected).Sum(ThanhTien)`) phải giữ dấu âm. Nếu ai thêm clamp/Abs →
// 2 test dưới RED.
[Fact]
public async Task WinnerQuoteTotal_TwoWinnersOppositeSign_SumsWithSign()
{
// khác winner: NCC-POS chào +50tr (vật tư), NCC-NEG ghi 20tr (hoàn tiền / giảm
// trừ) cùng 1 hạng mục — cả 2 ĐƯỢC CHỌN → tổng gói = 50 20 = 30tr.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-MW-SIGN");
var detailId = await SeedDetailAsync(db, pe);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-POS", 50_000_000m, isWinner: true, order: 0);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-NEG", -20_000_000m, isWinner: true, order: 1);
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
bundle.WinnerQuoteTotal.Should().Be(30_000_000m,
"SUM có dấu — báo giá âm (hoàn tiền / phát-sinh-giảm) TRỪ vào tổng, KHÔNG bị loại");
}
[Fact]
public async Task WinnerQuoteTotal_SameWinnerPositiveAndNegativeLines_SumsWithSign()
{
// cùng winner: 1 NCC trúng với 2 dòng (2 detail) — vật tư +80tr + phát-sinh-giảm
// 30tr → tổng = 80 30 = 50tr. Verify SelectMany gộp mọi dòng của winner-row
// GIỮ dấu (mirror WinnerQuoteTotal_MultipleQuotesPerWinner nhưng 1 dòng âm).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-MW-SIGN2");
var detail1 = await SeedDetailAsync(db, pe);
var detail2 = new PurchaseEvaluationDetail
{
Id = Guid.NewGuid(), PurchaseEvaluationId = pe.Id,
GroupCode = "A.II", GroupName = "Phát sinh", NoiDung = "Giảm trừ", Order = 1,
};
db.PurchaseEvaluationDetails.Add(detail2);
await db.SaveChangesAsync(CancellationToken.None);
var supplier = new Supplier { Id = Guid.NewGuid(), Code = "NCC-SGN", Name = "NCC 2 dòng trái dấu" };
db.Suppliers.Add(supplier);
var pes = new PurchaseEvaluationSupplier
{
Id = Guid.NewGuid(), PurchaseEvaluationId = pe.Id, SupplierId = supplier.Id,
Order = 0, IsWinner = true,
};
db.PurchaseEvaluationSuppliers.Add(pes);
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail1,
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 80_000_000m, IsSelected = true,
});
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail2.Id,
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = -30_000_000m, IsSelected = true,
});
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
bundle.WinnerQuoteTotal.Should().Be(50_000_000m,
"cùng winner 2 dòng trái dấu → SUM có dấu (80 30), dòng âm KHÔNG bị bỏ");
}
}

View File

@ -1034,4 +1034,55 @@ public class PeWorkItemBudgetTests
reloaded.BudgetPeriodAmount.Should().Be(80m, "field này được set");
reloaded.ExpectedRemainingAmount.Should().BeNull("absolute-set: null request CLEAR field, KHÔNG giữ 999");
}
// =====================================================================
// 7. [S116 anh Kiệt — SỐ ÂM báo giá] Regression-lock: báo giá ÂM (phát-sinh-giảm /
// hoàn tiền NCC = giá-trị THẬT) KHÔNG rò rỉ vào ngân sách. "Ngân sách giữ nguyên"
// là quyết định KIẾN-TRÚC (0 code nối quote→budget). Test tautological NHƯNG khoá
// cứng: nếu ai sau này wire "auto-trừ ngân sách theo báo giá" thì dòng âm sẽ phá
// ngân sách — test này bắt regression đó. Đồng thời chứng minh TÁCH BIỆT: dòng âm
// CHẢY vào CurrentProposalTotal (cột đề xuất) chứ KHÔNG vào FullAmount (cột ngân
// sách) + record PeWorkItemBudget bất biến.
// =====================================================================
[Fact]
public async Task NegativeSelectedQuote_DoesNotMutateBudgetRecord_ReflectsInProposalOnly()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-NEG1");
var pe = await SeedPeAsync(db, project.Id, wi.Id,
PurchaseEvaluationPhase.DangSoanThao, code: "PE-NEG1");
// Ngân sách CCM ban hành: Initial 80tr, Adjustment 5tr → FullAmount 75tr.
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
ProInitialAmount = 100_000_000m, ProAdjustmentAmount = 0m,
InitialAmount = 80_000_000m, AdjustmentAmount = -5_000_000m,
});
await db.SaveChangesAsync(CancellationToken.None);
// Báo giá ÂM 20tr được chọn (phát-sinh-giảm / hoàn tiền) cho phiếu này.
await SeedWinnerWithQuoteAsync(db, pe, Guid.NewGuid(), -20_000_000m);
var bundle = await BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin))
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
var s = bundle.BudgetSummary!;
// Cột NGÂN SÁCH bất biến — dòng âm KHÔNG kéo giảm ngân sách.
s.FullAmount.Should().Be(75_000_000m, "ngân sách = CCM Initial+Adjustment (805), KHÔNG bị báo giá âm chạm");
s.FullIsEstimate.Should().BeFalse();
// Cột ĐỀ XUẤT phản ánh dòng âm (SUM có dấu) — chứng minh âm chảy đúng chỗ.
s.CurrentProposalTotal.Should().Be(-20_000_000m, "báo giá âm phản ánh ở đề xuất, KHÔNG phải ngân sách");
// Record PeWorkItemBudget persistent BẤT BIẾN sau khi thêm quote âm + GET.
var rec = await db.PeWorkItemBudgets.AsNoTracking()
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.InitialAmount.Should().Be(80_000_000m, "record ngân sách KHÔNG đổi vì báo giá âm");
rec.AdjustmentAmount.Should().Be(-5_000_000m);
rec.ProInitialAmount.Should().Be(100_000_000m);
rec.ProAdjustmentAmount.Should().Be(0m);
}
}