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:
commit
4081bddfcc
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue