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

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

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