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,80 @@
namespace PowderCoating.Application.DTOs.AI;
// ── Input ─────────────────────────────────────────────────────────────────────
/// <summary>Lightweight representation of a catalog item sent to Claude for analysis.</summary>
public class CatalogItemForPriceCheck
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string CategoryName { get; set; } = string.Empty;
public decimal CurrentPrice { get; set; }
public decimal? ApproximateAreaSqFt { get; set; }
public int? EstimatedMinutes { get; set; }
public bool RequiresSandblasting { get; set; }
public bool RequiresMasking { get; set; }
}
/// <summary>Operating cost summary injected into the Claude system prompt.</summary>
public class ShopOperatingCostSummary
{
public decimal LaborRatePerHour { get; set; }
public decimal OvenCostPerHour { get; set; }
public decimal SandblasterCostPerHour { get; set; }
public decimal CoatingBoothCostPerHour { get; set; }
public decimal PowderCostPerSqFt { get; set; }
public decimal ShopSuppliesRatePercent { get; set; }
public decimal MarkupOrMarginPercent { get; set; }
public string PricingMode { get; set; } = "markup"; // "markup" or "margin"
public decimal ShopMinimumCharge { get; set; }
public string? AiContextProfile { get; set; }
}
// ── Per-Item Result ───────────────────────────────────────────────────────────
/// <summary>Verdict on a single catalog item's price health.</summary>
public class CatalogItemPriceVerdict
{
public int CatalogItemId { get; set; }
public string Name { get; set; } = string.Empty;
public decimal CurrentPrice { get; set; }
/// <summary>Assumptions Claude made about size/complexity to estimate costs.</summary>
public string Assumptions { get; set; } = string.Empty;
public decimal EstimatedSqFtMin { get; set; }
public decimal EstimatedSqFtMax { get; set; }
public int EstimatedMinutesMin { get; set; }
public int EstimatedMinutesMax { get; set; }
/// <summary>Calculated cost floor using the shop's own rates.</summary>
public decimal CostFloor { get; set; }
/// <summary>ok | low | high | below-cost</summary>
public string Verdict { get; set; } = "ok";
public decimal SuggestedPriceMin { get; set; }
public decimal SuggestedPriceMax { get; set; }
/// <summary>high | medium | low</summary>
public string Confidence { get; set; } = "medium";
public string Reasoning { get; set; } = string.Empty;
}
// ── Report ────────────────────────────────────────────────────────────────────
public class CatalogPriceCheckReportDto
{
public int Id { get; set; }
public DateTime RunAt { get; set; }
public int ItemsChecked { get; set; }
public int BelowCostCount { get; set; }
public int LowMarginCount { get; set; }
public int HighPriceCount { get; set; }
public int OkCount { get; set; }
public List<CatalogItemPriceVerdict> Results { get; set; } = new();
public string OperatingCostsSummary { get; set; } = string.Empty;
}
@@ -0,0 +1,16 @@
using PowderCoating.Application.DTOs.AI;
namespace PowderCoating.Application.Interfaces;
public interface IAiCatalogPriceCheckService
{
/// <summary>
/// Analyzes the provided catalog items against the shop's operating costs and returns
/// a verdict for each item. Items are batched into groups of 25 to stay within Claude's
/// context limits. Returns null results for any item that could not be analyzed.
/// </summary>
Task<List<CatalogItemPriceVerdict>> AnalyzeAsync(
List<CatalogItemForPriceCheck> items,
ShopOperatingCostSummary costs,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,20 @@
namespace PowderCoating.Core.Entities
{
/// <summary>
/// Stores the result of the most recent AI catalog price check run for a company.
/// ResultsJson holds the full per-item verdict array serialized as JSON, avoiding the
/// need for a wide per-item table while still persisting the report across sessions.
/// Only one report is kept per company — each new run overwrites the previous one.
/// </summary>
public class CatalogPriceCheckReport : BaseEntity
{
public DateTime RunAt { get; set; }
public int ItemsChecked { get; set; }
/// <summary>JSON-serialized List&lt;CatalogItemPriceVerdict&gt;.</summary>
public string ResultsJson { get; set; } = "[]";
/// <summary>Human-readable summary of the operating costs used for this run.</summary>
public string OperatingCostsSummary { get; set; } = string.Empty;
}
}
@@ -63,6 +63,7 @@ public interface IUnitOfWork : IDisposable
// Product Catalog
IRepository<CatalogCategory> CatalogCategories { get; }
IRepository<CatalogItem> CatalogItems { get; }
IRepository<CatalogPriceCheckReport> CatalogPriceCheckReports { get; }
// Oven Scheduling
IRepository<OvenBatch> OvenBatches { get; }
@@ -260,6 +260,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
public DbSet<CatalogCategory> CatalogCategories { get; set; }
/// <summary>Pre-priced service catalog items that can be added to quotes/jobs; tenant-filtered with soft delete.</summary>
public DbSet<CatalogItem> CatalogItems { get; set; }
/// <summary>Most-recent AI price-check report per company; tenant-filtered with soft delete.</summary>
public DbSet<CatalogPriceCheckReport> CatalogPriceCheckReports { get; set; }
// Notifications
/// <summary>Log of all outbound notifications (email, SMS, in-app) for audit and retry; tenant-filtered with soft delete.</summary>
@@ -508,6 +510,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CatalogItem>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CatalogPriceCheckReport>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<Appointment>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCatalogPriceCheckReport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CatalogPriceCheckReports",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
RunAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ItemsChecked = table.Column<int>(type: "int", nullable: false),
ResultsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
OperatingCostsSummary = table.Column<string>(type: "nvarchar(max)", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CatalogPriceCheckReports", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6987));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6993));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6994));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CatalogPriceCheckReports");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563));
}
}
}
@@ -1465,6 +1465,57 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CatalogItems");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CatalogPriceCheckReport", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("ItemsChecked")
.HasColumnType("int");
b.Property<string>("OperatingCostsSummary")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ResultsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("RunAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("CatalogPriceCheckReports");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Company", b =>
{
b.Property<int>("Id")
@@ -5782,7 +5833,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555),
CreatedAt = new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6987),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5793,7 +5844,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562),
CreatedAt = new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6993),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5804,7 +5855,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563),
CreatedAt = new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6994),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -84,6 +84,7 @@ public class UnitOfWork : IUnitOfWork
// Product Catalog
private IRepository<CatalogCategory>? _catalogCategories;
private IRepository<CatalogItem>? _catalogItems;
private IRepository<CatalogPriceCheckReport>? _catalogPriceCheckReports;
// Notifications
private IRepository<NotificationLog>? _notificationLogs;
@@ -344,6 +345,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<CatalogItem> CatalogItems =>
_catalogItems ??= new Repository<CatalogItem>(_context);
/// <summary>Repository for <see cref="CatalogPriceCheckReport"/> AI price-check results archived per company.</summary>
public IRepository<CatalogPriceCheckReport> CatalogPriceCheckReports =>
_catalogPriceCheckReports ??= new Repository<CatalogPriceCheckReport>(_context);
// Notifications
/// <summary>Repository for <see cref="NotificationLog"/> outbound notification audit records; tenant-filtered with soft delete.</summary>
public IRepository<NotificationLog> NotificationLogs =>
@@ -0,0 +1,250 @@
using System.Text;
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Sends catalog items to Claude in batches of 25 and collects per-item price verdicts.
/// Each batch produces one Claude call so large catalogs stay within the model's context
/// limits. Results across all batches are merged into a single flat list before returning.
/// </summary>
public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService
{
private readonly IConfiguration _config;
private readonly ILogger<AiCatalogPriceCheckService> _logger;
private const string Model = "claude-sonnet-4-6";
private const int BatchSize = 25;
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
public AiCatalogPriceCheckService(IConfiguration config, ILogger<AiCatalogPriceCheckService> logger)
{
_config = config;
_logger = logger;
}
private string? GetApiKey()
{
var key = _config["AI:Anthropic:ApiKey"];
return string.IsNullOrWhiteSpace(key) || key.StartsWith("your-") ? null : key;
}
/// <summary>
/// Strips optional ```json ... ``` fences that Claude sometimes adds despite instructions.
/// </summary>
private static string StripJsonFences(string text)
{
var trimmed = text.Trim();
if (trimmed.StartsWith("```"))
{
var firstNewline = trimmed.IndexOf('\n');
if (firstNewline >= 0) trimmed = trimmed[(firstNewline + 1)..];
if (trimmed.EndsWith("```")) trimmed = trimmed[..^3];
}
return trimmed.Trim();
}
private static async Task<MessageResponse> SendAsync(AnthropicClient client, MessageParameters parameters)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
return await client.Messages.GetClaudeMessageAsync(parameters, cts.Token);
}
/// <inheritdoc/>
public async Task<List<CatalogItemPriceVerdict>> AnalyzeAsync(
List<CatalogItemForPriceCheck> items,
ShopOperatingCostSummary costs,
CancellationToken cancellationToken = default)
{
var apiKey = GetApiKey();
if (apiKey == null)
{
_logger.LogWarning("AI Catalog Price Check called but Anthropic API key is not configured.");
return new List<CatalogItemPriceVerdict>();
}
var client = new AnthropicClient(apiKey);
var systemPrompt = BuildSystemPrompt(costs);
var results = new List<CatalogItemPriceVerdict>();
// Process items in batches of BatchSize
for (int batchStart = 0; batchStart < items.Count; batchStart += BatchSize)
{
cancellationToken.ThrowIfCancellationRequested();
var batch = items.Skip(batchStart).Take(BatchSize).ToList();
var batchResults = await AnalyzeBatchAsync(client, systemPrompt, batch, costs);
results.AddRange(batchResults);
}
return results;
}
private async Task<List<CatalogItemPriceVerdict>> AnalyzeBatchAsync(
AnthropicClient client,
string systemPrompt,
List<CatalogItemForPriceCheck> batch,
ShopOperatingCostSummary costs)
{
var userPrompt = BuildUserPrompt(batch);
var parameters = new MessageParameters
{
Model = Model,
MaxTokens = 4096,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new() { Role = RoleType.User, Content = new List<ContentBase> { new TextContent { Text = userPrompt } } }
}
};
try
{
var response = await SendAsync(client, parameters);
var raw = response.Content.OfType<TextContent>().FirstOrDefault()?.Text ?? "[]";
var json = StripJsonFences(raw);
var claudeItems = JsonSerializer.Deserialize<List<ClaudePriceCheckItem>>(json, JsonOpts) ?? new();
return claudeItems.Select(ci =>
{
var source = batch.FirstOrDefault(b => b.Id == ci.catalogItemId);
return new CatalogItemPriceVerdict
{
CatalogItemId = ci.catalogItemId,
Name = source?.Name ?? $"Item #{ci.catalogItemId}",
CurrentPrice = source?.CurrentPrice ?? 0,
Assumptions = ci.assumptions,
EstimatedSqFtMin = ci.estimatedSqFtMin,
EstimatedSqFtMax = ci.estimatedSqFtMax,
EstimatedMinutesMin = ci.estimatedMinutesMin,
EstimatedMinutesMax = ci.estimatedMinutesMax,
CostFloor = ci.costFloor,
Verdict = ci.verdict,
SuggestedPriceMin = ci.suggestedPriceMin,
SuggestedPriceMax = ci.suggestedPriceMax,
Confidence = ci.confidence,
Reasoning = ci.reasoning
};
}).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "AI catalog price check batch failed");
// Return stub verdicts so the rest of the report still renders
return batch.Select(item => new CatalogItemPriceVerdict
{
CatalogItemId = item.Id,
Name = item.Name,
CurrentPrice = item.CurrentPrice,
Verdict = "ok",
Confidence = "low",
Assumptions = "Analysis unavailable for this item.",
Reasoning = "An error occurred during analysis. Please re-run the price check."
}).ToList();
}
}
private static string BuildSystemPrompt(ShopOperatingCostSummary costs)
{
var sb = new StringBuilder();
sb.AppendLine("You are a pricing consultant for a powder coating business. Your job is to review catalog items and flag potential pricing problems against the shop's actual operating costs.");
sb.AppendLine();
sb.AppendLine("## Shop Operating Costs");
sb.AppendLine($"- Labor rate: ${costs.LaborRatePerHour:F2}/hr");
sb.AppendLine($"- Oven operating cost: ${costs.OvenCostPerHour:F2}/hr");
sb.AppendLine($"- Sandblaster cost: ${costs.SandblasterCostPerHour:F2}/hr");
sb.AppendLine($"- Coating booth cost: ${costs.CoatingBoothCostPerHour:F2}/hr");
sb.AppendLine($"- Powder material cost: ${costs.PowderCostPerSqFt:F2}/sqft");
sb.AppendLine($"- Shop supplies surcharge: {costs.ShopSuppliesRatePercent:F1}%");
if (costs.PricingMode == "margin")
sb.AppendLine($"- Target gross margin: {costs.MarkupOrMarginPercent:F1}%");
else
sb.AppendLine($"- Markup on material: {costs.MarkupOrMarginPercent:F1}%");
if (costs.ShopMinimumCharge > 0)
sb.AppendLine($"- Shop minimum charge: ${costs.ShopMinimumCharge:F2}");
if (!string.IsNullOrWhiteSpace(costs.AiContextProfile))
{
sb.AppendLine();
sb.AppendLine("## Shop Profile");
sb.AppendLine(costs.AiContextProfile);
}
sb.AppendLine();
sb.AppendLine("## Instructions");
sb.AppendLine("For each item, use industry knowledge to estimate a plausible surface area and processing time. Then compute a cost floor = (labor + equipment + material) using the shop's rates above. Compare the cost floor to the item's current price and return a verdict.");
sb.AppendLine();
sb.AppendLine("Verdict values:");
sb.AppendLine("- \"below-cost\" — price is at or below cost floor (the shop loses money)");
sb.AppendLine("- \"low\" — price is above cost floor but margin is thin (< the shop's target margin)");
sb.AppendLine("- \"high\" — price appears significantly above comparable market rates (risk of losing work)");
sb.AppendLine("- \"ok\" — price is within a reasonable range");
sb.AppendLine();
sb.AppendLine("Confidence values:");
sb.AppendLine("- \"high\" — item name clearly identifies part type and standard dimensions");
sb.AppendLine("- \"medium\" — reasonable assumptions were possible");
sb.AppendLine("- \"low\" — item is too vague to estimate reliably (e.g., 'Custom Part', 'Job Special')");
sb.AppendLine();
sb.AppendLine("If the item already has an ApproximateArea or EstimatedMinutes, use those instead of guessing.");
sb.AppendLine();
sb.AppendLine("Return ONLY a JSON array, no prose, no markdown fences. Use this exact schema for each element:");
sb.AppendLine(@"{
""catalogItemId"": <int>,
""assumptions"": ""<what you assumed about size/complexity>"",
""estimatedSqFtMin"": <decimal>,
""estimatedSqFtMax"": <decimal>,
""estimatedMinutesMin"": <int>,
""estimatedMinutesMax"": <int>,
""costFloor"": <decimal>,
""verdict"": ""ok|low|high|below-cost"",
""suggestedPriceMin"": <decimal>,
""suggestedPriceMax"": <decimal>,
""confidence"": ""high|medium|low"",
""reasoning"": ""<1-2 sentence explanation>""
}");
return sb.ToString();
}
// Local schema — mirrors the JSON shape Claude is asked to return. Kept private to
// the Infrastructure layer because it's a transport detail, not a domain concept.
private sealed class ClaudePriceCheckItem
{
public int catalogItemId { get; set; }
public string assumptions { get; set; } = string.Empty;
public decimal estimatedSqFtMin { get; set; }
public decimal estimatedSqFtMax { get; set; }
public int estimatedMinutesMin { get; set; }
public int estimatedMinutesMax { get; set; }
public decimal costFloor { get; set; }
public string verdict { get; set; } = "ok";
public decimal suggestedPriceMin { get; set; }
public decimal suggestedPriceMax { get; set; }
public string confidence { get; set; } = "medium";
public string reasoning { get; set; } = string.Empty;
}
private static string BuildUserPrompt(List<CatalogItemForPriceCheck> batch)
{
var itemsJson = JsonSerializer.Serialize(batch.Select(item => new
{
catalogItemId = item.Id,
name = item.Name,
description = item.Description,
category = item.CategoryName,
currentPrice = item.CurrentPrice,
approximateAreaSqFt = item.ApproximateAreaSqFt,
estimatedMinutes = item.EstimatedMinutes,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking
}), new JsonSerializerOptions { WriteIndented = false });
return $"Analyze these {batch.Count} catalog items and return the JSON verdict array:\n{itemsJson}";
}
}
@@ -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
+1
View File
@@ -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") &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>