Add platform powder catalog, catalog-first lookup, and label scanner

- Platform PowderCatalogItem table (IPlainRepository, no tenant filter) with
  full spec fields: cure temp/time, finish, color families, clear coat flag,
  coverage sq ft/lb, transfer efficiency, IsUserContributed
- Two EF migrations: AddPowderCatalogItem + AddPowderCatalogSpecFields
- PowderCatalogController (SuperAdminOnly): import from Prismatic JSON scrape,
  Lookup AJAX endpoint (catalog-first, ranked by SKU exact match), stats view
  with Tenant Contributed card
- Unified smart Lookup button on inventory Create/Edit: catalog hit fills all
  fields via catalogSnapshot pattern; AI augments cure/finish data from product
  URL if subscription enabled; catalog miss falls through to AI lookup
- In-browser label scanner (_LabelScanModal): getUserMedia live camera feed,
  jsQR auto-detects QR codes in rAF loop; "Scan Label Text" fallback sends
  captured frame to Claude vision via /Inventory/ScanLabel
- ScanLabel endpoint handles both QR URL path (LookupByUrlAsync) and vision
  path (ScanLabelAsync); auto-inserts unrecognized products as
  IsUserContributed=true; returns wasInCatalog/addedToCatalog flags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 16:36:25 -04:00
parent 90f333c8f3
commit 1fc79b77fe
25 changed files with 21279 additions and 23 deletions
@@ -279,6 +279,12 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
/// </summary>
public DbSet<SubscriptionPlanConfig> SubscriptionPlanConfigs { get; set; }
/// <summary>
/// Platform-level master list of powder coating products across all vendors.
/// Not tenant-scoped — no global query filters applied.
/// </summary>
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
public DbSet<BugReport> BugReports { get; set; }
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
@@ -1680,6 +1686,16 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
.IsUnique()
.HasDatabaseName("IX_NotificationTemplates_Company_Type_Channel");
// PowderCatalogItem — platform-level, no tenant filter, unique on (VendorName, Sku)
modelBuilder.Entity<PowderCatalogItem>()
.HasIndex(p => new { p.VendorName, p.Sku })
.IsUnique()
.HasDatabaseName("IX_PowderCatalogItems_Vendor_Sku");
modelBuilder.Entity<PowderCatalogItem>()
.HasIndex(p => p.ColorName)
.HasDatabaseName("IX_PowderCatalogItems_ColorName");
// OvenBatch → Equipment (nullable, legacy — batches are historical records)
modelBuilder.Entity<OvenBatch>()
.HasOne(b => b.Equipment)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPowderCatalogItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SdsUrl",
table: "InventoryItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TdsUrl",
table: "InventoryItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "PowderCatalogItems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VendorName = table.Column<string>(type: "nvarchar(450)", nullable: false),
Sku = table.Column<string>(type: "nvarchar(450)", nullable: false),
ColorName = table.Column<string>(type: "nvarchar(450)", nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
UnitPrice = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
PriceTiersJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
ImageUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
SdsUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
TdsUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
ApplicationGuideUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
ProductUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDiscontinued = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
LastSyncedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PowderCatalogItems", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3667));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3674));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3675));
migrationBuilder.CreateIndex(
name: "IX_PowderCatalogItems_ColorName",
table: "PowderCatalogItems",
column: "ColorName");
migrationBuilder.CreateIndex(
name: "IX_PowderCatalogItems_Vendor_Sku",
table: "PowderCatalogItems",
columns: new[] { "VendorName", "Sku" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "SdsUrl",
table: "InventoryItems");
migrationBuilder.DropColumn(
name: "TdsUrl",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941));
}
}
}
@@ -0,0 +1,142 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPowderCatalogSpecFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ColorFamilies",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CoverageSqFtPerLb",
table: "PowderCatalogItems",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CureTemperatureF",
table: "PowderCatalogItems",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CureTimeMinutes",
table: "PowderCatalogItems",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Finish",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsUserContributed",
table: "PowderCatalogItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "RequiresClearCoat",
table: "PowderCatalogItems",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "TransferEfficiency",
table: "PowderCatalogItems",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5184));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5189));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5191));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ColorFamilies",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "CoverageSqFtPerLb",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "CureTemperatureF",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "CureTimeMinutes",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "Finish",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "IsUserContributed",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "RequiresClearCoat",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "TransferEfficiency",
table: "PowderCatalogItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3667));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3674));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3675));
}
}
}
@@ -3307,9 +3307,15 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("SdsUrl")
.HasColumnType("nvarchar(max)");
b.Property<string>("SpecPageUrl")
.HasColumnType("nvarchar(max)");
b.Property<string>("TdsUrl")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("TransferEfficiency")
.HasColumnType("decimal(18,2)");
@@ -5740,6 +5746,98 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("PlatformSettings");
});
modelBuilder.Entity("PowderCoating.Core.Entities.PowderCatalogItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ApplicationGuideUrl")
.HasColumnType("nvarchar(max)");
b.Property<string>("ColorFamilies")
.HasColumnType("nvarchar(max)");
b.Property<string>("ColorName")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<decimal?>("CoverageSqFtPerLb")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CureTemperatureF")
.HasColumnType("decimal(18,2)");
b.Property<int?>("CureTimeMinutes")
.HasColumnType("int");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<string>("Finish")
.HasColumnType("nvarchar(max)");
b.Property<string>("ImageUrl")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDiscontinued")
.HasColumnType("bit");
b.Property<bool>("IsUserContributed")
.HasColumnType("bit");
b.Property<DateTime?>("LastSyncedAt")
.HasColumnType("datetime2");
b.Property<string>("PriceTiersJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProductUrl")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("RequiresClearCoat")
.HasColumnType("bit");
b.Property<string>("SdsUrl")
.HasColumnType("nvarchar(max)");
b.Property<string>("Sku")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("TdsUrl")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("TransferEfficiency")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("VendorName")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("ColorName")
.HasDatabaseName("IX_PowderCatalogItems_ColorName");
b.HasIndex("VendorName", "Sku")
.IsUnique()
.HasDatabaseName("IX_PowderCatalogItems_Vendor_Sku");
b.ToTable("PowderCatalogItems");
});
modelBuilder.Entity("PowderCoating.Core.Entities.PowderUsageLog", b =>
{
b.Property<int>("Id")
@@ -5930,7 +6028,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933),
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5184),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5941,7 +6039,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939),
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5189),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5952,7 +6050,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941),
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5191),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -60,6 +60,7 @@ public class UnitOfWork : IUnitOfWork
private IRepository<QuoteItemPrepService>? _quoteItemPrepServices;
private IRepository<QuoteChangeHistory>? _quoteChangeHistories;
private IRepository<InventoryItem>? _inventoryItems;
private IPlainRepository<PowderCatalogItem>? _powderCatalog;
private IInventoryTransactionRepository? _inventoryTransactions;
private IRepository<Equipment>? _equipment;
private IRepository<OvenCost>? _ovenCosts;
@@ -244,6 +245,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<InventoryItem> InventoryItems =>
_inventoryItems ??= new Repository<InventoryItem>(_context);
/// <summary>Platform-level powder catalog — no tenant filter, no soft delete.</summary>
public IPlainRepository<PowderCatalogItem> PowderCatalog =>
_powderCatalog ??= new PlainRepository<PowderCatalogItem>(_context);
/// <summary>Repository for <see cref="InventoryTransaction"/> stock movements; tenant-filtered with soft delete.</summary>
public IInventoryTransactionRepository InventoryTransactions =>
_inventoryTransactions ??= new InventoryTransactionRepository(_context);
@@ -267,6 +267,201 @@ Rules:
}
}
/// <summary>
/// Reads a powder label photo using Claude vision and extracts structured product data:
/// manufacturer, color name, SKU, cure temperature, cure time, and finish. Used by the
/// in-browser label scanner so shop staff can point a phone at a bag and auto-fill the
/// inventory form without typing anything.
/// </summary>
public async Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
return new InventoryAiLookupResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
const string labelPrompt = @"This is a photo of a powder coating product label. Extract every piece of product information visible on the label.
Respond ONLY with a valid JSON object — no markdown, no explanation:
{
""manufacturer"": ""the brand name shown on the label, e.g. 'Prismatic Powders', 'Columbia Coatings'"",
""manufacturerPartNumber"": ""the SKU or part number printed on the label, e.g. 'PPS-1505', 'S5700001'"",
""colorName"": ""the product color name printed on the label"",
""colorCode"": ""RAL or NCS code if printed, otherwise null"",
""description"": ""null — labels don't have descriptions"",
""finish"": ""one of: Gloss, Matte, Satin, Flat, Texture, Wrinkle, Metallic, Pearl, Hammertone, Chrome — infer from the color name or any finish text on label, or null"",
""cureTemperatureF"": ""temperature number in °F from the cure schedule printed on label — convert from °C if needed"",
""cureTimeMinutes"": ""minutes number from the cure schedule printed on label"",
""colorFamilies"": ""comma-separated families from: Red,Orange,Yellow,Green,Blue,Purple,Pink,Brown,Black,White,Gray,Silver,Gold,Bronze,Copper,Clear — infer from color name and bag color"",
""requiresClearCoat"": ""true/false/null based on product name or any text on label"",
""coverageSqFtPerLb"": null,
""transferEfficiency"": null,
""unitCostPerLb"": null,
""vendorName"": ""same as manufacturer for powder labels"",
""reasoning"": ""one sentence: what you read from the label""
}
Rules:
- Read the label text exactly as printed — do not guess or invent SKUs or part numbers.
- cureTemperatureF and cureTimeMinutes are almost always printed on powder labels — look carefully for 'Cure Schedule', 'Cure Time', 'Bake At', or similar text.
- colorFamilies: infer from the color name and the visible powder/bag color in the photo.
- If a field is not on the label and cannot be confidently inferred, use null.";
try
{
var client = new AnthropicClient(apiKey);
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 1024,
SystemMessage = labelPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase>
{
new ImageContent
{
Source = new ImageSource
{
MediaType = mediaType,
Data = base64Image
}
},
new TextContent { Text = "Read this powder coating label and return the JSON." }
}
}
}
};
var response = await client.Messages.GetClaudeMessageAsync(messageRequest);
var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? string.Empty;
rawText = rawText.Trim();
if (rawText.StartsWith("```"))
{
var start = rawText.IndexOf('\n') + 1;
var end = rawText.LastIndexOf("```");
rawText = rawText[start..end].Trim();
}
if (!rawText.StartsWith("{"))
{
var jsonStart = rawText.IndexOf('{');
var jsonEnd = rawText.LastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart)
rawText = rawText[jsonStart..(jsonEnd + 1)];
else
return new InventoryAiLookupResult { Success = false, ErrorMessage = "AI returned an unexpected response format." };
}
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
return new InventoryAiLookupResult
{
Success = true,
Manufacturer = GetString(parsed, "manufacturer"),
ManufacturerPartNumber= GetString(parsed, "manufacturerPartNumber"),
ColorName = GetString(parsed, "colorName"),
ColorCode = GetString(parsed, "colorCode"),
Finish = GetString(parsed, "finish"),
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
ColorFamilies = GetString(parsed, "colorFamilies"),
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
VendorName = GetString(parsed, "vendorName"),
Reasoning = GetString(parsed, "reasoning"),
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during label scan AI call");
return new InventoryAiLookupResult { Success = false, ErrorMessage = "Label scan failed: " + ex.Message };
}
}
/// <summary>
/// Fetches cure specs, color families, finish, and clear-coat data directly from a
/// known product page URL without running a Serper search. Used after a catalog hit
/// to augment the catalog record with fields the catalog table doesn't store.
/// </summary>
public async Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
return new InventoryAiLookupResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var (pageContent, pageImageUrl) = await FetchPageAsync(url);
var userPrompt = BuildUserPrompt(null, colorName, null, null, new List<string>(), url, pageContent);
var client = new AnthropicClient(apiKey);
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 1024,
SystemMessage = ClaudeSystemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await client.Messages.GetClaudeMessageAsync(messageRequest);
var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? string.Empty;
rawText = rawText.Trim();
if (rawText.StartsWith("```"))
{
var start = rawText.IndexOf('\n') + 1;
var end = rawText.LastIndexOf("```");
rawText = rawText[start..end].Trim();
}
if (!rawText.StartsWith("{"))
{
var jsonStart = rawText.IndexOf('{');
var jsonEnd = rawText.LastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart)
rawText = rawText[jsonStart..(jsonEnd + 1)];
else
return new InventoryAiLookupResult { Success = false, ErrorMessage = "AI returned an unexpected response format." };
}
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
return new InventoryAiLookupResult
{
Success = true,
Finish = GetString(parsed, "finish"),
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
ColorFamilies = GetString(parsed, "colorFamilies"),
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
TransferEfficiency= GetDecimal(parsed, "transferEfficiency"),
ImageUrl = pageImageUrl,
Reasoning = GetString(parsed, "reasoning"),
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during AI URL augment for {Url}", url);
return new InventoryAiLookupResult { Success = false, ErrorMessage = "AI lookup failed: " + ex.Message };
}
}
// ── Manufacturer URL pattern: build direct product page URL ───────────────
/// <summary>