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:
+11382
File diff suppressed because it is too large
Load Diff
+81
@@ -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 > 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