148a3f465e
The sync propagation now also backfills the catalog link: any inventory item with no PowderCatalogItemId that matches a catalog row by Manufacturer + ManufacturerPartNumber (the catalog SKU) gets linked and picks up the catalog price/product data. Only links on a confident match (exact SKU + matching vendor, or a single unambiguous candidate), so it never mis-links. This backfills items created before linking existed, automatically, on every environment (dev and prod) with no manual step or one-off script — legacy items link on the next sync, new items still link at create time. Cost basis, quantity, notes, and image remain untouched. Tests: links an unlinked item by manufacturer+part number; leaves it unlinked when the part number has no catalog match. Full suite 278 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
304 lines
14 KiB
C#
304 lines
14 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Interfaces;
|
|
|
|
namespace PowderCoating.Infrastructure.Services;
|
|
|
|
/// <summary>
|
|
/// Single upsert path for the platform <see cref="PowderCatalogItem"/> master list, shared by the
|
|
/// JSON file import and the Columbia API sync. Match key is (VendorName, SKU), case-insensitive.
|
|
/// </summary>
|
|
public class PowderCatalogUpsertService : IPowderCatalogUpsertService
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly ILogger<PowderCatalogUpsertService> _logger;
|
|
|
|
public PowderCatalogUpsertService(IUnitOfWork unitOfWork, ILogger<PowderCatalogUpsertService> logger)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<PowderCatalogUpsertResult> UpsertAsync(
|
|
IReadOnlyList<PowderCatalogItem> incoming,
|
|
DateTime runTimestamp,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var result = new PowderCatalogUpsertResult();
|
|
|
|
// Load existing rows for just the vendors we're touching, keyed by (vendor|sku) lower-cased.
|
|
var vendorNames = incoming
|
|
.Select(i => i.VendorName)
|
|
.Where(v => !string.IsNullOrWhiteSpace(v))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => vendorNames.Contains(p.VendorName)))
|
|
.ToDictionary(KeyOf, StringComparer.OrdinalIgnoreCase);
|
|
|
|
var toAdd = new List<PowderCatalogItem>();
|
|
|
|
foreach (var item in incoming)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(item.Sku) || string.IsNullOrWhiteSpace(item.ColorName))
|
|
{
|
|
result.Skipped++;
|
|
continue;
|
|
}
|
|
|
|
if (existing.TryGetValue(KeyOf(item), out var record))
|
|
{
|
|
if (ApplyFeedFields(record, item))
|
|
{
|
|
record.UpdatedAt = runTimestamp;
|
|
record.LastSyncedAt = runTimestamp;
|
|
await _unitOfWork.PowderCatalog.UpdateAsync(record);
|
|
result.Updated++;
|
|
}
|
|
else
|
|
{
|
|
result.Unchanged++;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
item.CreatedAt = runTimestamp;
|
|
item.LastSyncedAt = runTimestamp;
|
|
toAdd.Add(item);
|
|
result.Inserted++;
|
|
}
|
|
}
|
|
|
|
if (toAdd.Count > 0)
|
|
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
// Push current catalog price + product data down to any tenant inventory linked to these
|
|
// catalog rows, so quotes reflect the current price.
|
|
var propagated = await PropagateToLinkedInventoryAsync();
|
|
|
|
_logger.LogInformation(
|
|
"Powder catalog upsert: {Inserted} inserted, {Updated} updated, {Unchanged} unchanged, {Skipped} skipped; {Propagated} linked inventory item(s) refreshed.",
|
|
result.Inserted, result.Updated, result.Unchanged, result.Skipped, propagated);
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keeps tenant inventory in step with the catalog (across all companies): first self-heals by
|
|
/// linking any unlinked item to its catalog row by identity, then refreshes every linked item
|
|
/// with the catalog's current price and product data. Returns the number of items touched.
|
|
/// </summary>
|
|
public async Task<int> PropagateToLinkedInventoryAsync()
|
|
{
|
|
var linkedCount = await LinkUnlinkedInventoryAsync();
|
|
var refreshedCount = await RefreshLinkedInventoryAsync();
|
|
return linkedCount + refreshedCount;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Self-heals the catalog link: finds inventory items with no <see cref="InventoryItem.PowderCatalogItemId"/>
|
|
/// that match a catalog row by Manufacturer + ManufacturerPartNumber (the catalog SKU), sets the
|
|
/// FK, and applies the catalog price/product data. Only links on a confident match (exact SKU,
|
|
/// matching vendor, or a single unambiguous candidate) so it never mis-links. Returns the count
|
|
/// newly linked. This backfills items created before linking existed, on every environment, with
|
|
/// no manual step.
|
|
/// </summary>
|
|
private async Task<int> LinkUnlinkedInventoryAsync()
|
|
{
|
|
var unlinked = (await _unitOfWork.InventoryItems.FindAsync(
|
|
i => i.PowderCatalogItemId == null
|
|
&& i.Manufacturer != null && i.Manufacturer != ""
|
|
&& i.ManufacturerPartNumber != null && i.ManufacturerPartNumber != "",
|
|
ignoreQueryFilters: true)).ToList();
|
|
if (unlinked.Count == 0)
|
|
return 0;
|
|
|
|
var partNumbers = unlinked.Select(i => i.ManufacturerPartNumber!).Distinct().ToList();
|
|
var bySku = (await _unitOfWork.PowderCatalog.FindAsync(p => partNumbers.Contains(p.Sku)))
|
|
.GroupBy(c => c.Sku, StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
|
if (bySku.Count == 0)
|
|
return 0;
|
|
|
|
var linked = 0;
|
|
foreach (var inv in unlinked)
|
|
{
|
|
if (!bySku.TryGetValue(inv.ManufacturerPartNumber!, out var candidates))
|
|
continue;
|
|
|
|
var mfr = inv.Manufacturer!.Trim().ToLower();
|
|
var match = candidates.FirstOrDefault(c => c.VendorName.ToLower().Contains(mfr))
|
|
?? (candidates.Count == 1 ? candidates[0] : null);
|
|
if (match == null)
|
|
continue;
|
|
|
|
inv.PowderCatalogItemId = match.Id;
|
|
ApplyCatalogToLinkedInventory(inv, match);
|
|
await _unitOfWork.InventoryItems.UpdateAsync(inv);
|
|
linked++;
|
|
}
|
|
|
|
if (linked > 0)
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return linked;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refreshes every tenant inventory item linked to a powder catalog row (across all companies)
|
|
/// with the catalog's current list price and product data. Sets
|
|
/// <see cref="InventoryItem.CatalogReferencePrice"/> (the QUOTING price) and product spec/doc
|
|
/// fields, but NEVER the cost basis (UnitCost/AverageCost/LastPurchasePrice), quantity, notes,
|
|
/// image, location, or stock levels — those are tenant-owned. EF persists only items that
|
|
/// actually changed, so this is a cheap no-op when nothing moved. Returns the number updated.
|
|
/// </summary>
|
|
private async Task<int> RefreshLinkedInventoryAsync()
|
|
{
|
|
var linked = (await _unitOfWork.InventoryItems.FindAsync(
|
|
i => i.PowderCatalogItemId != null, ignoreQueryFilters: true)).ToList();
|
|
if (linked.Count == 0)
|
|
return 0;
|
|
|
|
var catalogIds = linked.Select(i => i.PowderCatalogItemId!.Value).Distinct().ToList();
|
|
var catalogById = (await _unitOfWork.PowderCatalog.FindAsync(p => catalogIds.Contains(p.Id)))
|
|
.ToDictionary(p => p.Id);
|
|
|
|
var updated = 0;
|
|
foreach (var inv in linked)
|
|
{
|
|
if (!catalogById.TryGetValue(inv.PowderCatalogItemId!.Value, out var cat))
|
|
continue;
|
|
|
|
if (ApplyCatalogToLinkedInventory(inv, cat))
|
|
{
|
|
await _unitOfWork.InventoryItems.UpdateAsync(inv);
|
|
updated++;
|
|
}
|
|
}
|
|
|
|
if (updated > 0)
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return updated;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies the catalog's current price and product data onto a linked inventory item, returning
|
|
/// true if anything changed. Sets the quoting reference price (only when the catalog has a real
|
|
/// price > 0) and refreshes product/spec fields where the catalog has a value — never erasing
|
|
/// tenant data with catalog nulls, and never touching cost basis, quantity, notes, image, or
|
|
/// stock levels.
|
|
/// </summary>
|
|
private static bool ApplyCatalogToLinkedInventory(InventoryItem inv, PowderCatalogItem cat)
|
|
{
|
|
var changed = false;
|
|
|
|
// Quoting price (the point of this): keep the current catalog list price, separate from cost.
|
|
if (cat.UnitPrice > 0 && inv.CatalogReferencePrice != cat.UnitPrice)
|
|
{
|
|
inv.CatalogReferencePrice = cat.UnitPrice;
|
|
inv.CatalogPriceUpdatedAt = DateTime.UtcNow;
|
|
changed = true;
|
|
}
|
|
|
|
// Product data — refresh from the catalog where it has a value (catalog is authoritative on
|
|
// these); do not null out a tenant value the catalog doesn't carry.
|
|
changed |= SetStrIfCatalogHas(() => inv.Description, v => inv.Description = v, cat.Description);
|
|
changed |= SetStrIfCatalogHas(() => inv.Finish, v => inv.Finish = v, cat.Finish);
|
|
changed |= SetStrIfCatalogHas(() => inv.ColorFamilies, v => inv.ColorFamilies = v, cat.ColorFamilies);
|
|
changed |= SetStrIfCatalogHas(() => inv.SdsUrl, v => inv.SdsUrl = v, cat.SdsUrl);
|
|
changed |= SetStrIfCatalogHas(() => inv.TdsUrl, v => inv.TdsUrl = v, cat.TdsUrl);
|
|
changed |= SetStrIfCatalogHas(() => inv.SpecPageUrl, v => inv.SpecPageUrl = v, cat.ProductUrl);
|
|
|
|
if (cat.CureTemperatureF.HasValue && inv.CureTemperatureF != cat.CureTemperatureF)
|
|
{ inv.CureTemperatureF = cat.CureTemperatureF; changed = true; }
|
|
if (cat.CureTimeMinutes.HasValue && inv.CureTimeMinutes != cat.CureTimeMinutes)
|
|
{ inv.CureTimeMinutes = cat.CureTimeMinutes; changed = true; }
|
|
if (cat.CoverageSqFtPerLb.HasValue && inv.CoverageSqFtPerLb != cat.CoverageSqFtPerLb)
|
|
{ inv.CoverageSqFtPerLb = cat.CoverageSqFtPerLb; changed = true; }
|
|
if (cat.SpecificGravity.HasValue && inv.SpecificGravity != cat.SpecificGravity)
|
|
{ inv.SpecificGravity = cat.SpecificGravity; changed = true; }
|
|
if (cat.TransferEfficiency.HasValue && inv.TransferEfficiency != cat.TransferEfficiency)
|
|
{ inv.TransferEfficiency = cat.TransferEfficiency; changed = true; }
|
|
if (cat.RequiresClearCoat == true && !inv.RequiresClearCoat)
|
|
{ inv.RequiresClearCoat = true; changed = true; }
|
|
|
|
return changed;
|
|
}
|
|
|
|
/// <summary>Sets a string property from the catalog only when the catalog value is non-blank and differs.</summary>
|
|
private static bool SetStrIfCatalogHas(Func<string?> get, Action<string?> set, string? catalogValue)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(catalogValue) && !string.Equals(get(), catalogValue, StringComparison.Ordinal))
|
|
{
|
|
set(catalogValue);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static string KeyOf(PowderCatalogItem p) => $"{p.VendorName}|{p.Sku}";
|
|
|
|
/// <summary>
|
|
/// Copies feed-sourced fields from <paramref name="src"/> onto <paramref name="dest"/> and
|
|
/// returns true if anything changed. Deliberately leaves enrichment fields (SpecificGravity,
|
|
/// CoverageSqFtPerLb, TransferEfficiency, Finish) and lifecycle flags untouched — those are
|
|
/// owned by lazy TDS/AI enrichment and the discontinuation sweep, not the feed.
|
|
/// </summary>
|
|
private static bool ApplyFeedFields(PowderCatalogItem dest, PowderCatalogItem src)
|
|
{
|
|
var changed = false;
|
|
|
|
changed |= Set(() => dest.ColorName, v => dest.ColorName = v, src.ColorName);
|
|
changed |= Set(() => dest.Description, v => dest.Description = v, src.Description);
|
|
changed |= src.UnitPrice > 0 && dest.UnitPrice != src.UnitPrice && Assign(() => dest.UnitPrice = src.UnitPrice);
|
|
changed |= Set(() => dest.PriceTiersJson, v => dest.PriceTiersJson = v, src.PriceTiersJson);
|
|
changed |= Set(() => dest.ImageUrl, v => dest.ImageUrl = v, src.ImageUrl);
|
|
changed |= Set(() => dest.SdsUrl, v => dest.SdsUrl = v, src.SdsUrl);
|
|
changed |= Set(() => dest.TdsUrl, v => dest.TdsUrl = v, src.TdsUrl);
|
|
changed |= Set(() => dest.ApplicationGuideUrl, v => dest.ApplicationGuideUrl = v, src.ApplicationGuideUrl);
|
|
changed |= Set(() => dest.ProductUrl, v => dest.ProductUrl = v, src.ProductUrl);
|
|
changed |= Set(() => dest.ChemistryType, v => dest.ChemistryType = v, src.ChemistryType);
|
|
changed |= Set(() => dest.MilThickness, v => dest.MilThickness = v, src.MilThickness);
|
|
changed |= Set(() => dest.CureScheduleText, v => dest.CureScheduleText = v, src.CureScheduleText);
|
|
changed |= Set(() => dest.CureCurvesJson, v => dest.CureCurvesJson = v, src.CureCurvesJson);
|
|
changed |= src.CureTemperatureF.HasValue && dest.CureTemperatureF != src.CureTemperatureF && Assign(() => dest.CureTemperatureF = src.CureTemperatureF);
|
|
changed |= src.CureTimeMinutes.HasValue && dest.CureTimeMinutes != src.CureTimeMinutes && Assign(() => dest.CureTimeMinutes = src.CureTimeMinutes);
|
|
changed |= src.RequiresClearCoat.HasValue && dest.RequiresClearCoat != src.RequiresClearCoat && Assign(() => dest.RequiresClearCoat = src.RequiresClearCoat);
|
|
changed |= Set(() => dest.ColorFamilies, v => dest.ColorFamilies = v, src.ColorFamilies);
|
|
changed |= Set(() => dest.FormulationChanges, v => dest.FormulationChanges = v, src.FormulationChanges);
|
|
changed |= Set(() => dest.Category, v => dest.Category = v, src.Category);
|
|
changed |= Set(() => dest.Source, v => dest.Source = v, src.Source);
|
|
|
|
return changed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets a nullable-string property when the feed provides a non-blank value that differs.
|
|
/// Merge semantics: a blank incoming value is ignored, so a partial feed (e.g. the Prismatic
|
|
/// file import, which omits cure/chemistry) never nulls out existing data.
|
|
/// </summary>
|
|
private static bool Set(Func<string?> get, Action<string?> set, string? newValue)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(newValue))
|
|
return false;
|
|
if (!string.Equals(get(), newValue, StringComparison.Ordinal))
|
|
{
|
|
set(newValue);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Helper so a value assignment can participate in a boolean OR chain.</summary>
|
|
private static bool Assign(Action assign)
|
|
{
|
|
assign();
|
|
return true;
|
|
}
|
|
}
|