581 lines
27 KiB
C#
581 lines
27 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;
|
||
|
||
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;
|
||
}
|
||
}
|