diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs index f421c37..de64b27 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs @@ -1,7 +1,10 @@ -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; +using Nop.Plugin.Misc.FruitBankPlugin.Helpers; using Nop.Plugin.Misc.FruitBankPlugin.Services; using Nop.Services.Catalog; using Nop.Services.Security; @@ -21,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; @@ -28,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; @@ -72,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 @@ -149,6 +158,101 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers message = "Failed to extract text. The API may have returned an empty response." }); } + OpenaiImageResponse deserializedContent = new(); + + var result = TextHelper.FixJsonWithoutAI(extractedText); + + 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); + + //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(true).ToListAsync(); + var historicalProducts = await _dbContext.ShippingItems.GetAll().ToListAsync(); + + //create json from product analyzis jsonstring + ProductReferenceResponse deserializedProducts = new ProductReferenceResponse(); + //deserializedProducts.products = new List(); + deserializedProducts.products = deserializedContent.extractedData.products; + Console.WriteLine($"Serialized Products: {deserializedProducts.products.Count}"); + + List matchedProducts = new List(); + //do we have historical references? + matchedProducts = await DetermineProducts(allProducts, historicalProducts, deserializedProducts); + + shippingDocument.ShippingItems = matchedProducts; + + if (matchedProducts.Count > 0) + { + Console.WriteLine($"Matched Products Count: {matchedProducts.Count}"); + foreach (var matchedProduct in matchedProducts) + { + Console.WriteLine($"Matched Product: {matchedProduct.Name}"); + } + } + else + { + Console.WriteLine("No products matched from the database."); + } + + 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 { @@ -156,7 +260,26 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers message = extension == ".pdf" ? "PDF converted and text extracted successfully" : "Text extracted successfully", - extractedText = extractedText, + 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, @@ -173,5 +296,612 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers }); } } + + private async Task> DetermineProducts(List allProducts, List historicalProducts, ProductReferenceResponse deserializedProducts) + { + List finalMatchedProducts = new List(); + //List allProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync(); + // 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 = allProducts.FirstOrDefault(p => p.Id == 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, + IsMeasurable = productDto.IsMeasurable, + UnitPriceOnDocument = deserializedProduct.unitCost + }; + } + + /// + /// 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 " + + $"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 DeterminePartner(string partnerAnalysis) + { + // Clean the input first + partnerAnalysis = CleanPartnerName(partnerAnalysis); + + if (string.IsNullOrWhiteSpace(partnerAnalysis)) + { + Console.WriteLine("Partner analysis is empty after cleaning."); + return 0; + } + + var possiblePartners = await _dbContext.Partners.GetAll().ToListAsync(); + + // 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) + { + 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; + } + + /// + /// Cleans and normalizes partner name from AI response + /// + private string CleanPartnerName(string rawPartnerName) + { + 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 + { + public string? name { get; set; } + public int? quantity { get; set; } + public double? netWeight { get; set; } + public double? grossWeight { get; set; } + public int? productId { get; set; } + public double unitCost { get; set; } + } + + public class ProductReferenceResponse + { + public List products { get; set; } + } + } 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..7ffafcb --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/VoiceOrderController.cs @@ -0,0 +1,674 @@ +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 + { + // Build partner names prompt for Whisper vocabulary hints + // Whisper has a 224 character limit, so extract unique KEYWORDS instead of full names + var allCustomers = await _customerService.GetAllCustomersAsync(pageIndex: 0, pageSize: 300); + + var companyNames = allCustomers + .Where(c => !string.IsNullOrEmpty(c.Company)) + .Select(c => c.Company.Trim()) + .Distinct() + .ToList(); + + // Extract unique keywords from company names + var keywords = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var company in companyNames) + { + // Split by common separators and extract meaningful words + var words = company.Split(new[] { ' ', ',', '.', '-', '/', '(', ')' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var word in words) + { + var cleaned = word.Trim(); + + // Skip very short words, common abbreviations, and legal terms + if (cleaned.Length < 3) continue; + if (cleaned.Equals("BV", StringComparison.OrdinalIgnoreCase)) continue; + if (cleaned.Equals("Ltd", StringComparison.OrdinalIgnoreCase)) continue; + if (cleaned.Equals("Kft", StringComparison.OrdinalIgnoreCase)) continue; + if (cleaned.Equals("Inc", StringComparison.OrdinalIgnoreCase)) continue; + if (cleaned.Equals("GmbH", StringComparison.OrdinalIgnoreCase)) continue; + if (cleaned.Equals("SRL", StringComparison.OrdinalIgnoreCase)) continue; + + keywords.Add(cleaned); + } + } + + // Build prompt from keywords, fitting as many as possible in 224 chars + var keywordList = keywords.OrderBy(k => k.Length).ToList(); + var promptParts = new List(); + int currentLength = 0; + const int maxLength = 220; + + foreach (var keyword in keywordList) + { + var toAdd = promptParts.Count == 0 ? keyword : ", " + keyword; + + if (currentLength + toAdd.Length > maxLength) + break; + + promptParts.Add(keyword); + currentLength += toAdd.Length; + } + + var partnerPrompt = string.Join(", ", promptParts); + Console.WriteLine($"[VoiceOrder] Whisper prompt with {promptParts.Count} keywords from {companyNames.Count} partners ({partnerPrompt.Length} chars)"); + + // Transcribe audio in HUNGARIAN with partner keywords as vocabulary hints + var transcribedText = await TranscribeAudioFile(audioFile, "hu", partnerPrompt); + + if (string.IsNullOrEmpty(transcribedText)) + { + return Json(new { success = false, message = "Failed to transcribe audio" }); + } + + Console.WriteLine($"[VoiceOrder] Partner transcription (HU): {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 in HUNGARIAN + 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 (HU): {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}" }); + } + } + + /// + /// Search for partners by manually typed text (no audio transcription needed) + /// + [HttpPost] + public async Task SearchPartnerByText(string text) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, message = "Access denied" }); + + if (string.IsNullOrWhiteSpace(text)) + { + return Json(new { success = false, message = "No text provided" }); + } + + try + { + Console.WriteLine($"[VoiceOrder] Manual partner search: {text}"); + + // Search for matching partners (same logic as voice) + var partners = await SearchPartners(text); + + return Json(new + { + success = true, + transcription = text, + partners = partners + }); + } + catch (Exception ex) + { + Console.WriteLine($"[VoiceOrder] Error in SearchPartnerByText: {ex.Message}"); + return Json(new { success = false, message = $"Error: {ex.Message}" }); + } + } + + /// + /// Parse manually typed product text (no audio transcription needed) + /// + [HttpPost] + public async Task ParseManualProductText(string text) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, message = "Access denied" }); + + if (string.IsNullOrWhiteSpace(text)) + { + return Json(new { success = false, message = "No text provided" }); + } + + try + { + Console.WriteLine($"[VoiceOrder] Manual product input: {text}"); + + // Parse products and quantities using AI (same as voice) + var parsedProducts = await ParseProductsFromText(text); + + if (parsedProducts == null || parsedProducts.Count == 0) + { + return Json(new + { + success = false, + message = "Could not parse products from text", + transcription = text + }); + } + + // Enrich with actual product data from database + var enrichedProducts = await EnrichProductData(parsedProducts); + + return Json(new + { + success = true, + transcription = text, + products = enrichedProducts + }); + } + catch (Exception ex) + { + Console.WriteLine($"[VoiceOrder] Error in ParseManualProductText: {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, string customPrompt = null) + { + 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, customPrompt); + } + + // Clean up temporary file + try + { + System.IO.File.Delete(filePath); + } + catch { /* Ignore cleanup errors */ } + + return transcribedText; + } + + /// + /// Search for partners matching the transcribed text + /// Uses both string-based and AI semantic search for best results + /// + private async Task> SearchPartners(string searchTerm) + { + const int maxResults = 10; + + Console.WriteLine($"[VoiceOrder] Searching partners for: {searchTerm}"); + + // Step 1: String-based search (fast, catches exact matches) + 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 string search results + var stringResults = customersByCompany + .Union(customersByName) + .Union(customersByLastName) + .DistinctBy(c => c.Id) + .Take(maxResults) + .ToList(); + + Console.WriteLine($"[VoiceOrder] String-based search found {stringResults.Count} partners"); + + // Step 2: ALWAYS use AI semantic search for better results + Console.WriteLine("[VoiceOrder] Using AI semantic matching for partners"); + var aiMatches = await SemanticPartnerSearch(searchTerm); + Console.WriteLine($"[VoiceOrder] AI semantic search found {aiMatches.Count} partners"); + + // Step 3: Merge results - string matches first (exact), then AI matches + var allCustomers = stringResults + .Union(aiMatches) + .DistinctBy(c => c.Id) + .Take(maxResults) + .ToList(); + + Console.WriteLine($"[VoiceOrder] Total unique partners: {allCustomers.Count}"); + + // 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 (increased limit) + var allCustomersWithCompany = await _customerService.GetAllCustomersAsync( + pageIndex: 0, + pageSize: 1000); // Increased from 500 to catch more companies + + // 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(); + } + + Console.WriteLine($"[VoiceOrder] AI searching through {customersWithCompany.Count} companies"); + + // 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 matches. + +CRITICAL MATCHING RULES (in priority order): +1. EXACT MATCH: If the search term appears exactly in a company name, prioritize it +2. SUBSTRING MATCH: If the search term is contained within a company name (e.g., 'Junket' in 'Junket Silver Kft.') +3. WORD MATCH: If all words from search term appear in company name (any order) +4. PARTIAL MATCH: If significant words overlap (e.g., 'Silver' matches 'Junket Silver') +5. PHONETIC SIMILARITY: How it sounds when spoken +6. ABBREVIATIONS: 'SFI' matches 'SFI Rotterdam B.V.' + +EXAMPLES: +Search: 'Junket Silver' +Should match: 'Junket Silver Kft.' (substring match - VERY HIGH PRIORITY) + +Search: 'Rotterdam' +Should match: 'SFI Rotterdam B.V.' (substring match) + +Return ONLY a JSON array with the top 5 indices, ordered by best match first. +If fewer than 5 matches exist, return fewer indices. + +OUTPUT FORMAT (JSON only): +[0, 15, 42, 103, 256]"; + + 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 and vegetable wholesale company. +Parse the product names and quantities from the user's speech. + +CRITICAL RULES: +1. Extract product names and quantities from ANY produce item (fruits, vegetables, herbs, etc.) +2. Normalize product names to singular, lowercase (e.g., 'narancsok' → 'narancs', 'áfonyák' → 'áfonya') +3. Handle Hungarian number words ('száz' = 100, 'ötven' = 50, 'húsz' = 20, 'tíz' = 10, 'öt' = 5, 'egy' = 1, etc.) +4. FIX COMMON TRANSCRIPTION ERRORS: + - 'datója' → 'datolya' (dates) + - 'szűlő' → 'szőlő' (grapes) + - 'mondarin' → 'mandarin' (mandarin) + - 'paprika' is correct (pepper/paprika) + - 'fokhagyma' is correct (garlic) + - Any obvious typo → correct it +5. Return ONLY valid JSON array, no explanations or empty arrays +6. DO NOT include units - only product name and quantity as a number +7. ALWAYS return at least one product if you can parse anything from the input + +OUTPUT FORMAT (JSON only): +[ + {""product"": ""narancs"", ""quantity"": 100}, + {""product"": ""alma"", ""quantity"": 50} +] + +EXAMPLES: +Input: 'narancs száz kilogram és alma ötven kiló' +Output: [{""product"":""narancs"",""quantity"":100},{""product"":""alma"",""quantity"":50}] + +Input: 'fokhagyma, 1' +Output: [{""product"":""fokhagyma"",""quantity"":1}] + +Input: 'paprika, öt rekesz' +Output: [{""product"":""paprika"",""quantity"":5}] + +Input: 'mondarin öt rekesz' (typo in 'mandarin') +Output: [{""product"":""mandarin"",""quantity"":5}] + +Input: 'menta, 1' +Output: [{""product"":""menta"",""quantity"":1}] + +Input: 'datója tíz láda' (WRONG transcription) +Output: [{""product"":""datolya"",""quantity"":10}] + +Input: 'szűlő ötven kiló' (WRONG transcription) +Output: [{""product"":""szőlő"",""quantity"":50}]"; + + 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 + /// Returns ALL matching products so admin can select the exact one + /// + private async Task> EnrichProductData(List parsedProducts) + { + var enrichedProducts = new List(); + + // OPTIMIZATION: Load all ProductDtos once instead of querying one by one + var helperProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync(); + + foreach (var parsed in parsedProducts) + { + // Search for ALL matching products in database + var products = await _productService.SearchProductsAsync( + keywords: parsed.Product, + pageIndex: 0, + pageSize: 20); // Get up to 20 matches + + if (!products.Any()) + { + Console.WriteLine($"[VoiceOrder] No product found for: {parsed.Product}"); + continue; + } + + Console.WriteLine($"[VoiceOrder] Found {products.Count()} products matching '{parsed.Product}'"); + + // Add ALL matching products for admin to choose from + foreach (var product in products) + { + var productDto = helperProductDtos.FirstOrDefault(x => x.Id == 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 - skipping"); + continue; + } + + // Validate requested quantity against available stock + var requestedQuantity = parsed.Quantity; + var finalQuantity = requestedQuantity; + var isQuantityReduced = false; + + if (requestedQuantity > availableQuantity) + { + Console.WriteLine($"[VoiceOrder] WARNING: Product {product.Name} - Requested {requestedQuantity} but only {availableQuantity} available. Capping to available."); + finalQuantity = availableQuantity; + isQuantityReduced = true; + } + + // Add to enriched list with validated quantity + enrichedProducts.Add(new + { + id = product.Id, + name = product.Name, + sku = product.Sku, + quantity = finalQuantity, // Use validated quantity (capped to available) + requestedQuantity = requestedQuantity, // Original requested amount + price = product.Price, + stockQuantity = availableQuantity, + searchTerm = parsed.Product, // Track what was searched for + isQuantityReduced = isQuantityReduced // Flag if we had to reduce + }); + } + } + + Console.WriteLine($"[VoiceOrder] Total enriched products to display: {enrichedProducts.Count}"); + return enrichedProducts; + } + + #endregion + + #region Models + + private class ParsedProduct + { + [System.Text.Json.Serialization.JsonPropertyName("product")] + public string Product { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("quantity")] + public int Quantity { 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 @@ -