[CLAUDE] PurchaseEvaluation: multi-NCC per hạng mục — chọn ≥2 NCC-TP trúng thầu/1 hạng mục (mỗi nhà 1 giá riêng)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m17s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m17s
Đổi winner-truth từ whole-supplier Supplier.IsWinner (Mig 58 liên-danh) sang per-hạng-mục
Quote.IsSelected (Mig 12 dormant hook → nay nguồn-sự-thật); Supplier.IsWinner = DERIVED (== Any IsSelected).
Anh Kiệt LOCKED D1=SUM(IsSelected) · D2=THAY-THẾ · D3=giá-chào-gốc · D4=1HĐ/NCC. 0 bảng/cột mới.
BE:
- SelectWinnerCommand + POST /select-winner: body {supplierIds} → {detailId, supplierIds} (per hạng mục).
- Re-key 5 financial-SUM → WHERE IsSelected: winnerQuoteTotal, submit-guard, CCM-threshold, giaTri, budget-B.
- giaTri bỏ shortcut isSingle→detailsSum (single đổi budget→bid theo D4).
- Derive IsWinner invariant ở MỌI writer: SelectWinner + UpsertQuote + seed + DeleteQuote/DeleteDetail (helper PeWinnerInvariant, fix Bước 4).
- GIỮ IsWinner-derived cho winner-names/existence + CreateContract winners-list.
- Migration BackfillPeQuoteIsSelectedAndWinnerInvariant (data-only 2-chiều IsWinner↔IsSelected, Down no-op).
FE 2-app (PeDetailTabs SHA-identical): HangMucCard per-cell winner grid Detail×NCC + toggle per-detail; NccSelectorRow hạ-cấp read-only summary; giữ derived display.
Test: 7 file update + 8 test mới (per-hạng-mục total, giaTri single-partial, threshold-crossing, derive-invariant, delete-path). 486 PASS.
Flow: fable-clone invest ensemble → anh Kiệt 4-decision → fable-clone review ensemble (6 adjustment) → hmw implement (disk-recover #53) → fable-clone verify (bắt MUST bug delete-path → fix test-before).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -80,6 +80,11 @@ public class PeCcmThresholdFinalizeTests
|
||||
|
||||
// Seed 1 NCC tham gia (winner) + 1 detail + 1 quote ThanhTien=amount → winner
|
||||
// quote sum = amount. Return supplierId (master ref) để pin SelectedSupplierId.
|
||||
// [multi-NCC 2026-07-12 cardinality-change] ccm-delegate winnerQuoteTotal NAY = SUM
|
||||
// ThanhTien quote ĐƯỢC CHỌN (Quote.IsSelected — service :884-889 `where q.IsSelected`),
|
||||
// KHÔNG còn theo IsWinner supplier-row. Quote PHẢI IsSelected=true thì mới vào tổng
|
||||
// → boundary test (b)/(b2) (gói >= ngưỡng → Conflict) mới đúng; nếu IsSelected=false thì
|
||||
// tổng=0 luôn < ngưỡng → mất case Conflict. Set CẢ IsSelected + IsWinner (giữ invariant).
|
||||
private static async Task<Guid> SeedWinnerWithQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
|
||||
{
|
||||
@ -89,7 +94,7 @@ public class PeCcmThresholdFinalizeTests
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = supplierId,
|
||||
Order = 0,
|
||||
IsWinner = true, // [Mig 58] ccm-delegate winnerQuoteTotal đếm SUM theo IsWinner rows
|
||||
IsWinner = true, // derived == Any(quote.IsSelected)
|
||||
};
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
@ -106,6 +111,47 @@ public class PeCcmThresholdFinalizeTests
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = quoteThanhTien,
|
||||
IsSelected = true, // [multi-NCC] báo giá được chọn → vào winnerQuoteTotal
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplierId;
|
||||
}
|
||||
|
||||
// [multi-NCC 2026-07-12 — new test (c) threshold-crossing] Seed 1 winner-row với 2 quote:
|
||||
// 1 ĐƯỢC CHỌN (selectedThanhTien) + 1 KHÔNG chọn (unselectedThanhTien). Tổng-theo-IsSelected
|
||||
// = selectedThanhTien; tổng-theo-CŨ (whole-supplier IsWinner, gồm cả quote không chọn) =
|
||||
// selected + unselected. Return supplierId.
|
||||
private static async Task<Guid> SeedWinnerWithSelectedAndUnselectedQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe,
|
||||
decimal selectedThanhTien, decimal unselectedThanhTien)
|
||||
{
|
||||
var supplierId = Guid.NewGuid();
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, SupplierId = supplierId, Order = 0, IsWinner = true,
|
||||
};
|
||||
var detailSel = new PurchaseEvaluationDetail
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, GroupCode = "A.I", GroupName = "Bê tông",
|
||||
NoiDung = "Hạng mục ĐƯỢC chọn", Order = 0,
|
||||
};
|
||||
var detailUnsel = new PurchaseEvaluationDetail
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, GroupCode = "A.II", GroupName = "Thép",
|
||||
NoiDung = "Hạng mục KHÔNG chọn", Order = 1,
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||
db.PurchaseEvaluationDetails.Add(detailSel);
|
||||
db.PurchaseEvaluationDetails.Add(detailUnsel);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
PurchaseEvaluationDetailId = detailSel.Id, PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = selectedThanhTien, IsSelected = true,
|
||||
});
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
PurchaseEvaluationDetailId = detailUnsel.Id, PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = unselectedThanhTien, IsSelected = false,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplierId;
|
||||
@ -611,4 +657,50 @@ public class PeCcmThresholdFinalizeTests
|
||||
.WithMessage("*giá chốt*");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// (c) [multi-NCC 2026-07-12 cardinality-change — NEW] THRESHOLD-CROSSING:
|
||||
// Winner có 1 quote ĐƯỢC CHỌN 500tr + 1 quote KHÔNG chọn 700tr.
|
||||
// • Tổng-theo-CŨ (whole-supplier IsWinner, gồm cả quote không chọn) = 1.2 tỷ ≥ ngưỡng
|
||||
// 1 tỷ → nếu code còn dùng logic cũ = Conflict (SAI).
|
||||
// • Tổng-theo-MỚI (Quote.IsSelected only) = 500tr < 1 tỷ → CCM duyệt done miễn CEO
|
||||
// HỢP LỆ → finalize DaDuyet, KHÔNG throw.
|
||||
// Chống regression: chứng minh winnerQuoteTotal đọc IsSelected-subset, KHÔNG whole-supplier.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_CcmDelegation_WholeSupplierSumAboveButSelectedSubsetBelow_Finalizes_NoThrow()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ccm = (await fix.CreateUserAsync("ccmXc@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceoXc@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-XC");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// Selected 500tr + Unselected 700tr → whole-supplier=1.2 tỷ ≥ ngưỡng, IsSelected=500tr < ngưỡng.
|
||||
var supplierId = await SeedWinnerWithSelectedAndUnselectedQuoteAsync(
|
||||
db, pe, selectedThanhTien: 500_000_000m, unselectedThanhTien: 700_000_000m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// finalize theo IsSelected-subset (500tr < 1 tỷ) → KHÔNG throw, DaDuyet.
|
||||
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
|
||||
finalizeByCcmDelegation: true,
|
||||
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||
|
||||
await act.Should().NotThrowAsync(
|
||||
"winnerQuoteTotal đọc SUM Quote.IsSelected (500tr < ngưỡng); logic cũ whole-supplier 1.2 tỷ đã bị thay");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "CCM duyệt done miễn CEO — gói ĐƯỢC CHỌN < ngưỡng");
|
||||
pe.CurrentWorkflowStepIndex.Should().BeNull();
|
||||
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,9 +96,13 @@ public class PeSubmitGuardAndBypassTests
|
||||
|
||||
// Seed 1 NCC tham gia (PurchaseEvaluationSupplier) cho winner + 1 detail + 1
|
||||
// quote ThanhTien=amount để winner quote sum > 0. Return supplierId (master ref).
|
||||
// [Mig 58] Winner = supplier-row IsWinner=true (nguồn-sự-thật mới — submit guard +
|
||||
// ccm-delegate + read DTO SUM theo IsWinner, KHÔNG còn theo SelectedSupplierId). Set
|
||||
// IsWinner=true + sync SelectedSupplierId trong caller để mirror handler select-winner.
|
||||
// [multi-NCC 2026-07-12 cardinality-change] Submit guard NAY đọc SUM theo
|
||||
// Quote.IsSelected (nguồn-sự-thật per Detail×Supplier — PurchaseEvaluationWorkflowService
|
||||
// :196-201 `where q.IsSelected`), KHÔNG còn IsWinner/SelectedSupplierId. Winner quote PHẢI
|
||||
// IsSelected=true thì mới lọt selectedThanhTien (nếu không → "chưa chọn Đơn vị NCC/TP" bắn
|
||||
// sai). Set CẢ quote.IsSelected=true + pes.IsWinner=true (invariant IsWinner==Any(IsSelected))
|
||||
// để mirror handler select-winner. Test (3) ThanhTien=0 vẫn đúng: IsSelected=true → lọt
|
||||
// list nhưng Sum≤0 → "chưa có giá chào thầu".
|
||||
private static async Task<Guid> SeedWinnerWithQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
|
||||
{
|
||||
@ -108,7 +112,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = supplierId,
|
||||
Order = 0,
|
||||
IsWinner = true, // [Mig 58] đánh dấu winner — submit guard đếm IsWinner rows
|
||||
IsWinner = true, // derived == Any(quote.IsSelected) — giữ đúng invariant
|
||||
};
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
@ -125,6 +129,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = quoteThanhTien,
|
||||
IsSelected = true, // [multi-NCC] báo giá ĐƯỢC CHỌN → lọt SUM submit-guard/DTO/threshold
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplierId;
|
||||
|
||||
Reference in New Issue
Block a user