diff --git a/src/PowderCoating.Application/Services/JobItemAssemblyService.cs b/src/PowderCoating.Application/Services/JobItemAssemblyService.cs
index 99a9764..88629d0 100644
--- a/src/PowderCoating.Application/Services/JobItemAssemblyService.cs
+++ b/src/PowderCoating.Application/Services/JobItemAssemblyService.cs
@@ -4,8 +4,26 @@ using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Services;
+///
+/// Converts quote/job data into persisted , ,
+/// and entities.
+///
+/// Three source types are supported, each with a matching overload:
+/// 1. — quote wizard (new job from form data + fresh pricing result)
+/// 2. — quote-to-job conversion (copies a saved quote line)
+/// 3. — job duplication / template instantiation (copies an existing job line)
+///
+/// The private / /
+/// intermediary classes exist solely to give all three overload paths a single
+/// construction site — avoiding subtle copy-paste drift where one overload forgets to copy a new field.
+///
public class JobItemAssemblyService : IJobItemAssemblyService
{
+ ///
+ /// Creates a from a quote wizard DTO and a pre-calculated pricing result.
+ /// Used when creating a job directly from the job form or from an approved quote via the wizard.
+ /// Pricing is passed in separately because it was already computed upstream (CalculateQuoteItemPriceAsync).
+ ///
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -42,6 +60,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
+ ///
+ /// Builds records from the coat DTOs in the quote wizard form.
+ /// PowderToOrder is recalculated server-side here (not trusted from the form) using surface area,
+ /// quantity, coverage, and transfer efficiency — the wizard's displayed value is for UI only.
+ ///
public IReadOnlyList CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -70,6 +93,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? [];
}
+ ///
+ /// Builds records (sandblasting, masking, etc.) from the
+ /// quote wizard DTO. These are per-item prep steps with individual time estimates that feed
+ /// labor cost calculations and shop floor instructions.
+ ///
public IReadOnlyList CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -85,6 +113,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
+ ///
+ /// Creates a by copying a saved during quote-to-job conversion.
+ /// Prices are taken directly from the quote snapshot — no repricing occurs — so the job starts with
+ /// exactly the amounts that were approved by the customer.
+ /// The first coat's color/finish is promoted to the job item's top-level fields for quick display
+ /// (details remain in the coat records).
+ ///
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -128,6 +163,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
+ ///
+ /// Builds records from a saved during quote-to-job conversion.
+ /// Coat appearance (color name, code, finish) is resolved from the linked if available,
+ /// because the inventory record is the canonical source of truth for a product's appearance —
+ /// the values typed into the quote form may be incomplete or informal.
+ ///
public IReadOnlyList CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -160,6 +201,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? [];
}
+ ///
+ /// Copies prep service records from a to a new job item during quote-to-job conversion.
+ ///
public IReadOnlyList CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -175,6 +219,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
+ ///
+ /// Creates a new by cloning an existing one — used for job templates
+ /// and rework duplication where an existing job line is reused on a new job.
+ /// Prices are copied as-is from the source; the job controller is responsible for repricing
+ /// if operating costs have changed since the original job was created.
+ ///
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -214,6 +264,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
+ ///
+ /// Clones coat records from an existing onto a new job item.
+ /// PowderToOrder is copied verbatim (not recalculated) because the original job's powder
+ /// quantities may have been manually adjusted after initial calculation.
+ ///
public IReadOnlyList CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -242,6 +297,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? [];
}
+ ///
+ /// Clones prep service records from an existing onto a new job item.
+ ///
public IReadOnlyList CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -257,6 +315,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
+ ///
+ /// Single construction point for all creation paths.
+ /// Centralised here so that adding a new field only requires one code change, not three.
+ ///
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
{
return new JobItem
@@ -293,6 +355,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
};
}
+ ///
+ /// Single construction point for all creation paths.
+ ///
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
{
return new JobItemCoat
@@ -315,6 +380,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
};
}
+ ///
+ /// Single construction point for all creation paths.
+ /// Returns an empty list (not null) when is null so callers
+ /// can safely iterate without a null check.
+ ///
private static IReadOnlyList BuildJobItemPrepServices(IEnumerable? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
{
return seeds?
@@ -330,6 +400,18 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? [];
}
+ ///
+ /// Returns the pounds of powder needed to coat a batch, preferring the pre-stored value
+ /// (which the user may have manually adjusted in the wizard) over a fresh recalculation.
+ ///
+ /// Formula: (surfaceAreaSqFt × quantity) ÷ (coverageSqFtPerLb × transferEfficiency)
+ ///
+ /// Industry defaults are applied when catalog data is missing:
+ /// - Coverage: 30 sqft/lb (typical for standard powder at 2–3 mil DFT)
+ /// - Transfer efficiency: 65% (industry average for electrostatic spray)
+ /// These are conservative defaults that slightly overestimate powder needed — intentional,
+ /// so the shop doesn't run short on a job.
+ ///
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
{
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
@@ -343,6 +425,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
}
+ ///
+ /// Resolves the display appearance (color name, code, finish) for a coat, preferring the linked
+ /// 's values over whatever was typed into the quote form.
+ /// The inventory record is the canonical source of truth — the form values are used as a fallback
+ /// only when no inventory item is linked (e.g. custom/one-off powder).
+ ///
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
string? colorName,
string? colorCode,
@@ -355,6 +443,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
}
+ ///
+ /// Intermediate value object that normalises the three different source types
+ /// (DTO, QuoteItem, JobItem) into a single shape before the shared BuildJobItem factory method.
+ /// Using a seed class prevents subtle bugs where an overload forgets to map a new field.
+ ///
private sealed class JobItemSeed
{
public string Description { get; init; } = string.Empty;
@@ -385,6 +478,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public int? AiPredictionId { get; init; }
}
+ /// Intermediate value object for coat creation — see for rationale.
private sealed class JobItemCoatSeed
{
public string CoatName { get; init; } = string.Empty;
@@ -401,6 +495,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public string? Notes { get; init; }
}
+ /// Intermediate value object for prep service creation — see for rationale.
private sealed class JobItemPrepServiceSeed
{
public int PrepServiceId { get; init; }
diff --git a/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs b/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs
index 2392def..9efa727 100644
--- a/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs
+++ b/src/PowderCoating.Application/Services/QuotePricingAssemblyService.cs
@@ -6,6 +6,20 @@ using Microsoft.Extensions.Logging;
namespace PowderCoating.Application.Services;
+///
+/// Orchestrates the full quote item assembly pipeline: pricing calculation, entity construction,
+/// AI prediction tracking, and automatic inventory record creation for incoming powder orders.
+///
+/// This service sits above — it knows HOW to build and
+/// persist quote entities, while PricingCalculationService knows HOW to compute dollar amounts.
+/// Keeping them separate means pricing logic can be unit-tested without any entity construction concerns.
+///
+/// Key responsibilities:
+/// - — stamps calculated totals onto the Quote entity so the
+/// displayed price is frozen at quote time and won't change if operating costs are updated later.
+/// - — builds QuoteItem + coats + prep services for each DTO,
+/// records AI prediction overrides, and auto-creates incoming inventory records when needed.
+///
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{
private readonly IUnitOfWork _unitOfWork;
@@ -25,6 +39,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
_logger = logger;
}
+ ///
+ /// Writes the calculated pricing breakdown onto the entity as a snapshot.
+ /// Snapshots are critical: once a quote is sent to a customer, operating cost changes must NOT
+ /// silently alter the quoted amounts — the snapshot preserves what was presented at the time.
+ ///
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
{
ArgumentNullException.ThrowIfNull(quote);
@@ -56,6 +75,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
quote.Total = pricingResult.Total;
}
+ ///
+ /// Builds and prices all entities from the incoming DTOs.
+ /// For each item: constructs the entity, calculates pricing, records whether the user overrode
+ /// an AI estimate, then attaches coats (including auto-creating incoming inventory entries when
+ /// the user selects a catalog powder not yet in their inventory) and prep services.
+ ///
public async Task> CreateQuoteItemsAsync(
IEnumerable itemDtos,
int quoteId,
@@ -80,6 +105,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return items;
}
+ ///
+ /// Routes a single item to the correct pricing path and stamps the result onto the entity.
+ /// Priority order matches the routing table in :
+ /// AI items → Sales items → Catalog (no coats) → full calculation engine.
+ /// Keeping pricing logic in PricingCalculationService means this method only decides WHICH
+ /// path to take, never HOW to compute the price.
+ ///
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
{
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
@@ -127,6 +159,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
ApplyCalculatedPricing(item, pricing);
}
+ ///
+ /// Builds entities for a single item, including per-coat pricing.
+ /// If a coat has AddAsIncoming = true and references a catalog item but not an inventory
+ /// item, an incoming is auto-created so the shop can track the powder
+ /// order and receive it later — see for details.
+ ///
private async Task> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
@@ -158,6 +196,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return coats;
}
+ /// Constructs entities from the item DTO's prep service list.
private static List BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
@@ -175,6 +214,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
.ToList();
}
+ ///
+ /// Constructs a bare entity from the DTO — no pricing or coats yet.
+ /// Pricing is applied separately by to keep the construction
+ /// and calculation steps distinct and individually testable.
+ ///
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
{
return new QuoteItem
@@ -204,6 +248,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
};
}
+ /// Constructs a entity from the coat DTO. Per-coat pricing is applied by the caller.
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
{
return new QuoteItemCoat
@@ -225,6 +270,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
};
}
+ ///
+ /// Stamps the pricing result onto the quote item entity.
+ /// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync.
+ ///
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
{
item.UnitPrice = pricing.UnitPrice;
@@ -234,6 +283,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
item.ItemEquipmentCost = pricing.EquipmentCost;
}
+ ///
+ /// Checks whether the user changed the AI's surface area or price estimates before saving,
+ /// and sets UserOverrodeEstimate = true on the prediction record if they did.
+ /// This flag feeds the AI analytics reports — over time it reveals how accurate the AI is
+ /// and whether certain item types consistently need manual correction.
+ /// A tolerance of $0.01 / 0.01 sqft is used to ignore floating-point rounding noise.
+ ///
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
{
if (!itemDto.AiPredictionId.HasValue) return;
@@ -247,6 +303,23 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
prediction.UpdatedAt = DateTime.UtcNow;
}
+ ///
+ /// Auto-creates an "incoming" when a user selects a powder from the
+ /// platform catalog that doesn't yet exist in their company's inventory.
+ ///
+ /// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
+ /// forcing the user to manually add the powder to inventory before quoting, we create an
+ /// IsIncoming=true record on their behalf. The shop can then receive the actual order against
+ /// this record later (updating quantity + receive date) without losing the link to the original quote.
+ ///
+ /// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
+ /// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
+ /// if it fails, the item is still created with whatever data the catalog has.
+ ///
+ /// After creation, coatDto.PowderCostPerLb is cleared so the pricing engine treats this
+ /// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
+ /// inventory unit cost rather than the now-stale manual price from the quote form.
+ ///
private async Task CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
{
try