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:
2026-06-17 15:34:30 -04:00
parent 115ccf7d5e
commit c22537b68f
11 changed files with 11760 additions and 14 deletions
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddInventoryCatalogReferencePrice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CatalogPriceUpdatedAt",
table: "InventoryItems",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CatalogReferencePrice",
table: "InventoryItems",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CatalogPriceUpdatedAt",
table: "InventoryItems");
migrationBuilder.DropColumn(
name: "CatalogReferencePrice",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259));
}
}
}
@@ -4075,6 +4075,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("AverageCost")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("CatalogPriceUpdatedAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CatalogReferencePrice")
.HasColumnType("decimal(18,2)");
b.Property<string>("Category")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -7235,7 +7241,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251),
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -7246,7 +7252,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258),
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -7257,7 +7263,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259),
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -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 &gt; 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>