This commit is contained in:
Loretta 2026-01-20 14:49:35 +01:00
commit f5e356555c
13 changed files with 3330 additions and 548 deletions

View File

@ -7,6 +7,7 @@ using DocumentFormat.OpenXml.Spreadsheet;
using FluentMigrator.Runner.Generators.Base;
using FruitBank.Common.Dtos;
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using FruitBank.Common.Interfaces;
using FruitBank.Common.Server.Interfaces;
using FruitBank.Common.Server.Services.SignalRs;
@ -324,7 +325,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
searchModel.SortColumn = columnName;
if(int.Parse(sortColumnIndex) > 0) searchModel.SortColumnDirection = sortDirection; // "asc" or "desc"
if (int.Parse(sortColumnIndex) > 0) searchModel.SortColumnDirection = sortDirection; // "asc" or "desc"
else searchModel.SortColumnDirection = "desc";
}
//else
@ -458,7 +459,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
//MeasurementService.OrderItemMeasuringReset
//Todo: ezt orderitiemnként kéne kirakni?? - Á.
var valami = await _measurementService.OrderItemMeasuringReset(model.OrderItemId);
return RedirectToAction("Edit", "Order", new { id = model.OrderId });
}
@ -587,7 +588,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
throw new Exception($"{errorText}");
}
if(orderProductItem.Price != product.Price)
if (orderProductItem.Price != product.Price)
{
//manual price change
unitPricesIncludeDiscounts = false;
@ -598,7 +599,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
}
var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store);
var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store);
await _orderService.InsertOrderItemAsync(orderItem);
await _productService.AdjustInventoryAsync(product, -orderItem.Quantity, orderItem.AttributesXml, string.Format(await _localizationService.GetResourceAsync("Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id));
@ -647,10 +648,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
}
// Calculate tax
//var (unitPriceInclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPrice, true, customer);
var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer);
// Calculate tax
//var (unitPriceInclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPrice, true, customer);
var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer);
return new OrderItem
{
OrderId = order.Id,
@ -664,7 +665,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
PriceInclTax = isMeasurable ? 0 : unitPriceInclTaxValue * orderProductItem.Quantity,
PriceExclTax = isMeasurable ? 0 : unitPriceExclTaxValue * orderProductItem.Quantity,
OriginalProductCost = await _priceCalculationService.GetProductCostAsync(product, null),
AttributeDescription = string.Empty,
@ -936,7 +937,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
var productDto = productDtosById[product.Id];
if (productDto != null)
{
if(productDto.AvailableQuantity > 0)
if (productDto.AvailableQuantity > 0)
{
result.Add(new
{
@ -954,6 +955,46 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
return Json(result);
}
[HttpGet]
[CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
public virtual async Task<IActionResult> ProductSearchUnfilteredAutoComplete(string term)
{
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
return Json(new List<object>());
const int maxResults = 30;
// Search products by name or SKU
var products = await _productService.SearchProductsAsync(
keywords: term,
pageIndex: 0,
pageSize: maxResults);
var result = new List<object>();
var productDtosById = await _dbContext.ProductDtos.GetAllByIds(products.Select(p => p.Id)).ToDictionaryAsync(k => k.Id, v => v);
foreach (var product in products)
{
var productDto = productDtosById[product.Id];
if (productDto != null)
{
result.Add(new
{
label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]",
value = product.Id,
sku = product.Sku,
price = product.Price,
stockQuantity = product.StockQuantity,
incomingQuantity = productDto.IncomingQuantity,
});
}
}
return Json(result);
}
//[HttpPost]
//public async Task<IActionResult> CreateInvoice(int orderId)
//{
@ -1362,21 +1403,21 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE))
return Json(new { success = false, message = "Access denied" });
if (string.IsNullOrEmpty(productsJson))
return Json(new { success = false, message = "No products data received" });
var order = await _orderService.GetOrderByIdAsync(orderId);
if (order == null || order.Deleted)
return Json(new { success = false, message = "Order not found" });
// Deserialize products
var products = productsJson.JsonTo<List<AddProductModel>>(); //JsonConvert.DeserializeObject<List<AddProductModel>>(productsJson);
if (products == null || products.Count == 0)
return Json(new { success = false, message = "No products to add" });
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
var store = await _storeContext.GetCurrentStoreAsync();
var admin = await _workContext.GetCurrentCustomerAsync();
@ -1409,6 +1450,272 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
return Json(new { success = false, message = $"Error: {ex.Message}" });
}
}
[HttpPost]
[ValidateAntiForgeryToken]
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
public async Task<IActionResult> SplitOrder(int orderId)
{
try
{
_logger.Info($"SplitOrder - OrderId: {orderId} - STARTED");
var order = await _orderService.GetOrderByIdAsync(orderId);
if (order == null || order.Deleted)
{
_logger.Warning($"SplitOrder - Order {orderId} not found or deleted");
return Json(new { success = false, message = "Rendelés nem található" });
}
_logger.Info($"SplitOrder - Order {orderId} found, checking access");
// Check if user has access to this order
if (!await HasAccessToOrderAsync(order))
{
_logger.Warning($"SplitOrder - No access to order {orderId}");
return Json(new { success = false, message = "Nincs jogosultsága ehhez a rendeléshez" });
}
_logger.Info($"SplitOrder - Getting OrderDto for order {orderId}");
var orderDto = await _dbContext.OrderDtos.GetByIdAsync(orderId, true);
if (orderDto == null)
{
_logger.Warning($"SplitOrder - OrderDto not found for order {orderId}");
return Json(new { success = false, message = "OrderDto nem található" });
}
// SAFETY CHECK: Don't allow splitting if order is complete/audited
if (orderDto.MeasuringStatus == MeasuringStatus.Audited)
{
_logger.Warning($"SplitOrder - Cannot split audited order {orderId}");
return Json(new
{
success = false,
message = "Ez a rendelés már auditált, nem választható szét!"
});
}
// SAFETY CHECK: Don't allow splitting if measuring hasn't started
if (orderDto.MeasuringStatus == MeasuringStatus.NotStarted)
{
_logger.Warning($"SplitOrder - Cannot split order {orderId} that hasn't been started");
return Json(new
{
success = false,
message = "Ez a rendelés még nem lett elkezdve, nem választható szét!"
});
}
_logger.Info($"SplitOrder - OrderDto found, separating items. Total items: {orderDto.OrderItemDtos.Count}, MeasuringStatus: {orderDto.MeasuringStatus}");
// Separate audited and non-audited items
var auditedItems = orderDto.OrderItemDtos.Where(oi => oi.IsAudited).ToList();
var nonAuditedItems = orderDto.OrderItemDtos.Where(oi => !oi.IsAudited).ToList();
_logger.Info($"SplitOrder - Audited items: {auditedItems.Count}, Non-audited items: {nonAuditedItems.Count}");
if (nonAuditedItems.Count == 0)
{
_logger.Warning($"SplitOrder - No non-audited items in order {orderId}");
return Json(new
{
success = false,
message = "Nincs nem auditált termék a rendelésben. Szétválasztás nem szükséges."
});
}
if (auditedItems.Count == 0)
{
_logger.Warning($"SplitOrder - All items are non-audited in order {orderId}");
return Json(new
{
success = false,
message = "Minden termék nem auditált. Szétválasztás nem szükséges."
});
}
_logger.Info($"SplitOrder - Getting customer, store, and admin");
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
var store = await _storeContext.GetCurrentStoreAsync();
var admin = await _workContext.GetCurrentCustomerAsync();
_logger.Info($"SplitOrder - Customer: {customer?.Id}, Store: {store?.Id}, Admin: {admin?.Id}");
_logger.Info($"SplitOrder - Creating new order (no transaction)");
// Create new order for non-audited items
var newOrder = new Order
{
OrderGuid = Guid.NewGuid(),
CustomOrderNumber = "",
CustomerId = order.CustomerId,
CustomerLanguageId = order.CustomerLanguageId,
CustomerTaxDisplayType = order.CustomerTaxDisplayType,
CustomerIp = order.CustomerIp,
OrderStatus = OrderStatus.Pending,
PaymentStatus = PaymentStatus.Pending,
ShippingStatus = ShippingStatus.ShippingNotRequired,
CreatedOnUtc = DateTime.UtcNow,
BillingAddressId = order.BillingAddressId,
ShippingAddressId = order.ShippingAddressId,
PaymentMethodSystemName = order.PaymentMethodSystemName,
CustomerCurrencyCode = order.CustomerCurrencyCode,
OrderTotal = 0,
OrderSubtotalInclTax = 0,
OrderSubtotalExclTax = 0,
OrderSubTotalDiscountInclTax = 0,
OrderSubTotalDiscountExclTax = 0,
};
_logger.Info($"SplitOrder - Inserting new order");
await _orderService.InsertOrderAsync(newOrder);
_logger.Info($"SplitOrder - New order inserted with ID: {newOrder.Id}");
newOrder.CustomOrderNumber = newOrder.Id.ToString();
await _orderService.UpdateOrderAsync(newOrder);
// Get original order items
_logger.Info($"SplitOrder - Getting original order items");
var originalOrderItems = await _orderService.GetOrderItemsAsync(orderId);
_logger.Info($"SplitOrder - Found {originalOrderItems.Count} original order items");
var orderItemsToMove = new List<OrderItem>();
// Find order items to move based on non-audited OrderItemDtos
foreach (var nonAuditedDto in nonAuditedItems)
{
var orderItemToMove = originalOrderItems.FirstOrDefault(oi => oi.Id == nonAuditedDto.Id);
if (orderItemToMove != null)
{
orderItemsToMove.Add(orderItemToMove);
}
}
_logger.Info($"SplitOrder - Found {orderItemsToMove.Count} items to move");
// Move non-audited items to new order
foreach (var orderItem in orderItemsToMove)
{
_logger.Info($"SplitOrder - Processing order item {orderItem.Id}");
var product = await _productService.GetProductByIdAsync(orderItem.ProductId);
if (product == null)
{
_logger.Warning($"Product with ID {orderItem.ProductId} not found during split");
continue;
}
// Create new order item for new order
var newOrderItem = new OrderItem
{
OrderId = newOrder.Id,
ProductId = orderItem.ProductId,
Quantity = orderItem.Quantity,
OrderItemGuid = Guid.NewGuid(),
UnitPriceInclTax = orderItem.UnitPriceInclTax,
UnitPriceExclTax = orderItem.UnitPriceExclTax,
PriceInclTax = orderItem.PriceInclTax,
PriceExclTax = orderItem.PriceExclTax,
OriginalProductCost = orderItem.OriginalProductCost,
AttributeDescription = orderItem.AttributeDescription,
AttributesXml = orderItem.AttributesXml,
DiscountAmountInclTax = orderItem.DiscountAmountInclTax,
DiscountAmountExclTax = orderItem.DiscountAmountExclTax,
DownloadCount = 0,
IsDownloadActivated = false,
LicenseDownloadId = 0,
ItemWeight = orderItem.ItemWeight,
RentalStartDateUtc = orderItem.RentalStartDateUtc,
RentalEndDateUtc = orderItem.RentalEndDateUtc
};
_logger.Info($"SplitOrder - Inserting new order item for product {orderItem.ProductId}");
await _orderService.InsertOrderItemAsync(newOrderItem);
// Update new order totals
newOrder.OrderSubtotalInclTax += newOrderItem.PriceInclTax;
newOrder.OrderSubtotalExclTax += newOrderItem.PriceExclTax;
newOrder.OrderTotal += newOrderItem.PriceInclTax;
_logger.Info($"SplitOrder - Adjusting inventory for product {orderItem.ProductId}");
// Return inventory to stock (from original order)
await _productService.AdjustInventoryAsync(
product,
orderItem.Quantity,
orderItem.AttributesXml,
$"Returned from split order #{order.Id}"
);
// Remove from stock (for new order)
await _productService.AdjustInventoryAsync(
product,
-orderItem.Quantity,
orderItem.AttributesXml,
$"Split to new order #{newOrder.Id}"
);
_logger.Info($"SplitOrder - Deleting order item {orderItem.Id} from original order");
// Delete from original order
await _orderService.DeleteOrderItemAsync(orderItem);
// Update original order totals
order.OrderSubtotalInclTax -= orderItem.PriceInclTax;
order.OrderSubtotalExclTax -= orderItem.PriceExclTax;
order.OrderTotal -= orderItem.PriceInclTax;
}
_logger.Info($"SplitOrder - Updating both orders");
// Update both orders
await _orderService.UpdateOrderAsync(newOrder);
await _orderService.UpdateOrderAsync(order);
_logger.Info($"SplitOrder - Adding order notes");
// Add notes to both orders
await InsertOrderNoteAsync(
order.Id,
false,
$"Rendelés szétválasztva. Nem auditált termékek átkerültek a #{newOrder.Id} rendelésbe. Művelet végrehajtója: {admin.FirstName} {admin.LastName} (ID: {admin.Id})"
);
await InsertOrderNoteAsync(
newOrder.Id,
false,
$"Új rendelés létrehozva a #{order.Id} rendelés szétválasztásával. Nem auditált termékek. Művelet végrehajtója: {admin.FirstName} {admin.LastName} (ID: {admin.Id})"
);
_logger.Info($"Order {orderId} split successfully. New order created: {newOrder.Id}. Moved {orderItemsToMove.Count} items.");
// Send notifications
_logger.Info($"SplitOrder - Sending notifications");
var originalOrderDto = await _dbContext.OrderDtos.GetByIdAsync(order.Id, true);
var newOrderDto = await _dbContext.OrderDtos.GetByIdAsync(newOrder.Id, true);
await _sendToClient.SendOrderChanged(originalOrderDto);
await _sendToClient.SendOrderChanged(newOrderDto);
_logger.Info($"SplitOrder - COMPLETED SUCCESSFULLY");
return Json(new
{
success = true,
message = "Rendelés sikeresen szétválasztva",
newOrderId = newOrder.Id,
originalOrderId = order.Id
});
}
catch (Exception ex)
{
_logger.Error($"Error splitting order {orderId}: {ex.Message}", ex);
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
}
}

View File

@ -1,11 +1,18 @@
using FruitBank.Common.Dtos;

using DocumentFormat.OpenXml.Vml;
using FruitBank.Common;
using FruitBank.Common.Dtos;
using FruitBank.Common.Entities;
using FruitBank.Common.Interfaces;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Nop.Core;
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.Plugin.Misc.FruitBankPlugin.Services.FileStorage;
using Nop.Services.Catalog;
using Nop.Services.Security;
using Nop.Web.Framework;
@ -28,6 +35,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
private readonly IProductService _productService;
private readonly FruitBankDbContext _dbContext;
private readonly PdfToImageService _pdfToImageService;
private readonly IWorkContext _workContext;
private readonly FileStorageService _fileStorageService;
public FileManagerController(
IPermissionService permissionService,
@ -35,7 +44,9 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
AICalculationService aiCalculationService,
IProductService productService,
FruitBankDbContext fruitBankDbContext,
PdfToImageService pdfToImageService)
PdfToImageService pdfToImageService,
IWorkContext workContext,
FileStorageService fileStorageService)
{
_permissionService = permissionService;
_aiApiService = aiApiService;
@ -43,6 +54,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
_productService = productService;
_dbContext = fruitBankDbContext;
_pdfToImageService = pdfToImageService;
_workContext = workContext;
_fileStorageService = fileStorageService;
}
/// <summary>
@ -57,7 +70,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
}
/// <summary>
/// Endpoint to extract text from uploaded image
/// Endpoint to extract text from uploaded image with duplicate detection
/// FIXED: Removed EF Core .Include() dependency
/// </summary>
[HttpPost]
public async Task<IActionResult> ExtractTextFromImage(IFormFile imageFile, string customPrompt = null)
@ -71,20 +85,114 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
}
// Validate file type - now including PDF
var extension = Path.GetExtension(imageFile.FileName).ToLowerInvariant();
var extension = System.IO.Path.GetExtension(imageFile.FileName).ToLowerInvariant();
if (extension != ".jpg" && extension != ".jpeg" && extension != ".png" &&
extension != ".gif" && extension != ".webp" && extension != ".pdf")
{
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
{
// ✅ STEP 1: Calculate file hash FIRST to check for duplicates
string fileHash;
using (var hashStream = imageFile.OpenReadStream())
{
using (var sha256 = System.Security.Cryptography.SHA256.Create())
{
var hashBytes = await Task.Run(() => sha256.ComputeHash(hashStream));
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
}
}
Console.WriteLine($"📝 Uploaded file hash: {fileHash}");
// ✅ STEP 2: Check if this file has already been processed
var existingFile = await _dbContext.Files
.GetAll()
.FirstOrDefaultAsync(f => f.FileHash == fileHash);
if (existingFile != null)
{
Console.WriteLine($"♻️ Duplicate file detected! File ID: {existingFile.Id}");
// Find the shipping document(s) associated with this file
var existingMappingList = await _dbContext.ShippingDocumentToFiles
.GetAll()
.Where(m => m.FilesId == existingFile.Id)
.OrderByDescending(m => m.Created).ToListAsync();
var existingMapping = existingMappingList.FirstOrDefault();
if (existingMapping != null)
{
// ✅ Load the existing shipping document (without .Include())
var existingShippingDoc = await _dbContext.ShippingDocuments
.GetByIdAsync(existingMapping.ShippingDocumentId);
if (existingShippingDoc != null)
{
Console.WriteLine($"✅ Found existing shipping document - ID: {existingShippingDoc.Id}");
// ✅ Manually load related data
var shippingItems = await _dbContext.ShippingItems
.GetAll()
.Where(si => si.ShippingDocumentId == existingShippingDoc.Id)
.ToListAsync();
Partner matchedPartner = null;
if (existingShippingDoc.PartnerId > 0)
{
matchedPartner = await _dbContext.Partners.GetByIdAsync(existingShippingDoc.PartnerId);
}
// Return the existing data instead of re-processing
return Json(new
{
success = true,
isDuplicate = true, // ✅ Flag so frontend can show warning
message = $"This document was already processed on {existingShippingDoc.ShippingDate.ToString("yyyy-MM-dd HH:mm")}. Loaded existing data.",
existingDocumentId = existingShippingDoc.Id,
shippingDocument = new
{
documentIdNumber = existingShippingDoc.DocumentIdNumber,
partnerId = existingShippingDoc.PartnerId,
partnerName = matchedPartner?.Name,
partnerTaxId = matchedPartner?.TaxId,
pdfFileName = existingShippingDoc.PdfFileName,
totalPallets = existingShippingDoc.TotalPallets,
shippingItems = 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,
unitPriceOnDocument = item.UnitPriceOnDocument
}).ToList(),
extractedText = existingFile.RawText
},
fileName = existingFile.FileName + existingFile.FileExtension,
fileSize = imageFile.Length,
wasConverted = extension == ".pdf"
});
}
}
// If we found the file but no shipping document, continue with extraction
Console.WriteLine("⚠️ File exists but no shipping document found. Continuing with extraction...");
}
// ✅ STEP 3: File is new or has no associated document - proceed with extraction
Console.WriteLine("🆕 New file detected. Starting AI extraction...");
ShippingDocument shippingDocument = new ShippingDocument();
shippingDocument.ShippingItems = new List<ShippingItem>();
// Define the uploads folder
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "ocr");
var uploadsFolder = System.IO.Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "ocr");
// Create directory if it doesn't exist
if (!Directory.Exists(uploadsFolder))
@ -100,7 +208,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
{
// Save the PDF temporarily
var tempPdfFileName = $"temp_pdf_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
var tempPdfPath = Path.Combine(uploadsFolder, tempPdfFileName);
var tempPdfPath = System.IO.Path.Combine(uploadsFolder, tempPdfFileName);
using (var stream = new FileStream(tempPdfPath, FileMode.Create))
{
@ -121,7 +229,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
// Use the first page
processedFilePath = convertedImages[0];
processedFileName = Path.GetFileName(processedFilePath);
processedFileName = System.IO.Path.GetFileName(processedFilePath);
// Clean up temp PDF
if (System.IO.File.Exists(tempPdfPath))
@ -131,7 +239,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
{
// Handle regular image files
processedFileName = $"ocr_image_{DateTime.Now:yyyyMMdd_HHmmss}{extension}";
processedFilePath = Path.Combine(uploadsFolder, processedFileName);
processedFilePath = System.IO.Path.Combine(uploadsFolder, processedFileName);
using (var stream = new FileStream(processedFilePath, FileMode.Create))
{
@ -158,14 +266,15 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
message = "Failed to extract text. The API may have returned an empty response."
});
}
OpenaiImageResponse deserializedContent = new();
var result = TextHelper.FixJsonWithoutAI(extractedText);
var options = new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true, // Handles camelCase/PascalCase mismatches
IncludeFields = true // This allows deserializing fields (in case you keep it as a field)
PropertyNameCaseInsensitive = true,
IncludeFields = true
};
try
@ -183,15 +292,11 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
Console.Error.WriteLine($"JSON content: {result}");
}
//string documentIdAnalysisResult = await ExtractDocumentId(deserializedContent.extractedData.fullText);
Console.WriteLine($"Document number analysis Result: {deserializedContent.extractedData.documentId}");
shippingDocument.DocumentIdNumber = deserializedContent.extractedData.documentId;
string partnerAnalysis = await ExtractPartnerName(extractedText);
//int? dbPartnerName = await DeterminePartner(deserializedContent.extractedData.partner.name);
int? dbPartnerName = await DeterminePartner(partnerAnalysis);
if (dbPartnerName != null)
{
@ -203,21 +308,17 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
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
// Identify products from database
var allProducts = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
var historicalProducts = await _dbContext.ShippingItems.GetAll().ToListAsync();
//create json from product analyzis jsonstring
ProductReferenceResponse deserializedProducts = new ProductReferenceResponse();
//deserializedProducts.products = new List<ProductReference>();
deserializedProducts.products = deserializedContent.extractedData.products;
Console.WriteLine($"Serialized Products: {deserializedProducts.products.Count}");
List<ShippingItem> matchedProducts = new List<ShippingItem>();
//do we have historical references?
matchedProducts = await DetermineProducts(allProducts, historicalProducts, deserializedProducts);
shippingDocument.ShippingItems = matchedProducts;
@ -236,27 +337,21 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
}
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;
// Get partner details if matched
Partner matchedPartner2 = null;
if (shippingDocument.PartnerId > 0)
{
matchedPartner2 = await _dbContext.Partners.GetByIdAsync(shippingDocument.PartnerId);
}
return Json(new
{
success = true,
isDuplicate = false, // ✅ Flag indicating this is new extraction
message = extension == ".pdf"
? "PDF converted and text extracted successfully"
: "Text extracted successfully",
@ -264,6 +359,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
{
documentIdNumber = shippingDocument.DocumentIdNumber,
partnerId = shippingDocument.PartnerId,
partnerName = matchedPartner2?.Name,
partnerTaxId = matchedPartner2?.TaxId,
pdfFileName = shippingDocument.PdfFileName,
totalPallets = shippingDocument.TotalPallets,
shippingItems = shippingDocument.ShippingItems?.Select(item => new
@ -276,7 +373,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
quantityOnDocument = item.QuantityOnDocument,
netWeightOnDocument = item.NetWeightOnDocument,
grossWeightOnDocument = item.GrossWeightOnDocument,
isMeasurable = item.IsMeasurable
unitPriceOnDocument = item.UnitPriceOnDocument
}).ToList(),
extractedText = deserializedContent.extractedData.fullText
},
@ -586,87 +683,6 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
return cleaned;
}
private async Task<string> TestFullResult(string extractedText)
{
string fullResultPrompt = $"Role:\r\nYou are an AI data extraction assistant for Fruitbank, a " +
$"fruit and vegetable wholesale company. Your task is to analyze a " +
$"provided text (delivery notes, invoices, or order confirmations) and extract structured information about " +
$"the shipment and its items.\r\n\r\n🎯 Goal:\r\nRead the provided text and extract all shipment " +
$"details and items according to the data model below.\r\n Generate the complete JSON output following this " +
$"structure.\r\n\r\n🧩 Data Models:\r\n\r\npublic " +
$"class Partner\r\n{{\r\n " +
$"/// <summary>\r\n /// Partner entity primary key\r\n /// </summary>\r\n " +
$"public int Id {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// Partner company name\r\n /// </summary>\r\n " +
$"public string Name {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// Partner company TaxId\r\n /// </summary>\r\n " +
$"public string TaxId {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company Certification if exists\r\n /// </summary>\r\n " +
$"public string CertificationNumber {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address PostalCode\r\n /// </summary>\r\n " +
$"public string PostalCode {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address Country\r\n /// </summary>\r\n " +
$"public string Country {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address State if exists\r\n /// </summary>\r\n " +
$"public string State {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address County if exists\r\n /// </summary>\r\n " +
$"public string County {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address City\r\n /// </summary>\r\n " +
$"public string City {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address Street\r\n /// </summary>\r\n " +
$"public string Street {{ get; set; }}\r\n\t/// <summary>\r\n " +
$"/// Entities of ShippingDocument\r\n /// </summary>\r\n\tpublic List<ShippingDocument> " +
$"ShippingDocuments {{ get; set; }}\t\r\n}}\r\n\r\npublic class ShippingDocument\r\n{{\r\n " +
$"/// <summary>\r\n /// ShippingItem entity primary key\r\n /// </summary>\r\n " +
$"public int Id {{ get; set; }}\r\n /// <summary>\r\n /// Partner entity primary key\r\n " +
$"/// </summary>\r\n public int PartnerId {{ get; set; }}\t\r\n\t/// <summary>\r\n " +
$"/// Entities of ShippingItem\r\n /// </summary>\r\n\t" +
$"public List<ShippingItem> ShippingItems {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// DocumentIdNumber if exists\r\n /// </summary>\r\n public string DocumentIdNumber {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// \r\n /// </summary>\r\n public DateTime ShippingDate {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// Shipping pickup Contry of origin\r\n /// </summary>\r\n " +
$"public string Country {{ get; set; }}\r\n\t/// <summary>\r\n /// Sum of ShippingItem pallets\r\n " +
$"/// </summary>\r\n public int TotalPallets {{ get; set; }}\r\n\t/// <summary>\r\n " +
$"/// Filename of pdf\r\n /// </summary>\r\n\tpublic string PdfFileName {{ get; set; }}\r\n}}\r\n\r\n" +
$"public class ShippingItem\r\n{{\r\n /// <summary>\r\n /// ShippingItem entity primary key\r\n /// " +
$"</summary>\r\n public int Id {{ get; set; }}\r\n /// <summary>\r\n /// " +
$"ShippingDocument entity primary key\r\n /// </summary>\r\n " +
$"public int ShippingDocumentId {{ get; set; }}\r\n /// " +
$"<summary>\r\n /// Name of the fruit or vegitable\r\n /// </summary>\r\n " +
$"public string Name {{ get; set; }}\r\n\t/// <summary>\r\n /// Translated Name to Hungarian\r\n " +
$"/// </summary>\r\n public string HungarianName {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Pallets of fruit or vegitable item\r\n /// </summary>\r\n " +
$"public int PalletsOnDocument {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Quantity of fruit or vegitable item\r\n /// </summary>\r\n " +
$"public int QuantityOnDocument {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Net weight in kg. of fruit or vegitable item\r\n /// </summary>\r\n " +
$"public double NetWeightOnDocument {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Gross weight in kg. of fruit or vegitable item\r\n /// </summary>\r\n " +
$"public double GrossWeightOnDocument {{ get; set; }}\r\n}}\r\n\r\n🧾 Output Requirements\r\n- " +
$"Output must be a single valid JSON object containing:\r\n- One Partner object\r\n- " +
$"One ShippingDocument object\r\n- A list of all related ShippingItem objects\r\n\r\n- " +
$"Primary keys (Partner.Id, ShippingDocument.Id, ShippingItem.Id) should be auto-generated integers " +
$"(e.g. sequential: 1, 2, 3…).\r\n\r\n- When a field is missing or unclear, return it as an empty " +
$"string or 0 (depending on type).\r\nDo not omit any fields.\r\n\r\n- " +
$"All dates must be in ISO 8601 format (yyyy-MM-dd).\r\n\r\n🧭 Instructions to the AI\r\n" +
$"1. Analyze the provided text carefully.\r\n" +
$"2. Identify the Partner/Company details of THE OTHER PARTY (other than Fruitbank), " +
$"document identifiers, and each shipment item.\r\n" +
$"3. FruitBank is not a partner! Always look for THE OTHER partner on the document. \r\n " +
$"4. Generate a complete hierarchical JSON of ALL received documents in ONE JSON structure according to the " +
$"data model above.\r\n5. Do not include any explanations or text outside the JSON output. " +
$"Only return the structured JSON.\r\n" +
$"6. A teljes ShippingItem.Name-et tedd bele a ShippingItem.HungarianName-be " +
$"és a zöldség vagy gyümölcs nevét fordítsd le magyarra!\r\n" +
$"7. A ShippingDocument-et tedd bele a Partner entitásba!\r\n" +
$"8. ShippingItem-eket tedd bele a ShippingDocument-be!\r\n" +
$"9. Do not assume or modify any data, if you don't find a value, return null, if you find a value, keep it unmodified.\r\n" +
$"10. Magyarázat nélkül válaszolj!";
var fullresult = await _aiApiService.GetSimpleResponseAsync(fullResultPrompt, extractedText);
return fullresult;
}
private async Task<int> DeterminePartner(string partnerAnalysis)
{
// Clean the input first
@ -887,8 +903,214 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
// //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}");
//}
/// <summary>
/// Partner search autocomplete endpoint for finding partners by name or tax ID
/// </summary>
[HttpGet]
public async Task<IActionResult> PartnerSearchAutoComplete(string term)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
return Json(new List<object>());
try
{
const int maxResults = 20;
// Get all partners and filter in-memory (or create a custom search method)
var allPartners = await _dbContext.Partners.GetAll().ToListAsync();
var matchedPartners = allPartners
.Where(p =>
(!string.IsNullOrEmpty(p.Name) && p.Name.Contains(term, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrEmpty(p.TaxId) && p.TaxId.Contains(term, StringComparison.OrdinalIgnoreCase))
)
.Take(maxResults)
.ToList();
var result = matchedPartners.Select(partner => new
{
label = $"{partner.Name} - Tax ID: {partner.TaxId ?? "N/A"}",
value = partner.Id,
name = partner.Name,
taxId = partner.TaxId,
city = partner.City,
country = partner.Country
}).ToList();
return Json(result);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error in PartnerSearchAutoComplete: {ex}");
return Json(new List<object>());
}
}
/// <summary>
/// Save a shipping document with its items AND the original uploaded file
/// </summary>
[HttpPost]
public async Task<IActionResult> SaveShippingDocument(
[FromForm] string documentData,
[FromForm] IFormFile originalFile,
[FromForm] string extractedText)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (string.IsNullOrEmpty(documentData))
{
return Json(new { success = false, message = "Invalid request data" });
}
try
{
// ✅ Configure JSON options to handle camelCase from JavaScript
var jsonOptions = new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true // This handles camelCase <-> PascalCase
};
// Deserialize the shipping document data
var request = System.Text.Json.JsonSerializer.Deserialize<SaveShippingDocumentRequest>(documentData, jsonOptions);
if (request == null)
{
return Json(new { success = false, message = "Failed to parse document data" });
}
// Validate required fields
if (string.IsNullOrEmpty(request.DocumentIdNumber))
{
return Json(new { success = false, message = "Document ID is required" });
}
if (!request.PartnerId.HasValue || request.PartnerId <= 0)
{
return Json(new { success = false, message = "Valid Partner ID is required" });
}
if (request.ShippingItems == null || !request.ShippingItems.Any())
{
return Json(new { success = false, message = "At least one shipping item is required" });
}
var partner = await _dbContext.Partners.GetByIdAsync(request.PartnerId.Value);
// Create the shipping document entity
var shippingDocument = new ShippingDocument
{
DocumentIdNumber = request.DocumentIdNumber,
PartnerId = request.PartnerId.Value,
Country = partner?.Country,
TotalPallets = request.TotalPallets,
PdfFileName = originalFile.FileName,
ShippingDate = DateTime.UtcNow,
ShippingItems = new List<ShippingItem>()
};
// Convert DTOs to entities
foreach (var itemDto in request.ShippingItems)
{
var productDto = await _dbContext.ProductDtos.GetByIdAsync(itemDto.ProductId ?? 0, true);
if (productDto != null && string.IsNullOrEmpty(itemDto.Name))
{
itemDto.IsMeasurable = productDto.IsMeasurable;
}
var shippingItem = new ShippingItem
{
Name = itemDto.Name,
HungarianName = itemDto.HungarianName,
NameOnDocument = itemDto.NameOnDocument,
ProductId = itemDto.ProductId,
PalletsOnDocument = itemDto.PalletsOnDocument,
QuantityOnDocument = itemDto.QuantityOnDocument,
NetWeightOnDocument = itemDto.NetWeightOnDocument,
GrossWeightOnDocument = itemDto.GrossWeightOnDocument,
UnitPriceOnDocument = itemDto.UnitPriceOnDocument,
IsMeasurable = itemDto.IsMeasurable
};
shippingDocument.ShippingItems.Add(shippingItem);
}
// ✅ STEP 1: Save shipping document to database FIRST
await _dbContext.ShippingDocuments.InsertAsync(shippingDocument);
foreach (var sItem in shippingDocument.ShippingItems)
{
sItem.ShippingDocumentId = shippingDocument.Id; // Set FK
await _dbContext.ShippingItems.InsertAsync(sItem);
}
Console.WriteLine($"✓ Shipping document saved - ID: {shippingDocument.Id}, Document ID: {shippingDocument.DocumentIdNumber}");
// ✅ STEP 2: NOW save the original file (only if document saved successfully)
if (originalFile != null && originalFile.Length > 0)
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
using (var fileStream = originalFile.OpenReadStream())
{
var savedFile = await _fileStorageService.SaveFileAsync(
fileStream: fileStream,
fileName: originalFile.FileName,
userId: currentUser.Id,
featureName: "ShippingDocumentProcessing",
entityType: "ShippingDocuments",
entityId: shippingDocument.Id, // ✅ Now we have the real ID!
rawText: extractedText,
checkForDuplicates: true
);
// Create mapping between ShippingDocument and File
var mapping = new ShippingDocumentToFiles
{
FilesId = savedFile.Id,
ShippingDocumentId = shippingDocument.Id,
DocumentTypeId = (int)DocumentType.ShippingDocument,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow
};
await _dbContext.ShippingDocumentToFiles.InsertAsync(mapping);
Console.WriteLine($"✓ Original file saved - File ID: {savedFile.Id}, Filename: {savedFile.FileName}");
}
}
else
{
Console.WriteLine("⚠ No original file provided - skipping file save");
}
return Json(new
{
success = true,
message = "Shipping document and file saved successfully",
shippingDocumentId = shippingDocument.Id,
documentId = shippingDocument.DocumentIdNumber,
itemCount = shippingDocument.ShippingItems.Count
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error saving shipping document: {ex}");
return Json(new
{
success = false,
message = $"Error saving document: {ex.Message}"
});
}
}
}
public class ProductReference
{
public string? name { get; set; }
@ -904,4 +1126,27 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
public List<ProductReference> products { get; set; }
}
public class SaveShippingDocumentRequest
{
public string DocumentIdNumber { get; set; }
public int? PartnerId { get; set; }
public int TotalPallets { get; set; }
public string PdfFileName { get; set; }
public List<SaveShippingItemDto> ShippingItems { get; set; }
}
public class SaveShippingItemDto
{
public string Name { get; set; }
public string HungarianName { get; set; }
public string NameOnDocument { get; set; }
public int? ProductId { get; set; }
public int PalletsOnDocument { get; set; }
public int QuantityOnDocument { get; set; }
public double NetWeightOnDocument { get; set; }
public double GrossWeightOnDocument { get; set; }
public double UnitPriceOnDocument { get; set; }
public bool IsMeasurable { get; set; }
}
}

View File

@ -0,0 +1,684 @@
using FruitBank.Common.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage;
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.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBank.Controllers
{
[AuthorizeAdmin]
[Area(AreaNames.ADMIN)]
[AutoValidateAntiforgeryToken]
public class FileStorageController : BasePluginController
{
private readonly FileStorageService _fileStorageService;
private readonly FruitBankDbContext _dbContext;
private readonly IPermissionService _permissionService;
private readonly IWorkContext _workContext;
public FileStorageController(
FileStorageService fileStorageService,
FruitBankDbContext dbContext,
IPermissionService permissionService,
IWorkContext workContext)
{
_fileStorageService = fileStorageService;
_dbContext = dbContext;
_permissionService = permissionService;
_workContext = workContext;
}
#region Upload Files
/// <summary>
/// Upload a single file
/// </summary>
/// <param name="file">The uploaded file</param>
/// <param name="featureName">Feature name (e.g., "AIdocumentprocessing")</param>
/// <param name="entityType">Entity type (e.g., "ShippingDocuments")</param>
/// <param name="entityId">Entity ID</param>
/// <param name="rawText">Optional raw text for searchable documents</param>
[HttpPost]
public async Task<IActionResult> UploadFile(
IFormFile file,
string featureName,
string entityType,
int entityId,
string rawText = null)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file uploaded" });
if (string.IsNullOrWhiteSpace(featureName))
return Json(new { success = false, message = "Feature name is required" });
if (string.IsNullOrWhiteSpace(entityType))
return Json(new { success = false, message = "Entity type is required" });
if (entityId <= 0)
return Json(new { success = false, message = "Valid entity ID is required" });
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
using (var stream = file.OpenReadStream())
{
var fileEntity = await _fileStorageService.SaveFileAsync(
fileStream: stream,
fileName: file.FileName,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId,
rawText: rawText
);
return Json(new
{
success = true,
message = "File uploaded successfully",
file = new
{
id = fileEntity.Id,
fileName = fileEntity.FileName,
fileExtension = fileEntity.FileExtension,
created = fileEntity.Created,
hasRawText = !string.IsNullOrEmpty(fileEntity.RawText)
}
});
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error uploading file: {ex}");
return Json(new
{
success = false,
message = $"Error uploading file: {ex.Message}"
});
}
}
/// <summary>
/// Upload multiple files at once
/// </summary>
[HttpPost]
public async Task<IActionResult> UploadMultipleFiles(
List<IFormFile> files,
string featureName,
string entityType,
int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (files == null || files.Count == 0)
return Json(new { success = false, message = "No files uploaded" });
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
var uploadedFiles = new List<object>();
var errors = new List<string>();
foreach (var file in files)
{
try
{
if (file.Length > 0)
{
using (var stream = file.OpenReadStream())
{
var fileEntity = await _fileStorageService.SaveFileAsync(
fileStream: stream,
fileName: file.FileName,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId
);
uploadedFiles.Add(new
{
id = fileEntity.Id,
fileName = fileEntity.FileName + fileEntity.FileExtension
});
}
}
}
catch (Exception ex)
{
errors.Add($"{file.FileName}: {ex.Message}");
}
}
return Json(new
{
success = uploadedFiles.Count > 0,
message = $"Uploaded {uploadedFiles.Count} of {files.Count} files",
files = uploadedFiles,
errors = errors
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error uploading multiple files: {ex}");
return Json(new
{
success = false,
message = $"Error: {ex.Message}"
});
}
}
#endregion
#region Download Files
/// <summary>
/// Download a file by ID
/// </summary>
[HttpGet]
public async Task<IActionResult> DownloadFile(
int fileId,
string featureName,
string entityType,
int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Unauthorized();
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
var (fileStream, fileInfo) = await _fileStorageService.GetFileByIdAsync(
fileId: fileId,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId
);
var fileName = $"{fileInfo.FileName}{fileInfo.FileExtension}";
var contentType = GetContentType(fileInfo.FileExtension);
return File(fileStream, contentType, fileName);
}
catch (FileNotFoundException ex)
{
return NotFound(new { success = false, message = ex.Message });
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error downloading file: {ex}");
return BadRequest(new { success = false, message = ex.Message });
}
}
/// <summary>
/// Preview a file inline (for PDFs, images)
/// </summary>
[HttpGet]
public async Task<IActionResult> PreviewFile(
int fileId,
string featureName,
string entityType,
int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Unauthorized();
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
var (fileStream, fileInfo) = await _fileStorageService.GetFileByIdAsync(
fileId: fileId,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId
);
var contentType = GetContentType(fileInfo.FileExtension);
// Return inline for preview
return File(fileStream, contentType);
}
catch (FileNotFoundException ex)
{
return NotFound(new { success = false, message = ex.Message });
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error previewing file: {ex}");
return BadRequest(new { success = false, message = ex.Message });
}
}
#endregion
#region List & Search Files
/// <summary>
/// Get all files
/// </summary>
[HttpGet]
public async Task<IActionResult> GetAllFiles()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
try
{
var files = await _fileStorageService.GetAllFilesAsync();
return Json(new
{
success = true,
count = files.Count,
files = files.Select(f => new
{
id = f.Id,
fileName = f.FileName,
fileExtension = f.FileExtension,
fullName = $"{f.FileName}{f.FileExtension}",
created = f.Created,
modified = f.Modified,
hasRawText = !string.IsNullOrEmpty(f.RawText)
})
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error getting files: {ex}");
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>
/// Search files by filename or content
/// </summary>
[HttpGet]
public async Task<IActionResult> SearchFiles(string searchTerm)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (string.IsNullOrWhiteSpace(searchTerm))
return Json(new { success = false, message = "Search term is required" });
try
{
var files = await _fileStorageService.SearchFilesAsync(searchTerm);
return Json(new
{
success = true,
searchTerm = searchTerm,
count = files.Count,
files = files.Select(f => new
{
id = f.Id,
fileName = f.FileName,
fileExtension = f.FileExtension,
fullName = $"{f.FileName}{f.FileExtension}",
created = f.Created,
hasRawText = !string.IsNullOrEmpty(f.RawText),
// Include snippet of matched text if available
textSnippet = GetTextSnippet(f.RawText, searchTerm)
})
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error searching files: {ex}");
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>
/// Get files for a specific entity
/// </summary>
[HttpGet]
public async Task<IActionResult> GetEntityFiles(string entityType, int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
try
{
// Example for ShippingDocuments - adapt for other entity types
if (entityType == "ShippingDocuments")
{
var mappings = await _dbContext.ShippingDocumentToFiles
.GetAll()
.Where(m => m.ShippingDocumentId == entityId)
.ToListAsync();
var fileIds = mappings.Select(m => m.FilesId).ToList();
var files = await _dbContext.Files
.GetAll()
.Where(f => fileIds.Contains(f.Id))
.ToListAsync();
return Json(new
{
success = true,
entityType = entityType,
entityId = entityId,
count = files.Count,
files = files.Select(f => new
{
id = f.Id,
fileName = $"{f.FileName}{f.FileExtension}",
created = f.Created,
documentType = mappings.FirstOrDefault(m => m.FilesId == f.Id)?.DocumentType.ToString()
})
});
}
return Json(new
{
success = false,
message = $"Entity type '{entityType}' not supported yet"
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error getting entity files: {ex}");
return Json(new { success = false, message = ex.Message });
}
}
#endregion
#region Delete Files
/// <summary>
/// Delete a file by ID
/// </summary>
[HttpPost]
public async Task<IActionResult> DeleteFile(
int fileId,
string featureName,
string entityType,
int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
var deleted = await _fileStorageService.DeleteFileAsync(
fileId: fileId,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId
);
if (deleted)
{
// Also delete mappings (example for ShippingDocuments)
if (entityType == "ShippingDocuments")
{
var mappings = await _dbContext.ShippingDocumentToFiles
.GetAll()
.Where(m => m.FilesId == fileId && m.ShippingDocumentId == entityId)
.ToListAsync();
foreach (var mapping in mappings)
{
await _dbContext.ShippingDocumentToFiles.DeleteAsync(mapping);
}
}
}
return Json(new
{
success = deleted,
message = deleted ? "File deleted successfully" : "File not found"
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error deleting file: {ex}");
return Json(new
{
success = false,
message = $"Error deleting file: {ex.Message}"
});
}
}
/// <summary>
/// Delete multiple files at once
/// </summary>
[HttpPost]
public async Task<IActionResult> DeleteMultipleFiles(
List<int> fileIds,
string featureName,
string entityType,
int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (fileIds == null || fileIds.Count == 0)
return Json(new { success = false, message = "No file IDs provided" });
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
var deletedCount = 0;
var errors = new List<string>();
foreach (var fileId in fileIds)
{
try
{
var deleted = await _fileStorageService.DeleteFileAsync(
fileId: fileId,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId
);
if (deleted)
{
deletedCount++;
}
}
catch (Exception ex)
{
errors.Add($"File {fileId}: {ex.Message}");
}
}
return Json(new
{
success = deletedCount > 0,
message = $"Deleted {deletedCount} of {fileIds.Count} files",
deletedCount = deletedCount,
errors = errors
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error deleting multiple files: {ex}");
return Json(new
{
success = false,
message = $"Error: {ex.Message}"
});
}
}
#endregion
#region File Information
/// <summary>
/// Get file information by ID
/// </summary>
[HttpGet]
public async Task<IActionResult> GetFileInfo(int fileId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
try
{
var fileEntity = await _dbContext.Files.GetByIdAsync(fileId);
if (fileEntity == null)
return NotFound(new { success = false, message = "File not found" });
return Json(new
{
success = true,
file = new
{
id = fileEntity.Id,
fileName = fileEntity.FileName,
fileExtension = fileEntity.FileExtension,
fullName = $"{fileEntity.FileName}{fileEntity.FileExtension}",
created = fileEntity.Created,
modified = fileEntity.Modified,
hasRawText = !string.IsNullOrEmpty(fileEntity.RawText),
rawTextLength = fileEntity.RawText?.Length ?? 0
}
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error getting file info: {ex}");
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>
/// Update file metadata (RawText)
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdateFileMetadata(int fileId, string rawText)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
try
{
var fileEntity = await _dbContext.Files.GetByIdAsync(fileId);
if (fileEntity == null)
return NotFound(new { success = false, message = "File not found" });
fileEntity.RawText = rawText;
await _fileStorageService.AddOrUpdateFileAsync(fileEntity);
return Json(new
{
success = true,
message = "File metadata updated successfully"
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error updating file metadata: {ex}");
return Json(new
{
success = false,
message = $"Error updating metadata: {ex.Message}"
});
}
}
#endregion
#region Helper Methods
/// <summary>
/// Get content type based on file extension
/// </summary>
private string GetContentType(string extension)
{
return extension.ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".bmp" => "image/bmp",
".txt" => "text/plain",
".csv" => "text/csv",
".json" => "application/json",
".xml" => "application/xml",
".html" => "text/html",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls" => "application/vnd.ms-excel",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".zip" => "application/zip",
".rar" => "application/x-rar-compressed",
_ => "application/octet-stream"
};
}
/// <summary>
/// Get a snippet of text around the search term
/// </summary>
private string GetTextSnippet(string text, string searchTerm, int contextLength = 100)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(searchTerm))
return null;
var index = text.IndexOf(searchTerm, StringComparison.OrdinalIgnoreCase);
if (index == -1)
return null;
var start = Math.Max(0, index - contextLength);
var length = Math.Min(text.Length - start, contextLength * 2 + searchTerm.Length);
var snippet = text.Substring(start, length);
if (start > 0)
snippet = "..." + snippet;
if (start + length < text.Length)
snippet = snippet + "...";
return snippet;
}
#endregion
}
}

View File

@ -1,5 +1,5 @@
@{
Layout = "_ConfigurePlugin";
Layout = "../_FruitBankEmptyAdminLayout.cshtml";
}
@await Component.InvokeAsync("StoreScopeConfiguration")
@ -60,12 +60,12 @@
</div>
</div>
<!-- Shipping Document Section -->
<!-- Shipping Document Section - Now Editable -->
<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 -->
<!-- Document Info Card - Now Editable -->
<div class="card card-primary">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-file-alt"></i> Document Information</h5>
@ -73,47 +73,66 @@
<div class="card-body">
<div class="row">
<div class="col-md-4">
<strong>Document ID:</strong>
<p id="documentIdNumber" class="text-muted">-</p>
<div class="form-group">
<label for="editDocumentIdNumber">Document ID:</label>
<input type="text" class="form-control" id="editDocumentIdNumber">
</div>
</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 class="col-md-8">
<div class="form-group">
<label for="editPartnerName">Partner:</label>
<input type="text" class="form-control partner-search-input" id="editPartnerName" placeholder="Type to search partners...">
<small class="form-text text-muted">
<span id="partnerIdDisplay"></span>
<span id="partnerTaxIdDisplay"></span>
</small>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<strong>PDF Filename:</strong>
<p id="pdfFileName" class="text-muted">-</p>
<div class="col-md-4">
<div class="form-group">
<label for="editTotalPallets">Total Pallets:</label>
<input type="number" class="form-control" id="editTotalPallets" readonly>
<small class="form-text text-muted">Auto-calculated from items</small>
</div>
</div>
<div class="col-md-8">
<div class="form-group">
<label for="editPdfFileName">PDF Filename:</label>
<input type="text" class="form-control" id="editPdfFileName" readonly>
</div>
</div>
</div>
</div>
</div>
<!-- Shipping Items Table -->
<!-- Shipping Items Table - Now Editable -->
<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>
<h5 class="mb-0">
<i class="fas fa-boxes"></i> Shipping Items (<span id="itemCount">0</span>)
<button type="button" id="addItemButton" class="btn btn-sm btn-success float-right">
<i class="fas fa-plus"></i> Add Item
</button>
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-striped" id="shippingItemsTable">
<table class="table table-bordered table-hover" id="shippingItemsTable">
<thead class="thead-dark">
<tr>
<th>#</th>
<th style="width: 50px;">#</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>
<th style="width: 100px;">Product ID</th>
<th style="width: 80px;">Pallets</th>
<th style="width: 80px;">Quantity</th>
<th style="width: 100px;">Net Weight (kg)</th>
<th style="width: 100px;">Gross Weight (kg)</th>
<th style="width: 100px;">Unit Cost</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody id="shippingItemsBody">
@ -124,6 +143,15 @@
</div>
</div>
<!-- Save Button -->
<div class="card card-success mt-3">
<div class="card-body">
<button type="button" id="saveShippingDocumentButton" class="btn btn-success btn-lg btn-block">
<i class="fas fa-save"></i> Save Shipping Document
</button>
</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;">
@ -146,6 +174,71 @@
</div>
</div>
<style>
.autocomplete-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-top: none;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.autocomplete-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.autocomplete-item:hover {
background-color: #f8f9fa;
}
.autocomplete-item:last-child {
border-bottom: none;
}
.product-search-input {
background-color: #fff3cd !important; /* Yellow for unmatched */
}
.product-search-input.matched-product {
background-color: #d1ecf1 !important; /* Light blue for matched (changeable) */
}
.partner-search-input-unmatched {
background-color: #fff3cd !important; /* Yellow for unmatched */
}
.partner-search-input-matched {
background-color: #d1ecf1 !important; /* Light blue for matched */
}
/* Add to existing styles */
.border-warning {
border: 2px solid #ffc107 !important;
}
.duplicate-warning-banner {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
margin-bottom: 15px;
border-radius: 4px;
}
.duplicate-warning-banner i {
color: #856404;
margin-right: 10px;
}
</style>
<script>
const imageFileInput = document.getElementById('imageFile');
const customPromptInput = document.getElementById('customPrompt');
@ -160,8 +253,17 @@
const extractedText = document.getElementById('extractedText');
const copyButton = document.getElementById('copyButton');
const fileLabel = document.querySelector('.custom-file-label');
const addItemButton = document.getElementById('addItemButton');
const saveShippingDocumentButton = document.getElementById('saveShippingDocumentButton');
let selectedFile = null;
let shippingItems = [];
let currentPartner = { id: null, name: '', taxId: '' };
let originalUploadedFile = null; // Store the original file for later save
let extractedFullText = ''; // Store extracted text for file metadata
let isKnownDuplicate = false; // Track if current document is a duplicate
// Update file label and enable upload button when file is selected
imageFileInput.addEventListener('change', (event) => {
@ -204,7 +306,7 @@
shippingDocumentSection.style.display = 'none';
});
uploadButton.addEventListener('click', async () => {
uploadButton.addEventListener('click', async () => {
if (!selectedFile) {
showMessage('Please select a file first!', 'warning');
return;
@ -239,14 +341,31 @@
const result = await response.json();
if (response.ok && result.success) {
const message = result.wasConverted
? 'PDF converted and text extracted successfully!'
: 'Text extracted successfully!';
showMessage(message, 'success');
// ✅ Check if this is a duplicate document
if (result.isDuplicate) {
isKnownDuplicate = true; // ✅ Set duplicate flag
// Show warning message with different style
showMessage(
`⚠️ DUPLICATE DETECTED: ${result.message} You can review/edit the data or upload a different document.`,
'warning'
);
console.log('📋 Loaded existing document ID:', result.existingDocumentId);
} else {
isKnownDuplicate = false; // ✅ Clear duplicate flag
// Normal success message for new documents
const message = result.wasConverted
? 'PDF converted and text extracted successfully!'
: 'Text extracted successfully!';
showMessage(message, 'success');
}
// Display shipping document data
// ✅ Store the file reference (for new documents or if user wants to create duplicate)
originalUploadedFile = selectedFile;
extractedFullText = result.shippingDocument.extractedText || '';
// Display shipping document data (works for both new and duplicate)
if (result.shippingDocument) {
displayShippingDocument(result.shippingDocument);
displayShippingDocument(result.shippingDocument, result.isDuplicate);
document.getElementById('shippingDocumentSection').style.display = 'block';
}
} else {
@ -281,53 +400,505 @@
});
});
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';
// Add new item button handler
addItemButton.addEventListener('click', () => {
addNewShippingItem();
});
// Save button handler
saveShippingDocumentButton.addEventListener('click', async () => {
await saveShippingDocument();
});
function displayShippingDocument(shippingDoc, isDuplicate = false) {
// Populate document information fields
document.getElementById('editDocumentIdNumber').value = shippingDoc.documentIdNumber || '';
document.getElementById('editTotalPallets').value = shippingDoc.totalPallets || '0';
document.getElementById('editPdfFileName').value = shippingDoc.pdfFileName || '';
// ✅ OPTIONAL: Add visual indicator if duplicate
if (isDuplicate) {
const documentCard = document.querySelector('.card-primary');
documentCard.classList.add('border-warning');
// Add duplicate badge to header
const cardHeader = documentCard.querySelector('.card-header h5');
if (!cardHeader.querySelector('.badge-warning')) {
cardHeader.innerHTML += ' <span class="badge badge-warning ml-2">DUPLICATE</span>';
}
}
// Populate partner information
currentPartner = {
id: shippingDoc.partnerId || null,
name: shippingDoc.partnerName || '',
taxId: shippingDoc.partnerTaxId || ''
};
updatePartnerDisplay();
// Populate extracted text (collapsible section)
extractedText.textContent = shippingDoc.extractedText || 'No text extracted';
// Populate shipping items table
// Store items in global array
shippingItems = shippingDoc.shippingItems || [];
// Render items table
renderShippingItemsTable();
// Initialize partner autocomplete
initializePartnerAutocomplete();
}
function updatePartnerDisplay() {
const partnerNameInput = document.getElementById('editPartnerName');
const partnerIdDisplay = document.getElementById('partnerIdDisplay');
const partnerTaxIdDisplay = document.getElementById('partnerTaxIdDisplay');
if (currentPartner.id) {
partnerNameInput.value = currentPartner.name;
partnerNameInput.classList.remove('partner-search-input-unmatched');
partnerNameInput.classList.add('partner-search-input-matched');
partnerIdDisplay.textContent = `Partner ID: ${currentPartner.id}`;
partnerTaxIdDisplay.textContent = currentPartner.taxId ? ` | Tax ID: ${currentPartner.taxId}` : '';
} else {
partnerNameInput.value = '';
partnerNameInput.classList.add('partner-search-input-unmatched');
partnerNameInput.classList.remove('partner-search-input-matched');
partnerIdDisplay.textContent = 'Partner ID: Not Matched';
partnerTaxIdDisplay.textContent = '';
}
}
function renderShippingItemsTable() {
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>';
document.getElementById('itemCount').textContent = shippingItems.length;
if (shippingItems.length === 0) {
tbody.innerHTML = '<tr><td colspan="11" class="text-center text-muted">No shipping items found. Click "Add Item" to add one.</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>
`;
shippingItems.forEach((item, index) => {
const row = createEditableRow(item, index);
tbody.appendChild(row);
});
}
// Update total pallets
updateTotalPallets();
}
function createEditableRow(item, index) {
const row = document.createElement('tr');
// Add status class based on whether product is matched
if (!item.productId) {
row.classList.add('table-warning');
}
// Determine if product is matched or not
const isMatched = item.productId && item.productId > 0;
// Create name field - always searchable, but pre-filled if matched
const nameFieldValue = isMatched ? item.name : (item.nameOnDocument || '');
const nameFieldClass = isMatched ? 'product-search-input matched-product' : 'product-search-input';
row.innerHTML = `
<td>${index + 1}</td>
<td>
<input type="text" class="form-control form-control-sm ${nameFieldClass}"
value="${escapeHtml(nameFieldValue)}"
data-index="${index}"
placeholder="Type to search products...">
</td>
<td><input type="text" class="form-control form-control-sm" value="${escapeHtml(item.hungarianName || '')}" data-field="hungarianName" data-index="${index}" readonly></td>
<td><input type="text" class="form-control form-control-sm" value="${escapeHtml(item.nameOnDocument || '')}" data-field="nameOnDocument" data-index="${index}"></td>
<td>
${isMatched
? `<span class="badge badge-success">${item.productId}</span>`
: `<span class="badge badge-warning">Not Matched</span>`}
</td>
<td><input type="number" class="form-control form-control-sm" value="${item.palletsOnDocument || 0}" data-field="palletsOnDocument" data-index="${index}"></td>
<td><input type="number" class="form-control form-control-sm" value="${item.quantityOnDocument || 0}" data-field="quantityOnDocument" data-index="${index}"></td>
<td><input type="number" step="0.01" class="form-control form-control-sm" value="${item.netWeightOnDocument ? item.netWeightOnDocument.toFixed(2) : '0.00'}" data-field="netWeightOnDocument" data-index="${index}"></td>
<td><input type="number" step="0.01" class="form-control form-control-sm" value="${item.grossWeightOnDocument ? item.grossWeightOnDocument.toFixed(2) : '0.00'}" data-field="grossWeightOnDocument" data-index="${index}"></td>
<td><input type="number" step="0.01" class="form-control form-control-sm" value="${item.unitPriceOnDocument ? item.unitPriceOnDocument.toFixed(2) : '0.00'}" data-field="unitPriceOnDocument" data-index="${index}"></td>
<td>
<button type="button" class="btn btn-sm btn-danger" onclick="deleteShippingItem(${index})">
<i class="fas fa-trash"></i>
</button>
</td>
`;
// Add change listeners to update the data array
const inputs = row.querySelectorAll('input:not(.product-search-input), select');
inputs.forEach(input => {
input.addEventListener('change', (e) => {
const field = e.target.dataset.field;
const idx = parseInt(e.target.dataset.index);
let value = e.target.value;
// Parse numeric values
if (field === 'productId' || field === 'palletsOnDocument' || field === 'quantityOnDocument') {
value = value ? parseInt(value) : null;
} else if (field === 'netWeightOnDocument' || field === 'grossWeightOnDocument' || field === 'unitPriceOnDocument') {
value = value ? parseFloat(value) : 0;
}
shippingItems[idx][field] = value;
// Update total pallets if pallets changed
if (field === 'palletsOnDocument') {
updateTotalPallets();
}
});
});
// Initialize autocomplete for all product search inputs
const searchInput = row.querySelector('.product-search-input');
initializeProductAutocomplete(searchInput, index);
return row;
}
function addNewShippingItem() {
const newItem = {
name: '',
hungarianName: '',
nameOnDocument: '',
productId: null,
palletsOnDocument: 0,
quantityOnDocument: 0,
netWeightOnDocument: 0,
grossWeightOnDocument: 0,
unitPriceOnDocument: 0
};
shippingItems.push(newItem);
renderShippingItemsTable();
}
window.deleteShippingItem = function(index) {
if (confirm('Are you sure you want to delete this item?')) {
shippingItems.splice(index, 1);
renderShippingItemsTable();
}
};
function updateTotalPallets() {
const total = shippingItems.reduce((sum, item) => sum + (item.palletsOnDocument || 0), 0);
document.getElementById('editTotalPallets').value = total;
}
// Product autocomplete functionality
let autocompleteTimeout = null;
let currentSearchIndex = null;
let partnerAutocompleteTimeout = null;
// Partner autocomplete initialization
function initializePartnerAutocomplete() {
const partnerInput = document.getElementById('editPartnerName');
// Create autocomplete container
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
wrapper.style.width = '100%';
// Check if wrapper already exists
if (partnerInput.parentNode.querySelector('.autocomplete-results')) {
return; // Already initialized
}
partnerInput.parentNode.insertBefore(wrapper, partnerInput);
wrapper.appendChild(partnerInput);
const resultsContainer = document.createElement('div');
resultsContainer.className = 'autocomplete-results';
resultsContainer.style.display = 'none';
wrapper.appendChild(resultsContainer);
// Input event for searching
partnerInput.addEventListener('input', (e) => {
const searchTerm = e.target.value.trim();
// Clear existing timeout
if (partnerAutocompleteTimeout) {
clearTimeout(partnerAutocompleteTimeout);
}
if (searchTerm.length < 2) {
resultsContainer.style.display = 'none';
return;
}
// Debounce search
partnerAutocompleteTimeout = setTimeout(async () => {
await searchPartners(searchTerm, resultsContainer);
}, 300);
});
// Hide results when clicking outside
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
resultsContainer.style.display = 'none';
}
});
}
async function searchPartners(term, resultsContainer) {
try {
const response = await fetch(`@Url.Action("PartnerSearchAutoComplete", "FileManager")?term=${encodeURIComponent(term)}`);
const partners = await response.json();
displayPartnerResults(partners, resultsContainer);
} catch (error) {
console.error('Error searching partners:', error);
}
}
function displayPartnerResults(partners, resultsContainer) {
resultsContainer.innerHTML = '';
if (!partners || partners.length === 0) {
resultsContainer.innerHTML = '<div class="autocomplete-item">No partners found</div>';
resultsContainer.style.display = 'block';
return;
}
partners.forEach(partner => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.innerHTML = `
<div style="font-weight: 500;">${escapeHtml(partner.name)}</div>
<div style="font-size: 0.85em; color: #666;">
Tax ID: ${escapeHtml(partner.taxId || 'N/A')} |
${escapeHtml(partner.city || '')}${partner.country ? ', ' + escapeHtml(partner.country) : ''}
</div>
`;
item.addEventListener('click', () => {
selectPartner(partner);
resultsContainer.style.display = 'none';
});
resultsContainer.appendChild(item);
});
resultsContainer.style.display = 'block';
}
function selectPartner(partner) {
const previousPartnerId = currentPartner.id;
const wasMatched = previousPartnerId && previousPartnerId > 0;
currentPartner = {
id: partner.value,
name: partner.name,
taxId: partner.taxId || ''
};
updatePartnerDisplay();
if (wasMatched) {
showMessage(`Partner changed from ID ${previousPartnerId} to: ${partner.name}`, 'info');
} else {
showMessage(`Partner matched: ${partner.name}`, 'success');
}
}
function initializeProductAutocomplete(inputElement, itemIndex) {
// Create autocomplete container
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
wrapper.style.width = '100%';
inputElement.parentNode.insertBefore(wrapper, inputElement);
wrapper.appendChild(inputElement);
const resultsContainer = document.createElement('div');
resultsContainer.className = 'autocomplete-results';
resultsContainer.style.display = 'none';
wrapper.appendChild(resultsContainer);
// Input event for searching
inputElement.addEventListener('input', (e) => {
const searchTerm = e.target.value.trim();
// Clear existing timeout
if (autocompleteTimeout) {
clearTimeout(autocompleteTimeout);
}
if (searchTerm.length < 2) {
resultsContainer.style.display = 'none';
return;
}
// Debounce search
autocompleteTimeout = setTimeout(async () => {
await searchProducts(searchTerm, resultsContainer, itemIndex);
}, 300);
});
// Hide results when clicking outside
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
resultsContainer.style.display = 'none';
}
});
}
async function searchProducts(term, resultsContainer, itemIndex) {
try {
const response = await fetch(`@Url.Action("ProductSearchUnfilteredAutoComplete", "CustomOrder")?term=${encodeURIComponent(term)}`);
const products = await response.json();
displayProductResults(products, resultsContainer, itemIndex);
} catch (error) {
console.error('Error searching products:', error);
}
}
function displayProductResults(products, resultsContainer, itemIndex) {
resultsContainer.innerHTML = '';
if (!products || products.length === 0) {
resultsContainer.innerHTML = '<div class="autocomplete-item">No products found</div>';
resultsContainer.style.display = 'block';
return;
}
products.forEach(product => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.innerHTML = `
<div style="font-weight: 500;">${escapeHtml(product.label)}</div>
<div style="font-size: 0.85em; color: #666;">SKU: ${escapeHtml(product.sku || 'N/A')}</div>
`;
item.addEventListener('click', () => {
selectProduct(product, itemIndex);
resultsContainer.style.display = 'none';
});
resultsContainer.appendChild(item);
});
resultsContainer.style.display = 'block';
}
function selectProduct(product, itemIndex) {
// Extract product name from label (remove the stock and price info)
const productName = product.label.split('[')[0].trim();
const previousProductId = shippingItems[itemIndex].productId;
const wasMatched = previousProductId && previousProductId > 0;
// Update the shipping item with selected product
shippingItems[itemIndex] = {
...shippingItems[itemIndex],
productId: product.value,
name: productName,
hungarianName: productName,
unitPriceOnDocument: product.price || 0
};
// Re-render the table to show the matched product
renderShippingItemsTable();
if (wasMatched) {
showMessage(`Product changed from ID ${previousProductId} to: ${productName}`, 'info');
} else {
showMessage(`Product matched: ${productName}`, 'success');
}
}
async function saveShippingDocument() {
if (isKnownDuplicate) {
const confirmSave = confirm(
'This document has already been processed before. ' +
'Do you want to create a new entry with the same data? ' +
'(This will NOT create duplicate file storage, but will create a new shipping document record.)'
);
if (!confirmSave) {
showMessage('Save cancelled.', 'info');
return;
}
}
// Validate partner is selected
if (!currentPartner.id) {
showMessage('Please select a partner before saving', 'warning');
return;
}
// Collect data from form
const shippingDocument = {
documentIdNumber: document.getElementById('editDocumentIdNumber').value,
partnerId: currentPartner.id,
totalPallets: parseInt(document.getElementById('editTotalPallets').value) || 0,
pdfFileName: document.getElementById('editPdfFileName').value,
shippingItems: shippingItems
};
try {
saveShippingDocumentButton.disabled = true;
saveShippingDocumentButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving...';
// Get the antiforgery token
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
// Create FormData to send both JSON and file
const formData = new FormData();
// Add document data as JSON string
formData.append('documentData', JSON.stringify(shippingDocument));
// Add extracted text
formData.append('extractedText', extractedFullText);
// Add original file if available
if (originalUploadedFile) {
formData.append('originalFile', originalUploadedFile);
console.log('✓ Including original file:', originalUploadedFile.name);
} else {
console.warn('⚠ No original file to save');
}
const response = await fetch('@Url.Action("SaveShippingDocument", "FileManager")', {
method: 'POST',
headers: {
'RequestVerificationToken': token
// Don't set Content-Type - browser sets it automatically for FormData
},
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
showMessage('Shipping document and file saved successfully!', 'success');
console.log('✓ Saved:', result);
// Optionally reload or redirect after save
// window.location.reload();
} else {
showMessage('Error: ' + (result.message || 'Failed to save shipping document'), 'danger');
}
} catch (error) {
console.error('Error saving shipping document:', error);
showMessage('Error: Failed to communicate with server', 'danger');
} finally {
saveShippingDocumentButton.disabled = false;
saveShippingDocumentButton.innerHTML = '<i class="fas fa-save"></i> Save Shipping Document';
}
}
function escapeHtml(text) {
if (!text) return '';
const map = {
'&': '&amp;',
'<': '&lt;',
@ -335,7 +906,7 @@
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
return text.toString().replace(/[&<>"']/g, m => map[m]);
}
function showMessage(message, type) {
@ -347,4 +918,4 @@
responseMessage.style.display = 'none';
}, 5000);
}
</script>
</script>

View File

@ -38,7 +38,7 @@
<button id="stopPartnerBtn" class="voice-btn recording" style="display: none;">
<i class="fas fa-stop"></i>
</button>
<p class="voice-hint">Tap to speak partner name</p>
<p class="voice-hint">Tap to speak - auto-stops on silence</p>
</div>
<!-- Recording Status -->
@ -202,45 +202,50 @@
let mediaRecorder = null;
let audioChunks = [];
$(document).ready(function() {
// Event listeners
$('#recordPartnerBtn').click(() => startRecording('partner'));
$('#stopPartnerBtn').click(() => stopRecording('partner'));
$('#recordProductBtn').click(() => startRecording('product'));
$('#stopProductBtn').click(() => stopRecording('product'));
$('#proceedToProductsBtn').click(proceedToStep2);
// VAD (Voice Activity Detection) state
let audioContext = null;
let analyser = null;
let volumeCheckInterval = null;
let recordingStartTime = null;
let isRecording = false;
let baselineNoiseLevel = -60; // Will be calibrated at start
let volumeHistory = []; // Track volume over time
// Check microphone availability and permissions on load
// VAD Configuration
const VAD_CONFIG = {
silenceThreshold: -50, // dB threshold for silence (will be adjusted dynamically)
silenceDuration: 1500, // 1.5 seconds of silence triggers auto-stop
minRecordingTime: 800, // Minimum 0.8 seconds before allowing auto-stop
volumeCheckInterval: 100, // Check volume every 100ms
calibrationTime: 500, // First 500ms used to calibrate noise floor
noiseGateOffset: 15, // dB above noise floor to trigger speech detection
volumeHistorySize: 10 // Keep last 10 volume samples
};
$(document).ready(function() {
// Single click to start - auto-stops on silence
$('#recordPartnerBtn').click(() => startRecording('partner'));
$('#recordProductBtn').click(() => startRecording('product'));
// Optional manual stop
$('#stopPartnerBtn').click(() => stopRecording('partner', false));
$('#stopProductBtn').click(() => stopRecording('product', false));
$('#proceedToProductsBtn').click(proceedToStep2);
checkMicrophoneAvailability();
});
async function checkMicrophoneAvailability() {
console.log('[VoiceOrder] Checking microphone availability...');
console.log('[VoiceOrder] Protocol:', window.location.protocol);
console.log('[VoiceOrder] Hostname:', window.location.hostname);
console.log('[VoiceOrder] Is secure context:', window.isSecureContext);
// Check if getUserMedia is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('[VoiceOrder] getUserMedia not supported');
showWarningBanner('Your browser does not support audio recording. Please use Chrome, Firefox, or Safari.');
return;
}
// Check if HTTPS (except localhost)
if (window.location.protocol !== 'https:' &&
window.location.hostname !== 'localhost' &&
window.location.hostname !== '127.0.0.1') {
console.error('[VoiceOrder] Not HTTPS');
showWarningBanner('Voice recording requires HTTPS. Please use a secure connection.');
return;
}
// Try to enumerate devices (doesn't require permission)
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(device => device.kind === 'audioinput');
console.log('[VoiceOrder] Audio input devices found:', audioInputs.length);
if (audioInputs.length === 0) {
showWarningBanner('No microphone detected. Please connect a microphone to use voice recording.');
@ -248,26 +253,6 @@
} catch (error) {
console.error('[VoiceOrder] Error enumerating devices:', error);
}
// Check permissions API if available
if (navigator.permissions && navigator.permissions.query) {
try {
const permissionStatus = await navigator.permissions.query({ name: 'microphone' });
console.log('[VoiceOrder] Microphone permission status:', permissionStatus.state);
if (permissionStatus.state === 'denied') {
showWarningBanner('Microphone access was denied. Please enable it in your browser settings.');
}
// Listen for permission changes
permissionStatus.onchange = function() {
console.log('[VoiceOrder] Permission changed to:', this.state);
};
} catch (error) {
// Permissions API not supported or microphone permission not available
console.log('[VoiceOrder] Permissions API not available:', error.message);
}
}
}
function showWarningBanner(message) {
@ -280,233 +265,267 @@
$('.mobile-content').prepend(banner);
}
function getSupportedMimeType() {
const mimeTypes = [
'audio/webm',
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/mp4'
];
for (const mimeType of mimeTypes) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
return 'audio/webm';
}
async function startRecording(type) {
console.log('[VoiceOrder] ========== START RECORDING ATTEMPT ==========');
console.log('[VoiceOrder] Type:', type);
console.log('[VoiceOrder] Timestamp:', new Date().toISOString());
console.log('[VoiceOrder] Starting recording with VAD for:', type);
try {
// Check 1: Browser support
console.log('[VoiceOrder] Check 1: Browser support');
if (!navigator.mediaDevices) {
console.error('[VoiceOrder] FAIL: navigator.mediaDevices is undefined');
alert('Your browser does not support mediaDevices API. Browser: ' + navigator.userAgent);
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert('Your browser does not support audio recording.');
return;
}
console.log('[VoiceOrder] PASS: navigator.mediaDevices exists');
if (!navigator.mediaDevices.getUserMedia) {
console.error('[VoiceOrder] FAIL: getUserMedia is undefined');
alert('Your browser does not support getUserMedia. Browser: ' + navigator.userAgent);
return;
}
console.log('[VoiceOrder] PASS: getUserMedia exists');
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('[VoiceOrder] Got media stream');
// Check 2: Security context
console.log('[VoiceOrder] Check 2: Security context');
console.log('[VoiceOrder] - Protocol:', window.location.protocol);
console.log('[VoiceOrder] - Hostname:', window.location.hostname);
console.log('[VoiceOrder] - IsSecureContext:', window.isSecureContext);
console.log('[VoiceOrder] - Full URL:', window.location.href);
// Setup Web Audio API for volume detection
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
analyser.fftSize = 512;
// Check 3: Try simple audio constraint first
console.log('[VoiceOrder] Check 3: Requesting microphone with simple constraints');
console.log('[VoiceOrder] Calling getUserMedia({ audio: true })...');
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('[VoiceOrder] SUCCESS: Got media stream');
console.log('[VoiceOrder] Stream ID:', stream.id);
console.log('[VoiceOrder] Stream active:', stream.active);
console.log('[VoiceOrder] Audio tracks:', stream.getAudioTracks().length);
if (stream.getAudioTracks().length > 0) {
const track = stream.getAudioTracks()[0];
console.log('[VoiceOrder] Track label:', track.label);
console.log('[VoiceOrder] Track enabled:', track.enabled);
console.log('[VoiceOrder] Track muted:', track.muted);
console.log('[VoiceOrder] Track readyState:', track.readyState);
console.log('[VoiceOrder] Track settings:', track.getSettings());
}
} catch (getUserMediaError) {
console.error('[VoiceOrder] FAIL: getUserMedia threw error');
console.error('[VoiceOrder] Error name:', getUserMediaError.name);
console.error('[VoiceOrder] Error message:', getUserMediaError.message);
console.error('[VoiceOrder] Error stack:', getUserMediaError.stack);
throw getUserMediaError;
}
// Check 4: MediaRecorder support
console.log('[VoiceOrder] Check 4: MediaRecorder support');
if (!window.MediaRecorder) {
console.error('[VoiceOrder] FAIL: MediaRecorder not supported');
stream.getTracks().forEach(track => track.stop());
alert('MediaRecorder is not supported in your browser: ' + navigator.userAgent);
return;
}
console.log('[VoiceOrder] PASS: MediaRecorder exists');
// Check 5: Check supported MIME types
console.log('[VoiceOrder] Check 5: Checking MIME type support');
const mimeTypes = [
'audio/webm',
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/mp4',
'audio/mpeg'
];
let supportedMimeType = null;
for (const mimeType of mimeTypes) {
const isSupported = MediaRecorder.isTypeSupported(mimeType);
console.log('[VoiceOrder] - ' + mimeType + ':', isSupported);
if (isSupported && !supportedMimeType) {
supportedMimeType = mimeType;
}
}
if (!supportedMimeType) {
console.error('[VoiceOrder] FAIL: No supported MIME type found');
stream.getTracks().forEach(track => track.stop());
alert('No supported audio recording format found in your browser');
return;
}
console.log('[VoiceOrder] Using MIME type:', supportedMimeType);
// Check 6: Create MediaRecorder
console.log('[VoiceOrder] Check 6: Creating MediaRecorder');
try {
mediaRecorder = new MediaRecorder(stream, {
mimeType: supportedMimeType
});
console.log('[VoiceOrder] SUCCESS: MediaRecorder created');
console.log('[VoiceOrder] MediaRecorder state:', mediaRecorder.state);
} catch (recorderError) {
console.error('[VoiceOrder] FAIL: MediaRecorder constructor threw error');
console.error('[VoiceOrder] Error:', recorderError);
stream.getTracks().forEach(track => track.stop());
throw recorderError;
}
const supportedMimeType = getSupportedMimeType();
mediaRecorder = new MediaRecorder(stream, {
mimeType: supportedMimeType
});
audioChunks = [];
recordingStartTime = Date.now();
isRecording = true;
mediaRecorder.addEventListener('dataavailable', event => {
console.log('[VoiceOrder] dataavailable event, size:', event.data.size);
audioChunks.push(event.data);
});
mediaRecorder.addEventListener('stop', () => {
console.log('[VoiceOrder] MediaRecorder stopped');
console.log('[VoiceOrder] Audio chunks:', audioChunks.length);
const audioBlob = new Blob(audioChunks, { type: supportedMimeType });
console.log('[VoiceOrder] Blob created, size:', audioBlob.size);
console.log('[VoiceOrder] Blob type:', audioBlob.type);
if (audioBlob.size === 0) {
console.error('[VoiceOrder] WARNING: Blob size is 0!');
alert('Recording failed: No audio data captured. Please check your microphone.');
alert('No audio captured. Please try again.');
resetRecordingUI(type);
return;
}
processAudio(audioBlob, type);
stream.getTracks().forEach(track => {
console.log('[VoiceOrder] Stopping track:', track.label);
track.stop();
});
});
mediaRecorder.addEventListener('error', event => {
console.error('[VoiceOrder] MediaRecorder error event:', event);
console.error('[VoiceOrder] Error:', event.error);
});
// Check 7: Start recording
console.log('[VoiceOrder] Check 7: Starting recording');
try {
mediaRecorder.start();
console.log('[VoiceOrder] SUCCESS: Recording started');
console.log('[VoiceOrder] MediaRecorder state after start:', mediaRecorder.state);
} catch (startError) {
console.error('[VoiceOrder] FAIL: start() threw error');
console.error('[VoiceOrder] Error:', startError);
stream.getTracks().forEach(track => track.stop());
throw startError;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
analyser = null;
isRecording = false;
});
mediaRecorder.start();
console.log('[VoiceOrder] Recording started');
// Update UI
if (type === 'partner') {
$('#recordPartnerBtn').hide();
$('#stopPartnerBtn').show();
showStatus('partnerRecordingStatus', 'Listening...');
showStatus('partnerRecordingStatus', 'Listening... (speak now)');
} else {
$('#recordProductBtn').hide();
$('#stopProductBtn').show();
showStatus('productRecordingStatus', 'Listening...');
showStatus('productRecordingStatus', 'Listening... (speak now)');
}
console.log('[VoiceOrder] ========== RECORDING STARTED SUCCESSFULLY ==========');
// Start voice activity detection
startVoiceActivityDetection(type);
} catch (error) {
console.error('[VoiceOrder] ========== RECORDING FAILED ==========');
console.error('[VoiceOrder] Error name:', error.name);
console.error('[VoiceOrder] Error message:', error.message);
console.error('[VoiceOrder] Error stack:', error.stack);
console.error('[VoiceOrder] Full error object:', error);
console.error('[VoiceOrder] Recording error:', error);
let errorMessage = 'Recording failed: ';
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
errorMessage += 'Microphone permission was denied.\n\n';
errorMessage += 'Steps to fix:\n';
errorMessage += '1. Click the 🔒 or ⓘ icon in the address bar\n';
errorMessage += '2. Find "Microphone" in permissions\n';
errorMessage += '3. Change to "Allow"\n';
errorMessage += '4. Refresh the page\n\n';
errorMessage += 'Current permission status: Check browser console for details';
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
errorMessage += 'No microphone device found.\n\n';
errorMessage += 'Please check:\n';
errorMessage += '- Is a microphone connected?\n';
errorMessage += '- Are you using headphones with a mic?\n';
errorMessage += '- Try a different microphone';
} else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') {
errorMessage += 'Microphone is in use by another application.\n\n';
errorMessage += 'Please:\n';
errorMessage += '- Close other apps using the microphone\n';
errorMessage += '- Close other browser tabs using microphone\n';
errorMessage += '- Restart your browser';
} else if (error.name === 'OverconstrainedError') {
errorMessage += 'Audio recording constraints not supported.\n\n';
errorMessage += 'Try: Refresh the page and try again';
} else if (error.name === 'SecurityError') {
errorMessage += 'Security error accessing microphone.\n\n';
errorMessage += 'Check:\n';
errorMessage += '- Page must use HTTPS (currently: ' + window.location.protocol + ')\n';
errorMessage += '- Valid SSL certificate\n';
errorMessage += '- No mixed content (HTTP resources on HTTPS page)';
} else if (error.name === 'TypeError') {
errorMessage += 'Browser compatibility issue.\n\n';
errorMessage += 'Your browser: ' + navigator.userAgent + '\n\n';
errorMessage += 'Try: Update your browser to the latest version';
let errorMessage = 'Could not start recording: ';
if (error.name === 'NotAllowedError') {
errorMessage += 'Microphone permission denied. Please allow microphone access.';
} else if (error.name === 'NotFoundError') {
errorMessage += 'No microphone found.';
} else {
errorMessage += error.message || 'Unknown error\n\n';
errorMessage += 'Browser: ' + navigator.userAgent + '\n';
errorMessage += 'Error type: ' + error.name;
errorMessage += error.message;
}
errorMessage += '\n\n** Check browser console (F12) for detailed technical information **';
alert(errorMessage);
resetRecordingUI(type);
}
}
function stopRecording(type) {
function startVoiceActivityDetection(type) {
console.log('[VoiceOrder] Starting voice activity detection with noise calibration');
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
if (volumeCheckInterval) clearInterval(volumeCheckInterval);
let consecutiveSilentChecks = 0;
const silentChecksNeeded = Math.ceil(VAD_CONFIG.silenceDuration / VAD_CONFIG.volumeCheckInterval);
let isCalibrated = false;
let calibrationSamples = [];
volumeHistory = [];
volumeCheckInterval = setInterval(() => {
if (!isRecording || !analyser) {
clearInterval(volumeCheckInterval);
return;
}
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i];
}
const average = sum / bufferLength;
const volume = 20 * Math.log10(average / 255);
const recordingDuration = Date.now() - recordingStartTime;
// CALIBRATION PHASE: First 500ms - measure background noise
if (!isCalibrated && recordingDuration < VAD_CONFIG.calibrationTime) {
calibrationSamples.push(volume);
updateVolumeIndicator(volume, type, false, 'Calibrating noise floor...');
console.log('[VAD] Calibration sample:', volume.toFixed(2), 'dB');
return;
}
// Finish calibration
if (!isCalibrated && calibrationSamples.length > 0) {
// Calculate baseline as average of calibration samples
const sum = calibrationSamples.reduce((a, b) => a + b, 0);
baselineNoiseLevel = sum / calibrationSamples.length;
// Set dynamic threshold: noise floor + offset
const dynamicThreshold = baselineNoiseLevel + VAD_CONFIG.noiseGateOffset;
console.log('[VAD] Calibration complete:');
console.log('[VAD] - Baseline noise:', baselineNoiseLevel.toFixed(2), 'dB');
console.log('[VAD] - Dynamic threshold:', dynamicThreshold.toFixed(2), 'dB');
isCalibrated = true;
}
// Track volume history
volumeHistory.push(volume);
if (volumeHistory.length > VAD_CONFIG.volumeHistorySize) {
volumeHistory.shift();
}
// Calculate rolling average to smooth out spikes
const avgVolume = volumeHistory.reduce((a, b) => a + b, 0) / volumeHistory.length;
// Dynamic threshold based on calibrated noise floor
const dynamicThreshold = baselineNoiseLevel + VAD_CONFIG.noiseGateOffset;
console.log('[VAD] Volume:', volume.toFixed(2), 'dB | Avg:', avgVolume.toFixed(2), 'dB | Threshold:', dynamicThreshold.toFixed(2), 'dB | Baseline:', baselineNoiseLevel.toFixed(2), 'dB');
updateVolumeIndicator(volume, type, true, null);
// Don't check for silence yet if still in minimum recording time
if (recordingDuration < VAD_CONFIG.minRecordingTime) {
return;
}
// Check if silent (using dynamic threshold and rolling average)
if (avgVolume < dynamicThreshold) {
consecutiveSilentChecks++;
console.log('[VAD] Silent check:', consecutiveSilentChecks, '/', silentChecksNeeded);
if (consecutiveSilentChecks >= silentChecksNeeded) {
console.log('[VAD] Silence detected - auto-stopping');
clearInterval(volumeCheckInterval);
stopRecording(type, true);
}
} else {
// Reset silence counter when sound detected above threshold
if (consecutiveSilentChecks > 0) {
console.log('[VAD] Sound detected, resetting silence counter');
}
consecutiveSilentChecks = 0;
}
}, VAD_CONFIG.volumeCheckInterval);
}
function updateVolumeIndicator(volume, type, showIndicator = true, customMessage = null) {
const statusId = type === 'partner' ? 'partnerRecordingStatus' : 'productRecordingStatus';
const statusEl = $('#' + statusId);
// Use dynamic threshold if calibrated, otherwise use baseline
const threshold = baselineNoiseLevel + VAD_CONFIG.noiseGateOffset;
const volumeAboveThreshold = volume - threshold;
// Normalize volume relative to threshold (0-100 scale)
// 0dB above threshold = 0%, 30dB above threshold = 100%
const normalizedVolume = Math.max(0, Math.min(100, ((volumeAboveThreshold + 10) / 40) * 100));
let statusText = customMessage || 'Listening...';
let barClass = 'volume-bar-silent';
if (showIndicator && !customMessage) {
if (normalizedVolume > 60) {
statusText = 'Listening... 🔊 (loud and clear!)';
barClass = 'volume-bar-high';
} else if (normalizedVolume > 30) {
statusText = 'Listening... 🔉 (speaking detected)';
barClass = 'volume-bar-medium';
} else if (normalizedVolume > 10) {
statusText = 'Listening... 🔈 (speak louder)';
barClass = 'volume-bar-low';
} else {
statusText = 'Listening... (waiting for voice...)';
barClass = 'volume-bar-silent';
}
}
statusEl.find('span').text(statusText);
if (statusEl.find('.volume-bar').length === 0) {
statusEl.append('<div class="volume-bar-container"><div class="volume-bar"></div></div>');
}
statusEl.find('.volume-bar')
.removeClass('volume-bar-low volume-bar-medium volume-bar-high volume-bar-silent')
.addClass(barClass)
.css('width', normalizedVolume + '%');
}
function stopRecording(type, autoStopped = false) {
console.log('[VoiceOrder] Stopping recording', autoStopped ? '(auto)' : '(manual)');
if (volumeCheckInterval) {
clearInterval(volumeCheckInterval);
volumeCheckInterval = null;
}
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
const statusId = type === 'partner' ? 'partnerRecordingStatus' : 'productRecordingStatus';
if (autoStopped) {
showStatus(statusId, 'Processing... (silence detected)');
} else {
showStatus(statusId, 'Processing...');
}
mediaRecorder.stop();
showStatus(type === 'partner' ? 'partnerRecordingStatus' : 'productRecordingStatus', 'Processing...');
}
}
@ -526,7 +545,6 @@
});
const result = await response.json();
resetRecordingUI(type);
if (result.success) {
@ -548,7 +566,6 @@
function handlePartnerTranscription(result) {
$('#partnerTranscribedText').text(result.transcription);
$('#partnerTranscribedCard').show();
displayPartnerMatches(result.partners);
}
@ -583,7 +600,6 @@
function selectPartner(partner) {
selectedPartnerId = partner.value;
selectedPartnerName = partner.label;
$('#selectedPartnerName').text(partner.label);
$('#partnerMatchesCard').hide();
$('#selectedPartnerCard').show();
@ -592,7 +608,6 @@
function resetPartnerSelection() {
selectedPartnerId = null;
selectedPartnerName = "";
$('#partnerTranscribedCard').hide();
$('#partnerMatchesCard').hide();
$('#selectedPartnerCard').hide();
@ -602,7 +617,6 @@
async function submitManualPartner() {
const text = $('#manualPartnerInput').val().trim();
if (!text) {
alert('Please enter a partner name');
return;
@ -633,31 +647,24 @@
}
} catch (error) {
$('#partnerRecordingStatus').hide();
console.error('Error searching partners:', error);
console.error('Error:', error);
alert('Error: ' + error.message);
}
}
function proceedToStep2() {
currentStep = 2;
// Update progress dots
$('#dot1').removeClass('active').addClass('completed');
$('#dot2').addClass('active');
// Update UI
$('#step1').removeClass('active').hide();
$('#step2').addClass('active').show();
$('#partnerSummary').text(selectedPartnerName);
// Scroll to top
$('.mobile-content').scrollTop(0);
}
function handleProductTranscription(result) {
$('#productTranscribedText').text(result.transcription);
$('#productTranscribedCard').show();
displayProductMatches(result.products);
}
@ -671,7 +678,6 @@
return;
}
// Group by search term
const grouped = {};
products.forEach(p => {
const term = p.searchTerm || 'other';
@ -724,7 +730,6 @@
value="${product.price.toFixed(0)}"
min="0"
step="1"
data-product-id="${product.id}"
onclick="event.stopPropagation()">
<span class="price-unit">Ft</span>
`);
@ -752,8 +757,6 @@
});
updateOrderItemsDisplay();
// Hide product matches after adding
$('#productMatchesCard').hide();
$('#productTranscribedCard').hide();
$('#recordProductBtn').show();
@ -805,7 +808,6 @@
window.removeItem = function(index) {
orderItems.splice(index, 1);
updateOrderItemsDisplay();
if (orderItems.length === 0) {
$('#orderItemsCard').hide();
}
@ -820,7 +822,6 @@
async function submitManualProducts() {
const text = $('#manualProductInput').val().trim();
if (!text) {
alert('Please enter some products');
return;
@ -865,8 +866,6 @@
$('#finishOrderBtn').prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Creating...');
try {
const orderProductsJson = JSON.stringify(orderItems);
const response = await fetch('@Url.Action("Create", "CustomOrder")', {
method: 'POST',
headers: {
@ -875,7 +874,7 @@
},
body: new URLSearchParams({
customerId: selectedPartnerId,
orderProductsJson: orderProductsJson,
orderProductsJson: JSON.stringify(orderItems),
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
})
});
@ -883,7 +882,6 @@
if (response.redirected) {
const url = new URL(response.url);
const orderId = url.searchParams.get('id');
if (orderId) {
showSuccess(orderId);
} else {
@ -917,22 +915,12 @@
$('#dot1').removeClass('completed').addClass('active');
$('#dot2').removeClass('active completed');
$('#successCard').hide();
$('#step2').hide();
$('#step1').show().addClass('active');
$('#partnerTranscribedCard').hide();
$('#partnerMatchesCard').hide();
$('#selectedPartnerCard').hide();
$('#productTranscribedCard').hide();
$('#productMatchesCard').hide();
$('#orderItemsCard').hide();
$('#partnerTranscribedCard, #partnerMatchesCard, #selectedPartnerCard, #productTranscribedCard, #productMatchesCard, #orderItemsCard').hide();
$('#recordPartnerBtn').show();
$('#manualPartnerInput').val('');
$('#manualProductInput').val('');
$('#manualPartnerInput, #manualProductInput').val('');
$('.mobile-content').scrollTop(0);
}
@ -955,24 +943,13 @@
</script>
<style>
/* Mobile-First Reset */
.voice-order-mobile {
/* position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0; */
background: #f5f7fa;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Fixed Header */
.mobile-header {
/* position: fixed;
top: 0;
left: 0;
right: 0; */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
@ -986,7 +963,6 @@
font-weight: 600;
}
/* Progress Dots */
.progress-dots {
display: flex;
gap: 0.5rem;
@ -1011,19 +987,12 @@
background: #4ade80;
}
/* Scrollable Content */
.mobile-content {
/* position: absolute;
top: 90px;
left: 0;
right: 0;
bottom: 0; */
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 1rem;
}
/* Step Container */
.step-container {
display: none;
animation: slideIn 0.3s ease-out;
@ -1045,7 +1014,6 @@
}
}
/* Step Title */
.step-title {
text-align: center;
margin-bottom: 2rem;
@ -1070,7 +1038,6 @@
margin: 0.5rem 0 0 0;
}
/* Voice Button (Hero Element) */
.voice-button-container {
text-align: center;
margin: 2rem 0;
@ -1141,7 +1108,6 @@
font-size: 0.9rem;
}
/* Status Indicator */
.status-indicator {
text-align: center;
padding: 1rem;
@ -1151,6 +1117,12 @@
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.status-indicator span {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.spinner {
display: inline-block;
width: 20px;
@ -1169,7 +1141,50 @@
}
}
/* OR Divider */
/* Volume Bar for VAD */
.volume-bar-container {
width: 100%;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
margin-top: 0.75rem;
overflow: hidden;
}
.volume-bar {
height: 100%;
transition: width 0.1s ease-out, background-color 0.3s;
border-radius: 4px;
}
.volume-bar-silent {
background: #9ca3af;
width: 0%;
}
.volume-bar-low {
background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%);
}
.volume-bar-medium {
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
}
.volume-bar-high {
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
animation: pulse-volume 0.5s infinite;
}
@@keyframes pulse-volume {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.divider-or {
text-align: center;
color: #9ca3af;
@ -1178,8 +1193,7 @@
position: relative;
}
.divider-or:before,
.divider-or:after {
.divider-or:before, .divider-or:after {
content: '';
position: absolute;
top: 50%;
@ -1196,7 +1210,6 @@
right: 0;
}
/* Input Container */
.input-container {
display: flex;
gap: 0.5rem;
@ -1233,7 +1246,6 @@
transform: scale(0.95);
}
/* Result Card */
.result-card {
background: white;
border-radius: 12px;
@ -1255,7 +1267,6 @@
color: #1f2937;
}
/* Matches Container */
.matches-container {
margin: 1.5rem 0;
}
@ -1276,7 +1287,6 @@
gap: 0.75rem;
}
/* Product Card Wrapper */
.product-card-wrapper {
background: white;
border-radius: 12px;
@ -1288,7 +1298,6 @@
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.2);
}
/* List Item Button */
.list-item-btn {
width: 100%;
padding: 1rem;
@ -1325,7 +1334,6 @@
color: #1f2937;
}
/* Product Item Button */
.product-item-btn {
width: 100%;
padding: 1rem;
@ -1349,15 +1357,6 @@
transform: scale(0.98);
}
.product-card-wrapper:hover .product-item-btn {
background: #f9fafb;
}
.product-card-wrapper.has-warning:hover .product-item-btn {
background: #fef3c7;
}
/* Price Editor */
.price-editor {
display: flex;
align-items: center;
@ -1408,11 +1407,6 @@
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.price-input::-webkit-inner-spin-button,
.price-input::-webkit-outer-spin-button {
opacity: 1;
}
.price-unit {
font-size: 0.875rem;
color: #6b7280;
@ -1482,11 +1476,6 @@
}
}
.price {
font-weight: 600;
color: #1f2937;
}
.stock {
color: #10b981;
}
@ -1504,7 +1493,6 @@
margin: 1rem 0 0.5rem 0;
}
/* Selected Card */
.selected-card {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
@ -1537,7 +1525,6 @@
text-decoration: underline;
}
/* Primary Button */
.btn-primary-mobile {
width: 100%;
padding: 1rem;
@ -1562,7 +1549,6 @@
cursor: not-allowed;
}
/* Secondary Button */
.btn-secondary-mobile {
width: 100%;
padding: 1rem;
@ -1581,7 +1567,6 @@
transform: scale(0.98);
}
/* Partner Summary Bar */
.partner-summary {
background: white;
padding: 0.75rem 1rem;
@ -1599,7 +1584,6 @@
color: #667eea;
}
/* Order Summary */
.order-summary {
background: white;
border-radius: 16px;
@ -1688,7 +1672,6 @@
font-weight: 600;
}
/* Success Screen */
.success-screen {
text-align: center;
padding: 2rem 0;
@ -1731,7 +1714,6 @@
padding: 2rem;
}
/* Permission Warning Banner */
.permission-warning {
background: #fef3c7;
border: 2px solid #f59e0b;
@ -1751,7 +1733,6 @@
flex-shrink: 0;
}
/* Responsive adjustments */
@@media (max-width: 375px) {
.voice-btn {
width: 100px;
@ -1760,7 +1741,6 @@
}
}
/* Prevent zoom on input focus (iOS) */
@@media screen and (max-width: 768px) {
input, select, textarea {
font-size: 16px;

View File

@ -0,0 +1,146 @@
@inject IWebHelper webHelper
@inject IDateTimeHelper dateTimeHelper
@inject IPermissionService permissionService
@inject ICustomerService customerService
@inject IEventPublisher eventPublisher
@inject LocalizationSettings localizationSettings
@inject StoreInformationSettings storeInformationSettings
@inject Nop.Services.Localization.ILanguageService languageService
@using Nop.Core.Domain
@using Nop.Core.Domain.Localization
@using Nop.Services.Customers
@using Nop.Services.Helpers
@using Nop.Services.Security
@*
@Html.DevExpress().GetStyleSheets()
@Html.DevExpress().GetScripts() *@
@{
var returnUrl = webHelper.GetRawUrl(Context.Request);
//page title
string adminPageTitle = !string.IsNullOrWhiteSpace(ViewBag.PageTitle) ? ViewBag.PageTitle + " / " : "";
adminPageTitle += T("Admin.PageTitle").Text;
//has "Manage Maintenance" permission?
var canManageMaintenance = await permissionService.AuthorizeAsync(StandardPermission.System.MANAGE_MAINTENANCE);
//avatar
var currentCustomer = await workContext.GetCurrentCustomerAsync();
//event
await eventPublisher.PublishAsync(new PageRenderingEvent(NopHtml));
//info: we specify "Admin" area for actions and widgets here for cases when we use this layout in a plugin that is running in a different area than "admin"
}
<!DOCTYPE html>
<html lang="@CultureInfo.CurrentUICulture.TwoLetterISOLanguageName" dir="@Html.GetUIDirection(localizationSettings.IgnoreRtlPropertyForAdminArea)">
<head>
<title>@adminPageTitle</title>
<meta http-equiv="Content-type" content="text/html;charset=UTF-8" />
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
@NopHtml.GenerateHeadCustom()
@* CSS & Script resources *@
@{
await Html.RenderPartialAsync("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/_AdminScripts.cshtml");
}
@*Insert favicon and app icons head code*@
@await Component.InvokeAsync(typeof(FaviconViewComponent))
</head>
<body class="hold-transition sidebar-mini layout-fixed control-sidebar-slide-open">
<div class="throbber">
<div class="curtain">
</div>
<div class="curtain-content">
<div>
<h1 class="throbber-header">@T("Common.Wait")</h1>
<p>
<img src="@Url.Content("~/css/admin/images/throbber-synchronizing.gif")" alt="" />
</p>
</div>
</div>
</div>
<div id="ajaxBusy">
<span>&nbsp;</span>
</div>
<div class="wrapper">
<div class="">
@await Html.PartialAsync("~/Areas/Admin/Views/Shared/Notifications.cshtml")
<nop-antiforgery-token />
@RenderBody()
</div>
@* <div class="main-footer">
<div class="container-fluid">
<div class="col-md-12">
<div class="row">
@if (!storeInformationSettings.HidePoweredByNopCommerce)
{
<div class="col-md-4 col-xs-12 text-md-left text-center">
Would you like to remove the "Powered by nopCommerce" link in the bottom of the footer?
Please find more info at https://www.nopcommerce.com/nopcommerce-copyright-removal-key
Powered by <a href="@(OfficialSite.Main + Utm.OnAdminFooter)" target="_blank">nopCommerce</a>
</div>
}
else
{
<div class="col-md-4 col-xs-12"></div>
}
<div class="col-md-4 col-xs-12 text-center">
@((await dateTimeHelper.ConvertToUserTimeAsync(DateTime.Now)).ToString("f", CultureInfo.CurrentCulture))
</div>
<div class="col-md-4 col-xs-12 text-md-right text-center">
<b>nopCommerce version @NopVersion.FULL_VERSION</b>
</div>
</div>
</div>
</div>
</div> *@
</div>
<script>
var AdminLTEOptions = {
boxWidgetOptions: {
boxWidgetIcons: {
collapse: 'fa-minus',
open: 'fa-plus'
}
}
};
</script>
@{
//scroll to a selected card (if specified)
var selectedCardName = Html.GetSelectedCardName();
if (!String.IsNullOrEmpty(selectedCardName))
{
<script>
location.hash = '#@(selectedCardName)';
</script>
}
}
<a id="backTop" class="btn btn-back-top bg-teal"></a>
<script>
$(function() {
//enable "back top" arrow
$('#backTop').backTop();
//enable tooltips
$('[data-toggle="tooltip"]').tooltip({ placement: 'bottom' });
});
</script>
@NopHtml.GenerateScripts(ResourceLocation.Footer)
@NopHtml.GenerateInlineScripts(ResourceLocation.Footer)
</body>
</html>

View File

@ -25,6 +25,7 @@ using Nop.Plugin.Misc.FruitBankPlugin.Factories;
using Nop.Plugin.Misc.FruitBankPlugin.Filters;
using Nop.Plugin.Misc.FruitBankPlugin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage;
using Nop.Services.Catalog;
using Nop.Services.Common;
using Nop.Services.Events;
@ -118,6 +119,15 @@ public class PluginNopStartup : INopStartup
services.AddScoped<AICalculationService>();
services.AddScoped<PdfToImageService>();
services.AddSingleton<IFileStorageProvider>(sp =>
new LocalFileStorageProvider() // Uses default wwwroot/uploads
// Or specify custom path:
// new LocalFileStorageProvider(Path.Combine(Directory.GetCurrentDirectory(), "MyCustomPath"))
);
// Register the file storage service
services.AddScoped<FileStorageService>();
services.AddControllersWithViews(options =>
{
options.Filters.AddService<PendingMeasurementCheckoutFilter>();

View File

@ -55,11 +55,6 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Areas\Admin\Views\Product\List.cshtml">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Areas\Admin\Views\_ViewStart.cshtml">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
@ -96,6 +91,7 @@
<Folder Include="Areas\Admin\Extensions\" />
<Folder Include="Areas\Admin\Factories\" />
<Folder Include="Areas\Admin\Validators\" />
<Folder Include="Areas\Admin\Views\Product\" />
<Folder Include="Domains\Entities\" />
<Folder Include="Extensions\" />
<Folder Include="Validators\" />
@ -212,6 +208,9 @@
<None Update="Areas\Admin\Views\Order\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Product\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Shipping\Edit.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@ -224,6 +223,9 @@
<None Update="Areas\Admin\Views\VoiceOrder\Create.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\_FruitBankEmptyAdminLayout.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\_FruitBankAdminLayout.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

View File

@ -121,30 +121,32 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
var shippingsListMenuItem = new AdminMenuItem
{
Visible = true,
OpenUrlInNewTab = true,
SystemName = "FruitBank",
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShippingsList"), // You can localize this with await _localizationService.GetResourceAsync("...")
IconClass = "fas fa-shipping-fast",
Url = _adminMenu.GetMenuItemUrl("Shipping", "List")
//Url = _adminMenu.GetMenuItemUrl("Shipping", "List")
Url = "https://app.fruitbank.hu"
};
var createShippingMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "Shippings.Create",
Title = "Create Shipping",
IconClass = "far fa-circle",
Url = _adminMenu.GetMenuItemUrl("Shipping", "Create")
};
//var createShippingMenuItem = new AdminMenuItem
//{
// Visible = true,
// SystemName = "Shippings.Create",
// Title = "Create Shipping",
// IconClass = "far fa-circle",
// Url = _adminMenu.GetMenuItemUrl("Shipping", "Create")
//};
var editShippingMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "Shippings.Edit",
Title = "Edit Shipping",
IconClass = "far fa-circle",
Url = _adminMenu.GetMenuItemUrl("Shipping", "Edit")
};
//var editShippingMenuItem = new AdminMenuItem
//{
// Visible = true,
// SystemName = "Shippings.Edit",
// Title = "Edit Shipping",
// IconClass = "far fa-circle",
// Url = _adminMenu.GetMenuItemUrl("Shipping", "Edit")
//};
// Create a new top-level menu item
var shippingsMenuItem = new AdminMenuItem
@ -154,12 +156,44 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.Shippings"), // You can localize this with await _localizationService.GetResourceAsync("...")
IconClass = "fas fa-shipping-fast",
//Url = _adminMenu.GetMenuItemUrl("Shipping", "List")
ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
//ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
ChildNodes = [shippingsListMenuItem]
};
var shippingConfigurationItem = rootNode;
shippingConfigurationItem.ChildNodes.Insert(2, shippingsMenuItem);
// Create a new top-level menu item
var voiceOrderMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "FruitBank",
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.VoiceOrder"), // You can localize this with await _localizationService.GetResourceAsync("...")
IconClass = "fas fa-microphone",
Url = _adminMenu.GetMenuItemUrl("VoiceOrder", "Create")
//ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
};
shippingConfigurationItem.ChildNodes.Insert(3, voiceOrderMenuItem);
// Create a new top-level menu item
var InvoiceSyncMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "FruitBank",
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.InnVoiceOrderSync"), // You can localize this with await _localizationService.GetResourceAsync("...")
IconClass = "fas fa-file-invoice",
Url = _adminMenu.GetMenuItemUrl("InnVoiceOrderSync", "Index")
//ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
};
shippingConfigurationItem.ChildNodes.Insert(4, InvoiceSyncMenuItem);
var invoiceListMenuItem = new AdminMenuItem
{

View File

@ -0,0 +1,378 @@
using FruitBank.Common.Entities;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage
{
/// <summary>
/// Generic file storage service with compression, hash calculation, and duplicate detection
/// </summary>
public class FileStorageService
{
private readonly IFileStorageProvider _storageProvider;
private readonly FruitBankDbContext _dbContext;
// File extensions that are already compressed (don't GZip these)
private static readonly HashSet<string> CompressedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", // Images
".pdf", // PDFs
".zip", ".rar", ".7z", ".gz", ".bz2", // Archives
".mp4", ".avi", ".mov", ".mkv", // Videos
".mp3", ".flac", ".aac", ".ogg" // Audio
};
public FileStorageService(IFileStorageProvider storageProvider, FruitBankDbContext dbContext)
{
_storageProvider = storageProvider ?? throw new ArgumentNullException(nameof(storageProvider));
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
}
/// <summary>
/// Saves a file with optional compression, hash calculation, and duplicate detection
/// </summary>
/// <param name="fileStream">The file stream to save</param>
/// <param name="fileName">Original filename with extension</param>
/// <param name="userId">User ID for path organization</param>
/// <param name="featureName">Feature name (e.g., "ShippingDocumentProcessing")</param>
/// <param name="entityType">Entity type (e.g., "ShippingDocuments")</param>
/// <param name="entityId">Entity ID</param>
/// <param name="rawText">Optional raw text content (for AI-extracted documents)</param>
/// <param name="checkForDuplicates">If true, checks if file already exists by hash</param>
/// <returns>Created or existing Files entity with ID</returns>
public async Task<Files> SaveFileAsync(
Stream fileStream,
string fileName,
int userId,
string featureName,
string entityType,
int entityId,
string rawText = null,
bool checkForDuplicates = true)
{
if (fileStream == null)
throw new ArgumentNullException(nameof(fileStream));
if (string.IsNullOrWhiteSpace(fileName))
throw new ArgumentNullException(nameof(fileName));
// ✅ STEP 1: Calculate file hash from original stream
string fileHash = await CalculateFileHashAsync(fileStream);
fileStream.Position = 0; // Reset stream position after hashing
Console.WriteLine($"📝 File hash calculated: {fileHash}");
// ✅ STEP 2: Check for duplicate file by hash
if (checkForDuplicates)
{
var existingFile = await _dbContext.Files
.GetAll()
.FirstOrDefaultAsync(f => f.FileHash == fileHash);
if (existingFile != null)
{
Console.WriteLine($"♻️ Duplicate file detected! Reusing existing file ID: {existingFile.Id}");
return existingFile; // Return existing file instead of creating new one
}
}
// ✅ STEP 3: Create database record first to get ID
var fileExtension = Path.GetExtension(fileName);
var fileEntity = new Files
{
FileName = Path.GetFileNameWithoutExtension(fileName),
FileExtension = fileExtension,
RawText = rawText,
FileHash = fileHash, // ✅ Store the hash
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
IsCompressed = !IsAlreadyCompressed(fileExtension)
};
await _dbContext.Files.InsertAsync(fileEntity);
Console.WriteLine($"✅ File record created - ID: {fileEntity.Id}, Hash: {fileHash}");
// ✅ STEP 4: Build storage path with file ID
var fileNameWithId = $"{fileEntity.FileName}_{fileEntity.Id}{fileExtension}";
var relativePath = BuildRelativePath(userId, featureName, entityType, entityId, fileNameWithId);
// ✅ STEP 5: Determine if file should be compressed
bool shouldCompress = !IsAlreadyCompressed(fileExtension);
try
{
Stream streamToSave = fileStream;
// Compress if needed
if (shouldCompress)
{
streamToSave = await CompressStreamAsync(fileStream);
// Update filename to indicate compression
fileNameWithId += ".gz";
relativePath += ".gz";
}
// Save to storage provider
await _storageProvider.SaveFileAsync(streamToSave, relativePath);
// Dispose compressed stream if we created one
if (shouldCompress && streamToSave != fileStream)
{
await streamToSave.DisposeAsync();
}
Console.WriteLine($"💾 File saved: {relativePath} (Compressed: {shouldCompress})");
return fileEntity;
}
catch (Exception ex)
{
// Rollback database record if file save fails
await _dbContext.Files.DeleteAsync(fileEntity);
Console.Error.WriteLine($"❌ Error saving file: {ex.Message}");
throw;
}
}
/// <summary>
/// Check if a file with this hash already exists
/// </summary>
public async Task<Files> FindFileByHashAsync(string fileHash)
{
return await _dbContext.Files
.GetAll()
.FirstOrDefaultAsync(f => f.FileHash == fileHash);
}
/// <summary>
/// Get all files with the same hash (duplicates)
/// </summary>
public async Task<List<Files>> FindDuplicateFilesByHashAsync(string fileHash)
{
return await _dbContext.Files
.GetAll()
.Where(f => f.FileHash == fileHash)
.ToListAsync();
}
/// <summary>
/// Calculate SHA256 hash from stream
/// </summary>
private async Task<string> CalculateFileHashAsync(Stream stream)
{
using (var sha256 = SHA256.Create())
{
var hashBytes = await Task.Run(() => sha256.ComputeHash(stream));
return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
}
}
/// <summary>
/// Retrieves a file by ID with automatic decompression
/// </summary>
public async Task<(Stream FileStream, Files FileInfo)> GetFileByIdAsync(
int fileId,
int userId,
string featureName,
string entityType,
int entityId)
{
// Get file record from database
var fileEntity = await _dbContext.Files.GetByIdAsync(fileId);
if (fileEntity == null)
throw new FileNotFoundException($"File with ID {fileId} not found in database");
// Build path
var fileNameWithId = $"{fileEntity.FileName}_{fileEntity.Id}{fileEntity.FileExtension}";
var isCompressed = !IsAlreadyCompressed(fileEntity.FileExtension);
if (isCompressed)
{
fileNameWithId += ".gz";
}
var relativePath = BuildRelativePath(userId, featureName, entityType, entityId, fileNameWithId);
// Get file from storage
var fileStream = await _storageProvider.GetFileAsync(relativePath);
// Decompress if needed
if (isCompressed)
{
var decompressedStream = await DecompressStreamAsync(fileStream);
await fileStream.DisposeAsync();
fileStream = decompressedStream;
}
return (fileStream, fileEntity);
}
/// <summary>
/// Gets all files from database
/// </summary>
public async Task<List<Files>> GetAllFilesAsync()
{
return await _dbContext.Files.GetAll().ToListAsync();
}
/// <summary>
/// Searches files by filename, hash, or raw text content
/// </summary>
public async Task<List<Files>> SearchFilesAsync(string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return await GetAllFilesAsync();
var allFiles = await _dbContext.Files.GetAll().ToListAsync();
var results = allFiles.Where(f =>
// Search in filename
(!string.IsNullOrEmpty(f.FileName) &&
f.FileName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) ||
// Search in file extension
(!string.IsNullOrEmpty(f.FileExtension) &&
f.FileExtension.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) ||
// Search in file hash
(!string.IsNullOrEmpty(f.FileHash) &&
f.FileHash.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) ||
// Full-text search in RawText (only if RawText is not null)
(!string.IsNullOrEmpty(f.RawText) &&
f.RawText.Contains(searchTerm, StringComparison.OrdinalIgnoreCase))
).ToList();
Console.WriteLine($"🔍 Search for '{searchTerm}' returned {results.Count} results");
return results;
}
/// <summary>
/// Adds or updates a file record in the database
/// </summary>
public async Task<Files> AddOrUpdateFileAsync(Files fileEntity)
{
if (fileEntity == null)
throw new ArgumentNullException(nameof(fileEntity));
if (fileEntity.Id > 0)
{
// Update existing
fileEntity.Modified = DateTime.UtcNow;
await _dbContext.Files.UpdateAsync(fileEntity);
}
else
{
// Add new
fileEntity.Created = DateTime.UtcNow;
fileEntity.Modified = DateTime.UtcNow;
await _dbContext.Files.InsertAsync(fileEntity);
}
return fileEntity;
}
/// <summary>
/// Deletes a file from both storage and database
/// </summary>
public async Task<bool> DeleteFileAsync(
int fileId,
int userId,
string featureName,
string entityType,
int entityId)
{
var fileEntity = await _dbContext.Files.GetByIdAsync(fileId);
if (fileEntity == null)
return false;
// Build path
var fileNameWithId = $"{fileEntity.FileName}_{fileEntity.Id}{fileEntity.FileExtension}";
var isCompressed = !IsAlreadyCompressed(fileEntity.FileExtension);
if (isCompressed)
{
fileNameWithId += ".gz";
}
var relativePath = BuildRelativePath(userId, featureName, entityType, entityId, fileNameWithId);
// Delete from storage
await _storageProvider.DeleteFileAsync(relativePath);
// Delete from database
await _dbContext.Files.DeleteAsync(fileEntity);
return true;
}
#region Private Helper Methods
/// <summary>
/// Builds the relative storage path
/// </summary>
private string BuildRelativePath(int userId, string featureName, string entityType, int entityId, string fileName)
{
return Path.Combine(
userId.ToString(),
featureName,
$"{entityType}-{entityId}",
fileName
);
}
/// <summary>
/// Checks if a file extension represents an already-compressed format
/// </summary>
private bool IsAlreadyCompressed(string extension)
{
return CompressedExtensions.Contains(extension);
}
/// <summary>
/// Compresses a stream using GZip
/// </summary>
private async Task<Stream> CompressStreamAsync(Stream inputStream)
{
var compressedStream = new MemoryStream();
using (var gzipStream = new GZipStream(compressedStream, CompressionMode.Compress, leaveOpen: true))
{
await inputStream.CopyToAsync(gzipStream);
}
compressedStream.Position = 0;
return compressedStream;
}
/// <summary>
/// Decompresses a GZip stream
/// </summary>
private async Task<Stream> DecompressStreamAsync(Stream compressedStream)
{
var decompressedStream = new MemoryStream();
using (var gzipStream = new GZipStream(compressedStream, CompressionMode.Decompress, leaveOpen: true))
{
await gzipStream.CopyToAsync(decompressedStream);
}
decompressedStream.Position = 0;
return decompressedStream;
}
#endregion
}
}

View File

@ -0,0 +1,40 @@
using System.IO;
using System.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage
{
/// <summary>
/// Interface for file storage providers (local, Azure, S3, etc.)
/// </summary>
public interface IFileStorageProvider
{
/// <summary>
/// Saves a file stream to the specified relative path
/// </summary>
/// <param name="fileStream">The file stream to save</param>
/// <param name="relativePath">Relative path from storage root (e.g., "123/AIdocumentprocessing/ShippingDocuments-456/file.pdf")</param>
/// <returns>The full path where the file was saved</returns>
Task<string> SaveFileAsync(Stream fileStream, string relativePath);
/// <summary>
/// Retrieves a file stream from the specified relative path
/// </summary>
/// <param name="relativePath">Relative path from storage root</param>
/// <returns>File stream</returns>
Task<Stream> GetFileAsync(string relativePath);
/// <summary>
/// Deletes a file at the specified relative path
/// </summary>
/// <param name="relativePath">Relative path from storage root</param>
/// <returns>True if deleted successfully</returns>
Task<bool> DeleteFileAsync(string relativePath);
/// <summary>
/// Checks if a file exists at the specified relative path
/// </summary>
/// <param name="relativePath">Relative path from storage root</param>
/// <returns>True if file exists</returns>
Task<bool> FileExistsAsync(string relativePath);
}
}

View File

@ -0,0 +1,139 @@
using System;
using System.IO;
using System.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage
{
/// <summary>
/// Local file system storage provider - saves files to wwwroot/uploads
/// </summary>
public class LocalFileStorageProvider : IFileStorageProvider
{
private readonly string _baseDirectory;
public LocalFileStorageProvider(string baseDirectory = null)
{
// Default to wwwroot/uploads if not specified
_baseDirectory = baseDirectory ?? Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads");
// Ensure base directory exists
if (!Directory.Exists(_baseDirectory))
{
Directory.CreateDirectory(_baseDirectory);
}
}
/// <summary>
/// Saves a file stream to local file system
/// </summary>
public async Task<string> SaveFileAsync(Stream fileStream, string relativePath)
{
if (fileStream == null)
throw new ArgumentNullException(nameof(fileStream));
if (string.IsNullOrWhiteSpace(relativePath))
throw new ArgumentNullException(nameof(relativePath));
var fullPath = Path.Combine(_baseDirectory, relativePath);
var directory = Path.GetDirectoryName(fullPath);
// Ensure directory exists
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
// Save file
using (var fileStreamOutput = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await fileStream.CopyToAsync(fileStreamOutput);
}
return fullPath;
}
/// <summary>
/// Retrieves a file stream from local file system
/// </summary>
public async Task<Stream> GetFileAsync(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
throw new ArgumentNullException(nameof(relativePath));
var fullPath = Path.Combine(_baseDirectory, relativePath);
if (!File.Exists(fullPath))
throw new FileNotFoundException($"File not found: {relativePath}", fullPath);
// Read file into memory stream to avoid file locks
var memoryStream = new MemoryStream();
using (var fileStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read))
{
await fileStream.CopyToAsync(memoryStream);
}
memoryStream.Position = 0;
return memoryStream;
}
/// <summary>
/// Deletes a file from local file system
/// </summary>
public Task<bool> DeleteFileAsync(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
throw new ArgumentNullException(nameof(relativePath));
var fullPath = Path.Combine(_baseDirectory, relativePath);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
// Try to clean up empty directories
CleanupEmptyDirectories(fullPath);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
/// <summary>
/// Checks if a file exists in local file system
/// </summary>
public Task<bool> FileExistsAsync(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
return Task.FromResult(false);
var fullPath = Path.Combine(_baseDirectory, relativePath);
return Task.FromResult(File.Exists(fullPath));
}
/// <summary>
/// Cleans up empty parent directories after file deletion
/// </summary>
private void CleanupEmptyDirectories(string filePath)
{
try
{
var directory = Path.GetDirectoryName(filePath);
while (!string.IsNullOrEmpty(directory) &&
directory != _baseDirectory &&
Directory.Exists(directory) &&
Directory.GetFiles(directory).Length == 0 &&
Directory.GetDirectories(directory).Length == 0)
{
Directory.Delete(directory);
directory = Path.GetDirectoryName(directory);
}
}
catch
{
// Ignore cleanup errors - not critical
}
}
}
}

View File

@ -38,21 +38,34 @@
</div>
<hr />
<div class="form-group row">
<div class="col-md-4">
<div class="col-md-3">
<button type="button" class="btn btn-warning btn-block" data-toggle="modal" data-target="#allowRevisionModal">
<i class="fa fa-redo"></i> Újramérés engedélyezése
</button>
</div>
<div class="col-md-4">
<div class="col-md-3">
<button type="button" class="btn btn-primary btn-block" data-toggle="modal" data-target="#sendMessageModal">
<i class="fas fa-paper-plane"></i> Üzenet küldése
</button>
</div>
<div class="col-md-4">
<div class="col-md-3">
<button type="button" class="btn btn-info btn-block" data-toggle="modal" data-target="#addOrderNoteModal">
<i class="fas fa-sticky-note"></i> Jegyzet hozzáadása
</button>
</div>
<div class="col-md-3">
<button type="button"
class="btn btn-danger btn-block"
data-toggle="modal"
data-target="#splitOrderModal"
@if(Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.Audited ||
Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.NotStarted)
{
<text>disabled</text>
}>
<i class="fas fa-cut"></i> Rendelés szétválasztása
</button>
</div>
</div>
</div>
</div>
@ -200,6 +213,73 @@
</div>
</div>
<!-- Split Order Modal -->
<div class="modal fade" id="splitOrderModal" tabindex="-1" role="dialog" aria-labelledby="splitOrderModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="splitOrderModalLabel">
<i class="fas fa-cut"></i> Rendelés szétválasztása
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
@if (Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.Audited)
{
<div class="alert alert-danger">
<i class="fas fa-lock"></i>
<strong>Ez a rendelés már auditált!</strong> Auditált rendelések nem választhatók szét.
</div>
}
else if (Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.NotStarted)
{
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>Ez a rendelés még nem lett elkezdve!</strong> Csak elkezdett rendelések választhatók szét.
</div>
}
else
{
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>Figyelem!</strong> Ez a művelet az auditált és nem auditált termékeket külön rendelésekbe választja szét.
</div>
<h6>Auditált termékek (maradnak ebben a rendelésben):</h6>
<ul id="auditedItemsList" class="list-group mb-3">
<!-- Will be populated by JavaScript -->
</ul>
<h6>Nem auditált termékek (új rendelésbe kerülnek):</h6>
<ul id="nonAuditedItemsList" class="list-group mb-3">
<!-- Will be populated by JavaScript -->
</ul>
}
<div id="splitOrderStatus" class="alert" style="display: none; margin-top: 15px;">
<i class="fas fa-info-circle"></i> <span id="splitOrderStatusMessage"></span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
<i class="fa fa-times"></i> Mégse
</button>
<button type="button" id="splitOrderBtn" class="btn btn-danger"
@if (Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.Audited ||
Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.NotStarted)
{
<text>disabled</text>
}
>
<i class="fas fa-cut"></i> Rendelés szétválasztása
</button>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function () {
@ -738,5 +818,171 @@
$("#orderNoteText").val("");
});
// ========== SPLIT ORDER MANAGEMENT ==========
var splitOrderUrl = '@Url.Action("SplitOrder", "CustomOrder")';
var measuringStatus = @((int)Model.OrderDto.MeasuringStatus);
var isAudited = measuringStatus === @((int)FruitBank.Common.Enums.MeasuringStatus.Audited);
var isNotStarted = measuringStatus === @((int)FruitBank.Common.Enums.MeasuringStatus.NotStarted);
// Populate split order modal when opened
$('#splitOrderModal').on('show.bs.modal', function () {
// Skip populating lists if order is audited or not started
if (isAudited || isNotStarted) {
return;
}
var auditedItems = [];
var nonAuditedItems = [];
@foreach (var orderItem in Model.OrderDto.OrderItemDtos)
{
@if (orderItem.IsAudited)
{
<text>
auditedItems.push({
id: @orderItem.Id,
name: '@Html.Raw(orderItem.ProductName)',
quantity: @orderItem.Quantity
});
</text>
}
else
{
<text>
nonAuditedItems.push({
id: @orderItem.Id,
name: '@Html.Raw(orderItem.ProductName)',
quantity: @orderItem.Quantity
});
</text>
}
}
// Populate audited items list
var auditedListHtml = '';
if (auditedItems.length > 0) {
auditedItems.forEach(function(item) {
auditedListHtml += '<li class="list-group-item">' +
'<i class="fas fa-check-circle text-success"></i> ' +
item.name + ' - ' + item.quantity + ' db</li>';
});
} else {
auditedListHtml = '<li class="list-group-item text-muted">Nincsenek auditált termékek</li>';
}
$('#auditedItemsList').html(auditedListHtml);
// Populate non-audited items list
var nonAuditedListHtml = '';
if (nonAuditedItems.length > 0) {
nonAuditedItems.forEach(function(item) {
nonAuditedListHtml += '<li class="list-group-item">' +
'<i class="fas fa-times-circle text-danger"></i> ' +
item.name + ' - ' + item.quantity + ' db</li>';
});
} else {
nonAuditedListHtml = '<li class="list-group-item text-muted">Nincsenek nem auditált termékek</li>';
}
$('#nonAuditedItemsList').html(nonAuditedListHtml);
// Disable split button if no non-audited items or all items audited
if (nonAuditedItems.length === 0 || auditedItems.length === 0) {
$('#splitOrderBtn').prop('disabled', true);
if (nonAuditedItems.length === 0) {
showSplitOrderStatus("Nincs szétválasztható termék (minden termék auditált)", "warning");
} else if (auditedItems.length === 0) {
showSplitOrderStatus("Nincs szétválasztható termék (minden termék nem auditált)", "warning");
}
} else {
$('#splitOrderBtn').prop('disabled', false);
}
});
// Split Order Button
$("#splitOrderBtn").click(function (e) {
e.preventDefault();
e.stopPropagation();
// Additional safety check
if (isAudited) {
showSplitOrderStatus("Ez a rendelés már auditált, nem választható szét!", "danger");
return false;
}
if (isNotStarted) {
showSplitOrderStatus("Ez a rendelés még nem lett elkezdve, nem választható szét!", "danger");
return false;
}
if (!confirm('Biztosan szét szeretné választani ezt a rendelést? Ez a művelet nem vonható vissza!')) {
return false;
}
var btn = $(this);
btn.prop("disabled", true).html('<i class="fas fa-spinner fa-spin"></i> Feldolgozás...');
showSplitOrderStatus("Rendelés szétválasztása folyamatban...", "info");
$.ajax({
type: "POST",
url: splitOrderUrl,
data: {
orderId: @Model.OrderId,
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
},
dataType: 'json',
success: function (response) {
console.log("Split Order Response:", response);
btn.prop("disabled", false).html('<i class="fas fa-cut"></i> Rendelés szétválasztása');
if (response.success) {
showSplitOrderStatus("Rendelés sikeresen szétválasztva! Új rendelés azonosító: #" + response.newOrderId, "success");
// Redirect to the current order after 2 seconds
setTimeout(function() {
window.location.href = '@Url.Action("Edit", "Order")' + '?id=' + @Model.OrderId;
}, 2000);
} else {
showSplitOrderStatus("Hiba: " + (response.message || "Ismeretlen hiba"), "danger");
}
},
error: function (xhr, status, error) {
console.error("Split Order AJAX Error:", xhr, status, error);
btn.prop("disabled", false).html('<i class="fas fa-cut"></i> Rendelés szétválasztása');
var errorMessage = "Hiba a rendelés szétválasztása közben";
if (xhr.responseJSON && xhr.responseJSON.message) {
errorMessage = xhr.responseJSON.message;
} else if (xhr.responseText) {
try {
var errorObj = JSON.parse(xhr.responseText);
errorMessage = errorObj.message || errorMessage;
} catch (e) {
errorMessage = "Hiba: " + xhr.status + " - " + xhr.statusText;
}
}
showSplitOrderStatus(errorMessage, "danger");
}
});
return false;
});
function showSplitOrderStatus(message, type) {
var statusDiv = $("#splitOrderStatus");
statusDiv.removeClass("alert-info alert-success alert-warning alert-danger")
.addClass("alert-" + type);
$("#splitOrderStatusMessage").text(message);
statusDiv.show();
}
// Clear split order status when modal is closed
$('#splitOrderModal').on('hidden.bs.modal', function () {
$("#splitOrderStatus").hide();
if (!isAudited && !isNotStarted) {
$("#splitOrderBtn").prop('disabled', false).html('<i class="fas fa-cut"></i> Rendelés szétválasztása');
}
});
});
</script>