[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:
@ -179,7 +179,8 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
[HttpPost("{id:guid}/select-winner")]
|
||||
public async Task<IActionResult> SelectWinner(Guid id, [FromBody] SelectWinnerBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(id, body.SupplierIds ?? Array.Empty<Guid>()), ct);
|
||||
await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(
|
||||
id, body.DetailId, body.SupplierIds ?? Array.Empty<Guid>()), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -359,7 +360,7 @@ public record AddSupplierBody(
|
||||
string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone,
|
||||
string? PaymentTermText, string? Note);
|
||||
|
||||
public record SelectWinnerBody(IReadOnlyList<Guid>? SupplierIds); // [Mig 58] multi-winner: tập đơn vị NCC/TP thắng; [] hoặc null = bỏ chọn (anh Kiệt FDC C5 + CCM multi)
|
||||
public record SelectWinnerBody(Guid DetailId, IReadOnlyList<Guid>? SupplierIds); // [multi-NCC] hạng mục DetailId + tập NCC/TP thắng hạng mục đó; [] hoặc null = bỏ chọn hạng mục này
|
||||
|
||||
public record DetailBody(
|
||||
string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh,
|
||||
|
||||
@ -11,8 +11,8 @@ using SolutionErp.Domain.PurchaseEvaluations;
|
||||
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||
|
||||
// Kế thừa từ phiếu Duyệt NCC đã DaDuyet → tạo HĐ Draft mới. Map cơ bản:
|
||||
// SupplierId = PE.SelectedSupplierId, ProjectId, DepartmentId, TenHopDong,
|
||||
// GiaTri (sum ThanhTienNganSach của details). ContractType do user chọn
|
||||
// SupplierId = mỗi NCC winner (DERIVED IsWinner == Any IsSelected), ProjectId, DepartmentId,
|
||||
// TenHopDong, GiaTri = SUM Quote.ThanhTien các báo giá IsSelected của NCC đó (S114 D4). ContractType do user chọn
|
||||
// (phiếu có thể gen HĐ ThauPhu/NhaCungCap/MuaBan... tùy gói thầu).
|
||||
//
|
||||
// KHÔNG copy Details per-type automatically — user điền riêng sau khi HĐ
|
||||
@ -70,11 +70,10 @@ public class CreateContractFromEvaluationCommandHandler(
|
||||
.Select(w => (Guid?)w.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// GiaTri: 1 winner → tổng ngân sách details (HÀNH VI CŨ giữ nguyên); ≥2 liên-danh →
|
||||
// mỗi HĐ = tổng báo giá của NCC đó (giá HĐ = giá NCC chào; HĐ draft sửa được sau —
|
||||
// anh Kiệt UAT con số này). 0 nếu NCC chưa báo giá.
|
||||
var detailsSum = pe.Details.Sum(d => d.ThanhTienNganSach);
|
||||
var isSingle = winners.Count == 1;
|
||||
// [multi-NCC 2026-07-12 — anh Kiệt LOCKED A1/D4] GiaTri mỗi HĐ = SUM báo giá ĐƯỢC CHỌN
|
||||
// (Quote.IsSelected) của NCC đó — CẢ single lẫn multi. BỎ shortcut detailsSum cũ (single
|
||||
// trước đây = tổng ngân sách; NAY = giá thầu chào, đúng D4 "1 HĐ/NCC = SUM IsSelected
|
||||
// quotes của NCC"). 0 nếu NCC chưa có báo giá được chọn.
|
||||
|
||||
// [Mig 58] Codegen TỰ-COMMIT (transaction + ContractCodeSequence RIÊNG mỗi call) → gen
|
||||
// TẤT CẢ mã TRƯỚC khi Add vào context, build N Contract trong list LOCAL → SaveChanges
|
||||
@ -86,11 +85,9 @@ public class CreateContractFromEvaluationCommandHandler(
|
||||
if (!supplierMap.TryGetValue(w.SupplierId, out var supplier))
|
||||
throw new NotFoundException("Supplier", w.SupplierId);
|
||||
|
||||
var giaTri = isSingle
|
||||
? detailsSum
|
||||
: pe.Details.SelectMany(d => d.Quotes)
|
||||
.Where(q => q.PurchaseEvaluationSupplierId == w.Id)
|
||||
.Sum(q => q.ThanhTien);
|
||||
var giaTri = pe.Details.SelectMany(d => d.Quotes)
|
||||
.Where(q => q.PurchaseEvaluationSupplierId == w.Id && q.IsSelected)
|
||||
.Sum(q => q.ThanhTien);
|
||||
|
||||
var contract = new Contract
|
||||
{
|
||||
|
||||
@ -261,11 +261,11 @@ public record PurchaseEvaluationDetailBundleDto(
|
||||
// [S69 2026-06-17] Cờ gấp per-vai (PRO ĐỎ / CCM XANH) — FE render badge + toggle.
|
||||
bool IsUrgentByPro,
|
||||
bool IsUrgentByCcm,
|
||||
// [S69 · Mig 58 multi-winner] Tổng giá chào của các đơn vị NCC/TP ĐƯỢC CHỌN
|
||||
// (winner quote total) — SUM ThanhTien báo giá của MỌI đơn vị IsWinner (multi-winner
|
||||
// Mig 58), KHÔNG còn chỉ SelectedSupplierId. 0 khi chưa chọn. Mirror predicate
|
||||
// submit-guard (PurchaseEvaluationWorkflowService ~:188). FE so với
|
||||
// CeoApprovalThreshold hiển thị "CCM duyệt-final" hoặc "cần CEO".
|
||||
// [S69 · S114 multi-NCC per hạng mục] Tổng giá chào của các báo giá ĐƯỢC CHỌN (winner quote
|
||||
// total) — SUM ThanhTien các quote IsSelected (NGUỒN-SỰ-THẬT per hạng mục × NCC; Supplier.IsWinner
|
||||
// = DERIVED == Any IsSelected). 0 khi chưa chọn. Mirror predicate submit-guard
|
||||
// (PurchaseEvaluationWorkflowService ~:196). FE so với CeoApprovalThreshold hiển thị
|
||||
// "CCM duyệt-final" hoặc "cần CEO".
|
||||
decimal WinnerQuoteTotal,
|
||||
// [S69] Ngưỡng gói CEO của workflow đã pin (PE.ApprovalWorkflowId). Null khi
|
||||
// phiếu chưa pin workflow V2 hoặc admin chưa set ngưỡng.
|
||||
@ -325,8 +325,8 @@ public record PurchaseEvaluationDetailBundleDto(
|
||||
// flag BE-computed theo role (Procurement / CostControl | Admin — pattern S54).
|
||||
// Lũy kế: các phiếu cùng cặp, Id != this, CreatedAt < this.CreatedAt:
|
||||
// PreviousSubmittedTotal = SUM(BudgetPeriodAmount) WHERE Phase IN (ChoDuyet, DaDuyet)
|
||||
// PreviousSelectedTotal = SUM(quote ThanhTien của SelectedSupplier) WHERE Phase = DaDuyet
|
||||
// CurrentProposalTotal = SUM(quote ThanhTien của SelectedSupplier) phiếu NÀY (0 khi chưa chọn).
|
||||
// PreviousSelectedTotal = SUM(quote ThanhTien WHERE IsSelected) WHERE Phase = DaDuyet
|
||||
// CurrentProposalTotal = SUM(quote ThanhTien WHERE IsSelected) phiếu NÀY (0 khi chưa chọn).
|
||||
public record PeBudgetSummaryDto(
|
||||
Guid? BudgetId,
|
||||
decimal? ProEstimateAmount,
|
||||
|
||||
@ -261,6 +261,9 @@ public class DeletePurchaseEvaluationDetailCommandHandler(
|
||||
|
||||
db.PurchaseEvaluationDetails.Remove(entity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
// [S114 A2 INVARIANT — Bước 4 verify bắt] Cascade xóa quotes của hạng mục có thể bỏ quote
|
||||
// IsSelected cuối của 1 NCC → re-derive IsWinner toàn phiếu (writer bị sót lúc implement).
|
||||
await PeWinnerInvariant.ReSyncAfterQuoteRemovalAsync(db, request.PurchaseEvaluationId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@ -307,6 +310,13 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
|
||||
existing.ThanhTien = request.ThanhTien;
|
||||
existing.IsSelected = request.IsSelected;
|
||||
existing.Note = request.Note;
|
||||
// A2(b) INVARIANT: supplier.IsWinner == đơn vị có ÍT NHẤT 1 quote IsSelected. Đổi
|
||||
// IsSelected của 1 quote → re-derive IsWinner của supplier-row (quote KHÁC từ DB +
|
||||
// OR giá trị vừa set — DB chưa flush thay đổi hiện tại nên loại trừ existing.Id).
|
||||
supplier.IsWinner = request.IsSelected
|
||||
|| await db.PurchaseEvaluationQuotes.AsNoTracking().AnyAsync(
|
||||
q => q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId
|
||||
&& q.Id != existing.Id && q.IsSelected, ct);
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||
@ -333,6 +343,12 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
|
||||
Note = request.Note,
|
||||
};
|
||||
db.PurchaseEvaluationQuotes.Add(entity);
|
||||
// A2(b) INVARIANT — quote MỚI: supplier winner nếu quote này IsSelected hoặc đã có quote
|
||||
// IsSelected khác (chưa có cặp (detail,supplier) này trước đó nên không cần loại trừ).
|
||||
supplier.IsWinner = request.IsSelected
|
||||
|| await db.PurchaseEvaluationQuotes.AsNoTracking().AnyAsync(
|
||||
q => q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId
|
||||
&& q.IsSelected, ct);
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||
@ -382,20 +398,65 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
|
||||
|
||||
db.PurchaseEvaluationQuotes.Remove(quote);
|
||||
await db.SaveChangesAsync(ct);
|
||||
// [S114 A2 INVARIANT — Bước 4 verify bắt] Xóa quote có thể bỏ quote IsSelected CUỐI của 1
|
||||
// NCC → re-derive IsWinner (writer bị sót ở implement → nếu không, IsWinner kẹt stale →
|
||||
// CreateContract gom NCC phantom sinh HĐ giaTri=0).
|
||||
await PeWinnerInvariant.ReSyncAfterQuoteRemovalAsync(db, request.PurchaseEvaluationId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Select winner (NCC/TP được chọn tổng thể — MULTI) ==========
|
||||
// [S114 A2 — cardinality invariant helper] Re-derive Supplier.IsWinner (== có ÍT NHẤT 1 quote
|
||||
// IsSelected TOÀN PHIẾU) + re-sync SelectedSupplierId. Gọi SAU khi đã flush 1 mutation XÓA quote
|
||||
// (DeleteQuote / DeleteDetail cascade) — nơi selection biến mất KHÔNG qua SelectWinner/UpsertQuote
|
||||
// (2 writer bị sót lúc implement). Đọc DB đã-flush → không cần loại-trừ quote-đã-xóa.
|
||||
internal static class PeWinnerInvariant
|
||||
{
|
||||
public static async Task ReSyncAfterQuoteRemovalAsync(
|
||||
IApplicationDbContext db, Guid peId, CancellationToken ct)
|
||||
{
|
||||
var supplierRows = await db.PurchaseEvaluationSuppliers
|
||||
.Where(s => s.PurchaseEvaluationId == peId).ToListAsync(ct);
|
||||
if (supplierRows.Count == 0) return;
|
||||
|
||||
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC] Multi-winner: chọn NHIỀU đơn vị NCC/TP cùng
|
||||
// trúng (whole-supplier multi-select). SupplierIds = tập đơn vị thắng:
|
||||
// - List rỗng [] = BỎ CHỌN tất cả (un-select về none) — thay null cũ (anh Kiệt C5).
|
||||
// - 1 phần tử = 1 winner (SelectedSupplierId sync = đơn vị đó — backward compat).
|
||||
// - ≥2 phần tử = joint winner (SelectedSupplierId = null vì không còn "1 winner").
|
||||
// IsWinner trên supplier-row là nguồn-sự-thật mới; winnerQuoteTotal = SUM ThanhTien
|
||||
// MỌI winner-row. Mỗi id phải nằm trong danh sách NCC tham gia của phiếu.
|
||||
// Supplier-row có ÍT NHẤT 1 quote IsSelected (bất kỳ hạng mục nào) = winner.
|
||||
var selectedRowIds = (await (
|
||||
from q in db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
join d in db.PurchaseEvaluationDetails.AsNoTracking()
|
||||
on q.PurchaseEvaluationDetailId equals d.Id
|
||||
where d.PurchaseEvaluationId == peId && q.IsSelected
|
||||
select q.PurchaseEvaluationSupplierId).ToListAsync(ct)).ToHashSet();
|
||||
|
||||
var changed = false;
|
||||
foreach (var r in supplierRows)
|
||||
{
|
||||
var win = selectedRowIds.Contains(r.Id);
|
||||
if (r.IsWinner != win) { r.IsWinner = win; changed = true; }
|
||||
}
|
||||
|
||||
// Sync SelectedSupplierId (trục A legacy display): đúng 1 winner toàn phiếu → đơn vị đó; ≥2 hoặc 0 → null.
|
||||
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(p => p.Id == peId, ct);
|
||||
if (pe is not null)
|
||||
{
|
||||
var winnerSupplierIds = supplierRows.Where(r => r.IsWinner).Select(r => r.SupplierId).ToList();
|
||||
var newSel = winnerSupplierIds.Count == 1 ? winnerSupplierIds[0] : (Guid?)null;
|
||||
if (pe.SelectedSupplierId != newSel) { pe.SelectedSupplierId = newSel; changed = true; }
|
||||
}
|
||||
|
||||
if (changed) await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Select winner PER HẠNG MỤC (Detail × NCC — multi-NCC/1 hạng mục) ==========
|
||||
|
||||
// [multi-NCC 2026-07-12 — anh Kiệt LOCKED] Chọn winner theo TỪNG HẠNG MỤC (Detail): mỗi hạng
|
||||
// mục có thể có ≥2 NCC/TP cùng trúng, mỗi nhà 1 giá riêng (Quote.ThanhTien). NGUỒN-SỰ-THẬT =
|
||||
// PurchaseEvaluationQuote.IsSelected (per Detail×Supplier). Supplier.IsWinner = DERIVED (đơn vị
|
||||
// có ÍT NHẤT 1 quote IsSelected ở bất kỳ hạng mục nào). Body {DetailId, SupplierIds} = "hạng mục
|
||||
// DetailId, các NCC SupplierIds thắng" → set Quote.IsSelected=true cho (DetailId × mỗi
|
||||
// SupplierId), false cho NCC còn lại của DetailId đó, rồi derive IsWinner toàn phiếu +
|
||||
// sync SelectedSupplierId (trục A legacy). SupplierIds rỗng [] = bỏ chọn mọi NCC cho hạng mục này.
|
||||
public record SelectPurchaseEvaluationWinnerCommand(
|
||||
Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds) : IRequest;
|
||||
Guid PurchaseEvaluationId, Guid DetailId, IReadOnlyList<Guid> SupplierIds) : IRequest;
|
||||
|
||||
public class SelectPurchaseEvaluationWinnerCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
@ -406,29 +467,48 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
|
||||
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
|
||||
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
||||
|
||||
// Load mọi supplier-row của phiếu (tracking — để set IsWinner).
|
||||
var rows = await db.PurchaseEvaluationSuppliers
|
||||
// Hạng mục phải thuộc phiếu (chặn cross-phiếu / stale).
|
||||
var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync(
|
||||
d => d.Id == request.DetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
|
||||
|
||||
// Load mọi supplier-row của phiếu (tracking — set IsWinner derived).
|
||||
var supplierRows = await db.PurchaseEvaluationSuppliers
|
||||
.Where(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Distinct các id được chọn (FE có thể gửi trùng).
|
||||
var selectedIds = request.SupplierIds?.Distinct().ToHashSet() ?? new HashSet<Guid>();
|
||||
|
||||
// Mỗi id chọn phải là 1 đơn vị tham gia phiếu (chặn forge/stale).
|
||||
var participatingSupplierIds = rows.Select(r => r.SupplierId).ToHashSet();
|
||||
foreach (var sid in selectedIds)
|
||||
// Distinct id chọn (FE có thể gửi trùng) + map SupplierId(master) → supplier-row Id.
|
||||
var selectedSupplierIds = request.SupplierIds?.Distinct().ToHashSet() ?? new HashSet<Guid>();
|
||||
var rowIdBySupplierId = supplierRows.ToDictionary(r => r.SupplierId, r => r.Id);
|
||||
foreach (var sid in selectedSupplierIds)
|
||||
{
|
||||
if (!participatingSupplierIds.Contains(sid))
|
||||
if (!rowIdBySupplierId.ContainsKey(sid))
|
||||
throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
|
||||
}
|
||||
var winningRowIds = selectedSupplierIds.Select(sid => rowIdBySupplierId[sid]).ToHashSet();
|
||||
|
||||
// Set IsWinner per-row: true nếu nằm trong tập chọn, false phần còn lại.
|
||||
foreach (var r in rows)
|
||||
r.IsWinner = selectedIds.Contains(r.SupplierId);
|
||||
// Load MỌI quote của phiếu (JOIN qua Detail) — tracking. Cần cả phiếu để derive IsWinner
|
||||
// đúng (1 đơn vị có thể còn thắng hạng mục khác dù mất selection ở hạng mục này).
|
||||
var allQuotes = await (
|
||||
from q in db.PurchaseEvaluationQuotes
|
||||
join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id
|
||||
where d.PurchaseEvaluationId == request.PurchaseEvaluationId
|
||||
select q).ToListAsync(ct);
|
||||
|
||||
// Sync SelectedSupplierId: đúng 1 winner → đơn vị đó (backward compat luồng
|
||||
// tạo HĐ + DTO cũ); ≥2 hoặc 0 → null.
|
||||
entity.SelectedSupplierId = selectedIds.Count == 1 ? selectedIds.First() : null;
|
||||
// Set IsSelected cho quotes CỦA HẠNG MỤC NÀY: true nếu supplier-row thắng, false còn lại.
|
||||
foreach (var q in allQuotes.Where(q => q.PurchaseEvaluationDetailId == request.DetailId))
|
||||
q.IsSelected = winningRowIds.Contains(q.PurchaseEvaluationSupplierId);
|
||||
|
||||
// Derive Supplier.IsWinner (INVARIANT): winner ⟺ có ÍT NHẤT 1 quote IsSelected toàn phiếu.
|
||||
var selectedRowIds = allQuotes.Where(q => q.IsSelected)
|
||||
.Select(q => q.PurchaseEvaluationSupplierId).ToHashSet();
|
||||
foreach (var r in supplierRows)
|
||||
r.IsWinner = selectedRowIds.Contains(r.Id);
|
||||
|
||||
// Sync SelectedSupplierId (trục A legacy display): đúng 1 đơn vị winner toàn phiếu → đơn vị
|
||||
// đó; ≥2 hoặc 0 → null. Derive từ tập IsWinner vừa tính (giữ trục A không stale).
|
||||
var winnerSupplierIds = supplierRows.Where(r => r.IsWinner).Select(r => r.SupplierId).ToList();
|
||||
entity.SelectedSupplierId = winnerSupplierIds.Count == 1 ? winnerSupplierIds[0] : null;
|
||||
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
@ -438,9 +518,9 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
|
||||
PhaseAtChange = entity.Phase,
|
||||
UserId = currentUser.UserId,
|
||||
UserName = currentUser.FullName ?? currentUser.Email,
|
||||
Summary = selectedIds.Count == 0
|
||||
? "Bỏ chọn đơn vị NCC/TP trúng thầu"
|
||||
: $"Chọn {selectedIds.Count} đơn vị NCC/TP trúng thầu",
|
||||
Summary = selectedSupplierIds.Count == 0
|
||||
? $"Bỏ chọn NCC/TP trúng thầu cho hạng mục {detail.GroupCode}"
|
||||
: $"Chọn {selectedSupplierIds.Count} NCC/TP trúng thầu cho hạng mục {detail.GroupCode}",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
@ -901,29 +901,28 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
var prevSubmittedCount = submitted.Count;
|
||||
var prevSubmittedTotal = submitted.Sum(v => v ?? 0m);
|
||||
|
||||
// [Mig 58 multi-winner] "đã chọn thầu" = phiếu DaDuyet có ÍT NHẤT 1 đơn vị
|
||||
// IsWinner (thay `SelectedSupplierId != null` cũ — null khi ≥2 đồng-trúng nên
|
||||
// bỏ sót phiếu liên-danh). Tổng = mọi báo giá của các đơn vị IsWinner.
|
||||
// "đã chọn thầu" = phiếu DaDuyet có ÍT NHẤT 1 đơn vị IsWinner (existence — IsWinner
|
||||
// là DERIVED == Any quote IsSelected, giữ đúng ngữ nghĩa "có chọn thầu"; A3(ii)).
|
||||
var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet
|
||||
&& p.Suppliers.Any(s => s.IsWinner));
|
||||
var prevSelectedCount = await selectedPeers.CountAsync(ct);
|
||||
// [multi-NCC A3(i)[5]] Tổng "đã chọn thầu" = SUM ThanhTien quote ĐƯỢC CHỌN (Quote.IsSelected
|
||||
// = nguồn-sự-thật per Detail×Supplier) của các phiếu DaDuyet trước — thay whole-supplier IsWinner.
|
||||
var prevSelectedTotal = await (
|
||||
from p in selectedPeers
|
||||
join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
|
||||
on p.Id equals s.PurchaseEvaluationId
|
||||
where s.IsWinner
|
||||
join q in db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
on s.Id equals q.PurchaseEvaluationSupplierId
|
||||
where q.IsSelected
|
||||
select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
|
||||
|
||||
// Row 4 "Giá trị kỳ này" = tổng giá chào của MỌI đơn vị IsWinner phiếu này
|
||||
// [Mig 58 multi-winner] (khớp winnerQuoteTotal :1087 — dùng IsWinner thay
|
||||
// SelectedSupplierId vì null khi ≥2 đồng-trúng → trước đây báo 0 cho liên-danh).
|
||||
var curWinnerRowIds = e.Suppliers.Where(s => s.IsWinner)
|
||||
.Select(s => s.Id).ToList();
|
||||
var currentProposalTotal = curWinnerRowIds.Count == 0 ? 0m
|
||||
// Row 4 "Giá trị kỳ này" = SUM báo giá ĐƯỢC CHỌN (Quote.IsSelected) phiếu này
|
||||
// [multi-NCC A3(i)[5]] (khớp winnerQuoteTotal :1189 — nguồn-sự-thật per Detail×Supplier).
|
||||
var curSupplierRowIds = e.Suppliers.Select(s => s.Id).ToList();
|
||||
var currentProposalTotal = curSupplierRowIds.Count == 0 ? 0m
|
||||
: await db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
.Where(q => curWinnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
|
||||
.Where(q => curSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId) && q.IsSelected)
|
||||
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
|
||||
|
||||
// [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho
|
||||
@ -1183,16 +1182,12 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// [S69] Giá trị gói = tổng ThanhTien báo giá của NCC/TP ĐƯỢC CHỌN (winner).
|
||||
// Tính từ data đã load (Suppliers + Details.Quotes) — KHÔNG query thêm. 0 khi
|
||||
// chưa chọn NCC. FE so với CeoApprovalThreshold → "CCM duyệt-final" / "cần CEO".
|
||||
var winnerSupplierRowIds = e.Suppliers
|
||||
.Where(s => s.IsWinner) // [Mig 58] multi-winner: tổng MỌI đơn vị IsWinner
|
||||
.Select(s => s.Id)
|
||||
.ToHashSet();
|
||||
// [multi-NCC 2026-07-12 — anh Kiệt LOCKED D1] Giá trị gói = SUM ThanhTien báo giá ĐƯỢC
|
||||
// CHỌN per hạng mục (Quote.IsSelected = nguồn-sự-thật). Tính từ data đã load (Details.Quotes)
|
||||
// — KHÔNG query thêm. 0 khi chưa chọn NCC nào. FE so CeoApprovalThreshold → "CCM duyệt-final".
|
||||
var winnerQuoteTotal = e.Details
|
||||
.SelectMany(det => det.Quotes)
|
||||
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId))
|
||||
.Where(q => q.IsSelected)
|
||||
.Sum(q => q.ThanhTien);
|
||||
|
||||
// [Mig 54] Capability nhập giá đề xuất theo role (mirror PeBudgetSummary canEdit).
|
||||
|
||||
@ -1260,6 +1260,21 @@ public static class DbInitializer
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// [multi-NCC 2026-07-12 — A2(c)] Derive IsWinner mỗi supplier-row từ grid IsSelected
|
||||
// (INVARIANT: supplier.IsWinner == đơn vị có ÍT NHẤT 1 quote IsSelected). Đặt SAU khi
|
||||
// tạo quotes để seed đồng bộ nguồn-sự-thật (Quote.IsSelected) với derived flag.
|
||||
for (int si = 0; si < supplierEntities.Count; si++)
|
||||
{
|
||||
bool anySelected = false;
|
||||
for (int di = 0; di < detailEntities.Count && di < quotesGrid.Length; di++)
|
||||
{
|
||||
var gridRow = quotesGrid[di];
|
||||
if (si < gridRow.Length && gridRow[si].IsSelected) { anySelected = true; break; }
|
||||
}
|
||||
supplierEntities[si].IsWinner = anySelected;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Approval history theo flow của type
|
||||
PurchaseEvaluationPhase[] flow = type == PurchaseEvaluationType.DuyetNcc
|
||||
? [PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BackfillPeQuoteIsSelectedAndWinnerInvariant : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// [multi-NCC 2026-07-12 — anh Kiệt LOCKED A2/A6] Data-only backfill 2 CHIỀU thiết lập
|
||||
// BẤT BIẾN: PurchaseEvaluationSupplier.IsWinner == đơn vị có ÍT NHẤT 1
|
||||
// PurchaseEvaluationQuote.IsSelected. NGUỒN-SỰ-THẬT mới = Quote.IsSelected (per
|
||||
// Detail×Supplier); IsWinner (whole-supplier, Mig 58) → DERIVED. Idempotent (WHERE ...=0
|
||||
// → re-run no-op). SQL-Server-only (tests SQLite EnsureCreated KHÔNG replay migration).
|
||||
|
||||
// CHIỀU 1 (IsWinner → IsSelected): phiếu cũ mô hình whole-supplier winner (Mig 58) →
|
||||
// mọi báo giá của đơn vị IsWinner=1 được đánh IsSelected=1 (đơn vị thắng cả gói → mọi
|
||||
// hạng mục của họ được chọn). Preserve tổng: winnerQuoteTotal cũ (SUM quote của IsWinner)
|
||||
// == winnerQuoteTotal mới (SUM quote IsSelected). Mirror Mig 58 idempotent.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE q SET IsSelected = 1
|
||||
FROM PurchaseEvaluationQuotes q
|
||||
INNER JOIN PurchaseEvaluationSuppliers s ON s.Id = q.PurchaseEvaluationSupplierId
|
||||
WHERE s.IsWinner = 1 AND q.IsSelected = 0;");
|
||||
|
||||
// CHIỀU 2 (IsSelected → IsWinner): dữ liệu dormant/seed có Quote.IsSelected=1 nhưng
|
||||
// supplier-row IsWinner=0 (per-hạng-mục selection chưa đồng bộ flag) → set IsWinner=1
|
||||
// cho đơn vị có tồn tại ≥1 quote IsSelected. Sau 2 chiều: IsWinner ⟺ Any(IsSelected).
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE s SET IsWinner = 1
|
||||
FROM PurchaseEvaluationSuppliers s
|
||||
WHERE s.IsWinner = 0
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM PurchaseEvaluationQuotes q
|
||||
WHERE q.PurchaseEvaluationSupplierId = s.Id AND q.IsSelected = 1);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Data-only backfill KHÔNG reversible (không snapshot trạng thái IsSelected/IsWinner
|
||||
// trước khi thiết lập bất biến; revert = phá bất biến / mất per-hạng-mục selection).
|
||||
// No-op an toàn — mirror BackfillEndedByLevelFinalizeFromChangelog (Mig 61).
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -189,25 +189,23 @@ public class PurchaseEvaluationWorkflowService(
|
||||
// missingForApproval (FE KHÔNG check Purpose enum → BE cũng không,
|
||||
// tránh mismatch 2 tầng).
|
||||
var missing = new List<string>();
|
||||
// [Mig 58 CCM item 1] Multi-winner: winner = các supplier-row IsWinner (≥1). Tổng giá
|
||||
// gói = SUM ThanhTien MỌI winner-row (vd 3 đơn vị 37.8+9+10=56.8tr). 0 winner →
|
||||
// "chưa chọn"; có winner nhưng tổng ≤ 0 → "chưa có giá chào thầu" (item 2 chỉ nới
|
||||
// NGÂN SÁCH, KHÔNG nới giá NCC — winner phải có giá).
|
||||
var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
|
||||
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner)
|
||||
.Select(s => s.Id)
|
||||
.ToListAsync(ct);
|
||||
if (winnerRowIds.Count == 0)
|
||||
// [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á).
|
||||
var selectedThanhTien = await (
|
||||
from q in db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
join d in db.PurchaseEvaluationDetails.AsNoTracking()
|
||||
on q.PurchaseEvaluationDetailId equals d.Id
|
||||
where d.PurchaseEvaluationId == evaluation.Id && q.IsSelected
|
||||
select q.ThanhTien).ToListAsync(ct);
|
||||
if (selectedThanhTien.Count == 0)
|
||||
{
|
||||
missing.Add("chưa chọn Đơn vị NCC/TP");
|
||||
}
|
||||
else
|
||||
else if (selectedThanhTien.Sum() <= 0)
|
||||
{
|
||||
var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
|
||||
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
|
||||
if (winnerQuoteTotal <= 0)
|
||||
missing.Add("Đơn vị được chọn chưa có giá chào thầu");
|
||||
missing.Add("Đơn vị được chọn chưa có giá chào thầu");
|
||||
}
|
||||
// [S61 Mig 50] Schema ngân sách mới — điều kiện (3) = "Ngân sách - kỳ này"
|
||||
// (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval
|
||||
@ -881,14 +879,14 @@ public class PurchaseEvaluationWorkflowService(
|
||||
throw new ForbiddenException(
|
||||
"Chỉ CCM (Kiểm soát Chi phí) được duyệt done miễn CEO.");
|
||||
|
||||
var winnerSupplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
|
||||
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner) // [Mig 58] multi-winner
|
||||
.Select(s => s.Id)
|
||||
.ToListAsync(ct);
|
||||
var winnerQuoteTotal = winnerSupplierRowIds.Count == 0 ? 0m
|
||||
: await db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId))
|
||||
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
|
||||
// [multi-NCC A3(i)[3]] Giá gói = SUM ThanhTien báo giá ĐƯỢC CHỌN (Quote.IsSelected =
|
||||
// nguồn-sự-thật) toàn phiếu. CCM duyệt done miễn CEO chỉ khi giá gói < ngưỡng (strict).
|
||||
var winnerQuoteTotal = await (
|
||||
from q in db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
join d in db.PurchaseEvaluationDetails.AsNoTracking()
|
||||
on q.PurchaseEvaluationDetailId equals d.Id
|
||||
where d.PurchaseEvaluationId == evaluation.Id && q.IsSelected
|
||||
select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
|
||||
|
||||
if (winnerQuoteTotal >= ceoThreshold)
|
||||
throw new ConflictException(
|
||||
|
||||
Reference in New Issue
Block a user