Mango.Nop.Plugins/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs

697 lines
34 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<int, SemaphoreSlim>
_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<int> 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<int>();
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<int, List<PreOrderItem>>();
// 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<int, int>();
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<PreOrderItem>();
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 ───────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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<PreOrderItem> fulfilledItems,
List<PreOrderItem> droppedItems,
int shippingDocumentId,
IReadOnlyDictionary<int, int> 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<PreOrderItem> newlyFulfilled,
List<PreOrderItem> newlyDropped,
int shippingDocumentId,
IReadOnlyDictionary<int, int> 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<PreOrderItem> items, IReadOnlyDictionary<int, int> 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<int> 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<PreOrderItem> fulfilled, List<PreOrderItem> 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<Product, int>(
productId, nameof(IIncomingQuantity.IncomingQuantity), storeId);
var updated = Math.Max(0, current + delta);
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Product, int>(
productId, nameof(IIncomingQuantity.IncomingQuantity), updated, storeId);
Console.WriteLine($"[PreOrderConversion] SyncIncomingQty product #{productId}: {current}+({delta})={updated}");
}
private async Task<decimal> CalculateTotalAsync(List<PreOrderItem> 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<Dictionary<int, int>> BuildIncomingQuantityPoolAsync(IList<int> 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));
}
}