512 lines
22 KiB
C#
512 lines
22 KiB
C#
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
|
||
}
|
||
}
|