Files
PowderCoatingLogix/src/PowderCoating.Application/Services/JobItemAssemblyService.cs
T
spouliot 2bf8871892 Fix NoExtraLayerCharge persistence, appointment reminders, coat notes display, scroll restoration, and invoice Send dead-button
- Appointment reminders: add AppointmentReminderBackgroundService (60s poll), ReminderSentAt
  dedup stamp, NotifyAppointmentReminderAsync sends both customer email and creator staff email;
  AppointmentReminderStaff notification type + default template added; DateTime.Now used instead
  of UtcNow to match locally-stored ScheduledStartTime; ToLocalTime() double-conversion removed

- NoExtraLayerCharge not persisted: flag existed on CreateQuoteItemCoatDto and was used by
  pricing engine but never written to JobItemCoat/QuoteItemCoat entities — every edit reset it
  to false and re-applied the extra layer charge; added column to both entities (migration
  AddNoExtraLayerChargeToCoats), both read DTOs, all 3 JobItemAssemblyService overloads,
  JobItemCoatSeed inner class, and existingItemsData JSON in all 5 wizard views; fixed JS
  template path that hard-coded noExtraLayerCharge: false

- Coat notes not visible: notes were rendered in desktop job details but missing from the wizard
  item card summary and the mobile card view; both fixed

- Scroll position lost on item save: sessionStorage save/restore added to item-wizard.js owner
  form submit handler; path-keyed so cross-page navigation does not restore stale position;
  requestAnimationFrame used for reliable mobile scroll restoration

- Invoice Send dead button: #sendChannelModal was gated inside @if (isDraft) but the button
  targeting it fires for Sent/Overdue invoices too when customer has both email and SMS; modal
  moved outside the Draft guard

- InitialCreate migration added for fresh database installs; Baseline migration guarded with
  IF OBJECT_ID check so it no-ops on fresh DBs; Razor scoping bug fixed in Customers/Index.cshtml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:16 -04:00

511 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
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
{
/// <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)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(pricing);
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = pricing.UnitPrice,
TotalPrice = pricing.TotalPrice,
LaborCost = pricing.LaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
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)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c => BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = c.ColorName,
VendorId = c.VendorId,
ColorCode = c.ColorCode,
Finish = c.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
createdAtUtc))
.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)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
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)
{
ArgumentNullException.ThrowIfNull(source);
var firstCoat = source.Coats?
.OrderBy(c => c.Sequence)
.FirstOrDefault();
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
ColorName = firstCoat?.ColorName,
ColorCode = firstCoat?.ColorCode,
Finish = firstCoat?.Finish,
SurfaceArea = source.SurfaceAreaSqFt,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice,
LaborCost = source.ItemLaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
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)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c =>
{
var appearance = ResolveCoatAppearance(c.ColorName, c.ColorCode, c.Finish, c.InventoryItem);
return BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = appearance.ColorName,
VendorId = c.VendorId,
ColorCode = appearance.ColorCode,
Finish = appearance.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
createdAtUtc);
})
.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)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
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)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItem(
new JobItemSeed
{
Description = source.Description,
Quantity = source.Quantity,
ColorName = source.ColorName,
ColorCode = source.ColorCode,
Finish = source.Finish,
SurfaceArea = source.SurfaceArea,
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
CatalogItemId = source.CatalogItemId,
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice,
LaborCost = source.LaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
Notes = source.Notes,
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
},
jobId,
companyId,
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)
{
ArgumentNullException.ThrowIfNull(source);
return source.Coats?
.OrderBy(c => c.Sequence)
.Select(c => BuildJobItemCoat(
new JobItemCoatSeed
{
CoatName = c.CoatName,
Sequence = c.Sequence,
InventoryItemId = c.InventoryItemId,
ColorName = c.ColorName,
VendorId = c.VendorId,
ColorCode = c.ColorCode,
Finish = c.Finish,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
createdAtUtc))
.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)
{
ArgumentNullException.ThrowIfNull(source);
return BuildJobItemPrepServices(
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
{
PrepServiceId = p.PrepServiceId,
EstimatedMinutes = p.EstimatedMinutes,
BlastSetupId = p.BlastSetupId
}),
jobItemId,
companyId,
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)
{
return new JobItem
{
JobId = jobId,
Description = seed.Description,
Quantity = seed.Quantity,
ColorName = seed.ColorName,
ColorCode = seed.ColorCode,
Finish = seed.Finish,
SurfaceArea = seed.SurfaceArea,
SurfaceAreaSqFt = seed.SurfaceAreaSqFt,
CatalogItemId = seed.CatalogItemId,
IsGenericItem = seed.IsGenericItem,
IsLaborItem = seed.IsLaborItem,
IsSalesItem = seed.IsSalesItem,
IsAiItem = seed.IsAiItem,
Sku = seed.Sku,
ManualUnitPrice = seed.ManualUnitPrice,
PowderCostOverride = seed.PowderCostOverride,
UnitPrice = seed.UnitPrice,
TotalPrice = seed.TotalPrice,
LaborCost = seed.LaborCost,
RequiresSandblasting = seed.RequiresSandblasting,
RequiresMasking = seed.RequiresMasking,
EstimatedMinutes = seed.EstimatedMinutes,
Notes = seed.Notes,
IncludePrepCost = seed.IncludePrepCost,
Complexity = seed.Complexity,
AiTags = seed.AiTags,
AiPredictionId = seed.AiPredictionId,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
/// <summary>
/// Single construction point for all <see cref="JobItemCoat"/> creation paths.
/// </summary>
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
{
return new JobItemCoat
{
JobItemId = jobItemId,
CoatName = seed.CoatName,
Sequence = seed.Sequence,
InventoryItemId = seed.InventoryItemId,
ColorName = seed.ColorName,
VendorId = seed.VendorId,
ColorCode = seed.ColorCode,
Finish = seed.Finish,
CoverageSqFtPerLb = seed.CoverageSqFtPerLb,
TransferEfficiency = seed.TransferEfficiency,
PowderCostPerLb = seed.PowderCostPerLb,
PowderToOrder = seed.PowderToOrder,
Notes = seed.Notes,
NoExtraLayerCharge = seed.NoExtraLayerCharge,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
/// <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)
{
return seeds?
.Select(seed => new JobItemPrepService
{
JobItemId = jobItemId,
PrepServiceId = seed.PrepServiceId,
EstimatedMinutes = seed.EstimatedMinutes,
BlastSetupId = seed.BlastSetupId,
CompanyId = companyId,
CreatedAt = createdAtUtc
})
.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 23 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)
{
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
return storedPowderToOrder;
if (surfaceAreaSqFt <= 0)
return null;
var coverage = coverageSqFtPerLb > 0 ? coverageSqFtPerLb : 30m;
var efficiency = transferEfficiency > 0 ? transferEfficiency / 100m : 0.65m;
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(
string? colorName,
string? colorCode,
string? finish,
InventoryItem? inventoryItem)
{
if (inventoryItem == null)
return (colorName, colorCode, 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
{
public string Description { get; init; } = string.Empty;
public decimal Quantity { get; init; }
public string? ColorName { get; init; }
public string? ColorCode { get; init; }
public string? Finish { get; init; }
public decimal? SurfaceArea { get; init; }
public decimal SurfaceAreaSqFt { get; init; }
public int? CatalogItemId { get; init; }
public bool IsGenericItem { get; init; }
public bool IsLaborItem { get; init; }
public bool IsSalesItem { get; init; }
public bool IsAiItem { get; init; }
public string? Sku { get; init; }
public decimal? ManualUnitPrice { get; init; }
public decimal? PowderCostOverride { get; init; }
public decimal UnitPrice { get; init; }
public decimal TotalPrice { get; init; }
public decimal LaborCost { get; init; }
public bool RequiresSandblasting { get; init; }
public bool RequiresMasking { get; init; }
public int EstimatedMinutes { get; init; }
public string? Notes { get; init; }
public bool IncludePrepCost { get; init; }
public string? Complexity { get; init; }
public string? AiTags { 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
{
public string CoatName { get; init; } = string.Empty;
public int Sequence { get; init; }
public int? InventoryItemId { get; init; }
public string? ColorName { get; init; }
public int? VendorId { get; init; }
public string? ColorCode { get; init; }
public string? Finish { get; init; }
public decimal CoverageSqFtPerLb { get; init; }
public decimal TransferEfficiency { get; init; }
public decimal? PowderCostPerLb { get; init; }
public decimal? PowderToOrder { get; init; }
public string? Notes { get; init; }
public bool NoExtraLayerCharge { get; init; }
}
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
private sealed class JobItemPrepServiceSeed
{
public int PrepServiceId { get; init; }
public int EstimatedMinutes { get; init; }
public int? BlastSetupId { get; init; }
}
}