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:
@@ -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