diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs index 572ae1d..fd09144 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs @@ -226,7 +226,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers PaymentStatusIds = paymentStatuses, ShippingStatusIds = shippingStatuses, AvailablePageSizes = "20,50,100,500", - + SortColumnDirection = "desc", }); return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/List.cshtml", model); diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs index acd05af..f421c37 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs @@ -1,12 +1,177 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Nop.Core.Domain.Catalog; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Catalog; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; +using PDFtoImage; +using SkiaSharp; -namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers + +namespace Nop.Plugin.Misc.FruitBank.Controllers { - public class FileManagerController : Controller + [AuthorizeAdmin] + [Area(AreaNames.ADMIN)] + [AutoValidateAntiforgeryToken] + public class FileManagerController : BasePluginController { - public IActionResult BindingToFileSystem() + private readonly IPermissionService _permissionService; + private readonly OpenAIApiService _aiApiService; + private readonly IProductService _productService; + private readonly FruitBankDbContext _dbContext; + private readonly PdfToImageService _pdfToImageService; + + public FileManagerController( + IPermissionService permissionService, + OpenAIApiService aiApiService, + IProductService productService, + FruitBankDbContext fruitBankDbContext, + PdfToImageService pdfToImageService) { - return View(); + _permissionService = permissionService; + _aiApiService = aiApiService; + _productService = productService; + _dbContext = fruitBankDbContext; + _pdfToImageService = pdfToImageService; + } + + /// + /// Display the image text extraction page + /// + public async Task ImageTextExtraction() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Extras/ImageTextExtraction.cshtml"); + } + + /// + /// Endpoint to extract text from uploaded image + /// + [HttpPost] + public async Task ExtractTextFromImage(IFormFile imageFile, string customPrompt = null) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, message = "Access denied" }); + + if (imageFile == null || imageFile.Length == 0) + { + return Json(new { success = false, message = "No file received" }); + } + + // Validate file type - now including PDF + var extension = Path.GetExtension(imageFile.FileName).ToLowerInvariant(); + if (extension != ".jpg" && extension != ".jpeg" && extension != ".png" && + extension != ".gif" && extension != ".webp" && extension != ".pdf") + { + return Json(new { success = false, message = "Invalid file type. Please upload JPG, PNG, GIF, WebP, or PDF." }); + } + + try + { + // Define the uploads folder + var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "ocr"); + + // Create directory if it doesn't exist + if (!Directory.Exists(uploadsFolder)) + { + Directory.CreateDirectory(uploadsFolder); + } + + string processedFilePath; + string processedFileName; + + // Handle PDF conversion + if (extension == ".pdf") + { + // Save the PDF temporarily + var tempPdfFileName = $"temp_pdf_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; + var tempPdfPath = Path.Combine(uploadsFolder, tempPdfFileName); + + using (var stream = new FileStream(tempPdfPath, FileMode.Create)) + { + await imageFile.CopyToAsync(stream); + } + + // Convert PDF to JPG using our service + var convertedImages = await _pdfToImageService.ConvertPdfToJpgAsync(tempPdfPath, uploadsFolder); + + if (convertedImages == null || convertedImages.Count == 0) + { + // Clean up temp PDF + if (System.IO.File.Exists(tempPdfPath)) + System.IO.File.Delete(tempPdfPath); + + return Json(new { success = false, message = "Failed to convert PDF or PDF is empty" }); + } + + // Use the first page + processedFilePath = convertedImages[0]; + processedFileName = Path.GetFileName(processedFilePath); + + // Clean up temp PDF + if (System.IO.File.Exists(tempPdfPath)) + System.IO.File.Delete(tempPdfPath); + } + else + { + // Handle regular image files + processedFileName = $"ocr_image_{DateTime.Now:yyyyMMdd_HHmmss}{extension}"; + processedFilePath = Path.Combine(uploadsFolder, processedFileName); + + using (var stream = new FileStream(processedFilePath, FileMode.Create)) + { + await imageFile.CopyToAsync(stream); + } + } + + // Extract text from the processed image using OpenAI Vision API + string extractedText; + using (var imageStream = new FileStream(processedFilePath, FileMode.Open, FileAccess.Read)) + { + extractedText = await _aiApiService.ExtractTextFromImageAsync( + imageStream, + processedFileName, + customPrompt + ); + } + + if (string.IsNullOrEmpty(extractedText)) + { + return Json(new + { + success = false, + message = "Failed to extract text. The API may have returned an empty response." + }); + } + + return Json(new + { + success = true, + message = extension == ".pdf" + ? "PDF converted and text extracted successfully" + : "Text extracted successfully", + extractedText = extractedText, + fileName = processedFileName, + filePath = processedFilePath, + fileSize = imageFile.Length, + wasConverted = extension == ".pdf" + }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error in ExtractTextFromImage: {ex}"); + return Json(new + { + success = false, + message = $"Error processing file: {ex.Message}" + }); + } } } } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankAudioController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankAudioController.cs new file mode 100644 index 0000000..fda3d98 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankAudioController.cs @@ -0,0 +1,160 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +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; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Nop.Plugin.Misc.FruitBank.Controllers +{ + [AuthorizeAdmin] + [Area(AreaNames.ADMIN)] + [AutoValidateAntiforgeryToken] + public class FruitBankAudioController : BasePluginController + { + private readonly IPermissionService _permissionService; + private readonly OpenAIApiService _aiApiService; + private readonly FruitBankDbContext _dbContext; + + public FruitBankAudioController( + IPermissionService permissionService, + OpenAIApiService aiApiService, + FruitBankDbContext fruitBankDbContext) + { + _permissionService = permissionService; + _aiApiService = aiApiService; + _dbContext = fruitBankDbContext; + } + + /// + /// Display the voice recorder page + /// + public async Task VoiceRecorder() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Extras/VoiceRecorder.cshtml"); + } + + /// + /// Endpoint to receive voice recording + /// + [HttpPost] + public async Task ReceiveVoiceRecording(IFormFile audioFile) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, message = "Access denied" }); + + if (audioFile == null || audioFile.Length == 0) + { + return Json(new { success = false, message = "No audio file received" }); + } + + string productDataSummary = string.Empty; + string orderDataSummary = string.Empty; + + var products = await _dbContext.Products.GetAll().ToListAsync(); + foreach (var product in products) + { + //let's prepare basic product and stock information for AI analysis + if (product == null) continue; + + var productName = product.Name; + var stockQuantity = product.StockQuantity; + + productDataSummary += $"Product: {productName}, Stock Quantity: {stockQuantity}\n"; + + } + + var orders = await _dbContext.OrderDtos.GetAll(true).ToListAsync(); + + foreach (var order in orders) + { + //let's prepare basic order information for AI analysis + if (order == null) continue; + var orderId = order.Id; + var customerName = order.Customer.Company; + var totalAmount = order.OrderTotal; + var dateofReceipt = order.DateOfReceipt; + var isMeasurable = order.IsMeasurable; + var itemsInOrder = order.OrderItemDtos.Count; + orderDataSummary += $"Order ID: {orderId}, Customer: {customerName}, Total Amount: {totalAmount}, Date of Receipt: {dateofReceipt}, Is Measurable: {isMeasurable}, Items in Order: {itemsInOrder}\n"; + } + + + Console.WriteLine("Product Data Summary: " + productDataSummary); + + try + { + // Generate a unique filename + var fileName = $"voice_recording_{DateTime.Now:yyyyMMdd_HHmmss}.webm"; + + // Define the path where you want to save the file + var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "voice"); + + // Create directory if it doesn't exist + if (!Directory.Exists(uploadsFolder)) + { + Directory.CreateDirectory(uploadsFolder); + } + + var filePath = Path.Combine(uploadsFolder, fileName); + + // Save the file locally + using (var stream = new FileStream(filePath, FileMode.Create)) + { + await audioFile.CopyToAsync(stream); + } + + // Transcribe the audio using OpenAI Whisper API + string transcribedText; + using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) + { + transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, "en"); // or "hu" for Hungarian + } + + if (string.IsNullOrEmpty(transcribedText)) + { + return Json(new + { + success = false, + message = "Failed to transcribe audio" + }); + } + + string SystemMessage = "You are an assistant that helps with analyzing data for Fruitbank, a fruit trading company based in Hungary. " + + "Provide insights and suggestions based on the provided data." + + $"Products information: {productDataSummary}" + + $"Orders information: {orderDataSummary}"; + + + var AIResponse = await _aiApiService.GetSimpleResponseAsync(SystemMessage, $"Transcribed Text: {transcribedText}"); + + + + return Json(new + { + success = true, + message = $"Audio transcribed successfully", + transcription = AIResponse, + filePath = filePath, + fileSize = audioFile.Length + }); + } + catch (Exception ex) + { + return Json(new + { + success = false, + message = $"Error processing audio: {ex.Message}" + }); + } + } + } +} \ No newline at end of file diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/EditShippingModel.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/EditShippingModel.cs index 124c0ee..414c02d 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/EditShippingModel.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/EditShippingModel.cs @@ -8,7 +8,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models public record EditShippingModel : BaseNopModel { public int Id { get; set; } - + public string LicencePlate { get; set; } @@ -61,4 +61,4 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models public List DocumentIds { get; set; } = new(); public string Operation { get; set; } // "delete", "activate", "deactivate" } -} \ No newline at end of file +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Extras/ImageTextExtraction.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Extras/ImageTextExtraction.cshtml new file mode 100644 index 0000000..edb52a2 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Extras/ImageTextExtraction.cshtml @@ -0,0 +1,223 @@ +@{ + Layout = "_ConfigurePlugin"; +} + +@await Component.InvokeAsync("StoreScopeConfiguration") + +
+ @Html.AntiForgeryToken() +
+ +
+
+

Image & PDF Text Extraction (OCR)

+

Upload an image or PDF to extract text using OpenAI Vision API.

+ +
+
+
+ + +
+ Supported formats: JPG, PNG, GIF, WebP, PDF +
+
+ +
+
+ + + Customize how the AI should extract and format the text +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + +
+
+ + \ No newline at end of file diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Extras/VoiceRecorder.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Extras/VoiceRecorder.cshtml new file mode 100644 index 0000000..643d1bb --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Extras/VoiceRecorder.cshtml @@ -0,0 +1,176 @@ +@{ + Layout = "_ConfigurePlugin"; +} + +@await Component.InvokeAsync("StoreScopeConfiguration") + +
+ @Html.AntiForgeryToken() +
+ +
+
+

Voice Recorder

+

Click the button below to start recording your voice message.

+ +
+
+ + + +
+
+ + + + + +
+
+ + +
+
+
+
+ + diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/List.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/List.cshtml index 0083277..4e4ce1b 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/List.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/List.cshtml @@ -345,7 +345,7 @@ @{ var gridModel = new DataTablesModel { - Name = "orders-grid", + Name = "orders-grid", UrlRead = new DataUrl("OrderList", "CustomOrder", null), SearchButtonId = "search-orders", Ordering = true, @@ -400,10 +400,19 @@ gridModel.ColumnCollection.Add(new ColumnProperty(nameof(OrderModelExtended.InnvoiceTechId)) { Title = "Innvoiceba beküldve", - Width = "150", + Width = "100", Render = new RenderCustom("renderColumnInnvoiceTechId"), ClassName = NopColumnClassDefaults.CenterAll }); + + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(OrderModelExtended.IsAllOrderItemAvgWeightValid)) + { + Title = "Súlyeltérés", + Width = "100", + Render = new RenderCustom("renderColumnAverageWeightError"), + ClassName = NopColumnClassDefaults.CenterAll + }); + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(OrderModelExtended.IsMeasurable)) { Title = T($"FruitBank.{nameof(OrderModelExtended.IsMeasurable)}").Text, @@ -542,7 +551,14 @@ if(data != null) { return 'Igen'; } - return 'Nem'; + return 'Nem'; + } + + function renderColumnAverageWeightError(data, type, row, meta) { + if(data) { + return 'OK'; + } + return '!!!'; } function renderColumnIsMeasurable(data, type, row, meta) { 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 a41d112..901e7a7 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 @@ -159,7 +159,12 @@ Mérés állapota - + + Súlyeltérés + + + Súlyeltérés mértéke + @* @T("Admin.Orders.Products.Discount") *@ @@ -336,6 +341,24 @@ + + @if (!item.AverageWeightIsValid) + { + !!! + } + else + { + OK + } + + + + + + Eltérés: @item.AverageWeightDifference (KG), Mért átlag: @item.AverageWeight (KG/rekesz) + + + @* @if (Model.AllowCustomersToSelectTaxDisplayType) { diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Product/List.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Product/List.cshtml index f12452f..c761be2 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Product/List.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Product/List.cshtml @@ -285,6 +285,10 @@ { Title = "Súly(kg)"//T("Admin.Catalog.Products.Fields.NetWeight").Text }, + new ColumnProperty(nameof(ProductModelExtended.AverageWeight)) + { + Title = "Átlagsúly (kg)"//T("Admin.Catalog.Products.Fields.AverageWeight").Text + }, new ColumnProperty(nameof(ProductModelExtended.Tare)) { Title = "Tára(kg)"//T("Admin.Catalog.Products.Fields.Tare").Text diff --git a/Nop.Plugin.Misc.AIPlugin/Components/ProductAttributesViewComponent.cs b/Nop.Plugin.Misc.AIPlugin/Components/ProductAttributesViewComponent.cs index 0c9d974..a3246cc 100644 --- a/Nop.Plugin.Misc.AIPlugin/Components/ProductAttributesViewComponent.cs +++ b/Nop.Plugin.Misc.AIPlugin/Components/ProductAttributesViewComponent.cs @@ -46,6 +46,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components model.Tare = await _fruitBankAttributeService.GetGenericAttributeValueAsync(model.ProductId, nameof(ITare.Tare)); model.IncomingQuantity = await _fruitBankAttributeService.GetGenericAttributeValueAsync(model.ProductId, nameof(IIncomingQuantity.IncomingQuantity)); + model.AverageWeight = await _fruitBankAttributeService.GetGenericAttributeValueAsync(model.ProductId, nameof(IProductDto.AverageWeight)); + model.AverageWeightTreshold = await _fruitBankAttributeService.GetGenericAttributeValueAsync(model.ProductId, nameof(IProductDto.AverageWeightTreshold)); } return View("~/Plugins/Misc.FruitBankPlugin/Views/ProductAttributes.cshtml", model); diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs b/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs index b637b75..6fcc4ca 100644 --- a/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs +++ b/Nop.Plugin.Misc.AIPlugin/Domains/EventConsumers/FruitBankEventConsumer.cs @@ -129,6 +129,20 @@ public class FruitBankEventConsumer : if (productDto == null || productDto.Tare != tare) await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync(product.Id, nameof(ITare.Tare), tare); + //AverageWeight + var averageWeight = double.Round(CommonHelper.To(form[nameof(IProductDto.AverageWeight)].ToString()), 1); + if (averageWeight < 0) throw new Exception($"FruitBankEventConsumer->SaveProductCustomAttributesAsync(); (averageWeight < 0); productId: {product.Id}; averageWeight: {averageWeight}"); + + if (productDto == null || productDto.AverageWeight != averageWeight) + await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync(product.Id, nameof(IProductDto.AverageWeight), averageWeight); + + //AverageWeightTreshold + var averageWeightTreshold = double.Round(CommonHelper.To(form[nameof(IProductDto.AverageWeightTreshold)].ToString()), 1); + if (averageWeightTreshold < 0) throw new Exception($"FruitBankEventConsumer->SaveProductCustomAttributesAsync(); (averageWeightTreshold < 0); productId: {product.Id}; averageWeight: {averageWeightTreshold}"); + + if (productDto == null || productDto.AverageWeightTreshold != averageWeightTreshold) + await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync(product.Id, nameof(IProductDto.AverageWeightTreshold), averageWeightTreshold); + //IncomingQuantity var incomingQuantity = CommonHelper.To(form[nameof(IIncomingQuantity.IncomingQuantity)].ToString()); if (incomingQuantity < 0) throw new Exception($"FruitBankEventConsumer->SaveProductCustomAttributesAsync(); (incomingQuantity < 0); productId: {product.Id}; incomingQuantity: {incomingQuantity}"); diff --git a/Nop.Plugin.Misc.AIPlugin/Factories/CustomOrderModelFactory.cs b/Nop.Plugin.Misc.AIPlugin/Factories/CustomOrderModelFactory.cs index 2045dd1..3d94a7e 100644 --- a/Nop.Plugin.Misc.AIPlugin/Factories/CustomOrderModelFactory.cs +++ b/Nop.Plugin.Misc.AIPlugin/Factories/CustomOrderModelFactory.cs @@ -182,6 +182,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Factories orderModelExtended.IsMeasurable = orderDto.IsMeasurable; orderModelExtended.DateOfReceipt = orderDto.DateOfReceipt; orderModelExtended.OrderTotal = !orderDto.IsComplete && orderDto.IsMeasurable ? "kalkuláció alatt..." : orderModelExtended.OrderTotal; + orderModelExtended.IsAllOrderItemAvgWeightValid = orderDto.IsAllOrderItemAvgWeightValid; + //var fullName = $"{orderDto.Customer.FirstName}_{orderDto.Customer.LastName}".Trim(); orderModelExtended.CustomerCompany = $"{orderDto.Customer.Company} {orderDto.Customer.FirstName}_{orderDto.Customer.LastName}"; @@ -226,7 +228,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Factories orderItemModelExtended.ProductStockQuantity = orderItemDto.ProductDto!.StockQuantity; orderItemModelExtended.ProductIncomingQuantity = orderItemDto.ProductDto.IncomingQuantity; orderItemModelExtended.ProductAvailableQuantity = orderItemDto.ProductDto.AvailableQuantity; - + orderItemModelExtended.AverageWeight = orderItemDto.AverageWeight; + orderItemModelExtended.AverageWeightIsValid = orderItemDto.AverageWeightIsValid; + orderItemModelExtended.AverageWeightDifference = orderItemDto.AverageWeightDifference; orderItemModelExtended.SubTotalInclTax = orderItemDto.IsMeasurable && !orderItemDto.IsAudited ? "kalkuláció alatt..." : orderItemModelExtended.SubTotalInclTax; diff --git a/Nop.Plugin.Misc.AIPlugin/Factories/CustomProductModelFactory.cs b/Nop.Plugin.Misc.AIPlugin/Factories/CustomProductModelFactory.cs index 0617f9d..a51148e 100644 --- a/Nop.Plugin.Misc.AIPlugin/Factories/CustomProductModelFactory.cs +++ b/Nop.Plugin.Misc.AIPlugin/Factories/CustomProductModelFactory.cs @@ -62,6 +62,8 @@ public class CustomProductModelFactory : MgProductModelFactory(); //services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddControllersWithViews(options => { options.Filters.AddService(); diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs index 4b35c8a..7fe2e05 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs @@ -171,6 +171,16 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.AppDownload.Download", pattern: "Admin/AppDownload/Download/{version}/{fileName}", defaults: new { controller = "AppDownload", action = "Download", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.FruitBankAudio", + pattern: "Admin/VoiceRecorder", + defaults: new { controller = "FruitBankAudio", action = "VoiceRecorder", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.ExtractText", + pattern: "Admin/ExtractText", + defaults: new { controller = "FileManager", action = "ImageTextExtraction", area = AreaNames.ADMIN }); } /// diff --git a/Nop.Plugin.Misc.AIPlugin/Models/Orders/OrderModelExtended.cs b/Nop.Plugin.Misc.AIPlugin/Models/Orders/OrderModelExtended.cs index fb45d48..a43ba95 100644 --- a/Nop.Plugin.Misc.AIPlugin/Models/Orders/OrderModelExtended.cs +++ b/Nop.Plugin.Misc.AIPlugin/Models/Orders/OrderModelExtended.cs @@ -14,6 +14,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders public int ProductStockQuantity { get; set; } public int ProductIncomingQuantity { get; set; } public int ProductAvailableQuantity { get; set; } + public double AverageWeight { get; set; } + public bool AverageWeightIsValid { get; set; } + public double AverageWeightDifference { get; set; } + + + } public partial record OrderModelExtended : MgOrderModelExtended, IOrderModelExtended @@ -28,6 +34,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders public string InnvoiceTechId { get; set; } + public bool IsAllOrderItemAvgWeightValid { get; set; } + public IList ItemExtendeds { get; set; } } } diff --git a/Nop.Plugin.Misc.AIPlugin/Models/ProductAttributesModel.cs b/Nop.Plugin.Misc.AIPlugin/Models/ProductAttributesModel.cs index 56726ab..87fd459 100644 --- a/Nop.Plugin.Misc.AIPlugin/Models/ProductAttributesModel.cs +++ b/Nop.Plugin.Misc.AIPlugin/Models/ProductAttributesModel.cs @@ -22,5 +22,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models [NopResourceDisplayName("Plugins.YourCompany.ProductAttributes.Fields.Tare")] public double Tare { get; set; } + + [NopResourceDisplayName("Plugins.YourCompany.ProductAttributes.Fields.AverageWeight")] + public double AverageWeight { get; set; } + + [NopResourceDisplayName("Plugins.YourCompany.ProductAttributes.Fields.AverageWeightTreshold")] + public double AverageWeightTreshold { get; set; } } } \ No newline at end of file diff --git a/Nop.Plugin.Misc.AIPlugin/Models/Products/ProductModelExtended.cs b/Nop.Plugin.Misc.AIPlugin/Models/Products/ProductModelExtended.cs index 07559a9..30a5f8b 100644 --- a/Nop.Plugin.Misc.AIPlugin/Models/Products/ProductModelExtended.cs +++ b/Nop.Plugin.Misc.AIPlugin/Models/Products/ProductModelExtended.cs @@ -13,6 +13,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Products public int IncomingQuantity { get; set; } public int AvailableQuantity { get; set; } + + public double AverageWeight { get; set; } } } diff --git a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj index c0c9e98..746e23c 100644 --- a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj +++ b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj @@ -28,13 +28,18 @@ + - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -171,6 +176,12 @@ Always + + Always + + + Always + Always @@ -655,4 +666,10 @@ + + + + + + \ No newline at end of file diff --git a/Nop.Plugin.Misc.AIPlugin/Services/OpenAIApiService.cs b/Nop.Plugin.Misc.AIPlugin/Services/OpenAIApiService.cs index 70802ee..c1124da 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/OpenAIApiService.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/OpenAIApiService.cs @@ -20,6 +20,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services private const string OpenAiEndpoint = "https://api.openai.com/v1/chat/completions"; private const string OpenAiImageEndpoint = "https://api.openai.com/v1/images/generations"; private const string OpenAiFileEndpoint = "https://api.openai.com/v1/files"; + private const string OpenAiAudioEndpoint = "https://api.openai.com/v1/audio/transcriptions"; private const string BaseUrl = "https://api.openai.com/v1"; private string? _assistantId; @@ -43,6 +44,79 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services _onError = onErrorCallback; } + #region === AUDIO TRANSCRIPTION === + + /// + /// Transcribe audio file to text using OpenAI Whisper API + /// + /// The audio file stream + /// The original filename (used to determine format) + /// Optional language code (e.g., "en", "hu"). If null, auto-detects. + /// The transcribed text + public async Task TranscribeAudioAsync(Stream audioStream, string fileName, string? language = null) + { + try + { + using var form = new MultipartFormDataContent(); + + // Add the audio file + var audioContent = new StreamContent(audioStream); + + // Determine content type based on file extension + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + audioContent.Headers.ContentType = extension switch + { + ".mp3" => new MediaTypeHeaderValue("audio/mpeg"), + ".mp4" => new MediaTypeHeaderValue("audio/mp4"), + ".mpeg" => new MediaTypeHeaderValue("audio/mpeg"), + ".mpga" => new MediaTypeHeaderValue("audio/mpeg"), + ".m4a" => new MediaTypeHeaderValue("audio/m4a"), + ".wav" => new MediaTypeHeaderValue("audio/wav"), + ".webm" => new MediaTypeHeaderValue("audio/webm"), + _ => new MediaTypeHeaderValue("application/octet-stream") + }; + + form.Add(audioContent, "file", fileName); + + // Add model + form.Add(new StringContent("whisper-1"), "model"); + + // Add language if specified + if (!string.IsNullOrEmpty(language)) + { + form.Add(new StringContent(language), "language"); + } + + // Optional: Add response format (json is default, can also be text, srt, verbose_json, or vtt) + form.Add(new StringContent("json"), "response_format"); + + var response = await _httpClient.PostAsync(OpenAiAudioEndpoint, form); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Audio transcription failed: {error}"); + return null; + } + + await using var contentStream = await response.Content.ReadAsStreamAsync(); + using var json = await JsonDocument.ParseAsync(contentStream); + + var transcription = json.RootElement.GetProperty("text").GetString(); + + Console.WriteLine($"Audio transcription successful. Length: {transcription?.Length ?? 0} characters"); + + return transcription; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error transcribing audio: {ex}"); + return null; + } + } + + #endregion + #region === CHAT (TEXT INPUT) === public async Task GetSimpleResponseAsync(string systemMessage, string userMessage, string? assistantMessage = null) @@ -317,24 +391,24 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services Console.WriteLine("Cleaning up all existing vector stores..."); var listRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/vector_stores"); listRequest.Headers.Add("OpenAI-Beta", "assistants=v2"); - + var response = await _httpClient.SendAsync(listRequest); if (response.IsSuccessStatusCode) { using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); var vectorStores = json.RootElement.GetProperty("data"); - + foreach (var vectorStore in vectorStores.EnumerateArray()) { var id = vectorStore.GetProperty("id").GetString(); var name = vectorStore.TryGetProperty("name", out var nameElement) && nameElement.ValueKind != JsonValueKind.Null ? nameElement.GetString() : "Unnamed"; - + var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, $"{BaseUrl}/vector_stores/{id}"); deleteRequest.Headers.Add("OpenAI-Beta", "assistants=v2"); await _httpClient.SendAsync(deleteRequest); - + Console.WriteLine($"Deleted vector store: {name} ({id})"); } Console.WriteLine("Vector store cleanup complete!"); @@ -682,6 +756,99 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services } #endregion - } -} + + #region === IMAGE TEXT EXTRACTION === + + /// + /// Extract text from an image using OpenAI Vision API + /// + /// The image file stream + /// The original filename + /// Optional custom prompt for text extraction + /// Extracted and structured text from the image + public async Task ExtractTextFromImageAsync(Stream imageStream, string fileName, string? customPrompt = null) + { + try + { + // Read image bytes from stream + byte[] imgBytes; + using (var memoryStream = new MemoryStream()) + { + await imageStream.CopyToAsync(memoryStream); + imgBytes = memoryStream.ToArray(); + } + + string base64 = Convert.ToBase64String(imgBytes); + + // Determine image format + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + var mimeType = extension switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + _ => "image/jpeg" + }; + + var prompt = customPrompt ?? "Olvasd ki a szöveget és add vissza szépen strukturálva."; + + var payload = new + { + model = GetModelName(), // Use the configured model + messages = new object[] + { + new { + role = "user", + content = new object[] + { + new { type = "text", text = prompt }, + new { type = "image_url", image_url = new { url = $"data:{mimeType};base64,{base64}" } } + } + } + } + }; + + var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + var response = await _httpClient.PostAsync( + OpenAiEndpoint, + new StringContent(json, Encoding.UTF8, "application/json")); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Image text extraction failed: {error}"); + return null; + } + + var resultJson = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(resultJson); + + var inputTokens = doc.RootElement.GetProperty("usage").GetProperty("prompt_tokens").GetInt32(); + var outputTokens = doc.RootElement.GetProperty("usage").GetProperty("completion_tokens").GetInt32(); + Console.WriteLine($"USAGE STATS - Image OCR Tokens: {inputTokens} + {outputTokens} = {inputTokens + outputTokens}"); + + string text = doc.RootElement + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString(); + + return text; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error extracting text from image: {ex}"); + return null; + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Nop.Plugin.Misc.AIPlugin/Services/PdfToImageService.cs b/Nop.Plugin.Misc.AIPlugin/Services/PdfToImageService.cs new file mode 100644 index 0000000..1ffc07f --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/PdfToImageService.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Hosting; +using PDFtoImage; +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +public class PdfToImageService +{ + private readonly PdfiumBootstrapper _pdfiumBootstrapper; + + public PdfToImageService(IWebHostEnvironment env) + { + _pdfiumBootstrapper = new PdfiumBootstrapper(env); + } + + public async Task> ConvertPdfToJpgAsync(string pdfPath, string outputFolder) + { + _pdfiumBootstrapper.EnsurePdfiumLoaded(); + + var imagePaths = new List(); + + try + { + Directory.CreateDirectory(outputFolder); + + await Task.Run(() => + { + byte[] pdfBytes = File.ReadAllBytes(pdfPath); + + var options = new RenderOptions + { + Dpi = 300, + BackgroundColor = SKColors.White + }; + + var pdfImages = PDFtoImage.Conversion.ToImages(pdfBytes, options: options); + + int pageNumber = 1; + foreach (var page in pdfImages) + { + var outputPath = Path.Combine(outputFolder, $"page_{pageNumber}.jpg"); + + using (var fileStream = File.Create(outputPath)) + { + page.Encode(fileStream, SKEncodedImageFormat.Jpeg, 90); + } + + imagePaths.Add(outputPath); + page.Dispose(); + pageNumber++; + } + }); + + return imagePaths; + } + catch (Exception ex) + { + throw new Exception($"Error converting PDF to images: {ex.Message}", ex); + } + } + + public class PdfiumBootstrapper + { + private readonly IWebHostEnvironment _env; + private bool _loaded = false; + + public PdfiumBootstrapper(IWebHostEnvironment env) + { + _env = env; + } + + public void EnsurePdfiumLoaded() + { + if (_loaded) + return; + + var pluginPath = Path.Combine( + _env.ContentRootPath, + "Plugins", + "Misc.FruitBankPlugin" // <- change this + ); + + var archFolder = Environment.Is64BitProcess ? "win-x64" : "win-x86"; + var dllPath = Path.Combine(pluginPath, "runtimes", archFolder, "native", "pdfium.dll"); + + if (!File.Exists(dllPath)) + throw new FileNotFoundException($"Pdfium.dll not found: {dllPath}"); + + NativeLibrary.Load(dllPath); + _loaded = true; + } + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Views/ProductAttributes.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/ProductAttributes.cshtml index acde179..a167db2 100644 --- a/Nop.Plugin.Misc.AIPlugin/Views/ProductAttributes.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Views/ProductAttributes.cshtml @@ -21,7 +21,7 @@
- +
@@ -46,5 +46,24 @@ +
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+ \ No newline at end of file