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
|
||||
|
||||
Reference in New Issue
Block a user