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
@@ -40,6 +40,18 @@ public class InventoryItem : BaseEntity
/// </summary>
public int? PowderCatalogItemId { get; set; }
/// <summary>
/// Latest list price from the linked powder catalog, refreshed by the catalog sync. This is the
/// QUOTING price (current replacement cost) and is kept deliberately SEPARATE from
/// <see cref="UnitCost"/>/<see cref="AverageCost"/> (the actual paid cost basis that drives
/// inventory valuation and COGS). Quoting prefers this when present so quotes reflect the
/// current price; accounting never reads it. Null for manual/non-catalog powders.
/// </summary>
public decimal? CatalogReferencePrice { get; set; }
/// <summary>Timestamp (UTC) when <see cref="CatalogReferencePrice"/> was last refreshed by the sync.</summary>
public DateTime? CatalogPriceUpdatedAt { get; set; }
// Sample Panel Tracking (coating category items only)
public bool HasSamplePanel { get; set; } = false;