Add AI Catalog Price Check feature

Claude reviews every active catalog item against the shop's own operating costs
and returns a per-item verdict (below-cost / thin-margin / high / ok) with a
suggested price range, cost floor, and assumptions.

- New entity: CatalogPriceCheckReport (JSON blob, archived per company)
- New service: IAiCatalogPriceCheckService / AiCatalogPriceCheckService
  batches items 25 at a time to stay within model context limits
- Two new controller actions: GET AiPriceCheck (view report) + POST RunAiPriceCheck
- AiPriceCheck view: summary cards (counts by verdict), color-coded item cards
  with Edit Price link, assumptions detail, and loading spinner on submit
- AI Price Check button added to catalog Index header
- Migration AddCatalogPriceCheckReport applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 18:41:56 -04:00
parent dbe4170986
commit 54f444d981
15 changed files with 10220 additions and 5 deletions
@@ -4,14 +4,17 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Identity;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.DTOs.Catalog;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
namespace PowderCoating.Web.Controllers
@@ -35,6 +38,7 @@ namespace PowderCoating.Web.Controllers
private readonly IMeasurementConversionService _measurementService;
private readonly ISubscriptionService _subscriptionService;
private readonly ICatalogImageService _catalogImageService;
private readonly IAiCatalogPriceCheckService _priceCheckService;
public CatalogItemsController(
IUnitOfWork unitOfWork,
@@ -45,7 +49,8 @@ namespace PowderCoating.Web.Controllers
ITenantContext tenantContext,
IMeasurementConversionService measurementService,
ISubscriptionService subscriptionService,
ICatalogImageService catalogImageService)
ICatalogImageService catalogImageService,
IAiCatalogPriceCheckService priceCheckService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -56,6 +61,7 @@ namespace PowderCoating.Web.Controllers
_measurementService = measurementService;
_subscriptionService = subscriptionService;
_catalogImageService = catalogImageService;
_priceCheckService = priceCheckService;
}
/// <summary>
@@ -915,6 +921,155 @@ namespace PowderCoating.Web.Controllers
return RedirectToAction(nameof(Index));
}
}
// ── AI Price Check ────────────────────────────────────────────────────
/// <summary>
/// Displays the most recent AI price-check report for this company, or an empty state
/// if the check has never been run. The report is stored as JSON in the database so
/// users can review it later without re-running the AI call.
/// </summary>
public async Task<IActionResult> AiPriceCheck()
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
var existing = await _unitOfWork.CatalogPriceCheckReports.FindAsync(
r => r.CompanyId == currentUser.CompanyId);
var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault();
CatalogPriceCheckReportDto? dto = null;
if (report != null)
{
var results = JsonSerializer.Deserialize<List<CatalogItemPriceVerdict>>(
report.ResultsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
dto = new CatalogPriceCheckReportDto
{
Id = report.Id,
RunAt = report.RunAt,
ItemsChecked = report.ItemsChecked,
Results = results,
OperatingCostsSummary = report.OperatingCostsSummary,
BelowCostCount = results.Count(r => r.Verdict == "below-cost"),
LowMarginCount = results.Count(r => r.Verdict == "low"),
HighPriceCount = results.Count(r => r.Verdict == "high"),
OkCount = results.Count(r => r.Verdict == "ok")
};
}
return View(dto);
}
/// <summary>
/// Runs the AI price check against all active catalog items using the company's current
/// operating costs. Batches items in groups of 25 to stay within model context limits.
/// Overwrites any existing report for this company rather than accumulating a history,
/// since operating costs change and old reports become misleading.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RunAiPriceCheck()
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
try
{
// Load all active catalog items with categories
var items = (await _unitOfWork.CatalogItems.FindAsync(
ci => ci.IsActive, false, ci => ci.Category)).ToList();
if (items.Count == 0)
{
TempData["Warning"] = "No active catalog items to analyze.";
return RedirectToAction(nameof(AiPriceCheck));
}
// Load company operating costs
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(
c => c.CompanyId == currentUser.CompanyId)).FirstOrDefault();
var costSummary = BuildCostSummary(costs);
// Map to service DTOs
var itemDtos = items.Select(i => new CatalogItemForPriceCheck
{
Id = i.Id,
Name = i.Name,
Description = i.Description,
CategoryName = i.Category?.Name ?? "Uncategorized",
CurrentPrice = i.DefaultPrice,
ApproximateAreaSqFt = i.ApproximateArea,
EstimatedMinutes = i.DefaultEstimatedMinutes,
RequiresSandblasting = i.DefaultRequiresSandblasting,
RequiresMasking = i.DefaultRequiresMasking
}).ToList();
// Run AI analysis
var verdicts = await _priceCheckService.AnalyzeAsync(itemDtos, costSummary);
// Soft-delete any previous report for this company
var existing = await _unitOfWork.CatalogPriceCheckReports.FindAsync(
r => r.CompanyId == currentUser.CompanyId);
foreach (var old in existing)
await _unitOfWork.CatalogPriceCheckReports.SoftDeleteAsync(old.Id);
// Save new report
var report = new CatalogPriceCheckReport
{
CompanyId = currentUser.CompanyId,
RunAt = DateTime.UtcNow,
ItemsChecked = items.Count,
ResultsJson = JsonSerializer.Serialize(verdicts),
OperatingCostsSummary = BuildCostSummaryText(costSummary)
};
await _unitOfWork.CatalogPriceCheckReports.AddAsync(report);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"AI price check complete — {items.Count} items analyzed.";
}
catch (OperationCanceledException)
{
TempData["Error"] = "The AI analysis timed out. Try again or reduce your catalog size.";
}
catch (Exception ex)
{
_logger.LogError(ex, "AI catalog price check failed");
TempData["Error"] = "An error occurred during the AI price check. Please try again.";
}
return RedirectToAction(nameof(AiPriceCheck));
}
private static ShopOperatingCostSummary BuildCostSummary(CompanyOperatingCosts? costs)
{
if (costs == null)
return new ShopOperatingCostSummary();
return new ShopOperatingCostSummary
{
LaborRatePerHour = costs.StandardLaborRate,
OvenCostPerHour = costs.OvenOperatingCostPerHour,
SandblasterCostPerHour = costs.SandblasterCostPerHour,
CoatingBoothCostPerHour = costs.CoatingBoothCostPerHour,
PowderCostPerSqFt = costs.PowderCoatingCostPerSqFt,
ShopSuppliesRatePercent = costs.ShopSuppliesRate,
MarkupOrMarginPercent = costs.PricingMode == PricingMode.MarginOnTotalCost
? costs.TargetMarginPercent
: costs.GeneralMarkupPercentage,
PricingMode = costs.PricingMode == PricingMode.MarginOnTotalCost ? "margin" : "markup",
ShopMinimumCharge = costs.ShopMinimumCharge,
AiContextProfile = costs.AiContextProfile
};
}
private static string BuildCostSummaryText(ShopOperatingCostSummary c) =>
$"Labor ${c.LaborRatePerHour:F2}/hr | Oven ${c.OvenCostPerHour:F2}/hr | " +
$"Blaster ${c.SandblasterCostPerHour:F2}/hr | Booth ${c.CoatingBoothCostPerHour:F2}/hr | " +
$"Powder ${c.PowderCostPerSqFt:F2}/sqft | " +
$"{(c.PricingMode == "margin" ? "Margin" : "Markup")} {c.MarkupOrMarginPercent:F1}%";
}
// Helper class for hierarchical display