From eb265e9a5cab5902afdcf98fc115d57c1828ca2f Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 10 Jul 2026 13:06:31 +0200 Subject: [PATCH] duplicated preorder conversion issue, missing preorder status operations fix, preorder delete fix, preorder expiration fix --- .../Controllers/PreorderAdminController.cs | 60 ++++- .../Domains/DataLayer/PreorderDbTable.cs | 5 +- .../Domains/DataLayer/PreorderItemDbTable.cs | 5 +- .../Services/PreorderConversionService.cs | 218 +++++++++++------- .../docs/PREORDER/PREORDER_ISSUES.md | 40 +++- 5 files changed, 240 insertions(+), 88 deletions(-) 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).