diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index ae3788e..2ff0760 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -79,6 +79,16 @@ function computeGiaChaoThau(ev: PeDetailBundle): number | null { return ev.winnerQuoteTotal } +// [S116 anh Kiệt] "Có giá là được": phiếu gửi-duyệt được khi có ≥1 báo giá ĐƯỢC CHỌN +// (IsSelected) với thanhTien !== 0. Giá ÂM = phát-sinh-giảm / hoàn tiền NCC = giá THẬT, +// KHÔNG phải lỗi. Thay điều-kiện computeGiaChaoThau > 0 cũ: SUM không phân biệt all-zero +// (chưa nhập) vs net-zero (+100,-100=0 nhưng có giá thật) → net-zero bị chặn oan. Mirror +// BE selectedThanhTien = quotes WHERE IsSelected. Quote sống ở details[].quotes (KHÔNG +// suppliers[].quotes); IsSelected = báo giá được tick trúng trong hàng hạng mục. +function hasRealSelectedQuote(ev: PeDetailBundle): boolean { + return ev.details.some(d => d.quotes.some(q => q.isSelected && q.thanhTien !== 0)) +} + // Main detail content — flat render 3 section không tabs. // Tên giữ PeDetailTabs để không break callsite (rename gây churn). // @@ -195,10 +205,11 @@ export function PeDetailTabs({ if (!evaluation.suppliers.some(s => s.isWinner)) { missing.push("Chưa chọn Đơn vị NCC/TP") } else { - // 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0). - // Chỉ check khi đã chọn (không spam khi chưa chọn — đã có mục 1). - const gia = computeGiaChaoThau(evaluation) - if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu") + // 2. [S116 anh Kiệt] Đơn vị được chọn CHƯA NHẬP giá chào thầu nào (mọi báo giá + // được chọn = 0). "Có giá là được": giá ÂM (phát-sinh-giảm / hoàn tiền NCC) = giá + // THẬT → gửi được; net-zero có giá thật (+100,-100) cũng gửi được. Chỉ chặn khi + // TOÀN BỘ = 0 (chưa nhập gì). Mirror BE selectedThanhTien = quotes WHERE IsSelected. + if (!hasRealSelectedQuote(evaluation)) missing.push("Đơn vị được chọn chưa có giá chào thầu") } // 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập). // Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0. @@ -227,12 +238,14 @@ export function PeDetailTabs({ // các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút. const submitChecklist = useMemo(() => { const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner - const gia = supplierOk ? computeGiaChaoThau(evaluation) : null + // [S116 anh Kiệt] "đã nhập giá" = có ≥1 báo giá được chọn !== 0 (âm = giá thật). Không + // dùng SUM > 0 (net-zero có giá thật bị false-đỏ). Mirror BE quotes WHERE IsSelected. + const hasRealQuote = supplierOk && hasRealSelectedQuote(evaluation) const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null return [ { label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null }, { label: 'Đơn vị NCC/TP được chọn', ok: supplierOk }, - { label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 }, + { label: 'Đã nhập giá chào thầu', ok: hasRealQuote }, { label: 'Ngân sách kỳ này', ok: effBudget != null }, { label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) }, ] @@ -2182,6 +2195,10 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: { note: '', thanhTien: 0, }) + // [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG + // (KHÔNG nhét vào form) — nhớ ý-định-dấu khi ô còn trống/0. Toggle ghi giá-trị-CÓ-DẤU + // vào form.thanhTien NGAY (POST commit thẳng, không có nút "Lưu" riêng). + const [neg, setNeg] = useState(false) const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : '' const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : '' const hasError = !!(phoneError || emailError) @@ -2222,8 +2239,9 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: { note: form.note, }) const newSupplierRowId = res.data.id - // Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien > 0) - if (detailId && form.thanhTien > 0) { + // Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien !== 0). + // [S116] !== 0 (không > 0): giá ÂM tạo quote; rỗng/0 vẫn skip = "chưa báo giá". + if (detailId && form.thanhTien !== 0) { await api.post(`/purchase-evaluations/${evaluationId}/quotes`, { purchaseEvaluationDetailId: detailId, purchaseEvaluationSupplierId: newSupplierRowId, @@ -2236,7 +2254,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: { } }, onSuccess: () => { - toast.success(showQuote && form.thanhTien > 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.') + toast.success(showQuote && form.thanhTien !== 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.') qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }) onClose() }, @@ -2361,19 +2379,32 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: { {showQuote && (
-
- setForm({ ...form, thanhTien: parseVnd(e.target.value) })} - placeholder="0" - className="pr-10 font-mono text-right" - /> - đ +
+ +
+ { const mag = parseVnd(e.target.value); setForm({ ...form, thanhTien: neg ? -mag : mag }) }} + placeholder="0" + className="pr-10 font-mono text-right" + /> + đ +

- Để trống / 0 → chỉ tạo NCC, chưa báo giá. Sửa lại sau bằng cách click số tiền trong bảng. + Để trống / 0 → chỉ tạo NCC, chưa báo giá. Bấm +/− để nhập số âm (phát sinh giảm / hoàn tiền NCC). Sửa lại sau bằng cách click số tiền trong bảng.

)} @@ -2864,6 +2895,10 @@ function QuoteDialog({ const [form, setForm] = useState({ thanhTien: existing?.thanhTien ?? 0, }) + // [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG + // (KHÔNG nhét vào form: setForm({thanhTien}) thay-thế cả object sẽ nuốt mất). Init theo + // dấu quote đang mở. Toggle ghi giá-trị-CÓ-DẤU vào form.thanhTien NGAY (POST commit thẳng). + const [neg, setNeg] = useState((existing?.thanhTien ?? 0) < 0) const mut = useMutation({ mutationFn: async () => @@ -2913,19 +2948,32 @@ function QuoteDialog({

Hạng mục: {itemName}

-
- setForm({ thanhTien: parseVnd(e.target.value) })} - placeholder="0" - autoFocus - className="pr-12 font-mono text-right" - /> - đ +
+ +
+ { const mag = parseVnd(e.target.value); setForm({ thanhTien: neg ? -mag : mag }) }} + placeholder="0" + autoFocus + className="pr-12 font-mono text-right" + /> + đ +
-

VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000)

+

VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000). Bấm +/− để nhập số âm (phát sinh giảm / hoàn tiền NCC).

diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index ae3788e..2ff0760 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -79,6 +79,16 @@ function computeGiaChaoThau(ev: PeDetailBundle): number | null { return ev.winnerQuoteTotal } +// [S116 anh Kiệt] "Có giá là được": phiếu gửi-duyệt được khi có ≥1 báo giá ĐƯỢC CHỌN +// (IsSelected) với thanhTien !== 0. Giá ÂM = phát-sinh-giảm / hoàn tiền NCC = giá THẬT, +// KHÔNG phải lỗi. Thay điều-kiện computeGiaChaoThau > 0 cũ: SUM không phân biệt all-zero +// (chưa nhập) vs net-zero (+100,-100=0 nhưng có giá thật) → net-zero bị chặn oan. Mirror +// BE selectedThanhTien = quotes WHERE IsSelected. Quote sống ở details[].quotes (KHÔNG +// suppliers[].quotes); IsSelected = báo giá được tick trúng trong hàng hạng mục. +function hasRealSelectedQuote(ev: PeDetailBundle): boolean { + return ev.details.some(d => d.quotes.some(q => q.isSelected && q.thanhTien !== 0)) +} + // Main detail content — flat render 3 section không tabs. // Tên giữ PeDetailTabs để không break callsite (rename gây churn). // @@ -195,10 +205,11 @@ export function PeDetailTabs({ if (!evaluation.suppliers.some(s => s.isWinner)) { missing.push("Chưa chọn Đơn vị NCC/TP") } else { - // 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0). - // Chỉ check khi đã chọn (không spam khi chưa chọn — đã có mục 1). - const gia = computeGiaChaoThau(evaluation) - if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu") + // 2. [S116 anh Kiệt] Đơn vị được chọn CHƯA NHẬP giá chào thầu nào (mọi báo giá + // được chọn = 0). "Có giá là được": giá ÂM (phát-sinh-giảm / hoàn tiền NCC) = giá + // THẬT → gửi được; net-zero có giá thật (+100,-100) cũng gửi được. Chỉ chặn khi + // TOÀN BỘ = 0 (chưa nhập gì). Mirror BE selectedThanhTien = quotes WHERE IsSelected. + if (!hasRealSelectedQuote(evaluation)) missing.push("Đơn vị được chọn chưa có giá chào thầu") } // 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập). // Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0. @@ -227,12 +238,14 @@ export function PeDetailTabs({ // các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút. const submitChecklist = useMemo(() => { const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner - const gia = supplierOk ? computeGiaChaoThau(evaluation) : null + // [S116 anh Kiệt] "đã nhập giá" = có ≥1 báo giá được chọn !== 0 (âm = giá thật). Không + // dùng SUM > 0 (net-zero có giá thật bị false-đỏ). Mirror BE quotes WHERE IsSelected. + const hasRealQuote = supplierOk && hasRealSelectedQuote(evaluation) const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null return [ { label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null }, { label: 'Đơn vị NCC/TP được chọn', ok: supplierOk }, - { label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 }, + { label: 'Đã nhập giá chào thầu', ok: hasRealQuote }, { label: 'Ngân sách kỳ này', ok: effBudget != null }, { label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) }, ] @@ -2182,6 +2195,10 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: { note: '', thanhTien: 0, }) + // [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG + // (KHÔNG nhét vào form) — nhớ ý-định-dấu khi ô còn trống/0. Toggle ghi giá-trị-CÓ-DẤU + // vào form.thanhTien NGAY (POST commit thẳng, không có nút "Lưu" riêng). + const [neg, setNeg] = useState(false) const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : '' const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : '' const hasError = !!(phoneError || emailError) @@ -2222,8 +2239,9 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: { note: form.note, }) const newSupplierRowId = res.data.id - // Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien > 0) - if (detailId && form.thanhTien > 0) { + // Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien !== 0). + // [S116] !== 0 (không > 0): giá ÂM tạo quote; rỗng/0 vẫn skip = "chưa báo giá". + if (detailId && form.thanhTien !== 0) { await api.post(`/purchase-evaluations/${evaluationId}/quotes`, { purchaseEvaluationDetailId: detailId, purchaseEvaluationSupplierId: newSupplierRowId, @@ -2236,7 +2254,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: { } }, onSuccess: () => { - toast.success(showQuote && form.thanhTien > 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.') + toast.success(showQuote && form.thanhTien !== 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.') qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }) onClose() }, @@ -2361,19 +2379,32 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: { {showQuote && (
-
- setForm({ ...form, thanhTien: parseVnd(e.target.value) })} - placeholder="0" - className="pr-10 font-mono text-right" - /> - đ +
+ +
+ { const mag = parseVnd(e.target.value); setForm({ ...form, thanhTien: neg ? -mag : mag }) }} + placeholder="0" + className="pr-10 font-mono text-right" + /> + đ +

- Để trống / 0 → chỉ tạo NCC, chưa báo giá. Sửa lại sau bằng cách click số tiền trong bảng. + Để trống / 0 → chỉ tạo NCC, chưa báo giá. Bấm +/− để nhập số âm (phát sinh giảm / hoàn tiền NCC). Sửa lại sau bằng cách click số tiền trong bảng.

)} @@ -2864,6 +2895,10 @@ function QuoteDialog({ const [form, setForm] = useState({ thanhTien: existing?.thanhTien ?? 0, }) + // [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG + // (KHÔNG nhét vào form: setForm({thanhTien}) thay-thế cả object sẽ nuốt mất). Init theo + // dấu quote đang mở. Toggle ghi giá-trị-CÓ-DẤU vào form.thanhTien NGAY (POST commit thẳng). + const [neg, setNeg] = useState((existing?.thanhTien ?? 0) < 0) const mut = useMutation({ mutationFn: async () => @@ -2913,19 +2948,32 @@ function QuoteDialog({

Hạng mục: {itemName}

-
- setForm({ thanhTien: parseVnd(e.target.value) })} - placeholder="0" - autoFocus - className="pr-12 font-mono text-right" - /> - đ +
+ +
+ { const mag = parseVnd(e.target.value); setForm({ thanhTien: neg ? -mag : mag }) }} + placeholder="0" + autoFocus + className="pr-12 font-mono text-right" + /> + đ +
-

VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000)

+

VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000). Bấm +/− để nhập số âm (phát sinh giảm / hoàn tiền NCC).

diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 8fa3c97..8d2ee4e 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -191,8 +191,9 @@ public class PurchaseEvaluationWorkflowService( var missing = new List(); // [multi-NCC A3(i)[2] + D5] Winner = có ÍT NHẤT 1 báo giá ĐƯỢC CHỌN (Quote.IsSelected // = nguồn-sự-thật per Detail×Supplier). Tổng giá gói = SUM ThanhTien mọi quote IsSelected - // toàn phiếu (D5 giữ >0). 0 selected → "chưa chọn"; có selected nhưng tổng ≤0 → "chưa có giá" - // (item 2 chỉ nới NGÂN SÁCH, KHÔNG nới giá NCC — winner phải có giá). + // toàn phiếu (SUM có-dấu, chỉ dùng ở ngưỡng CEO/giaTri). [S116 anh Kiệt: cho nhập giá ÂM = + // phát-sinh-giảm/hoàn-tiền = giá THẬT] 0 selected → "chưa chọn"; có selected nhưng TẤT CẢ = 0 + // → "chưa có giá" (âm/dương đều là giá thật → gửi được, kể cả 1 NCC 1 hạng mục âm; Sum()<=0→All(==0)). var selectedThanhTien = await ( from q in db.PurchaseEvaluationQuotes.AsNoTracking() join d in db.PurchaseEvaluationDetails.AsNoTracking() @@ -203,7 +204,7 @@ public class PurchaseEvaluationWorkflowService( { missing.Add("chưa chọn Đơn vị NCC/TP"); } - else if (selectedThanhTien.Sum() <= 0) + else if (selectedThanhTien.All(x => x == 0)) { missing.Add("Đơn vị được chọn chưa có giá chào thầu"); } diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeMultiWinnerTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeMultiWinnerTests.cs index cc5f7b1..e1edc55 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/PeMultiWinnerTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeMultiWinnerTests.cs @@ -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(); + 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(); + 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ỏ"); + } } diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs index 3b160e4..498835e 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs @@ -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(); + 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); + } } diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeCcmThresholdFinalizeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeCcmThresholdFinalizeTests.cs index 4d330ed..0c4b840 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PeCcmThresholdFinalizeTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeCcmThresholdFinalizeTests.cs @@ -703,4 +703,122 @@ public class PeCcmThresholdFinalizeTests pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice); } } + + // [S116 anh Kiệt — SỐ ÂM báo giá] Seed 1 winner mang 2 báo giá ĐƯỢC CHỌN trái dấu + // (mỗi giá 1 detail) → winnerQuoteTotal = SUM có dấu (NET). Cho test threshold theo + // NET: R-CEO giữ ngưỡng NET, dòng âm (phát-sinh-giảm / hoàn tiền) kéo tổng xuống. + private static async Task SeedWinnerWithTwoSelectedSignedQuotesAsync( + TestApplicationDbContext db, PurchaseEvaluation pe, decimal a, decimal b) + { + var supplierId = Guid.NewGuid(); + var pes = new PurchaseEvaluationSupplier + { + PurchaseEvaluationId = pe.Id, SupplierId = supplierId, Order = 0, IsWinner = true, + }; + var d1 = new PurchaseEvaluationDetail + { + PurchaseEvaluationId = pe.Id, GroupCode = "A.I", GroupName = "Vật tư", NoiDung = "Dòng +", Order = 0, + }; + var d2 = new PurchaseEvaluationDetail + { + PurchaseEvaluationId = pe.Id, GroupCode = "A.II", GroupName = "Phát sinh", NoiDung = "Dòng −", Order = 1, + }; + db.PurchaseEvaluationSuppliers.Add(pes); + db.PurchaseEvaluationDetails.Add(d1); + db.PurchaseEvaluationDetails.Add(d2); + db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote + { + PurchaseEvaluationDetailId = d1.Id, PurchaseEvaluationSupplierId = pes.Id, ThanhTien = a, IsSelected = true, + }); + db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote + { + PurchaseEvaluationDetailId = d2.Id, PurchaseEvaluationSupplierId = pes.Id, ThanhTien = b, IsSelected = true, + }); + await db.SaveChangesAsync(CancellationToken.None); + return supplierId; + } + + // ===================================================================== + // (d) [S116 anh Kiệt — SỐ ÂM báo giá] NET threshold: dòng âm (phát-sinh-giảm / + // hoàn tiền) kéo winnerQuoteTotal (SUM có dấu) xuống DƯỚI ngưỡng CEO → CCM + // duyệt done miễn CEO HỢP LỆ. R-CEO giữ ngưỡng NET (KHÔNG đổi) — chỉ tổng đại + // số quyết định, KHÔNG phải dòng dương lớn nhất. + // ===================================================================== + [Fact] + public async Task ApproveV2_CcmDelegation_NegativeLinePullsNetBelowThreshold_Finalizes_NoThrow() + { + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var ccm = (await fix.CreateUserAsync("ccmNeg@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id; + var ceo = (await fix.CreateUserAsync("ceoNeg@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { ccm }, // Bước 1 = CCM (đứng đây) + new[] { ceo }, // Bước 2 = CEO (sẽ bị bỏ qua) + }, ceoThreshold: CeoThreshold); // 1 tỷ + + var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-NEG"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + // +1.2 tỷ (vật tư) + (−0.3 tỷ) (giảm trừ) → NET 0.9 tỷ < ngưỡng 1 tỷ. + // Dòng dương RIÊNG (1.2 tỷ) ≥ ngưỡng — nếu code lấy max-line / positive-only = SAI. + var supplierId = await SeedWinnerWithTwoSelectedSignedQuotesAsync( + db, pe, a: 1_200_000_000m, b: -300_000_000m); + pe.SelectedSupplierId = supplierId; + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl }, + finalizeByCcmDelegation: true, + approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource); + + await act.Should().NotThrowAsync( + "winnerQuoteTotal = SUM có dấu (1.2 tỷ − 0.3 tỷ = 0.9 tỷ) < ngưỡng → CCM finalize hợp lệ"); + pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "net dưới ngưỡng → duyệt done miễn CEO"); + pe.CurrentWorkflowStepIndex.Should().BeNull(); + pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice); + } + } + + // ===================================================================== + // (e) [S116] CONTRAST — CÙNG dòng dương 1.2 tỷ NHƯNG KHÔNG có dòng âm → NET 1.2 tỷ + // ≥ ngưỡng → Conflict. Chứng minh dòng âm ở (d) là LOAD-BEARING (chính nó kéo + // net xuống mới cho finalize), test (d) KHÔNG phải happy-path trivial. + // ===================================================================== + [Fact] + public async Task ApproveV2_CcmDelegation_SamePositiveLineWithoutNegative_NetAboveThreshold_ThrowsConflict() + { + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var ccm = (await fix.CreateUserAsync("ccmNeg2@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id; + var ceo = (await fix.CreateUserAsync("ceoNeg2@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { ccm }, + new[] { ceo }, + }, ceoThreshold: CeoThreshold); + + var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-NEG2"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + // CHỈ dòng +1.2 tỷ (KHÔNG dòng âm) → net 1.2 tỷ ≥ ngưỡng. + var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_200_000_000m); + pe.SelectedSupplierId = supplierId; + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl }, + finalizeByCcmDelegation: true, + approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource); + + await act.Should().ThrowAsync() + .WithMessage("*ngưỡng CEO*"); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, + "không có dòng âm → net ≥ ngưỡng → CEO vẫn phải duyệt"); + } + } } diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs index fcf6bd1..672d0a9 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs @@ -1066,4 +1066,170 @@ public class PeSubmitGuardAndBypassTests cap1Opinion.Comment.Should().Be("Ý kiến mới sau bản rỗng", "opinion overwrite bình thường"); } } + + // ===================================================================== + // [S116 anh Kiệt — SỐ ÂM báo giá / R2-revised submit-guard] Báo giá ÂM + // (phát-sinh-giảm / hoàn tiền NCC = giá-trị THẬT) → gửi duyệt được. + // Guard ĐỔI (prod :202-208): `Sum() <= 0` (cũ) → `All(x => x == 0)` (mới): + // • CHƯA chọn gì (0 quote IsSelected) → "chưa chọn Đơn vị NCC/TP" (chặn) + // • Có chọn nhưng TẤT CẢ = 0 → "chưa có giá chào thầu" (chặn) + // • Có ≥1 báo giá KHÁC 0 (âm HOẶC dương) → QUA guard (gửi được) + // Regression-lock mạnh nhất = mixed net==0 nhưng từng dòng ≠0: dưới guard CŨ + // (Sum<=0) bị CHẶN, dưới guard MỚI (All==0) cho QUA. Revert về Sum()<=0 → + // 2 test net≤0 dưới đây RED ngay. + // ===================================================================== + + // Seed 1 winner (PES) mang N báo giá ĐƯỢC CHỌN (mỗi giá 1 detail) — cho phép âm / + // trái dấu. Mở rộng SeedWinnerWithQuoteAsync (:106, 1 quote/winner) → N quote/winner. + private static async Task SeedWinnerWithQuotesAsync( + TestApplicationDbContext db, PurchaseEvaluation pe, params decimal[] quoteThanhTien) + { + var supplierId = Guid.NewGuid(); + var pes = new PurchaseEvaluationSupplier + { + PurchaseEvaluationId = pe.Id, + SupplierId = supplierId, + Order = 0, + IsWinner = true, + }; + db.PurchaseEvaluationSuppliers.Add(pes); + var order = 0; + foreach (var tt in quoteThanhTien) + { + var detail = new PurchaseEvaluationDetail + { + PurchaseEvaluationId = pe.Id, + GroupCode = "A.I", + GroupName = "Bê tông", + NoiDung = $"Dòng {order}", + Order = order, + }; + db.PurchaseEvaluationDetails.Add(detail); + db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote + { + PurchaseEvaluationDetailId = detail.Id, + PurchaseEvaluationSupplierId = pes.Id, + ThanhTien = tt, + IsSelected = true, + }); + order++; + } + await db.SaveChangesAsync(CancellationToken.None); + return supplierId; + } + + // Dựng PE V2 đủ 4 ĐK Section-3 NHƯNG winner mang N báo giá tuỳ ý (âm / mixed). + // budget=1tr (>0 qua budget-guard) + comparison + pin workflow. Drafter ngoài + // chuỗi → submit start pointer (0,1) KHÔNG bypass. + private static async Task BuildV2PeReadyToSubmitWithQuotesAsync( + TestApplicationDbContext db, Guid workflowId, Guid drafterUserId, string code, + params decimal[] selectedQuoteThanhTien) + { + var pe = BuildPeNhap( + budgetPeriodAmount: 1_000_000m, + approvalWorkflowId: workflowId, + drafterUserId: drafterUserId, + code: code); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuotesAsync(db, pe, selectedQuoteThanhTien); + pe.SelectedSupplierId = supplierId; + SeedComparisonAttachment(db, pe); + await db.SaveChangesAsync(CancellationToken.None); + return pe; + } + + [Fact] + public async Task Submit_AllSelectedQuotesZero_ThrowsConflict_NoBidPrice_R2Revised() + { + // all-zero: winner có 2 báo giá ĐƯỢC CHỌN NHƯNG cả 2 = 0 → All(==0) → chặn + // "chưa có giá chào thầu" (số ÂM mới hợp lệ; số 0 vẫn = chưa nhập giá). Đủ + // budget + comparison → message CHỈ nhắc giá. Bổ sung test (3) single-zero: + // ở đây .All() chạy trên list NHIỀU phần tử (đều 0). + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var pe = BuildPeNhap(budgetPeriodAmount: 500_000m, code: "PE-NEG-ZERO"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuotesAsync(db, pe, 0m, 0m); + pe.SelectedSupplierId = supplierId; + SeedComparisonAttachment(db, pe); + await db.SaveChangesAsync(CancellationToken.None); + + var act = () => SubmitAsync(svc, pe, Guid.NewGuid()); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("chưa có giá chào thầu"); + ex.Which.Message.Should().NotContain("chưa chọn Đơn vị NCC/TP"); + ex.Which.Message.Should().NotContain("Ngân sách"); + ex.Which.Message.Should().NotContain("Bảng so sánh"); + pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao, "guard chặn TRƯỚC mutate"); + } + } + + [Fact] + public async Task Submit_PureNegativeQuote_PassesBidPriceGuard_SucceedsToChoDuyet_R2Revised() + { + // ⭐ TRỤC CỐT LÕI: 1 winner, 1 báo giá ÂM −500k (hoàn tiền / phát-sinh-giảm = + // giá THẬT) → All(==0) false → QUA guard → gửi duyệt OK (ChoDuyet 0,1). Dưới + // guard CŨ (Sum<=0) case này BỊ CHẶN → đây là hành-vi mới R2-revised. + var (svc, fix, db, clock) = CreateService(); + using (fix) + { + var approver = (await fix.CreateUserAsync("apprneg@s116.test", "Approver", null, Array.Empty())).Id; + var wf = await SeedWorkflowSingleStepAsync(db, approver); + var pe = await BuildV2PeReadyToSubmitWithQuotesAsync( + db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-PURE", -500_000m); + + await SubmitAsync(svc, pe, Guid.NewGuid()); // actor ngoài chuỗi + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "báo giá âm là giá THẬT → gửi duyệt được"); + pe.CurrentWorkflowStepIndex.Should().Be(0); + pe.CurrentApprovalLevelOrder.Should().Be(1); + pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7)); + } + } + + [Fact] + public async Task Submit_MixedSignQuotes_NetPositive_SucceedsToChoDuyet_R2Revised() + { + // mixed net>0: +1tr vật tư + (−300k) giảm trừ → net +700k, từng dòng ≠0 → qua guard. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var approver = (await fix.CreateUserAsync("apprmix@s116.test", "Approver", null, Array.Empty())).Id; + var wf = await SeedWorkflowSingleStepAsync(db, approver); + var pe = await BuildV2PeReadyToSubmitWithQuotesAsync( + db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-MIXPOS", 1_000_000m, -300_000m); + + await SubmitAsync(svc, pe, Guid.NewGuid()); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "net dương + từng dòng ≠0 → gửi duyệt OK"); + pe.CurrentApprovalLevelOrder.Should().Be(1); + } + } + + [Fact] + public async Task Submit_MixedSignQuotes_NetZeroButIndividualNonzero_SucceedsToChoDuyet_R2Revised() + { + // 🔒 STRONGEST regression-lock cho R2-revised: +500k + (−500k) → net == 0 NHƯNG + // từng dòng ≠0 → All(==0) FALSE → QUA guard. Dưới guard CŨ (Sum()<=0) net==0 sẽ + // BỊ CHẶN → nếu code revert về Sum()<=0, test này RED ngay. Đây chính là ranh + // giới phân biệt All(==0) vs Sum()<=0. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var approver = (await fix.CreateUserAsync("apprz@s116.test", "Approver", null, Array.Empty())).Id; + var wf = await SeedWorkflowSingleStepAsync(db, approver); + var pe = await BuildV2PeReadyToSubmitWithQuotesAsync( + db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-NET0", 500_000m, -500_000m); + + await SubmitAsync(svc, pe, Guid.NewGuid()); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, + "net==0 nhưng từng báo giá ≠0 → All(==0) false → gửi được (phân biệt All==0 vs Sum<=0)"); + pe.CurrentApprovalLevelOrder.Should().Be(1); + } + } }