Mango.Nop.Plugins/Nop.Plugin.Misc.AIPlugin/Controllers/QuickOrderController.cs

512 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Core.Domain.Orders;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Catalog;
using Nop.Services.Customers;
using Nop.Services.Localization;
using Nop.Services.Orders;
using Nop.Web.Framework.Controllers;
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.Controllers
{
[AutoValidateAntiforgeryToken]
public class QuickOrderController : BasePluginController
{
private readonly IWorkContext _workContext;
private readonly IStoreContext _storeContext;
private readonly IProductService _productService;
private readonly IShoppingCartService _shoppingCartService;
private readonly ICustomerService _customerService;
private readonly ILocalizationService _localizationService;
private readonly CustomPriceCalculationService _customPriceCalculationService;
private readonly OpenAIApiService _aiApiService;
private readonly CerebrasAPIService _cerebrasApiService;
private readonly FruitBankDbContext _dbContext;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private const string PendingDeliveryDateTimeKey = "QuickOrderPendingDeliveryDateTime";
// Resource key prefix
private const string Prefix = "Plugins.Misc.FruitBankPlugin.QuickOrder.";
public QuickOrderController(
IWorkContext workContext,
IStoreContext storeContext,
IProductService productService,
IShoppingCartService shoppingCartService,
ICustomerService customerService,
ILocalizationService localizationService,
IPriceCalculationService priceCalculationService,
OpenAIApiService aiApiService,
CerebrasAPIService cerebrasApiService,
FruitBankDbContext dbContext,
FruitBankAttributeService fruitBankAttributeService)
{
_workContext = workContext;
_storeContext = storeContext;
_productService = productService;
_shoppingCartService = shoppingCartService;
_customerService = customerService;
_localizationService = localizationService;
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
_aiApiService = aiApiService;
_cerebrasApiService = cerebrasApiService;
_dbContext = dbContext;
_fruitBankAttributeService = fruitBankAttributeService;
}
[HttpGet]
public async Task<IActionResult> Index()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Challenge();
return View("~/Plugins/Misc.FruitBankPlugin/Views/QuickOrder/Index.cshtml");
}
/// <summary>
/// Return the previously saved delivery datetime for this customer, if any.
/// Used on page load to restore state when the customer revisits or opens a new tab.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetDeliveryDateTime()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false });
var store = await _storeContext.GetCurrentStoreAsync();
var saved = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Nop.Core.Domain.Customers.Customer, DateTime?>(
customer.Id, PendingDeliveryDateTimeKey, store.Id);
if (!saved.HasValue)
return Json(new { success = true, hasValue = false });
return Json(new
{
success = true,
hasValue = true,
date = saved.Value.ToString("yyyy-MM-dd"),
time = saved.Value.ToString("HH:mm"),
iso = saved.Value.ToString("yyyy-MM-ddTHH:mm")
});
}
/// <summary>
/// Return all available products with prices, optionally filtered by delivery date/slot.
/// </summary>
/// <summary>
/// Save the customer's chosen delivery date+time as a generic attribute.
/// The OrderPlacedEvent handler will transfer it to the order as DateOfReceipt.
/// </summary>
[HttpPost]
public async Task<IActionResult> SetDeliveryDateTime(string deliveryDateTime)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
if (string.IsNullOrWhiteSpace(deliveryDateTime))
return Json(new { success = false, message = await L("NoDeliveryDateTimeProvided") });
if (!DateTime.TryParse(deliveryDateTime, out var parsedDateTime))
return Json(new { success = false, message = await L("InvalidDeliveryDateTime") });
var store = await _storeContext.GetCurrentStoreAsync();
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Nop.Core.Domain.Customers.Customer, DateTime>(
customer.Id, PendingDeliveryDateTimeKey, parsedDateTime, store.Id);
Console.WriteLine($"[QuickOrder] SetDeliveryDateTime customerId={customer.Id}, dateTime={parsedDateTime:u}");
return Json(new { success = true });
}
[HttpGet]
public async Task<IActionResult> GetAllProducts(string deliveryDate = null, string deliveryTime = null)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
try
{
Console.WriteLine($"[QuickOrder] GetAllProducts deliveryDate={deliveryDate}, time={deliveryTime}");
var store = await _storeContext.GetCurrentStoreAsync();
var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync())
.Where(pd => pd.AvailableQuantity > 0);
// TODO: filter allProductDtos by deliveryDate + deliverySlot once
// availability data model is defined (e.g. scheduled stock, delivery windows).
var result = new List<object>();
foreach (var product in allProductDtos)
{
var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id);
if (productDto == null) continue;
var availableQty = product.StockQuantity + productDto.IncomingQuantity;
if (availableQty <= 0) continue;
decimal? unitPrice = null;
if (!productDto.IsMeasurable)
{
var tproduct = await _productService.GetProductByIdAsync(productDto.Id);
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
tproduct, customer, store, null, 0, true, 1, null, null);
unitPrice = priceResult.finalPrice;
}
result.Add(new
{
id = product.Id,
name = product.Name,
quantity = 1,
requestedQuantity = 1,
unitPrice,
stockQuantity = availableQty,
searchTerm = (string)null,
isQuantityReduced = false,
isMeasurable = productDto.IsMeasurable
});
}
return Json(new { success = true, products = result });
}
catch (Exception ex)
{
Console.WriteLine($"[QuickOrder] GetAllProducts error: {ex.Message}");
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
/// <summary>
/// Parse a manually typed product list and return matching products with prices,
/// optionally filtered by delivery date/slot.
/// </summary>
[HttpPost]
public async Task<IActionResult> SearchProducts(string text, string deliveryDate = null, string deliveryTime = null)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
if (string.IsNullOrWhiteSpace(text))
return Json(new { success = false, message = await L("NoTextProvided") });
Console.WriteLine($"[QuickOrder] SearchProducts deliveryDate={deliveryDate}, time={deliveryTime}");
// TODO: pass deliveryDate + deliverySlot to EnrichProductData when availability filtering is implemented.
try
{
var parsedProducts = await ParseProductsFromText(text);
if (parsedProducts == null || parsedProducts.Count == 0)
return Json(new { success = false, message = await L("NoProductsIdentified"), transcription = text });
var store = await _storeContext.GetCurrentStoreAsync();
var enrichedProducts = await EnrichProductData(parsedProducts, customer, store);
return Json(new { success = true, transcription = text, products = enrichedProducts });
}
catch (Exception ex)
{
Console.WriteLine($"[QuickOrder] SearchProducts error: {ex.Message}");
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
/// <summary>
/// Transcribe voice audio (Hungarian) then parse and match products,
/// optionally filtered by delivery date/slot.
/// </summary>
[HttpPost]
public async Task<IActionResult> TranscribeAndSearch(IFormFile audioFile, string deliveryDate = null, string deliveryTime = null)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
if (audioFile == null || audioFile.Length == 0)
return Json(new { success = false, message = await L("NoAudioReceived") });
Console.WriteLine($"[QuickOrder] TranscribeAndSearch deliveryDate={deliveryDate}, time={deliveryTime}");
// TODO: pass deliveryDate + deliverySlot to EnrichProductData when availability filtering is implemented.
try
{
var transcribedText = await TranscribeAudioFile(audioFile, "hu");
if (string.IsNullOrEmpty(transcribedText))
return Json(new { success = false, message = await L("TranscriptionFailed") });
Console.WriteLine($"[QuickOrder] Transcription: {transcribedText}");
var parsedProducts = await ParseProductsFromText(transcribedText);
if (parsedProducts == null || parsedProducts.Count == 0)
return Json(new { success = false, message = await L("NoProductsIdentified"), transcription = transcribedText });
var store = await _storeContext.GetCurrentStoreAsync();
var enrichedProducts = await EnrichProductData(parsedProducts, customer, store);
return Json(new { success = true, transcription = transcribedText, products = enrichedProducts });
}
catch (Exception ex)
{
Console.WriteLine($"[QuickOrder] TranscribeAndSearch error: {ex.Message}");
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
/// <summary>
/// Add a product to the current customer's shopping cart and return the updated cart.
/// </summary>
[HttpPost]
public async Task<IActionResult> AddToCart(int productId, int quantity)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
if (productId <= 0 || quantity <= 0)
return Json(new { success = false, message = await L("InvalidProductOrQuantity") });
try
{
var product = await _productService.GetProductByIdAsync(productId);
if (product == null || product.Deleted || !product.Published)
return Json(new { success = false, message = await L("ProductNotAvailable") });
var store = await _storeContext.GetCurrentStoreAsync();
var warnings = await _shoppingCartService.AddToCartAsync(
customer: customer,
product: product,
shoppingCartType: ShoppingCartType.ShoppingCart,
storeId: store.Id,
quantity: quantity);
if (warnings.Any())
return Json(new { success = false, message = string.Join("; ", warnings) });
var cartItems = await GetCartItemsJson(customer, store);
return Json(new { success = true, cartItems });
}
catch (Exception ex)
{
Console.WriteLine($"[QuickOrder] AddToCart error: {ex.Message}");
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
/// <summary>
/// Return the current customer's cart as JSON (for cart panel refresh).
/// </summary>
[HttpGet]
public async Task<IActionResult> GetCartItems()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false });
var store = await _storeContext.GetCurrentStoreAsync();
var cartItems = await GetCartItemsJson(customer, store);
return Json(new { success = true, cartItems });
}
#region Private helpers
/// <summary>Shorthand: get a localized QuickOrder resource string</summary>
private Task<string> L(string keySuffix)
=> _localizationService.GetResourceAsync(Prefix + keySuffix);
private async Task<string> TranscribeAudioFile(IFormFile audioFile, string language)
{
var fileName = $"quick_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);
using (var stream = new FileStream(filePath, FileMode.Create))
await audioFile.CopyToAsync(stream);
string transcribedText;
using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language, null);
if (!string.IsNullOrEmpty(transcribedText) &&
(transcribedText.EndsWith(".") || transcribedText.EndsWith("!") || transcribedText.EndsWith("?")))
transcribedText = transcribedText[..^1];
try { System.IO.File.Delete(filePath); } catch { /* ignore cleanup errors */ }
return transcribedText;
}
private async Task<List<ParsedProduct>> ParseProductsFromText(string text)
{
var systemPrompt = @"You are a product parser for a Hungarian fruit and vegetable wholesale company.
Parse the product names and quantities from the user's input.
CRITICAL RULES:
1. Extract product names and quantities from ANY produce item
2. Normalize product names to singular, lowercase (e.g., 'narancsok' → 'narancs')
3. Handle Hungarian number words ('száz' = 100, 'ötven' = 50, 'húsz' = 20, 'tíz' = 10, 'öt' = 5, 'egy' = 1)
4. Fix common transcription/typing errors (e.g., 'datója' → 'datolya', 'szűlő' → 'szőlő', 'mondarin' → 'mandarin')
5. Return ONLY valid JSON array, no explanations
6. DO NOT include units - only product name and quantity as a number
7. ALWAYS return at least one product if you can parse anything from the input
OUTPUT FORMAT (JSON only):
[
{""product"": ""narancs"", ""quantity"": 100},
{""product"": ""alma"", ""quantity"": 50}
]";
var aiResponse = await _cerebrasApiService.GetSimpleResponseAsync(systemPrompt, $"Parse this: {text}");
Console.WriteLine($"[QuickOrder] AI parse response: {aiResponse}");
var jsonMatch = Regex.Match(aiResponse, @"\[.*\]", RegexOptions.Singleline);
if (!jsonMatch.Success) return new List<ParsedProduct>();
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<ParsedProduct>>(jsonMatch.Value)
?? new List<ParsedProduct>();
}
catch (Exception ex)
{
Console.WriteLine($"[QuickOrder] JSON parse error: {ex.Message}");
return new List<ParsedProduct>();
}
}
private async Task<List<object>> EnrichProductData(
List<ParsedProduct> parsedProducts,
Nop.Core.Domain.Customers.Customer customer,
Nop.Core.Domain.Stores.Store store)
{
var enrichedProducts = new List<object>();
var allProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
foreach (var parsed in parsedProducts)
{
var dbProducts = await _productService.SearchProductsAsync(
keywords: parsed.Product,
pageIndex: 0,
pageSize: 20);
if (!dbProducts.Any())
{
Console.WriteLine($"[QuickOrder] No products found for: {parsed.Product}");
continue;
}
foreach (var product in dbProducts)
{
var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id);
if (productDto == null) continue;
var availableQty = product.StockQuantity + productDto.IncomingQuantity;
if (availableQty <= 0) continue;
var requestedQty = parsed.Quantity;
var finalQty = Math.Min(requestedQty, availableQty);
var isReduced = finalQty < requestedQty;
decimal? unitPrice = null;
if (!productDto.IsMeasurable)
{
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
product, customer, store, null, 0, true, finalQty, null, null);
unitPrice = priceResult.finalPrice;
}
enrichedProducts.Add(new
{
id = product.Id,
name = product.Name,
quantity = finalQty,
requestedQuantity = requestedQty,
unitPrice,
stockQuantity = availableQty,
searchTerm = parsed.Product,
isQuantityReduced = isReduced,
isMeasurable = productDto.IsMeasurable
});
}
}
Console.WriteLine($"[QuickOrder] Enriched product count: {enrichedProducts.Count}");
return enrichedProducts;
}
private async Task<List<object>> GetCartItemsJson(
Nop.Core.Domain.Customers.Customer customer,
Nop.Core.Domain.Stores.Store store)
{
var cart = await _shoppingCartService.GetShoppingCartAsync(
customer, ShoppingCartType.ShoppingCart, store.Id);
var allProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
var result = new List<object>();
foreach (var item in cart)
{
var product = await _productService.GetProductByIdAsync(item.ProductId);
if (product == null) continue;
var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id);
var isMeasurable = productDto?.IsMeasurable ?? false;
decimal? unitPrice = null;
if (!isMeasurable)
{
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
product, customer, store, null, 0, true, item.Quantity, null, null);
unitPrice = priceResult.finalPrice;
}
result.Add(new
{
id = item.Id,
productId = item.ProductId,
name = product.Name,
quantity = item.Quantity,
unitPrice,
isMeasurable
});
}
return result;
}
#endregion
#region Inner models
private class ParsedProduct
{
[System.Text.Json.Serialization.JsonPropertyName("product")]
public string Product { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("quantity")]
public int Quantity { get; set; }
}
#endregion
}
}