From a0585576203402043416476ad9f30a2dcbafb609 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 14 Dec 2025 13:37:25 +0100 Subject: [PATCH 1/3] AI progress --- .../Controllers/FileManagerController.cs | 200 +++++++++++++++++- .../Services/OpenAIApiService.cs | 2 +- 2 files changed, 199 insertions(+), 3 deletions(-) diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs index f421c37..9207ad2 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Http; +using FruitBank.Common.Dtos; +using FruitBank.Common.Entities; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Nop.Core.Domain.Catalog; using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; @@ -150,13 +152,83 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers }); } + string analysisResult = await ExtractDocumentId(extractedText); + + Console.WriteLine($"Document number analysis Result: {analysisResult}"); + string partnerAnalysis = await ExtractPartnerName(extractedText); + await DeterminePartner(partnerAnalysis); + string productAnalysis = await ExtractProducts(extractedText); + Console.WriteLine($"Product analysis Result: {productAnalysis}"); + + //identify products from database + var allProducts = await _dbContext.ProductDtos.GetAll().ToListAsync(); + var historicalProducts = await _dbContext.ShippingItems.GetAll().ToListAsync(); + + //create json from product analyzis jsonstring + ProductReferenceResponse deserializedProducts = System.Text.Json.JsonSerializer.Deserialize(productAnalysis); + Console.WriteLine($"Serealized Products: {deserializedProducts.products.Count}"); + + List matchedProducts = new List(); + //do we have historical references? + foreach (var deserializedProduct in deserializedProducts.products) + { + var historicalProduct = historicalProducts.Where(hp => !string.IsNullOrEmpty(hp.NameOnDocument)) + .FirstOrDefault(p => p.NameOnDocument.Equals(deserializedProduct.name, StringComparison.OrdinalIgnoreCase)); + if (historicalProduct != null) + { + Console.WriteLine($"Historical product found: {historicalProduct.Name}"); + var productDto = allProducts.FirstOrDefault(p => p.Id == historicalProduct.ProductId); + if (productDto != null) + { + matchedProducts.Add(productDto); + Console.WriteLine($"Matched product from historical data: {productDto.Name}"); + } + } + } + Console.WriteLine($"Total matched products from historical data: {matchedProducts.Count}"); + if (matchedProducts.Count == 0) + { + //let's filter similar names first + var similarNameProducts = new List(); + foreach (var deserializedProduct in deserializedProducts.products) + { + //var similarProducts = historicalProducts.Where(hp => hp.ShippingDocument.Partner.Name == partnerAnalysis) + var similarProducts = historicalProducts + .Where(p => !string.IsNullOrEmpty(p.NameOnDocument) && + p.NameOnDocument.Contains(deserializedProduct.name.Substring(0,6), StringComparison.OrdinalIgnoreCase)) + .ToList(); + similarNameProducts.AddRange(similarProducts); + Console.WriteLine($"Similar products found for {deserializedProduct.name}: {similarProducts.Count}"); + } + + + //no exact name matches, try to match with AI + foreach (var deserializedProduct in deserializedProducts.products) + { + var aiMatchPrompt = $"You are an agent of Fruitbank to analyize product names and match them to existing products in the Fruitbank product catalog. " + + $"Given the following product catalog: {string.Join(", ", similarNameProducts.Select(p => p.NameOnDocument))}, " + + $"which product from the catalog best matches this product name: {deserializedProduct.name}. " + + $"Reply with NOTHING ELSE THAN the exact product name from the catalog, if no match found, reply with 'NONE'."; + var aiMatchedProductName = await _aiApiService.GetSimpleResponseAsync(aiMatchPrompt, deserializedProduct.name); + var productDto = allProducts.FirstOrDefault(p => p.Name.Equals(aiMatchedProductName, StringComparison.OrdinalIgnoreCase)); + if (productDto != null) + { + matchedProducts.Add(productDto); + Console.WriteLine($"AI Matched product: {productDto.Name}"); + } + } + } + + string fullresult = await TestFullResult(extractedText); + Console.WriteLine($"Full structured JSON Result: {fullresult}"); + return Json(new { success = true, message = extension == ".pdf" ? "PDF converted and text extracted successfully" : "Text extracted successfully", - extractedText = extractedText, + extractedText = extractedText + "/n" + fullresult, fileName = processedFileName, filePath = processedFilePath, fileSize = imageFile.Length, @@ -173,5 +245,129 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers }); } } + + private async Task TestFullResult(string extractedText) + { + string fullResultPrompt = $"Role:\r\nYou are an AI data extraction assistant for Fruitbank, a " + + $"fruit and vegetable wholesale company. Your task is to analyze a " + + $"provided text (delivery notes, invoices, or order confirmations) and extract structured information about " + + $"the shipment and its items.\r\n\r\n🎯 Goal:\r\nRead the provided text and extract all shipment " + + $"details and items according to the data model below.\r\n Generate the complete JSON output following this " + + $"structure.\r\n\r\n🧩 Data Models:\r\n\r\npublic " + + $"class Partner\r\n{{\r\n " + + $"/// \r\n /// Partner entity primary key\r\n /// \r\n " + + $"public int Id {{ get; set; }}\r\n " + + $"/// \r\n /// Partner company name\r\n /// \r\n " + + $"public string Name {{ get; set; }}\r\n " + + $"/// \r\n /// Partner company TaxId\r\n /// \r\n " + + $"public string TaxId {{ get; set; }}\r\n /// \r\n " + + $"/// Partner company Certification if exists\r\n /// \r\n " + + $"public string CertificationNumber {{ get; set; }}\r\n /// \r\n " + + $"/// Partner company address PostalCode\r\n /// \r\n " + + $"public string PostalCode {{ get; set; }}\r\n /// \r\n " + + $"/// Partner company address Country\r\n /// \r\n " + + $"public string Country {{ get; set; }}\r\n /// \r\n " + + $"/// Partner company address State if exists\r\n /// \r\n " + + $"public string State {{ get; set; }}\r\n /// \r\n " + + $"/// Partner company address County if exists\r\n /// \r\n " + + $"public string County {{ get; set; }}\r\n /// \r\n " + + $"/// Partner company address City\r\n /// \r\n " + + $"public string City {{ get; set; }}\r\n /// \r\n " + + $"/// Partner company address Street\r\n /// \r\n " + + $"public string Street {{ get; set; }}\r\n\t/// \r\n " + + $"/// Entities of ShippingDocument\r\n /// \r\n\tpublic List " + + $"ShippingDocuments {{ get; set; }}\t\r\n}}\r\n\r\npublic class ShippingDocument\r\n{{\r\n " + + $"/// \r\n /// ShippingItem entity primary key\r\n /// \r\n " + + $"public int Id {{ get; set; }}\r\n /// \r\n /// Partner entity primary key\r\n " + + $"/// \r\n public int PartnerId {{ get; set; }}\t\r\n\t/// \r\n " + + $"/// Entities of ShippingItem\r\n /// \r\n\t" + + $"public List ShippingItems {{ get; set; }}\r\n /// \r\n " + + $"/// DocumentIdNumber if exists\r\n /// \r\n public string DocumentIdNumber {{ get; set; }}\r\n " + + $"/// \r\n /// \r\n /// \r\n public DateTime ShippingDate {{ get; set; }}\r\n " + + $"/// \r\n /// Shipping pickup Contry of origin\r\n /// \r\n " + + $"public string Country {{ get; set; }}\r\n\t/// \r\n /// Sum of ShippingItem pallets\r\n " + + $"/// \r\n public int TotalPallets {{ get; set; }}\r\n\t/// \r\n " + + $"/// Filename of pdf\r\n /// \r\n\tpublic string PdfFileName {{ get; set; }}\r\n}}\r\n\r\n" + + $"public class ShippingItem\r\n{{\r\n /// \r\n /// ShippingItem entity primary key\r\n /// " + + $"\r\n public int Id {{ get; set; }}\r\n /// \r\n /// " + + $"ShippingDocument entity primary key\r\n /// \r\n " + + $"public int ShippingDocumentId {{ get; set; }}\r\n /// " + + $"\r\n /// Name of the fruit or vegitable\r\n /// \r\n " + + $"public string Name {{ get; set; }}\r\n\t/// \r\n /// Translated Name to Hungarian\r\n " + + $"/// \r\n public string HungarianName {{ get; set; }}\r\n /// \r\n " + + $"/// Pallets of fruit or vegitable item\r\n /// \r\n " + + $"public int PalletsOnDocument {{ get; set; }}\r\n /// \r\n " + + $"/// Quantity of fruit or vegitable item\r\n /// \r\n " + + $"public int QuantityOnDocument {{ get; set; }}\r\n /// \r\n " + + $"/// Net weight in kg. of fruit or vegitable item\r\n /// \r\n " + + $"public double NetWeightOnDocument {{ get; set; }}\r\n /// \r\n " + + $"/// Gross weight in kg. of fruit or vegitable item\r\n /// \r\n " + + $"public double GrossWeightOnDocument {{ get; set; }}\r\n}}\r\n\r\n🧾 Output Requirements\r\n- " + + $"Output must be a single valid JSON object containing:\r\n- One Partner object\r\n- " + + $"One ShippingDocument object\r\n- A list of all related ShippingItem objects\r\n\r\n- " + + $"Primary keys (Partner.Id, ShippingDocument.Id, ShippingItem.Id) should be auto-generated integers " + + $"(e.g. sequential: 1, 2, 3…).\r\n\r\n- When a field is missing or unclear, return it as an empty " + + $"string or 0 (depending on type).\r\nDo not omit any fields.\r\n\r\n- " + + $"All dates must be in ISO 8601 format (yyyy-MM-dd).\r\n\r\n🧭 Instructions to the AI\r\n" + + $"1. Analyze the provided text carefully.\r\n" + + $"2. Identify the Partner/Company details of THE OTHER PARTY (other than Fruitbank), " + + $"document identifiers, and each shipment item.\r\n" + + $"3. FruitBank is not a partner! Always look for THE OTHER partner on the document. \r\n " + + $"4. Generate a complete hierarchical JSON of ALL received documents in ONE JSON structure according to the " + + $"data model above.\r\n5. Do not include any explanations or text outside the JSON output. " + + $"Only return the structured JSON.\r\n" + + $"6. A teljes ShippingItem.Name-et tedd bele a ShippingItem.HungarianName-be " + + $"és a zöldség vagy gyümölcs nevét fordítsd le magyarra!\r\n" + + $"7. A ShippingDocument-et tedd bele a Partner entitásba!\r\n" + + $"8. ShippingItem-eket tedd bele a ShippingDocument-be!\r\n" + + $"9. Do not assume or modify any data, if you don't find a value, return null, if you find a value, keep it unmodified.\r\n" + + $"10. Magyarázat nélkül válaszolj!"; + + var fullresult = await _aiApiService.GetSimpleResponseAsync(fullResultPrompt, extractedText); + return fullresult; + } + + private async Task ExtractProducts(string extractedText) + { + //analyze document for product references + return await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted from a pdf document, and find any product references mentioned in the document. Do not translate or modify the product information. Format your response as JSON object named 'products' with the following fields in the child objects: 'name' (string), 'quantity' (int), 'netWeight' (double), 'grossWeight' (double), ", $"What product references are mentioned in this document: {extractedText}"); + } + + private async Task DeterminePartner(string partnerAnalysis) + { + //analyze the text to match partner + var possiblePartners = await _dbContext.Partners.GetAll().ToListAsync(); + var partnerNames = string.Join(", ", possiblePartners.Select(p => p.Name)); + var partnerMatchAnalysis = await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted from a pdf document, and match the partner information to existing partners of Fruitbank. If you find a match, you reply with NOTHING ELSE THAN the parter's name, if not, reply with NOTHING ELSE THAN 'NONE'.", $"Given the following possible partners: {partnerNames}, which partner matches this partner information: {partnerAnalysis}"); + Console.WriteLine($"Partner match analysis Result: {partnerMatchAnalysis}"); + } + + private async Task ExtractPartnerName(string extractedText) + { + //analyze the text to find partner information + var partnerAnalysis = await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted from a pdf document, and find the partner information of the other party, so the party that is NOT FRUITBANK.", $"What is the partner information of this document: {extractedText}"); + Console.WriteLine($"Partner analysis Result: {partnerAnalysis}"); + return partnerAnalysis; + } + + private async Task ExtractDocumentId(string extractedText) + { + //analyze the text for document number or identifiers + return await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted frem a pfd document, and find the document number or identifier. IMPORTANT: reply only with the number, do not add further explanation.", $"What is the document identifier of this document: {extractedText}"); + } } + + public class ProductReference + { + public string? name { get; set; } + public int? quantity { get; set; } + public double? netWeight { get; set; } + public double? grossWeight { get; set; } + } + + public class ProductReferenceResponse + { + public List products { get; set; } + } + } diff --git a/Nop.Plugin.Misc.AIPlugin/Services/OpenAIApiService.cs b/Nop.Plugin.Misc.AIPlugin/Services/OpenAIApiService.cs index c1124da..297cbc5 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/OpenAIApiService.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/OpenAIApiService.cs @@ -792,7 +792,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services _ => "image/jpeg" }; - var prompt = customPrompt ?? "Olvasd ki a szöveget és add vissza szépen strukturálva."; + var prompt = customPrompt ?? "Extract all text from this image, without modifying or translateing it."; var payload = new { From eb40643d625aea4854503df72597fd20b172012a Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 17 Dec 2025 19:09:09 +0100 Subject: [PATCH 2/3] AI document upload latest approach, innvoice order sync --- .../Controllers/FileManagerController.cs | 673 ++++++++++++++++-- .../Controllers/FruitBankAudioController.cs | 2 +- .../InnVoiceOrderSyncController.cs | 308 ++++++++ .../Admin/Controllers/VoiceOrderController.cs | 486 +++++++++++++ .../Views/Extras/ImageTextExtraction.cshtml | 155 +++- .../Views/InnVoiceOrderSync/Index.cshtml | 458 ++++++++++++ .../Admin/Views/VoiceOrder/Create.cshtml | 649 +++++++++++++++++ .../Nop.Plugin.Misc.FruitBankPlugin.csproj | 6 + .../Services/AICalculationService.cs | 16 + .../Services/InnVoiceOrderService.cs | 25 + .../Services/OpenAIApiService.cs | 48 +- 11 files changed, 2739 insertions(+), 87 deletions(-) create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/InnVoiceOrderSyncController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/VoiceOrderController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/InnVoiceOrderSync/Index.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/VoiceOrder/Create.cshtml diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs index 9207ad2..35d45a1 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs @@ -4,6 +4,7 @@ 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.Helpers; using Nop.Plugin.Misc.FruitBankPlugin.Services; using Nop.Services.Catalog; using Nop.Services.Security; @@ -23,6 +24,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers { private readonly IPermissionService _permissionService; private readonly OpenAIApiService _aiApiService; + private readonly AICalculationService _aiCalculationService; private readonly IProductService _productService; private readonly FruitBankDbContext _dbContext; private readonly PdfToImageService _pdfToImageService; @@ -30,12 +32,14 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers public FileManagerController( IPermissionService permissionService, OpenAIApiService aiApiService, + AICalculationService aiCalculationService, IProductService productService, FruitBankDbContext fruitBankDbContext, PdfToImageService pdfToImageService) { _permissionService = permissionService; _aiApiService = aiApiService; + _aiCalculationService = aiCalculationService; _productService = productService; _dbContext = fruitBankDbContext; _pdfToImageService = pdfToImageService; @@ -74,6 +78,9 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers return Json(new { success = false, message = "Invalid file type. Please upload JPG, PNG, GIF, WebP, or PDF." }); } + ShippingDocument shippingDocument = new ShippingDocument(); + shippingDocument.ShippingItems = new List(); + try { // Define the uploads folder @@ -151,76 +158,101 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers message = "Failed to extract text. The API may have returned an empty response." }); } + OpenaiImageResponse deserializedContent = new(); - string analysisResult = await ExtractDocumentId(extractedText); + var result = TextHelper.FixJsonWithoutAI(extractedText); - Console.WriteLine($"Document number analysis Result: {analysisResult}"); + var options = new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, // Handles camelCase/PascalCase mismatches + IncludeFields = true // This allows deserializing fields (in case you keep it as a field) + }; + + try + { + deserializedContent = System.Text.Json.JsonSerializer.Deserialize(result, options); + + if (deserializedContent == null || deserializedContent.extractedData == null) + { + Console.Error.WriteLine($"Deserialization returned null. JSON was: {result}"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error deserializing extracted text: {ex}"); + Console.Error.WriteLine($"JSON content: {result}"); + } + + + //string documentIdAnalysisResult = await ExtractDocumentId(deserializedContent.extractedData.fullText); + + Console.WriteLine($"Document number analysis Result: {deserializedContent.extractedData.documentId}"); + + shippingDocument.DocumentIdNumber = deserializedContent.extractedData.documentId; string partnerAnalysis = await ExtractPartnerName(extractedText); - await DeterminePartner(partnerAnalysis); - string productAnalysis = await ExtractProducts(extractedText); - Console.WriteLine($"Product analysis Result: {productAnalysis}"); + + //int? dbPartnerName = await DeterminePartner(deserializedContent.extractedData.partner.name); + int? dbPartnerName = await DeterminePartner(partnerAnalysis); + if (dbPartnerName != null) + { + shippingDocument.PartnerId = (int)dbPartnerName; + Console.WriteLine($"Determined Partner ID: {dbPartnerName}"); + } + else + { + Console.WriteLine("No matching partner found in the database."); + } + + //string productAnalysis = await _aiCalculationService.ExtractProducts(extractedText); + Console.WriteLine($"Product analysis Result: {deserializedContent.extractedData.products}"); //identify products from database var allProducts = await _dbContext.ProductDtos.GetAll().ToListAsync(); var historicalProducts = await _dbContext.ShippingItems.GetAll().ToListAsync(); //create json from product analyzis jsonstring - ProductReferenceResponse deserializedProducts = System.Text.Json.JsonSerializer.Deserialize(productAnalysis); - Console.WriteLine($"Serealized Products: {deserializedProducts.products.Count}"); + ProductReferenceResponse deserializedProducts = new ProductReferenceResponse(); + //deserializedProducts.products = new List(); + deserializedProducts.products = deserializedContent.extractedData.products; + Console.WriteLine($"Serialized Products: {deserializedProducts.products.Count}"); - List matchedProducts = new List(); + List matchedProducts = new List(); //do we have historical references? - foreach (var deserializedProduct in deserializedProducts.products) - { - var historicalProduct = historicalProducts.Where(hp => !string.IsNullOrEmpty(hp.NameOnDocument)) - .FirstOrDefault(p => p.NameOnDocument.Equals(deserializedProduct.name, StringComparison.OrdinalIgnoreCase)); - if (historicalProduct != null) - { - Console.WriteLine($"Historical product found: {historicalProduct.Name}"); - var productDto = allProducts.FirstOrDefault(p => p.Id == historicalProduct.ProductId); - if (productDto != null) - { - matchedProducts.Add(productDto); - Console.WriteLine($"Matched product from historical data: {productDto.Name}"); - } - } - } - Console.WriteLine($"Total matched products from historical data: {matchedProducts.Count}"); - if (matchedProducts.Count == 0) - { - //let's filter similar names first - var similarNameProducts = new List(); - foreach (var deserializedProduct in deserializedProducts.products) - { - //var similarProducts = historicalProducts.Where(hp => hp.ShippingDocument.Partner.Name == partnerAnalysis) - var similarProducts = historicalProducts - .Where(p => !string.IsNullOrEmpty(p.NameOnDocument) && - p.NameOnDocument.Contains(deserializedProduct.name.Substring(0,6), StringComparison.OrdinalIgnoreCase)) - .ToList(); - similarNameProducts.AddRange(similarProducts); - Console.WriteLine($"Similar products found for {deserializedProduct.name}: {similarProducts.Count}"); - } + matchedProducts = await DetermineProducts(allProducts, historicalProducts, deserializedProducts); + shippingDocument.ShippingItems = matchedProducts; - //no exact name matches, try to match with AI - foreach (var deserializedProduct in deserializedProducts.products) + if (matchedProducts.Count > 0) + { + Console.WriteLine($"Matched Products Count: {matchedProducts.Count}"); + foreach (var matchedProduct in matchedProducts) { - var aiMatchPrompt = $"You are an agent of Fruitbank to analyize product names and match them to existing products in the Fruitbank product catalog. " + - $"Given the following product catalog: {string.Join(", ", similarNameProducts.Select(p => p.NameOnDocument))}, " + - $"which product from the catalog best matches this product name: {deserializedProduct.name}. " + - $"Reply with NOTHING ELSE THAN the exact product name from the catalog, if no match found, reply with 'NONE'."; - var aiMatchedProductName = await _aiApiService.GetSimpleResponseAsync(aiMatchPrompt, deserializedProduct.name); - var productDto = allProducts.FirstOrDefault(p => p.Name.Equals(aiMatchedProductName, StringComparison.OrdinalIgnoreCase)); - if (productDto != null) - { - matchedProducts.Add(productDto); - Console.WriteLine($"AI Matched product: {productDto.Name}"); - } + Console.WriteLine($"Matched Product: {matchedProduct.Name}"); } } + else + { + Console.WriteLine("No products matched from the database."); + } - string fullresult = await TestFullResult(extractedText); - Console.WriteLine($"Full structured JSON Result: {fullresult}"); + shippingDocument.PdfFileName = processedFileName; + shippingDocument.ShippingDocumentToFiles = new List(); + + Files processedFile = new Files + { + FileName = processedFileName, + FileExtension = extension, + RawText = deserializedContent.extractedData.fullText, + }; + + ShippingDocumentToFiles shippingDocumentToFiles = new ShippingDocumentToFiles + { + FilesId = processedFile.Id, + ShippingDocumentId = shippingDocument.Id, + }; + + // Calculate total pallets from shipping items + shippingDocument.TotalPallets = shippingDocument.ShippingItems?.Sum(item => item.PalletsOnDocument) ?? 0; return Json(new { @@ -228,7 +260,26 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers message = extension == ".pdf" ? "PDF converted and text extracted successfully" : "Text extracted successfully", - extractedText = extractedText + "/n" + fullresult, + shippingDocument = new + { + documentIdNumber = shippingDocument.DocumentIdNumber, + partnerId = shippingDocument.PartnerId, + pdfFileName = shippingDocument.PdfFileName, + totalPallets = shippingDocument.TotalPallets, + shippingItems = shippingDocument.ShippingItems?.Select(item => new + { + name = item.Name, + hungarianName = item.HungarianName, + nameOnDocument = item.NameOnDocument, + productId = item.ProductId, + palletsOnDocument = item.PalletsOnDocument, + quantityOnDocument = item.QuantityOnDocument, + netWeightOnDocument = item.NetWeightOnDocument, + grossWeightOnDocument = item.GrossWeightOnDocument, + isMeasurable = item.IsMeasurable + }).ToList(), + extractedText = deserializedContent.extractedData.fullText + }, fileName = processedFileName, filePath = processedFilePath, fileSize = imageFile.Length, @@ -246,6 +297,293 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers } } + private async Task> DetermineProducts(List allProducts, List historicalProducts, ProductReferenceResponse deserializedProducts) + { + List finalMatchedProducts = new List(); + + // Load all shipping items once + var allShippingItems = await _dbContext.ShippingItems.GetAll(true).ToListAsync(); + + foreach (var deserializedProduct in deserializedProducts.products) + { + ShippingItem matchedItem = null; + + // Step 1: Try exact historical match + var historicalProduct = historicalProducts + .Where(hp => !string.IsNullOrEmpty(hp.NameOnDocument)) + .FirstOrDefault(p => p.NameOnDocument.Equals(deserializedProduct.name, StringComparison.OrdinalIgnoreCase)); + + if (historicalProduct != null) + { + Console.WriteLine($"Historical product found: {historicalProduct.Name}"); + var productDto = await _dbContext.ProductDtos.GetByIdAsync(historicalProduct.ProductId); + + if (productDto != null) + { + matchedItem = CreateShippingItem(productDto, deserializedProduct); + Console.WriteLine($"Matched product from historical data: {productDto.Name}"); + } + } + + // Step 2: If no exact match, try AI matching with similar names + if (matchedItem == null) + { + // Get similar products (safe substring) + int substringLength = Math.Min(6, deserializedProduct.name?.Length ?? 0); + + if (substringLength > 0) + { + var similarNameProducts = historicalProducts + .Where(p => !string.IsNullOrEmpty(p.NameOnDocument) && + p.NameOnDocument.Contains(deserializedProduct.name.Substring(0, substringLength), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + Console.WriteLine($"Similar products found for {deserializedProduct.name}: {similarNameProducts.Count}"); + + // Try AI match with similar historical products + if (similarNameProducts.Any()) + { + //var aiMatchPrompt = $"You are an agent of Fruitbank to analyze product names and match them to existing products in the Fruitbank product catalog. " + + // $"Given the following product catalog: {string.Join(", ", similarNameProducts.Select(p => p.NameOnDocument))}, " + + // $"which product from the catalog best matches this product name: {deserializedProduct.name}. " + + // $"Reply with NOTHING ELSE THAN the exact product name from the catalog, if no match found, reply with 'NONE'."; + + var systemPrompt = "You are a product name matching specialist for FruitBank, a wholesale fruit and vegetable company.\n\n" + + "Your task: Match a product name from a shipping document to the most similar product in our historical catalog.\n\n" + + "MATCHING RULES:\n" + + "1. Match based on ALL details including:\n" + + " - Product type (apples, bananas, oranges)\n" + + " - Variety (Golden Delicious, Cavendish, Valencia)\n" + + " - Quality grade (Class I, Class II, Extra, Premium)\n" + + " - Size markers (60+, 70+, 80+, Large, Small)\n" + + " - Packaging type if mentioned (Carton, Box, Loose)\n" + + "2. Consider language variations:\n" + + " - Spanish: Manzanas = Apples, Plátanos = Bananas, Naranjas = Oranges\n" + + " - Hungarian: Alma = Apples, Banán = Bananas, Narancs = Oranges\n" + + " - Plural/singular: 'Bananas' = 'Banana'\n" + + "3. Match as specifically as possible:\n" + + " - 'APPLES CLASS I 70+' should match 'APPLES CLASS I 70+' (not just 'APPLES')\n" + + " - 'ORANGES 60+' is different from 'ORANGES 70+'\n" + + " - 'TOMATOES EXTRA' is different from 'TOMATOES CLASS I'\n" + + "4. Abbreviations to recognize:\n" + + " - 'GOLDEN DEL' = 'GOLDEN DELICIOUS'\n" + + " - 'CAT I' = 'CLASS I' = 'CATEGORY I'\n" + + " - 'CAT II' = 'CLASS II' = 'CATEGORY II'\n" + + " - 'BIO' = 'ORGANIC'\n\n" + + "OUTPUT:\n" + + "Return ONLY the exact product name from the catalog that best matches ALL the details.\n" + + "If no good match exists (less than 70% similarity including grade/size), return 'NONE'.\n\n" + + "Examples:\n" + + "Document: 'GOLDEN DEL APPLES CAT I 70+' | Catalog: ['GOLDEN DELICIOUS APPLES CLASS I 70+', 'GOLDEN DELICIOUS APPLES CLASS II 70+'] → GOLDEN DELICIOUS APPLES CLASS I 70+\n" + + "Document: 'PLATANOS CAVENDISH 70+' | Catalog: ['BANANAS CAVENDISH 60+', 'BANANAS CAVENDISH 70+', 'BANANAS CAVENDISH 80+'] → BANANAS CAVENDISH 70+\n" + + "Document: 'MANZANAS ROJAS EXTRA' | Catalog: ['RED APPLES CLASS I', 'RED APPLES EXTRA', 'RED APPLES CLASS II'] → RED APPLES EXTRA\n" + + "Document: 'SWEET PEPPERS' | Catalog: ['TOMATOES', 'CUCUMBERS', 'CARROTS'] → NONE"; + + var userPrompt = "HISTORICAL PRODUCT CATALOG:\n" + + string.Join("\n", similarNameProducts.Select(p => $"- {p.NameOnDocument}")) + "\n\n" + + "---\n\n" + + "PRODUCT NAME FROM DOCUMENT:\n" + + deserializedProduct.name + "\n\n" + + "Return the best matching product name from the catalog above (matching ALL details including size/grade), or 'NONE' if no good match exists."; + + var aiMatchedProductName = await _aiApiService.GetSimpleResponseAsync(systemPrompt, userPrompt); + + //var aiMatchedProductName = await _aiApiService.GetSimpleResponseAsync(aiMatchPrompt, deserializedProduct.name); + Console.WriteLine($"AI matched product name for {deserializedProduct.name}: {aiMatchedProductName}"); + + if (!string.IsNullOrEmpty(aiMatchedProductName) && aiMatchedProductName != "NONE") + { + var matchingShippingItem = allShippingItems.FirstOrDefault(x => + x.NameOnDocument != null && + x.NameOnDocument.Equals(aiMatchedProductName, StringComparison.OrdinalIgnoreCase)); + + if (matchingShippingItem?.ProductDto != null) + { + matchedItem = CreateShippingItem(matchingShippingItem.ProductDto, deserializedProduct); + Console.WriteLine($"AI Matched product from historical: {matchingShippingItem.ProductDto.Name}"); + } + } + } + + // Step 3: If still no match, try AI with full product catalog + if (matchedItem == null) + { + // HYBRID APPROACH: Combine recent products + fuzzy matched products + + // Get recent products (50 newest - most likely to be in current shipments) + var recentProducts = allProducts + .OrderByDescending(p => p.Id) // Use Id as proxy for CreatedDate if CreatedOnUtc not available + .Take(50) + .ToList(); + + // Get products that fuzzy match the search term (similar names) + int fuzzySearchLength = Math.Min(4, deserializedProduct.name?.Length ?? 0); + var fuzzyMatches = fuzzySearchLength > 0 + ? allProducts + .Where(p => p.Name.Contains( + deserializedProduct.name.Substring(0, fuzzySearchLength), + StringComparison.OrdinalIgnoreCase)) + .Take(30) + .ToList() + : new List(); + + // Combine and deduplicate + var combinedProducts = recentProducts + .Union(fuzzyMatches) + .GroupBy(p => p.Id) + .Select(g => g.First()) + .Take(100) + .ToList(); + + Console.WriteLine($"Hybrid search: {combinedProducts.Count} products ({recentProducts.Count} recent + {fuzzyMatches.Count} fuzzy matched) for: {deserializedProduct.name}"); + + var systemPrompt2 = "You are a product name matching specialist for FruitBank wholesale company.\n\n" + + "Your task: Match a product name from a shipping document to our product catalog.\n\n" + + "MATCHING RULES:\n" + + "1. Match based on ALL product details:\n" + + " - Product type and variety\n" + + " - Quality grades: 'Extra', 'Class I', 'Class II', 'Premium', 'Category I/II'\n" + + " - Size markers: '60+', '70+', '80+', 'Large', 'Small', 'Medium'\n" + + " - Packaging: 'Carton', 'Box', 'Loose', 'Packed' (if it's part of product ID)\n" + + " - Origin country: 'Spanish', 'Italian', 'Dutch', 'Turkish' (if tracked separately)\n" + + "2. Language variations:\n" + + " SPANISH → ENGLISH → HUNGARIAN\n" + + " - Manzanas → Apples → Alma\n" + + " - Plátanos → Bananas → Banán\n" + + " - Naranjas → Oranges → Narancs\n" + + " - Tomates → Tomatoes → Paradicsom\n" + + " - Pimientos → Peppers → Paprika\n" + + " - Uvas → Grapes → Szőlő\n" + + " - Limones → Lemons → Citrom\n" + + "3. Quality grade abbreviations:\n" + + " - 'CAT I' / 'CAT. I' / 'CATEGORY I' = 'CLASS I'\n" + + " - 'CAT II' / 'CAT. II' / 'CATEGORY II' = 'CLASS II'\n" + + " - '1' = 'CLASS I', '2' = 'CLASS II'\n" + + "4. Be specific:\n" + + " - 'APPLES 70+' is NOT the same as 'APPLES 80+'\n" + + " - 'TOMATOES CLASS I' is NOT the same as 'TOMATOES CLASS II'\n" + + " - 'ORANGES SPANISH' may be different from 'ORANGES ITALIAN'\n\n" + + "OUTPUT:\n" + + "Return ONLY the exact product name from the catalog that matches ALL the details.\n" + + "If no close match exists (below 70% similarity), return 'NONE'.\n\n" + + "Examples:\n" + + "Document: 'MANZANAS GOLDEN CAT I 70+' | Best match: 'GOLDEN DELICIOUS APPLES CLASS I 70+'\n" + + "Document: 'BIO BANANEN 80+' | Best match: 'ORGANIC BANANAS 80+' (NOT just 'BANANAS')\n" + + "Document: 'POMODORI CILIEGINI EXTRA' | Best match: 'CHERRY TOMATOES EXTRA' (NOT 'CHERRY TOMATOES CLASS I')\n" + + "Document: 'NARANJAS 60+' | Best match: 'ORANGES 60+' (NOT 'ORANGES 70+')\n" + + "Document: 'RARE EXOTIC FRUIT' | No match: 'NONE'"; + + var userPrompt2 = "PRODUCT CATALOG (recent products + similar names):\n" + + string.Join("\n", combinedProducts.Select(p => $"- {p.Name}")) + "\n\n" + + "---\n\n" + + "PRODUCT NAME FROM DOCUMENT:\n" + + deserializedProduct.name + "\n\n" + + "Return the best matching product name from the catalog above that matches ALL details (size, grade, quality), or 'NONE' if no confident match exists."; + + var aiMatchedProductName2 = await _aiApiService.GetSimpleResponseAsync(systemPrompt2, userPrompt2); + Console.WriteLine($"AI matched product name from hybrid catalog for {deserializedProduct.name}: {aiMatchedProductName2}"); + + if (!string.IsNullOrEmpty(aiMatchedProductName2) && aiMatchedProductName2 != "NONE") + { + // Clean the AI response + aiMatchedProductName2 = CleanProductName(aiMatchedProductName2); + + var matchingProduct = combinedProducts.FirstOrDefault(x => + x.Name.Equals(aiMatchedProductName2, StringComparison.OrdinalIgnoreCase)); + + if (matchingProduct != null) + { + matchedItem = CreateShippingItem(matchingProduct, deserializedProduct); + Console.WriteLine($"AI Matched product from hybrid catalog: {matchingProduct.Name}"); + } + } + } + } + } + + // Step 4: Add matched or unmatched item + if (matchedItem != null) + { + finalMatchedProducts.Add(matchedItem); + } + else + { + // Create unmatched item + finalMatchedProducts.Add(new ShippingItem + { + Name = "", + HungarianName = "", + PalletsOnDocument = 1, + IsMeasurable = false, + QuantityOnDocument = deserializedProduct.quantity ?? 0, + NetWeightOnDocument = deserializedProduct.netWeight ?? 0, + GrossWeightOnDocument = deserializedProduct.grossWeight ?? 0, + ProductId = null, + NameOnDocument = deserializedProduct.name + }); + Console.WriteLine($"No match found for: {deserializedProduct.name}"); + } + } + + Console.WriteLine($"Total matched products: {finalMatchedProducts.Count(x => x.ProductId != null)}"); + Console.WriteLine($"Total unmatched products: {finalMatchedProducts.Count(x => x.ProductId == null)}"); + + return finalMatchedProducts; + } + + private ShippingItem CreateShippingItem(ProductDto productDto, ProductReference deserializedProduct) + { + return new ShippingItem + { + Name = productDto.Name, + HungarianName = productDto.Name, + PalletsOnDocument = 1, + QuantityOnDocument = deserializedProduct.quantity ?? 0, + NetWeightOnDocument = deserializedProduct.netWeight ?? 0, + GrossWeightOnDocument = deserializedProduct.grossWeight ?? 0, + ProductId = productDto.Id, + NameOnDocument = deserializedProduct.name + }; + } + + /// + /// Cleans and normalizes product name returned from AI + /// + private string CleanProductName(string rawProductName) + { + if (string.IsNullOrWhiteSpace(rawProductName)) + return string.Empty; + + var cleaned = rawProductName.Trim(); + + // Remove common prefixes that AI might add + var prefixesToRemove = new[] + { + "Product name:", + "Match:", + "Best match:", + "The product is", + "Answer:", + "-" + }; + + foreach (var prefix in prefixesToRemove) + { + if (cleaned.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + cleaned = cleaned.Substring(prefix.Length).Trim(); + } + } + + // Remove quotes if present + cleaned = cleaned.Trim('\"', '\'', '\'', '«', '»'); + + // Remove trailing punctuation + cleaned = cleaned.TrimEnd('.', ',', ';', ':'); + + return cleaned; + } + private async Task TestFullResult(string extractedText) { string fullResultPrompt = $"Role:\r\nYou are an AI data extraction assistant for Fruitbank, a " + @@ -327,34 +665,226 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers return fullresult; } - private async Task ExtractProducts(string extractedText) + private async Task DeterminePartner(string partnerAnalysis) { - //analyze document for product references - return await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted from a pdf document, and find any product references mentioned in the document. Do not translate or modify the product information. Format your response as JSON object named 'products' with the following fields in the child objects: 'name' (string), 'quantity' (int), 'netWeight' (double), 'grossWeight' (double), ", $"What product references are mentioned in this document: {extractedText}"); - } + // Clean the input first + partnerAnalysis = CleanPartnerName(partnerAnalysis); + + if (string.IsNullOrWhiteSpace(partnerAnalysis)) + { + Console.WriteLine("Partner analysis is empty after cleaning."); + return 0; + } - private async Task DeterminePartner(string partnerAnalysis) - { - //analyze the text to match partner var possiblePartners = await _dbContext.Partners.GetAll().ToListAsync(); - var partnerNames = string.Join(", ", possiblePartners.Select(p => p.Name)); - var partnerMatchAnalysis = await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted from a pdf document, and match the partner information to existing partners of Fruitbank. If you find a match, you reply with NOTHING ELSE THAN the parter's name, if not, reply with NOTHING ELSE THAN 'NONE'.", $"Given the following possible partners: {partnerNames}, which partner matches this partner information: {partnerAnalysis}"); - Console.WriteLine($"Partner match analysis Result: {partnerMatchAnalysis}"); + + // STEP 1: Try exact match first (fast, free, no AI needed!) + var exactMatch = possiblePartners.FirstOrDefault(p => + p.Name.Trim().Equals(partnerAnalysis.Trim(), StringComparison.OrdinalIgnoreCase)); + + if (exactMatch != null) + { + Console.WriteLine($"✓ Exact partner match found: {exactMatch.Name} (ID: {exactMatch.Id})"); + return exactMatch.Id; + } + + Console.WriteLine($"No exact match found for '{partnerAnalysis}'. Trying AI matching..."); + + // STEP 2: No exact match? Use AI with IDs (handles fuzzy matching) + var partnerListWithIds = string.Join("\n", possiblePartners.Select(p => $"ID: {p.Id} | Name: {p.Name}")); + + var systemPrompt = "You are a partner matching specialist for FruitBank.\n\n" + + "Your task: Match a partner name to the correct partner from our database.\n\n" + + "MATCHING RULES:\n" + + "1. Ignore minor differences:\n" + + " - Trailing/leading spaces\n" + + " - Periods and punctuation\n" + + " - Case differences (B.V. vs BV vs b.v.)\n" + + " - Legal entity suffixes (B.V., S.L., S.R.L., Kft., Ltd.)\n" + + "2. Match based on core company name\n" + + "3. Be flexible with abbreviations\n\n" + + "OUTPUT:\n" + + "Return ONLY the numeric ID of the matching partner.\n" + + "If no match found, return '0'.\n\n" + + "Examples:\n" + + "Input: 'SFI Rotterdam' | Database: 'ID: 42 | Name: SFI Rotterdam B.V.' → 42\n" + + "Input: 'Frutas Sanchez SL' | Database: 'ID: 15 | Name: FRUTAS SÁNCHEZ S.L.' → 15\n" + + "Input: 'Van den Berg' | Database: 'ID: 8 | Name: Van den Berg B.V.' → 8\n" + + "Input: 'Unknown Company' | No match in database → 0"; + + var userPrompt = "PARTNER DATABASE:\n" + + partnerListWithIds + "\n\n" + + "---\n\n" + + "PARTNER TO MATCH:\n" + + partnerAnalysis + "\n\n" + + "Return ONLY the numeric ID of the matching partner, or '0' if no match found."; + + var aiResponse = await _aiApiService.GetSimpleResponseAsync(systemPrompt, userPrompt); + Console.WriteLine($"AI Partner Match Response: {aiResponse}"); + + // Parse the ID + if (int.TryParse(aiResponse.Trim(), out int partnerId)) + { + if (partnerId == 0) + { + Console.WriteLine("AI found no matching partner."); + return 0; + } + + // Verify the ID exists in our list + var matchedPartner = possiblePartners.FirstOrDefault(p => p.Id == partnerId); + if (matchedPartner != null) + { + Console.WriteLine($"✓ AI matched partner: {matchedPartner.Name} (ID: {matchedPartner.Id})"); + return partnerId; + } + else + { + Console.WriteLine($"⚠ AI returned invalid partner ID: {partnerId}"); + return 0; + } + } + else + { + Console.WriteLine($"⚠ AI returned non-numeric response: {aiResponse}"); + return 0; + } } private async Task ExtractPartnerName(string extractedText) { - //analyze the text to find partner information - var partnerAnalysis = await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted from a pdf document, and find the partner information of the other party, so the party that is NOT FRUITBANK.", $"What is the partner information of this document: {extractedText}"); - Console.WriteLine($"Partner analysis Result: {partnerAnalysis}"); - return partnerAnalysis; + var availablePartners = await _dbContext.Partners.GetAll().ToListAsync(); + Console.WriteLine($"Available partners count: {availablePartners.Count}"); + + string partnerListForAI = ""; + foreach (var partner in availablePartners) + { + //let's make a string list of available partners for AI + + partnerListForAI += $"- {partner.Name}\n"; + + } + + // Enhanced system prompt with explicit instructions and examples + var systemPrompt = "You are a specialized data extraction agent for FruitBank, a Hungarian fruit and vegetable wholesale company.\n\n" + + "Your task: Extract the SUPPLIER/SENDER company name from shipping documents (CMR, delivery notes, invoices).\n\n" + + "CRITICAL RULES:\n" + + "1. FruitBank (Gyümölcsbank Kft.) is the RECEIVER - NEVER return FruitBank as the partner\n" + + "2. Look for these indicators of the SUPPLIER:\n" + + " - 'Sender' / 'Expediteur' / 'Feladó' / 'Absender' section\n" + + " - 'From' / 'De' / 'Kitől' field\n" + + " - Company name at TOP of document (usually sender)\n" + + " - Tax ID / VAT number paired with company name\n" + + " - EORI number holder (if present)\n" + + "3. The supplier is typically:\n" + + " - A farm, cooperative, or wholesaler\n" + + " - Located in Spain, Italy, Netherlands, Poland, Germany, Greece, Turkey, or other EU countries\n" + + " - NOT FruitBank and NOT the transport company\n\n" + + "Document structure hints:\n" + + "- CMR documents: Sender is box 1-2, Receiver is box 3-4\n" + + "- Invoices: Look for 'Seller' / 'Eladó' / 'Vendedor' (NOT Buyer)\n" + + "- Delivery notes: Sender/Origin section at top\n\n" + + "OUTPUT FORMAT:\n" + + "Return ONLY the exact company name as it appears in the document.\n" + + "Do not include:\n" + + "- Tax IDs\n" + + "- Addresses\n" + + "- Country codes\n" + + "- Legal entity types (unless part of official name)\n\n" + + "Examples:\n" + + "[CORRECT] FRUTAS SÁNCHEZ S.L.\n" + + "[CORRECT] Van den Berg B.V.\n" + + "[CORRECT] Agricola Romana SRL\n" + + "[WRONG] FruitBank (this is us!)\n" + + "[WRONG] DHL Supply Chain (transport company)\n" + + "[WRONG] FRUTAS SÁNCHEZ S.L. - ES12345678 (no tax ID)"; + + // Enhanced user prompt with context and structure + var userPrompt = "DOCUMENT TEXT:\n" + + extractedText + "\n\n" + + "---\n\n" + + "INSTRUCTIONS:\n" + + "1. Identify the SENDER/SUPPLIER company name\n" + + "2. Ignore FruitBank (Gyümölcsbank) - that's the receiver\n" + + "3. Ignore transport companies (DHL, Transporeon, etc.)\n" + + "4. Return ONLY the company name, nothing else\n\n" + + "If uncertain, return the most prominent non-FruitBank company name from the document."; + + var partnerAnalysis = await _aiApiService.GetSimpleResponseAsync(systemPrompt, userPrompt); + + // Clean up the response + var cleanedPartnerName = CleanPartnerName(partnerAnalysis); + + Console.WriteLine($"Partner analysis Result: {cleanedPartnerName}"); + return cleanedPartnerName; } - private async Task ExtractDocumentId(string extractedText) + /// + /// Cleans and normalizes partner name from AI response + /// + private string CleanPartnerName(string rawPartnerName) { - //analyze the text for document number or identifiers - return await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted frem a pfd document, and find the document number or identifier. IMPORTANT: reply only with the number, do not add further explanation.", $"What is the document identifier of this document: {extractedText}"); + if (string.IsNullOrWhiteSpace(rawPartnerName)) + return string.Empty; + + var cleaned = rawPartnerName.Trim(); + + // Remove common prefixes that AI might add + var prefixesToRemove = new[] + { + "Company name:", + "Sender:", + "Supplier:", + "Partner:", + "The partner is", + "The company is", + "Feladó:", + "Expediteur:" + }; + + foreach (var prefix in prefixesToRemove) + { + if (cleaned.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + cleaned = cleaned.Substring(prefix.Length).Trim(); + break; + } + } + + // Remove quotes if present + cleaned = cleaned.Trim('\"', '\'', '\'', '«', '»'); + + // Remove trailing punctuation + cleaned = cleaned.TrimEnd('.', ',', ';'); + + // Remove tax IDs that might have slipped through (pattern: letters followed by 8+ digits) + var taxIdPattern = new System.Text.RegularExpressions.Regex(@"\s*-?\s*[A-Z]{2}\d{8,}.*$"); + cleaned = taxIdPattern.Replace(cleaned, string.Empty).Trim(); + + // If AI returned 'NONE' or similar, return empty + if (cleaned.Equals("NONE", StringComparison.OrdinalIgnoreCase) || + cleaned.Equals("N/A", StringComparison.OrdinalIgnoreCase) || + cleaned.Equals("NOT FOUND", StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; + } + + // Check if accidentally returned FruitBank + if (cleaned.Contains("FruitBank", StringComparison.OrdinalIgnoreCase) || + cleaned.Contains("Gyümölcsbank", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"WARNING: AI returned FruitBank as partner. Returning empty."); + return string.Empty; + } + + return cleaned; } + + //private async Task ExtractDocumentId(string extractedText) + //{ + // //analyze the text for document number or identifiers + // return await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted frem a pfd document, and find the document number or identifier. IMPORTANT: reply only with the number, do not add further explanation.", $"What is the document identifier of this document: {extractedText}"); + //} } public class ProductReference @@ -363,6 +893,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers public int? quantity { get; set; } public double? netWeight { get; set; } public double? grossWeight { get; set; } + public int? productId { get; set; } } public class ProductReferenceResponse diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankAudioController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankAudioController.cs index fda3d98..f5cb5e6 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankAudioController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FruitBankAudioController.cs @@ -116,7 +116,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers string transcribedText; using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) { - transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, "en"); // or "hu" for Hungarian + transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, "hu"); // Hungarian language } if (string.IsNullOrEmpty(transcribedText)) diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/InnVoiceOrderSyncController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/InnVoiceOrderSyncController.cs new file mode 100644 index 0000000..e761d30 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/InnVoiceOrderSyncController.cs @@ -0,0 +1,308 @@ +using Microsoft.AspNetCore.Mvc; +using Nop.Core; +using Nop.Core.Domain.Orders; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Common; +using Nop.Services.Orders; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers +{ + [Area(AreaNames.ADMIN)] + [AuthorizeAdmin] + public class InnVoiceOrderSyncController : BasePluginController + { + private readonly IOrderService _orderService; + private readonly IStoreContext _storeContext; + private readonly InnVoiceOrderService _innVoiceOrderService; + private readonly IGenericAttributeService _genericAttributeService; + + public InnVoiceOrderSyncController( + IOrderService _orderService, + IStoreContext storeContext, + InnVoiceOrderService innVoiceOrderService, + IGenericAttributeService genericAttributeService) + { + this._orderService = _orderService; + _storeContext = storeContext; + _innVoiceOrderService = innVoiceOrderService; + _genericAttributeService = genericAttributeService; + } + + /// + /// Display the order sync page + /// + [HttpGet] + public IActionResult Index() + { + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/InnVoiceOrderSync/Index.cshtml"); + } + + /// + /// Sync orders between NopCommerce and InnVoice for a given date range + /// + [HttpPost] + public async Task SyncOrders(DateTime startDate, DateTime endDate) + { + try + { + // Validate dates + if (startDate > endDate) + { + return Json(new { success = false, message = "Start date cannot be after end date" }); + } + + // Get store ID + var storeId = (await _storeContext.GetCurrentStoreAsync()).Id; + + // Step 1: Get all InnVoice orders for the date range using UpdateTime + var innVoiceOrders = await GetInnVoiceOrdersByDateRange(startDate, endDate); + Console.WriteLine($"DEBUG: Found {innVoiceOrders.Count} InnVoice orders"); + Console.WriteLine($"DEBUG: InnVoice TableIds: {string.Join(", ", innVoiceOrders.Select(o => o.TableId))}"); + + // Step 2: Get all NopCommerce orders in the date range (filtered by DateOfReceipt) + var nopOrders = await GetNopOrdersByDateRange(startDate, endDate, storeId); + Console.WriteLine($"DEBUG: Found {nopOrders.Count} NopCommerce orders with DateOfReceipt in range"); + + // Step 3: Get TableIds from NopCommerce orders + var nopOrderTableIds = new Dictionary(); // orderId -> tableId + var nopOrdersWithoutTableId = new List(); // Track orders without TableId + + foreach (var order in nopOrders) + { + var tableIdStr = await _genericAttributeService.GetAttributeAsync( + order, + "InnVoiceOrderTableId", + storeId + ); + + if (!string.IsNullOrEmpty(tableIdStr) && int.TryParse(tableIdStr, out int tableId)) + { + nopOrderTableIds[order.Id] = tableId; + Console.WriteLine($"DEBUG: Order {order.Id} has TableId {tableId}"); + } + else + { + nopOrdersWithoutTableId.Add(order.Id); + Console.WriteLine($"DEBUG: Order {order.Id} has NO TableId (tableIdStr='{tableIdStr}')"); + } + } + + // Step 4: Compare and find discrepancies + var innVoiceTableIds = new HashSet(innVoiceOrders.Select(o => o.TableId).Where(t => t > 0)); + var nopTableIds = new HashSet(nopOrderTableIds.Values); + + // Orders in InnVoice but not in NopCommerce + var innVoiceOnly = innVoiceTableIds.Except(nopTableIds).ToList(); + + // Orders in NopCommerce but not in InnVoice + var nopOnly = nopTableIds.Except(innVoiceTableIds).ToList(); + + // Orders in both systems + var inBoth = innVoiceTableIds.Intersect(nopTableIds).ToList(); + + // Build detailed results + var innVoiceOnlyDetails = innVoiceOrders + .Where(o => innVoiceOnly.Contains(o.TableId)) + .Select(o => new + { + tableId = o.TableId, + techId = o.TechId, + customerName = o.CustomerName, + totalGross = o.TotalGross, + orderDate = o.OrderDate, + externalOrderNumber = o.ExternalOrderNumber + }) + .ToList(); + + var nopOnlyDetails = nopOrders + .Where(o => nopOrderTableIds.ContainsKey(o.Id) && nopOnly.Contains(nopOrderTableIds[o.Id])) + .Select(o => new + { + orderId = o.Id, + tableId = nopOrderTableIds.ContainsKey(o.Id) ? nopOrderTableIds[o.Id] : 0, + customOrderNumber = o.CustomOrderNumber, + orderTotal = o.OrderTotal, + createdOn = o.CreatedOnUtc + }) + .ToList(); + + // Orders without TableId (never uploaded to InnVoice) + var nopOrdersNotUploadedDetails = new List(); + + foreach (var order in nopOrders.Where(o => nopOrdersWithoutTableId.Contains(o.Id))) + { + // Check if order has items + var orderItems = await _orderService.GetOrderItemsAsync(order.Id); + var hasItems = orderItems.Any(); + + nopOrdersNotUploadedDetails.Add(new + { + orderId = order.Id, + customOrderNumber = order.CustomOrderNumber, + orderTotal = order.OrderTotal, + createdOn = order.CreatedOnUtc, + orderStatus = order.OrderStatus.ToString(), + orderStatusId = (int)order.OrderStatus, + hasItems = hasItems, + itemCount = orderItems.Count() + }); + } + + // Separate orders by whether they have items + var deletedOrders = nopOrdersNotUploadedDetails + .Where(o => !o.hasItems) // No items = deleted/empty order + .ToList(); + + var missingUploads = nopOrdersNotUploadedDetails + .Where(o => o.hasItems) // Has items = should have been uploaded + .ToList(); + + return Json(new + { + success = true, + data = new + { + summary = new + { + totalInnVoiceOrders = innVoiceOrders.Count, + totalNopOrders = nopOrders.Count, + totalNopOrdersWithTableId = nopOrderTableIds.Count, + totalNopOrdersWithoutTableId = nopOrdersWithoutTableId.Count, + totalDeletedOrders = deletedOrders.Count, + totalMissingUploads = missingUploads.Count, + inBothSystems = inBoth.Count, + onlyInInnVoice = innVoiceOnly.Count, + onlyInNopCommerce = nopOnly.Count + }, + innVoiceOnly = innVoiceOnlyDetails, + nopCommerceOnly = nopOnlyDetails, + nopCommerceNotUploaded = nopOrdersNotUploadedDetails, + deletedOrders = deletedOrders, + missingUploads = missingUploads, + dateRange = new + { + startDate = startDate.ToString("yyyy-MM-dd"), + endDate = endDate.ToString("yyyy-MM-dd") + } + } + }); + } + catch (Exception ex) + { + return Json(new + { + success = false, + message = $"Error: {ex.Message}" + }); + } + } + + /// + /// Quick action: Sync last 30 days + /// + [HttpPost] + public async Task SyncLast30Days() + { + var endDate = DateTime.Now; + var startDate = endDate.AddDays(-30); + + return await SyncOrders(startDate, endDate); + } + + /// + /// Get InnVoice orders by UpdateTime range + /// We query by UpdateTime but DON'T filter by MegrendelesKelte + /// This allows comparing ALL orders regardless of order date mismatches + /// + private async Task> GetInnVoiceOrdersByDateRange(DateTime startDate, DateTime endDate) + { + var allOrders = new List(); + + // Add 14-day buffer to catch orders that might have been modified/created around this time + //var queryStartDate = startDate.AddDays(-14); + //var queryEndDate = endDate.AddDays(7); + //var currentDate = queryStartDate; + var currentDate = startDate; + + //Console.WriteLine($"DEBUG: Querying InnVoice from {queryStartDate:yyyy-MM-dd} to {queryEndDate:yyyy-MM-dd}"); + Console.WriteLine($"DEBUG: Querying InnVoice from {startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}"); + + // Query day by day to avoid rate limits + while (currentDate <= endDate) + { + try + { + var orders = await _innVoiceOrderService.GetOrdersByOrderDateAsync(currentDate); + + if (orders != null && orders.Any()) + { + Console.WriteLine($"DEBUG: Found {orders.Count} orders updated on {currentDate:yyyy-MM-dd}"); + allOrders.AddRange(orders); + } + + currentDate = currentDate.AddDays(1); + } + catch (Exception ex) + { + // Log and continue + Console.WriteLine($"Error fetching InnVoice orders for {currentDate:yyyy-MM-dd}: {ex.Message}"); + currentDate = currentDate.AddDays(1); + } + } + + // Remove duplicates based on TableId + var uniqueOrders = allOrders + .GroupBy(o => o.TableId) + .Select(g => g.First()) + .ToList(); + + Console.WriteLine($"DEBUG: Total unique InnVoice orders: {uniqueOrders.Count}"); + + return uniqueOrders; + } + + /// + /// Get NopCommerce orders by DateOfReceipt generic attribute date range + /// + private async Task> GetNopOrdersByDateRange(DateTime startDate, DateTime endDate, int storeId) + { + // Get ALL orders (we'll filter by DateOfReceipt manually) + var allOrders = await _orderService.SearchOrdersAsync(); + + var filteredOrders = new List(); + + foreach (var order in allOrders) + { + // Get the DateOfReceipt generic attribute + var dateOfReceiptStr = await _genericAttributeService.GetAttributeAsync( + order, + "DateOfReceipt", + storeId + ); + + // Parse and check if within range + if (!string.IsNullOrEmpty(dateOfReceiptStr) && DateTime.TryParse(dateOfReceiptStr, out DateTime dateOfReceipt)) + { + // Compare dates (ignoring time component) + var receiptDate = dateOfReceipt.Date; + var start = startDate.Date; + var end = endDate.Date; + + if (receiptDate >= start && receiptDate <= end) + { + filteredOrders.Add(order); + } + } + } + + return filteredOrders; + } + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/VoiceOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/VoiceOrderController.cs new file mode 100644 index 0000000..d949332 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/VoiceOrderController.cs @@ -0,0 +1,486 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Catalog; +using Nop.Services.Customers; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers +{ + [AuthorizeAdmin] + [Area(AreaNames.ADMIN)] + [AutoValidateAntiforgeryToken] + public class VoiceOrderController : BasePluginController + { + private readonly IPermissionService _permissionService; + private readonly OpenAIApiService _aiApiService; + private readonly ICustomerService _customerService; + private readonly IProductService _productService; + private readonly FruitBankDbContext _dbContext; + + public VoiceOrderController( + IPermissionService permissionService, + OpenAIApiService aiApiService, + ICustomerService customerService, + IProductService productService, + FruitBankDbContext dbContext) + { + _permissionService = permissionService; + _aiApiService = aiApiService; + _customerService = customerService; + _productService = productService; + _dbContext = dbContext; + } + + /// + /// Display the voice order creation page + /// + [HttpGet] + public async Task Create() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/VoiceOrder/Create.cshtml"); + } + + /// + /// Transcribe audio for partner name and return matching partners + /// + [HttpPost] + public async Task TranscribeForPartner(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" }); + } + + try + { + // Transcribe audio + var transcribedText = await TranscribeAudioFile(audioFile, "hu"); + + if (string.IsNullOrEmpty(transcribedText)) + { + return Json(new { success = false, message = "Failed to transcribe audio" }); + } + + Console.WriteLine($"[VoiceOrder] Partner transcription: {transcribedText}"); + + // Search for matching partners + var partners = await SearchPartners(transcribedText); + + return Json(new + { + success = true, + transcription = transcribedText, + partners = partners + }); + } + catch (Exception ex) + { + Console.WriteLine($"[VoiceOrder] Error in TranscribeForPartner: {ex.Message}"); + return Json(new { success = false, message = $"Error: {ex.Message}" }); + } + } + + /// + /// Transcribe audio for products and parse quantities using AI + /// + [HttpPost] + public async Task TranscribeForProducts(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" }); + } + + try + { + // Transcribe audio + var transcribedText = await TranscribeAudioFile(audioFile, "hu"); + + if (string.IsNullOrEmpty(transcribedText)) + { + return Json(new { success = false, message = "Failed to transcribe audio" }); + } + + Console.WriteLine($"[VoiceOrder] Product transcription: {transcribedText}"); + + // Parse products and quantities using AI + var parsedProducts = await ParseProductsFromText(transcribedText); + + if (parsedProducts == null || parsedProducts.Count == 0) + { + return Json(new + { + success = false, + message = "Could not parse products from transcription", + transcription = transcribedText + }); + } + + // Enrich with actual product data from database + var enrichedProducts = await EnrichProductData(parsedProducts); + + return Json(new + { + success = true, + transcription = transcribedText, + products = enrichedProducts + }); + } + catch (Exception ex) + { + Console.WriteLine($"[VoiceOrder] Error in TranscribeForProducts: {ex.Message}"); + return Json(new { success = false, message = $"Error: {ex.Message}" }); + } + } + + #region Helper Methods + + /// + /// Transcribe audio file using OpenAI Whisper + /// + private async Task TranscribeAudioFile(IFormFile audioFile, string language) + { + var fileName = $"voice_order_{DateTime.Now:yyyyMMdd_HHmmss}.webm"; + var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "voice"); + + if (!Directory.Exists(uploadsFolder)) + { + Directory.CreateDirectory(uploadsFolder); + } + + var filePath = Path.Combine(uploadsFolder, fileName); + + // Save file temporarily + using (var stream = new FileStream(filePath, FileMode.Create)) + { + await audioFile.CopyToAsync(stream); + } + + // Transcribe using OpenAI Whisper + string transcribedText; + using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) + { + transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language); + } + + // Clean up temporary file + try + { + System.IO.File.Delete(filePath); + } + catch { /* Ignore cleanup errors */ } + + return transcribedText; + } + + /// + /// Search for partners matching the transcribed text + /// Uses string-based search first, then semantic AI matching if needed + /// + private async Task> SearchPartners(string searchTerm) + { + const int maxResults = 10; + const int minResultsForAI = 3; // If we get fewer than this, use AI semantic search + + Console.WriteLine($"[VoiceOrder] Searching partners for: {searchTerm}"); + + // Step 1: Try string-based search + var customersByCompany = await _customerService.GetAllCustomersAsync( + company: searchTerm, + pageIndex: 0, + pageSize: maxResults); + + var customersByName = await _customerService.GetAllCustomersAsync( + firstName: searchTerm, + pageIndex: 0, + pageSize: maxResults); + + var customersByLastName = await _customerService.GetAllCustomersAsync( + lastName: searchTerm, + pageIndex: 0, + pageSize: maxResults); + + // Combine and deduplicate + var allCustomers = customersByCompany + .Union(customersByName) + .Union(customersByLastName) + .DistinctBy(c => c.Id) + .Take(maxResults) + .ToList(); + + Console.WriteLine($"[VoiceOrder] String-based search found {allCustomers.Count} partners"); + + // Step 2: If we don't have enough results, use AI semantic matching + if (allCustomers.Count < minResultsForAI) + { + Console.WriteLine("[VoiceOrder] Using AI semantic matching for partners"); + + var aiMatches = await SemanticPartnerSearch(searchTerm); + + // Merge AI matches with string matches, remove duplicates + allCustomers = allCustomers + .Union(aiMatches) + .DistinctBy(c => c.Id) + .Take(maxResults) + .ToList(); + + Console.WriteLine($"[VoiceOrder] After AI matching: {allCustomers.Count} partners"); + } + + // Format results + var result = new List(); + foreach (var customer in allCustomers) + { + var fullName = await _customerService.GetCustomerFullNameAsync(customer); + var company = customer.Company; + + if (string.IsNullOrEmpty(fullName)) + fullName = "[No name]"; + if (string.IsNullOrEmpty(company)) + company = "[No company]"; + + string label = $"{company} ({fullName}), {customer.Email}"; + + result.Add(new + { + label = label, + value = customer.Id + }); + } + + return result; + } + + /// + /// Use AI to semantically match partner names based on company name + /// + private async Task> SemanticPartnerSearch(string searchTerm) + { + try + { + // Get all customers with company names (limit to reasonable number) + var allCustomersWithCompany = await _customerService.GetAllCustomersAsync( + pageIndex: 0, + pageSize: 500); // Reasonable limit for AI processing + + // Filter to only those with company names + var customersWithCompany = allCustomersWithCompany + .Where(c => !string.IsNullOrEmpty(c.Company)) + .ToList(); + + if (customersWithCompany.Count == 0) + { + Console.WriteLine("[VoiceOrder] No customers with company names found"); + return new List(); + } + + // Build company list for AI + var companyList = string.Join("\n", customersWithCompany + .Select((c, index) => $"{index}|{c.Company}")); + + var systemPrompt = @"You are a company name matcher for a B2B system. +Given a spoken company name and a list of company names, find the 5 best semantic matches. + +RULES: +1. Consider phonetic similarity (how it sounds) +2. Consider abbreviations (e.g., 'SFI' matches 'SFI Rotterdam B.V.') +3. Consider partial matches (e.g., 'Rotterdam' matches 'SFI Rotterdam B.V.') +4. Consider common misspellings or mishearings +5. Return ONLY valid JSON array with indices, no explanations + +INPUT FORMAT: +Search term: [spoken company name] +Companies: [index]|[company name] (one per line) + +OUTPUT FORMAT (JSON only): +[0, 15, 42, 103, 256] + +Return the top 5 indices that best match the search term. If fewer than 5 good matches exist, return fewer indices."; + + var userPrompt = $@"Search term: {searchTerm} +Companies: +{companyList}"; + + var aiResponse = await _aiApiService.GetSimpleResponseAsync(systemPrompt, userPrompt); + + Console.WriteLine($"[VoiceOrder] AI company matching response: {aiResponse}"); + + // Extract JSON array from response + var jsonMatch = Regex.Match(aiResponse, @"\[[\d,\s]*\]", RegexOptions.Singleline); + if (!jsonMatch.Success) + { + Console.WriteLine("[VoiceOrder] No JSON array found in AI response"); + return new List(); + } + + var jsonText = jsonMatch.Value; + var indices = System.Text.Json.JsonSerializer.Deserialize>(jsonText); + + if (indices == null || indices.Count == 0) + { + Console.WriteLine("[VoiceOrder] AI returned no matches"); + return new List(); + } + + // Get customers by indices + var matchedCustomers = indices + .Where(i => i >= 0 && i < customersWithCompany.Count) + .Select(i => customersWithCompany[i]) + .ToList(); + + Console.WriteLine($"[VoiceOrder] AI matched {matchedCustomers.Count} companies: {string.Join(", ", matchedCustomers.Select(c => c.Company))}"); + + return matchedCustomers; + } + catch (Exception ex) + { + Console.WriteLine($"[VoiceOrder] Error in semantic partner search: {ex.Message}"); + return new List(); + } + } + + /// + /// Parse products and quantities from transcribed text using AI + /// + private async Task> ParseProductsFromText(string text) + { + var systemPrompt = @"You are a product parser for a Hungarian fruit wholesale company. +Parse the product names and quantities from the user's speech. + +RULES: +1. Extract product names and quantities +2. Normalize product names to singular, lowercase (e.g., 'narancsok' → 'narancs') +3. Convert quantity units to standard format (kg, db, láda) +4. Handle Hungarian number words ('száz' = 100, 'ötven' = 50, etc.) +5. Return ONLY valid JSON array, no explanations + +OUTPUT FORMAT (JSON only): +[ + {""product"": ""narancs"", ""quantity"": 100, ""unit"": ""kg""}, + {""product"": ""alma"", ""quantity"": 50, ""unit"": ""kg""} +] + +EXAMPLES: +Input: 'narancs száz kilogram és alma ötven kiló' +Output: [{""product"":""narancs"",""quantity"":100,""unit"":""kg""},{""product"":""alma"",""quantity"":50,""unit"":""kg""}] + +Input: 'Kérek 200 kg narancsot meg 150 kg almát' +Output: [{""product"":""narancs"",""quantity"":200,""unit"":""kg""},{""product"":""alma"",""quantity"":150,""unit"":""kg""}]"; + + var userPrompt = $"Parse this: {text}"; + + var aiResponse = await _aiApiService.GetSimpleResponseAsync(systemPrompt, userPrompt); + + Console.WriteLine($"[VoiceOrder] AI Response: {aiResponse}"); + + // Try to extract JSON from response + var jsonMatch = Regex.Match(aiResponse, @"\[.*\]", RegexOptions.Singleline); + if (!jsonMatch.Success) + { + Console.WriteLine("[VoiceOrder] No JSON array found in AI response"); + return new List(); + } + + var jsonText = jsonMatch.Value; + + try + { + var parsedProducts = System.Text.Json.JsonSerializer.Deserialize>(jsonText); + return parsedProducts ?? new List(); + } + catch (Exception ex) + { + Console.WriteLine($"[VoiceOrder] Error parsing JSON: {ex.Message}"); + return new List(); + } + } + + /// + /// Enrich parsed products with actual product data from database + /// + private async Task> EnrichProductData(List parsedProducts) + { + var enrichedProducts = new List(); + + foreach (var parsed in parsedProducts) + { + // Search for matching product in database + var products = await _productService.SearchProductsAsync( + keywords: parsed.Product, + pageIndex: 0, + pageSize: 5); + + if (!products.Any()) + { + Console.WriteLine($"[VoiceOrder] No product found for: {parsed.Product}"); + continue; + } + + // Take the best match (first result) + var product = products.First(); + var productDto = await _dbContext.ProductDtos.GetByIdAsync(product.Id); + + if (productDto == null) + { + Console.WriteLine($"[VoiceOrder] ProductDto not found for product ID: {product.Id}"); + continue; + } + + // Check if enough stock + var availableQuantity = product.StockQuantity + productDto.IncomingQuantity; + if (availableQuantity <= 0) + { + Console.WriteLine($"[VoiceOrder] Product {product.Name} has no stock"); + continue; + } + + // Add to enriched list + enrichedProducts.Add(new + { + id = product.Id, + name = product.Name, + sku = product.Sku, + quantity = parsed.Quantity, + unit = parsed.Unit, + price = product.Price, + stockQuantity = availableQuantity + }); + } + + return enrichedProducts; + } + + #endregion + + #region Models + + private class ParsedProduct + { + public string Product { get; set; } + public int Quantity { get; set; } + public string Unit { get; set; } + } + + #endregion + } +} \ 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 index edb52a2..1486f46 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Extras/ImageTextExtraction.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Extras/ImageTextExtraction.cshtml @@ -60,17 +60,87 @@ -