diff --git a/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs b/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs index 6e5752a..6017d65 100644 --- a/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs +++ b/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs @@ -59,9 +59,17 @@ public interface IInventoryAiLookupService Task ScanLabelAsync(string base64Image, string mediaType); /// - /// 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. /// Task FetchTdsCureSpecsAsync(string tdsUrl, string? colorName); + + /// + /// 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. + /// + Task EnsureCatalogTdsSpecsAsync(PowderCoating.Core.Entities.PowderCatalogItem catalog); } diff --git a/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs b/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs index 39aaaed..b2ea949 100644 --- a/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs +++ b/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs @@ -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: } } + /// + public async Task 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 ─────────────── /// diff --git a/src/PowderCoating.Web/Controllers/DashboardController.cs b/src/PowderCoating.Web/Controllers/DashboardController.cs index 042f451..dfea15d 100644 --- a/src/PowderCoating.Web/Controllers/DashboardController.cs +++ b/src/PowderCoating.Web/Controllers/DashboardController.cs @@ -25,6 +25,7 @@ public class DashboardController : Controller private readonly ICompanyConfigHealthService _configHealth; private readonly UserManager _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 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; } /// @@ -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); } /// @@ -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 diff --git a/src/PowderCoating.Web/Controllers/InventoryController.cs b/src/PowderCoating.Web/Controllers/InventoryController.cs index 9381b93..deb8ceb 100644 --- a/src/PowderCoating.Web/Controllers/InventoryController.cs +++ b/src/PowderCoating.Web/Controllers/InventoryController.cs @@ -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.