[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

Đổ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:
pqhuy1987
2026-07-12 22:22:09 +07:00
parent 8a1b0d4901
commit d436af2971
18 changed files with 7272 additions and 425 deletions

View File

@ -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);
}
}
}

View File

@ -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;