c22537b68f
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>
146 lines
5.3 KiB
C#
146 lines
5.3 KiB
C#
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!);
|
|
}
|
|
}
|