Compare commits

...

5 Commits

12 changed files with 4205 additions and 19 deletions

View File

@ -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<ShippingItem>();
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<OpenaiImageResponse>(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<ProductReference>();
deserializedProducts.products = deserializedContent.extractedData.products;
Console.WriteLine($"Serialized Products: {deserializedProducts.products.Count}");
List<ShippingItem> matchedProducts = new List<ShippingItem>();
//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<ShippingDocumentToFiles>();
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<List<ShippingItem>> DetermineProducts(List<ProductDto> allProducts, List<ShippingItem> historicalProducts, ProductReferenceResponse deserializedProducts)
{
List<ShippingItem> finalMatchedProducts = new List<ShippingItem>();
//List<ProductDto> 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<ProductDto>();
// 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
};
}
/// <summary>
/// Cleans and normalizes product name returned from AI
/// </summary>
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<string> 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 " +
$"/// <summary>\r\n /// Partner entity primary key\r\n /// </summary>\r\n " +
$"public int Id {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// Partner company name\r\n /// </summary>\r\n " +
$"public string Name {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// Partner company TaxId\r\n /// </summary>\r\n " +
$"public string TaxId {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company Certification if exists\r\n /// </summary>\r\n " +
$"public string CertificationNumber {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address PostalCode\r\n /// </summary>\r\n " +
$"public string PostalCode {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address Country\r\n /// </summary>\r\n " +
$"public string Country {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address State if exists\r\n /// </summary>\r\n " +
$"public string State {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address County if exists\r\n /// </summary>\r\n " +
$"public string County {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address City\r\n /// </summary>\r\n " +
$"public string City {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address Street\r\n /// </summary>\r\n " +
$"public string Street {{ get; set; }}\r\n\t/// <summary>\r\n " +
$"/// Entities of ShippingDocument\r\n /// </summary>\r\n\tpublic List<ShippingDocument> " +
$"ShippingDocuments {{ get; set; }}\t\r\n}}\r\n\r\npublic class ShippingDocument\r\n{{\r\n " +
$"/// <summary>\r\n /// ShippingItem entity primary key\r\n /// </summary>\r\n " +
$"public int Id {{ get; set; }}\r\n /// <summary>\r\n /// Partner entity primary key\r\n " +
$"/// </summary>\r\n public int PartnerId {{ get; set; }}\t\r\n\t/// <summary>\r\n " +
$"/// Entities of ShippingItem\r\n /// </summary>\r\n\t" +
$"public List<ShippingItem> ShippingItems {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// DocumentIdNumber if exists\r\n /// </summary>\r\n public string DocumentIdNumber {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// \r\n /// </summary>\r\n public DateTime ShippingDate {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// Shipping pickup Contry of origin\r\n /// </summary>\r\n " +
$"public string Country {{ get; set; }}\r\n\t/// <summary>\r\n /// Sum of ShippingItem pallets\r\n " +
$"/// </summary>\r\n public int TotalPallets {{ get; set; }}\r\n\t/// <summary>\r\n " +
$"/// Filename of pdf\r\n /// </summary>\r\n\tpublic string PdfFileName {{ get; set; }}\r\n}}\r\n\r\n" +
$"public class ShippingItem\r\n{{\r\n /// <summary>\r\n /// ShippingItem entity primary key\r\n /// " +
$"</summary>\r\n public int Id {{ get; set; }}\r\n /// <summary>\r\n /// " +
$"ShippingDocument entity primary key\r\n /// </summary>\r\n " +
$"public int ShippingDocumentId {{ get; set; }}\r\n /// " +
$"<summary>\r\n /// Name of the fruit or vegitable\r\n /// </summary>\r\n " +
$"public string Name {{ get; set; }}\r\n\t/// <summary>\r\n /// Translated Name to Hungarian\r\n " +
$"/// </summary>\r\n public string HungarianName {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Pallets of fruit or vegitable item\r\n /// </summary>\r\n " +
$"public int PalletsOnDocument {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Quantity of fruit or vegitable item\r\n /// </summary>\r\n " +
$"public int QuantityOnDocument {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Net weight in kg. of fruit or vegitable item\r\n /// </summary>\r\n " +
$"public double NetWeightOnDocument {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Gross weight in kg. of fruit or vegitable item\r\n /// </summary>\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<int> 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<string> 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;
}
/// <summary>
/// Cleans and normalizes partner name from AI response
/// </summary>
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<string> 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<ProductReference> products { get; set; }
}
}

View File

@ -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))

View File

@ -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;
}
/// <summary>
/// Display the order sync page
/// </summary>
[HttpGet]
public IActionResult Index()
{
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/InnVoiceOrderSync/Index.cshtml");
}
/// <summary>
/// Sync orders between NopCommerce and InnVoice for a given date range
/// </summary>
[HttpPost]
public async Task<IActionResult> 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<int, int>(); // orderId -> tableId
var nopOrdersWithoutTableId = new List<int>(); // Track orders without TableId
foreach (var order in nopOrders)
{
var tableIdStr = await _genericAttributeService.GetAttributeAsync<string>(
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<int>(innVoiceOrders.Select(o => o.TableId).Where(t => t > 0));
var nopTableIds = new HashSet<int>(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<dynamic>();
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}"
});
}
}
/// <summary>
/// Quick action: Sync last 30 days
/// </summary>
[HttpPost]
public async Task<IActionResult> SyncLast30Days()
{
var endDate = DateTime.Now;
var startDate = endDate.AddDays(-30);
return await SyncOrders(startDate, endDate);
}
/// <summary>
/// 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
/// </summary>
private async Task<List<InnVoiceOrder>> GetInnVoiceOrdersByDateRange(DateTime startDate, DateTime endDate)
{
var allOrders = new List<InnVoiceOrder>();
// 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;
}
/// <summary>
/// Get NopCommerce orders by DateOfReceipt generic attribute date range
/// </summary>
private async Task<List<Order>> 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<Order>();
foreach (var order in allOrders)
{
// Get the DateOfReceipt generic attribute
var dateOfReceiptStr = await _genericAttributeService.GetAttributeAsync<string>(
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;
}
}
}

View File

@ -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;
}
/// <summary>
/// Display the voice order creation page
/// </summary>
[HttpGet]
public async Task<IActionResult> Create()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/VoiceOrder/Create.cshtml");
}
/// <summary>
/// Transcribe audio for partner name and return matching partners
/// </summary>
[HttpPost]
public async Task<IActionResult> 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<string>(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<string>();
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}" });
}
}
/// <summary>
/// Transcribe audio for products and parse quantities using AI
/// </summary>
[HttpPost]
public async Task<IActionResult> 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}" });
}
}
/// <summary>
/// Search for partners by manually typed text (no audio transcription needed)
/// </summary>
[HttpPost]
public async Task<IActionResult> 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}" });
}
}
/// <summary>
/// Parse manually typed product text (no audio transcription needed)
/// </summary>
[HttpPost]
public async Task<IActionResult> 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
/// <summary>
/// Transcribe audio file using OpenAI Whisper
/// </summary>
private async Task<string> 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;
}
/// <summary>
/// Search for partners matching the transcribed text
/// Uses both string-based and AI semantic search for best results
/// </summary>
private async Task<List<object>> 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<object>();
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;
}
/// <summary>
/// Use AI to semantically match partner names based on company name
/// </summary>
private async Task<List<Nop.Core.Domain.Customers.Customer>> 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<Nop.Core.Domain.Customers.Customer>();
}
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<Nop.Core.Domain.Customers.Customer>();
}
var jsonText = jsonMatch.Value;
var indices = System.Text.Json.JsonSerializer.Deserialize<List<int>>(jsonText);
if (indices == null || indices.Count == 0)
{
Console.WriteLine("[VoiceOrder] AI returned no matches");
return new List<Nop.Core.Domain.Customers.Customer>();
}
// 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<Nop.Core.Domain.Customers.Customer>();
}
}
/// <summary>
/// Parse products and quantities from transcribed text using AI
/// </summary>
private async Task<List<ParsedProduct>> 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<ParsedProduct>();
}
var jsonText = jsonMatch.Value;
try
{
var parsedProducts = System.Text.Json.JsonSerializer.Deserialize<List<ParsedProduct>>(jsonText);
return parsedProducts ?? new List<ParsedProduct>();
}
catch (Exception ex)
{
Console.WriteLine($"[VoiceOrder] Error parsing JSON: {ex.Message}");
return new List<ParsedProduct>();
}
}
/// <summary>
/// Enrich parsed products with actual product data from database
/// Returns ALL matching products so admin can select the exact one
/// </summary>
private async Task<List<object>> EnrichProductData(List<ParsedProduct> parsedProducts)
{
var enrichedProducts = new List<object>();
// 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
}
}

View File

@ -60,17 +60,87 @@
</div>
</div>
<div class="form-group row" id="extractedTextSection" style="display:none;">
<div class="col-md-9">
<h5>Extracted Text:</h5>
<div class="card">
<!-- Shipping Document Section -->
<div class="form-group row" id="shippingDocumentSection" style="display:none;">
<div class="col-md-12">
<h4 class="mt-4"><i class="fas fa-shipping-fast"></i> Shipping Document Details</h4>
<!-- Document Info Card -->
<div class="card card-primary">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-file-alt"></i> Document Information</h5>
</div>
<div class="card-body">
<pre id="extractedText" style="white-space: pre-wrap; word-wrap: break-word; max-height: 500px; overflow-y: auto;"></pre>
<div class="row">
<div class="col-md-4">
<strong>Document ID:</strong>
<p id="documentIdNumber" class="text-muted">-</p>
</div>
<div class="col-md-4">
<strong>Partner ID:</strong>
<p id="partnerId" class="text-muted">-</p>
</div>
<div class="col-md-4">
<strong>Total Pallets:</strong>
<p id="totalPallets" class="text-muted">-</p>
</div>
</div>
<div class="row">
<div class="col-md-12">
<strong>PDF Filename:</strong>
<p id="pdfFileName" class="text-muted">-</p>
</div>
</div>
</div>
</div>
<!-- Shipping Items Table -->
<div class="card card-info mt-3">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-boxes"></i> Shipping Items (<span id="itemCount">0</span>)</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-striped" id="shippingItemsTable">
<thead class="thead-dark">
<tr>
<th>#</th>
<th>Name</th>
<th>Hungarian Name</th>
<th>Name on Document</th>
<th>Product ID</th>
<th>Pallets</th>
<th>Quantity</th>
<th>Net Weight (kg)</th>
<th>Gross Weight (kg)</th>
<th>Measurable</th>
</tr>
</thead>
<tbody id="shippingItemsBody">
<!-- Items will be populated here -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Extracted Text Card (Collapsible) -->
<div class="card card-secondary mt-3">
<div class="card-header" data-toggle="collapse" data-target="#extractedTextCollapse" style="cursor: pointer;">
<h5 class="mb-0">
<i class="fas fa-file-alt"></i> Raw Extracted Text
<i class="fas fa-chevron-down float-right"></i>
</h5>
</div>
<div id="extractedTextCollapse" class="collapse">
<div class="card-body">
<pre id="extractedText" style="white-space: pre-wrap; word-wrap: break-word; max-height: 400px; overflow-y: auto;"></pre>
<button type="button" id="copyButton" class="btn btn-secondary mt-2">
<i class="fas fa-copy"></i> Copy to Clipboard
</button>
</div>
</div>
</div>
<button type="button" id="copyButton" class="btn btn-secondary mt-2">
<i class="fas fa-copy"></i> Copy to Clipboard
</button>
</div>
</div>
</div>
@ -86,7 +156,7 @@
const pdfPreviewContainer = document.getElementById('pdfPreviewContainer');
const imagePreview = document.getElementById('imagePreview');
const pdfPreview = document.getElementById('pdfPreview');
const extractedTextSection = document.getElementById('extractedTextSection');
const shippingDocumentSection = document.getElementById('shippingDocumentSection');
const extractedText = document.getElementById('extractedText');
const copyButton = document.getElementById('copyButton');
const fileLabel = document.querySelector('.custom-file-label');
@ -131,7 +201,7 @@
uploadButton.disabled = true;
filePreviewSection.style.display = 'none';
}
extractedTextSection.style.display = 'none';
shippingDocumentSection.style.display = 'none';
});
uploadButton.addEventListener('click', async () => {
@ -174,10 +244,10 @@
: 'Text extracted successfully!';
showMessage(message, 'success');
// Display extracted text
if (result.extractedText) {
extractedText.textContent = result.extractedText;
extractedTextSection.style.display = 'block';
// Display shipping document data
if (result.shippingDocument) {
displayShippingDocument(result.shippingDocument);
document.getElementById('shippingDocumentSection').style.display = 'block';
}
} else {
showMessage('Error: ' + (result.message || 'Failed to extract text'), 'danger');
@ -211,6 +281,63 @@
});
});
function displayShippingDocument(shippingDoc) {
// Populate document information
document.getElementById('documentIdNumber').textContent = shippingDoc.documentIdNumber || 'N/A';
document.getElementById('partnerId').textContent = shippingDoc.partnerId || 'N/A';
document.getElementById('totalPallets').textContent = shippingDoc.totalPallets || '0';
document.getElementById('pdfFileName').textContent = shippingDoc.pdfFileName || 'N/A';
// Populate extracted text (collapsible section)
extractedText.textContent = shippingDoc.extractedText || 'No text extracted';
// Populate shipping items table
const tbody = document.getElementById('shippingItemsBody');
tbody.innerHTML = ''; // Clear existing rows
const items = shippingDoc.shippingItems || [];
document.getElementById('itemCount').textContent = items.length;
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted">No shipping items found</td></tr>';
} else {
items.forEach((item, index) => {
const row = document.createElement('tr');
// Add status class based on whether product is matched
if (!item.productId) {
row.classList.add('table-warning');
}
row.innerHTML = `
<td>${index + 1}</td>
<td>${escapeHtml(item.name || '-')}</td>
<td>${escapeHtml(item.hungarianName || '-')}</td>
<td>${escapeHtml(item.nameOnDocument || '-')}</td>
<td>${item.productId ? `<span class="badge badge-success">${item.productId}</span>` : '<span class="badge badge-warning">Not Matched</span>'}</td>
<td>${item.palletsOnDocument || '0'}</td>
<td>${item.quantityOnDocument || '0'}</td>
<td>${item.netWeightOnDocument ? item.netWeightOnDocument.toFixed(2) : '0.00'}</td>
<td>${item.grossWeightOnDocument ? item.grossWeightOnDocument.toFixed(2) : '0.00'}</td>
<td>${item.isMeasurable ? '<span class="badge badge-info">Yes</span>' : '<span class="badge badge-secondary">No</span>'}</td>
`;
tbody.appendChild(row);
});
}
}
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
function showMessage(message, type) {
responseMessage.textContent = message;
responseMessage.className = 'alert alert-' + type;

View File

@ -0,0 +1,458 @@
@{
Layout = "_AdminLayout";
ViewBag.PageTitle = "InnVoice Order Sync";
}
<div class="content-header clearfix">
<h1 class="float-left">
InnVoice Order Synchronization
</h1>
</div>
<section class="content">
<div class="container-fluid">
<!-- Date Range Selector Card -->
<div class="card card-default">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-calendar-alt"></i>
Select Date Range
</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="form-group">
<label for="startDate">Start Date</label>
<input type="date" class="form-control" id="startDate" />
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label for="endDate">End Date</label>
<input type="date" class="form-control" id="endDate" />
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>&nbsp;</label>
<div>
<button type="button" class="btn btn-primary" onclick="syncOrders()">
<i class="fas fa-sync"></i> Sync Orders
</button>
<button type="button" class="btn btn-info" onclick="syncLast30Days()">
<i class="fas fa-clock"></i> Last 30 Days
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Loading Indicator -->
<div id="loadingIndicator" class="card card-info" style="display: none;">
<div class="card-body text-center">
<i class="fas fa-spinner fa-spin fa-3x"></i>
<h4 class="mt-3">Synchronizing orders...</h4>
<p>This may take a few moments depending on the date range.</p>
</div>
</div>
<!-- Summary Card -->
<div id="summaryCard" class="card card-primary" style="display: none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-chart-bar"></i>
Sync Summary
</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="info-box bg-info">
<span class="info-box-icon"><i class="fas fa-shopping-cart"></i></span>
<div class="info-box-content">
<span class="info-box-text">InnVoice Orders</span>
<span class="info-box-number" id="totalInnVoiceOrders">0</span>
</div>
</div>
</div>
<div class="col-md-4">
<div class="info-box bg-success">
<span class="info-box-icon"><i class="fas fa-database"></i></span>
<div class="info-box-content">
<span class="info-box-text">NopCommerce Orders</span>
<span class="info-box-number" id="totalNopOrders">0</span>
</div>
</div>
</div>
<div class="col-md-4">
<div class="info-box bg-warning">
<span class="info-box-icon"><i class="fas fa-check-double"></i></span>
<div class="info-box-content">
<span class="info-box-text">In Both Systems</span>
<span class="info-box-number" id="inBothSystems">0</span>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="info-box bg-danger">
<span class="info-box-icon"><i class="fas fa-exclamation-triangle"></i></span>
<div class="info-box-content">
<span class="info-box-text">Only in InnVoice</span>
<span class="info-box-number" id="onlyInInnVoice">0</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="info-box bg-secondary">
<span class="info-box-icon"><i class="fas fa-question-circle"></i></span>
<div class="info-box-content">
<span class="info-box-text">Only in NopCommerce</span>
<span class="info-box-number" id="onlyInNopCommerce">0</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="info-box bg-dark">
<span class="info-box-icon"><i class="fas fa-upload"></i></span>
<div class="info-box-content">
<span class="info-box-text">Missing Uploads</span>
<span class="info-box-number" id="missingUploads">0</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="info-box bg-light text-dark">
<span class="info-box-icon bg-secondary"><i class="fas fa-trash"></i></span>
<div class="info-box-content">
<span class="info-box-text">Deleted/Cancelled</span>
<span class="info-box-number" id="deletedOrders">0</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Orders Only in InnVoice -->
<div id="innVoiceOnlyCard" class="card card-danger collapsed-card" style="display: none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-exclamation-triangle"></i>
Orders Only in InnVoice (<span id="innVoiceOnlyCount">0</span>)
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="card-body">
<table class="table table-bordered table-striped" id="innVoiceOnlyTable">
<thead>
<tr>
<th>Table ID</th>
<th>Tech ID</th>
<th>Customer Name</th>
<th>External Order #</th>
<th>Total</th>
<th>Order Date</th>
</tr>
</thead>
<tbody id="innVoiceOnlyBody">
</tbody>
</table>
</div>
</div>
<!-- Orders Only in NopCommerce -->
<div id="nopOnlyCard" class="card card-secondary collapsed-card" style="display: none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-question-circle"></i>
Orders Only in NopCommerce (<span id="nopOnlyCount">0</span>)
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="card-body">
<table class="table table-bordered table-striped" id="nopOnlyTable">
<thead>
<tr>
<th>Order ID</th>
<th>Table ID</th>
<th>Custom Order #</th>
<th>Total</th>
<th>Created On (UTC)</th>
</tr>
</thead>
<tbody id="nopOnlyBody">
</tbody>
</table>
</div>
</div>
<!-- Missing Uploads -->
<div id="missingUploadsCard" class="card card-dark collapsed-card" style="display: none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-upload"></i>
Missing Uploads (<span id="missingUploadsCount">0</span>)
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="card-body">
<p class="text-warning"><i class="fas fa-exclamation-circle"></i> These orders should have been uploaded to InnVoice but weren't.</p>
<table class="table table-bordered table-striped" id="missingUploadsTable">
<thead>
<tr>
<th>Order ID</th>
<th>Custom Order #</th>
<th>Status</th>
<th>Items</th>
<th>Total</th>
<th>Created On (UTC)</th>
</tr>
</thead>
<tbody id="missingUploadsBody">
</tbody>
</table>
</div>
</div>
<!-- Deleted/Cancelled Orders -->
<div id="deletedOrdersCard" class="card card-secondary collapsed-card" style="display: none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-trash"></i>
Deleted/Cancelled Orders (<span id="deletedOrdersCount">0</span>)
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="card-body">
<p class="text-muted"><i class="fas fa-info-circle"></i> These orders are deleted or cancelled and don't need to be uploaded.</p>
<table class="table table-bordered table-striped" id="deletedOrdersTable">
<thead>
<tr>
<th>Order ID</th>
<th>Custom Order #</th>
<th>Status</th>
<th>Items</th>
<th>Total</th>
<th>Created On (UTC)</th>
</tr>
</thead>
<tbody id="deletedOrdersBody">
</tbody>
</table>
</div>
</div>
</div>
</section>
<script>
// Set default dates
$(document).ready(function() {
var today = new Date();
var thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(today.getDate() - 30);
$('#endDate').val(today.toISOString().split('T')[0]);
$('#startDate').val(thirtyDaysAgo.toISOString().split('T')[0]);
});
function syncOrders() {
var startDate = $('#startDate').val();
var endDate = $('#endDate').val();
if (!startDate || !endDate) {
alert('Please select both start and end dates');
return;
}
performSync(startDate, endDate);
}
function syncLast30Days() {
var endDate = new Date();
var startDate = new Date();
startDate.setDate(endDate.getDate() - 30);
$('#startDate').val(startDate.toISOString().split('T')[0]);
$('#endDate').val(endDate.toISOString().split('T')[0]);
performSync(
startDate.toISOString().split('T')[0],
endDate.toISOString().split('T')[0]
);
}
function performSync(startDate, endDate) {
// Show loading
$('#loadingIndicator').show();
$('#summaryCard').hide();
$('#innVoiceOnlyCard').hide();
$('#nopOnlyCard').hide();
$('#missingUploadsCard').hide();
$('#deletedOrdersCard').hide();
$.ajax({
url: '@Url.Action("SyncOrders", "InnVoiceOrderSync")',
type: 'POST',
data: {
startDate: startDate,
endDate: endDate
},
success: function(response) {
$('#loadingIndicator').hide();
if (response.success) {
displayResults(response.data);
} else {
alert('Error: ' + response.message);
}
},
error: function(xhr, status, error) {
$('#loadingIndicator').hide();
alert('Error synchronizing orders: ' + error);
}
});
}
function getOrderStatusBadge(status) {
var badges = {
'Pending': '<span class="badge badge-warning">Pending</span>',
'Processing': '<span class="badge badge-info">Processing</span>',
'Complete': '<span class="badge badge-success">Complete</span>',
'Cancelled': '<span class="badge badge-danger">Cancelled</span>'
};
return badges[status] || '<span class="badge badge-secondary">' + status + '</span>';
}
function displayResults(data) {
// Update summary
$('#totalInnVoiceOrders').text(data.summary.totalInnVoiceOrders);
$('#totalNopOrders').text(data.summary.totalNopOrders);
$('#inBothSystems').text(data.summary.inBothSystems);
$('#onlyInInnVoice').text(data.summary.onlyInInnVoice);
$('#onlyInNopCommerce').text(data.summary.onlyInNopCommerce);
$('#missingUploads').text(data.summary.totalMissingUploads || 0);
$('#deletedOrders').text(data.summary.totalDeletedOrders || 0);
$('#summaryCard').show();
// Display InnVoice only orders
if (data.innVoiceOnly && data.innVoiceOnly.length > 0) {
$('#innVoiceOnlyCount').text(data.innVoiceOnly.length);
var tbody = $('#innVoiceOnlyBody');
tbody.empty();
data.innVoiceOnly.forEach(function(order) {
tbody.append(
'<tr>' +
'<td>' + (order.tableId || '-') + '</td>' +
'<td>' + (order.techId || '-') + '</td>' +
'<td>' + (order.customerName || '-') + '</td>' +
'<td>' + (order.externalOrderNumber || '-') + '</td>' +
'<td>' + (order.totalGross || 0).toFixed(2) + ' Ft</td>' +
'<td>' + (order.orderDate || '-') + '</td>' +
'</tr>'
);
});
$('#innVoiceOnlyCard').show();
}
// Display NopCommerce only orders
if (data.nopCommerceOnly && data.nopCommerceOnly.length > 0) {
$('#nopOnlyCount').text(data.nopCommerceOnly.length);
var tbody = $('#nopOnlyBody');
tbody.empty();
data.nopCommerceOnly.forEach(function(order) {
tbody.append(
'<tr>' +
'<td><a href="/Admin/Order/Edit/' + order.orderId + '" target="_blank">' + order.orderId + '</a></td>' +
'<td>' + (order.tableId || '-') + '</td>' +
'<td>' + (order.customOrderNumber || '-') + '</td>' +
'<td>' + (order.orderTotal || 0).toFixed(2) + '</td>' +
'<td>' + new Date(order.createdOn).toLocaleString() + '</td>' +
'</tr>'
);
});
$('#nopOnlyCard').show();
}
// Display Missing Uploads
if (data.missingUploads && data.missingUploads.length > 0) {
$('#missingUploadsCount').text(data.missingUploads.length);
var tbody = $('#missingUploadsBody');
tbody.empty();
data.missingUploads.forEach(function(order) {
tbody.append(
'<tr>' +
'<td><a href="/Admin/Order/Edit/' + order.orderId + '" target="_blank">' + order.orderId + '</a></td>' +
'<td>' + (order.customOrderNumber || '-') + '</td>' +
'<td>' + getOrderStatusBadge(order.orderStatus) + '</td>' +
'<td><span class="badge badge-success">' + (order.itemCount || 0) + '</span></td>' +
'<td>' + (order.orderTotal || 0).toFixed(2) + '</td>' +
'<td>' + new Date(order.createdOn).toLocaleString() + '</td>' +
'</tr>'
);
});
$('#missingUploadsCard').show();
}
// Display Deleted/Cancelled Orders
if (data.deletedOrders && data.deletedOrders.length > 0) {
$('#deletedOrdersCount').text(data.deletedOrders.length);
var tbody = $('#deletedOrdersBody');
tbody.empty();
data.deletedOrders.forEach(function(order) {
tbody.append(
'<tr>' +
'<td><a href="/Admin/Order/Edit/' + order.orderId + '" target="_blank">' + order.orderId + '</a></td>' +
'<td>' + (order.customOrderNumber || '-') + '</td>' +
'<td>' + getOrderStatusBadge(order.orderStatus) + '</td>' +
'<td><span class="badge badge-secondary">0</span></td>' +
'<td>' + (order.orderTotal || 0).toFixed(2) + '</td>' +
'<td>' + new Date(order.createdOn).toLocaleString() + '</td>' +
'</tr>'
);
});
$('#deletedOrdersCard').show();
}
}
</script>
<style>
.info-box-number {
font-size: 2rem;
font-weight: bold;
}
.info-box-text {
font-size: 0.9rem;
}
</style>

View File

@ -0,0 +1,1771 @@
@{
Layout = "_AdminLayout";
ViewBag.PageTitle = "Voice Order Creation";
}
<!-- Mobile-optimized Voice Order Creation -->
<div class="voice-order-mobile">
<!-- Fixed Header with Progress -->
<div class="mobile-header">
<div class="header-content">
<h1><i class="fas fa-microphone-alt"></i> Voice Order</h1>
<div class="progress-dots">
<div id="dot1" class="dot active"></div>
<div id="dot2" class="dot"></div>
</div>
</div>
</div>
<!-- Main Content Area (scrollable) -->
<div class="mobile-content">
<!-- Step 1: Partner Selection -->
<div id="step1" class="step-container active">
<!-- Step Title -->
<div class="step-title">
<span class="step-number">1</span>
<h2>Select Partner</h2>
</div>
<!-- Voice Button (Primary Action) -->
<div class="voice-button-container">
<button id="recordPartnerBtn" class="voice-btn">
<div class="pulse-ring"></div>
<i class="fas fa-microphone"></i>
</button>
<button id="stopPartnerBtn" class="voice-btn recording" style="display: none;">
<i class="fas fa-stop"></i>
</button>
<p class="voice-hint">Tap to speak partner name</p>
</div>
<!-- Recording Status -->
<div id="partnerRecordingStatus" class="status-indicator" style="display: none;">
<div class="spinner"></div>
<span id="partnerStatusText">Listening...</span>
</div>
<!-- OR Divider -->
<div class="divider-or">or</div>
<!-- Text Input (Secondary Option) -->
<div class="input-container">
<input type="text"
id="manualPartnerInput"
class="mobile-input"
placeholder="Type partner name..."
onkeypress="if(event.key === 'Enter') submitManualPartner()">
<button class="search-btn" onclick="submitManualPartner()">
<i class="fas fa-search"></i>
</button>
</div>
<!-- Transcription Result -->
<div id="partnerTranscribedCard" class="result-card" style="display: none;">
<div class="result-label">You said:</div>
<div id="partnerTranscribedText" class="result-text"></div>
</div>
<!-- Partner List -->
<div id="partnerMatchesCard" class="matches-container" style="display: none;">
<div class="matches-label">
<i class="fas fa-users"></i> Select partner:
</div>
<div id="partnerButtons" class="button-list"></div>
</div>
<!-- Selected Partner -->
<div id="selectedPartnerCard" class="selected-card" style="display: none;">
<div class="selected-header">
<i class="fas fa-check-circle"></i> Selected
</div>
<div class="selected-content">
<h3 id="selectedPartnerName"></h3>
<button id="changePartnerBtn" class="btn-text" onclick="resetPartnerSelection()">
<i class="fas fa-undo"></i> Change
</button>
</div>
<button id="proceedToProductsBtn" class="btn-primary-mobile">
Continue to Products <i class="fas fa-arrow-right"></i>
</button>
</div>
</div>
<!-- Step 2: Product Selection -->
<div id="step2" class="step-container" style="display: none;">
<!-- Partner Summary Bar -->
<div class="partner-summary">
<i class="fas fa-user-circle"></i>
<span id="partnerSummary"></span>
</div>
<!-- Step Title -->
<div class="step-title">
<span class="step-number">2</span>
<h2>Add Products</h2>
</div>
<!-- Voice Button -->
<div class="voice-button-container">
<button id="recordProductBtn" class="voice-btn">
<div class="pulse-ring"></div>
<i class="fas fa-microphone"></i>
</button>
<button id="stopProductBtn" class="voice-btn recording" style="display: none;">
<i class="fas fa-stop"></i>
</button>
<p class="voice-hint">Say: "Narancs 100, Alma 50"</p>
</div>
<!-- Recording Status -->
<div id="productRecordingStatus" class="status-indicator" style="display: none;">
<div class="spinner"></div>
<span id="productStatusText">Listening...</span>
</div>
<!-- OR Divider -->
<div class="divider-or">or</div>
<!-- Text Input -->
<div class="input-container">
<input type="text"
id="manualProductInput"
class="mobile-input"
placeholder="Type products: narancs 100, alma 50"
onkeypress="if(event.key === 'Enter') submitManualProducts()">
<button class="search-btn" onclick="submitManualProducts()">
<i class="fas fa-search"></i>
</button>
</div>
<!-- Transcription Result -->
<div id="productTranscribedCard" class="result-card" style="display: none;">
<div class="result-label">You said:</div>
<div id="productTranscribedText" class="result-text"></div>
</div>
<!-- Product Matches -->
<div id="productMatchesCard" class="matches-container" style="display: none;">
<div class="matches-label">
<i class="fas fa-boxes"></i> Tap to add:
</div>
<div id="productButtons" class="button-list"></div>
</div>
<!-- Order Items -->
<div id="orderItemsCard" class="order-summary" style="display: none;">
<div class="order-header">
<h3><i class="fas fa-shopping-cart"></i> Order Items</h3>
</div>
<div class="order-items" id="orderItemsList"></div>
<div class="order-total">
<span>Total:</span>
<strong id="orderTotalDisplay">0.00 Ft</strong>
</div>
<button id="addMoreBtn" class="btn-secondary-mobile" onclick="addMoreProducts()">
<i class="fas fa-plus"></i> Add More Products
</button>
<button id="finishOrderBtn" class="btn-primary-mobile" onclick="finishOrder()">
<i class="fas fa-check"></i> Create Order
</button>
</div>
</div>
<!-- Success Screen -->
<div id="successCard" class="step-container success-screen" style="display: none;">
<div class="success-icon">
<i class="fas fa-check-circle"></i>
</div>
<h2>Order Created!</h2>
<p class="success-order-id">Order #<span id="createdOrderId"></span></p>
<button id="viewOrderBtn" class="btn-primary-mobile">
<i class="fas fa-eye"></i> View Order
</button>
<button class="btn-text" onclick="resetWizard()">
<i class="fas fa-plus"></i> Create Another Order
</button>
</div>
</div>
</div>
<script>
// State management
let currentStep = 1;
let selectedPartnerId = null;
let selectedPartnerName = "";
let orderItems = [];
let orderTotal = 0;
let mediaRecorder = null;
let audioChunks = [];
$(document).ready(function() {
// Event listeners
$('#recordPartnerBtn').click(() => startRecording('partner'));
$('#stopPartnerBtn').click(() => stopRecording('partner'));
$('#recordProductBtn').click(() => startRecording('product'));
$('#stopProductBtn').click(() => stopRecording('product'));
$('#proceedToProductsBtn').click(proceedToStep2);
// Check microphone availability and permissions on load
checkMicrophoneAvailability();
});
async function checkMicrophoneAvailability() {
console.log('[VoiceOrder] Checking microphone availability...');
console.log('[VoiceOrder] Protocol:', window.location.protocol);
console.log('[VoiceOrder] Hostname:', window.location.hostname);
console.log('[VoiceOrder] Is secure context:', window.isSecureContext);
// Check if getUserMedia is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('[VoiceOrder] getUserMedia not supported');
showWarningBanner('Your browser does not support audio recording. Please use Chrome, Firefox, or Safari.');
return;
}
// Check if HTTPS (except localhost)
if (window.location.protocol !== 'https:' &&
window.location.hostname !== 'localhost' &&
window.location.hostname !== '127.0.0.1') {
console.error('[VoiceOrder] Not HTTPS');
showWarningBanner('Voice recording requires HTTPS. Please use a secure connection.');
return;
}
// Try to enumerate devices (doesn't require permission)
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(device => device.kind === 'audioinput');
console.log('[VoiceOrder] Audio input devices found:', audioInputs.length);
if (audioInputs.length === 0) {
showWarningBanner('No microphone detected. Please connect a microphone to use voice recording.');
}
} catch (error) {
console.error('[VoiceOrder] Error enumerating devices:', error);
}
// Check permissions API if available
if (navigator.permissions && navigator.permissions.query) {
try {
const permissionStatus = await navigator.permissions.query({ name: 'microphone' });
console.log('[VoiceOrder] Microphone permission status:', permissionStatus.state);
if (permissionStatus.state === 'denied') {
showWarningBanner('Microphone access was denied. Please enable it in your browser settings.');
}
// Listen for permission changes
permissionStatus.onchange = function() {
console.log('[VoiceOrder] Permission changed to:', this.state);
};
} catch (error) {
// Permissions API not supported or microphone permission not available
console.log('[VoiceOrder] Permissions API not available:', error.message);
}
}
}
function showWarningBanner(message) {
const banner = $(`
<div class="permission-warning">
<i class="fas fa-exclamation-triangle"></i>
<span>${message}</span>
</div>
`);
$('.mobile-content').prepend(banner);
}
async function startRecording(type) {
console.log('[VoiceOrder] ========== START RECORDING ATTEMPT ==========');
console.log('[VoiceOrder] Type:', type);
console.log('[VoiceOrder] Timestamp:', new Date().toISOString());
try {
// Check 1: Browser support
console.log('[VoiceOrder] Check 1: Browser support');
if (!navigator.mediaDevices) {
console.error('[VoiceOrder] FAIL: navigator.mediaDevices is undefined');
alert('Your browser does not support mediaDevices API. Browser: ' + navigator.userAgent);
return;
}
console.log('[VoiceOrder] PASS: navigator.mediaDevices exists');
if (!navigator.mediaDevices.getUserMedia) {
console.error('[VoiceOrder] FAIL: getUserMedia is undefined');
alert('Your browser does not support getUserMedia. Browser: ' + navigator.userAgent);
return;
}
console.log('[VoiceOrder] PASS: getUserMedia exists');
// Check 2: Security context
console.log('[VoiceOrder] Check 2: Security context');
console.log('[VoiceOrder] - Protocol:', window.location.protocol);
console.log('[VoiceOrder] - Hostname:', window.location.hostname);
console.log('[VoiceOrder] - IsSecureContext:', window.isSecureContext);
console.log('[VoiceOrder] - Full URL:', window.location.href);
// Check 3: Try simple audio constraint first
console.log('[VoiceOrder] Check 3: Requesting microphone with simple constraints');
console.log('[VoiceOrder] Calling getUserMedia({ audio: true })...');
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('[VoiceOrder] SUCCESS: Got media stream');
console.log('[VoiceOrder] Stream ID:', stream.id);
console.log('[VoiceOrder] Stream active:', stream.active);
console.log('[VoiceOrder] Audio tracks:', stream.getAudioTracks().length);
if (stream.getAudioTracks().length > 0) {
const track = stream.getAudioTracks()[0];
console.log('[VoiceOrder] Track label:', track.label);
console.log('[VoiceOrder] Track enabled:', track.enabled);
console.log('[VoiceOrder] Track muted:', track.muted);
console.log('[VoiceOrder] Track readyState:', track.readyState);
console.log('[VoiceOrder] Track settings:', track.getSettings());
}
} catch (getUserMediaError) {
console.error('[VoiceOrder] FAIL: getUserMedia threw error');
console.error('[VoiceOrder] Error name:', getUserMediaError.name);
console.error('[VoiceOrder] Error message:', getUserMediaError.message);
console.error('[VoiceOrder] Error stack:', getUserMediaError.stack);
throw getUserMediaError;
}
// Check 4: MediaRecorder support
console.log('[VoiceOrder] Check 4: MediaRecorder support');
if (!window.MediaRecorder) {
console.error('[VoiceOrder] FAIL: MediaRecorder not supported');
stream.getTracks().forEach(track => track.stop());
alert('MediaRecorder is not supported in your browser: ' + navigator.userAgent);
return;
}
console.log('[VoiceOrder] PASS: MediaRecorder exists');
// Check 5: Check supported MIME types
console.log('[VoiceOrder] Check 5: Checking MIME type support');
const mimeTypes = [
'audio/webm',
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/mp4',
'audio/mpeg'
];
let supportedMimeType = null;
for (const mimeType of mimeTypes) {
const isSupported = MediaRecorder.isTypeSupported(mimeType);
console.log('[VoiceOrder] - ' + mimeType + ':', isSupported);
if (isSupported && !supportedMimeType) {
supportedMimeType = mimeType;
}
}
if (!supportedMimeType) {
console.error('[VoiceOrder] FAIL: No supported MIME type found');
stream.getTracks().forEach(track => track.stop());
alert('No supported audio recording format found in your browser');
return;
}
console.log('[VoiceOrder] Using MIME type:', supportedMimeType);
// Check 6: Create MediaRecorder
console.log('[VoiceOrder] Check 6: Creating MediaRecorder');
try {
mediaRecorder = new MediaRecorder(stream, {
mimeType: supportedMimeType
});
console.log('[VoiceOrder] SUCCESS: MediaRecorder created');
console.log('[VoiceOrder] MediaRecorder state:', mediaRecorder.state);
} catch (recorderError) {
console.error('[VoiceOrder] FAIL: MediaRecorder constructor threw error');
console.error('[VoiceOrder] Error:', recorderError);
stream.getTracks().forEach(track => track.stop());
throw recorderError;
}
audioChunks = [];
mediaRecorder.addEventListener('dataavailable', event => {
console.log('[VoiceOrder] dataavailable event, size:', event.data.size);
audioChunks.push(event.data);
});
mediaRecorder.addEventListener('stop', () => {
console.log('[VoiceOrder] MediaRecorder stopped');
console.log('[VoiceOrder] Audio chunks:', audioChunks.length);
const audioBlob = new Blob(audioChunks, { type: supportedMimeType });
console.log('[VoiceOrder] Blob created, size:', audioBlob.size);
console.log('[VoiceOrder] Blob type:', audioBlob.type);
if (audioBlob.size === 0) {
console.error('[VoiceOrder] WARNING: Blob size is 0!');
alert('Recording failed: No audio data captured. Please check your microphone.');
resetRecordingUI(type);
return;
}
processAudio(audioBlob, type);
stream.getTracks().forEach(track => {
console.log('[VoiceOrder] Stopping track:', track.label);
track.stop();
});
});
mediaRecorder.addEventListener('error', event => {
console.error('[VoiceOrder] MediaRecorder error event:', event);
console.error('[VoiceOrder] Error:', event.error);
});
// Check 7: Start recording
console.log('[VoiceOrder] Check 7: Starting recording');
try {
mediaRecorder.start();
console.log('[VoiceOrder] SUCCESS: Recording started');
console.log('[VoiceOrder] MediaRecorder state after start:', mediaRecorder.state);
} catch (startError) {
console.error('[VoiceOrder] FAIL: start() threw error');
console.error('[VoiceOrder] Error:', startError);
stream.getTracks().forEach(track => track.stop());
throw startError;
}
// Update UI
if (type === 'partner') {
$('#recordPartnerBtn').hide();
$('#stopPartnerBtn').show();
showStatus('partnerRecordingStatus', 'Listening...');
} else {
$('#recordProductBtn').hide();
$('#stopProductBtn').show();
showStatus('productRecordingStatus', 'Listening...');
}
console.log('[VoiceOrder] ========== RECORDING STARTED SUCCESSFULLY ==========');
} catch (error) {
console.error('[VoiceOrder] ========== RECORDING FAILED ==========');
console.error('[VoiceOrder] Error name:', error.name);
console.error('[VoiceOrder] Error message:', error.message);
console.error('[VoiceOrder] Error stack:', error.stack);
console.error('[VoiceOrder] Full error object:', error);
let errorMessage = 'Recording failed: ';
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
errorMessage += 'Microphone permission was denied.\n\n';
errorMessage += 'Steps to fix:\n';
errorMessage += '1. Click the 🔒 or ⓘ icon in the address bar\n';
errorMessage += '2. Find "Microphone" in permissions\n';
errorMessage += '3. Change to "Allow"\n';
errorMessage += '4. Refresh the page\n\n';
errorMessage += 'Current permission status: Check browser console for details';
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
errorMessage += 'No microphone device found.\n\n';
errorMessage += 'Please check:\n';
errorMessage += '- Is a microphone connected?\n';
errorMessage += '- Are you using headphones with a mic?\n';
errorMessage += '- Try a different microphone';
} else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') {
errorMessage += 'Microphone is in use by another application.\n\n';
errorMessage += 'Please:\n';
errorMessage += '- Close other apps using the microphone\n';
errorMessage += '- Close other browser tabs using microphone\n';
errorMessage += '- Restart your browser';
} else if (error.name === 'OverconstrainedError') {
errorMessage += 'Audio recording constraints not supported.\n\n';
errorMessage += 'Try: Refresh the page and try again';
} else if (error.name === 'SecurityError') {
errorMessage += 'Security error accessing microphone.\n\n';
errorMessage += 'Check:\n';
errorMessage += '- Page must use HTTPS (currently: ' + window.location.protocol + ')\n';
errorMessage += '- Valid SSL certificate\n';
errorMessage += '- No mixed content (HTTP resources on HTTPS page)';
} else if (error.name === 'TypeError') {
errorMessage += 'Browser compatibility issue.\n\n';
errorMessage += 'Your browser: ' + navigator.userAgent + '\n\n';
errorMessage += 'Try: Update your browser to the latest version';
} else {
errorMessage += error.message || 'Unknown error\n\n';
errorMessage += 'Browser: ' + navigator.userAgent + '\n';
errorMessage += 'Error type: ' + error.name;
}
errorMessage += '\n\n** Check browser console (F12) for detailed technical information **';
alert(errorMessage);
resetRecordingUI(type);
}
}
function stopRecording(type) {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
showStatus(type === 'partner' ? 'partnerRecordingStatus' : 'productRecordingStatus', 'Processing...');
}
}
async function processAudio(audioBlob, type) {
const formData = new FormData();
formData.append('audioFile', audioBlob, 'recording.webm');
formData.append('__RequestVerificationToken', $('input[name="__RequestVerificationToken"]').val());
try {
const endpoint = type === 'partner'
? '@Url.Action("TranscribeForPartner", "VoiceOrder")'
: '@Url.Action("TranscribeForProducts", "VoiceOrder")';
const response = await fetch(endpoint, {
method: 'POST',
body: formData
});
const result = await response.json();
resetRecordingUI(type);
if (result.success) {
if (type === 'partner') {
handlePartnerTranscription(result);
} else {
handleProductTranscription(result);
}
} else {
alert('Error: ' + result.message);
}
} catch (error) {
resetRecordingUI(type);
console.error('Error processing audio:', error);
alert('Error processing audio: ' + error.message);
}
}
function handlePartnerTranscription(result) {
$('#partnerTranscribedText').text(result.transcription);
$('#partnerTranscribedCard').show();
displayPartnerMatches(result.partners);
}
function displayPartnerMatches(partners) {
const container = $('#partnerButtons');
container.empty();
if (partners.length === 0) {
container.append('<p class="no-results">No partners found</p>');
$('#partnerMatchesCard').show();
return;
}
partners.forEach(partner => {
const btn = $('<button>')
.addClass('list-item-btn')
.html(`
<div class="item-content">
<i class="fas fa-building"></i>
<span>${partner.label}</span>
</div>
<i class="fas fa-chevron-right"></i>
`)
.click(() => selectPartner(partner));
container.append(btn);
});
$('#partnerMatchesCard').show();
}
function selectPartner(partner) {
selectedPartnerId = partner.value;
selectedPartnerName = partner.label;
$('#selectedPartnerName').text(partner.label);
$('#partnerMatchesCard').hide();
$('#selectedPartnerCard').show();
}
function resetPartnerSelection() {
selectedPartnerId = null;
selectedPartnerName = "";
$('#partnerTranscribedCard').hide();
$('#partnerMatchesCard').hide();
$('#selectedPartnerCard').hide();
$('#recordPartnerBtn').show();
$('#manualPartnerInput').val('');
}
async function submitManualPartner() {
const text = $('#manualPartnerInput').val().trim();
if (!text) {
alert('Please enter a partner name');
return;
}
showStatus('partnerRecordingStatus', 'Searching...');
try {
const response = await fetch('@Url.Action("SearchPartnerByText", "VoiceOrder")', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
},
body: new URLSearchParams({
text: text,
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
})
});
const result = await response.json();
$('#partnerRecordingStatus').hide();
if (result.success) {
handlePartnerTranscription(result);
} else {
alert('Error: ' + result.message);
}
} catch (error) {
$('#partnerRecordingStatus').hide();
console.error('Error searching partners:', error);
alert('Error: ' + error.message);
}
}
function proceedToStep2() {
currentStep = 2;
// Update progress dots
$('#dot1').removeClass('active').addClass('completed');
$('#dot2').addClass('active');
// Update UI
$('#step1').removeClass('active').hide();
$('#step2').addClass('active').show();
$('#partnerSummary').text(selectedPartnerName);
// Scroll to top
$('.mobile-content').scrollTop(0);
}
function handleProductTranscription(result) {
$('#productTranscribedText').text(result.transcription);
$('#productTranscribedCard').show();
displayProductMatches(result.products);
}
function displayProductMatches(products) {
const container = $('#productButtons');
container.empty();
if (products.length === 0) {
container.append('<p class="no-results">No products found</p>');
$('#productMatchesCard').show();
return;
}
// Group by search term
const grouped = {};
products.forEach(p => {
const term = p.searchTerm || 'other';
if (!grouped[term]) grouped[term] = [];
grouped[term].push(p);
});
Object.keys(grouped).forEach(term => {
if (Object.keys(grouped).length > 1) {
container.append(`<div class="group-label">${term}</div>`);
}
grouped[term].forEach(product => {
const hasStockWarning = product.isQuantityReduced;
const warningBadge = hasStockWarning
? `<span class="stock-warning-badge">⚠️ Only ${product.quantity} available (you said ${product.requestedQuantity})</span>`
: '';
const productCard = $('<div>')
.addClass('product-card-wrapper')
.addClass(hasStockWarning ? 'has-warning' : '');
const productButton = $('<button>')
.addClass('product-item-btn')
.addClass(hasStockWarning ? 'stock-warning' : '')
.attr('type', 'button')
.html(`
<div class="product-info">
<div class="product-name">
<i class="fas fa-box"></i>
<strong>${product.name}</strong>
</div>
${warningBadge}
<div class="product-details">
<span class="qty-badge ${hasStockWarning ? 'qty-reduced' : ''}">${product.quantity}</span>
<span class="stock ${product.stockQuantity < 50 ? 'stock-low' : ''}">Stock: ${product.stockQuantity}</span>
</div>
</div>
<i class="fas fa-plus-circle"></i>
`);
const priceEditor = $('<div>')
.addClass('price-editor')
.html(`
<label class="price-label">
<i class="fas fa-tag"></i> Price:
</label>
<input type="number"
class="price-input"
value="${product.price.toFixed(0)}"
min="0"
step="1"
data-product-id="${product.id}"
onclick="event.stopPropagation()">
<span class="price-unit">Ft</span>
`);
productButton.click(() => {
const customPrice = parseFloat(priceEditor.find('.price-input').val()) || product.price;
addProductToOrder({...product, price: customPrice});
});
productCard.append(productButton);
productCard.append(priceEditor);
container.append(productCard);
});
});
$('#productMatchesCard').show();
}
function addProductToOrder(product) {
orderItems.push({
id: product.id,
name: product.name,
quantity: product.quantity,
price: product.price
});
updateOrderItemsDisplay();
// Hide product matches after adding
$('#productMatchesCard').hide();
$('#productTranscribedCard').hide();
$('#recordProductBtn').show();
$('#manualProductInput').val('');
}
function updateOrderItemsDisplay() {
const container = $('#orderItemsList');
container.empty();
orderTotal = 0;
orderItems.forEach((item, index) => {
const itemTotal = item.quantity * item.price;
orderTotal += itemTotal;
const itemEl = $(`
<div class="order-item">
<div class="order-item-header">
<span class="item-name">${item.name}</span>
<button class="btn-icon-delete" onclick="removeItem(${index})">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="order-item-details">
<input type="number"
class="qty-input"
value="${item.quantity}"
min="1"
onchange="updateQuantity(${index}, this.value)">
<span class="item-price">${item.price.toFixed(0)} Ft/unit</span>
<strong class="item-total">${itemTotal.toFixed(0)} Ft</strong>
</div>
</div>
`);
container.append(itemEl);
});
$('#orderTotalDisplay').text(orderTotal.toFixed(0) + ' Ft');
$('#orderItemsCard').show();
}
window.updateQuantity = function(index, newQuantity) {
orderItems[index].quantity = parseInt(newQuantity);
updateOrderItemsDisplay();
};
window.removeItem = function(index) {
orderItems.splice(index, 1);
updateOrderItemsDisplay();
if (orderItems.length === 0) {
$('#orderItemsCard').hide();
}
};
function addMoreProducts() {
$('#productTranscribedCard').hide();
$('#productMatchesCard').hide();
$('#recordProductBtn').show();
$('#manualProductInput').val('');
}
async function submitManualProducts() {
const text = $('#manualProductInput').val().trim();
if (!text) {
alert('Please enter some products');
return;
}
showStatus('productRecordingStatus', 'Processing...');
try {
const response = await fetch('@Url.Action("ParseManualProductText", "VoiceOrder")', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
},
body: new URLSearchParams({
text: text,
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
})
});
const result = await response.json();
$('#productRecordingStatus').hide();
if (result.success) {
handleProductTranscription(result);
} else {
alert('Error: ' + result.message);
}
} catch (error) {
$('#productRecordingStatus').hide();
console.error('Error:', error);
alert('Error: ' + error.message);
}
}
async function finishOrder() {
if (!selectedPartnerId || orderItems.length === 0) {
alert('Please select a partner and add products');
return;
}
$('#finishOrderBtn').prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Creating...');
try {
const orderProductsJson = JSON.stringify(orderItems);
const response = await fetch('@Url.Action("Create", "CustomOrder")', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
},
body: new URLSearchParams({
customerId: selectedPartnerId,
orderProductsJson: orderProductsJson,
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
})
});
if (response.redirected) {
const url = new URL(response.url);
const orderId = url.searchParams.get('id');
if (orderId) {
showSuccess(orderId);
} else {
window.location.href = response.url;
}
} else {
alert('Error creating order');
$('#finishOrderBtn').prop('disabled', false).html('<i class="fas fa-check"></i> Create Order');
}
} catch (error) {
console.error('Error:', error);
alert('Error: ' + error.message);
$('#finishOrderBtn').prop('disabled', false).html('<i class="fas fa-check"></i> Create Order');
}
}
function showSuccess(orderId) {
$('#step2').hide();
$('#successCard').show();
$('#createdOrderId').text(orderId);
$('#viewOrderBtn').attr('href', '@Url.Action("Edit", "Order")?id=' + orderId);
$('.mobile-content').scrollTop(0);
}
function resetWizard() {
currentStep = 1;
selectedPartnerId = null;
selectedPartnerName = "";
orderItems = [];
orderTotal = 0;
$('#dot1').removeClass('completed').addClass('active');
$('#dot2').removeClass('active completed');
$('#successCard').hide();
$('#step2').hide();
$('#step1').show().addClass('active');
$('#partnerTranscribedCard').hide();
$('#partnerMatchesCard').hide();
$('#selectedPartnerCard').hide();
$('#productTranscribedCard').hide();
$('#productMatchesCard').hide();
$('#orderItemsCard').hide();
$('#recordPartnerBtn').show();
$('#manualPartnerInput').val('');
$('#manualProductInput').val('');
$('.mobile-content').scrollTop(0);
}
function showStatus(elementId, message) {
$(`#${elementId}`).find('span').text(message);
$(`#${elementId}`).show();
}
function resetRecordingUI(type) {
if (type === 'partner') {
$('#partnerRecordingStatus').hide();
$('#recordPartnerBtn').show();
$('#stopPartnerBtn').hide();
} else {
$('#productRecordingStatus').hide();
$('#recordProductBtn').show();
$('#stopProductBtn').hide();
}
}
</script>
<style>
/* Mobile-First Reset */
.voice-order-mobile {
/* position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0; */
background: #f5f7fa;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Fixed Header */
.mobile-header {
/* position: fixed;
top: 0;
left: 0;
right: 0; */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 100;
}
.header-content h1 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 600;
}
/* Progress Dots */
.progress-dots {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255,255,255,0.3);
transition: all 0.3s;
}
.dot.active {
width: 24px;
border-radius: 4px;
background: white;
}
.dot.completed {
background: #4ade80;
}
/* Scrollable Content */
.mobile-content {
/* position: absolute;
top: 90px;
left: 0;
right: 0;
bottom: 0; */
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 1rem;
}
/* Step Container */
.step-container {
display: none;
animation: slideIn 0.3s ease-out;
}
.step-container.active {
display: block;
}
@@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Step Title */
.step-title {
text-align: center;
margin-bottom: 2rem;
}
.step-number {
display: inline-block;
width: 32px;
height: 32px;
line-height: 32px;
background: #667eea;
color: white;
border-radius: 50%;
font-weight: 600;
margin-bottom: 0.5rem;
}
.step-title h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0.5rem 0 0 0;
}
/* Voice Button (Hero Element) */
.voice-button-container {
text-align: center;
margin: 2rem 0;
}
.voice-btn {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 2.5rem;
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
cursor: pointer;
transition: all 0.3s;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.voice-btn:active {
transform: scale(0.95);
}
.voice-btn.recording {
background: #ef4444;
animation: pulse 1.5s infinite;
}
@@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
}
50% {
box-shadow: 0 0 0 20px rgba(239, 68, 68, 0);
}
}
.pulse-ring {
position: absolute;
width: 140px;
height: 140px;
border-radius: 50%;
border: 3px solid #667eea;
opacity: 0;
animation: pulseRing 2s infinite;
}
@@keyframes pulseRing {
0% {
transform: scale(0.9);
opacity: 0.5;
}
100% {
transform: scale(1.3);
opacity: 0;
}
}
.voice-hint {
margin-top: 1rem;
color: #6b7280;
font-size: 0.9rem;
}
/* Status Indicator */
.status-indicator {
text-align: center;
padding: 1rem;
background: white;
border-radius: 12px;
margin: 1rem 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #e5e7eb;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 0.5rem;
vertical-align: middle;
}
@@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* OR Divider */
.divider-or {
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
margin: 1.5rem 0;
position: relative;
}
.divider-or:before,
.divider-or:after {
content: '';
position: absolute;
top: 50%;
width: 40%;
height: 1px;
background: #e5e7eb;
}
.divider-or:before {
left: 0;
}
.divider-or:after {
right: 0;
}
/* Input Container */
.input-container {
display: flex;
gap: 0.5rem;
margin: 1rem 0 2rem 0;
}
.mobile-input {
flex: 1;
padding: 1rem;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 1rem;
transition: border-color 0.2s;
}
.mobile-input:focus {
outline: none;
border-color: #667eea;
}
.search-btn {
width: 50px;
height: 50px;
border: none;
border-radius: 12px;
background: #667eea;
color: white;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.2s;
}
.search-btn:active {
transform: scale(0.95);
}
/* Result Card */
.result-card {
background: white;
border-radius: 12px;
padding: 1rem;
margin: 1rem 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.result-label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
font-weight: 600;
margin-bottom: 0.5rem;
}
.result-text {
font-size: 1.125rem;
color: #1f2937;
}
/* Matches Container */
.matches-container {
margin: 1.5rem 0;
}
.matches-label {
font-size: 0.875rem;
color: #6b7280;
font-weight: 600;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.button-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Product Card Wrapper */
.product-card-wrapper {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.product-card-wrapper.has-warning {
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.2);
}
/* List Item Button */
.list-item-btn {
width: 100%;
padding: 1rem;
border: 2px solid #e5e7eb;
border-radius: 12px;
background: white;
text-align: left;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
}
.list-item-btn:active {
transform: scale(0.98);
border-color: #667eea;
}
.item-content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.item-content i {
color: #667eea;
font-size: 1.25rem;
}
.item-content span {
font-size: 0.95rem;
color: #1f2937;
}
/* Product Item Button */
.product-item-btn {
width: 100%;
padding: 1rem;
border: 2px solid #e5e7eb;
border-radius: 12px 12px 0 0;
background: white;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: none;
}
.product-item-btn.stock-warning {
border-color: #f59e0b;
background: #fffbeb;
}
.product-item-btn:active {
transform: scale(0.98);
}
.product-card-wrapper:hover .product-item-btn {
background: #f9fafb;
}
.product-card-wrapper.has-warning:hover .product-item-btn {
background: #fef3c7;
}
/* Price Editor */
.price-editor {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border: 2px solid #e5e7eb;
border-top: 1px solid #e5e7eb;
border-radius: 0 0 12px 12px;
}
.product-card-wrapper.has-warning .price-editor {
background: #fffbeb;
border-color: #f59e0b;
}
.price-label {
font-size: 0.875rem;
color: #6b7280;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.25rem;
margin: 0;
}
.price-label i {
color: #667eea;
}
.price-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
color: #1f2937;
background: white;
text-align: right;
max-width: 120px;
transition: border-color 0.2s;
}
.price-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.price-input::-webkit-inner-spin-button,
.price-input::-webkit-outer-spin-button {
opacity: 1;
}
.price-unit {
font-size: 0.875rem;
color: #6b7280;
font-weight: 600;
}
.stock-warning-badge {
display: block;
background: #fef3c7;
color: #92400e;
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
margin: 0.5rem 0;
border: 1px solid #f59e0b;
}
.product-info {
flex: 1;
text-align: left;
}
.product-name {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.product-name i {
color: #667eea;
}
.product-name strong {
font-size: 1rem;
color: #1f2937;
}
.product-details {
display: flex;
gap: 0.75rem;
font-size: 0.875rem;
color: #6b7280;
}
.qty-badge {
background: #667eea;
color: white;
padding: 0.125rem 0.5rem;
border-radius: 6px;
font-weight: 600;
}
.qty-badge.qty-reduced {
background: #f59e0b;
animation: pulse-warning 2s infinite;
}
@@keyframes pulse-warning {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.price {
font-weight: 600;
color: #1f2937;
}
.stock {
color: #10b981;
}
.stock.stock-low {
color: #f59e0b;
font-weight: 600;
}
.group-label {
font-size: 0.75rem;
color: #667eea;
font-weight: 600;
text-transform: uppercase;
margin: 1rem 0 0.5rem 0;
}
/* Selected Card */
.selected-card {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
border-radius: 16px;
padding: 1.5rem;
margin: 1.5rem 0;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.selected-header {
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
margin-bottom: 0.5rem;
opacity: 0.9;
}
.selected-content h3 {
font-size: 1.25rem;
margin: 0 0 1rem 0;
}
.btn-text {
background: transparent;
border: none;
color: white;
font-size: 0.875rem;
cursor: pointer;
margin-bottom: 1rem;
text-decoration: underline;
}
/* Primary Button */
.btn-primary-mobile {
width: 100%;
padding: 1rem;
border: none;
border-radius: 12px;
background: #667eea;
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
margin-top: 1rem;
}
.btn-primary-mobile:active {
transform: scale(0.98);
}
.btn-primary-mobile:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Secondary Button */
.btn-secondary-mobile {
width: 100%;
padding: 1rem;
border: 2px solid #e5e7eb;
border-radius: 12px;
background: white;
color: #667eea;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 1rem;
}
.btn-secondary-mobile:active {
transform: scale(0.98);
}
/* Partner Summary Bar */
.partner-summary {
background: white;
padding: 0.75rem 1rem;
border-radius: 12px;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: #1f2937;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.partner-summary i {
color: #667eea;
}
/* Order Summary */
.order-summary {
background: white;
border-radius: 16px;
padding: 1.5rem;
margin: 2rem 0;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.order-header {
margin-bottom: 1rem;
}
.order-header h3 {
font-size: 1.25rem;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.order-items {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.order-item {
background: #f9fafb;
border-radius: 12px;
padding: 1rem;
}
.order-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.item-name {
font-weight: 600;
color: #1f2937;
}
.btn-icon-delete {
background: transparent;
border: none;
color: #ef4444;
cursor: pointer;
padding: 0.25rem;
}
.order-item-details {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.qty-input {
width: 60px;
padding: 0.5rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
text-align: center;
font-weight: 600;
}
.item-price {
color: #6b7280;
}
.item-total {
margin-left: auto;
color: #1f2937;
font-size: 1rem;
}
.order-total {
display: flex;
justify-content: space-between;
padding: 1rem 0;
border-top: 2px solid #e5e7eb;
font-size: 1.25rem;
font-weight: 600;
}
/* Success Screen */
.success-screen {
text-align: center;
padding: 2rem 0;
}
.success-icon {
font-size: 5rem;
color: #10b981;
margin: 2rem 0;
animation: scaleIn 0.5s ease-out;
}
@@keyframes scaleIn {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.success-screen h2 {
font-size: 2rem;
margin: 1rem 0;
color: #1f2937;
}
.success-order-id {
font-size: 1.125rem;
color: #6b7280;
margin-bottom: 2rem;
}
.no-results {
text-align: center;
color: #6b7280;
padding: 2rem;
}
/* Permission Warning Banner */
.permission-warning {
background: #fef3c7;
border: 2px solid #f59e0b;
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
color: #92400e;
font-size: 0.9rem;
}
.permission-warning i {
font-size: 1.5rem;
color: #f59e0b;
flex-shrink: 0;
}
/* Responsive adjustments */
@@media (max-width: 375px) {
.voice-btn {
width: 100px;
height: 100px;
font-size: 2rem;
}
}
/* Prevent zoom on input focus (iOS) */
@@media screen and (max-width: 768px) {
input, select, textarea {
font-size: 16px;
}
}
</style>
@Html.AntiForgeryToken()

View File

@ -365,6 +365,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
_logger.Detail($"AddShippingDocument invoked; id: {shippingDocument.Id}");
await ctx.ShippingDocuments.InsertAsync(shippingDocument);
foreach (var item in shippingDocument.ShippingItems)
{
var product = await ctx.Products.GetByIdAsync(item.ProductId);
product.ProductCost = Convert.ToDecimal(item.UnitPriceOnDocument);
}
return await ctx.ShippingDocuments.GetByIdAsync(shippingDocument.Id, shippingDocument.Shipping != null || shippingDocument.Partner != null);
}

View File

@ -179,6 +179,9 @@
<None Update="Areas\Admin\Views\Extras\VoiceRecorder.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\InnVoiceOrderSync\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Order\FileUploadGridComponent.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@ -218,6 +221,9 @@
<None Update="Areas\Admin\Views\Shipping\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\VoiceOrder\Create.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\_FruitBankAdminLayout.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

View File

@ -29,6 +29,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
return response;
}
/// <summary>
/// OSOLETED
/// </summary>
/// <param name="pdfText"></param>
/// <param name="userQuestion"></param>
/// <returns></returns>
public async Task<string> GetOpenAIPDFAnalysisFromText(string pdfText, string userQuestion)
{
var systemMessage = $"You are a pdf analyzis assistant of FRUITBANK, the user is asking you questions about a PDF document, that you have access to. The content of the PDF document is the following: {pdfText}";
@ -41,5 +47,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
return fixedResponse;
}
//public async Task<string> ExtractProducts(string extractedText)
//{
// //analyze document for product references
// return await _openAIApiService.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}");
//}
}
}

View File

@ -78,6 +78,31 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
return responses?.FirstOrDefault() ?? new OrderCreateResponse();
}
/// <summary>
/// Get orders modified since a specific timestamp
/// Format: YYYYMMDDHHmmss (year, month, day, hour, minute, second)
/// Recommended for tracking changes
/// Rate limit: Full queries or queries older than current month limited to 10 times per 30 days
/// Recommended: Only use current month dates
/// </summary>
public async Task<List<InnVoiceOrder>> GetOrdersByUpdateTimeAsync(DateTime updateTime)
{
var timeStr = updateTime.ToString("yyyyMMddHHmmss");
var url = $"{_baseUrl}/{_companyName}/order/updatedtime/{timeStr}";
var result = await GetOrdersFromUrlAsync(url);
return result;
}
public async Task<List<InnVoiceOrder>> GetOrdersByOrderDateAsync(DateTime orderDate)
{
var timeStr = orderDate.ToString("yyyy.MM.dd."); // Format: éééé.hh.nn.
var url = $"{_baseUrl}/{_companyName}/order/megrendeleskelte/{timeStr}";
Console.WriteLine($"DEBUG InnVoiceOrderService: Calling URL: {url}");
var result = await GetOrdersFromUrlAsync(url);
return result;
}
private string BuildOrdersXml(List<OrderCreateRequest> orders)
{
var ordersElement = new XElement("orders");

View File

@ -4,6 +4,7 @@ using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using AyCode.Utils.Extensions;
using Nop.Plugin.Misc.FruitBank.Controllers;
#nullable enable
namespace Nop.Plugin.Misc.FruitBankPlugin.Services
@ -52,8 +53,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
/// <param name="audioStream">The audio file stream</param>
/// <param name="fileName">The original filename (used to determine format)</param>
/// <param name="language">Optional language code (e.g., "en", "hu"). If null, auto-detects.</param>
/// <param name="customPrompt">Optional vocabulary hints to help Whisper recognize specific words/names</param>
/// <returns>The transcribed text</returns>
public async Task<string?> TranscribeAudioAsync(Stream audioStream, string fileName, string? language = null)
public async Task<string?> TranscribeAudioAsync(Stream audioStream, string fileName, string? language = null, string? customPrompt = null)
{
try
{
@ -87,6 +89,22 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
form.Add(new StringContent(language), "language");
}
// Add prompt with vocabulary hints for better transcription
// Use custom prompt if provided, otherwise use default produce vocabulary
var prompt = customPrompt ??
"Gyümölcsök és zöldségek: narancs, mandarin, citrom, alma, körte, szőlő, datolya, áfonya, " +
"paprika, fokhagyma, hagyma, paradicsom, uborka, saláta, menta, bazsalikom, " +
"mennyiségek: rekesz, láda, kilogram, darab";
// Truncate prompt if too long (Whisper has a limit)
if (prompt.Length > 224)
{
prompt = prompt.Substring(0, 224);
Console.WriteLine($"[Whisper] Prompt truncated to 224 characters");
}
form.Add(new StringContent(prompt), "prompt");
// Optional: Add response format (json is default, can also be text, srt, verbose_json, or vtt)
form.Add(new StringContent("json"), "response_format");
@ -792,13 +810,39 @@ 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 ?? "You have 4 tasks: \r \n \n" +
"TASK 1: Extract all text from this image, without modifying or translating it. \n \n" +
"TASK 2: Determine the document identifier number, like orderconfirmation number, or invoice number, or such, that seems to identify the document. \n \n" +
"TASK 3: Extract partner information (name and tax id number). IMPORTANT: You work for Fruitbank, so Fruitbank is not a requested partner, extract the company information of the party OTHER THAN Fruitbank. \n \n" +
"TASK 4: Extract product information. \n \n" +
"Perform the tasks carefully, and format your response as JSON object named 'extractedData' with the following fields in the child objects: " +
"'fullText' (string - the unmodified full text), " +
"'documentId' (string)," +
"'partner' (an object with fields 'name and 'taxId'')" +
"'products' (array of objects with the following fields: " +
"'name' (string), " +
"'quantity' (int - the number of cartons, boxes or packages), " +
"'netWeight' (double - the net kilograms), " +
"'grossWeight' (double - the gross kilograms)," +
"'unitCost (double - the unit price of the product on the document)'.\r \n \n" +
"";
string systemPrompt = "You are an AI assistant of FRUITBANK that extracts text and structured data from images. " +
"Carefully analyze the image content to extract all relevant information accurately. " +
"Provide the extracted data in a well-formatted JSON structure as specified.";
var payload = new
{
model = GetModelName(), // Use the configured model
messages = new object[]
{
new {
role = "system",
content = new object[]
{
new { type = "text", text = prompt },
}
},
new {
role = "user",
content = new object[]
@ -851,4 +895,24 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
#endregion
}
public class OpenaiImageResponse
{
public ExtractedData extractedData { get; set; } = new ExtractedData(); // Changed to property with { get; set; }
}
public class ExtractedData
{
public string? fullText { get; set; } = string.Empty;
public string? documentId { get; set; } = string.Empty;
public PartnerInfo? partner { get; set; } = new PartnerInfo();
public List<ProductReference>? products { get; set; } = new List<ProductReference>();
}
public class PartnerInfo
{
public string? name { get; set; } = string.Empty;
public string? taxId { get; set; } = string.Empty;
}
}