diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Components/_FruitBankDashboard.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Components/_FruitBankDashboard.cshtml new file mode 100644 index 0000000..6de8dc6 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Components/_FruitBankDashboard.cshtml @@ -0,0 +1,428 @@ +@* FruitBank operational dashboard — loaded via AJAX, never blocks page render *@ + +
+ + Operatív adatok betöltése... +
+ + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Components/_WelcomeMessage.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Components/_WelcomeMessage.cshtml index 074d727..35e3f66 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Components/_WelcomeMessage.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Components/_WelcomeMessage.cshtml @@ -1,62 +1,59 @@ -@using AyCode.Utils.Extensions @using Nop.Core; -@using Nop.Plugin.Misc.FruitBankPlugin.Services @using Nop.Core.Domain.Customers @inject IWorkContext workContext -@inject AICalculationService aiCalculationService + +@{ + var customer = await workContext.GetCurrentCustomerAsync(); +}
-
-

- - Üdvözöljük a Fruit Bank rendszerben! -

-
-
-
-
- @{ - Customer customer = await workContext.GetCurrentCustomerAsync(); +
+

+ + Üdvözöljük a Fruit Bank rendszerben! +

+
+
+
+
+
+

+ + Összefoglaló betöltése... +

+
+

+ Mai dátum: @DateTime.Now.ToString("yyyy. MMMM dd., dddd", new System.Globalization.CultureInfo("hu-HU")) +

+
+
+
+
Mai összefoglaló
+
    +
  • Bejelentkezés ideje: @DateTime.Now.ToString("HH:mm")
  • +
  • Aktív napok: @DateTime.Now.DayOfYear
  • +
  • Szép napot kívánunk!
  • +
+
+
+
+
+
- var welcomeMessage = await aiCalculationService.GetWelcomeMessageAsync(customer); - if (welcomeMessage.IsNullOrWhiteSpace()) - //if(string.IsNullOrWhiteSpace(welcomeMessage)) - { -

Nincs kapcsolat az AI szerverrel...

- } - else - { - var email = customer.Email; -

Ssytem check

-

@welcomeMessage

- } - } -

- Mai dátum: @DateTime.Now.ToString("yyyy. MMMM dd., dddd", new System.Globalization.CultureInfo("hu-HU")) -

- @* -
-
-
-
Gyümölcsök
-

Friss, minőségi gyümölcsök széles választéka

-
-
-
Gyors szállítás
-

Megbízható kiszállítás országszerte

-
-
*@ -
-
-
-
Mai összefoglaló
-
    -
  • Bejelentkezés ideje: @DateTime.Now.ToString("HH:mm")
  • -
  • Aktív napok: @DateTime.Now.DayOfYear
  • -
  • Szép napot kívánunk!
  • -
-
-
-
-
- \ No newline at end of file + diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomDashboardController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomDashboardController.cs index 11871b7..0e7f3c3 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomDashboardController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomDashboardController.cs @@ -1,8 +1,14 @@ -using ExCSS; +using ExCSS; +using FruitBank.Common.Entities; +using FruitBank.Common.Enums; using Microsoft.AspNetCore.Mvc; using Nop.Core; using Nop.Core.Domain.Common; using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Orders; +using Nop.Core.Domain.Payments; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; using Nop.Services.Common; using Nop.Services.Configuration; using Nop.Services.Localization; @@ -29,12 +35,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers protected readonly ISettingService _settingService; protected readonly IGenericAttributeService _genericAttributeService; protected readonly IWorkContext _workContext; + protected readonly AICalculationService _aiCalculationService; + protected readonly FruitBankDbContext _fruitBankDbContext; + protected readonly PreorderDbContext _preorderDbContext; #endregion #region Ctor - public CustomDashboardController(AdminAreaSettings adminAreaSettings, + public CustomDashboardController( + AdminAreaSettings adminAreaSettings, ICommonModelFactory commonModelFactory, IHomeModelFactory homeModelFactory, ILocalizationService localizationService, @@ -42,7 +52,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers IPermissionService permissionService, ISettingService settingService, IGenericAttributeService genericAttributeService, - IWorkContext workContext) + IWorkContext workContext, + AICalculationService aiCalculationService, + FruitBankDbContext fruitBankDbContext, + PreorderDbContext preorderDbContext) { _adminAreaSettings = adminAreaSettings; _commonModelFactory = commonModelFactory; @@ -53,6 +66,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers _settingService = settingService; _workContext = workContext; _genericAttributeService = genericAttributeService; + _aiCalculationService = aiCalculationService; + _fruitBankDbContext = fruitBankDbContext; + _preorderDbContext = preorderDbContext; } #endregion @@ -61,9 +77,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers public virtual async Task Index() { - //display a warning to a store owner if there are some error var customer = await _workContext.GetCurrentCustomerAsync(); - var email = customer.Email; var hideCard = await _genericAttributeService.GetAttributeAsync(customer, NopCustomerDefaults.HideConfigurationStepsAttribute); var closeCard = await _genericAttributeService.GetAttributeAsync(customer, NopCustomerDefaults.CloseConfigurationStepsAttribute); @@ -73,11 +87,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers if (warnings.Any(warning => warning.Level == SystemWarningLevel.Fail || warning.Level == SystemWarningLevel.Warning)) { var locale = await _localizationService.GetResourceAsync("Admin.System.Warnings.Errors"); - _notificationService.WarningNotification(string.Format(locale, Url.Action("Warnings", "Common")), false); //do not encode URLs + _notificationService.WarningNotification(string.Format(locale, Url.Action("Warnings", "Common")), false); } } - //progress of localization var currentLanguage = await _workContext.GetWorkingLanguageAsync(); var progress = await _genericAttributeService.GetAttributeAsync(currentLanguage, NopCommonDefaults.LanguagePackProgressAttribute); if (!string.IsNullOrEmpty(progress)) @@ -87,10 +100,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers await _genericAttributeService.SaveAttributeAsync(currentLanguage, NopCommonDefaults.LanguagePackProgressAttribute, string.Empty); } - //prepare model var model = await _homeModelFactory.PrepareDashboardModelAsync(new DashboardModel()); - - //return View(model); return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Index.cshtml", model); } @@ -99,7 +109,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers { _adminAreaSettings.HideAdvertisementsOnAdminArea = !_adminAreaSettings.HideAdvertisementsOnAdminArea; await _settingService.SaveSettingAsync(_adminAreaSettings); - return Content("Setting changed"); } @@ -145,7 +154,300 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers return PartialView("Table", model); } + [HttpGet] + public virtual async Task GetWelcomeMessage() + { + try + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var message = await _aiCalculationService.GetWelcomeMessageAsync(customer); + + if (string.IsNullOrWhiteSpace(message)) + return Content("

Nincs kapcsolat az AI szerverrel...

"); + + return Content($"

{System.Net.WebUtility.HtmlEncode(message)}

"); + } + catch (Exception ex) + { + return Content($"

{System.Net.WebUtility.HtmlEncode(ex.Message)}

"); + } + } + + /// + /// Returns all FruitBank dashboard data as a single JSON payload. + /// Called via AJAX after page load — never blocks the dashboard render. + /// + [HttpGet] + public virtual async Task GetDashboardData() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_VIEW)) + return Forbid(); + + var today = DateTime.Now.Date; + + // ── Batch 1: parallel queries ───────────────────────────────────── + // NOTE: PreorderItems is not a LinqToDB [Association] — never use LoadWith on it. + // Preorders and items are loaded separately and joined in memory below. + var allOrdersTask = _fruitBankDbContext.OrderDtos.GetAll(true).ToListAsync(); + var allCreditsTask = _fruitBankDbContext.CustomerCredits.GetAll().ToListAsync(); + var allPreordersTask = _preorderDbContext.Preorders.GetAll().ToListAsync(); + var unprocessedDocsTask = _fruitBankDbContext.ShippingDocuments.GetAllNotMeasured(true).ToListAsync(); + + await Task.WhenAll(allOrdersTask, allCreditsTask, allPreordersTask, unprocessedDocsTask); + + var allOrders = await allOrdersTask; + var credits = await allCreditsTask; + var unprocessedDocs = await unprocessedDocsTask; + + // Filter pending preorders in memory — LinqToDB cannot translate enum comparisons to SQL + var pendingStatuses = new[] { PreorderStatus.Pending, PreorderStatus.PartiallyFulfilled }; + var pendingPreorders = (await allPreordersTask) + .Where(p => pendingStatuses.Contains(p.Status)) + .ToList(); + + // ── Batch 1b: preorder items for pending preorders only ──────────── + var pendingPreorderIds = pendingPreorders.Select(p => p.Id).ToList(); + var pendingItems = pendingPreorderIds.Any() + ? await _preorderDbContext.PreorderItems.GetAll() + .Where(i => pendingPreorderIds.Contains(i.PreorderId)) + .ToListAsync() + : new List(); + + // ── Today's orders (in-memory filter, same pattern as AICalculationService) + var todaysOrders = allOrders + .Where(o => o.DateOfReceiptOrCreated.Date == today) + .OrderByDescending(o => o.DateOfReceiptOrCreated) + .ToList(); + + // ── Batch 2: unpaid balances for credit customers ────────────────── + var creditCustomerIds = credits.Select(c => c.CustomerId).ToList(); + var unpaidByCustomer = new Dictionary(); + + if (creditCustomerIds.Any()) + { + var unpaid = await _fruitBankDbContext.OrderDtos.GetAll(false) + .Where(o => creditCustomerIds.Contains(o.CustomerId) + && o.OrderStatusId != (int)OrderStatus.Cancelled + && o.PaymentStatusId < (int)PaymentStatus.Paid) + .Select(o => new { o.CustomerId, o.OrderTotal }) + .ToListAsync(); + + unpaidByCustomer = unpaid + .GroupBy(o => o.CustomerId) + .ToDictionary(g => g.Key, g => g.Sum(o => o.OrderTotal)); + } + + // ── Customer name helper: orders cache first, then ID fallback ───── + string CustomerName(int customerId) + { + var fromOrder = allOrders.FirstOrDefault(o => o.CustomerId == customerId)?.Customer; + if (fromOrder != null) + { + if (!string.IsNullOrEmpty(fromOrder.Company)) return fromOrder.Company; + if (!string.IsNullOrEmpty(fromOrder.Email)) return fromOrder.Email; + } + return $"#{customerId}"; + } + + // ── Preorder customer names (may not appear in allOrders) ────────── + var preorderCustomerIds = pendingPreorders + .Select(p => p.CustomerId).Distinct() + .Where(id => allOrders.All(o => o.CustomerId != id)) + .ToList(); + + var preorderCustomerLookup = new Dictionary(); + if (preorderCustomerIds.Any()) + { + var customers = await _preorderDbContext.Customers.Table + .Where(c => preorderCustomerIds.Contains(c.Id)) + .Select(c => new { c.Id, c.Company, c.Email }) + .ToListAsync(); + + foreach (var c in customers) + preorderCustomerLookup[c.Id] = !string.IsNullOrEmpty(c.Company) ? c.Company : c.Email; + } + + string PreorderCustomerName(int customerId) + => preorderCustomerLookup.TryGetValue(customerId, out var name) + ? name + : CustomerName(customerId); + + // ───────────────────────────────────────────────────────────────── + // Section 1: Today's pipeline + // ───────────────────────────────────────────────────────────────── + var todayRows = todaysOrders.Take(20).Select(o => + { + var hasInnVoice = o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId"); + return new + { + id = o.Id, + orderNumber = o.CustomOrderNumber, + company = CustomerName(o.CustomerId), + total = o.OrderTotal, + measuringStatus = o.MeasuringStatus.ToString(), + audited = o.IsAllOrderItemAudited, + hasInnVoice, + orderStatus = o.OrderStatus.ToString(), + dateOfReceipt = o.DateOfReceiptOrCreated.ToString("yyyy.MM.dd HH:mm") + }; + }).ToList(); + + var pipeline = new + { + total = todaysOrders.Count, + measuring = todaysOrders.Count(o => o.MeasuringStatus == MeasuringStatus.Started), + audited = todaysOrders.Count(o => o.IsAllOrderItemAudited), + missingInnVoice = todaysOrders.Count(o => + o.IsAllOrderItemAudited && + !o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId")), + completed = todaysOrders.Count(o => o.OrderStatus == OrderStatus.Complete), + rows = todayRows + }; + + // ───────────────────────────────────────────────────────────────── + // Section 2: Alerts + // ───────────────────────────────────────────────────────────────── + var alerts = new List(); + + // Audited today but InnVoice sync missing and not yet completed + foreach (var o in todaysOrders.Where(o => + o.IsAllOrderItemAudited && + o.OrderStatus != OrderStatus.Complete && + !o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId"))) + { + alerts.Add(new + { + type = "missing_innvoice", + orderId = o.Id, + orderNumber = o.CustomOrderNumber, + company = CustomerName(o.CustomerId), + message = "Auditálva, InnVoice szinkron hiányzik" + }); + } + + // Measuring started but DateOfReceipt is past (stale, stuck in progress) + foreach (var o in allOrders.Where(o => + o.MeasuringStatus == MeasuringStatus.Started && + o.DateOfReceiptOrCreated.Date < today)) + { + alerts.Add(new + { + type = "stale_measuring", + orderId = o.Id, + orderNumber = o.CustomOrderNumber, + company = CustomerName(o.CustomerId), + dateOfReceipt = o.DateOfReceiptOrCreated.ToString("yyyy.MM.dd"), + message = "Régi, befejezetlen mérés" + }); + } + + // Partners over their credit limit + foreach (var credit in credits) + { + var outstanding = unpaidByCustomer.GetValueOrDefault(credit.CustomerId, 0m); + if (outstanding > credit.CreditLimit) + { + alerts.Add(new + { + type = "credit_exceeded", + customerId = credit.CustomerId, + company = CustomerName(credit.CustomerId), + outstanding, + creditLimit = credit.CreditLimit, + message = "Hitelkeret túllépve" + }); + } + } + + // Pending preorders older than 7 days + var sevenDaysAgo = DateTime.UtcNow.AddDays(-7); + foreach (var p in pendingPreorders.Where(p => + p.Status == PreorderStatus.Pending && + p.CreatedOnUtc < sevenDaysAgo)) + { + alerts.Add(new + { + type = "old_preorder", + preorderId = p.Id, + company = PreorderCustomerName(p.CustomerId), + createdAt = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd"), + message = "Régi, nyitott előrendelés" + }); + } + + // ───────────────────────────────────────────────────────────────── + // Section 3: Credit status (only partners with a limit set) + // ───────────────────────────────────────────────────────────────── + var creditRows = credits + .Select(credit => + { + var outstanding = unpaidByCustomer.GetValueOrDefault(credit.CustomerId, 0m); + var remaining = credit.CreditLimit - outstanding; + var status = outstanding > credit.CreditLimit + ? "exceeded" + : credit.CreditLimit > 0 && outstanding > credit.CreditLimit * 0.8m + ? "warning" + : "ok"; + return new + { + customerId = credit.CustomerId, + company = CustomerName(credit.CustomerId), + creditLimit = credit.CreditLimit, + outstanding, + remaining, + status + }; + }) + .OrderByDescending(c => c.outstanding) + .ToList(); + + // ───────────────────────────────────────────────────────────────── + // Section 4: Pending preorders (items joined in memory) + // ───────────────────────────────────────────────────────────────── + var preorderRows = pendingPreorders + .OrderBy(p => p.CreatedOnUtc) + .Select(p => + { + var items = pendingItems.Where(i => i.PreorderId == p.Id).ToList(); + return new + { + id = p.Id, + customerId = p.CustomerId, + company = PreorderCustomerName(p.CustomerId), + status = p.Status.ToString(), + createdAt = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd"), + itemCount = items.Count, + fulfilledCount = items.Count(i => i.Status == PreorderItemStatus.Fulfilled) + }; + }) + .ToList(); + + // ───────────────────────────────────────────────────────────────── + // Section 5: Unprocessed shipping documents + // ───────────────────────────────────────────────────────────────── + var docRows = unprocessedDocs + .OrderBy(d => d.ShippingDate) + .Select(doc => new + { + id = doc.Id, + partnerName = doc.Partner?.Name ?? "–", + shippingDate = doc.ShippingDate.ToString("yyyy.MM.dd"), + totalItems = doc.ShippingItems?.Count ?? 0, + measuredItems = doc.ShippingItems?.Count(si => si.IsMeasured) ?? 0 + }) + .ToList(); + + // ───────────────────────────────────────────────────────────────── + return Json(new + { + pipeline, + alerts, + creditStatus = creditRows, + pendingPreorders = preorderRows, + unprocessedDocs = docRows + }); + } + #endregion } } - diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs index d873cf0..9afa861 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs @@ -9,6 +9,7 @@ using FruitBank.Common.Dtos; using FruitBank.Common.Entities; using FruitBank.Common.Enums; using FruitBank.Common.Interfaces; +using FruitBank.Common.Server; using FruitBank.Common.Server.Interfaces; using FruitBank.Common.Server.Services.SignalRs; using FruitBank.Common.SignalRs; @@ -72,7 +73,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers private readonly CustomOrderModelFactory _orderModelFactory; private readonly ICustomOrderSignalREndpointServer _customOrderSignalREndpoint; private readonly IPermissionService _permissionService; - private readonly IGenericAttributeService _genericAttributeService; + //private readonly IGenericAttributeService _genericAttributeService; + private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly INotificationService _notificationService; private readonly ICustomerService _customerService; private readonly IProductService _productService; @@ -89,6 +91,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers protected readonly ITaxService _taxService; protected readonly MeasurementService _measurementService; protected readonly IWorkflowMessageService _workflowMessageService; + protected readonly FruitBankNotificationService _fruitBankNotificationService; + protected readonly IAddressService _addressService; + private readonly FruitBankOrderItemService _orderItemService; private static readonly char[] _separator = [',']; // ... other dependencies @@ -121,7 +126,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers IOrderModelFactory orderModelFactory, ICustomOrderSignalREndpointServer customOrderSignalREndpoint, IPermissionService permissionService, - IGenericAttributeService genericAttributeService, + //IGenericAttributeService genericAttributeService, + FruitBankAttributeService fruitBankAttributeService, INotificationService notificationService, ICustomerService customerService, IProductService productService, @@ -136,7 +142,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers IImportManager importManager, IDateTimeHelper dateTimeHelper, ITaxService taxService, - MeasurementService measurementService, IWorkflowMessageService workflowMessageService) + MeasurementService measurementService, + IWorkflowMessageService workflowMessageService, + FruitBankNotificationService fruitBankNotificationService, + IAddressService addressService, + FruitBankOrderItemService orderItemService) { _logger = new Logger(logWriters.ToArray()); @@ -147,7 +157,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers _orderModelFactory = orderModelFactory as CustomOrderModelFactory; _customOrderSignalREndpoint = customOrderSignalREndpoint; _permissionService = permissionService; - _genericAttributeService = genericAttributeService; + //_genericAttributeService = genericAttributeService; + _fruitBankAttributeService = fruitBankAttributeService; _notificationService = notificationService; _customerService = customerService; _productService = productService; @@ -165,7 +176,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers _taxService = taxService; _measurementService = measurementService; _workflowMessageService = workflowMessageService; + _fruitBankNotificationService = fruitBankNotificationService; + _addressService = addressService; + _orderItemService = orderItemService; + // ... initialize other deps + } #region CustomOrderSignalREndpoint @@ -247,7 +263,77 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/List.cshtml", model); } - [HttpPost] + [HttpPost] + public virtual async Task AdminQuickCreateOrder(int customerId, string orderProductsJson, string deliveryDateTime) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)) + return Json(new { success = false, error = "Hozzáférés megtagadva" }); + try + { + var customer = await _customerService.GetCustomerByIdAsync(customerId); + if (customer == null) return Json(new { success = false, error = "Az ügyfél nem található" }); + + var billingAddress = await _customerService.GetCustomerBillingAddressAsync(customer); + if (billingAddress == null) + { + var addresses = await _customerService.GetAddressesByCustomerIdAsync(customer.Id); + if (addresses?.Count > 0) { billingAddress = addresses[0]; customer.BillingAddressId = billingAddress.Id; await _customerService.UpdateCustomerAsync(customer); } + else return Json(new { success = false, error = "Az ügyfélnek nincs számlázási címe" }); + } + + var orderProducts = string.IsNullOrEmpty(orderProductsJson) + ? new List() + : Newtonsoft.Json.JsonConvert.DeserializeObject>(orderProductsJson); + + var store = await _storeContext.GetCurrentStoreAsync(); + var admin = await _workContext.GetCurrentCustomerAsync(); + + var order = new Order + { + OrderGuid = Guid.NewGuid(), CustomOrderNumber = "", CustomerId = customerId, + CustomerLanguageId = customer.LanguageId ?? 2, + CustomerTaxDisplayType = Nop.Core.Domain.Tax.TaxDisplayType.IncludingTax, + CustomerIp = string.Empty, + OrderStatus = Nop.Core.Domain.Orders.OrderStatus.Pending, + PaymentStatus = Nop.Core.Domain.Payments.PaymentStatus.Pending, + ShippingStatus = Nop.Core.Domain.Shipping.ShippingStatus.ShippingNotRequired, + CreatedOnUtc = DateTime.UtcNow, + BillingAddressId = customer.BillingAddressId ?? 0, + ShippingAddressId = customer.ShippingAddressId, + PaymentMethodSystemName = "Payments.CheckMoneyOrder", + CustomerCurrencyCode = "HUF", CurrencyRate = 1, + OrderTotal = 0, OrderSubtotalInclTax = 0, OrderSubtotalExclTax = 0, + OrderSubTotalDiscountInclTax = 0, OrderSubTotalDiscountExclTax = 0, + }; + + var ok = await _dbContext.TransactionSafeAsync(async _ => + { + await _orderService.InsertOrderAsync(order); + order.CustomOrderNumber = order.Id.ToString(); + await AddOrderItemsThenUpdateOrder(order, orderProducts, true, customer, store, admin); + return true; + }); + + if (!ok) return Json(new { success = false, error = "Rendelés létrehozása meghiúsult" }); + + if (!string.IsNullOrWhiteSpace(deliveryDateTime) && DateTime.TryParse(deliveryDateTime, out var deliveryDate)) + { + var formatted = deliveryDate.ToString("MM/dd/yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); + await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync( + order.Id, nameof(IOrderDto.DateOfReceipt), formatted, store.Id); + } + + _logger.Info($"[AdminQuickCreateOrder] Order #{order.Id} for customer #{customerId}"); + return Json(new { success = true, orderId = order.Id }); + } + catch (Exception ex) + { + _logger.Error($"[AdminQuickCreateOrder] {ex.Message}", ex); + return Json(new { success = false, error = ex.Message }); + } + } + + [HttpPost] [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)] public async Task OrderList(OrderSearchModelExtended searchModel) { @@ -434,7 +520,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers // store attributes in GenericAttribute table //await _genericAttributeService.SaveAttributeAsync(order, nameof(IMeasurable.IsMeasurable), model.IsMeasurable, _storeContext.GetCurrentStore().Id); - await _genericAttributeService.SaveAttributeAsync(order, nameof(IOrderDto.DateOfReceipt), model.DateOfReceipt, _storeContext.GetCurrentStore().Id); + //await _genericAttributeService.SaveAttributeAsync(order, nameof(IOrderDto.DateOfReceipt), model.DateOfReceipt, _storeContext.GetCurrentStore().Id); + await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync( + order.Id, + nameof(IOrderDto.DateOfReceipt), + model.DateOfReceipt.HasValue + ? model.DateOfReceipt.Value.ToString("MM/dd/yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) + : null, + _storeContext.GetCurrentStore().Id); + var orderDto = await _dbContext.OrderDtos.GetByIdAsync(model.OrderId, true); await _sendToClient.SendOrderChanged(orderDto); @@ -494,6 +588,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers { //no address at all, cannot create order _logger.Error($"Cannot create order for customer {customer.Id}, no billing address found."); + _notificationService.ErrorNotification("Cannot create order for customer, no billing address found. Please create a billing address for the customer first."); return RedirectToAction("List"); } } @@ -547,7 +642,28 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers //var orderDto = await _dbContext.OrderDtos.GetByIdAsync(order.Id, true); //await _sendToClient.SendMeasuringNotification("Módosult a rendelés, mérjétek újra!", orderDto); //var updatedOrder = await _orderService.GetOrderByIdAsync(order.Id); - await _workflowMessageService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId); + //await _workflowMessageService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId); + if(customer.BillingAddressId.HasValue) + { + //var billingAddress = await _addressService.GetAddressByIdAsync((int)customer.BillingAddressId); + if (billingAddress.Email != null) + { + if (!billingAddress.Email.EndsWith("inval.id")) + { + var messageResult = await _fruitBankNotificationService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId); + if (messageResult.First() != -1) + { + _notificationService.SuccessNotification("Order placed email sent to customer."); + } + else + { + _logger.Warning($"Order placed email was not sent to customer {customer.Id} because of invalid email address: {billingAddress.Email}"); + _notificationService.WarningNotification("Order placed email was not sent to customer because of invalid email address."); + } + } + } + + } return RedirectToAction("Edit", "Order", new { id = order.Id }); } @@ -591,6 +707,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers throw new Exception($"{errorText}"); } + //itt vajon elég ez a vizsgálat, vagy a priceCalculationService.GetFinalPriceAsync-al kéne lekérni a végső árat és azt összehasonlítani? - A. + //ha kedvezménye is van, de manuálisan is le van csökkentve az ár, akkor a kedvezményt látja a rendszer, és azt kellene összevetni a bejövő árral... - A. if (orderProductItem.Price != product.Price) { //manual price change @@ -601,27 +719,32 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers unitPricesIncludeDiscounts = true; } - + //itt ha includeDiscounts van, akkor már a beírt ár megy be? var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store); + _logger.Detail($"Adding order item: ProductId: {orderItem.ProductId}, Quantity: {orderItem.Quantity}, UnitPriceInclTax: {orderItem.UnitPriceInclTax}, UnitPriceExclTax: {orderItem.UnitPriceExclTax}, PriceInclTax: {orderItem.PriceInclTax}, PriceExclTax: {orderItem.PriceExclTax}"); + await _orderService.InsertOrderItemAsync(orderItem); await _productService.AdjustInventoryAsync(product, -orderItem.Quantity, orderItem.AttributesXml, string.Format(await _localizationService.GetResourceAsync("Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id)); - var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: false); - var unitPriceInclTaxValue = priceCalculation.finalPrice; + // Use the order item values directly — these already reflect any manual + // price override from CreateOrderItem, unlike a fresh GetFinalPriceAsync call. + order.OrderSubtotalInclTax += orderItem.UnitPriceInclTax * orderItem.Quantity; + order.OrderSubtotalExclTax += orderItem.UnitPriceExclTax * orderItem.Quantity; - var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer); + // Discount only applies when price was NOT manually overridden. + if (unitPricesIncludeDiscounts) + { + var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: true); + var appliedDiscounts = priceCalculation.appliedDiscountAmount; + order.OrderSubTotalDiscountInclTax += appliedDiscounts * orderItem.Quantity; + order.OrderSubTotalDiscountExclTax += appliedDiscounts * orderItem.Quantity; + } - order.OrderSubtotalInclTax += unitPriceInclTaxValue * orderItem.Quantity; - order.OrderSubtotalExclTax += unitPriceExclTaxValue * orderItem.Quantity; - - order.OrderSubTotalDiscountInclTax += order.OrderSubtotalInclTax - orderItem.PriceInclTax; - order.OrderSubTotalDiscountExclTax += order.OrderSubtotalExclTax - orderItem.PriceExclTax; - - //order.OrderTax - //order.TaxRates - - order.OrderTotal += orderItem.PriceInclTax + order.OrderShippingInclTax + order.PaymentMethodAdditionalFeeInclTax; + // OrderTotal: add item price only. Shipping and payment fees are NOT added + // per item — they are already in the order total from creation and should + // not be multiplied by the number of items being added. + order.OrderTotal += orderItem.PriceInclTax; } await _orderService.UpdateOrderAsync(order); @@ -686,15 +809,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers }; } - public interface IOrderProductItemBase - { - /// - /// ProductId - /// - public int Id { get; set; } - public int Quantity { get; set; } - public decimal Price { get; set; } - } + // IOrderProductItemBase is defined in Models/Orders/IOrderProductItemBase.cs + // and used as the shared contract across CustomOrderController and FruitBankOrderItemService. public class OrderProductItem : IOrderProductItemBase { @@ -944,12 +1060,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers { result.Add(new { - label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]", + label = $"{product.Name} [RENDELHETŐ: {productDto.AvailableQuantity} (R:{productDto.StockQuantity}/K:{productDto.IncomingQuantity})] [ÁR: {product.Price}]", value = product.Id, sku = product.Sku, price = product.Price, stockQuantity = product.StockQuantity, - incomingQuantity = productDto.IncomingQuantity, + availableQuantity = productDto.AvailableQuantity, }); } } @@ -959,6 +1075,79 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers } [HttpGet] + [CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)] + public virtual async Task PreorderProductSearchAutoComplete(string term) + { + if (string.IsNullOrWhiteSpace(term) || term.Length < 2) + return Json(new List()); + + const int maxResults = 30; + var today = DateTime.UtcNow.Date; + var store = await _storeContext.GetCurrentStoreAsync(); + + // Load preorder window attributes in two batch queries + var gaStart = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowStart + && ga.StoreId == store.Id) + .ToListAsync(); + + var gaEnd = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowEnd + && ga.StoreId == store.Id) + .ToListAsync(); + + var startById = gaStart.ToDictionary(g => g.EntityId, g => g.Value); + var endById = gaEnd.ToDictionary(g => g.EntityId, g => g.Value); + + // Product IDs currently in the preorder window + var availableIds = startById.Keys + .Intersect(endById.Keys) + .Where(id => + { + DateTime.TryParse(startById[id], out var ws); + DateTime.TryParse(endById[id], out var we); + return ws.Date <= today && today <= we.Date; + }) + .ToHashSet(); + + if (!availableIds.Any()) + return Json(new List()); + + // Search within available products only + var products = await _productService.SearchProductsAsync( + keywords: term, + pageIndex: 0, + pageSize: maxResults); + + var inWindow = products.Where(p => availableIds.Contains(p.Id)).ToList(); + if (!inWindow.Any()) + return Json(new List()); + + var productDtosById = await _dbContext.ProductDtos + .GetAllByIds(inWindow.Select(p => p.Id)) + .ToDictionaryAsync(k => k.Id, v => v); + + var result = new List(); + foreach (var product in inWindow) + { + productDtosById.TryGetValue(product.Id, out var dto); + result.Add(new + { + label = $"{product.Name} [KÉSZLET: {(product.StockQuantity + (dto?.IncomingQuantity ?? 0))}] [ÁR: {product.Price}]", + value = product.Id, + sku = product.Sku, + price = product.Price, + stockQuantity = product.StockQuantity, + incomingQuantity = dto?.IncomingQuantity ?? 0 + }); + } + + return Json(result); + } + + [HttpGet] [CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)] public virtual async Task ProductSearchUnfilteredAutoComplete(string term) { @@ -967,7 +1156,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers const int maxResults = 30; - // Search products by name or SKU var products = await _productService.SearchProductsAsync( keywords: term, pageIndex: 0, @@ -981,24 +1169,21 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers var productDto = productDtosById[product.Id]; if (productDto != null) { - result.Add(new { - label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]", - value = product.Id, - sku = product.Sku, - price = product.Price, - stockQuantity = product.StockQuantity, + label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]", + value = product.Id, + sku = product.Sku, + price = product.Price, + stockQuantity = product.StockQuantity, incomingQuantity = productDto.IncomingQuantity, }); - } } return Json(result); } - //[HttpPost] //public async Task CreateInvoice(int orderId) //{ // try @@ -1396,14 +1581,38 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers [HttpPost] - //[IgnoreAntiforgeryToken] - [ValidateAntiForgeryToken] - public async Task FruitBankAddProductToOrder(int orderId, string productsJson) + [ValidateAntiForgeryToken] + [CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)] + public async Task SendOrderEmailToCustomer(int orderId) + { + try { - try - { - _logger.Info($"AddProductToOrder - OrderId: {orderId}, ProductsJson: {productsJson}"); + var order = await _orderService.GetOrderByIdAsync(orderId); + if (order == null) + return Json(new { success = false, message = "Rendelés nem található" }); + var sentIds = await _fruitBankNotificationService.SendOrderInfoEmailAsync(order); + var sentCount = sentIds?.Count(id => id > 0) ?? 0; + + if (sentCount > 0) + return Json(new { success = true, message = $"Email sikeresen elküldve ({sentCount} címzett)" }); + + return Json(new { success = false, message = "Az email nem került elküldésre. Ellenőrizze az email sablont és az ügyfél email címét." }); + } + catch (Exception ex) + { + _logger.Error($"SendOrderEmailToCustomer error – orderId={orderId}: {ex.Message}", ex); + return Json(new { success = false, message = $"Hiba: {ex.Message}" }); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + [CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)] + public async Task FruitBankAddProductToOrder(int orderId, string productsJson) + { + try { + _logger.Info($"AddProductToOrder - OrderId: {orderId}, ProductsJson: {productsJson}"); if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)) return Json(new { success = false, message = "Access denied" }); @@ -1751,6 +1960,371 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers } } + + + // ═══════════════════════════════════════════════════════════════════ + // FruitBank Order Grid – new server-side DataTables endpoint + // ═══════════════════════════════════════════════════════════════════ + + /// + /// Returns the new FruitBank order list view (replaces the default NopCommerce grid). + /// + [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)] + public async Task NewList( + List orderStatuses = null, + List paymentStatuses = null, + List shippingStatuses = null) + { + var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended + { + OrderStatusIds = orderStatuses, + PaymentStatusIds = paymentStatuses, + ShippingStatusIds = shippingStatuses, + Length = 50, + AvailablePageSizes = "20,50,100,500", + SortColumn = "Id", + SortColumnDirection = "desc", + }); + model.SetGridSort("Id", "desc"); + model.SetGridPageSize(50, "20,50,100,500"); + + return View( + "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml", + model); + } + + /// + /// DataTables server-side endpoint for the FruitBank order grid. + /// Handles NopCommerce base filters + FruitBank-specific filters + per-column search + sort + pagination. + /// + [HttpPost] + [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)] + public async Task FruitBankOrderList() + { + var swTotal = System.Diagnostics.Stopwatch.StartNew(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + // ── 1. Parse DataTables protocol params ──────────────────────── + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 50 : Math.Min(length, 500); + + // Sort column + _ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx); + var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc"; + var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "Id"; + + // Per-column search values keyed by column data-field name + var colSearch = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int ci = 0; Request.Form.ContainsKey($"columns[{ci}][data]"); ci++) + { + var cData = Request.Form[$"columns[{ci}][data]"].FirstOrDefault(); + var cVal = Request.Form[$"columns[{ci}][search][value]"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(cData) && !string.IsNullOrWhiteSpace(cVal)) + colSearch[cData] = cVal.Trim(); + } + + // ── 2. Parse custom filter params ───────────────────────────── + DateTime? startDate = null, endDate = null; + if (DateTime.TryParse(Request.Form["StartDate"].FirstOrDefault(), out var sd)) startDate = sd; + if (DateTime.TryParse(Request.Form["EndDate"].FirstOrDefault(), out var ed)) endDate = ed; + + var orderStatusIds = Request.Form["OrderStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + var paymentStatusIds = Request.Form["PaymentStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + var shippingStatusIds = Request.Form["ShippingStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + + var billingCompany = Request.Form["BillingCompany"].FirstOrDefault(); + + bool? isMeasurableFilter = null; + var imStr = Request.Form["IsMeasurable"].FirstOrDefault(); + if (imStr == "true") isMeasurableFilter = true; + if (imStr == "false") isMeasurableFilter = false; + + bool? hasInnvoiceFilter = null; + var hiStr = Request.Form["HasInnvoiceTechId"].FirstOrDefault(); + if (hiStr == "true") hasInnvoiceFilter = true; + if (hiStr == "false") hasInnvoiceFilter = false; + + _logger.Info($"[PERF] FruitBankOrderList – params parsed in {sw.ElapsedMilliseconds} ms"); + sw.Restart(); + + // ── 3. Direct lean query – bypasses the factory N+1 problem ─────── + // OrderDtos already has all FruitBank fields + Customer + GenericAttributes. + // LinqToDB LoadWith batches relations into 1 query each – 3 queries total + // regardless of row count, vs the factory’s ~5 queries per row. + int? filterCustomerId = int.TryParse(billingCompany, out var cid) && cid > 0 ? cid : null; + + // UTC conversion for date filters (same logic as base factory) + var currentTz = await _dateTimeHelper.GetCurrentTimeZoneAsync(); + DateTime? startUtc = startDate.HasValue ? (DateTime?)_dateTimeHelper.ConvertToUtcTime(startDate.Value, currentTz) : null; + DateTime? endUtc = endDate.HasValue ? (DateTime?)_dateTimeHelper.ConvertToUtcTime(endDate.Value, currentTz).AddDays(1) : null; + + var query = _dbContext.OrderDtos + .GetAll(true) // loads GenericAttributes in 1 batch query + .Where(o => !o.Deleted); + + if (startUtc.HasValue) query = query.Where(o => o.CreatedOnUtc >= startUtc.Value); + if (endUtc.HasValue) query = query.Where(o => o.CreatedOnUtc <= endUtc.Value); + if (filterCustomerId.HasValue) query = query.Where(o => o.CustomerId == filterCustomerId.Value); + if (orderStatusIds.Any()) query = query.Where(o => orderStatusIds.Contains(o.OrderStatusId)); + if (paymentStatusIds.Any()) query = query.Where(o => paymentStatusIds.Contains(o.PaymentStatusId)); + if (shippingStatusIds.Any()) query = query.Where(o => shippingStatusIds.Contains(o.ShippingStatusId)); + + // Apply sort at DB level + bool asc = sortDir == "asc"; + query = sortColName.ToLowerInvariant() switch + { + "customordernumber" => asc ? query.OrderBy(o => o.CustomOrderNumber) : query.OrderByDescending(o => o.CustomOrderNumber), + "createdon" => asc ? query.OrderBy(o => o.CreatedOnUtc) : query.OrderByDescending(o => o.CreatedOnUtc), + "dateofreceipt" => asc ? query.OrderBy(o => o.DateOfReceipt) : query.OrderByDescending(o => o.DateOfReceipt), + "orderstatusid" => asc ? query.OrderBy(o => o.OrderStatusId) : query.OrderByDescending(o => o.OrderStatusId), + "measuringstatus" => asc ? query.OrderBy(o => o.MeasuringStatus) : query.OrderByDescending(o => o.MeasuringStatus), + "customercompany" => asc ? query.OrderBy(o => o.CustomerId) : query.OrderByDescending(o => o.CustomerId), + _ => query.OrderByDescending(o => o.Id) + }; + + // Per-column DB-mappable filters + if (colSearch.TryGetValue("CustomOrderNumber", out var coNum) && !string.IsNullOrEmpty(coNum)) + query = query.Where(o => o.CustomOrderNumber.Contains(coNum)); + if (colSearch.TryGetValue("OrderStatusId", out var osColStr) && int.TryParse(osColStr, out var osColId)) + query = query.Where(o => o.OrderStatusId == osColId); + if (colSearch.TryGetValue("MeasuringStatus", out var msColStr) && int.TryParse(msColStr, out var msColId)) + query = query.Where(o => (int)o.MeasuringStatus == msColId); + // IsMeasurable: computed from OrderItemDtos – pre-query the OrderItem table + // to get order IDs where any item belongs to a measurable product, then filter SQL + var isMeasurableColVal = colSearch.TryGetValue("IsMeasurable", out var imcs) ? imcs : null; + bool? effectiveIsMeasurable = isMeasurableFilter; + if (isMeasurableColVal != null && bool.TryParse(isMeasurableColVal, out var imcb)) + effectiveIsMeasurable = imcb; + + if (effectiveIsMeasurable.HasValue) + { + // Get all order IDs where any item has a measurable product + var measurableOrderIds = await _dbContext.OrderItemDtos + .GetAll(false) + .Where(oi => oi.ProductDto != null && oi.ProductDto.IsMeasurable) + .Select(oi => oi.OrderId) + .Distinct() + .ToListAsync(); + + query = effectiveIsMeasurable.Value + ? query.Where(o => measurableOrderIds.Contains(o.Id)) + : query.Where(o => !measurableOrderIds.Contains(o.Id)); + + _logger.Info($"[PERF] FruitBankOrderList – IsMeasurable pre-query: {measurableOrderIds.Count} measurable order IDs"); + } + + // CustomerCompany column search: pre-query Customer table for matching IDs + if (colSearch.TryGetValue("CustomerCompany", out var ccColVal) && !string.IsNullOrEmpty(ccColVal)) + { + var matchingCustomerIds = await _dbContext.Customers.Table + .Where(c => c.Company.Contains(ccColVal) || + (c.FirstName + " " + c.LastName).Contains(ccColVal)) + .Select(c => c.Id) + .ToListAsync(); + + query = query.Where(o => matchingCustomerIds.Contains(o.CustomerId)); + _logger.Info($"[PERF] FruitBankOrderList – CustomerCompany pre-query: {matchingCustomerIds.Count} matching customers"); + } + + // COUNT – runs as a simple SELECT COUNT(*) against the filtered set + int total; + try { total = await query.CountAsync(); } + catch (Exception ex) + { + _logger.Error($"FruitBankOrderList – count error: {ex.Message}", ex); + return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); + } + + _logger.Info($"[PERF] FruitBankOrderList – COUNT {sw.ElapsedMilliseconds} ms | total: {total}"); + sw.Restart(); + + // Step 1: get just the IDs for the current page (plain SQL, no relations) + List pageIds; + try + { + pageIds = await query.Skip(start).Take(length).Select(o => o.Id).ToListAsync(); + } + catch (Exception ex) + { + _logger.Error($"FruitBankOrderList – page IDs query error: {ex.Message}", ex); + return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); + } + + _logger.Info($"[PERF] FruitBankOrderList – page IDs (Skip+Take) {sw.ElapsedMilliseconds} ms | ids: {pageIds.Count}"); + sw.Restart(); + + // Step 2: reload those ~50 rows with only the relations we need. + // LoadWith works here because it’s applied to the base table query, not a filtered IQueryable. + List rows; + try + { + // GetAllByIds(ids, false) uses GetAll(false) which has LoadWith(GenericAttributes) baked in. + // LoadWith on a chained IQueryable is not supported by LinqToDB. + rows = await _dbContext.OrderDtos + .GetAllByIds(pageIds, true) + .ToListAsync(); + + // Re-sort to match the original query order (IN clause doesn’t guarantee order) + rows = pageIds + .Select(id => rows.FirstOrDefault(r => r.Id == id)) + .Where(r => r != null) + .ToList(); + } + catch (Exception ex) + { + _logger.Error($"FruitBankOrderList – relations query error: {ex.Message}", ex); + return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); + } + + _logger.Info($"[PERF] FruitBankOrderList – relations load (GetAll+LoadWith) {sw.ElapsedMilliseconds} ms | rows: {rows.Count}"); + sw.Restart(); + + var userTz = currentTz; + + // ── 4. Map to lightweight DTO ────────────────────────────────── + static string MeasuringStatusLabel(MeasuringStatus s) => s switch + { + MeasuringStatus.NotStarted => "Nincs elindítva", + MeasuringStatus.Started => "Folyamatban", + MeasuringStatus.Finnished => "Mérve", + MeasuringStatus.Audited => "Lezárva", + _ => s.ToString() + }; + static string OrderStatusLabel(int id) => id switch + { + 10 => "Függőben", + 20 => "Feldolgozás", + 30 => "Teljesítve", + 40 => "Törölve", + _ => id.ToString() + }; + static string PaymentStatusLabel(int id) => id switch + { + 10 => "Fizetésre vár", + 20 => "Félig fizetve", + 30 => "Fizetve", + 35 => "Túlfizetve", + 40 => "Visszatérítve", + _ => id.ToString() + }; + static string ShippingStatusLabel(int id) => id switch + { + 10 => "Szállítás nincs", + 20 => "Nincs kiszállítva", + 25 => "Részben kiszállítva", + 30 => "Kiszállítva", + _ => id.ToString() + }; + + var dtos = rows.Select(o => + { + var ga = o.GenericAttributes; + var dateOfReceipt = ga?.FirstOrDefault(a => a.Key == "DateOfReceipt")?.Value is string dv && DateTime.TryParse(dv, out var dp) ? dp : (DateTime?)null; + var innvoiceTechId = ga?.FirstOrDefault(a => a.Key == "InnVoiceOrderTechId")?.Value; + var company = o.Customer != null + ? $"{o.Customer.Company} {o.Customer.FirstName}_{o.Customer.LastName}".Trim() + : string.Empty; + + return new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto + { + Id = o.Id, + CustomOrderNumber = o.CustomOrderNumber, + CustomerCompany = company, + CustomerId = o.CustomerId, + InnvoiceTechId = innvoiceTechId, + IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid, + IsMeasurable = o.IsMeasurable, + MeasuringStatus = (int)o.MeasuringStatus, + MeasuringStatusString = MeasuringStatusLabel(o.MeasuringStatus), + DateOfReceipt = dateOfReceipt, + OrderStatusId = o.OrderStatusId, + OrderStatus = OrderStatusLabel(o.OrderStatusId), + PaymentStatusId = o.PaymentStatusId, + PaymentStatus = PaymentStatusLabel(o.PaymentStatusId), + ShippingStatusId = o.ShippingStatusId, + ShippingStatus = ShippingStatusLabel(o.ShippingStatusId), + StoreName = string.Empty, // not needed in grid + CreatedOn = TimeZoneInfo.ConvertTimeFromUtc(o.CreatedOnUtc, userTz), + OrderTotal = !o.IsComplete && o.IsMeasurable + ? "kalkuláció alatt..." + : $"{o.OrderTotal:N0} Ft" + }; + }).ToList(); + + _logger.Info($"[PERF] FruitBankOrderList – DTO mapping {sw.ElapsedMilliseconds} ms"); + sw.Restart(); + + // InnVoice filter is post-query (it’s in GenericAttributes, not a plain column) + if (hasInnvoiceFilter.HasValue) + dtos = hasInnvoiceFilter.Value + ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList(); + + // InnVoice column-header filter (post-query: stored in GenericAttributes) + if (colSearch.TryGetValue("InnvoiceTechId", out var innColVal)) + dtos = innColVal == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : innColVal == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : dtos; + + var result = Json(new { draw, recordsTotal = total, recordsFiltered = total, data = dtos }); + + _logger.Info($"[PERF] FruitBankOrderList – JSON serialize {sw.ElapsedMilliseconds} ms"); + _logger.Info($"[PERF] FruitBankOrderList – TOTAL {swTotal.ElapsedMilliseconds} ms | page: {dtos.Count}"); + + return result; + } + + + + /// + /// Inline-edit save endpoint. Currently supports DateOfReceipt. + /// + [HttpPost] + [CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)] + public async Task UpdateOrderField(int orderId, string field, string value) + { + try + { + var order = await _orderService.GetOrderByIdAsync(orderId); + if (order == null) + return Json(new { success = false, error = "Rendelés nem található" }); + + switch (field?.ToUpperInvariant()) + { + case "DATEOFRECEIPT": + var dateOdReceiptDateTime = DateTime.TryParse(value, out var dp); + if (string.IsNullOrWhiteSpace(value)) + { + //await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync(order.Id, nameof(IOrderDto.DateOfReceipt), null, _storeContext.GetCurrentStore().Id); + await _fruitBankAttributeService.DeleteGenericAttributeAsync(order.Id, nameof(IOrderDto.DateOfReceipt)); + //await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", null); + return Json(new { success = true, displayValue = (string)null }); + } + if (DateTime.TryParse(value, out var newDate)) + { + // Store in the same format that NopCommerce's SaveAttributeAsync uses (MM/dd/yyyy HH:mm:ss invariant) + // so OrderDto deserialization in the Blazor app doesn't break. + var formattedValue = newDate.ToString("MM/dd/yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); + await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync(order.Id, nameof(IOrderDto.DateOfReceipt), formattedValue, _storeContext.GetCurrentStore().Id); + return Json(new { success = true, displayValue = newDate.ToString("yyyy. MM. dd. HH:mm") }); + } + return Json(new { success = false, error = "Érvénytelen dátum formátum" }); + + default: + return Json(new { success = false, error = $"Ismeretlen mező: {field}" }); + } + } + catch (Exception ex) + { + _logger.Error($"UpdateOrderField error – orderId={orderId} field={field}: {ex.Message}", ex); + return Json(new { success = false, error = ex.Message }); + } + } + } } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerCreditController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerCreditController.cs new file mode 100644 index 0000000..2b4de66 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerCreditController.cs @@ -0,0 +1,285 @@ +using FruitBank.Common.Entities; +using Microsoft.AspNetCore.Mvc; +using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Orders; +using Nop.Core.Domain.Payments; +using Nop.Data; +using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Customers; +using Nop.Services.Localization; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; + +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +public class CustomerCreditController : BasePluginController +{ + private readonly ICustomerCreditService _customerCreditService; + private readonly ICustomerService _customerService; + private readonly IRepository _orderRepository; + private readonly IRepository _customerRepository; + private readonly CustomerCreditDbTable _customerCreditDbTable; + private readonly IPermissionService _permissionService; + private readonly ILocalizationService _localizationService; + + public CustomerCreditController( + ICustomerCreditService customerCreditService, + ICustomerService customerService, + IRepository orderRepository, + IRepository customerRepository, + CustomerCreditDbTable customerCreditDbTable, + IPermissionService permissionService, + ILocalizationService localizationService) + { + _customerCreditService = customerCreditService; + _customerService = customerService; + _orderRepository = orderRepository; + _customerRepository = customerRepository; + _customerCreditDbTable = customerCreditDbTable; + _permissionService = permissionService; + _localizationService = localizationService; + } + + // ── LIST PAGE ───────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/CustomerCredit/List")] + public async Task List() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml"); + } + + // ── DATATABLES SERVER-SIDE ENDPOINT ────────────────────────────────────── + + [HttpPost] + [Route("Admin/CustomerCredit/CustomerCreditList")] + public async Task CustomerCreditList() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { draw = 1, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); + + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500); + + _ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx); + var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc"; + var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "OutstandingBalance"; + + var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? ""; + + // 1. Customers — single query + var customers = await _customerRepository.Table + .Where(c => !c.Deleted && c.Active && c.Email != null) + .Select(c => new { c.Id, c.Email, c.FirstName, c.LastName }) + .ToListAsync(); + + // 2. Credit records — single query + var credits = await _customerCreditDbTable.GetAll().ToListAsync(); + var creditByCustomer = credits.ToDictionary(x => x.CustomerId); + + // 3. Outstanding balances — single grouped query, no N+1 + var outstandingByCustomer = await _orderRepository.Table + .Where(o => + o.OrderStatusId != (int)OrderStatus.Cancelled && + (o.PaymentStatusId == (int)PaymentStatus.Pending || + o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded)) + .GroupBy(o => o.CustomerId) + .Select(g => new { CustomerId = g.Key, Total = g.Sum(o => (decimal?)o.OrderTotal) ?? 0m }) + .ToListAsync(); + var outstandingDict = outstandingByCustomer.ToDictionary(x => x.CustomerId, x => x.Total); + + // 4. Build rows + var rows = customers.Select(c => + { + creditByCustomer.TryGetValue(c.Id, out var credit); + outstandingDict.TryGetValue(c.Id, out var outstanding); + var hasLimit = credit != null; + var remaining = hasLimit ? credit!.CreditLimit - outstanding : (decimal?)null; + + return new CustomerCreditListRow + { + CustomerId = c.Id, + CustomerEmail = c.Email ?? string.Empty, + CustomerName = $"{c.FirstName} {c.LastName}".Trim(), + HasCreditLimit = hasLimit, + CreditLimit = credit?.CreditLimit ?? 0m, + OutstandingBalance = outstanding, + RemainingCredit = remaining, + Comment = credit?.Comment + }; + }).ToList(); + + int recordsTotal = rows.Count; + + // 5. Global search + if (!string.IsNullOrWhiteSpace(globalSearch)) + { + rows = rows.Where(r => + r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + r.CustomerEmail.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + (r.Comment?.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ?? false) + ).ToList(); + } + + int recordsFiltered = rows.Count; + + // 6. Sort + bool asc = sortDir == "asc"; + rows = sortColName.ToLowerInvariant() switch + { + "customername" => asc ? rows.OrderBy(r => r.CustomerName).ToList() : rows.OrderByDescending(r => r.CustomerName).ToList(), + "customeremail" => asc ? rows.OrderBy(r => r.CustomerEmail).ToList() : rows.OrderByDescending(r => r.CustomerEmail).ToList(), + "creditlimit" => asc ? rows.OrderBy(r => r.CreditLimit).ToList() : rows.OrderByDescending(r => r.CreditLimit).ToList(), + "outstandingbalance" => asc ? rows.OrderBy(r => r.OutstandingBalance).ToList() : rows.OrderByDescending(r => r.OutstandingBalance).ToList(), + "remainingcredit" => asc ? rows.OrderBy(r => r.RemainingCredit ?? decimal.MaxValue).ToList() : rows.OrderByDescending(r => r.RemainingCredit ?? decimal.MinValue).ToList(), + _ => rows.OrderByDescending(r => r.OutstandingBalance).ToList() + }; + + // 7. Paginate + var page = rows.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } + + // ── INLINE EDIT: CREDIT LIMIT ───────────────────────────────────────────── + + [HttpPost] + [Route("Admin/CustomerCredit/UpdateCreditLimit")] + public async Task UpdateCreditLimit(int customerId, string? creditLimit, bool removeLimit, string? comment) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + try + { + var existing = await _customerCreditService.GetByCustomerIdAsync(customerId); + var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId); + + // Empty input or explicit removeLimit flag → delete record = unlimited + if (removeLimit || string.IsNullOrWhiteSpace(creditLimit)) + { + if (existing != null) + await _customerCreditService.DeleteAsync(existing); + + return Json(new + { + success = true, + hasLimit = false, + creditLimit = (decimal?)null, + outstanding, + remaining = (decimal?)null + }); + } + + // Parse the value (JS sends invariant decimal) + if (!decimal.TryParse(creditLimit, + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, + out var limit) || limit < 0) + return Json(new { success = false, error = "Érvénytelen összeg" }); + + var entity = existing ?? new CustomerCredit { CustomerId = customerId }; + entity.CreditLimit = limit; + if (comment != null) entity.Comment = comment; + + await _customerCreditService.SaveAsync(entity); + + return Json(new + { + success = true, + hasLimit = true, + creditLimit = limit, + outstanding, + remaining = limit - outstanding + }); + } + catch (Exception ex) + { + return Json(new { success = false, error = ex.Message }); + } + } + + // ── DETAILS ─────────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/CustomerCredit/Details/{customerId:int}")] + public async Task Details(int customerId) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + var customer = await _customerService.GetCustomerByIdAsync(customerId); + if (customer == null) + return NotFound(); + + var credit = await _customerCreditService.GetByCustomerIdAsync(customerId); + var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId); + + var unpaidOrders = await _orderRepository.Table + .Where(o => + o.CustomerId == customerId && + o.OrderStatusId != (int)OrderStatus.Cancelled && + (o.PaymentStatusId == (int)PaymentStatus.Pending || + o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded)) + .OrderByDescending(o => o.CreatedOnUtc) + .ToListAsync(); + + var model = new CustomerCreditModel + { + CustomerId = customerId, + CustomerEmail = customer.Email, + CustomerName = $"{customer.FirstName} {customer.LastName}".Trim(), + CreditId = credit?.Id ?? 0, + CreditLimit = credit?.CreditLimit ?? 0m, + Comment = credit?.Comment, + OutstandingBalance = outstanding, + RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null, + HasCreditLimit = credit != null, + UnpaidOrders = unpaidOrders.Select(o => new CustomerCreditOrderRow + { + OrderId = o.Id, + OrderTotal = o.OrderTotal, + CreatedOnUtc = o.CreatedOnUtc, + OrderStatus = o.OrderStatus.ToString(), + PaymentStatus = o.PaymentStatus.ToString() + }).ToList() + }; + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml", model); + } + + // ── SAVE (from Details page) ────────────────────────────────────────────── + + [HttpPost] + [Route("Admin/CustomerCredit/Save")] + public async Task Save(CustomerCreditModel model) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + if (!ModelState.IsValid) + return RedirectToAction("Details", new { customerId = model.CustomerId }); + + var entity = model.CreditId > 0 + ? await _customerCreditService.GetByCustomerIdAsync(model.CustomerId) ?? new CustomerCredit() + : new CustomerCredit(); + + entity.CustomerId = model.CustomerId; + entity.CreditLimit = model.CreditLimit; + entity.Comment = model.Comment; + + await _customerCreditService.SaveAsync(entity); + + return RedirectToAction("Details", new { customerId = model.CustomerId }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs index fd0742d..cfeb78a 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs @@ -41,6 +41,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers private readonly FileStorageService _fileStorageService; private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly IStoreContext _storeContext; + private readonly PreorderConversionService _preorderConversionService; public FileManagerController( IPermissionService permissionService, @@ -53,7 +54,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers IWorkContext workContext, FileStorageService fileStorageService, FruitBankAttributeService fruitBankAttributeService, - IStoreContext storeContext) + IStoreContext storeContext, + PreorderConversionService preorderConversionService) { _permissionService = permissionService; _aiApiService = aiApiService; @@ -66,6 +68,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers _fileStorageService = fileStorageService; _fruitBankAttributeService = fruitBankAttributeService; _storeContext = storeContext; + _preorderConversionService = preorderConversionService; } /// @@ -1120,6 +1123,32 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers newIncomingQuantity, _storeContext.GetCurrentStore().Id ); } + + // ── Step 3: Convert pending preorders that cover these products ────────── + var productIdsWithIncoming = shippingDocument.ShippingItems + .Where(x => x.ProductId != null) + .Select(x => x.ProductId!.Value) + .Distinct() + .ToList(); + + if (productIdsWithIncoming.Any()) + { + // Fire-and-forget with error isolation so a conversion failure + // never blocks the shipping document save response + _ = Task.Run(async () => + { + try + { + await _preorderConversionService + .ConvertPreordersForProductsAsync(productIdsWithIncoming, shippingDocument.Id); + } + catch (Exception convEx) + { + Console.Error.WriteLine( + $"[PreorderConversion] Error during conversion for document #{shippingDocument.Id}: {convEx.Message}"); + } + }); + } return Json(new diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankPluginAdminController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankPluginAdminController.cs index 991b046..f4ea7d1 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankPluginAdminController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankPluginAdminController.cs @@ -34,7 +34,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers ApiBaseUrl = _settings.ApiBaseUrl, MaxTokens = _settings.MaxTokens, Temperature = _settings.Temperature, - RequestTimeoutSeconds = _settings.RequestTimeoutSeconds + RequestTimeoutSeconds = _settings.RequestTimeoutSeconds, + ZaiApiKey = _settings.ZaiApiKey, + ZaiModel = _settings.ZaiModel }; return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Configure/Configure.cshtml", model); } @@ -58,6 +60,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers _settings.MaxTokens = model.MaxTokens; _settings.Temperature = model.Temperature; _settings.RequestTimeoutSeconds = model.RequestTimeoutSeconds; + _settings.ZaiApiKey = model.ZaiApiKey ?? string.Empty; + _settings.ZaiModel = model.ZaiModel ?? "glm-ocr"; // Save settings await _settingService.SaveSettingAsync(_settings); diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs new file mode 100644 index 0000000..d400687 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs @@ -0,0 +1,452 @@ +using FruitBank.Common.Entities; +using FruitBank.Common.Enums; +using Microsoft.AspNetCore.Mvc; +using Nop.Core.Domain.Customers; +using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Customers; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; + +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +[AutoValidateAntiforgeryToken] +public class PreorderAdminController : BasePluginController +{ + private readonly IPermissionService _permissionService; + private readonly PreorderDbContext _preorderDbContext; + private readonly FruitBankDbContext _dbContext; + private readonly ICustomerService _customerService; + private readonly PreorderConversionService _preorderConversionService; + + private static readonly Dictionary StatusLabels = new() + { + { PreorderStatus.Pending, "Függőben" }, + { PreorderStatus.Confirmed, "Megerősítve" }, + { PreorderStatus.PartiallyFulfilled, "Részben teljesítve" }, + { PreorderStatus.Cancelled, "Törölve" } + }; + + private static readonly Dictionary ItemStatusLabels = new() + { + { PreorderItemStatus.Pending, "Függőben" }, + { PreorderItemStatus.Fulfilled, "Teljesítve" }, + { PreorderItemStatus.PartiallyFulfilled, "Részben" }, + { PreorderItemStatus.Dropped, "Ejtve" } + }; + + public PreorderAdminController( + IPermissionService permissionService, + PreorderDbContext preorderDbContext, + FruitBankDbContext dbContext, + ICustomerService customerService, + PreorderConversionService preorderConversionService) + { + _permissionService = permissionService; + _preorderDbContext = preorderDbContext; + _dbContext = dbContext; + _customerService = customerService; + _preorderConversionService = preorderConversionService; + } + + // ── LIST PAGE ───────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/Preorders")] + public async Task List() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Preorder/List.cshtml"); + } + + // ── DATATABLES SERVER-SIDE ──────────────────────────────────────────────── + + [HttpPost] + [Route("Admin/Preorders/PreorderList")] + public async Task PreorderList() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Forbid(); + + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500); + + _ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx); + var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc"; + var sortColName = Request.Form[$"columns[{sortColIdx}][name]"].FirstOrDefault() ?? "CreatedOnUtc"; + + var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? ""; + var statusFilter = Request.Form["statusFilter"].FirstOrDefault()?.Trim() ?? ""; + + // 1. All preorders with items — two queries + var preorders = await _preorderDbContext.Preorders.GetAll(false).ToListAsync(); + var allItems = await _preorderDbContext.PreorderItems.GetAll().ToListAsync(); + + var itemsByPreorder = allItems + .GroupBy(i => i.PreorderId) + .ToDictionary(g => g.Key, g => g.ToList()); + + // 2. Customers — batch + var customerIds = preorders.Select(p => p.CustomerId).Distinct().ToList(); + var customers = await _dbContext.Customers.Table + .Where(c => customerIds.Contains(c.Id)) + .Select(c => new { c.Id, c.Email, c.FirstName, c.LastName }) + .ToListAsync(); + var customerById = customers.ToDictionary(c => c.Id); + + // 3. Linked orders — find orders created from preorders via CustomOrderNumber lookup + // We store the preorder id in the order note, but the simplest link is checking + // OrderNotes for "előrendelésből" text matching preorderId. + // For now we surface the link on the detail page only. + + // 4. Build rows — derive status from quantities, not enum (LinqToDB enum reads unreliable) + var rows = preorders.Select(p => + { + customerById.TryGetValue(p.CustomerId, out var c); + var items = itemsByPreorder.TryGetValue(p.Id, out var its) ? its : new(); + + // Derive status from quantities rather than relying on the enum read + var fulfilledCount = items.Count(i => i.FulfilledQuantity > 0); + var allFulfilled = items.Any() && items.All(i => i.FulfilledQuantity >= i.RequestedQuantity); + var anyFulfilled = items.Any(i => i.FulfilledQuantity > 0); + var hasOrderId = p.OrderId.HasValue; + + // Derive a display status: use the DB enum if it looks valid (non-zero), + // otherwise infer from quantities + var effectiveStatus = (int)p.Status != 0 + ? p.Status + : allFulfilled ? PreorderStatus.Confirmed + : anyFulfilled ? PreorderStatus.PartiallyFulfilled + : PreorderStatus.Pending; + + return new PreorderListRow + { + PreorderId = p.Id, + CustomerId = p.CustomerId, + CustomerName = c != null ? $"{c.FirstName} {c.LastName}".Trim() : $"#{p.CustomerId}", + CustomerEmail = c?.Email ?? string.Empty, + DateOfReceipt = p.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + CreatedOnUtc = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + Status = effectiveStatus, + StatusLabel = StatusLabels.TryGetValue(effectiveStatus, out var sl) ? sl : effectiveStatus.ToString(), + ItemCount = items.Count, + FulfilledCount = fulfilledCount, + OrderId = p.OrderId + }; + }).ToList(); + + int recordsTotal = rows.Count; + + // 5. Filter by status + if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse(statusFilter, out var statusEnum)) + rows = rows.Where(r => r.Status == statusEnum).ToList(); + + // 6. Global search + if (!string.IsNullOrWhiteSpace(globalSearch)) + rows = rows.Where(r => + r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + r.CustomerEmail.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + r.PreorderId.ToString().Contains(globalSearch) + ).ToList(); + + int recordsFiltered = rows.Count; + + // 7. Sort + bool asc = sortDir == "asc"; + rows = sortColName switch + { + "CustomerName" => asc ? rows.OrderBy(r => r.CustomerName).ToList() : rows.OrderByDescending(r => r.CustomerName).ToList(), + "DateOfReceipt" => asc ? rows.OrderBy(r => r.DateOfReceipt).ToList() : rows.OrderByDescending(r => r.DateOfReceipt).ToList(), + "Status" => asc ? rows.OrderBy(r => r.Status).ToList() : rows.OrderByDescending(r => r.Status).ToList(), + _ => asc ? rows.OrderBy(r => r.CreatedOnUtc).ToList() : rows.OrderByDescending(r => r.CreatedOnUtc).ToList() + }; + + // 8. Paginate + var page = rows.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } + + // ── DETAIL PAGE ─────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/Preorders/Detail/{id:int}")] + public async Task Detail(int id) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id, loadRelations: false); + if (preorder == null) return NotFound(); + + var items = await _preorderDbContext.PreorderItems + .GetAllByPreorderIdAsync(id) + .ToListAsync(); + + var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId); + + // Resolve product names in one batch + var productIds = items.Select(i => i.ProductId).Distinct().ToList(); + var productDtos = await _dbContext.ProductDtos + .GetAll(false) + .Where(p => productIds.Contains(p.Id)) + .ToListAsync(); + var productById = productDtos.ToDictionary(p => p.Id); + + // Use preorder.OrderId directly — stored on the entity at conversion time + int? linkedOrderId = preorder.OrderId; + + var model = new PreorderDetailModel + { + PreorderId = preorder.Id, + CustomerId = preorder.CustomerId, + CustomerName = customer != null ? $"{customer.FirstName} {customer.LastName}".Trim() : $"#{preorder.CustomerId}", + CustomerEmail = customer?.Email ?? string.Empty, + DateOfReceipt = preorder.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + CreatedOnUtc = preorder.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + UpdatedOnUtc = preorder.UpdatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + Status = preorder.Status, + CustomerNote = preorder.CustomerNote, + OrderId = linkedOrderId, + Items = items.Select(i => + { + productById.TryGetValue(i.ProductId, out var dto); + + // Derive item status from quantities — enum reads unreliable in LinqToDB + var derivedStatus = i.FulfilledQuantity == 0 + ? PreorderItemStatus.Pending + : i.FulfilledQuantity >= i.RequestedQuantity + ? PreorderItemStatus.Fulfilled + : PreorderItemStatus.PartiallyFulfilled; + + // If DB enum read as non-zero, prefer it; otherwise use derived + var effectiveItemStatus = (int)i.Status != 0 ? i.Status : derivedStatus; + + return new PreorderDetailItemRow + { + ItemId = i.Id, + ProductId = i.ProductId, + ProductName = dto?.Name ?? $"Product #{i.ProductId}", + IsMeasurable = dto?.IsMeasurable ?? false, + RequestedQuantity = i.RequestedQuantity, + FulfilledQuantity = i.FulfilledQuantity, + UnitPriceInclTax = i.UnitPriceInclTax, + Status = effectiveItemStatus, + StatusLabel = ItemStatusLabels.TryGetValue(effectiveItemStatus, out var isl) ? isl : effectiveItemStatus.ToString() + }; + }).ToList() + }; + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Preorder/Detail.cshtml", model); + } + + // ── CREATE (admin phone order) ─────────────────────────────────────────── + + [HttpPost] + [Route("Admin/Preorders/CreatePreorder")] + public async Task CreatePreorder( + int customerId, + string deliveryDateTime, + string? customerNote, + string productsJson) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Hozzáférés megtagadva" }); + + try + { + // Validate customer + var customer = await _customerService.GetCustomerByIdAsync(customerId); + if (customer == null) + return Json(new { success = false, error = "Az ügyfél nem található" }); + + // Validate delivery date + if (!DateTime.TryParse(deliveryDateTime, out var deliveryDate)) + return Json(new { success = false, error = "Érvénytelen szállítási dátum" }); + + // Parse products + if (string.IsNullOrWhiteSpace(productsJson)) + return Json(new { success = false, error = "Nincs termék megadva" }); + + var productItems = System.Text.Json.JsonSerializer.Deserialize>(productsJson); + if (productItems == null || !productItems.Any()) + return Json(new { success = false, error = "Nincs érvényes termék" }); + + // Get store + var storeId = (await _dbContext.Shippings.GetAll().Select(s => s.Id).FirstOrDefaultAsync() > 0) + ? 1 : 1; // fallback to store 1 + // Use first available store from generic attributes context + var gaStore = await _dbContext.GenericAttributes.Table + .Select(g => g.StoreId).FirstOrDefaultAsync(); + storeId = gaStore > 0 ? gaStore : 1; + + var preorder = new Preorder + { + CustomerId = customerId, + StoreId = storeId, + DateOfReceipt = deliveryDate, + CustomerNote = customerNote?.Trim() + }; + + var items = new List(); + foreach (var pi in productItems.Where(p => p.quantity > 0)) + { + var product = await _dbContext.Products.GetByIdAsync(pi.id); + if (product == null || product.Deleted || !product.Published) continue; + + items.Add(new PreorderItem + { + ProductId = pi.id, + RequestedQuantity = pi.quantity, + UnitPriceInclTax = (decimal)pi.price + }); + } + + if (!items.Any()) + return Json(new { success = false, error = "Nincs érvényes termék az előrendelésben" }); + + var saved = await _preorderDbContext.InsertPreorderAsync(preorder, items); + + Console.WriteLine($"[Admin] Created preorder #{saved.Id} for customer #{customerId} " + + $"by admin, {items.Count} items, delivery {deliveryDate:u}"); + + // Immediately check if any items can be fulfilled from current stock — + // same inline conversion as the customer-facing PlacePreorder endpoint. + var productIds = items.Select(i => i.ProductId).Distinct().ToList(); + await _preorderConversionService.ConvertPreordersForProductsAsync(productIds, 0); + + // Re-read to pick up OrderId if conversion created a real order + var refreshed = await _preorderDbContext.Preorders.GetByIdAsync(saved.Id); + + return Json(new { success = true, preorderId = saved.Id, orderId = refreshed?.OrderId }); + } + catch (Exception ex) + { + return Json(new { success = false, error = ex.Message }); + } + } + + private class ProductItemRequest + { + public int id { get; set; } + public string? name { get; set; } + public int quantity { get; set; } + public double price { get; set; } + } + + // ── CANCEL ──────────────────────────────────────────────────────────────── + + // ── CANCEL ─────────────────────────────────────────────────────────── + + [HttpPost] + [Route("Admin/Preorders/Cancel/{id:int}")] + public async Task Cancel(int id) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id); + if (preorder == null) + return Json(new { success = false, error = "Preorder not found" }); + + if (preorder.Status != PreorderStatus.Pending) + return Json(new { success = false, error = "Only pending preorders can be cancelled" }); + + await _preorderDbContext.CancelPreorderAsync(id); + return Json(new { success = true }); + } + + // ── DEMAND LIST ─────────────────────────────────────────────────────────── + + [HttpPost] + [Route("Admin/Preorders/DemandList")] + public async Task DemandList(bool openOnly = true) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Forbid(); + + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500); + var openOnlyParam = Request.Form["openOnly"].FirstOrDefault(); + openOnly = openOnlyParam != "false"; + + // Fetch all preorder items + preorders in two queries + var allItems = await _preorderDbContext.PreorderItems.GetAll().ToListAsync(); + var allPreorders = await _preorderDbContext.Preorders.GetAll(false).ToListAsync(); + + // For "open only": include only items from preorders that still have + // unfulfilled demand (FulfilledQuantity < RequestedQuantity). + // We use quantities rather than Status enum (enum reads unreliable). + IEnumerable items = allItems; + if (openOnly) + { + // Open preorders: those where at least one item still needs fulfillment + var openPreorderIds = allPreorders + .Where(p => allItems + .Where(i => i.PreorderId == p.Id) + .Any(i => i.FulfilledQuantity < i.RequestedQuantity)) + .Select(p => p.Id) + .ToHashSet(); + + items = allItems.Where(i => openPreorderIds.Contains(i.PreorderId)); + } + + // Group by product + var grouped = items + .GroupBy(i => i.ProductId) + .Select(g => new + { + ProductId = g.Key, + TotalRequested = g.Sum(i => i.RequestedQuantity), + TotalFulfilled = g.Sum(i => i.FulfilledQuantity), + TotalUnfulfilled = g.Sum(i => i.RequestedQuantity - i.FulfilledQuantity), + PreorderCount = g.Select(i => i.PreorderId).Distinct().Count(), + AvgUnitPrice = g.Where(i => i.UnitPriceInclTax > 0).Any() + ? g.Where(i => i.UnitPriceInclTax > 0).Average(i => i.UnitPriceInclTax) + : 0m + }) + .OrderByDescending(g => g.TotalUnfulfilled) + .ThenByDescending(g => g.TotalRequested) + .ToList(); + + // Resolve product names in one batch + var productIds = grouped.Select(g => g.ProductId).Distinct().ToList(); + var productDtos = await _dbContext.ProductDtos + .GetAll(false) + .Where(p => productIds.Contains(p.Id)) + .ToListAsync(); + var productById = productDtos.ToDictionary(p => p.Id); + + var rows = grouped.Select(g => + { + productById.TryGetValue(g.ProductId, out var dto); + return new PreorderDemandRow + { + ProductId = g.ProductId, + ProductName = dto?.Name ?? $"Product #{g.ProductId}", + Sku = dto?.Id.ToString(), + IsMeasurable = dto?.IsMeasurable ?? false, + TotalRequested = g.TotalRequested, + TotalFulfilled = g.TotalFulfilled, + TotalUnfulfilled = g.TotalUnfulfilled, + PreorderCount = g.PreorderCount, + AvgUnitPrice = Math.Round(g.AvgUnitPrice, 0) + }; + }).ToList(); + + int recordsTotal = rows.Count; + int recordsFiltered = rows.Count; + var page = rows.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAvailabilityController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAvailabilityController.cs new file mode 100644 index 0000000..bb7c412 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAvailabilityController.cs @@ -0,0 +1,258 @@ +using FruitBank.Common.Server; +using LinqToDB; +using Microsoft.AspNetCore.Mvc; +using Nop.Core; +using Nop.Core.Domain.Catalog; +using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; + +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +[AutoValidateAntiforgeryToken] +public class PreorderAvailabilityController : BasePluginController +{ + private readonly IPermissionService _permissionService; + private readonly FruitBankDbContext _dbContext; + private readonly FruitBankAttributeService _fruitBankAttributeService; + private readonly IStoreContext _storeContext; + + public PreorderAvailabilityController( + IPermissionService permissionService, + FruitBankDbContext dbContext, + FruitBankAttributeService fruitBankAttributeService, + IStoreContext storeContext) + { + _permissionService = permissionService; + _dbContext = dbContext; + _fruitBankAttributeService = fruitBankAttributeService; + _storeContext = storeContext; + } + + // ── INDEX ───────────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/PreorderAvailability")] + public async Task Index() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/PreorderAvailability/Index.cshtml"); + } + + // ── ALL PRODUCTS — DataTables server-side ───────────────────────────────── + + [HttpPost] + [Route("Admin/PreorderAvailability/ProductList")] + public async Task ProductList() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Forbid(); + + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500); + + var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? ""; + + var storeId = (await _storeContext.GetCurrentStoreAsync()).Id; + + // 1. All published products + var products = await _dbContext.Products.Table + .Where(p => !p.Deleted && p.Published) + .OrderBy(p => p.Name) + .Select(p => new { p.Id, p.Name, p.Sku }) + .ToListAsync(); + + // 2. All preorder window generic attributes — two queries, no N+1 + var gaStart = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowStart + && ga.StoreId == storeId) + .ToListAsync(); + + var gaEnd = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowEnd + && ga.StoreId == storeId) + .ToListAsync(); + + var startByProduct = gaStart.ToDictionary(g => g.EntityId, g => g.Value); + var endByProduct = gaEnd.ToDictionary(g => g.EntityId, g => g.Value); + + var today = DateTime.UtcNow.Date; + + // 3. Build rows + var rows = products.Select(p => + { + DateTime.TryParse(startByProduct.GetValueOrDefault(p.Id), out var ws); + DateTime.TryParse(endByProduct.GetValueOrDefault(p.Id), out var we); + + var hasStart = startByProduct.ContainsKey(p.Id); + var hasEnd = endByProduct.ContainsKey(p.Id); + + return new PreorderAvailabilityRow + { + ProductId = p.Id, + ProductName = p.Name, + Sku = p.Sku, + WindowStart = hasStart ? ws.ToString("yyyy-MM-dd") : null, + WindowEnd = hasEnd ? we.ToString("yyyy-MM-dd") : null, + IsAvailableToday = hasStart && hasEnd && ws.Date <= today && today <= we.Date + }; + }).ToList(); + + int recordsTotal = rows.Count; + + // 4. Global search + if (!string.IsNullOrWhiteSpace(globalSearch)) + { + rows = rows.Where(r => + r.ProductName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + (r.Sku?.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ?? false) + ).ToList(); + } + + int recordsFiltered = rows.Count; + + // 5. Paginate + var page = rows.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } + + // ── AVAILABLE TODAY — DataTables server-side ────────────────────────────── + + [HttpPost] + [Route("Admin/PreorderAvailability/AvailableTodayList")] + public async Task AvailableTodayList() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Forbid(); + + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500); + + var storeId = (await _storeContext.GetCurrentStoreAsync()).Id; + var today = DateTime.UtcNow.Date; + + // Reuse same build logic — filter to available today only + var products = await _dbContext.Products.Table + .Where(p => !p.Deleted && p.Published) + .OrderBy(p => p.Name) + .Select(p => new { p.Id, p.Name, p.Sku }) + .ToListAsync(); + + var gaStart = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowStart + && ga.StoreId == storeId) + .ToListAsync(); + + var gaEnd = await _dbContext.GenericAttributes.Table + .Where(ga => ga.KeyGroup == nameof(Product) + && ga.Key == FruitBankConst.PreorderWindowEnd + && ga.StoreId == storeId) + .ToListAsync(); + + var startByProduct = gaStart.ToDictionary(g => g.EntityId, g => g.Value); + var endByProduct = gaEnd.ToDictionary(g => g.EntityId, g => g.Value); + + var rows = products + .Where(p => + { + if (!startByProduct.TryGetValue(p.Id, out var sRaw)) return false; + if (!endByProduct.TryGetValue(p.Id, out var eRaw)) return false; + if (!DateTime.TryParse(sRaw, out var ws)) return false; + if (!DateTime.TryParse(eRaw, out var we)) return false; + return ws.Date <= today && today <= we.Date; + }) + .Select(p => + { + DateTime.TryParse(startByProduct[p.Id], out var ws); + DateTime.TryParse(endByProduct[p.Id], out var we); + return new PreorderAvailabilityRow + { + ProductId = p.Id, + ProductName = p.Name, + Sku = p.Sku, + WindowStart = ws.ToString("yyyy-MM-dd"), + WindowEnd = we.ToString("yyyy-MM-dd"), + IsAvailableToday = true + }; + }) + .ToList(); + + int recordsTotal = rows.Count; + int recordsFiltered = rows.Count; + + var page = rows.Skip(start).Take(length).ToList(); + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } + + // ── SAVE WINDOW DATES for a product ─────────────────────────────────────── + + [HttpPost] + [Route("Admin/PreorderAvailability/SaveWindow")] + public async Task SaveWindow(int productId, string? windowStart, string? windowEnd) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + try + { + var storeId = (await _storeContext.GetCurrentStoreAsync()).Id; + + // WindowStart + if (string.IsNullOrWhiteSpace(windowStart)) + { + await _fruitBankAttributeService + .DeleteGenericAttributeAsync(productId, FruitBankConst.PreorderWindowStart, storeId); + } + else if (DateTime.TryParse(windowStart, out var ws)) + { + await _fruitBankAttributeService + .InsertOrUpdateGenericAttributeAsync( + productId, FruitBankConst.PreorderWindowStart, ws.Date, storeId); + } + else return Json(new { success = false, error = $"Invalid start date: {windowStart}" }); + + // WindowEnd + if (string.IsNullOrWhiteSpace(windowEnd)) + { + await _fruitBankAttributeService + .DeleteGenericAttributeAsync(productId, FruitBankConst.PreorderWindowEnd, storeId); + } + else if (DateTime.TryParse(windowEnd, out var we)) + { + await _fruitBankAttributeService + .InsertOrUpdateGenericAttributeAsync( + productId, FruitBankConst.PreorderWindowEnd, we.Date, storeId); + } + else return Json(new { success = false, error = $"Invalid end date: {windowEnd}" }); + + // Return the new availability state + var today = DateTime.UtcNow.Date; + DateTime.TryParse(windowStart, out var startParsed); + DateTime.TryParse(windowEnd, out var endParsed); + bool isAvailableToday = !string.IsNullOrWhiteSpace(windowStart) + && !string.IsNullOrWhiteSpace(windowEnd) + && startParsed.Date <= today + && today <= endParsed.Date; + + return Json(new { success = true, isAvailableToday }); + } + catch (Exception ex) + { + return Json(new { success = false, error = ex.Message }); + } + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/ConfigureModel.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/ConfigureModel.cs index 0b5c786..94508bc 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/ConfigureModel.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/ConfigureModel.cs @@ -9,25 +9,25 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models { public record ConfigureModel { - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiKey")] public string ApiKey { get; set; } = string.Empty; - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasModelName")] public string ModelName { get; set; } = string.Empty; - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiKey")] public string OpenAIApiKey { get; set; } = string.Empty; - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIModelName")] public string OpenAIModelName { get; set; } = string.Empty; [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.IsEnabled")] public bool IsEnabled { get; set; } - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl")] public string ApiBaseUrl { get; set; } = string.Empty; - [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")] + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl")] public string OpenAIApiBaseUrl { get; set; } = string.Empty; [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.MaxTokens")] @@ -38,6 +38,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds")] public int RequestTimeoutSeconds { get; set; } + + // ── Z.ai GLM-OCR ────────────────────────────────────────────────────────────── + + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ZaiApiKey")] + public string ZaiApiKey { get; set; } = string.Empty; + + [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ZaiModel")] + public string ZaiModel { get; set; } = "glm-ocr"; } } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditListRow.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditListRow.cs new file mode 100644 index 0000000..fd05fee --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditListRow.cs @@ -0,0 +1,13 @@ +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class CustomerCreditListRow +{ + public int CustomerId { get; set; } + public string CustomerEmail { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public bool HasCreditLimit { get; set; } + public decimal CreditLimit { get; set; } + public decimal OutstandingBalance { get; set; } + public decimal? RemainingCredit { get; set; } + public string? Comment { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditModel.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditModel.cs new file mode 100644 index 0000000..3fff84c --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditModel.cs @@ -0,0 +1,30 @@ +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class CustomerCreditModel +{ + public int CustomerId { get; set; } + public string CustomerEmail { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + + // Credit record + public int CreditId { get; set; } + public decimal CreditLimit { get; set; } + public string? Comment { get; set; } + public bool HasCreditLimit { get; set; } + + // Calculated + public decimal OutstandingBalance { get; set; } + public decimal? RemainingCredit { get; set; } + + // Unpaid orders table + public List UnpaidOrders { get; set; } = new(); +} + +public class CustomerCreditOrderRow +{ + public int OrderId { get; set; } + public decimal OrderTotal { get; set; } + public DateTime CreatedOnUtc { get; set; } + public string OrderStatus { get; set; } = string.Empty; + public string PaymentStatus { get; set; } = string.Empty; +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs new file mode 100644 index 0000000..71a8ffa --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs @@ -0,0 +1,59 @@ +using FruitBank.Common.Enums; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class PreorderListRow +{ + public int PreorderId { get; set; } + public int CustomerId { get; set; } + public string CustomerName { get; set; } = string.Empty; + public string CustomerEmail { get; set; } = string.Empty; + public string DateOfReceipt { get; set; } = string.Empty; // formatted + public string CreatedOnUtc { get; set; } = string.Empty; // formatted + public PreorderStatus Status { get; set; } + public string StatusLabel { get; set; } = string.Empty; + public int ItemCount { get; set; } + public int FulfilledCount { get; set; } + public int? OrderId { get; set; } // linked real order, if created +} + +public class PreorderDetailModel +{ + public int PreorderId { get; set; } + public int CustomerId { get; set; } + public string CustomerName { get; set; } = string.Empty; + public string CustomerEmail { get; set; } = string.Empty; + public string DateOfReceipt { get; set; } = string.Empty; + public string CreatedOnUtc { get; set; } = string.Empty; + public string UpdatedOnUtc { get; set; } = string.Empty; + public PreorderStatus Status { get; set; } + public string? CustomerNote { get; set; } + public int? OrderId { get; set; } + public List Items { get; set; } = new(); +} + +public class PreorderDetailItemRow +{ + public int ItemId { get; set; } + public int ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public bool IsMeasurable { get; set; } + public int RequestedQuantity { get; set; } + public int FulfilledQuantity { get; set; } + public decimal UnitPriceInclTax { get; set; } + public PreorderItemStatus Status { get; set; } + public string StatusLabel { get; set; } = string.Empty; +} + +public class PreorderDemandRow +{ + public int ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public string? Sku { get; set; } + public bool IsMeasurable { get; set; } + public int TotalRequested { get; set; } // sum of RequestedQuantity + public int TotalFulfilled { get; set; } // sum of FulfilledQuantity + public int TotalUnfulfilled { get; set; } // TotalRequested - TotalFulfilled + public int PreorderCount { get; set; } // distinct preorders containing this product + public decimal AvgUnitPrice { get; set; } // average snapshot price +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAvailabilityRow.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAvailabilityRow.cs new file mode 100644 index 0000000..baf8d6f --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAvailabilityRow.cs @@ -0,0 +1,11 @@ +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class PreorderAvailabilityRow +{ + public int ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public string? Sku { get; set; } + public string? WindowStart { get; set; } // ISO date string "yyyy-MM-dd" or null + public string? WindowEnd { get; set; } // ISO date string "yyyy-MM-dd" or null + public bool IsAvailableToday { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Configure/Configure.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Configure/Configure.cshtml index ddcca9b..c5b9281 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Configure/Configure.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Configure/Configure.cshtml @@ -23,40 +23,42 @@ + A Cerebras API kulcs
- Az AI modell neve (pl. gpt-3.5-turbo, gpt-4, claude-3-sonnet) + A Cerebras AI modell neve (pl. gpt-3.5-turbo, gpt-4, claude-3-sonnet)
+ Az OpenAI API kulcs
- Az AI modell neve (pl. gpt-3.5-turbo, gpt-4) + Az OpenAI AI modell neve (pl. gpt-3.5-turbo, gpt-4)
- Az API alapcíme (OpenAI, Azure OpenAI, stb.) + A Cerebras API alapcíme (OpenAI, Azure OpenAI, stb.)
- Az API alapcíme (OpenAI, Azure OpenAI, stb.) + Az OpenAI API alapcíme (OpenAI, Azure OpenAI, stb.)
@@ -88,6 +90,28 @@
+
+
Z.ai GLM-OCR — Dokumentumfeldolgozás
+

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

+ +
+ + + + Z.ai API kulcs (bigmodel.cn). Üres hagyva a GLM-OCR funkció nem érhető el. +
+ +
+ + + + GLM-OCR modell neve. Alapesetben: glm-ocr +
+
+
+ + + + + +@* ── Unpaid orders table ── *@ +
+
+

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

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

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

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

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

+
+ +
+
+
+
+ @Html.AntiForgeryToken() + + + + + + + + + + + + +
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit") ✏️@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")
+
+
+
+
+ + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Index.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Index.cshtml index 2a8b998..e25b253 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Index.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Index.cshtml @@ -41,17 +41,15 @@ @await Html.PartialAsync("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Components/_WelcomeMessage.cshtml") - } - - @await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardNewsAfter, additionalData = Model }) - @if (!Model.IsLoggedInAsVendor && canManageOrders && canManageCustomers && canManageProducts && canManageReturnRequests) - {
- @await Component.InvokeAsync(typeof(CommonStatisticsViewComponent)) + @await Html.PartialAsync("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Components/_FruitBankDashboard.cshtml")
} + + @await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardNewsAfter, additionalData = Model }) + @* CommonStatisticsViewComponent removed — runs GetLowStockProductsAsync full table scan, causes SQL timeout *@ @await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardCommonstatisticsAfter, additionalData = Model }) @if (!Model.IsLoggedInAsVendor && (canManageOrders || canManageCustomers)) { @@ -83,35 +81,16 @@ } @await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardOrderreportsAfter, additionalData = Model }) - @if (!Model.IsLoggedInAsVendor && (canManageOrders || canManageProducts)) + @if (!Model.IsLoggedInAsVendor && canManageOrders) {
- @if (canManageOrders) - { -
- @await Html.PartialAsync("~/Areas/Admin/Views/Home/_LatestOrders.cshtml") -
- } -
- @if (canManageProducts) - { - @await Html.PartialAsync("~/Areas/Admin/Views/Home/_PopularSearchTermsReport.cshtml") - } +
+ @await Html.PartialAsync("~/Areas/Admin/Views/Home/_LatestOrders.cshtml")
} @await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardLatestordersSearchtermsAfter, additionalData = Model }) - @if (canManageOrders) - { -
-
- @await Html.PartialAsync("~/Areas/Admin/Views/Home/_BestsellersBriefReportByQuantity.cshtml") -
-
- @await Html.PartialAsync("~/Areas/Admin/Views/Home/_BestsellersBriefReportByAmount.cshtml") -
-
- } + @* PopularSearchTermsReport and BestsellersBriefReports removed — not relevant to FruitBank warehouse workflow *@ @await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardBottom, additionalData = Model })
diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/Edit.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/Edit.cshtml index 97cd999..409fb07 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/Edit.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/Edit.cshtml @@ -29,7 +29,7 @@ @T("Admin.Orders.EditOrderDetails") - @Model.CustomOrderNumber - @T("Admin.Orders.BackToList") + @T("Admin.Orders.BackToList")
diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml new file mode 100644 index 0000000..ebada1f --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml @@ -0,0 +1,642 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.Order.OrderSearchModelExtended + +@using FruitBank.Common.Interfaces +@using Nop.Services.Stores +@using Nop.Web.Areas.Admin.Components +@using Nop.Web.Areas.Admin.Models.Orders +@using Nop.Web.Framework.Infrastructure +@inject IStoreService storeService + +@{ + // Layout = "~/Areas/Admin/Views/Shared/_LayoutAdmin.cshtml"; + ViewBag.PageTitle = "FruitBank Rendelések"; + NopHtml.SetActiveMenuItemSystemName("Orders"); +} + +@* ── Action buttons ─────────────────────────────────────────────── *@ +
+
+

Rendelések

+
+ +
+ + + +
+
+ + + +
+
+
+
+ +
+
+ + @* ── Filter Panel ─────────────────────────────────────────────── *@ + + + @* ── Grid ─────────────────────────────────────────────────────── *@ +
+
+ @* Anti-forgery token for AJAX POSTs *@ + @Html.AntiForgeryToken() + + + + + + + + + + + + + + + + + + + +
Rendelés #PartnerInnVoiceSúlyMérendőMérésÁtvétel ✏️StátuszFizetésSzállításLétrehozvaÖsszeg
+
+ +
+ +
+
+ + +@* ── Export selected – XML ──────────────────────────────────────── *@ +
+ +
+@* ── Export selected – Excel ────────────────────────────────────── *@ +
+ +
+@* ── PDF selected ───────────────────────────────────────────────── *@ +
+ +
+ +@* ── Create Order Modal ─────────────────────────────────────────── *@ + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/_CustomOrderDetails.Products.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/_CustomOrderDetails.Products.cshtml index cfb2b49..e678249 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/_CustomOrderDetails.Products.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/_CustomOrderDetails.Products.cshtml @@ -1,4 +1,4 @@ -@model Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.OrderModelExtended +@model Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.OrderModelExtended @using Nop.Core.Domain.Tax; @using Nop.Core.Domain.Catalog; @@ -6,95 +6,141 @@ @Html.AntiForgeryToken() +@* ── Single shared modal for editing any order item ────────────────── *@ + +
@@ -105,384 +151,211 @@ { } } } + + @* Hidden form fields required by the existing save mechanism *@ + @if (Model.ItemExtendeds != null) + { + foreach (var item in Model.ItemExtendeds) + { + + + + + + + + + + } + } + - - - @if (Model.HasDownloadableProducts) - { - - } - - - - - @if (!Model.IsLoggedInAsVendor) - { - - } - @* - *@ + @if (Model.HasDownloadableProducts) { - + } - - - - - - - - - @* *@ - + + + + + + + + + @if (!Model.IsLoggedInAsVendor) { - + } - @{ - if (Model.ItemExtendeds != null) + @if (Model.ItemExtendeds != null) + { + foreach (var item in Model.ItemExtendeds) { - foreach (var item in Model.ItemExtendeds) - { - - @* - *@ - @if (Model.HasDownloadableProducts) + var maxQty = Math.Max(item.ProductStockQuantity + item.ProductIncomingQuantity + item.Quantity, item.Quantity); + + +

} - + - - - - - - - - - - - - - @* *@ - - @if (!Model.IsLoggedInAsVendor) + } + + +
@Html.Raw(item.UnitPriceInclTax)
+
@Html.Raw(item.UnitPriceExclTax)
} - - } + else + { + switch (Model.TaxDisplayType) + { + case TaxDisplayType.ExcludingTax: @Html.Raw(item.UnitPriceExclTax) break; + case TaxDisplayType.IncludingTax: @Html.Raw(item.UnitPriceInclTax) break; + } + } + + + + + + + + + + + + + + + + + + + @if (!Model.IsLoggedInAsVendor) + { + + } + } } -
- @T("Admin.Orders.Products.Picture") - - @T("Admin.Orders.Products.ProductName") - @T("Admin.Orders.Products.ProductName") - @T("Admin.Orders.Products.Download") - @T("Admin.Orders.Products.Download") - @T("Admin.Orders.Products.Price") - - @T("Admin.Orders.Products.Quantity") - - @T("FruitBank.StockQuantity") - - @T("FruitBank.NetWeight") - - @T("FruitBank.IsMeasurable") - - Mérés állapota - - Súlyeltérés - - Súlyeltérés mértéke - - @T("Admin.Orders.Products.Discount") - - @T("Admin.Orders.Products.Total") - @T("Admin.Orders.Products.Price")@T("Admin.Orders.Products.Quantity")@T("FruitBank.StockQuantity")@T("FruitBank.NetWeight")@T("FruitBank.IsMeasurable")Mérés állapotaSúlyeltérésSúlyeltérés mértéke@T("Admin.Orders.Products.Total") - @T("Admin.Common.Edit") - @T("Admin.Common.Edit")
- - - @item.ProductName - @if (!string.IsNullOrEmpty(item.AttributeInfo)) - { -

- @Html.Raw(item.AttributeInfo) -

- } - @if (!string.IsNullOrEmpty(item.RecurringInfo)) - { -

- @Html.Raw(item.RecurringInfo) -

- } - @if (!string.IsNullOrEmpty(item.RentalInfo)) - { -

- @Html.Raw(item.RentalInfo) -

- } - @if (!string.IsNullOrEmpty(item.Sku)) - { -

- @T("Admin.Orders.Products.SKU"): - @item.Sku -

- } - @if (!string.IsNullOrEmpty(item.VendorName)) - { -

- @T("Admin.Orders.Products.Vendor"): - @item.VendorName -

- } - @if (item.ReturnRequests.Count > 0) - { -

- @T("Admin.Orders.Products.ReturnRequests"): - @for (var i = 0; i < item.ReturnRequests.Count; i++) - { - var returnRequest = item.ReturnRequests[i]; - @returnRequest.CustomNumber - if (i != item.ReturnRequests.Count - 1) - { - , - } - } -

- } -
+ @item.ProductName + @if (!string.IsNullOrEmpty(item.AttributeInfo)) {

@Html.Raw(item.AttributeInfo)

} + @if (!string.IsNullOrEmpty(item.RecurringInfo)) {

@Html.Raw(item.RecurringInfo)

} + @if (!string.IsNullOrEmpty(item.RentalInfo)) {

@Html.Raw(item.RentalInfo)

} + @if (!string.IsNullOrEmpty(item.Sku)) {

@T("Admin.Orders.Products.SKU"): @item.Sku

} + @if (!string.IsNullOrEmpty(item.VendorName)) {

@T("Admin.Orders.Products.Vendor"): @item.VendorName

} + @if (item.ReturnRequests.Count > 0) { -
- @if (item.IsDownload) +

@T("Admin.Orders.Products.ReturnRequests"): + @for (int i = 0; i < item.ReturnRequests.Count; i++) { - - @T("Admin.Orders.Products.Download.Download") - - + var rr = item.ReturnRequests[i]; + @rr.CustomNumber + if (i != item.ReturnRequests.Count - 1) { , } } - else - { - @T("Admin.Orders.Products.Download.NotAvailable") - } -

- @if (Model.AllowCustomersToSelectTaxDisplayType) - { -
@Html.Raw(item.UnitPriceInclTax)
-
@Html.Raw(item.UnitPriceExclTax)
- } - else - { - switch (Model.TaxDisplayType) - { - case TaxDisplayType.ExcludingTax: - { - @Html.Raw(item.UnitPriceExclTax) - } - break; - case TaxDisplayType.IncludingTax: - { - @Html.Raw(item.UnitPriceInclTax) - } - break; - default: - break; - } - } -
-
-
- @T("Admin.Orders.Products.Edit.InclTax") -
-
- -
-
- @*
-
- @T("Admin.Orders.Products.Edit.ExclTax") -
-
- -
-
- *@
-
-
@(item.Quantity) kt.
-
-
-
- -
-
-
-
-
- @($"{item.ProductStockQuantity} kt.")@($"{(item.ProductIncomingQuantity > 0 ? " (+" + item.ProductIncomingQuantity + ")" : string.Empty)}") -
-
- @(item.NetWeight) kg. - - - @if (item.IsMeasurable) - { - Yes - } - else - { - No - } - - -
- @($"{item.MeasuringStatusString}") -
-
- @if (!item.AverageWeightIsValid) - { - !!! - } - else - { - OK - } - - - - - Eltérés: @item.AverageWeightDifference (KG) - Mért átlag: @item.AverageWeight (KG/rekesz) - Elvárt: @item.ProductAverageWeight - - - - @if (Model.AllowCustomersToSelectTaxDisplayType) + @if (Model.HasDownloadableProducts) { -
@Html.Raw(item.DiscountInclTax)
-
@Html.Raw(item.DiscountExclTax)
- } - else - { - switch (Model.TaxDisplayType) - { - case TaxDisplayType.ExcludingTax: - { - @Html.Raw(item.DiscountExclTax) - } - break; - case TaxDisplayType.IncludingTax: - { - @Html.Raw(item.DiscountInclTax) - } - break; - default: - break; - } - } -
-
-
- @T("Admin.Orders.Products.Edit.InclTax") -
-
- -
-
-
-
- @T("Admin.Orders.Products.Edit.ExclTax") -
-
- -
-
-
-
- @if (Model.AllowCustomersToSelectTaxDisplayType) + + @if (item.IsDownload) { -
@Html.Raw(item.SubTotalInclTax)
-
@Html.Raw(item.SubTotalExclTax)
+ + @T("Admin.Orders.Products.Download.Download") + } - else - { - switch (Model.TaxDisplayType) - { - case TaxDisplayType.ExcludingTax: - { - @Html.Raw(item.SubTotalExclTax) - } - break; - case TaxDisplayType.IncludingTax: - { - @Html.Raw(item.SubTotalInclTax) - } - break; - default: - break; - } - } -
-
-
- @T("Admin.Orders.Products.Edit.InclTax") -
-
- -
-
- @*
-
- @T("Admin.Orders.Products.Edit.ExclTax") -
-
- -
-
*@ -
+ else { @T("Admin.Orders.Products.Download.NotAvailable") }
+ @if (Model.AllowCustomersToSelectTaxDisplayType) { - - - - - - - - - - -
+ @item.Quantity kt. + + @($"{item.ProductStockQuantity} kt.") + @($"{(item.ProductIncomingQuantity > 0 ? " (+" + item.ProductIncomingQuantity + ")" : string.Empty)}") + + @(item.NetWeight) kg. + + @if (item.IsMeasurable) + { + Yes + } + else + { + No + } + + @item.MeasuringStatusString + + @if (!item.AverageWeightIsValid) + { + !!! + } + else + { + OK + } + + Eltérés: @item.AverageWeightDifference (KG) + Mért átlag: @item.AverageWeight (KG/rekesz) + Elvárt: @item.ProductAverageWeight + + @if (Model.AllowCustomersToSelectTaxDisplayType) + { +
@Html.Raw(item.SubTotalInclTax)
+
@Html.Raw(item.SubTotalExclTax)
+ } + else + { + switch (Model.TaxDisplayType) + { + case TaxDisplayType.ExcludingTax: @Html.Raw(item.SubTotalExclTax) break; + case TaxDisplayType.IncludingTax: @Html.Raw(item.SubTotalInclTax) break; + } + } +
+ @* Edit → opens modal *@ + + + @* Delete (unchanged) *@ + + + + @* Hidden save/cancel buttons — still submitted by the form *@ + + +
+ @if (!string.IsNullOrEmpty(Model.CheckoutAttributeInfo) && !Model.IsLoggedInAsVendor) {
@@ -491,288 +364,137 @@
} + @if (!Model.IsLoggedInAsVendor) { - @*
-
- -
-
*@ -
-
} -@* Add Product to Order Modal *@ -