697 lines
34 KiB
C#
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));
|
|
}
|
|
}
|