- @* ─ Customer ─ *@
-
diff --git a/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs
index 90b2490..1f6f4a6 100644
--- a/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs
@@ -39,6 +39,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
ICustomerService customerService,
ICustomerRegistrationService customerRegistrationService,
ILocalizationService localizationService,
+ PreorderConversionService preorderConversionService,
IEnumerable
logWriters)
: BasePluginController, IFruitBankDataControllerServer
{
@@ -241,6 +242,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
_logger.Detail($"AddShippingItem invoked; id: {shippingItem.Id}");
if (!await ctx.AddShippingItemAsync(shippingItem)) return null;
+
+ // Update IncomingQuantity — EventConsumer handles conversion separately
+ if (shippingItem.ProductId.HasValue && shippingItem.QuantityOnDocument > 0)
+ await preorderConversionService.SyncIncomingQuantityAsync(
+ shippingItem.ProductId.Value, 0, shippingItem.QuantityOnDocument);
+
return await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, true);
}
@@ -251,7 +258,40 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
_logger.Detail($"UpdateShippingItem invoked; id: {shippingItem.Id}");
+ // Load BEFORE the update to capture previous ProductId and QuantityOnDocument
+ var oldItem = await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, false);
+
if (!await ctx.UpdateShippingItemSafeAsync(shippingItem)) return null;
+
+ if (oldItem != null)
+ {
+ var productChanged = oldItem.ProductId != shippingItem.ProductId;
+ var quantityChanged = oldItem.QuantityOnDocument != shippingItem.QuantityOnDocument;
+
+ if (productChanged && shippingItem.ProductId.HasValue)
+ {
+ // Full replacement: swap stock, order items, preorder items
+ await preorderConversionService.ReplaceShippingItemProductAsync(
+ shippingItem.Id, shippingItem.ProductId.Value, oldItem);
+ }
+ else if (quantityChanged && shippingItem.ProductId.HasValue)
+ {
+ // Only quantity changed: sync IncomingQuantity delta
+ await preorderConversionService.SyncIncomingQuantityAsync(
+ shippingItem.ProductId.Value,
+ oldItem.QuantityOnDocument,
+ shippingItem.QuantityOnDocument);
+
+ // If quantity increased, trigger conversion
+ // (EventConsumer also fires this, double-call is idempotent)
+ if (shippingItem.QuantityOnDocument > oldItem.QuantityOnDocument)
+ _ = Task.Run(async () => await preorderConversionService
+ .ConvertPreordersForProductsAsync(
+ new List { shippingItem.ProductId.Value },
+ shippingItem.ShippingDocumentId));
+ }
+ }
+
return await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, shippingItem.ShippingDocument != null);
}
diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs b/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs
index 96bf8bc..9e47be1 100644
--- a/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs
@@ -3,6 +3,7 @@ using FruitBank.Common.Entities;
using FruitBank.Common.Interfaces;
using Mango.Nop.Services;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Core.Events;
@@ -38,14 +39,17 @@ public class FruitBankEventConsumer :
private readonly MeasurementService _measurementService;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly PreorderConversionService _preorderConversionService;
+ private readonly IServiceScopeFactory _serviceScopeFactory;
public FruitBankEventConsumer(IHttpContextAccessor httpContextAcc, FruitBankDbContext ctx, MeasurementService measurementService,
- FruitBankAttributeService fruitBankAttributeService, PreorderConversionService preorderConversionService, IEnumerable logWriters) : base(ctx, httpContextAcc, logWriters)
+ FruitBankAttributeService fruitBankAttributeService, PreorderConversionService preorderConversionService,
+ IServiceScopeFactory serviceScopeFactory, IEnumerable logWriters) : base(ctx, httpContextAcc, logWriters)
{
_ctx = ctx;
_measurementService = measurementService;
_fruitBankAttributeService = fruitBankAttributeService;
_preorderConversionService = preorderConversionService;
+ _serviceScopeFactory = serviceScopeFactory;
}
public override async Task HandleEventAsync(EntityUpdatedEvent eventMessage)
@@ -201,15 +205,16 @@ public class FruitBankEventConsumer :
{
_ = Task.Run(async () =>
{
- try
- {
- await _preorderConversionService.ConvertPreordersForProductsAsync(
- new List { item.ProductId.Value }, item.ShippingDocumentId);
- }
- catch (Exception ex)
- {
- Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={item.ProductId}: {ex.Message}", ex);
- }
+ // Suppress the ambient TransactionScope from the parent context —
+ // TransactionScope flows through async by default and would cause
+ // MSDTC promotion failures if the background task enlists in it.
+ using var suppress = new System.Transactions.TransactionScope(
+ System.Transactions.TransactionScopeOption.Suppress,
+ System.Transactions.TransactionScopeAsyncFlowOption.Enabled);
+ using var scope = _serviceScopeFactory.CreateScope();
+ var conversion = scope.ServiceProvider.GetRequiredService();
+ try { await conversion.ConvertPreordersForProductsAsync(new List { item.ProductId.Value }, item.ShippingDocumentId); }
+ catch (Exception ex) { Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={item.ProductId}: {ex.Message}", ex); }
});
}
}
@@ -229,15 +234,13 @@ public class FruitBankEventConsumer :
{
_ = Task.Run(async () =>
{
- try
- {
- await _preorderConversionService.ConvertPreordersForProductsAsync(
- new List { shippingItem.ProductId.Value }, shippingItem.ShippingDocumentId);
- }
- catch (Exception ex)
- {
- Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={shippingItem.ProductId}: {ex.Message}", ex);
- }
+ using var suppress = new System.Transactions.TransactionScope(
+ System.Transactions.TransactionScopeOption.Suppress,
+ System.Transactions.TransactionScopeAsyncFlowOption.Enabled);
+ using var scope = _serviceScopeFactory.CreateScope();
+ var conversion = scope.ServiceProvider.GetRequiredService();
+ try { await conversion.ConvertPreordersForProductsAsync(new List { shippingItem.ProductId.Value }, shippingItem.ShippingDocumentId); }
+ catch (Exception ex) { Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={shippingItem.ProductId}: {ex.Message}", ex); }
});
}
}
diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs
index f3b9d64..ffd3f0c 100644
--- a/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs
+++ b/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs
@@ -1,4 +1,4 @@
-//using AyCode.Core.Consts;
+//using AyCode.Core.Consts;
//using Mango.Nop.Core;
namespace Nop.Plugin.Misc.FruitBankPlugin
@@ -15,4 +15,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
// }
//}
+ public static class FruitBankPluginConst
+ {
+ ///
+ /// Preorders whose DateOfReceipt is further than this many days in the future
+ /// are NOT converted at the current conversion run.
+ /// Based on the bi-weekly truck cycle (~3-4 days between arrivals):
+ /// if delivery is more than 4 days away, the next truck will arrive before
+ /// that delivery date, and its document processing will be the correct trigger.
+ ///
+ public const int PreorderConversionWindowDays = 4;
+ }
}
diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs
index 5036dcf..646d14d 100644
--- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs
@@ -138,6 +138,7 @@ public class PluginNopStartup : INopStartup
});
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddSingleton(sp =>
new LocalFileStorageProvider() // Uses default wwwroot/uploads
diff --git a/Nop.Plugin.Misc.AIPlugin/Models/Orders/IOrderProductItemBase.cs b/Nop.Plugin.Misc.AIPlugin/Models/Orders/IOrderProductItemBase.cs
new file mode 100644
index 0000000..57c109c
--- /dev/null
+++ b/Nop.Plugin.Misc.AIPlugin/Models/Orders/IOrderProductItemBase.cs
@@ -0,0 +1,14 @@
+namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders;
+
+///
+/// Minimal contract for adding a product to an order.
+/// Implemented by CustomOrderController.OrderProductItem, AddProductModel,
+/// and any other DTO that needs to be passed to FruitBankOrderItemService.
+///
+public interface IOrderProductItemBase
+{
+ /// ProductId
+ int Id { get; set; }
+ int Quantity { get; set; }
+ decimal Price { get; set; }
+}
diff --git a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj
index ad495d8..8c5915c 100644
--- a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj
+++ b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj
@@ -170,6 +170,9 @@
+
+ Always
+
Always
diff --git a/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs b/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs
index 9d17111..b6f61d1 100644
--- a/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs
@@ -97,6 +97,16 @@ public class CustomPriceCalculationService : PriceCalculationService
{
_logger.Info($"orderItem.Id: {orderItem.Id}");
+ if (orderItem.UnitPriceInclTax == 0 || orderItem.UnitPriceExclTax == 0)
+ {
+ var orderDto = await _dbContext.OrderDtos.GetByIdAsync(orderItem.OrderId, false);
+ var customer = await _dbContext.Customers.GetByIdAsync(orderDto.CustomerId);
+ var product = await _dbContext.Products.GetByIdAsync(orderItem.ProductId);
+ var pr = await GetFinalPriceAsync(product, customer, _storeContext.GetCurrentStore(), null, 0, true, 1, null, null);
+ orderItem.UnitPriceInclTax = pr.finalPrice;
+ orderItem.UnitPriceExclTax = pr.finalPrice / (decimal)1.27;
+ }
+
var finalPrices = CalculateOrderItemFinalPrices(orderItem.Quantity, orderItem.UnitPriceInclTax, orderItem.UnitPriceExclTax, isMeasurable, netWeight);
if (finalPrices.finalPriceInclTax == orderItem.PriceInclTax && finalPrices.finalPriceExclTax == orderItem.PriceExclTax) return false;
@@ -181,7 +191,12 @@ public class CustomPriceCalculationService : PriceCalculationService
// physical weighing. Until then we expose 0 so the cart and checkout total are honest.
// The actual PriceInclTax / PriceExclTax on OrderItem is set by
// CheckAndUpdateOrderItemFinalPricesAsync after the order is weighed.
- return (0m, 0m, 0m, new System.Collections.Generic.List());
+ //return (0m, 0m, 0m, new System.Collections.Generic.List());
+
+ //finalPrice.priceWithoutDiscounts = 0;
+ //return (0, finalPrice.finalPrice, finalPrice.appliedDiscountAmount, []);
+ return finalPrice;
+ //return (overriddenProductPrice.GetValueOrDefault(0), overriddenProductPrice.GetValueOrDefault(0), 0m, []);
}
//var productAttributeMappings = await _specificationAttributeService.GetProductSpecificationAttributesAsync(product.Id);
////Product Attributes
diff --git a/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs b/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs
index 324925e..26866fa 100644
--- a/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs
@@ -198,7 +198,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
public override async Task HandleEventAsync(AdminMenuCreatedEvent eventMessage)
{
var rootNode = eventMessage.RootMenuItem;
-
+
var shippingsListMenuItem = new AdminMenuItem
{
@@ -266,7 +266,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
Url = _adminMenu.GetMenuItemUrl("PreorderAvailability", "Index")
};
- shippingConfigurationItem.ChildNodes.Insert(4, preorderAvailabilityMenuItem);
+ //shippingConfigurationItem.ChildNodes.Insert(4, preorderAvailabilityMenuItem);
var preorderListMenuItem = new AdminMenuItem
{
@@ -277,7 +277,21 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
Url = _adminMenu.GetMenuItemUrl("PreorderAdmin", "List")
};
- shippingConfigurationItem.ChildNodes.Insert(5, preorderListMenuItem);
+ var preordersRootMenuItem = new AdminMenuItem
+ {
+ Visible = true,
+ SystemName = "FruitBank",
+ Title = "Előrendelés",
+ //Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.Preorders"), // You can localize this with await _localizationService.GetResourceAsync("...")
+ IconClass = "fas fa-heart",
+ //Url = _adminMenu.GetMenuItemUrl("Shipping", "List")
+ //ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
+ ChildNodes = [preorderAvailabilityMenuItem, preorderListMenuItem]
+ };
+
+ rootNode.ChildNodes.Insert(3, preordersRootMenuItem);
+
+ //shippingConfigurationItem.ChildNodes.Insert(5, preorderListMenuItem);
// Create a new top-level menu item
diff --git a/Nop.Plugin.Misc.AIPlugin/Services/FruitBankOrderItemService.cs b/Nop.Plugin.Misc.AIPlugin/Services/FruitBankOrderItemService.cs
new file mode 100644
index 0000000..d055e8b
--- /dev/null
+++ b/Nop.Plugin.Misc.AIPlugin/Services/FruitBankOrderItemService.cs
@@ -0,0 +1,231 @@
+using AyCode.Core.Loggers;
+using FruitBank.Common.Dtos;
+using FruitBank.Common.Interfaces;
+using Mango.Nop.Core.Loggers;
+using Nop.Core;
+using Nop.Core.Domain.Catalog;
+using Nop.Core.Domain.Customers;
+using Nop.Core.Domain.Orders;
+using Nop.Core.Domain.Stores;
+using Nop.Core.Domain.Tax;
+using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
+using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders;
+using Nop.Services.Catalog;
+using Nop.Services.Localization;
+using Nop.Services.Orders;
+using Nop.Services.Tax;
+
+namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
+
+///
+/// Shared service for creating, adding and removing order items.
+/// Extracted from CustomOrderController so the same logic can be reused
+/// by PreorderConversionService without duplication.
+///
+public class FruitBankOrderItemService
+{
+ private readonly FruitBankDbContext _dbContext;
+ private readonly IProductService _productService;
+ private readonly IPriceCalculationService _priceCalculationService;
+ private readonly ITaxService _taxService;
+ private readonly IOrderService _orderService;
+ private readonly IStoreContext _storeContext;
+ private readonly ILocalizationService _localizationService;
+ private readonly ILogger _logger;
+
+ public FruitBankOrderItemService(
+ FruitBankDbContext dbContext,
+ IProductService productService,
+ IPriceCalculationService priceCalculationService,
+ ITaxService taxService,
+ IOrderService orderService,
+ IStoreContext storeContext,
+ ILocalizationService localizationService,
+ IEnumerable logWriters)
+ {
+ _dbContext = dbContext;
+ _productService = productService;
+ _priceCalculationService = priceCalculationService;
+ _taxService = taxService;
+ _orderService = orderService;
+ _storeContext = storeContext;
+ _localizationService = localizationService;
+ _logger = new Logger(logWriters.ToArray());
+ }
+
+ // ── Create ────────────────────────────────────────────────────────────────
+
+ ///
+ /// Builds an OrderItem entity — no DB writes.
+ /// unitPricesIncludeDiscounts=true recalculates price from product;
+ /// false uses the supplied price as-is (manual override).
+ ///
+ public async Task CreateOrderItemAsync(
+ Product product,
+ Order order,
+ int productId,
+ int quantity,
+ decimal price,
+ bool isMeasurable,
+ bool unitPricesIncludeDiscounts,
+ Customer? customer = null,
+ Store? store = null)
+ {
+ store ??= await _storeContext.GetCurrentStoreAsync();
+ customer ??= new Customer { Id = order.CustomerId };
+
+ decimal unitPriceInclTax;
+ if (unitPricesIncludeDiscounts)
+ {
+ var calc = await _priceCalculationService.GetFinalPriceAsync(
+ product, customer, store, includeDiscounts: true);
+ unitPriceInclTax = calc.finalPrice;
+ }
+ else
+ {
+ unitPriceInclTax = price;
+ }
+
+ var (unitPriceExclTax, _) = await _taxService.GetProductPriceAsync(
+ product, unitPriceInclTax, includingTax: false, customer);
+
+ return new OrderItem
+ {
+ OrderId = order.Id,
+ ProductId = productId,
+ Quantity = quantity,
+ OrderItemGuid = Guid.NewGuid(),
+ UnitPriceInclTax = unitPriceInclTax,
+ UnitPriceExclTax = unitPriceExclTax,
+ PriceInclTax = isMeasurable ? 0m : unitPriceInclTax * quantity,
+ PriceExclTax = isMeasurable ? 0m : unitPriceExclTax * quantity,
+ OriginalProductCost = await _priceCalculationService.GetProductCostAsync(product, null),
+ AttributeDescription = string.Empty,
+ AttributesXml = string.Empty,
+ DiscountAmountInclTax = decimal.Zero,
+ DiscountAmountExclTax = decimal.Zero,
+ DownloadCount = 0,
+ IsDownloadActivated = false,
+ LicenseDownloadId = 0,
+ ItemWeight = product.Weight * quantity
+ };
+ }
+
+ // ── Add ───────────────────────────────────────────────────────────────────
+
+ ///
+ /// Inserts a single OrderItem, deducts inventory, and updates order totals.
+ /// Does NOT check availability — caller is responsible for that guard.
+ /// Does NOT call UpdateOrderAsync — caller must do that after all items are added.
+ ///
+ public async Task AddOrderItemToOrderAsync(
+ Order order,
+ OrderItem orderItem,
+ string inventoryMessage)
+ {
+ await _orderService.InsertOrderItemAsync(orderItem);
+
+ var product = await _productService.GetProductByIdAsync(orderItem.ProductId);
+ if (product != null)
+ await _productService.AdjustInventoryAsync(
+ product, -orderItem.Quantity, orderItem.AttributesXml, inventoryMessage);
+
+ order.OrderSubtotalInclTax += orderItem.UnitPriceInclTax * orderItem.Quantity;
+ order.OrderSubtotalExclTax += orderItem.UnitPriceExclTax * orderItem.Quantity;
+ order.OrderTotal += orderItem.PriceInclTax;
+ }
+
+ ///
+ /// Batch add — mirrors the original CustomOrderController.AddOrderItemsThenUpdateOrder.
+ /// Checks availability, creates each OrderItem, adjusts inventory,
+ /// updates order totals, then persists the order and writes an order note.
+ ///
+ public async Task AddOrderItemsThenUpdateOrderAsync(
+ Order order,
+ IReadOnlyList items,
+ Customer? customer = null,
+ Store? store = null,
+ Customer? admin = null)
+ {
+ store ??= await _storeContext.GetCurrentStoreAsync();
+ customer ??= new Customer { Id = order.CustomerId };
+ admin ??= customer;
+
+ var productDtosById = await _dbContext.ProductDtos
+ .GetAllByIds(items.Select(x => x.Id).ToArray())
+ .ToDictionaryAsync(k => k.Id, v => v);
+
+ foreach (var item in items)
+ {
+ var product = await _productService.GetProductByIdAsync(item.Id);
+ if (product == null)
+ {
+ _logger.Warning($"AddOrderItemsThenUpdateOrderAsync: product #{item.Id} not found, skipped");
+ continue;
+ }
+
+ productDtosById.TryGetValue(item.Id, out var dto);
+ var isMeasurable = dto?.IsMeasurable ?? false;
+ var available = product.StockQuantity + (dto?.IncomingQuantity ?? 0);
+
+ if (available - item.Quantity < 0)
+ throw new Exception(
+ $"Insufficient stock for product #{product.Id} " +
+ $"(available: {available}, requested: {item.Quantity})");
+
+ bool useDiscountPrice = item.Price == product.Price;
+
+ var orderItem = await CreateOrderItemAsync(
+ product, order, item.Id, item.Quantity, item.Price,
+ isMeasurable, useDiscountPrice, customer, store);
+
+ var msg = string.Format(
+ await _localizationService.GetResourceAsync(
+ "Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id);
+
+ await AddOrderItemToOrderAsync(order, orderItem, msg);
+ }
+
+ await _orderService.UpdateOrderAsync(order);
+ await InsertOrderNoteAsync(order.Id, false,
+ $"Products added ({items.Count} item(s)) by " +
+ $"{admin.FirstName} {admin.LastName} (Id: {admin.Id})");
+ }
+
+ // ── Remove ────────────────────────────────────────────────────────────────
+
+ ///
+ /// Removes an OrderItem: deletes pallets/GAs/weight constraints,
+ /// restores inventory, deletes the item, adjusts order totals.
+ /// Caller must call UpdateOrderAsync after.
+ ///
+ public async Task RemoveOrderItemFromOrderAsync(
+ Order order,
+ OrderItem orderItem,
+ string inventoryMessage)
+ {
+ await _dbContext.DeleteOrderItemConstraintsAsync(orderItem);
+
+ var product = await _productService.GetProductByIdAsync(orderItem.ProductId);
+ if (product != null)
+ await _productService.AdjustInventoryAsync(
+ product, +orderItem.Quantity, orderItem.AttributesXml, inventoryMessage);
+
+ await _orderService.DeleteOrderItemAsync(orderItem);
+
+ order.OrderSubtotalInclTax -= orderItem.UnitPriceInclTax * orderItem.Quantity;
+ order.OrderSubtotalExclTax -= orderItem.UnitPriceExclTax * orderItem.Quantity;
+ order.OrderTotal -= orderItem.PriceInclTax;
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ public Task InsertOrderNoteAsync(int orderId, bool displayToCustomer, string note)
+ => _orderService.InsertOrderNoteAsync(new OrderNote
+ {
+ OrderId = orderId,
+ Note = note,
+ DisplayToCustomer = displayToCustomer,
+ CreatedOnUtc = DateTime.UtcNow
+ });
+}
diff --git a/Nop.Plugin.Misc.AIPlugin/Services/InnVoiceOrderService.cs b/Nop.Plugin.Misc.AIPlugin/Services/InnVoiceOrderService.cs
index de74e7f..ede918e 100644
--- a/Nop.Plugin.Misc.AIPlugin/Services/InnVoiceOrderService.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Services/InnVoiceOrderService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Net.Http;
using System.Threading.Tasks;
using System.Xml.Linq;
@@ -156,8 +157,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
tetelElement.Add(new XElement("TetelNev", item.TetelNev ?? ""));
tetelElement.Add(new XElement("AfaSzoveg", item.AfaSzoveg ?? ""));
tetelElement.Add(new XElement("Brutto", item.Brutto ? "1" : "0"));
- tetelElement.Add(new XElement("EgysegAr", item.EgysegAr.ToString()));
- tetelElement.Add(new XElement("Mennyiseg", item.Mennyiseg.ToString()));
+ tetelElement.Add(new XElement("EgysegAr", item.EgysegAr.ToString("0.##", CultureInfo.InvariantCulture)));
+ tetelElement.Add(new XElement("Mennyiseg", item.Mennyiseg.ToString("0.##", CultureInfo.InvariantCulture)));
tetelElement.Add(new XElement("MennyisegEgyseg", new XCData(item.MennyisegEgyseg ?? "")));
if (!string.IsNullOrEmpty(item.CikkSzam))
diff --git a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.Replace.cs b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.Replace.cs
new file mode 100644
index 0000000..c8b98a5
--- /dev/null
+++ b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.Replace.cs
@@ -0,0 +1,184 @@
+using FruitBank.Common.Dtos;
+using FruitBank.Common.Entities;
+using FruitBank.Common.Enums;
+using FruitBank.Common.Interfaces;
+using Nop.Core.Domain.Catalog;
+using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
+using Nop.Services.Catalog;
+using Nop.Services.Orders;
+
+namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
+
+///
+/// Extension methods for product replacement logic on PreorderConversionService.
+///
+/// SETUP REQUIRED: Add this as a partial class by:
+/// 1. Add `partial` keyword to PreorderConversionService class declaration
+/// 2. This file uses the same injected fields — no extra DI needed
+///
+public partial class PreorderConversionService
+{
+ // ── Product replacement ───────────────────────────────────────────────────
+
+ ///
+ /// Called from FruitBankDataController.UpdateShippingItem when ProductId changes.
+ /// oldItem must be loaded BEFORE the DB update to capture the previous state.
+ ///
+ public async Task ReplaceShippingItemProductAsync(
+ int shippingItemId,
+ int newProductId,
+ ShippingItem oldItem)
+ {
+ if (oldItem.ProductId == null || oldItem.ProductId.Value == newProductId) return;
+
+ var oldProductId = oldItem.ProductId.Value;
+ var qty = oldItem.QuantityOnDocument;
+ var isMeasured = oldItem.IsMeasured;
+
+ Console.WriteLine($"[ReplaceShippingItemProduct] #{shippingItemId}: {oldProductId}→{newProductId}, qty={qty}, measured={isMeasured}");
+
+ // ── Guard: reject if any linked order is being measured ───────────────
+ var affectedOrders = await GetAffectedOpenOrdersAsync(oldProductId);
+ var startedOrders = affectedOrders
+ .Where(o => o.MeasuringStatus > MeasuringStatus.NotStarted)
+ .ToList();
+
+ if (startedOrders.Any())
+ throw new InvalidOperationException(
+ $"Mérés folyamatban a következő rendeléseken: " +
+ $"#{string.Join(", #", startedOrders.Select(o => o.Id))}. " +
+ $"Termékcsere nem lehetséges.");
+
+ // ── Stock / IncomingQuantity swap ─────────────────────────────────────
+ if (!isMeasured)
+ {
+ // Item not yet on the truck — only IncomingQuantity moves
+ await SyncIncomingQuantityAsync(oldProductId, qty, 0);
+ await SyncIncomingQuantityAsync(newProductId, 0, qty);
+ }
+ else
+ {
+ // Item already measured/received into stock — adjust actual stock
+ await _dbContext.UpdateStockQuantityAndWeightAsync(
+ oldProductId, -oldItem.MeasuredQuantity,
+ $"Termék csere: shippingItem #{shippingItemId} — {oldProductId}→{newProductId}",
+ oldItem.IsMeasurable ? -oldItem.MeasuredNetWeight : 0d);
+
+ await _dbContext.UpdateStockQuantityAndWeightAsync(
+ newProductId, +oldItem.MeasuredQuantity,
+ $"Termék csere: shippingItem #{shippingItemId} — {oldProductId}→{newProductId}",
+ oldItem.IsMeasurable ? +oldItem.MeasuredNetWeight : 0d);
+ }
+
+ // ── Swap order items ──────────────────────────────────────────────────
+ var openOrders = affectedOrders
+ .Where(o => o.MeasuringStatus == MeasuringStatus.NotStarted)
+ .OrderBy(o => o.Id)
+ .ToList();
+
+ var replacementBudget = qty;
+ var affectedOrderIds = new List();
+
+ foreach (var orderDto in openOrders)
+ {
+ if (replacementBudget <= 0) break;
+
+ var order = await _dbContext.Orders.GetByIdAsync(orderDto.Id);
+ if (order == null) continue;
+
+ var allItems = await _orderService.GetOrderItemsAsync(order.Id);
+ var oldOrderItem = allItems.FirstOrDefault(oi => oi.ProductId == oldProductId);
+ if (oldOrderItem == null) continue;
+
+ var swapQty = Math.Min(oldOrderItem.Quantity, replacementBudget);
+
+ // Remove old item — restores stock, deletes pallets/GAs, deletes item
+ await _orderItemService.RemoveOrderItemFromOrderAsync(
+ order, oldOrderItem,
+ $"Termék csere: #{oldProductId}→#{newProductId}, rendelés #{order.Id}");
+
+ // Add new item — direct insert without availability check
+ var newProduct = await _productService.GetProductByIdAsync(newProductId);
+ if (newProduct != null)
+ {
+ var newProductDto = await _dbContext.ProductDtos.GetByIdAsync(newProductId, true);
+ var newOrderItem = await _orderItemService.CreateOrderItemAsync(
+ newProduct, order,
+ productId : newProductId,
+ quantity : swapQty,
+ price : oldOrderItem.UnitPriceInclTax,
+ isMeasurable : newProductDto?.IsMeasurable ?? false,
+ unitPricesIncludeDiscounts : false);
+
+ await _orderItemService.AddOrderItemToOrderAsync(
+ order, newOrderItem,
+ $"Termék csere felvitel: #{newProductId}, rendelés #{order.Id}");
+ }
+
+ await _orderService.UpdateOrderAsync(order);
+ await _orderItemService.InsertOrderNoteAsync(order.Id, false,
+ $"Termék cserélve: #{oldProductId}→#{newProductId} ({swapQty} db), " +
+ $"szállítói dok. #{oldItem.ShippingDocumentId}");
+
+ replacementBudget -= swapQty;
+ affectedOrderIds.Add(order.Id);
+ }
+
+ // ── Swap preorder items ───────────────────────────────────────────────
+ if (affectedOrderIds.Any())
+ {
+ var preorders = (await _preorderDbContext.Preorders.GetAll(false).ToListAsync())
+ .Where(p => p.OrderId.HasValue && affectedOrderIds.Contains(p.OrderId.Value))
+ .ToList();
+
+ foreach (var preorder in preorders)
+ {
+ var piList = await _preorderDbContext.PreorderItems
+ .GetAllByPreorderIdAsync(preorder.Id)
+ .ToListAsync();
+
+ foreach (var pi in piList.Where(i => i.ProductId == oldProductId))
+ {
+ pi.ProductId = newProductId;
+ await _preorderDbContext.PreorderItems.UpdateAsync(pi);
+ }
+ }
+ }
+
+ // ── Trigger conversion for new product ────────────────────────────────
+ // New product may now have pending preorders that can be fulfilled
+ await ConvertPreordersForProductsAsync(
+ new List { newProductId },
+ oldItem.ShippingDocumentId);
+
+ // TODO: SignalR notification to admin hub
+ // TODO: SendPreorderProductReplacedNotificationAsync per affected customer
+
+ Console.WriteLine($"[ReplaceShippingItemProduct] Complete: " +
+ $"{affectedOrderIds.Count} orders swapped, budget remaining={replacementBudget}");
+ }
+
+ private async Task> GetAffectedOpenOrdersAsync(int oldProductId)
+ {
+ // Orders that are referenced by a preorder AND contain the old product
+ var preorderOrderIds = (await _preorderDbContext.Preorders.GetAll(false).ToListAsync())
+ .Where(p => p.OrderId.HasValue)
+ .Select(p => p.OrderId!.Value)
+ .ToHashSet();
+
+ if (!preorderOrderIds.Any()) return new();
+
+ var matchingOrderIds = await _dbContext.OrderItems.Table
+ .Where(oi => oi.ProductId == oldProductId && preorderOrderIds.Contains(oi.OrderId))
+ .Select(oi => oi.OrderId)
+ .Distinct()
+ .ToListAsync();
+
+ if (!matchingOrderIds.Any()) return new();
+
+ return await _dbContext.OrderDtos
+ .GetAll(false)
+ .Where(o => matchingOrderIds.Contains(o.Id) && !o.Deleted)
+ .ToListAsync();
+ }
+}
diff --git a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs
index 37c3c21..ba01858 100644
--- a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs
+++ b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs
@@ -1,5 +1,10 @@
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
+using FruitBank.Common.Interfaces;
+using Nop.Core;
+using Nop.Core.Domain.Catalog;
+
+
//using LinqToDB;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
@@ -27,7 +32,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// - Subsequent documents → appends only newly-fulfilled items to that same order.
/// - Dropped items are recorded in an order note but never become OrderItems.
///
-public class PreorderConversionService
+public partial class PreorderConversionService
{
private readonly PreorderDbContext _preorderDbContext;
private readonly FruitBankDbContext _dbContext;
@@ -36,6 +41,9 @@ public class PreorderConversionService
private readonly IEventPublisher _eventPublisher;
private readonly CustomPriceCalculationService _customPriceCalculationService;
private readonly IOrderService _orderService;
+ private readonly FruitBankAttributeService _fruitBankAttributeService;
+ private readonly FruitBankOrderItemService _orderItemService;
+ private readonly IStoreContext _storeContext;
public PreorderConversionService(
PreorderDbContext preorderDbContext,
@@ -44,7 +52,10 @@ public class PreorderConversionService
IProductService productService,
IEventPublisher eventPublisher,
IPriceCalculationService priceCalculationService,
- IOrderService orderService)
+ IOrderService orderService,
+ FruitBankAttributeService fruitBankAttributeService,
+ FruitBankOrderItemService orderItemService,
+ IStoreContext storeContext)
{
_preorderDbContext = preorderDbContext;
_dbContext = dbContext;
@@ -53,6 +64,9 @@ public class PreorderConversionService
_eventPublisher = eventPublisher;
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
_orderService = orderService;
+ _fruitBankAttributeService = fruitBankAttributeService;
+ _orderItemService = orderItemService;
+ _storeContext = storeContext;
}
// ── Entry point ───────────────────────────────────────────────────────────
@@ -72,6 +86,34 @@ public 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);
+ var pendingPreorderIds = pendingItems.Select(i => i.PreorderId).Distinct().ToList();
+ var parentPreorders = await _preorderDbContext.Preorders
+ .GetAll(false)
+ .Where(p => pendingPreorderIds.Contains(p.Id))
+ .ToListAsync();
+
+ var eligiblePreorderIds = parentPreorders
+ .Where(p => p.DateOfReceipt.Date <= conversionCutoff)
+ .Select(p => p.Id)
+ .ToHashSet();
+
+ pendingItems = pendingItems.Where(i => eligiblePreorderIds.Contains(i.PreorderId)).ToList();
+
+ if (!pendingItems.Any())
+ {
+ Console.WriteLine($"[PreorderConversion] All pending preorders are beyond the " +
+ $"{FruitBankPluginConst.PreorderConversionWindowDays}-day window — skipped.");
+ return;
+ }
+
+ Console.WriteLine($"[PreorderConversion] {pendingItems.Count} items eligible " +
+ $"(within {FruitBankPluginConst.PreorderConversionWindowDays}-day window).");
+
var incomingPool = await BuildIncomingQuantityPoolAsync(productIds);
// Track which items were newly resolved in THIS run, grouped by preorder
@@ -396,11 +438,14 @@ public class PreorderConversionService
var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true);
if (productDto == null) continue;
+ var product = await _productService.GetProductByIdAsync(item.ProductId);
+ if (product == null) continue;
+
var unitPriceExclTax = Math.Round(item.UnitPriceInclTax / 1.27m, 4);
var priceInclTax = productDto.IsMeasurable ? 0m : item.UnitPriceInclTax * item.FulfilledQuantity;
var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * item.FulfilledQuantity;
- await _dbContext.OrderItems.InsertAsync(new OrderItem
+ var orderItem = new OrderItem
{
OrderItemGuid = Guid.NewGuid(),
OrderId = order.Id,
@@ -420,7 +465,17 @@ public class PreorderConversionService
LicenseDownloadId = 0,
RentalStartDateUtc = null,
RentalEndDateUtc = null
- });
+ };
+
+ // Use the service (fires NopCommerce events) instead of direct DB insert
+ await _orderService.InsertOrderItemAsync(orderItem);
+
+ // Deduct from stock — same as CustomOrderController and FruitBankOrderItemService
+ await _productService.AdjustInventoryAsync(
+ product,
+ -item.FulfilledQuantity,
+ string.Empty,
+ $"Előrendelés #{item.PreorderId} — rendelés #{order.Id} létrehozása");
}
}
@@ -451,6 +506,24 @@ public class PreorderConversionService
await _orderService.InsertOrderNoteAsync(note);
}
+ // ── IncomingQuantity sync ────────────────────────────────────────
+
+ public async Task SyncIncomingQuantityAsync(int productId, int oldQty, int newQty)
+ {
+ var delta = newQty - oldQty;
+ if (delta == 0 || productId <= 0) return;
+
+ var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
+ var current = await _fruitBankAttributeService
+ .GetGenericAttributeValueAsync(
+ productId, nameof(IIncomingQuantity.IncomingQuantity), storeId);
+ var updated = Math.Max(0, current + delta);
+ await _fruitBankAttributeService
+ .InsertOrUpdateGenericAttributeAsync(
+ productId, nameof(IIncomingQuantity.IncomingQuantity), updated, storeId);
+ Console.WriteLine($"[PreorderConversion] SyncIncomingQty product #{productId}: {current}+({delta})={updated}");
+ }
+
private async Task CalculateTotalAsync(List items)
{
var total = 0m;