[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
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:
@ -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ỏ");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (80−5), 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user