Fix oven batch conversion, invoice quantity, AI photo pricing, and enforce pricing flag propagation
- Carry OvenBatches/OvenCycleMinutes from Quote → Job entity (was missing fields; all job pricing recalcs hardcoded 1/null) - Fix invoice creation from job always showing Quantity=1 (was using TotalPrice as UnitPrice with qty 1) - Add IsAiItem to JobItem + migration; map in all 3 JobItemAssemblyService.CreateJobItem overloads so AI photo jobs no longer double-price on first edit after quote→job conversion - Propagate IsAiItem through all existingItemsData JSON blocks in Jobs views (Edit, EditItems, Create) so the wizard preserves AI routing on re-edit - Add PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem structural test + 3 behavioral IsAiItem tests to JobItemAssemblyServiceTests - Consolidate item wizard partials (_ItemWizardModal, _SqFtCalculatorModal) and item-wizard.css into shared locations - Document pricing flag propagation checklist in CLAUDE.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -195,6 +195,106 @@ public class JobItemAssemblyServiceTests
|
||||
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),
|
||||
};
|
||||
|
||||
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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user