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