Merge branch '4.80' of https://git.aycode.com/Adam/Mango.Nop.Plugins into 4.80
This commit is contained in:
commit
f5e356555c
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
//{
|
||||
|
|
@ -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}" });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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." });
|
||||
}
|
||||
|
||||
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>();
|
||||
|
||||
try
|
||||
{
|
||||
// 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,7 +903,213 @@ 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
|
||||
{
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 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) => {
|
||||
|
|
@ -239,14 +341,31 @@
|
|||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.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,27 +400,96 @@
|
|||
});
|
||||
});
|
||||
|
||||
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;
|
||||
document.getElementById('itemCount').textContent = shippingItems.length;
|
||||
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted">No shipping items found</td></tr>';
|
||||
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) => {
|
||||
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
|
||||
|
|
@ -309,25 +497,408 @@
|
|||
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>${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>
|
||||
<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>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
// 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
|
|
@ -335,7 +906,7 @@
|
|||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
return text.toString().replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
function showMessage(message, type) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
async function startRecording(type) {
|
||||
console.log('[VoiceOrder] ========== START RECORDING ATTEMPT ==========');
|
||||
console.log('[VoiceOrder] Type:', type);
|
||||
console.log('[VoiceOrder] Timestamp:', new Date().toISOString());
|
||||
|
||||
try {
|
||||
// Check 1: Browser support
|
||||
console.log('[VoiceOrder] Check 1: Browser support');
|
||||
if (!navigator.mediaDevices) {
|
||||
console.error('[VoiceOrder] FAIL: navigator.mediaDevices is undefined');
|
||||
alert('Your browser does not support mediaDevices API. Browser: ' + navigator.userAgent);
|
||||
return;
|
||||
}
|
||||
console.log('[VoiceOrder] PASS: navigator.mediaDevices exists');
|
||||
|
||||
if (!navigator.mediaDevices.getUserMedia) {
|
||||
console.error('[VoiceOrder] FAIL: getUserMedia is undefined');
|
||||
alert('Your browser does not support getUserMedia. Browser: ' + navigator.userAgent);
|
||||
return;
|
||||
}
|
||||
console.log('[VoiceOrder] PASS: getUserMedia exists');
|
||||
|
||||
// Check 2: Security context
|
||||
console.log('[VoiceOrder] Check 2: Security context');
|
||||
console.log('[VoiceOrder] - Protocol:', window.location.protocol);
|
||||
console.log('[VoiceOrder] - Hostname:', window.location.hostname);
|
||||
console.log('[VoiceOrder] - IsSecureContext:', window.isSecureContext);
|
||||
console.log('[VoiceOrder] - Full URL:', window.location.href);
|
||||
|
||||
// Check 3: Try simple audio constraint first
|
||||
console.log('[VoiceOrder] Check 3: Requesting microphone with simple constraints');
|
||||
console.log('[VoiceOrder] Calling getUserMedia({ audio: true })...');
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log('[VoiceOrder] SUCCESS: Got media stream');
|
||||
console.log('[VoiceOrder] Stream ID:', stream.id);
|
||||
console.log('[VoiceOrder] Stream active:', stream.active);
|
||||
console.log('[VoiceOrder] Audio tracks:', stream.getAudioTracks().length);
|
||||
|
||||
if (stream.getAudioTracks().length > 0) {
|
||||
const track = stream.getAudioTracks()[0];
|
||||
console.log('[VoiceOrder] Track label:', track.label);
|
||||
console.log('[VoiceOrder] Track enabled:', track.enabled);
|
||||
console.log('[VoiceOrder] Track muted:', track.muted);
|
||||
console.log('[VoiceOrder] Track readyState:', track.readyState);
|
||||
console.log('[VoiceOrder] Track settings:', track.getSettings());
|
||||
}
|
||||
} catch (getUserMediaError) {
|
||||
console.error('[VoiceOrder] FAIL: getUserMedia threw error');
|
||||
console.error('[VoiceOrder] Error name:', getUserMediaError.name);
|
||||
console.error('[VoiceOrder] Error message:', getUserMediaError.message);
|
||||
console.error('[VoiceOrder] Error stack:', getUserMediaError.stack);
|
||||
throw getUserMediaError;
|
||||
}
|
||||
|
||||
// Check 4: MediaRecorder support
|
||||
console.log('[VoiceOrder] Check 4: MediaRecorder support');
|
||||
if (!window.MediaRecorder) {
|
||||
console.error('[VoiceOrder] FAIL: MediaRecorder not supported');
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
alert('MediaRecorder is not supported in your browser: ' + navigator.userAgent);
|
||||
return;
|
||||
}
|
||||
console.log('[VoiceOrder] PASS: MediaRecorder exists');
|
||||
|
||||
// Check 5: Check supported MIME types
|
||||
console.log('[VoiceOrder] Check 5: Checking MIME type support');
|
||||
function getSupportedMimeType() {
|
||||
const mimeTypes = [
|
||||
'audio/webm',
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/mp4',
|
||||
'audio/mpeg'
|
||||
'audio/mp4'
|
||||
];
|
||||
|
||||
let supportedMimeType = null;
|
||||
for (const mimeType of mimeTypes) {
|
||||
const isSupported = MediaRecorder.isTypeSupported(mimeType);
|
||||
console.log('[VoiceOrder] - ' + mimeType + ':', isSupported);
|
||||
if (isSupported && !supportedMimeType) {
|
||||
supportedMimeType = mimeType;
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) {
|
||||
return 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 'audio/webm';
|
||||
}
|
||||
|
||||
async function startRecording(type) {
|
||||
console.log('[VoiceOrder] Starting recording with VAD for:', type);
|
||||
|
||||
try {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
alert('Your browser does not support audio recording.');
|
||||
return;
|
||||
}
|
||||
console.log('[VoiceOrder] Using MIME type:', supportedMimeType);
|
||||
|
||||
// Check 6: Create MediaRecorder
|
||||
console.log('[VoiceOrder] Check 6: Creating MediaRecorder');
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log('[VoiceOrder] Got media stream');
|
||||
|
||||
// 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;
|
||||
|
||||
const supportedMimeType = getSupportedMimeType();
|
||||
mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: supportedMimeType
|
||||
});
|
||||
console.log('[VoiceOrder] SUCCESS: MediaRecorder created');
|
||||
console.log('[VoiceOrder] MediaRecorder state:', mediaRecorder.state);
|
||||
} catch (recorderError) {
|
||||
console.error('[VoiceOrder] FAIL: MediaRecorder constructor threw error');
|
||||
console.error('[VoiceOrder] Error:', recorderError);
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
throw recorderError;
|
||||
}
|
||||
|
||||
audioChunks = [];
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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> </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>
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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">×</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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue