Merge branch 'FruitBank_v0.0.8.0' of https://git.aycode.com/Adam/Mango.Nop.Plugins into FruitBank_v0.0.8.0

This commit is contained in:
Loretta 2026-07-10 15:38:22 +02:00
commit 4081bddfcc
5 changed files with 240 additions and 88 deletions

View File

@ -377,17 +377,67 @@ public partial class PreOrderAdminController : BasePluginController
if (preorder.Status == PreOrderStatus.Cancelled) if (preorder.Status == PreOrderStatus.Cancelled)
return Json(new { success = false, error = "Már visszavont előrendelés" }); return Json(new { success = false, error = "Már visszavont előrendelés" });
// Only not-yet-fulfilled preorders can be cancelled — once a real order // Gate on OrderId (always reliable), not on the stored status: once a real order
// exists (OrderId set at first fulfilment), cancelling the preorder must // exists (OrderId set at first fulfilment), cancelling the preorder must not silently
// not silently undo it. // undo it. A preorder with no OrderId has never been converted → it is pending and
if (preorder.Status != PreOrderStatus.Pending || preorder.OrderId.HasValue) // cancellable. This is robust even for legacy rows whose stored status was never
return Json(new { success = false, error = "Csak függőben lévő előrendelés vonható vissza" }); // persisted correctly (see the StatusId entity fix).
if (preorder.OrderId.HasValue)
return Json(new { success = false, error = "Rendeléssé konvertált előrendelés nem vonható vissza" });
// Sets header → Cancelled and drops all still-active items → Dropped // Sets header → Cancelled and drops all still-active items → Dropped
await _preorderDbContext.CancelPreOrderAsync(id); await _preorderDbContext.CancelPreOrderAsync(id);
return Json(new { success = true }); return Json(new { success = true });
} }
// ── ONE-TIME HEALING: recompute stored statuses from quantities ─────────────
/// <summary>
/// Before the StatusId entity fix, the Status enum column was never persisted (stuck at 0).
/// This recomputes every item's status from its quantities, finalizes expired preorders via
/// the sweep, and refreshes header statuses. Idempotent — safe to run more than once.
/// Trigger once after deploying the entity fix: POST Admin/PreOrders/RecomputeStatuses.
/// (Cancelled state cannot be recovered — it was never stored — so cancelled preorders are
/// recomputed from quantities; re-cancel them if needed.)
/// </summary>
[HttpPost]
[Route("Admin/PreOrders/RecomputeStatuses")]
public async Task<IActionResult> RecomputeStatuses()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, error = "Access denied" });
var allItems = await _preorderDbContext.PreOrderItems.GetAll().ToListAsync();
var itemsFixed = 0;
foreach (var item in allItems)
{
var derived = item.FulfilledQuantity <= 0
? PreOrderItemStatus.Pending
: item.FulfilledQuantity >= item.RequestedQuantity
? PreOrderItemStatus.Fulfilled
: PreOrderItemStatus.PartiallyFulfilled;
if (item.Status != derived)
{
item.Status = derived;
await _preorderDbContext.PreOrderItems.UpdateAsync(item);
itemsFixed++;
}
}
// Finalize expired preorders (drops never-fulfilled items, closes partials) —
// the same logic every conversion run uses.
await _preorderConversionService.SweepExpiredPreOrdersAsync();
// Refresh every header from the (now persisted) item states.
var preorderIds = allItems.Select(i => i.PreOrderId).Distinct().ToList();
foreach (var pid in preorderIds)
await _preorderDbContext.RefreshPreOrderStatusAsync(pid);
return Json(new { success = true, itemsFixed, preordersRefreshed = preorderIds.Count });
}
// ── DEMAND LIST ─────────────────────────────────────────────────────────── // ── DEMAND LIST ───────────────────────────────────────────────────────────
[HttpPost] [HttpPost]

View File

@ -38,7 +38,8 @@ public class PreOrderDbTable : MgDbTableBase<PreOrder>
public IQueryable<PreOrder> GetAllPendingAsync(bool loadRelations) public IQueryable<PreOrder> GetAllPendingAsync(bool loadRelations)
{ {
var pendingStatuses = new[] { PreOrderStatus.Pending, PreOrderStatus.PartiallyFulfilled }; // Filter on the mapped StatusId column — Status is [NotColumn] and not SQL-translatable.
return GetAll(loadRelations).Where(p => pendingStatuses.Contains(p.Status)); var pendingStatusIds = new[] { (int)PreOrderStatus.Pending, (int)PreOrderStatus.PartiallyFulfilled };
return GetAll(loadRelations).Where(p => pendingStatusIds.Contains(p.StatusId));
} }
} }

View File

@ -34,9 +34,10 @@ public class PreOrderItemDbTable : MgDbTableBase<PreOrderItem>
/// </summary> /// </summary>
public IQueryable<PreOrderItem> GetPendingByProductIdOrderedAsync(int productId) public IQueryable<PreOrderItem> GetPendingByProductIdOrderedAsync(int productId)
{ {
var pendingStatuses = new[] { PreOrderItemStatus.Pending, PreOrderItemStatus.PartiallyFulfilled }; // Filter on the mapped StatusId column — Status is [NotColumn] and not SQL-translatable.
var pendingStatusIds = new[] { (int)PreOrderItemStatus.Pending, (int)PreOrderItemStatus.PartiallyFulfilled };
return GetAll() return GetAll()
.Where(i => i.ProductId == productId && pendingStatuses.Contains(i.Status)) .Where(i => i.ProductId == productId && pendingStatusIds.Contains(i.StatusId))
.OrderBy(i => i.PreOrderId); .OrderBy(i => i.PreOrderId);
} }
} }

View File

@ -93,11 +93,16 @@ public partial class PreOrderConversionService
return; return;
} }
// Filter out preorders whose delivery date is more than PreOrderConversionWindowDays // Date window for eligibility:
// (4 days) away. With bi-weekly trucks, a delivery that far out will be served // - LOWER bound (today): NEVER convert a preorder whose delivery date has already
// by the next truck's document — converting now would steal stock from // passed. Once expired it is closed and must not receive fresh stock. Without this
// earlier deliveries that legitimately need it. // a PartiallyFulfilled expired preorder (which the sweep leaves alone because it is
var conversionCutoff = DateTime.UtcNow.Date.AddDays(FruitBankPluginConst.PreOrderConversionWindowDays); // 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 pendingPreOrderIds = pendingItems.Select(i => i.PreOrderId).Distinct().ToList();
var parentPreOrders = await _preorderDbContext.PreOrders var parentPreOrders = await _preorderDbContext.PreOrders
.GetAll(false) .GetAll(false)
@ -105,7 +110,7 @@ public partial class PreOrderConversionService
.ToListAsync(); .ToListAsync();
var eligiblePreOrderIds = parentPreOrders var eligiblePreOrderIds = parentPreOrders
.Where(p => p.DateOfReceipt.Date <= conversionCutoff) .Where(p => p.DateOfReceipt.Date >= today && p.DateOfReceipt.Date <= conversionCutoff)
.Select(p => p.Id) .Select(p => p.Id)
.ToHashSet(); .ToHashSet();
@ -113,20 +118,49 @@ public partial class PreOrderConversionService
if (!pendingItems.Any()) if (!pendingItems.Any())
{ {
Console.WriteLine($"[PreOrderConversion] All pending preorders are beyond the " + Console.WriteLine($"[PreOrderConversion] No pending preorders within the conversion window " +
$"{FruitBankPluginConst.PreOrderConversionWindowDays}-day window — skipped."); $"(today .. +{FruitBankPluginConst.PreOrderConversionWindowDays} days) — expired or too-far-out skipped.");
return; return;
} }
Console.WriteLine($"[PreOrderConversion] {pendingItems.Count} items eligible " + Console.WriteLine($"[PreOrderConversion] {pendingItems.Count} items eligible " +
$"(within {FruitBankPluginConst.PreOrderConversionWindowDays}-day window)."); $"(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); var incomingPool = await BuildIncomingQuantityPoolAsync(productIds);
// Track which items were newly resolved in THIS run, grouped by preorder // Track which items were newly resolved in THIS run, grouped by preorder
// Key: preorderId Value: list of items whose status changed in this run // Key: preorderId Value: list of items whose status changed in this run
var newlyResolvedByPreOrder = new Dictionary<int, List<PreOrderItem>>(); 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) foreach (var item in pendingItems)
{ {
var prevFulfilled = item.FulfilledQuantity; var prevFulfilled = item.FulfilledQuantity;
@ -143,6 +177,7 @@ public partial class PreOrderConversionService
var fulfill = Math.Min(item.RequestedQuantity - item.FulfilledQuantity, available); var fulfill = Math.Min(item.RequestedQuantity - item.FulfilledQuantity, available);
item.FulfilledQuantity += fulfill; item.FulfilledQuantity += fulfill;
incomingPool[item.ProductId] -= fulfill; incomingPool[item.ProductId] -= fulfill;
gainedByItemId[item.Id] = fulfill;
item.Status = item.FulfilledQuantity >= item.RequestedQuantity item.Status = item.FulfilledQuantity >= item.RequestedQuantity
? PreOrderItemStatus.Fulfilled ? PreOrderItemStatus.Fulfilled
@ -213,11 +248,11 @@ public partial class PreOrderConversionService
if (preorderLocked.OrderId == null) if (preorderLocked.OrderId == null)
{ {
if (newlyFulfilled2.Any() || newlyDropped2.Any()) if (newlyFulfilled2.Any() || newlyDropped2.Any())
await CreateOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId); await CreateOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId, gainedByItemId);
} }
else else
{ {
await AppendItemsToOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId); await AppendItemsToOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId, gainedByItemId);
} }
} }
finally { sem.Release(); } finally { sem.Release(); }
@ -229,23 +264,26 @@ public partial class PreOrderConversionService
// ── Expiry sweep ─────────────────────────────────────────────────────────── // ── Expiry sweep ───────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Closes all preorders whose DateOfReceipt is in the past and still have /// Closes all preorders whose delivery DATE has passed (strictly before today). Never-fulfilled
/// Pending or PartiallyFulfilled items. Any still-Pending items become Dropped. /// items (FulfilledQuantity == 0) become Dropped. Partially fulfilled items are finalized at the
/// Items that were already Fulfilled/PartiallyFulfilled stay as-is (those /// delivered quantity (RequestedQuantity ← FulfilledQuantity, status Fulfilled) so the undelivered
/// quantities already made it into a real order). /// remainder is dropped and no longer shows as open demand — the shortfall is recorded as a note on
/// Called at the start of every conversion run. /// 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> /// </summary>
private async Task SweepExpiredPreOrdersAsync() 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; var now = DateTime.UtcNow;
var activePreOrderStatuses = new[] { PreOrderStatus.Pending, PreOrderStatus.PartiallyFulfilled };
// Find preorders that are past their receipt date — fetch by date only, // Find preorders that are past their receipt date — fetch by date only,
// then filter by status in memory (LinqToDB can't translate enum comparisons) // then filter by status in memory (LinqToDB can't translate enum comparisons)
var expiredPreOrders = (await _preorderDbContext.PreOrders var expiredPreOrders = (await _preorderDbContext.PreOrders
.GetAll(false) .GetAll(false)
.Where(p => p.DateOfReceipt < now) .Where(p => p.DateOfReceipt < now.Date)
.ToListAsync()) .ToListAsync())
.Where(p => p.Status == PreOrderStatus.Pending || .Where(p => p.Status == PreOrderStatus.Pending ||
p.Status == PreOrderStatus.PartiallyFulfilled) p.Status == PreOrderStatus.PartiallyFulfilled)
@ -261,15 +299,46 @@ public partial class PreOrderConversionService
.GetAllByPreOrderIdAsync(preorder.Id) .GetAllByPreOrderIdAsync(preorder.Id)
.ToListAsync(); .ToListAsync();
// Drop only the items that were never fulfilled — already-fulfilled // Drop the items that were never fulfilled (quantity-based, robust against
// items stay as-is since they are already on a real order // unreliable status enum reads): the whole line becomes Dropped.
var stillPending = items.Where(i => i.Status == PreOrderItemStatus.Pending).ToList(); var neverFulfilled = items.Where(i => i.FulfilledQuantity == 0).ToList();
foreach (var item in stillPending) foreach (var item in neverFulfilled)
{ {
item.Status = PreOrderItemStatus.Dropped; item.Status = PreOrderItemStatus.Dropped;
await _preorderDbContext.PreOrderItems.UpdateAsync(item); 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 // Recalculate header status
await _preorderDbContext.RefreshPreOrderStatusAsync(preorder.Id); await _preorderDbContext.RefreshPreOrderStatusAsync(preorder.Id);
@ -278,7 +347,7 @@ public partial class PreOrderConversionService
i.Status == PreOrderItemStatus.PartiallyFulfilled); i.Status == PreOrderItemStatus.PartiallyFulfilled);
Console.WriteLine($"[PreOrderConversion] Expired preorder #{preorder.Id}: " + Console.WriteLine($"[PreOrderConversion] Expired preorder #{preorder.Id}: " +
$"{stillPending.Count} items dropped, " + $"{neverFulfilled.Count} items dropped, {partiallyFulfilled.Count} partials finalized, " +
$"hadFulfillment={hadAnyFulfillment}, orderId={preorder.OrderId}"); $"hadFulfillment={hadAnyFulfillment}, orderId={preorder.OrderId}");
// TODO: Send expiry notification if nothing was ever fulfilled // TODO: Send expiry notification if nothing was ever fulfilled
@ -294,26 +363,21 @@ public partial class PreOrderConversionService
PreOrder preorder, PreOrder preorder,
List<PreOrderItem> fulfilledItems, List<PreOrderItem> fulfilledItems,
List<PreOrderItem> droppedItems, List<PreOrderItem> droppedItems,
int shippingDocumentId) int shippingDocumentId,
IReadOnlyDictionary<int, int> gainedByItemId)
{ {
var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId); var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId);
if (customer == null) 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}"); Console.WriteLine($"[PreOrderConversion] Customer {preorder.CustomerId} not found — skipping order creation for preorder #{preorder.Id}");
return; return;
} }
var billingAddressId = customer.BillingAddressId ?? 0; var billingAddressId = await ResolveBillingAddressIdAsync(customer);
if (billingAddressId == 0)
{
var addrMapping = await _dbContext.CustomerAddressMappings.Table
.Where(m => m.CustomerId == customer.Id)
.FirstOrDefaultAsync();
billingAddressId = addrMapping?.AddressId ?? 0;
}
if (billingAddressId == 0) 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}"); Console.WriteLine($"[PreOrderConversion] No billing address for customer {customer.Id} — skipping for preorder #{preorder.Id}");
return; return;
} }
@ -389,7 +453,7 @@ public partial class PreOrderConversionService
CreatedOrUpdatedDateUTC = DateTime.UtcNow CreatedOrUpdatedDateUTC = DateTime.UtcNow
}); });
await InsertOrderItemsAsync(order, fulfilledItems); await InsertOrderItemsAsync(order, fulfilledItems, gainedByItemId);
// Recalculate header totals from the actual inserted order items rather than the // Recalculate header totals from the actual inserted order items rather than the
// PreOrderItem snapshot price: that snapshot is 0 for OrderDraft-origin preorders // PreOrderItem snapshot price: that snapshot is 0 for OrderDraft-origin preorders
@ -423,14 +487,15 @@ public partial class PreOrderConversionService
PreOrder preorder, PreOrder preorder,
List<PreOrderItem> newlyFulfilled, List<PreOrderItem> newlyFulfilled,
List<PreOrderItem> newlyDropped, List<PreOrderItem> newlyDropped,
int shippingDocumentId) int shippingDocumentId,
IReadOnlyDictionary<int, int> gainedByItemId)
{ {
var order = await _dbContext.Orders.GetByIdAsync(preorder.OrderId!.Value); var order = await _dbContext.Orders.GetByIdAsync(preorder.OrderId!.Value);
if (order == null) if (order == null)
{ {
Console.WriteLine($"[PreOrderConversion] PreOrder #{preorder.Id} references Order #{preorder.OrderId} which no longer exists — creating fresh"); Console.WriteLine($"[PreOrderConversion] PreOrder #{preorder.Id} references Order #{preorder.OrderId} which no longer exists — creating fresh");
preorder.OrderId = null; preorder.OrderId = null;
await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId); await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId, gainedByItemId);
return; return;
} }
@ -440,8 +505,8 @@ public partial class PreOrderConversionService
return; return;
} }
// Append new OrderItems for the newly fulfilled items only // Append new OrderItems for the quantity gained THIS run (delta) only
await InsertOrderItemsAsync(order, newlyFulfilled); await InsertOrderItemsAsync(order, newlyFulfilled, gainedByItemId);
// Recalculate order total from all order items // Recalculate order total from all order items
var allItems = await _dbContext.OrderItems.Table var allItems = await _dbContext.OrderItems.Table
@ -470,13 +535,21 @@ public partial class PreOrderConversionService
// ── Shared helpers ──────────────────────────────────────────────────────── // ── Shared helpers ────────────────────────────────────────────────────────
private async Task InsertOrderItemsAsync(Order order, List<PreOrderItem> items) private async Task InsertOrderItemsAsync(Order order, List<PreOrderItem> items, IReadOnlyDictionary<int, int> gainedByItemId)
{ {
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId); var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
var store = await _storeContext.GetCurrentStoreAsync(); var store = await _storeContext.GetCurrentStoreAsync();
foreach (var item in items) 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); var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true);
if (productDto == null) continue; if (productDto == null) continue;
@ -496,15 +569,15 @@ public partial class PreOrderConversionService
unitPriceInclTax = item.UnitPriceInclTax; unitPriceInclTax = item.UnitPriceInclTax;
} }
var unitPriceExclTax = Math.Round(unitPriceInclTax / 1.27m, 4); var unitPriceExclTax = Math.Round(unitPriceInclTax / 1.27m, 4);
var priceInclTax = productDto.IsMeasurable ? 0m : unitPriceInclTax * item.FulfilledQuantity; var priceInclTax = productDto.IsMeasurable ? 0m : unitPriceInclTax * quantityToAdd;
var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * item.FulfilledQuantity; var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * quantityToAdd;
var orderItem = new OrderItem var orderItem = new OrderItem
{ {
OrderItemGuid = Guid.NewGuid(), OrderItemGuid = Guid.NewGuid(),
OrderId = order.Id, OrderId = order.Id,
ProductId = item.ProductId, ProductId = item.ProductId,
Quantity = item.FulfilledQuantity, Quantity = quantityToAdd,
UnitPriceInclTax = unitPriceInclTax, UnitPriceInclTax = unitPriceInclTax,
UnitPriceExclTax = unitPriceExclTax, UnitPriceExclTax = unitPriceExclTax,
PriceInclTax = priceInclTax, PriceInclTax = priceInclTax,
@ -527,12 +600,27 @@ public partial class PreOrderConversionService
// Deduct from stock — same as CustomOrderController and FruitBankOrderItemService // Deduct from stock — same as CustomOrderController and FruitBankOrderItemService
await _productService.AdjustInventoryAsync( await _productService.AdjustInventoryAsync(
product, product,
-item.FulfilledQuantity, -quantityToAdd,
string.Empty, string.Empty,
$"Előrendelés #{item.PreOrderId} — rendelés #{order.Id} létrehozása"); $"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( private async Task InsertOrderNoteAsync(
int orderId, int preorderId, int shippingDocumentId, int orderId, int preorderId, int shippingDocumentId,
List<PreOrderItem> fulfilled, List<PreOrderItem> dropped) List<PreOrderItem> fulfilled, List<PreOrderItem> dropped)
@ -592,43 +680,17 @@ public partial class PreOrderConversionService
private async Task<Dictionary<int, int>> BuildIncomingQuantityPoolAsync(IList<int> productIds) private async Task<Dictionary<int, int>> BuildIncomingQuantityPoolAsync(IList<int> productIds)
{ {
// 1. AvailableQuantity from ProductDto already accounts for // AvailableQuantity = StockQuantity + IncomingQuantity, from the live product row.
// StockQuantity + IncomingQuantity (stock is allowed to go negative // Every fulfilled preorder item is deducted from StockQuantity at order creation
// to the limit of IncomingQuantity in the FruitBank stock model) // (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 var productDtos = await _dbContext.ProductDtos
.GetAllByIds(productIds, loadRelations: false) .GetAllByIds(productIds, loadRelations: false)
.ToListAsync(); .ToListAsync();
var availableByProduct = productDtos.ToDictionary( return productDtos.ToDictionary(p => p.Id, p => Math.Max(0, p.AvailableQuantity));
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;
} }
} }

File diff suppressed because one or more lines are too long