Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/JobItemAssemblyServiceTests.cs
T
spouliot 1eba50cf0f Add Custom Formula Item Templates with AI generation and wizard integration
Introduces per-company reusable NCalc2 pricing formula templates for complex
fabricated items (roof curbs, enclosures, welded frames). Templates support
two output modes — FixedRate (formula yields a dollar amount) and SurfaceAreaSqFt
(formula yields sq ft fed into the standard coating engine). Includes:

- CustomItemTemplate entity, migration (AddCustomItemTemplates), IUnitOfWork repo
- IsCustomFormulaItem / CustomItemTemplateId / FormulaFieldValuesJson flags on
  QuoteItem, JobItem, CreateQuoteItemDto; mapped in all 3 JobItemAssemblyService
  overloads and all existingItemsData JSON projections + pageMeta blocks
- ICustomFormulaAiService / CustomFormulaAiService: Claude-powered formula
  generator (natural language + optional diagram image) and NCalc2 evaluator
- CompanySettings CRUD endpoints: GetCustomItemTemplates, Create/Update/Delete,
  UploadTemplateDiagram, TemplateDiagram (blob serve), EvaluateFormula, GenerateFormulaFromAi
- Company Settings "Custom Formulas" tab + cfModal + company-settings-custom-formulas.js
- item-wizard.js: formula item type card, renderFormulaFields, wzFormulaRecalc
  (live evaluate via POST), collectStep2 formula branch, buildCardHtml / emitHiddenFields
- Formula badge in Quotes/Details and Jobs/Details; AI badge gap fixed in Jobs/Details
- Help article (CustomFormulaTemplates.cshtml), Help Index card, HelpController action,
  HelpKnowledgeBase entry; 225/225 unit tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:09:22 -04:00

380 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
namespace PowderCoating.UnitTests;
public class JobItemAssemblyServiceTests
{
private static readonly DateTime CreatedAtUtc = new(2026, 5, 9, 14, 30, 0, DateTimeKind.Utc);
private readonly IJobItemAssemblyService _service = new JobItemAssemblyService();
[Fact]
public void CreateJobItem_FromWizardDto_PreservesSalesFieldsAndCalculatedChildren()
{
var source = new CreateQuoteItemDto
{
Description = "Powder coated tumbler",
Quantity = 2m,
SurfaceAreaSqFt = 12m,
EstimatedMinutes = 18,
CatalogItemId = 44,
IsSalesItem = true,
Sku = "TMB-RED-20",
ManualUnitPrice = 29.99m,
PowderCostOverride = 7.25m,
RequiresSandblasting = true,
RequiresMasking = true,
Notes = "Merch item",
IncludePrepCost = false,
Complexity = "Moderate",
AiTags = "merch,tumbler",
AiPredictionId = 91,
Coats =
[
new CreateQuoteItemCoatDto
{
CoatName = "Base",
Sequence = 1,
ColorName = "Signal Red",
ColorCode = "RAL3001",
Finish = "Gloss",
CoverageSqFtPerLb = 30m,
TransferEfficiency = 50m
}
],
PrepServices =
[
new CreateQuoteItemPrepServiceDto
{
PrepServiceId = 7,
EstimatedMinutes = 12,
BlastSetupId = 88
}
]
};
var pricing = new QuoteItemPricingResult
{
UnitPrice = 29.99m,
TotalPrice = 59.98m,
LaborCost = 23.992m // explicitly from pricing engine, not a 0.4× multiplier
};
var jobItem = _service.CreateJobItem(source, jobId: 10, companyId: 3, pricing: pricing, createdAtUtc: CreatedAtUtc);
var coats = _service.CreateJobItemCoats(source, jobItemId: 25, companyId: 3, CreatedAtUtc);
var prepServices = _service.CreateJobItemPrepServices(source, jobItemId: 25, companyId: 3, CreatedAtUtc);
Assert.Equal(10, jobItem.JobId);
Assert.Equal("Powder coated tumbler", jobItem.Description);
Assert.True(jobItem.IsSalesItem);
Assert.Equal("TMB-RED-20", jobItem.Sku);
Assert.False(jobItem.IncludePrepCost);
Assert.Equal(91, jobItem.AiPredictionId);
Assert.Equal("merch,tumbler", jobItem.AiTags);
Assert.Equal(59.98m, jobItem.TotalPrice);
Assert.Equal(23.992m, jobItem.LaborCost);
Assert.Equal(CreatedAtUtc, jobItem.CreatedAt);
var coat = Assert.Single(coats);
Assert.Equal(25, coat.JobItemId);
Assert.Equal(1.6m, coat.PowderToOrder);
Assert.Equal("Signal Red", coat.ColorName);
Assert.Equal(CreatedAtUtc, coat.CreatedAt);
var prepService = Assert.Single(prepServices);
Assert.Equal(88, prepService.BlastSetupId);
Assert.Equal(12, prepService.EstimatedMinutes);
Assert.Equal(CreatedAtUtc, prepService.CreatedAt);
}
[Fact]
public void CreateJobItem_FromQuoteItem_PreservesQuoteShapeAndPrepCostFlag()
{
var quoteItem = new QuoteItem
{
Description = "Bracket set",
Quantity = 3m,
SurfaceAreaSqFt = 10m,
CatalogItemId = 14,
IsGenericItem = false,
IsLaborItem = false,
IsSalesItem = true,
Sku = "BRK-SET",
ManualUnitPrice = 18m,
PowderCostOverride = 6m,
UnitPrice = 42m,
TotalPrice = 126m,
RequiresSandblasting = true,
RequiresMasking = false,
EstimatedMinutes = 25,
Notes = "Use existing hang points",
Complexity = "Complex",
IncludePrepCost = true,
AiTags = "bracket,steel",
AiPredictionId = 55,
Coats =
[
new QuoteItemCoat
{
CoatName = "Top Coat",
Sequence = 1,
InventoryItemId = 12,
ColorName = "Stale Name",
ColorCode = "STALE",
Finish = "Stale",
CoverageSqFtPerLb = 20m,
TransferEfficiency = 80m,
PowderCostPerLb = 5m,
Notes = "Resolved from inventory",
InventoryItem = new InventoryItem
{
Id = 12,
Name = "Gloss Black",
ColorCode = "RAL9005",
Finish = "Gloss"
}
}
],
PrepServices =
[
new QuoteItemPrepService
{
PrepServiceId = 4,
EstimatedMinutes = 9,
BlastSetupId = 41
}
]
};
var jobItem = _service.CreateJobItem(quoteItem, jobId: 99, companyId: 6, createdAtUtc: CreatedAtUtc);
var coats = _service.CreateJobItemCoats(quoteItem, jobItemId: 70, companyId: 6, CreatedAtUtc);
var prepServices = _service.CreateJobItemPrepServices(quoteItem, jobItemId: 70, companyId: 6, CreatedAtUtc);
Assert.Equal(99, jobItem.JobId);
Assert.True(jobItem.IsSalesItem);
Assert.Equal("BRK-SET", jobItem.Sku);
Assert.True(jobItem.IncludePrepCost);
Assert.Equal(55, jobItem.AiPredictionId);
Assert.Equal("bracket,steel", jobItem.AiTags);
var coat = Assert.Single(coats);
Assert.Equal("Gloss Black", coat.ColorName);
Assert.Equal("RAL9005", coat.ColorCode);
Assert.Equal("Gloss", coat.Finish);
Assert.Equal(1.88m, coat.PowderToOrder);
var prepService = Assert.Single(prepServices);
Assert.Equal(41, prepService.BlastSetupId);
}
[Fact]
public void CreateJobItem_FromQuoteItem_UsesStoredPowderToOrderWhenPresent()
{
var quoteItem = new QuoteItem
{
Description = "Wheel",
Quantity = 4m,
SurfaceAreaSqFt = 15m,
Coats =
[
new QuoteItemCoat
{
CoatName = "Primer",
Sequence = 1,
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
PowderToOrder = 9.5m
}
]
};
var coat = Assert.Single(_service.CreateJobItemCoats(quoteItem, jobItemId: 5, companyId: 1, CreatedAtUtc));
Assert.Equal(9.5m, coat.PowderToOrder);
}
// ─── IsAiItem propagation tests ──────────────────────────────────────────────
// AI items use ManualUnitPrice as-is and are excluded from quote-level oven cost
// (the pricing engine assumes oven is already baked into the AI estimate).
// IsAiItem MUST survive every conversion path or the job will be mispriced.
[Fact]
public void PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem()
{
// These bool flags are read by PricingCalculationService to route items to the
// correct pricing path. They MUST exist on both QuoteItem and JobItem, and MUST
// be mapped by JobItemAssemblyService in all three overloads.
//
// If this test fails: you added a pricing flag to one entity but not the other.
// Fix: add the field to both entities, add it to JobItemSeed, map it in all three
// CreateJobItem overloads, and add it to the known list below.
var requiredPricingFlags = new[]
{
nameof(QuoteItem.IsGenericItem),
nameof(QuoteItem.IsLaborItem),
nameof(QuoteItem.IsSalesItem),
nameof(QuoteItem.IsAiItem),
nameof(QuoteItem.IsCustomFormulaItem),
};
foreach (var flag in requiredPricingFlags)
{
Assert.True(typeof(QuoteItem).GetProperty(flag) != null,
$"QuoteItem is missing pricing flag '{flag}' — add it or remove it from this list.");
Assert.True(typeof(JobItem).GetProperty(flag) != null,
$"JobItem is missing pricing flag '{flag}' — add it to JobItem and map it in JobItemAssemblyService.");
Assert.True(typeof(CreateQuoteItemDto).GetProperty(flag) != null,
$"CreateQuoteItemDto is missing pricing flag '{flag}' — add it to the DTO and include it in all existingItemsData JSON blocks.");
}
}
[Fact]
public void CreateJobItem_FromDto_PreservesIsAiItemFlag()
{
var source = new CreateQuoteItemDto
{
Description = "AI Photo Item",
Quantity = 1m,
SurfaceAreaSqFt = 20m,
EstimatedMinutes = 45,
IsAiItem = true,
ManualUnitPrice = 500m,
Complexity = "Moderate",
Coats = [new CreateQuoteItemCoatDto { CoatName = "Coat 1", Sequence = 1 }]
};
var pricing = new QuoteItemPricingResult { UnitPrice = 500m, TotalPrice = 500m };
var jobItem = _service.CreateJobItem(source, jobId: 1, companyId: 1, pricing: pricing, createdAtUtc: CreatedAtUtc);
Assert.True(jobItem.IsAiItem,
"IsAiItem must survive DTO → JobItem conversion. Without it, saved AI jobs are repriced as " +
"calculated items on next edit and oven cost is double-charged.");
}
[Fact]
public void CreateJobItem_FromQuoteItem_PreservesIsAiItemFlag()
{
var quoteItem = new QuoteItem
{
Description = "AI Photo Item",
Quantity = 1m,
SurfaceAreaSqFt = 20m,
IsAiItem = true,
ManualUnitPrice = 500m,
UnitPrice = 500m,
TotalPrice = 500m,
Coats = [new QuoteItemCoat { CoatName = "Coat 1", Sequence = 1 }]
};
var jobItem = _service.CreateJobItem(quoteItem, jobId: 1, companyId: 1, createdAtUtc: CreatedAtUtc);
Assert.True(jobItem.IsAiItem,
"IsAiItem must survive QuoteItem → JobItem conversion (quote-approval / CreateJobFromQuote path).");
}
[Fact]
public void CreateJobItem_FromExistingJobItem_PreservesIsAiItemFlag()
{
var source = new JobItem
{
Description = "AI Photo Item",
Quantity = 1m,
SurfaceAreaSqFt = 20m,
IsAiItem = true,
ManualUnitPrice = 500m,
UnitPrice = 500m,
TotalPrice = 500m,
LaborCost = 200m,
Coats = [new JobItemCoat { CoatName = "Coat 1", Sequence = 1 }]
};
var jobItem = _service.CreateJobItem(source, jobId: 1, companyId: 1, createdAtUtc: CreatedAtUtc);
Assert.True(jobItem.IsAiItem,
"IsAiItem must survive JobItem → JobItem copy (rework path).");
}
[Fact]
public void CreateJobItem_FromExistingJobItem_PreservesTransferableShapeForRework()
{
var source = new JobItem
{
Description = "Gate panel",
Quantity = 1m,
ColorName = "Bronze",
ColorCode = "BZ-22",
Finish = "Textured",
SurfaceArea = 22m,
SurfaceAreaSqFt = 22m,
CatalogItemId = 8,
IsGenericItem = false,
IsLaborItem = false,
IsSalesItem = true,
Sku = "GATE-BRZ",
ManualUnitPrice = 140m,
PowderCostOverride = 9m,
UnitPrice = 140m,
TotalPrice = 140m,
LaborCost = 56m,
RequiresSandblasting = true,
RequiresMasking = true,
EstimatedMinutes = 90,
Notes = "Rework copy",
IncludePrepCost = false,
Complexity = "Extreme",
AiTags = "gate,outdoor",
AiPredictionId = 12,
Coats =
[
new JobItemCoat
{
CoatName = "Top Coat",
Sequence = 1,
InventoryItemId = 21,
ColorName = "Bronze",
VendorId = 13,
ColorCode = "BZ-22",
Finish = "Textured",
CoverageSqFtPerLb = 26m,
TransferEfficiency = 70m,
PowderCostPerLb = 8m,
PowderToOrder = 2.75m,
Notes = "Keep order qty"
}
],
PrepServices =
[
new JobItemPrepService
{
PrepServiceId = 3,
EstimatedMinutes = 45,
BlastSetupId = 77
}
]
};
var jobItem = _service.CreateJobItem(source, jobId: 222, companyId: 9, createdAtUtc: CreatedAtUtc);
var coats = _service.CreateJobItemCoats(source, jobItemId: 333, companyId: 9, CreatedAtUtc);
var prepServices = _service.CreateJobItemPrepServices(source, jobItemId: 333, companyId: 9, CreatedAtUtc);
Assert.Equal(222, jobItem.JobId);
Assert.Equal("Bronze", jobItem.ColorName);
Assert.True(jobItem.IsSalesItem);
Assert.Equal("GATE-BRZ", jobItem.Sku);
Assert.False(jobItem.IncludePrepCost);
Assert.Equal(56m, jobItem.LaborCost);
Assert.Equal(12, jobItem.AiPredictionId);
var coat = Assert.Single(coats);
Assert.Equal(2.75m, coat.PowderToOrder);
Assert.Equal("Bronze", coat.ColorName);
var prepService = Assert.Single(prepServices);
Assert.Equal(77, prepService.BlastSetupId);
Assert.Equal(45, prepService.EstimatedMinutes);
}
}