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:
@@ -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)
|
||||
|
||||
Generated
+9472
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9496
File diff suppressed because it is too large
Load Diff
+142
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user