Propagate catalog price to inventory and quote at current price
Quotes now reflect the current catalog price instead of a tenant's stale typed-in cost, without disturbing accounting. - InventoryItem gains CatalogReferencePrice + CatalogPriceUpdatedAt: the QUOTING price (current replacement cost), kept separate from UnitCost/ AverageCost (the cost basis that drives valuation/COGS). - The catalog sync (PowderCatalogUpsertService.PropagateToLinkedInventoryAsync, run at the end of every upsert) refreshes linked inventory items with the catalog's current price and product data (description, cure, SDS/TDS, color families, coverage, SG, transfer eff, requires-clear-coat). It NEVER touches cost, quantity, notes, image, location, or stock levels, and never nulls a tenant value with a catalog null. EF persists only actual changes. - CatalogReferencePrice is also set at link time (catalog receive, incoming- from-catalog, identity match on create) so a freshly added powder quotes at the current price immediately. - Pricing now uses CatalogReferencePrice ?? UnitCost: the quote/job powder pickers and PricingCalculationService (in-stock usage and powder-to-order billing). Falls back to UnitCost for non-catalog/manual powders, so nothing regresses. One current price for the whole quantity — no on-hand/to-order split. Per-coat snapshot still locks the price at quote creation. Tests: propagation updates reference price + specs but not cost/qty/notes/ image, and skips a $0 catalog price. Full suite 276 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -76,13 +76,110 @@ public class PowderCatalogUpsertService : IPowderCatalogUpsertService
|
||||
|
||||
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.",
|
||||
result.Inserted, result.Updated, result.Unchanged, result.Skipped);
|
||||
"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>
|
||||
/// 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>
|
||||
public async Task<int> PropagateToLinkedInventoryAsync()
|
||||
{
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user