using FruitBank.Common.Entities; using FruitBank.Common.Enums; using FruitBank.Common.Interfaces; using Nop.Core; using Nop.Core.Domain.Catalog; //using LinqToDB; using Nop.Core.Domain.Orders; using Nop.Core.Domain.Payments; using Nop.Core.Domain.Shipping; using Nop.Core.Events; using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; using Nop.Services.Catalog; using Nop.Services.Customers; using Nop.Services.Orders; namespace Nop.Plugin.Misc.FruitBankPlugin.Services; /// /// Converts pending preorder items into real NopCommerce orders when /// incoming stock is confirmed via shipping document processing. /// /// Called once per shipping document save, after all IncomingQuantity /// attributes have been written for that document's product set. /// /// Allocation strategy: first-come-first-served by PreOrderId (insertion order). /// /// Multi-document design: /// - PreOrder.OrderId tracks the linked real order once created. /// - First partial fulfillment → creates the order, saves OrderId on PreOrder. /// - Subsequent documents → appends only newly-fulfilled items to that same order. /// - Dropped items are recorded in an order note but never become OrderItems. /// public partial class PreOrderConversionService { private readonly PreOrderDbContext _preorderDbContext; private readonly FruitBankDbContext _dbContext; private readonly ICustomerService _customerService; private readonly IProductService _productService; private readonly IEventPublisher _eventPublisher; private readonly CustomPriceCalculationService _customPriceCalculationService; private readonly IOrderService _orderService; private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly FruitBankOrderItemService _orderItemService; private readonly IStoreContext _storeContext; // Per-preorder async locks — static so they're shared across all scoped instances private static readonly System.Collections.Concurrent.ConcurrentDictionary _preorderLocks = new(); private static SemaphoreSlim GetPreorderLock(int preorderId) => _preorderLocks.GetOrAdd(preorderId, _ => new SemaphoreSlim(1, 1)); public PreOrderConversionService( PreOrderDbContext preorderDbContext, FruitBankDbContext dbContext, ICustomerService customerService, IProductService productService, IEventPublisher eventPublisher, IPriceCalculationService priceCalculationService, IOrderService orderService, FruitBankAttributeService fruitBankAttributeService, FruitBankOrderItemService orderItemService, IStoreContext storeContext) { _preorderDbContext = preorderDbContext; _dbContext = dbContext; _customerService = customerService; _productService = productService; _eventPublisher = eventPublisher; _customPriceCalculationService = priceCalculationService as CustomPriceCalculationService; _orderService = orderService; _fruitBankAttributeService = fruitBankAttributeService; _orderItemService = orderItemService; _storeContext = storeContext; } // ── Entry point ─────────────────────────────────────────────────────────── public async Task ConvertPreOrdersForProductsAsync(IList productIds, int shippingDocumentId) { Console.WriteLine($"[PreOrderConversion] Starting for {productIds.Count} products, shippingDocumentId={shippingDocumentId}"); // Always sweep expired preorders first — any preorder whose DateOfReceipt // is in the past is closed regardless of stock, before we allocate anything await SweepExpiredPreOrdersAsync(); var pendingItems = await _preorderDbContext.GetPendingItemsForProductsAsync(productIds); if (!pendingItems.Any()) { Console.WriteLine("[PreOrderConversion] No pending preorder items — done."); return; } // Date window for eligibility: // - LOWER bound (today): NEVER convert a preorder whose delivery date has already // passed. Once expired it is closed and must not receive fresh stock. Without this // a PartiallyFulfilled expired preorder (which the sweep leaves alone because it is // already on a real order) keeps getting topped up from every later shipment forever. // - UPPER bound (today + PreOrderConversionWindowDays): skip deliveries too far out; // with bi-weekly trucks a later document will serve them, and converting now would // steal stock from earlier deliveries that legitimately need it. var today = DateTime.UtcNow.Date; var conversionCutoff = today.AddDays(FruitBankPluginConst.PreOrderConversionWindowDays); var pendingPreOrderIds = pendingItems.Select(i => i.PreOrderId).Distinct().ToList(); var parentPreOrders = await _preorderDbContext.PreOrders .GetAll(false) .Where(p => pendingPreOrderIds.Contains(p.Id)) .ToListAsync(); var eligiblePreOrderIds = parentPreOrders .Where(p => p.DateOfReceipt.Date >= today && p.DateOfReceipt.Date <= conversionCutoff) .Select(p => p.Id) .ToHashSet(); pendingItems = pendingItems.Where(i => eligiblePreOrderIds.Contains(i.PreOrderId)).ToList(); if (!pendingItems.Any()) { Console.WriteLine($"[PreOrderConversion] No pending preorders within the conversion window " + $"(today .. +{FruitBankPluginConst.PreOrderConversionWindowDays} days) — expired or too-far-out skipped."); return; } Console.WriteLine($"[PreOrderConversion] {pendingItems.Count} items eligible " + $"(within {FruitBankPluginConst.PreOrderConversionWindowDays}-day window)."); // Convertibility filter: only fulfil preorders that can actually produce an order — // those already linked to an order (append path), OR whose customer + billing address // resolve (new-order path). This guarantees the invariant "item Fulfilled ⇒ order // created ⇒ stock deducted", which is what lets BuildIncomingQuantityPoolAsync trust // AvailableQuantity without re-subtracting committed allocations (K7M2). Items of // non-convertible preorders stay Pending (picked up once fixed, or dropped at expiry). var convertiblePreOrderIds = new HashSet(); foreach (var p in parentPreOrders.Where(p => eligiblePreOrderIds.Contains(p.Id))) { if (p.OrderId != null) { convertiblePreOrderIds.Add(p.Id); continue; } var validationCustomer = await _customerService.GetCustomerByIdAsync(p.CustomerId); if (validationCustomer == null) continue; if (await ResolveBillingAddressIdAsync(validationCustomer) > 0) convertiblePreOrderIds.Add(p.Id); } pendingItems = pendingItems.Where(i => convertiblePreOrderIds.Contains(i.PreOrderId)).ToList(); if (!pendingItems.Any()) { Console.WriteLine("[PreOrderConversion] No convertible preorders (missing customer/billing address) — skipped."); return; } var incomingPool = await BuildIncomingQuantityPoolAsync(productIds); // Track which items were newly resolved in THIS run, grouped by preorder // Key: preorderId Value: list of items whose status changed in this run var newlyResolvedByPreOrder = new Dictionary>(); // Quantity each item gained THIS run (delta), keyed by item id. Order items and // stock deductions use this delta — not the cumulative FulfilledQuantity — so a // partial item topped up across documents is not deducted/billed twice. var gainedByItemId = new Dictionary(); foreach (var item in pendingItems) { var prevFulfilled = item.FulfilledQuantity; if (!incomingPool.TryGetValue(item.ProductId, out var available) || available <= 0) { // No stock available in this document run — leave item Pending // so it can be picked up by a future document. The expiry sweep // above handles permanent closure once DateOfReceipt is past. continue; } else { var fulfill = Math.Min(item.RequestedQuantity - item.FulfilledQuantity, available); item.FulfilledQuantity += fulfill; incomingPool[item.ProductId] -= fulfill; gainedByItemId[item.Id] = fulfill; item.Status = item.FulfilledQuantity >= item.RequestedQuantity ? PreOrderItemStatus.Fulfilled : item.FulfilledQuantity > 0 ? PreOrderItemStatus.PartiallyFulfilled : PreOrderItemStatus.Dropped; await _preorderDbContext.PreOrderItems.UpdateAsync(item); } // Only track this item if something actually changed this run // (i.e. it gained fulfilled quantity or got dropped) var gainedQuantity = item.FulfilledQuantity - prevFulfilled; bool wasDropped = item.Status == PreOrderItemStatus.Dropped && prevFulfilled == 0; if (gainedQuantity > 0 || wasDropped) { if (!newlyResolvedByPreOrder.ContainsKey(item.PreOrderId)) newlyResolvedByPreOrder[item.PreOrderId] = new List(); newlyResolvedByPreOrder[item.PreOrderId].Add(item); } Console.WriteLine($"[PreOrderConversion] Item #{item.Id} (product {item.ProductId}): " + $"requested={item.RequestedQuantity}, fulfilled={item.FulfilledQuantity}, " + $"gained={item.FulfilledQuantity - prevFulfilled}, status={item.Status}"); } // Process each affected preorder foreach (var (preorderId, changedItems) in newlyResolvedByPreOrder) { await _preorderDbContext.RefreshPreOrderStatusAsync(preorderId); var preorder = await _preorderDbContext.PreOrders.GetByIdAsync(preorderId); if (preorder == null) continue; // Items newly gaining fulfilled quantity in this run var newlyFulfilled = changedItems .Where(i => i.FulfilledQuantity - 0 > 0 && (i.Status == PreOrderItemStatus.Fulfilled || i.Status == PreOrderItemStatus.PartiallyFulfilled)) .ToList(); // Items dropped in this run (no stock at all) var newlyDropped = changedItems .Where(i => i.Status == PreOrderItemStatus.Dropped) .ToList(); // Acquire a per-preorder lock BEFORE checking OrderId. // Without this, 3 concurrent conversions (one per shipping item event) // all read OrderId=null and each creates a separate order. // Re-reading inside the lock ensures only one creates the order. var sem = GetPreorderLock(preorderId); await sem.WaitAsync(); try { var preorderLocked = await _preorderDbContext.PreOrders.GetByIdAsync(preorderId); if (preorderLocked == null) continue; var newlyFulfilled2 = changedItems .Where(i => i.FulfilledQuantity > 0 && (i.Status == PreOrderItemStatus.Fulfilled || i.Status == PreOrderItemStatus.PartiallyFulfilled)) .ToList(); var newlyDropped2 = changedItems .Where(i => i.Status == PreOrderItemStatus.Dropped) .ToList(); if (preorderLocked.OrderId == null) { if (newlyFulfilled2.Any() || newlyDropped2.Any()) await CreateOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId, gainedByItemId); } else { await AppendItemsToOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId, gainedByItemId); } } finally { sem.Release(); } } Console.WriteLine($"[PreOrderConversion] Done. {newlyResolvedByPreOrder.Count} preorders affected."); } // ── Expiry sweep ─────────────────────────────────────────────────────────── /// /// Closes all preorders whose delivery DATE has passed (strictly before today). Never-fulfilled /// items (FulfilledQuantity == 0) become Dropped. Partially fulfilled items are finalized at the /// delivered quantity (RequestedQuantity ← FulfilledQuantity, status Fulfilled) so the undelivered /// remainder is dropped and no longer shows as open demand — the shortfall is recorded as a note on /// the linked order. Fully fulfilled items are untouched. The delivered quantities already made it /// into a real order and are never topped up again (the conversion window also excludes past-date /// preorders). Same-day preorders stay active for the whole delivery day. Called at the start of every run. /// public async Task SweepExpiredPreOrdersAsync() { // Expired = delivery date strictly before today. Compare against today-midnight // (a client-side constant) so the SQL stays a plain datetime '<' — LinqToDB cannot // translate a .Date applied to the column itself. var now = DateTime.UtcNow; // Find preorders that are past their receipt date — fetch by date only, // then filter by status in memory (LinqToDB can't translate enum comparisons) var expiredPreOrders = (await _preorderDbContext.PreOrders .GetAll(false) .Where(p => p.DateOfReceipt < now.Date) .ToListAsync()) .Where(p => p.Status == PreOrderStatus.Pending || p.Status == PreOrderStatus.PartiallyFulfilled) .ToList(); if (!expiredPreOrders.Any()) return; Console.WriteLine($"[PreOrderConversion] Sweeping {expiredPreOrders.Count} expired preorders"); foreach (var preorder in expiredPreOrders) { var items = await _preorderDbContext.PreOrderItems .GetAllByPreOrderIdAsync(preorder.Id) .ToListAsync(); // Drop the items that were never fulfilled (quantity-based, robust against // unreliable status enum reads): the whole line becomes Dropped. var neverFulfilled = items.Where(i => i.FulfilledQuantity == 0).ToList(); foreach (var item in neverFulfilled) { item.Status = PreOrderItemStatus.Dropped; await _preorderDbContext.PreOrderItems.UpdateAsync(item); } // Finalize partially fulfilled items: the delivered quantity stays on the order, // the never-to-be-delivered remainder is dropped. Close the line at the delivered // amount (so it no longer shows as open demand) and record the shortfall on the // linked order for audit — the original requested quantity survives in that note. var partiallyFulfilled = items.Where(i => i.FulfilledQuantity > 0 && i.FulfilledQuantity < i.RequestedQuantity).ToList(); foreach (var item in partiallyFulfilled) { var droppedRemainder = item.RequestedQuantity - item.FulfilledQuantity; var originalRequested = item.RequestedQuantity; item.RequestedQuantity = item.FulfilledQuantity; item.Status = PreOrderItemStatus.Fulfilled; await _preorderDbContext.PreOrderItems.UpdateAsync(item); if (preorder.OrderId.HasValue) { await _orderService.InsertOrderNoteAsync(new OrderNote { OrderId = preorder.OrderId.Value, Note = $"Előrendelés #{preorder.Id} lejárt — termék #{item.ProductId}: " + $"{item.FulfilledQuantity}/{originalRequested} db teljesült, " + $"{droppedRemainder} db ejtve (lejárat után nem teljesíthető).", DisplayToCustomer = false, CreatedOnUtc = DateTime.UtcNow }); } Console.WriteLine($"[PreOrderConversion] Expired preorder #{preorder.Id} item #{item.Id}: " + $"closed at {item.FulfilledQuantity}/{originalRequested}, dropped remainder {droppedRemainder}."); } // Recalculate header status await _preorderDbContext.RefreshPreOrderStatusAsync(preorder.Id); var hadAnyFulfillment = items.Any(i => i.Status == PreOrderItemStatus.Fulfilled || i.Status == PreOrderItemStatus.PartiallyFulfilled); Console.WriteLine($"[PreOrderConversion] Expired preorder #{preorder.Id}: " + $"{neverFulfilled.Count} items dropped, {partiallyFulfilled.Count} partials finalized, " + $"hadFulfillment={hadAnyFulfillment}, orderId={preorder.OrderId}"); // TODO: Send expiry notification if nothing was ever fulfilled // (fully unfulfilled preorders — customer should be notified) // if (!hadAnyFulfillment) // await _fruitBankNotificationService.SendPreOrderExpiredNotificationAsync(preorder); } } // ── Create new order (first document that fulfills anything) ────────────── private async Task CreateOrderAsync( PreOrder preorder, List fulfilledItems, List droppedItems, int shippingDocumentId, IReadOnlyDictionary gainedByItemId) { var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId); if (customer == null) { // Safety net — the convertibility filter should have excluded this preorder. Console.WriteLine($"[PreOrderConversion] Customer {preorder.CustomerId} not found — skipping order creation for preorder #{preorder.Id}"); return; } var billingAddressId = await ResolveBillingAddressIdAsync(customer); if (billingAddressId == 0) { // Safety net — the convertibility filter should have excluded this preorder. Console.WriteLine($"[PreOrderConversion] No billing address for customer {customer.Id} — skipping for preorder #{preorder.Id}"); return; } var orderTotal = await CalculateTotalAsync(fulfilledItems); var order = new Order { OrderGuid = Guid.NewGuid(), StoreId = preorder.StoreId, CustomerId = preorder.CustomerId, BillingAddressId = billingAddressId, OrderStatusId = (int)OrderStatus.Pending, PaymentStatusId = (int)PaymentStatus.Pending, ShippingStatusId = (int)ShippingStatus.ShippingNotRequired, PaymentMethodSystemName = "Payments.CheckMoneyOrder", CustomerLanguageId = 1, CustomerTaxDisplayTypeId = 0, OrderSubtotalInclTax = orderTotal, OrderSubtotalExclTax = Math.Round(orderTotal / 1.27m, 2), OrderSubTotalDiscountInclTax = 0m, OrderSubTotalDiscountExclTax = 0m, OrderShippingInclTax = 0m, OrderShippingExclTax = 0m, PaymentMethodAdditionalFeeInclTax = 0m, PaymentMethodAdditionalFeeExclTax = 0m, TaxRates = "0:0;", OrderTax = 0m, OrderTotal = orderTotal, RefundedAmount = 0m, CustomerCurrencyCode = "HUF", CurrencyRate = 1m, OrderDiscount = 0m, CheckoutAttributeDescription = string.Empty, CheckoutAttributesXml = string.Empty, CustomerIp = string.Empty, AllowStoringCreditCardNumber = false, CardType = string.Empty, CardName = string.Empty, CardNumber = string.Empty, MaskedCreditCardNumber = string.Empty, CardCvv2 = string.Empty, CardExpirationMonth = string.Empty, CardExpirationYear = string.Empty, AuthorizationTransactionId = string.Empty, AuthorizationTransactionCode = string.Empty, AuthorizationTransactionResult = string.Empty, CaptureTransactionId = string.Empty, CaptureTransactionResult = string.Empty, SubscriptionTransactionId = string.Empty, PaidDateUtc = null, ShippingMethod = string.Empty, ShippingRateComputationMethodSystemName = string.Empty, Deleted = false, CreatedOnUtc = DateTime.UtcNow, CustomOrderNumber = string.Empty }; await _dbContext.Orders.InsertAsync(order); order.CustomOrderNumber = order.Id.ToString(); await _dbContext.Orders.UpdateAsync(order); // Save OrderId back on the PreOrder so future documents can find it preorder.OrderId = order.Id; preorder.UpdatedOnUtc = DateTime.UtcNow; await _preorderDbContext.PreOrders.UpdateAsync(preorder); // DateOfReceipt generic attribute await _dbContext.GenericAttributes.InsertAsync(new Nop.Core.Domain.Common.GenericAttribute { EntityId = order.Id, KeyGroup = nameof(Order), Key = "DateOfReceipt", Value = preorder.DateOfReceipt.ToString("O"), StoreId = preorder.StoreId, CreatedOrUpdatedDateUTC = DateTime.UtcNow }); await InsertOrderItemsAsync(order, fulfilledItems, gainedByItemId); // Recalculate header totals from the actual inserted order items rather than the // PreOrderItem snapshot price: that snapshot is 0 for OrderDraft-origin preorders // (priced at conversion) and may be stale for others (price drift since placement), // so the order items are the authoritative source — same approach as AppendItemsToOrderAsync. var insertedItems = await _dbContext.OrderItems.Table .Where(oi => oi.OrderId == order.Id) .ToListAsync(); var recalculatedTotal = insertedItems.Sum(oi => oi.PriceInclTax); order.OrderSubtotalInclTax = recalculatedTotal; order.OrderSubtotalExclTax = Math.Round(recalculatedTotal / 1.27m, 2); order.OrderTotal = recalculatedTotal; await _dbContext.Orders.UpdateAsync(order); await InsertOrderNoteAsync(order.Id, preorder.Id, shippingDocumentId, fulfilledItems, droppedItems); // Fire event so existing handlers (EventConsumer etc.) run await _eventPublisher.PublishAsync(new OrderPlacedEvent(order)); // TODO: Send "FruitBank.PreOrderConverted.CustomerNotification" email // summarising fulfilled items, dropped items, order ID, DateOfReceipt // await _fruitBankNotificationService.SendPreOrderConvertedNotificationAsync(order, preorder, fulfilledItems, droppedItems); Console.WriteLine($"[PreOrderConversion] Created Order #{order.Id} from PreOrder #{preorder.Id} — " + $"{fulfilledItems.Count} fulfilled, {droppedItems.Count} dropped, total {orderTotal:N0} Ft"); } // ── Append to existing order (subsequent documents) ─────────────────────── private async Task AppendItemsToOrderAsync( PreOrder preorder, List newlyFulfilled, List newlyDropped, int shippingDocumentId, IReadOnlyDictionary gainedByItemId) { var order = await _dbContext.Orders.GetByIdAsync(preorder.OrderId!.Value); if (order == null) { Console.WriteLine($"[PreOrderConversion] PreOrder #{preorder.Id} references Order #{preorder.OrderId} which no longer exists — creating fresh"); preorder.OrderId = null; await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId, gainedByItemId); return; } if (!newlyFulfilled.Any() && !newlyDropped.Any()) { Console.WriteLine($"[PreOrderConversion] PreOrder #{preorder.Id}: no new items to append to Order #{order.Id}"); return; } // Append new OrderItems for the quantity gained THIS run (delta) only await InsertOrderItemsAsync(order, newlyFulfilled, gainedByItemId); // Recalculate order total from all order items var allItems = await _dbContext.OrderItems.Table .Where(oi => oi.OrderId == order.Id) .ToListAsync(); var newTotal = 0m; foreach (var oi in allItems) newTotal += oi.PriceInclTax; order.OrderTotal = newTotal; order.OrderSubtotalInclTax = newTotal; order.OrderSubtotalExclTax = Math.Round(newTotal / 1.27m, 2); await _dbContext.Orders.UpdateAsync(order); // Add a note for this document's contribution await InsertOrderNoteAsync(order.Id, preorder.Id, shippingDocumentId, newlyFulfilled, newlyDropped); // TODO: Send update notification email (same template as initial, but framed as an update) // await _fruitBankNotificationService.SendPreOrderConvertedNotificationAsync(order, preorder, newlyFulfilled, newlyDropped); Console.WriteLine($"[PreOrderConversion] Appended {newlyFulfilled.Count} items to Order #{order.Id} " + $"from PreOrder #{preorder.Id} via document #{shippingDocumentId}. " + $"New total: {newTotal:N0} Ft"); } // ── Shared helpers ──────────────────────────────────────────────────────── private async Task InsertOrderItemsAsync(Order order, List items, IReadOnlyDictionary gainedByItemId) { var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId); var store = await _storeContext.GetCurrentStoreAsync(); foreach (var item in items) { // Quantity to add to the order / deduct from stock THIS run = the delta gained // this run (equals FulfilledQuantity for a first-time conversion, the partial // top-up amount for an append). Falls back to the cumulative value if unknown. var quantityToAdd = gainedByItemId != null && gainedByItemId.TryGetValue(item.Id, out var gained) ? gained : item.FulfilledQuantity; if (quantityToAdd <= 0) continue; var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true); if (productDto == null) continue; var product = await _productService.GetProductByIdAsync(item.ProductId); if (product == null) continue; // Recalculate price at conversion time — may have changed since preorder was placed decimal unitPriceInclTax; if (customer != null && _customPriceCalculationService != null) { var calc = await _customPriceCalculationService.GetFinalPriceAsync( product, customer, store, includeDiscounts: true); unitPriceInclTax = calc.finalPrice; } else { unitPriceInclTax = item.UnitPriceInclTax; } var unitPriceExclTax = Math.Round(unitPriceInclTax / 1.27m, 4); var priceInclTax = productDto.IsMeasurable ? 0m : unitPriceInclTax * quantityToAdd; var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * quantityToAdd; var orderItem = new OrderItem { OrderItemGuid = Guid.NewGuid(), OrderId = order.Id, ProductId = item.ProductId, Quantity = quantityToAdd, UnitPriceInclTax = unitPriceInclTax, UnitPriceExclTax = unitPriceExclTax, PriceInclTax = priceInclTax, PriceExclTax = priceExclTax, DiscountAmountInclTax = 0m, DiscountAmountExclTax = 0m, OriginalProductCost = 0m, AttributeDescription = string.Empty, AttributesXml = string.Empty, DownloadCount = 0, IsDownloadActivated = false, LicenseDownloadId = 0, RentalStartDateUtc = null, RentalEndDateUtc = null }; // Use the service (fires NopCommerce events) instead of direct DB insert await _orderService.InsertOrderItemAsync(orderItem); // Deduct from stock — same as CustomOrderController and FruitBankOrderItemService await _productService.AdjustInventoryAsync( product, -quantityToAdd, string.Empty, $"Előrendelés #{item.PreOrderId} — rendelés #{order.Id} létrehozása"); } } // Resolves the billing address id for a customer: the customer's own BillingAddressId, // else the first CustomerAddressMapping. Returns 0 if none (preorder not convertible). private async Task ResolveBillingAddressIdAsync(Nop.Core.Domain.Customers.Customer customer) { var billingAddressId = customer.BillingAddressId ?? 0; if (billingAddressId == 0) { var addrMapping = await _dbContext.CustomerAddressMappings.Table .Where(m => m.CustomerId == customer.Id) .FirstOrDefaultAsync(); billingAddressId = addrMapping?.AddressId ?? 0; } return billingAddressId; } private async Task InsertOrderNoteAsync( int orderId, int preorderId, int shippingDocumentId, List fulfilled, List dropped) { var fulfilledDesc = fulfilled.Any() ? $"Teljesített: {string.Join(", ", fulfilled.Select(i => $"#{i.ProductId} ({i.FulfilledQuantity} db)"))}" : "Nincs teljesített tétel"; var droppedDesc = dropped.Any() ? $"Ejtett: {string.Join(", ", dropped.Select(i => $"#{i.ProductId}"))}" : string.Empty; var docRef = shippingDocumentId > 0 ? $"szállítási dokumentum #{shippingDocumentId}" : "azonnali készletből (előrendelés leadásakor)"; var note = new OrderNote { OrderId = orderId, Note = $"Előrendelés #{preorderId} — {docRef}. " + $"{fulfilledDesc}. {droppedDesc}".TrimEnd('.', ' ') + ".", DisplayToCustomer = false, CreatedOnUtc = DateTime.UtcNow }; await _orderService.InsertOrderNoteAsync(note); } // ── IncomingQuantity sync ──────────────────────────────────────── public async Task SyncIncomingQuantityAsync(int productId, int oldQty, int newQty) { var delta = newQty - oldQty; if (delta == 0 || productId <= 0) return; var storeId = (await _storeContext.GetCurrentStoreAsync()).Id; var current = await _fruitBankAttributeService .GetGenericAttributeValueAsync( productId, nameof(IIncomingQuantity.IncomingQuantity), storeId); var updated = Math.Max(0, current + delta); await _fruitBankAttributeService .InsertOrUpdateGenericAttributeAsync( productId, nameof(IIncomingQuantity.IncomingQuantity), updated, storeId); Console.WriteLine($"[PreOrderConversion] SyncIncomingQty product #{productId}: {current}+({delta})={updated}"); } private async Task CalculateTotalAsync(List items) { var total = 0m; foreach (var item in items) { var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true); if (productDto == null || productDto.IsMeasurable) continue; total += item.UnitPriceInclTax * item.FulfilledQuantity; } return total; } private async Task> BuildIncomingQuantityPoolAsync(IList productIds) { // AvailableQuantity = StockQuantity + IncomingQuantity, from the live product row. // Every fulfilled preorder item is deducted from StockQuantity at order creation // (InsertOrderItemsAsync → AdjustInventoryAsync), so AvailableQuantity ALREADY // reflects all prior allocations. We must NOT subtract committed quantities again — // that double-counted and under-allocated (PREORDER_ISSUES MGFBANKPLUG-PREO-B-K7M2). // The invariant that makes this safe (Fulfilled ⇒ order created ⇒ stock deducted) is // enforced up-front by the convertibility filter in ConvertPreOrdersForProductsAsync. var productDtos = await _dbContext.ProductDtos .GetAllByIds(productIds, loadRelations: false) .ToListAsync(); return productDtos.ToDictionary(p => p.Id, p => Math.Max(0, p.AvailableQuantity)); } }