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

581 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
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;
}
// 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<int, List<PreorderItem>>();
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<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();
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 ───────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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<PreorderItem> fulfilledItems,
List<PreorderItem> 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<PreorderItem> newlyFulfilled,
List<PreorderItem> 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<PreorderItem> 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<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)
{
// 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<int, int>();
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;
}
}