diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Components/_DocumentsGridPartial.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Components/_DocumentsGridPartial.cshtml deleted file mode 100644 index 400c358..0000000 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Components/_DocumentsGridPartial.cshtml +++ /dev/null @@ -1,25 +0,0 @@ -@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.ShippingDocumentListModel -@using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models -@using FruitBank.Common.Entities; -@using DevExtreme.AspNet.Mvc -

Id: @Model.ShippingId

- -@(Html.DevExtreme().DataGrid() - .ID("documentsGrid") - .DataSource(Model.ShippingDocumentList) - .KeyExpr("Id") - .ShowBorders(true) - .Editing(editing => { - editing.Mode(GridEditMode.Row); - editing.AllowUpdating(true); - editing.AllowAdding(false); - editing.AllowDeleting(true); - }) - .Columns(c => { - c.AddFor(m => m.DocumentDate).Caption("Date").DataType(GridDataType.Date); - c.AddFor(m => m.SenderName).Caption("Sender"); - c.AddFor(m => m.InvoiceNumber).Caption("Invoice #"); - c.AddFor(m => m.TotalAmount).Caption("Amount").DataType(GridDataType.Number); - c.AddFor(m => m.ItemCount).Caption("ItemCount").DataType(GridDataType.Number); - }) -) \ No newline at end of file diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs index 103d63d..1291b4c 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs @@ -8,6 +8,8 @@ using Nop.Core.Domain.Orders; using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; using Nop.Plugin.Misc.FruitBankPlugin.Factories; using Nop.Plugin.Misc.FruitBankPlugin.Models; +using Nop.Services.Common; +using Nop.Services.Messages; using Nop.Services.Orders; using Nop.Services.Security; using Nop.Web.Areas.Admin.Controllers; @@ -26,14 +28,18 @@ 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 INotificationService _notificationService; // ... other dependencies - public CustomOrderController(IOrderService orderService, IOrderModelFactory orderModelFactory, ICustomOrderSignalREndpointServer customOrderSignalREndpoint, IPermissionService permissionService) + public CustomOrderController(IOrderService orderService, IOrderModelFactory orderModelFactory, ICustomOrderSignalREndpointServer customOrderSignalREndpoint, IPermissionService permissionService, IGenericAttributeService genericAttributeService, INotificationService notificationService) { _orderService = orderService; _orderModelFactory = orderModelFactory as CustomOrderModelFactory; _customOrderSignalREndpoint = customOrderSignalREndpoint; _permissionService = permissionService; + _genericAttributeService = genericAttributeService; + _notificationService = notificationService; // ... initialize other deps } @@ -101,6 +107,30 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers // return Json(model); //} + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SaveOrderAttributes(OrderAttributesModel model) + { + if (!ModelState.IsValid) + { + // reload order page with errors + return RedirectToAction("Edit", "Order", new { id = model.OrderId }); + } + + var order = await _orderService.GetOrderByIdAsync(model.OrderId); + if (order == null) + return RedirectToAction("List", "Order"); + + // store attributes in GenericAttribute table + await _genericAttributeService.SaveAttributeAsync(order, nameof(OrderModelExtended.IsMeasurable), model.IsMeasurable); + await _genericAttributeService.SaveAttributeAsync(order, nameof(IOrderDto.DateOfReceipt), model.DateOfReceipt); + + _notificationService.SuccessNotification("Custom attributes saved successfully."); + + return RedirectToAction("Edit", "Order", new { id = model.OrderId }); + } + } } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ManagementPageController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ManagementPageController.cs index c74551e..dbfaf58 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ManagementPageController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ManagementPageController.cs @@ -24,12 +24,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers private readonly IPermissionService _permissionService; protected readonly FruitBankDbContext _dbContext; protected readonly AICalculationService _aiCalculationService; + protected readonly OpenAIApiService _openAIApiService; - public ManagementPageController(IPermissionService permissionService, FruitBankDbContext fruitBankDbContext, AICalculationService aiCalculationService) + public ManagementPageController(IPermissionService permissionService, FruitBankDbContext fruitBankDbContext, AICalculationService aiCalculationService, OpenAIApiService openAIApiService) { _permissionService = permissionService; _dbContext = fruitBankDbContext; _aiCalculationService = aiCalculationService; + _openAIApiService = openAIApiService; } public async Task Test() @@ -190,6 +192,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers [RequestFormLimits(MultipartBodyLengthLimit = 10485760)] public async Task UploadFile(List files, int shippingDocumentId, int? partnerId) { + + var shippingDocument = await _dbContext.ShippingDocuments.GetByIdAsync(shippingDocumentId); //checks // - files exist if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) @@ -209,8 +213,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers } var filesList = new List(); - var shippingDocumentToFileList = new List(); - + //iteratation 1: iterate documents to determine their type by AI @@ -218,16 +221,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers foreach (var file in files) { - var fileName = file.FileName; var fileSize = file.Length; var dbFile = new Files(); string pdfText = ""; - Console.WriteLine($"Received file: {fileName} for Document ID: {shippingDocumentId}"); + Console.WriteLine($"Received file: {fileName} for Document ID: {shippingDocumentId}, content type: {file.ContentType}"); - if (!file.ContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) - return Json(new { success = false, errorMessage = "Only PDF files are allowed" }); + if (!file.ContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) && !file.ContentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)) + return Json(new { success = false, errorMessage = "Only PDF or jpg files are allowed" }); // Validate file size (max 20MB) if (file.Length > 20 * 1024 * 1024) @@ -235,36 +237,89 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers // - get text extracted from pdf // Validate file type (PDF only) - if (file.Length > 0 && file.ContentType == "application/pdf") + //if (file.Length > 0 && file.ContentType == "application/pdf") + if (file.Length > 0) { - - try - { - // Open the PDF from the IFormFile's stream directly in memory - using (var stream = file.OpenReadStream()) - using (var pdf = UglyToad.PdfPig.PdfDocument.Open(stream)) + if (!file.ContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)){ + try { - // Now you can analyze the PDF content - - foreach (var page in pdf.GetPages()) + // Open the PDF from the IFormFile's stream directly in memory + using (var stream = file.OpenReadStream()) + using (var pdf = UglyToad.PdfPig.PdfDocument.Open(stream)) { - // Extract text from each page - pdfText += ContentOrderTextExtractor.GetText(page); + // Now you can analyze the PDF content + + foreach (var page in pdf.GetPages()) + { + // Extract text from each page + pdfText += ContentOrderTextExtractor.GetText(page); + + } + //still nothing? let's send it to AI + if (string.IsNullOrWhiteSpace(pdfText)) + { + try + { + // ✅ Use the service we implemented earlier + pdfText = await _openAIApiService.AnalyzePdfAsync(stream, file.FileName, "Please extract all readable text from this PDF."); + } + catch (Exception aiEx) + { + Console.Error.WriteLine($"OpenAI Assistants API failed: {aiEx.Message}"); + return StatusCode(500, $"Failed to process PDF: {aiEx.Message}"); + } + } + + // For demonstration, let's just log the extracted text + Console.WriteLine($"Extracted text from {file.FileName}: {pdfText}"); + } - - // For demonstration, let's just log the extracted text - Console.WriteLine($"Extracted text from {file.FileName}: {pdfText}"); - + } + catch (Exception ex) + { + // Handle potential exceptions during PDF processing + Console.Error.WriteLine($"Error processing PDF file {file.FileName}: {ex.Message}"); + return StatusCode(500, $"Error processing PDF file: {ex.Message}"); } } - catch (Exception ex) + else { - // Handle potential exceptions during PDF processing - Console.Error.WriteLine($"Error processing PDF file {file.FileName}: {ex.Message}"); - return StatusCode(500, $"Error processing PDF file: {ex.Message}"); + try + { + // Open the PDF from the IFormFile's stream directly in memory + using (var stream = file.OpenReadStream()) + { + + try + { + // ✅ Use the service we implemented earlier + pdfText = await _openAIApiService.AnalyzePdfAsync(stream, file.FileName, "Please extract all readable text from this PDF."); + } + catch (Exception aiEx) + { + Console.Error.WriteLine($"OpenAI Assistants API failed: {aiEx.Message}"); + return StatusCode(500, $"Failed to process PDF: {aiEx.Message}"); + } + + // For demonstration, let's just log the extracted text + Console.WriteLine($"Extracted text from {file.FileName}: {pdfText}"); + + } + } + + catch (Exception ex) + { + // Handle potential exceptions during PDF processing + Console.Error.WriteLine($"Error processing PDF file {file.FileName}: {ex.Message}"); + return StatusCode(500, $"Error processing PDF file: {ex.Message}"); + } } + //we should have some kind of text now + Console.WriteLine(pdfText); } + + string analysisPrompt = "Extract the document identification number from this document, determine the type of the " + "document IN ENGLISH from the available list, and return them as JSON: documentNumber, documentType. " + $"Available filetypes: {nameof(DocumentType.Invoice)}, {nameof(DocumentType.ShippingDocument)} , {nameof(DocumentType.OrderConfirmation)}, {nameof(DocumentType.Unknown)}" + @@ -282,7 +337,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers dbFile.FileName = extractedMetaData.DocumentNumber; } + await _dbContext.Files.InsertAsync(dbFile); + filesList.Add(dbFile); + ShippingDocumentToFiles shippingDocumentToFiles = new ShippingDocumentToFiles + { + ShippingDocumentId = shippingDocumentId, + FilesId = dbFile.Id + }; + + await _dbContext.ShippingDocumentToFiles.InsertAsync(shippingDocumentToFiles); // - IF WE DON'T HAVE PARTNERID ALREADY: read partner information // (check if all 3 refers to the same partner) // save partner information to partners table { Id, Name, TaxId, CertificationNumber, PostalCode, Country, State, County, City, Street } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FileUploadGridComponent.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FileUploadGridComponent.cshtml index 948f6ea..ad331a6 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FileUploadGridComponent.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FileUploadGridComponent.cshtml @@ -1,18 +1,6 @@ @model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.TestGridModel @using DevExtreme.AspNet.Mvc -@{ - - - if (Model.DataContext.ContainsKey("contextId")) - { - - -

@Model.DataContext["contextId"]

-
- } - // var gridId = $"dataGrid_{Guid.NewGuid():N}"; -} @{ var contextId = Model.DataContext["contextId"]; @@ -21,7 +9,7 @@ } -

@Model.DataContext["contextId"], @Model.GridName, @Model.ViewComponentName

+
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 8648ceb..0540362 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/List.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/List.cshtml @@ -1,6 +1,7 @@ @model OrderSearchModel @inject IStoreService storeService +@using FruitBank.Common.Interfaces @using Nop.Plugin.Misc.FruitBankPlugin.Models @using Nop.Services.Stores @using Nop.Web.Areas.Admin.Components @@ -290,9 +291,7 @@
-
-

RTTTTTTTTTT

-
+
@@ -342,11 +341,18 @@ }; gridModel.ColumnCollection.Add(new ColumnProperty(nameof(OrderModelExtended.IsMeasurable)) { - Title = "Needs Measurement", + Title = T("Admin.Orders.Fields.ToBeMeasured").Text, Width = "100", Render = new RenderCustom("renderColumnIsMeasurable"), ClassName = NopColumnClassDefaults.CenterAll }); + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(IOrderDto.DateOfReceipt)) + { + Title = T("Admin.Orders.Fields.PickupDate").Text, + Width = "100", + Render = new RenderCustom("renderColumnPickupDateAndTime"), + ClassName = NopColumnClassDefaults.CenterAll + }); //a vendor does not have access to this functionality if (!Model.IsLoggedInAsVendor) @@ -443,9 +449,14 @@ function renderColumnIsMeasurable(data, type, row, meta) { if(data === true) { - return 'Yes'; + return 'Yes'; } - return 'No'; + return 'No'; + } + + function renderColumnPickupDateAndTime(data, type, row, meta) { + + return `${data}`; } $(function() { diff --git a/Nop.Plugin.Misc.AIPlugin/Components/OrderAttributesViewComponent.cs b/Nop.Plugin.Misc.AIPlugin/Components/OrderAttributesViewComponent.cs new file mode 100644 index 0000000..6cc2ddc --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Components/OrderAttributesViewComponent.cs @@ -0,0 +1,55 @@ +// File: Plugins/YourCompany.ProductAttributes/Components/ProductAttributesViewComponent.cs + +using FruitBank.Common.Interfaces; +using Microsoft.AspNetCore.Mvc; +using Nop.Core; +using Nop.Core.Domain.Catalog; +using Nop.Core.Domain.Orders; +using Nop.Plugin.Misc.FruitBankPlugin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Common; +using Nop.Web.Areas.Admin.Models.Catalog; +using Nop.Web.Areas.Admin.Models.Orders; +using Nop.Web.Framework.Components; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Components +{ + [ViewComponent(Name = "OrderAttributes")] + public class OrderAttributesViewComponent : NopViewComponent + { + private readonly FruitBankAttributeService _fruitBankAttributeService; + private readonly IWorkContext _workContext; + private readonly IStoreContext _storeContext; + + public OrderAttributesViewComponent(FruitBankAttributeService fruitBankAttributeService, IWorkContext workContext, IStoreContext storeContext) + { + _workContext = workContext; + _storeContext = storeContext; + _fruitBankAttributeService = fruitBankAttributeService; + } + + public async Task InvokeAsync(string widgetZone, object additionalData) + { + if (additionalData is not OrderModel orderModel) return Content(""); + + var model = new OrderAttributesModel { OrderId = orderModel.Id }; + + if (model.OrderId > 0) + { + var orderPickupAttributeValue = await _fruitBankAttributeService.GetGenericAttributeValueAsync(model.OrderId, nameof(IOrderDto.DateOfReceipt)); + var orderMeasurableAttributeValue = await _fruitBankAttributeService.GetGenericAttributeValueAsync(model.OrderId, nameof(OrderModelExtended.IsMeasurable)); + + model.IsMeasurable = orderMeasurableAttributeValue; + if(orderPickupAttributeValue.HasValue && orderPickupAttributeValue.Value != DateTime.MinValue) + { + model.DateOfReceipt = orderPickupAttributeValue; + } + else + { + model.DateOfReceipt = null; + } + } + return View("~/Plugins/Misc.FruitBankPlugin/Views/OrderAttributes.cshtml", model); + } + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Factories/CustomOrderModelFactory.cs b/Nop.Plugin.Misc.AIPlugin/Factories/CustomOrderModelFactory.cs index 3783369..f313eb8 100644 --- a/Nop.Plugin.Misc.AIPlugin/Factories/CustomOrderModelFactory.cs +++ b/Nop.Plugin.Misc.AIPlugin/Factories/CustomOrderModelFactory.cs @@ -1,4 +1,5 @@ using AyCode.Core.Extensions; +using FruitBank.Common.Interfaces; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.TagHelpers; @@ -41,6 +42,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Factories public class CustomOrderModelFactory : OrderModelFactory { private readonly IOrderMeasurementService _orderMeasurementService; + private readonly IGenericAttributeService _genericAttributeService; public CustomOrderModelFactory( IOrderMeasurementService orderMeasurementService, @@ -88,36 +90,37 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Factories OrderSettings orderSettings, ShippingSettings shippingSettings, IUrlRecordService urlRecordService, - TaxSettings taxSettings - ) : base(addressSettings, - catalogSettings, - currencySettings, - actionContextAccessor, - addressModelFactory, - addressService, - affiliateService, - baseAdminModelFactory, - countryService, - currencyService, - customerService, - dateTimeHelper, - discountService, - downloadService, - encryptionService, - giftCardService, - localizationService, - measureService, - orderProcessingService, - orderReportService, - orderService, - paymentPluginManager, - paymentService, - pictureService, - priceCalculationService, - priceFormatter, - productAttributeService, - productService, - returnRequestService, + TaxSettings taxSettings, + IGenericAttributeService genericAttributeService + ) : base(addressSettings, + catalogSettings, + currencySettings, + actionContextAccessor, + addressModelFactory, + addressService, + affiliateService, + baseAdminModelFactory, + countryService, + currencyService, + customerService, + dateTimeHelper, + discountService, + downloadService, + encryptionService, + giftCardService, + localizationService, + measureService, + orderProcessingService, + orderReportService, + orderService, + paymentPluginManager, + paymentService, + pictureService, + priceCalculationService, + priceFormatter, + productAttributeService, + productService, + returnRequestService, rewardPointService, settingService, shipmentService, @@ -137,6 +140,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Factories ) { _orderMeasurementService = orderMeasurementService; + _genericAttributeService = genericAttributeService; } public override async Task PrepareOrderSearchModelAsync(OrderSearchModel searchModel) @@ -169,7 +173,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Factories } public override async Task PrepareOrderListModelAsync(OrderSearchModel searchModel) - =>await base.PrepareOrderListModelAsync(searchModel); + => await base.PrepareOrderListModelAsync(searchModel); public async Task PrepareOrderListModelExtendedAsync(OrderSearchModel searchModel) { @@ -182,6 +186,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Factories PropertyHelper.CopyPublicValueTypeProperties(orderModel, orderModelExtended); orderModelExtended.IsMeasurable = await ShouldMarkAsNeedsMeasurementAsync(orderModel); + orderModelExtended.DateOfReceipt = await GetPickupDateTimeAsync(orderModel); Console.WriteLine(orderModelExtended.Id); extendedRows.Add(orderModelExtended); @@ -210,7 +215,20 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Factories return await Task.FromResult(false); } - + private async Task GetPickupDateTimeAsync(OrderModel order) + { + DateTime? dateTime = DateTime.MinValue; + var fullOrder = await _orderService.GetOrderByIdAsync(order.Id); + if (fullOrder != null) + { + dateTime = await _genericAttributeService.GetAttributeAsync(fullOrder, nameof(IOrderDto.DateOfReceipt)); + if(dateTime == DateTime.MinValue || !dateTime.HasValue) + { + dateTime = null; + } + } + return dateTime; + } } } diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs index afaeca5..4d5f0dd 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs @@ -84,7 +84,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin public Task> GetWidgetZonesAsync() { - return Task.FromResult>(new List { PublicWidgetZones.ProductBoxAddinfoBefore, PublicWidgetZones.ProductDetailsBottom, AdminWidgetZones.ProductDetailsBlock }); + return Task.FromResult>(new List { PublicWidgetZones.ProductBoxAddinfoBefore, PublicWidgetZones.ProductDetailsBottom, AdminWidgetZones.ProductDetailsBlock, AdminWidgetZones.OrderDetailsBlock }); } //public string GetWidgetViewComponentName(string widgetZone) @@ -138,6 +138,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin { return zones.Any(widgetZone.Equals) ? typeof(ProductAttributesViewComponent) : null; } + + else if (widgetZone == AdminWidgetZones.OrderDetailsBlock) + { + return zones.Any(widgetZone.Equals) ? typeof(OrderAttributesViewComponent) : null; + } } return null; diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs index ae953a6..c37686b 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs @@ -126,6 +126,11 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.Admin.Products.ProductList", pattern: "Admin/Product/ProductList", defaults: new { controller = "CustomProduct", action = "ProductList", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.Orders.SaveOrderAttributes", + pattern: "Admin/CustomOrder/SaveOrderAttributes", + defaults: new { controller = "CustomOrder", action = "SaveOrderAttributes", area = AreaNames.ADMIN }); } /// diff --git a/Nop.Plugin.Misc.AIPlugin/Models/OrderAttributesModel.cs b/Nop.Plugin.Misc.AIPlugin/Models/OrderAttributesModel.cs new file mode 100644 index 0000000..080e9f6 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Models/OrderAttributesModel.cs @@ -0,0 +1,18 @@ +using FruitBank.Common.Interfaces; +using Nop.Web.Framework.Models; +using Nop.Web.Framework.Mvc.ModelBinding; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Models +{ + public record OrderAttributesModel : BaseNopModel, IMeasurable + { + public int OrderId { get; set; } + + [NopResourceDisplayName("Plugins.YourCompany.ProductAttributes.Fields.IsMeasurable")] + public bool IsMeasurable { get; set; } + + [NopResourceDisplayName("Plugins.YourCompany.ProductAttributes.Fields.DateOfReceipt")] + public DateTime? DateOfReceipt { get; set; } + + } +} \ No newline at end of file diff --git a/Nop.Plugin.Misc.AIPlugin/Models/OrderModelExtended.cs b/Nop.Plugin.Misc.AIPlugin/Models/OrderModelExtended.cs index 5d6c936..8687e20 100644 --- a/Nop.Plugin.Misc.AIPlugin/Models/OrderModelExtended.cs +++ b/Nop.Plugin.Misc.AIPlugin/Models/OrderModelExtended.cs @@ -5,6 +5,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models public partial record OrderModelExtended : OrderModel { public bool IsMeasurable { get; set; } + public DateTime? DateOfReceipt { 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 c76f8b8..ac55a51 100644 --- a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj +++ b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj @@ -7,6 +7,9 @@ true true + + + @@ -28,7 +31,10 @@ + + + @@ -151,9 +157,6 @@ - - Always - Always @@ -616,6 +619,9 @@ Always + + Always + Always @@ -631,6 +637,10 @@ + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Services/AICalculationService.cs b/Nop.Plugin.Misc.AIPlugin/Services/AICalculationService.cs index e90d7c1..f5e65f8 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/AICalculationService.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/AICalculationService.cs @@ -1,14 +1,6 @@ -using Azure; -using Nop.Core; +using Nop.Core; using Nop.Core.Domain.Customers; -using Nop.Core.Domain.Stores; using Nop.Plugin.Misc.FruitBankPlugin.Helpers; -using StackExchange.Redis; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Nop.Plugin.Misc.FruitBankPlugin.Services { @@ -37,16 +29,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services return response; } - //public async Task GetOpenAIPDFAnalyzisFromText(string pdfText, string userQuestion) - //{ - // string systemMessage = $"You are a helpful assistant of a webshop, you work in the administration area, with the ADMIN user. The ADMIN user is asking you questions about a PDF document, that you have access to. The content of the PDF document is the following: {pdfText}"; - // var response = await _openAIApiService.GetSimpleResponseAsync(systemMessage, userQuestion); - - // var fixedResponse = TextHelper.FixJsonWithoutAI(response); - - // return fixedResponse; - //} - public async Task GetOpenAIPDFAnalysisFromText(string pdfText, string userQuestion) { string systemMessage = $"You are a pdf analyzis assistant, the user is asking you questions about a PDF document, that you have access to. The content of the PDF document is the following: {pdfText}"; @@ -57,15 +39,5 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services return fixedResponse; } - //public async Task GetOpenAIPartnerInfoFromText(string pdfText, string userQuestion) - //{ - // string systemMessage = $"You are a pdf analyzis assistant, the user is asking you questions about a PDF document, that you have access to. The content of the PDF document is the following: {pdfText}"; - // var response = await _openAIApiService.GetSimpleResponseAsync(systemMessage, userQuestion); - - // var fixedResponse = TextHelper.FixJsonWithoutAI(response); - - // return fixedResponse; - //} - } } diff --git a/Nop.Plugin.Misc.AIPlugin/Services/InnvoiceApiService.cs b/Nop.Plugin.Misc.AIPlugin/Services/InnvoiceApiService.cs new file mode 100644 index 0000000..ad3a08b --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/InnvoiceApiService.cs @@ -0,0 +1,496 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Xml.Linq; +using System.Linq; +using System.Text; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services +{ + /// + /// Service for interacting with InnVoice Invoice API + /// API Documentation: https://help.innvoice.hu/hc/hu/articles/360003142839 + /// + public class InnVoiceApiService + { + private readonly HttpClient _httpClient; + private readonly string _companyName; + private readonly string _username; + private readonly string _password; + private readonly string _baseUrl; + + public InnVoiceApiService(string companyName, string username, string password, string baseUrl = "https://api.innvoice.hu") + { + _companyName = companyName ?? throw new ArgumentNullException(nameof(companyName)); + _username = username ?? throw new ArgumentNullException(nameof(username)); + _password = password ?? throw new ArgumentNullException(nameof(password)); + _baseUrl = baseUrl.TrimEnd('/'); + + _httpClient = new HttpClient(); + SetupAuthentication(); + } + + private void SetupAuthentication() + { + var authToken = Convert.ToBase64String( + System.Text.Encoding.ASCII.GetBytes($"{_username}:{_password}") + ); + _httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authToken); + } + + /// + /// Get all invoices + /// Rate limit: 20 times per hour without ID parameter + /// + public async Task> GetAllInvoicesAsync() + { + var url = $"{_baseUrl}/{_companyName}/invoice"; + return await GetInvoicesFromUrlAsync(url); + } + + /// + /// Get invoice by internal table ID + /// + public async Task GetInvoiceByIdAsync(int tableId) + { + var url = $"{_baseUrl}/{_companyName}/invoice/id/{tableId}"; + var invoices = await GetInvoicesFromUrlAsync(url); + return invoices.Count > 0 ? invoices[0] : null; + } + + /// + /// Get invoice by invoice number + /// + public async Task> GetInvoiceByNumberAsync(string invoiceNumber) + { + var url = $"{_baseUrl}/{_companyName}/invoice/invoicenumber/{Uri.EscapeDataString(invoiceNumber)}"; + return await GetInvoicesFromUrlAsync(url); + } + + /// + /// Get invoices by creation date + /// Format: YYYYMMDD + /// + public async Task> GetInvoicesByCreationDateAsync(DateTime date) + { + var dateStr = date.ToString("yyyyMMdd"); + var url = $"{_baseUrl}/{_companyName}/invoice/created/{dateStr}"; + return await GetInvoicesFromUrlAsync(url); + } + + /// + /// Get invoices by fulfillment date + /// Format: YYYYMMDD + /// + public async Task> GetInvoicesByFulfillmentDateAsync(DateTime date) + { + var dateStr = date.ToString("yyyyMMdd"); + var url = $"{_baseUrl}/{_companyName}/invoice/fulfillment/{dateStr}"; + return await GetInvoicesFromUrlAsync(url); + } + + /// + /// Get invoices by due date + /// Format: YYYYMMDD + /// + public async Task> GetInvoicesByDueDateAsync(DateTime date) + { + var dateStr = date.ToString("yyyyMMdd"); + var url = $"{_baseUrl}/{_companyName}/invoice/duedate/{dateStr}"; + return await GetInvoicesFromUrlAsync(url); + } + + /// + /// Get invoices by payment date + /// Format: YYYYMMDD + /// + public async Task> GetInvoicesByPaymentDateAsync(DateTime date) + { + var dateStr = date.ToString("yyyyMMdd"); + var url = $"{_baseUrl}/{_companyName}/invoice/paymentdate/{dateStr}"; + return await GetInvoicesFromUrlAsync(url); + } + + /// + /// Get invoices by customer tax number + /// + public async Task> GetInvoicesByCustomerTaxNumberAsync(string taxNumber) + { + var url = $"{_baseUrl}/{_companyName}/invoice/taxnumber/{Uri.EscapeDataString(taxNumber)}"; + return await GetInvoicesFromUrlAsync(url); + } + + /// + /// Get invoices modified since a specific timestamp + /// Format: YYYYMMDDHHmmss (year, month, day, hour, minute, second) + /// Recommended for tracking changes every 10 minutes + /// Rate limit: Full queries or queries older than current month limited to 10 times per 30 days + /// Recommended: Only use current month dates + /// + public async Task> GetInvoicesByUpdateTimeAsync(DateTime updateTime) + { + var timeStr = updateTime.ToString("yyyyMMddHHmmss"); + var url = $"{_baseUrl}/{_companyName}/invoice/updatedtime/{timeStr}"; + return await GetInvoicesFromUrlAsync(url); + } + + private async Task> GetInvoicesFromUrlAsync(string url) + { + try + { + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var invoices = JsonSerializer.Deserialize>(content, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return invoices ?? new List(); + } + catch (HttpRequestException ex) + { + throw new InnVoiceApiException($"Error calling InnVoice API: {ex.Message}", ex); + } + catch (JsonException ex) + { + throw new InnVoiceApiException($"Error parsing API response: {ex.Message}", ex); + } + } + } + + // Models + public class Invoice + { + [JsonPropertyName("TABLE_ID")] + public int TableId { get; set; } + + [JsonPropertyName("InvoiceNumber")] + public string InvoiceNumber { get; set; } + + [JsonPropertyName("Created")] + public string Created { get; set; } + + [JsonPropertyName("Fulfillment")] + public string Fulfillment { get; set; } + + [JsonPropertyName("DueDate")] + public string DueDate { get; set; } + + [JsonPropertyName("PaymentDate")] + public string PaymentDate { get; set; } + + [JsonPropertyName("CustomerName")] + public string CustomerName { get; set; } + + [JsonPropertyName("CustomerTaxNumber")] + public string CustomerTaxNumber { get; set; } + + [JsonPropertyName("CustomerAddress")] + public string CustomerAddress { get; set; } + + [JsonPropertyName("TotalNet")] + public decimal TotalNet { get; set; } + + [JsonPropertyName("TotalGross")] + public decimal TotalGross { get; set; } + + [JsonPropertyName("Currency")] + public string Currency { get; set; } + + [JsonPropertyName("Status")] + public string Status { get; set; } + + [JsonPropertyName("InvoiceType")] + public string InvoiceType { get; set; } + + [JsonPropertyName("PaymentMethod")] + public string PaymentMethod { get; set; } + + // Add more properties as needed based on actual API response + } + + public class InnVoiceApiException : Exception + { + public InnVoiceApiException(string message) : base(message) { } + public InnVoiceApiException(string message, Exception innerException) : base(message, innerException) { } + } + + // Invoice Creation Models + public class InvoiceCreateRequest + { + public int VevoID { get; set; } = 0; + public string VevoNev { get; set; } + public string VevoIrsz { get; set; } + public string VevoOrszag { get; set; } + public string VevoTelep { get; set; } + public string VevoUtcaHsz { get; set; } + public string VevoEPNev { get; set; } + public string VevoEPKod { get; set; } + public string SzallNev { get; set; } + public string SzallIrsz { get; set; } + public string SzallTelep { get; set; } + public string SzallUtcaHsz { get; set; } + public string SzallOrszag { get; set; } + public int SzamlatombID { get; set; } + public DateTime SzamlaKelte { get; set; } + public DateTime TeljesitesKelte { get; set; } + public DateTime Hatarido { get; set; } + public string Devizanem { get; set; } + public string FizetesiMod { get; set; } + public string Megjegyzes { get; set; } + public string Nyelv1 { get; set; } + public string Nyelv2 { get; set; } + public decimal? Arfolyam { get; set; } + public string ArfolyamDeviza { get; set; } + public bool Fizetve { get; set; } + public bool Eszamla { get; set; } + public string VevoAdoszam { get; set; } + public string VevoCsAdoszam { get; set; } + public string Telefon { get; set; } + public string Email { get; set; } + public string MegrendelesSzamStr { get; set; } + public string MegrendelesIdopontStr { get; set; } + public bool Felretett { get; set; } + public bool Proforma { get; set; } + public bool AutomatikusAr { get; set; } + public bool Eloleg { get; set; } + public bool Sendmail { get; set; } + public string MailSubject { get; set; } + public string MailBody { get; set; } + public string Eredetiszamla { get; set; } + + public List Items { get; set; } = new List(); + + public void AddItem(InvoiceItem item) + { + Items.Add(item); + } + + public string ToXml() + { + var invoices = new XElement("invoices"); + var invoice = new XElement("invoice"); + + if (VevoID > 0) + invoice.Add(new XElement("VevoID", new XCData(VevoID.ToString()))); + + invoice.Add(new XElement("VevoNev", new XCData(VevoNev ?? ""))); + invoice.Add(new XElement("VevoIrsz", new XCData(VevoIrsz ?? ""))); + invoice.Add(new XElement("VevoTelep", new XCData(VevoTelep ?? ""))); + invoice.Add(new XElement("VevoOrszag", new XCData(VevoOrszag ?? ""))); + invoice.Add(new XElement("VevoUtcaHsz", new XCData(VevoUtcaHsz ?? ""))); + + if (!string.IsNullOrEmpty(VevoEPNev)) + invoice.Add(new XElement("VevoEPNev", new XCData(VevoEPNev))); + if (!string.IsNullOrEmpty(VevoEPKod)) + invoice.Add(new XElement("VevoEPKod", new XCData(VevoEPKod))); + if (!string.IsNullOrEmpty(SzallNev)) + invoice.Add(new XElement("SzallNev", new XCData(SzallNev))); + if (!string.IsNullOrEmpty(SzallIrsz)) + invoice.Add(new XElement("SzallIrsz", new XCData(SzallIrsz))); + if (!string.IsNullOrEmpty(SzallTelep)) + invoice.Add(new XElement("SzallTelep", new XCData(SzallTelep))); + if (!string.IsNullOrEmpty(SzallUtcaHsz)) + invoice.Add(new XElement("SzallUtcaHsz", new XCData(SzallUtcaHsz))); + if (!string.IsNullOrEmpty(SzallOrszag)) + invoice.Add(new XElement("SzallOrszag", new XCData(SzallOrszag))); + + invoice.Add(new XElement("SzamlatombID", new XCData(SzamlatombID.ToString()))); + invoice.Add(new XElement("SzamlaKelte", new XCData(SzamlaKelte.ToString("yyyy.MM.dd.")))); + invoice.Add(new XElement("TeljesitesKelte", new XCData(TeljesitesKelte.ToString("yyyy.MM.dd.")))); + invoice.Add(new XElement("Hatarido", new XCData(Hatarido.ToString("yyyy.MM.dd.")))); + invoice.Add(new XElement("Devizanem", new XCData(Devizanem ?? ""))); + invoice.Add(new XElement("FizetesiMod", new XCData(FizetesiMod ?? ""))); + + if (!string.IsNullOrEmpty(Megjegyzes)) + invoice.Add(new XElement("Megjegyzes", new XCData(Megjegyzes))); + if (!string.IsNullOrEmpty(Nyelv1)) + invoice.Add(new XElement("Nyelv1", new XCData(Nyelv1))); + if (!string.IsNullOrEmpty(Nyelv2)) + invoice.Add(new XElement("Nyelv2", new XCData(Nyelv2))); + if (Arfolyam.HasValue) + invoice.Add(new XElement("Arfolyam", new XCData(Arfolyam.Value.ToString()))); + if (!string.IsNullOrEmpty(ArfolyamDeviza)) + invoice.Add(new XElement("ArfolyamDeviza", new XCData(ArfolyamDeviza))); + + invoice.Add(new XElement("Fizetve", Fizetve ? "1" : "0")); + invoice.Add(new XElement("Eszamla", Eszamla ? "1" : "0")); + + if (!string.IsNullOrEmpty(VevoAdoszam)) + invoice.Add(new XElement("VevoAdoszam", new XCData(VevoAdoszam))); + if (!string.IsNullOrEmpty(VevoCsAdoszam)) + invoice.Add(new XElement("VevoCsAdoszam", new XCData(VevoCsAdoszam))); + if (!string.IsNullOrEmpty(Telefon)) + invoice.Add(new XElement("Telefon", new XCData(Telefon))); + if (!string.IsNullOrEmpty(Email)) + invoice.Add(new XElement("Email", new XCData(Email))); + if (!string.IsNullOrEmpty(MegrendelesSzamStr)) + invoice.Add(new XElement("MegrendelesSzamStr", new XCData(MegrendelesSzamStr))); + if (!string.IsNullOrEmpty(MegrendelesIdopontStr)) + invoice.Add(new XElement("MegrendelesIdopontStr", new XCData(MegrendelesIdopontStr))); + + invoice.Add(new XElement("Felretett", Felretett ? "1" : "0")); + invoice.Add(new XElement("Proforma", Proforma ? "1" : "0")); + + if (AutomatikusAr) + invoice.Add(new XElement("AutomatikusAr", "1")); + if (Eloleg) + invoice.Add(new XElement("Eloleg", "1")); + if (Sendmail) + { + invoice.Add(new XElement("Sendmail", "1")); + if (!string.IsNullOrEmpty(MailSubject)) + invoice.Add(new XElement("MailSubject", new XCData(MailSubject))); + if (!string.IsNullOrEmpty(MailBody)) + invoice.Add(new XElement("MailBody", new XCData(MailBody))); + } + if (!string.IsNullOrEmpty(Eredetiszamla)) + invoice.Add(new XElement("Eredetiszamla", new XCData(Eredetiszamla))); + + // Add items + foreach (var item in Items) + { + var tetel = new XElement("tetel"); + tetel.Add(new XElement("TetelNev", new XCData(item.TetelNev ?? ""))); + tetel.Add(new XElement("AfaSzoveg", item.AfaSzoveg ?? "")); + tetel.Add(new XElement("Brutto", item.Brutto ? "1" : "0")); + tetel.Add(new XElement("EgysegAr", item.EgysegAr.ToString())); + tetel.Add(new XElement("Mennyiseg", item.Mennyiseg.ToString())); + tetel.Add(new XElement("MennyisegEgyseg", new XCData(item.MennyisegEgyseg ?? ""))); + + if (item.KedvezmenyOsszeg.HasValue) + tetel.Add(new XElement("KedvezmenyOsszeg", item.KedvezmenyOsszeg.Value.ToString())); + if (item.TermekID.HasValue) + tetel.Add(new XElement("TermekID", item.TermekID.Value.ToString())); + if (!string.IsNullOrEmpty(item.Megjegyzes)) + tetel.Add(new XElement("Megjegyzes", new XCData(item.Megjegyzes))); + if (!string.IsNullOrEmpty(item.CikkSzam)) + tetel.Add(new XElement("CikkSzam", new XCData(item.CikkSzam))); + if (!string.IsNullOrEmpty(item.VTSZSZJ)) + tetel.Add(new XElement("VTSZSZJ", new XCData(item.VTSZSZJ))); + if (item.ElolegSzamlaTABLE_ID.HasValue) + tetel.Add(new XElement("ElolegSzamlaTABLE_ID", item.ElolegSzamlaTABLE_ID.Value.ToString())); + if (!string.IsNullOrEmpty(item.ElolegSzamlaSorszam)) + tetel.Add(new XElement("ElolegSzamlaSorszam", new XCData(item.ElolegSzamlaSorszam))); + + invoice.Add(tetel); + } + + invoices.Add(invoice); + return new XDeclaration("1.0", "UTF-8", null).ToString() + "\n" + invoices.ToString(); + } + } + + public class InvoiceItem + { + public string TetelNev { get; set; } + public string AfaSzoveg { get; set; } + public bool Brutto { get; set; } + public decimal EgysegAr { get; set; } + public decimal Mennyiseg { get; set; } + public string MennyisegEgyseg { get; set; } + public decimal? KedvezmenyOsszeg { get; set; } + public int? TermekID { get; set; } + public string Megjegyzes { get; set; } + public string CikkSzam { get; set; } + public string VTSZSZJ { get; set; } + public int? ElolegSzamlaTABLE_ID { get; set; } + public string ElolegSzamlaSorszam { get; set; } + } + + public class InvoiceCreateResponse + { + public string ErrorCode { get; set; } + public string Message { get; set; } + public int? TableId { get; set; } + public int? VevoID { get; set; } + public string TechId { get; set; } + public string Sorszam { get; set; } + public string PrintUrl { get; set; } + + public bool IsSuccess => ErrorCode == "200"; + + public static InvoiceCreateResponse FromXml(string xml) + { + var doc = XDocument.Parse(xml); + var invoice = doc.Descendants("invoice").FirstOrDefault(); + + if (invoice == null) + { + throw new InnVoiceApiException("Invalid XML response format"); + } + + return new InvoiceCreateResponse + { + ErrorCode = invoice.Element("error")?.Value, + Message = invoice.Element("message")?.Value?.Trim(), + TableId = int.TryParse(invoice.Element("TABLE_ID")?.Value?.Trim(), out var tid) ? tid : (int?)null, + VevoID = int.TryParse(invoice.Element("VevoID")?.Value?.Trim(), out var vid) ? vid : (int?)null, + TechId = invoice.Element("techid")?.Value?.Trim(), + Sorszam = invoice.Element("Sorszam")?.Value?.Trim(), + PrintUrl = invoice.Element("PrintUrl")?.Value?.Trim() + }; + } + } + + /// + /// Create a new invoice + /// + public async Task CreateInvoiceAsync(InvoiceCreateRequest request) + { + var url = $"{_baseUrl}/{_companyName}/invoice"; + + var xml = request.ToXml(); + var content = new FormUrlEncodedContent(new[] + { + new KeyValuePair("data", xml) + }); + + try + { + var response = await _httpClient.PostAsync(url, content); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(); + return InvoiceCreateResponse.FromXml(responseContent); + } + catch (HttpRequestException ex) + { + throw new InnVoiceApiException($"Error creating invoice: {ex.Message}", ex); + } + } + + /// + /// Update an existing invoice + /// + public async Task UpdateInvoiceAsync(int tableId, InvoiceCreateRequest request) + { + // Set the VevoID if updating customer information + var url = $"{_baseUrl}/{_companyName}/invoice"; + + var xml = request.ToXml(); + var content = new FormUrlEncodedContent(new[] + { + new KeyValuePair("data", xml), + new KeyValuePair("id", tableId.ToString()) + }); + + try + { + var response = await _httpClient.PostAsync(url, content); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(); + return InvoiceCreateResponse.FromXml(responseContent); + } + catch (HttpRequestException ex) + { + throw new InnVoiceApiException($"Error updating invoice: {ex.Message}", ex); + } + } \ 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 bc12f6b..7e01266 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/OpenAIApiService.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/OpenAIApiService.cs @@ -21,6 +21,10 @@ 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 BaseUrl = "https://api.openai.com/v1"; + + private string? _assistantId; + private string? _vectorStoreId; public OpenAIApiService(ISettingService settingService, HttpClient httpClient) { @@ -286,66 +290,371 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services #endregion #region === PDF ANALYSIS (NEW) === - public async Task AnalyzePdfAsync(string filePath, string userPrompt) - { - // Step 1: Upload PDF - using var form = new MultipartFormDataContent(); - using var fileStream = File.OpenRead(filePath); - var fileContent = new StreamContent(fileStream); - fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); - form.Add(fileContent, "file", Path.GetFileName(filePath)); - form.Add(new StringContent("assistants"), "purpose"); - var uploadResponse = await _httpClient.PostAsync(OpenAiFileEndpoint, form); - if (!uploadResponse.IsSuccessStatusCode) + private async Task EnsureAssistantAndVectorStoreAsync() + { + // Find or create vector store + if (_vectorStoreId == null) { - var error = await uploadResponse.Content.ReadAsStringAsync(); - throw new Exception($"File upload failed: {error}"); + _vectorStoreId = await FindOrCreateVectorStoreAsync("pdf-analysis-store"); } - using var uploadJson = await JsonDocument.ParseAsync(await uploadResponse.Content.ReadAsStreamAsync()); - var fileId = uploadJson.RootElement.GetProperty("id").GetString(); - - // Step 2: Ask model with file reference - var requestBody = new + // Find or create assistant + if (_assistantId == null) { - model = "gpt-4.1", // must support file_search - messages = new[] + _assistantId = await FindOrCreateAssistantAsync("PDF and Image Analyzer Assistant"); + } + } + + //TEMPORARY: Cleanup all assistants (for testing purposes) - A. + public async Task CleanupAllAssistantsAsync() + { + Console.WriteLine("Cleaning up all existing assistants..."); + + var listRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/assistants"); + 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 assistants = json.RootElement.GetProperty("data"); + + foreach (var assistant in assistants.EnumerateArray()) { - new { role = "system", content = "You are an assistant that analyzes uploaded PDF files." }, - new { role = "user", content = userPrompt } - }, - tools = new[] + var id = assistant.GetProperty("id").GetString(); + var name = assistant.GetProperty("name").GetString(); + + var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, $"{BaseUrl}/assistants/{id}"); + deleteRequest.Headers.Add("OpenAI-Beta", "assistants=v2"); + await _httpClient.SendAsync(deleteRequest); + + Console.WriteLine($"Deleted assistant: {name} ({id})"); + } + + Console.WriteLine("Cleanup complete!"); + } + + // Reset local cache + _assistantId = null; + } + + public async Task AnalyzePdfAsync(Stream file, string fileName, string userPrompt) + { + + await EnsureAssistantAndVectorStoreAsync(); + var fileId = await UploadFileAsync(file, fileName); + var isImage = IsImageFile(fileName); + + if (!isImage) + { + await AttachFileToVectorStoreAsync(fileId); + } + + var threadId = await CreateThreadAsync(); + + if (isImage) + { + await AddUserMessageWithImageAsync(threadId, userPrompt, fileId); + } + else + { + await AddUserMessageAsync(threadId, userPrompt); + } + + var runId = await CreateRunAsync(threadId); + await WaitForRunCompletionAsync(threadId, runId); + + return await GetAssistantResponseAsync(threadId); + } + + private bool IsImageFile(string fileName) + { + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + return extension == ".jpg" || extension == ".jpeg" || extension == ".png" || extension == ".gif" || extension == ".webp"; + } + + private async Task UploadFileAsync(Stream file, string fileName) + { + using var form = new MultipartFormDataContent(); + var fileContent = new StreamContent(file); + + // Determine MIME type based on file extension + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + fileContent.Headers.ContentType = extension switch + { + ".pdf" => new MediaTypeHeaderValue("application/pdf"), + ".jpg" or ".jpeg" => new MediaTypeHeaderValue("image/jpeg"), + ".png" => new MediaTypeHeaderValue("image/png"), + ".gif" => new MediaTypeHeaderValue("image/gif"), + ".webp" => new MediaTypeHeaderValue("image/webp"), + _ => new MediaTypeHeaderValue("application/octet-stream") + }; + + form.Add(fileContent, "file", fileName); + form.Add(new StringContent("assistants"), "purpose"); + + var response = await _httpClient.PostAsync($"{BaseUrl}/files", form); + await EnsureSuccessAsync(response, "upload file"); + + using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return json.RootElement.GetProperty("id").GetString()!; + } + + private async Task AttachFileToVectorStoreAsync(string fileId) + { + var body = new { file_id = fileId }; + var request = CreateAssistantRequest( + HttpMethod.Post, + $"{BaseUrl}/vector_stores/{_vectorStoreId}/files", + body + ); + + var response = await _httpClient.SendAsync(request); + await EnsureSuccessAsync(response, "attach file to vector store"); + } + + private async Task CreateThreadAsync() + { + var request = CreateAssistantRequest( + HttpMethod.Post, + $"{BaseUrl}/threads", + new { } + ); + + var response = await _httpClient.SendAsync(request); + await EnsureSuccessAsync(response, "create thread"); + + using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return json.RootElement.GetProperty("id").GetString()!; + } + + private async Task AddUserMessageAsync(string threadId, string userPrompt) + { + var body = new + { + role = "user", + content = userPrompt + }; + + var request = CreateAssistantRequest( + HttpMethod.Post, + $"{BaseUrl}/threads/{threadId}/messages", + body + ); + + var response = await _httpClient.SendAsync(request); + await EnsureSuccessAsync(response, "add user message"); + } + + private async Task AddUserMessageWithImageAsync(string threadId, string userPrompt, string fileId) + { + var body = new + { + role = "user", + content = new object[] { - new { type = "file_search" } - }, + new { type = "text", text = userPrompt }, + new { type = "image_file", image_file = new { file_id = fileId } } + } + }; + + var request = CreateAssistantRequest( + HttpMethod.Post, + $"{BaseUrl}/threads/{threadId}/messages", + body + ); + + var response = await _httpClient.SendAsync(request); + await EnsureSuccessAsync(response, "add user message with image"); + } + + private async Task CreateRunAsync(string threadId) + { + var body = new + { + assistant_id = _assistantId, tool_resources = new { file_search = new { - vector_store_ids = new string[] { fileId! } + vector_store_ids = new[] { _vectorStoreId } } } }; - var requestJson = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); + var request = CreateAssistantRequest( + HttpMethod.Post, + $"{BaseUrl}/threads/{threadId}/runs", + body + ); - var requestContent = new StringContent(requestJson, Encoding.UTF8, "application/json"); - var chatResponse = await _httpClient.PostAsync(OpenAiEndpoint, requestContent); - chatResponse.EnsureSuccessStatusCode(); + var response = await _httpClient.SendAsync(request); + await EnsureSuccessAsync(response, "create run"); - using var responseJson = await JsonDocument.ParseAsync(await chatResponse.Content.ReadAsStreamAsync()); - var result = responseJson.RootElement - .GetProperty("choices")[0] - .GetProperty("message") - .GetProperty("content") - .GetString(); - - return result ?? "No response from model"; + using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return json.RootElement.GetProperty("id").GetString()!; } - #endregion + + private async Task WaitForRunCompletionAsync(string threadId, string runId) + { + const int pollIntervalMs = 1000; + const int maxAttempts = 60; // 1 minute timeout + int attempts = 0; + + while (attempts < maxAttempts) + { + var request = CreateAssistantRequest( + HttpMethod.Get, + $"{BaseUrl}/threads/{threadId}/runs/{runId}" + ); + + var response = await _httpClient.SendAsync(request); + await EnsureSuccessAsync(response, "check run status"); + + using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var status = json.RootElement.GetProperty("status").GetString()!; + + if (status == "completed") + return; + + if (status != "in_progress" && status != "queued") + throw new Exception($"Run failed with status: {status}"); + + await Task.Delay(pollIntervalMs); + attempts++; + } + + throw new TimeoutException("Run did not complete within the expected time"); + } + + private async Task GetAssistantResponseAsync(string threadId) + { + var request = CreateAssistantRequest( + HttpMethod.Get, + $"{BaseUrl}/threads/{threadId}/messages" + ); + + var response = await _httpClient.SendAsync(request); + await EnsureSuccessAsync(response, "retrieve messages"); + + using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var messages = json.RootElement.GetProperty("data"); + + if (messages.GetArrayLength() == 0) + return "No response"; + + var firstMessage = messages[0] + .GetProperty("content")[0] + .GetProperty("text") + .GetProperty("value") + .GetString(); + + return firstMessage ?? "No response"; + } + + private HttpRequestMessage CreateAssistantRequest(HttpMethod method, string url, object? body = null) + { + var request = new HttpRequestMessage(method, url); + request.Headers.Add("OpenAI-Beta", "assistants=v2"); + + if (body != null) + { + var json = JsonSerializer.Serialize(body, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + + return request; + } + + private async Task EnsureSuccessAsync(HttpResponseMessage response, string operation) + { + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error Status: {response.StatusCode}"); + Console.WriteLine($"Error Body: {errorBody}"); + throw new Exception($"Failed to {operation}: {errorBody}"); + } + } + + + + private async Task FindOrCreateVectorStoreAsync(string name) + { + // List 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 stores = json.RootElement.GetProperty("data"); + + foreach (var store in stores.EnumerateArray()) + { + if (store.GetProperty("name").GetString() == name) + { + return store.GetProperty("id").GetString()!; + } + } + } + + // Create new if not found + var createBody = new { name = name }; + var createRequest = CreateAssistantRequest(HttpMethod.Post, $"{BaseUrl}/vector_stores", createBody); + var createResponse = await _httpClient.SendAsync(createRequest); + await EnsureSuccessAsync(createResponse, "create vector store"); + + using var createJson = await JsonDocument.ParseAsync(await createResponse.Content.ReadAsStreamAsync()); + return createJson.RootElement.GetProperty("id").GetString()!; + } + + private async Task FindOrCreateAssistantAsync(string name) + { + // List existing assistants + var listRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/assistants"); + 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 assistants = json.RootElement.GetProperty("data"); + + foreach (var assistant in assistants.EnumerateArray()) + { + if (assistant.GetProperty("name").GetString() == name) + { + return assistant.GetProperty("id").GetString()!; + } + } + } + + // Create new if not found + var assistantBody = new + { + name = name, + instructions = "You are an assistant that analyzes uploaded files. When you receive an image, analyze and describe what you see in the image in detail. When you receive a PDF or text document, use the file_search tool to find and analyze relevant information. Always respond directly to the user's question about the file they uploaded.", + model = "gpt-4o", + tools = new[] { new { type = "file_search" } } + }; + + var request = CreateAssistantRequest(HttpMethod.Post, $"{BaseUrl}/assistants", assistantBody); + var createResponse = await _httpClient.SendAsync(request); + await EnsureSuccessAsync(createResponse, "create assistant"); + + using var createJson = await JsonDocument.ParseAsync(await createResponse.Content.ReadAsStreamAsync()); + return createJson.RootElement.GetProperty("id").GetString()!; + } + + #endregion + } + } + diff --git a/Nop.Plugin.Misc.AIPlugin/Views/OrderAttributes.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/OrderAttributes.cshtml new file mode 100644 index 0000000..96e8e4e --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Views/OrderAttributes.cshtml @@ -0,0 +1,61 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Models.OrderAttributesModel + +
+
+ + Custom Order Attributes +
+
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+
+
+ + + diff --git a/Nop.Plugin.Misc.AIPlugin/Views/ProductAttributes.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/ProductAttributes.cshtml index 221ea78..b144f6b 100644 --- a/Nop.Plugin.Misc.AIPlugin/Views/ProductAttributes.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Views/ProductAttributes.cshtml @@ -1,4 +1,4 @@ -@* File: Plugins/Nop.Plugin.YourCompany.ProductAttributes/Views/ProductCustomAttributes.cshtml *@ + @model Nop.Plugin.Misc.FruitBankPlugin.Models.ProductAttributesModel