Initial commit
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Azure Blob Storage wrapper that provides a uniform, result-tuple API for blob operations used
|
||||
/// throughout the application (company logos, quote photos, documents). Containers are created
|
||||
/// on first use with private access so blobs are never publicly URL-accessible; all serving goes
|
||||
/// through controller actions that perform authorization checks before streaming bytes.
|
||||
/// All methods catch exceptions and return a failure result rather than propagating so that blob
|
||||
/// storage errors degrade gracefully without crashing the request pipeline.
|
||||
/// </summary>
|
||||
public class AzureBlobStorageService : IAzureBlobStorageService
|
||||
{
|
||||
private readonly BlobServiceClient _blobServiceClient;
|
||||
private readonly ILogger<AzureBlobStorageService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the service using the storage connection string from <see cref="StorageSettings"/>.
|
||||
/// The <see cref="BlobServiceClient"/> is created once and reused across requests because it is
|
||||
/// registered as a singleton in DI, matching the thread-safe design of the Azure SDK client.
|
||||
/// </summary>
|
||||
public AzureBlobStorageService(
|
||||
IOptions<StorageSettings> settings,
|
||||
ILogger<AzureBlobStorageService> logger)
|
||||
{
|
||||
_blobServiceClient = new BlobServiceClient(settings.Value.ConnectionString);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a stream to the specified blob path, setting the Content-Type header so that the
|
||||
/// Azure portal and download responses report the correct MIME type. The container is created
|
||||
/// with private access if it does not already exist, preventing accidental public exposure.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string ErrorMessage)> UploadAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
Stream content,
|
||||
string contentType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
await containerClient.CreateIfNotExistsAsync(PublicAccessType.None);
|
||||
|
||||
var blobClient = containerClient.GetBlobClient(blobName);
|
||||
var uploadOptions = new BlobUploadOptions
|
||||
{
|
||||
HttpHeaders = new BlobHttpHeaders { ContentType = contentType }
|
||||
};
|
||||
|
||||
await blobClient.UploadAsync(content, uploadOptions);
|
||||
|
||||
_logger.LogInformation("Uploaded blob {BlobName} to container {Container}", blobName, containerName);
|
||||
return (true, string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error uploading blob {BlobName} to container {Container}", blobName, containerName);
|
||||
return (false, "An error occurred while uploading the file.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a blob's full content into memory and returns the byte array along with the
|
||||
/// stored Content-Type. Checks blob existence before attempting download to return a clean
|
||||
/// "not found" failure rather than surfacing an Azure SDK exception to the caller.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(
|
||||
string containerName,
|
||||
string blobName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
var blobClient = containerClient.GetBlobClient(blobName);
|
||||
|
||||
if (!await blobClient.ExistsAsync())
|
||||
{
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File not found.");
|
||||
}
|
||||
|
||||
var response = await blobClient.DownloadContentAsync();
|
||||
var content = response.Value.Content.ToArray();
|
||||
var contentType = response.Value.Details.ContentType ?? "application/octet-stream";
|
||||
|
||||
return (true, content, contentType, string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading blob {BlobName} from container {Container}", blobName, containerName);
|
||||
return (false, Array.Empty<byte>(), string.Empty, "An error occurred while retrieving the file.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a blob if it exists, returning success in both the deleted and not-found cases.
|
||||
/// Using <c>DeleteIfExistsAsync</c> makes the operation idempotent so callers do not need to
|
||||
/// check existence before deleting (e.g., during temp photo cleanup after quote promotion).
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string ErrorMessage)> DeleteAsync(
|
||||
string containerName,
|
||||
string blobName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
var blobClient = containerClient.GetBlobClient(blobName);
|
||||
|
||||
var deleted = await blobClient.DeleteIfExistsAsync();
|
||||
|
||||
if (deleted)
|
||||
_logger.LogInformation("Deleted blob {BlobName} from container {Container}", blobName, containerName);
|
||||
|
||||
return (true, string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting blob {BlobName} from container {Container}", blobName, containerName);
|
||||
return (false, "An error occurred while deleting the file.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a blob exists without downloading its content.
|
||||
/// Used by controllers to determine whether to show a file download link or a
|
||||
/// "no file available" placeholder, avoiding a full download just for an existence check.
|
||||
/// Returns <c>false</c> on exception so that storage errors do not surface as unhandled faults.
|
||||
/// </summary>
|
||||
public async Task<bool> ExistsAsync(string containerName, string blobName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
var blobClient = containerClient.GetBlobClient(blobName);
|
||||
var response = await blobClient.ExistsAsync();
|
||||
return response.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking existence of blob {BlobName} in container {Container}", blobName, containerName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all blob names in a container that share the given prefix, used by
|
||||
/// <see cref="QuotePhotoService"/> to enumerate all photos belonging to a temp session or a
|
||||
/// specific quote. Returns an empty enumerable on exception so callers can safely iterate
|
||||
/// without null checks.
|
||||
/// </summary>
|
||||
public async Task<IEnumerable<string>> ListBlobsByPrefixAsync(string containerName, string prefix)
|
||||
{
|
||||
try
|
||||
{
|
||||
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
var names = new List<string>();
|
||||
await foreach (var item in containerClient.GetBlobsAsync(prefix: prefix))
|
||||
names.Add(item.Name);
|
||||
return names;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error listing blobs with prefix {Prefix} in container {Container}", prefix, containerName);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a thin wrapper around <see cref="IMemoryCache"/> that adds thread-safe
|
||||
/// get-or-create semantics and prefix-based bulk invalidation.
|
||||
/// </summary>
|
||||
public interface ICachingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the cached value for <paramref name="key"/> if present; otherwise
|
||||
/// invokes <paramref name="factory"/>, stores the result, and returns it.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the cached value.</typeparam>
|
||||
/// <param name="key">Unique cache key.</param>
|
||||
/// <param name="factory">Async delegate used to produce the value on a cache miss.</param>
|
||||
/// <param name="absoluteExpiration">
|
||||
/// How long the entry lives before being evicted regardless of access.
|
||||
/// Defaults to 15 minutes when <c>null</c>.
|
||||
/// </param>
|
||||
Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan? absoluteExpiration = null);
|
||||
|
||||
/// <summary>Removes a single cache entry by its exact key.</summary>
|
||||
/// <param name="key">The key to remove.</param>
|
||||
void Remove(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all cache entries whose keys begin with <paramref name="prefix"/>.
|
||||
/// This enables coarse-grained invalidation (e.g. all keys for a given company)
|
||||
/// without knowing every individual key in advance.
|
||||
/// </summary>
|
||||
/// <param name="prefix">The key prefix to match.</param>
|
||||
void RemoveByPrefix(string prefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-process memory cache wrapper that enforces a double-checked locking pattern
|
||||
/// to prevent cache stampedes under concurrent load.
|
||||
/// <para>
|
||||
/// Design notes:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// A static <see cref="HashSet{T}"/> tracks every key written so that
|
||||
/// <see cref="RemoveByPrefix"/> can iterate them — <see cref="IMemoryCache"/>
|
||||
/// does not expose its key set natively.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// A static <see cref="SemaphoreSlim"/> (capacity 1) serialises factory
|
||||
/// invocations for the same key, preventing multiple simultaneous DB calls
|
||||
/// for an identical cache miss.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// Both statics are process-wide intentionally: this service is registered as
|
||||
/// Scoped but the underlying <see cref="IMemoryCache"/> is Singleton, so the
|
||||
/// key registry must outlive individual request scopes.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class CachingService : ICachingService
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<CachingService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide set of all keys written to the cache.
|
||||
/// Required because <see cref="IMemoryCache"/> has no built-in key enumeration.
|
||||
/// Static so the registry survives across DI scope lifetimes.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> _cacheKeys = new();
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide semaphore that serialises factory invocations to prevent
|
||||
/// cache stampedes when multiple requests miss the same key simultaneously.
|
||||
/// </summary>
|
||||
private static readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new <see cref="CachingService"/> with the required dependencies.
|
||||
/// </summary>
|
||||
/// <param name="cache">The underlying ASP.NET Core memory cache.</param>
|
||||
/// <param name="logger">Logger for debug-level cache hit/miss diagnostics.</param>
|
||||
public CachingService(IMemoryCache cache, ILogger<CachingService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached value for <paramref name="key"/>; on a miss, acquires the
|
||||
/// semaphore and double-checks before invoking <paramref name="factory"/> to
|
||||
/// prevent multiple simultaneous DB hits for the same key.
|
||||
/// Combined absolute + sliding expiration means infrequently-accessed entries
|
||||
/// are evicted early while hot entries live up to the absolute limit.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the cached value.</typeparam>
|
||||
/// <param name="key">Unique cache key.</param>
|
||||
/// <param name="factory">Async delegate that loads the value from the source of truth.</param>
|
||||
/// <param name="absoluteExpiration">
|
||||
/// Hard upper bound on entry lifetime; defaults to 15 minutes.
|
||||
/// </param>
|
||||
/// <returns>The cached or freshly loaded value.</returns>
|
||||
public async Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan? absoluteExpiration = null)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out T cachedValue))
|
||||
{
|
||||
_logger.LogDebug("Cache hit for key: {Key}", key);
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
await _semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Double-check after acquiring lock
|
||||
if (_cache.TryGetValue(key, out cachedValue))
|
||||
{
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cache miss for key: {Key}. Loading from source...", key);
|
||||
var value = await factory();
|
||||
|
||||
var cacheEntryOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = absoluteExpiration ?? TimeSpan.FromMinutes(15),
|
||||
SlidingExpiration = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
_cache.Set(key, value, cacheEntryOptions);
|
||||
_cacheKeys.Add(key);
|
||||
|
||||
_logger.LogDebug("Cached value for key: {Key}", key);
|
||||
return value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the cache entry with the given <paramref name="key"/> from both the
|
||||
/// underlying <see cref="IMemoryCache"/> and the internal key registry.
|
||||
/// </summary>
|
||||
/// <param name="key">The exact key to remove.</param>
|
||||
public void Remove(string key)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
_cacheKeys.Remove(key);
|
||||
_logger.LogDebug("Removed cache key: {Key}", key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all cache entries whose keys start with <paramref name="prefix"/>.
|
||||
/// Used for coarse-grained invalidation — for example, wiping all lookup data
|
||||
/// for a single company by passing <c>"JobStatus_42"</c> prefix patterns.
|
||||
/// Snapshot the matching keys first to avoid mutating the set while iterating.
|
||||
/// </summary>
|
||||
/// <param name="prefix">Key prefix to match.</param>
|
||||
public void RemoveByPrefix(string prefix)
|
||||
{
|
||||
var keysToRemove = _cacheKeys.Where(k => k.StartsWith(prefix)).ToList();
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
Remove(key);
|
||||
}
|
||||
_logger.LogDebug("Removed {Count} cache keys with prefix: {Prefix}", keysToRemove.Count, prefix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages company logo files stored in Azure Blob Storage.
|
||||
/// Logos are stored in the <c>companylogos</c> container using the path
|
||||
/// <c>{companyId}/company-logo{ext}</c> (e.g. <c>7/company-logo.png</c>).
|
||||
/// The service enforces a 10 MB size limit and restricts uploads to common
|
||||
/// web-safe image formats. It also handles the "replace" case by deleting
|
||||
/// blobs of every other allowed extension before writing the new one, so a
|
||||
/// company can never end up with multiple active logos at different paths.
|
||||
/// </summary>
|
||||
public class CompanyLogoService : ICompanyLogoService
|
||||
{
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
private readonly StorageSettings _settings;
|
||||
|
||||
/// <summary>Maximum logo file size accepted on upload (10 MB).</summary>
|
||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// <summary>
|
||||
/// Exhaustive list of image extensions permitted for company logos.
|
||||
/// SVG is included because some companies upload vector-format brand assets.
|
||||
/// </summary>
|
||||
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the service with the blob storage provider and storage
|
||||
/// configuration (container names, etc.).
|
||||
/// </summary>
|
||||
public CompanyLogoService(
|
||||
IAzureBlobStorageService blobService,
|
||||
IOptions<StorageSettings> settings)
|
||||
{
|
||||
_blobService = blobService;
|
||||
_settings = settings.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the deterministic blob path for a company logo given its file
|
||||
/// extension. Using a fixed name (rather than a GUID) allows the path to
|
||||
/// be derived from <c>companyId</c> alone — useful for PDF embedding and
|
||||
/// sidebar rendering without an extra DB lookup.
|
||||
/// </summary>
|
||||
/// <param name="companyId">The tenant company's database ID.</param>
|
||||
/// <param name="extension">File extension including the leading dot (e.g. <c>.png</c>).</param>
|
||||
/// <returns>Blob-relative path such as <c>7/company-logo.png</c>.</returns>
|
||||
public string GetCompanyLogoPath(int companyId, string extension)
|
||||
{
|
||||
return $"{companyId}/company-logo{extension}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and uploads a new company logo, replacing any existing logo
|
||||
/// regardless of its extension. Old blobs at alternative extensions are
|
||||
/// deleted first so the container never holds stale copies.
|
||||
/// </summary>
|
||||
/// <param name="file">The uploaded file from the HTTP request.</param>
|
||||
/// <param name="companyId">The tenant company's database ID.</param>
|
||||
/// <returns>
|
||||
/// A tuple indicating success, the stored blob path (on success), and a
|
||||
/// human-readable error message (on failure).
|
||||
/// </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)}");
|
||||
|
||||
// Delete old logo (any extension) before saving new one
|
||||
await DeleteOldLogosAsync(companyId, extension);
|
||||
|
||||
var blobName = GetCompanyLogoPath(companyId, extension);
|
||||
var contentType = GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType);
|
||||
|
||||
if (!result.Success)
|
||||
return (false, string.Empty, result.ErrorMessage);
|
||||
|
||||
return (true, blobName, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the company logo blob at the given path from Azure Blob Storage.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path previously returned by <see cref="SaveCompanyLogoAsync"/>.</param>
|
||||
/// <returns>Success flag and an error message on failure.</returns>
|
||||
public async Task<(bool Success, string ErrorMessage)> DeleteCompanyLogoAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return (false, "File path is empty");
|
||||
|
||||
return await _blobService.DeleteAsync(_settings.Containers.CompanyLogos, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the raw bytes of the company logo for embedding in PDFs or
|
||||
/// serving through the <c>/CompanyLogo</c> controller endpoint.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path of the logo.</param>
|
||||
/// <returns>
|
||||
/// A tuple with a success flag, the raw file bytes, the MIME content type,
|
||||
/// and an error message on failure.
|
||||
/// </returns>
|
||||
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetCompanyLogoAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File path is empty");
|
||||
|
||||
return await _blobService.DownloadAsync(_settings.Containers.CompanyLogos, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a logo blob exists at the given path without downloading it.
|
||||
/// Used by the sidebar to decide whether to show the tenant logo or fall back
|
||||
/// to the default PCL logo.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path to check.</param>
|
||||
/// <returns><c>true</c> if the blob exists; otherwise <c>false</c>.</returns>
|
||||
public async Task<bool> CompanyLogoExistsAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return false;
|
||||
|
||||
return await _blobService.ExistsAsync(_settings.Containers.CompanyLogos, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes any pre-existing logo blobs at extensions other than
|
||||
/// <paramref name="currentExtension"/> so that uploading a PNG logo
|
||||
/// automatically removes a previously stored JPG logo (and vice versa).
|
||||
/// Errors are intentionally swallowed — a missing old blob is not a failure.
|
||||
/// </summary>
|
||||
/// <param name="companyId">The tenant company's database ID.</param>
|
||||
/// <param name="currentExtension">
|
||||
/// The extension of the incoming upload; blobs with this extension are
|
||||
/// skipped because they will be overwritten by the caller.
|
||||
/// </param>
|
||||
private async Task DeleteOldLogosAsync(int companyId, string currentExtension)
|
||||
{
|
||||
foreach (var ext in AllowedExtensions)
|
||||
{
|
||||
if (ext == currentExtension) continue;
|
||||
var oldBlobName = GetCompanyLogoPath(companyId, ext);
|
||||
await _blobService.DeleteAsync(_settings.Containers.CompanyLogos, oldBlobName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages equipment manual documents stored in Azure Blob Storage.
|
||||
/// Manuals are stored in the <c>manuals</c> container under the path
|
||||
/// <c>{companyId}/equipment-manuals/{equipmentId}/{sanitizedFilename}{ext}</c>.
|
||||
/// <para>
|
||||
/// The 50 MB limit (5× larger than other upload types) is intentional —
|
||||
/// equipment OEM manuals are often large PDF scans. Only document formats
|
||||
/// are accepted; image uploads are rejected to keep this container clean.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class EquipmentManualService : IEquipmentManualService
|
||||
{
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
private readonly StorageSettings _settings;
|
||||
|
||||
/// <summary>Maximum manual file size accepted on upload (50 MB).</summary>
|
||||
private const long MaxFileSize = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
/// <summary>
|
||||
/// Document formats permitted for equipment manuals.
|
||||
/// Images and spreadsheets are deliberately excluded to keep
|
||||
/// the container purpose-specific.
|
||||
/// </summary>
|
||||
private static readonly string[] AllowedExtensions = [".pdf", ".doc", ".docx", ".txt"];
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the service with the blob storage provider and storage
|
||||
/// configuration (container names, etc.).
|
||||
/// </summary>
|
||||
public EquipmentManualService(
|
||||
IAzureBlobStorageService blobService,
|
||||
IOptions<StorageSettings> settings)
|
||||
{
|
||||
_blobService = blobService;
|
||||
_settings = settings.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates, sanitizes, and uploads an equipment manual to Azure Blob Storage.
|
||||
/// The original filename (minus invalid characters) is preserved in the blob
|
||||
/// name so operators can recognise the document from the path alone.
|
||||
/// </summary>
|
||||
/// <param name="file">The uploaded file from the HTTP request.</param>
|
||||
/// <param name="companyId">The tenant company's database ID (for path scoping).</param>
|
||||
/// <param name="equipmentId">The equipment record's database ID.</param>
|
||||
/// <returns>
|
||||
/// A tuple with a success flag, the stored blob path (on success), and a
|
||||
/// human-readable error message (on failure).
|
||||
/// </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)}");
|
||||
|
||||
// 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 blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.Manuals, blobName, stream, contentType);
|
||||
|
||||
if (!result.Success)
|
||||
return (false, string.Empty, result.ErrorMessage);
|
||||
|
||||
return (true, blobName, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the equipment manual blob at the given path from Azure Blob Storage.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path previously returned by <see cref="SaveEquipmentManualAsync"/>.</param>
|
||||
/// <returns>Success flag and an error message on failure.</returns>
|
||||
public async Task<(bool Success, string ErrorMessage)> DeleteEquipmentManualAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return (false, "File path is empty");
|
||||
|
||||
return await _blobService.DeleteAsync(_settings.Containers.Manuals, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the raw bytes of an equipment manual so the controller can
|
||||
/// stream it to the browser with the appropriate content-disposition header.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path of the manual.</param>
|
||||
/// <returns>
|
||||
/// A tuple with a success flag, the raw file bytes, the MIME content type,
|
||||
/// and an error message on failure.
|
||||
/// </returns>
|
||||
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetEquipmentManualAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File path is empty");
|
||||
|
||||
return await _blobService.DownloadAsync(_settings.Containers.Manuals, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a manual blob exists at the given path without downloading it.
|
||||
/// Used by the Equipment Details view to determine whether a "Download Manual"
|
||||
/// button should be rendered.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path to check.</param>
|
||||
/// <returns><c>true</c> if the blob exists; otherwise <c>false</c>.</returns>
|
||||
public async Task<bool> EquipmentManualExistsAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return false;
|
||||
|
||||
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,215 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// On-premises file storage service that saves, retrieves, and deletes files under the
|
||||
/// application's <c>wwwroot/uploads/</c> directory. This service is the legacy storage path for
|
||||
/// self-hosted deployments; cloud-hosted tenants use <see cref="AzureBlobStorageService"/> instead.
|
||||
/// All file names are prefixed with a new GUID to prevent collisions and block path traversal
|
||||
/// attacks that embed directory separators in the original file name.
|
||||
/// </summary>
|
||||
public class FileService : IFileService
|
||||
{
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<FileService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the service with the hosting environment (provides <c>WebRootPath</c>) and logger.
|
||||
/// </summary>
|
||||
public FileService(IWebHostEnvironment environment, ILogger<FileService> logger)
|
||||
{
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and saves an uploaded file to the specified subfolder under <c>wwwroot/uploads/</c>.
|
||||
/// Validation order: null/empty check, size limit, then extension allowlist. The original file
|
||||
/// name is sanitised with <see cref="Path.GetFileName"/> to strip any directory components before
|
||||
/// prepending the GUID prefix, preventing path traversal if the browser supplies a name with
|
||||
/// slashes. Returns a relative path (from <c>wwwroot</c>) suitable for storing in the database.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveFileAsync(
|
||||
IFormFile file,
|
||||
string subfolder,
|
||||
string[] allowedExtensions,
|
||||
long maxFileSize)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate file
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return (false, string.Empty, "No file was uploaded.");
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.Length > maxFileSize)
|
||||
{
|
||||
var maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
return (false, string.Empty, $"File size exceeds the maximum allowed size of {maxSizeMB} MB.");
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension))
|
||||
{
|
||||
var allowedExts = string.Join(", ", allowedExtensions);
|
||||
return (false, string.Empty, $"Invalid file type. Allowed types: {allowedExts}");
|
||||
}
|
||||
|
||||
// Create upload directory if it doesn't exist
|
||||
// NOTE: WebRootPath is read-only on Azure Linux App Service; this service is legacy
|
||||
// and should only be called for on-premises deployments. New uploads use Azure Blob.
|
||||
var uploadPath = Path.Combine(_environment.WebRootPath, "uploads", subfolder);
|
||||
if (!Directory.Exists(uploadPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(uploadPath);
|
||||
_logger.LogInformation("Created upload directory: {UploadPath}", uploadPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not create upload directory {UploadPath} — filesystem may be read-only", uploadPath);
|
||||
return (false, string.Empty, "File storage is not available in this environment.");
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique filename — strip directory components from the original name
|
||||
// to prevent path traversal if the browser sends a filename with slashes.
|
||||
var safeOriginalName = Path.GetFileName(file.FileName);
|
||||
var uniqueFileName = $"{Guid.NewGuid()}-{safeOriginalName}";
|
||||
var filePath = Path.Combine(uploadPath, uniqueFileName);
|
||||
|
||||
// Save the file
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
// Return relative path from wwwroot
|
||||
var relativePath = Path.Combine("uploads", subfolder, uniqueFileName).Replace("\\", "/");
|
||||
|
||||
_logger.LogInformation("File saved successfully: {FilePath}", relativePath);
|
||||
return (true, relativePath, string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving file: {FileName}", file?.FileName);
|
||||
return (false, string.Empty, "An error occurred while saving the file.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a file given its relative path from <c>wwwroot</c>.
|
||||
/// Returns success if the file does not exist (idempotent) so that callers do not need to check
|
||||
/// existence before calling. The relative path is converted to an absolute path with
|
||||
/// <see cref="Path.Combine"/> rather than string concatenation to prevent directory traversal.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string ErrorMessage)> DeleteFileAsync(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return (false, "File path is required.");
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogWarning("File not found for deletion: {FilePath}", filePath);
|
||||
return (true, string.Empty); // Consider it successful if file doesn't exist
|
||||
}
|
||||
|
||||
await Task.Run(() => File.Delete(fullPath));
|
||||
|
||||
_logger.LogInformation("File deleted successfully: {FilePath}", filePath);
|
||||
return (true, string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting file: {FilePath}", filePath);
|
||||
return (false, "An error occurred while deleting the file.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a file from disk and returns its raw bytes along with a derived MIME content type.
|
||||
/// Intended for serving files that are stored outside <c>wwwroot</c> (or otherwise not directly
|
||||
/// accessible via the static-files middleware) so controllers can stream them as file responses.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetFileAsync(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogWarning("File not found: {FilePath}", filePath);
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File not found.");
|
||||
}
|
||||
|
||||
var fileBytes = await File.ReadAllBytesAsync(fullPath);
|
||||
var contentType = GetContentType(filePath);
|
||||
|
||||
return (true, fileBytes, contentType, string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving file: {FilePath}", filePath);
|
||||
return (false, Array.Empty<byte>(), string.Empty, "An error occurred while retrieving the file.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a file exists at the given <c>wwwroot</c>-relative path without reading it.
|
||||
/// Used by views and controllers to conditionally show download links only when the file is present.
|
||||
/// </summary>
|
||||
public bool FileExists(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
return File.Exists(fullPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a file name or path to its MIME content type based on the extension.
|
||||
/// Falls back to <c>application/octet-stream</c> for unrecognised extensions so the browser
|
||||
/// triggers a download rather than attempting to render an unknown format inline.
|
||||
/// </summary>
|
||||
public string GetContentType(string fileName)
|
||||
{
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
|
||||
return extension switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls" => "application/vnd.ms-excel",
|
||||
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages job progress/completion photos stored in Azure Blob Storage.
|
||||
/// Photos are stored in the <c>jobimages</c> container under the path
|
||||
/// <c>{companyId}/job-photos/{jobId}/{guid}{ext}</c>.
|
||||
/// <para>
|
||||
/// A random GUID is used in the blob name (instead of a sequential ID or the
|
||||
/// original filename) to prevent direct enumeration of photos across jobs by
|
||||
/// guessing predictable URLs — a common IDOR (Insecure Direct Object Reference)
|
||||
/// vulnerability pattern.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class JobPhotoService : IJobPhotoService
|
||||
{
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
private readonly StorageSettings _settings;
|
||||
private readonly ILogger<JobPhotoService> _logger;
|
||||
|
||||
/// <summary>Image extensions accepted for job photos.</summary>
|
||||
private static readonly string[] AllowedImageTypes = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
||||
|
||||
/// <summary>Maximum photo size accepted on upload (10 MB).</summary>
|
||||
private const long MaxPhotoSize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the service with the blob storage provider, storage
|
||||
/// configuration, and a logger for upload audit messages.
|
||||
/// </summary>
|
||||
public JobPhotoService(
|
||||
IAzureBlobStorageService blobService,
|
||||
IOptions<StorageSettings> settings,
|
||||
ILogger<JobPhotoService> logger)
|
||||
{
|
||||
_blobService = blobService;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and uploads a job photo to Azure Blob Storage.
|
||||
/// A GUID is generated for the blob name to prevent IDOR enumeration attacks —
|
||||
/// without it, an attacker could iterate <c>/job-photos/42/1.jpg</c>,
|
||||
/// <c>/job-photos/42/2.jpg</c>, etc. to access another company's photos.
|
||||
/// The <paramref name="caption"/> and <paramref name="photoType"/> parameters
|
||||
/// are accepted for API symmetry but are persisted by the caller (controller)
|
||||
/// in the <c>JobPhoto</c> entity, not by this service.
|
||||
/// </summary>
|
||||
/// <param name="file">The uploaded image file from the HTTP request.</param>
|
||||
/// <param name="jobId">The job record's database ID.</param>
|
||||
/// <param name="companyId">The tenant company's database ID (for path scoping).</param>
|
||||
/// <param name="caption">Optional display caption stored by the caller.</param>
|
||||
/// <param name="photoType">Photo classification (Before, After, Progress, etc.).</param>
|
||||
/// <returns>
|
||||
/// A tuple with a success flag, the stored blob path (on success), and a
|
||||
/// human-readable error message (on failure).
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveJobPhotoAsync(
|
||||
IFormFile file,
|
||||
int jobId,
|
||||
int companyId,
|
||||
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.");
|
||||
|
||||
// SECURITY: Use GUID for blob name to prevent enumeration
|
||||
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType);
|
||||
|
||||
if (!result.Success)
|
||||
return (false, string.Empty, result.ErrorMessage);
|
||||
|
||||
_logger.LogInformation("Job photo saved: {BlobName} for job {JobId}", blobName, jobId);
|
||||
return (true, blobName, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the job photo blob at the given path from Azure Blob Storage.
|
||||
/// Called when a user removes a photo from the Job Details view.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path previously returned by <see cref="SaveJobPhotoAsync"/>.</param>
|
||||
/// <returns>Success flag and an error message on failure.</returns>
|
||||
public async Task<(bool Success, string ErrorMessage)> DeleteJobPhotoAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return (false, "File path is required.");
|
||||
|
||||
return await _blobService.DeleteAsync(_settings.Containers.JobImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the raw bytes of a job photo for serving through the controller's
|
||||
/// photo-proxy endpoint (which enforces tenant authorization before streaming).
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path of the photo.</param>
|
||||
/// <returns>
|
||||
/// A tuple with a success flag, the raw file bytes, the MIME content type,
|
||||
/// and an error message on failure.
|
||||
/// </returns>
|
||||
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetJobPhotoAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
|
||||
|
||||
return await _blobService.DownloadAsync(_settings.Containers.JobImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a photo blob exists without downloading its content.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path to check.</param>
|
||||
/// <returns><c>true</c> if the blob exists; otherwise <c>false</c>.</returns>
|
||||
public async Task<bool> JobPhotoExistsAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return false;
|
||||
|
||||
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"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides cached access to application lookup tables (job statuses,
|
||||
/// job priorities, quote statuses, appointment statuses/types) to avoid
|
||||
/// repeated database round-trips on every page load.
|
||||
/// <para>
|
||||
/// Lookup data is essentially static for normal operation — statuses and
|
||||
/// priorities are only changed by SuperAdmins during platform configuration.
|
||||
/// A 1-hour TTL is therefore appropriate, with <see cref="InvalidateCompanyCache"/>
|
||||
/// available for immediate invalidation after any admin change.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ILookupCacheService
|
||||
{
|
||||
/// <summary>Returns all job status lookup rows, from cache if available.</summary>
|
||||
/// <param name="companyId">Tenant company ID used to scope the cache key.</param>
|
||||
Task<IEnumerable<JobStatusLookup>> GetJobStatusLookupsAsync(int companyId);
|
||||
|
||||
/// <summary>Returns all job priority lookup rows, from cache if available.</summary>
|
||||
/// <param name="companyId">Tenant company ID used to scope the cache key.</param>
|
||||
Task<IEnumerable<JobPriorityLookup>> GetJobPriorityLookupsAsync(int companyId);
|
||||
|
||||
/// <summary>Returns all quote status lookup rows, from cache if available.</summary>
|
||||
/// <param name="companyId">Tenant company ID used to scope the cache key.</param>
|
||||
Task<IEnumerable<QuoteStatusLookup>> GetQuoteStatusLookupsAsync(int companyId);
|
||||
|
||||
/// <summary>Returns all appointment status lookup rows, from cache if available.</summary>
|
||||
/// <param name="companyId">Tenant company ID used to scope the cache key.</param>
|
||||
Task<IEnumerable<AppointmentStatusLookup>> GetAppointmentStatusLookupsAsync(int companyId);
|
||||
|
||||
/// <summary>Returns all appointment type lookup rows, from cache if available.</summary>
|
||||
/// <param name="companyId">Tenant company ID used to scope the cache key.</param>
|
||||
Task<IEnumerable<AppointmentTypeLookup>> GetAppointmentTypeLookupsAsync(int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all lookup cache entries for the given company.
|
||||
/// Call this after any SuperAdmin operation that modifies lookup tables so
|
||||
/// subsequent requests reload fresh data from the database.
|
||||
/// </summary>
|
||||
/// <param name="companyId">The company whose cached lookups should be purged.</param>
|
||||
void InvalidateCompanyCache(int companyId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="ILookupCacheService"/> that delegates to
|
||||
/// <see cref="ICachingService"/> for thread-safe get-or-create semantics and
|
||||
/// to <see cref="IUnitOfWork"/> for database access.
|
||||
/// <para>
|
||||
/// Cache keys are scoped per company (e.g. <c>JobStatus_42</c>) even though
|
||||
/// lookup tables are currently platform-wide. The per-company scoping is
|
||||
/// forward-compatible with future per-tenant lookup customisation and allows
|
||||
/// <see cref="InvalidateCompanyCache"/> to operate at company granularity
|
||||
/// without flushing other tenants' cached data.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class LookupCacheService : ILookupCacheService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ICachingService _cachingService;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the service with the unit-of-work (for DB access on misses)
|
||||
/// and the caching service (for in-memory storage).
|
||||
/// </summary>
|
||||
public LookupCacheService(IUnitOfWork unitOfWork, ICachingService cachingService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_cachingService = cachingService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all job status rows. Cached for 1 hour because statuses change
|
||||
/// only when a platform admin updates the lookup table configuration.
|
||||
/// </summary>
|
||||
/// <param name="companyId">Tenant company ID — scopes the cache key.</param>
|
||||
public async Task<IEnumerable<JobStatusLookup>> GetJobStatusLookupsAsync(int companyId)
|
||||
{
|
||||
var cacheKey = $"JobStatus_{companyId}";
|
||||
return await _cachingService.GetOrCreateAsync(
|
||||
cacheKey,
|
||||
async () => await _unitOfWork.JobStatusLookups.GetAllAsync(),
|
||||
TimeSpan.FromHours(1) // Lookup data rarely changes
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all job priority rows. Cached for 1 hour for the same reason as
|
||||
/// <see cref="GetJobStatusLookupsAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="companyId">Tenant company ID — scopes the cache key.</param>
|
||||
public async Task<IEnumerable<JobPriorityLookup>> GetJobPriorityLookupsAsync(int companyId)
|
||||
{
|
||||
var cacheKey = $"JobPriority_{companyId}";
|
||||
return await _cachingService.GetOrCreateAsync(
|
||||
cacheKey,
|
||||
async () => await _unitOfWork.JobPriorityLookups.GetAllAsync(),
|
||||
TimeSpan.FromHours(1)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all quote status rows. Cached for 1 hour.
|
||||
/// </summary>
|
||||
/// <param name="companyId">Tenant company ID — scopes the cache key.</param>
|
||||
public async Task<IEnumerable<QuoteStatusLookup>> GetQuoteStatusLookupsAsync(int companyId)
|
||||
{
|
||||
var cacheKey = $"QuoteStatus_{companyId}";
|
||||
return await _cachingService.GetOrCreateAsync(
|
||||
cacheKey,
|
||||
async () => await _unitOfWork.QuoteStatusLookups.GetAllAsync(),
|
||||
TimeSpan.FromHours(1)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all appointment status rows. Cached for 1 hour.
|
||||
/// </summary>
|
||||
/// <param name="companyId">Tenant company ID — scopes the cache key.</param>
|
||||
public async Task<IEnumerable<AppointmentStatusLookup>> GetAppointmentStatusLookupsAsync(int companyId)
|
||||
{
|
||||
var cacheKey = $"AppointmentStatus_{companyId}";
|
||||
return await _cachingService.GetOrCreateAsync(
|
||||
cacheKey,
|
||||
async () => await _unitOfWork.AppointmentStatusLookups.GetAllAsync(),
|
||||
TimeSpan.FromHours(1)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all appointment type rows. Cached for 1 hour.
|
||||
/// </summary>
|
||||
/// <param name="companyId">Tenant company ID — scopes the cache key.</param>
|
||||
public async Task<IEnumerable<AppointmentTypeLookup>> GetAppointmentTypeLookupsAsync(int companyId)
|
||||
{
|
||||
var cacheKey = $"AppointmentType_{companyId}";
|
||||
return await _cachingService.GetOrCreateAsync(
|
||||
cacheKey,
|
||||
async () => await _unitOfWork.AppointmentTypeLookups.GetAllAsync(),
|
||||
TimeSpan.FromHours(1)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purges all five lookup cache entries for <paramref name="companyId"/>.
|
||||
/// Each key is removed individually (rather than by prefix) because the
|
||||
/// key names are known statically, making this more explicit and efficient
|
||||
/// than a prefix scan of the key registry.
|
||||
/// </summary>
|
||||
/// <param name="companyId">The company whose cached lookups should be purged.</param>
|
||||
public void InvalidateCompanyCache(int companyId)
|
||||
{
|
||||
_cachingService.Remove($"JobStatus_{companyId}");
|
||||
_cachingService.Remove($"JobPriority_{companyId}");
|
||||
_cachingService.Remove($"QuoteStatus_{companyId}");
|
||||
_cachingService.Remove($"AppointmentStatus_{companyId}");
|
||||
_cachingService.Remove($"AppointmentType_{companyId}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts measurement values and produces display labels for the two unit systems the pricing
|
||||
/// engine supports: Imperial (sq ft, lb) and Metric (sq m, kg). Centralised here so that every
|
||||
/// caller — the pricing service, quote wizard, and reports — uses identical conversion factors
|
||||
/// and rounding instead of duplicating them.
|
||||
/// </summary>
|
||||
public interface IMeasurementConversionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts square feet to square meters using the exact SI factor (1 sq ft = 0.09290304 sq m).
|
||||
/// Result is rounded to 2 decimal places for display and database storage.
|
||||
/// </summary>
|
||||
decimal SquareFeetToMeters(decimal squareFeet);
|
||||
|
||||
/// <summary>
|
||||
/// Converts square meters back to square feet by dividing by the same SI factor.
|
||||
/// Inverse of <see cref="SquareFeetToMeters"/>; rounding to 2 decimals is applied consistently.
|
||||
/// </summary>
|
||||
decimal SquareMetersToFeet(decimal squareMeters);
|
||||
|
||||
/// <summary>
|
||||
/// Converts pounds to kilograms using the exact avoirdupois factor (1 lb = 0.45359237 kg).
|
||||
/// Used by the pricing engine when computing powder weight in metric mode.
|
||||
/// </summary>
|
||||
decimal PoundsToKilograms(decimal pounds);
|
||||
|
||||
/// <summary>
|
||||
/// Converts kilograms to pounds by dividing by the avoirdupois factor.
|
||||
/// Inverse of <see cref="PoundsToKilograms"/>; used when displaying metric-entered weights
|
||||
/// in Imperial mode.
|
||||
/// </summary>
|
||||
decimal KilogramsToPounds(decimal kilograms);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the localised area unit label ("sq ft" or "sq m") for use in UI display strings
|
||||
/// and report headers so that callers do not hard-code unit strings.
|
||||
/// </summary>
|
||||
string GetAreaUnitLabel(bool useMetric);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the localised weight unit label ("lb" or "kg") for UI display strings and reports.
|
||||
/// </summary>
|
||||
string GetWeightUnitLabel(bool useMetric);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the localised powder coverage unit label ("sq ft/lb" or "sq m/kg").
|
||||
/// Coverage rate is the key input for powder quantity calculations.
|
||||
/// </summary>
|
||||
string GetCoverageUnitLabel(bool useMetric);
|
||||
|
||||
/// <summary>
|
||||
/// Converts an area value between unit systems based on the source and target preferences.
|
||||
/// When <paramref name="fromImperial"/> and <paramref name="toMetric"/> are the same system,
|
||||
/// the value is returned unchanged to avoid double-conversion bugs.
|
||||
/// </summary>
|
||||
decimal ConvertArea(decimal value, bool fromImperial, bool toMetric);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a weight value between unit systems based on the source and target preferences.
|
||||
/// Returns the value unchanged when both parameters indicate the same system.
|
||||
/// </summary>
|
||||
decimal ConvertWeight(decimal value, bool fromImperial, bool toMetric);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concrete implementation of <see cref="IMeasurementConversionService"/> using SI-exact conversion
|
||||
/// constants. All results are rounded to 2 decimal places to match the precision stored in the
|
||||
/// database and shown in the UI, preventing floating-point drift across conversion round-trips.
|
||||
/// </summary>
|
||||
public class MeasurementConversionService : IMeasurementConversionService
|
||||
{
|
||||
// Conversion constants
|
||||
private const decimal SQ_FT_TO_SQ_M = 0.09290304m; // 1 sq ft = 0.09290304 sq m
|
||||
private const decimal LB_TO_KG = 0.45359237m; // 1 lb = 0.45359237 kg
|
||||
|
||||
/// <summary>
|
||||
/// Converts square feet to square meters. Rounds to 2 decimal places for storage consistency.
|
||||
/// </summary>
|
||||
public decimal SquareFeetToMeters(decimal squareFeet)
|
||||
{
|
||||
return Math.Round(squareFeet * SQ_FT_TO_SQ_M, 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts square meters to square feet by dividing by the sq-ft-to-sq-m factor.
|
||||
/// Rounds to 2 decimal places.
|
||||
/// </summary>
|
||||
public decimal SquareMetersToFeet(decimal squareMeters)
|
||||
{
|
||||
return Math.Round(squareMeters / SQ_FT_TO_SQ_M, 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts pounds to kilograms. Rounds to 2 decimal places.
|
||||
/// </summary>
|
||||
public decimal PoundsToKilograms(decimal pounds)
|
||||
{
|
||||
return Math.Round(pounds * LB_TO_KG, 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts kilograms to pounds by dividing by the lb-to-kg factor.
|
||||
/// Rounds to 2 decimal places.
|
||||
/// </summary>
|
||||
public decimal KilogramsToPounds(decimal kilograms)
|
||||
{
|
||||
return Math.Round(kilograms / LB_TO_KG, 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns "sq m" for metric mode, "sq ft" for imperial mode.
|
||||
/// </summary>
|
||||
public string GetAreaUnitLabel(bool useMetric)
|
||||
{
|
||||
return useMetric ? "sq m" : "sq ft";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns "kg" for metric mode, "lb" for imperial mode.
|
||||
/// </summary>
|
||||
public string GetWeightUnitLabel(bool useMetric)
|
||||
{
|
||||
return useMetric ? "kg" : "lb";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns "sq m/kg" for metric mode, "sq ft/lb" for imperial mode.
|
||||
/// </summary>
|
||||
public string GetCoverageUnitLabel(bool useMetric)
|
||||
{
|
||||
return useMetric ? "sq m/kg" : "sq ft/lb";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an area value from one unit system to another.
|
||||
/// The parameter names use <c>fromImperial</c> and <c>toMetric</c> rather than a single enum
|
||||
/// so that callers can independently specify the source data format and the desired output format.
|
||||
/// No conversion is applied when source and target are the same system.
|
||||
/// </summary>
|
||||
public decimal ConvertArea(decimal value, bool fromImperial, bool toMetric)
|
||||
{
|
||||
// If converting from imperial to metric
|
||||
if (fromImperial && toMetric)
|
||||
return SquareFeetToMeters(value);
|
||||
|
||||
// If converting from metric to imperial
|
||||
if (!fromImperial && !toMetric)
|
||||
return SquareMetersToFeet(value);
|
||||
|
||||
// No conversion needed (same system)
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a weight value from one unit system to another.
|
||||
/// No conversion is applied when source and target are the same system.
|
||||
/// </summary>
|
||||
public decimal ConvertWeight(decimal value, bool fromImperial, bool toMetric)
|
||||
{
|
||||
// If converting from imperial to metric
|
||||
if (fromImperial && toMetric)
|
||||
return PoundsToKilograms(value);
|
||||
|
||||
// If converting from metric to imperial
|
||||
if (!fromImperial && !toMetric)
|
||||
return KilogramsToPounds(value);
|
||||
|
||||
// No conversion needed (same system)
|
||||
return value;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,814 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Core pricing engine for quotes and jobs. All dollar amounts produced here are stored as
|
||||
/// snapshots on the Quote/Job rows at save time — they are NOT recalculated on every page load.
|
||||
/// This means a quote's displayed total is always exactly what was presented to the customer,
|
||||
/// even if operating costs change after the quote was created.
|
||||
///
|
||||
/// Pricing flow (high-level):
|
||||
/// 1. Per-coat: material cost + labor cost → <see cref="CalculateCoatPriceAsync"/>
|
||||
/// 2. Per-item: base + additional coats + complexity → <see cref="CalculateQuoteItemPriceAsync"/>
|
||||
/// 3. Quote total: items + oven batch + shop supplies + discounts + rush + tax → <see cref="CalculateQuoteTotalsAsync"/>
|
||||
///
|
||||
/// Key design decisions documented inline:
|
||||
/// - Custom powder charges the full ordered quantity; in-stock charges calculated usage only.
|
||||
/// - Markup is baked into item prices, NOT added as a separate line at the quote level.
|
||||
/// - Overhead was removed as a separate charge — it is now absorbed into the markup %.
|
||||
/// - AI items bypass the pricing engine entirely (ManualUnitPrice used as-is).
|
||||
/// - Oven cost is a quote-level batch charge, not per-item; scaled for AI item fraction.
|
||||
/// </summary>
|
||||
public class PricingCalculationService : IPricingCalculationService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<PricingCalculationService> _logger;
|
||||
private readonly IMeasurementConversionService _measurementService;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
// Constants for additional cost calculations
|
||||
private const decimal ConsumablesSurchargePercent = 0.05m; // 5% of material costs
|
||||
|
||||
// Sandblasting and masking multipliers removed - labor is the same for all tasks
|
||||
// private const decimal SandblastingLaborMultiplier = 1.5m; // 1.5x base labor
|
||||
// private const decimal MaskingLaborMultiplier = 0.5m; // 0.5x base labor
|
||||
// private const decimal SandblastingTimePercent = 0.3m; // 30% of estimated time
|
||||
|
||||
public PricingCalculationService(
|
||||
IUnitOfWork unitOfWork,
|
||||
ILogger<PricingCalculationService> logger,
|
||||
IMeasurementConversionService measurementService,
|
||||
ITenantContext tenantContext)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_measurementService = measurementService;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the operating costs row for a company. Returns null (and logs a warning) if none
|
||||
/// is configured — callers fall back to zero pricing rather than throwing, so a misconfigured
|
||||
/// company shows $0 quotes rather than crashing the wizard.
|
||||
/// </summary>
|
||||
public async Task<CompanyOperatingCosts?> GetOperatingCostsAsync(int companyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var costs = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId);
|
||||
return costs.FirstOrDefault();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving operating costs for company {CompanyId}", companyId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the material and labor cost for a single coat on a single quote item.
|
||||
///
|
||||
/// Material cost rules:
|
||||
/// - Custom powder (no InventoryItemId, manual PowderCostPerLb): charges for the full
|
||||
/// PowderToOrder quantity, not just calculated usage. The shop must purchase the whole
|
||||
/// bag/order for this job, so the customer pays for all of it.
|
||||
/// - In-stock powder (InventoryItemId set): charges calculated usage only
|
||||
/// (surface area × lbs/sqft ÷ transfer efficiency × unit cost).
|
||||
/// - No surface area, no PowderToOrder → $0 material cost.
|
||||
///
|
||||
/// Labor cost rules:
|
||||
/// - First coat (coatIndex 0): 100% of EstimatedMinutes × StandardLaborRate.
|
||||
/// - Additional coats: AdditionalCoatLaborPercent % of base minutes.
|
||||
/// - NoExtraLayerCharge flag → 0% multiplier for that coat (used for clear coats, etc.).
|
||||
///
|
||||
/// Surface area is converted from m² to sqft when the tenant uses metric, so all internal
|
||||
/// math stays in imperial regardless of the UI unit setting.
|
||||
/// </summary>
|
||||
public async Task<QuoteItemCoatPricingResult> CalculateCoatPriceAsync(
|
||||
CreateQuoteItemCoatDto coat,
|
||||
decimal itemSurfaceAreaSqFt,
|
||||
decimal quantity,
|
||||
int coatIndex,
|
||||
int estimatedMinutesBase,
|
||||
int companyId)
|
||||
{
|
||||
var costs = await GetOperatingCostsAsync(companyId);
|
||||
if (costs == null)
|
||||
{
|
||||
_logger.LogWarning("No operating costs configured for company {CompanyId}, using default values", companyId);
|
||||
return new QuoteItemCoatPricingResult
|
||||
{
|
||||
CoatMaterialCost = 0,
|
||||
CoatLaborCost = 0,
|
||||
CoatTotalCost = 0
|
||||
};
|
||||
}
|
||||
|
||||
// Convert from metric if needed
|
||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||
var perItemSurfaceAreaSqFt = itemSurfaceAreaSqFt;
|
||||
|
||||
if (useMetric)
|
||||
{
|
||||
perItemSurfaceAreaSqFt = _measurementService.SquareMetersToFeet(itemSurfaceAreaSqFt);
|
||||
_logger.LogInformation("Converted surface area from {SqM} sq m to {SqFt} sq ft",
|
||||
itemSurfaceAreaSqFt, perItemSurfaceAreaSqFt);
|
||||
}
|
||||
|
||||
// 1. Calculate powder cost per sq ft for this coat (also track raw costPerLb for order-quantity billing)
|
||||
decimal powderCostPerSqFt = costs.PowderCoatingCostPerSqFt; // Default
|
||||
decimal costPerLb = 0m;
|
||||
|
||||
// A coat is "custom" (must be purchased) when it has no inventory item but has a manual price.
|
||||
// In-stock coats reference an inventory item that already has stock on hand.
|
||||
bool isCustomPowder = !coat.InventoryItemId.HasValue
|
||||
&& coat.PowderCostPerLb.HasValue
|
||||
&& coat.PowderCostPerLb.Value > 0;
|
||||
|
||||
if (coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||
{
|
||||
// Custom powder with manual cost
|
||||
costPerLb = coat.PowderCostPerLb.Value;
|
||||
var poundsPerSqFt = 1m / coat.CoverageSqFtPerLb;
|
||||
var actualPoundsPerSqFt = poundsPerSqFt / (coat.TransferEfficiency / 100m);
|
||||
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
|
||||
|
||||
_logger.LogInformation("Coat {CoatName}: Using custom powder cost: {Cost}/sqft",
|
||||
coat.CoatName, powderCostPerSqFt);
|
||||
}
|
||||
else if (coat.InventoryItemId.HasValue && coat.InventoryItemId.Value > 0)
|
||||
{
|
||||
// In-stock powder - use inventory cost
|
||||
try
|
||||
{
|
||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||
if (inventoryItem != null && inventoryItem.UnitCost > 0)
|
||||
{
|
||||
costPerLb = inventoryItem.UnitCost;
|
||||
var coverage = coat.CoverageSqFtPerLb;
|
||||
var transferEfficiency = coat.TransferEfficiency;
|
||||
|
||||
var poundsPerSqFt = 1m / coverage;
|
||||
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
|
||||
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
|
||||
|
||||
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem}, UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
|
||||
coat.CoatName, inventoryItem.Name, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not retrieve inventory item {InventoryItemId} for coat {CoatName}, using default powder cost",
|
||||
coat.InventoryItemId.Value, coat.CoatName);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Calculate material cost for this coat
|
||||
var batchSurfaceAreaSqFt = perItemSurfaceAreaSqFt * quantity;
|
||||
decimal coatMaterialCost;
|
||||
|
||||
if (batchSurfaceAreaSqFt > 0 && isCustomPowder && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
{
|
||||
// Custom powder that must be purchased: charge for the full ordered quantity, not just
|
||||
// the calculated usage. The shop is spending money on the entire order for this job.
|
||||
coatMaterialCost = coat.PowderToOrder.Value * costPerLb;
|
||||
_logger.LogInformation("Coat {CoatName}: Custom powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})",
|
||||
coat.CoatName, coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt);
|
||||
}
|
||||
else if (batchSurfaceAreaSqFt > 0)
|
||||
{
|
||||
// In-stock powder: charge for calculated usage only
|
||||
coatMaterialCost = batchSurfaceAreaSqFt * powderCostPerSqFt;
|
||||
}
|
||||
else if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
{
|
||||
// No surface area (generic item): use PowderToOrder × cost per lb directly
|
||||
coatMaterialCost = coat.PowderToOrder.Value * costPerLb;
|
||||
_logger.LogInformation("Coat {CoatName}: Using PowderToOrder={Lbs}lb × ${CostPerLb}/lb = ${Total}",
|
||||
coat.CoatName, coat.PowderToOrder.Value, costPerLb, coatMaterialCost);
|
||||
}
|
||||
else
|
||||
{
|
||||
coatMaterialCost = 0;
|
||||
}
|
||||
|
||||
// 3. Calculate labor cost for this coat
|
||||
// First coat = 100% of base minutes, each additional coat = configurable percent from operating costs
|
||||
// NoExtraLayerCharge flag skips the additional layer charge (0x) for that specific coat
|
||||
var additionalCoatPercent = costs.AdditionalCoatLaborPercent / 100m;
|
||||
var laborMultiplier = coatIndex == 0 ? 1.0m : (coat.NoExtraLayerCharge ? 0m : additionalCoatPercent);
|
||||
var coatMinutes = estimatedMinutesBase * laborMultiplier * quantity;
|
||||
var coatLaborHours = coatMinutes / 60m;
|
||||
var coatLaborCost = coatLaborHours * costs.StandardLaborRate;
|
||||
|
||||
_logger.LogInformation("Coat {CoatName} (index {Index}): Labor={LaborMult}x, Minutes={Minutes}, LaborCost=${LaborCost}",
|
||||
coat.CoatName, coatIndex, laborMultiplier, coatMinutes, coatLaborCost);
|
||||
|
||||
return new QuoteItemCoatPricingResult
|
||||
{
|
||||
CoatMaterialCost = coatMaterialCost,
|
||||
CoatLaborCost = coatLaborCost,
|
||||
CoatTotalCost = coatMaterialCost + coatLaborCost
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the total price for a single quote line item, routing to the correct pricing
|
||||
/// path based on item type:
|
||||
///
|
||||
/// • AI item (IsAiItem = true) — ManualUnitPrice used directly; engine skipped entirely.
|
||||
/// • Sales item (IsSalesItem) — ManualUnitPrice × Qty; no coating math.
|
||||
/// • Generic item (IsGenericItem) — ManualUnitPrice as base; custom powder coat material added on top.
|
||||
/// • Labor item (IsLaborItem) — Qty treated as hours × StandardLaborRate; no markup.
|
||||
/// • Catalog, no coats — DefaultPrice × Qty (or PowderCostOverride if set).
|
||||
/// • Catalog with coats — DefaultPrice base + custom powder coat costs on top;
|
||||
/// in-stock powder assumed baked into the catalog price.
|
||||
/// • Calculated (surface area) — Material + labor + consumables surcharge + coating booth
|
||||
/// + markup + additional coat % + complexity surcharge.
|
||||
///
|
||||
/// Markup (GeneralMarkupPercentage) is applied to materials only for calculated items —
|
||||
/// labor and equipment rates are already the billable customer rates, not cost rates.
|
||||
/// Prep service labor is added to the base for non-AI items when IncludePrepCost is true.
|
||||
/// </summary>
|
||||
public async Task<QuoteItemPricingResult> CalculateQuoteItemPriceAsync(CreateQuoteItemDto item, int companyId, decimal? ovenCostOverride = null)
|
||||
{
|
||||
var costs = await GetOperatingCostsAsync(companyId);
|
||||
if (costs == null)
|
||||
{
|
||||
_logger.LogWarning("No operating costs configured for company {CompanyId}, using default values", companyId);
|
||||
return new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = 0,
|
||||
UnitPrice = 0,
|
||||
TotalPrice = 0
|
||||
};
|
||||
}
|
||||
|
||||
// AI items use ManualUnitPrice directly (set to either the AI estimate or the user's price override).
|
||||
// The AI already factored in all costs — skip the pricing engine entirely.
|
||||
if (item.IsAiItem && item.ManualUnitPrice.HasValue)
|
||||
{
|
||||
var aiUnitPrice = item.ManualUnitPrice.Value;
|
||||
var aiTotal = aiUnitPrice * item.Quantity;
|
||||
return new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = aiTotal,
|
||||
UnitPrice = aiUnitPrice,
|
||||
TotalPrice = aiTotal
|
||||
};
|
||||
}
|
||||
|
||||
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
|
||||
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
|
||||
{
|
||||
var total = item.ManualUnitPrice.Value * item.Quantity;
|
||||
return new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = total,
|
||||
UnitPrice = item.ManualUnitPrice.Value,
|
||||
TotalPrice = total
|
||||
};
|
||||
}
|
||||
|
||||
// Generic items use a manually-entered flat price as the base;
|
||||
// coat material costs (powder) are calculated and added on top.
|
||||
if (item.IsGenericItem && item.ManualUnitPrice.HasValue)
|
||||
{
|
||||
decimal coatMaterialCost = 0;
|
||||
if (item.Coats != null && item.Coats.Any())
|
||||
{
|
||||
for (int i = 0; i < item.Coats.Count; i++)
|
||||
{
|
||||
var coatResult = await CalculateCoatPriceAsync(
|
||||
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
|
||||
coatMaterialCost += coatResult.CoatMaterialCost;
|
||||
}
|
||||
}
|
||||
var baseTotal = item.ManualUnitPrice.Value * item.Quantity;
|
||||
var grandTotal = baseTotal + coatMaterialCost;
|
||||
return new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = coatMaterialCost,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = grandTotal,
|
||||
UnitPrice = grandTotal / item.Quantity,
|
||||
TotalPrice = grandTotal
|
||||
};
|
||||
}
|
||||
|
||||
// Labor items: Quantity = hours, priced at StandardLaborRate.
|
||||
// No markup applied — the labor rate is already the billable customer rate.
|
||||
if (item.IsLaborItem)
|
||||
{
|
||||
var laborHours = (decimal)item.Quantity;
|
||||
var laborTotal = laborHours * costs.StandardLaborRate;
|
||||
_logger.LogInformation("Labor item '{Description}': {Hours}h × ${Rate}/h = ${Total}",
|
||||
item.Description, laborHours, costs.StandardLaborRate, laborTotal);
|
||||
return new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = laborTotal,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = laborTotal,
|
||||
UnitPrice = costs.StandardLaborRate,
|
||||
TotalPrice = laborTotal
|
||||
};
|
||||
}
|
||||
|
||||
// If catalog item with no coats, use override price or catalog default price
|
||||
if (item.CatalogItemId.HasValue && (item.Coats == null || !item.Coats.Any()))
|
||||
{
|
||||
decimal noCoatUnitPrice = item.PowderCostOverride ?? 0m;
|
||||
if (noCoatUnitPrice == 0m)
|
||||
{
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
|
||||
noCoatUnitPrice = catalogItem?.DefaultPrice ?? 0m;
|
||||
}
|
||||
var catalogTotal = noCoatUnitPrice * item.Quantity;
|
||||
return new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = catalogTotal,
|
||||
UnitPrice = noCoatUnitPrice,
|
||||
TotalPrice = catalogTotal
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step 1: calculate base price ──────────────────────────────────────────
|
||||
// For catalog items: DefaultPrice × Qty is the base (covers one standard coat).
|
||||
// For non-catalog items: calculate first coat's material + labor + markup as the base.
|
||||
bool isCatalogItem = item.CatalogItemId.HasValue;
|
||||
|
||||
_logger.LogInformation("Item {Description}: Qty={Qty}, SqFt={SqFt}, Coats={CoatCount}, IsCatalog={IsCatalog}",
|
||||
item.Description, item.Quantity, item.SurfaceAreaSqFt, item.Coats?.Count ?? 0, isCatalogItem);
|
||||
|
||||
decimal baseSubtotal;
|
||||
decimal totalMaterialCost = 0m;
|
||||
decimal totalLaborCost = 0m;
|
||||
decimal totalEquipmentCost = 0m;
|
||||
|
||||
if (isCatalogItem)
|
||||
{
|
||||
decimal baseUnitPrice = item.PowderCostOverride ?? 0m;
|
||||
if (baseUnitPrice == 0m)
|
||||
{
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId!.Value);
|
||||
baseUnitPrice = catalogItem?.DefaultPrice ?? 0m;
|
||||
}
|
||||
baseSubtotal = baseUnitPrice * item.Quantity;
|
||||
_logger.LogInformation("Catalog base: ${Price} × {Qty} = ${Base}", baseUnitPrice, item.Quantity, baseSubtotal);
|
||||
|
||||
// Optionally add prep service labor cost for catalog items (opt-in toggle in wizard)
|
||||
// AI items exclude prep cost — it's already baked into the AI estimate
|
||||
if (!item.IsAiItem && item.IncludePrepCost && item.PrepServices != null && item.PrepServices.Any())
|
||||
{
|
||||
var totalPrepMinutes = item.PrepServices.Sum(ps => ps.EstimatedMinutes);
|
||||
var prepLaborCost = (totalPrepMinutes / 60m) * costs.StandardLaborRate;
|
||||
baseSubtotal += prepLaborCost;
|
||||
totalLaborCost += prepLaborCost;
|
||||
_logger.LogInformation("Catalog item prep cost: {Min}min × ${Rate}/h = ${Cost}", totalPrepMinutes, costs.StandardLaborRate, prepLaborCost);
|
||||
}
|
||||
|
||||
// Custom (non-inventory) powder coats must be purchased separately — add their material
|
||||
// cost on top of the catalog base price. Inventory powder is assumed baked into DefaultPrice.
|
||||
if (item.Coats != null && item.Coats.Any())
|
||||
{
|
||||
for (int ci = 0; ci < item.Coats.Count; ci++)
|
||||
{
|
||||
var coat = item.Coats[ci];
|
||||
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||
{
|
||||
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
|
||||
totalMaterialCost += coatResult.CoatMaterialCost;
|
||||
baseSubtotal += coatResult.CoatMaterialCost;
|
||||
_logger.LogInformation("Catalog item custom powder coat {Name}: +${Cost}", coat.CoatName, coatResult.CoatMaterialCost);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-catalog: derive base from first coat's material + labor + equipment + markup
|
||||
if (item.Coats != null && item.Coats.Count > 0)
|
||||
{
|
||||
var firstCoatResult = await CalculateCoatPriceAsync(
|
||||
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
||||
totalMaterialCost = firstCoatResult.CoatMaterialCost;
|
||||
totalLaborCost = firstCoatResult.CoatLaborCost;
|
||||
}
|
||||
|
||||
// Prep service labor (done once per item batch)
|
||||
// AI items exclude prep cost — it's already baked into the AI estimate
|
||||
if (!item.IsAiItem && item.PrepServices != null && item.PrepServices.Any())
|
||||
{
|
||||
var totalPrepMinutes = item.PrepServices.Sum(ps => ps.EstimatedMinutes);
|
||||
var prepLaborHours = totalPrepMinutes / 60m;
|
||||
totalLaborCost += prepLaborHours * costs.StandardLaborRate;
|
||||
_logger.LogInformation("PrepServices={Count}, TotalPrepMinutes={Min}", item.PrepServices.Count, totalPrepMinutes);
|
||||
}
|
||||
|
||||
// Consumables surcharge (5% of material)
|
||||
totalMaterialCost += totalMaterialCost * ConsumablesSurchargePercent;
|
||||
|
||||
// Equipment cost: coating booth only (oven cost moved to quote-level batch calculation)
|
||||
var totalLaborHours = totalLaborCost / costs.StandardLaborRate;
|
||||
totalEquipmentCost = totalLaborHours * costs.CoatingBoothCostPerHour;
|
||||
|
||||
// Apply pricing mode: markup on material only, or target margin on total cost
|
||||
if (costs.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost)
|
||||
{
|
||||
var totalCost = totalMaterialCost + totalLaborCost + totalEquipmentCost;
|
||||
var margin = Math.Min(costs.TargetMarginPercent / 100m, 0.99m); // clamp to avoid divide-by-zero
|
||||
baseSubtotal = margin < 0.001m ? totalCost : totalCost / (1m - margin);
|
||||
_logger.LogInformation("Non-catalog base (margin mode): TotalCost=${C}, Margin={M}% → Base=${Base}",
|
||||
totalCost, costs.TargetMarginPercent, baseSubtotal);
|
||||
}
|
||||
else
|
||||
{
|
||||
var materialWithMarkup = totalMaterialCost * (1 + costs.GeneralMarkupPercentage / 100m);
|
||||
baseSubtotal = materialWithMarkup + totalLaborCost + totalEquipmentCost;
|
||||
_logger.LogInformation("Non-catalog base (markup mode): Material=${M} (markup {Mk}% → ${MW}), Labor=${L}, Equipment=${E}, Base=${Base}",
|
||||
totalMaterialCost, costs.GeneralMarkupPercentage, materialWithMarkup, totalLaborCost, totalEquipmentCost, baseSubtotal);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: additional coat charges (% of base per additional coat) ──────
|
||||
// Each additional coat (index 1+) without NoExtraLayerCharge adds
|
||||
// AdditionalCoatLaborPercent % of the base subtotal.
|
||||
int additionalCoatCount = 0;
|
||||
if (item.Coats != null)
|
||||
{
|
||||
for (int i = 1; i < item.Coats.Count; i++)
|
||||
{
|
||||
if (!item.Coats[i].NoExtraLayerCharge)
|
||||
additionalCoatCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var additionalCoatCharge = baseSubtotal * additionalCoatCount * (costs.AdditionalCoatLaborPercent / 100m);
|
||||
var itemSubtotal = baseSubtotal + additionalCoatCharge;
|
||||
|
||||
_logger.LogInformation("Additional coats: {Count} × {Pct}% of ${Base} = ${Charge}; Total=${Total}",
|
||||
additionalCoatCount, costs.AdditionalCoatLaborPercent, baseSubtotal, additionalCoatCharge, itemSubtotal);
|
||||
|
||||
// ── Step 3: apply complexity multiplier (calculated items only) ────────
|
||||
if (!isCatalogItem && !item.IsGenericItem && !item.IsLaborItem)
|
||||
{
|
||||
var complexityPercent = item.Complexity switch
|
||||
{
|
||||
"Moderate" => costs.ComplexityModeratePercent,
|
||||
"Complex" => costs.ComplexityComplexPercent,
|
||||
"Extreme" => costs.ComplexityExtremePercent,
|
||||
_ => costs.ComplexitySimplePercent // "Simple" or null
|
||||
};
|
||||
|
||||
if (complexityPercent > 0)
|
||||
{
|
||||
var complexityCharge = itemSubtotal * (complexityPercent / 100m);
|
||||
_logger.LogInformation("Complexity '{Complexity}': +{Pct}% = +${Charge}",
|
||||
item.Complexity ?? "Simple", complexityPercent, complexityCharge);
|
||||
itemSubtotal += complexityCharge;
|
||||
}
|
||||
}
|
||||
|
||||
var unitPrice = item.Quantity > 0 ? itemSubtotal / item.Quantity : 0m;
|
||||
var totalPrice = itemSubtotal;
|
||||
|
||||
return new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = totalMaterialCost,
|
||||
LaborCost = totalLaborCost,
|
||||
EquipmentCost = totalEquipmentCost,
|
||||
ItemSubtotal = itemSubtotal,
|
||||
UnitPrice = unitPrice,
|
||||
TotalPrice = totalPrice
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the complete quote total from all line items, applying quote-level charges,
|
||||
/// discounts, and tax. The calculation sequence is strictly ordered (steps 1–14) and must
|
||||
/// not be reordered, as each step feeds the next:
|
||||
///
|
||||
/// 1. Sum item subtotals (via <see cref="CalculateQuoteItemPriceAsync"/> per item).
|
||||
/// 2. Add oven batch cost (batches × cycle hours × oven rate) — quote-level, not per-item.
|
||||
/// AI items are excluded from the oven charge by scaling with the non-AI surface-area fraction.
|
||||
/// 3. Add shop supplies charge (ShopSuppliesRate % of items + oven subtotal).
|
||||
/// 4. Apply customer pricing-tier discount (percentage off subtotal before any quote discount).
|
||||
/// 5. Apply quote-level discount (percentage or fixed amount, applied after tier discount).
|
||||
/// 6. Apply rush fee (percentage or fixed amount from operating costs, if IsRushJob).
|
||||
/// 7. Apply tax (manualTaxPercent if provided — used for tax-exempt customers — else CompanyTaxPercent).
|
||||
/// 8. Round all values to 2 decimal places.
|
||||
///
|
||||
/// Overhead and profit margin are NOT added here — they were removed as separate line items.
|
||||
/// Markup is now baked into per-item prices (step 1). The ProfitPercent field in the result
|
||||
/// is the markup % from operating costs, kept for the breakdown display only.
|
||||
/// </summary>
|
||||
public async Task<QuotePricingResult> CalculateQuoteTotalsAsync(
|
||||
List<CreateQuoteItemDto> items,
|
||||
int companyId,
|
||||
int? customerId = null,
|
||||
decimal? manualTaxPercent = null,
|
||||
string discountType = "None",
|
||||
decimal discountValue = 0,
|
||||
bool isRushJob = false,
|
||||
decimal? ovenCostOverride = null,
|
||||
int ovenBatches = 1,
|
||||
int? ovenCycleMinutes = null)
|
||||
{
|
||||
var costs = await GetOperatingCostsAsync(companyId);
|
||||
if (costs == null)
|
||||
{
|
||||
_logger.LogWarning("No operating costs configured for company {CompanyId}, returning zero pricing", companyId);
|
||||
return new QuotePricingResult
|
||||
{
|
||||
ItemsSubtotal = 0,
|
||||
ShopSuppliesAmount = 0,
|
||||
ShopSuppliesPercent = 0,
|
||||
OverheadCosts = 0,
|
||||
OverheadPercent = 0,
|
||||
ProfitMargin = 0,
|
||||
ProfitPercent = 0,
|
||||
SubtotalBeforeDiscount = 0,
|
||||
DiscountAmount = 0,
|
||||
DiscountPercent = 0,
|
||||
SubtotalAfterDiscount = 0,
|
||||
TaxAmount = 0,
|
||||
TaxPercent = 0,
|
||||
Total = 0,
|
||||
MaterialCosts = 0,
|
||||
LaborCosts = 0,
|
||||
EquipmentCosts = 0
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate all items
|
||||
var itemResults = new List<QuoteItemPricingResult>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
QuoteItemPricingResult itemResult;
|
||||
|
||||
// Catalog items - if they have coats, add coat costs to catalog base price
|
||||
if (item.CatalogItemId.HasValue)
|
||||
{
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
// If the catalog item has coats, calculate using CalculateQuoteItemPriceAsync
|
||||
// (which already includes the catalog base price + coat costs)
|
||||
if (item.Coats != null && item.Coats.Any())
|
||||
{
|
||||
// CalculateQuoteItemPriceAsync already adds catalog base price to coat costs
|
||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No coats - use simple catalog default price
|
||||
var catalogItemTotal = catalogItem.DefaultPrice * item.Quantity;
|
||||
itemResult = new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = catalogItemTotal,
|
||||
UnitPrice = catalogItem.DefaultPrice,
|
||||
TotalPrice = catalogItemTotal
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Catalog item not found, create zero result
|
||||
itemResult = new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = 0,
|
||||
UnitPrice = 0,
|
||||
TotalPrice = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculated items use the full pricing calculation
|
||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||
}
|
||||
|
||||
itemResults.Add(itemResult);
|
||||
}
|
||||
|
||||
// 1. SEPARATE CATALOG ITEMS FROM CALCULATED ITEMS
|
||||
// Catalog items WITHOUT coats are final prices - no breakdown
|
||||
// Catalog items WITH coats should include their cost breakdown
|
||||
var catalogItemsWithoutCoatsTotal = items
|
||||
.Where(i => i.CatalogItemId.HasValue && (i.Coats == null || !i.Coats.Any()))
|
||||
.Zip(itemResults.Where((r, idx) => items[idx].CatalogItemId.HasValue && (items[idx].Coats == null || !items[idx].Coats.Any())), (item, result) => result.ItemSubtotal)
|
||||
.Sum();
|
||||
|
||||
// Include calculated items AND catalog items with coats in the breakdown
|
||||
var itemsWithBreakdown = itemResults
|
||||
.Where((r, idx) => !items[idx].CatalogItemId.HasValue || (items[idx].Coats != null && items[idx].Coats.Any()))
|
||||
.ToList();
|
||||
|
||||
var calculatedItemsSubtotal = itemsWithBreakdown.Sum(r => r.ItemSubtotal);
|
||||
var totalMaterialCosts = itemsWithBreakdown.Sum(r => r.MaterialCost);
|
||||
var totalLaborCosts = itemsWithBreakdown.Sum(r => r.LaborCost);
|
||||
var totalEquipmentCosts = itemsWithBreakdown.Sum(r => r.EquipmentCost);
|
||||
|
||||
// 2. OVERHEAD COSTS (removed - was calculated separately)
|
||||
var overheadCosts = 0m;
|
||||
|
||||
// 3. PROFIT MARGIN (now baked into item prices, not added separately)
|
||||
// Store whichever % was active so the breakdown display shows the right label.
|
||||
var profitPercent = costs.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost
|
||||
? costs.TargetMarginPercent
|
||||
: costs.GeneralMarkupPercentage;
|
||||
var profitMargin = 0m; // Already included in item prices
|
||||
|
||||
// 4. TOTAL ITEMS SUBTOTAL
|
||||
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
|
||||
|
||||
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
|
||||
// AI items already have oven cost baked into their AI-estimated price, so we only
|
||||
// charge the proportion of the oven that's attributable to non-AI items.
|
||||
var effectiveOvenRate = ovenCostOverride ?? costs.OvenOperatingCostPerHour;
|
||||
var effectiveCycleMinutes = ovenCycleMinutes.HasValue && ovenCycleMinutes.Value > 0
|
||||
? ovenCycleMinutes.Value
|
||||
: costs.DefaultOvenCycleMinutes;
|
||||
var effectiveBatches = Math.Max(1, ovenBatches);
|
||||
var fullOvenBatchCost = effectiveBatches * (effectiveCycleMinutes / 60m) * effectiveOvenRate;
|
||||
|
||||
// Scale oven cost by the fraction of total surface area coming from non-AI items.
|
||||
// Use item count as a fallback when surface areas are all zero.
|
||||
var totalSqFt = items.Sum(i => i.SurfaceAreaSqFt * i.Quantity);
|
||||
var aiSqFt = items.Where(i => i.IsAiItem).Sum(i => i.SurfaceAreaSqFt * i.Quantity);
|
||||
var nonAiSqFt = totalSqFt - aiSqFt;
|
||||
|
||||
decimal nonAiFraction;
|
||||
if (totalSqFt > 0)
|
||||
{
|
||||
nonAiFraction = nonAiSqFt / totalSqFt;
|
||||
}
|
||||
else
|
||||
{
|
||||
var totalCount = items.Count;
|
||||
var aiCount = items.Count(i => i.IsAiItem);
|
||||
nonAiFraction = totalCount > 0 ? (decimal)(totalCount - aiCount) / totalCount : 1m;
|
||||
}
|
||||
|
||||
var ovenBatchCost = fullOvenBatchCost * nonAiFraction;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Oven batch cost: {Batches} × {Min}min × ${Rate}/hr = ${Full}; non-AI fraction {Frac:P0} → charged ${Cost}",
|
||||
effectiveBatches, effectiveCycleMinutes, effectiveOvenRate, fullOvenBatchCost, nonAiFraction, ovenBatchCost);
|
||||
|
||||
var itemsAndOvenSubtotal = itemsSubtotal + ovenBatchCost;
|
||||
|
||||
// 5. SHOP SUPPLIES (percentage of items + oven subtotal)
|
||||
var shopSuppliesPercent = costs.ShopSuppliesRate;
|
||||
var shopSuppliesAmount = itemsAndOvenSubtotal * (shopSuppliesPercent / 100m);
|
||||
|
||||
// 6. SUBTOTAL BEFORE DISCOUNT (items + oven + shop supplies)
|
||||
var subtotalBeforeDiscount = itemsAndOvenSubtotal + shopSuppliesAmount;
|
||||
|
||||
// 7. CUSTOMER PRICING TIER DISCOUNT (if applicable)
|
||||
var pricingTierDiscountPercent = 0m;
|
||||
var pricingTierDiscountAmount = 0m;
|
||||
|
||||
if (customerId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var customers = await _unitOfWork.Customers.FindAsync(c => c.Id == customerId.Value);
|
||||
var customer = customers.FirstOrDefault();
|
||||
|
||||
if (customer != null)
|
||||
{
|
||||
// Get pricing tier if assigned
|
||||
if (customer.PricingTierId.HasValue)
|
||||
{
|
||||
var pricingTier = await _unitOfWork.PricingTiers.GetByIdAsync(customer.PricingTierId.Value);
|
||||
if (pricingTier != null)
|
||||
{
|
||||
pricingTierDiscountPercent = pricingTier.DiscountPercent;
|
||||
pricingTierDiscountAmount = subtotalBeforeDiscount * (pricingTierDiscountPercent / 100m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving customer {CustomerId} for discount calculation", customerId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var subtotalAfterTierDiscount = subtotalBeforeDiscount - pricingTierDiscountAmount;
|
||||
|
||||
// 8. QUOTE-LEVEL DISCOUNT (applied after pricing tier discount)
|
||||
var quoteDiscountPercent = 0m;
|
||||
var quoteDiscountAmount = 0m;
|
||||
|
||||
if (discountType != "None" && discountValue > 0)
|
||||
{
|
||||
if (discountType == "Percentage")
|
||||
{
|
||||
quoteDiscountPercent = discountValue;
|
||||
quoteDiscountAmount = subtotalAfterTierDiscount * (discountValue / 100m);
|
||||
}
|
||||
else if (discountType == "FixedAmount")
|
||||
{
|
||||
quoteDiscountAmount = discountValue;
|
||||
// Calculate what percentage this is for display purposes
|
||||
if (subtotalAfterTierDiscount > 0)
|
||||
{
|
||||
quoteDiscountPercent = (discountValue / subtotalAfterTierDiscount) * 100m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. COMBINED DISCOUNT (pricing tier + quote-level)
|
||||
var totalDiscountPercent = pricingTierDiscountPercent + quoteDiscountPercent;
|
||||
var totalDiscountAmount = pricingTierDiscountAmount + quoteDiscountAmount;
|
||||
|
||||
// 10. SUBTOTAL AFTER ALL DISCOUNTS
|
||||
var subtotalAfterDiscount = subtotalAfterTierDiscount - quoteDiscountAmount;
|
||||
|
||||
// 11. RUSH FEE (if applicable)
|
||||
var rushFee = 0m;
|
||||
if (isRushJob)
|
||||
{
|
||||
if (costs.RushChargeType == "Percentage")
|
||||
{
|
||||
rushFee = subtotalAfterDiscount * (costs.RushChargePercentage / 100m);
|
||||
}
|
||||
else // FixedAmount
|
||||
{
|
||||
rushFee = costs.RushChargeFixedAmount;
|
||||
}
|
||||
}
|
||||
|
||||
// 12. SUBTOTAL INCLUDING RUSH FEE
|
||||
var subtotalWithRushFee = subtotalAfterDiscount + rushFee;
|
||||
|
||||
// 13. TAX
|
||||
var taxPercent = manualTaxPercent ?? costs.TaxPercent;
|
||||
var taxAmount = subtotalWithRushFee * (taxPercent / 100m);
|
||||
|
||||
// 14. FINAL TOTAL
|
||||
var total = subtotalWithRushFee + taxAmount;
|
||||
|
||||
return new QuotePricingResult
|
||||
{
|
||||
ItemsSubtotal = Math.Round(itemsSubtotal, 2),
|
||||
OvenBatchCost = Math.Round(ovenBatchCost, 2),
|
||||
OvenBatches = effectiveBatches,
|
||||
OvenCycleMinutes = effectiveCycleMinutes,
|
||||
ShopSuppliesAmount = Math.Round(shopSuppliesAmount, 2),
|
||||
ShopSuppliesPercent = Math.Round(shopSuppliesPercent, 2),
|
||||
OverheadCosts = Math.Round(overheadCosts, 2),
|
||||
OverheadPercent = 0m, // Overhead removed
|
||||
ProfitMargin = Math.Round(profitMargin, 2), // 0 - now baked into item prices
|
||||
ProfitPercent = Math.Round(profitPercent, 2), // Markup % used (for reference)
|
||||
SubtotalBeforeDiscount = Math.Round(subtotalBeforeDiscount, 2),
|
||||
|
||||
// Separate discounts
|
||||
PricingTierDiscountAmount = Math.Round(pricingTierDiscountAmount, 2),
|
||||
PricingTierDiscountPercent = Math.Round(pricingTierDiscountPercent, 2),
|
||||
QuoteDiscountAmount = Math.Round(quoteDiscountAmount, 2),
|
||||
QuoteDiscountPercent = Math.Round(quoteDiscountPercent, 2),
|
||||
|
||||
// Combined discount (for backward compatibility)
|
||||
DiscountAmount = Math.Round(totalDiscountAmount, 2),
|
||||
DiscountPercent = Math.Round(totalDiscountPercent, 2),
|
||||
|
||||
SubtotalAfterDiscount = Math.Round(subtotalAfterDiscount, 2),
|
||||
RushFee = Math.Round(rushFee, 2),
|
||||
TaxAmount = Math.Round(taxAmount, 2),
|
||||
TaxPercent = Math.Round(taxPercent, 2),
|
||||
Total = Math.Round(total, 2),
|
||||
MaterialCosts = Math.Round(totalMaterialCosts, 2),
|
||||
LaborCosts = Math.Round(totalLaborCosts, 2),
|
||||
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
|
||||
ItemResults = itemResults
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages user profile photos stored in Azure Blob Storage.
|
||||
/// Photos are stored in the <c>profileimages</c> container under the
|
||||
/// deterministic path <c>{companyId}/profile-photos/{userId}{ext}</c>
|
||||
/// (e.g. <c>7/profile-photos/abc-guid.png</c>).
|
||||
/// <para>
|
||||
/// A deterministic path (keyed by userId rather than a random GUID) is used
|
||||
/// here intentionally — unlike job photos, profile photos are accessed by
|
||||
/// user ID from multiple places (nav bar, worker cards) and a predictable
|
||||
/// path avoids the need to store it in an additional DB column. Access is
|
||||
/// still protected: the <c>/Profile/Photo</c> endpoint validates that the
|
||||
/// requesting user is authenticated before streaming the image.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ProfilePhotoService : IProfilePhotoService
|
||||
{
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
private readonly StorageSettings _settings;
|
||||
private readonly ILogger<ProfilePhotoService> _logger;
|
||||
|
||||
/// <summary>Image extensions accepted for profile photos.</summary>
|
||||
private static readonly string[] AllowedImageTypes = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
||||
|
||||
/// <summary>Maximum profile photo size accepted on upload (10 MB).</summary>
|
||||
private const long MaxPhotoSize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the service with the blob storage provider, storage
|
||||
/// configuration, and a logger for upload audit messages.
|
||||
/// </summary>
|
||||
public ProfilePhotoService(
|
||||
IAzureBlobStorageService blobService,
|
||||
IOptions<StorageSettings> settings,
|
||||
ILogger<ProfilePhotoService> logger)
|
||||
{
|
||||
_blobService = blobService;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and uploads a user profile photo, replacing any existing photo
|
||||
/// regardless of its extension. Old blobs at alternative extensions are
|
||||
/// deleted first (via <see cref="DeleteOldPhotosForUserAsync"/>) so a user
|
||||
/// never has multiple active photos in the container.
|
||||
/// The blob path mirrors the former filesystem path to simplify the migration
|
||||
/// from local storage to Azure without requiring a database schema change.
|
||||
/// </summary>
|
||||
/// <param name="file">The uploaded image file from the HTTP request.</param>
|
||||
/// <param name="userId">The ASP.NET Identity user ID (GUID string).</param>
|
||||
/// <param name="companyId">The tenant company's database ID (for path scoping).</param>
|
||||
/// <returns>
|
||||
/// A tuple with a success flag, the stored blob path (on success), and a
|
||||
/// human-readable error message (on failure).
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveProfilePhotoAsync(
|
||||
IFormFile file,
|
||||
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.");
|
||||
|
||||
// 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);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
|
||||
|
||||
if (!result.Success)
|
||||
return (false, string.Empty, result.ErrorMessage);
|
||||
|
||||
_logger.LogInformation("Profile photo saved: {BlobName} for user {UserId}", blobName, userId);
|
||||
return (true, blobName, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the profile photo blob at the given path from Azure Blob Storage.
|
||||
/// Called when a user removes their avatar from the Profile settings page.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path previously returned by <see cref="SaveProfilePhotoAsync"/>.</param>
|
||||
/// <returns>Success flag and an error message on failure.</returns>
|
||||
public async Task<(bool Success, string ErrorMessage)> DeleteProfilePhotoAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return (false, "File path is required.");
|
||||
|
||||
return await _blobService.DeleteAsync(_settings.Containers.ProfileImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the raw bytes of a profile photo for serving through the
|
||||
/// <c>/Profile/Photo</c> controller endpoint, which enforces authentication
|
||||
/// before streaming the image to the client.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path of the photo.</param>
|
||||
/// <returns>
|
||||
/// A tuple with a success flag, the raw file bytes, the MIME content type,
|
||||
/// and an error message on failure.
|
||||
/// </returns>
|
||||
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetProfilePhotoAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
|
||||
|
||||
return await _blobService.DownloadAsync(_settings.Containers.ProfileImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a profile photo blob exists without downloading its content.
|
||||
/// Used by the nav bar and worker card views to decide whether to show the
|
||||
/// user's avatar or a generic placeholder icon.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path to check.</param>
|
||||
/// <returns><c>true</c> if the blob exists; otherwise <c>false</c>.</returns>
|
||||
public async Task<bool> ProfilePhotoExistsAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return false;
|
||||
|
||||
return await _blobService.ExistsAsync(_settings.Containers.ProfileImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to delete any pre-existing profile photo blobs for the user at
|
||||
/// extensions other than <paramref name="currentExtension"/> so that uploading
|
||||
/// a new PNG replaces an old JPG (and vice versa).
|
||||
/// Errors are caught and logged as warnings rather than propagated because a
|
||||
/// missing old blob is not a blocking failure — the new photo should still be
|
||||
/// saved successfully.
|
||||
/// </summary>
|
||||
/// <param name="companyId">The tenant company's database ID.</param>
|
||||
/// <param name="userId">The ASP.NET Identity user ID.</param>
|
||||
/// <param name="currentExtension">
|
||||
/// Extension of the incoming upload; blobs with this extension are skipped
|
||||
/// because they will be overwritten immediately afterwards.
|
||||
/// </param>
|
||||
private async Task DeleteOldPhotosForUserAsync(int companyId, string userId, string currentExtension)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var ext in AllowedImageTypes)
|
||||
{
|
||||
if (ext == currentExtension) continue;
|
||||
|
||||
var oldBlobName = $"{companyId}/profile-photos/{userId}{ext}";
|
||||
await _blobService.DeleteAsync(_settings.Containers.ProfileImages, oldBlobName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error deleting old profile photos for user {UserId}", userId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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"
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the two-phase lifecycle of AI photo quote images in Azure Blob Storage.
|
||||
/// Phase 1 (temp): photos uploaded during the quote wizard are stored under
|
||||
/// <c>temp/{tempId}/{guid}{ext}</c> before the quote is saved and has an ID.
|
||||
/// Phase 2 (permanent): when the quote is created or edited, temp blobs are promoted to
|
||||
/// <c>{companyId}/quote-photos/{quoteId}/{guid}{ext}</c> and the temp blobs are deleted.
|
||||
/// Keeping photos in temp storage until the quote is committed prevents orphaned blobs from
|
||||
/// quotes that are abandoned mid-wizard, and ensures blobs are never written to a quote-scoped
|
||||
/// path before a valid quote ID exists.
|
||||
/// </summary>
|
||||
public class QuotePhotoService : IQuotePhotoService
|
||||
{
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
private readonly StorageSettings _settings;
|
||||
private readonly ILogger<QuotePhotoService> _logger;
|
||||
|
||||
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
||||
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the service with the blob storage wrapper, storage configuration, and logger.
|
||||
/// </summary>
|
||||
public QuotePhotoService(
|
||||
IAzureBlobStorageService blobService,
|
||||
IOptions<StorageSettings> settings,
|
||||
ILogger<QuotePhotoService> logger)
|
||||
{
|
||||
_blobService = blobService;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// Temp blobs: temp/{tempId}/{guid}{ext}
|
||||
// Permanent blobs: {companyId}/quote-photos/{quoteId}/{guid}{ext}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and uploads a photo to temporary blob storage, generating a new <c>tempId</c>
|
||||
/// (GUID) that the client uses to reference this photo during the rest of the quote wizard.
|
||||
/// A fresh GUID blob name is used within the temp folder so that concurrent uploads for the
|
||||
/// same session do not overwrite each other.
|
||||
/// </summary>
|
||||
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 tempId = Guid.NewGuid().ToString("N");
|
||||
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
|
||||
var contentType = GetContentType(ext);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
|
||||
|
||||
if (!result.Success)
|
||||
return (false, string.Empty, string.Empty, result.ErrorMessage);
|
||||
|
||||
_logger.LogInformation("Saved temp quote photo {TempId}: {BlobName}", tempId, blobName);
|
||||
return (true, tempId, blobName, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotes a previously uploaded temp photo to its permanent blob path once the quote has been
|
||||
/// saved and its ID is known. The photo is downloaded from temp storage and re-uploaded to the
|
||||
/// permanent path rather than using a server-side copy API, which keeps the service compatible
|
||||
/// with blob storage providers that do not support atomic renames. The temp blob is deleted
|
||||
/// on a best-effort basis after a successful promotion.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> PromoteTempPhotoAsync(
|
||||
string tempId, int quoteId, int companyId)
|
||||
{
|
||||
var prefix = $"temp/{tempId}/";
|
||||
var blobs = (await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix)).ToList();
|
||||
|
||||
if (blobs.Count == 0)
|
||||
return (false, string.Empty, "Temp photo not found.");
|
||||
|
||||
var srcBlob = blobs[0];
|
||||
var ext = Path.GetExtension(srcBlob);
|
||||
var destBlob = $"{companyId}/quote-photos/{quoteId}/{Guid.NewGuid():N}{ext}";
|
||||
|
||||
// Download from temp then re-upload to permanent location
|
||||
var download = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, srcBlob);
|
||||
if (!download.Success)
|
||||
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));
|
||||
if (!upload.Success)
|
||||
return (false, string.Empty, "Failed to save permanent photo.");
|
||||
|
||||
// Best-effort cleanup of temp blob
|
||||
await _blobService.DeleteAsync(_settings.Containers.QuoteImages, srcBlob);
|
||||
|
||||
_logger.LogInformation("Promoted temp photo {TempId} to {DestBlob}", tempId, destBlob);
|
||||
return (true, destBlob, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a photo from blob storage and returns its raw bytes and content type.
|
||||
/// Used by the <c>Quotes/Photo</c> controller action which streams photos stored outside
|
||||
/// the public web root so they are not directly URL-accessible.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, byte[] Data, string ContentType)> ReadPhotoAsync(string filePath)
|
||||
{
|
||||
var result = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, filePath);
|
||||
if (!result.Success)
|
||||
return (false, Array.Empty<byte>(), string.Empty);
|
||||
|
||||
return (true, result.Content, result.ContentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a photo blob from storage. Returns <c>true</c> if deletion succeeded (including the
|
||||
/// case where the blob does not exist, which the underlying service treats as a no-op success).
|
||||
/// </summary>
|
||||
public async Task<bool> DeletePhotoAsync(string filePath)
|
||||
{
|
||||
var result = await _blobService.DeleteAsync(_settings.Containers.QuoteImages, filePath);
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads all blobs under the temp prefix for a given <paramref name="tempId"/> and returns
|
||||
/// them as in-memory byte arrays. Used by the AI analysis endpoint to supply photo bytes to the
|
||||
/// Anthropic API when the quote has not yet been saved and photos have no permanent path.
|
||||
/// </summary>
|
||||
public async Task<List<(byte[] Data, string ContentType, string FileName)>> ReadTempPhotosAsync(string tempId)
|
||||
{
|
||||
var prefix = $"temp/{tempId}/";
|
||||
var blobs = (await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix)).ToList();
|
||||
|
||||
var results = new List<(byte[] Data, string ContentType, string FileName)>();
|
||||
foreach (var blobName in blobs)
|
||||
{
|
||||
var download = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, blobName);
|
||||
if (download.Success)
|
||||
results.Add((download.Content, download.ContentType, Path.GetFileName(blobName)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all blobs under the temp prefix for a given <paramref name="tempId"/>.
|
||||
/// Called when the user cancels the quote wizard or when promotion fails partway through so
|
||||
/// that orphaned temp blobs do not accumulate in storage. Each blob deletion is wrapped in
|
||||
/// its own try/catch so that one failed delete does not abort cleanup of the remaining blobs.
|
||||
/// </summary>
|
||||
public async Task CleanupTempAsync(string tempId)
|
||||
{
|
||||
var prefix = $"temp/{tempId}/";
|
||||
var blobs = await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix);
|
||||
foreach (var blob in blobs)
|
||||
{
|
||||
try { await _blobService.DeleteAsync(_settings.Containers.QuoteImages, blob); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Could not clean up temp blob {Blob}", blob); }
|
||||
}
|
||||
}
|
||||
|
||||
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,149 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
|
||||
/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop
|
||||
/// speeds) and the calculated-item wizard (to show a suggested blast time hint).
|
||||
///
|
||||
/// Formula:
|
||||
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier
|
||||
///
|
||||
/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint.
|
||||
/// All multipliers are relative to that baseline.
|
||||
/// </summary>
|
||||
public static class ShopCapabilityCalculator
|
||||
{
|
||||
// ── Blast rate derivation ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective blast rate in sqft/hr.
|
||||
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly.
|
||||
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
|
||||
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
|
||||
/// </summary>
|
||||
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||
{
|
||||
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
||||
return costs.BlastRateSqFtPerHourOverride.Value;
|
||||
|
||||
if (costs.CompressorCfm <= 0)
|
||||
return 0m;
|
||||
|
||||
var baseRate = BaseByCfm(costs.CompressorCfm);
|
||||
var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
|
||||
var setup = SetupMultiplier(costs.BlastSetupType);
|
||||
var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
|
||||
|
||||
return Math.Round(baseRate * nozzle * setup * substrate, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>.
|
||||
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set,
|
||||
/// otherwise derives from the setup's equipment specs.
|
||||
/// </summary>
|
||||
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
||||
{
|
||||
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
||||
return setup.BlastRateSqFtPerHourOverride.Value;
|
||||
|
||||
if (setup.CompressorCfm <= 0)
|
||||
return 0m;
|
||||
|
||||
var baseRate = BaseByCfm(setup.CompressorCfm);
|
||||
var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
|
||||
var setupMult = SetupMultiplier(setup.SetupType);
|
||||
var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
|
||||
|
||||
return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective coating application rate in sqft/hr.
|
||||
/// If override is set, returns it directly.
|
||||
/// Otherwise derives a sensible default from gun type.
|
||||
/// </summary>
|
||||
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||
{
|
||||
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
||||
return costs.CoatingRateSqFtPerHourOverride.Value;
|
||||
|
||||
// Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
|
||||
// Without more equipment data (voltage, gun model) we use a single reasonable default.
|
||||
return costs.CoatingGunType switch
|
||||
{
|
||||
CoatingGunType.Corona => 40m,
|
||||
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default
|
||||
CoatingGunType.Both => 40m,
|
||||
_ => 40m
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns default equipment field values for a given capability tier.
|
||||
/// Applied during Setup Wizard tier selection so the shop gets reasonable
|
||||
/// starting values even if they never visit the Quoting Calibration tab.
|
||||
/// </summary>
|
||||
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
||||
TierDefaults(ShopCapabilityTier tier) => tier switch
|
||||
{
|
||||
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed),
|
||||
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed)
|
||||
};
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint.
|
||||
/// Calibrated so that real-world examples produce expected results:
|
||||
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel)
|
||||
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel)
|
||||
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
|
||||
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
|
||||
/// </summary>
|
||||
private static decimal BaseByCfm(decimal cfm) => cfm switch
|
||||
{
|
||||
< 10 => 5m,
|
||||
< 20 => 9m,
|
||||
< 40 => 15m,
|
||||
< 80 => 25m,
|
||||
< 120 => 35m,
|
||||
_ => 45m
|
||||
};
|
||||
|
||||
private static decimal NozzleMultiplier(int nozzle) => nozzle switch
|
||||
{
|
||||
2 => 0.35m,
|
||||
3 => 0.55m,
|
||||
4 => 0.75m,
|
||||
5 => 1.00m,
|
||||
6 => 1.30m,
|
||||
7 => 1.65m,
|
||||
8 => 2.00m,
|
||||
_ => 1.00m
|
||||
};
|
||||
|
||||
private static decimal SetupMultiplier(BlastSetupType setup) => setup switch
|
||||
{
|
||||
BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time
|
||||
BlastSetupType.SiphonPot => 0.70m,
|
||||
BlastSetupType.PressurePot => 1.00m, // baseline
|
||||
BlastSetupType.WetBlasting => 0.60m,
|
||||
_ => 1.00m
|
||||
};
|
||||
|
||||
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
|
||||
{
|
||||
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint
|
||||
BlastSubstrateType.Paint => 1.00m, // baseline
|
||||
BlastSubstrateType.Mixed => 0.90m,
|
||||
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes
|
||||
_ => 0.90m
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One-time migration utility that copies files from the legacy local filesystem
|
||||
/// (<c>wwwroot/media/</c>) to Azure Blob Storage containers, then optionally
|
||||
/// deletes the originals.
|
||||
/// <para>
|
||||
/// The migration is <strong>idempotent</strong>: if a blob already exists in Azure
|
||||
/// it is skipped (not overwritten). This means the operation is safe to run
|
||||
/// multiple times — for example if a previous run was interrupted part-way through.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Container assignment is inferred from the relative path of each file
|
||||
/// (see <see cref="DetermineContainer"/>). Files that do not match any known
|
||||
/// path pattern are reported as failures but do not abort the overall run.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class StorageMigrationService : IStorageMigrationService
|
||||
{
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
private readonly StorageSettings _settings;
|
||||
private readonly ILogger<StorageMigrationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the service with the blob storage provider, storage
|
||||
/// configuration, and a logger for per-file progress messages.
|
||||
/// </summary>
|
||||
public StorageMigrationService(
|
||||
IAzureBlobStorageService blobService,
|
||||
IOptions<StorageSettings> settings,
|
||||
ILogger<StorageMigrationService> logger)
|
||||
{
|
||||
_blobService = blobService;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively enumerates all files under <paramref name="mediaBasePath"/>,
|
||||
/// determines the correct Azure Blob Storage container for each file, and
|
||||
/// uploads those that do not already exist in Azure.
|
||||
/// </summary>
|
||||
/// <param name="mediaBasePath">
|
||||
/// Absolute path to the root media directory on the local filesystem
|
||||
/// (e.g. <c>C:\app\wwwroot\media</c>).
|
||||
/// </param>
|
||||
/// <param name="deleteLocalAfterMigration">
|
||||
/// When <c>true</c>, the local file is deleted immediately after a successful
|
||||
/// upload. Defaults to <c>false</c> so a dry-run can be performed first.
|
||||
/// <strong>Warning:</strong> set to <c>true</c> only after verifying the
|
||||
/// migration result — deleted local files cannot be recovered without a backup.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// A <see cref="StorageMigrationResult"/> summarising how many files were
|
||||
/// migrated, skipped (already in Azure), and failed, along with total bytes
|
||||
/// transferred and elapsed time.
|
||||
/// </returns>
|
||||
public async Task<StorageMigrationResult> MigrateFilesystemToAzureAsync(
|
||||
string mediaBasePath,
|
||||
bool deleteLocalAfterMigration = false)
|
||||
{
|
||||
var result = new StorageMigrationResult();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
if (!Directory.Exists(mediaBasePath))
|
||||
{
|
||||
result.Errors.Add($"Media directory not found: {mediaBasePath}");
|
||||
result.Duration = stopwatch.Elapsed;
|
||||
return result;
|
||||
}
|
||||
|
||||
var allFiles = Directory.EnumerateFiles(mediaBasePath, "*.*", SearchOption.AllDirectories).ToList();
|
||||
_logger.LogInformation("Starting filesystem-to-Azure migration. Found {Count} files in {Path}", allFiles.Count, mediaBasePath);
|
||||
|
||||
foreach (var fullPath in allFiles)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(mediaBasePath, fullPath).Replace("\\", "/");
|
||||
var container = DetermineContainer(relativePath);
|
||||
|
||||
if (container is null)
|
||||
{
|
||||
_logger.LogWarning("Could not determine container for file: {RelativePath} — skipping", relativePath);
|
||||
result.Errors.Add($"Unknown file type (no matching container): {relativePath}");
|
||||
result.Failed++;
|
||||
result.Files.Add(new MigratedFileEntry
|
||||
{
|
||||
RelativePath = relativePath,
|
||||
Container = "unknown",
|
||||
FileSize = new FileInfo(fullPath).Length,
|
||||
Status = MigrationFileStatus.Failed
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(fullPath);
|
||||
|
||||
// Skip if blob already exists in Azure
|
||||
var alreadyExists = await _blobService.ExistsAsync(container, relativePath);
|
||||
if (alreadyExists)
|
||||
{
|
||||
_logger.LogDebug("Blob already exists, skipping: {Container}/{BlobName}", container, relativePath);
|
||||
result.Skipped++;
|
||||
result.Files.Add(new MigratedFileEntry
|
||||
{
|
||||
RelativePath = relativePath,
|
||||
Container = container,
|
||||
FileSize = fileInfo.Length,
|
||||
Status = MigrationFileStatus.Skipped
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var contentType = GetContentType(Path.GetExtension(fullPath).ToLowerInvariant());
|
||||
|
||||
await using var stream = File.OpenRead(fullPath);
|
||||
var uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType);
|
||||
|
||||
if (!uploadResult.Success)
|
||||
{
|
||||
result.Failed++;
|
||||
result.Errors.Add($"Upload failed for {relativePath}: {uploadResult.ErrorMessage}");
|
||||
result.Files.Add(new MigratedFileEntry
|
||||
{
|
||||
RelativePath = relativePath,
|
||||
Container = container,
|
||||
FileSize = fileInfo.Length,
|
||||
Status = MigrationFileStatus.Failed
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Migrated++;
|
||||
result.BytesMigrated += fileInfo.Length;
|
||||
result.Files.Add(new MigratedFileEntry
|
||||
{
|
||||
RelativePath = relativePath,
|
||||
Container = container,
|
||||
FileSize = fileInfo.Length,
|
||||
Status = MigrationFileStatus.Migrated
|
||||
});
|
||||
|
||||
if (deleteLocalAfterMigration)
|
||||
{
|
||||
File.Delete(fullPath);
|
||||
_logger.LogInformation("Deleted local file after migration: {FullPath}", fullPath);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Migrated: {RelativePath} → {Container}", relativePath, container);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error migrating file: {RelativePath}", relativePath);
|
||||
result.Failed++;
|
||||
result.Errors.Add($"Exception for {relativePath}: {ex.Message}");
|
||||
result.Files.Add(new MigratedFileEntry
|
||||
{
|
||||
RelativePath = relativePath,
|
||||
Container = container,
|
||||
FileSize = 0,
|
||||
Status = MigrationFileStatus.Failed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
result.Duration = stopwatch.Elapsed;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Migration complete. Migrated: {Migrated}, Skipped: {Skipped}, Failed: {Failed}, Duration: {Duration}",
|
||||
result.Migrated, result.Skipped, result.Failed, result.Duration);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines which Azure Blob Storage container a local file belongs in by
|
||||
/// inspecting its path segments. The mapping mirrors the blob naming
|
||||
/// conventions used by each file service:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>/profile-photos/</c> → <c>profileimages</c> container</item>
|
||||
/// <item><c>/job-photos/</c> → <c>jobimages</c> container</item>
|
||||
/// <item><c>/equipment-manuals/</c> → <c>manuals</c> container</item>
|
||||
/// <item><c>company-logo</c> (anywhere in path) → <c>companylogos</c> container</item>
|
||||
/// </list>
|
||||
/// Returns <c>null</c> for files that do not match any known pattern; the
|
||||
/// caller treats these as failures and continues with the next file.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">
|
||||
/// Path of the file relative to the media root, with forward slashes.
|
||||
/// </param>
|
||||
/// <returns>Container name, or <c>null</c> if no match is found.</returns>
|
||||
private string? DetermineContainer(string relativePath)
|
||||
{
|
||||
if (relativePath.Contains("/profile-photos/")) return _settings.Containers.ProfileImages;
|
||||
if (relativePath.Contains("/job-photos/")) return _settings.Containers.JobImages;
|
||||
if (relativePath.Contains("/equipment-manuals/"))return _settings.Containers.Manuals;
|
||||
if (relativePath.Contains("company-logo")) return _settings.Containers.CompanyLogos;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type for
|
||||
/// the <c>Content-Type</c> metadata stored with each Azure blob. Correct
|
||||
/// MIME types ensure browsers handle downloads appropriately (inline vs.
|
||||
/// prompt for application) when blobs are accessed via signed URLs.
|
||||
/// </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",
|
||||
".pdf" => "application/pdf",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt" => "text/plain",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user