e185e3b7e3
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>
506 lines
22 KiB
C#
506 lines
22 KiB
C#
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
|
||
},
|
||
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
|
||
},
|
||
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
|
||
},
|
||
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,
|
||
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 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)
|
||
{
|
||
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; }
|
||
}
|
||
|
||
/// <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; }
|
||
}
|
||
}
|