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
@@ -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") &bull;
@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 &bull;
@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>