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:
@@ -149,9 +149,12 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
try
|
||||
{
|
||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||
if (inventoryItem != null && inventoryItem.UnitCost > 0)
|
||||
// Prefer the current catalog price (replacement cost) so quotes reflect the latest
|
||||
// price; fall back to the item's own cost when it isn't catalog-linked.
|
||||
var effectiveCostPerLb = inventoryItem?.CatalogReferencePrice ?? inventoryItem?.UnitCost ?? 0m;
|
||||
if (inventoryItem != null && effectiveCostPerLb > 0)
|
||||
{
|
||||
costPerLb = inventoryItem.UnitCost;
|
||||
costPerLb = effectiveCostPerLb;
|
||||
isIncomingPowder = inventoryItem.IsIncoming;
|
||||
var coverage = coat.CoverageSqFtPerLb;
|
||||
var transferEfficiency = coat.TransferEfficiency;
|
||||
@@ -160,8 +163,8 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
|
||||
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
|
||||
|
||||
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
|
||||
coat.CoatName, inventoryItem.Name, isIncomingPowder, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
|
||||
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), CostPerLb={CostPerLb}/lb (catalog ref={CatalogRef}), Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
|
||||
coat.CoatName, inventoryItem.Name, isIncomingPowder, costPerLb, inventoryItem.CatalogReferencePrice, coverage, transferEfficiency, powderCostPerSqFt);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -691,7 +694,8 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
|
||||
if (invItem?.IsIncoming == true)
|
||||
{
|
||||
customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
|
||||
// Bill the powder-to-order at the current catalog price when linked.
|
||||
customPowderOrderAmount += c.PowderToOrder.Value * (invItem.CatalogReferencePrice ?? invItem.UnitCost);
|
||||
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
|
||||
if (!string.IsNullOrWhiteSpace(colorName))
|
||||
customPowderOrderColors.Add(colorName);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+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>
|
||||
|
||||
@@ -850,6 +850,13 @@ public class DashboardController : Controller
|
||||
item.UnitCost = catalog.UnitPrice;
|
||||
item.LastPurchasePrice = catalog.UnitPrice;
|
||||
}
|
||||
|
||||
// Quoting reference price (current catalog list price) — separate from cost basis above.
|
||||
if (catalog.UnitPrice > 0)
|
||||
{
|
||||
item.CatalogReferencePrice = catalog.UnitPrice;
|
||||
item.CatalogPriceUpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -314,10 +314,18 @@ public class InventoryController : Controller
|
||||
}
|
||||
|
||||
// Link to the platform catalog row when this item's identity matches one, so the detail
|
||||
// screen can show manufacturer-level status (discontinued / cannot reorder).
|
||||
// screen can show manufacturer-level status (discontinued / cannot reorder) and quotes
|
||||
// can use the current catalog price.
|
||||
var catalogMatch = await FindCatalogMatchAsync(item.Manufacturer, item.ManufacturerPartNumber);
|
||||
if (catalogMatch != null)
|
||||
{
|
||||
item.PowderCatalogItemId = catalogMatch.Id;
|
||||
if (catalogMatch.UnitPrice > 0)
|
||||
{
|
||||
item.CatalogReferencePrice = catalogMatch.UnitPrice;
|
||||
item.CatalogPriceUpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
@@ -1308,6 +1316,8 @@ public class InventoryController : Controller
|
||||
UnitCost = catalogItem.UnitPrice,
|
||||
AverageCost = catalogItem.UnitPrice,
|
||||
LastPurchasePrice = catalogItem.UnitPrice,
|
||||
CatalogReferencePrice = catalogItem.UnitPrice > 0 ? catalogItem.UnitPrice : (decimal?)null,
|
||||
CatalogPriceUpdatedAt = catalogItem.UnitPrice > 0 ? DateTime.UtcNow : (DateTime?)null,
|
||||
QuantityOnHand = 0,
|
||||
UnitOfMeasure = "lbs",
|
||||
InventoryCategoryId = coatingCategory.Id,
|
||||
@@ -1333,7 +1343,7 @@ public class InventoryController : Controller
|
||||
efficiency = item.TransferEfficiency ?? 65m,
|
||||
unitOfMeasure= item.UnitOfMeasure,
|
||||
categoryName = coatingCategory.DisplayName,
|
||||
costPerLb = item.UnitCost,
|
||||
costPerLb = item.CatalogReferencePrice ?? item.UnitCost,
|
||||
colorName = item.ColorName ?? item.Name,
|
||||
colorCode = "",
|
||||
isIncoming = true
|
||||
|
||||
@@ -3492,7 +3492,8 @@ public class JobsController : Controller
|
||||
efficiency = i.TransferEfficiency ?? 65m,
|
||||
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
|
||||
categoryName = i.InventoryCategory!.DisplayName,
|
||||
costPerLb = i.UnitCost,
|
||||
// Quote at the current catalog price when linked; fall back to their cost otherwise.
|
||||
costPerLb = i.CatalogReferencePrice ?? i.UnitCost,
|
||||
colorName = i.ColorName ?? i.Name,
|
||||
colorCode = i.ColorCode ?? "",
|
||||
isIncoming = i.IsIncoming
|
||||
|
||||
@@ -2545,7 +2545,8 @@ public class QuotesController : Controller
|
||||
efficiency = i.TransferEfficiency ?? 65m,
|
||||
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
|
||||
categoryName = i.InventoryCategory!.DisplayName,
|
||||
costPerLb = i.UnitCost,
|
||||
// Quote at the current catalog price when linked; fall back to their cost otherwise.
|
||||
costPerLb = i.CatalogReferencePrice ?? i.UnitCost,
|
||||
colorName = i.ColorName ?? i.Name,
|
||||
colorCode = i.ColorCode ?? "",
|
||||
isIncoming = i.IsIncoming
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Infrastructure.Services;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that catalog sync propagation updates a linked inventory item's quoting reference price
|
||||
/// and product data, while never touching the tenant-owned cost basis, quantity, notes, or image.
|
||||
/// </summary>
|
||||
public class PowderCatalogPropagationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Propagate_UpdatesReferencePriceAndSpecs_ButNotCostQuantityNotesOrImage()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
|
||||
var catalog = new PowderCatalogItem
|
||||
{
|
||||
VendorName = "Columbia Coatings",
|
||||
Sku = "CS1693053",
|
||||
ColorName = "Joker Jewel",
|
||||
Source = "Columbia Coatings API",
|
||||
UnitPrice = 28m, // new catalog price
|
||||
SdsUrl = "https://cc/sds.pdf",
|
||||
TdsUrl = "https://cc/tds.pdf",
|
||||
CureTemperatureF = 400m,
|
||||
CureTimeMinutes = 10,
|
||||
ColorFamilies = "Green,Purple",
|
||||
};
|
||||
context.PowderCatalogItems.Add(catalog);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var inv = new InventoryItem
|
||||
{
|
||||
CompanyId = 1,
|
||||
SKU = "POWD-2606-0001",
|
||||
Name = "Joker Jewel",
|
||||
PowderCatalogItemId = catalog.Id,
|
||||
UnitCost = 20m, // what they actually paid
|
||||
AverageCost = 20m,
|
||||
LastPurchasePrice = 20m,
|
||||
QuantityOnHand = 5m,
|
||||
Notes = "keep my note",
|
||||
ImageUrl = "my-own-photo.jpg",
|
||||
CatalogReferencePrice = null, // not yet set
|
||||
};
|
||||
context.InventoryItems.Add(inv);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new PowderCatalogUpsertService(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<ILogger<PowderCatalogUpsertService>>());
|
||||
|
||||
var updated = await service.PropagateToLinkedInventoryAsync();
|
||||
|
||||
Assert.Equal(1, updated);
|
||||
|
||||
var refreshed = await context.InventoryItems.FindAsync(inv.Id);
|
||||
Assert.NotNull(refreshed);
|
||||
|
||||
// Quoting reference price + product data refreshed from the catalog.
|
||||
Assert.Equal(28m, refreshed!.CatalogReferencePrice);
|
||||
Assert.NotNull(refreshed.CatalogPriceUpdatedAt);
|
||||
Assert.Equal("https://cc/sds.pdf", refreshed.SdsUrl);
|
||||
Assert.Equal("https://cc/tds.pdf", refreshed.TdsUrl);
|
||||
Assert.Equal(400m, refreshed.CureTemperatureF);
|
||||
Assert.Equal("Green,Purple", refreshed.ColorFamilies);
|
||||
|
||||
// Tenant-owned fields untouched.
|
||||
Assert.Equal(20m, refreshed.UnitCost);
|
||||
Assert.Equal(20m, refreshed.AverageCost);
|
||||
Assert.Equal(20m, refreshed.LastPurchasePrice);
|
||||
Assert.Equal(5m, refreshed.QuantityOnHand);
|
||||
Assert.Equal("keep my note", refreshed.Notes);
|
||||
Assert.Equal("my-own-photo.jpg", refreshed.ImageUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Propagate_DoesNotSetReferencePrice_WhenCatalogPriceIsZero()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
|
||||
var catalog = new PowderCatalogItem
|
||||
{
|
||||
VendorName = "Columbia Coatings",
|
||||
Sku = "X1",
|
||||
ColorName = "No Price",
|
||||
Source = "Columbia Coatings API",
|
||||
UnitPrice = 0m, // unknown price — must not wipe quoting with $0
|
||||
};
|
||||
context.PowderCatalogItems.Add(catalog);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var inv = new InventoryItem
|
||||
{
|
||||
CompanyId = 1,
|
||||
SKU = "POWD-2606-0002",
|
||||
Name = "No Price",
|
||||
PowderCatalogItemId = catalog.Id,
|
||||
UnitCost = 15m,
|
||||
CatalogReferencePrice = null,
|
||||
};
|
||||
context.InventoryItems.Add(inv);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new PowderCatalogUpsertService(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<ILogger<PowderCatalogUpsertService>>());
|
||||
|
||||
await service.PropagateToLinkedInventoryAsync();
|
||||
|
||||
var refreshed = await context.InventoryItems.FindAsync(inv.Id);
|
||||
Assert.Null(refreshed!.CatalogReferencePrice); // stays null -> quoting falls back to UnitCost
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
byte[]? noBytes = null;
|
||||
var sessionMock = new Mock<ISession>();
|
||||
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||
|
||||
var httpContextMock = new Mock<HttpContext>();
|
||||
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||
|
||||
var accessor = new Mock<IHttpContextAccessor>();
|
||||
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||
|
||||
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user