From c1e27e12b4fea49c899a09b6da451fa33c7aa7f8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 5 Jun 2026 00:06:11 +0200 Subject: [PATCH 1/6] fixes --- .../PreorderAdminController.Edit.cs | 75 ++++++ .../Controllers/PreorderAdminController.cs | 44 ++-- .../Controllers/OrderController.cs | 26 ++ .../Services/PreorderConversionService.cs | 23 +- .../Views/CustomerPreorder/List.cshtml | 235 +++++++++++++++++- 5 files changed, 374 insertions(+), 29 deletions(-) create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.Edit.cs diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.Edit.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.Edit.cs new file mode 100644 index 0000000..a13ac1c --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.Edit.cs @@ -0,0 +1,75 @@ +using FruitBank.Common.Enums; +using Microsoft.AspNetCore.Mvc; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Services.Security; +using Nop.Web.Framework.Mvc.Filters; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; + +public partial class PreorderAdminController +{ + [HttpPost, Route("Admin/Preorders/UpdateHeader/{id:int}")] + public async Task UpdateHeader(int id, string deliveryDateTime, string? customerNote) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid(); + var p = await _preorderDbContext.Preorders.GetByIdAsync(id); + if (p == null || p.Status == PreorderStatus.Cancelled) return Json(new { success = false }); + if (!DateTime.TryParse(deliveryDateTime, out var d)) return Json(new { success = false, error = "Érvénytelen dátum" }); + p.DateOfReceipt = d; p.CustomerNote = customerNote?.Trim(); p.UpdatedOnUtc = DateTime.UtcNow; + await _preorderDbContext.Preorders.UpdateAsync(p); + return Json(new { success = true }); + } + + [HttpPost, Route("Admin/Preorders/UpdateItem/{id:int}")] + public async Task UpdateItem(int id, int quantity, decimal unitPrice) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid(); + var item = await _preorderDbContext.PreorderItems.GetByIdAsync(id); + if (item == null || item.FulfilledQuantity > 0) return Json(new { success = false, error = "Módosítás nem lehetséges" }); + item.RequestedQuantity = Math.Max(1, quantity); + item.UnitPriceInclTax = Math.Max(0, unitPrice); + await _preorderDbContext.PreorderItems.UpdateAsync(item); + return Json(new { success = true }); + } + + [HttpPost, Route("Admin/Preorders/ReplaceItemProduct/{id:int}")] + public async Task ReplaceItemProduct(int id, int newProductId) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid(); + var item = await _preorderDbContext.PreorderItems.GetByIdAsync(id); + if (item == null || item.FulfilledQuantity > 0) return Json(new { success = false, error = "Csere nem lehetséges" }); + var product = await _dbContext.Products.GetByIdAsync(newProductId); + if (product == null || product.Deleted) return Json(new { success = false, error = "Termék nem található" }); + + item.ProductId = newProductId; + + // Recalculate price for the preorder's customer using CustomPriceCalculationService + var preorder = await _preorderDbContext.Preorders.GetByIdAsync(item.PreorderId); + var customer = preorder != null ? await _customerService.GetCustomerByIdAsync(preorder.CustomerId) : null; + if (customer != null) + { + var store = await _storeContext.GetCurrentStoreAsync(); + var calc = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: true); + item.UnitPriceInclTax = calc.finalPrice; + } + + await _preorderDbContext.PreorderItems.UpdateAsync(item); + return Json(new { success = true, productName = product.Name, newPrice = item.UnitPriceInclTax }); + } + + [HttpPost, Route("Admin/Preorders/ConvertNow/{id:int}")] + public async Task ConvertNow(int id) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid(); + var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id); + if (preorder == null || preorder.Status == PreorderStatus.Cancelled) + return Json(new { success = false, error = "Konvertálás nem lehetséges" }); + var pending = await _preorderDbContext.PreorderItems.GetAll() + .Where(i => i.PreorderId == id && i.FulfilledQuantity < i.RequestedQuantity).ToListAsync(); + if (!pending.Any()) return Json(new { success = false, error = "Nincs teljesítetlen tétel" }); + await _preorderConversionService.ConvertPreordersForProductsAsync( + pending.Select(i => i.ProductId).Distinct().ToList(), 0); + var refreshed = await _preorderDbContext.Preorders.GetByIdAsync(id); + return Json(new { success = true, orderId = refreshed?.OrderId }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs index d400687..9fe273a 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs @@ -1,3 +1,4 @@ +using Nop.Core; using FruitBank.Common.Entities; using FruitBank.Common.Enums; using Microsoft.AspNetCore.Mvc; @@ -5,6 +6,7 @@ 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.Catalog; using Nop.Services.Customers; using Nop.Services.Security; using Nop.Web.Framework; @@ -16,13 +18,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; [AuthorizeAdmin] [Area(AreaNames.ADMIN)] [AutoValidateAntiforgeryToken] -public class PreorderAdminController : BasePluginController +public partial class PreorderAdminController : BasePluginController { private readonly IPermissionService _permissionService; private readonly PreorderDbContext _preorderDbContext; private readonly FruitBankDbContext _dbContext; private readonly ICustomerService _customerService; private readonly PreorderConversionService _preorderConversionService; + private readonly IPriceCalculationService _priceCalculationService; + private readonly IStoreContext _storeContext; private static readonly Dictionary StatusLabels = new() { @@ -45,13 +49,17 @@ public class PreorderAdminController : BasePluginController PreorderDbContext preorderDbContext, FruitBankDbContext dbContext, ICustomerService customerService, - PreorderConversionService preorderConversionService) + PreorderConversionService preorderConversionService, + IPriceCalculationService priceCalculationService, + IStoreContext storeContext) { _permissionService = permissionService; _preorderDbContext = preorderDbContext; _dbContext = dbContext; _customerService = customerService; _preorderConversionService = preorderConversionService; + _priceCalculationService = priceCalculationService; + _storeContext = storeContext; } // ── LIST PAGE ───────────────────────────────────────────────────────────── @@ -121,11 +129,12 @@ public class PreorderAdminController : BasePluginController // 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; + var effectiveStatus = + p.Status == PreorderStatus.Cancelled ? PreorderStatus.Cancelled : + (int)p.Status != 0 ? p.Status : + allFulfilled ? PreorderStatus.Confirmed : + anyFulfilled || p.OrderId.HasValue ? PreorderStatus.PartiallyFulfilled : + PreorderStatus.Pending; return new PreorderListRow { @@ -222,13 +231,13 @@ public class PreorderAdminController : BasePluginController // 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; + ? 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; + // If DB enum read as non-zero, prefer it; otherwise use derived + var effectiveItemStatus = (int)i.Status != 0 ? i.Status : derivedStatus; return new PreorderDetailItemRow { @@ -357,10 +366,13 @@ public class PreorderAdminController : BasePluginController 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" }); + if (preorder.Status == PreorderStatus.Cancelled) + return Json(new { success = false, error = "Már visszavont előrendelés" }); + + preorder.Status = PreorderStatus.Cancelled; + preorder.UpdatedOnUtc = DateTime.UtcNow; + await _preorderDbContext.Preorders.UpdateAsync(preorder); - await _preorderDbContext.CancelPreorderAsync(id); return Json(new { success = true }); } diff --git a/Nop.Plugin.Misc.AIPlugin/Controllers/OrderController.cs b/Nop.Plugin.Misc.AIPlugin/Controllers/OrderController.cs index eebcdeb..56e9e05 100644 --- a/Nop.Plugin.Misc.AIPlugin/Controllers/OrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Controllers/OrderController.cs @@ -356,6 +356,32 @@ public class OrderController : BasePluginController } } + // ── TRANSCRIBE ONLY (for preorder history page filter) ───────────────── + + [HttpPost] + public async Task TranscribeOnly(Microsoft.AspNetCore.Http.IFormFile audioFile) + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Json(new { success = false }); + + if (audioFile == null || audioFile.Length == 0) + return Json(new { success = false, message = "Nem érkezett hangfájl" }); + + try + { + var text = await TranscribeAudioFile(audioFile, "hu"); + if (string.IsNullOrWhiteSpace(text)) + return Json(new { success = false, message = "Nem sikerült a hangfelismerés" }); + + return Json(new { success = true, transcription = text }); + } + catch (Exception ex) + { + return Json(new { success = false, message = ex.Message }); + } + } + // ── ADD TO CART (Quick Order flow) ──────────────────────────────────────── [HttpPost] diff --git a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs index ba01858..e4a0d0d 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs @@ -303,7 +303,7 @@ public partial class PreorderConversionService BillingAddressId = billingAddressId, OrderStatusId = (int)OrderStatus.Pending, PaymentStatusId = (int)PaymentStatus.Pending, - ShippingStatusId = (int)ShippingStatus.NotYetShipped, + ShippingStatusId = (int)ShippingStatus.ShippingNotRequired, PaymentMethodSystemName = "Payments.CheckMoneyOrder", CustomerLanguageId = 1, CustomerTaxDisplayTypeId = 0, @@ -433,6 +433,9 @@ public partial class PreorderConversionService private async Task InsertOrderItemsAsync(Order order, List items) { + var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId); + var store = await _storeContext.GetCurrentStoreAsync(); + foreach (var item in items) { var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true); @@ -441,8 +444,20 @@ public partial class PreorderConversionService var product = await _productService.GetProductByIdAsync(item.ProductId); if (product == null) continue; - var unitPriceExclTax = Math.Round(item.UnitPriceInclTax / 1.27m, 4); - var priceInclTax = productDto.IsMeasurable ? 0m : item.UnitPriceInclTax * item.FulfilledQuantity; + // Recalculate price at conversion time — may have changed since preorder was placed + decimal unitPriceInclTax; + if (customer != null && _customPriceCalculationService != null) + { + var calc = await _customPriceCalculationService.GetFinalPriceAsync( + product, customer, store, includeDiscounts: true); + unitPriceInclTax = calc.finalPrice; + } + else + { + unitPriceInclTax = item.UnitPriceInclTax; + } + var unitPriceExclTax = Math.Round(unitPriceInclTax / 1.27m, 4); + var priceInclTax = productDto.IsMeasurable ? 0m : unitPriceInclTax * item.FulfilledQuantity; var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * item.FulfilledQuantity; var orderItem = new OrderItem @@ -451,7 +466,7 @@ public partial class PreorderConversionService OrderId = order.Id, ProductId = item.ProductId, Quantity = item.FulfilledQuantity, - UnitPriceInclTax = item.UnitPriceInclTax, + UnitPriceInclTax = unitPriceInclTax, UnitPriceExclTax = unitPriceExclTax, PriceInclTax = priceInclTax, PriceExclTax = priceExclTax, diff --git a/Nop.Plugin.Misc.AIPlugin/Views/CustomerPreorder/List.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/CustomerPreorder/List.cshtml index 12809ba..b3e6c58 100644 --- a/Nop.Plugin.Misc.AIPlugin/Views/CustomerPreorder/List.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Views/CustomerPreorder/List.cshtml @@ -24,7 +24,39 @@ } else { - foreach (var preorder in Model) + +
+
+ + + + +
+ + +
+ + +
+ @foreach (var preorder in Model) { var statusClass = preorder.Status switch { @@ -40,8 +72,13 @@ PreorderStatus.Cancelled => "Törölve / Lejárt", _ => "Függőben" }; + // Build searchable text for client-side filtering + var searchText = string.Join(" ", + preorder.Items.Select(i => i.ProductName) + .Append(preorder.CustomerNote ?? "") + ).ToLowerInvariant(); -
+
#@preorder.PreorderId előrendelés @@ -126,13 +163,47 @@
} +
}
+@Html.AntiForgeryToken() + + + From 40c9018513264be4b5c70a761c92f138ec273205 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 5 Jun 2026 00:07:10 +0200 Subject: [PATCH 2/6] wtf --- Nop.Plugin.Misc.AIPlugin/docs/DOMAIN_MODEL.md | Bin 5155 -> 5271 bytes Nop.Plugin.Misc.AIPlugin/docs/MEASUREMENT.md | Bin 7171 -> 7333 bytes .../docs/SIGNALR/README.md | Bin 7435 -> 7605 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/Nop.Plugin.Misc.AIPlugin/docs/DOMAIN_MODEL.md b/Nop.Plugin.Misc.AIPlugin/docs/DOMAIN_MODEL.md index 46ef545062d09a9e1cc186f40c8a2d59a2c4974a..e7ac61c4703c2eda29a501883934f1607bdaa92e 100644 GIT binary patch literal 5271 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 K8Q;rf)cX&&zPOJ7 diff --git a/Nop.Plugin.Misc.AIPlugin/docs/MEASUREMENT.md b/Nop.Plugin.Misc.AIPlugin/docs/MEASUREMENT.md index 3640028d81e7856efcc436b6a5e9a8582e5ee67a..92a70dbac9bc699c160d3b796481c7ae542ea5bd 100644 GIT binary patch delta 3754 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 F{{hgJ_mltt diff --git a/Nop.Plugin.Misc.AIPlugin/docs/SIGNALR/README.md b/Nop.Plugin.Misc.AIPlugin/docs/SIGNALR/README.md index 2cb5dde1c4ed8a58c8a9b678d992b6ff0c833460..30647815aba4ba0c02098fce4f175e0bb2fff004 100644 GIT binary patch literal 7605 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 V{T8(WVu5XDoCJ}YVvcns{}1Jhs;B?} literal 7435 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 OA52^oGQ%8xNcbP6XlZN! From 1b60684f85abab1fcd94d0ff973b9f0217a8b4d5 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 6 Jun 2026 17:18:04 +0200 Subject: [PATCH 3/6] jjghjgh --- .../PreorderAdminController.Edit.cs | 38 +++++++++---------- .../Nop.Plugin.Misc.FruitBankPlugin.csproj | 3 ++ 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.Edit.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.Edit.cs index a13ac1c..07fbb88 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.Edit.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.Edit.cs @@ -6,37 +6,37 @@ using Nop.Web.Framework.Mvc.Filters; namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; -public partial class PreorderAdminController +public partial class PreOrderAdminController { - [HttpPost, Route("Admin/Preorders/UpdateHeader/{id:int}")] + [HttpPost, Route("Admin/PreOrders/UpdateHeader/{id:int}")] public async Task UpdateHeader(int id, string deliveryDateTime, string? customerNote) { if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid(); - var p = await _preorderDbContext.Preorders.GetByIdAsync(id); - if (p == null || p.Status == PreorderStatus.Cancelled) return Json(new { success = false }); + var p = await _preorderDbContext.PreOrders.GetByIdAsync(id); + if (p == null || p.Status == PreOrderStatus.Cancelled) return Json(new { success = false }); if (!DateTime.TryParse(deliveryDateTime, out var d)) return Json(new { success = false, error = "Érvénytelen dátum" }); p.DateOfReceipt = d; p.CustomerNote = customerNote?.Trim(); p.UpdatedOnUtc = DateTime.UtcNow; - await _preorderDbContext.Preorders.UpdateAsync(p); + await _preorderDbContext.PreOrders.UpdateAsync(p); return Json(new { success = true }); } - [HttpPost, Route("Admin/Preorders/UpdateItem/{id:int}")] + [HttpPost, Route("Admin/PreOrders/UpdateItem/{id:int}")] public async Task UpdateItem(int id, int quantity, decimal unitPrice) { if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid(); - var item = await _preorderDbContext.PreorderItems.GetByIdAsync(id); + var item = await _preorderDbContext.PreOrderItems.GetByIdAsync(id); if (item == null || item.FulfilledQuantity > 0) return Json(new { success = false, error = "Módosítás nem lehetséges" }); item.RequestedQuantity = Math.Max(1, quantity); item.UnitPriceInclTax = Math.Max(0, unitPrice); - await _preorderDbContext.PreorderItems.UpdateAsync(item); + await _preorderDbContext.PreOrderItems.UpdateAsync(item); return Json(new { success = true }); } - [HttpPost, Route("Admin/Preorders/ReplaceItemProduct/{id:int}")] + [HttpPost, Route("Admin/PreOrders/ReplaceItemProduct/{id:int}")] public async Task ReplaceItemProduct(int id, int newProductId) { if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid(); - var item = await _preorderDbContext.PreorderItems.GetByIdAsync(id); + var item = await _preorderDbContext.PreOrderItems.GetByIdAsync(id); if (item == null || item.FulfilledQuantity > 0) return Json(new { success = false, error = "Csere nem lehetséges" }); var product = await _dbContext.Products.GetByIdAsync(newProductId); if (product == null || product.Deleted) return Json(new { success = false, error = "Termék nem található" }); @@ -44,7 +44,7 @@ public partial class PreorderAdminController item.ProductId = newProductId; // Recalculate price for the preorder's customer using CustomPriceCalculationService - var preorder = await _preorderDbContext.Preorders.GetByIdAsync(item.PreorderId); + var preorder = await _preorderDbContext.PreOrders.GetByIdAsync(item.PreOrderId); var customer = preorder != null ? await _customerService.GetCustomerByIdAsync(preorder.CustomerId) : null; if (customer != null) { @@ -53,23 +53,23 @@ public partial class PreorderAdminController item.UnitPriceInclTax = calc.finalPrice; } - await _preorderDbContext.PreorderItems.UpdateAsync(item); + await _preorderDbContext.PreOrderItems.UpdateAsync(item); return Json(new { success = true, productName = product.Name, newPrice = item.UnitPriceInclTax }); } - [HttpPost, Route("Admin/Preorders/ConvertNow/{id:int}")] + [HttpPost, Route("Admin/PreOrders/ConvertNow/{id:int}")] public async Task ConvertNow(int id) { if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid(); - var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id); - if (preorder == null || preorder.Status == PreorderStatus.Cancelled) + var preorder = await _preorderDbContext.PreOrders.GetByIdAsync(id); + if (preorder == null || preorder.Status == PreOrderStatus.Cancelled) return Json(new { success = false, error = "Konvertálás nem lehetséges" }); - var pending = await _preorderDbContext.PreorderItems.GetAll() - .Where(i => i.PreorderId == id && i.FulfilledQuantity < i.RequestedQuantity).ToListAsync(); + var pending = await _preorderDbContext.PreOrderItems.GetAll() + .Where(i => i.PreOrderId == id && i.FulfilledQuantity < i.RequestedQuantity).ToListAsync(); if (!pending.Any()) return Json(new { success = false, error = "Nincs teljesítetlen tétel" }); - await _preorderConversionService.ConvertPreordersForProductsAsync( + await _preorderConversionService.ConvertPreOrdersForProductsAsync( pending.Select(i => i.ProductId).Distinct().ToList(), 0); - var refreshed = await _preorderDbContext.Preorders.GetByIdAsync(id); + var refreshed = await _preorderDbContext.PreOrders.GetByIdAsync(id); return Json(new { success = true, orderId = refreshed?.OrderId }); } } diff --git a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj index 60a98f2..94c4e7b 100644 --- a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj +++ b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj @@ -151,12 +151,15 @@ ..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.Http.Connections.Client.dll + True ..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.SignalR.Client.dll + True ..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.SignalR.Client.Core.dll + True ..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson.dll From 9209b26386c85e4a29a696155c6be78b24e12469 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 23 Jun 2026 23:38:51 +0200 Subject: [PATCH 4/6] OrderDraft, bugfixes, location, license plate in progress --- .../.github/TOPIC_CODES.md | 1 + .../Controllers/CustomOrderController.cs | 13 +- .../CustomerAttributesController.cs | 89 ++++ .../Controllers/OrderDraftAdminController.cs | 296 +++++++++++++ .../Controllers/PreorderAdminController.cs | 43 +- .../Areas/Admin/Models/OrderDraftModels.cs | 59 +++ .../Areas/Admin/Models/PreorderAdminModels.cs | 3 +- .../Admin/Views/OrderDraft/Detail.cshtml | 377 +++++++++++++++++ .../Areas/Admin/Views/OrderDraft/List.cshtml | 138 ++++++ .../Areas/Admin/Views/Preorder/Detail.cshtml | 398 ++++++++++++++---- .../CustomerCreditWidgetViewComponent.cs | 72 +++- .../Controllers/FruitBankDataController.cs | 23 +- .../Controllers/OrderDraftApiController.cs | 112 +++++ .../ExternalUserPartnerMapDbTable.cs | 24 ++ .../Domains/DataLayer/FruitBankDbContext.cs | 12 + .../Domains/DataLayer/OrderDraftDbTable.cs | 36 ++ .../DataLayer/OrderDraftItemDbTable.cs | 21 + .../DataLayer/ProductTextMappingDbTable.cs | 27 ++ Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs | 7 + Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs | 26 ++ Nop.Plugin.Misc.AIPlugin/FruitBankSettings.cs | 8 + .../Infrastructure/PluginNopStartup.cs | 10 +- .../Infrastructure/RouteProvider.cs | 42 ++ .../Localization/customercredit.en.xml | 37 ++ .../Localization/customercredit.hu.xml | 37 ++ .../Models/CustomerCreditWidgetModel.cs | 22 + .../Models/CustomerSiteEntry.cs | 20 + .../Services/IOrderDraftService.cs | 56 +++ .../Services/OrderDraftParserService.cs | 230 ++++++++++ .../Services/OrderDraftService.cs | 392 +++++++++++++++++ .../PreorderConversionService.Replace.cs | 13 +- .../Services/PreorderConversionService.cs | 57 ++- .../Views/CustomerCreditWidget.cshtml | 351 ++++++++++++--- .../docs/ORDERDRAFT/ORDERDRAFT_ISSUES.md | 46 ++ .../docs/ORDERDRAFT/README.md | 69 +++ .../docs/PREORDER/PREORDER_ISSUES.md | 19 +- Nop.Plugin.Misc.AIPlugin/docs/README.md | 1 + 37 files changed, 3019 insertions(+), 168 deletions(-) create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerAttributesController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/OrderDraftAdminController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/OrderDraftModels.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/Detail.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/List.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Controllers/OrderDraftApiController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ExternalUserPartnerMapDbTable.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftDbTable.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftItemDbTable.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ProductTextMappingDbTable.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Models/CustomerSiteEntry.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/IOrderDraftService.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/OrderDraftParserService.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/OrderDraftService.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/ORDERDRAFT_ISSUES.md create mode 100644 Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/README.md diff --git a/Nop.Plugin.Misc.AIPlugin/.github/TOPIC_CODES.md b/Nop.Plugin.Misc.AIPlugin/.github/TOPIC_CODES.md index 615b498..46932ff 100644 --- a/Nop.Plugin.Misc.AIPlugin/.github/TOPIC_CODES.md +++ b/Nop.Plugin.Misc.AIPlugin/.github/TOPIC_CODES.md @@ -12,3 +12,4 @@ Per-repo topic registry for this plugin, per the **per-repo extension convention |---------|-----------|------------------------------------------------------------------------------------------------|------------------| | `PREO` | PRE-ORDER | Customer advance orders placed before stock arrives, and their conversion into real nopCommerce orders when an inbound shipping document confirms incoming stock. | `docs/PREORDER/` | | `EKAER` | EKÁER | Server-side / nopCommerce-specific aspects of NAV EKÁER reporting: VTSZ source from `Product`, `Shipping`→`tradeCard` data needs, serving the NAV call. The protocol/base layer lives in AyCode.Core (`AyCode.Services/Nav/`). | `docs/EKAER/` | +| `ORDD` | ORDER-DRAFT | AI-assisted intake channel: inbound free-text messages (Viber/email/dictation) parsed into order drafts, admin review/approval, then PreOrder creation that feeds the existing PRE-ORDER conversion. Covers parsing, product/partner resolution, learning mappings, the draft lifecycle and the approval entry point. | `docs/ORDERDRAFT/` | diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs index cc3a337..4afc1a6 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs @@ -1123,12 +1123,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers 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 + label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + (dto?.IncomingQuantity ?? 0))} (R:{product.StockQuantity}/K:{dto?.IncomingQuantity ?? 0})] [ÁR: {product.Price}]", + value = product.Id, + sku = product.Sku, + price = product.Price, + stockQuantity = product.StockQuantity, + incomingQuantity = dto?.IncomingQuantity ?? 0, + availableQuantity = product.StockQuantity + (dto?.IncomingQuantity ?? 0) }); } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerAttributesController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerAttributesController.cs new file mode 100644 index 0000000..95d06bd --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerAttributesController.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Nop.Core.Domain.Customers; +using Nop.Plugin.Misc.FruitBankPlugin.Models; +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; + +/// +/// Inline-save endpoints for the FruitBank "Customer attributes" block on the +/// admin customer page (rendered by CustomerCreditWidgetViewComponent). +/// Values are stored as store-agnostic (StoreId 0) customer generic attributes +/// via . +/// +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +public class CustomerAttributesController : BasePluginController +{ + private readonly FruitBankAttributeService _attributeService; + private readonly ICustomerService _customerService; + private readonly IPermissionService _permissionService; + + public CustomerAttributesController( + FruitBankAttributeService attributeService, + ICustomerService customerService, + IPermissionService permissionService) + { + _attributeService = attributeService; + _customerService = customerService; + _permissionService = permissionService; + } + + [HttpPost] + [Route("Admin/CustomerAttributes/SetEkaer")] + public async Task SetEkaer(int customerId, bool value) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + var customer = await _customerService.GetCustomerByIdAsync(customerId); + if (customer == null) + return Json(new { success = false, error = "Customer not found" }); + + await _attributeService.InsertOrUpdateGenericAttributeAsync( + customerId, FruitBankPluginConst.IsEkaerAttribute, value, storeId: 0); + + return Json(new { success = true, value }); + } + + [HttpPost] + [Route("Admin/CustomerAttributes/SaveSites")] + public async Task SaveSites(int customerId, int[] siteAddressIds, int defaultAddressId) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + var customer = await _customerService.GetCustomerByIdAsync(customerId); + if (customer == null) + return Json(new { success = false, error = "Customer not found" }); + + // Only keep ids that actually belong to this customer (guards against tampering / stale ids) + var validIds = (await _customerService.GetAddressesByCustomerIdAsync(customerId)) + .Select(a => a.Id) + .ToHashSet(); + + var selected = (siteAddressIds ?? Array.Empty()) + .Where(validIds.Contains) + .Distinct() + .ToList(); + + // Default must be one of the selected sites; otherwise no default + var effectiveDefault = selected.Contains(defaultAddressId) ? defaultAddressId : 0; + + var entries = selected + .Select(id => new CustomerSiteEntry { AddressId = id, IsDefault = id == effectiveDefault }) + .ToList(); + + var json = JsonSerializer.Serialize(entries); + await _attributeService.InsertOrUpdateGenericAttributeAsync( + customerId, FruitBankPluginConst.CustomerSitesAttribute, json, storeId: 0); + + return Json(new { success = true, siteCount = entries.Count, defaultAddressId = effectiveDefault }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/OrderDraftAdminController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/OrderDraftAdminController.cs new file mode 100644 index 0000000..68a8d3d --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/OrderDraftAdminController.cs @@ -0,0 +1,296 @@ +using FruitBank.Common.Entities; +using FruitBank.Common.Enums; +using LinqToDB; +using Microsoft.AspNetCore.Mvc; +using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Catalog; +using Nop.Services.Customers; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; +using System.Text.Json; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; + +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +public class OrderDraftAdminController : BasePluginController +{ + private readonly IOrderDraftService _draftService; + private readonly FruitBankDbContext _dbContext; + private readonly ICustomerService _customerService; + private readonly IProductService _productService; + private readonly IPermissionService _permissionService; + + public OrderDraftAdminController( + IOrderDraftService draftService, + FruitBankDbContext dbContext, + ICustomerService customerService, + IProductService productService, + IPermissionService permissionService) + { + _draftService = draftService; + _dbContext = dbContext; + _customerService = customerService; + _productService = productService; + _permissionService = permissionService; + } + + // ── LISTA ───────────────────────────────────────────────────────────────── + + [HttpGet] + public IActionResult List() + { + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/OrderDraft/List.cshtml", + new OrderDraftListModel()); + } + + [HttpPost] + public async Task OrderDraftList() + { + 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, 200); + + var statusFilter = Request.Form["statusFilter"].FirstOrDefault() ?? "Pending"; + var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? ""; + + // 1. Draftek lekérése + var query = _dbContext.OrderDrafts.GetAll(false); + + if (Enum.TryParse(statusFilter, out var statusEnum)) + query = query.Where(d => d.Status == statusEnum); + + var allDrafts = await query.OrderByDescending(d => d.CreatedOnUtc).ToListAsync(); + + // 2. Kapcsolódó customer nevek — batch + var customerIds = allDrafts.Where(d => d.CustomerId.HasValue).Select(d => d.CustomerId!.Value).Distinct().ToList(); + var customers = new Dictionary(); + if (customerIds.Any()) + { + var customerList = await _dbContext.Customers.Table + .Where(c => customerIds.Contains(c.Id)) + .Select(c => new { c.Id, c.FirstName, c.LastName, c.Email }) + .ToListAsync(); + customers = customerList.ToDictionary( + c => c.Id, + c => string.IsNullOrWhiteSpace($"{c.FirstName} {c.LastName}".Trim()) + ? c.Email ?? c.Id.ToString() + : $"{c.FirstName} {c.LastName}".Trim()); + } + + // 3. Item count — batch + var draftIds = allDrafts.Select(d => d.Id).ToList(); + var itemCounts = await _dbContext.OrderDraftItems.GetAll() + .Where(i => draftIds.Contains(i.DraftId)) + .GroupBy(i => i.DraftId) + .Select(g => new { DraftId = g.Key, Count = g.Count() }) + .ToListAsync(); + var itemCountDict = itemCounts.ToDictionary(x => x.DraftId, x => x.Count); + + var now = DateTime.UtcNow; + + var rows = allDrafts.Select(d => new OrderDraftListRow + { + Id = d.Id, + ExternalUsername = d.ExternalUsername, + CustomerId = d.CustomerId, + CustomerName = d.CustomerId.HasValue && customers.TryGetValue(d.CustomerId.Value, out var cn) ? cn : "— ismeretlen —", + RawMessageTextShort = d.RawMessageText.Length > 80 ? d.RawMessageText[..80] + "…" : d.RawMessageText, + ParsedDeliveryDate = d.ParsedDeliveryDate.ToString("yyyy-MM-dd"), + ItemCount = itemCountDict.TryGetValue(d.Id, out var cnt) ? cnt : 0, + Status = d.Status.ToString(), + StatusBadgeClass = d.Status switch + { + OrderDraftStatus.Pending => "badge-warning", + OrderDraftStatus.Approved => "badge-success", + OrderDraftStatus.Rejected => "badge-danger", + OrderDraftStatus.Expired => "badge-secondary", + _ => "badge-light" + }, + IsUrgent = d.Status == OrderDraftStatus.Pending && (now - d.CreatedOnUtc).TotalHours > 20, + CreatedOnUtc = d.CreatedOnUtc.ToString("yyyy-MM-dd HH:mm") + }).ToList(); + + int recordsTotal = rows.Count; + + if (!string.IsNullOrWhiteSpace(globalSearch)) + rows = rows.Where(r => + r.ExternalUsername.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + r.RawMessageTextShort.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) + ).ToList(); + + int recordsFiltered = rows.Count; + var page = rows.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } + + // ── RÉSZLETEK / JÓVÁHAGYÓ OLDAL ────────────────────────────────────────── + + [HttpGet] + public async Task Detail(int id) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + var draft = await _draftService.GetByIdAsync(id, true); + if (draft == null) return NotFound(); + + // Customer név + string customerName = "— ismeretlen partner —"; + if (draft.CustomerId.HasValue) + { + var customer = await _customerService.GetCustomerByIdAsync(draft.CustomerId.Value); + if (customer != null) + customerName = string.IsNullOrWhiteSpace($"{customer.FirstName} {customer.LastName}".Trim()) + ? customer.Email ?? customer.Id.ToString() + : $"{customer.FirstName} {customer.LastName}".Trim(); + } + + // Termék nevek és kandidátusok batch lekérése + var allCandidateIds = draft.Items + .SelectMany(i => + { + try { return JsonSerializer.Deserialize>(i.SuggestedProductIdsJson) ?? []; } + catch { return []; } + }) + .Distinct() + .ToList(); + + var productMap = new Dictionary(); + if (allCandidateIds.Any()) + { + var products = await _dbContext.ProductDtos + .GetAllByIds(allCandidateIds, false) + .ToListAsync(); + productMap = products.ToDictionary( + p => p.Id, + p => (p.Name, p.StockQuantity)); + } + + var model = new OrderDraftDetailModel + { + Id = draft.Id, + ExternalUsername = draft.ExternalUsername, + CustomerId = draft.CustomerId, + CustomerName = customerName, + RawMessageText = draft.RawMessageText, + ParsedDeliveryDate = draft.ParsedDeliveryDate, + IsPreorder = draft.ParsedDeliveryDate.Date > DateTime.Today.AddDays(4), + Status = draft.Status.ToString(), + AdminNote = draft.AdminNote ?? string.Empty, + CreatedOnUtc = draft.CreatedOnUtc.ToString("yyyy-MM-dd HH:mm"), + CanApprove = draft.Status == OrderDraftStatus.Pending, + Items = draft.Items.OrderBy(i => i.SortOrder).Select(i => + { + var candidateIds = new List(); + try { candidateIds = JsonSerializer.Deserialize>(i.SuggestedProductIdsJson) ?? []; } + catch { /* */ } + + var selectedName = i.SelectedProductId.HasValue && productMap.TryGetValue(i.SelectedProductId.Value, out var sel) + ? sel.Name : string.Empty; + + return new OrderDraftItemModel + { + Id = i.Id, + RawProductText = i.RawProductText, + AiResolvedProductName = i.AiResolvedProductName, + RequestedQuantity = i.RequestedQuantity, + IsPreorder = i.IsPreorder, + SortOrder = i.SortOrder, + SelectedProductId = i.SelectedProductId, + SelectedProductName = selectedName, + Candidates = candidateIds + .Where(pid => productMap.ContainsKey(pid)) + .Select(pid => new OrderDraftProductCandidate + { + Id = pid, + Name = productMap[pid].Name, + Stock = productMap[pid].Stock.ToString() + }).ToList() + }; + }).ToList() + }; + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/OrderDraft/Detail.cshtml", model); + } + + // ── AJAX: TÉTEL FRISSÍTÉSE ──────────────────────────────────────────────── + + [HttpPost] + public async Task UpdateItem(int draftItemId, int selectedProductId, int quantity) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false }); + + await _draftService.UpdateDraftItemAsync(draftItemId, selectedProductId, quantity); + return Json(new { success = true }); + } + + // ── AJAX: PARTNER HOZZÁRENDELÉSE ────────────────────────────────────────── + + [HttpPost] + public async Task AssignCustomer(int draftId, int customerId) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false }); + + var workContext = HttpContext.RequestServices.GetService(typeof(Nop.Core.IWorkContext)) as Nop.Core.IWorkContext; + var admin = workContext != null ? await workContext.GetCurrentCustomerAsync() : null; + + await _draftService.AssignCustomerAsync(draftId, customerId, admin?.Id ?? 0); + + var customer = await _customerService.GetCustomerByIdAsync(customerId); + var name = customer == null ? customerId.ToString() + : string.IsNullOrWhiteSpace($"{customer.FirstName} {customer.LastName}".Trim()) + ? customer.Email ?? customerId.ToString() + : $"{customer.FirstName} {customer.LastName}".Trim(); + + return Json(new { success = true, customerName = name }); + } + + // ── AJAX: JÓVÁHAGYÁS ───────────────────────────────────────────────────── + + [HttpPost] + public async Task Approve(int draftId) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Hozzáférés megtagadva." }); + + var workContext = HttpContext.RequestServices.GetService(typeof(Nop.Core.IWorkContext)) as Nop.Core.IWorkContext; + var admin = workContext != null ? await workContext.GetCurrentCustomerAsync() : null; + + var result = await _draftService.ApproveAsync(draftId, admin?.Id ?? 0); + + return Json(new + { + success = result.Success, + error = result.ErrorMessage, + preOrderIds = result.PreOrderIds, + orderIds = result.OrderIds + }); + } + + // ── AJAX: ELUTASÍTÁS ────────────────────────────────────────────────────── + + [HttpPost] + public async Task Reject(int draftId, string? adminNote) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false }); + + var workContext = HttpContext.RequestServices.GetService(typeof(Nop.Core.IWorkContext)) as Nop.Core.IWorkContext; + var admin = workContext != null ? await workContext.GetCurrentCustomerAsync() : null; + + await _draftService.RejectAsync(draftId, admin?.Id ?? 0, adminNote); + return Json(new { success = true }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs index a1b2554..e67d334 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs @@ -12,6 +12,7 @@ using Nop.Services.Security; using Nop.Web.Framework; using Nop.Web.Framework.Controllers; using Nop.Web.Framework.Mvc.Filters; +using Nop.Services.Helpers; namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; @@ -27,6 +28,7 @@ public partial class PreOrderAdminController : BasePluginController private readonly PreOrderConversionService _preorderConversionService; private readonly IPriceCalculationService _priceCalculationService; private readonly IStoreContext _storeContext; + private readonly IDateTimeHelper _dateTimeHelper; private static readonly Dictionary StatusLabels = new() { @@ -51,7 +53,8 @@ public partial class PreOrderAdminController : BasePluginController ICustomerService customerService, PreOrderConversionService preorderConversionService, IPriceCalculationService priceCalculationService, - IStoreContext storeContext) + IStoreContext storeContext, + IDateTimeHelper dateTimeHelper) { _permissionService = permissionService; _preorderDbContext = preorderDbContext; @@ -60,6 +63,7 @@ public partial class PreOrderAdminController : BasePluginController _preorderConversionService = preorderConversionService; _priceCalculationService = priceCalculationService; _storeContext = storeContext; + _dateTimeHelper = dateTimeHelper; } // ── LIST PAGE ───────────────────────────────────────────────────────────── @@ -106,7 +110,7 @@ public partial class PreOrderAdminController : BasePluginController 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 }) + .Select(c => new { c.Id, c.Email, c.FirstName, c.LastName, c.Company }) .ToListAsync(); var customerById = customers.ToDictionary(c => c.Id); @@ -116,7 +120,7 @@ public partial class PreOrderAdminController : BasePluginController // 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 => + var rowTasks = preorders.Select(async p => { customerById.TryGetValue(p.CustomerId, out var c); var items = itemsByPreOrder.TryGetValue(p.Id, out var its) ? its : new(); @@ -140,17 +144,18 @@ public partial class PreOrderAdminController : BasePluginController { PreOrderId = p.Id, CustomerId = p.CustomerId, - CustomerName = c != null ? $"{c.FirstName} {c.LastName}".Trim() : $"#{p.CustomerId}", + CustomerName = c != null ? (!string.IsNullOrWhiteSpace(c.Company) ? c.Company : $"{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"), + DateOfReceipt = (await _dateTimeHelper.ConvertToUserTimeAsync(p.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"), + CreatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(p.CreatedOnUtc, DateTimeKind.Utc)).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(); + }); + var rows = (await Task.WhenAll(rowTasks)).ToList(); int recordsTotal = rows.Count; @@ -217,11 +222,14 @@ public partial class PreOrderAdminController : BasePluginController { PreOrderId = preorder.Id, CustomerId = preorder.CustomerId, - CustomerName = customer != null ? $"{customer.FirstName} {customer.LastName}".Trim() : $"#{preorder.CustomerId}", + CustomerName = customer != null + ? (!string.IsNullOrWhiteSpace(customer.Company) ? customer.Company : $"{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"), + DateOfReceipt = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"), + DateOfReceiptRaw = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy-MM-ddTHH:mm"), + CreatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.CreatedOnUtc, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"), + UpdatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.UpdatedOnUtc, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"), Status = preorder.Status, CustomerNote = preorder.CustomerNote, OrderId = linkedOrderId, @@ -369,13 +377,14 @@ public partial class PreOrderAdminController : BasePluginController if (preorder.Status == PreOrderStatus.Cancelled) return Json(new { success = false, error = "Már visszavont előrendelés" }); - preorder.Status = PreOrderStatus.Cancelled; - preorder.UpdatedOnUtc = DateTime.UtcNow; - await _preorderDbContext.PreOrders.UpdateAsync(preorder); - if (preorder.Status != PreOrderStatus.Pending) - return Json(new { success = false, error = "Only pending preorders can be cancelled" }); + // Only not-yet-fulfilled preorders can be cancelled — once a real order + // exists (OrderId set at first fulfilment), cancelling the preorder must + // not silently undo it. + if (preorder.Status != PreOrderStatus.Pending || preorder.OrderId.HasValue) + return Json(new { success = false, error = "Csak függőben lévő előrendelés vonható vissza" }); - //await _preorderDbContext.CancelPreOrderAsync(id); + // Sets header → Cancelled and drops all still-active items → Dropped + await _preorderDbContext.CancelPreOrderAsync(id); return Json(new { success = true }); } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/OrderDraftModels.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/OrderDraftModels.cs new file mode 100644 index 0000000..af12573 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/OrderDraftModels.cs @@ -0,0 +1,59 @@ +using FruitBank.Common.Enums; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class OrderDraftListModel +{ + public string StatusFilter { get; set; } = "Pending"; +} + +public class OrderDraftListRow +{ + public int Id { get; set; } + public string ExternalUsername { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public int? CustomerId { get; set; } + public string RawMessageTextShort { get; set; } = string.Empty; + public string ParsedDeliveryDate { get; set; } = string.Empty; + public int ItemCount { get; set; } + public string Status { get; set; } = string.Empty; + public string StatusBadgeClass { get; set; } = string.Empty; + public bool IsUrgent { get; set; } // Pending és > 20 óra régi + public string CreatedOnUtc { get; set; } = string.Empty; +} + +public class OrderDraftDetailModel +{ + public int Id { get; set; } + public string ExternalUsername { get; set; } = string.Empty; + public int? CustomerId { get; set; } + public string CustomerName { get; set; } = string.Empty; + public string RawMessageText { get; set; } = string.Empty; + public DateTime ParsedDeliveryDate { get; set; } + public bool IsPreorder { get; set; } + public string Status { get; set; } = string.Empty; + public string AdminNote { get; set; } = string.Empty; + public string CreatedOnUtc { get; set; } = string.Empty; + public bool CanApprove { get; set; } + public List Items { get; set; } = []; +} + +public class OrderDraftItemModel +{ + public int Id { get; set; } + public string RawProductText { get; set; } = string.Empty; + public string AiResolvedProductName{ get; set; } = string.Empty; + public int RequestedQuantity { get; set; } + public bool IsPreorder { get; set; } + public int SortOrder { get; set; } + public int? SelectedProductId { get; set; } + public string SelectedProductName { get; set; } = string.Empty; + public List Candidates { get; set; } = []; +} + +public class OrderDraftProductCandidate +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Stock { get; set; } = string.Empty; +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs index 2f5f0a8..adaea63 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs @@ -23,7 +23,8 @@ public class PreOrderDetailModel 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 DateOfReceipt { get; set; } = string.Empty; // formatted for display + public string DateOfReceiptRaw { get; set; } = string.Empty; // yyyy-MM-ddTHH:mm for datetime-local input public string CreatedOnUtc { get; set; } = string.Empty; public string UpdatedOnUtc { get; set; } = string.Empty; public PreOrderStatus Status { get; set; } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/Detail.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/Detail.cshtml new file mode 100644 index 0000000..63137d3 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/Detail.cshtml @@ -0,0 +1,377 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.OrderDraftDetailModel +@{ + ViewBag.PageTitle = "Rendelés tervezet #" + Model.Id; +} + +
+

+ + Rendelés tervezet #@Model.Id + @Model.Status +

+ +
+ +
+
+ @Html.AntiForgeryToken() + +
+ + {{-- BAL OLDAL: meta + üzenet --}} +
+ + {{-- Eredeti üzenet --}} +
+
+ Eredeti üzenet +
+
+
@Model.RawMessageText
+
+
+ + {{-- Meta adatok --}} +
+
Adatok
+
+ + + + + + + + + + + + + + + + + + @if (!string.IsNullOrEmpty(Model.AdminNote)) + { + + + + + } +
Feladó@Model.ExternalUsername
Partner + @if (Model.CustomerId.HasValue) + { + @Model.CustomerName + } + else + { + Nincs hozzárendelve + } +
Szállítás + @Model.ParsedDeliveryDate.ToString("yyyy-MM-dd") + @if (Model.IsPreorder) + { + Előrendelés + } + else + { + Azonnali + } +
Beérkezett@Model.CreatedOnUtc
Megjegyzés@Model.AdminNote
+
+
+ + {{-- Partner hozzárendelés (csak ha nincs még) --}} + @if (!Model.CustomerId.HasValue && Model.CanApprove) + { +
+
Partner hozzárendelése
+
+

+ Ismeretlen feladó: @Model.ExternalUsername
+ Rendeld hozzá a megfelelő partnerhez. A rendszer megjegyzi a kapcsolatot. +

+ + + + +
+
+ } + +
{{-- /col-md-4 --}} + + {{-- JOBB OLDAL: tételek --}} +
+
+
+ Tételek + @Model.Items.Count db +
+
+ + + + + + + + + + + @foreach (var item in Model.Items) + { + + + + + + + } + +
Eredeti szövegAI javaslat / Termék kiválasztásaMenny. (kt.)Típus
+ @item.RawProductText + @if (!string.IsNullOrEmpty(item.AiResolvedProductName) && item.AiResolvedProductName != item.RawProductText) + { +
→ @item.AiResolvedProductName + } +
+ + + + + + @if (item.IsPreorder) + { + Elő + } + else + { + Azonnali + } +
+
+
+ + {{-- Akció gombok --}} + @if (Model.CanApprove) + { +
+
+
+
+ +
+
+ +
+
+ +
+
+ } + +
{{-- /col-md-8 --}} + +
{{-- /row --}} +
+
+ + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/List.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/List.cshtml new file mode 100644 index 0000000..dedd8f3 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/List.cshtml @@ -0,0 +1,138 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.OrderDraftListModel +@{ + ViewBag.PageTitle = "Rendelés tervezetek"; + NopHtml.SetActiveMenuItemSystemName("OrderDraft.List"); +} + +
+

+ + Rendelés tervezetek +

+
+ +
+
+ + {{-- Státusz szűrő --}} +
+
+
+ + + + + +
+
+
+ +
+
+ @Html.AntiForgeryToken() + + + + + + + + + + + + + + +
#FeladóPartnerÜzenet (részlet)SzállításTételekStátuszBeérkezett
+
+
+ +
+
+ + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Preorder/Detail.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Preorder/Detail.cshtml index a41426c..b200516 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Preorder/Detail.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Preorder/Detail.cshtml @@ -12,7 +12,6 @@ PreOrderStatus.Cancelled => "po-status-cancelled", _ => "po-status-pending" }; - var statusLabel = Model.Status switch { PreOrderStatus.Confirmed => "Megerősítve", @@ -20,6 +19,7 @@ PreOrderStatus.Cancelled => "Törölve", _ => "Függőben" }; + var canEdit = Model.Status != PreOrderStatus.Cancelled; } @Html.AntiForgeryToken() @@ -35,17 +35,23 @@ .po-meta-item .label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:#6b7c6e; margin-bottom:4px; } .po-meta-item .value { font-size:15px; color:#1a3c22; font-weight:600; } -.item-fulfilled { background:#eaf7ee; } -.item-partial { background:#fffbf0; } -.item-dropped { background:#fdf0f0; color:#888; } -.item-pending { } +.item-fulfilled { background:#eaf7ee; } +.item-partial { background:#fffbf0; } +.item-dropped { background:#fdf0f0; color:#888; } -.qty-bar-wrap { width:100px; display:inline-block; vertical-align:middle; } -.qty-bar { height:6px; background:#dde8da; border-radius:3px; overflow:hidden; display:inline-block; width:100%; } -.qty-bar-fill { height:100%; border-radius:3px; } +.qty-bar-wrap { width:100px; display:inline-block; vertical-align:middle; } +.qty-bar { height:6px; background:#dde8da; border-radius:3px; overflow:hidden; display:inline-block; width:100%; } +.qty-bar-fill { height:100%; border-radius:3px; } + +/* Click-to-edit */ +.editable-cell { cursor:pointer; border-radius:4px; padding:2px 6px; transition:background .15s; } +.editable-cell:hover { background:#f0f8f1; outline:1px dashed #2d7a3a; } +.edit-input { width:90px; } +.btn-swap { padding:2px 7px; font-size:11px; } + +.ui-autocomplete { z-index:1060 !important; max-height:220px; overflow-y:auto; overflow-x:hidden; } - Vissza a listához @@ -56,16 +62,25 @@ Előrendelés #@Model.PreOrderId @statusLabel -
+
@if (Model.OrderId.HasValue) { Rendelés #@Model.OrderId } + @if (canEdit) + { + + + } @if (Model.Status == PreOrderStatus.Pending) { - } @@ -75,39 +90,61 @@
- +
-
-
-
Ügyfél
-
- @Model.CustomerName + +
+
+
+
Ügyfél
+ + @Model.CustomerEmail
- @Model.CustomerEmail -
-
-
Kért szállítási időpont
-
@Model.DateOfReceipt
-
-
-
Leadva
-
@Model.CreatedOnUtc
-
-
-
Utoljára frissítve
-
@Model.UpdatedOnUtc
-
- @if (!string.IsNullOrWhiteSpace(Model.CustomerNote)) - { -
-
Ügyfél megjegyzése
-
@Model.CustomerNote
+
+
Kért szállítási időpont
+
@Model.DateOfReceipt
- } +
+
Leadva
+
@Model.CreatedOnUtc
+
+
+
Utoljára frissítve
+
@Model.UpdatedOnUtc
+
+ @if (!string.IsNullOrWhiteSpace(Model.CustomerNote)) + { +
+
Ügyfél megjegyzése
+
@Model.CustomerNote
+
+ } +
+
+ + +
- +
Tételek (@Model.Items.Count) @@ -118,7 +155,7 @@ var pending = Model.Items.Count(i => i.Status == PreOrderItemStatus.Pending); } - @if (fulfilled > 0) { @fulfilled teljesítve } + @if (fulfilled > 0) { @fulfilled teljesítve } @if (partial > 0) { @partial részben } @if (dropped > 0) { @dropped ejtve } @if (pending > 0) { @pending függőben } @@ -129,9 +166,9 @@ Termék - Kérve - Teljesítve - Teljesítés + Kérve + Teljesítve + Teljesítés Egységár Becsült ár Állapot @@ -140,34 +177,66 @@ @foreach (var item in Model.Items) { - var rowClass = item.Status switch + var rowClass = item.Status switch { PreOrderItemStatus.Fulfilled => "item-fulfilled", PreOrderItemStatus.PartiallyFulfilled => "item-partial", PreOrderItemStatus.Dropped => "item-dropped", - _ => "item-pending" + _ => "" }; - var pct = item.RequestedQuantity > 0 + var pct = item.RequestedQuantity > 0 ? (int)Math.Round((double)item.FulfilledQuantity / item.RequestedQuantity * 100) : 0; - var barColor = pct == 100 ? "#2d7a3a" : pct > 0 ? "#f4a236" : "#dc3545"; + var barColor = pct == 100 ? "#2d7a3a" : pct > 0 ? "#f4a236" : "#dc3545"; var estimatedPrice = item.IsMeasurable ? "—" : (item.UnitPriceInclTax * item.FulfilledQuantity).ToString("N0") + " Ft"; - var unitPrice = item.IsMeasurable ? "súlymérés" : item.UnitPriceInclTax.ToString("N0") + " Ft"; + var unitPriceStr = item.IsMeasurable ? "súlymérés" : item.UnitPriceInclTax.ToString("N0") + " Ft"; + var isLocked = item.FulfilledQuantity > 0; - + + @item.ProductName @if (item.IsMeasurable) { ⚖️ } + @if (canEdit && !isLocked) + { + + } + @if (isLocked) + { + + } - @item.RequestedQuantity db + + - @item.FulfilledQuantity db + @if (canEdit && !isLocked) + { + @item.RequestedQuantity db + + } + else + { + @item.RequestedQuantity db + } + + + @item.FulfilledQuantity db + +
@@ -176,8 +245,25 @@
@pct% - @unitPrice + + + + @if (canEdit && !isLocked && !item.IsMeasurable) + { + @unitPriceStr + + } + else + { + @unitPriceStr + } + + @estimatedPrice + @item.StatusLabel @@ -188,7 +274,8 @@ @{ var totalEstimated = Model.Items - .Where(i => !i.IsMeasurable && (i.Status == PreOrderItemStatus.Fulfilled || i.Status == PreOrderItemStatus.PartiallyFulfilled)) + .Where(i => !i.IsMeasurable && + (i.Status == PreOrderItemStatus.Fulfilled || i.Status == PreOrderItemStatus.PartiallyFulfilled)) .Sum(i => i.UnitPriceInclTax * i.FulfilledQuantity); } @@ -205,25 +292,192 @@
-@if (Model.Status == PreOrderStatus.Pending) -{ - -} + + // ── Click-to-edit: quantity ─────────────────────────────────────────── + $(document).on('click', '.qty-display', function () { + var id = $(this).data('item-id'); + $(this).hide(); + $('[data-item-id="' + id + '"].qty-input').closest('.qty-edit-wrap').show() + .find('.qty-input').focus().select(); + }); + $(document).on('keydown blur', '.qty-input', function (e) { + if (e.type === 'keydown' && e.key === 'Escape') { cancelEdit($(this).data('item-id'), 'qty'); return; } + if (e.type === 'keydown' && e.key !== 'Enter') return; + saveItemField($(this).data('item-id')); + }); + + // ── Click-to-edit: price ────────────────────────────────────────────── + $(document).on('click', '.price-display', function () { + var id = $(this).data('item-id'); + $(this).hide(); + $(this).siblings('.price-edit-wrap').show().find('.price-input').focus().select(); + }); + $(document).on('keydown blur', '.price-input', function (e) { + if (e.type === 'keydown' && e.key === 'Escape') { cancelEdit($(this).data('item-id'), 'price'); return; } + if (e.type === 'keydown' && e.key !== 'Enter') return; + saveItemField($(this).data('item-id')); + }); + + function cancelEdit(id, type) { + if (type === 'qty') { + $('[data-item-id="' + id + '"].qty-display').show(); + $('[data-item-id="' + id + '"].qty-input').closest('.qty-edit-wrap').hide(); + } else { + $('[data-item-id="' + id + '"].price-display').show(); + $('[data-item-id="' + id + '"].price-input').closest('.price-edit-wrap').hide(); + } + } + + function saveItemField(id) { + var qty = parseInt($('[data-item-id="' + id + '"].qty-input').val()) || 1; + var price = parseFloat($('[data-item-id="' + id + '"].price-input').val()) || 0; + $.post('/Admin/PreOrders/UpdateItem/' + id, { + quantity: qty, unitPrice: price, __RequestVerificationToken: _token + }, function (r) { + if (r.success) { + $('[data-item-id="' + id + '"].qty-display').text(qty + ' db').show(); + $('[data-item-id="' + id + '"].qty-input').closest('.qty-edit-wrap').hide(); + $('[data-item-id="' + id + '"].price-display').text(Math.round(price).toLocaleString('hu-HU') + ' Ft').show(); + $('[data-item-id="' + id + '"].price-input').closest('.price-edit-wrap').hide(); + } else { + alert('Hiba: ' + (r.error || 'Ismeretlen hiba')); + } + }); + } + + // ── Product swap ────────────────────────────────────────────────────── + $(document).on('click', '.swap-product-btn', function () { + $('#swapItemId').val($(this).data('item-id')); + $('#swapNewProductId').val(''); + $('#swapProductSearch, #swapSelectedName, #swapNewPrice').val('').text(''); + $('#swapSelectedProduct').hide(); + $('#confirmSwapBtn').prop('disabled', true); + $('#swapProductModal').modal('show'); + }); + + $('#swapProductSearch').autocomplete({ + delay: 400, minLength: 2, + source: '/Admin/CustomOrder/PreorderProductSearchAutoComplete', + select: function (e, ui) { + $('#swapNewProductId').val(ui.item.value); + $('#swapSelectedName').text(ui.item.label); + $('#swapSelectedProduct').show(); + $('#confirmSwapBtn').prop('disabled', false); + $('#swapProductSearch').val(''); + return false; + } + }); + + $('#swapProductModal').on('hidden.bs.modal', function () { + $('#swapNewProductId').val(''); + $('#swapSelectedProduct').hide(); + $('#confirmSwapBtn').prop('disabled', true); + }); + + $('#confirmSwapBtn').click(function () { + var $btn = $(this).prop('disabled', true).html(''); + $.post('/Admin/PreOrders/ReplaceItemProduct/' + $('#swapItemId').val(), { + newProductId: $('#swapNewProductId').val(), + __RequestVerificationToken: _token + }, function (r) { + if (r.success) { + $('#swapProductModal').modal('hide'); + location.reload(); + } else { + alert('Hiba: ' + (r.error || 'Ismeretlen hiba')); + $btn.prop('disabled', false).html(' Csere megerősítése'); + } + }); + }); + + // ── Manual conversion ───────────────────────────────────────────────── + $('#convertNowBtn').click(function () { + if (!confirm('Azonnali konvertálást indítasz — a függőben lévő tételeket megpróbálja rendeléssé alakítani a jelenlegi készlet alapján. Folytatod?')) return; + var $btn = $(this).prop('disabled', true).html(' Konvertálás...'); + $.post('/Admin/PreOrders/ConvertNow/' + preorderId, { + __RequestVerificationToken: _token + }, function (r) { + if (r.success) { + alert(r.orderId + ? 'Sikeresen konvertálva! Rendelés: #' + r.orderId + : 'A konvertálás lefutott, de nincs elegendő készlet.'); + location.reload(); + } else { + alert('Hiba: ' + (r.error || 'Ismeretlen hiba')); + $btn.prop('disabled', false).html(' Konvertálás rendeléssé'); + } + }); + }); + + // ── Cancel preorder ─────────────────────────────────────────────────── + @if (Model.Status == PreOrderStatus.Pending) + { + + $('#cancelBtn').click(function () { + if (!confirm('Biztosan visszavonod ezt az előrendelést? Ez a művelet nem visszafordítható.')) return; + $.post('/Admin/PreOrders/Cancel/@Model.PreOrderId', { + __RequestVerificationToken: _token + }, function (r) { + if (r.success) { location.href = '/Admin/PreOrders'; } + else { alert('Hiba: ' + (r.error || 'Ismeretlen hiba')); } + }); + }); + + } +}); + diff --git a/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs b/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs index 405f26c..3fb892d 100644 --- a/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs +++ b/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs @@ -1,6 +1,10 @@ +using System.Text.Json; using Microsoft.AspNetCore.Mvc; +using Nop.Core.Domain.Common; +using Nop.Core.Domain.Customers; using Nop.Plugin.Misc.FruitBankPlugin.Models; using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Customers; using Nop.Web.Areas.Admin.Models.Customers; using Nop.Web.Framework.Components; @@ -10,10 +14,17 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components; public class CustomerCreditWidgetViewComponent : NopViewComponent { private readonly ICustomerCreditService _customerCreditService; + private readonly FruitBankAttributeService _attributeService; + private readonly ICustomerService _customerService; - public CustomerCreditWidgetViewComponent(ICustomerCreditService customerCreditService) + public CustomerCreditWidgetViewComponent( + ICustomerCreditService customerCreditService, + FruitBankAttributeService attributeService, + ICustomerService customerService) { _customerCreditService = customerCreditService; + _attributeService = attributeService; + _customerService = customerService; } public async Task InvokeAsync(string widgetZone, object additionalData) @@ -24,6 +35,9 @@ public class CustomerCreditWidgetViewComponent : NopViewComponent var credit = await _customerCreditService.GetByCustomerIdAsync(customerId); var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId); + var isEkaer = await _attributeService + .GetGenericAttributeValueAsync(customerId, FruitBankPluginConst.IsEkaerAttribute, storeId: 0); + var model = new CustomerCreditWidgetModel { CustomerId = customerId, @@ -31,9 +45,63 @@ public class CustomerCreditWidgetViewComponent : NopViewComponent CreditLimit = credit?.CreditLimit ?? 0m, OutstandingBalance = outstanding, RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null, - Comment = credit?.Comment + Comment = credit?.Comment, + IsEkaer = isEkaer }; + await PopulateSitesAsync(model, customerId); + return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model); } + + // Merge the customer's current addresses with the saved site list (orphaned + // address ids — deleted addresses — are dropped because we iterate addresses). + private async Task PopulateSitesAsync(CustomerCreditWidgetModel model, int customerId) + { + var addresses = await _customerService.GetAddressesByCustomerIdAsync(customerId); + + var savedJson = await _attributeService + .GetGenericAttributeValueAsync(customerId, FruitBankPluginConst.CustomerSitesAttribute, storeId: 0); + + var saved = ParseSites(savedJson); + var savedById = saved.ToDictionary(s => s.AddressId); + + foreach (var addr in addresses) + { + savedById.TryGetValue(addr.Id, out var entry); + model.SiteAddresses.Add(new CustomerSiteAddressRow + { + AddressId = addr.Id, + Display = FormatAddress(addr), + IsSite = entry != null, + IsDefault = entry?.IsDefault ?? false + }); + } + + var sites = model.SiteAddresses.Where(a => a.IsSite).ToList(); + model.SiteCount = sites.Count; + model.DefaultSiteDisplay = sites.FirstOrDefault(a => a.IsDefault)?.Display; + } + + private static List ParseSites(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(); + try { return JsonSerializer.Deserialize>(json) ?? new(); } + catch { return new(); } + } + + private static string FormatAddress(Address a) + { + var name = !string.IsNullOrWhiteSpace(a.Company) + ? a.Company + : $"{a.FirstName} {a.LastName}".Trim(); + + var locality = $"{a.ZipPostalCode} {a.City}".Trim(); + + var parts = new[] { name, a.Address1, locality } + .Where(p => !string.IsNullOrWhiteSpace(p)); + + var display = string.Join(", ", parts); + return string.IsNullOrWhiteSpace(display) ? $"#{a.Id}" : display; + } } diff --git a/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs index d7dcf78..395fcdf 100644 --- a/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs @@ -342,18 +342,31 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers // Load BEFORE the update to capture previous ProductId and QuantityOnDocument var oldItem = await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, false); - if (!await ctx.UpdateShippingItemSafeAsync(shippingItem)) return null; + // For product swap: move IncomingQuantity BEFORE saving the new ProductId to DB. + // This ensures the EventConsumer (triggered by UpdateShippingItemSafeAsync) already + // sees the correct IncomingQuantity when ConvertPreOrdersForProductsAsync fires. + var productChanged = oldItem != null && oldItem.ProductId != shippingItem.ProductId; + var quantityChanged = oldItem != null && oldItem.QuantityOnDocument != shippingItem.QuantityOnDocument; + + if (productChanged && shippingItem.ProductId.HasValue && + oldItem?.ProductId.HasValue == true && !oldItem.IsMeasured) + { + var qty = oldItem.QuantityOnDocument; + await preorderConversionService.SyncIncomingQuantityAsync(oldItem.ProductId.Value, qty, 0); + await preorderConversionService.SyncIncomingQuantityAsync(shippingItem.ProductId.Value, 0, qty); + } + + if (!await ctx.UpdateShippingItemSafeAsync(shippingItem)) return null; if (oldItem != null) { - var productChanged = oldItem.ProductId != shippingItem.ProductId; - var quantityChanged = oldItem.QuantityOnDocument != shippingItem.QuantityOnDocument; - if (productChanged && shippingItem.ProductId.HasValue) { // Full replacement: swap stock, order items, preorder items + // skipIncomingSync=true because we already moved IncomingQty above await preorderConversionService.ReplaceShippingItemProductAsync( - shippingItem.Id, shippingItem.ProductId.Value, oldItem); + shippingItem.Id, shippingItem.ProductId.Value, oldItem, + skipIncomingSync: !oldItem.IsMeasured); } else if (quantityChanged && shippingItem.ProductId.HasValue) { diff --git a/Nop.Plugin.Misc.AIPlugin/Controllers/OrderDraftApiController.cs b/Nop.Plugin.Misc.AIPlugin/Controllers/OrderDraftApiController.cs new file mode 100644 index 0000000..f917e1f --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Controllers/OrderDraftApiController.cs @@ -0,0 +1,112 @@ +using Microsoft.AspNetCore.Mvc; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Configuration; +using Nop.Web.Framework.Controllers; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers; + +/// +/// Külső rendszerek (Viber bot, email feldolgozó, diktáló app) hívják ezt az endpointot. +/// Authentikáció: X-FruitBank-ApiKey header, értéke megegyezik a FruitBankSettings.OrderDraftApiKey-jel. +/// +public class OrderDraftApiController : BasePluginController +{ + private readonly IOrderDraftService _orderDraftService; + private readonly FruitBankSettings _settings; + + public OrderDraftApiController( + IOrderDraftService orderDraftService, + ISettingService settingService) + { + _orderDraftService = orderDraftService; + _settings = settingService.LoadSetting(); + } + + /// + /// POST /api/order-draft + /// Body: { "externalUsername": "...", "messageText": "..." } + /// Header: X-FruitBank-ApiKey: {key} + /// + [HttpPost] + public async Task Create([FromBody] OrderDraftApiRequest request) + { + // API key validáció + if (!ValidateApiKey()) + return Unauthorized(new { error = "Érvénytelen vagy hiányzó API kulcs." }); + + if (request == null + || string.IsNullOrWhiteSpace(request.ExternalUsername) + || string.IsNullOrWhiteSpace(request.MessageText)) + { + return BadRequest(new { error = "ExternalUsername és MessageText megadása kötelező." }); + } + + try + { + var drafts = await _orderDraftService.CreateDraftsFromMessageAsync( + request.ExternalUsername.Trim(), + request.MessageText.Trim()); + + if (!drafts.Any()) + return Ok(new OrderDraftApiResponse + { + Success = false, + Message = "Az üzenetből nem sikerült rendelési szándékot azonosítani.", + Drafts = [] + }); + + return Ok(new OrderDraftApiResponse + { + Success = true, + Message = $"{drafts.Count} rendelés tervezet létrehozva.", + Drafts = drafts.Select(d => new OrderDraftApiResponseItem + { + DraftId = d.Id, + ParsedDeliveryDate = d.ParsedDeliveryDate, + IsPartnerKnown = d.CustomerId.HasValue, + ItemCount = d.Items?.Count ?? 0 + }).ToList() + }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[OrderDraftApiController] Error: {ex}"); + return StatusCode(500, new { error = "Belső hiba. Próbáld újra később." }); + } + } + + private bool ValidateApiKey() + { + if (string.IsNullOrWhiteSpace(_settings.OrderDraftApiKey)) + return false; + + Request.Headers.TryGetValue("X-FruitBank-ApiKey", out var providedKey); + return string.Equals( + _settings.OrderDraftApiKey, + providedKey.ToString(), + StringComparison.Ordinal); + } +} + +// ── Request / Response modellek ─────────────────────────────────────────── + +public class OrderDraftApiRequest +{ + public string ExternalUsername { get; set; } = string.Empty; + public string MessageText { get; set; } = string.Empty; +} + +public class OrderDraftApiResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public List Drafts { get; set; } = []; +} + +public class OrderDraftApiResponseItem +{ + public int DraftId { get; set; } + public DateTime ParsedDeliveryDate { get; set; } + public bool IsPartnerKnown { get; set; } + public int ItemCount { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ExternalUserPartnerMapDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ExternalUserPartnerMapDbTable.cs new file mode 100644 index 0000000..04c21a9 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ExternalUserPartnerMapDbTable.cs @@ -0,0 +1,24 @@ +using FruitBank.Common.Entities; +using LinqToDB; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; +using Mango.Nop.Core.Loggers; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class ExternalUserPartnerMapDbTable : MgDbTableBase +{ + public ExternalUserPartnerMapDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + public Task GetByUsernameAsync(string externalUsername) + => GetAll().FirstOrDefaultAsync(m => m.ExternalUsername == externalUsername); + + public IQueryable GetByCustomerId(int customerId) + => GetAll().Where(m => m.CustomerId == customerId); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs index 4186529..dec7e6a 100644 --- a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs @@ -70,6 +70,11 @@ public class FruitBankDbContext : MgDbContextBase, public StockQuantityHistoryDtoDbTable StockQuantityHistoryDtos { get; set; } public CustomerCreditDbTable CustomerCredits { get; set; } + public OrderDraftDbTable OrderDrafts { get; set; } + public OrderDraftItemDbTable OrderDraftItems { get; set; } + public ExternalUserPartnerMapDbTable ExternalUserPartnerMaps { get; set; } + public ProductTextMappingDbTable ProductTextMappings { get; set; } + public IRepository Customers { get; set; } public IRepository CustomerRoles { get; set; } public IRepository CustomerRoleMappings { get; set; } @@ -85,6 +90,8 @@ public class FruitBankDbContext : MgDbContextBase, ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable, ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable, StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable, + OrderDraftDbTable orderDraftDbTable, OrderDraftItemDbTable orderDraftItemDbTable, + ExternalUserPartnerMapDbTable externalUserPartnerMapDbTable, ProductTextMappingDbTable productTextMappingDbTable, IProductService productService, IStaticCacheManager staticCacheManager, IRepository orderRepository, IRepository orderItemRepository, @@ -135,6 +142,11 @@ public class FruitBankDbContext : MgDbContextBase, StockQuantityHistoriesExt = stockQuantityHistoriesExt; StockQuantityHistoryDtos = stockQuantityHistoryDtos; CustomerCredits = customerCreditDbTable; + + OrderDrafts = orderDraftDbTable; + OrderDraftItems = orderDraftItemDbTable; + ExternalUserPartnerMaps = externalUserPartnerMapDbTable; + ProductTextMappings = productTextMappingDbTable; } public IQueryable GetCustomersBySystemRoleName(string systemRoleName) diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftDbTable.cs new file mode 100644 index 0000000..abbe589 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftDbTable.cs @@ -0,0 +1,36 @@ +using FruitBank.Common.Entities; +using FruitBank.Common.Enums; +using LinqToDB; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; +using Mango.Nop.Core.Loggers; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class OrderDraftDbTable : MgDbTableBase +{ + public OrderDraftDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + public IQueryable GetAll(bool loadRelations) + { + return loadRelations + ? GetAll().LoadWith(d => d.Items) + : GetAll(); + } + + public Task GetByIdAsync(int id, bool loadRelations) + => GetAll(loadRelations).FirstOrDefaultAsync(d => d.Id == id); + + public IQueryable GetPending() + => GetAll().Where(d => d.Status == OrderDraftStatus.Pending); + + public IQueryable GetExpiredCandidates() + => GetAll().Where(d => d.Status == OrderDraftStatus.Pending + && d.CreatedOnUtc <= DateTime.UtcNow.AddDays(-14)); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftItemDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftItemDbTable.cs new file mode 100644 index 0000000..1291d40 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftItemDbTable.cs @@ -0,0 +1,21 @@ +using FruitBank.Common.Entities; +using LinqToDB; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; +using Mango.Nop.Core.Loggers; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class OrderDraftItemDbTable : MgDbTableBase +{ + public OrderDraftItemDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + public IQueryable GetByDraftId(int draftId) + => GetAll().Where(i => i.DraftId == draftId).OrderBy(i => i.SortOrder); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ProductTextMappingDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ProductTextMappingDbTable.cs new file mode 100644 index 0000000..38c6095 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ProductTextMappingDbTable.cs @@ -0,0 +1,27 @@ +using FruitBank.Common.Entities; +using LinqToDB; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; +using Mango.Nop.Core.Loggers; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class ProductTextMappingDbTable : MgDbTableBase +{ + public ProductTextMappingDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + public IQueryable GetByRawText(string rawText) + => GetAll().Where(m => m.RawText == rawText.ToLower().Trim()); + + public IQueryable GetByRawText(string rawText, bool isPreorder) + => GetByRawText(rawText).Where(m => m.IsPreorder == isPreorder); + + public IQueryable GetRecentByRawText(string rawText, bool isPreorder, int lastCount = 5) + => GetByRawText(rawText, isPreorder).OrderByDescending(m => m.CreatedOnUtc).Take(lastCount); +} diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs index 3961f51..e70f701 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs @@ -25,5 +25,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin /// that delivery date, and its document processing will be the correct trigger. /// public const int PreOrderConversionWindowDays = 4; + + // ── Customer generic-attribute keys (store-agnostic, StoreId 0) ────────── + /// Bool flag: the customer is subject to EKÁER reporting. + public const string IsEkaerAttribute = "isEkaer"; + + /// JSON list of the customer's site (telephely) addresses — see CustomerSiteEntry. + public const string CustomerSitesAttribute = "customerSites"; } } diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs index 85696c6..abe0783 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs @@ -352,6 +352,32 @@ namespace Nop.Plugin.Misc.FruitBankPlugin 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); + // ── Customer attributes widget (generic attributes block) ─────────── + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title", "Customer attributes", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title", "Ügyfél jellemzők", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer", "EKÁER required", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer", "Ekáer köteles", hu); + + // ── Customer sites (telephelyek) ──────────────────────────────────── + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Title", "Sites", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Title", "Telephelyek", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Edit", "Edit sites", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Edit", "Telephelyek szerkesztése", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord", "site(s)", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord", "telephely", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel", "Default", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel", "Alapértelmezett", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel", "Site", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel", "Telephely", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel", "Address", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel", "Cím", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.None", "No sites set", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.None", "Nincs telephely megadva", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle", "Select sites", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle", "Telephelyek kiválasztása", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses", "This customer has no addresses yet (Addresses tab).", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses", "Ehhez az ügyfélhez még nincs cím rögzítve a Címek fülön.", 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); diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankSettings.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankSettings.cs index 3297b36..60e6117 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankSettings.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankSettings.cs @@ -67,6 +67,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin /// Z.ai GLM-OCR modell neve (default: "glm-ocr"). /// public string ZaiModel { get; set; } = "glm-ocr"; + + // ── OrderDraft API ──────────────────────────────────────────────────────────── + + /// + /// API kulcs a külső rendelés tervezet endpointhoz (X-FruitBank-ApiKey header). + /// Viber/email/diktálás integrációkhoz. + /// + public string OrderDraftApiKey { get; set; } = string.Empty; } } diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs index 9c11b76..0a356bc 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs @@ -103,6 +103,14 @@ public class PluginNopStartup : INopStartup services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); @@ -229,7 +237,7 @@ public class PluginNopStartup : INopStartup }) .AddAcBinaryProtocol(opts => { - opts.ProtocolMode = BinaryProtocolMode.AsyncSegment; + opts.ProtocolMode = BinaryProtocolMode.Bytes; opts.FlushPolicy = FlushPolicy.Coalesced; // Explicit AcLogger instance (name-based category, matches the previous setup). diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs index fe27521..1c1df59 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs @@ -378,6 +378,48 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.QuickOrder.GetDeliveryDateTime", pattern: "gyors-rendeles/szallitas-idopont-lekerdezes", defaults: new { controller = "QuickOrder", action = "GetDeliveryDateTime" }); + + // ── API: OrderDraft (külső rendszerek: Viber, email, diktálás) ───────────── + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Api.OrderDraft.Create", + pattern: "api/order-draft", + defaults: new { controller = "OrderDraftApi", action = "Create" }); + + // ── Admin: OrderDraft ───────────────────────────────────────────────── + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.List", + pattern: "Admin/OrderDraft", + defaults: new { controller = "OrderDraftAdmin", action = "List", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.OrderDraftList", + pattern: "Admin/OrderDraft/OrderDraftList", + defaults: new { controller = "OrderDraftAdmin", action = "OrderDraftList", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.Detail", + pattern: "Admin/OrderDraft/Detail/{id:int}", + defaults: new { controller = "OrderDraftAdmin", action = "Detail", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.UpdateItem", + pattern: "Admin/OrderDraft/UpdateItem", + defaults: new { controller = "OrderDraftAdmin", action = "UpdateItem", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.AssignCustomer", + pattern: "Admin/OrderDraft/AssignCustomer", + defaults: new { controller = "OrderDraftAdmin", action = "AssignCustomer", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.Approve", + pattern: "Admin/OrderDraft/Approve", + defaults: new { controller = "OrderDraftAdmin", action = "Approve", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.Reject", + pattern: "Admin/OrderDraft/Reject", + defaults: new { controller = "OrderDraftAdmin", action = "Reject", area = AreaNames.ADMIN }); } /// diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml index caefd73..515bf8f 100644 --- a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml +++ b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml @@ -81,4 +81,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml index 8bfe77d..260d54f 100644 --- a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml +++ b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml @@ -81,4 +81,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs b/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs index 7570ed7..b5adbb9 100644 --- a/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs +++ b/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs @@ -18,4 +18,26 @@ public record CustomerCreditWidgetModel : BaseNopModel public decimal? RemainingCredit { get; set; } public string? Comment { get; set; } + + // ── Generic attributes block (rendered above the credit card) ────────────── + [NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer")] + public bool IsEkaer { get; set; } + + // ── Sites (telephelyek) ──────────────────────────────────────────────────── + /// All current customer addresses, flagged with site/default state — feeds the edit modal. + public List SiteAddresses { get; set; } = new(); + + /// Number of addresses currently marked as sites. + public int SiteCount { get; set; } + + /// Display string of the default site, if any. + public string? DefaultSiteDisplay { get; set; } +} + +public class CustomerSiteAddressRow +{ + public int AddressId { get; set; } + public string Display { get; set; } = string.Empty; + public bool IsSite { get; set; } + public bool IsDefault { get; set; } } diff --git a/Nop.Plugin.Misc.AIPlugin/Models/CustomerSiteEntry.cs b/Nop.Plugin.Misc.AIPlugin/Models/CustomerSiteEntry.cs new file mode 100644 index 0000000..9784b74 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Models/CustomerSiteEntry.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Models; + +/// +/// One entry in a customer's site (telephely) list, stored as JSON in the +/// customerSites generic attribute. References an existing customer +/// Address by id; extensible with per-site fields (e.g. Note) later. +/// +public class CustomerSiteEntry +{ + [JsonPropertyName("addressId")] + public int AddressId { get; set; } + + [JsonPropertyName("isDefault")] + public bool IsDefault { get; set; } + + [JsonPropertyName("note")] + public string? Note { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/IOrderDraftService.cs b/Nop.Plugin.Misc.AIPlugin/Services/IOrderDraftService.cs new file mode 100644 index 0000000..36f789f --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/IOrderDraftService.cs @@ -0,0 +1,56 @@ +using FruitBank.Common.Entities; +using FruitBank.Common.Enums; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +public interface IOrderDraftService +{ + /// + /// Egy bejövő szöveges üzenet alapján létrehozza a rendelés tervezet(eke)t. + /// Ha az üzenetben több különböző szállítási dátum szerepel, minden dátumhoz külön draft jön létre. + /// + Task> CreateDraftsFromMessageAsync(string externalUsername, string rawMessageText); + + /// Egy draft betöltése tételeivel együtt. + Task GetByIdAsync(int id, bool loadRelations = true); + + /// Pending draftek listája, opcionálisan szűrve partner szerint. + Task> GetPendingAsync(int? customerId = null); + + /// Összes draft lapozhatóan (admin lista). + Task<(List Items, int TotalCount)> GetPagedAsync( + int pageIndex, int pageSize, + OrderDraftStatus? status = null, + int? customerId = null); + + /// + /// Admin jóváhagyja a draftot: minden tétel SelectedProductId-ja be kell legyen állítva. + /// A dátumtól függően PreOrder vagy Order jön létre. + /// Menti a ProductTextMapping rekordokat a tanuláshoz. + /// + Task ApproveAsync(int draftId, int adminId); + + /// Admin elutasítja a draftot. + Task RejectAsync(int draftId, int adminId, string? adminNote = null); + + /// Admin hozzárendeli a partnert egy mappingból még hiányzó drafthoz. + Task AssignCustomerAsync(int draftId, int customerId, int adminId); + + /// Admin frissíti egy tétel kiválasztott termékét és/vagy mennyiségét. + Task UpdateDraftItemAsync(int draftItemId, int selectedProductId, int quantity); + + /// 14 napnál régebbi pending draftek automatikus lejáratása (background job hívja). + Task ExpireOldDraftsAsync(); +} + +public class OrderDraftApproveResult +{ + public bool Success { get; set; } + public string? ErrorMessage { get; set; } + + /// A létrehozott PreOrder ID-k (ha előrendelés lett). + public List PreOrderIds { get; set; } = []; + + /// A létrehozott Order ID-k (ha azonnali rendelés lett). + public List OrderIds { get; set; } = []; +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftParserService.cs b/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftParserService.cs new file mode 100644 index 0000000..87475de --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftParserService.cs @@ -0,0 +1,230 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +/// +/// Szöveges üzenetet parse-ol: kinyeri a szállítási dátumokat és a termékigényeket. +/// Ha egy üzenetben több dátum szerepel, minden dátumhoz külön ParsedDraftGroup-ot ad vissza. +/// +public class OrderDraftParserService +{ + private readonly OpenAIApiService _openAiService; + + // Hány napnál távolabbi dátum számít előrendelésnek + public const int PreorderThresholdDays = 4; + + public OrderDraftParserService(OpenAIApiService openAiService) + { + _openAiService = openAiService; + } + + /// + /// Egy raw üzenetből kinyeri a (dátum → termékigények) csoportokat. + /// Minden visszaadott csoport egy külön OrderDraft-nak felel meg. + /// + public async Task> ParseMessageAsync(string rawMessage) + { + var systemPrompt = """ + Te egy magyar gyümölcs- és zöldségnagyker rendelési asszisztense vagy. + Egy beérkező szöveges üzenetből ki kell nyerned a rendelési szándékokat. + + FELADATOD: + 1. Azonosítsd az összes szállítási dátumot az üzenetben. + 2. Csoportosítsd a termékigényeket dátum szerint. + 3. Ha egy tételhez nincs explicit dátum megadva, rendeld a legközelebbi említett dátumhoz. + 4. Ha egyáltalán nincs dátum az üzenetben, használd a mai napot + 1 nap értéket. + 5. A mennyiségnél mindig rekeszben gondolkodj (ha nem egyértelmű, rekesz az alapértelmezett). + 6. A termék nevét pontosan úgy add vissza, ahogy az üzenetben szerepel (ne fordítsd, ne értelmezd). + + VÁLASZ FORMÁTUM (csak JSON, semmi más): + { + "groups": [ + { + "deliveryDate": "YYYY-MM-DD", + "items": [ + { + "rawProductText": "az eredeti szöveg ahogy szerepelt", + "requestedQuantity": 10, + "sortOrder": 1 + } + ] + } + ] + } + + FONTOS: + - Csak JSON-t adj vissza, semmilyen magyarázatot, bevezető szöveget vagy markdown blokkot ne írj. + - Ha a dátum relatív (pl. "jövő héten", "jövő szerdán", "holnap"), számítsd ki a tényleges dátumot a mai naphoz képest. + - Mai dátum: + """ + " " + DateTime.Today.ToString("yyyy-MM-dd"); + + var userPrompt = $"Üzenet:\n{rawMessage}"; + + var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, userPrompt); + if (string.IsNullOrWhiteSpace(response)) + return []; + + try + { + // Tisztítjuk a markdown code fences-t ha mégis beleírta + var clean = response + .Replace("```json", "") + .Replace("```", "") + .Trim(); + + var parsed = JsonSerializer.Deserialize(clean, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return parsed?.Groups ?? []; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[OrderDraftParser] JSON parse error: {ex.Message}\nRaw response: {response}"); + return []; + } + } + + /// + /// Egy termék raw szövegéből meghatározza a kanonikus terméknevet és + /// a NopCommerce kereséshez használható kulcsszavakat. + /// + public async Task ResolveProductNameAsync(string rawProductText) + { + var systemPrompt = """ + Te egy magyar gyümölcs- és zöldségnagyker asszisztense vagy. + Egy vevő által írt termékmegnevezésből kell meghatároznod: + 1. Mi a termék pontos, kanonikus neve magyarul (pl. "piros kalif" → "Kaliforniai paprika piros") + 2. Milyen kulcsszavakkal érdemes keresni a termékadatbázisban (pl. ["paprika", "kaliforniai", "piros"]) + + VÁLASZ FORMÁTUM (csak JSON): + { + "canonicalName": "Kaliforniai paprika piros", + "searchKeywords": ["paprika", "kaliforniai", "piros"] + } + + Csak JSON-t adj vissza. + """; + + var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, rawProductText); + if (string.IsNullOrWhiteSpace(response)) + return new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] }; + + try + { + var clean = response.Replace("```json", "").Replace("```", "").Trim(); + var hint = JsonSerializer.Deserialize(clean, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + return hint ?? new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] }; + } + catch + { + return new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] }; + } + } + + /// + /// Több termék ID-t sorrendbe rendez az AI segítségével a raw szöveg és a terméknevek alapján. + /// A historikus mapping által már előre rangsorolt lista végső finomhangolása. + /// + public async Task> RankCandidatesAsync( + string rawProductText, + string canonicalName, + List<(int Id, string Name)> candidates) + { + if (candidates.Count <= 1) + return candidates.Select(c => c.Id).ToList(); + + var candidateList = candidates + .Select((c, i) => $"{i + 1}. ID={c.Id} Név=\"{c.Name}\"") + .Aggregate(new StringBuilder(), (sb, s) => sb.AppendLine(s)) + .ToString(); + + var systemPrompt = """ + Te egy magyar gyümölcs- és zöldségnagyker asszisztense vagy. + Rendezd sorrendbe a termékkandidátusokat aszerint, hogy melyik felel meg legjobban a keresett terméknek. + Az első legyen a legjobb találat. + + VÁLASZ FORMÁTUM (csak JSON, semmi más): + { "rankedIds": [123, 456, 789] } + """; + + var userPrompt = $""" + Keresett termék (eredeti szöveg): "{rawProductText}" + Kanonikus név: "{canonicalName}" + + Kandid\u00e1tusok: + {candidateList} + """; + + var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, userPrompt); + if (string.IsNullOrWhiteSpace(response)) + return candidates.Select(c => c.Id).ToList(); + + try + { + var clean = response.Replace("```json", "").Replace("```", "").Trim(); + using var doc = JsonDocument.Parse(clean); + var ranked = doc.RootElement + .GetProperty("rankedIds") + .EnumerateArray() + .Select(e => e.GetInt32()) + .ToList(); + + // Csak valódi kandidátus ID-kat tartunk meg, a maradékot hozzáfűzzük + var validIds = ranked.Where(id => candidates.Any(c => c.Id == id)).ToList(); + var missing = candidates.Select(c => c.Id).Except(validIds).ToList(); + return [.. validIds, .. missing]; + } + catch + { + return candidates.Select(c => c.Id).ToList(); + } + } +} + +// ── DTO-k ────────────────────────────────────────────────────────────────── + +public class ParsedMessageResponse +{ + [JsonPropertyName("groups")] + public List Groups { get; set; } = []; +} + +public class ParsedDraftGroup +{ + [JsonPropertyName("deliveryDate")] + public string DeliveryDate { get; set; } = string.Empty; + + [JsonPropertyName("items")] + public List Items { get; set; } = []; + + public DateTime GetDeliveryDateTime() + => DateTime.TryParse(DeliveryDate, out var dt) ? dt : DateTime.Today.AddDays(1); +} + +public class ParsedDraftItem +{ + [JsonPropertyName("rawProductText")] + public string RawProductText { get; set; } = string.Empty; + + [JsonPropertyName("requestedQuantity")] + public int RequestedQuantity { get; set; } = 1; + + [JsonPropertyName("sortOrder")] + public int SortOrder { get; set; } +} + +public class ProductResolutionHint +{ + [JsonPropertyName("canonicalName")] + public string CanonicalName { get; set; } = string.Empty; + + [JsonPropertyName("searchKeywords")] + public List SearchKeywords { get; set; } = []; +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftService.cs b/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftService.cs new file mode 100644 index 0000000..d5099f0 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftService.cs @@ -0,0 +1,392 @@ +using FruitBank.Common.Entities; +using FruitBank.Common.Enums; +using LinqToDB; +using Nop.Core.Domain.Catalog; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Services.Catalog; +using System.Threading; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +public class OrderDraftService : IOrderDraftService +{ + private readonly FruitBankDbContext _dbContext; + private readonly PreOrderDbContext _preOrderDbContext; + private readonly OrderDraftParserService _parserService; + private readonly IProductService _productService; + private readonly PreOrderConversionService _preOrderConversionService; + + public OrderDraftService( + FruitBankDbContext dbContext, + PreOrderDbContext preOrderDbContext, + OrderDraftParserService parserService, + IProductService productService, + PreOrderConversionService preOrderConversionService) + { + _dbContext = dbContext; + _preOrderDbContext = preOrderDbContext; + _parserService = parserService; + _productService = productService; + _preOrderConversionService = preOrderConversionService; + } + + // ── Létrehozás ──────────────────────────────────────────────────────────── + + public async Task> CreateDraftsFromMessageAsync(string externalUsername, string rawMessageText) + { + // 1. Partner azonosítás + var mapping = await _dbContext.ExternalUserPartnerMaps.GetByUsernameAsync(externalUsername); + + // 2. AI parse: dátumok + termékek kinyerése + var parsedGroups = await _parserService.ParseMessageAsync(rawMessageText); + if (!parsedGroups.Any()) + return []; + + var now = DateTime.UtcNow; + var createdDrafts = new List(); + + foreach (var group in parsedGroups) + { + var deliveryDate = group.GetDeliveryDateTime(); + var isPreorder = deliveryDate.Date > now.Date.AddDays(OrderDraftParserService.PreorderThresholdDays); + + var draft = new OrderDraft + { + ExternalUsername = externalUsername, + CustomerId = mapping?.CustomerId, + RawMessageText = rawMessageText, + ParsedDeliveryDate = deliveryDate, + Status = OrderDraftStatus.Pending, + CreatedOnUtc = now + }; + + await _dbContext.OrderDrafts.InsertAsync(draft); + + // 3. Tételek feldolgozása + var sortOrder = 0; + var itemTasks = group.Items.Select(parsedItem => ProcessDraftItemAsync( + draft.Id, parsedItem, isPreorder, sortOrder++)).ToList(); + + var items = await Task.WhenAll(itemTasks); + draft.Items = items.ToList(); + + createdDrafts.Add(draft); + + Console.WriteLine($"[OrderDraftService] Created draft #{draft.Id} for '{externalUsername}', " + + $"deliveryDate={deliveryDate:yyyy-MM-dd}, isPreorder={isPreorder}, " + + $"items={items.Length}, customerId={mapping?.CustomerId.ToString() ?? "null"}"); + } + + return createdDrafts; + } + + private async Task ProcessDraftItemAsync( + int draftId, ParsedDraftItem parsed, bool isPreorder, int sortOrder) + { + // 1. Historikus súlyozás: utolsó 5 admin döntés ugyanerre a szövegre + var historicMappings = await _dbContext.ProductTextMappings + .GetRecentByRawText(parsed.RawProductText, isPreorder, 5) + .ToListAsync(); + + var historicProductIds = historicMappings.Select(m => m.ResolvedProductId).Distinct().ToList(); + + // 2. AI: kanonikus név + kulcsszavak + var hint = await _parserService.ResolveProductNameAsync(parsed.RawProductText); + + // 3. Termékkeresés az összes kulcsszóra — párhuzamosan + var searchTasks = hint.SearchKeywords + .Select(kw => _productService.SearchProductsAsync(keywords: kw, visibleIndividuallyOnly: true, pageSize: 10)) + .ToList(); + + var searchResults = await Task.WhenAll(searchTasks); + var allFoundProducts = searchResults + .SelectMany(r => r) + .DistinctBy(p => p.Id) + .ToList(); + + // 4. Sorrendezés: historikus találatok elől, majd AI ranking + List rankedIds; + if (allFoundProducts.Count == 0) + { + rankedIds = historicProductIds; + } + else + { + // Historikus ID-k kerülnek előre ha az aktuális keresésben is szerepelnek + var foundIds = allFoundProducts.Select(p => p.Id).ToList(); + var historicAndFound = historicProductIds.Where(id => foundIds.Contains(id)).ToList(); + var restFound = foundIds.Except(historicAndFound).ToList(); + + // Ha több mint 1 nem-historikus találat van, AI rangsorolja + if (restFound.Count > 1) + { + var candidates = allFoundProducts + .Where(p => restFound.Contains(p.Id)) + .Select(p => (p.Id, p.Name)) + .ToList(); + + var aiRanked = await _parserService.RankCandidatesAsync( + parsed.RawProductText, hint.CanonicalName, candidates); + + rankedIds = [.. historicAndFound, .. aiRanked]; + } + else + { + rankedIds = [.. historicAndFound, .. restFound]; + } + } + + var item = new OrderDraftItem + { + DraftId = draftId, + RawProductText = parsed.RawProductText, + AiResolvedProductName = hint.CanonicalName, + SuggestedProductIdsJson = System.Text.Json.JsonSerializer.Serialize(rankedIds), + SelectedProductId = rankedIds.Count > 0 ? rankedIds[0] : null, // első javaslat előre kiválasztva + RequestedQuantity = parsed.RequestedQuantity > 0 ? parsed.RequestedQuantity : 1, + IsPreorder = isPreorder, + SortOrder = sortOrder + }; + + await _dbContext.OrderDraftItems.InsertAsync(item); + return item; + } + + // ── Lekérdezések ────────────────────────────────────────────────────────── + + public Task GetByIdAsync(int id, bool loadRelations = true) + => _dbContext.OrderDrafts.GetByIdAsync(id, loadRelations); + + public async Task> GetPendingAsync(int? customerId = null) + { + var query = _dbContext.OrderDrafts.GetPending(); + if (customerId.HasValue) + query = query.Where(d => d.CustomerId == customerId.Value); + return await query.OrderByDescending(d => d.CreatedOnUtc).ToListAsync(); + } + + public async Task<(List Items, int TotalCount)> GetPagedAsync( + int pageIndex, int pageSize, + OrderDraftStatus? status = null, + int? customerId = null) + { + var query = _dbContext.OrderDrafts.GetAll(false); + + if (status.HasValue) + query = query.Where(d => d.Status == status.Value); + + if (customerId.HasValue) + query = query.Where(d => d.CustomerId == customerId.Value); + + query = query.OrderByDescending(d => d.CreatedOnUtc); + + var totalCount = await query.CountAsync(CancellationToken.None); + var items = await query + .Skip(pageIndex * pageSize) + .Take(pageSize) + .ToListAsync(); + + return (items, totalCount); + } + + // ── Admin műveletek ─────────────────────────────────────────────────────── + + public async Task ApproveAsync(int draftId, int adminId) + { + var result = new OrderDraftApproveResult(); + + var draft = await GetByIdAsync(draftId, true); + if (draft == null) + { + result.ErrorMessage = $"Draft #{draftId} nem található."; + return result; + } + + if (draft.Status != OrderDraftStatus.Pending) + { + result.ErrorMessage = $"Draft #{draftId} már le van zárva (státusz: {draft.Status})."; + return result; + } + + if (!draft.CustomerId.HasValue) + { + result.ErrorMessage = "Jóváhagyás előtt rendelj hozzá partnert a drafthoz."; + return result; + } + + var unresolved = draft.Items.Where(i => i.SelectedProductId == null).ToList(); + if (unresolved.Any()) + { + result.ErrorMessage = $"{unresolved.Count} tétel nincs kitöltve. Minden sorban ki kell választani a terméket."; + return result; + } + + try + { + // Tételek szétválasztása: preorder vs. azonnali + var preorderItems = draft.Items.Where(i => i.IsPreorder).ToList(); + var orderItems = draft.Items.Where(i => !i.IsPreorder).ToList(); + + // Tanulási rekordok mentése (párhuzamosan) + var mappingTasks = draft.Items.Select(item => SaveProductTextMappingAsync(item, adminId)); + await Task.WhenAll(mappingTasks); + + // Előrendelések létrehozása + if (preorderItems.Any()) + { + var preorderNopItems = preorderItems.Select(i => new PreOrderItem + { + ProductId = i.SelectedProductId!.Value, + RequestedQuantity = i.RequestedQuantity, + FulfilledQuantity = 0, + UnitPriceInclTax = 0m, // Árat a konverziókor számítja a service + Status = PreOrderItemStatus.Pending + }).ToList(); + + var preorder = new PreOrder + { + CustomerId = draft.CustomerId!.Value, + StoreId = 1, + DateOfReceipt = draft.ParsedDeliveryDate, + CustomerNote = $"OrderDraft #{draft.Id} alapján létrehozva" + }; + + var saved = await _preOrderDbContext.InsertPreOrderAsync(preorder, preorderNopItems); + result.PreOrderIds.Add(saved.Id); + + // Azonnali konverzió ha van raktáron + await _preOrderConversionService.ConvertPreOrdersForProductsAsync( + preorderNopItems.Select(i => i.ProductId).ToList(), 0); + } + + // Azonnali rendelések: a PreOrderConversionService kezeli ha létrehozunk egy + // preordert aminek dátuma <= 4 nap — itt is ugyanazt a flow-t használjuk + if (orderItems.Any()) + { + var orderNopItems = orderItems.Select(i => new PreOrderItem + { + ProductId = i.SelectedProductId!.Value, + RequestedQuantity = i.RequestedQuantity, + FulfilledQuantity = 0, + UnitPriceInclTax = 0m, + Status = PreOrderItemStatus.Pending + }).ToList(); + + var immediatePreorder = new PreOrder + { + CustomerId = draft.CustomerId!.Value, + StoreId = 1, + DateOfReceipt = draft.ParsedDeliveryDate, + CustomerNote = $"OrderDraft #{draft.Id} alapján létrehozva (azonnali)" + }; + + var saved = await _preOrderDbContext.InsertPreOrderAsync(immediatePreorder, orderNopItems); + + // Az azonnali rendeléseknél inline futtatjuk a konverziót + await _preOrderConversionService.ConvertPreOrdersForProductsAsync( + orderNopItems.Select(i => i.ProductId).ToList(), 0); + + // Ha lett belőle rendelés, azt is visszaadjuk + var converted = await _preOrderDbContext.PreOrders.GetByIdAsync(saved.Id); + if (converted?.OrderId.HasValue == true) + result.OrderIds.Add(converted.OrderId.Value); + else + result.PreOrderIds.Add(saved.Id); + } + + // Draft lezárása + draft.Status = OrderDraftStatus.Approved; + draft.ResolvedByAdminId = adminId; + draft.ResolvedOnUtc = DateTime.UtcNow; + await _dbContext.OrderDrafts.UpdateAsync(draft); + + result.Success = true; + + Console.WriteLine($"[OrderDraftService] Draft #{draftId} approved by admin #{adminId}. " + + $"PreOrders: [{string.Join(",", result.PreOrderIds)}], " + + $"Orders: [{string.Join(",", result.OrderIds)}]"); + } + catch (Exception ex) + { + result.ErrorMessage = $"Hiba a jóváhagyás során: {ex.Message}"; + Console.Error.WriteLine($"[OrderDraftService] ApproveAsync error for draft #{draftId}: {ex}"); + } + + return result; + } + + public async Task RejectAsync(int draftId, int adminId, string? adminNote = null) + { + var draft = await GetByIdAsync(draftId, false); + if (draft == null || draft.Status != OrderDraftStatus.Pending) return; + + draft.Status = OrderDraftStatus.Rejected; + draft.ResolvedByAdminId = adminId; + draft.ResolvedOnUtc = DateTime.UtcNow; + draft.AdminNote = adminNote; + await _dbContext.OrderDrafts.UpdateAsync(draft); + + Console.WriteLine($"[OrderDraftService] Draft #{draftId} rejected by admin #{adminId}."); + } + + public async Task AssignCustomerAsync(int draftId, int customerId, int adminId) + { + var draft = await GetByIdAsync(draftId, false); + if (draft == null) return; + + // Mapping mentése a jövőre + var existingMapping = await _dbContext.ExternalUserPartnerMaps.GetByUsernameAsync(draft.ExternalUsername); + if (existingMapping == null || existingMapping.CustomerId != customerId) + { + await _dbContext.ExternalUserPartnerMaps.InsertAsync(new ExternalUserPartnerMapping + { + ExternalUsername = draft.ExternalUsername, + CustomerId = customerId, + CreatedByAdminId = adminId, + CreatedOnUtc = DateTime.UtcNow + }); + } + + draft.CustomerId = customerId; + await _dbContext.OrderDrafts.UpdateAsync(draft); + } + + public async Task UpdateDraftItemAsync(int draftItemId, int selectedProductId, int quantity) + { + var item = await _dbContext.OrderDraftItems.GetByIdAsync(draftItemId); + if (item == null) return; + + item.SelectedProductId = selectedProductId; + if (quantity > 0) item.RequestedQuantity = quantity; + await _dbContext.OrderDraftItems.UpdateAsync(item); + } + + public async Task ExpireOldDraftsAsync() + { + var candidates = await _dbContext.OrderDrafts.GetExpiredCandidates().ToListAsync(); + foreach (var draft in candidates) + { + draft.Status = OrderDraftStatus.Expired; + await _dbContext.OrderDrafts.UpdateAsync(draft); + } + + if (candidates.Any()) + Console.WriteLine($"[OrderDraftService] Expired {candidates.Count} old drafts."); + } + + // ── Privát segédek ──────────────────────────────────────────────────────── + + private async Task SaveProductTextMappingAsync(OrderDraftItem item, int adminId) + { + if (item.SelectedProductId == null) return; + + await _dbContext.ProductTextMappings.InsertAsync(new ProductTextMapping + { + RawText = item.RawProductText.ToLower().Trim(), + ResolvedProductId = item.SelectedProductId.Value, + IsPreorder = item.IsPreorder, + SelectedByAdminId = adminId, + CreatedOnUtc = DateTime.UtcNow + }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.Replace.cs b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.Replace.cs index 8276df7..140ea66 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.Replace.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.Replace.cs @@ -27,7 +27,8 @@ public partial class PreOrderConversionService public async Task ReplaceShippingItemProductAsync( int shippingItemId, int newProductId, - ShippingItem oldItem) + ShippingItem oldItem, + bool skipIncomingSync = false) { if (oldItem.ProductId == null || oldItem.ProductId.Value == newProductId) return; @@ -52,9 +53,13 @@ public partial class PreOrderConversionService // ── Stock / IncomingQuantity swap ───────────────────────────────────── if (!isMeasured) { - // Item not yet on the truck — only IncomingQuantity moves - await SyncIncomingQuantityAsync(oldProductId, qty, 0); - await SyncIncomingQuantityAsync(newProductId, 0, qty); + if (!skipIncomingSync) + { + // Item not yet on the truck — only IncomingQuantity moves + // (skipped if already done before UpdateShippingItemSafeAsync to avoid race) + await SyncIncomingQuantityAsync(oldProductId, qty, 0); + await SyncIncomingQuantityAsync(newProductId, 0, qty); + } } else { diff --git a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs index 686659b..bed377c 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs @@ -45,6 +45,13 @@ public partial class PreOrderConversionService private readonly FruitBankOrderItemService _orderItemService; private readonly IStoreContext _storeContext; + // Per-preorder async locks — static so they're shared across all scoped instances + private static readonly System.Collections.Concurrent.ConcurrentDictionary + _preorderLocks = new(); + + private static SemaphoreSlim GetPreorderLock(int preorderId) => + _preorderLocks.GetOrAdd(preorderId, _ => new SemaphoreSlim(1, 1)); + public PreOrderConversionService( PreOrderDbContext preorderDbContext, FruitBankDbContext dbContext, @@ -183,19 +190,37 @@ public partial class PreOrderConversionService .Where(i => i.Status == PreOrderItemStatus.Dropped) .ToList(); - if (preorder.OrderId == null) + // Acquire a per-preorder lock BEFORE checking OrderId. + // Without this, 3 concurrent conversions (one per shipping item event) + // all read OrderId=null and each creates a separate order. + // Re-reading inside the lock ensures only one creates the order. + var sem = GetPreorderLock(preorderId); + await sem.WaitAsync(); + try { - // First time any items are resolved → create the order - if (newlyFulfilled.Any() || newlyDropped.Any()) + var preorderLocked = await _preorderDbContext.PreOrders.GetByIdAsync(preorderId); + if (preorderLocked == null) continue; + + var newlyFulfilled2 = changedItems + .Where(i => i.FulfilledQuantity > 0 && + (i.Status == PreOrderItemStatus.Fulfilled || + i.Status == PreOrderItemStatus.PartiallyFulfilled)) + .ToList(); + var newlyDropped2 = changedItems + .Where(i => i.Status == PreOrderItemStatus.Dropped) + .ToList(); + + if (preorderLocked.OrderId == null) { - await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId); + if (newlyFulfilled2.Any() || newlyDropped2.Any()) + await CreateOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId); + } + else + { + await AppendItemsToOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId); } } - else - { - // Order already exists from a previous document → append new items only - await AppendItemsToOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId); - } + finally { sem.Release(); } } Console.WriteLine($"[PreOrderConversion] Done. {newlyResolvedByPreOrder.Count} preorders affected."); @@ -365,6 +390,20 @@ public partial class PreOrderConversionService }); await InsertOrderItemsAsync(order, fulfilledItems); + + // Recalculate header totals from the actual inserted order items rather than the + // PreOrderItem snapshot price: that snapshot is 0 for OrderDraft-origin preorders + // (priced at conversion) and may be stale for others (price drift since placement), + // so the order items are the authoritative source — same approach as AppendItemsToOrderAsync. + var insertedItems = await _dbContext.OrderItems.Table + .Where(oi => oi.OrderId == order.Id) + .ToListAsync(); + var recalculatedTotal = insertedItems.Sum(oi => oi.PriceInclTax); + order.OrderSubtotalInclTax = recalculatedTotal; + order.OrderSubtotalExclTax = Math.Round(recalculatedTotal / 1.27m, 2); + order.OrderTotal = recalculatedTotal; + await _dbContext.Orders.UpdateAsync(order); + await InsertOrderNoteAsync(order.Id, preorder.Id, shippingDocumentId, fulfilledItems, droppedItems); // Fire event so existing handlers (EventConsumer etc.) run diff --git a/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml index 1fe1975..73b5961 100644 --- a/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml @@ -1,4 +1,6 @@ @model Nop.Plugin.Misc.FruitBankPlugin.Models.CustomerCreditWidgetModel +@inject Nop.Services.Common.IGenericAttributeService genericAttributeService +@inject Nop.Core.IWorkContext workContext @{ var remaining = Model.RemainingCredit; @@ -6,78 +8,327 @@ : remaining <= 0 ? "text-danger" : remaining < Model.CreditLimit * 0.2m ? "text-warning" : "text-success"; + + // Per-admin collapse state, persisted like the core nopCommerce cards + var admin = await workContext.GetCurrentCustomerAsync(); + + const string hideAttributesCardName = "FruitBank.CustomerAttributesCard.Hide"; + var hideAttributesCard = await genericAttributeService.GetAttributeAsync(admin, hideAttributesCardName); + + const string hideCreditCardName = "FruitBank.CustomerCreditCard.Hide"; + var hideCreditCard = await genericAttributeService.GetAttributeAsync(admin, hideCreditCardName); } -
-
- - @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle") -
-
+@* FruitBank customer-top section: injected into the CustomerDetailsBlock widget zone + (which renders at the BOTTOM of the customer page) and relocated to the top of + #customer-cards by the script below. Future non-credit elements go inside this + same wrapper so the single relocation keeps them all together at the top. *@ +
-
-
- -
-
- - @(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text) - + @* ── Customer attributes (generic attributes block) ───────────────────────── *@ +
+
+

+ + @T("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title") +

+
+
+
+
+
+ +
+
+ + @* No name attribute: this must NOT post with the core Customer Edit form; + it is saved via AJAX on change to CustomerAttributesController.SetEkaer. *@ + + + +
+
-
-
- -
-
- - @Model.OutstandingBalance.ToString("N0") Ft - -
-
- -
-
- -
-
- - - @if (!Model.HasCreditLimit) +
+
+ +
+
+ + @if (Model.SiteCount > 0) { - @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited") + @Model.SiteCount @T("Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord") + if (!string.IsNullOrWhiteSpace(Model.DefaultSiteDisplay)) + { + (@T("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel"): @Model.DefaultSiteDisplay) + } } else { - @(remaining!.Value.ToString("N0")) - Ft + @T("Plugins.Misc.FruitBankPlugin.CustomerSites.None") } - - + + +
+
+ + @* ── Customer credit ──────────────────────────────────────────────────────── *@ +
+
+

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

+
+ +
+
+
- @if (!string.IsNullOrWhiteSpace(Model.Comment)) - {
- +
- @Model.Comment + + @(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text) +
- } -
-
- - - @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle") - +
+
+ +
+
+ + @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 +
+
+ } + + + +
+
+ +
+ +@* ── Sites edit modal ───────────────────────────────────────────────────────── *@ + + + diff --git a/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/ORDERDRAFT_ISSUES.md b/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/ORDERDRAFT_ISSUES.md new file mode 100644 index 0000000..553b70b --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/ORDERDRAFT_ISSUES.md @@ -0,0 +1,46 @@ +# ORDER-DRAFT — Known Issues + +> Companion to [`README.md`](README.md). Topic `ORDD`, prefix `MGFBANKPLUG` → entry IDs `MGFBANKPLUG-ORDD-I-` (issue) / `-B-` (confirmed bug) / `-T-` (tech-debt). +> ID format, Status vocabulary, type codes and archival → `../../.github/TOPIC_CODES.md` (→ framework registry). + +Scope: az AI-alapú rendelésfelvevő (OrderDraft) problémái — beérkező üzenet parse-olása, termék/partner feloldás, tanuló mappingek, draft életciklus, jóváhagyási belépési pont a PRE-ORDER folyamatba. + +## Active entries + +## MGFBANKPLUG-ORDD-I-Q4N7: Az `ApproveAsync` fixen `StoreId = 1`-et használ + +**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** I (multi-store helyesség) + +`OrderDraftService.ApproveAsync` ([`Services/OrderDraftService.cs`](../../Services/OrderDraftService.cs)) mindkét ágban (`preorder` és `azonnali`) `StoreId = 1` konstanssal hozza létre a `PreOrder`-t: + +```csharp +var preorder = new PreOrder { CustomerId = draft.CustomerId!.Value, StoreId = 1, ... }; +``` + +**Hatás:** jelenleg egy store-os környezetben nincs gond. Multi-store esetén viszont a draftból mindig az 1-es store-ba kerül a rendelés, függetlenül a partner/üzenet kontextusától — rossz store-hoz rendelt rendelések. + +**Javítási irány:** a store-t a draft kontextusából vagy a partner (`Customer`) alapértelmezett store-jából vezessük le, ne hardcode-oljuk. (Megj.: a `PreOrderAdminController.CreatePreOrder` is hasonló fallback-logikát használ — érdemes közös segédbe emelni.) + +**Affected:** `Services/OrderDraftService.cs` → `ApproveAsync` (mindkét `new PreOrder { StoreId = 1 }`). + +## MGFBANKPLUG-ORDD-T-R8W3: A jóváhagyás preorder/azonnali ága szinte duplikátum + +**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** T (tech-debt / refaktor) + +`ApproveAsync`-ben a `preorderItems` és az `orderItems` (azonnali) ág kódja közel azonos: `PreOrderItem` lista építése (`UnitPriceInclTax = 0`, `Status = Pending`), `PreOrder` létrehozás, `InsertPreOrderAsync`, majd `ConvertPreOrdersForProductsAsync` hívás. Az egyetlen érdemi különbség a `CustomerNote` szövege és hogy az azonnali ág a végén visszaolvassa az `OrderId`-t. + +Mivel mindkét ág ugyanazon a `PreOrder` + konverzió flow-n megy keresztül (a konverziós ablak dönti el, hogy azonnal teljesül-e), a kettő **egyetlen** közös ággá vonható össze, dátum szerinti elágazás nélkül — vagy egy közös privát helperrel (`CreatePreOrderFromDraftItemsAsync(items, note)`). + +**Affected:** `Services/OrderDraftService.cs` → `ApproveAsync`. + +## MGFBANKPLUG-ORDD-I-M2K9: Az API-kulcs összehasonlítása nem konstans idejű + +**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** I (security hardening) + +`OrderDraftApiController.ValidateApiKey` ([`Controllers/OrderDraftApiController.cs`](../../Controllers/OrderDraftApiController.cs)) `string.Equals(..., StringComparison.Ordinal)`-lal hasonlítja össze a beküldött és a tárolt API-kulcsot. Ez **nem konstans idejű** — elvi timing-oldalcsatorna a kulcs kitalálására. + +**Hatás:** alacsony (a kulcs hosszú, és a háló-jitter elnyomja a mikro-eltérést), de egy publikus, csak-API-kulccsal védett végpontnál a fixed-time összehasonlítás az elvárt gyakorlat. + +**Javítási irány:** fixed-time összehasonlítás, pl. `CryptographicOperations.FixedTimeEquals` a két kulcs UTF-8 byte-jaira (hossz-eltérés előzetes kezelésével). Opcionálisan rate-limiting a végponton. + +**Affected:** `Controllers/OrderDraftApiController.cs` → `ValidateApiKey`. diff --git a/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/README.md b/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/README.md new file mode 100644 index 0000000..f72f763 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/README.md @@ -0,0 +1,69 @@ +# Order-Draft Workflow + +> Part of `Nop.Plugin.Misc.FruitBankPlugin`. See `../README.md` for the docs index, `../../README.md` for project overview. +> Closely coupled to the pre-order subsystem — see [`../PREORDER/README.md`](../PREORDER/README.md): an approved draft creates `PreOrder`s and runs the same conversion. +> Known issues / planned work: [`ORDERDRAFT_ISSUES.md`](ORDERDRAFT_ISSUES.md). + +An **order draft** is an AI-assisted intake channel. External systems (Viber bot, email processor, dictation app) post a raw free-text message; the AI extracts delivery dates and product demands; a draft is created for admin review. On approval the draft becomes one or more `PreOrder`s, which then flow through the existing **PRE-ORDER conversion** — so OrderDraft is a new, admin-gated *placement* channel into that pipeline, not a parallel order path. + +## Entity Hierarchy + +``` +OrderDraft ← one inbound message for one delivery date + └─ OrderDraftItem ← one parsed product-demand line [1:N] + +ExternalUserPartnerMapping ← external username → nopCommerce CustomerId (learned) +ProductTextMapping ← raw text → admin-chosen ProductId (learned, for ranking) +``` + +All four tables live in `FruitBank.Common/Entities/` and are registered on `FruitBankDbContext`. `OrderDraftStatus`: `Pending` → `Approved` / `Rejected` / `Expired`. + +## Workflow + +### 1. Intake +`OrderDraftApiController.Create` (`Controllers/`): auth via `X-FruitBank-ApiKey` header (matched against `FruitBankSettings.OrderDraftApiKey`), body `{ externalUsername, messageText }`. + +### 2. Draft build +`OrderDraftService.CreateDraftsFromMessageAsync`: +- Partner resolution from `ExternalUserPartnerMapping` (may be `null` → admin assigns later). +- AI parse (`OrderDraftParserService.ParseMessageAsync`): one draft per delivery date; `isPreorder = deliveryDate > today + PreorderThresholdDays (4)`. +- Per item (`ProcessDraftItemAsync`): historic `ProductTextMapping` weighting (last 5 admin decisions) + AI canonical name/keywords + `SearchProductsAsync` + AI ranking → `SuggestedProductIdsJson`, first suggestion pre-selected. + +### 3. Admin review +`OrderDraftAdminController` (`Areas/Admin/`): DataTables list (urgent = Pending > 20h), Detail page with candidates, `UpdateItem` / `AssignCustomer`. + +### 4. Approval (the entry point into PRE-ORDER) +`OrderDraftService.ApproveAsync`: +- Validates: partner assigned, every item has `SelectedProductId`. +- Saves `ProductTextMapping` learning records. +- Splits items by `IsPreorder`; **both branches create a `PreOrder`** via `InsertPreOrderAsync`, then call `PreOrderConversionService.ConvertPreOrdersForProductsAsync`. The "immediate" branch relies on the 4-day conversion window to convert at once. + +### 5. Expiry +`OrderDraftService.ExpireOldDraftsAsync`: Pending drafts older than 14 days → `Expired` (background job). + +## AI touchpoints + +Three distinct AI calls, all in `OrderDraftParserService` via `OpenAIApiService.GetSimpleResponseAsync` (OpenAI Chat Completions). OrderDraft uses **OpenAI exclusively** — unlike `QuickOrderController` / `OrderController` parsing, which use `CerebrasApiService`. + +| # | Call | Input → Output | Used in | +|---|---|---|---| +| 1 | `ParseMessageAsync` | raw message → delivery-date groups of `{ rawProductText, requestedQuantity }`; one draft per date | `CreateDraftsFromMessageAsync` | +| 2 | `ResolveProductNameAsync` | one raw product text → `{ canonicalName, searchKeywords[] }` (keywords feed NopCommerce `SearchProductsAsync`) | `ProcessDraftItemAsync` | +| 3 | `RankCandidatesAsync` | raw text + canonical name + candidate `(id, name)[]` → ranked product-id list; only when >1 non-historic search hit | `ProcessDraftItemAsync` | + +Resolution is **hybrid**: AI proposes candidates, but ordering is front-loaded by historic admin decisions (`ProductTextMapping`, last 5 for the same raw text) — a DB heuristic, not AI. Product search itself (`SearchProductsAsync`) is NopCommerce full-text, not AI. + +**Model:** `GetModelName()` from `FruitBankSettings.OpenAIModelName` (default `gpt-3.5-turbo`; code paths for `gpt-4o-mini`/`4.1-mini`/`4.1-nano`/`5-nano` at `temperature=0.2`, and a `gpt-5` path at `reasoning_effort=minimal`). + +**Not yet AI-wired:** partner identification (plain `ExternalUserPartnerMapping` username lookup), quantity/unit normalisation (relies on the parse prompt only), confidence/uncertainty scoring (admin approves everything manually), and the preorder-vs-immediate split (fixed 4-day `PreorderThresholdDays`, not AI). + +## Key Source + +| Type | Location | +|---|---| +| `OrderDraftService` / `IOrderDraftService` | `Services/` | +| `OrderDraftParserService` (AI parse / resolve / rank) | `Services/` | +| `OrderDraftApiController` | `Controllers/` | +| `OrderDraftAdminController` (+ models) | `Areas/Admin/` | +| `OrderDraftDbTable` / `OrderDraftItemDbTable` / `ExternalUserPartnerMapDbTable` / `ProductTextMappingDbTable` | `Domains/DataLayer/` | +| `OrderDraft` / `OrderDraftItem` / `ExternalUserPartnerMapping` / `ProductTextMapping` entities | `FruitBank.Common/Entities/` | diff --git a/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md b/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md index 2bb6ded..af23385 100644 --- a/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md +++ b/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md @@ -7,4 +7,21 @@ Scope: bugs / inconsistent or observable edge-case behaviour in the pre-order su ## Active entries -*(None yet — to be filled from the code review as needed.)* +## MGFBANKPLUG-PREO-B-K7M2: A konverziós pool dupla-számolja a már teljesített foglalásokat — alul-allokáció + +**Status:** Open (2026-06-15) · **Priority:** P2 · **Type:** B (megerősített bug) + +`PreOrderConversionService.BuildIncomingQuantityPoolAsync` ([`Services/PreorderConversionService.cs`](../../Services/PreorderConversionService.cs)) a rendelkezésre álló mennyiségből **kétszer** vonja le a korábbi futásokban már teljesített (`Fulfilled`/`PartiallyFulfilled`) preorder-tételeket. + +- `AvailableQuantity = StockQuantity + IncomingQuantity` (SCHEMA.md), az élő `Product.StockQuantity`-ből. +- Teljesítéskor `InsertOrderItemsAsync` → `AdjustInventoryAsync(-FulfilledQuantity)` **már csökkenti** a `StockQuantity`-t, így a következő futás `AvailableQuantity`-je **már tartalmazza** a foglalást. +- A pool ezen felül **még levonja** az `alreadyAllocated`-et (a `Fulfilled`/`PartiallyFulfilled` itemek `FulfilledQuantity` összegét) → **futások közötti dupla-levonás**. + +**Reprodukció:** `Stock=0, Incoming=10`. „A" preorder 6-ot teljesít (run 1) → `Stock=-6`, allocated=6. „B" preorder 6-ot kér (run 2): `available = -6+10 = 4`, `committed = 6` → `pool = max(0, 4-6) = 0`. **B nem kap semmit, pedig fizikailag 4 jár neki.** Az alul-allokáció a „még nincs beérkezve" eset — az előrendelés tipikus forgatókönyve. + +**Miért nem puszta törlés a fix:** a `CreateOrderAsync` korai `return`-jei (nincs `Customer` / nincs számlázási cím, [sorok ~300-319](../../Services/PreorderConversionService.cs)) úgy lépnek ki, hogy az item a fő ciklusban (sor ~153) **már `Fulfilled`-re lett állítva és elmentve**, de a készletlevonás nem futott le. Ezeken a hibautakon az `alreadyAllocated` levonás *helyesen* kompenzál; a levonás puszta törlése **túl-allokációt (overselling)** okozna. + +**Javítási irány (ajánlott):** az item csak akkor legyen `Fulfilled`, ha a rendelés + készletlevonás ténylegesen sikerült (korai return esetén maradjon `Pending`/visszaállítás), majd ezután eltávolítható a dupláló `alreadyAllocated` levonás. Így megszűnik az alul-allokáció túl-allokációs kockázat nélkül. + +**Affected:** +- `Services/PreorderConversionService.cs` → `BuildIncomingQuantityPoolAsync` (a `alreadyAllocated` levonás), `ConvertPreOrdersForProductsAsync` fő ciklusa (státusz-mutáció sorrendje), `CreateOrderAsync` korai return ágai. diff --git a/Nop.Plugin.Misc.AIPlugin/docs/README.md b/Nop.Plugin.Misc.AIPlugin/docs/README.md index cf9face..107d5db 100644 --- a/Nop.Plugin.Misc.AIPlugin/docs/README.md +++ b/Nop.Plugin.Misc.AIPlugin/docs/README.md @@ -14,6 +14,7 @@ Topic documentation for the FruitBank-specific NopCommerce plugin (Layer 3 — c - [`SIGNALR/`](SIGNALR/README.md) — SignalR endpoints exposed by this plugin (project-specific variant) - [`PREORDER/`](PREORDER/README.md) — Customer pre-order workflow: advance orders converted to real orders on incoming stock (+ `PREORDER_ISSUES.md` / `PREORDER_TODO.md`) +- [`ORDERDRAFT/`](ORDERDRAFT/README.md) — AI-assisted intake: free-text messages parsed into order drafts, admin-approved into pre-orders (+ `ORDERDRAFT_ISSUES.md`) ## Navigation From d568931bd9e5ac75bdb0bdc0013efcc107a15520 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 24 Jun 2026 00:47:58 +0200 Subject: [PATCH 5/6] more bug fix --- Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs index 19bf8b2..72683af 100644 --- a/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs @@ -10,7 +10,7 @@ using DocumentFormat.OpenXml.Office2010.Excel; using FruitBank.Common.Dtos; using FruitBank.Common.Entities; using FruitBank.Common.Interfaces; -using FruitBank.Common.Loggers; +//using FruitBank.Common.Loggers; using FruitBank.Common.Models; using FruitBank.Common.Server; using FruitBank.Common.Server.Interfaces; From d5f151b5acfa64acc6368c20227c4589af7909d4 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 27 Jun 2026 01:57:56 +0200 Subject: [PATCH 6/6] site, license plate --- .../CustomerAttributesController.cs | 31 +++++ .../Controllers/OrderAttributesController.cs | 114 ++++++++++++++++ .../CustomerCreditWidgetViewComponent.cs | 21 +++ .../OrderAttributesViewComponent.cs | 82 +++++++++-- .../EventConsumers/FruitBankEventConsumer.cs | 93 ++++++++++++- Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs | 11 ++ Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs | 14 ++ .../Localization/customercredit.en.xml | 20 +++ .../Localization/customercredit.hu.xml | 20 +++ .../Models/CustomerCreditWidgetModel.cs | 15 ++ .../Models/CustomerLicensePlateEntry.cs | 20 +++ .../Models/Orders/OrderAttributesModel.cs | 22 ++- .../Services/OrderSiteSnapshotBuilder.cs | 39 ++++++ .../Views/CustomerCreditWidget.cshtml | 128 ++++++++++++++++++ .../Views/OrderAttributes.cshtml | 111 ++++++++++++++- Nop.Plugin.Misc.AIPlugin/docs/INNVOICE.md | 41 ++++++ Nop.Plugin.Misc.AIPlugin/docs/README.md | 1 + 17 files changed, 766 insertions(+), 17 deletions(-) create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/OrderAttributesController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Models/CustomerLicensePlateEntry.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/OrderSiteSnapshotBuilder.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/docs/INNVOICE.md diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerAttributesController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerAttributesController.cs index 95d06bd..2ac7cab 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerAttributesController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerAttributesController.cs @@ -86,4 +86,35 @@ public class CustomerAttributesController : BasePluginController return Json(new { success = true, siteCount = entries.Count, defaultAddressId = effectiveDefault }); } + + [HttpPost] + [Route("Admin/CustomerAttributes/SaveLicensePlates")] + public async Task SaveLicensePlates(int customerId, string[] plates, string? defaultPlate) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + var customer = await _customerService.GetCustomerByIdAsync(customerId); + if (customer == null) + return Json(new { success = false, error = "Customer not found" }); + + var cleaned = (plates ?? Array.Empty()) + .Select(p => (p ?? string.Empty).Trim().ToUpperInvariant()) + .Where(p => p.Length > 0) + .Distinct() + .ToList(); + + var def = (defaultPlate ?? string.Empty).Trim().ToUpperInvariant(); + var effectiveDefault = cleaned.Contains(def) ? def : null; + + var entries = cleaned + .Select(p => new CustomerLicensePlateEntry { Plate = p, IsDefault = p == effectiveDefault }) + .ToList(); + + var json = JsonSerializer.Serialize(entries); + await _attributeService.InsertOrUpdateGenericAttributeAsync( + customerId, FruitBankPluginConst.CustomerLicensePlatesAttribute, json, storeId: 0); + + return Json(new { success = true, plateCount = entries.Count, defaultPlate = effectiveDefault }); + } } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/OrderAttributesController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/OrderAttributesController.cs new file mode 100644 index 0000000..0cc8cc6 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/OrderAttributesController.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Orders; +using Nop.Plugin.Misc.FruitBankPlugin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Customers; +using Nop.Services.Orders; +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; + +/// +/// Inline-save endpoints for the FruitBank order attributes (site / license plate) +/// on the admin order page (rendered by OrderAttributesViewComponent). Stored as +/// store-agnostic (StoreId 0) order generic attributes via , +/// using the same keys the OrderDto exposes. +/// +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +public class OrderAttributesController : BasePluginController +{ + private readonly FruitBankAttributeService _attributeService; + private readonly ICustomerService _customerService; + private readonly IOrderService _orderService; + private readonly IPermissionService _permissionService; + + public OrderAttributesController( + FruitBankAttributeService attributeService, + ICustomerService customerService, + IOrderService orderService, + IPermissionService permissionService) + { + _attributeService = attributeService; + _customerService = customerService; + _orderService = orderService; + _permissionService = permissionService; + } + + [HttpPost] + [Route("Admin/OrderAttributes/SaveSite")] + public async Task SaveSite(int orderId, int addressId) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + var order = await _orderService.GetOrderByIdAsync(orderId); + if (order == null) return Json(new { success = false, error = "Rendelés nem található" }); + + // Clear + if (addressId <= 0) + { + await _attributeService.DeleteGenericAttributeAsync(orderId, FruitBankPluginConst.OrderSiteAttribute, 0); + return Json(new { success = true, cleared = true }); + } + + // The chosen address MUST be one of the customer's sites (telephely) — not a + // shipping/billing address. + var sitesJson = await _attributeService + .GetGenericAttributeValueAsync(order.CustomerId, FruitBankPluginConst.CustomerSitesAttribute, 0); + if (!ParseSiteAddressIds(sitesJson).Contains(addressId)) + return Json(new { success = false, error = "A cím nem a vevő telephelye." }); + + var address = (await _customerService.GetAddressesByCustomerIdAsync(order.CustomerId)) + .FirstOrDefault(a => a.Id == addressId); + if (address == null) return Json(new { success = false, error = "Cím nem található" }); + + var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId); + var snapshot = OrderSiteSnapshotBuilder.FromAddress(address, customer?.VatNumber); + var json = Newtonsoft.Json.JsonConvert.SerializeObject(snapshot); + + await _attributeService.InsertOrUpdateGenericAttributeAsync( + orderId, FruitBankPluginConst.OrderSiteAttribute, json, 0); + + return Json(new { success = true, display = snapshot.Display }); + } + + [HttpPost] + [Route("Admin/OrderAttributes/SaveLicensePlate")] + public async Task SaveLicensePlate(int orderId, string? licensePlate) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + var order = await _orderService.GetOrderByIdAsync(orderId); + if (order == null) return Json(new { success = false, error = "Rendelés nem található" }); + + var plate = licensePlate?.Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(plate)) + { + await _attributeService.DeleteGenericAttributeAsync(orderId, FruitBankPluginConst.OrderLicensePlateAttribute, 0); + return Json(new { success = true, cleared = true }); + } + + await _attributeService.InsertOrUpdateGenericAttributeAsync( + orderId, FruitBankPluginConst.OrderLicensePlateAttribute, plate, 0); + + return Json(new { success = true, licensePlate = plate }); + } + + private static HashSet ParseSiteAddressIds(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(); + try + { + var entries = JsonSerializer.Deserialize>(json) ?? new(); + return entries.Select(e => e.AddressId).ToHashSet(); + } + catch { return new(); } + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs b/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs index 3fb892d..0cafbfa 100644 --- a/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs +++ b/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs @@ -50,10 +50,31 @@ public class CustomerCreditWidgetViewComponent : NopViewComponent }; await PopulateSitesAsync(model, customerId); + await PopulatePlatesAsync(model, customerId); return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model); } + private async Task PopulatePlatesAsync(CustomerCreditWidgetModel model, int customerId) + { + var json = await _attributeService + .GetGenericAttributeValueAsync(customerId, FruitBankPluginConst.CustomerLicensePlatesAttribute, storeId: 0); + + var entries = ParsePlates(json); + model.LicensePlates = entries + .Select(e => new CustomerLicensePlateRow { Plate = e.Plate, IsDefault = e.IsDefault }) + .ToList(); + model.PlateCount = model.LicensePlates.Count; + model.DefaultPlate = model.LicensePlates.FirstOrDefault(p => p.IsDefault)?.Plate; + } + + private static List ParsePlates(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(); + try { return JsonSerializer.Deserialize>(json) ?? new(); } + catch { return new(); } + } + // Merge the customer's current addresses with the saved site list (orphaned // address ids — deleted addresses — are dropped because we iterate addresses). private async Task PopulateSitesAsync(CustomerCreditWidgetModel model, int customerId) diff --git a/Nop.Plugin.Misc.AIPlugin/Components/OrderAttributesViewComponent.cs b/Nop.Plugin.Misc.AIPlugin/Components/OrderAttributesViewComponent.cs index a832c88..3f92e14 100644 --- a/Nop.Plugin.Misc.AIPlugin/Components/OrderAttributesViewComponent.cs +++ b/Nop.Plugin.Misc.AIPlugin/Components/OrderAttributesViewComponent.cs @@ -1,14 +1,17 @@ -// File: Plugins/YourCompany.ProductAttributes/Components/ProductAttributesViewComponent.cs - +using System.Text.Json; using FruitBank.Common.Interfaces; using Microsoft.AspNetCore.Mvc; using Nop.Core; using Nop.Core.Domain.Catalog; +using Nop.Core.Domain.Common; +using Nop.Core.Domain.Customers; using Nop.Core.Domain.Orders; using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Models; using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders; using Nop.Plugin.Misc.FruitBankPlugin.Services; using Nop.Services.Common; +using Nop.Services.Customers; using Nop.Web.Areas.Admin.Models.Catalog; using Nop.Web.Areas.Admin.Models.Orders; using Nop.Web.Framework.Components; @@ -21,15 +24,18 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly IWorkContext _workContext; private readonly IStoreContext _storeContext; + private readonly ICustomerService _customerService; private FruitBankDbContext _dbContext; - public OrderAttributesViewComponent(FruitBankDbContext dbContext, FruitBankAttributeService fruitBankAttributeService, IWorkContext workContext, IStoreContext storeContext) + public OrderAttributesViewComponent(FruitBankDbContext dbContext, FruitBankAttributeService fruitBankAttributeService, + IWorkContext workContext, IStoreContext storeContext, ICustomerService customerService) { _workContext = workContext; _storeContext = storeContext; _fruitBankAttributeService = fruitBankAttributeService; + _customerService = customerService; - _dbContext= dbContext; + _dbContext = dbContext; } public async Task InvokeAsync(string widgetZone, object additionalData) @@ -45,19 +51,69 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components model.IsMeasurable = orderDto.IsMeasurable; model.DateOfReceipt = orderDto.DateOfReceipt; model.OrderDto = orderDto; - //var orderPickupAttributeValue = await _fruitBankAttributeService.GetGenericAttributeValueAsync(model.OrderId, nameof(IOrderDto.DateOfReceipt)); - //if (orderPickupAttributeValue.HasValue && orderPickupAttributeValue.Value != DateTime.MinValue) - //{ - // model.DateOfReceipt = orderPickupAttributeValue; - //} - //else - //{ - // model.DateOfReceipt = null; - //} + // Site (telephely) + license plate (rendszám) + model.CustomerId = orderDto.CustomerId; + model.Site = orderDto.Site; + model.LicensePlate = orderDto.LicensePlate; + model.CustomerSites = await BuildCustomerSiteOptionsAsync(orderDto.CustomerId); + model.CustomerLicensePlateOptions = await BuildCustomerPlateOptionsAsync(orderDto.CustomerId); } return View("~/Plugins/Misc.FruitBankPlugin/Views/OrderAttributes.cshtml", model); } + + // The customer's current sites (telephely) → selector options. Orphaned site + // ids (deleted addresses) drop out because we iterate the live addresses. + private async Task> BuildCustomerSiteOptionsAsync(int customerId) + { + if (customerId <= 0) return new(); + + var json = await _fruitBankAttributeService + .GetGenericAttributeValueAsync(customerId, FruitBankPluginConst.CustomerSitesAttribute, 0); + if (string.IsNullOrWhiteSpace(json)) return new(); + + List entries; + try { entries = JsonSerializer.Deserialize>(json) ?? new(); } + catch { return new(); } + if (entries.Count == 0) return new(); + + var byId = entries.GroupBy(e => e.AddressId).ToDictionary(g => g.Key, g => g.First()); + var addresses = await _customerService.GetAddressesByCustomerIdAsync(customerId); + + return addresses + .Where(a => byId.ContainsKey(a.Id)) + .Select(a => new OrderSiteOption + { + AddressId = a.Id, + Display = FormatAddress(a), + IsDefault = byId[a.Id].IsDefault + }) + .ToList(); + } + + private async Task> BuildCustomerPlateOptionsAsync(int customerId) + { + if (customerId <= 0) return new(); + + var json = await _fruitBankAttributeService + .GetGenericAttributeValueAsync(customerId, FruitBankPluginConst.CustomerLicensePlatesAttribute, 0); + if (string.IsNullOrWhiteSpace(json)) return new(); + + try + { + var entries = JsonSerializer.Deserialize>(json) ?? new(); + return entries.Select(e => e.Plate).Where(p => !string.IsNullOrWhiteSpace(p)).Distinct().ToList(); + } + catch { return new(); } + } + + private static string FormatAddress(Address a) + { + var name = !string.IsNullOrWhiteSpace(a.Company) ? a.Company : $"{a.FirstName} {a.LastName}".Trim(); + var locality = $"{a.ZipPostalCode} {a.City}".Trim(); + var display = string.Join(", ", new[] { name, a.Address1, locality }.Where(p => !string.IsNullOrWhiteSpace(p))); + return string.IsNullOrWhiteSpace(display) ? $"#{a.Id}" : display; + } } } diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs b/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs index 2a23237..cb3611e 100644 --- a/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs +++ b/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs @@ -13,6 +13,9 @@ using Nop.Services.Events; using Mango.Nop.Core.Extensions; using Nop.Core.Domain.Orders; using Nop.Services.Catalog; +using Nop.Services.Customers; +using Nop.Core.Domain.Customers; +using Nop.Plugin.Misc.FruitBankPlugin.Models; namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.EventConsumers; @@ -31,7 +34,9 @@ public class FruitBankEventConsumer : IConsumer>, IConsumer>, - IConsumer> + IConsumer>, + + IConsumer { //private readonly CustomPriceCalculationService _customPriceCalculationService; @@ -40,16 +45,100 @@ public class FruitBankEventConsumer : private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly PreOrderConversionService _preorderConversionService; private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ICustomerService _customerService; public FruitBankEventConsumer(IHttpContextAccessor httpContextAcc, FruitBankDbContext ctx, MeasurementService measurementService, FruitBankAttributeService fruitBankAttributeService, PreOrderConversionService preorderConversionService, - IServiceScopeFactory serviceScopeFactory, IEnumerable logWriters) : base(ctx, httpContextAcc, logWriters) + IServiceScopeFactory serviceScopeFactory, ICustomerService customerService, IEnumerable logWriters) : base(ctx, httpContextAcc, logWriters) { _ctx = ctx; _measurementService = measurementService; _fruitBankAttributeService = fruitBankAttributeService; _preorderConversionService = preorderConversionService; _serviceScopeFactory = serviceScopeFactory; + _customerService = customerService; + } + + // ── Order placed: auto-assign the customer's DEFAULT site (telephely) and DEFAULT + // license plate (rendszám) to the order. Covers every creation path that fires + // OrderPlacedEvent (checkout, admin, preorder conversion, OrderDraft). No default + // → left empty. The two are independent (one missing must not skip the other). + public async Task HandleEventAsync(OrderPlacedEvent eventMessage) + { + var order = eventMessage.Order; + if (order == null || order.CustomerId <= 0) return; + + await TryAssignDefaultSiteAsync(order); + await TryAssignDefaultPlateAsync(order); + } + + private async Task TryAssignDefaultSiteAsync(Order order) + { + try + { + // Don't overwrite an already-set site (e.g. event re-publish). + var existing = await _fruitBankAttributeService + .GetGenericAttributeValueAsync(order.Id, FruitBankPluginConst.OrderSiteAttribute, 0); + if (!string.IsNullOrWhiteSpace(existing)) return; + + var sitesJson = await _fruitBankAttributeService + .GetGenericAttributeValueAsync(order.CustomerId, FruitBankPluginConst.CustomerSitesAttribute, 0); + if (string.IsNullOrWhiteSpace(sitesJson)) return; + + List sites; + try { sites = System.Text.Json.JsonSerializer.Deserialize>(sitesJson) ?? new(); } + catch { return; } + + var defaultSite = sites.FirstOrDefault(s => s.IsDefault); + if (defaultSite == null) return; + + var address = (await _customerService.GetAddressesByCustomerIdAsync(order.CustomerId)) + .FirstOrDefault(a => a.Id == defaultSite.AddressId); + if (address == null) return; + + var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId); + var snapshot = OrderSiteSnapshotBuilder.FromAddress(address, customer?.VatNumber); + var json = Newtonsoft.Json.JsonConvert.SerializeObject(snapshot); + + await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync( + order.Id, FruitBankPluginConst.OrderSiteAttribute, json, 0); + + Logger.Info($"[FruitBankEventConsumer] Order #{order.Id}: auto-assigned default site (address #{address.Id})."); + } + catch (Exception ex) + { + Logger.Error($"[FruitBankEventConsumer] OrderPlaced site auto-assign failed for order #{order.Id}: {ex.Message}", ex); + } + } + + private async Task TryAssignDefaultPlateAsync(Order order) + { + try + { + var existing = await _fruitBankAttributeService + .GetGenericAttributeValueAsync(order.Id, FruitBankPluginConst.OrderLicensePlateAttribute, 0); + if (!string.IsNullOrWhiteSpace(existing)) return; + + var platesJson = await _fruitBankAttributeService + .GetGenericAttributeValueAsync(order.CustomerId, FruitBankPluginConst.CustomerLicensePlatesAttribute, 0); + if (string.IsNullOrWhiteSpace(platesJson)) return; + + List plates; + try { plates = System.Text.Json.JsonSerializer.Deserialize>(platesJson) ?? new(); } + catch { return; } + + var defaultPlate = plates.FirstOrDefault(p => p.IsDefault)?.Plate; + if (string.IsNullOrWhiteSpace(defaultPlate)) return; + + await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync( + order.Id, FruitBankPluginConst.OrderLicensePlateAttribute, defaultPlate, 0); + + Logger.Info($"[FruitBankEventConsumer] Order #{order.Id}: auto-assigned default license plate '{defaultPlate}'."); + } + catch (Exception ex) + { + Logger.Error($"[FruitBankEventConsumer] OrderPlaced plate auto-assign failed for order #{order.Id}: {ex.Message}", ex); + } } public override async Task HandleEventAsync(EntityUpdatedEvent eventMessage) diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs index e70f701..a54496e 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs @@ -32,5 +32,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin /// JSON list of the customer's site (telephely) addresses — see CustomerSiteEntry. public const string CustomerSitesAttribute = "customerSites"; + + /// JSON list of the customer's selectable license plates — see CustomerLicensePlateEntry. + public const string CustomerLicensePlatesAttribute = "customerLicensePlates"; + + // ── Order generic-attribute keys (KeyGroup "Order", StoreId 0) ─────────── + // MUST match the IOrderDto/OrderDto property names (the DTO reads them by nameof). + /// JSON snapshot of the order's site (telephely) — see FruitBank.Common OrderSiteSnapshot. + public const string OrderSiteAttribute = "Site"; + + /// License plate (rendszám) of the vehicle picking up the order. Raw; the NAV mapper normalizes. + public const string OrderLicensePlateAttribute = "LicensePlate"; } } diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs index abe0783..dd03d9c 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs @@ -378,6 +378,20 @@ namespace Nop.Plugin.Misc.FruitBankPlugin await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses", "This customer has no addresses yet (Addresses tab).", en); await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses", "Ehhez az ügyfélhez még nincs cím rögzítve a Címek fülön.", hu); + // ── Customer license plates (rendszámok) ──────────────────────────── + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Title", "License plates", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Title", "Rendszámok", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Edit", "Edit plates", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Edit", "Rendszámok szerkesztése", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.CountWord", "plate(s)", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.CountWord", "rendszám", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.None", "No plates set", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.None", "Nincs rendszám megadva", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.ModalTitle", "Manage plates", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.ModalTitle", "Rendszámok kezelése", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Add", "Add", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Add", "Hozzáadás", 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); diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml index 515bf8f..c72c0f6 100644 --- a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml +++ b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml @@ -118,4 +118,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml index 260d54f..6991747 100644 --- a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml +++ b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml @@ -118,4 +118,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs b/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs index b5adbb9..7ede298 100644 --- a/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs +++ b/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs @@ -32,6 +32,15 @@ public record CustomerCreditWidgetModel : BaseNopModel /// Display string of the default site, if any. public string? DefaultSiteDisplay { get; set; } + + // ── License plates (rendszámok) ──────────────────────────────────────────── + /// The customer's selectable plates — feeds the summary and the edit modal. + public List LicensePlates { get; set; } = new(); + + public int PlateCount { get; set; } + + /// The default plate, if any (auto-assigned to new orders). + public string? DefaultPlate { get; set; } } public class CustomerSiteAddressRow @@ -41,3 +50,9 @@ public class CustomerSiteAddressRow public bool IsSite { get; set; } public bool IsDefault { get; set; } } + +public class CustomerLicensePlateRow +{ + public string Plate { get; set; } = string.Empty; + public bool IsDefault { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Models/CustomerLicensePlateEntry.cs b/Nop.Plugin.Misc.AIPlugin/Models/CustomerLicensePlateEntry.cs new file mode 100644 index 0000000..983d077 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Models/CustomerLicensePlateEntry.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Models; + +/// +/// One selectable license plate (rendszám) in a customer's plate list, stored as JSON +/// in the customerLicensePlates generic attribute. Extensible with per-plate +/// fields (e.g. Note) later. One entry may be the default, auto-assigned to new orders. +/// +public class CustomerLicensePlateEntry +{ + [JsonPropertyName("plate")] + public string Plate { get; set; } = string.Empty; + + [JsonPropertyName("isDefault")] + public bool IsDefault { get; set; } + + [JsonPropertyName("note")] + public string? Note { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Models/Orders/OrderAttributesModel.cs b/Nop.Plugin.Misc.AIPlugin/Models/Orders/OrderAttributesModel.cs index 49cf5cc..b86f348 100644 --- a/Nop.Plugin.Misc.AIPlugin/Models/Orders/OrderAttributesModel.cs +++ b/Nop.Plugin.Misc.AIPlugin/Models/Orders/OrderAttributesModel.cs @@ -14,8 +14,28 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders [NopResourceDisplayName("Plugins.YourCompany.ProductAttributes.Fields.DateOfReceipt")] public DateTime? DateOfReceipt { get; set; } - + public OrderDto OrderDto { get; set; } + // ── Site (telephely) + license plate (rendszám) ──────────────────────── + public int CustomerId { get; set; } + + /// The order's current frozen site snapshot, if any. + public OrderSiteSnapshot? Site { get; set; } + + public string? LicensePlate { get; set; } + + /// The customer's current sites — choices for the order-site selector. + public List CustomerSites { get; set; } = new(); + + /// The customer's saved license plates — suggestions (datalist) for the order plate field. + public List CustomerLicensePlateOptions { get; set; } = new(); + } + + public class OrderSiteOption + { + public int AddressId { get; set; } + public string Display { get; set; } = string.Empty; + public bool IsDefault { get; set; } } } \ No newline at end of file diff --git a/Nop.Plugin.Misc.AIPlugin/Services/OrderSiteSnapshotBuilder.cs b/Nop.Plugin.Misc.AIPlugin/Services/OrderSiteSnapshotBuilder.cs new file mode 100644 index 0000000..136f144 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/OrderSiteSnapshotBuilder.cs @@ -0,0 +1,39 @@ +using FruitBank.Common.Dtos; +using Nop.Core.Domain.Common; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +/// +/// Builds the frozen for an order from a customer +/// Address. Shaped to the NAV EKÁER LocationType (UnloadLocation). Stateless — used +/// by both the OrderPlacedEvent auto-assign and the manual save endpoint. +/// +public static class OrderSiteSnapshotBuilder +{ + public static OrderSiteSnapshot FromAddress(Address a, string? customerVatNumber) + { + var name = !string.IsNullOrWhiteSpace(a.Company) + ? a.Company + : $"{a.FirstName} {a.LastName}".Trim(); + + var street = string.Join(" ", new[] { a.Address1, a.Address2 } + .Where(s => !string.IsNullOrWhiteSpace(s))); + + var locality = $"{a.ZipPostalCode} {a.City}".Trim(); + + var display = string.Join(", ", new[] { name, street, locality } + .Where(s => !string.IsNullOrWhiteSpace(s))); + + return new OrderSiteSnapshot + { + SourceAddressId = a.Id, + Name = string.IsNullOrWhiteSpace(name) ? null : name, + VatNumber = customerVatNumber, + CountryCode = "HU", // outgoing EKÁER is domestic; export would resolve a.CountryId + ZipCode = a.ZipPostalCode, + City = a.City, + Street = string.IsNullOrWhiteSpace(street) ? null : street, + Display = string.IsNullOrWhiteSpace(display) ? $"#{a.Id}" : display + }; + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml index 73b5961..39fefa1 100644 --- a/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml @@ -78,6 +78,32 @@
+ +
+
+ +
+
+ + @if (Model.PlateCount > 0) + { + @Model.PlateCount @T("Plugins.Misc.FruitBankPlugin.CustomerPlates.CountWord") + if (!string.IsNullOrWhiteSpace(Model.DefaultPlate)) + { + (@T("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel"): @Model.DefaultPlate) + } + } + else + { + @T("Plugins.Misc.FruitBankPlugin.CustomerPlates.None") + } + + +
+
@@ -218,6 +244,51 @@
+@* ── License plates edit modal ─────────────────────────────────────────────── *@ + + diff --git a/Nop.Plugin.Misc.AIPlugin/Views/OrderAttributes.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/OrderAttributes.cshtml index 36b8caf..a64d270 100644 --- a/Nop.Plugin.Misc.AIPlugin/Views/OrderAttributes.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Views/OrderAttributes.cshtml @@ -1,4 +1,5 @@ -@using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders +@using System.Linq +@using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders @model OrderAttributesModel @@ -37,6 +38,69 @@

+ @* ── Telephely ─────────────────────────────────────────────── *@ +
+
+ +
+
+ @if (Model.CustomerSites.Any()) + { + + @if (Model.Site != null && Model.CustomerSites.All(s => s.AddressId != Model.Site.SourceAddressId)) + { + Jelenlegi (már nem a vevő telephelye): @Model.Site.Display + } + else if (Model.Site == null) + { + Nincs telephely beállítva ehhez a rendeléshez. + } + } + else + { +
+ A vevőnek nincs telephelye beállítva. + Beállítás a vevőnél +
+ } +
+
+ @if (Model.CustomerSites.Any()) + { + + } +
+
+
+ @* ── Rendszám ──────────────────────────────────────────────── *@ +
+
+ +
+
+ + + @foreach (var p in Model.CustomerLicensePlateOptions) + { + + } + +
+
+ +
+
+