diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs
index e67d334..8862cda 100644
--- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs
@@ -377,17 +377,67 @@ public partial class PreOrderAdminController : BasePluginController
if (preorder.Status == PreOrderStatus.Cancelled)
return Json(new { success = false, error = "Már visszavont előrendelés" });
- // Only not-yet-fulfilled preorders can be cancelled — once a real order
- // exists (OrderId set at first fulfilment), cancelling the preorder must
- // not silently undo it.
- if (preorder.Status != PreOrderStatus.Pending || preorder.OrderId.HasValue)
- return Json(new { success = false, error = "Csak függőben lévő előrendelés vonható vissza" });
+ // Gate on OrderId (always reliable), not on the stored status: once a real order
+ // exists (OrderId set at first fulfilment), cancelling the preorder must not silently
+ // undo it. A preorder with no OrderId has never been converted → it is pending and
+ // cancellable. This is robust even for legacy rows whose stored status was never
+ // 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
await _preorderDbContext.CancelPreOrderAsync(id);
return Json(new { success = true });
}
+ // ── ONE-TIME HEALING: recompute stored statuses from quantities ─────────────
+
+ ///
+ /// 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.)
+ ///
+ [HttpPost]
+ [Route("Admin/PreOrders/RecomputeStatuses")]
+ public async Task 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 ───────────────────────────────────────────────────────────
[HttpPost]
diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PreorderDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PreorderDbTable.cs
index 9a6996b..f6f130b 100644
--- a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PreorderDbTable.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PreorderDbTable.cs
@@ -38,7 +38,8 @@ public class PreOrderDbTable : MgDbTableBase
public IQueryable GetAllPendingAsync(bool loadRelations)
{
- var pendingStatuses = new[] { PreOrderStatus.Pending, PreOrderStatus.PartiallyFulfilled };
- return GetAll(loadRelations).Where(p => pendingStatuses.Contains(p.Status));
+ // Filter on the mapped StatusId column — Status is [NotColumn] and not SQL-translatable.
+ var pendingStatusIds = new[] { (int)PreOrderStatus.Pending, (int)PreOrderStatus.PartiallyFulfilled };
+ return GetAll(loadRelations).Where(p => pendingStatusIds.Contains(p.StatusId));
}
}
diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PreorderItemDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PreorderItemDbTable.cs
index 3b0d711..7f721e8 100644
--- a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PreorderItemDbTable.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PreorderItemDbTable.cs
@@ -34,9 +34,10 @@ public class PreOrderItemDbTable : MgDbTableBase
///
public IQueryable 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()
- .Where(i => i.ProductId == productId && pendingStatuses.Contains(i.Status))
+ .Where(i => i.ProductId == productId && pendingStatusIds.Contains(i.StatusId))
.OrderBy(i => i.PreOrderId);
}
}
diff --git a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs
index bed377c..da19dca 100644
--- a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs
@@ -93,11 +93,16 @@ public partial class PreOrderConversionService
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);
+ // 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)
@@ -105,7 +110,7 @@ public partial class PreOrderConversionService
.ToListAsync();
var eligiblePreOrderIds = parentPreOrders
- .Where(p => p.DateOfReceipt.Date <= conversionCutoff)
+ .Where(p => p.DateOfReceipt.Date >= today && p.DateOfReceipt.Date <= conversionCutoff)
.Select(p => p.Id)
.ToHashSet();
@@ -113,20 +118,49 @@ public partial class PreOrderConversionService
if (!pendingItems.Any())
{
- Console.WriteLine($"[PreOrderConversion] All pending preorders are beyond the " +
- $"{FruitBankPluginConst.PreOrderConversionWindowDays}-day window — skipped.");
+ 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();
+ 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>();
+ // 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();
+
foreach (var item in pendingItems)
{
var prevFulfilled = item.FulfilledQuantity;
@@ -143,6 +177,7 @@ public partial class PreOrderConversionService
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
@@ -213,11 +248,11 @@ public partial class PreOrderConversionService
if (preorderLocked.OrderId == null)
{
if (newlyFulfilled2.Any() || newlyDropped2.Any())
- await CreateOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId);
+ await CreateOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId, gainedByItemId);
}
else
{
- await AppendItemsToOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId);
+ await AppendItemsToOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId, gainedByItemId);
}
}
finally { sem.Release(); }
@@ -229,23 +264,26 @@ public partial class PreOrderConversionService
// ── Expiry sweep ───────────────────────────────────────────────────────────
///
- /// 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.
+ /// 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.
///
- 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 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)
+ .Where(p => p.DateOfReceipt < now.Date)
.ToListAsync())
.Where(p => p.Status == PreOrderStatus.Pending ||
p.Status == PreOrderStatus.PartiallyFulfilled)
@@ -261,15 +299,46 @@ public partial class PreOrderConversionService
.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)
+ // 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);
@@ -278,7 +347,7 @@ public partial class PreOrderConversionService
i.Status == PreOrderItemStatus.PartiallyFulfilled);
Console.WriteLine($"[PreOrderConversion] Expired preorder #{preorder.Id}: " +
- $"{stillPending.Count} items dropped, " +
+ $"{neverFulfilled.Count} items dropped, {partiallyFulfilled.Count} partials finalized, " +
$"hadFulfillment={hadAnyFulfillment}, orderId={preorder.OrderId}");
// TODO: Send expiry notification if nothing was ever fulfilled
@@ -294,26 +363,21 @@ public partial class PreOrderConversionService
PreOrder preorder,
List fulfilledItems,
List droppedItems,
- int shippingDocumentId)
+ int shippingDocumentId,
+ IReadOnlyDictionary 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 = customer.BillingAddressId ?? 0;
- if (billingAddressId == 0)
- {
- var addrMapping = await _dbContext.CustomerAddressMappings.Table
- .Where(m => m.CustomerId == customer.Id)
- .FirstOrDefaultAsync();
- billingAddressId = addrMapping?.AddressId ?? 0;
- }
-
+ 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;
}
@@ -389,7 +453,7 @@ public partial class PreOrderConversionService
CreatedOrUpdatedDateUTC = DateTime.UtcNow
});
- await InsertOrderItemsAsync(order, fulfilledItems);
+ 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
@@ -423,14 +487,15 @@ public partial class PreOrderConversionService
PreOrder preorder,
List newlyFulfilled,
List newlyDropped,
- int shippingDocumentId)
+ int shippingDocumentId,
+ IReadOnlyDictionary 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);
+ await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId, gainedByItemId);
return;
}
@@ -440,8 +505,8 @@ public partial class PreOrderConversionService
return;
}
- // Append new OrderItems for the newly fulfilled items only
- await InsertOrderItemsAsync(order, newlyFulfilled);
+ // 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
@@ -470,13 +535,21 @@ public partial class PreOrderConversionService
// ── Shared helpers ────────────────────────────────────────────────────────
- private async Task InsertOrderItemsAsync(Order order, List items)
+ private async Task InsertOrderItemsAsync(Order order, List items, IReadOnlyDictionary 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;
@@ -496,15 +569,15 @@ public partial class PreOrderConversionService
unitPriceInclTax = item.UnitPriceInclTax;
}
var unitPriceExclTax = Math.Round(unitPriceInclTax / 1.27m, 4);
- var priceInclTax = productDto.IsMeasurable ? 0m : unitPriceInclTax * item.FulfilledQuantity;
- var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * item.FulfilledQuantity;
+ 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 = item.FulfilledQuantity,
+ Quantity = quantityToAdd,
UnitPriceInclTax = unitPriceInclTax,
UnitPriceExclTax = unitPriceExclTax,
PriceInclTax = priceInclTax,
@@ -527,12 +600,27 @@ public partial class PreOrderConversionService
// Deduct from stock — same as CustomOrderController and FruitBankOrderItemService
await _productService.AdjustInventoryAsync(
product,
- -item.FulfilledQuantity,
+ -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 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 fulfilled, List dropped)
@@ -592,43 +680,17 @@ public partial class PreOrderConversionService
private async Task> BuildIncomingQuantityPoolAsync(IList 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)
+ // 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();
- 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();
- 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;
+ return productDtos.ToDictionary(p => p.Id, p => Math.Max(0, p.AvailableQuantity));
}
}
diff --git a/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md b/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md
index af23385..d59e62b 100644
--- a/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md
+++ b/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md
@@ -9,7 +9,23 @@ Scope: bugs / inconsistent or observable edge-case behaviour in the pre-order su
## MGFBANKPLUG-PREO-B-K7M2: A konverziós pool dupla-számolja a már teljesített foglalásokat — alul-allokáció
-**Status:** Open (2026-06-15) · **Priority:** P2 · **Type:** B (megerősített bug)
+**Status:** Closed (2026-06-29) — javítva · **Priority:** P2 · **Type:** B (megerősített bug)
+
+### Resolution (2026-06-29)
+Kijavítva a `PreOrderConversionService`-ben, három összefüggő lépéssel (mind a `Services/PreorderConversionService.cs`-ben):
+1. **Konvertálhatósági előszűrés** a `ConvertPreOrdersForProductsAsync`-ben: csak az a preorder kap teljesítést, ami tényleg rendelést tud létrehozni (van `OrderId` = append-út, vagy feloldható customer + számlázási cím = új-rendelés-út, az új `ResolveBillingAddressIdAsync` helperrel). Ezzel garantált az invariáns: **`Fulfilled ⇒ rendelés létrejön ⇒ készlet levonva`**. A nem konvertálható preorderek tételei `Pending`-ben maradnak.
+2. **Delta-alapú order-item**: az `InsertOrderItemsAsync` mostantól a futásban szerzett mennyiséget (`gainedByItemId` delta) viszi a tételbe / vonja le a készletből, nem a kumulatív `FulfilledQuantity`-t. Ez javítja a **vele összefonódó, korábban elfedett második hibát**: részleges utántöltésnél (multi-dokumentum) az append a *teljes* mennyiséget vonta le → dupla-levonás + duplikált order-item.
+3. A garantált invariáns mellett a `BuildIncomingQuantityPoolAsync` dupláló `alreadyAllocated` levonása **eltávolítva**: `pool = AvailableQuantity` (ami az `AdjustInventoryAsync` miatt már tartalmazza a korábbi foglalásokat).
+
+**Eredmény:** a reprodukciós eset (`Stock=0, Incoming=10`, A 6-ot kap, B 6-ot kér) most helyesen B-nek 4-et ad; a részleges utántöltés is helyesen a deltát viszi.
+
+**Maradék (elhanyagolható) él:** ha a customer/cím a futás közben (előszűrés után, rendeléslétrehozás előtt) törlődne — extrém versenyhelyzet —, az item `Fulfilled` maradhatna rendelés nélkül. A `CreateOrderAsync` korai return-jei safety netként megmaradtak; a teljes védelemhez a státusz-mentést a rendeléslétrehozás *után* kellene végezni (külön, nem szükséges most).
+
+---
+
+#### Eredeti leírás
+
+**Type:** B (megerősített bug)
`PreOrderConversionService.BuildIncomingQuantityPoolAsync` ([`Services/PreorderConversionService.cs`](../../Services/PreorderConversionService.cs)) a rendelkezésre álló mennyiségből **kétszer** vonja le a korábbi futásokban már teljesített (`Fulfilled`/`PartiallyFulfilled`) preorder-tételeket.
@@ -25,3 +41,25 @@ Scope: bugs / inconsistent or observable edge-case behaviour in the pre-order su
**Affected:**
- `Services/PreorderConversionService.cs` → `BuildIncomingQuantityPoolAsync` (a `alreadyAllocated` levonás), `ConvertPreOrdersForProductsAsync` fő ciklusa (státusz-mutáció sorrendje), `CreateOrderAsync` korai return ágai.
+
+## MGFBANKPLUG-PREO-B-M4T9: Lejárt előrendeléseket kielégít a rendszer friss szállítmányból
+
+**Status:** Closed (2026-07-08) — javítva · **Priority:** P1 · **Type:** B (megerősített bug, éles jelzés)
+
+Admin-jelzés: hosszú ideje **lejárt** előrendeléseket a rendszer friss szállítmányból kielégít, holott a lejárt előrendelésnek lezártnak kellene lennie és soha többé nem konvertálódnia.
+
+**Gyökérok — két hiba játszik össze:**
+1. A `ConvertPreOrdersForProductsAsync` jogosultsági szűrőjének **csak felső korlátja volt** (`DateOfReceipt.Date <= ma + PreOrderConversionWindowDays`), **alsó korlátja nem** — így egy tetszőleges múltbeli dátumú előrendelés is átment (múltbeli dátum ≤ ma+4 mindig igaz).
+2. A `SweepExpiredPreOrdersAsync` **csak a `Pending` tételeket ejti**; a `PartiallyFulfilled` tételeket érintetlenül hagyja (mert azok már valós rendelésen vannak). Ezek `OrderId`-vel rendelkeznek → az `AppendItemsToOrderAsync` a maradék mennyiséget **minden későbbi szállítmányból utántölti**, korlátlan ideig a lejárat után.
+
+**Tipikus eset:** vevő 100-at rendel, 60 megjön (`PartiallyFulfilled`), a dátum lejár → a maradék 40-et a rendszer örökké tölti a friss szállítmányokból.
+
+### Resolution (2026-07-08)
+`Services/PreorderConversionService.cs`:
+- **Alsó korlát** a jogosultsági szűrőben: `DateOfReceipt.Date >= ma` — lejárt (múltbeli) előrendelés **soha** nem kap allokációt (sem új rendelést, sem utántöltést). Ez az egyetlen konverziós kapu, minden trigger ezen megy át.
+- A **sweep** lejárat-határa dátum-szintre igazítva (`DateOfReceipt < ma-éjfél`, kliens-oldali konstans → SQL-biztos), hogy egy **aznapi** előrendelés a teljes szállítási napon aktív maradjon, és ne legyen „félig lezárva" (Pending ejtve, de PartiallyFulfilled még tölthető). Így a sweep (dátum < ma) és a szűrő (dátum ≥ ma) pontos komplementerek, nincs rés.
+
+**Maradék-véglegesítés (2026-07-08, riport-tisztaság):** a `SweepExpiredPreOrdersAsync` mostantól a lejárt előrendelések `PartiallyFulfilled` tételeit is lezárja: a tétel a teljesített mennyiségen záródik (`RequestedQuantity ← FulfilledQuantity`, státusz `Fulfilled`), így a maradék ejtve — nem mutatkozik többé nyitott igényként (pl. DemandList). A shortfall (eredeti igény vs. teljesített) a kapcsolt **rendeléshez jegyzetként** rögzül, tehát nem vész el. A soha nem teljesített tételeket mennyiség-alapon (`FulfilledQuantity == 0`) ejti, robusztusan a bizonytalan enum-olvasás ellen.
+
+**Affected:**
+- `Services/PreorderConversionService.cs` → `ConvertPreOrdersForProductsAsync` (jogosultsági szűrő alsó korlát), `SweepExpiredPreOrdersAsync` (dátum-szintű lejárat + `PartiallyFulfilled` maradék véglegesítése + rendelés-jegyzet).