Commit remaining unstaged changes from this session

- Platform settings service: IPlatformSettingsService, PlatformSettingKeys,
  PlatformSettingsService, SubscriptionService, AppConstants,
  SubscriptionExpiryBackgroundService, SubscriptionMiddleware
- JobTimeEntry entity, DTOs, AutoMapper profile (ShopWorker → UserId migration)
- InventoryDtos: SourceTransactionId on PowderUsageLogDto
- InventoryTransactionRepository: include Job.Customer in ledger query
- InventoryAiLookupService: @graph unwrap + HTML price fallback
- ApplicationDbContextModelSnapshot: reflect migration changes
- launchSettings.json, publish profile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 21:20:30 -04:00
parent 4c070e7487
commit 20ae11be03
16 changed files with 177 additions and 35 deletions
@@ -834,6 +834,10 @@ Rules:
// machine-readable price, SKU, and product info that would otherwise be lost.
var structuredData = ExtractJsonLdData(html);
// Extract visible price text BEFORE stripping HTML as a fallback when
// JSON-LD is absent or incomplete (e.g. JS-rendered stores).
var htmlPriceSnippet = ExtractHtmlPriceSnippet(html);
// Remove script/style blocks
html = System.Text.RegularExpressions.Regex.Replace(
html, @"<(script|style)[^>]*>[\s\S]*?</(script|style)>", "",
@@ -851,10 +855,11 @@ Rules:
if (text.Length > maxChars)
text = text[..maxChars] + "…";
// Prepend structured data + document links — Claude treats these as high-confidence
// Prepend structured data + document links + price fallback — Claude treats these as high-confidence
var header = new StringBuilder();
if (!string.IsNullOrWhiteSpace(structuredData)) header.Append(structuredData);
if (!string.IsNullOrWhiteSpace(docLinks)) header.Append(docLinks);
if (!string.IsNullOrWhiteSpace(structuredData)) header.Append(structuredData);
if (!string.IsNullOrWhiteSpace(htmlPriceSnippet)) header.Append(htmlPriceSnippet);
if (!string.IsNullOrWhiteSpace(docLinks)) header.Append(docLinks);
if (header.Length > 0) text = header + "\n" + text;
_logger.LogInformation("Fetched {Chars} chars from {Url} (structured data: {HasData}, image: {HasImage})",
@@ -897,6 +902,46 @@ Rules:
return null;
}
/// <summary>
/// Scans raw HTML for visible price elements — common WooCommerce/Shopify price class
/// names and itemprop="price" microdata — and returns them as a "[Page Price]" header
/// line. This runs on the full, untruncated HTML before tag stripping so prices that
/// would fall past the 3,500-char page-text cutoff are still surfaced to Claude.
/// Only used when JSON-LD structured data didn't already yield a price.
/// </summary>
private static string? ExtractHtmlPriceSnippet(string html)
{
// itemprop="price" microdata (e.g. <span itemprop="price">12.99</span>)
var microprice = System.Text.RegularExpressions.Regex.Match(
html,
@"itemprop=[""']price[""'][^>]*content=[""']([0-9]+\.?[0-9]*)[""']|" +
@"itemprop=[""']price[""'][^>]*>[\s]*\$?([0-9]+\.?[0-9]*)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (microprice.Success)
{
var val = (microprice.Groups[1].Value.Trim().Length > 0
? microprice.Groups[1].Value
: microprice.Groups[2].Value).Trim();
if (!string.IsNullOrEmpty(val))
return $"[Page Price] Price found in page microdata: ${val}\n";
}
// WooCommerce / common e-commerce price class names
var classPricePattern = System.Text.RegularExpressions.Regex.Match(
html,
@"class=[""'][^""']*(?:price|woocommerce-Price-amount|product-price|bdi)[^""']*[""'][^>]*>" +
@"[\s\S]{0,60}?\$\s*([0-9]+\.[0-9]{2})",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (classPricePattern.Success)
{
var val = classPricePattern.Groups[1].Value.Trim();
if (!string.IsNullOrEmpty(val))
return $"[Page Price] Price found in page HTML: ${val}\n";
}
return null;
}
/// <summary>
/// Scans raw HTML for anchor tags linking to SDS or TDS documents and returns them as
/// "[Structured Data]" lines that Claude can read and echo back in its JSON response.
@@ -959,6 +1004,12 @@ Rules:
/// Extracts product name, SKU, and price from JSON-LD structured data blocks.
/// Many e-commerce sites (Shopify, WooCommerce, etc.) embed this in the page HTML
/// even when the visible price is rendered by JavaScript.
/// <para>
/// Handles three common JSON-LD shapes:
/// - Single Product object: <c>{"@type":"Product",...}</c>
/// - Top-level array: <c>[{"@type":"Product",...},...]</c>
/// - WooCommerce/Yoast @graph wrapper: <c>{"@graph":[{"@type":"Product",...},...]}</c>
/// </para>
/// </summary>
private static string? ExtractJsonLdData(string html)
{
@@ -976,12 +1027,7 @@ Rules:
using var doc = JsonDocument.Parse(jsonText);
var root = doc.RootElement;
// Handle both single-object and @graph array
IEnumerable<JsonElement> nodes = root.ValueKind == JsonValueKind.Array
? root.EnumerateArray()
: new[] { root };
foreach (var node in nodes)
foreach (var node in FlattenJsonLdNodes(root))
{
if (!node.TryGetProperty("@type", out var typeEl)) continue;
@@ -1013,6 +1059,34 @@ Rules:
return sb.Length > 0 ? sb.ToString() : null;
}
/// <summary>
/// Recursively flattens a JSON-LD element into individual nodes for processing.
/// Handles a top-level array, a single object, and the WooCommerce/Yoast @graph
/// wrapper pattern where the root object has no @type but contains an "@graph" array.
/// </summary>
private static IEnumerable<JsonElement> FlattenJsonLdNodes(JsonElement el)
{
if (el.ValueKind == JsonValueKind.Array)
{
foreach (var item in el.EnumerateArray())
foreach (var n in FlattenJsonLdNodes(item))
yield return n;
}
else if (el.ValueKind == JsonValueKind.Object)
{
// @graph wrapper: {"@graph": [...]} — recurse into the array
if (el.TryGetProperty("@graph", out var graph) && graph.ValueKind == JsonValueKind.Array)
{
foreach (var n in FlattenJsonLdNodes(graph))
yield return n;
}
else
{
yield return el;
}
}
}
/// <summary>
/// Dispatches JSON-LD offer extraction for both the single-object form
/// (<c>"offers": { ... }</c>) and the array form (<c>"offers": [ ... ]</c>)
@@ -52,6 +52,17 @@ public class PlatformSettingsService : IPlatformSettingsService
return bool.TryParse(value, out var result) ? result : defaultValue;
}
/// <summary>
/// Reads a platform setting as an integer. Returns <paramref name="defaultValue"/> when
/// the key is missing or the stored value cannot be parsed as an integer.
/// </summary>
public async Task<int> GetIntAsync(string key, int defaultValue)
{
var value = await GetAsync(key);
if (value == null) return defaultValue;
return int.TryParse(value, out var result) ? result : defaultValue;
}
/// <summary>
/// Creates or updates the platform setting identified by <paramref name="key"/>.
/// Records <paramref name="updatedBy"/> (typically the SuperAdmin's username) and
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
@@ -27,11 +28,13 @@ public class SubscriptionService : ISubscriptionService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ApplicationDbContext _context;
private readonly IPlatformSettingsService _platformSettings;
public SubscriptionService(IUnitOfWork unitOfWork, ApplicationDbContext context)
public SubscriptionService(IUnitOfWork unitOfWork, ApplicationDbContext context, IPlatformSettingsService platformSettings)
{
_unitOfWork = unitOfWork;
_context = context;
_platformSettings = platformSettings;
}
/// <summary>
@@ -203,7 +206,8 @@ public class SubscriptionService : ISubscriptionService
if (daysUntil == null || daysUntil > 0)
return SubscriptionStatus.Active;
if (daysUntil >= -AppConstants.SubscriptionConstants.GracePeriodDays)
var graceDays = await GetEffectiveGracePeriodDaysAsync(company);
if (daysUntil >= -graceDays)
return SubscriptionStatus.GracePeriod;
return SubscriptionStatus.Expired;
@@ -226,6 +230,25 @@ public class SubscriptionService : ISubscriptionService
return (int)(expiry - today).TotalDays;
}
/// <summary>
/// Returns the effective grace period in days for a company. Trial companies (no Stripe subscription)
/// get 0 days unless the <c>GracePeriodAppliesToTrials</c> platform setting is explicitly enabled.
/// Paid companies always use the configured <c>GracePeriodDays</c> value.
/// </summary>
private async Task<int> GetEffectiveGracePeriodDaysAsync(Company company)
{
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
if (isTrial)
{
var appliesToTrials = await _platformSettings.GetBoolAsync(
PlatformSettingKeys.GracePeriodAppliesToTrials, defaultValue: false);
if (!appliesToTrials) return 0;
}
return await _platformSettings.GetIntAsync(
PlatformSettingKeys.GracePeriodDays,
AppConstants.SubscriptionConstants.GracePeriodDays);
}
/// <summary>
/// Returns the total catalog item count (including soft-deleted) and the plan maximum.
/// Returns a max of -1 when the plan config is absent (unlimited by default).