using PowderCoating.Application.DTOs.Quote; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; using Microsoft.Extensions.Logging; namespace PowderCoating.Application.Services; /// /// Orchestrates the full quote item assembly pipeline: pricing calculation, entity construction, /// AI prediction tracking, and automatic inventory record creation for incoming powder orders. /// /// This service sits above — it knows HOW to build and /// persist quote entities, while PricingCalculationService knows HOW to compute dollar amounts. /// Keeping them separate means pricing logic can be unit-tested without any entity construction concerns. /// /// Key responsibilities: /// - — stamps calculated totals onto the Quote entity so the /// displayed price is frozen at quote time and won't change if operating costs are updated later. /// - — builds QuoteItem + coats + prep services for each DTO, /// records AI prediction overrides, and auto-creates incoming inventory records when needed. /// public class QuotePricingAssemblyService : IQuotePricingAssemblyService { private readonly IUnitOfWork _unitOfWork; private readonly IPricingCalculationService _pricingService; private readonly IInventoryAiLookupService _aiLookupService; private readonly ILogger _logger; public QuotePricingAssemblyService( IUnitOfWork unitOfWork, IPricingCalculationService pricingService, IInventoryAiLookupService aiLookupService, ILogger logger) { _unitOfWork = unitOfWork; _pricingService = pricingService; _aiLookupService = aiLookupService; _logger = logger; } /// /// Writes the calculated pricing breakdown onto the entity as a snapshot. /// Snapshots are critical: once a quote is sent to a customer, operating cost changes must NOT /// silently alter the quoted amounts — the snapshot preserves what was presented at the time. /// public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult) { ArgumentNullException.ThrowIfNull(quote); ArgumentNullException.ThrowIfNull(pricingResult); quote.MaterialCosts = pricingResult.MaterialCosts; quote.LaborCosts = pricingResult.LaborCosts; quote.EquipmentCosts = pricingResult.EquipmentCosts; quote.ItemsSubtotal = pricingResult.ItemsSubtotal; quote.OvenBatchCost = pricingResult.OvenBatchCost; quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost; quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour; quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount; quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent; quote.OverheadAmount = pricingResult.OverheadCosts; quote.OverheadPercent = pricingResult.OverheadPercent; quote.ProfitMargin = pricingResult.ProfitMargin; quote.ProfitPercent = pricingResult.ProfitPercent; quote.SubTotal = pricingResult.SubtotalBeforeDiscount; quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount; quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent; quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount; quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent; quote.DiscountPercent = pricingResult.DiscountPercent; quote.DiscountAmount = pricingResult.DiscountAmount; quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount; quote.RushFee = pricingResult.RushFee; quote.TaxAmount = pricingResult.TaxAmount; quote.Total = pricingResult.Total; } /// /// Builds and prices all entities from the incoming DTOs. /// For each item: constructs the entity, calculates pricing, records whether the user overrode /// an AI estimate, then attaches coats (including auto-creating incoming inventory entries when /// the user selects a catalog powder not yet in their inventory) and prep services. /// public async Task> CreateQuoteItemsAsync( IEnumerable itemDtos, int quoteId, int companyId, decimal? ovenRateOverride, DateTime createdAtUtc) { ArgumentNullException.ThrowIfNull(itemDtos); var dtoList = itemDtos.ToList(); var items = new List(); foreach (var itemDto in dtoList) { var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc); await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride); await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice); item.Coats = await BuildQuoteItemCoatsAsync(itemDto, companyId, createdAtUtc); item.PrepServices = BuildQuoteItemPrepServices(itemDto, companyId, createdAtUtc); items.Add(item); } // Option B: auto-create the Custom Powder Order item only on first save. // Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it. bool hasExistingCustomPowderOrder = dtoList.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true); if (!hasExistingCustomPowderOrder) { var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc); if (customPowderItem != null) items.Add(customPowderItem); } return items; } /// /// Routes a single item to the correct pricing path and stamps the result onto the entity. /// Priority order matches the routing table in : /// AI items → Sales items → Catalog (no coats) → full calculation engine. /// Keeping pricing logic in PricingCalculationService means this method only decides WHICH /// path to take, never HOW to compute the price. /// private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride) { if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0) { item.UnitPrice = itemDto.ManualUnitPrice.Value; item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity; _logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); return; } if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue) { item.UnitPrice = itemDto.ManualUnitPrice.Value; item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity; _logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); return; } if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue) { item.UnitPrice = itemDto.ManualUnitPrice.Value; item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity; _logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); return; } if (itemDto.CatalogItemId.HasValue) { if (itemDto.Coats != null && itemDto.Coats.Any()) { _logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count); var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride); ApplyCalculatedPricing(item, itemPricing); return; } var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value); if (catalogItem != null) { var unitPrice = itemDto.PowderCostOverride is > 0 ? itemDto.PowderCostOverride.Value : catalogItem.DefaultPrice; item.UnitPrice = unitPrice; item.TotalPrice = unitPrice * itemDto.Quantity; _logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice); } return; } _logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0); var pricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride); ApplyCalculatedPricing(item, pricing); } /// /// Builds entities for a single item, including per-coat pricing. /// When a coat references the platform catalog (CatalogItemId set), the ID is stored on /// so that at approval time the system /// can create exactly one per unique powder across all coats on the /// quote (deduplication). No inventory is created during quote save. /// private async Task> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc) { if (itemDto.Coats == null || itemDto.Coats.Count == 0) return []; var coats = new List(); for (var coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++) { var coatDto = itemDto.Coats[coatIndex]; // Incoming-inventory creation is intentionally deferred to quote approval. // PowderCatalogItemId is persisted on the coat entity for later use. var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc); var coatPricing = await _pricingService.CalculateCoatPriceAsync( coatDto, itemDto.SurfaceAreaSqFt, itemDto.Quantity, coatIndex, itemDto.EstimatedMinutes, companyId); coat.CoatMaterialCost = coatPricing.CoatMaterialCost; coat.CoatLaborCost = coatPricing.CoatLaborCost; coat.CoatTotalCost = coatPricing.CoatTotalCost; coats.Add(coat); } return coats; } /// Constructs entities from the item DTO's prep service list. private static List BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc) { if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0) return []; return itemDto.PrepServices .Select(ps => new QuoteItemPrepService { PrepServiceId = ps.PrepServiceId, EstimatedMinutes = ps.EstimatedMinutes, BlastSetupId = ps.BlastSetupId, CompanyId = companyId, CreatedAt = createdAtUtc }) .ToList(); } /// /// Constructs a bare entity from the DTO — no pricing or coats yet. /// Pricing is applied separately by to keep the construction /// and calculation steps distinct and individually testable. /// private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc) { return new QuoteItem { QuoteId = quoteId, Description = itemDto.Description, Quantity = itemDto.Quantity, SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt, CatalogItemId = itemDto.CatalogItemId, IsGenericItem = itemDto.IsGenericItem, ManualUnitPrice = itemDto.ManualUnitPrice, PowderCostOverride = itemDto.PowderCostOverride, IsLaborItem = itemDto.IsLaborItem, IsSalesItem = itemDto.IsSalesItem, Sku = itemDto.Sku, RequiresSandblasting = itemDto.RequiresSandblasting, RequiresMasking = itemDto.RequiresMasking, EstimatedMinutes = itemDto.EstimatedMinutes, IncludePrepCost = itemDto.IncludePrepCost, Notes = itemDto.Notes, Complexity = itemDto.Complexity, IsAiItem = itemDto.IsAiItem, AiTags = itemDto.AiTags, AiPredictionId = itemDto.AiPredictionId, IsCustomFormulaItem = itemDto.IsCustomFormulaItem, CustomItemTemplateId = itemDto.CustomItemTemplateId, FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson, CompanyId = companyId, CreatedAt = createdAtUtc }; } /// Constructs a entity from the coat DTO. Per-coat pricing is applied by the caller. private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc) { return new QuoteItemCoat { CoatName = coatDto.CoatName, Sequence = coatDto.Sequence, InventoryItemId = coatDto.InventoryItemId, PowderCatalogItemId = coatDto.CatalogItemId, ColorName = coatDto.ColorName, VendorId = coatDto.VendorId, ColorCode = coatDto.ColorCode, Finish = coatDto.Finish, CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb, TransferEfficiency = coatDto.TransferEfficiency, PowderCostPerLb = coatDto.PowderCostPerLb, PowderToOrder = coatDto.PowderToOrder, NoExtraLayerCharge = coatDto.NoExtraLayerCharge, Notes = coatDto.Notes, CompanyId = companyId, CreatedAt = createdAtUtc }; } /// /// Stamps the pricing result onto the quote item entity. /// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync. /// private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing) { item.UnitPrice = pricing.UnitPrice; item.TotalPrice = pricing.TotalPrice; item.ItemMaterialCost = pricing.MaterialCost; item.ItemLaborCost = pricing.LaborCost; item.ItemEquipmentCost = pricing.EquipmentCost; } /// /// Checks whether the user changed the AI's surface area or price estimates before saving, /// and sets UserOverrodeEstimate = true on the prediction record if they did. /// This flag feeds the AI analytics reports — over time it reveals how accurate the AI is /// and whether certain item types consistently need manual correction. /// A tolerance of $0.01 / 0.01 sqft is used to ignore floating-point rounding noise. /// private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice) { if (!itemDto.AiPredictionId.HasValue) return; var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value); if (prediction == null) return; var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt); var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice); prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m; prediction.UpdatedAt = DateTime.UtcNow; } /// /// Creates one "incoming" from a platform catalog entry. /// Called at quote-approval time (not during quote save) so inventory records only appear /// when a job is actually going to be created. The caller groups coats by /// PowderCatalogItemId and calls this once per unique catalog item, preventing /// duplicate records when the same powder appears on multiple items in the same quote. /// /// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER") /// so the item always lands in the right bucket regardless of how many IsCoating categories /// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category. /// /// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families) /// from the manufacturer product page. Best-effort — item is still created from catalog data /// if the AI call fails. /// private async Task CreateIncomingInventoryItemAsync(int catalogItemId, int companyId) { try { var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId); if (catalogItem == null) return null; var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); // Prefer the canonical "POWDER" category so catalog-sourced items never land in an // unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true. var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating && c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase)) ?? categories .Where(c => c.IsActive && c.IsCoating) .OrderBy(c => c.DisplayOrder) .FirstOrDefault(); var vendors = await _unitOfWork.Vendors.GetAllAsync(); var vendorNameLower = catalogItem.VendorName.ToLower(); var matchedVendor = vendors.FirstOrDefault(v => v.CompanyName.ToLower().Contains(vendorNameLower) || vendorNameLower.Contains(v.CompanyName.ToLower())); var code = coatingCategory != null ? (coatingCategory.CategoryCode.Length >= 4 ? coatingCategory.CategoryCode[..4].ToUpperInvariant() : coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X')) : "POWD"; var prefix = $"{code}-{DateTime.Now:yyMM}-"; var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true); var maxSeq = allItems .Where(i => i.SKU.StartsWith(prefix)) .Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0) .DefaultIfEmpty(0) .Max(); var sku = $"{prefix}{(maxSeq + 1):D4}"; var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo .ToTitleCase(catalogItem.ColorName.Trim().ToLower()); var description = catalogItem.Description; var finish = catalogItem.Finish; var colorFamilies = catalogItem.ColorFamilies; var cureTemp = catalogItem.CureTemperatureF; var cureTime = catalogItem.CureTimeMinutes; var coverage = catalogItem.CoverageSqFtPerLb; var transferEff = catalogItem.TransferEfficiency; var specificGravity = catalogItem.SpecificGravity; var imageUrl = catalogItem.ImageUrl; var sdsUrl = catalogItem.SdsUrl; var tdsUrl = catalogItem.TdsUrl; var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) && (string.IsNullOrWhiteSpace(description) || string.IsNullOrWhiteSpace(colorFamilies) || cureTemp == null || cureTime == null); if (needsAugment) { try { var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl); if (augmented.Success) { description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description; finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish; colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies; cureTemp ??= augmented.CureTemperatureF; cureTime ??= augmented.CureTimeMinutes; coverage ??= augmented.CoverageSqFtPerLb; transferEff ??= augmented.TransferEfficiency; specificGravity ??= augmented.SpecificGravity; imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl; sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl; tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl; _logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id); } } catch (Exception ex) { _logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id); } } var item = new InventoryItem { SKU = sku, Name = name, Description = description, ColorName = catalogItem.ColorName, Manufacturer = catalogItem.VendorName, ManufacturerPartNumber = catalogItem.Sku, Finish = finish, ColorFamilies = colorFamilies, RequiresClearCoat = catalogItem.RequiresClearCoat ?? false, CoverageSqFtPerLb = coverage ?? 30m, TransferEfficiency = transferEff ?? 65m, CureTemperatureF = cureTemp, CureTimeMinutes = cureTime, SpecificGravity = specificGravity, SpecPageUrl = catalogItem.ProductUrl, ImageUrl = imageUrl, SdsUrl = sdsUrl, TdsUrl = tdsUrl, UnitCost = catalogItem.UnitPrice, AverageCost = catalogItem.UnitPrice, LastPurchasePrice = catalogItem.UnitPrice, QuantityOnHand = 0, UnitOfMeasure = "lbs", PrimaryVendorId = matchedVendor?.Id, InventoryCategoryId = coatingCategory?.Id, Category = coatingCategory?.DisplayName ?? "Powder Coating", IsActive = true, IsIncoming = true, CompanyId = companyId, CreatedAt = DateTime.UtcNow, }; await _unitOfWork.InventoryItems.AddAsync(item); await _unitOfWork.SaveChangesAsync(); _logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval", item.Id, item.Name, catalogItemId); return item.Id; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link", catalogItemId); return null; } } /// /// Scans all coat DTOs for powder that must be ordered (custom or catalog-sourced) and returns a /// single "Custom Powder Order" QuoteItem aggregating all material costs and color names. /// Returns null when no such coats are found. Used by /// on the first save only — Option B means the user owns the price after creation. /// /// Coat types that qualify: /// - Custom powder: no InventoryItemId, manual PowderCostPerLb > 0 (user-entered) /// - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb /// pre-filled from catalog unit price (inventory creation deferred to approval) /// - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records) /// private async Task BuildCustomPowderOrderItemAsync( IReadOnlyList itemDtos, int quoteId, int companyId, DateTime createdAtUtc) { var colorNames = new List(); decimal totalCost = 0m; foreach (var itemDto in itemDtos) { if (itemDto.Coats == null) continue; foreach (var coat in itemDto.Coats) { if (!coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0) { // Custom powder (manual cost) or catalog-sourced incoming (cost pre-filled from catalog). // Both arrive here the same way: PowderCostPerLb set, no inventory link yet. totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value; if (!string.IsNullOrWhiteSpace(coat.ColorName)) colorNames.Add(coat.ColorName); } else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0) { // Legacy path: inventory was already created (quotes saved before the deferred-creation fix). // PowderCostPerLb was cleared on those coats so cost must come from inventory. var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value); if (invItem?.IsIncoming == true) { totalCost += coat.PowderToOrder.Value * invItem.UnitCost; var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name; if (!string.IsNullOrWhiteSpace(colorName)) colorNames.Add(colorName); } } } } if (totalCost <= 0) return null; var uniqueColors = colorNames .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var description = uniqueColors.Any() ? $"Custom Powder Order ({string.Join(", ", uniqueColors)})" : "Custom Powder Order"; return new QuoteItem { QuoteId = quoteId, Description = description, Quantity = 1, IsGenericItem = true, ManualUnitPrice = totalCost, UnitPrice = totalCost, TotalPrice = totalCost, ItemMaterialCost = totalCost, CompanyId = companyId, CreatedAt = createdAtUtc, Coats = [], PrepServices = [] }; } /// /// Called at quote approval time to create exactly one per unique /// powder catalog entry referenced across all coats on the quote, then links each coat to its /// new (or existing) inventory record. /// /// WHY deferred: during quoting the job may never be approved, so creating inventory records at /// quote-save time produces orphaned, never-ordered items. Deferring to approval ensures inventory /// only reflects powders the shop is actually going to process. /// /// Deduplication: multiple items on the same quote that use the same catalog powder receive the /// same InventoryItemId — no duplicate records are created. /// /// Idempotent: coats that already have an InventoryItemId are skipped, so calling this method /// on an already-approved quote (e.g. retry after a transient error) is safe. /// public async Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId) { // Load all QuoteItems for this quote with their coats so we can inspect PowderCatalogItemId. var quoteItems = await _unitOfWork.QuoteItems.FindAsync( qi => qi.QuoteId == quoteId && qi.CompanyId == companyId, false, qi => qi.Coats); var pendingCoats = quoteItems .SelectMany(qi => qi.Coats) .Where(c => c.PowderCatalogItemId.HasValue && !c.InventoryItemId.HasValue) .ToList(); if (pendingCoats.Count == 0) return; // Group by catalog item ID so each unique powder generates exactly one inventory record. var groups = pendingCoats .GroupBy(c => c.PowderCatalogItemId!.Value) .ToList(); foreach (var group in groups) { var newInventoryId = await CreateIncomingInventoryItemAsync(group.Key, companyId); if (newInventoryId == null) continue; // Link every coat in this group to the single newly-created inventory record. foreach (var coat in group) { coat.InventoryItemId = newInventoryId; coat.UpdatedAt = DateTime.UtcNow; await _unitOfWork.QuoteItemCoats.UpdateAsync(coat); } } await _unitOfWork.SaveChangesAsync(); } }