Add XML doc comments to pricing assembly services
Added comprehensive XML documentation to JobItemAssemblyService and QuotePricingAssemblyService — the most complex area of the codebase. Comments explain the three-overload pattern, seed class rationale, powder-to-order formula and industry default fallbacks, AI prediction override tracking, and the incoming inventory auto-creation workflow. PricingCalculationService was already well-documented; no changes needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,26 @@ using PowderCoating.Core.Entities;
|
|||||||
|
|
||||||
namespace PowderCoating.Application.Services;
|
namespace PowderCoating.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts quote/job data into persisted <see cref="JobItem"/>, <see cref="JobItemCoat"/>,
|
||||||
|
/// and <see cref="JobItemPrepService"/> entities.
|
||||||
|
///
|
||||||
|
/// Three source types are supported, each with a matching overload:
|
||||||
|
/// 1. <see cref="CreateQuoteItemDto"/> — quote wizard (new job from form data + fresh pricing result)
|
||||||
|
/// 2. <see cref="QuoteItem"/> — quote-to-job conversion (copies a saved quote line)
|
||||||
|
/// 3. <see cref="JobItem"/> — job duplication / template instantiation (copies an existing job line)
|
||||||
|
///
|
||||||
|
/// The private <see cref="JobItemSeed"/> / <see cref="JobItemCoatSeed"/> / <see cref="JobItemPrepServiceSeed"/>
|
||||||
|
/// intermediary classes exist solely to give all three overload paths a single <see cref="BuildJobItem"/>
|
||||||
|
/// construction site — avoiding subtle copy-paste drift where one overload forgets to copy a new field.
|
||||||
|
/// </summary>
|
||||||
public class JobItemAssemblyService : IJobItemAssemblyService
|
public class JobItemAssemblyService : IJobItemAssemblyService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a <see cref="JobItem"/> 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).
|
||||||
|
/// </summary>
|
||||||
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -42,6 +60,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
createdAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds <see cref="JobItemCoat"/> 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.
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -70,6 +93,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.ToList() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds <see cref="JobItemPrepService"/> 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.
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -85,6 +113,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
createdAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a <see cref="JobItem"/> by copying a saved <see cref="QuoteItem"/> 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).
|
||||||
|
/// </summary>
|
||||||
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -128,6 +163,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
createdAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds <see cref="JobItemCoat"/> records from a saved <see cref="QuoteItem"/> during quote-to-job conversion.
|
||||||
|
/// Coat appearance (color name, code, finish) is resolved from the linked <see cref="InventoryItem"/> 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.
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -160,6 +201,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.ToList() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies prep service records from a <see cref="QuoteItem"/> to a new job item during quote-to-job conversion.
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -175,6 +219,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
createdAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new <see cref="JobItem"/> 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.
|
||||||
|
/// </summary>
|
||||||
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -214,6 +264,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
createdAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clones coat records from an existing <see cref="JobItem"/> 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.
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -242,6 +297,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.ToList() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clones prep service records from an existing <see cref="JobItem"/> onto a new job item.
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -257,6 +315,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
createdAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single construction point for all <see cref="JobItem"/> creation paths.
|
||||||
|
/// Centralised here so that adding a new field only requires one code change, not three.
|
||||||
|
/// </summary>
|
||||||
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new JobItem
|
return new JobItem
|
||||||
@@ -293,6 +355,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single construction point for all <see cref="JobItemCoat"/> creation paths.
|
||||||
|
/// </summary>
|
||||||
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new JobItemCoat
|
return new JobItemCoat
|
||||||
@@ -315,6 +380,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single construction point for all <see cref="JobItemPrepService"/> creation paths.
|
||||||
|
/// Returns an empty list (not null) when <paramref name="seeds"/> is null so callers
|
||||||
|
/// can safely iterate without a null check.
|
||||||
|
/// </summary>
|
||||||
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return seeds?
|
return seeds?
|
||||||
@@ -330,6 +400,18 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.ToList() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
||||||
{
|
{
|
||||||
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
||||||
@@ -343,6 +425,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the display appearance (color name, code, finish) for a coat, preferring the linked
|
||||||
|
/// <see cref="InventoryItem"/>'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).
|
||||||
|
/// </summary>
|
||||||
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
||||||
string? colorName,
|
string? colorName,
|
||||||
string? colorCode,
|
string? colorCode,
|
||||||
@@ -355,6 +443,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private sealed class JobItemSeed
|
private sealed class JobItemSeed
|
||||||
{
|
{
|
||||||
public string Description { get; init; } = string.Empty;
|
public string Description { get; init; } = string.Empty;
|
||||||
@@ -385,6 +478,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public int? AiPredictionId { get; init; }
|
public int? AiPredictionId { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||||
private sealed class JobItemCoatSeed
|
private sealed class JobItemCoatSeed
|
||||||
{
|
{
|
||||||
public string CoatName { get; init; } = string.Empty;
|
public string CoatName { get; init; } = string.Empty;
|
||||||
@@ -401,6 +495,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public string? Notes { get; init; }
|
public string? Notes { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||||
private sealed class JobItemPrepServiceSeed
|
private sealed class JobItemPrepServiceSeed
|
||||||
{
|
{
|
||||||
public int PrepServiceId { get; init; }
|
public int PrepServiceId { get; init; }
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace PowderCoating.Application.Services;
|
namespace PowderCoating.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="PricingCalculationService"/> — 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:
|
||||||
|
/// - <see cref="ApplyPricingSnapshot"/> — 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.
|
||||||
|
/// - <see cref="CreateQuoteItemsAsync"/> — builds QuoteItem + coats + prep services for each DTO,
|
||||||
|
/// records AI prediction overrides, and auto-creates incoming inventory records when needed.
|
||||||
|
/// </summary>
|
||||||
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
@@ -25,6 +39,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the calculated pricing breakdown onto the <see cref="Quote"/> 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.
|
||||||
|
/// </summary>
|
||||||
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(quote);
|
ArgumentNullException.ThrowIfNull(quote);
|
||||||
@@ -56,6 +75,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
quote.Total = pricingResult.Total;
|
quote.Total = pricingResult.Total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds and prices all <see cref="QuoteItem"/> 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.
|
||||||
|
/// </summary>
|
||||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||||
int quoteId,
|
int quoteId,
|
||||||
@@ -80,6 +105,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes a single item to the correct pricing path and stamps the result onto the entity.
|
||||||
|
/// Priority order matches the routing table in <see cref="PricingCalculationService.CalculateQuoteItemPriceAsync"/>:
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
||||||
{
|
{
|
||||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||||
@@ -127,6 +159,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
ApplyCalculatedPricing(item, pricing);
|
ApplyCalculatedPricing(item, pricing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
||||||
|
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
|
||||||
|
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
|
||||||
|
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
|
||||||
|
/// </summary>
|
||||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
||||||
@@ -158,6 +196,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
return coats;
|
return coats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Constructs <see cref="QuoteItemPrepService"/> entities from the item DTO's prep service list.</summary>
|
||||||
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
||||||
@@ -175,6 +214,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructs a bare <see cref="QuoteItem"/> entity from the DTO — no pricing or coats yet.
|
||||||
|
/// Pricing is applied separately by <see cref="ApplyPricingAsync"/> to keep the construction
|
||||||
|
/// and calculation steps distinct and individually testable.
|
||||||
|
/// </summary>
|
||||||
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new QuoteItem
|
return new QuoteItem
|
||||||
@@ -204,6 +248,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Constructs a <see cref="QuoteItemCoat"/> entity from the coat DTO. Per-coat pricing is applied by the caller.</summary>
|
||||||
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new QuoteItemCoat
|
return new QuoteItemCoat
|
||||||
@@ -225,6 +270,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stamps the pricing result onto the quote item entity.
|
||||||
|
/// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync.
|
||||||
|
/// </summary>
|
||||||
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
||||||
{
|
{
|
||||||
item.UnitPrice = pricing.UnitPrice;
|
item.UnitPrice = pricing.UnitPrice;
|
||||||
@@ -234,6 +283,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
item.ItemEquipmentCost = pricing.EquipmentCost;
|
item.ItemEquipmentCost = pricing.EquipmentCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether the user changed the AI's surface area or price estimates before saving,
|
||||||
|
/// and sets <c>UserOverrodeEstimate = true</c> 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.
|
||||||
|
/// </summary>
|
||||||
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||||
{
|
{
|
||||||
if (!itemDto.AiPredictionId.HasValue) return;
|
if (!itemDto.AiPredictionId.HasValue) return;
|
||||||
@@ -247,6 +303,23 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
prediction.UpdatedAt = DateTime.UtcNow;
|
prediction.UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-creates an "incoming" <see cref="InventoryItem"/> 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, <c>coatDto.PowderCostPerLb</c> 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.
|
||||||
|
/// </summary>
|
||||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
Reference in New Issue
Block a user