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:
@@ -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
|
||||
|
||||
@@ -200,6 +200,7 @@ builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>();
|
||||
builder.Services.AddScoped<IAiSchedulingService, AiSchedulingService>();
|
||||
builder.Services.AddScoped<IAccountingAiService, AccountingAiService>();
|
||||
builder.Services.AddScoped<IAiHelpService, AiHelpService>();
|
||||
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
|
||||
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
@model PowderCoating.Application.DTOs.AI.CatalogPriceCheckReportDto?
|
||||
@{
|
||||
ViewData["Title"] = "AI Catalog Price Check";
|
||||
ViewData["PageIcon"] = "bi-robot";
|
||||
ViewData["PageHelpTitle"] = "AI Catalog Price Check";
|
||||
ViewData["PageHelpContent"] = "The AI Price Check reviews every item in your catalog against your actual operating costs and flags items that may be priced below cost, have thin margins, or appear unusually high. Results are estimates based on industry knowledge and your shop's rates — always apply your own judgment before changing prices.";
|
||||
|
||||
var sortedResults = Model?.Results
|
||||
.OrderBy(r => r.Verdict switch
|
||||
{
|
||||
"below-cost" => 0,
|
||||
"low" => 1,
|
||||
"high" => 2,
|
||||
_ => 3
|
||||
})
|
||||
.ThenBy(r => r.Name)
|
||||
.ToList() ?? new List<PowderCoating.Application.DTOs.AI.CatalogItemPriceVerdict>();
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
.verdict-badge { font-size: 0.8rem; font-weight: 600; padding: 0.3em 0.7em; border-radius: 20px; }
|
||||
.verdict-below-cost { background: #fee2e2; color: #991b1b; }
|
||||
.verdict-low { background: #fef3c7; color: #92400e; }
|
||||
.verdict-high { background: #e0e7ff; color: #3730a3; }
|
||||
.verdict-ok { background: #d1fae5; color: #065f46; }
|
||||
.confidence-low { opacity: 0.6; }
|
||||
.price-card { border-left: 4px solid #e5e7eb; }
|
||||
.price-card.below-cost { border-left-color: #ef4444; }
|
||||
.price-card.low { border-left-color: #f59e0b; }
|
||||
.price-card.high { border-left-color: #6366f1; }
|
||||
.price-card.ok { border-left-color: #10b981; }
|
||||
.cost-table td { font-size: 0.85rem; }
|
||||
.summary-stat { text-align: center; }
|
||||
.summary-stat .num { font-size: 2rem; font-weight: 700; line-height: 1; }
|
||||
.summary-stat .lbl { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
|
||||
.run-btn-wrap { min-height: 3rem; }
|
||||
</style>
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i> Back to Catalog
|
||||
</a>
|
||||
<form asp-action="RunAiPriceCheck" method="post" id="runForm" class="run-btn-wrap">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-primary" id="runBtn">
|
||||
<i class="bi bi-robot me-2"></i>
|
||||
@(Model == null ? "Run Price Check" : "Re-run Price Check")
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent mb-4">
|
||||
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Warning"] != null)
|
||||
{
|
||||
<div class="alert alert-warning alert-permanent mb-4">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Warning"]
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent mb-4">
|
||||
<i class="bi bi-x-circle me-2"></i>@TempData["Error"]
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model == null)
|
||||
{
|
||||
<!-- Empty state -->
|
||||
<div class="card text-center py-5">
|
||||
<div class="card-body">
|
||||
<i class="bi bi-robot text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="mt-3">No price check has been run yet</h4>
|
||||
<p class="text-muted mb-4">
|
||||
Click <strong>Run Price Check</strong> above to have Claude review your entire catalog
|
||||
against your shop's operating costs. Each item receives a verdict and suggested price range.
|
||||
</p>
|
||||
<p class="text-muted small">
|
||||
Make sure your <a asp-controller="CompanySettings" asp-action="Index">operating costs</a>
|
||||
are up to date before running for the first time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Summary cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body summary-stat">
|
||||
<div class="num text-danger">@Model.BelowCostCount</div>
|
||||
<div class="lbl mt-1">Below Cost</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body summary-stat">
|
||||
<div class="num text-warning">@Model.LowMarginCount</div>
|
||||
<div class="lbl mt-1">Thin Margin</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body summary-stat">
|
||||
<div class="num text-primary">@Model.HighPriceCount</div>
|
||||
<div class="lbl mt-1">Possibly High</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body summary-stat">
|
||||
<div class="num text-success">@Model.OkCount</div>
|
||||
<div class="lbl mt-1">Looks Good</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta / costs used -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body d-flex flex-wrap align-items-center gap-3">
|
||||
<i class="bi bi-clock-history text-muted"></i>
|
||||
<span class="text-muted small">
|
||||
Run @Model.RunAt.ToLocalTime().ToString("MMM d, yyyy h:mm tt") •
|
||||
@Model.ItemsChecked items checked
|
||||
</span>
|
||||
<span class="badge bg-light text-secondary small ms-auto">
|
||||
Costs used: @Model.OperatingCostsSummary
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results list -->
|
||||
<div class="row g-3">
|
||||
@foreach (var item in sortedResults!)
|
||||
{
|
||||
var cardClass = item.Verdict switch
|
||||
{
|
||||
"below-cost" => "below-cost",
|
||||
"low" => "low",
|
||||
"high" => "high",
|
||||
_ => "ok"
|
||||
};
|
||||
var verdictClass = item.Verdict switch
|
||||
{
|
||||
"below-cost" => "verdict-below-cost",
|
||||
"low" => "verdict-low",
|
||||
"high" => "verdict-high",
|
||||
_ => "verdict-ok"
|
||||
};
|
||||
var verdictLabel = item.Verdict switch
|
||||
{
|
||||
"below-cost" => "Below Cost",
|
||||
"low" => "Thin Margin",
|
||||
"high" => "High",
|
||||
_ => "OK"
|
||||
};
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card price-card @cardClass @(item.Confidence == "low" ? "confidence-low" : "")">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<strong>@item.Name</strong>
|
||||
@if (item.Confidence == "low")
|
||||
{
|
||||
<span class="badge bg-light text-secondary ms-2" title="Item name was too vague for a confident estimate">
|
||||
<i class="bi bi-question-circle me-1"></i>Low confidence
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span class="verdict-badge @verdictClass">@verdictLabel</span>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-4 text-center">
|
||||
<div class="small text-muted">Current</div>
|
||||
<div class="fw-semibold">@item.CurrentPrice.ToString("C")</div>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<div class="small text-muted">Cost Floor</div>
|
||||
<div class="fw-semibold @(item.CostFloor > item.CurrentPrice ? "text-danger" : "")">
|
||||
@item.CostFloor.ToString("C")
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<div class="small text-muted">Suggested</div>
|
||||
<div class="fw-semibold text-primary">
|
||||
@item.SuggestedPriceMin.ToString("C") – @item.SuggestedPriceMax.ToString("C")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="small text-muted mb-1">
|
||||
<i class="bi bi-rulers me-1"></i>
|
||||
Est. @item.EstimatedSqFtMin–@item.EstimatedSqFtMax sqft •
|
||||
@item.EstimatedMinutesMin–@item.EstimatedMinutesMax min
|
||||
</div>
|
||||
|
||||
<p class="small mb-1">@item.Reasoning</p>
|
||||
|
||||
<details class="small">
|
||||
<summary class="text-muted" style="cursor:pointer;">Assumptions</summary>
|
||||
<p class="mt-1 mb-0 text-muted">@item.Assumptions</p>
|
||||
</details>
|
||||
|
||||
<div class="mt-2 text-end">
|
||||
<a asp-action="Edit" asp-route-id="@item.CatalogItemId"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit Price
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/catalog-price-check.js"></script>
|
||||
}
|
||||
@@ -22,7 +22,12 @@
|
||||
<script src="~/js/catalog.js"></script>
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-end align-items-center mb-4">
|
||||
<div class="d-flex justify-content-end align-items-center gap-2 mb-4">
|
||||
<a asp-action="AiPriceCheck" class="btn btn-outline-primary text-nowrap">
|
||||
<i class="bi bi-robot me-2"></i>
|
||||
<span class="d-none d-sm-inline">AI Price Check</span>
|
||||
<span class="d-inline d-sm-none">AI</span>
|
||||
</a>
|
||||
<a asp-action="ExportCatalogPdf" class="btn btn-primary text-nowrap">
|
||||
<i class="bi bi-file-pdf me-2"></i>
|
||||
<span class="d-none d-sm-inline">Export Product Catalog to PDF</span>
|
||||
|
||||
Reference in New Issue
Block a user