From 8e1b3f2a5dc7f85d068053ddd19bdf6d8808d792 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 18 Mar 2026 15:15:50 +0100 Subject: [PATCH 1/4] =?UTF-8?q?nem=20=C3=ADr=20ez=20l=C3=B3szart=20se...?= =?UTF-8?q?=20gyors=20rendel=C3=A9s,=20deisgn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CustomOrderController.cs | 18 +- .../Controllers/QuickOrderController.cs | 441 ++++++++++ Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs | 156 +++- .../FruitBankMessageTokenProvider.cs | 237 ++++++ .../Infrastructure/PluginNopStartup.cs | 12 +- .../Infrastructure/RouteProvider.cs | 36 +- .../Localization/quickorder.en.xml | 177 ++++ .../Localization/quickorder.hu.xml | 177 ++++ .../Nop.Plugin.Misc.FruitBankPlugin.csproj | 7 + Nop.Plugin.Misc.AIPlugin/SKILL.md | 351 ++++++++ .../Services/CustomPriceCalculationService.cs | 28 +- .../Views/QuickOrder/Index.cshtml | 566 +++++++++++++ Nop.Plugin.Misc.AIPlugin/css/quick-order.css | 756 ++++++++++++++++++ 13 files changed, 2918 insertions(+), 44 deletions(-) create mode 100644 Nop.Plugin.Misc.AIPlugin/Controllers/QuickOrderController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Infrastructure/FruitBankMessageTokenProvider.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/quickorder.en.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/quickorder.hu.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/SKILL.md create mode 100644 Nop.Plugin.Misc.AIPlugin/Views/QuickOrder/Index.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/css/quick-order.css diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs index d873cf0..09744fc 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs @@ -591,6 +591,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers throw new Exception($"{errorText}"); } + //itt vajon elég ez a vizsgálat, vagy a priceCalculationService.GetFinalPriceAsync-al kéne lekérni a végső árat és azt összehasonlítani? - A. + //ha kedvezménye is van, de manuálisan is le van csökkentve az ár, akkor a kedvezményt látja a rendszer, és azt kellene összevetni a bejövő árral... - A. if (orderProductItem.Price != product.Price) { //manual price change @@ -600,14 +602,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers { unitPricesIncludeDiscounts = true; } - - + + //itt ha includeDiscounts van, akkor már a beírt ár megy be? var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store); + _logger.Detail($"Adding order item: ProductId: {orderItem.ProductId}, Quantity: {orderItem.Quantity}, UnitPriceInclTax: {orderItem.UnitPriceInclTax}, UnitPriceExclTax: {orderItem.UnitPriceExclTax}, PriceInclTax: {orderItem.PriceInclTax}, PriceExclTax: {orderItem.PriceExclTax}"); + await _orderService.InsertOrderItemAsync(orderItem); await _productService.AdjustInventoryAsync(product, -orderItem.Quantity, orderItem.AttributesXml, string.Format(await _localizationService.GetResourceAsync("Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id)); - var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: false); + var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: true); var unitPriceInclTaxValue = priceCalculation.finalPrice; var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer); @@ -615,8 +619,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers order.OrderSubtotalInclTax += unitPriceInclTaxValue * orderItem.Quantity; order.OrderSubtotalExclTax += unitPriceExclTaxValue * orderItem.Quantity; - order.OrderSubTotalDiscountInclTax += order.OrderSubtotalInclTax - orderItem.PriceInclTax; - order.OrderSubTotalDiscountExclTax += order.OrderSubtotalExclTax - orderItem.PriceExclTax; + var appliedDiscounts = priceCalculation.appliedDiscountAmount; + var totalDiscountInclTax = appliedDiscounts * orderProductItem.Quantity; + var totalDiscountExclTax = appliedDiscounts * orderProductItem.Quantity; + + order.OrderSubTotalDiscountInclTax += totalDiscountInclTax; + order.OrderSubTotalDiscountExclTax += totalDiscountExclTax; //order.OrderTax //order.TaxRates diff --git a/Nop.Plugin.Misc.AIPlugin/Controllers/QuickOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Controllers/QuickOrderController.cs new file mode 100644 index 0000000..c4c92b9 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Controllers/QuickOrderController.cs @@ -0,0 +1,441 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Nop.Core; +using Nop.Core.Domain.Orders; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Catalog; +using Nop.Services.Customers; +using Nop.Services.Localization; +using Nop.Services.Orders; +using Nop.Web.Framework.Controllers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers +{ + [AutoValidateAntiforgeryToken] + public class QuickOrderController : BasePluginController + { + private readonly IWorkContext _workContext; + private readonly IStoreContext _storeContext; + private readonly IProductService _productService; + private readonly IShoppingCartService _shoppingCartService; + private readonly ICustomerService _customerService; + private readonly ILocalizationService _localizationService; + private readonly CustomPriceCalculationService _customPriceCalculationService; + private readonly OpenAIApiService _aiApiService; + private readonly CerebrasAPIService _cerebrasApiService; + private readonly FruitBankDbContext _dbContext; + + // Resource key prefix + private const string Prefix = "Plugins.Misc.FruitBankPlugin.QuickOrder."; + + public QuickOrderController( + IWorkContext workContext, + IStoreContext storeContext, + IProductService productService, + IShoppingCartService shoppingCartService, + ICustomerService customerService, + ILocalizationService localizationService, + IPriceCalculationService priceCalculationService, + OpenAIApiService aiApiService, + CerebrasAPIService cerebrasApiService, + FruitBankDbContext dbContext) + { + _workContext = workContext; + _storeContext = storeContext; + _productService = productService; + _shoppingCartService = shoppingCartService; + _customerService = customerService; + _localizationService = localizationService; + _customPriceCalculationService = priceCalculationService as CustomPriceCalculationService; + _aiApiService = aiApiService; + _cerebrasApiService = cerebrasApiService; + _dbContext = dbContext; + } + + [HttpGet] + public async Task Index() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Challenge(); + + return View("~/Plugins/Misc.FruitBankPlugin/Views/QuickOrder/Index.cshtml"); + } + + /// + /// Return all available products with prices (for initial page load) + /// + [HttpGet] + public async Task GetAllProducts() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Json(new { success = false, message = await L("NotLoggedIn") }); + + try + { + var store = await _storeContext.GetCurrentStoreAsync(); + var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync()).Where(pd => pd.AvailableQuantity > 0); + + //var dbProducts = await _productService.SearchProductsAsync( + // pageIndex: 0, + // pageSize: 500, + // orderBy: ); + + var result = new List(); + + foreach (var product in allProductDtos) + { + var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id); + if (productDto == null) continue; + + var availableQty = product.StockQuantity + productDto.IncomingQuantity; + if (availableQty <= 0) continue; + + decimal? unitPrice = null; + if (!productDto.IsMeasurable) + { + var tproduct = await _productService.GetProductByIdAsync(productDto.Id); + var priceResult = await _customPriceCalculationService.GetFinalPriceAsync( + tproduct, customer, store, null, 0, true, 1, null, null); + unitPrice = priceResult.finalPrice; + } + + result.Add(new + { + id = product.Id, + name = product.Name, + quantity = 1, + requestedQuantity = 1, + unitPrice, + stockQuantity = availableQty, + searchTerm = (string)null, + isQuantityReduced = false, + isMeasurable = productDto.IsMeasurable + }); + } + + return Json(new { success = true, products = result }); + } + catch (Exception ex) + { + Console.WriteLine($"[QuickOrder] GetAllProducts error: {ex.Message}"); + return Json(new { success = false, message = $"Hiba: {ex.Message}" }); + } + } + + /// + /// Parse a manually typed product list and return matching products with prices + /// + [HttpPost] + public async Task SearchProducts(string text) + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Json(new { success = false, message = await L("NotLoggedIn") }); + + if (string.IsNullOrWhiteSpace(text)) + return Json(new { success = false, message = await L("NoTextProvided") }); + + try + { + var parsedProducts = await ParseProductsFromText(text); + if (parsedProducts == null || parsedProducts.Count == 0) + return Json(new { success = false, message = await L("NoProductsIdentified"), transcription = text }); + + var store = await _storeContext.GetCurrentStoreAsync(); + var enrichedProducts = await EnrichProductData(parsedProducts, customer, store); + + return Json(new { success = true, transcription = text, products = enrichedProducts }); + } + catch (Exception ex) + { + Console.WriteLine($"[QuickOrder] SearchProducts error: {ex.Message}"); + return Json(new { success = false, message = $"Hiba: {ex.Message}" }); + } + } + + /// + /// Transcribe voice audio (Hungarian) then parse and match products + /// + [HttpPost] + public async Task TranscribeAndSearch(IFormFile audioFile) + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Json(new { success = false, message = await L("NotLoggedIn") }); + + if (audioFile == null || audioFile.Length == 0) + return Json(new { success = false, message = await L("NoAudioReceived") }); + + try + { + var transcribedText = await TranscribeAudioFile(audioFile, "hu"); + if (string.IsNullOrEmpty(transcribedText)) + return Json(new { success = false, message = await L("TranscriptionFailed") }); + + Console.WriteLine($"[QuickOrder] Transcription: {transcribedText}"); + + var parsedProducts = await ParseProductsFromText(transcribedText); + if (parsedProducts == null || parsedProducts.Count == 0) + return Json(new { success = false, message = await L("NoProductsIdentified"), transcription = transcribedText }); + + var store = await _storeContext.GetCurrentStoreAsync(); + var enrichedProducts = await EnrichProductData(parsedProducts, customer, store); + + return Json(new { success = true, transcription = transcribedText, products = enrichedProducts }); + } + catch (Exception ex) + { + Console.WriteLine($"[QuickOrder] TranscribeAndSearch error: {ex.Message}"); + return Json(new { success = false, message = $"Hiba: {ex.Message}" }); + } + } + + /// + /// Add a product to the current customer's shopping cart and return the updated cart + /// + [HttpPost] + public async Task AddToCart(int productId, int quantity) + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Json(new { success = false, message = await L("NotLoggedIn") }); + + if (productId <= 0 || quantity <= 0) + return Json(new { success = false, message = await L("InvalidProductOrQuantity") }); + + try + { + var product = await _productService.GetProductByIdAsync(productId); + if (product == null || product.Deleted || !product.Published) + return Json(new { success = false, message = await L("ProductNotAvailable") }); + + var store = await _storeContext.GetCurrentStoreAsync(); + + var warnings = await _shoppingCartService.AddToCartAsync( + customer: customer, + product: product, + shoppingCartType: ShoppingCartType.ShoppingCart, + storeId: store.Id, + quantity: quantity); + + if (warnings.Any()) + return Json(new { success = false, message = string.Join("; ", warnings) }); + + var cartItems = await GetCartItemsJson(customer, store); + return Json(new { success = true, cartItems }); + } + catch (Exception ex) + { + Console.WriteLine($"[QuickOrder] AddToCart error: {ex.Message}"); + return Json(new { success = false, message = $"Hiba: {ex.Message}" }); + } + } + + /// + /// Return the current customer's cart as JSON (for cart panel refresh) + /// + [HttpGet] + public async Task GetCartItems() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Json(new { success = false }); + + var store = await _storeContext.GetCurrentStoreAsync(); + var cartItems = await GetCartItemsJson(customer, store); + return Json(new { success = true, cartItems }); + } + + #region Private helpers + + /// Shorthand: get a localized QuickOrder resource string + private Task L(string keySuffix) + => _localizationService.GetResourceAsync(Prefix + keySuffix); + + private async Task TranscribeAudioFile(IFormFile audioFile, string language) + { + var fileName = $"quick_order_{DateTime.Now:yyyyMMdd_HHmmss}.webm"; + var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "voice"); + + if (!Directory.Exists(uploadsFolder)) + Directory.CreateDirectory(uploadsFolder); + + var filePath = Path.Combine(uploadsFolder, fileName); + using (var stream = new FileStream(filePath, FileMode.Create)) + await audioFile.CopyToAsync(stream); + + string transcribedText; + using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) + transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language, null); + + if (!string.IsNullOrEmpty(transcribedText) && + (transcribedText.EndsWith(".") || transcribedText.EndsWith("!") || transcribedText.EndsWith("?"))) + transcribedText = transcribedText[..^1]; + + try { System.IO.File.Delete(filePath); } catch { /* ignore cleanup errors */ } + return transcribedText; + } + + private async Task> ParseProductsFromText(string text) + { + var systemPrompt = @"You are a product parser for a Hungarian fruit and vegetable wholesale company. +Parse the product names and quantities from the user's input. + +CRITICAL RULES: +1. Extract product names and quantities from ANY produce item +2. Normalize product names to singular, lowercase (e.g., 'narancsok' → 'narancs') +3. Handle Hungarian number words ('száz' = 100, 'ötven' = 50, 'húsz' = 20, 'tíz' = 10, 'öt' = 5, 'egy' = 1) +4. Fix common transcription/typing errors (e.g., 'datója' → 'datolya', 'szűlő' → 'szőlő', 'mondarin' → 'mandarin') +5. Return ONLY valid JSON array, no explanations +6. DO NOT include units - only product name and quantity as a number +7. ALWAYS return at least one product if you can parse anything from the input + +OUTPUT FORMAT (JSON only): +[ + {""product"": ""narancs"", ""quantity"": 100}, + {""product"": ""alma"", ""quantity"": 50} +]"; + + var aiResponse = await _cerebrasApiService.GetSimpleResponseAsync(systemPrompt, $"Parse this: {text}"); + Console.WriteLine($"[QuickOrder] AI parse response: {aiResponse}"); + + var jsonMatch = Regex.Match(aiResponse, @"\[.*\]", RegexOptions.Singleline); + if (!jsonMatch.Success) return new List(); + + try + { + return System.Text.Json.JsonSerializer.Deserialize>(jsonMatch.Value) + ?? new List(); + } + catch (Exception ex) + { + Console.WriteLine($"[QuickOrder] JSON parse error: {ex.Message}"); + return new List(); + } + } + + private async Task> EnrichProductData( + List parsedProducts, + Nop.Core.Domain.Customers.Customer customer, + Nop.Core.Domain.Stores.Store store) + { + var enrichedProducts = new List(); + var allProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync(); + + foreach (var parsed in parsedProducts) + { + var dbProducts = await _productService.SearchProductsAsync( + keywords: parsed.Product, + pageIndex: 0, + pageSize: 20); + + if (!dbProducts.Any()) + { + Console.WriteLine($"[QuickOrder] No products found for: {parsed.Product}"); + continue; + } + + foreach (var product in dbProducts) + { + var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id); + if (productDto == null) continue; + + var availableQty = product.StockQuantity + productDto.IncomingQuantity; + if (availableQty <= 0) continue; + + var requestedQty = parsed.Quantity; + var finalQty = Math.Min(requestedQty, availableQty); + var isReduced = finalQty < requestedQty; + + decimal? unitPrice = null; + if (!productDto.IsMeasurable) + { + var priceResult = await _customPriceCalculationService.GetFinalPriceAsync( + product, customer, store, null, 0, true, finalQty, null, null); + unitPrice = priceResult.finalPrice; + } + + enrichedProducts.Add(new + { + id = product.Id, + name = product.Name, + quantity = finalQty, + requestedQuantity = requestedQty, + unitPrice, + stockQuantity = availableQty, + searchTerm = parsed.Product, + isQuantityReduced = isReduced, + isMeasurable = productDto.IsMeasurable + }); + } + } + + Console.WriteLine($"[QuickOrder] Enriched product count: {enrichedProducts.Count}"); + return enrichedProducts; + } + + private async Task> GetCartItemsJson( + Nop.Core.Domain.Customers.Customer customer, + Nop.Core.Domain.Stores.Store store) + { + var cart = await _shoppingCartService.GetShoppingCartAsync( + customer, ShoppingCartType.ShoppingCart, store.Id); + + var allProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync(); + var result = new List(); + + foreach (var item in cart) + { + var product = await _productService.GetProductByIdAsync(item.ProductId); + if (product == null) continue; + + var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id); + var isMeasurable = productDto?.IsMeasurable ?? false; + + decimal? unitPrice = null; + if (!isMeasurable) + { + var priceResult = await _customPriceCalculationService.GetFinalPriceAsync( + product, customer, store, null, 0, true, item.Quantity, null, null); + unitPrice = priceResult.finalPrice; + } + + result.Add(new + { + id = item.Id, + productId = item.ProductId, + name = product.Name, + quantity = item.Quantity, + unitPrice, + isMeasurable + }); + } + + return result; + } + + #endregion + + #region Inner models + + private class ParsedProduct + { + [System.Text.Json.Serialization.JsonPropertyName("product")] + public string Product { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("quantity")] + public int Quantity { get; set; } + } + + #endregion + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs index 4d5f0dd..49f2c2f 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs @@ -1,4 +1,3 @@ - using FruitBank.Common.Server; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -59,7 +58,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin //TODO: Add "IsMeasurable" product attribute - FruitBankConst.IsMeasurableAttributeName //TODO: Add "NeedsToBeMeasured" product attribute if not exists - //TODO: Add unique index to GenericAttribute[EntityId, KeyGroup, Key]???!? TGONDOLNI, mert lehet esetleg lista is a visszaadott, de a nopcommerce kdban fellrja ha azonos key-el vannak! - J. + //TODO: Add unique index to GenericAttribute[EntityId, KeyGroup, Key]???!? ATGONDOLNI, mert lehet esetleg lista is a visszaadott, de a nopcommerce kodban felulirja ha azonos key-el vannak! - J. // Default settings var settings = new FruitBankSettings @@ -67,8 +66,132 @@ namespace Nop.Plugin.Misc.FruitBankPlugin ApiKey = string.Empty }; await _settingService.SaveSettingAsync(settings); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Shipment", "EN"); - await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Szlltmnyok", "HU"); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Sz\u00e1ll\u00edtm\u00e1nyok", "HU"); + + // ── Quick Order page ─────────────────────────────────────────────────── + const string en = "EN"; + const string hu = "HU"; + + // Page title + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle", "Quick Order", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle", "Gyors rendel\u00e9s", hu); + + // Navigation menu label + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel", "Quick Order", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel", "Gyors rendel\u00e9s", hu); + + // Search bar + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Start voice recording", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Hangfelv\u00e9tel ind\u00edt\u00e1sa", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle", "Stop", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle", "Le\u00e1ll\u00edt\u00e1s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder", "Search for products (e.g. orange 100, apple 50) or use the microphone...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder", "Keress term\u00e9keket (pl. narancs 100, alma 50) vagy haszn\u00e1ld a mikrofont...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder", "Listening... (start speaking)", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder", "Figye\u0151s... (kezdj el besz\u00e9lni)", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton", "Search", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton", "Keres\u00e9s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus", "Listening...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus", "Figye\u0151s...", hu); + + // Product panel + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel", "I heard:", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel", "Hallottam:", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText", "No products found. Try a different search.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText", "Nem tal\u00e1ltunk term\u00e9keket. Pr\u00f3b\u00e1ljunk m\u00e1s keres\u00e9st.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts", "Loading products...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts", "Term\u00e9kek bet\u00f6lt\u00e9se...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel", "All products", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel", "\u00d6sszes term\u00e9k", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel", "Results", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel", "Tal\u00e1latok", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint", "\u2014 set quantity, then add to cart:", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint", "\u2014 \u00e1ll\u00edtsd be a mennyis\u00e9get, majd add a kos\u00e1rhoz:", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge", "Requires weighing", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge", "S\u00falym\u00e9r\u00e9st ig\u00e9nyel", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel", "Stock:", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel", "K\u00e9szlet:", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix", "Only", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix", "Csak", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix", "pcs available", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix", "db el\u00e9rhet\u0151", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit", "pcs", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit", "db", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece", "Ft/pcs", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece", "Ft/db", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle", "Add to cart", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle", "Kos\u00e1rba", hu); + + // Cart panel + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle", "Cart", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle", "Kos\u00e1r", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1", "Your cart is empty.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1", "A kos\u00e1r \u00fcres.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2", "Search for products and add them.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2", "Keress term\u00e9keket \u00e9s add hozz\u00e1 \u0151ket.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote", "Prices for weighed items will be finalized after measurement.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote", "A s\u00falym\u00e9r\u00e9st ig\u00e9nyl\u0151 t\u00e9teleikn\u00e9l az \u00e1r a m\u00e9r\u00e9s ut\u00e1n v\u00e9glegesedik.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal", "Estimated total:", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal", "Becs\u00fclt \u00f6sszeg:", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton", "Proceed to checkout", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton", "Tov\u00e1bb a p\u00e9nzt\u00e1rhoz", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton", "View cart", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton", "Kos\u00e1r megtekint\u00e9se", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart", "added", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart", "hozz\u00e1adva", hu); + + // JS voice / status strings + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported", "Your browser does not support audio recording.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported", "A b\u00f6ng\u00e9sz\u0151 nem t\u00e1mogatja a hangfelv\u00e9telt.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError", "Could not access microphone: ", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError", "Nem siker\u00fclt a mikrofon el\u00e9r\u00e9se: ", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied", "Please allow microphone access.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied", "Enged\u00e9lyezd a mikrofon haszn\u00e1lat\u00e1t.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound", "No microphone found.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound", "Nincs mikrofon csatlakoztatva.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating", "Calibrating...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating", "Kalib\u00e1l\u00f3d\u00e1s...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing", "Processing...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing", "Feldolgoz\u00e1s...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed", "Could not record audio. Please try again.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed", "Nem siker\u00fclt hangot r\u00f6gz\u00edteni. K\u00e9rem, pr\u00f3b\u00e1lja \u00fajra.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh", "Loud and clear", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh", "Hangos \u00e9s \u00e9rhet\u0151", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking", "Speaking...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking", "Besz\u00e9l...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder", "Speak louder!", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder", "Hangosabban!", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching", "Searching...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching", "Keres\u00e9s...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts", "Please enter the products!", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts", "K\u00e9rem, add meg a term\u00e9keket!", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError", "Error during search.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError", "Hiba a keres\u00e9s sor\u00e1n.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError", "Error processing audio.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError", "Hiba a hangfeldolgoz\u00e1s sor\u00e1n.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError", "Error adding item to cart.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError", "Hiba a kos\u00e1rba helyez\u00e9s sor\u00e1n.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix", "Error: ", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix", "Hiba: ", hu); + + // Controller JSON error messages + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Not logged in", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Nincs bejelentkezve", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided", "No text provided", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided", "Nincs sz\u00f6veg megadva", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified", "Could not identify products", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified", "Nem siker\u00fclt term\u00e9keket azonos\u00edtani", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived", "No audio received", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived", "Nem \u00e9rkezett hangf\u00e1jl", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed", "Speech recognition failed", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed", "Nem siker\u00fclt a hangfelismer\u00e9s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable", "Product not available", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable", "A term\u00e9k nem el\u00e9rhet\u0151", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "Invalid product or quantity", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "\u00c9rv\u00e9nytelen term\u00e9k vagy mennyis\u00e9g", hu); + await base.InstallAsync(); } @@ -87,33 +210,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin return Task.FromResult>(new List { PublicWidgetZones.ProductBoxAddinfoBefore, PublicWidgetZones.ProductDetailsBottom, AdminWidgetZones.ProductDetailsBlock, AdminWidgetZones.OrderDetailsBlock }); } - //public string GetWidgetViewComponentName(string widgetZone) - //{ - // return "ProductAIWidget"; // A ViewComponent neve - //} - - // --- ADMIN MEN --- - //public async Task ManageSiteMapAsync(AdminMenuItem rootNode) - //{ - // if (!await _permissionService.AuthorizeAsync(StandardPermission.Configuration.MANAGE_PLUGINS)) - // return; - - // var pluginNode = new AdminMenuItem - // { - // SystemName = "FruitBankPlugin.Configure", - // Title = "AI Assistant", - // Url = $"{_webHelper.GetStoreLocation()}Admin/FruitBankPluginAdmin/Configure", - // Visible = true - // }; - // rootNode.ChildNodes.Add(pluginNode); - // //return Task.CompletedTask; - //} - public async Task ManageSiteMapAsync(AdminMenuItem rootNode) { if (!await _permissionService.AuthorizeAsync(StandardPermission.Configuration.MANAGE_PLUGINS)) return; - } public override string GetConfigurationPageUrl() @@ -138,15 +238,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin { return zones.Any(widgetZone.Equals) ? typeof(ProductAttributesViewComponent) : null; } - else if (widgetZone == AdminWidgetZones.OrderDetailsBlock) { return zones.Any(widgetZone.Equals) ? typeof(OrderAttributesViewComponent) : null; } } - + return null; - } } } diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/FruitBankMessageTokenProvider.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/FruitBankMessageTokenProvider.cs new file mode 100644 index 0000000..e5edec1 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/FruitBankMessageTokenProvider.cs @@ -0,0 +1,237 @@ +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; +using Nop.Core; +using Nop.Core.Domain; +using Nop.Core.Domain.Catalog; +using Nop.Core.Domain.Common; +using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Directory; +using Nop.Core.Domain.Messages; +using Nop.Core.Domain.Orders; +using Nop.Core.Domain.Payments; +using Nop.Core.Domain.Tax; +using Nop.Core.Domain.Vendors; +using Nop.Core.Events; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Services.Attributes; +using Nop.Services.Blogs; +using Nop.Services.Catalog; +using Nop.Services.Common; +using Nop.Services.Customers; +using Nop.Services.Directory; +using Nop.Services.Events; +using Nop.Services.Helpers; +using Nop.Services.Html; +using Nop.Services.Localization; +using Nop.Services.Logging; +using Nop.Services.Messages; +using Nop.Services.News; +using Nop.Services.Orders; +using Nop.Services.Payments; +using Nop.Services.Seo; +using Nop.Services.Shipping; +using Nop.Services.Stores; +using Nop.Services.Vendors; +using System.ComponentModel.DataAnnotations; +using System.Text; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Infrastructure +{ + public class FruitBankMessageTokenProvider : MessageTokenProvider + { + private readonly IOrderService _orderService; + private readonly IPriceFormatter _priceFormatter; + private readonly ICurrencyService _currencyService; + private readonly CurrencySettings _currencySettings; + private readonly FruitBankDbContext _dbContext; + + public FruitBankMessageTokenProvider( + CatalogSettings catalogSettings, + CurrencySettings currencySettings, + IActionContextAccessor actionContextAccessor, + IAddressService addressService, + IAttributeFormatter addressAttributeFormatter, + IAttributeFormatter customerAttributeFormatter, + IAttributeFormatter vendorAttributeFormatter, + IBlogService blogService, + ICountryService countryService, + ICurrencyService currencyService, + ICustomerService customerService, + IDateTimeHelper dateTimeHelper, + IEventPublisher eventPublisher, + IGenericAttributeService genericAttributeService, + IGiftCardService giftCardService, + IHtmlFormatter htmlFormatter, + ILanguageService languageService, + ILocalizationService localizationService, + ILogger logger, + INewsService newsService, + IOrderService orderService, + IPaymentPluginManager paymentPluginManager, + IPaymentService paymentService, + IPriceFormatter priceFormatter, + IProductService productService, + IRewardPointService rewardPointService, + IShipmentService shipmentService, + IStateProvinceService stateProvinceService, + IStoreContext storeContext, + IStoreService storeService, + IUrlHelperFactory urlHelperFactory, + IUrlRecordService urlRecordService, + IWorkContext workContext, + MessageTemplatesSettings templatesSettings, + PaymentSettings paymentSettings, + StoreInformationSettings storeInformationSettings, + TaxSettings taxSettings, + FruitBankDbContext dbContext + ) : base( + catalogSettings, + currencySettings, + actionContextAccessor, + addressService, + addressAttributeFormatter, + customerAttributeFormatter, + vendorAttributeFormatter, + blogService, + countryService, + currencyService, + customerService, + dateTimeHelper, + eventPublisher, + genericAttributeService, + giftCardService, + htmlFormatter, + languageService, + localizationService, + logger, + newsService, + orderService, + paymentPluginManager, + paymentService, + priceFormatter, + productService, + rewardPointService, + shipmentService, + stateProvinceService, + storeContext, + storeService, + urlHelperFactory, + urlRecordService, + workContext, + templatesSettings, + paymentSettings, + storeInformationSettings, + taxSettings) + { + _orderService = orderService; + _priceFormatter = priceFormatter; + _currencyService = currencyService; + _currencySettings = currencySettings; + _dbContext = dbContext; + } + + public override async Task AddOrderTokensAsync( + IList tokens, + Order order, + int languageId, + int vendorId = 0) + { + // Run base first to populate all other Order.* tokens + await base.AddOrderTokensAsync(tokens, order, languageId, vendorId); + + // Replace the product table token with our custom version + var existing = tokens.FirstOrDefault(t => t.Key == "Order.Product(s)"); + if (existing != null) + tokens.Remove(existing); + + tokens.Add(new Token("Order.Product(s)", await BuildCustomProductTableAsync(order, languageId), true)); + } + + private async Task BuildCustomProductTableAsync(Order order, int languageId) + { + var currency = await _currencyService.GetCurrencyByCodeAsync(order.CustomerCurrencyCode) + ?? await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId); + + var items = await _orderService.GetOrderItemsAsync(order.Id); + var itemDtos = await _dbContext.OrderItemDtos.GetAllByOrderId(order.Id).ToListAsync(); + + var sb = new StringBuilder(); + + sb.AppendLine(@" + + + + + + + + + + "); + + var rowIndex = 0; + foreach (var item in itemDtos) + { + var product = await _orderService.GetProductByOrderItemIdAsync(item.Id); + if (product == null) continue; + + var unitPrice = await _priceFormatter.FormatPriceAsync( + item.UnitPriceInclTax, true, currency, languageId, true); + var lineTotal = await _priceFormatter.FormatPriceAsync( + item.PriceInclTax, true, currency, languageId, true); + + var rowBg = rowIndex % 2 == 0 ? "#ffffff" : "#f2f7f0"; + rowIndex++; + if (item.IsMeasurable) + { + var averageWeight = item.AverageWeight; + var approximatePrice = item.Quantity * item.UnitPriceInclTax * (decimal)averageWeight; + sb.AppendLine($@" + + + + + + "); + } + else + { + sb.AppendLine($@" + + + + + + "); + } + } + + var orderTotal = await _priceFormatter.FormatPriceAsync( + order.OrderTotal, true, currency, languageId, true); + + if(itemDtos.Any(i => i.IsMeasurable)) + { + sb.AppendLine($@" + + + + "); + } + + else + { + sb.AppendLine($@" + + + + "); + + } + + + sb.AppendLine(" \n
TermékMennyiségEgységárÖsszesen
{product.Name}{item.Quantity}{unitPrice}Kalkuláció alatt, nagyságrendileg {approximatePrice}
{product.Name}{item.Quantity}{unitPrice}{lineTotal}
Végösszeg:Mérendő termék miatt kalkuláció alatt...
Végösszeg:{orderTotal}
"); + + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs index 18bcd93..2d81000 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Nop.Core.Domain.Orders; using Nop.Core.Infrastructure; using Nop.Data; @@ -90,8 +91,15 @@ public class PluginNopStartup : INopStartup services.AddScoped(); //services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + //services.AddScoped(); + //services.AddScoped(); + services.Replace( + ServiceDescriptor.Scoped() + ); + //services.AddScoped(); + services.Replace( + ServiceDescriptor.Scoped() + ); services.AddScoped, EventConsumer>(); services.AddScoped(); services.AddScoped(); diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs index 7fe2e05..6e2d201 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs @@ -27,7 +27,6 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.Admin.Order.List", pattern: "Admin/Order/List", defaults: new { controller = "CustomOrder", action = "List", area = AreaNames.ADMIN } - //constraints: new { area = AreaNames.ADMIN } ); endpointRouteBuilder.MapControllerRoute( @@ -39,14 +38,12 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.Admin.Order.Test", pattern: "Admin/Order/Test", defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN } - //constraints: new { area = AreaNames.ADMIN } ); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Index", pattern: "Admin", defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN } - //constraints: new { area = AreaNames.ADMIN } ); endpointRouteBuilder.MapControllerRoute( @@ -181,10 +178,41 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.Admin.ExtractText", pattern: "Admin/ExtractText", defaults: new { controller = "FileManager", action = "ImageTextExtraction", area = AreaNames.ADMIN }); + + // ── Public: Quick Order ────────────────────────────────────────────── + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.Index", + pattern: "gyors-rendeles", + defaults: new { controller = "QuickOrder", action = "Index" }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.GetAllProducts", + pattern: "gyors-rendeles/osszes-termek", + defaults: new { controller = "QuickOrder", action = "GetAllProducts" }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.SearchProducts", + pattern: "gyors-rendeles/kereses", + defaults: new { controller = "QuickOrder", action = "SearchProducts" }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.TranscribeAndSearch", + pattern: "gyors-rendeles/hang", + defaults: new { controller = "QuickOrder", action = "TranscribeAndSearch" }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.AddToCart", + pattern: "gyors-rendeles/kosarba", + defaults: new { controller = "QuickOrder", action = "AddToCart" }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.GetCartItems", + pattern: "gyors-rendeles/kosar", + defaults: new { controller = "QuickOrder", action = "GetCartItems" }); } /// /// Gets a priority of route provider /// public int Priority => 4000; -} \ No newline at end of file +} diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.en.xml b/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.en.xml new file mode 100644 index 0000000..2b6c5a3 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.en.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.hu.xml b/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.hu.xml new file mode 100644 index 0000000..53e0ea7 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.hu.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj index 9803096..a5ecaa7 100644 --- a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj +++ b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj @@ -10,6 +10,7 @@ + @@ -65,6 +66,9 @@ PreserveNewest Always + + Always + PreserveNewest @@ -661,6 +665,9 @@ Always + + Always + diff --git a/Nop.Plugin.Misc.AIPlugin/SKILL.md b/Nop.Plugin.Misc.AIPlugin/SKILL.md new file mode 100644 index 0000000..83b2ff7 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/SKILL.md @@ -0,0 +1,351 @@ +# FruitBank Plugin – Claude Skill Reference + +> **Purpose:** This file is a reference document for Claude to quickly understand the FruitBank NopCommerce plugin codebase, patterns, and conventions so that new work sessions can ramp up without re-reading the entire codebase from scratch. + +--- + +## 1. Project Identity + +| Property | Value | +|---|---| +| Plugin system name | `Misc.FruitBankPlugin` | +| DLL | `Nop.Plugin.Misc.FruitBankPlugin.dll` | +| Namespace root | `Nop.Plugin.Misc.FruitBankPlugin` | +| NopCommerce version | **4.80** | +| Author | Adam Gelencser | +| Plugin source path | `D:\REPOS\MANGO\source\Nopcommerce.Common\4.70\Plugins\Nop.Plugin.Misc.AIPlugin` | +| Theme path | `D:\REPOS\MANGO\source\FruitBank\Presentation\Nop.Web\Themes\CarHaven` | + +The plugin is called **AIPlugin** on disk (folder/csproj) but the assembly and namespace use `FruitBankPlugin`. Both names are the same thing. + +--- + +## 2. Business Domain + +FruitBank is a **Hungarian fruit and vegetable wholesale company** running a private B2B NopCommerce webshop. The typical user is a warehouse employee or admin working on mobile. Key business concepts: + +- **Partners** – business customers (companies), matched by name across multiple systems +- **Shipping documents** – PDF/image documents received from suppliers, parsed by AI +- **Measurable products** – products that require physical weighing before price is finalized; `IsMeasurable` is determined server-side only +- **Stock taking** – periodic inventory audit workflow with discrepancy reports +- **InnVoice** – external accounting/invoicing system, synced via `InnVoiceOrderService` / `InnVoiceApiService` +- **Voice ordering** – warehouse staff dictate orders in Hungarian; transcribed via Whisper + +--- + +## 3. Folder Structure + +``` +Nop.Plugin.Misc.AIPlugin/ +├── Areas/Admin/ +│ ├── Controllers/ # All admin-area controllers +│ ├── Components/ # Admin view components +│ ├── Factories/ # CustomOrderModelFactory, CustomProductModelFactory +│ ├── Models/ # Admin view models (extended Nop models) +│ ├── Validators/ +│ └── Views/ # Admin Razor views; custom layouts: _FruitBankAdminLayout.cshtml +├── Controllers/ # Public-facing controllers (QuickOrder, Checkout, FruitBankData) +├── Components/ # Widget view components (ProductAI, ProductAttributes, OrderAttributes) +├── css/ / js/ # Static assets for the plugin +├── Domains/ +│ └── DataLayer/ # LinqToDB table classes + DbContexts +│ ├── FruitBankDbContext.cs +│ ├── StockTakingDbContext.cs +│ └── *DbTable.cs # One file per custom table +├── Infrastructure/ +│ ├── PluginNopStartup.cs # DI registration + SignalR + middleware +│ ├── RouteProvider.cs +│ ├── ViewLocationExpander.cs +│ └── FruitBankMessageTokenProvider.cs # Overrides IMessageTokenProvider +├── Services/ # Business logic services +├── Localization/ +│ ├── quickorder.en.xml +│ └── quickorder.hu.xml +├── FruitBankPlugin.cs # Main plugin class (IWidgetPlugin) +├── FruitBankSettings.cs # Plugin settings (ApiKey etc.) +├── FruitBankConst.cs # Constants +└── plugin.json +``` + +--- + +## 4. Key Services + +### AI / LLM +| Service | Purpose | +|---|---| +| `OpenAIApiService` | Primary OpenAI integration – chat completions, Whisper transcription | +| `OpenAiService` | Lightweight wrapper using `gpt-4o-mini` for simple prompts | +| `CerebrasAPIService` | Alternative LLM provider | +| `ReplicateService` | Replicate.com API (image/audio models); registered with a hardcoded Bearer token | +| `AICalculationService` | AI-assisted price/measurement calculations | + +### Storage +| Service | Purpose | +|---|---| +| `FileStorageService` | Generic file storage: SHA256 hash dedup, GZip compression, path building | +| `IFileStorageProvider` / `LocalFileStorageProvider` | Strategy pattern storage backend (currently local disk / wwwroot/uploads) | + +**FileStorageService patterns:** +- Calculates SHA256 on upload BEFORE any AI processing → prevents duplicate API calls +- Skips GZip for already-compressed formats (jpg, pdf, mp4, zip, etc.) +- Path format: `{userId}/{featureName}/{entityType}-{entityId}/{fileName}_{id}.ext` +- DB record created first to get ID, then file is saved; rolled back on failure + +### Order / Measurement +| Service | Purpose | +|---|---| +| `OrderMeasurementService` / `IOrderMeasurementService` | Handles orders that contain measurable products | +| `MeasurementService` / `IMeasurementService` | Core weighing logic | +| `InnVoiceOrderService` | Syncs orders with InnVoice accounting system | +| `InnVoiceApiService` | HTTP client for the InnVoice REST API | + +### Infrastructure +| Service | Purpose | +|---|---| +| `FruitBankAttributeService` | Custom product/order attribute helpers | +| `LockService` / `ILockService` | Singleton distributed lock | +| `PdfToImageService` | Converts PDF pages to images for AI vision processing | +| `EventConsumer` | Handles `OrderPlacedEvent` | +| `FruitBankHub` | SignalR hub for real-time admin notifications | + +--- + +## 5. Admin Controllers + +| Controller | Purpose | +|---|---| +| `CustomOrderController` | Extended order management: **split order** feature (audit-based + manual selection modes), order notes, SignalR events | +| `CustomDashboardController` | AI-powered admin dashboard with `GetWelcomeMessageAsync` (store summary, order totals, stock discrepancies, OpenWeatherMap weather) | +| `ShippingController` | Shipping document management + AI PDF extraction workflow | +| `VoiceOrderController` | Voice-to-order admin tool (mobile-optimized) | +| `FruitBankAudioController` | Audio upload/processing endpoint for Whisper transcription | +| `InvoiceController` | Invoice generation and management | +| `InnVoiceOrderController` | InnVoice order sync UI | +| `InnVoiceOrderSyncController` | InnVoice sync API endpoints | +| `ManagementPageController` | General management page | +| `FileManagerController` + `FileManagerScriptsApiController` | File manager UI | +| `FileStorageController` | File storage API endpoints | +| `AppDownloadController` | App download/distribution page | +| `FruitBankPluginAdminController` | Plugin configuration page | +| `CustomProductController` | Extended product admin (IsMeasurable etc.) | + +--- + +## 6. Public Controllers + +| Controller | Route | Purpose | +|---|---|---| +| `QuickOrderController` | `/gyors-rendeles` | Customer-facing quick order page with voice + text search | +| `CheckoutController` | `/checkout/*` | Custom checkout flow override | +| `FruitBankDataController` | `/fruitbankdata/*` | Public data API endpoints; also implements `IFruitBankDataControllerServer` | + +--- + +## 7. Widget Zones + +The plugin registers widgets in: +- `PublicWidgetZones.ProductBoxAddinfoBefore` → `ProductAIWidgetViewComponent` +- `PublicWidgetZones.ProductDetailsBottom` → `ProductAIWidgetViewComponent` +- `AdminWidgetZones.ProductDetailsBlock` → `ProductAttributesViewComponent` +- `AdminWidgetZones.OrderDetailsBlock` → `OrderAttributesViewComponent` + +--- + +## 8. DI Registration Patterns (PluginNopStartup) + +Important overrides / replacements: +```csharp +// Replaces the default NopCommerce price calculator +services.Replace(ServiceDescriptor.Scoped()); + +// Overrides email order table rendering +services.Replace(ServiceDescriptor.Scoped()); + +// Overrides generic attribute service +services.AddScoped(); + +// Overrides order model and product model factories +services.AddScoped(); +services.AddScoped(); + +// Overrides WorkflowMessageService (order emails) +services.AddScoped(); +``` + +SignalR is configured with: +- MaximumReceiveMessageSize / StatefulReconnectBufferSize: 30 MB +- `DevAdminSignalRHub` on `/{FruitBankConstClient.DefaultHubName}` (WebSockets only) +- `LoggerSignalRHub` on `/{FruitBankConstClient.LoggerHubName}` + +--- + +## 9. Database / Data Layer + +Custom tables use **LinqToDB** (not EF Core) through wrapper DbTable classes registered in DI. Two DbContext wrappers: +- `FruitBankDbContext` – main plugin data +- `StockTakingDbContext` – stock taking workflow data + +Key custom tables: +| DbTable class | Purpose | +|---|---| +| `PartnerDbTable` | Business partners (wholesale customers) | +| `ShippingDbTable` | Shipping records | +| `ShippingDocumentDbTable` | Parsed shipping document metadata | +| `ShippingItemDbTable` | Line items from shipping documents | +| `ShippingDocumentToFilesDbTable` | Junction: document ↔ file | +| `FilesDbTable` | Generic file records (hash, compression flag, raw text) | +| `OrderDtoDbTable` / `OrderItemDtoDbTable` | Order DTO projections | +| `OrderItemPalletDbTable` / `ShippingItemPalletDbTable` / etc. | Pallet tracking for measurement workflow | +| `StockTakingDbTable` / `StockTakingItemDbTable` | Stock audit records | +| `StockQuantityHistoryDtoDbTable` | Stock movement history | +| `MeasuringItemPalletBaseDbTable` | Base pallet measuring data | + +**N+1 query prevention:** Always batch DB calls with `Task.WhenAll`. Never query per-item inside a loop. + +--- + +## 10. Localization + +All resource keys follow the prefix: `Plugins.Misc.FruitBankPlugin.*` + +- Keys are registered programmatically in `FruitBankPlugin.InstallAsync()` for **both EN and HU** +- XML locale files in `/Localization/`: `quickorder.en.xml`, `quickorder.hu.xml` +- When adding new keys: update **all three places** (InstallAsync + both XML files) +- Hungarian is the **primary** language; English is secondary +- Use `_localizationService.AddOrUpdateLocaleResourceAsync("key", "value", "HU")` pattern + +Common key prefixes: +- `Plugins.Misc.FruitBankPlugin.Menu.*` – navigation +- `Plugins.Misc.FruitBankPlugin.QuickOrder.*` – quick order page (extensive set) + +--- + +## 11. Quick Order Page (`/gyors-rendeles`) + +**Controller:** `QuickOrderController` +**View:** `/Views/QuickOrder/` +**CSS:** `/css/quick-order.css` (in plugin) + deployed to CarHaven theme + +Design system tokens (CarHaven theme): +```css +--theme-color: #2d7a3a /* green */ +--active-color: #f4a236 /* amber */ +--dark: #1a3c22 +--light-bg: #f5f7f2 +font-family: 'DM Sans' +border-radius: 8px +``` + +Product cards use full-width flex rows: `.product-card { flex-direction: row }` with `.pc-body` (left, grows) and `.pc-actions` (right, fixed). + +Navigation menu integration: +- CarHaven `TopMenu/Default.cshtml` has a `
  • ` for both desktop (`.notmobile`) and mobile (`.mobile`) menu blocks +- Guarded by `@if (Model.DisplayCustomerInfoMenuItem)` (login-gated) +- Menu item styled in `quick-order-menu.css` (amber, bold) included via `Head.cshtml` +- Uses `fa fa-bolt` icon + +Voice input: +- Records audio in browser, POSTs to `FruitBankAudioController` +- Whisper transcription with Hungarian vocabulary hints (partner names + produce terms) +- **Prompt character limit is 224** – use keyword extraction, not full company names +- Fallback: manual text search input + +--- + +## 12. Split Order Feature + +Admin page on order detail. Two modes selectable via radio buttons: + +| Mode | Behaviour | +|---|---| +| **Audit-based** | Available only when order has both "started" and "non-started" audit items. Audited items stay; non-audited items move to new order. | +| **Manual selection** | Always available (except for fully audited orders). Checkbox per item; user chooses what moves. | + +Split button is always enabled (except audited orders). Mode availability is communicated visually if a mode is disabled. + +**Critical lesson:** `TransactionSafeAsync` caused deadlocks because `TaskHelper.ToThreadPoolTask` creates async/await context switching in ASP.NET. The transaction wrapper was removed. Avoid wrapping split logic in `TransactionSafeAsync`. + +After split: inventory adjustments, order notes written, SignalR notification sent to admin clients. + +--- + +## 13. AI Admin Dashboard (`GetWelcomeMessageAsync`) + +Located in `CustomDashboardController`. Generates a structured OpenAI prompt containing: +- Store data summary +- Today's order totals +- Stock discrepancy summary (from stock taking audit) +- OpenWeatherMap weather data (real API key configured in settings) + +Patterns used: +- Typed C# records for data transfer +- `Task.WhenAll` for parallel DB calls +- Batched product history queries (no N+1) +- Bilingual (Hungarian/English) system prompt with JSON field guide for the AI +- `salesAdjustmentSum` – be careful not to double-count + +--- + +## 14. Shipping Document Processing + +AI-driven workflow for extracting partner + product data from uploaded PDFs/images: + +1. File uploaded → SHA256 hash calculated **before AI call** +2. Hash checked against DB → if duplicate, load existing data (skip AI, save API cost) +3. PDF converted to image(s) via `PdfToImageService` if needed +4. OpenAI vision API extracts structured product/partner data +5. Multi-stage matching: string search → historical shipping data → AI semantic match +6. UI shows visual matched/unmatched indicators + autocomplete for manual correction +7. `IsMeasurable` is **server-side only** – never expose in frontend forms + +--- + +## 15. NopCommerce 4.80 Gotchas + +| Issue | Correct approach | +|---|---| +| `ICustomerAttributeService` does not exist | Use direct `XDocument.Parse` on the XML stored in `GenericAttribute.Value` | +| `ParseAttributeValuesAsync` returns empty for free-text attributes | It's designed for predefined selection attributes (ID lookup). For free-text: parse XML directly: `......` | +| `TransactionSafeAsync` + async = deadlock | `TaskHelper.ToThreadPoolTask` inside it causes context switching deadlocks in ASP.NET; remove transaction wrapper for affected code | +| Email order table customization | Override `IMessageTokenProvider` with `FruitBankMessageTokenProvider` (already done); base class constructor has many parameters – pass all through exactly | +| `OrderPlaced.CustomerNotification` email template | Configured in NopCommerce admin under Content → Message Templates; code hook is `WorkflowMessageService.SendOrderPlacedCustomerNotificationAsync` | + +--- + +## 16. Theme (CarHaven) + +Path: `D:\REPOS\MANGO\source\FruitBank\Presentation\Nop.Web\Themes\CarHaven` + +Relevant files modified by this plugin's work: +- `TopMenu/Default.cshtml` – quick order nav item added +- `Head.cshtml` – includes `quick-order-menu.css` +- `css/quick-order-menu.css` – amber nav item styling + +--- + +## 17. External APIs / Credentials + +| Service | Usage | Notes | +|---|---|---| +| OpenAI | Chat completions (gpt-4o-mini / gpt-4o), Whisper transcription | API key in `FruitBankSettings.ApiKey` | +| OpenWeatherMap | Weather data for dashboard welcome message | Real API key confirmed in settings | +| Replicate | Image/audio AI models | Bearer token hardcoded in `PluginNopStartup` HTTP client registration | +| InnVoice | Hungarian accounting/invoicing system | REST API via `InnVoiceApiService` | + +--- + +## 18. Conventions & Patterns to Follow + +1. **Always bilingual** – every new locale resource key goes into InstallAsync (EN + HU) and both XML files. +2. **Mobile-first** for warehouse tools – large touch targets, step-by-step UX, pulse animations. +3. **Server-side business rules** – never expose `IsMeasurable` or similar computed flags in frontend forms. +4. **Batch DB calls** – use `Task.WhenAll`, never query inside a loop. +5. **Hash before AI** – always deduplicate files by SHA256 hash before calling any AI API. +6. **CarHaven design tokens** – use CSS variables (`--theme-color`, `--active-color`, `--dark`, `--light-bg`, `DM Sans` font) consistently. +7. **No `TransactionSafeAsync`** on code paths that use async/await through thread pool. +8. **Expect corrections** – Adam knows the codebase deeply; treat any correction as authoritative and apply it without re-litigating. + +--- + +*Last updated: 2026-03-18 | Maintained by: Claude (auto-generated from codebase + project chat history)* diff --git a/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs b/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs index 125a6bf..4458787 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs @@ -26,6 +26,7 @@ public class CustomPriceCalculationService : PriceCalculationService private readonly IProductAttributeService _productAttributeService; private readonly ISpecificationAttributeService _specificationAttributeService; private readonly ILocalizationService _localizationService; + private readonly IStoreContext _storeContext; private ILogger _logger; public CustomPriceCalculationService( @@ -46,7 +47,8 @@ public class CustomPriceCalculationService : PriceCalculationService ILocalizationService localizationService, IStaticCacheManager cacheManager, IWorkContext workContext, - IEnumerable logWriters) + IEnumerable logWriters, + IStoreContext storeContext) : base(catalogSettings, currencySettings, categoryService, currencyService, customerService, discountService, manufacturerService, productAttributeParser, productService, cacheManager) @@ -58,6 +60,7 @@ public class CustomPriceCalculationService : PriceCalculationService _productAttributeService = productAttributeService; _specificationAttributeService = specificationAttributeService; _localizationService = localizationService; + _storeContext = storeContext; } public static decimal CalculateOrderItemFinalPrice(bool isMeasurable, decimal unitPrice, int quantity, double netWeight) @@ -135,9 +138,26 @@ public class CustomPriceCalculationService : PriceCalculationService _logger.Info($"order.OrderTotal({order.OrderTotal}) == prevOrderTotal({prevOrderTotal})"); order.OrderSubtotalInclTax = order.OrderTotal; - order.OrderSubtotalExclTax = order.OrderTotal; - order.OrderSubTotalDiscountInclTax = order.OrderTotal; - order.OrderSubTotalDiscountExclTax = order.OrderTotal; + order.OrderSubtotalExclTax = (order.OrderTotal / (decimal)1.27); + + //mivel csak csekkolunk, de nem adunk vissza semmilyen kedvezményt, így a subtotal discount értékek kiszámolááshoz meg kell hívni megint a calculate final price-t + decimal orderSubTotalDiscountInclTax = 0; + decimal orderSubTotalDiscountExclTax = 0; + var store = await _storeContext.GetCurrentStoreAsync(); + + foreach (var orderItem in orderItems) + { + var orderItemDto = orderItemDtosById[orderItem.Id]; + var product = await _dbContext.Products.GetByIdAsync(orderItem.ProductId); + var customer = await _dbContext.Customers.GetByIdAsync(order.CustomerId); + var itemPrice = await GetFinalPriceAsync(product, customer, store, 0, true, orderItemDto.Quantity); + orderSubTotalDiscountInclTax += itemPrice.appliedDiscountAmount; + orderSubTotalDiscountExclTax += itemPrice.appliedDiscountAmount / (decimal)1.27; + } + + + order.OrderSubTotalDiscountInclTax = orderSubTotalDiscountInclTax; + order.OrderSubTotalDiscountExclTax = orderSubTotalDiscountExclTax; await _dbContext.Orders.UpdateAsync(order, false); return true; diff --git a/Nop.Plugin.Misc.AIPlugin/Views/QuickOrder/Index.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/QuickOrder/Index.cshtml new file mode 100644 index 0000000..5644530 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Views/QuickOrder/Index.cshtml @@ -0,0 +1,566 @@ +@{ + Layout = "_Root"; + ViewBag.Title = T("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle").Text; +} + + + +
    + + +
    + +
    + + +
    + + +
    + + + + + + +
    + +

    @T("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts")

    +
    + + + +
    + + +
    + +
    + @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle") + 0 +
    + +
    + +

    @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1")
    @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2")

    +
    + + + + + + + +
    +
    +
    + +@Html.AntiForgeryToken() + +@* JS string bundle — Razor renders these once so JS never contains raw Hungarian *@ + + + diff --git a/Nop.Plugin.Misc.AIPlugin/css/quick-order.css b/Nop.Plugin.Misc.AIPlugin/css/quick-order.css new file mode 100644 index 0000000..a850bc9 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/css/quick-order.css @@ -0,0 +1,756 @@ +/* + * Quick Order Page — FruitBank / CarHaven Theme + * Design tokens inherited from themes/CarHaven/Content/css/styles.css :root + * --theme-color : #2d7a3a (forest green) + * --active-color: #f4a236 (amber / CTA) + * --dark : #1a3c22 (dark green) + * --light-bg : #f5f7f2 (off-white green tint) + * --text-primary: #2c2c2c + * --text-muted : #6b7c6e + * --accent-lime : #8cb63c + * --warm-bg : #faebd7 + * font : 'DM Sans', sans-serif + * radius : 8px + */ + +/* ───────────────────────────────────────────── + PAGE SHELL +───────────────────────────────────────────── */ +.quick-order-page { + width: 94%; + max-width: 1400px; + margin: 0 auto; + padding: 24px 0 60px; + font-family: 'DM Sans', sans-serif; + color: #2c2c2c; +} + +/* ───────────────────────────────────────────── + SEARCH BAR +───────────────────────────────────────────── */ +.qo-search-bar-wrapper { + background: #fff; + border: 1px solid #dde8da; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(45, 122, 58, 0.08); + padding: 18px 20px; + margin-bottom: 24px; +} + +.search-input-group { + display: flex; + align-items: center; + gap: 0; +} + +/* Mic button */ +.mic-btn { + flex-shrink: 0; + width: 46px; + height: 46px; + border: 2px solid #2d7a3a; + background: #fff; + color: #2d7a3a; + border-radius: 8px 0 0 8px; + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s, color 0.2s; + position: relative; +} + +.mic-btn:hover { + background: #2d7a3a; + color: #fff; +} + +.mic-btn-recording { + background: #1a3c22; + color: #f4a236; + border-color: #1a3c22; + animation: mic-pulse 1.4s ease-in-out infinite; +} + +@keyframes mic-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(244,162,54,.5); } + 50% { box-shadow: 0 0 0 8px rgba(244,162,54,0); } +} + +.mic-pulse { + display: none; +} + +/* Search text input */ +.qo-input { + flex: 1; + height: 46px; + border: 2px solid #dde8da; + border-left: none; + border-right: none; + border-radius: 0; + padding: 0 16px; + font-size: 15px; + font-family: 'DM Sans', sans-serif; + color: #2c2c2c; + outline: none; + transition: border-color 0.2s; +} + +.qo-input:focus { + border-color: #2d7a3a; + z-index: 1; +} + +.qo-input::placeholder { + color: #6b7c6e; +} + +/* Search button */ +.qo-search-btn { + flex-shrink: 0; + height: 46px; + padding: 0 22px; + background: #2d7a3a; + color: #fff; + border: 2px solid #2d7a3a; + border-radius: 0 8px 8px 0; + font-size: 14px; + font-family: 'DM Sans', sans-serif; + font-weight: 600; + letter-spacing: 0.3px; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.qo-search-btn:hover { + background: #1a3c22; + border-color: #1a3c22; +} + +/* Recording status bar */ +.recording-status-bar { + margin-top: 12px; + display: flex; + align-items: center; + gap: 14px; + background: #f5f7f2; + border: 1px solid #dde8da; + border-radius: 6px; + padding: 8px 14px; +} + +#statusText { + font-size: 13px; + color: #2d7a3a; + font-weight: 600; + min-width: 130px; + white-space: nowrap; +} + +.volume-bar-container { + flex: 1; + height: 6px; + background: #dde8da; + border-radius: 3px; + overflow: hidden; +} + +.volume-bar { + height: 100%; + width: 0; + border-radius: 3px; + transition: width 0.1s ease, background 0.2s; + background: #dde8da; +} +.volume-bar-low { background: #f4a236; } +.volume-bar-medium { background: #8cb63c; } +.volume-bar-high { background: #2d7a3a; } +.volume-bar-silent { background: #dde8da; } + +/* ───────────────────────────────────────────── + TWO-COLUMN LAYOUT +───────────────────────────────────────────── */ +.qo-layout { + display: grid; + grid-template-columns: 1fr 340px; + gap: 24px; + align-items: start; +} + +/* ───────────────────────────────────────────── + PRODUCTS PANEL (LEFT) +───────────────────────────────────────────── */ + +/* "I heard" transcription card */ +.result-card { + background: #fff; + border: 1px solid #dde8da; + border-left: 4px solid #2d7a3a; + border-radius: 8px; + padding: 14px 18px; + margin-bottom: 16px; +} + +.result-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + color: #2d7a3a; + margin-bottom: 4px; +} + +.result-text { + font-size: 15px; + color: #2c2c2c; +} + +/* No results / empty */ +.no-results-card { + background: #fff; + border: 1px dashed #dde8da; + border-radius: 8px; + text-align: center; + padding: 40px 20px; + color: #6b7c6e; + font-size: 15px; +} + +.no-results-card .fa { + font-size: 28px; + color: #dde8da; + margin-bottom: 10px; + display: block; +} + +/* Loading state */ +.products-empty-state { + background: #fff; + border: 1px solid #dde8da; + border-radius: 8px; + text-align: center; + padding: 48px 20px; + color: #6b7c6e; +} + +.products-empty-state .fa { + font-size: 28px; + color: #2d7a3a; + margin-bottom: 10px; + display: block; +} + +/* Section header above product list */ +.matches-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #6b7c6e; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 6px; +} + +.matches-label .fa { + color: #2d7a3a; + font-size: 14px; +} + +/* Group label (search results grouped by keyword) */ +.group-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #f4a236; + border-bottom: 1px solid #f5f7f2; + padding: 4px 0 8px; + margin: 12px 0 6px; + display: flex; + align-items: center; + gap: 6px; +} + +/* ───────────────────────────────────────────── + PRODUCT LIST — full-width rows +───────────────────────────────────────────── */ +.product-grid { + display: flex; + flex-direction: column; + gap: 6px; +} + +.product-card { + background: #fff; + border: 1px solid #dde8da; + border-radius: 8px; + padding: 10px 14px; + display: flex; + flex-direction: row; + align-items: center; + gap: 14px; + transition: box-shadow 0.18s, border-color 0.18s; +} + +.product-card:hover { + box-shadow: 0 3px 12px rgba(45, 122, 58, 0.10); + border-color: #2d7a3a; +} + +.product-card.has-warning { + border-left: 3px solid #f4a236; +} + +/* Body — grows, holds name + meta inline */ +.pc-body { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 14px; +} + +/* Product name */ +.pc-name { + font-size: 14px; + font-weight: 700; + color: #1a3c22; + line-height: 1.3; + display: flex; + align-items: flex-start; + gap: 5px; + flex: 1 1 200px; + min-width: 0; +} + +.pc-name .fa { + color: #8cb63c; + font-size: 12px; + margin-top: 2px; + flex-shrink: 0; +} + +/* Meta row — stock + price inline */ +.pc-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + flex-shrink: 0; +} + +.pc-stock { + font-size: 12px; + color: #6b7c6e; + background: #f5f7f2; + border-radius: 4px; + padding: 2px 8px; + white-space: nowrap; +} + +.pc-stock.stock-low { + background: #fff8ee; + color: #e8734a; +} + +.pm-price { + font-size: 13px; + font-weight: 700; + color: #2d7a3a; + white-space: nowrap; +} + +/* Badges */ +.stock-warning-badge { + font-size: 11px; + color: #e8734a; + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.measurable-badge { + font-size: 11px; + background: #faebd7; + color: #e8734a; + border-radius: 4px; + padding: 2px 8px; + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +/* Actions — fixed width, right-aligned */ +.pc-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* Qty stepper */ +.qty-stepper { + display: flex; + align-items: center; + border: 1px solid #dde8da; + border-radius: 8px; + overflow: hidden; +} + +.qty-btn { + width: 34px; + height: 36px; + background: #f5f7f2; + border: none; + color: #2d7a3a; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s; + flex-shrink: 0; +} + +.qty-btn:hover { + background: #dde8da; +} + +.qty-input { + width: 48px; + height: 36px; + border: none; + border-left: 1px solid #dde8da; + border-right: 1px solid #dde8da; + text-align: center; + font-size: 14px; + font-weight: 700; + color: #1a3c22; + font-family: 'DM Sans', sans-serif; + -moz-appearance: textfield; +} + +.qty-input::-webkit-outer-spin-button, +.qty-input::-webkit-inner-spin-button { + -webkit-appearance: none; +} + +/* Add to cart button */ +.pc-add-btn { + width: 36px; + height: 36px; + background: #2d7a3a; + color: #fff; + border: none; + border-radius: 8px; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background 0.18s, transform 0.12s; +} + +.pc-add-btn:hover { + background: #1a3c22; + transform: scale(1.06); +} + +.pc-add-btn:disabled { + background: #dde8da; + cursor: default; + transform: none; +} + +.pc-add-btn.added { + background: #8cb63c; +} + +/* ───────────────────────────────────────────── + CART PANEL (RIGHT) +───────────────────────────────────────────── */ +.qo-cart-panel { + background: #fff; + border: 1px solid #dde8da; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(45, 122, 58, 0.06); + position: sticky; + top: 16px; + overflow: hidden; +} + +.qo-section-title { + background: #1a3c22; + color: #fff; + font-size: 15px; + font-weight: 700; + padding: 14px 18px; + display: flex; + align-items: center; + gap: 8px; + letter-spacing: 0.3px; +} + +.qo-section-title .fa { + color: #f4a236; + font-size: 17px; +} + +.cart-count-badge { + background: #f4a236; + color: #fff; + font-size: 11px; + font-weight: 700; + border-radius: 12px; + padding: 1px 7px; + margin-left: auto; + min-width: 24px; + text-align: center; +} + +.cart-empty { + padding: 36px 20px; + text-align: center; + color: #6b7c6e; +} + +.cart-empty .fa { + font-size: 30px; + color: #dde8da; + display: block; + margin-bottom: 10px; +} + +.cart-empty p { + font-size: 14px; + line-height: 1.5; +} + +.cart-items-list { + padding: 4px 0; + max-height: 400px; + overflow-y: auto; +} + +.cart-item { + padding: 11px 18px; + border-bottom: 1px solid #f5f7f2; + display: flex; + flex-direction: column; + gap: 4px; +} + +.cart-item:last-child { + border-bottom: none; +} + +.ci-name { + font-size: 14px; + font-weight: 600; + color: #1a3c22; + line-height: 1.3; +} + +.ci-details { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ci-qty { + font-size: 12px; + background: #f5f7f2; + color: #2d7a3a; + font-weight: 700; + border-radius: 4px; + padding: 1px 7px; +} + +.ci-price { + font-size: 12px; + color: #6b7c6e; +} + +.line-total { + font-size: 13px; + font-weight: 700; + color: #2d7a3a; + margin-left: auto; +} + +.measurable-badge-sm { + font-size: 12px; + color: #e8734a; + margin-left: auto; +} + +.cart-total-row { + border-top: 1px solid #dde8da; + padding: 14px 18px; + background: #f5f7f2; +} + +.cart-total-note { + display: flex; + align-items: flex-start; + gap: 6px; + font-size: 11px; + color: #6b7c6e; + line-height: 1.4; + margin-bottom: 10px; +} + +.cart-total-note .fa { + color: #f4a236; + margin-top: 1px; + flex-shrink: 0; +} + +.cart-total { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; + color: #2c2c2c; +} + +.cart-total strong { + font-size: 18px; + font-weight: 800; + color: #1a3c22; +} + +#cartActions { + padding: 14px 18px; + display: flex; + flex-direction: column; + gap: 8px; + border-top: 1px solid #dde8da; +} + +.btn-checkout { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + background: #2d7a3a; + color: #fff !important; + border-radius: 8px; + font-size: 14px; + font-weight: 700; + text-decoration: none; + text-align: center; + transition: background 0.18s; + letter-spacing: 0.2px; +} + +.btn-checkout:hover { + background: #1a3c22; +} + +.btn-checkout .fa { + font-size: 16px; + color: #f4a236; +} + +.btn-view-cart { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 10px; + background: #f5f7f2; + color: #2d7a3a !important; + border: 1px solid #dde8da; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + text-decoration: none; + text-align: center; + transition: background 0.18s; +} + +.btn-view-cart:hover { + background: #dde8da; +} + +/* ───────────────────────────────────────────── + TOAST NOTIFICATION +───────────────────────────────────────────── */ +.qo-toast { + position: fixed; + bottom: 28px; + right: 24px; + background: #1a3c22; + color: #fff; + padding: 13px 20px; + border-radius: 8px; + font-size: 14px; + font-family: 'DM Sans', sans-serif; + box-shadow: 0 4px 20px rgba(26, 60, 34, 0.3); + z-index: 9999; + opacity: 0; + transform: translateY(12px); + transition: opacity 0.28s, transform 0.28s; + max-width: 320px; + border-left: 4px solid #f4a236; +} + +.qo-toast.show { + opacity: 1; + transform: translateY(0); +} + +.qo-toast .fa { + color: #8cb63c; + margin-right: 6px; +} + +/* ───────────────────────────────────────────── + RESPONSIVE +───────────────────────────────────────────── */ +@media (max-width: 960px) { + .qo-layout { + grid-template-columns: 1fr; + } + + .qo-cart-panel { + position: static; + } +} + +@media (max-width: 600px) { + .quick-order-page { + width: 100%; + padding: 12px 12px 40px; + } + + .product-card { + flex-wrap: wrap; + } + + .pc-body { + flex: 1 1 100%; + } + + .pc-actions { + width: 100%; + justify-content: flex-end; + } + + .qo-search-btn { + padding: 0 14px; + font-size: 13px; + } +} From 51f546caeca2e8c726f83033adae4b1da464baa3 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 27 Mar 2026 17:14:40 +0100 Subject: [PATCH 2/4] CustomerCredit, new order --- .../Controllers/CustomOrderController.cs | 228 +++++++ .../Controllers/CustomerCreditController.cs | 285 ++++++++ .../Admin/Models/CustomerCreditListRow.cs | 13 + .../Areas/Admin/Models/CustomerCreditModel.cs | 30 + .../Admin/Views/CustomerCredit/Details.cshtml | 151 +++++ .../Admin/Views/CustomerCredit/List.cshtml | 200 ++++++ .../Views/Order/FruitBankOrderList.cshtml | 634 ++++++++++++++++++ .../CustomerCreditWidgetViewComponent.cs | 39 ++ .../DataLayer/CustomerCreditDbTable.cs | 27 + .../Domains/DataLayer/FruitBankDbContext.cs | 7 +- .../Interfaces/ICustomerCreditDbSet.cs | 10 + .../Factories/MgBase/MgOrderModelFactory.cs | 4 +- Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs | 112 +++- .../Infrastructure/PluginNopStartup.cs | 5 +- .../Infrastructure/RouteProvider.cs | 66 +- .../Localization/customercredit.en.xml | 84 +++ .../Localization/customercredit.hu.xml | 84 +++ .../Mapping/NameCompatibility.cs | 1 + .../Models/CustomerCreditWidgetModel.cs | 21 + .../Models/Orders/FruitBankOrderRowDto.cs | 35 + .../Nop.Plugin.Misc.FruitBankPlugin.csproj | 12 + .../Services/CustomerCreditService.cs | 71 ++ .../Services/EventConsumer.cs | 55 +- .../Services/FruitBankNotificationService.cs | 84 +++ .../Services/ICustomerCreditService.cs | 32 + .../Views/CustomerCreditWidget.cshtml | 83 +++ 26 files changed, 2335 insertions(+), 38 deletions(-) create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerCreditController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditListRow.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditModel.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/CustomerCreditDbTable.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/Interfaces/ICustomerCreditDbSet.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Models/Orders/FruitBankOrderRowDto.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/CustomerCreditService.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/FruitBankNotificationService.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/ICustomerCreditService.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs index 09744fc..4856394 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs @@ -1759,7 +1759,235 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers } } + + + // ═══════════════════════════════════════════════════════════════════ + // FruitBank Order Grid – new server-side DataTables endpoint + // ═══════════════════════════════════════════════════════════════════ + + /// + /// Returns the new FruitBank order list view (replaces the default NopCommerce grid). + /// + [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)] + public async Task NewList( + List orderStatuses = null, + List paymentStatuses = null, + List shippingStatuses = null) + { + var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended + { + OrderStatusIds = orderStatuses, + PaymentStatusIds = paymentStatuses, + ShippingStatusIds = shippingStatuses, + Length = 50, + AvailablePageSizes = "20,50,100,500", + SortColumn = "Id", + SortColumnDirection = "desc", + }); + model.SetGridSort("Id", "desc"); + model.SetGridPageSize(50, "20,50,100,500"); + + return View( + "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml", + model); } + + /// + /// DataTables server-side endpoint for the FruitBank order grid. + /// Handles NopCommerce base filters + FruitBank-specific filters + per-column search + sort + pagination. + /// + [HttpPost] + [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)] + public async Task FruitBankOrderList() + { + // ── 1. Parse DataTables protocol params ──────────────────────── + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 50 : Math.Min(length, 500); + + // Sort column + _ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx); + var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc"; + var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "Id"; + + // Per-column search values keyed by column data-field name + var colSearch = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int ci = 0; Request.Form.ContainsKey($"columns[{ci}][data]"); ci++) + { + var cData = Request.Form[$"columns[{ci}][data]"].FirstOrDefault(); + var cVal = Request.Form[$"columns[{ci}][search][value]"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(cData) && !string.IsNullOrWhiteSpace(cVal)) + colSearch[cData] = cVal.Trim(); + } + + // ── 2. Parse custom filter params ───────────────────────────── + DateTime? startDate = null, endDate = null; + if (DateTime.TryParse(Request.Form["StartDate"].FirstOrDefault(), out var sd)) startDate = sd; + if (DateTime.TryParse(Request.Form["EndDate"].FirstOrDefault(), out var ed)) endDate = ed; + + var orderStatusIds = Request.Form["OrderStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + var paymentStatusIds = Request.Form["PaymentStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + var shippingStatusIds = Request.Form["ShippingStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + + var billingCompany = Request.Form["BillingCompany"].FirstOrDefault(); // holds customer ID (string) + + bool? isMeasurableFilter = null; + var imStr = Request.Form["IsMeasurable"].FirstOrDefault(); + if (imStr == "true") isMeasurableFilter = true; + if (imStr == "false") isMeasurableFilter = false; + + bool? hasInnvoiceFilter = null; + var hiStr = Request.Form["HasInnvoiceTechId"].FirstOrDefault(); + if (hiStr == "true") hasInnvoiceFilter = true; + if (hiStr == "false") hasInnvoiceFilter = false; + + // ── 3. Fetch data via factory (applies NopCommerce base filters) + // We ask for a large page so all matching records come back in one shot; + // FruitBank-specific filtering + pagination happen below in-process. + var searchModel = new OrderSearchModelExtended + { + StartDate = startDate, + EndDate = endDate, + OrderStatusIds = orderStatusIds.Any() ? orderStatusIds : null, + PaymentStatusIds = paymentStatusIds.Any() ? paymentStatusIds : null, + ShippingStatusIds = shippingStatusIds.Any() ? shippingStatusIds : null, + BillingCompany = billingCompany, + SortColumn = "Id", + SortColumnDirection = "desc" + }; + // SetGridPageSize is the proper NopCommerce way to override Page/PageSize + searchModel.SetGridPageSize(5000, "5000"); + + OrderListModelExtended orderListModel; + try + { + orderListModel = await _orderModelFactory.PrepareOrderListModelExtendedAsync(searchModel); + } + catch (Exception ex) + { + _logger.Error($"FruitBankOrderList – factory error: {ex.Message}", ex); + return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); + } + + var rows = orderListModel.Data?.ToList() ?? new List(); + int total = orderListModel.RecordsTotal; + + // ── 4. Map to lightweight DTO ────────────────────────────────── + var dtos = rows.Select(o => new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto + { + Id = o.Id, + CustomOrderNumber = o.CustomOrderNumber, + CustomerCompany = o.CustomerCompany, + CustomerId = o.CustomerId, + InnvoiceTechId = o.InnvoiceTechId, + IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid, + IsMeasurable = o.IsMeasurable, + MeasuringStatus = (int)o.MeasuringStatus, + MeasuringStatusString = o.MeasuringStatusString, + DateOfReceipt = o.DateOfReceipt, + OrderStatusId = o.OrderStatusId, + OrderStatus = o.OrderStatus, + PaymentStatusId = o.PaymentStatusId, + PaymentStatus = o.PaymentStatus, + ShippingStatusId = o.ShippingStatusId, + ShippingStatus = o.ShippingStatus, + StoreName = o.StoreName, + CreatedOn = o.CreatedOn, + OrderTotal = o.OrderTotal + }).ToList(); + + // ── 5. Apply FruitBank-specific top-level filters ────────────── + if (isMeasurableFilter.HasValue) + dtos = dtos.Where(o => o.IsMeasurable == isMeasurableFilter.Value).ToList(); + + if (hasInnvoiceFilter.HasValue) + dtos = hasInnvoiceFilter.Value + ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList(); + + // ── 6. Apply per-column search ───────────────────────────────── + foreach (var (col, val) in colSearch) + { + dtos = col.ToLowerInvariant() switch + { + "customordernumber" => dtos.Where(o => o.CustomOrderNumber?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(), + "customercompany" => dtos.Where(o => o.CustomerCompany?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(), + "orderstatusid" => int.TryParse(val, out int osId) ? dtos.Where(o => o.OrderStatusId == osId).ToList() : dtos, + "measuringstatus" => int.TryParse(val, out int msId) ? dtos.Where(o => o.MeasuringStatus == msId).ToList() : dtos, + "ismeasurable" => bool.TryParse(val, out bool bm) ? dtos.Where(o => o.IsMeasurable == bm).ToList() : dtos, + // InnVoice column sends 'has' or 'none' strings + "innvoicetechid" => val == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : val == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : dtos, + _ => dtos + }; + } + + int recordsFiltered = dtos.Count; + + // ── 7. Sort ──────────────────────────────────────────────────── + bool asc = sortDir == "asc"; + dtos = sortColName.ToLowerInvariant() switch + { + "id" => asc ? dtos.OrderBy(o => o.Id).ToList() : dtos.OrderByDescending(o => o.Id).ToList(), + "customordernumber" => asc ? dtos.OrderBy(o => o.CustomOrderNumber).ToList() : dtos.OrderByDescending(o => o.CustomOrderNumber).ToList(), + "customercompany" => asc ? dtos.OrderBy(o => o.CustomerCompany).ToList() : dtos.OrderByDescending(o => o.CustomerCompany).ToList(), + "dateofreceipt" => asc ? dtos.OrderBy(o => o.DateOfReceipt ?? DateTime.MinValue).ToList() : dtos.OrderByDescending(o => o.DateOfReceipt ?? DateTime.MinValue).ToList(), + "createdon" => asc ? dtos.OrderBy(o => o.CreatedOn).ToList() : dtos.OrderByDescending(o => o.CreatedOn).ToList(), + "orderstatusid" => asc ? dtos.OrderBy(o => o.OrderStatusId).ToList() : dtos.OrderByDescending(o => o.OrderStatusId).ToList(), + "measuringstatus" => asc ? dtos.OrderBy(o => o.MeasuringStatus).ToList() : dtos.OrderByDescending(o => o.MeasuringStatus).ToList(), + _ => dtos.OrderByDescending(o => o.Id).ToList() + }; + + // ── 8. Paginate ──────────────────────────────────────────────── + var page = dtos.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal = total, recordsFiltered, data = page }); + } + + /// + /// Inline-edit save endpoint. Currently supports DateOfReceipt. + /// + [HttpPost] + [CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)] + public async Task UpdateOrderField(int orderId, string field, string value) + { + try + { + var order = await _orderService.GetOrderByIdAsync(orderId); + if (order == null) + return Json(new { success = false, error = "Rendelés nem található" }); + + switch (field?.ToUpperInvariant()) + { + case "DATEOFRECEIPT": + if (string.IsNullOrWhiteSpace(value)) + { + await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", null); + return Json(new { success = true, displayValue = (string)null }); + } + if (DateTime.TryParse(value, out var newDate)) + { + await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", newDate); + return Json(new { success = true, displayValue = newDate.ToString("yyyy. MM. dd. HH:mm") }); + } + return Json(new { success = false, error = "Érvénytelen dátum formátum" }); + + default: + return Json(new { success = false, error = $"Ismeretlen mező: {field}" }); + } + } + catch (Exception ex) + { + _logger.Error($"UpdateOrderField error – orderId={orderId} field={field}: {ex.Message}", ex); + return Json(new { success = false, error = ex.Message }); + } + } + +} } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerCreditController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerCreditController.cs new file mode 100644 index 0000000..2b4de66 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerCreditController.cs @@ -0,0 +1,285 @@ +using FruitBank.Common.Entities; +using Microsoft.AspNetCore.Mvc; +using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Orders; +using Nop.Core.Domain.Payments; +using Nop.Data; +using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Customers; +using Nop.Services.Localization; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; + +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +public class CustomerCreditController : BasePluginController +{ + private readonly ICustomerCreditService _customerCreditService; + private readonly ICustomerService _customerService; + private readonly IRepository _orderRepository; + private readonly IRepository _customerRepository; + private readonly CustomerCreditDbTable _customerCreditDbTable; + private readonly IPermissionService _permissionService; + private readonly ILocalizationService _localizationService; + + public CustomerCreditController( + ICustomerCreditService customerCreditService, + ICustomerService customerService, + IRepository orderRepository, + IRepository customerRepository, + CustomerCreditDbTable customerCreditDbTable, + IPermissionService permissionService, + ILocalizationService localizationService) + { + _customerCreditService = customerCreditService; + _customerService = customerService; + _orderRepository = orderRepository; + _customerRepository = customerRepository; + _customerCreditDbTable = customerCreditDbTable; + _permissionService = permissionService; + _localizationService = localizationService; + } + + // ── LIST PAGE ───────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/CustomerCredit/List")] + public async Task List() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml"); + } + + // ── DATATABLES SERVER-SIDE ENDPOINT ────────────────────────────────────── + + [HttpPost] + [Route("Admin/CustomerCredit/CustomerCreditList")] + public async Task CustomerCreditList() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { draw = 1, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); + + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500); + + _ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx); + var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc"; + var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "OutstandingBalance"; + + var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? ""; + + // 1. Customers — single query + var customers = await _customerRepository.Table + .Where(c => !c.Deleted && c.Active && c.Email != null) + .Select(c => new { c.Id, c.Email, c.FirstName, c.LastName }) + .ToListAsync(); + + // 2. Credit records — single query + var credits = await _customerCreditDbTable.GetAll().ToListAsync(); + var creditByCustomer = credits.ToDictionary(x => x.CustomerId); + + // 3. Outstanding balances — single grouped query, no N+1 + var outstandingByCustomer = await _orderRepository.Table + .Where(o => + o.OrderStatusId != (int)OrderStatus.Cancelled && + (o.PaymentStatusId == (int)PaymentStatus.Pending || + o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded)) + .GroupBy(o => o.CustomerId) + .Select(g => new { CustomerId = g.Key, Total = g.Sum(o => (decimal?)o.OrderTotal) ?? 0m }) + .ToListAsync(); + var outstandingDict = outstandingByCustomer.ToDictionary(x => x.CustomerId, x => x.Total); + + // 4. Build rows + var rows = customers.Select(c => + { + creditByCustomer.TryGetValue(c.Id, out var credit); + outstandingDict.TryGetValue(c.Id, out var outstanding); + var hasLimit = credit != null; + var remaining = hasLimit ? credit!.CreditLimit - outstanding : (decimal?)null; + + return new CustomerCreditListRow + { + CustomerId = c.Id, + CustomerEmail = c.Email ?? string.Empty, + CustomerName = $"{c.FirstName} {c.LastName}".Trim(), + HasCreditLimit = hasLimit, + CreditLimit = credit?.CreditLimit ?? 0m, + OutstandingBalance = outstanding, + RemainingCredit = remaining, + Comment = credit?.Comment + }; + }).ToList(); + + int recordsTotal = rows.Count; + + // 5. Global search + if (!string.IsNullOrWhiteSpace(globalSearch)) + { + rows = rows.Where(r => + r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + r.CustomerEmail.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + (r.Comment?.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ?? false) + ).ToList(); + } + + int recordsFiltered = rows.Count; + + // 6. Sort + bool asc = sortDir == "asc"; + rows = sortColName.ToLowerInvariant() switch + { + "customername" => asc ? rows.OrderBy(r => r.CustomerName).ToList() : rows.OrderByDescending(r => r.CustomerName).ToList(), + "customeremail" => asc ? rows.OrderBy(r => r.CustomerEmail).ToList() : rows.OrderByDescending(r => r.CustomerEmail).ToList(), + "creditlimit" => asc ? rows.OrderBy(r => r.CreditLimit).ToList() : rows.OrderByDescending(r => r.CreditLimit).ToList(), + "outstandingbalance" => asc ? rows.OrderBy(r => r.OutstandingBalance).ToList() : rows.OrderByDescending(r => r.OutstandingBalance).ToList(), + "remainingcredit" => asc ? rows.OrderBy(r => r.RemainingCredit ?? decimal.MaxValue).ToList() : rows.OrderByDescending(r => r.RemainingCredit ?? decimal.MinValue).ToList(), + _ => rows.OrderByDescending(r => r.OutstandingBalance).ToList() + }; + + // 7. Paginate + var page = rows.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } + + // ── INLINE EDIT: CREDIT LIMIT ───────────────────────────────────────────── + + [HttpPost] + [Route("Admin/CustomerCredit/UpdateCreditLimit")] + public async Task UpdateCreditLimit(int customerId, string? creditLimit, bool removeLimit, string? comment) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + try + { + var existing = await _customerCreditService.GetByCustomerIdAsync(customerId); + var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId); + + // Empty input or explicit removeLimit flag → delete record = unlimited + if (removeLimit || string.IsNullOrWhiteSpace(creditLimit)) + { + if (existing != null) + await _customerCreditService.DeleteAsync(existing); + + return Json(new + { + success = true, + hasLimit = false, + creditLimit = (decimal?)null, + outstanding, + remaining = (decimal?)null + }); + } + + // Parse the value (JS sends invariant decimal) + if (!decimal.TryParse(creditLimit, + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, + out var limit) || limit < 0) + return Json(new { success = false, error = "Érvénytelen összeg" }); + + var entity = existing ?? new CustomerCredit { CustomerId = customerId }; + entity.CreditLimit = limit; + if (comment != null) entity.Comment = comment; + + await _customerCreditService.SaveAsync(entity); + + return Json(new + { + success = true, + hasLimit = true, + creditLimit = limit, + outstanding, + remaining = limit - outstanding + }); + } + catch (Exception ex) + { + return Json(new { success = false, error = ex.Message }); + } + } + + // ── DETAILS ─────────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/CustomerCredit/Details/{customerId:int}")] + public async Task Details(int customerId) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + var customer = await _customerService.GetCustomerByIdAsync(customerId); + if (customer == null) + return NotFound(); + + var credit = await _customerCreditService.GetByCustomerIdAsync(customerId); + var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId); + + var unpaidOrders = await _orderRepository.Table + .Where(o => + o.CustomerId == customerId && + o.OrderStatusId != (int)OrderStatus.Cancelled && + (o.PaymentStatusId == (int)PaymentStatus.Pending || + o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded)) + .OrderByDescending(o => o.CreatedOnUtc) + .ToListAsync(); + + var model = new CustomerCreditModel + { + CustomerId = customerId, + CustomerEmail = customer.Email, + CustomerName = $"{customer.FirstName} {customer.LastName}".Trim(), + CreditId = credit?.Id ?? 0, + CreditLimit = credit?.CreditLimit ?? 0m, + Comment = credit?.Comment, + OutstandingBalance = outstanding, + RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null, + HasCreditLimit = credit != null, + UnpaidOrders = unpaidOrders.Select(o => new CustomerCreditOrderRow + { + OrderId = o.Id, + OrderTotal = o.OrderTotal, + CreatedOnUtc = o.CreatedOnUtc, + OrderStatus = o.OrderStatus.ToString(), + PaymentStatus = o.PaymentStatus.ToString() + }).ToList() + }; + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml", model); + } + + // ── SAVE (from Details page) ────────────────────────────────────────────── + + [HttpPost] + [Route("Admin/CustomerCredit/Save")] + public async Task Save(CustomerCreditModel model) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + if (!ModelState.IsValid) + return RedirectToAction("Details", new { customerId = model.CustomerId }); + + var entity = model.CreditId > 0 + ? await _customerCreditService.GetByCustomerIdAsync(model.CustomerId) ?? new CustomerCredit() + : new CustomerCredit(); + + entity.CustomerId = model.CustomerId; + entity.CreditLimit = model.CreditLimit; + entity.Comment = model.Comment; + + await _customerCreditService.SaveAsync(entity); + + return RedirectToAction("Details", new { customerId = model.CustomerId }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditListRow.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditListRow.cs new file mode 100644 index 0000000..fd05fee --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditListRow.cs @@ -0,0 +1,13 @@ +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class CustomerCreditListRow +{ + public int CustomerId { get; set; } + public string CustomerEmail { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public bool HasCreditLimit { get; set; } + public decimal CreditLimit { get; set; } + public decimal OutstandingBalance { get; set; } + public decimal? RemainingCredit { get; set; } + public string? Comment { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditModel.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditModel.cs new file mode 100644 index 0000000..3fff84c --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditModel.cs @@ -0,0 +1,30 @@ +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class CustomerCreditModel +{ + public int CustomerId { get; set; } + public string CustomerEmail { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + + // Credit record + public int CreditId { get; set; } + public decimal CreditLimit { get; set; } + public string? Comment { get; set; } + public bool HasCreditLimit { get; set; } + + // Calculated + public decimal OutstandingBalance { get; set; } + public decimal? RemainingCredit { get; set; } + + // Unpaid orders table + public List UnpaidOrders { get; set; } = new(); +} + +public class CustomerCreditOrderRow +{ + public int OrderId { get; set; } + public decimal OrderTotal { get; set; } + public DateTime CreatedOnUtc { get; set; } + public string OrderStatus { get; set; } = string.Empty; + public string PaymentStatus { get; set; } = string.Empty; +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml new file mode 100644 index 0000000..130b1d3 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml @@ -0,0 +1,151 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.CustomerCreditModel +@using Nop.Web.Framework.UI + +@{ + // Layout = "_FruitBankAdminLayout"; + var remaining = Model.RemainingCredit; + var statusClass = !Model.HasCreditLimit ? "status-unlimited" + : remaining <= 0 ? "status-blocked" + : remaining < Model.CreditLimit * 0.2m ? "status-warning" + : "status-ok"; +} + + + + + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer") + + +
    +

    + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle") — @Model.CustomerName (@Model.CustomerEmail) +

    +
    + +@* ── Summary cards ── *@ +
    +
    +
    @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")
    +
    @(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : "—")
    +
    +
    +
    @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")
    +
    @Model.OutstandingBalance.ToString("N0") Ft
    +
    +
    +
    @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")
    +
    + @if (!Model.HasCreditLimit) + { + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited") + } + else + { + @remaining!.Value.ToString("N0") + Ft + } +
    +
    +
    + +@* ── Edit form ── *@ +
    +
    +

    @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle")

    +
    +
    +
    + + + +
    +
    + +
    +
    + + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint") +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +@* ── Unpaid orders table ── *@ +
    +
    +

    @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle") (@Model.UnpaidOrders.Count)

    +
    +
    + @if (!Model.UnpaidOrders.Any()) + { +

    @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders")

    + } + else + { + + + + + + + + + + + + + @foreach (var o in Model.UnpaidOrders) + { + + + + + + + + + } + + + + + + + + +
    @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus")
    #@o.OrderId@o.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm")@o.OrderTotal.ToString("N0") Ft@o.OrderStatus@o.PaymentStatus + + + +
    @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total")@Model.OutstandingBalance.ToString("N0") Ft
    + } +
    +
    diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml new file mode 100644 index 0000000..2a918eb --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml @@ -0,0 +1,200 @@ +@{ + ViewBag.PageTitle = "Hitelkeretek"; + NopHtml.SetActiveMenuItemSystemName("CustomerCredit.List"); +} + +
    +

    @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle")

    +
    + +
    +
    +
    +
    + @Html.AntiForgeryToken() + + + + + + + + + + + + +
    @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit") ✏️@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")
    +
    +
    +
    +
    + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml new file mode 100644 index 0000000..8477c55 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml @@ -0,0 +1,634 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.Order.OrderSearchModelExtended + +@using FruitBank.Common.Interfaces +@using Nop.Services.Stores +@using Nop.Web.Areas.Admin.Components +@using Nop.Web.Areas.Admin.Models.Orders +@using Nop.Web.Framework.Infrastructure +@inject IStoreService storeService + +@{ + // Layout = "~/Areas/Admin/Views/Shared/_LayoutAdmin.cshtml"; + ViewBag.PageTitle = "FruitBank Rendelések"; + NopHtml.SetActiveMenuItemSystemName("Orders"); +} + +@* ── Action buttons ─────────────────────────────────────────────── *@ +
    +
    +

    Rendelések

    +
    + +
    + + + +
    +
    + + + +
    +
    +
    +
    + +
    +
    + + @* ── Filter Panel ─────────────────────────────────────────────── *@ + + + @* ── Grid ─────────────────────────────────────────────────────── *@ +
    +
    + @* Anti-forgery token for AJAX POSTs *@ + @Html.AntiForgeryToken() + + + + + + + + + + + + + + + + + + + +
    Rendelés #PartnerInnVoiceSúlyMérhetőMérésÁtvétel ✏️StátuszFizetésSzállításLétrehozvaÖsszeg
    +
    + +
    + +
    +
    + + +@* ── Export selected – XML ──────────────────────────────────────── *@ +
    + +
    +@* ── Export selected – Excel ────────────────────────────────────── *@ +
    + +
    +@* ── PDF selected ───────────────────────────────────────────────── *@ +
    + +
    + +@* ── Create Order Modal ─────────────────────────────────────────── *@ + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs b/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs new file mode 100644 index 0000000..405f26c --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Mvc; +using Nop.Plugin.Misc.FruitBankPlugin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Web.Areas.Admin.Models.Customers; +using Nop.Web.Framework.Components; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Components; + +[ViewComponent(Name = "CustomerCreditWidget")] +public class CustomerCreditWidgetViewComponent : NopViewComponent +{ + private readonly ICustomerCreditService _customerCreditService; + + public CustomerCreditWidgetViewComponent(ICustomerCreditService customerCreditService) + { + _customerCreditService = customerCreditService; + } + + public async Task InvokeAsync(string widgetZone, object additionalData) + { + if (additionalData is not CustomerModel customerModel) return Content(""); + + var customerId = customerModel.Id; + var credit = await _customerCreditService.GetByCustomerIdAsync(customerId); + var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId); + + var model = new CustomerCreditWidgetModel + { + CustomerId = customerId, + HasCreditLimit = credit != null, + CreditLimit = credit?.CreditLimit ?? 0m, + OutstandingBalance = outstanding, + RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null, + Comment = credit?.Comment + }; + + return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/CustomerCreditDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/CustomerCreditDbTable.cs new file mode 100644 index 0000000..2ce2714 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/CustomerCreditDbTable.cs @@ -0,0 +1,27 @@ +using FruitBank.Common.Entities; +using LinqToDB; +using Mango.Nop.Core.Loggers; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; + + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class CustomerCreditDbTable : MgDbTableBase +{ + public CustomerCreditDbTable( + IEventPublisher eventPublisher, + INopDataProvider dataProvider, + IShortTermCacheManager shortTermCacheManager, + IStaticCacheManager staticCacheManager, + AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + public Task GetByCustomerIdAsync(int customerId) + => GetAll().FirstOrDefaultAsync(x => x.CustomerId == customerId); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs index 8b05e30..ff35b13 100644 --- a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs @@ -40,7 +40,8 @@ public class FruitBankDbContext : MgDbContextBase, IShippingItemPalletDbSet, IOrderItemPalletDbSet, IShippingDocumentToFilesDbSet, - IFilesDbSet + IFilesDbSet, + ICustomerCreditDbSet { private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly IStoreContext _storeContext; @@ -64,6 +65,7 @@ public class FruitBankDbContext : MgDbContextBase, public FilesDbTable Files { get; set; } public ShippingDocumentToFilesDbTable ShippingDocumentToFiles { get; set; } public StockQuantityHistoryDtoDbTable StockQuantityHistoryDtos { get; set; } + public CustomerCreditDbTable CustomerCredits { get; set; } public IRepository Customers { get; set; } public IRepository CustomerRoles { get; set; } @@ -79,7 +81,7 @@ public class FruitBankDbContext : MgDbContextBase, PartnerDbTable partnerDbTable, ShippingDbTable shippingDbTable, ShippingDocumentDbTable shippingDocumentDbTable, ShippingItemDbTable shippingItemDbTable, ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable, ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable, - StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, + StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable, IProductService productService, IStaticCacheManager staticCacheManager, IRepository orderRepository, IRepository orderItemRepository, @@ -127,6 +129,7 @@ public class FruitBankDbContext : MgDbContextBase, StockQuantityHistories = stockQuantityHistories; StockQuantityHistoriesExt = stockQuantityHistoriesExt; StockQuantityHistoryDtos = stockQuantityHistoryDtos; + CustomerCredits = customerCreditDbTable; } public IQueryable GetCustomersBySystemRoleName(string systemRoleName) diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/Interfaces/ICustomerCreditDbSet.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/Interfaces/ICustomerCreditDbSet.cs new file mode 100644 index 0000000..b565f75 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/Interfaces/ICustomerCreditDbSet.cs @@ -0,0 +1,10 @@ +using FruitBank.Common.Entities; +using Mango.Nop.Data.Interfaces; +using Nop.Data; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces; + +public interface ICustomerCreditDbSet : IMgDbTableBase where TDbTable : IRepository +{ + public TDbTable CustomerCredits { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Factories/MgBase/MgOrderModelFactory.cs b/Nop.Plugin.Misc.AIPlugin/Factories/MgBase/MgOrderModelFactory.cs index eb126c8..6fb4141 100644 --- a/Nop.Plugin.Misc.AIPlugin/Factories/MgBase/MgOrderModelFactory.cs +++ b/Nop.Plugin.Misc.AIPlugin/Factories/MgBase/MgOrderModelFactory.cs @@ -331,7 +331,9 @@ public class MgOrderModelFactory : OrderMode public virtual async Task PrepareOrderListModelExtendedAsync(OrderSearchModelExtended searchModel, Func dataItemCopiedCallback) { var customerCompany = searchModel.BillingCompany; - var customer = await _customerService.GetCustomerByIdAsync(Convert.ToInt32(customerCompany)); + var customer = int.TryParse(customerCompany, out var customerId) && customerId > 0 + ? await _customerService.GetCustomerByIdAsync(customerId) + : null; //var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase)); //var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase)); OrderListModel prefiltered; diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs index 49f2c2f..3b4c253 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs @@ -3,13 +3,16 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; using Nop.Plugin.Misc.FruitBankPlugin.Components; +using Nop.Core.Domain.Messages; using Nop.Services.Cms; using Nop.Services.Configuration; using Nop.Services.Localization; +using Nop.Services.Messages; using Nop.Services.Plugins; using Nop.Services.Security; using Nop.Web.Framework.Infrastructure; using Nop.Web.Framework.Menu; +using Nop.Plugin.Misc.FruitBankPlugin.Services; namespace Nop.Plugin.Misc.FruitBankPlugin @@ -28,6 +31,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin protected readonly ILocalizationService _localizationService; protected readonly IUrlHelperFactory _urlHelperFactory; private readonly IAdminMenu _adminMenu; + private readonly IMessageTemplateService _messageTemplateService; //handle AdminMenuCreatedEvent @@ -38,7 +42,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin ILocalizationService localizationService, IPermissionService permissionService, IUrlHelperFactory urlHelperFactory, - IAdminMenu adminMenu) + IAdminMenu adminMenu, + IMessageTemplateService messageTemplateService) { _actionContextAccessor = actionContextAccessor; _settingService = settingService; @@ -47,6 +52,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin _urlHelperFactory = urlHelperFactory; _adminMenu = adminMenu; _permissionService = permissionService; + _messageTemplateService = messageTemplateService; } // --- INSTALL --- @@ -192,6 +198,91 @@ namespace Nop.Plugin.Misc.FruitBankPlugin await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "Invalid product or quantity", en); await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "\u00c9rv\u00e9nytelen term\u00e9k vagy mennyis\u00e9g", hu); + // ── Customer Credit ──────────────────────────────────────────────────── + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "Customer Credit Management", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "\u00dcgyf\u00e9l hitelkeret kezel\u00e9s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer", "Back to customer", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer", "Vissza az \u00fcgyf\u00e9lhez", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle", "Set Credit Limit", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle", "Hitelkeret be\u00e1ll\u00edt\u00e1sa", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit", "Credit Limit", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit", "Hitelkeret", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint", "Set to 0 to block all orders. Leave the record absent to allow unlimited.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint", "0 eset\u00e9n minden rendel\u00e9s le van tiltva. Ha nincs rekord, a limit korl\u00e1tlan.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance", "Outstanding Balance", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance", "Kintlév\u0151 egyenleg", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit", "Remaining Credit", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit", "Szabad keret", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited", "Unlimited", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited", "Korl\u00e1tlan", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment", "Notes", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment", "Megjegyz\u00e9s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save", "Save", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save", "Ment\u00e9s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle", "Unpaid / Pending Orders", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle", "Kifizetetlen / f\u00fcgg\u0151 rendel\u00e9sek", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders", "No unpaid orders.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders", "Nincs kifizetetlen rendel\u00e9s.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId", "Order #", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId", "Rendel\u00e9s #", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate", "Date", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate", "D\u00e1tum", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal", "Total", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal", "\u00d6sszeg", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus", "Order Status", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus", "Rendel\u00e9s \u00e1llapot", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus", "Payment Status", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus", "Fizet\u00e9si \u00e1llapot", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "Total", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "\u00d6sszesen", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked", "Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked", "A rendel\u00e9st nem lehet leadni, mert a kintlév\u0151 egyenlege el\u00e9rte a hitelkeret\u00e9t. K\u00e9rj\u00fck, el\u0151sz\u00f6r rendezze meglév\u0151 tartoz\u00e1s\u00e1t.", hu); + + // ── Order Started email template ──────────────────────────────────── + var existingStartedTemplate = await _messageTemplateService + .GetMessageTemplatesByNameAsync(FruitBankNotificationService.ORDER_STARTED_TEMPLATE_NAME, 0); + if (!existingStartedTemplate.Any()) + { + await _messageTemplateService.InsertMessageTemplateAsync(new MessageTemplate + { + Name = FruitBankNotificationService.ORDER_STARTED_TEMPLATE_NAME, + Subject = "%Store.Name% - Rendelésed feldolgozás alatt (#%Order.OrderNumber%)", + Body = "

    Kedves %Order.CustomerFullName%,

    " + + "

    Rendelésedet (#%Order.OrderNumber%) elkezdtük feldolgozni.

    " + + "%Order.MeasurableNote%" + + "

    Amint elkészül, értesítünk!

    " + + "

    %Store.Name% csapata

    ", + IsActive = true, + EmailAccountId = 0, + LimitedToStores = false, + AllowDirectReply = false, + AttachedDownloadId = 0, + }); + } + + // ── Order Audited email template ───────────────────────────────────── + var existingTemplate = await _messageTemplateService + .GetMessageTemplatesByNameAsync(FruitBankNotificationService.ORDER_AUDITED_TEMPLATE_NAME, 0); + if (existingTemplate.Count == 0) + { + await _messageTemplateService.InsertMessageTemplateAsync(new MessageTemplate + { + Name = FruitBankNotificationService.ORDER_AUDITED_TEMPLATE_NAME, + Subject = "%Store.Name% - Rendelésed elkészült (#%Order.OrderNumber%)", + Body = "

    Kedves %Order.CustomerFullName%,

    " + + "

    Rendelésed (#%Order.OrderNumber%) elkészült és átvételre vár.

    " + + "%Order.MeasurableNote%" + + "

    Végleges összeg: %Order.OrderTotal%

    " + + "

    Köszönjük a rendelésedet!

    " + + "

    %Store.Name% csapata

    ", + IsActive = true, + EmailAccountId = 0, // 0 = use store default + LimitedToStores = false, + AllowDirectReply = false, + AttachedDownloadId = 0, + }); + } + await base.InstallAsync(); } @@ -207,7 +298,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin public Task> GetWidgetZonesAsync() { - return Task.FromResult>(new List { PublicWidgetZones.ProductBoxAddinfoBefore, PublicWidgetZones.ProductDetailsBottom, AdminWidgetZones.ProductDetailsBlock, AdminWidgetZones.OrderDetailsBlock }); + return Task.FromResult>(new List + { + PublicWidgetZones.ProductBoxAddinfoBefore, + PublicWidgetZones.ProductDetailsBottom, + AdminWidgetZones.ProductDetailsBlock, + AdminWidgetZones.OrderDetailsBlock, + AdminWidgetZones.CustomerDetailsBlock + }); } public async Task ManageSiteMapAsync(AdminMenuItem rootNode) @@ -232,15 +330,19 @@ namespace Nop.Plugin.Misc.FruitBankPlugin { if (widgetZone == PublicWidgetZones.ProductBoxAddinfoBefore || widgetZone == PublicWidgetZones.ProductDetailsBottom) { - return zones.Any(widgetZone.Equals) ? typeof(ProductAIWidgetViewComponent) : null; + return typeof(ProductAIWidgetViewComponent); } else if (widgetZone == AdminWidgetZones.ProductDetailsBlock) { - return zones.Any(widgetZone.Equals) ? typeof(ProductAttributesViewComponent) : null; + return typeof(ProductAttributesViewComponent); } else if (widgetZone == AdminWidgetZones.OrderDetailsBlock) { - return zones.Any(widgetZone.Equals) ? typeof(OrderAttributesViewComponent) : null; + return typeof(OrderAttributesViewComponent); + } + else if (widgetZone == AdminWidgetZones.CustomerDetailsBlock) + { + return typeof(CustomerCreditWidgetViewComponent); } } diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs index 2d81000..f46ebf6 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs @@ -81,6 +81,7 @@ public class PluginNopStartup : INopStartup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -127,8 +128,7 @@ public class PluginNopStartup : INopStartup //services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); - + services.AddScoped(); services.AddSingleton(sp => new LocalFileStorageProvider() // Uses default wwwroot/uploads // Or specify custom path: @@ -137,6 +137,7 @@ public class PluginNopStartup : INopStartup // Register the file storage service services.AddScoped(); + services.AddScoped(); services.AddControllersWithViews(options => { diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs index 6e2d201..28a72ea 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs @@ -21,58 +21,60 @@ public class RouteProvider : IRouteProvider pattern: "Admin/FruitBankPlugin/Configure", defaults: new { controller = "FruitBankPluginAdmin", action = "Configure", area = AreaNames.ADMIN }); - //endpointRouteBuilder.MapHub("/fbhub");//.RequireCors("AllowBlazorClient"); - endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Order.List", pattern: "Admin/Order/List", - defaults: new { controller = "CustomOrder", action = "List", area = AreaNames.ADMIN } - ); + defaults: new { controller = "CustomOrder", action = "NewList", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Order.OrderList", pattern: "Admin/Order/OrderList", defaults: new { controller = "CustomOrder", action = "OrderList", area = AreaNames.ADMIN }); + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.Order.FruitBankOrderList", + pattern: "Admin/CustomOrder/FruitBankOrderList", + defaults: new { controller = "CustomOrder", action = "FruitBankOrderList", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.Order.UpdateOrderField", + pattern: "Admin/CustomOrder/UpdateOrderField", + defaults: new { controller = "CustomOrder", action = "UpdateOrderField", area = AreaNames.ADMIN }); + endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Order.Test", pattern: "Admin/Order/Test", - defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN } - ); + defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Index", pattern: "Admin", - defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN } - ); + defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Shipping.List", pattern: "Admin/Shipping/List", - defaults: new { controller = "Shipping", action = "List", area = AreaNames.ADMIN } - ); + defaults: new { controller = "Shipping", action = "List", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Shipping.ShippingList", pattern: "Admin/Shipping/ShippingList", - defaults: new { controller = "Shipping", action = "ShippingList", area = AreaNames.ADMIN } - ); + defaults: new { controller = "Shipping", action = "ShippingList", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Invoices.List", pattern: "Admin/Invoices/List", - defaults: new { controller = "Invoice", action = "List", area = AreaNames.ADMIN } - ); + defaults: new { controller = "Invoice", action = "List", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( - name: "Plugin.FruitBank.Admin.Shipping.Create", - pattern: "Admin/Shipping/Create", - defaults: new { controller = "Shipping", action = "Create", area = AreaNames.ADMIN }); + name: "Plugin.FruitBank.Admin.Shipping.Create", + pattern: "Admin/Shipping/Create", + defaults: new { controller = "Shipping", action = "Create", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( - name: "Plugin.FruitBank.Admin.Shipping.Edit", - pattern: "Admin/Shipping/Edit", - defaults: new { controller = "Shipping", action = "Edit", area = AreaNames.ADMIN }); + name: "Plugin.FruitBank.Admin.Shipping.Edit", + pattern: "Admin/Shipping/Edit", + defaults: new { controller = "Shipping", action = "Edit", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Shipping.UploadFile", @@ -118,7 +120,7 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.Admin.Products.List", pattern: "Admin/Product/List", defaults: new { controller = "CustomProduct", action = "List", area = AreaNames.ADMIN }); - + endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Products.ProductList", pattern: "Admin/Product/ProductList", @@ -150,9 +152,9 @@ public class RouteProvider : IRouteProvider defaults: new { controller = "CustomOrder", action = "Edit", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( - name: "Plugin.FruitBank.Admin.Order.AddProduct", - pattern: "Admin/CustomOrder/FruitBankAddProductToOrder", - defaults: new { controller = "CustomOrder", action = "FruitBankAddProductToOrder", area = AreaNames.ADMIN }); + name: "Plugin.FruitBank.Admin.Order.AddProduct", + pattern: "Admin/CustomOrder/FruitBankAddProductToOrder", + defaults: new { controller = "CustomOrder", action = "FruitBankAddProductToOrder", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.ManagementPage.ProcessShippingDocument", @@ -179,6 +181,22 @@ public class RouteProvider : IRouteProvider pattern: "Admin/ExtractText", defaults: new { controller = "FileManager", action = "ImageTextExtraction", area = AreaNames.ADMIN }); + // ── Customer Credit ────────────────────────────────────────────────── + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.CustomerCredit.List", + pattern: "Admin/CustomerCredit/List", + defaults: new { controller = "CustomerCredit", action = "List", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.CustomerCredit.CustomerCreditList", + pattern: "Admin/CustomerCredit/CustomerCreditList", + defaults: new { controller = "CustomerCredit", action = "CustomerCreditList", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.CustomerCredit.UpdateCreditLimit", + pattern: "Admin/CustomerCredit/UpdateCreditLimit", + defaults: new { controller = "CustomerCredit", action = "UpdateCreditLimit", area = AreaNames.ADMIN }); + // ── Public: Quick Order ────────────────────────────────────────────── endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.QuickOrder.Index", diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml new file mode 100644 index 0000000..caefd73 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml new file mode 100644 index 0000000..8bfe77d --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs b/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs index e4d2e6f..a38de4c 100644 --- a/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs +++ b/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs @@ -42,6 +42,7 @@ public partial class NameCompatibility : INameCompatibility { typeof(StockTaking), FruitBankConstClient.StockTakingDbTableName}, { typeof(StockTakingItem), FruitBankConstClient.StockTakingItemDbTableName}, { typeof(StockTakingItemPallet), FruitBankConstClient.StockTakingItemPalletDbTableName}, + { typeof(CustomerCredit), FruitBankConstClient.CustomerCreditDbTableName}, }; diff --git a/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs b/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs new file mode 100644 index 0000000..7570ed7 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs @@ -0,0 +1,21 @@ +using Nop.Web.Framework.Models; +using Nop.Web.Framework.Mvc.ModelBinding; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Models; + +public record CustomerCreditWidgetModel : BaseNopModel +{ + public int CustomerId { get; set; } + public bool HasCreditLimit { get; set; } + + [NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")] + public decimal CreditLimit { get; set; } + + [NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")] + public decimal OutstandingBalance { get; set; } + + [NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")] + public decimal? RemainingCredit { get; set; } + + public string? Comment { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Models/Orders/FruitBankOrderRowDto.cs b/Nop.Plugin.Misc.AIPlugin/Models/Orders/FruitBankOrderRowDto.cs new file mode 100644 index 0000000..46a5671 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Models/Orders/FruitBankOrderRowDto.cs @@ -0,0 +1,35 @@ +using System; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders +{ + /// + /// Lightweight DTO returned by the FruitBankOrderList endpoint. + /// Contains only what the grid needs – avoids serialising heavy OrderModel navigation properties. + /// + public record FruitBankOrderRowDto + { + public int Id { get; init; } + public string CustomOrderNumber { get; init; } + public string CustomerCompany { get; init; } + public int CustomerId { get; init; } + + // FruitBank-specific fields + public string InnvoiceTechId { get; init; } + public bool IsAllOrderItemAvgWeightValid { get; init; } + public bool IsMeasurable { get; init; } + public int MeasuringStatus { get; init; } + public string MeasuringStatusString { get; init; } + public DateTime? DateOfReceipt { get; init; } + + // NopCommerce order fields + public int OrderStatusId { get; init; } + public string OrderStatus { get; init; } + public int PaymentStatusId { get; init; } + public string PaymentStatus { get; init; } + public int ShippingStatusId { get; init; } + public string ShippingStatus { get; init; } + public string StoreName { get; init; } + public DateTime CreatedOn { get; init; } + public string OrderTotal { get; init; } + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj index a5ecaa7..50ae569 100644 --- a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj +++ b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj @@ -173,6 +173,12 @@ Always + + Always + + + Always + Always @@ -185,6 +191,9 @@ Always + + Always + Always @@ -650,6 +659,9 @@ Always + + Always + Always diff --git a/Nop.Plugin.Misc.AIPlugin/Services/CustomerCreditService.cs b/Nop.Plugin.Misc.AIPlugin/Services/CustomerCreditService.cs new file mode 100644 index 0000000..1bdce5b --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/CustomerCreditService.cs @@ -0,0 +1,71 @@ +using FruitBank.Common.Entities; +using Nop.Core.Domain.Orders; +using Nop.Core.Domain.Payments; +using Nop.Data; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +public class CustomerCreditService : ICustomerCreditService +{ + private readonly CustomerCreditDbTable _customerCreditDbTable; + private readonly IRepository _orderRepository; + + public CustomerCreditService( + CustomerCreditDbTable customerCreditDbTable, + IRepository orderRepository) + { + _customerCreditDbTable = customerCreditDbTable; + _orderRepository = orderRepository; + } + + public Task GetByCustomerIdAsync(int customerId) + => _customerCreditDbTable.GetByCustomerIdAsync(customerId); + + public async Task SaveAsync(CustomerCredit entity) + { + entity.UpdatedOnUtc = DateTime.UtcNow; + + if (entity.Id <= 0) + { + entity.CreatedOnUtc = DateTime.UtcNow; + await _customerCreditDbTable.InsertAsync(entity); + } + else + { + await _customerCreditDbTable.UpdateAsync(entity); + } + } + + public Task DeleteAsync(CustomerCredit entity) + => _customerCreditDbTable.DeleteAsync(entity); + + public async Task GetOutstandingBalanceAsync(int customerId) + { + return await _orderRepository.Table + .Where(o => + o.CustomerId == customerId && + o.OrderStatusId != (int)OrderStatus.Cancelled && + (o.PaymentStatusId == (int)PaymentStatus.Pending || + o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded)) + .SumAsync(o => (decimal?)o.OrderTotal) ?? 0m; + } + + public async Task GetRemainingCreditAsync(int customerId) + { + var credit = await GetByCustomerIdAsync(customerId); + if (credit == null) return null; + + var outstanding = await GetOutstandingBalanceAsync(customerId); + return credit.CreditLimit - outstanding; + } + + public async Task IsOrderAllowedAsync(int customerId, decimal newOrderTotal) + { + var credit = await GetByCustomerIdAsync(customerId); + if (credit == null) return true; + + var outstanding = await GetOutstandingBalanceAsync(customerId); + return outstanding + newOrderTotal <= credit.CreditLimit; + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs b/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs index febb2de..aecf2cd 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs @@ -1,4 +1,5 @@ -using FruitBank.Common.Interfaces; +using FruitBank.Common.Enums; +using FruitBank.Common.Interfaces; using FruitBank.Common.Server; using Mango.Nop.Core.Dtos; using Microsoft.AspNetCore.Http; @@ -8,6 +9,7 @@ using Nop.Core; using Nop.Core.Domain.Catalog; using Nop.Core.Domain.Common; using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Messages; using Nop.Core.Domain.Orders; using Nop.Core.Domain.Tax; using Nop.Core.Events; @@ -20,11 +22,13 @@ using Nop.Services.Common; using Nop.Services.Customers; using Nop.Services.Events; using Nop.Services.Localization; +using Nop.Services.Messages; using Nop.Services.Orders; using Nop.Services.Plugins; using Nop.Web.Framework.Events; using Nop.Web.Framework.Menu; using Nop.Web.Models.Sitemap; +using NUglify.JavaScript.Syntax; using System.Linq; using System.Xml.Linq; @@ -47,6 +51,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services private readonly FruitBankDbContext _dbContext; private readonly IAttributeParser _attributeParser; private readonly ICustomerService _customerService; + private readonly IWorkflowMessageService _workflowMessageService; + private readonly FruitBankNotificationService _fruitBankNotificationService; public EventConsumer( IGenericAttributeService genericAttributeService, @@ -64,7 +70,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services FruitBankAttributeService fruitBankAttributeService, FruitBankDbContext dbContext, IAttributeParser attributeParser, - ICustomerService customerService + ICustomerService customerService, + IWorkflowMessageService workflowMessageService, + FruitBankNotificationService fruitBankNotificationService ) : base(pluginManager) { _genericAttributeService = genericAttributeService; @@ -82,6 +90,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services _dbContext = dbContext; _attributeParser = attributeParser; _customerService = customerService; + _workflowMessageService = workflowMessageService; + _fruitBankNotificationService = fruitBankNotificationService; } protected override string PluginSystemName => "Misc.FruitBankPlugin"; @@ -93,6 +103,43 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services public async Task HandleEventAsync(EntityUpdatedEvent eventMessage) { await SaveOrderCustomAttributesAsync(eventMessage.Entity); + if (eventMessage.Entity == null) return; + + var orderDto = await _dbContext.OrderDtos.GetByIdAsync(eventMessage.Entity.Id, true); + if (orderDto == null) return; + + if (orderDto.MeasuringStatus == MeasuringStatus.Audited) + { + var alreadySent = await _fruitBankAttributeService + .GetGenericAttributeValueAsync(eventMessage.Entity.Id, "OrderAuditedNotificationSent"); + + if (!alreadySent) + { + await _fruitBankNotificationService + .SendOrderAuditedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable); + + await _fruitBankAttributeService + .InsertOrUpdateGenericAttributeAsync( + eventMessage.Entity.Id, "OrderAuditedNotificationSent", true); + } + } + else if (orderDto.MeasuringStatus == MeasuringStatus.Started) + { + var alreadySent = await _fruitBankAttributeService + .GetGenericAttributeValueAsync(eventMessage.Entity.Id, "OrderStartedNotificationSent"); + + if (!alreadySent) + { + await _fruitBankNotificationService + .SendOrderStartedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable); + + await _fruitBankAttributeService + .InsertOrUpdateGenericAttributeAsync( + eventMessage.Entity.Id, "OrderStartedNotificationSent", true); + } + } + + } @@ -182,10 +229,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services IconClass = "fas fa-microphone", Url = _adminMenu.GetMenuItemUrl("VoiceOrder", "Create") //ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem] - + }; - + shippingConfigurationItem.ChildNodes.Insert(3, voiceOrderMenuItem); diff --git a/Nop.Plugin.Misc.AIPlugin/Services/FruitBankNotificationService.cs b/Nop.Plugin.Misc.AIPlugin/Services/FruitBankNotificationService.cs new file mode 100644 index 0000000..61be177 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/FruitBankNotificationService.cs @@ -0,0 +1,84 @@ +using Nop.Core; +using Nop.Core.Domain.Messages; +using Nop.Core.Domain.Orders; +using Nop.Services.Customers; +using Nop.Services.Messages; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +public class FruitBankNotificationService( + IMessageTemplateService messageTemplateService, + IEmailAccountService emailAccountService, + EmailAccountSettings emailAccountSettings, + IMessageTokenProvider messageTokenProvider, + IWorkflowMessageService workflowMessageService, + ICustomerService customerService, + IStoreContext storeContext) +{ + public const string ORDER_AUDITED_TEMPLATE_NAME = "FruitBank.OrderAudited.CustomerNotification"; + public const string ORDER_STARTED_TEMPLATE_NAME = "FruitBank.OrderStarted.CustomerNotification"; + + /// + /// Sends the "order started" (being prepared) customer notification. + /// For measurable orders, informs the customer that final prices will be + /// confirmed after weighing. Fires once when MeasuringStatus transitions to Started. + /// + public async Task SendOrderStartedCustomerNotificationAsync(Order order, bool isMeasurable) + { + var measurableNote = isMeasurable + ? "

    Rendelésed mérhető tételeket tartalmaz. A végleges ár a mérés után kerül megerősítésre.

    " + : string.Empty; + + return await SendNotificationAsync(ORDER_STARTED_TEMPLATE_NAME, order, measurableNote); + } + + /// + /// Sends the "order audited" customer notification. + /// For measurable orders, confirms that weights have been recorded and + /// the final price is as shown on the order. + /// Fires once when MeasuringStatus transitions to Audited. + /// + public async Task SendOrderAuditedCustomerNotificationAsync(Order order, bool isMeasurable) + { + var measurableNote = isMeasurable + ? "

    A mért tételek súlyait rögzítettük, a végleges ár a rendelésen feltüntetett összeg.

    " + : string.Empty; + + return await SendNotificationAsync(ORDER_AUDITED_TEMPLATE_NAME, order, measurableNote); + } + + // ── shared core ───────────────────────────────────────────────────────── + + private async Task SendNotificationAsync(string templateName, Order order, string measurableNote) + { + var store = await storeContext.GetCurrentStoreAsync(); + + var templates = await messageTemplateService.GetMessageTemplatesByNameAsync(templateName, store.Id); + var messageTemplate = templates?.FirstOrDefault(); + + if (messageTemplate is null || !messageTemplate.IsActive) + return 0; + + var emailAccount = await emailAccountService.GetEmailAccountByIdAsync(messageTemplate.EmailAccountId) + ?? await emailAccountService.GetEmailAccountByIdAsync(emailAccountSettings.DefaultEmailAccountId); + + var tokens = new List(); + await messageTokenProvider.AddStoreTokensAsync(tokens, store, emailAccount, order.CustomerLanguageId); + await messageTokenProvider.AddOrderTokensAsync(tokens, order, order.CustomerLanguageId); + + var customer = await customerService.GetCustomerByIdAsync(order.CustomerId); + await messageTokenProvider.AddCustomerTokensAsync(tokens, customer); + + tokens.Add(new Token("Order.MeasurableNote", measurableNote, true)); + + var toEmail = customer.Email; + var toName = $"{customer.FirstName} {customer.LastName}".Trim(); + if (string.IsNullOrWhiteSpace(toName)) toName = customer.Email; + + return await workflowMessageService.SendNotificationAsync( + messageTemplate, emailAccount, + order.CustomerLanguageId, + tokens, + toEmail, toName); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/ICustomerCreditService.cs b/Nop.Plugin.Misc.AIPlugin/Services/ICustomerCreditService.cs new file mode 100644 index 0000000..e08c4f9 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/ICustomerCreditService.cs @@ -0,0 +1,32 @@ +using FruitBank.Common.Entities; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +public interface ICustomerCreditService +{ + /// Gets the credit record for a customer, or null if none exists (= unlimited). + Task GetByCustomerIdAsync(int customerId); + + /// Insert or update a customer credit record. + Task SaveAsync(CustomerCredit entity); + + /// Delete the credit record for a customer, restoring unlimited access. + Task DeleteAsync(CustomerCredit entity); + + /// + /// Sum of OrderTotal for all pending/unpaid, non-cancelled orders for the customer. + /// + Task GetOutstandingBalanceAsync(int customerId); + + /// + /// CreditLimit - OutstandingBalance. Returns null if no credit record exists (= unlimited). + /// + Task GetRemainingCreditAsync(int customerId); + + /// + /// Returns true if the customer is allowed to place a new order with the given total. + /// Rule: no credit record = always allowed. + /// Otherwise: OutstandingBalance + newOrderTotal must be <= CreditLimit. + /// + Task IsOrderAllowedAsync(int customerId, decimal newOrderTotal); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml new file mode 100644 index 0000000..1fe1975 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml @@ -0,0 +1,83 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Models.CustomerCreditWidgetModel + +@{ + var remaining = Model.RemainingCredit; + var statusClass = !Model.HasCreditLimit ? "text-muted" + : remaining <= 0 ? "text-danger" + : remaining < Model.CreditLimit * 0.2m ? "text-warning" + : "text-success"; +} + +
    +
    + + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle") +
    +
    + +
    +
    + +
    +
    + + @(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text) + +
    +
    + +
    +
    + +
    +
    + + @Model.OutstandingBalance.ToString("N0") Ft + +
    +
    + +
    +
    + +
    +
    + + + @if (!Model.HasCreditLimit) + { + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited") + } + else + { + @(remaining!.Value.ToString("N0")) + Ft + } + + +
    +
    + + @if (!string.IsNullOrWhiteSpace(Model.Comment)) + { +
    +
    + +
    +
    + @Model.Comment +
    +
    + } + + + +
    +
    From c86ef0e416cf27d5851a1df42d78d718e4b362b9 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 12 May 2026 16:18:19 +0200 Subject: [PATCH 3/4] =?UTF-8?q?gyorsrendel=C3=A9s,=20el=C5=91rendel=C3=A9s?= =?UTF-8?q?,=20el=C5=91rendel=C3=A9s=20management,=20hasonl=C3=B3k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CustomOrderController.cs | 747 ++++++++---- .../Controllers/FileManagerController.cs | 31 +- .../FruitBankPluginAdminController.cs | 6 +- .../Controllers/PreorderAdminController.cs | 452 +++++++ .../PreorderAvailabilityController.cs | 258 ++++ .../Areas/Admin/Models/ConfigureModel.cs | 20 +- .../Areas/Admin/Models/PreorderAdminModels.cs | 59 + .../Admin/Models/PreorderAvailabilityRow.cs | 11 + .../Admin/Views/Configure/Configure.cshtml | 32 +- .../Areas/Admin/Views/Order/Edit.cshtml | 2 +- .../Views/Order/FruitBankOrderList.cshtml | 4 +- .../Order/_CustomOrderDetails.Products.cshtml | 1054 ++++++---------- .../Areas/Admin/Views/Preorder/Detail.cshtml | 229 ++++ .../Areas/Admin/Views/Preorder/List.cshtml | 497 ++++++++ .../Views/PreorderAvailability/Index.cshtml | 282 +++++ .../CustomerPreorderNavViewComponent.cs | 12 + .../Controllers/CustomerPreorderController.cs | 123 ++ .../Controllers/HelpController.cs | 13 + .../Controllers/OrderController.cs | 630 ++++++++++ .../Controllers/PreorderController.cs | 290 +++++ .../Controllers/QuickOrderController.cs | 100 +- .../DataLayer/Interfaces/IPreorderDbSet.cs | 10 + .../Interfaces/IPreorderItemDbSet.cs | 10 + .../Domains/DataLayer/PreorderDbContext.cs | 141 +++ .../Domains/DataLayer/PreorderDbTable.cs | 44 + .../Domains/DataLayer/PreorderItemDbTable.cs | 42 + .../EventConsumers/FruitBankEventConsumer.cs | 46 +- Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs | 124 +- Nop.Plugin.Misc.AIPlugin/FruitBankSettings.cs | 13 + .../Infrastructure/PluginNopStartup.cs | 10 + .../Infrastructure/RouteProvider.cs | 151 +++ .../Localization/configure.en.xml | 53 + .../Localization/configure.hu.xml | 53 + .../Localization/preorder.en.xml | 143 +++ .../Localization/preorder.hu.xml | 143 +++ .../Localization/quickorder.en.xml | 35 + .../Localization/quickorder.hu.xml | 35 + .../Mapping/NameCompatibility.cs | 2 + .../Nop.Plugin.Misc.FruitBankPlugin.csproj | 27 + Nop.Plugin.Misc.AIPlugin/SKILL.md | 1 + .../Services/CustomPriceCalculationService.cs | 9 +- .../Services/EventConsumer.cs | 52 +- .../Services/FruitBankNotificationService.cs | 131 +- .../Services/OpenAIApiService.cs | 8 +- .../Services/PreorderConversionService.cs | 507 ++++++++ .../Services/ZaiService.cs | 235 ++++ .../Views/CustomerPreorder/List.cshtml | 286 +++++ .../Views/CustomerPreorder/NavItem.cshtml | 10 + .../Views/Help/Index.cshtml | 533 ++++++++ .../Views/Order/Index.cshtml | 1068 +++++++++++++++++ .../Views/OrderAttributes.cshtml | 95 +- .../Views/Preorder/Index.cshtml | 460 +++++++ .../Views/QuickOrder/Index.cshtml | 438 +++++-- Nop.Plugin.Misc.AIPlugin/css/preorder.css | 203 ++++ Nop.Plugin.Misc.AIPlugin/css/quick-order.css | 414 ++++--- 55 files changed, 9143 insertions(+), 1241 deletions(-) create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAvailabilityController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAvailabilityRow.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Preorder/Detail.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Preorder/List.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/PreorderAvailability/Index.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Components/CustomerPreorderNavViewComponent.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Controllers/CustomerPreorderController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Controllers/HelpController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Controllers/OrderController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Controllers/PreorderController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/Interfaces/IPreorderDbSet.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/Interfaces/IPreorderItemDbSet.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PreorderDbContext.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PreorderDbTable.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/PreorderItemDbTable.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/configure.en.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/configure.hu.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/preorder.en.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/preorder.hu.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/ZaiService.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Views/CustomerPreorder/List.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Views/CustomerPreorder/NavItem.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Views/Help/Index.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Views/Order/Index.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Views/Preorder/Index.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/css/preorder.css diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs index 4856394..8cb8ed6 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs @@ -9,6 +9,7 @@ using FruitBank.Common.Dtos; using FruitBank.Common.Entities; using FruitBank.Common.Enums; using FruitBank.Common.Interfaces; +using FruitBank.Common.Server; using FruitBank.Common.Server.Interfaces; using FruitBank.Common.Server.Services.SignalRs; using FruitBank.Common.SignalRs; @@ -72,7 +73,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers private readonly CustomOrderModelFactory _orderModelFactory; private readonly ICustomOrderSignalREndpointServer _customOrderSignalREndpoint; private readonly IPermissionService _permissionService; - private readonly IGenericAttributeService _genericAttributeService; + //private readonly IGenericAttributeService _genericAttributeService; + private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly INotificationService _notificationService; private readonly ICustomerService _customerService; private readonly IProductService _productService; @@ -89,6 +91,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers protected readonly ITaxService _taxService; protected readonly MeasurementService _measurementService; protected readonly IWorkflowMessageService _workflowMessageService; + protected readonly FruitBankNotificationService _fruitBankNotificationService; + protected readonly IAddressService _addressService; private static readonly char[] _separator = [',']; // ... other dependencies @@ -121,7 +125,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers IOrderModelFactory orderModelFactory, ICustomOrderSignalREndpointServer customOrderSignalREndpoint, IPermissionService permissionService, - IGenericAttributeService genericAttributeService, + //IGenericAttributeService genericAttributeService, + FruitBankAttributeService fruitBankAttributeService, INotificationService notificationService, ICustomerService customerService, IProductService productService, @@ -136,7 +141,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers IImportManager importManager, IDateTimeHelper dateTimeHelper, ITaxService taxService, - MeasurementService measurementService, IWorkflowMessageService workflowMessageService) + MeasurementService measurementService, + IWorkflowMessageService workflowMessageService, + FruitBankNotificationService fruitBankNotificationService, + IAddressService addressService) { _logger = new Logger(logWriters.ToArray()); @@ -147,7 +155,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers _orderModelFactory = orderModelFactory as CustomOrderModelFactory; _customOrderSignalREndpoint = customOrderSignalREndpoint; _permissionService = permissionService; - _genericAttributeService = genericAttributeService; + //_genericAttributeService = genericAttributeService; + _fruitBankAttributeService = fruitBankAttributeService; _notificationService = notificationService; _customerService = customerService; _productService = productService; @@ -165,7 +174,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers _taxService = taxService; _measurementService = measurementService; _workflowMessageService = workflowMessageService; + _fruitBankNotificationService = fruitBankNotificationService; + _addressService = addressService; + // ... initialize other deps + } #region CustomOrderSignalREndpoint @@ -434,7 +447,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers // store attributes in GenericAttribute table //await _genericAttributeService.SaveAttributeAsync(order, nameof(IMeasurable.IsMeasurable), model.IsMeasurable, _storeContext.GetCurrentStore().Id); - await _genericAttributeService.SaveAttributeAsync(order, nameof(IOrderDto.DateOfReceipt), model.DateOfReceipt, _storeContext.GetCurrentStore().Id); + //await _genericAttributeService.SaveAttributeAsync(order, nameof(IOrderDto.DateOfReceipt), model.DateOfReceipt, _storeContext.GetCurrentStore().Id); + await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync( + order.Id, + nameof(IOrderDto.DateOfReceipt), + model.DateOfReceipt.HasValue + ? model.DateOfReceipt.Value.ToString("MM/dd/yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) + : null, + _storeContext.GetCurrentStore().Id); + var orderDto = await _dbContext.OrderDtos.GetByIdAsync(model.OrderId, true); await _sendToClient.SendOrderChanged(orderDto); @@ -494,6 +515,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers { //no address at all, cannot create order _logger.Error($"Cannot create order for customer {customer.Id}, no billing address found."); + _notificationService.ErrorNotification("Cannot create order for customer, no billing address found. Please create a billing address for the customer first."); return RedirectToAction("List"); } } @@ -547,7 +569,28 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers //var orderDto = await _dbContext.OrderDtos.GetByIdAsync(order.Id, true); //await _sendToClient.SendMeasuringNotification("Módosult a rendelés, mérjétek újra!", orderDto); //var updatedOrder = await _orderService.GetOrderByIdAsync(order.Id); - await _workflowMessageService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId); + //await _workflowMessageService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId); + if(customer.BillingAddressId.HasValue) + { + //var billingAddress = await _addressService.GetAddressByIdAsync((int)customer.BillingAddressId); + if (billingAddress.Email != null) + { + if (!billingAddress.Email.EndsWith("inval.id")) + { + var messageResult = await _fruitBankNotificationService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId); + if (messageResult.First() != -1) + { + _notificationService.SuccessNotification("Order placed email sent to customer."); + } + else + { + _logger.Warning($"Order placed email was not sent to customer {customer.Id} because of invalid email address: {billingAddress.Email}"); + _notificationService.WarningNotification("Order placed email was not sent to customer because of invalid email address."); + } + } + } + + } return RedirectToAction("Edit", "Order", new { id = order.Id }); } @@ -602,7 +645,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers { unitPricesIncludeDiscounts = true; } - + //itt ha includeDiscounts van, akkor már a beírt ár megy be? var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store); @@ -967,6 +1010,79 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers } [HttpGet] + [CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)] + public virtual async Task PreorderProductSearchAutoComplete(string term) + { + if (string.IsNullOrWhiteSpace(term) || term.Length < 2) + return Json(new List()); + + const int maxResults = 30; + var today = DateTime.UtcNow.Date; + var store = await _storeContext.GetCurrentStoreAsync(); + + // Load preorder window attributes in two batch queries + var gaStart = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowStart + && ga.StoreId == store.Id) + .ToListAsync(); + + var gaEnd = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowEnd + && ga.StoreId == store.Id) + .ToListAsync(); + + var startById = gaStart.ToDictionary(g => g.EntityId, g => g.Value); + var endById = gaEnd.ToDictionary(g => g.EntityId, g => g.Value); + + // Product IDs currently in the preorder window + var availableIds = startById.Keys + .Intersect(endById.Keys) + .Where(id => + { + DateTime.TryParse(startById[id], out var ws); + DateTime.TryParse(endById[id], out var we); + return ws.Date <= today && today <= we.Date; + }) + .ToHashSet(); + + if (!availableIds.Any()) + return Json(new List()); + + // Search within available products only + var products = await _productService.SearchProductsAsync( + keywords: term, + pageIndex: 0, + pageSize: maxResults); + + var inWindow = products.Where(p => availableIds.Contains(p.Id)).ToList(); + if (!inWindow.Any()) + return Json(new List()); + + var productDtosById = await _dbContext.ProductDtos + .GetAllByIds(inWindow.Select(p => p.Id)) + .ToDictionaryAsync(k => k.Id, v => v); + + var result = new List(); + foreach (var product in inWindow) + { + productDtosById.TryGetValue(product.Id, out var dto); + result.Add(new + { + label = $"{product.Name} [KÉSZLET: {(product.StockQuantity + (dto?.IncomingQuantity ?? 0))}] [ÁR: {product.Price}]", + value = product.Id, + sku = product.Sku, + price = product.Price, + stockQuantity = product.StockQuantity, + incomingQuantity = dto?.IncomingQuantity ?? 0 + }); + } + + return Json(result); + } + + [HttpGet] [CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)] public virtual async Task ProductSearchUnfilteredAutoComplete(string term) { @@ -975,7 +1091,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers const int maxResults = 30; - // Search products by name or SKU var products = await _productService.SearchProductsAsync( keywords: term, pageIndex: 0, @@ -989,24 +1104,21 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers var productDto = productDtosById[product.Id]; if (productDto != null) { - result.Add(new { - label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]", - value = product.Id, - sku = product.Sku, - price = product.Price, - stockQuantity = product.StockQuantity, + label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]", + value = product.Id, + sku = product.Sku, + price = product.Price, + stockQuantity = product.StockQuantity, incomingQuantity = productDto.IncomingQuantity, }); - } } return Json(result); } - //[HttpPost] //public async Task CreateInvoice(int orderId) //{ // try @@ -1404,14 +1516,38 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers [HttpPost] - //[IgnoreAntiforgeryToken] - [ValidateAntiForgeryToken] - public async Task FruitBankAddProductToOrder(int orderId, string productsJson) + [ValidateAntiForgeryToken] + [CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)] + public async Task SendOrderEmailToCustomer(int orderId) + { + try { - try - { - _logger.Info($"AddProductToOrder - OrderId: {orderId}, ProductsJson: {productsJson}"); + var order = await _orderService.GetOrderByIdAsync(orderId); + if (order == null) + return Json(new { success = false, message = "Rendelés nem található" }); + var sentIds = await _fruitBankNotificationService.SendOrderInfoEmailAsync(order); + var sentCount = sentIds?.Count(id => id > 0) ?? 0; + + if (sentCount > 0) + return Json(new { success = true, message = $"Email sikeresen elküldve ({sentCount} címzett)" }); + + return Json(new { success = false, message = "Az email nem került elküldésre. Ellenőrizze az email sablont és az ügyfél email címét." }); + } + catch (Exception ex) + { + _logger.Error($"SendOrderEmailToCustomer error – orderId={orderId}: {ex.Message}", ex); + return Json(new { success = false, message = $"Hiba: {ex.Message}" }); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + [CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)] + public async Task FruitBankAddProductToOrder(int orderId, string productsJson) + { + try { + _logger.Info($"AddProductToOrder - OrderId: {orderId}, ProductsJson: {productsJson}"); if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)) return Json(new { success = false, message = "Access denied" }); @@ -1759,235 +1895,372 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers } } - - // ═══════════════════════════════════════════════════════════════════ - // FruitBank Order Grid – new server-side DataTables endpoint - // ═══════════════════════════════════════════════════════════════════ - /// - /// Returns the new FruitBank order list view (replaces the default NopCommerce grid). - /// - [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)] - public async Task NewList( - List orderStatuses = null, - List paymentStatuses = null, - List shippingStatuses = null) - { - var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended + // ═══════════════════════════════════════════════════════════════════ + // FruitBank Order Grid – new server-side DataTables endpoint + // ═══════════════════════════════════════════════════════════════════ + + /// + /// Returns the new FruitBank order list view (replaces the default NopCommerce grid). + /// + [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)] + public async Task NewList( + List orderStatuses = null, + List paymentStatuses = null, + List shippingStatuses = null) { - OrderStatusIds = orderStatuses, - PaymentStatusIds = paymentStatuses, - ShippingStatusIds = shippingStatuses, - Length = 50, - AvailablePageSizes = "20,50,100,500", - SortColumn = "Id", - SortColumnDirection = "desc", - }); - model.SetGridSort("Id", "desc"); - model.SetGridPageSize(50, "20,50,100,500"); - - return View( - "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml", - model); - } - - /// - /// DataTables server-side endpoint for the FruitBank order grid. - /// Handles NopCommerce base filters + FruitBank-specific filters + per-column search + sort + pagination. - /// - [HttpPost] - [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)] - public async Task FruitBankOrderList() - { - // ── 1. Parse DataTables protocol params ──────────────────────── - _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); - _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); - _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 50 : Math.Min(length, 500); - - // Sort column - _ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx); - var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc"; - var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "Id"; - - // Per-column search values keyed by column data-field name - var colSearch = new Dictionary(StringComparer.OrdinalIgnoreCase); - for (int ci = 0; Request.Form.ContainsKey($"columns[{ci}][data]"); ci++) - { - var cData = Request.Form[$"columns[{ci}][data]"].FirstOrDefault(); - var cVal = Request.Form[$"columns[{ci}][search][value]"].FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(cData) && !string.IsNullOrWhiteSpace(cVal)) - colSearch[cData] = cVal.Trim(); - } - - // ── 2. Parse custom filter params ───────────────────────────── - DateTime? startDate = null, endDate = null; - if (DateTime.TryParse(Request.Form["StartDate"].FirstOrDefault(), out var sd)) startDate = sd; - if (DateTime.TryParse(Request.Form["EndDate"].FirstOrDefault(), out var ed)) endDate = ed; - - var orderStatusIds = Request.Form["OrderStatusIds"] - .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); - var paymentStatusIds = Request.Form["PaymentStatusIds"] - .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); - var shippingStatusIds = Request.Form["ShippingStatusIds"] - .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); - - var billingCompany = Request.Form["BillingCompany"].FirstOrDefault(); // holds customer ID (string) - - bool? isMeasurableFilter = null; - var imStr = Request.Form["IsMeasurable"].FirstOrDefault(); - if (imStr == "true") isMeasurableFilter = true; - if (imStr == "false") isMeasurableFilter = false; - - bool? hasInnvoiceFilter = null; - var hiStr = Request.Form["HasInnvoiceTechId"].FirstOrDefault(); - if (hiStr == "true") hasInnvoiceFilter = true; - if (hiStr == "false") hasInnvoiceFilter = false; - - // ── 3. Fetch data via factory (applies NopCommerce base filters) - // We ask for a large page so all matching records come back in one shot; - // FruitBank-specific filtering + pagination happen below in-process. - var searchModel = new OrderSearchModelExtended - { - StartDate = startDate, - EndDate = endDate, - OrderStatusIds = orderStatusIds.Any() ? orderStatusIds : null, - PaymentStatusIds = paymentStatusIds.Any() ? paymentStatusIds : null, - ShippingStatusIds = shippingStatusIds.Any() ? shippingStatusIds : null, - BillingCompany = billingCompany, - SortColumn = "Id", - SortColumnDirection = "desc" - }; - // SetGridPageSize is the proper NopCommerce way to override Page/PageSize - searchModel.SetGridPageSize(5000, "5000"); - - OrderListModelExtended orderListModel; - try - { - orderListModel = await _orderModelFactory.PrepareOrderListModelExtendedAsync(searchModel); - } - catch (Exception ex) - { - _logger.Error($"FruitBankOrderList – factory error: {ex.Message}", ex); - return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); - } - - var rows = orderListModel.Data?.ToList() ?? new List(); - int total = orderListModel.RecordsTotal; - - // ── 4. Map to lightweight DTO ────────────────────────────────── - var dtos = rows.Select(o => new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto - { - Id = o.Id, - CustomOrderNumber = o.CustomOrderNumber, - CustomerCompany = o.CustomerCompany, - CustomerId = o.CustomerId, - InnvoiceTechId = o.InnvoiceTechId, - IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid, - IsMeasurable = o.IsMeasurable, - MeasuringStatus = (int)o.MeasuringStatus, - MeasuringStatusString = o.MeasuringStatusString, - DateOfReceipt = o.DateOfReceipt, - OrderStatusId = o.OrderStatusId, - OrderStatus = o.OrderStatus, - PaymentStatusId = o.PaymentStatusId, - PaymentStatus = o.PaymentStatus, - ShippingStatusId = o.ShippingStatusId, - ShippingStatus = o.ShippingStatus, - StoreName = o.StoreName, - CreatedOn = o.CreatedOn, - OrderTotal = o.OrderTotal - }).ToList(); - - // ── 5. Apply FruitBank-specific top-level filters ────────────── - if (isMeasurableFilter.HasValue) - dtos = dtos.Where(o => o.IsMeasurable == isMeasurableFilter.Value).ToList(); - - if (hasInnvoiceFilter.HasValue) - dtos = hasInnvoiceFilter.Value - ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() - : dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList(); - - // ── 6. Apply per-column search ───────────────────────────────── - foreach (var (col, val) in colSearch) - { - dtos = col.ToLowerInvariant() switch + var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended { - "customordernumber" => dtos.Where(o => o.CustomOrderNumber?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(), - "customercompany" => dtos.Where(o => o.CustomerCompany?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(), - "orderstatusid" => int.TryParse(val, out int osId) ? dtos.Where(o => o.OrderStatusId == osId).ToList() : dtos, - "measuringstatus" => int.TryParse(val, out int msId) ? dtos.Where(o => o.MeasuringStatus == msId).ToList() : dtos, - "ismeasurable" => bool.TryParse(val, out bool bm) ? dtos.Where(o => o.IsMeasurable == bm).ToList() : dtos, - // InnVoice column sends 'has' or 'none' strings - "innvoicetechid" => val == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() - : val == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() - : dtos, - _ => dtos + OrderStatusIds = orderStatuses, + PaymentStatusIds = paymentStatuses, + ShippingStatusIds = shippingStatuses, + Length = 50, + AvailablePageSizes = "20,50,100,500", + SortColumn = "Id", + SortColumnDirection = "desc", + }); + model.SetGridSort("Id", "desc"); + model.SetGridPageSize(50, "20,50,100,500"); + + return View( + "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml", + model); + } + + /// + /// DataTables server-side endpoint for the FruitBank order grid. + /// Handles NopCommerce base filters + FruitBank-specific filters + per-column search + sort + pagination. + /// + [HttpPost] + [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)] + public async Task FruitBankOrderList() + { + var swTotal = System.Diagnostics.Stopwatch.StartNew(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + // ── 1. Parse DataTables protocol params ──────────────────────── + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 50 : Math.Min(length, 500); + + // Sort column + _ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx); + var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc"; + var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "Id"; + + // Per-column search values keyed by column data-field name + var colSearch = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int ci = 0; Request.Form.ContainsKey($"columns[{ci}][data]"); ci++) + { + var cData = Request.Form[$"columns[{ci}][data]"].FirstOrDefault(); + var cVal = Request.Form[$"columns[{ci}][search][value]"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(cData) && !string.IsNullOrWhiteSpace(cVal)) + colSearch[cData] = cVal.Trim(); + } + + // ── 2. Parse custom filter params ───────────────────────────── + DateTime? startDate = null, endDate = null; + if (DateTime.TryParse(Request.Form["StartDate"].FirstOrDefault(), out var sd)) startDate = sd; + if (DateTime.TryParse(Request.Form["EndDate"].FirstOrDefault(), out var ed)) endDate = ed; + + var orderStatusIds = Request.Form["OrderStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + var paymentStatusIds = Request.Form["PaymentStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + var shippingStatusIds = Request.Form["ShippingStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + + var billingCompany = Request.Form["BillingCompany"].FirstOrDefault(); + + bool? isMeasurableFilter = null; + var imStr = Request.Form["IsMeasurable"].FirstOrDefault(); + if (imStr == "true") isMeasurableFilter = true; + if (imStr == "false") isMeasurableFilter = false; + + bool? hasInnvoiceFilter = null; + var hiStr = Request.Form["HasInnvoiceTechId"].FirstOrDefault(); + if (hiStr == "true") hasInnvoiceFilter = true; + if (hiStr == "false") hasInnvoiceFilter = false; + + _logger.Info($"[PERF] FruitBankOrderList – params parsed in {sw.ElapsedMilliseconds} ms"); + sw.Restart(); + + // ── 3. Direct lean query – bypasses the factory N+1 problem ─────── + // OrderDtos already has all FruitBank fields + Customer + GenericAttributes. + // LinqToDB LoadWith batches relations into 1 query each – 3 queries total + // regardless of row count, vs the factory’s ~5 queries per row. + int? filterCustomerId = int.TryParse(billingCompany, out var cid) && cid > 0 ? cid : null; + + // UTC conversion for date filters (same logic as base factory) + var currentTz = await _dateTimeHelper.GetCurrentTimeZoneAsync(); + DateTime? startUtc = startDate.HasValue ? (DateTime?)_dateTimeHelper.ConvertToUtcTime(startDate.Value, currentTz) : null; + DateTime? endUtc = endDate.HasValue ? (DateTime?)_dateTimeHelper.ConvertToUtcTime(endDate.Value, currentTz).AddDays(1) : null; + + var query = _dbContext.OrderDtos + .GetAll(true) // loads GenericAttributes in 1 batch query + .Where(o => !o.Deleted); + + if (startUtc.HasValue) query = query.Where(o => o.CreatedOnUtc >= startUtc.Value); + if (endUtc.HasValue) query = query.Where(o => o.CreatedOnUtc <= endUtc.Value); + if (filterCustomerId.HasValue) query = query.Where(o => o.CustomerId == filterCustomerId.Value); + if (orderStatusIds.Any()) query = query.Where(o => orderStatusIds.Contains(o.OrderStatusId)); + if (paymentStatusIds.Any()) query = query.Where(o => paymentStatusIds.Contains(o.PaymentStatusId)); + if (shippingStatusIds.Any()) query = query.Where(o => shippingStatusIds.Contains(o.ShippingStatusId)); + + // Apply sort at DB level + bool asc = sortDir == "asc"; + query = sortColName.ToLowerInvariant() switch + { + "customordernumber" => asc ? query.OrderBy(o => o.CustomOrderNumber) : query.OrderByDescending(o => o.CustomOrderNumber), + "createdon" => asc ? query.OrderBy(o => o.CreatedOnUtc) : query.OrderByDescending(o => o.CreatedOnUtc), + "dateofreceipt" => asc ? query.OrderBy(o => o.DateOfReceipt) : query.OrderByDescending(o => o.DateOfReceipt), + "orderstatusid" => asc ? query.OrderBy(o => o.OrderStatusId) : query.OrderByDescending(o => o.OrderStatusId), + "measuringstatus" => asc ? query.OrderBy(o => o.MeasuringStatus) : query.OrderByDescending(o => o.MeasuringStatus), + "customercompany" => asc ? query.OrderBy(o => o.CustomerId) : query.OrderByDescending(o => o.CustomerId), + _ => query.OrderByDescending(o => o.Id) }; + + // Per-column DB-mappable filters + if (colSearch.TryGetValue("CustomOrderNumber", out var coNum) && !string.IsNullOrEmpty(coNum)) + query = query.Where(o => o.CustomOrderNumber.Contains(coNum)); + if (colSearch.TryGetValue("OrderStatusId", out var osColStr) && int.TryParse(osColStr, out var osColId)) + query = query.Where(o => o.OrderStatusId == osColId); + if (colSearch.TryGetValue("MeasuringStatus", out var msColStr) && int.TryParse(msColStr, out var msColId)) + query = query.Where(o => (int)o.MeasuringStatus == msColId); + // IsMeasurable: computed from OrderItemDtos – pre-query the OrderItem table + // to get order IDs where any item belongs to a measurable product, then filter SQL + var isMeasurableColVal = colSearch.TryGetValue("IsMeasurable", out var imcs) ? imcs : null; + bool? effectiveIsMeasurable = isMeasurableFilter; + if (isMeasurableColVal != null && bool.TryParse(isMeasurableColVal, out var imcb)) + effectiveIsMeasurable = imcb; + + if (effectiveIsMeasurable.HasValue) + { + // Get all order IDs where any item has a measurable product + var measurableOrderIds = await _dbContext.OrderItemDtos + .GetAll(false) + .Where(oi => oi.ProductDto != null && oi.ProductDto.IsMeasurable) + .Select(oi => oi.OrderId) + .Distinct() + .ToListAsync(); + + query = effectiveIsMeasurable.Value + ? query.Where(o => measurableOrderIds.Contains(o.Id)) + : query.Where(o => !measurableOrderIds.Contains(o.Id)); + + _logger.Info($"[PERF] FruitBankOrderList – IsMeasurable pre-query: {measurableOrderIds.Count} measurable order IDs"); + } + + // CustomerCompany column search: pre-query Customer table for matching IDs + if (colSearch.TryGetValue("CustomerCompany", out var ccColVal) && !string.IsNullOrEmpty(ccColVal)) + { + var matchingCustomerIds = await _dbContext.Customers.Table + .Where(c => c.Company.Contains(ccColVal) || + (c.FirstName + " " + c.LastName).Contains(ccColVal)) + .Select(c => c.Id) + .ToListAsync(); + + query = query.Where(o => matchingCustomerIds.Contains(o.CustomerId)); + _logger.Info($"[PERF] FruitBankOrderList – CustomerCompany pre-query: {matchingCustomerIds.Count} matching customers"); + } + + // COUNT – runs as a simple SELECT COUNT(*) against the filtered set + int total; + try { total = await query.CountAsync(); } + catch (Exception ex) + { + _logger.Error($"FruitBankOrderList – count error: {ex.Message}", ex); + return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); + } + + _logger.Info($"[PERF] FruitBankOrderList – COUNT {sw.ElapsedMilliseconds} ms | total: {total}"); + sw.Restart(); + + // Step 1: get just the IDs for the current page (plain SQL, no relations) + List pageIds; + try + { + pageIds = await query.Skip(start).Take(length).Select(o => o.Id).ToListAsync(); + } + catch (Exception ex) + { + _logger.Error($"FruitBankOrderList – page IDs query error: {ex.Message}", ex); + return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); + } + + _logger.Info($"[PERF] FruitBankOrderList – page IDs (Skip+Take) {sw.ElapsedMilliseconds} ms | ids: {pageIds.Count}"); + sw.Restart(); + + // Step 2: reload those ~50 rows with only the relations we need. + // LoadWith works here because it’s applied to the base table query, not a filtered IQueryable. + List rows; + try + { + // GetAllByIds(ids, false) uses GetAll(false) which has LoadWith(GenericAttributes) baked in. + // LoadWith on a chained IQueryable is not supported by LinqToDB. + rows = await _dbContext.OrderDtos + .GetAllByIds(pageIds, true) + .ToListAsync(); + + // Re-sort to match the original query order (IN clause doesn’t guarantee order) + rows = pageIds + .Select(id => rows.FirstOrDefault(r => r.Id == id)) + .Where(r => r != null) + .ToList(); + } + catch (Exception ex) + { + _logger.Error($"FruitBankOrderList – relations query error: {ex.Message}", ex); + return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); + } + + _logger.Info($"[PERF] FruitBankOrderList – relations load (GetAll+LoadWith) {sw.ElapsedMilliseconds} ms | rows: {rows.Count}"); + sw.Restart(); + + var userTz = currentTz; + + // ── 4. Map to lightweight DTO ────────────────────────────────── + static string MeasuringStatusLabel(MeasuringStatus s) => s switch + { + MeasuringStatus.NotStarted => "Nincs elindítva", + MeasuringStatus.Started => "Folyamatban", + MeasuringStatus.Finnished => "Mérve", + MeasuringStatus.Audited => "Lezárva", + _ => s.ToString() + }; + static string OrderStatusLabel(int id) => id switch + { + 10 => "Függőben", + 20 => "Feldolgozás", + 30 => "Teljesítve", + 40 => "Törölve", + _ => id.ToString() + }; + static string PaymentStatusLabel(int id) => id switch + { + 10 => "Fizetésre vár", + 20 => "Félig fizetve", + 30 => "Fizetve", + 35 => "Túlfizetve", + 40 => "Visszatérítve", + _ => id.ToString() + }; + static string ShippingStatusLabel(int id) => id switch + { + 10 => "Szállítás nincs", + 20 => "Nincs kiszállítva", + 25 => "Részben kiszállítva", + 30 => "Kiszállítva", + _ => id.ToString() + }; + + var dtos = rows.Select(o => + { + var ga = o.GenericAttributes; + var dateOfReceipt = ga?.FirstOrDefault(a => a.Key == "DateOfReceipt")?.Value is string dv && DateTime.TryParse(dv, out var dp) ? dp : (DateTime?)null; + var innvoiceTechId = ga?.FirstOrDefault(a => a.Key == "InnVoiceOrderTechId")?.Value; + var company = o.Customer != null + ? $"{o.Customer.Company} {o.Customer.FirstName}_{o.Customer.LastName}".Trim() + : string.Empty; + + return new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto + { + Id = o.Id, + CustomOrderNumber = o.CustomOrderNumber, + CustomerCompany = company, + CustomerId = o.CustomerId, + InnvoiceTechId = innvoiceTechId, + IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid, + IsMeasurable = o.IsMeasurable, + MeasuringStatus = (int)o.MeasuringStatus, + MeasuringStatusString = MeasuringStatusLabel(o.MeasuringStatus), + DateOfReceipt = dateOfReceipt, + OrderStatusId = o.OrderStatusId, + OrderStatus = OrderStatusLabel(o.OrderStatusId), + PaymentStatusId = o.PaymentStatusId, + PaymentStatus = PaymentStatusLabel(o.PaymentStatusId), + ShippingStatusId = o.ShippingStatusId, + ShippingStatus = ShippingStatusLabel(o.ShippingStatusId), + StoreName = string.Empty, // not needed in grid + CreatedOn = TimeZoneInfo.ConvertTimeFromUtc(o.CreatedOnUtc, userTz), + OrderTotal = !o.IsComplete && o.IsMeasurable + ? "kalkuláció alatt..." + : $"{o.OrderTotal:N0} Ft" + }; + }).ToList(); + + _logger.Info($"[PERF] FruitBankOrderList – DTO mapping {sw.ElapsedMilliseconds} ms"); + sw.Restart(); + + // InnVoice filter is post-query (it’s in GenericAttributes, not a plain column) + if (hasInnvoiceFilter.HasValue) + dtos = hasInnvoiceFilter.Value + ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList(); + + // InnVoice column-header filter (post-query: stored in GenericAttributes) + if (colSearch.TryGetValue("InnvoiceTechId", out var innColVal)) + dtos = innColVal == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : innColVal == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : dtos; + + var result = Json(new { draw, recordsTotal = total, recordsFiltered = total, data = dtos }); + + _logger.Info($"[PERF] FruitBankOrderList – JSON serialize {sw.ElapsedMilliseconds} ms"); + _logger.Info($"[PERF] FruitBankOrderList – TOTAL {swTotal.ElapsedMilliseconds} ms | page: {dtos.Count}"); + + return result; } - int recordsFiltered = dtos.Count; + - // ── 7. Sort ──────────────────────────────────────────────────── - bool asc = sortDir == "asc"; - dtos = sortColName.ToLowerInvariant() switch + /// + /// Inline-edit save endpoint. Currently supports DateOfReceipt. + /// + [HttpPost] + [CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)] + public async Task UpdateOrderField(int orderId, string field, string value) { - "id" => asc ? dtos.OrderBy(o => o.Id).ToList() : dtos.OrderByDescending(o => o.Id).ToList(), - "customordernumber" => asc ? dtos.OrderBy(o => o.CustomOrderNumber).ToList() : dtos.OrderByDescending(o => o.CustomOrderNumber).ToList(), - "customercompany" => asc ? dtos.OrderBy(o => o.CustomerCompany).ToList() : dtos.OrderByDescending(o => o.CustomerCompany).ToList(), - "dateofreceipt" => asc ? dtos.OrderBy(o => o.DateOfReceipt ?? DateTime.MinValue).ToList() : dtos.OrderByDescending(o => o.DateOfReceipt ?? DateTime.MinValue).ToList(), - "createdon" => asc ? dtos.OrderBy(o => o.CreatedOn).ToList() : dtos.OrderByDescending(o => o.CreatedOn).ToList(), - "orderstatusid" => asc ? dtos.OrderBy(o => o.OrderStatusId).ToList() : dtos.OrderByDescending(o => o.OrderStatusId).ToList(), - "measuringstatus" => asc ? dtos.OrderBy(o => o.MeasuringStatus).ToList() : dtos.OrderByDescending(o => o.MeasuringStatus).ToList(), - _ => dtos.OrderByDescending(o => o.Id).ToList() - }; - - // ── 8. Paginate ──────────────────────────────────────────────── - var page = dtos.Skip(start).Take(length).ToList(); - - return Json(new { draw, recordsTotal = total, recordsFiltered, data = page }); - } - - /// - /// Inline-edit save endpoint. Currently supports DateOfReceipt. - /// - [HttpPost] - [CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)] - public async Task UpdateOrderField(int orderId, string field, string value) - { - try - { - var order = await _orderService.GetOrderByIdAsync(orderId); - if (order == null) - return Json(new { success = false, error = "Rendelés nem található" }); - - switch (field?.ToUpperInvariant()) + try { - case "DATEOFRECEIPT": - if (string.IsNullOrWhiteSpace(value)) - { - await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", null); - return Json(new { success = true, displayValue = (string)null }); - } - if (DateTime.TryParse(value, out var newDate)) - { - await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", newDate); - return Json(new { success = true, displayValue = newDate.ToString("yyyy. MM. dd. HH:mm") }); - } - return Json(new { success = false, error = "Érvénytelen dátum formátum" }); + var order = await _orderService.GetOrderByIdAsync(orderId); + if (order == null) + return Json(new { success = false, error = "Rendelés nem található" }); - default: - return Json(new { success = false, error = $"Ismeretlen mező: {field}" }); + switch (field?.ToUpperInvariant()) + { + case "DATEOFRECEIPT": + var dateOdReceiptDateTime = DateTime.TryParse(value, out var dp); + if (string.IsNullOrWhiteSpace(value)) + { + //await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync(order.Id, nameof(IOrderDto.DateOfReceipt), null, _storeContext.GetCurrentStore().Id); + await _fruitBankAttributeService.DeleteGenericAttributeAsync(order.Id, nameof(IOrderDto.DateOfReceipt)); + //await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", null); + return Json(new { success = true, displayValue = (string)null }); + } + if (DateTime.TryParse(value, out var newDate)) + { + // Store in the same format that NopCommerce's SaveAttributeAsync uses (MM/dd/yyyy HH:mm:ss invariant) + // so OrderDto deserialization in the Blazor app doesn't break. + var formattedValue = newDate.ToString("MM/dd/yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); + await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync(order.Id, nameof(IOrderDto.DateOfReceipt), formattedValue, _storeContext.GetCurrentStore().Id); + return Json(new { success = true, displayValue = newDate.ToString("yyyy. MM. dd. HH:mm") }); + } + return Json(new { success = false, error = "Érvénytelen dátum formátum" }); + + default: + return Json(new { success = false, error = $"Ismeretlen mező: {field}" }); + } + } + catch (Exception ex) + { + _logger.Error($"UpdateOrderField error – orderId={orderId} field={field}: {ex.Message}", ex); + return Json(new { success = false, error = ex.Message }); } } - catch (Exception ex) - { - _logger.Error($"UpdateOrderField error – orderId={orderId} field={field}: {ex.Message}", ex); - return Json(new { success = false, error = ex.Message }); - } + } - -} } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs index fd0742d..cfeb78a 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs @@ -41,6 +41,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers private readonly FileStorageService _fileStorageService; private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly IStoreContext _storeContext; + private readonly PreorderConversionService _preorderConversionService; public FileManagerController( IPermissionService permissionService, @@ -53,7 +54,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers IWorkContext workContext, FileStorageService fileStorageService, FruitBankAttributeService fruitBankAttributeService, - IStoreContext storeContext) + IStoreContext storeContext, + PreorderConversionService preorderConversionService) { _permissionService = permissionService; _aiApiService = aiApiService; @@ -66,6 +68,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers _fileStorageService = fileStorageService; _fruitBankAttributeService = fruitBankAttributeService; _storeContext = storeContext; + _preorderConversionService = preorderConversionService; } /// @@ -1120,6 +1123,32 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers newIncomingQuantity, _storeContext.GetCurrentStore().Id ); } + + // ── Step 3: Convert pending preorders that cover these products ────────── + var productIdsWithIncoming = shippingDocument.ShippingItems + .Where(x => x.ProductId != null) + .Select(x => x.ProductId!.Value) + .Distinct() + .ToList(); + + if (productIdsWithIncoming.Any()) + { + // Fire-and-forget with error isolation so a conversion failure + // never blocks the shipping document save response + _ = Task.Run(async () => + { + try + { + await _preorderConversionService + .ConvertPreordersForProductsAsync(productIdsWithIncoming, shippingDocument.Id); + } + catch (Exception convEx) + { + Console.Error.WriteLine( + $"[PreorderConversion] Error during conversion for document #{shippingDocument.Id}: {convEx.Message}"); + } + }); + } return Json(new diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankPluginAdminController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankPluginAdminController.cs index 991b046..f4ea7d1 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankPluginAdminController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankPluginAdminController.cs @@ -34,7 +34,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers ApiBaseUrl = _settings.ApiBaseUrl, MaxTokens = _settings.MaxTokens, Temperature = _settings.Temperature, - RequestTimeoutSeconds = _settings.RequestTimeoutSeconds + RequestTimeoutSeconds = _settings.RequestTimeoutSeconds, + ZaiApiKey = _settings.ZaiApiKey, + ZaiModel = _settings.ZaiModel }; return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Configure/Configure.cshtml", model); } @@ -58,6 +60,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers _settings.MaxTokens = model.MaxTokens; _settings.Temperature = model.Temperature; _settings.RequestTimeoutSeconds = model.RequestTimeoutSeconds; + _settings.ZaiApiKey = model.ZaiApiKey ?? string.Empty; + _settings.ZaiModel = model.ZaiModel ?? "glm-ocr"; // Save settings await _settingService.SaveSettingAsync(_settings); diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs new file mode 100644 index 0000000..d400687 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs @@ -0,0 +1,452 @@ +using FruitBank.Common.Entities; +using FruitBank.Common.Enums; +using Microsoft.AspNetCore.Mvc; +using Nop.Core.Domain.Customers; +using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Customers; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; + +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +[AutoValidateAntiforgeryToken] +public class PreorderAdminController : BasePluginController +{ + private readonly IPermissionService _permissionService; + private readonly PreorderDbContext _preorderDbContext; + private readonly FruitBankDbContext _dbContext; + private readonly ICustomerService _customerService; + private readonly PreorderConversionService _preorderConversionService; + + private static readonly Dictionary StatusLabels = new() + { + { PreorderStatus.Pending, "Függőben" }, + { PreorderStatus.Confirmed, "Megerősítve" }, + { PreorderStatus.PartiallyFulfilled, "Részben teljesítve" }, + { PreorderStatus.Cancelled, "Törölve" } + }; + + private static readonly Dictionary ItemStatusLabels = new() + { + { PreorderItemStatus.Pending, "Függőben" }, + { PreorderItemStatus.Fulfilled, "Teljesítve" }, + { PreorderItemStatus.PartiallyFulfilled, "Részben" }, + { PreorderItemStatus.Dropped, "Ejtve" } + }; + + public PreorderAdminController( + IPermissionService permissionService, + PreorderDbContext preorderDbContext, + FruitBankDbContext dbContext, + ICustomerService customerService, + PreorderConversionService preorderConversionService) + { + _permissionService = permissionService; + _preorderDbContext = preorderDbContext; + _dbContext = dbContext; + _customerService = customerService; + _preorderConversionService = preorderConversionService; + } + + // ── LIST PAGE ───────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/Preorders")] + public async Task List() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Preorder/List.cshtml"); + } + + // ── DATATABLES SERVER-SIDE ──────────────────────────────────────────────── + + [HttpPost] + [Route("Admin/Preorders/PreorderList")] + public async Task PreorderList() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Forbid(); + + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500); + + _ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx); + var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc"; + var sortColName = Request.Form[$"columns[{sortColIdx}][name]"].FirstOrDefault() ?? "CreatedOnUtc"; + + var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? ""; + var statusFilter = Request.Form["statusFilter"].FirstOrDefault()?.Trim() ?? ""; + + // 1. All preorders with items — two queries + var preorders = await _preorderDbContext.Preorders.GetAll(false).ToListAsync(); + var allItems = await _preorderDbContext.PreorderItems.GetAll().ToListAsync(); + + var itemsByPreorder = allItems + .GroupBy(i => i.PreorderId) + .ToDictionary(g => g.Key, g => g.ToList()); + + // 2. Customers — batch + var customerIds = preorders.Select(p => p.CustomerId).Distinct().ToList(); + var customers = await _dbContext.Customers.Table + .Where(c => customerIds.Contains(c.Id)) + .Select(c => new { c.Id, c.Email, c.FirstName, c.LastName }) + .ToListAsync(); + var customerById = customers.ToDictionary(c => c.Id); + + // 3. Linked orders — find orders created from preorders via CustomOrderNumber lookup + // We store the preorder id in the order note, but the simplest link is checking + // OrderNotes for "előrendelésből" text matching preorderId. + // For now we surface the link on the detail page only. + + // 4. Build rows — derive status from quantities, not enum (LinqToDB enum reads unreliable) + var rows = preorders.Select(p => + { + customerById.TryGetValue(p.CustomerId, out var c); + var items = itemsByPreorder.TryGetValue(p.Id, out var its) ? its : new(); + + // Derive status from quantities rather than relying on the enum read + var fulfilledCount = items.Count(i => i.FulfilledQuantity > 0); + var allFulfilled = items.Any() && items.All(i => i.FulfilledQuantity >= i.RequestedQuantity); + var anyFulfilled = items.Any(i => i.FulfilledQuantity > 0); + var hasOrderId = p.OrderId.HasValue; + + // Derive a display status: use the DB enum if it looks valid (non-zero), + // otherwise infer from quantities + var effectiveStatus = (int)p.Status != 0 + ? p.Status + : allFulfilled ? PreorderStatus.Confirmed + : anyFulfilled ? PreorderStatus.PartiallyFulfilled + : PreorderStatus.Pending; + + return new PreorderListRow + { + PreorderId = p.Id, + CustomerId = p.CustomerId, + CustomerName = c != null ? $"{c.FirstName} {c.LastName}".Trim() : $"#{p.CustomerId}", + CustomerEmail = c?.Email ?? string.Empty, + DateOfReceipt = p.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + CreatedOnUtc = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + Status = effectiveStatus, + StatusLabel = StatusLabels.TryGetValue(effectiveStatus, out var sl) ? sl : effectiveStatus.ToString(), + ItemCount = items.Count, + FulfilledCount = fulfilledCount, + OrderId = p.OrderId + }; + }).ToList(); + + int recordsTotal = rows.Count; + + // 5. Filter by status + if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse(statusFilter, out var statusEnum)) + rows = rows.Where(r => r.Status == statusEnum).ToList(); + + // 6. Global search + if (!string.IsNullOrWhiteSpace(globalSearch)) + rows = rows.Where(r => + r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + r.CustomerEmail.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + r.PreorderId.ToString().Contains(globalSearch) + ).ToList(); + + int recordsFiltered = rows.Count; + + // 7. Sort + bool asc = sortDir == "asc"; + rows = sortColName switch + { + "CustomerName" => asc ? rows.OrderBy(r => r.CustomerName).ToList() : rows.OrderByDescending(r => r.CustomerName).ToList(), + "DateOfReceipt" => asc ? rows.OrderBy(r => r.DateOfReceipt).ToList() : rows.OrderByDescending(r => r.DateOfReceipt).ToList(), + "Status" => asc ? rows.OrderBy(r => r.Status).ToList() : rows.OrderByDescending(r => r.Status).ToList(), + _ => asc ? rows.OrderBy(r => r.CreatedOnUtc).ToList() : rows.OrderByDescending(r => r.CreatedOnUtc).ToList() + }; + + // 8. Paginate + var page = rows.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } + + // ── DETAIL PAGE ─────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/Preorders/Detail/{id:int}")] + public async Task Detail(int id) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id, loadRelations: false); + if (preorder == null) return NotFound(); + + var items = await _preorderDbContext.PreorderItems + .GetAllByPreorderIdAsync(id) + .ToListAsync(); + + var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId); + + // Resolve product names in one batch + var productIds = items.Select(i => i.ProductId).Distinct().ToList(); + var productDtos = await _dbContext.ProductDtos + .GetAll(false) + .Where(p => productIds.Contains(p.Id)) + .ToListAsync(); + var productById = productDtos.ToDictionary(p => p.Id); + + // Use preorder.OrderId directly — stored on the entity at conversion time + int? linkedOrderId = preorder.OrderId; + + var model = new PreorderDetailModel + { + PreorderId = preorder.Id, + CustomerId = preorder.CustomerId, + CustomerName = customer != null ? $"{customer.FirstName} {customer.LastName}".Trim() : $"#{preorder.CustomerId}", + CustomerEmail = customer?.Email ?? string.Empty, + DateOfReceipt = preorder.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + CreatedOnUtc = preorder.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + UpdatedOnUtc = preorder.UpdatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + Status = preorder.Status, + CustomerNote = preorder.CustomerNote, + OrderId = linkedOrderId, + Items = items.Select(i => + { + productById.TryGetValue(i.ProductId, out var dto); + + // Derive item status from quantities — enum reads unreliable in LinqToDB + var derivedStatus = i.FulfilledQuantity == 0 + ? PreorderItemStatus.Pending + : i.FulfilledQuantity >= i.RequestedQuantity + ? PreorderItemStatus.Fulfilled + : PreorderItemStatus.PartiallyFulfilled; + + // If DB enum read as non-zero, prefer it; otherwise use derived + var effectiveItemStatus = (int)i.Status != 0 ? i.Status : derivedStatus; + + return new PreorderDetailItemRow + { + ItemId = i.Id, + ProductId = i.ProductId, + ProductName = dto?.Name ?? $"Product #{i.ProductId}", + IsMeasurable = dto?.IsMeasurable ?? false, + RequestedQuantity = i.RequestedQuantity, + FulfilledQuantity = i.FulfilledQuantity, + UnitPriceInclTax = i.UnitPriceInclTax, + Status = effectiveItemStatus, + StatusLabel = ItemStatusLabels.TryGetValue(effectiveItemStatus, out var isl) ? isl : effectiveItemStatus.ToString() + }; + }).ToList() + }; + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Preorder/Detail.cshtml", model); + } + + // ── CREATE (admin phone order) ─────────────────────────────────────────── + + [HttpPost] + [Route("Admin/Preorders/CreatePreorder")] + public async Task CreatePreorder( + int customerId, + string deliveryDateTime, + string? customerNote, + string productsJson) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Hozzáférés megtagadva" }); + + try + { + // Validate customer + var customer = await _customerService.GetCustomerByIdAsync(customerId); + if (customer == null) + return Json(new { success = false, error = "Az ügyfél nem található" }); + + // Validate delivery date + if (!DateTime.TryParse(deliveryDateTime, out var deliveryDate)) + return Json(new { success = false, error = "Érvénytelen szállítási dátum" }); + + // Parse products + if (string.IsNullOrWhiteSpace(productsJson)) + return Json(new { success = false, error = "Nincs termék megadva" }); + + var productItems = System.Text.Json.JsonSerializer.Deserialize>(productsJson); + if (productItems == null || !productItems.Any()) + return Json(new { success = false, error = "Nincs érvényes termék" }); + + // Get store + var storeId = (await _dbContext.Shippings.GetAll().Select(s => s.Id).FirstOrDefaultAsync() > 0) + ? 1 : 1; // fallback to store 1 + // Use first available store from generic attributes context + var gaStore = await _dbContext.GenericAttributes.Table + .Select(g => g.StoreId).FirstOrDefaultAsync(); + storeId = gaStore > 0 ? gaStore : 1; + + var preorder = new Preorder + { + CustomerId = customerId, + StoreId = storeId, + DateOfReceipt = deliveryDate, + CustomerNote = customerNote?.Trim() + }; + + var items = new List(); + foreach (var pi in productItems.Where(p => p.quantity > 0)) + { + var product = await _dbContext.Products.GetByIdAsync(pi.id); + if (product == null || product.Deleted || !product.Published) continue; + + items.Add(new PreorderItem + { + ProductId = pi.id, + RequestedQuantity = pi.quantity, + UnitPriceInclTax = (decimal)pi.price + }); + } + + if (!items.Any()) + return Json(new { success = false, error = "Nincs érvényes termék az előrendelésben" }); + + var saved = await _preorderDbContext.InsertPreorderAsync(preorder, items); + + Console.WriteLine($"[Admin] Created preorder #{saved.Id} for customer #{customerId} " + + $"by admin, {items.Count} items, delivery {deliveryDate:u}"); + + // Immediately check if any items can be fulfilled from current stock — + // same inline conversion as the customer-facing PlacePreorder endpoint. + var productIds = items.Select(i => i.ProductId).Distinct().ToList(); + await _preorderConversionService.ConvertPreordersForProductsAsync(productIds, 0); + + // Re-read to pick up OrderId if conversion created a real order + var refreshed = await _preorderDbContext.Preorders.GetByIdAsync(saved.Id); + + return Json(new { success = true, preorderId = saved.Id, orderId = refreshed?.OrderId }); + } + catch (Exception ex) + { + return Json(new { success = false, error = ex.Message }); + } + } + + private class ProductItemRequest + { + public int id { get; set; } + public string? name { get; set; } + public int quantity { get; set; } + public double price { get; set; } + } + + // ── CANCEL ──────────────────────────────────────────────────────────────── + + // ── CANCEL ─────────────────────────────────────────────────────────── + + [HttpPost] + [Route("Admin/Preorders/Cancel/{id:int}")] + public async Task Cancel(int id) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id); + if (preorder == null) + return Json(new { success = false, error = "Preorder not found" }); + + if (preorder.Status != PreorderStatus.Pending) + return Json(new { success = false, error = "Only pending preorders can be cancelled" }); + + await _preorderDbContext.CancelPreorderAsync(id); + return Json(new { success = true }); + } + + // ── DEMAND LIST ─────────────────────────────────────────────────────────── + + [HttpPost] + [Route("Admin/Preorders/DemandList")] + public async Task DemandList(bool openOnly = true) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Forbid(); + + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500); + var openOnlyParam = Request.Form["openOnly"].FirstOrDefault(); + openOnly = openOnlyParam != "false"; + + // Fetch all preorder items + preorders in two queries + var allItems = await _preorderDbContext.PreorderItems.GetAll().ToListAsync(); + var allPreorders = await _preorderDbContext.Preorders.GetAll(false).ToListAsync(); + + // For "open only": include only items from preorders that still have + // unfulfilled demand (FulfilledQuantity < RequestedQuantity). + // We use quantities rather than Status enum (enum reads unreliable). + IEnumerable items = allItems; + if (openOnly) + { + // Open preorders: those where at least one item still needs fulfillment + var openPreorderIds = allPreorders + .Where(p => allItems + .Where(i => i.PreorderId == p.Id) + .Any(i => i.FulfilledQuantity < i.RequestedQuantity)) + .Select(p => p.Id) + .ToHashSet(); + + items = allItems.Where(i => openPreorderIds.Contains(i.PreorderId)); + } + + // Group by product + var grouped = items + .GroupBy(i => i.ProductId) + .Select(g => new + { + ProductId = g.Key, + TotalRequested = g.Sum(i => i.RequestedQuantity), + TotalFulfilled = g.Sum(i => i.FulfilledQuantity), + TotalUnfulfilled = g.Sum(i => i.RequestedQuantity - i.FulfilledQuantity), + PreorderCount = g.Select(i => i.PreorderId).Distinct().Count(), + AvgUnitPrice = g.Where(i => i.UnitPriceInclTax > 0).Any() + ? g.Where(i => i.UnitPriceInclTax > 0).Average(i => i.UnitPriceInclTax) + : 0m + }) + .OrderByDescending(g => g.TotalUnfulfilled) + .ThenByDescending(g => g.TotalRequested) + .ToList(); + + // Resolve product names in one batch + var productIds = grouped.Select(g => g.ProductId).Distinct().ToList(); + var productDtos = await _dbContext.ProductDtos + .GetAll(false) + .Where(p => productIds.Contains(p.Id)) + .ToListAsync(); + var productById = productDtos.ToDictionary(p => p.Id); + + var rows = grouped.Select(g => + { + productById.TryGetValue(g.ProductId, out var dto); + return new PreorderDemandRow + { + ProductId = g.ProductId, + ProductName = dto?.Name ?? $"Product #{g.ProductId}", + Sku = dto?.Id.ToString(), + IsMeasurable = dto?.IsMeasurable ?? false, + TotalRequested = g.TotalRequested, + TotalFulfilled = g.TotalFulfilled, + TotalUnfulfilled = g.TotalUnfulfilled, + PreorderCount = g.PreorderCount, + AvgUnitPrice = Math.Round(g.AvgUnitPrice, 0) + }; + }).ToList(); + + int recordsTotal = rows.Count; + int recordsFiltered = rows.Count; + var page = rows.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAvailabilityController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAvailabilityController.cs new file mode 100644 index 0000000..bb7c412 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAvailabilityController.cs @@ -0,0 +1,258 @@ +using FruitBank.Common.Server; +using LinqToDB; +using Microsoft.AspNetCore.Mvc; +using Nop.Core; +using Nop.Core.Domain.Catalog; +using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; + +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +[AutoValidateAntiforgeryToken] +public class PreorderAvailabilityController : BasePluginController +{ + private readonly IPermissionService _permissionService; + private readonly FruitBankDbContext _dbContext; + private readonly FruitBankAttributeService _fruitBankAttributeService; + private readonly IStoreContext _storeContext; + + public PreorderAvailabilityController( + IPermissionService permissionService, + FruitBankDbContext dbContext, + FruitBankAttributeService fruitBankAttributeService, + IStoreContext storeContext) + { + _permissionService = permissionService; + _dbContext = dbContext; + _fruitBankAttributeService = fruitBankAttributeService; + _storeContext = storeContext; + } + + // ── INDEX ───────────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/PreorderAvailability")] + public async Task Index() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/PreorderAvailability/Index.cshtml"); + } + + // ── ALL PRODUCTS — DataTables server-side ───────────────────────────────── + + [HttpPost] + [Route("Admin/PreorderAvailability/ProductList")] + public async Task ProductList() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Forbid(); + + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500); + + var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? ""; + + var storeId = (await _storeContext.GetCurrentStoreAsync()).Id; + + // 1. All published products + var products = await _dbContext.Products.Table + .Where(p => !p.Deleted && p.Published) + .OrderBy(p => p.Name) + .Select(p => new { p.Id, p.Name, p.Sku }) + .ToListAsync(); + + // 2. All preorder window generic attributes — two queries, no N+1 + var gaStart = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowStart + && ga.StoreId == storeId) + .ToListAsync(); + + var gaEnd = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowEnd + && ga.StoreId == storeId) + .ToListAsync(); + + var startByProduct = gaStart.ToDictionary(g => g.EntityId, g => g.Value); + var endByProduct = gaEnd.ToDictionary(g => g.EntityId, g => g.Value); + + var today = DateTime.UtcNow.Date; + + // 3. Build rows + var rows = products.Select(p => + { + DateTime.TryParse(startByProduct.GetValueOrDefault(p.Id), out var ws); + DateTime.TryParse(endByProduct.GetValueOrDefault(p.Id), out var we); + + var hasStart = startByProduct.ContainsKey(p.Id); + var hasEnd = endByProduct.ContainsKey(p.Id); + + return new PreorderAvailabilityRow + { + ProductId = p.Id, + ProductName = p.Name, + Sku = p.Sku, + WindowStart = hasStart ? ws.ToString("yyyy-MM-dd") : null, + WindowEnd = hasEnd ? we.ToString("yyyy-MM-dd") : null, + IsAvailableToday = hasStart && hasEnd && ws.Date <= today && today <= we.Date + }; + }).ToList(); + + int recordsTotal = rows.Count; + + // 4. Global search + if (!string.IsNullOrWhiteSpace(globalSearch)) + { + rows = rows.Where(r => + r.ProductName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + (r.Sku?.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ?? false) + ).ToList(); + } + + int recordsFiltered = rows.Count; + + // 5. Paginate + var page = rows.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } + + // ── AVAILABLE TODAY — DataTables server-side ────────────────────────────── + + [HttpPost] + [Route("Admin/PreorderAvailability/AvailableTodayList")] + public async Task AvailableTodayList() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Forbid(); + + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500); + + var storeId = (await _storeContext.GetCurrentStoreAsync()).Id; + var today = DateTime.UtcNow.Date; + + // Reuse same build logic — filter to available today only + var products = await _dbContext.Products.Table + .Where(p => !p.Deleted && p.Published) + .OrderBy(p => p.Name) + .Select(p => new { p.Id, p.Name, p.Sku }) + .ToListAsync(); + + var gaStart = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowStart + && ga.StoreId == storeId) + .ToListAsync(); + + var gaEnd = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowEnd + && ga.StoreId == storeId) + .ToListAsync(); + + var startByProduct = gaStart.ToDictionary(g => g.EntityId, g => g.Value); + var endByProduct = gaEnd.ToDictionary(g => g.EntityId, g => g.Value); + + var rows = products + .Where(p => + { + if (!startByProduct.TryGetValue(p.Id, out var sRaw)) return false; + if (!endByProduct.TryGetValue(p.Id, out var eRaw)) return false; + if (!DateTime.TryParse(sRaw, out var ws)) return false; + if (!DateTime.TryParse(eRaw, out var we)) return false; + return ws.Date <= today && today <= we.Date; + }) + .Select(p => + { + DateTime.TryParse(startByProduct[p.Id], out var ws); + DateTime.TryParse(endByProduct[p.Id], out var we); + return new PreorderAvailabilityRow + { + ProductId = p.Id, + ProductName = p.Name, + Sku = p.Sku, + WindowStart = ws.ToString("yyyy-MM-dd"), + WindowEnd = we.ToString("yyyy-MM-dd"), + IsAvailableToday = true + }; + }) + .ToList(); + + int recordsTotal = rows.Count; + int recordsFiltered = rows.Count; + + var page = rows.Skip(start).Take(length).ToList(); + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } + + // ── SAVE WINDOW DATES for a product ─────────────────────────────────────── + + [HttpPost] + [Route("Admin/PreorderAvailability/SaveWindow")] + public async Task SaveWindow(int productId, string? windowStart, string? windowEnd) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + try + { + var storeId = (await _storeContext.GetCurrentStoreAsync()).Id; + + // WindowStart + if (string.IsNullOrWhiteSpace(windowStart)) + { + await _fruitBankAttributeService + .DeleteGenericAttributeAsync(productId, FruitBankConst.PreorderWindowStart, storeId); + } + else if (DateTime.TryParse(windowStart, out var ws)) + { + await _fruitBankAttributeService + .InsertOrUpdateGenericAttributeAsync( + productId, FruitBankConst.PreorderWindowStart, ws.Date, storeId); + } + else return Json(new { success = false, error = $"Invalid start date: {windowStart}" }); + + // WindowEnd + if (string.IsNullOrWhiteSpace(windowEnd)) + { + await _fruitBankAttributeService + .DeleteGenericAttributeAsync(productId, FruitBankConst.PreorderWindowEnd, storeId); + } + else if (DateTime.TryParse(windowEnd, out var we)) + { + await _fruitBankAttributeService + .InsertOrUpdateGenericAttributeAsync( + productId, FruitBankConst.PreorderWindowEnd, we.Date, storeId); + } + else return Json(new { success = false, error = $"Invalid end date: {windowEnd}" }); + + // Return the new availability state + var today = DateTime.UtcNow.Date; + DateTime.TryParse(windowStart, out var startParsed); + DateTime.TryParse(windowEnd, out var endParsed); + bool isAvailableToday = !string.IsNullOrWhiteSpace(windowStart) + && !string.IsNullOrWhiteSpace(windowEnd) + && startParsed.Date <= today + && today <= endParsed.Date; + + return Json(new { success = true, isAvailableToday }); + } + catch (Exception ex) + { + return Json(new { success = false, error = ex.Message }); + } + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/ConfigureModel.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/ConfigureModel.cs index 0b5c786..94508bc 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/ConfigureModel.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/ConfigureModel.cs @@ -9,25 +9,25 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models { public record ConfigureModel { - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiKey")] public string ApiKey { get; set; } = string.Empty; - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasModelName")] public string ModelName { get; set; } = string.Empty; - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiKey")] public string OpenAIApiKey { get; set; } = string.Empty; - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIModelName")] public string OpenAIModelName { get; set; } = string.Empty; [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.IsEnabled")] public bool IsEnabled { get; set; } - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl")] public string ApiBaseUrl { get; set; } = string.Empty; - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl")] public string OpenAIApiBaseUrl { get; set; } = string.Empty; [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.MaxTokens")] @@ -38,6 +38,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds")] public int RequestTimeoutSeconds { get; set; } + + // ── Z.ai GLM-OCR ────────────────────────────────────────────────────────────── + + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ZaiApiKey")] + public string ZaiApiKey { get; set; } = string.Empty; + + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ZaiModel")] + public string ZaiModel { get; set; } = "glm-ocr"; } } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs new file mode 100644 index 0000000..71a8ffa --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs @@ -0,0 +1,59 @@ +using FruitBank.Common.Enums; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class PreorderListRow +{ + public int PreorderId { get; set; } + public int CustomerId { get; set; } + public string CustomerName { get; set; } = string.Empty; + public string CustomerEmail { get; set; } = string.Empty; + public string DateOfReceipt { get; set; } = string.Empty; // formatted + public string CreatedOnUtc { get; set; } = string.Empty; // formatted + public PreorderStatus Status { get; set; } + public string StatusLabel { get; set; } = string.Empty; + public int ItemCount { get; set; } + public int FulfilledCount { get; set; } + public int? OrderId { get; set; } // linked real order, if created +} + +public class PreorderDetailModel +{ + public int PreorderId { get; set; } + public int CustomerId { get; set; } + public string CustomerName { get; set; } = string.Empty; + public string CustomerEmail { get; set; } = string.Empty; + public string DateOfReceipt { get; set; } = string.Empty; + public string CreatedOnUtc { get; set; } = string.Empty; + public string UpdatedOnUtc { get; set; } = string.Empty; + public PreorderStatus Status { get; set; } + public string? CustomerNote { get; set; } + public int? OrderId { get; set; } + public List Items { get; set; } = new(); +} + +public class PreorderDetailItemRow +{ + public int ItemId { get; set; } + public int ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public bool IsMeasurable { get; set; } + public int RequestedQuantity { get; set; } + public int FulfilledQuantity { get; set; } + public decimal UnitPriceInclTax { get; set; } + public PreorderItemStatus Status { get; set; } + public string StatusLabel { get; set; } = string.Empty; +} + +public class PreorderDemandRow +{ + public int ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public string? Sku { get; set; } + public bool IsMeasurable { get; set; } + public int TotalRequested { get; set; } // sum of RequestedQuantity + public int TotalFulfilled { get; set; } // sum of FulfilledQuantity + public int TotalUnfulfilled { get; set; } // TotalRequested - TotalFulfilled + public int PreorderCount { get; set; } // distinct preorders containing this product + public decimal AvgUnitPrice { get; set; } // average snapshot price +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAvailabilityRow.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAvailabilityRow.cs new file mode 100644 index 0000000..baf8d6f --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAvailabilityRow.cs @@ -0,0 +1,11 @@ +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class PreorderAvailabilityRow +{ + public int ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public string? Sku { get; set; } + public string? WindowStart { get; set; } // ISO date string "yyyy-MM-dd" or null + public string? WindowEnd { get; set; } // ISO date string "yyyy-MM-dd" or null + public bool IsAvailableToday { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Configure/Configure.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Configure/Configure.cshtml index ddcca9b..c5b9281 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Configure/Configure.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Configure/Configure.cshtml @@ -23,40 +23,42 @@ + A Cerebras API kulcs
    - Az AI modell neve (pl. gpt-3.5-turbo, gpt-4, claude-3-sonnet) + A Cerebras AI modell neve (pl. gpt-3.5-turbo, gpt-4, claude-3-sonnet)
    + Az OpenAI API kulcs
    - Az AI modell neve (pl. gpt-3.5-turbo, gpt-4) + Az OpenAI AI modell neve (pl. gpt-3.5-turbo, gpt-4)
    - Az API alapcíme (OpenAI, Azure OpenAI, stb.) + A Cerebras API alapcíme (OpenAI, Azure OpenAI, stb.)
    - Az API alapcíme (OpenAI, Azure OpenAI, stb.) + Az OpenAI API alapcíme (OpenAI, Azure OpenAI, stb.)
    @@ -88,6 +90,28 @@
    +
    +
    Z.ai GLM-OCR — Dokumentumfeldolgozás
    +

    + A GLM-OCR multimodális modell szállítólevelek és rendelési dokumentumok (kép, PDF) strukturált szövegkinyerésére. + Táblázatokat HTML formátumban ad vissza, amit közvetlenül LLM promptba lehet illeszteni. + API kulcs igénylése: bigmodel.cn — ingyenes tier elérhető. +

    + +
    + + + + Z.ai API kulcs (bigmodel.cn). Üres hagyva a GLM-OCR funkció nem érhető el. +
    + +
    + + + + GLM-OCR modell neve. Alapesetben: glm-ocr +
    +
    } + @if (!Model.IsLoggedInAsVendor) { - @*
    -
    - -
    -
    *@ -
    -
    } -@* Add Product to Order Modal *@ -