AI document upload latest approach, innvoice order sync
This commit is contained in:
parent
b5ef6caa39
commit
eb40643d62
|
|
@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Helpers;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Services.Catalog;
|
||||
using Nop.Services.Security;
|
||||
|
|
@ -23,6 +24,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
{
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly OpenAIApiService _aiApiService;
|
||||
private readonly AICalculationService _aiCalculationService;
|
||||
private readonly IProductService _productService;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly PdfToImageService _pdfToImageService;
|
||||
|
|
@ -30,12 +32,14 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
public FileManagerController(
|
||||
IPermissionService permissionService,
|
||||
OpenAIApiService aiApiService,
|
||||
AICalculationService aiCalculationService,
|
||||
IProductService productService,
|
||||
FruitBankDbContext fruitBankDbContext,
|
||||
PdfToImageService pdfToImageService)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
_aiApiService = aiApiService;
|
||||
_aiCalculationService = aiCalculationService;
|
||||
_productService = productService;
|
||||
_dbContext = fruitBankDbContext;
|
||||
_pdfToImageService = pdfToImageService;
|
||||
|
|
@ -74,6 +78,9 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
return Json(new { success = false, message = "Invalid file type. Please upload JPG, PNG, GIF, WebP, or PDF." });
|
||||
}
|
||||
|
||||
ShippingDocument shippingDocument = new ShippingDocument();
|
||||
shippingDocument.ShippingItems = new List<ShippingItem>();
|
||||
|
||||
try
|
||||
{
|
||||
// Define the uploads folder
|
||||
|
|
@ -151,76 +158,101 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
message = "Failed to extract text. The API may have returned an empty response."
|
||||
});
|
||||
}
|
||||
OpenaiImageResponse deserializedContent = new();
|
||||
|
||||
string analysisResult = await ExtractDocumentId(extractedText);
|
||||
var result = TextHelper.FixJsonWithoutAI(extractedText);
|
||||
|
||||
Console.WriteLine($"Document number analysis Result: {analysisResult}");
|
||||
var options = new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true, // Handles camelCase/PascalCase mismatches
|
||||
IncludeFields = true // This allows deserializing fields (in case you keep it as a field)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
deserializedContent = System.Text.Json.JsonSerializer.Deserialize<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);
|
||||
await DeterminePartner(partnerAnalysis);
|
||||
string productAnalysis = await ExtractProducts(extractedText);
|
||||
Console.WriteLine($"Product analysis Result: {productAnalysis}");
|
||||
|
||||
//int? dbPartnerName = await DeterminePartner(deserializedContent.extractedData.partner.name);
|
||||
int? dbPartnerName = await DeterminePartner(partnerAnalysis);
|
||||
if (dbPartnerName != null)
|
||||
{
|
||||
shippingDocument.PartnerId = (int)dbPartnerName;
|
||||
Console.WriteLine($"Determined Partner ID: {dbPartnerName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("No matching partner found in the database.");
|
||||
}
|
||||
|
||||
//string productAnalysis = await _aiCalculationService.ExtractProducts(extractedText);
|
||||
Console.WriteLine($"Product analysis Result: {deserializedContent.extractedData.products}");
|
||||
|
||||
//identify products from database
|
||||
var allProducts = await _dbContext.ProductDtos.GetAll().ToListAsync();
|
||||
var historicalProducts = await _dbContext.ShippingItems.GetAll().ToListAsync();
|
||||
|
||||
//create json from product analyzis jsonstring
|
||||
ProductReferenceResponse deserializedProducts = System.Text.Json.JsonSerializer.Deserialize<ProductReferenceResponse>(productAnalysis);
|
||||
Console.WriteLine($"Serealized Products: {deserializedProducts.products.Count}");
|
||||
ProductReferenceResponse deserializedProducts = new ProductReferenceResponse();
|
||||
//deserializedProducts.products = new List<ProductReference>();
|
||||
deserializedProducts.products = deserializedContent.extractedData.products;
|
||||
Console.WriteLine($"Serialized Products: {deserializedProducts.products.Count}");
|
||||
|
||||
List<ProductDto> matchedProducts = new List<ProductDto>();
|
||||
List<ShippingItem> matchedProducts = new List<ShippingItem>();
|
||||
//do we have historical references?
|
||||
foreach (var deserializedProduct in deserializedProducts.products)
|
||||
{
|
||||
var historicalProduct = historicalProducts.Where(hp => !string.IsNullOrEmpty(hp.NameOnDocument))
|
||||
.FirstOrDefault(p => p.NameOnDocument.Equals(deserializedProduct.name, StringComparison.OrdinalIgnoreCase));
|
||||
if (historicalProduct != null)
|
||||
{
|
||||
Console.WriteLine($"Historical product found: {historicalProduct.Name}");
|
||||
var productDto = allProducts.FirstOrDefault(p => p.Id == historicalProduct.ProductId);
|
||||
if (productDto != null)
|
||||
{
|
||||
matchedProducts.Add(productDto);
|
||||
Console.WriteLine($"Matched product from historical data: {productDto.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"Total matched products from historical data: {matchedProducts.Count}");
|
||||
if (matchedProducts.Count == 0)
|
||||
{
|
||||
//let's filter similar names first
|
||||
var similarNameProducts = new List<ShippingItem>();
|
||||
foreach (var deserializedProduct in deserializedProducts.products)
|
||||
{
|
||||
//var similarProducts = historicalProducts.Where(hp => hp.ShippingDocument.Partner.Name == partnerAnalysis)
|
||||
var similarProducts = historicalProducts
|
||||
.Where(p => !string.IsNullOrEmpty(p.NameOnDocument) &&
|
||||
p.NameOnDocument.Contains(deserializedProduct.name.Substring(0,6), StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
similarNameProducts.AddRange(similarProducts);
|
||||
Console.WriteLine($"Similar products found for {deserializedProduct.name}: {similarProducts.Count}");
|
||||
}
|
||||
matchedProducts = await DetermineProducts(allProducts, historicalProducts, deserializedProducts);
|
||||
|
||||
shippingDocument.ShippingItems = matchedProducts;
|
||||
|
||||
//no exact name matches, try to match with AI
|
||||
foreach (var deserializedProduct in deserializedProducts.products)
|
||||
if (matchedProducts.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"Matched Products Count: {matchedProducts.Count}");
|
||||
foreach (var matchedProduct in matchedProducts)
|
||||
{
|
||||
var aiMatchPrompt = $"You are an agent of Fruitbank to analyize product names and match them to existing products in the Fruitbank product catalog. " +
|
||||
$"Given the following product catalog: {string.Join(", ", similarNameProducts.Select(p => p.NameOnDocument))}, " +
|
||||
$"which product from the catalog best matches this product name: {deserializedProduct.name}. " +
|
||||
$"Reply with NOTHING ELSE THAN the exact product name from the catalog, if no match found, reply with 'NONE'.";
|
||||
var aiMatchedProductName = await _aiApiService.GetSimpleResponseAsync(aiMatchPrompt, deserializedProduct.name);
|
||||
var productDto = allProducts.FirstOrDefault(p => p.Name.Equals(aiMatchedProductName, StringComparison.OrdinalIgnoreCase));
|
||||
if (productDto != null)
|
||||
{
|
||||
matchedProducts.Add(productDto);
|
||||
Console.WriteLine($"AI Matched product: {productDto.Name}");
|
||||
}
|
||||
Console.WriteLine($"Matched Product: {matchedProduct.Name}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("No products matched from the database.");
|
||||
}
|
||||
|
||||
string fullresult = await TestFullResult(extractedText);
|
||||
Console.WriteLine($"Full structured JSON Result: {fullresult}");
|
||||
shippingDocument.PdfFileName = processedFileName;
|
||||
shippingDocument.ShippingDocumentToFiles = new List<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
|
||||
{
|
||||
|
|
@ -228,7 +260,26 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
message = extension == ".pdf"
|
||||
? "PDF converted and text extracted successfully"
|
||||
: "Text extracted successfully",
|
||||
extractedText = extractedText + "/n" + fullresult,
|
||||
shippingDocument = new
|
||||
{
|
||||
documentIdNumber = shippingDocument.DocumentIdNumber,
|
||||
partnerId = shippingDocument.PartnerId,
|
||||
pdfFileName = shippingDocument.PdfFileName,
|
||||
totalPallets = shippingDocument.TotalPallets,
|
||||
shippingItems = shippingDocument.ShippingItems?.Select(item => new
|
||||
{
|
||||
name = item.Name,
|
||||
hungarianName = item.HungarianName,
|
||||
nameOnDocument = item.NameOnDocument,
|
||||
productId = item.ProductId,
|
||||
palletsOnDocument = item.PalletsOnDocument,
|
||||
quantityOnDocument = item.QuantityOnDocument,
|
||||
netWeightOnDocument = item.NetWeightOnDocument,
|
||||
grossWeightOnDocument = item.GrossWeightOnDocument,
|
||||
isMeasurable = item.IsMeasurable
|
||||
}).ToList(),
|
||||
extractedText = deserializedContent.extractedData.fullText
|
||||
},
|
||||
fileName = processedFileName,
|
||||
filePath = processedFilePath,
|
||||
fileSize = imageFile.Length,
|
||||
|
|
@ -246,6 +297,293 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
private async Task<List<ShippingItem>> DetermineProducts(List<ProductDto> allProducts, List<ShippingItem> historicalProducts, ProductReferenceResponse deserializedProducts)
|
||||
{
|
||||
List<ShippingItem> finalMatchedProducts = new List<ShippingItem>();
|
||||
|
||||
// Load all shipping items once
|
||||
var allShippingItems = await _dbContext.ShippingItems.GetAll(true).ToListAsync();
|
||||
|
||||
foreach (var deserializedProduct in deserializedProducts.products)
|
||||
{
|
||||
ShippingItem matchedItem = null;
|
||||
|
||||
// Step 1: Try exact historical match
|
||||
var historicalProduct = historicalProducts
|
||||
.Where(hp => !string.IsNullOrEmpty(hp.NameOnDocument))
|
||||
.FirstOrDefault(p => p.NameOnDocument.Equals(deserializedProduct.name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (historicalProduct != null)
|
||||
{
|
||||
Console.WriteLine($"Historical product found: {historicalProduct.Name}");
|
||||
var productDto = await _dbContext.ProductDtos.GetByIdAsync(historicalProduct.ProductId);
|
||||
|
||||
if (productDto != null)
|
||||
{
|
||||
matchedItem = CreateShippingItem(productDto, deserializedProduct);
|
||||
Console.WriteLine($"Matched product from historical data: {productDto.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: If no exact match, try AI matching with similar names
|
||||
if (matchedItem == null)
|
||||
{
|
||||
// Get similar products (safe substring)
|
||||
int substringLength = Math.Min(6, deserializedProduct.name?.Length ?? 0);
|
||||
|
||||
if (substringLength > 0)
|
||||
{
|
||||
var similarNameProducts = historicalProducts
|
||||
.Where(p => !string.IsNullOrEmpty(p.NameOnDocument) &&
|
||||
p.NameOnDocument.Contains(deserializedProduct.name.Substring(0, substringLength), StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"Similar products found for {deserializedProduct.name}: {similarNameProducts.Count}");
|
||||
|
||||
// Try AI match with similar historical products
|
||||
if (similarNameProducts.Any())
|
||||
{
|
||||
//var aiMatchPrompt = $"You are an agent of Fruitbank to analyze product names and match them to existing products in the Fruitbank product catalog. " +
|
||||
// $"Given the following product catalog: {string.Join(", ", similarNameProducts.Select(p => p.NameOnDocument))}, " +
|
||||
// $"which product from the catalog best matches this product name: {deserializedProduct.name}. " +
|
||||
// $"Reply with NOTHING ELSE THAN the exact product name from the catalog, if no match found, reply with 'NONE'.";
|
||||
|
||||
var systemPrompt = "You are a product name matching specialist for FruitBank, a wholesale fruit and vegetable company.\n\n" +
|
||||
"Your task: Match a product name from a shipping document to the most similar product in our historical catalog.\n\n" +
|
||||
"MATCHING RULES:\n" +
|
||||
"1. Match based on ALL details including:\n" +
|
||||
" - Product type (apples, bananas, oranges)\n" +
|
||||
" - Variety (Golden Delicious, Cavendish, Valencia)\n" +
|
||||
" - Quality grade (Class I, Class II, Extra, Premium)\n" +
|
||||
" - Size markers (60+, 70+, 80+, Large, Small)\n" +
|
||||
" - Packaging type if mentioned (Carton, Box, Loose)\n" +
|
||||
"2. Consider language variations:\n" +
|
||||
" - Spanish: Manzanas = Apples, Plátanos = Bananas, Naranjas = Oranges\n" +
|
||||
" - Hungarian: Alma = Apples, Banán = Bananas, Narancs = Oranges\n" +
|
||||
" - Plural/singular: 'Bananas' = 'Banana'\n" +
|
||||
"3. Match as specifically as possible:\n" +
|
||||
" - 'APPLES CLASS I 70+' should match 'APPLES CLASS I 70+' (not just 'APPLES')\n" +
|
||||
" - 'ORANGES 60+' is different from 'ORANGES 70+'\n" +
|
||||
" - 'TOMATOES EXTRA' is different from 'TOMATOES CLASS I'\n" +
|
||||
"4. Abbreviations to recognize:\n" +
|
||||
" - 'GOLDEN DEL' = 'GOLDEN DELICIOUS'\n" +
|
||||
" - 'CAT I' = 'CLASS I' = 'CATEGORY I'\n" +
|
||||
" - 'CAT II' = 'CLASS II' = 'CATEGORY II'\n" +
|
||||
" - 'BIO' = 'ORGANIC'\n\n" +
|
||||
"OUTPUT:\n" +
|
||||
"Return ONLY the exact product name from the catalog that best matches ALL the details.\n" +
|
||||
"If no good match exists (less than 70% similarity including grade/size), return 'NONE'.\n\n" +
|
||||
"Examples:\n" +
|
||||
"Document: 'GOLDEN DEL APPLES CAT I 70+' | Catalog: ['GOLDEN DELICIOUS APPLES CLASS I 70+', 'GOLDEN DELICIOUS APPLES CLASS II 70+'] → GOLDEN DELICIOUS APPLES CLASS I 70+\n" +
|
||||
"Document: 'PLATANOS CAVENDISH 70+' | Catalog: ['BANANAS CAVENDISH 60+', 'BANANAS CAVENDISH 70+', 'BANANAS CAVENDISH 80+'] → BANANAS CAVENDISH 70+\n" +
|
||||
"Document: 'MANZANAS ROJAS EXTRA' | Catalog: ['RED APPLES CLASS I', 'RED APPLES EXTRA', 'RED APPLES CLASS II'] → RED APPLES EXTRA\n" +
|
||||
"Document: 'SWEET PEPPERS' | Catalog: ['TOMATOES', 'CUCUMBERS', 'CARROTS'] → NONE";
|
||||
|
||||
var userPrompt = "HISTORICAL PRODUCT CATALOG:\n" +
|
||||
string.Join("\n", similarNameProducts.Select(p => $"- {p.NameOnDocument}")) + "\n\n" +
|
||||
"---\n\n" +
|
||||
"PRODUCT NAME FROM DOCUMENT:\n" +
|
||||
deserializedProduct.name + "\n\n" +
|
||||
"Return the best matching product name from the catalog above (matching ALL details including size/grade), or 'NONE' if no good match exists.";
|
||||
|
||||
var aiMatchedProductName = await _aiApiService.GetSimpleResponseAsync(systemPrompt, userPrompt);
|
||||
|
||||
//var aiMatchedProductName = await _aiApiService.GetSimpleResponseAsync(aiMatchPrompt, deserializedProduct.name);
|
||||
Console.WriteLine($"AI matched product name for {deserializedProduct.name}: {aiMatchedProductName}");
|
||||
|
||||
if (!string.IsNullOrEmpty(aiMatchedProductName) && aiMatchedProductName != "NONE")
|
||||
{
|
||||
var matchingShippingItem = allShippingItems.FirstOrDefault(x =>
|
||||
x.NameOnDocument != null &&
|
||||
x.NameOnDocument.Equals(aiMatchedProductName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingShippingItem?.ProductDto != null)
|
||||
{
|
||||
matchedItem = CreateShippingItem(matchingShippingItem.ProductDto, deserializedProduct);
|
||||
Console.WriteLine($"AI Matched product from historical: {matchingShippingItem.ProductDto.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: If still no match, try AI with full product catalog
|
||||
if (matchedItem == null)
|
||||
{
|
||||
// HYBRID APPROACH: Combine recent products + fuzzy matched products
|
||||
|
||||
// Get recent products (50 newest - most likely to be in current shipments)
|
||||
var recentProducts = allProducts
|
||||
.OrderByDescending(p => p.Id) // Use Id as proxy for CreatedDate if CreatedOnUtc not available
|
||||
.Take(50)
|
||||
.ToList();
|
||||
|
||||
// Get products that fuzzy match the search term (similar names)
|
||||
int fuzzySearchLength = Math.Min(4, deserializedProduct.name?.Length ?? 0);
|
||||
var fuzzyMatches = fuzzySearchLength > 0
|
||||
? allProducts
|
||||
.Where(p => p.Name.Contains(
|
||||
deserializedProduct.name.Substring(0, fuzzySearchLength),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.Take(30)
|
||||
.ToList()
|
||||
: new List<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
|
||||
};
|
||||
}
|
||||
|
||||
/// <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 " +
|
||||
|
|
@ -327,34 +665,226 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
return fullresult;
|
||||
}
|
||||
|
||||
private async Task<string> ExtractProducts(string extractedText)
|
||||
private async Task<int> DeterminePartner(string partnerAnalysis)
|
||||
{
|
||||
//analyze document for product references
|
||||
return await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted from a pdf document, and find any product references mentioned in the document. Do not translate or modify the product information. Format your response as JSON object named 'products' with the following fields in the child objects: 'name' (string), 'quantity' (int), 'netWeight' (double), 'grossWeight' (double), ", $"What product references are mentioned in this document: {extractedText}");
|
||||
}
|
||||
// Clean the input first
|
||||
partnerAnalysis = CleanPartnerName(partnerAnalysis);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(partnerAnalysis))
|
||||
{
|
||||
Console.WriteLine("Partner analysis is empty after cleaning.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async Task DeterminePartner(string partnerAnalysis)
|
||||
{
|
||||
//analyze the text to match partner
|
||||
var possiblePartners = await _dbContext.Partners.GetAll().ToListAsync();
|
||||
var partnerNames = string.Join(", ", possiblePartners.Select(p => p.Name));
|
||||
var partnerMatchAnalysis = await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted from a pdf document, and match the partner information to existing partners of Fruitbank. If you find a match, you reply with NOTHING ELSE THAN the parter's name, if not, reply with NOTHING ELSE THAN 'NONE'.", $"Given the following possible partners: {partnerNames}, which partner matches this partner information: {partnerAnalysis}");
|
||||
Console.WriteLine($"Partner match analysis Result: {partnerMatchAnalysis}");
|
||||
|
||||
// STEP 1: Try exact match first (fast, free, no AI needed!)
|
||||
var exactMatch = possiblePartners.FirstOrDefault(p =>
|
||||
p.Name.Trim().Equals(partnerAnalysis.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (exactMatch != null)
|
||||
{
|
||||
Console.WriteLine($"✓ Exact partner match found: {exactMatch.Name} (ID: {exactMatch.Id})");
|
||||
return exactMatch.Id;
|
||||
}
|
||||
|
||||
Console.WriteLine($"No exact match found for '{partnerAnalysis}'. Trying AI matching...");
|
||||
|
||||
// STEP 2: No exact match? Use AI with IDs (handles fuzzy matching)
|
||||
var partnerListWithIds = string.Join("\n", possiblePartners.Select(p => $"ID: {p.Id} | Name: {p.Name}"));
|
||||
|
||||
var systemPrompt = "You are a partner matching specialist for FruitBank.\n\n" +
|
||||
"Your task: Match a partner name to the correct partner from our database.\n\n" +
|
||||
"MATCHING RULES:\n" +
|
||||
"1. Ignore minor differences:\n" +
|
||||
" - Trailing/leading spaces\n" +
|
||||
" - Periods and punctuation\n" +
|
||||
" - Case differences (B.V. vs BV vs b.v.)\n" +
|
||||
" - Legal entity suffixes (B.V., S.L., S.R.L., Kft., Ltd.)\n" +
|
||||
"2. Match based on core company name\n" +
|
||||
"3. Be flexible with abbreviations\n\n" +
|
||||
"OUTPUT:\n" +
|
||||
"Return ONLY the numeric ID of the matching partner.\n" +
|
||||
"If no match found, return '0'.\n\n" +
|
||||
"Examples:\n" +
|
||||
"Input: 'SFI Rotterdam' | Database: 'ID: 42 | Name: SFI Rotterdam B.V.' → 42\n" +
|
||||
"Input: 'Frutas Sanchez SL' | Database: 'ID: 15 | Name: FRUTAS SÁNCHEZ S.L.' → 15\n" +
|
||||
"Input: 'Van den Berg' | Database: 'ID: 8 | Name: Van den Berg B.V.' → 8\n" +
|
||||
"Input: 'Unknown Company' | No match in database → 0";
|
||||
|
||||
var userPrompt = "PARTNER DATABASE:\n" +
|
||||
partnerListWithIds + "\n\n" +
|
||||
"---\n\n" +
|
||||
"PARTNER TO MATCH:\n" +
|
||||
partnerAnalysis + "\n\n" +
|
||||
"Return ONLY the numeric ID of the matching partner, or '0' if no match found.";
|
||||
|
||||
var aiResponse = await _aiApiService.GetSimpleResponseAsync(systemPrompt, userPrompt);
|
||||
Console.WriteLine($"AI Partner Match Response: {aiResponse}");
|
||||
|
||||
// Parse the ID
|
||||
if (int.TryParse(aiResponse.Trim(), out int partnerId))
|
||||
{
|
||||
if (partnerId == 0)
|
||||
{
|
||||
Console.WriteLine("AI found no matching partner.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Verify the ID exists in our list
|
||||
var matchedPartner = possiblePartners.FirstOrDefault(p => p.Id == partnerId);
|
||||
if (matchedPartner != null)
|
||||
{
|
||||
Console.WriteLine($"✓ AI matched partner: {matchedPartner.Name} (ID: {matchedPartner.Id})");
|
||||
return partnerId;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"⚠ AI returned invalid partner ID: {partnerId}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"⚠ AI returned non-numeric response: {aiResponse}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ExtractPartnerName(string extractedText)
|
||||
{
|
||||
//analyze the text to find partner information
|
||||
var partnerAnalysis = await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted from a pdf document, and find the partner information of the other party, so the party that is NOT FRUITBANK.", $"What is the partner information of this document: {extractedText}");
|
||||
Console.WriteLine($"Partner analysis Result: {partnerAnalysis}");
|
||||
return partnerAnalysis;
|
||||
var availablePartners = await _dbContext.Partners.GetAll().ToListAsync();
|
||||
Console.WriteLine($"Available partners count: {availablePartners.Count}");
|
||||
|
||||
string partnerListForAI = "";
|
||||
foreach (var partner in availablePartners)
|
||||
{
|
||||
//let's make a string list of available partners for AI
|
||||
|
||||
partnerListForAI += $"- {partner.Name}\n";
|
||||
|
||||
}
|
||||
|
||||
// Enhanced system prompt with explicit instructions and examples
|
||||
var systemPrompt = "You are a specialized data extraction agent for FruitBank, a Hungarian fruit and vegetable wholesale company.\n\n" +
|
||||
"Your task: Extract the SUPPLIER/SENDER company name from shipping documents (CMR, delivery notes, invoices).\n\n" +
|
||||
"CRITICAL RULES:\n" +
|
||||
"1. FruitBank (Gyümölcsbank Kft.) is the RECEIVER - NEVER return FruitBank as the partner\n" +
|
||||
"2. Look for these indicators of the SUPPLIER:\n" +
|
||||
" - 'Sender' / 'Expediteur' / 'Feladó' / 'Absender' section\n" +
|
||||
" - 'From' / 'De' / 'Kitől' field\n" +
|
||||
" - Company name at TOP of document (usually sender)\n" +
|
||||
" - Tax ID / VAT number paired with company name\n" +
|
||||
" - EORI number holder (if present)\n" +
|
||||
"3. The supplier is typically:\n" +
|
||||
" - A farm, cooperative, or wholesaler\n" +
|
||||
" - Located in Spain, Italy, Netherlands, Poland, Germany, Greece, Turkey, or other EU countries\n" +
|
||||
" - NOT FruitBank and NOT the transport company\n\n" +
|
||||
"Document structure hints:\n" +
|
||||
"- CMR documents: Sender is box 1-2, Receiver is box 3-4\n" +
|
||||
"- Invoices: Look for 'Seller' / 'Eladó' / 'Vendedor' (NOT Buyer)\n" +
|
||||
"- Delivery notes: Sender/Origin section at top\n\n" +
|
||||
"OUTPUT FORMAT:\n" +
|
||||
"Return ONLY the exact company name as it appears in the document.\n" +
|
||||
"Do not include:\n" +
|
||||
"- Tax IDs\n" +
|
||||
"- Addresses\n" +
|
||||
"- Country codes\n" +
|
||||
"- Legal entity types (unless part of official name)\n\n" +
|
||||
"Examples:\n" +
|
||||
"[CORRECT] FRUTAS SÁNCHEZ S.L.\n" +
|
||||
"[CORRECT] Van den Berg B.V.\n" +
|
||||
"[CORRECT] Agricola Romana SRL\n" +
|
||||
"[WRONG] FruitBank (this is us!)\n" +
|
||||
"[WRONG] DHL Supply Chain (transport company)\n" +
|
||||
"[WRONG] FRUTAS SÁNCHEZ S.L. - ES12345678 (no tax ID)";
|
||||
|
||||
// Enhanced user prompt with context and structure
|
||||
var userPrompt = "DOCUMENT TEXT:\n" +
|
||||
extractedText + "\n\n" +
|
||||
"---\n\n" +
|
||||
"INSTRUCTIONS:\n" +
|
||||
"1. Identify the SENDER/SUPPLIER company name\n" +
|
||||
"2. Ignore FruitBank (Gyümölcsbank) - that's the receiver\n" +
|
||||
"3. Ignore transport companies (DHL, Transporeon, etc.)\n" +
|
||||
"4. Return ONLY the company name, nothing else\n\n" +
|
||||
"If uncertain, return the most prominent non-FruitBank company name from the document.";
|
||||
|
||||
var partnerAnalysis = await _aiApiService.GetSimpleResponseAsync(systemPrompt, userPrompt);
|
||||
|
||||
// Clean up the response
|
||||
var cleanedPartnerName = CleanPartnerName(partnerAnalysis);
|
||||
|
||||
Console.WriteLine($"Partner analysis Result: {cleanedPartnerName}");
|
||||
return cleanedPartnerName;
|
||||
}
|
||||
|
||||
private async Task<string> ExtractDocumentId(string extractedText)
|
||||
/// <summary>
|
||||
/// Cleans and normalizes partner name from AI response
|
||||
/// </summary>
|
||||
private string CleanPartnerName(string rawPartnerName)
|
||||
{
|
||||
//analyze the text for document number or identifiers
|
||||
return await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted frem a pfd document, and find the document number or identifier. IMPORTANT: reply only with the number, do not add further explanation.", $"What is the document identifier of this document: {extractedText}");
|
||||
if (string.IsNullOrWhiteSpace(rawPartnerName))
|
||||
return string.Empty;
|
||||
|
||||
var cleaned = rawPartnerName.Trim();
|
||||
|
||||
// Remove common prefixes that AI might add
|
||||
var prefixesToRemove = new[]
|
||||
{
|
||||
"Company name:",
|
||||
"Sender:",
|
||||
"Supplier:",
|
||||
"Partner:",
|
||||
"The partner is",
|
||||
"The company is",
|
||||
"Feladó:",
|
||||
"Expediteur:"
|
||||
};
|
||||
|
||||
foreach (var prefix in prefixesToRemove)
|
||||
{
|
||||
if (cleaned.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
cleaned = cleaned.Substring(prefix.Length).Trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove quotes if present
|
||||
cleaned = cleaned.Trim('\"', '\'', '\'', '«', '»');
|
||||
|
||||
// Remove trailing punctuation
|
||||
cleaned = cleaned.TrimEnd('.', ',', ';');
|
||||
|
||||
// Remove tax IDs that might have slipped through (pattern: letters followed by 8+ digits)
|
||||
var taxIdPattern = new System.Text.RegularExpressions.Regex(@"\s*-?\s*[A-Z]{2}\d{8,}.*$");
|
||||
cleaned = taxIdPattern.Replace(cleaned, string.Empty).Trim();
|
||||
|
||||
// If AI returned 'NONE' or similar, return empty
|
||||
if (cleaned.Equals("NONE", StringComparison.OrdinalIgnoreCase) ||
|
||||
cleaned.Equals("N/A", StringComparison.OrdinalIgnoreCase) ||
|
||||
cleaned.Equals("NOT FOUND", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Check if accidentally returned FruitBank
|
||||
if (cleaned.Contains("FruitBank", StringComparison.OrdinalIgnoreCase) ||
|
||||
cleaned.Contains("Gyümölcsbank", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine($"WARNING: AI returned FruitBank as partner. Returning empty.");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
//private async Task<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
|
||||
|
|
@ -363,6 +893,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
public int? quantity { get; set; }
|
||||
public double? netWeight { get; set; }
|
||||
public double? grossWeight { get; set; }
|
||||
public int? productId { get; set; }
|
||||
}
|
||||
|
||||
public class ProductReferenceResponse
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,486 @@
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Services.Catalog;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Services.Security;
|
||||
using Nop.Web.Framework;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
using Nop.Web.Framework.Mvc.Filters;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
||||
{
|
||||
[AuthorizeAdmin]
|
||||
[Area(AreaNames.ADMIN)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class VoiceOrderController : BasePluginController
|
||||
{
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly OpenAIApiService _aiApiService;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly IProductService _productService;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
|
||||
public VoiceOrderController(
|
||||
IPermissionService permissionService,
|
||||
OpenAIApiService aiApiService,
|
||||
ICustomerService customerService,
|
||||
IProductService productService,
|
||||
FruitBankDbContext dbContext)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
_aiApiService = aiApiService;
|
||||
_customerService = customerService;
|
||||
_productService = productService;
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
// Transcribe audio
|
||||
var transcribedText = await TranscribeAudioFile(audioFile, "hu");
|
||||
|
||||
if (string.IsNullOrEmpty(transcribedText))
|
||||
{
|
||||
return Json(new { success = false, message = "Failed to transcribe audio" });
|
||||
}
|
||||
|
||||
Console.WriteLine($"[VoiceOrder] Partner transcription: {transcribedText}");
|
||||
|
||||
// Search for matching partners
|
||||
var partners = await SearchPartners(transcribedText);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
transcription = transcribedText,
|
||||
partners = partners
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[VoiceOrder] Error in TranscribeForPartner: {ex.Message}");
|
||||
return Json(new { success = false, message = $"Error: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
var transcribedText = await TranscribeAudioFile(audioFile, "hu");
|
||||
|
||||
if (string.IsNullOrEmpty(transcribedText))
|
||||
{
|
||||
return Json(new { success = false, message = "Failed to transcribe audio" });
|
||||
}
|
||||
|
||||
Console.WriteLine($"[VoiceOrder] Product transcription: {transcribedText}");
|
||||
|
||||
// Parse products and quantities using AI
|
||||
var parsedProducts = await ParseProductsFromText(transcribedText);
|
||||
|
||||
if (parsedProducts == null || parsedProducts.Count == 0)
|
||||
{
|
||||
return Json(new
|
||||
{
|
||||
success = false,
|
||||
message = "Could not parse products from transcription",
|
||||
transcription = transcribedText
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich with actual product data from database
|
||||
var enrichedProducts = await EnrichProductData(parsedProducts);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
transcription = transcribedText,
|
||||
products = enrichedProducts
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[VoiceOrder] Error in TranscribeForProducts: {ex.Message}");
|
||||
return Json(new { success = false, message = $"Error: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Transcribe audio file using OpenAI Whisper
|
||||
/// </summary>
|
||||
private async Task<string> TranscribeAudioFile(IFormFile audioFile, string language)
|
||||
{
|
||||
var fileName = $"voice_order_{DateTime.Now:yyyyMMdd_HHmmss}.webm";
|
||||
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "voice");
|
||||
|
||||
if (!Directory.Exists(uploadsFolder))
|
||||
{
|
||||
Directory.CreateDirectory(uploadsFolder);
|
||||
}
|
||||
|
||||
var filePath = Path.Combine(uploadsFolder, fileName);
|
||||
|
||||
// Save file temporarily
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await audioFile.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
// Transcribe using OpenAI Whisper
|
||||
string transcribedText;
|
||||
using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
|
||||
{
|
||||
transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language);
|
||||
}
|
||||
|
||||
// Clean up temporary file
|
||||
try
|
||||
{
|
||||
System.IO.File.Delete(filePath);
|
||||
}
|
||||
catch { /* Ignore cleanup errors */ }
|
||||
|
||||
return transcribedText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search for partners matching the transcribed text
|
||||
/// Uses string-based search first, then semantic AI matching if needed
|
||||
/// </summary>
|
||||
private async Task<List<object>> SearchPartners(string searchTerm)
|
||||
{
|
||||
const int maxResults = 10;
|
||||
const int minResultsForAI = 3; // If we get fewer than this, use AI semantic search
|
||||
|
||||
Console.WriteLine($"[VoiceOrder] Searching partners for: {searchTerm}");
|
||||
|
||||
// Step 1: Try string-based search
|
||||
var customersByCompany = await _customerService.GetAllCustomersAsync(
|
||||
company: searchTerm,
|
||||
pageIndex: 0,
|
||||
pageSize: maxResults);
|
||||
|
||||
var customersByName = await _customerService.GetAllCustomersAsync(
|
||||
firstName: searchTerm,
|
||||
pageIndex: 0,
|
||||
pageSize: maxResults);
|
||||
|
||||
var customersByLastName = await _customerService.GetAllCustomersAsync(
|
||||
lastName: searchTerm,
|
||||
pageIndex: 0,
|
||||
pageSize: maxResults);
|
||||
|
||||
// Combine and deduplicate
|
||||
var allCustomers = customersByCompany
|
||||
.Union(customersByName)
|
||||
.Union(customersByLastName)
|
||||
.DistinctBy(c => c.Id)
|
||||
.Take(maxResults)
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"[VoiceOrder] String-based search found {allCustomers.Count} partners");
|
||||
|
||||
// Step 2: If we don't have enough results, use AI semantic matching
|
||||
if (allCustomers.Count < minResultsForAI)
|
||||
{
|
||||
Console.WriteLine("[VoiceOrder] Using AI semantic matching for partners");
|
||||
|
||||
var aiMatches = await SemanticPartnerSearch(searchTerm);
|
||||
|
||||
// Merge AI matches with string matches, remove duplicates
|
||||
allCustomers = allCustomers
|
||||
.Union(aiMatches)
|
||||
.DistinctBy(c => c.Id)
|
||||
.Take(maxResults)
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"[VoiceOrder] After AI matching: {allCustomers.Count} partners");
|
||||
}
|
||||
|
||||
// Format results
|
||||
var result = new List<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 (limit to reasonable number)
|
||||
var allCustomersWithCompany = await _customerService.GetAllCustomersAsync(
|
||||
pageIndex: 0,
|
||||
pageSize: 500); // Reasonable limit for AI processing
|
||||
|
||||
// Filter to only those with company names
|
||||
var customersWithCompany = allCustomersWithCompany
|
||||
.Where(c => !string.IsNullOrEmpty(c.Company))
|
||||
.ToList();
|
||||
|
||||
if (customersWithCompany.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[VoiceOrder] No customers with company names found");
|
||||
return new List<Nop.Core.Domain.Customers.Customer>();
|
||||
}
|
||||
|
||||
// Build company list for AI
|
||||
var companyList = string.Join("\n", customersWithCompany
|
||||
.Select((c, index) => $"{index}|{c.Company}"));
|
||||
|
||||
var systemPrompt = @"You are a company name matcher for a B2B system.
|
||||
Given a spoken company name and a list of company names, find the 5 best semantic matches.
|
||||
|
||||
RULES:
|
||||
1. Consider phonetic similarity (how it sounds)
|
||||
2. Consider abbreviations (e.g., 'SFI' matches 'SFI Rotterdam B.V.')
|
||||
3. Consider partial matches (e.g., 'Rotterdam' matches 'SFI Rotterdam B.V.')
|
||||
4. Consider common misspellings or mishearings
|
||||
5. Return ONLY valid JSON array with indices, no explanations
|
||||
|
||||
INPUT FORMAT:
|
||||
Search term: [spoken company name]
|
||||
Companies: [index]|[company name] (one per line)
|
||||
|
||||
OUTPUT FORMAT (JSON only):
|
||||
[0, 15, 42, 103, 256]
|
||||
|
||||
Return the top 5 indices that best match the search term. If fewer than 5 good matches exist, return fewer indices.";
|
||||
|
||||
var userPrompt = $@"Search term: {searchTerm}
|
||||
Companies:
|
||||
{companyList}";
|
||||
|
||||
var aiResponse = await _aiApiService.GetSimpleResponseAsync(systemPrompt, userPrompt);
|
||||
|
||||
Console.WriteLine($"[VoiceOrder] AI company matching response: {aiResponse}");
|
||||
|
||||
// Extract JSON array from response
|
||||
var jsonMatch = Regex.Match(aiResponse, @"\[[\d,\s]*\]", RegexOptions.Singleline);
|
||||
if (!jsonMatch.Success)
|
||||
{
|
||||
Console.WriteLine("[VoiceOrder] No JSON array found in AI response");
|
||||
return new List<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 wholesale company.
|
||||
Parse the product names and quantities from the user's speech.
|
||||
|
||||
RULES:
|
||||
1. Extract product names and quantities
|
||||
2. Normalize product names to singular, lowercase (e.g., 'narancsok' → 'narancs')
|
||||
3. Convert quantity units to standard format (kg, db, láda)
|
||||
4. Handle Hungarian number words ('száz' = 100, 'ötven' = 50, etc.)
|
||||
5. Return ONLY valid JSON array, no explanations
|
||||
|
||||
OUTPUT FORMAT (JSON only):
|
||||
[
|
||||
{""product"": ""narancs"", ""quantity"": 100, ""unit"": ""kg""},
|
||||
{""product"": ""alma"", ""quantity"": 50, ""unit"": ""kg""}
|
||||
]
|
||||
|
||||
EXAMPLES:
|
||||
Input: 'narancs száz kilogram és alma ötven kiló'
|
||||
Output: [{""product"":""narancs"",""quantity"":100,""unit"":""kg""},{""product"":""alma"",""quantity"":50,""unit"":""kg""}]
|
||||
|
||||
Input: 'Kérek 200 kg narancsot meg 150 kg almát'
|
||||
Output: [{""product"":""narancs"",""quantity"":200,""unit"":""kg""},{""product"":""alma"",""quantity"":150,""unit"":""kg""}]";
|
||||
|
||||
var userPrompt = $"Parse this: {text}";
|
||||
|
||||
var aiResponse = await _aiApiService.GetSimpleResponseAsync(systemPrompt, userPrompt);
|
||||
|
||||
Console.WriteLine($"[VoiceOrder] AI Response: {aiResponse}");
|
||||
|
||||
// Try to extract JSON from response
|
||||
var jsonMatch = Regex.Match(aiResponse, @"\[.*\]", RegexOptions.Singleline);
|
||||
if (!jsonMatch.Success)
|
||||
{
|
||||
Console.WriteLine("[VoiceOrder] No JSON array found in AI response");
|
||||
return new List<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
|
||||
/// </summary>
|
||||
private async Task<List<object>> EnrichProductData(List<ParsedProduct> parsedProducts)
|
||||
{
|
||||
var enrichedProducts = new List<object>();
|
||||
|
||||
foreach (var parsed in parsedProducts)
|
||||
{
|
||||
// Search for matching product in database
|
||||
var products = await _productService.SearchProductsAsync(
|
||||
keywords: parsed.Product,
|
||||
pageIndex: 0,
|
||||
pageSize: 5);
|
||||
|
||||
if (!products.Any())
|
||||
{
|
||||
Console.WriteLine($"[VoiceOrder] No product found for: {parsed.Product}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Take the best match (first result)
|
||||
var product = products.First();
|
||||
var productDto = await _dbContext.ProductDtos.GetByIdAsync(product.Id);
|
||||
|
||||
if (productDto == null)
|
||||
{
|
||||
Console.WriteLine($"[VoiceOrder] ProductDto not found for product ID: {product.Id}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if enough stock
|
||||
var availableQuantity = product.StockQuantity + productDto.IncomingQuantity;
|
||||
if (availableQuantity <= 0)
|
||||
{
|
||||
Console.WriteLine($"[VoiceOrder] Product {product.Name} has no stock");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add to enriched list
|
||||
enrichedProducts.Add(new
|
||||
{
|
||||
id = product.Id,
|
||||
name = product.Name,
|
||||
sku = product.Sku,
|
||||
quantity = parsed.Quantity,
|
||||
unit = parsed.Unit,
|
||||
price = product.Price,
|
||||
stockQuantity = availableQuantity
|
||||
});
|
||||
}
|
||||
|
||||
return enrichedProducts;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Models
|
||||
|
||||
private class ParsedProduct
|
||||
{
|
||||
public string Product { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public string Unit { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
function showMessage(message, type) {
|
||||
responseMessage.textContent = message;
|
||||
responseMessage.className = 'alert alert-' + type;
|
||||
|
|
|
|||
|
|
@ -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> </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>
|
||||
|
|
@ -0,0 +1,649 @@
|
|||
@{
|
||||
Layout = "_AdminLayout";
|
||||
ViewBag.PageTitle = "Voice Order Creation";
|
||||
}
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="float-left">
|
||||
<i class="fas fa-microphone"></i> Voice Order Creation
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Progress Steps -->
|
||||
<div class="card card-default mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div id="step1Indicator" class="alert alert-info">
|
||||
<i class="fas fa-user"></i> <strong>Step 1:</strong> Select Partner
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div id="step2Indicator" class="alert alert-secondary">
|
||||
<i class="fas fa-box"></i> <strong>Step 2:</strong> Add Products
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Partner Selection -->
|
||||
<div id="step1Card" class="card card-primary">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
<i class="fas fa-user"></i> Step 1: Select Partner
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-4">
|
||||
<h4 id="step1Prompt">🎤 Tell me the partner name</h4>
|
||||
</div>
|
||||
|
||||
<!-- Voice Recording Button -->
|
||||
<div class="text-center mb-4">
|
||||
<button id="recordPartnerBtn" class="btn btn-lg btn-primary">
|
||||
<i class="fas fa-microphone"></i> Click to Record Partner Name
|
||||
</button>
|
||||
<button id="stopPartnerBtn" class="btn btn-lg btn-danger" style="display: none;">
|
||||
<i class="fas fa-stop"></i> Stop Recording
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recording Status -->
|
||||
<div id="partnerRecordingStatus" class="alert alert-info text-center" style="display: none;">
|
||||
<i class="fas fa-spinner fa-spin"></i> <span id="partnerStatusText">Recording...</span>
|
||||
</div>
|
||||
|
||||
<!-- Transcribed Text -->
|
||||
<div id="partnerTranscribedCard" class="card card-secondary" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title"><i class="fas fa-comment"></i> You said:</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p id="partnerTranscribedText" class="lead"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Partner Matches -->
|
||||
<div id="partnerMatchesCard" class="card card-success" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title"><i class="fas fa-users"></i> Matching Partners:</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="partnerButtons" class="d-grid gap-2">
|
||||
<!-- Partner buttons will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Partner Display -->
|
||||
<div id="selectedPartnerCard" class="card card-success" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title"><i class="fas fa-check-circle"></i> Selected Partner:</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h4 id="selectedPartnerName"></h4>
|
||||
<p id="selectedPartnerDetails" class="text-muted"></p>
|
||||
<button id="changePartnerBtn" class="btn btn-warning">
|
||||
<i class="fas fa-undo"></i> Change Partner
|
||||
</button>
|
||||
<button id="proceedToProductsBtn" class="btn btn-success">
|
||||
<i class="fas fa-arrow-right"></i> Proceed to Add Products
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Product Selection -->
|
||||
<div id="step2Card" class="card card-info" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
<i class="fas fa-box"></i> Step 2: Add Products
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Selected Partner Summary -->
|
||||
<div class="alert alert-success mb-3">
|
||||
<strong><i class="fas fa-user"></i> Partner:</strong> <span id="partnerSummary"></span>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<h4 id="step2Prompt">🎤 Tell me the products and quantities</h4>
|
||||
<p class="text-muted">Example: "Narancs 100 kilogram, Alma 50 kilogram"</p>
|
||||
</div>
|
||||
|
||||
<!-- Voice Recording Button -->
|
||||
<div class="text-center mb-4">
|
||||
<button id="recordProductBtn" class="btn btn-lg btn-primary">
|
||||
<i class="fas fa-microphone"></i> Click to Record Products
|
||||
</button>
|
||||
<button id="stopProductBtn" class="btn btn-lg btn-danger" style="display: none;">
|
||||
<i class="fas fa-stop"></i> Stop Recording
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recording Status -->
|
||||
<div id="productRecordingStatus" class="alert alert-info text-center" style="display: none;">
|
||||
<i class="fas fa-spinner fa-spin"></i> <span id="productStatusText">Recording...</span>
|
||||
</div>
|
||||
|
||||
<!-- Transcribed Text -->
|
||||
<div id="productTranscribedCard" class="card card-secondary" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title"><i class="fas fa-comment"></i> You said:</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p id="productTranscribedText" class="lead"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Matches (for confirmation) -->
|
||||
<div id="productMatchesCard" class="card card-warning" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title"><i class="fas fa-boxes"></i> Found Products - Click to Confirm:</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="productButtons" class="d-grid gap-2">
|
||||
<!-- Product buttons will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Order Items -->
|
||||
<div id="orderItemsCard" class="card card-success" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title"><i class="fas fa-shopping-cart"></i> Current Order Items:</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-bordered" id="orderItemsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Quantity</th>
|
||||
<th>Unit Price</th>
|
||||
<th>Total</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="orderItemsBody">
|
||||
<!-- Order items will be inserted here -->
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="3" class="text-right">Order Total:</th>
|
||||
<th id="orderTotalDisplay">0.00 Ft</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<button id="addMoreProductsBtn" class="btn btn-primary">
|
||||
<i class="fas fa-microphone"></i> Add More Products
|
||||
</button>
|
||||
<button id="finishOrderBtn" class="btn btn-success btn-lg">
|
||||
<i class="fas fa-check"></i> Finish & Create Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Creation Success -->
|
||||
<div id="successCard" class="card card-success" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
<i class="fas fa-check-circle"></i> Order Created Successfully!
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<h3>Order #<span id="createdOrderId"></span> has been created!</h3>
|
||||
<div class="mt-4">
|
||||
<a id="viewOrderBtn" href="#" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-eye"></i> View Order
|
||||
</a>
|
||||
<button id="createAnotherOrderBtn" class="btn btn-secondary btn-lg">
|
||||
<i class="fas fa-plus"></i> Create Another Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// State management
|
||||
let currentStep = 1;
|
||||
let selectedPartnerId = null;
|
||||
let selectedPartnerName = "";
|
||||
let orderItems = [];
|
||||
let mediaRecorder = null;
|
||||
let audioChunks = [];
|
||||
let orderTotal = 0;
|
||||
|
||||
$(document).ready(function() {
|
||||
setupEventHandlers();
|
||||
});
|
||||
|
||||
function setupEventHandlers() {
|
||||
// Partner recording
|
||||
$('#recordPartnerBtn').click(() => startRecording('partner'));
|
||||
$('#stopPartnerBtn').click(() => stopRecording('partner'));
|
||||
|
||||
// Product recording
|
||||
$('#recordProductBtn').click(() => startRecording('product'));
|
||||
$('#stopProductBtn').click(() => stopRecording('product'));
|
||||
|
||||
// Navigation
|
||||
$('#changePartnerBtn').click(resetPartnerSelection);
|
||||
$('#proceedToProductsBtn').click(proceedToProducts);
|
||||
$('#addMoreProductsBtn').click(addMoreProducts);
|
||||
$('#finishOrderBtn').click(finishOrder);
|
||||
$('#createAnotherOrderBtn').click(resetWizard);
|
||||
}
|
||||
|
||||
async function startRecording(type) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
audioChunks = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
audioChunks.push(event.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||
processAudio(audioBlob, type);
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
|
||||
// Update UI
|
||||
if (type === 'partner') {
|
||||
$('#recordPartnerBtn').hide();
|
||||
$('#stopPartnerBtn').show();
|
||||
showStatus('partnerRecordingStatus', 'Recording... Speak now!');
|
||||
} else {
|
||||
$('#recordProductBtn').hide();
|
||||
$('#stopProductBtn').show();
|
||||
showStatus('productRecordingStatus', 'Recording... Speak now!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error accessing microphone:', error);
|
||||
alert('Could not access microphone. Please check permissions.');
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording(type) {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop();
|
||||
|
||||
// Update UI
|
||||
if (type === 'partner') {
|
||||
$('#stopPartnerBtn').hide();
|
||||
showStatus('partnerRecordingStatus', 'Processing audio...');
|
||||
} else {
|
||||
$('#stopProductBtn').hide();
|
||||
showStatus('productRecordingStatus', 'Processing audio...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processAudio(audioBlob, type) {
|
||||
const formData = new FormData();
|
||||
formData.append('audioFile', audioBlob, 'recording.webm');
|
||||
|
||||
const endpoint = type === 'partner'
|
||||
? '@Url.Action("TranscribeForPartner", "VoiceOrder")'
|
||||
: '@Url.Action("TranscribeForProducts", "VoiceOrder")';
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
if (type === 'partner') {
|
||||
handlePartnerTranscription(result);
|
||||
} else {
|
||||
handleProductTranscription(result);
|
||||
}
|
||||
} else {
|
||||
alert('Error: ' + result.message);
|
||||
resetRecordingUI(type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing audio:', error);
|
||||
alert('Error processing audio: ' + error.message);
|
||||
resetRecordingUI(type);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePartnerTranscription(result) {
|
||||
$('#partnerRecordingStatus').hide();
|
||||
$('#recordPartnerBtn').show();
|
||||
|
||||
// Show transcribed text
|
||||
$('#partnerTranscribedText').text(result.transcription);
|
||||
$('#partnerTranscribedCard').show();
|
||||
|
||||
// Show matching partners
|
||||
if (result.partners && result.partners.length > 0) {
|
||||
displayPartnerMatches(result.partners);
|
||||
} else {
|
||||
alert('No matching partners found. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function displayPartnerMatches(partners) {
|
||||
const container = $('#partnerButtons');
|
||||
container.empty();
|
||||
|
||||
partners.forEach(partner => {
|
||||
const btn = $('<button>')
|
||||
.addClass('btn btn-outline-primary btn-lg mb-2 text-left')
|
||||
.css('width', '100%')
|
||||
.html(`
|
||||
<i class="fas fa-user"></i> <strong>${partner.label}</strong><br>
|
||||
<small class="text-muted">ID: ${partner.value}</small>
|
||||
`)
|
||||
.click(() => selectPartner(partner));
|
||||
|
||||
container.append(btn);
|
||||
});
|
||||
|
||||
$('#partnerMatchesCard').show();
|
||||
}
|
||||
|
||||
function selectPartner(partner) {
|
||||
selectedPartnerId = partner.value;
|
||||
selectedPartnerName = partner.label;
|
||||
|
||||
$('#selectedPartnerName').text(partner.label);
|
||||
$('#selectedPartnerDetails').text('Customer ID: ' + partner.value);
|
||||
|
||||
$('#partnerMatchesCard').hide();
|
||||
$('#selectedPartnerCard').show();
|
||||
}
|
||||
|
||||
function resetPartnerSelection() {
|
||||
selectedPartnerId = null;
|
||||
selectedPartnerName = "";
|
||||
|
||||
$('#partnerTranscribedCard').hide();
|
||||
$('#partnerMatchesCard').hide();
|
||||
$('#selectedPartnerCard').hide();
|
||||
$('#recordPartnerBtn').show();
|
||||
}
|
||||
|
||||
function proceedToProducts() {
|
||||
currentStep = 2;
|
||||
|
||||
// Update step indicators
|
||||
$('#step1Indicator').removeClass('alert-info').addClass('alert-success');
|
||||
$('#step2Indicator').removeClass('alert-secondary').addClass('alert-info');
|
||||
|
||||
// Hide step 1, show step 2
|
||||
$('#step1Card').hide();
|
||||
$('#step2Card').show();
|
||||
|
||||
// Update partner summary
|
||||
$('#partnerSummary').text(selectedPartnerName);
|
||||
}
|
||||
|
||||
function handleProductTranscription(result) {
|
||||
$('#productRecordingStatus').hide();
|
||||
$('#recordProductBtn').show();
|
||||
|
||||
// Show transcribed text
|
||||
$('#productTranscribedText').text(result.transcription);
|
||||
$('#productTranscribedCard').show();
|
||||
|
||||
// Show matching products for confirmation
|
||||
if (result.products && result.products.length > 0) {
|
||||
displayProductMatches(result.products);
|
||||
} else {
|
||||
alert('No matching products found. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function displayProductMatches(products) {
|
||||
const container = $('#productButtons');
|
||||
container.empty();
|
||||
|
||||
products.forEach(product => {
|
||||
const btn = $('<button>')
|
||||
.addClass('btn btn-outline-success btn-lg mb-2 text-left')
|
||||
.css('width', '100%')
|
||||
.html(`
|
||||
<i class="fas fa-box"></i> <strong>${product.name}</strong><br>
|
||||
<small>Quantity: ${product.quantity} ${product.unit || 'kg'} | Price: ${product.price.toFixed(2)} Ft | Available: ${product.stockQuantity}</small>
|
||||
`)
|
||||
.click(() => addProductToOrder(product));
|
||||
|
||||
container.append(btn);
|
||||
});
|
||||
|
||||
$('#productMatchesCard').show();
|
||||
}
|
||||
|
||||
function addProductToOrder(product) {
|
||||
// Check if product already exists in order
|
||||
const existingIndex = orderItems.findIndex(item => item.id === product.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update quantity
|
||||
orderItems[existingIndex].quantity += product.quantity;
|
||||
} else {
|
||||
// Add new item
|
||||
orderItems.push({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
sku: product.sku,
|
||||
quantity: product.quantity,
|
||||
price: product.price,
|
||||
stockQuantity: product.stockQuantity
|
||||
});
|
||||
}
|
||||
|
||||
updateOrderItemsDisplay();
|
||||
|
||||
// Hide product matches after adding
|
||||
$('#productMatchesCard').hide();
|
||||
$('#productTranscribedCard').hide();
|
||||
}
|
||||
|
||||
function updateOrderItemsDisplay() {
|
||||
const tbody = $('#orderItemsBody');
|
||||
tbody.empty();
|
||||
orderTotal = 0;
|
||||
|
||||
orderItems.forEach((item, index) => {
|
||||
const itemTotal = item.quantity * item.price;
|
||||
orderTotal += itemTotal;
|
||||
|
||||
const row = $('<tr>').html(`
|
||||
<td>${item.name}<br><small class="text-muted">SKU: ${item.sku}</small></td>
|
||||
<td>
|
||||
<input type="number" class="form-control" value="${item.quantity}"
|
||||
onchange="updateQuantity(${index}, this.value)" min="1" max="${item.stockQuantity}">
|
||||
</td>
|
||||
<td>${item.price.toFixed(2)} Ft</td>
|
||||
<td>${itemTotal.toFixed(2)} Ft</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="removeItem(${index})">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
`);
|
||||
tbody.append(row);
|
||||
});
|
||||
|
||||
$('#orderTotalDisplay').text(orderTotal.toFixed(2) + ' 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();
|
||||
}
|
||||
|
||||
async function finishOrder() {
|
||||
if (!selectedPartnerId) {
|
||||
alert('No partner selected!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (orderItems.length === 0) {
|
||||
alert('No products in order!');
|
||||
return;
|
||||
}
|
||||
|
||||
$('#finishOrderBtn').prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Creating order...');
|
||||
|
||||
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()
|
||||
})
|
||||
});
|
||||
|
||||
// Check if redirect happened (order created successfully)
|
||||
if (response.redirected) {
|
||||
// Extract order ID from redirect URL
|
||||
const url = new URL(response.url);
|
||||
const orderId = url.searchParams.get('id');
|
||||
|
||||
if (orderId) {
|
||||
showSuccess(orderId);
|
||||
} else {
|
||||
// Fallback: redirect to the order edit page
|
||||
window.location.href = response.url;
|
||||
}
|
||||
} else {
|
||||
alert('Error creating order. Please try again.');
|
||||
$('#finishOrderBtn').prop('disabled', false).html('<i class="fas fa-check"></i> Finish & Create Order');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating order:', error);
|
||||
alert('Error creating order: ' + error.message);
|
||||
$('#finishOrderBtn').prop('disabled', false).html('<i class="fas fa-check"></i> Finish & Create Order');
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccess(orderId) {
|
||||
$('#step2Card').hide();
|
||||
$('#successCard').show();
|
||||
$('#createdOrderId').text(orderId);
|
||||
$('#viewOrderBtn').attr('href', '@Url.Action("Edit", "Order")?id=' + orderId);
|
||||
}
|
||||
|
||||
function resetWizard() {
|
||||
// Reset all state
|
||||
currentStep = 1;
|
||||
selectedPartnerId = null;
|
||||
selectedPartnerName = "";
|
||||
orderItems = [];
|
||||
orderTotal = 0;
|
||||
|
||||
// Reset UI
|
||||
$('#step1Indicator').removeClass('alert-success').addClass('alert-info');
|
||||
$('#step2Indicator').removeClass('alert-info').addClass('alert-secondary');
|
||||
|
||||
$('#successCard').hide();
|
||||
$('#step2Card').hide();
|
||||
$('#step1Card').show();
|
||||
|
||||
$('#partnerTranscribedCard').hide();
|
||||
$('#partnerMatchesCard').hide();
|
||||
$('#selectedPartnerCard').hide();
|
||||
$('#productTranscribedCard').hide();
|
||||
$('#productMatchesCard').hide();
|
||||
$('#orderItemsCard').hide();
|
||||
|
||||
$('#recordPartnerBtn').show();
|
||||
}
|
||||
|
||||
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>
|
||||
.d-grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#step1Indicator, #step2Indicator {
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.card-body h4 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@* Anti-forgery token *@
|
||||
@Html.AntiForgeryToken()
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
//}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -792,13 +793,38 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|||
_ => "image/jpeg"
|
||||
};
|
||||
|
||||
var prompt = customPrompt ?? "Extract all text from this image, without modifying or translateing it.";
|
||||
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).\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 +877,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;
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue