Refactor: extract shared helpers, fix field drift, add assembly services

- IJobItemAssemblyService / IQuotePricingAssemblyService: centralize job item
  and quote pricing construction that was duplicated across create, rework copy,
  and quote-to-job conversion paths
- BlobFileHelper: single ValidateUpload/GetContentType/SanitizeFileName used by
  6 blob services (JobPhoto, QuotePhoto, ProfilePhoto, CompanyLogo, Equipment,
  Catalog) and BillsController + ExpensesController, removing 8 private copies
- PagedResult<T>.From(): static factory eliminates 6-line boilerplate in 11
  controllers (Appointments, Customers, Equipment, Inventory, Invoices, Jobs,
  Maintenance, CompanyUsers, PlatformUsers, Quotes, Vendors)
- AccountingDropdownHelper: single LoadAsync() call replaces duplicate
  vendor/account/job queries in BillsController and ExpensesController
- JobTemplateItem: add IsSalesItem + Sku fields with migration; propagate
  through JobTemplatesController snapshot copy and GetTemplatesJson projection,
  and JobsController template-application path
- Test assertions updated for standardized BlobFileHelper error messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 22:12:33 -04:00
parent 61866e1d1e
commit edd7389d7d
37 changed files with 11819 additions and 1211 deletions
@@ -3,6 +3,22 @@ namespace PowderCoating.Application.DTOs.Common;
public class PagedResult<T>
{
public IEnumerable<T> Items { get; set; } = new List<T>();
/// <summary>
/// Creates a PagedResult populated from a GridRequest, avoiding repetitive property
/// assignments across every Index action. SortColumn, SortDirection, and SearchTerm
/// are copied from the grid so the model carries full state for view binding.
/// </summary>
public static PagedResult<T> From(GridRequest grid, IEnumerable<T> items, int totalCount) => new()
{
Items = items,
PageNumber = grid.PageNumber,
PageSize = grid.PageSize,
TotalCount = totalCount,
SortColumn = grid.SortColumn,
SortDirection = grid.SortDirection,
SearchTerm = grid.SearchTerm
};
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
@@ -0,0 +1,19 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Interfaces;
public interface IJobItemAssemblyService
{
JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc);
IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
}
@@ -0,0 +1,16 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Interfaces;
public interface IQuotePricingAssemblyService
{
void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult);
Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
IEnumerable<CreateQuoteItemDto> itemDtos,
int quoteId,
int companyId,
decimal? ovenRateOverride,
DateTime createdAtUtc);
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Http;
namespace PowderCoating.Application.Services;
/// <summary>
/// Shared file validation and content-type resolution used across all blob storage services.
/// </summary>
public static class BlobFileHelper
{
/// <summary>
/// Validates an uploaded file against an extension allowlist and a maximum size.
/// Returns the normalized (lowercase) extension on success so callers do not re-derive it.
/// </summary>
public static (bool IsValid, string Extension, string Error) ValidateUpload(
IFormFile? file,
string[] allowedExtensions,
long maxBytes)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file provided.");
if (file.Length > maxBytes)
return (false, string.Empty, $"File exceeds the {maxBytes / 1024 / 1024} MB limit.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed: {string.Join(", ", allowedExtensions)}.");
return (true, extension, string.Empty);
}
/// <summary>
/// Maps a file extension to its MIME content type, covering common image formats and
/// document types. Falls back to <c>application/octet-stream</c>.
/// </summary>
public static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
".pdf" => "application/pdf",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt" => "text/plain",
_ => "application/octet-stream"
};
/// <summary>
/// Strips OS-invalid filename characters from a base filename (no extension), replacing
/// them with underscores to produce a safe blob path segment.
/// </summary>
public static string SanitizeFileName(string fileName)
{
var sanitized = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
return string.IsNullOrWhiteSpace(sanitized) ? "file" : sanitized;
}
}
@@ -47,15 +47,9 @@ public class CatalogImageService : ICatalogImageService
string? existingImagePath,
string? existingThumbnailPath)
{
if (file == null || file.Length == 0)
return (false, string.Empty, string.Empty, "No file provided.");
if (file.Length > MaxFileSizeBytes)
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp.");
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
if (!isValid)
return (false, string.Empty, string.Empty, validationError);
var container = _settings.Containers.CatalogImages;
var blobId = Guid.NewGuid().ToString("N");
@@ -67,21 +67,15 @@ public class CompanyLogoService : ICompanyLogoService
/// </returns>
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file provided");
if (file.Length > MaxFileSize)
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
if (!isValid)
return (false, string.Empty, error);
// Delete old logo (any extension) before saving new one
await DeleteOldLogosAsync(companyId, extension);
var blobName = GetCompanyLogoPath(companyId, extension);
var contentType = GetContentType(extension);
var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType);
@@ -158,20 +152,4 @@ public class CompanyLogoService : ICompanyLogoService
}
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// The correct content type is required so that browsers render the image
/// inline rather than triggering a download.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
private static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
_ => "application/octet-stream"
};
}
@@ -56,25 +56,16 @@ public class EquipmentManualService : IEquipmentManualService
/// </returns>
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveEquipmentManualAsync(IFormFile file, int companyId, int equipmentId)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file provided");
if (file.Length > MaxFileSize)
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
if (!isValid)
return (false, string.Empty, error);
// Sanitize filename — replace OS-invalid characters with underscores to
// prevent path traversal and blob naming errors in Azure.
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(fileName))
fileName = "manual";
var fileName = BlobFileHelper.SanitizeFileName(Path.GetFileNameWithoutExtension(file.FileName));
var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
var contentType = GetContentType(extension);
var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.Manuals, blobName, stream, contentType);
@@ -130,19 +121,4 @@ public class EquipmentManualService : IEquipmentManualService
return await _blobService.ExistsAsync(_settings.Containers.Manuals, filePath);
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// Correct MIME types are required so browsers open PDFs inline and
/// Word documents prompt a compatible application rather than a raw download.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
private static string GetContentType(string extension) => extension switch
{
".pdf" => "application/pdf",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt" => "text/plain",
_ => "application/octet-stream"
};
}
@@ -0,0 +1,405 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Services;
public class JobItemAssemblyService : IJobItemAssemblyService
{
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,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = pricing.UnitPrice,
TotalPrice = pricing.TotalPrice,
LaborCost = pricing.TotalPrice * 0.4m,
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);
}
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() ?? [];
}
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);
}
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,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice,
LaborCost = source.TotalPrice * 0.4m,
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);
}
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() ?? [];
}
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);
}
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,
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);
}
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() ?? [];
}
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);
}
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,
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
};
}
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
};
}
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() ?? [];
}
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);
}
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);
}
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 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; }
}
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; }
}
private sealed class JobItemPrepServiceSeed
{
public int PrepServiceId { get; init; }
public int EstimatedMinutes { get; init; }
public int? BlastSetupId { get; init; }
}
}
@@ -69,19 +69,13 @@ public class JobPhotoService : IJobPhotoService
string? caption = null,
JobPhotoType photoType = JobPhotoType.Progress)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file was uploaded.");
if (file.Length > MaxPhotoSize)
return (false, string.Empty, "Photo must be smaller than 10 MB.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
if (!isValid)
return (false, string.Empty, error);
// SECURITY: Use GUID for blob name to prevent enumeration
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
var contentType = GetContentType(extension);
var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType);
@@ -137,19 +131,4 @@ public class JobPhotoService : IJobPhotoService
return await _blobService.ExistsAsync(_settings.Containers.JobImages, filePath);
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
/// allowed extensions are image types and browsers will render them correctly.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string.</returns>
private static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}
@@ -66,22 +66,16 @@ public class ProfilePhotoService : IProfilePhotoService
string userId,
int companyId)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file was uploaded.");
if (file.Length > MaxPhotoSize)
return (false, string.Empty, "Photo must be smaller than 10 MB.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
if (!isValid)
return (false, string.Empty, error);
// Delete old photos for this user with different extensions
await DeleteOldPhotosForUserAsync(companyId, userId, extension);
// Blob path mirrors former filesystem path
var blobName = $"{companyId}/profile-photos/{userId}{extension}";
var contentType = GetContentType(extension);
var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
@@ -172,19 +166,4 @@ public class ProfilePhotoService : IProfilePhotoService
}
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
/// allowed extensions are image types and browsers will render them correctly.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string.</returns>
private static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}
@@ -50,19 +50,13 @@ public class QuotePhotoService : IQuotePhotoService
public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync(
IFormFile file, int companyId)
{
if (file == null || file.Length == 0)
return (false, string.Empty, string.Empty, "No file provided.");
if (file.Length > MaxFileSizeBytes)
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed.");
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
if (!isValid)
return (false, string.Empty, string.Empty, validationError);
var tempId = Guid.NewGuid().ToString("N");
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
var contentType = GetContentType(ext);
var contentType = BlobFileHelper.GetContentType(ext);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
@@ -100,7 +94,7 @@ public class QuotePhotoService : IQuotePhotoService
return (false, string.Empty, "Failed to read temp photo.");
using var ms = new MemoryStream(download.Content);
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, GetContentType(ext));
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, BlobFileHelper.GetContentType(ext));
if (!upload.Success)
return (false, string.Empty, "Failed to save permanent photo.");
@@ -173,12 +167,4 @@ public class QuotePhotoService : IQuotePhotoService
}
}
private static string GetContentType(string ext) => ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}
@@ -0,0 +1,369 @@
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using Microsoft.Extensions.Logging;
namespace PowderCoating.Application.Services;
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IPricingCalculationService _pricingService;
private readonly IInventoryAiLookupService _aiLookupService;
private readonly ILogger<QuotePricingAssemblyService> _logger;
public QuotePricingAssemblyService(
IUnitOfWork unitOfWork,
IPricingCalculationService pricingService,
IInventoryAiLookupService aiLookupService,
ILogger<QuotePricingAssemblyService> logger)
{
_unitOfWork = unitOfWork;
_pricingService = pricingService;
_aiLookupService = aiLookupService;
_logger = logger;
}
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
{
ArgumentNullException.ThrowIfNull(quote);
ArgumentNullException.ThrowIfNull(pricingResult);
quote.MaterialCosts = pricingResult.MaterialCosts;
quote.LaborCosts = pricingResult.LaborCosts;
quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts;
quote.OverheadPercent = pricingResult.OverheadPercent;
quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent;
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
quote.DiscountPercent = pricingResult.DiscountPercent;
quote.DiscountAmount = pricingResult.DiscountAmount;
quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total;
}
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
IEnumerable<CreateQuoteItemDto> itemDtos,
int quoteId,
int companyId,
decimal? ovenRateOverride,
DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(itemDtos);
var items = new List<QuoteItem>();
foreach (var itemDto in itemDtos)
{
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
item.Coats = await BuildQuoteItemCoatsAsync(itemDto, companyId, createdAtUtc);
item.PrepServices = BuildQuoteItemPrepServices(itemDto, companyId, createdAtUtc);
items.Add(item);
}
return items;
}
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
{
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
return;
}
if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
return;
}
if (itemDto.CatalogItemId.HasValue)
{
if (itemDto.Coats != null && itemDto.Coats.Any())
{
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
ApplyCalculatedPricing(item, itemPricing);
return;
}
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
if (catalogItem != null)
{
item.UnitPrice = catalogItem.DefaultPrice;
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
}
return;
}
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
var pricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
ApplyCalculatedPricing(item, pricing);
}
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
return [];
var coats = new List<QuoteItemCoat>();
for (var coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
{
var coatDto = itemDto.Coats[coatIndex];
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
coatDto,
itemDto.SurfaceAreaSqFt,
itemDto.Quantity,
coatIndex,
itemDto.EstimatedMinutes,
companyId);
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
coat.CoatLaborCost = coatPricing.CoatLaborCost;
coat.CoatTotalCost = coatPricing.CoatTotalCost;
coats.Add(coat);
}
return coats;
}
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
return [];
return itemDto.PrepServices
.Select(ps => new QuoteItemPrepService
{
PrepServiceId = ps.PrepServiceId,
EstimatedMinutes = ps.EstimatedMinutes,
BlastSetupId = ps.BlastSetupId,
CompanyId = companyId,
CreatedAt = createdAtUtc
})
.ToList();
}
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
{
return new QuoteItem
{
QuoteId = quoteId,
Description = itemDto.Description,
Quantity = itemDto.Quantity,
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
CatalogItemId = itemDto.CatalogItemId,
IsGenericItem = itemDto.IsGenericItem,
ManualUnitPrice = itemDto.ManualUnitPrice,
PowderCostOverride = itemDto.PowderCostOverride,
IsLaborItem = itemDto.IsLaborItem,
IsSalesItem = itemDto.IsSalesItem,
Sku = itemDto.Sku,
RequiresSandblasting = itemDto.RequiresSandblasting,
RequiresMasking = itemDto.RequiresMasking,
EstimatedMinutes = itemDto.EstimatedMinutes,
IncludePrepCost = itemDto.IncludePrepCost,
Notes = itemDto.Notes,
Complexity = itemDto.Complexity,
IsAiItem = itemDto.IsAiItem,
AiTags = itemDto.AiTags,
AiPredictionId = itemDto.AiPredictionId,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
{
return new QuoteItemCoat
{
CoatName = coatDto.CoatName,
Sequence = coatDto.Sequence,
InventoryItemId = coatDto.InventoryItemId,
ColorName = coatDto.ColorName,
VendorId = coatDto.VendorId,
ColorCode = coatDto.ColorCode,
Finish = coatDto.Finish,
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
TransferEfficiency = coatDto.TransferEfficiency,
PowderCostPerLb = coatDto.PowderCostPerLb,
PowderToOrder = coatDto.PowderToOrder,
Notes = coatDto.Notes,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
{
item.UnitPrice = pricing.UnitPrice;
item.TotalPrice = pricing.TotalPrice;
item.ItemMaterialCost = pricing.MaterialCost;
item.ItemLaborCost = pricing.LaborCost;
item.ItemEquipmentCost = pricing.EquipmentCost;
}
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
{
if (!itemDto.AiPredictionId.HasValue) return;
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
if (prediction == null) return;
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
prediction.UpdatedAt = DateTime.UtcNow;
}
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
{
try
{
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
if (catalogItem == null) return null;
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendorNameLower = catalogItem.VendorName.ToLower();
var matchedVendor = vendors.FirstOrDefault(v =>
v.CompanyName.ToLower().Contains(vendorNameLower) ||
vendorNameLower.Contains(v.CompanyName.ToLower()));
var code = coatingCategory != null
? (coatingCategory.CategoryCode.Length >= 4
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
: "POWD";
var prefix = $"{code}-{DateTime.Now:yyMM}-";
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
var maxSeq = allItems
.Where(i => i.SKU.StartsWith(prefix))
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
.DefaultIfEmpty(0)
.Max();
var sku = $"{prefix}{(maxSeq + 1):D4}";
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
var description = catalogItem.Description;
var finish = catalogItem.Finish;
var colorFamilies = catalogItem.ColorFamilies;
var cureTemp = catalogItem.CureTemperatureF;
var cureTime = catalogItem.CureTimeMinutes;
var coverage = catalogItem.CoverageSqFtPerLb;
var transferEff = catalogItem.TransferEfficiency;
var specificGravity = catalogItem.SpecificGravity;
var imageUrl = catalogItem.ImageUrl;
var sdsUrl = catalogItem.SdsUrl;
var tdsUrl = catalogItem.TdsUrl;
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
(string.IsNullOrWhiteSpace(description) ||
string.IsNullOrWhiteSpace(colorFamilies) ||
cureTemp == null || cureTime == null);
if (needsAugment)
{
try
{
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
if (augmented.Success)
{
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
cureTemp ??= augmented.CureTemperatureF;
cureTime ??= augmented.CureTimeMinutes;
coverage ??= augmented.CoverageSqFtPerLb;
transferEff ??= augmented.TransferEfficiency;
specificGravity ??= augmented.SpecificGravity;
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
}
}
var item = new InventoryItem
{
SKU = sku,
Name = name,
Description = description,
ColorName = catalogItem.ColorName,
Manufacturer = catalogItem.VendorName,
ManufacturerPartNumber = catalogItem.Sku,
Finish = finish,
ColorFamilies = colorFamilies,
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
CoverageSqFtPerLb = coverage ?? 30m,
TransferEfficiency = transferEff ?? 65m,
CureTemperatureF = cureTemp,
CureTimeMinutes = cureTime,
SpecificGravity = specificGravity,
SpecPageUrl = catalogItem.ProductUrl,
ImageUrl = imageUrl,
SdsUrl = sdsUrl,
TdsUrl = tdsUrl,
UnitCost = catalogItem.UnitPrice,
AverageCost = catalogItem.UnitPrice,
LastPurchasePrice = catalogItem.UnitPrice,
QuantityOnHand = 0,
UnitOfMeasure = "lbs",
PrimaryVendorId = matchedVendor?.Id,
InventoryCategoryId = coatingCategory?.Id,
Category = coatingCategory?.DisplayName ?? "Powder Coating",
IsActive = true,
IsIncoming = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
};
await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
coatDto.PowderCostPerLb = null;
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
item.Id, item.Name, coatDto.CatalogItemId);
return item.Id;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
coatDto.CatalogItemId);
return null;
}
}
}