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; 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; } // Filter out preorders whose delivery date is more than PreorderConversionWindowDays // (4 days) away. With bi-weekly trucks, a delivery that far out will be served // by the next truck's document — converting now would steal stock from // earlier deliveries that legitimately need it. var conversionCutoff = DateTime.UtcNow.Date.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 <= conversionCutoff) .Select(p => p.Id) .ToHashSet(); pendingItems = pendingItems.Where(i => eligiblePreorderIds.Contains(i.PreorderId)).ToList(); if (!pendingItems.Any()) { Console.WriteLine($"[PreorderConversion] All pending preorders are beyond the " + $"{FruitBankPluginConst.PreorderConversionWindowDays}-day window — skipped."); return; } Console.WriteLine($"[PreorderConversion] {pendingItems.Count} items eligible " + $"(within {FruitBankPluginConst.PreorderConversionWindowDays}-day window)."); 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>(); 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; 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(); if (preorder.OrderId == null) { // First time any items are resolved → create the order if (newlyFulfilled.Any() || newlyDropped.Any()) { await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId); } } else { // Order already exists from a previous document → append new items only await AppendItemsToOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId); } } Console.WriteLine($"[PreorderConversion] Done. {newlyResolvedByPreorder.Count} preorders affected."); } // ── Expiry sweep ─────────────────────────────────────────────────────────── /// /// Closes all preorders whose DateOfReceipt is in the past and still have /// Pending or PartiallyFulfilled items. Any still-Pending items become Dropped. /// Items that were already Fulfilled/PartiallyFulfilled stay as-is (those /// quantities already made it into a real order). /// Called at the start of every conversion run. /// private async Task SweepExpiredPreordersAsync() { var now = DateTime.UtcNow; var activePreorderStatuses = new[] { PreorderStatus.Pending, PreorderStatus.PartiallyFulfilled }; // 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) .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 only the items that were never fulfilled — already-fulfilled // items stay as-is since they are already on a real order var stillPending = items.Where(i => i.Status == PreorderItemStatus.Pending).ToList(); foreach (var item in stillPending) { item.Status = PreorderItemStatus.Dropped; await _preorderDbContext.PreorderItems.UpdateAsync(item); } // 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}: " + $"{stillPending.Count} items dropped, " + $"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) { var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId); if (customer == null) { Console.WriteLine($"[PreorderConversion] Customer {preorder.CustomerId} not found — skipping order creation for preorder #{preorder.Id}"); return; } 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; } if (billingAddressId == 0) { 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.NotYetShipped, 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); 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) { 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); 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 newly fulfilled items only await InsertOrderItemsAsync(order, newlyFulfilled); // 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) { foreach (var item in items) { var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true); if (productDto == null) continue; var product = await _productService.GetProductByIdAsync(item.ProductId); if (product == null) continue; var unitPriceExclTax = Math.Round(item.UnitPriceInclTax / 1.27m, 4); var priceInclTax = productDto.IsMeasurable ? 0m : item.UnitPriceInclTax * item.FulfilledQuantity; var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * item.FulfilledQuantity; var orderItem = new OrderItem { OrderItemGuid = Guid.NewGuid(), OrderId = order.Id, ProductId = item.ProductId, Quantity = item.FulfilledQuantity, UnitPriceInclTax = item.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, -item.FulfilledQuantity, string.Empty, $"Előrendelés #{item.PreorderId} — rendelés #{order.Id} létrehozása"); } } 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) { // 1. AvailableQuantity from ProductDto already accounts for // StockQuantity + IncomingQuantity (stock is allowed to go negative // to the limit of IncomingQuantity in the FruitBank stock model) var productDtos = await _dbContext.ProductDtos .GetAllByIds(productIds, loadRelations: false) .ToListAsync(); var availableByProduct = productDtos.ToDictionary( p => p.Id, p => p.AvailableQuantity); var activeItemStatuses = new[] { PreorderItemStatus.Fulfilled, PreorderItemStatus.PartiallyFulfilled }; // 2. Subtract quantities already committed to preorders in previous runs // Fetch by productId only, filter by status in memory var allCommittedItems = await _preorderDbContext.PreorderItems.Table .Where(i => productIds.Contains(i.ProductId)) .ToListAsync(); var alreadyAllocated = allCommittedItems .Where(i => i.Status == PreorderItemStatus.Fulfilled || i.Status == PreorderItemStatus.PartiallyFulfilled) .GroupBy(i => i.ProductId) .Select(g => new { ProductId = g.Key, Allocated = g.Sum(i => i.FulfilledQuantity) }) .ToList(); var allocatedByProduct = alreadyAllocated.ToDictionary(x => x.ProductId, x => x.Allocated); // 3. Net pool = available − already committed to preorders var result = new Dictionary(); foreach (var productId in productIds) { var available = availableByProduct.TryGetValue(productId, out var avail) ? avail : 0; var committed = allocatedByProduct.TryGetValue(productId, out var alloc) ? alloc : 0; result[productId] = Math.Max(0, available - committed); } return result; } }