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:
2026-05-14 16:54:22 -04:00
parent 7e79a13cb1
commit 539c6c2559
24 changed files with 22175 additions and 994 deletions
+17 -189
View File
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Quote.UpdateQuoteDto
@model PowderCoating.Application.DTOs.Quote.UpdateQuoteDto
@using PowderCoating.Core.Entities
@{
@@ -109,7 +109,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Information"
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more &lt;/a&gt;">
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional — use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -173,7 +173,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill — for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -216,7 +216,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Item Types"
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more &lt;/a&gt;">
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; — upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -277,7 +277,7 @@
<div class="form-check">
<input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" />
<label class="form-check-label small" for="hideDiscountFromCustomer">
Hide discount from customer PDFs and approval portal show final price only
Hide discount from customer — PDFs and approval portal show final price only
</label>
</div>
</div>
@@ -292,7 +292,7 @@
<a tabindex="0" class="help-icon text-white" role="button"
data-bs-toggle="popover" data-bs-placement="left"
data-bs-title="Pricing Summary"
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more &lt;/a&gt;">
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -303,7 +303,7 @@
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong>
</p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
@@ -407,7 +407,7 @@
</div>
<div id="editPhotoUploadProgress" class="d-none mt-2">
<div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div>
<small class="text-muted">Uploading</small>
<small class="text-muted">Uploading…</small>
</div>
</div>
</div>
@@ -435,11 +435,11 @@
<span id="smsNotifyNote" class="badge @(editHasSms ? "bg-info text-white" : "bg-warning text-dark")">
@if (editHasSms)
{
<i class="bi bi-phone me-1"></i><text>No email send via SMS from quote details</text>
<i class="bi bi-phone me-1"></i><text>No email — send via SMS from quote details</text>
}
else
{
<i class="bi bi-phone-slash me-1"></i><text>No email SMS consent required</text>
<i class="bi bi-phone-slash me-1"></i><text>No email — SMS consent required</text>
}
</span>
}
@@ -459,99 +459,8 @@
</form>
</div>
<!-- Surface Area Calculator Modal -->
<div class="modal fade" id="sqFtCalculatorModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-calculator me-2"></i>Surface Area Calculator <small class="text-muted">(per item)</small></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Shape</label>
<select id="calcShape" class="form-select" onchange="toggleShapeInputs()">
<option value="rectangle">Rectangle / Square</option>
<option value="cylinder">Cylinder (Tube)</option>
<option value="circle">Circle (Flat)</option>
</select>
</div>
<div id="rectangleInputs">
<div class="row g-2">
<div class="col-6"><label class="form-label">Length (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
<input type="number" id="rectLength" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
</div>
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
</div>
<div id="cylinderInputs" style="display:none">
<div class="row g-2">
<div class="col-6"><label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
<input type="number" id="cylDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
<div class="col-6"><label class="form-label">Height (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
<input type="number" id="cylHeight" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
</div>
</div>
<div id="circleInputs" style="display:none">
<label class="form-label">Diameter (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
</div>
<hr />
<div class="alert alert-info alert-permanent mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
<i class="bi bi-check-circle me-1"></i>Use This Value
</button>
</div>
</div>
</div>
</div>
<!-- ========================= ITEM WIZARD MODAL ========================= -->
<div class="modal fade" id="itemWizardModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div class="d-flex flex-column">
<h5 class="modal-title mb-0" id="wizardTitle">Add Item</h5>
<div class="text-muted small mb-1" id="wizardStepTitle">Choose Item Type</div>
<!-- Step progress -->
<div class="d-flex align-items-center gap-2" id="wizardStepIndicator">
<span class="wizard-step-dot active" data-step="1" title="Item Type"></span>
<div class="wizard-step-line"></div>
<span class="wizard-step-dot" data-step="2" title="Item Details"></span>
<div class="wizard-step-line" id="step2Line"></div>
<span class="wizard-step-dot" data-step="3" title="Coating Layers" id="step3Dot"></span>
<div class="wizard-step-line" id="step3Line"></div>
<span class="wizard-step-dot" data-step="4" title="Prep Services" id="step4Dot"></span>
<span class="text-muted small ms-2" id="wizardStepLabel">Step 1 of 4</span>
</div>
</div>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="wizardBody" style="min-height: 300px;">
<!-- Content injected by JS -->
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary d-none" id="btnWizardBack" onclick="wizardBack()">
<i class="bi bi-arrow-left me-1"></i>Back
</button>
<button type="button" class="btn btn-primary" id="btnWizardNext" onclick="wizardNext()">
Next <i class="bi bi-arrow-right ms-1"></i>
</button>
<button type="button" class="btn btn-success d-none" id="btnWizardSave" onclick="wizardSave()">
<i class="bi bi-check-lg me-1"></i>Add Item
</button>
</div>
</div>
</div>
</div>
</div>
@await Html.PartialAsync("_SqFtCalculatorModal")
@await Html.PartialAsync("_ItemWizardModal")
<!-- Embedded data for JS -->
@if (ViewBag.InventoryCoatings != null)
@@ -579,7 +488,7 @@
@Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>()))
</script>
<!-- Existing items always populated on Edit -->
<!-- Existing items — always populated on Edit -->
<script id="existingItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.QuoteItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select((item, i) => new {
description = item.Description,
@@ -647,43 +556,7 @@
</script>
@section Styles {
<style>
.wizard-step-dot {
width: 22px; height: 22px; border-radius: 50%;
background: #dee2e6; display: inline-block; cursor: default;
border: 2px solid #dee2e6; transition: all .2s;
flex-shrink: 0;
}
.wizard-step-dot.active { background: #0d6efd; border-color: #0d6efd; }
.wizard-step-dot.done { background: #198754; border-color: #198754; }
.wizard-step-dot.skip { background: #adb5bd; border-color: #adb5bd; }
.wizard-step-line { flex: 1; height: 2px; background: #dee2e6; min-width: 30px; }
.item-type-card {
border: 2px solid #dee2e6; border-radius: .75rem; padding: 1.25rem 1rem;
cursor: pointer; transition: all .15s; text-align: center;
background: #fff; user-select: none;
}
.item-type-card:hover { border-color: #86b7fe; background: #f0f6ff; }
.item-type-card.selected { border-color: #0d6efd; background: #eef3ff; }
.item-type-card .item-type-icon { font-size: 2rem; margin-bottom: .5rem; }
[data-bs-theme="dark"] .item-type-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
[data-bs-theme="dark"] .item-type-card:hover { border-color: #86b7fe; background: var(--bs-secondary-bg); }
[data-bs-theme="dark"] .item-type-card.selected { border-color: #0d6efd; background: #1a2a4a; }
.catalog-list-item { cursor: pointer; border-bottom: 1px solid var(--bs-border-color); font-size: .9rem; transition: background .1s; }
.catalog-list-item:last-child { border-bottom: none; }
.catalog-list-item:hover { background: var(--bs-tertiary-bg); }
.catalog-list-item.selected { background: #eef3ff; color: #0d6efd; font-weight: 600; }
[data-bs-theme="dark"] .catalog-list-item.selected { background: #1a2a4a; color: #86b7fe; }
.quote-item-card {
border: 1px solid #dee2e6; border-radius: .5rem;
padding: .75rem 1rem; margin-bottom: .5rem;
background: #fafafa;
}
.quote-item-card .item-badge { font-size: .7rem; }
.coat-row { border: 1px solid #dee2e6; border-radius: .5rem; padding: .75rem; margin-bottom: .5rem; }
[data-bs-theme="dark"] .quote-item-card { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); }
[data-bs-theme="dark"] .coat-row { background: var(--bs-tertiary-bg); border-color: var(--bs-border-color); }
</style>
<link rel="stylesheet" href="~/css/item-wizard.css">
}
@section Scripts {
@@ -740,8 +613,8 @@
smsNote.style.display = 'inline';
smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark';
smsNote.innerHTML = hasSms
? '<i class="bi bi-phone me-1"></i>No email send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email SMS consent required';
? '<i class="bi bi-phone me-1"></i>No email — send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email — SMS consent required';
} else {
smsNote.style.display = 'none';
}
@@ -758,51 +631,6 @@
}
// Surface area calculator
let _sqFtTargetInput = null;
function openSqFtCalculator(inputId) {
_sqFtTargetInput = inputId;
document.getElementById('rectLength').value = 0;
document.getElementById('rectWidth').value = 0;
document.getElementById('calcResult').textContent = '0.00';
new bootstrap.Modal(document.getElementById('sqFtCalculatorModal')).show();
}
function toggleShapeInputs() {
const shape = document.getElementById('calcShape').value;
document.getElementById('rectangleInputs').style.display = shape === 'rectangle' ? 'block' : 'none';
document.getElementById('cylinderInputs').style.display = shape === 'cylinder' ? 'block' : 'none';
document.getElementById('circleInputs').style.display = shape === 'circle' ? 'block' : 'none';
calculateSqFt();
}
function calculateSqFt() {
const useMetric = @Json.Serialize((bool)(ViewBag.UseMetric ?? false));
const divisor = useMetric ? 10000 : 144;
const shape = document.getElementById('calcShape').value;
let result = 0;
if (shape === 'rectangle') {
const l = parseFloat(document.getElementById('rectLength').value) || 0;
const w = parseFloat(document.getElementById('rectWidth').value) || 0;
result = (l * w) / divisor;
} else if (shape === 'cylinder') {
const d = parseFloat(document.getElementById('cylDiameter').value) || 0;
const h = parseFloat(document.getElementById('cylHeight').value) || 0;
const r = d / 2;
result = (2 * Math.PI * r * r + 2 * Math.PI * r * h) / divisor;
} else {
const d = parseFloat(document.getElementById('circDiameter').value) || 0;
const r = d / 2;
result = (Math.PI * r * r) / divisor;
}
document.getElementById('calcResult').textContent = result.toFixed(4);
}
function useSqFtResult() {
const val = document.getElementById('calcResult').textContent;
if (_sqFtTargetInput) {
const el = document.getElementById(_sqFtTargetInput) || document.querySelector(`[name="${_sqFtTargetInput}"]`);
if (el) { el.value = parseFloat(val).toFixed(2); el.dispatchEvent(new Event('change')); }
}
bootstrap.Modal.getInstance(document.getElementById('sqFtCalculatorModal'))?.hide();
}
// Form submit guard
document.getElementById('quoteForm').addEventListener('submit', function(e) {
if (typeof quoteItems === 'undefined' || quoteItems.length === 0) {
@@ -811,7 +639,7 @@
}
});
// Quote photo direct upload (Edit page quoteId is known)
// Quote photo direct upload (Edit page — quoteId is known)
(function () {
const quoteId = @Model.Id;
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';