Fix Quotes Index stat cards counting Converted quotes hidden from the list

The Quotes Index stat strip (OPEN / APPROVED / TOTAL VALUE) summed every
non-deleted quote, while the default list hides Converted quotes. A quote
converted to a job (whose deletion is blocked by the linked job) therefore
stayed invisible in the list but kept inflating the cards -- e.g. a blank
list showing "1" and a non-zero total value.

GetIndexStatsAsync now excludes the Converted status so the cards reflect
the same population as the default list. Converted value is intentionally
dropped from the quote pipeline because it carries forward on the job
(counting it in both would double-count the same dollars).

Also adds an explicit CompanyId predicate to GetIndexStatsAsync (defense in
depth) -- it was the only Quote query in the typed repo relying solely on
the global tenant filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:45:40 -04:00
parent c16b2445bc
commit 45d9614c47
3 changed files with 28 additions and 7 deletions
@@ -33,10 +33,17 @@ public interface IQuoteRepository : IRepository<Quote>
/// <summary>
/// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the
/// current company by the global query filter. Pass status ID sets (derived from QuoteStatusLookup)
/// to classify open vs. approved/converted quotes.
/// given <paramref name="companyId"/> (explicit predicate for defense in depth, in addition to
/// the global query filter). Pass status ID sets (derived from QuoteStatusLookup) to classify
/// open vs. approved/converted quotes.
/// <para>
/// <paramref name="excludedStatusId"/> drops quotes in that status (Converted) from every card so
/// the stat strip reflects the same population as the default Index list, which hides Converted
/// quotes. Without this, a converted quote (which cannot be deleted while a job is linked) inflates
/// TotalValue and the approved count even though it never appears in the list.
/// </para>
/// </summary>
Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> approvedConvertedStatusIds);
Task<QuoteIndexStats> GetIndexStatsAsync(int companyId, List<int> openStatusIds, List<int> approvedConvertedStatusIds, int? excludedStatusId = null);
/// <summary>
/// Loads quote items with their coat passes (InventoryItem + Vendor) and prep services for
@@ -69,10 +69,21 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
}
/// <inheritdoc/>
public async Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> approvedConvertedStatusIds)
public async Task<QuoteIndexStats> GetIndexStatsAsync(int companyId, List<int> openStatusIds, List<int> approvedConvertedStatusIds, int? excludedStatusId = null)
{
var stats = await _context.Quotes
.Where(q => !q.IsDeleted)
// Explicit CompanyId predicate (defense in depth) on top of the global tenant filter.
var query = _context.Quotes
.Where(q => q.CompanyId == companyId && !q.IsDeleted);
// Exclude the same status the default Index list hides (Converted) so the stat strip and the
// visible list summarize the identical population — a converted quote left behind by a blocked
// delete must not inflate the cards while being invisible in the list.
if (excludedStatusId.HasValue)
{
query = query.Where(q => q.QuoteStatusId != excludedStatusId.Value);
}
var stats = await query
.Select(q => new { q.QuoteStatusId, q.Total })
.ToListAsync();
@@ -248,7 +248,10 @@ public class QuotesController : Controller
var approvedConvertedIds = quoteStatuses
.Where(s => s.StatusCode == AppConstants.StatusCodes.Quote.Approved || s.StatusCode == AppConstants.StatusCodes.Quote.Converted)
.Select(s => s.Id).ToList();
var indexStats = await _unitOfWork.Quotes.GetIndexStatsAsync(draftSentIds, approvedConvertedIds);
// Exclude Converted from the stat cards so they match the default list (which hides Converted).
var convertedStatusId = quoteStatuses
.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted)?.Id;
var indexStats = await _unitOfWork.Quotes.GetIndexStatsAsync(companyId, draftSentIds, approvedConvertedIds, convertedStatusId);
ViewBag.StatOpenCount = indexStats.OpenCount;
ViewBag.StatApprovedCount = indexStats.ApprovedConvertedCount;
ViewBag.StatTotalValue = indexStats.TotalValue;