Lazily enrich catalog specs from TDS on first use
Specific gravity, coverage, and ~55% of cure specs aren't in the Columbia feed. Rather than read 2,400 TDS PDFs up front, enrich a catalog item the first time it's actually used: - FetchTdsCureSpecsAsync now also extracts specific gravity from the TDS. - New EnsureCatalogTdsSpecsAsync fills a catalog item's specific gravity (and any missing cure temp/time) from its TDS, then derives theoretical coverage (192.3 / (SG x mils)). No-op once specific gravity is known or when there's no TDS; persists to the catalog so the work is done once and benefits everyone. - Hooked into the catalog->inventory paths (CreateIncomingFromCatalog, the custom-powder receive enrichment, and ReceivePowderFromCatalog) so a powder's full specs land on both the catalog and the new inventory record. DashboardController gains the AI lookup service for this. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -59,9 +59,17 @@ public interface IInventoryAiLookupService
|
||||
Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a Technical Data Sheet URL and extracts cure temperature and cure time.
|
||||
/// Called when the main lookup found a TDS URL but cure specs are still missing.
|
||||
/// Fetches a Technical Data Sheet URL and extracts cure temperature, cure time, and specific
|
||||
/// gravity. Called when the main lookup found a TDS URL but specs are still missing.
|
||||
/// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable.
|
||||
/// </summary>
|
||||
Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName);
|
||||
|
||||
/// <summary>
|
||||
/// Lazily fills a powder catalog item's specific gravity (and any missing cure specs) from its
|
||||
/// TDS the first time it's needed, then derives theoretical coverage. No-op when specific
|
||||
/// gravity is already known or no TDS URL is present. Persists the enrichment to the catalog so
|
||||
/// it's done once and benefits every future use. Returns true if anything was filled.
|
||||
/// </summary>
|
||||
Task<bool> EnsureCatalogTdsSpecsAsync(PowderCoating.Core.Entities.PowderCatalogItem catalog);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using Anthropic.SDK.Messaging;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
@@ -541,18 +542,20 @@ Rules:
|
||||
|
||||
// Targeted prompt: we only need cure specs from this document
|
||||
const string curePrompt = @"You are reading a Technical Data Sheet (TDS) for a powder coating product.
|
||||
Extract ONLY the cure schedule. Respond with a valid JSON object — no markdown, no explanation:
|
||||
Extract the cure schedule and the specific gravity. Respond with a valid JSON object — no markdown, no explanation:
|
||||
|
||||
{
|
||||
""cureTemperatureF"": number or null,
|
||||
""cureTimeMinutes"": number or null,
|
||||
""reasoning"": ""one sentence: what cure schedule you found""
|
||||
""specificGravity"": number or null,
|
||||
""reasoning"": ""one sentence: what cure schedule and specific gravity you found""
|
||||
}
|
||||
|
||||
Rules:
|
||||
- cureTemperatureF: the recommended cure temperature in °F. Convert from °C if needed (multiply by 1.8 + 32). If a range is given use the midpoint. Most powders cure 325–400 °F.
|
||||
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 10–20 min.
|
||||
- If neither value can be found in the document, return null for both.";
|
||||
- specificGravity: the specific gravity / density value from the TDS (often labeled ""Specific Gravity"" or ""Density""). Typically 1.2–1.8. Null if not stated.
|
||||
- Return null for any value not found in the document.";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Technical Data Sheet content:");
|
||||
@@ -603,11 +606,12 @@ Rules:
|
||||
Success = true,
|
||||
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
|
||||
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
||||
SpecificGravity = GetDecimal(parsed, "specificGravity"),
|
||||
Reasoning = GetString(parsed, "reasoning"),
|
||||
};
|
||||
|
||||
_logger.LogInformation("TDS cure lookup for {Url}: temp={Temp}°F, time={Time}min ({Reasoning})",
|
||||
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.Reasoning);
|
||||
_logger.LogInformation("TDS spec lookup for {Url}: temp={Temp}°F, time={Time}min, sg={Sg} ({Reasoning})",
|
||||
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.SpecificGravity, result.Reasoning);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -617,6 +621,58 @@ Rules:
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> EnsureCatalogTdsSpecsAsync(PowderCatalogItem catalog)
|
||||
{
|
||||
// Already enriched, or nothing to read from. Specific gravity is the trigger: it's never in
|
||||
// the API feed, so its absence means this item hasn't been TDS-enriched yet.
|
||||
if (catalog == null || catalog.SpecificGravity.HasValue || string.IsNullOrWhiteSpace(catalog.TdsUrl))
|
||||
return false;
|
||||
|
||||
var tds = await FetchTdsCureSpecsAsync(catalog.TdsUrl, catalog.ColorName);
|
||||
if (!tds.Success)
|
||||
return false;
|
||||
|
||||
var changed = false;
|
||||
|
||||
if (tds.SpecificGravity is > 0)
|
||||
{
|
||||
catalog.SpecificGravity = tds.SpecificGravity;
|
||||
changed = true;
|
||||
}
|
||||
if (!catalog.CureTemperatureF.HasValue && tds.CureTemperatureF.HasValue)
|
||||
{
|
||||
catalog.CureTemperatureF = tds.CureTemperatureF;
|
||||
changed = true;
|
||||
}
|
||||
if (!catalog.CureTimeMinutes.HasValue && tds.CureTimeMinutes.HasValue)
|
||||
{
|
||||
catalog.CureTimeMinutes = tds.CureTimeMinutes;
|
||||
changed = true;
|
||||
}
|
||||
// Derive theoretical coverage once specific gravity is known.
|
||||
if (!catalog.CoverageSqFtPerLb.HasValue && catalog.SpecificGravity is > 0)
|
||||
{
|
||||
catalog.CoverageSqFtPerLb = Math.Round(
|
||||
TheoreticalCoverageConstant / (catalog.SpecificGravity.Value * DefaultCoverageThicknessMils),
|
||||
2, MidpointRounding.AwayFromZero);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
catalog.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(catalog);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
_logger.LogInformation(
|
||||
"Lazily enriched catalog item {Vendor} {Sku} from TDS: sg={Sg}, cure={Temp}F/{Time}min, coverage={Cov}",
|
||||
catalog.VendorName, catalog.Sku, catalog.SpecificGravity, catalog.CureTemperatureF,
|
||||
catalog.CureTimeMinutes, catalog.CoverageSqFtPerLb);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ── Manufacturer URL pattern: build direct product page URL ───────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -25,6 +25,7 @@ public class DashboardController : Controller
|
||||
private readonly ICompanyConfigHealthService _configHealth;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
|
||||
public DashboardController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -33,7 +34,8 @@ public class DashboardController : Controller
|
||||
ITenantContext tenantContext,
|
||||
ICompanyConfigHealthService configHealth,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ISubscriptionService subscriptionService)
|
||||
ISubscriptionService subscriptionService,
|
||||
IInventoryAiLookupService aiLookupService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
@@ -42,6 +44,7 @@ public class DashboardController : Controller
|
||||
_configHealth = configHealth;
|
||||
_userManager = userManager;
|
||||
_subscriptionService = subscriptionService;
|
||||
_aiLookupService = aiLookupService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -868,8 +871,12 @@ public class DashboardController : Controller
|
||||
InventoryItem item, string? colorCode, string? colorName, string? manufacturer)
|
||||
{
|
||||
var catalog = await FindCatalogByIdentityAsync(colorCode, colorName, manufacturer);
|
||||
if (catalog != null)
|
||||
ApplyCatalogToInventory(item, catalog);
|
||||
if (catalog == null)
|
||||
return;
|
||||
|
||||
// First use — lazily fill specific gravity / cure from the TDS before copying onto the item.
|
||||
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
|
||||
ApplyCatalogToInventory(item, catalog);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -987,6 +994,9 @@ public class DashboardController : Controller
|
||||
if (catalog == null)
|
||||
return Json(new { success = false, needsDetails = true });
|
||||
|
||||
// First use — lazily fill specific gravity / cure from the TDS so the new record is complete.
|
||||
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
|
||||
|
||||
// Resolve the company's POWDER (coating) inventory category.
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||
|
||||
@@ -1263,6 +1263,10 @@ public class InventoryController : Controller
|
||||
if (catalogItem == null)
|
||||
return Json(new { success = false, error = "Catalog item not found." });
|
||||
|
||||
// First use of this powder — lazily fill specific gravity / cure from its TDS so the new
|
||||
// inventory record (and the catalog) carry complete specs. No-op once already enriched.
|
||||
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalogItem);
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Find the default coating category to assign.
|
||||
|
||||
Reference in New Issue
Block a user