duplicated preorder conversion issue, missing preorder status operations fix, preorder delete fix, preorder expiration fix

This commit is contained in:
Adam 2026-07-10 13:06:31 +02:00
parent d5f151b5ac
commit eb265e9a5c
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)
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 ─────────────
/// <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 ───────────────────────────────────────────────────────────
[HttpPost]

View File

@ -38,7 +38,8 @@ public class PreOrderDbTable : MgDbTableBase<PreOrder>
public IQueryable<PreOrder> 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));
}
}

View File

@ -34,9 +34,10 @@ public class PreOrderItemDbTable : MgDbTableBase<PreOrderItem>
/// </summary>
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()
.Where(i => i.ProductId == productId && pendingStatuses.Contains(i.Status))
.Where(i => i.ProductId == productId && pendingStatusIds.Contains(i.StatusId))
.OrderBy(i => i.PreOrderId);
}
}

View File

@ -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<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);
// 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>>();
// 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)
{
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 ───────────────────────────────────────────────────────────
/// <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.
/// 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.
/// </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 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<PreOrderItem> fulfilledItems,
List<PreOrderItem> droppedItems,
int shippingDocumentId)
int shippingDocumentId,
IReadOnlyDictionary<int, int> 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<PreOrderItem> newlyFulfilled,
List<PreOrderItem> newlyDropped,
int shippingDocumentId)
int shippingDocumentId,
IReadOnlyDictionary<int, int> 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<PreOrderItem> items)
private async Task InsertOrderItemsAsync(Order order, List<PreOrderItem> items, IReadOnlyDictionary<int, int> 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<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(
int orderId, int preorderId, int shippingDocumentId,
List<PreOrderItem> fulfilled, List<PreOrderItem> dropped)
@ -592,43 +680,17 @@ public partial class PreOrderConversionService
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)
// 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<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;
return productDtos.ToDictionary(p => p.Id, p => Math.Max(0, p.AvailableQuantity));
}
}

File diff suppressed because one or more lines are too long