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)
|
if (preorder.Status == PreOrderStatus.Cancelled)
|
||||||
return Json(new { success = false, error = "Már visszavont előrendelés" });
|
return Json(new { success = false, error = "Már visszavont előrendelés" });
|
||||||
|
|
||||||
// Only not-yet-fulfilled preorders can be cancelled — once a real order
|
// Gate on OrderId (always reliable), not on the stored status: once a real order
|
||||||
// exists (OrderId set at first fulfilment), cancelling the preorder must
|
// exists (OrderId set at first fulfilment), cancelling the preorder must not silently
|
||||||
// not silently undo it.
|
// undo it. A preorder with no OrderId has never been converted → it is pending and
|
||||||
if (preorder.Status != PreOrderStatus.Pending || preorder.OrderId.HasValue)
|
// cancellable. This is robust even for legacy rows whose stored status was never
|
||||||
return Json(new { success = false, error = "Csak függőben lévő előrendelés vonható vissza" });
|
// persisted correctly (see the StatusId entity fix).
|
||||||
|
if (preorder.OrderId.HasValue)
|
||||||
|
return Json(new { success = false, error = "Rendeléssé konvertált előrendelés nem vonható vissza" });
|
||||||
|
|
||||||
// Sets header → Cancelled and drops all still-active items → Dropped
|
// Sets header → Cancelled and drops all still-active items → Dropped
|
||||||
await _preorderDbContext.CancelPreOrderAsync(id);
|
await _preorderDbContext.CancelPreOrderAsync(id);
|
||||||
return Json(new { success = true });
|
return Json(new { success = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ONE-TIME HEALING: recompute stored statuses from quantities ─────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Before the StatusId entity fix, the Status enum column was never persisted (stuck at 0).
|
||||||
|
/// This recomputes every item's status from its quantities, finalizes expired preorders via
|
||||||
|
/// the sweep, and refreshes header statuses. Idempotent — safe to run more than once.
|
||||||
|
/// Trigger once after deploying the entity fix: POST Admin/PreOrders/RecomputeStatuses.
|
||||||
|
/// (Cancelled state cannot be recovered — it was never stored — so cancelled preorders are
|
||||||
|
/// recomputed from quantities; re-cancel them if needed.)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[Route("Admin/PreOrders/RecomputeStatuses")]
|
||||||
|
public async Task<IActionResult> RecomputeStatuses()
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||||
|
return Json(new { success = false, error = "Access denied" });
|
||||||
|
|
||||||
|
var allItems = await _preorderDbContext.PreOrderItems.GetAll().ToListAsync();
|
||||||
|
|
||||||
|
var itemsFixed = 0;
|
||||||
|
foreach (var item in allItems)
|
||||||
|
{
|
||||||
|
var derived = item.FulfilledQuantity <= 0
|
||||||
|
? PreOrderItemStatus.Pending
|
||||||
|
: item.FulfilledQuantity >= item.RequestedQuantity
|
||||||
|
? PreOrderItemStatus.Fulfilled
|
||||||
|
: PreOrderItemStatus.PartiallyFulfilled;
|
||||||
|
|
||||||
|
if (item.Status != derived)
|
||||||
|
{
|
||||||
|
item.Status = derived;
|
||||||
|
await _preorderDbContext.PreOrderItems.UpdateAsync(item);
|
||||||
|
itemsFixed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize expired preorders (drops never-fulfilled items, closes partials) —
|
||||||
|
// the same logic every conversion run uses.
|
||||||
|
await _preorderConversionService.SweepExpiredPreOrdersAsync();
|
||||||
|
|
||||||
|
// Refresh every header from the (now persisted) item states.
|
||||||
|
var preorderIds = allItems.Select(i => i.PreOrderId).Distinct().ToList();
|
||||||
|
foreach (var pid in preorderIds)
|
||||||
|
await _preorderDbContext.RefreshPreOrderStatusAsync(pid);
|
||||||
|
|
||||||
|
return Json(new { success = true, itemsFixed, preordersRefreshed = preorderIds.Count });
|
||||||
|
}
|
||||||
|
|
||||||
// ── DEMAND LIST ───────────────────────────────────────────────────────────
|
// ── DEMAND LIST ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ public class PreOrderDbTable : MgDbTableBase<PreOrder>
|
||||||
|
|
||||||
public IQueryable<PreOrder> GetAllPendingAsync(bool loadRelations)
|
public IQueryable<PreOrder> GetAllPendingAsync(bool loadRelations)
|
||||||
{
|
{
|
||||||
var pendingStatuses = new[] { PreOrderStatus.Pending, PreOrderStatus.PartiallyFulfilled };
|
// Filter on the mapped StatusId column — Status is [NotColumn] and not SQL-translatable.
|
||||||
return GetAll(loadRelations).Where(p => pendingStatuses.Contains(p.Status));
|
var pendingStatusIds = new[] { (int)PreOrderStatus.Pending, (int)PreOrderStatus.PartiallyFulfilled };
|
||||||
|
return GetAll(loadRelations).Where(p => pendingStatusIds.Contains(p.StatusId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,10 @@ public class PreOrderItemDbTable : MgDbTableBase<PreOrderItem>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IQueryable<PreOrderItem> GetPendingByProductIdOrderedAsync(int productId)
|
public IQueryable<PreOrderItem> GetPendingByProductIdOrderedAsync(int productId)
|
||||||
{
|
{
|
||||||
var pendingStatuses = new[] { PreOrderItemStatus.Pending, PreOrderItemStatus.PartiallyFulfilled };
|
// Filter on the mapped StatusId column — Status is [NotColumn] and not SQL-translatable.
|
||||||
|
var pendingStatusIds = new[] { (int)PreOrderItemStatus.Pending, (int)PreOrderItemStatus.PartiallyFulfilled };
|
||||||
return GetAll()
|
return GetAll()
|
||||||
.Where(i => i.ProductId == productId && pendingStatuses.Contains(i.Status))
|
.Where(i => i.ProductId == productId && pendingStatusIds.Contains(i.StatusId))
|
||||||
.OrderBy(i => i.PreOrderId);
|
.OrderBy(i => i.PreOrderId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,11 +93,16 @@ public partial class PreOrderConversionService
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out preorders whose delivery date is more than PreOrderConversionWindowDays
|
// Date window for eligibility:
|
||||||
// (4 days) away. With bi-weekly trucks, a delivery that far out will be served
|
// - LOWER bound (today): NEVER convert a preorder whose delivery date has already
|
||||||
// by the next truck's document — converting now would steal stock from
|
// passed. Once expired it is closed and must not receive fresh stock. Without this
|
||||||
// earlier deliveries that legitimately need it.
|
// a PartiallyFulfilled expired preorder (which the sweep leaves alone because it is
|
||||||
var conversionCutoff = DateTime.UtcNow.Date.AddDays(FruitBankPluginConst.PreOrderConversionWindowDays);
|
// already on a real order) keeps getting topped up from every later shipment forever.
|
||||||
|
// - UPPER bound (today + PreOrderConversionWindowDays): skip deliveries too far out;
|
||||||
|
// with bi-weekly trucks a later document will serve them, and converting now would
|
||||||
|
// steal stock from earlier deliveries that legitimately need it.
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
var conversionCutoff = today.AddDays(FruitBankPluginConst.PreOrderConversionWindowDays);
|
||||||
var pendingPreOrderIds = pendingItems.Select(i => i.PreOrderId).Distinct().ToList();
|
var pendingPreOrderIds = pendingItems.Select(i => i.PreOrderId).Distinct().ToList();
|
||||||
var parentPreOrders = await _preorderDbContext.PreOrders
|
var parentPreOrders = await _preorderDbContext.PreOrders
|
||||||
.GetAll(false)
|
.GetAll(false)
|
||||||
|
|
@ -105,7 +110,7 @@ public partial class PreOrderConversionService
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var eligiblePreOrderIds = parentPreOrders
|
var eligiblePreOrderIds = parentPreOrders
|
||||||
.Where(p => p.DateOfReceipt.Date <= conversionCutoff)
|
.Where(p => p.DateOfReceipt.Date >= today && p.DateOfReceipt.Date <= conversionCutoff)
|
||||||
.Select(p => p.Id)
|
.Select(p => p.Id)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
|
|
@ -113,20 +118,49 @@ public partial class PreOrderConversionService
|
||||||
|
|
||||||
if (!pendingItems.Any())
|
if (!pendingItems.Any())
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[PreOrderConversion] All pending preorders are beyond the " +
|
Console.WriteLine($"[PreOrderConversion] No pending preorders within the conversion window " +
|
||||||
$"{FruitBankPluginConst.PreOrderConversionWindowDays}-day window — skipped.");
|
$"(today .. +{FruitBankPluginConst.PreOrderConversionWindowDays} days) — expired or too-far-out skipped.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"[PreOrderConversion] {pendingItems.Count} items eligible " +
|
Console.WriteLine($"[PreOrderConversion] {pendingItems.Count} items eligible " +
|
||||||
$"(within {FruitBankPluginConst.PreOrderConversionWindowDays}-day window).");
|
$"(within {FruitBankPluginConst.PreOrderConversionWindowDays}-day window).");
|
||||||
|
|
||||||
|
// Convertibility filter: only fulfil preorders that can actually produce an order —
|
||||||
|
// those already linked to an order (append path), OR whose customer + billing address
|
||||||
|
// resolve (new-order path). This guarantees the invariant "item Fulfilled ⇒ order
|
||||||
|
// created ⇒ stock deducted", which is what lets BuildIncomingQuantityPoolAsync trust
|
||||||
|
// AvailableQuantity without re-subtracting committed allocations (K7M2). Items of
|
||||||
|
// non-convertible preorders stay Pending (picked up once fixed, or dropped at expiry).
|
||||||
|
var convertiblePreOrderIds = new HashSet<int>();
|
||||||
|
foreach (var p in parentPreOrders.Where(p => eligiblePreOrderIds.Contains(p.Id)))
|
||||||
|
{
|
||||||
|
if (p.OrderId != null) { convertiblePreOrderIds.Add(p.Id); continue; }
|
||||||
|
|
||||||
|
var validationCustomer = await _customerService.GetCustomerByIdAsync(p.CustomerId);
|
||||||
|
if (validationCustomer == null) continue;
|
||||||
|
if (await ResolveBillingAddressIdAsync(validationCustomer) > 0)
|
||||||
|
convertiblePreOrderIds.Add(p.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingItems = pendingItems.Where(i => convertiblePreOrderIds.Contains(i.PreOrderId)).ToList();
|
||||||
|
if (!pendingItems.Any())
|
||||||
|
{
|
||||||
|
Console.WriteLine("[PreOrderConversion] No convertible preorders (missing customer/billing address) — skipped.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var incomingPool = await BuildIncomingQuantityPoolAsync(productIds);
|
var incomingPool = await BuildIncomingQuantityPoolAsync(productIds);
|
||||||
|
|
||||||
// Track which items were newly resolved in THIS run, grouped by preorder
|
// Track which items were newly resolved in THIS run, grouped by preorder
|
||||||
// Key: preorderId Value: list of items whose status changed in this run
|
// Key: preorderId Value: list of items whose status changed in this run
|
||||||
var newlyResolvedByPreOrder = new Dictionary<int, List<PreOrderItem>>();
|
var newlyResolvedByPreOrder = new Dictionary<int, List<PreOrderItem>>();
|
||||||
|
|
||||||
|
// Quantity each item gained THIS run (delta), keyed by item id. Order items and
|
||||||
|
// stock deductions use this delta — not the cumulative FulfilledQuantity — so a
|
||||||
|
// partial item topped up across documents is not deducted/billed twice.
|
||||||
|
var gainedByItemId = new Dictionary<int, int>();
|
||||||
|
|
||||||
foreach (var item in pendingItems)
|
foreach (var item in pendingItems)
|
||||||
{
|
{
|
||||||
var prevFulfilled = item.FulfilledQuantity;
|
var prevFulfilled = item.FulfilledQuantity;
|
||||||
|
|
@ -143,6 +177,7 @@ public partial class PreOrderConversionService
|
||||||
var fulfill = Math.Min(item.RequestedQuantity - item.FulfilledQuantity, available);
|
var fulfill = Math.Min(item.RequestedQuantity - item.FulfilledQuantity, available);
|
||||||
item.FulfilledQuantity += fulfill;
|
item.FulfilledQuantity += fulfill;
|
||||||
incomingPool[item.ProductId] -= fulfill;
|
incomingPool[item.ProductId] -= fulfill;
|
||||||
|
gainedByItemId[item.Id] = fulfill;
|
||||||
|
|
||||||
item.Status = item.FulfilledQuantity >= item.RequestedQuantity
|
item.Status = item.FulfilledQuantity >= item.RequestedQuantity
|
||||||
? PreOrderItemStatus.Fulfilled
|
? PreOrderItemStatus.Fulfilled
|
||||||
|
|
@ -213,11 +248,11 @@ public partial class PreOrderConversionService
|
||||||
if (preorderLocked.OrderId == null)
|
if (preorderLocked.OrderId == null)
|
||||||
{
|
{
|
||||||
if (newlyFulfilled2.Any() || newlyDropped2.Any())
|
if (newlyFulfilled2.Any() || newlyDropped2.Any())
|
||||||
await CreateOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId);
|
await CreateOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId, gainedByItemId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await AppendItemsToOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId);
|
await AppendItemsToOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId, gainedByItemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally { sem.Release(); }
|
finally { sem.Release(); }
|
||||||
|
|
@ -229,23 +264,26 @@ public partial class PreOrderConversionService
|
||||||
// ── Expiry sweep ───────────────────────────────────────────────────────────
|
// ── Expiry sweep ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Closes all preorders whose DateOfReceipt is in the past and still have
|
/// Closes all preorders whose delivery DATE has passed (strictly before today). Never-fulfilled
|
||||||
/// Pending or PartiallyFulfilled items. Any still-Pending items become Dropped.
|
/// items (FulfilledQuantity == 0) become Dropped. Partially fulfilled items are finalized at the
|
||||||
/// Items that were already Fulfilled/PartiallyFulfilled stay as-is (those
|
/// delivered quantity (RequestedQuantity ← FulfilledQuantity, status Fulfilled) so the undelivered
|
||||||
/// quantities already made it into a real order).
|
/// remainder is dropped and no longer shows as open demand — the shortfall is recorded as a note on
|
||||||
/// Called at the start of every conversion run.
|
/// the linked order. Fully fulfilled items are untouched. The delivered quantities already made it
|
||||||
|
/// into a real order and are never topped up again (the conversion window also excludes past-date
|
||||||
|
/// preorders). Same-day preorders stay active for the whole delivery day. Called at the start of every run.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task SweepExpiredPreOrdersAsync()
|
public async Task SweepExpiredPreOrdersAsync()
|
||||||
{
|
{
|
||||||
|
// Expired = delivery date strictly before today. Compare against today-midnight
|
||||||
|
// (a client-side constant) so the SQL stays a plain datetime '<' — LinqToDB cannot
|
||||||
|
// translate a .Date applied to the column itself.
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
var activePreOrderStatuses = new[] { PreOrderStatus.Pending, PreOrderStatus.PartiallyFulfilled };
|
|
||||||
|
|
||||||
// Find preorders that are past their receipt date — fetch by date only,
|
// Find preorders that are past their receipt date — fetch by date only,
|
||||||
// then filter by status in memory (LinqToDB can't translate enum comparisons)
|
// then filter by status in memory (LinqToDB can't translate enum comparisons)
|
||||||
var expiredPreOrders = (await _preorderDbContext.PreOrders
|
var expiredPreOrders = (await _preorderDbContext.PreOrders
|
||||||
.GetAll(false)
|
.GetAll(false)
|
||||||
.Where(p => p.DateOfReceipt < now)
|
.Where(p => p.DateOfReceipt < now.Date)
|
||||||
.ToListAsync())
|
.ToListAsync())
|
||||||
.Where(p => p.Status == PreOrderStatus.Pending ||
|
.Where(p => p.Status == PreOrderStatus.Pending ||
|
||||||
p.Status == PreOrderStatus.PartiallyFulfilled)
|
p.Status == PreOrderStatus.PartiallyFulfilled)
|
||||||
|
|
@ -261,15 +299,46 @@ public partial class PreOrderConversionService
|
||||||
.GetAllByPreOrderIdAsync(preorder.Id)
|
.GetAllByPreOrderIdAsync(preorder.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Drop only the items that were never fulfilled — already-fulfilled
|
// Drop the items that were never fulfilled (quantity-based, robust against
|
||||||
// items stay as-is since they are already on a real order
|
// unreliable status enum reads): the whole line becomes Dropped.
|
||||||
var stillPending = items.Where(i => i.Status == PreOrderItemStatus.Pending).ToList();
|
var neverFulfilled = items.Where(i => i.FulfilledQuantity == 0).ToList();
|
||||||
foreach (var item in stillPending)
|
foreach (var item in neverFulfilled)
|
||||||
{
|
{
|
||||||
item.Status = PreOrderItemStatus.Dropped;
|
item.Status = PreOrderItemStatus.Dropped;
|
||||||
await _preorderDbContext.PreOrderItems.UpdateAsync(item);
|
await _preorderDbContext.PreOrderItems.UpdateAsync(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Finalize partially fulfilled items: the delivered quantity stays on the order,
|
||||||
|
// the never-to-be-delivered remainder is dropped. Close the line at the delivered
|
||||||
|
// amount (so it no longer shows as open demand) and record the shortfall on the
|
||||||
|
// linked order for audit — the original requested quantity survives in that note.
|
||||||
|
var partiallyFulfilled = items.Where(i => i.FulfilledQuantity > 0 &&
|
||||||
|
i.FulfilledQuantity < i.RequestedQuantity).ToList();
|
||||||
|
foreach (var item in partiallyFulfilled)
|
||||||
|
{
|
||||||
|
var droppedRemainder = item.RequestedQuantity - item.FulfilledQuantity;
|
||||||
|
var originalRequested = item.RequestedQuantity;
|
||||||
|
item.RequestedQuantity = item.FulfilledQuantity;
|
||||||
|
item.Status = PreOrderItemStatus.Fulfilled;
|
||||||
|
await _preorderDbContext.PreOrderItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
if (preorder.OrderId.HasValue)
|
||||||
|
{
|
||||||
|
await _orderService.InsertOrderNoteAsync(new OrderNote
|
||||||
|
{
|
||||||
|
OrderId = preorder.OrderId.Value,
|
||||||
|
Note = $"Előrendelés #{preorder.Id} lejárt — termék #{item.ProductId}: " +
|
||||||
|
$"{item.FulfilledQuantity}/{originalRequested} db teljesült, " +
|
||||||
|
$"{droppedRemainder} db ejtve (lejárat után nem teljesíthető).",
|
||||||
|
DisplayToCustomer = false,
|
||||||
|
CreatedOnUtc = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[PreOrderConversion] Expired preorder #{preorder.Id} item #{item.Id}: " +
|
||||||
|
$"closed at {item.FulfilledQuantity}/{originalRequested}, dropped remainder {droppedRemainder}.");
|
||||||
|
}
|
||||||
|
|
||||||
// Recalculate header status
|
// Recalculate header status
|
||||||
await _preorderDbContext.RefreshPreOrderStatusAsync(preorder.Id);
|
await _preorderDbContext.RefreshPreOrderStatusAsync(preorder.Id);
|
||||||
|
|
||||||
|
|
@ -278,7 +347,7 @@ public partial class PreOrderConversionService
|
||||||
i.Status == PreOrderItemStatus.PartiallyFulfilled);
|
i.Status == PreOrderItemStatus.PartiallyFulfilled);
|
||||||
|
|
||||||
Console.WriteLine($"[PreOrderConversion] Expired preorder #{preorder.Id}: " +
|
Console.WriteLine($"[PreOrderConversion] Expired preorder #{preorder.Id}: " +
|
||||||
$"{stillPending.Count} items dropped, " +
|
$"{neverFulfilled.Count} items dropped, {partiallyFulfilled.Count} partials finalized, " +
|
||||||
$"hadFulfillment={hadAnyFulfillment}, orderId={preorder.OrderId}");
|
$"hadFulfillment={hadAnyFulfillment}, orderId={preorder.OrderId}");
|
||||||
|
|
||||||
// TODO: Send expiry notification if nothing was ever fulfilled
|
// TODO: Send expiry notification if nothing was ever fulfilled
|
||||||
|
|
@ -294,26 +363,21 @@ public partial class PreOrderConversionService
|
||||||
PreOrder preorder,
|
PreOrder preorder,
|
||||||
List<PreOrderItem> fulfilledItems,
|
List<PreOrderItem> fulfilledItems,
|
||||||
List<PreOrderItem> droppedItems,
|
List<PreOrderItem> droppedItems,
|
||||||
int shippingDocumentId)
|
int shippingDocumentId,
|
||||||
|
IReadOnlyDictionary<int, int> gainedByItemId)
|
||||||
{
|
{
|
||||||
var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId);
|
var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId);
|
||||||
if (customer == null)
|
if (customer == null)
|
||||||
{
|
{
|
||||||
|
// Safety net — the convertibility filter should have excluded this preorder.
|
||||||
Console.WriteLine($"[PreOrderConversion] Customer {preorder.CustomerId} not found — skipping order creation for preorder #{preorder.Id}");
|
Console.WriteLine($"[PreOrderConversion] Customer {preorder.CustomerId} not found — skipping order creation for preorder #{preorder.Id}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var billingAddressId = customer.BillingAddressId ?? 0;
|
var billingAddressId = await ResolveBillingAddressIdAsync(customer);
|
||||||
if (billingAddressId == 0)
|
|
||||||
{
|
|
||||||
var addrMapping = await _dbContext.CustomerAddressMappings.Table
|
|
||||||
.Where(m => m.CustomerId == customer.Id)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
billingAddressId = addrMapping?.AddressId ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (billingAddressId == 0)
|
if (billingAddressId == 0)
|
||||||
{
|
{
|
||||||
|
// Safety net — the convertibility filter should have excluded this preorder.
|
||||||
Console.WriteLine($"[PreOrderConversion] No billing address for customer {customer.Id} — skipping for preorder #{preorder.Id}");
|
Console.WriteLine($"[PreOrderConversion] No billing address for customer {customer.Id} — skipping for preorder #{preorder.Id}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -389,7 +453,7 @@ public partial class PreOrderConversionService
|
||||||
CreatedOrUpdatedDateUTC = DateTime.UtcNow
|
CreatedOrUpdatedDateUTC = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
await InsertOrderItemsAsync(order, fulfilledItems);
|
await InsertOrderItemsAsync(order, fulfilledItems, gainedByItemId);
|
||||||
|
|
||||||
// Recalculate header totals from the actual inserted order items rather than the
|
// Recalculate header totals from the actual inserted order items rather than the
|
||||||
// PreOrderItem snapshot price: that snapshot is 0 for OrderDraft-origin preorders
|
// PreOrderItem snapshot price: that snapshot is 0 for OrderDraft-origin preorders
|
||||||
|
|
@ -423,14 +487,15 @@ public partial class PreOrderConversionService
|
||||||
PreOrder preorder,
|
PreOrder preorder,
|
||||||
List<PreOrderItem> newlyFulfilled,
|
List<PreOrderItem> newlyFulfilled,
|
||||||
List<PreOrderItem> newlyDropped,
|
List<PreOrderItem> newlyDropped,
|
||||||
int shippingDocumentId)
|
int shippingDocumentId,
|
||||||
|
IReadOnlyDictionary<int, int> gainedByItemId)
|
||||||
{
|
{
|
||||||
var order = await _dbContext.Orders.GetByIdAsync(preorder.OrderId!.Value);
|
var order = await _dbContext.Orders.GetByIdAsync(preorder.OrderId!.Value);
|
||||||
if (order == null)
|
if (order == null)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[PreOrderConversion] PreOrder #{preorder.Id} references Order #{preorder.OrderId} which no longer exists — creating fresh");
|
Console.WriteLine($"[PreOrderConversion] PreOrder #{preorder.Id} references Order #{preorder.OrderId} which no longer exists — creating fresh");
|
||||||
preorder.OrderId = null;
|
preorder.OrderId = null;
|
||||||
await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId);
|
await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId, gainedByItemId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,8 +505,8 @@ public partial class PreOrderConversionService
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append new OrderItems for the newly fulfilled items only
|
// Append new OrderItems for the quantity gained THIS run (delta) only
|
||||||
await InsertOrderItemsAsync(order, newlyFulfilled);
|
await InsertOrderItemsAsync(order, newlyFulfilled, gainedByItemId);
|
||||||
|
|
||||||
// Recalculate order total from all order items
|
// Recalculate order total from all order items
|
||||||
var allItems = await _dbContext.OrderItems.Table
|
var allItems = await _dbContext.OrderItems.Table
|
||||||
|
|
@ -470,13 +535,21 @@ public partial class PreOrderConversionService
|
||||||
|
|
||||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task InsertOrderItemsAsync(Order order, List<PreOrderItem> items)
|
private async Task InsertOrderItemsAsync(Order order, List<PreOrderItem> items, IReadOnlyDictionary<int, int> gainedByItemId)
|
||||||
{
|
{
|
||||||
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
|
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
|
||||||
var store = await _storeContext.GetCurrentStoreAsync();
|
var store = await _storeContext.GetCurrentStoreAsync();
|
||||||
|
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
|
// Quantity to add to the order / deduct from stock THIS run = the delta gained
|
||||||
|
// this run (equals FulfilledQuantity for a first-time conversion, the partial
|
||||||
|
// top-up amount for an append). Falls back to the cumulative value if unknown.
|
||||||
|
var quantityToAdd = gainedByItemId != null && gainedByItemId.TryGetValue(item.Id, out var gained)
|
||||||
|
? gained
|
||||||
|
: item.FulfilledQuantity;
|
||||||
|
if (quantityToAdd <= 0) continue;
|
||||||
|
|
||||||
var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true);
|
var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true);
|
||||||
if (productDto == null) continue;
|
if (productDto == null) continue;
|
||||||
|
|
||||||
|
|
@ -496,15 +569,15 @@ public partial class PreOrderConversionService
|
||||||
unitPriceInclTax = item.UnitPriceInclTax;
|
unitPriceInclTax = item.UnitPriceInclTax;
|
||||||
}
|
}
|
||||||
var unitPriceExclTax = Math.Round(unitPriceInclTax / 1.27m, 4);
|
var unitPriceExclTax = Math.Round(unitPriceInclTax / 1.27m, 4);
|
||||||
var priceInclTax = productDto.IsMeasurable ? 0m : unitPriceInclTax * item.FulfilledQuantity;
|
var priceInclTax = productDto.IsMeasurable ? 0m : unitPriceInclTax * quantityToAdd;
|
||||||
var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * item.FulfilledQuantity;
|
var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * quantityToAdd;
|
||||||
|
|
||||||
var orderItem = new OrderItem
|
var orderItem = new OrderItem
|
||||||
{
|
{
|
||||||
OrderItemGuid = Guid.NewGuid(),
|
OrderItemGuid = Guid.NewGuid(),
|
||||||
OrderId = order.Id,
|
OrderId = order.Id,
|
||||||
ProductId = item.ProductId,
|
ProductId = item.ProductId,
|
||||||
Quantity = item.FulfilledQuantity,
|
Quantity = quantityToAdd,
|
||||||
UnitPriceInclTax = unitPriceInclTax,
|
UnitPriceInclTax = unitPriceInclTax,
|
||||||
UnitPriceExclTax = unitPriceExclTax,
|
UnitPriceExclTax = unitPriceExclTax,
|
||||||
PriceInclTax = priceInclTax,
|
PriceInclTax = priceInclTax,
|
||||||
|
|
@ -527,12 +600,27 @@ public partial class PreOrderConversionService
|
||||||
// Deduct from stock — same as CustomOrderController and FruitBankOrderItemService
|
// Deduct from stock — same as CustomOrderController and FruitBankOrderItemService
|
||||||
await _productService.AdjustInventoryAsync(
|
await _productService.AdjustInventoryAsync(
|
||||||
product,
|
product,
|
||||||
-item.FulfilledQuantity,
|
-quantityToAdd,
|
||||||
string.Empty,
|
string.Empty,
|
||||||
$"Előrendelés #{item.PreOrderId} — rendelés #{order.Id} létrehozása");
|
$"Előrendelés #{item.PreOrderId} — rendelés #{order.Id} létrehozása");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolves the billing address id for a customer: the customer's own BillingAddressId,
|
||||||
|
// else the first CustomerAddressMapping. Returns 0 if none (preorder not convertible).
|
||||||
|
private async Task<int> ResolveBillingAddressIdAsync(Nop.Core.Domain.Customers.Customer customer)
|
||||||
|
{
|
||||||
|
var billingAddressId = customer.BillingAddressId ?? 0;
|
||||||
|
if (billingAddressId == 0)
|
||||||
|
{
|
||||||
|
var addrMapping = await _dbContext.CustomerAddressMappings.Table
|
||||||
|
.Where(m => m.CustomerId == customer.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
billingAddressId = addrMapping?.AddressId ?? 0;
|
||||||
|
}
|
||||||
|
return billingAddressId;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task InsertOrderNoteAsync(
|
private async Task InsertOrderNoteAsync(
|
||||||
int orderId, int preorderId, int shippingDocumentId,
|
int orderId, int preorderId, int shippingDocumentId,
|
||||||
List<PreOrderItem> fulfilled, List<PreOrderItem> dropped)
|
List<PreOrderItem> fulfilled, List<PreOrderItem> dropped)
|
||||||
|
|
@ -592,43 +680,17 @@ public partial class PreOrderConversionService
|
||||||
|
|
||||||
private async Task<Dictionary<int, int>> BuildIncomingQuantityPoolAsync(IList<int> productIds)
|
private async Task<Dictionary<int, int>> BuildIncomingQuantityPoolAsync(IList<int> productIds)
|
||||||
{
|
{
|
||||||
// 1. AvailableQuantity from ProductDto already accounts for
|
// AvailableQuantity = StockQuantity + IncomingQuantity, from the live product row.
|
||||||
// StockQuantity + IncomingQuantity (stock is allowed to go negative
|
// Every fulfilled preorder item is deducted from StockQuantity at order creation
|
||||||
// to the limit of IncomingQuantity in the FruitBank stock model)
|
// (InsertOrderItemsAsync → AdjustInventoryAsync), so AvailableQuantity ALREADY
|
||||||
|
// reflects all prior allocations. We must NOT subtract committed quantities again —
|
||||||
|
// that double-counted and under-allocated (PREORDER_ISSUES MGFBANKPLUG-PREO-B-K7M2).
|
||||||
|
// The invariant that makes this safe (Fulfilled ⇒ order created ⇒ stock deducted) is
|
||||||
|
// enforced up-front by the convertibility filter in ConvertPreOrdersForProductsAsync.
|
||||||
var productDtos = await _dbContext.ProductDtos
|
var productDtos = await _dbContext.ProductDtos
|
||||||
.GetAllByIds(productIds, loadRelations: false)
|
.GetAllByIds(productIds, loadRelations: false)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var availableByProduct = productDtos.ToDictionary(
|
return productDtos.ToDictionary(p => p.Id, p => Math.Max(0, p.AvailableQuantity));
|
||||||
p => p.Id,
|
|
||||||
p => p.AvailableQuantity);
|
|
||||||
|
|
||||||
var activeItemStatuses = new[] { PreOrderItemStatus.Fulfilled, PreOrderItemStatus.PartiallyFulfilled };
|
|
||||||
|
|
||||||
// 2. Subtract quantities already committed to preorders in previous runs
|
|
||||||
// Fetch by productId only, filter by status in memory
|
|
||||||
var allCommittedItems = await _preorderDbContext.PreOrderItems.Table
|
|
||||||
.Where(i => productIds.Contains(i.ProductId))
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var alreadyAllocated = allCommittedItems
|
|
||||||
.Where(i => i.Status == PreOrderItemStatus.Fulfilled ||
|
|
||||||
i.Status == PreOrderItemStatus.PartiallyFulfilled)
|
|
||||||
.GroupBy(i => i.ProductId)
|
|
||||||
.Select(g => new { ProductId = g.Key, Allocated = g.Sum(i => i.FulfilledQuantity) })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var allocatedByProduct = alreadyAllocated.ToDictionary(x => x.ProductId, x => x.Allocated);
|
|
||||||
|
|
||||||
// 3. Net pool = available − already committed to preorders
|
|
||||||
var result = new Dictionary<int, int>();
|
|
||||||
foreach (var productId in productIds)
|
|
||||||
{
|
|
||||||
var available = availableByProduct.TryGetValue(productId, out var avail) ? avail : 0;
|
|
||||||
var committed = allocatedByProduct.TryGetValue(productId, out var alloc) ? alloc : 0;
|
|
||||||
result[productId] = Math.Max(0, available - committed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue