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;
}
}