This commit is contained in:
Loretta 2025-11-18 12:52:15 +01:00
commit 91f1d5b9f8
22 changed files with 1137 additions and 21 deletions

View File

@ -226,7 +226,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
PaymentStatusIds = paymentStatuses,
ShippingStatusIds = shippingStatuses,
AvailablePageSizes = "20,50,100,500",
SortColumnDirection = "desc",
});
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/List.cshtml", model);

View File

@ -1,12 +1,177 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nop.Core.Domain.Catalog;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Catalog;
using Nop.Services.Security;
using Nop.Web.Framework;
using Nop.Web.Framework.Controllers;
using Nop.Web.Framework.Mvc.Filters;
using PDFtoImage;
using SkiaSharp;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
namespace Nop.Plugin.Misc.FruitBank.Controllers
{
public class FileManagerController : Controller
[AuthorizeAdmin]
[Area(AreaNames.ADMIN)]
[AutoValidateAntiforgeryToken]
public class FileManagerController : BasePluginController
{
public IActionResult BindingToFileSystem()
private readonly IPermissionService _permissionService;
private readonly OpenAIApiService _aiApiService;
private readonly IProductService _productService;
private readonly FruitBankDbContext _dbContext;
private readonly PdfToImageService _pdfToImageService;
public FileManagerController(
IPermissionService permissionService,
OpenAIApiService aiApiService,
IProductService productService,
FruitBankDbContext fruitBankDbContext,
PdfToImageService pdfToImageService)
{
return View();
_permissionService = permissionService;
_aiApiService = aiApiService;
_productService = productService;
_dbContext = fruitBankDbContext;
_pdfToImageService = pdfToImageService;
}
/// <summary>
/// Display the image text extraction page
/// </summary>
public async Task<IActionResult> ImageTextExtraction()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Extras/ImageTextExtraction.cshtml");
}
/// <summary>
/// Endpoint to extract text from uploaded image
/// </summary>
[HttpPost]
public async Task<IActionResult> ExtractTextFromImage(IFormFile imageFile, string customPrompt = null)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (imageFile == null || imageFile.Length == 0)
{
return Json(new { success = false, message = "No file received" });
}
// Validate file type - now including PDF
var extension = 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
{
// Define the uploads folder
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "ocr");
// Create directory if it doesn't exist
if (!Directory.Exists(uploadsFolder))
{
Directory.CreateDirectory(uploadsFolder);
}
string processedFilePath;
string processedFileName;
// Handle PDF conversion
if (extension == ".pdf")
{
// Save the PDF temporarily
var tempPdfFileName = $"temp_pdf_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
var tempPdfPath = Path.Combine(uploadsFolder, tempPdfFileName);
using (var stream = new FileStream(tempPdfPath, FileMode.Create))
{
await imageFile.CopyToAsync(stream);
}
// Convert PDF to JPG using our service
var convertedImages = await _pdfToImageService.ConvertPdfToJpgAsync(tempPdfPath, uploadsFolder);
if (convertedImages == null || convertedImages.Count == 0)
{
// Clean up temp PDF
if (System.IO.File.Exists(tempPdfPath))
System.IO.File.Delete(tempPdfPath);
return Json(new { success = false, message = "Failed to convert PDF or PDF is empty" });
}
// Use the first page
processedFilePath = convertedImages[0];
processedFileName = Path.GetFileName(processedFilePath);
// Clean up temp PDF
if (System.IO.File.Exists(tempPdfPath))
System.IO.File.Delete(tempPdfPath);
}
else
{
// Handle regular image files
processedFileName = $"ocr_image_{DateTime.Now:yyyyMMdd_HHmmss}{extension}";
processedFilePath = Path.Combine(uploadsFolder, processedFileName);
using (var stream = new FileStream(processedFilePath, FileMode.Create))
{
await imageFile.CopyToAsync(stream);
}
}
// Extract text from the processed image using OpenAI Vision API
string extractedText;
using (var imageStream = new FileStream(processedFilePath, FileMode.Open, FileAccess.Read))
{
extractedText = await _aiApiService.ExtractTextFromImageAsync(
imageStream,
processedFileName,
customPrompt
);
}
if (string.IsNullOrEmpty(extractedText))
{
return Json(new
{
success = false,
message = "Failed to extract text. The API may have returned an empty response."
});
}
return Json(new
{
success = true,
message = extension == ".pdf"
? "PDF converted and text extracted successfully"
: "Text extracted successfully",
extractedText = extractedText,
fileName = processedFileName,
filePath = processedFilePath,
fileSize = imageFile.Length,
wasConverted = extension == ".pdf"
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error in ExtractTextFromImage: {ex}");
return Json(new
{
success = false,
message = $"Error processing file: {ex.Message}"
});
}
}
}
}

View File

@ -0,0 +1,160 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Security;
using Nop.Web.Framework;
using Nop.Web.Framework.Controllers;
using Nop.Web.Framework.Mvc.Filters;
using System;
using System.IO;
using System.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBank.Controllers
{
[AuthorizeAdmin]
[Area(AreaNames.ADMIN)]
[AutoValidateAntiforgeryToken]
public class FruitBankAudioController : BasePluginController
{
private readonly IPermissionService _permissionService;
private readonly OpenAIApiService _aiApiService;
private readonly FruitBankDbContext _dbContext;
public FruitBankAudioController(
IPermissionService permissionService,
OpenAIApiService aiApiService,
FruitBankDbContext fruitBankDbContext)
{
_permissionService = permissionService;
_aiApiService = aiApiService;
_dbContext = fruitBankDbContext;
}
/// <summary>
/// Display the voice recorder page
/// </summary>
public async Task<IActionResult> VoiceRecorder()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Extras/VoiceRecorder.cshtml");
}
/// <summary>
/// Endpoint to receive voice recording
/// </summary>
[HttpPost]
public async Task<IActionResult> ReceiveVoiceRecording(IFormFile audioFile)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (audioFile == null || audioFile.Length == 0)
{
return Json(new { success = false, message = "No audio file received" });
}
string productDataSummary = string.Empty;
string orderDataSummary = string.Empty;
var products = await _dbContext.Products.GetAll().ToListAsync();
foreach (var product in products)
{
//let's prepare basic product and stock information for AI analysis
if (product == null) continue;
var productName = product.Name;
var stockQuantity = product.StockQuantity;
productDataSummary += $"Product: {productName}, Stock Quantity: {stockQuantity}\n";
}
var orders = await _dbContext.OrderDtos.GetAll(true).ToListAsync();
foreach (var order in orders)
{
//let's prepare basic order information for AI analysis
if (order == null) continue;
var orderId = order.Id;
var customerName = order.Customer.Company;
var totalAmount = order.OrderTotal;
var dateofReceipt = order.DateOfReceipt;
var isMeasurable = order.IsMeasurable;
var itemsInOrder = order.OrderItemDtos.Count;
orderDataSummary += $"Order ID: {orderId}, Customer: {customerName}, Total Amount: {totalAmount}, Date of Receipt: {dateofReceipt}, Is Measurable: {isMeasurable}, Items in Order: {itemsInOrder}\n";
}
Console.WriteLine("Product Data Summary: " + productDataSummary);
try
{
// Generate a unique filename
var fileName = $"voice_recording_{DateTime.Now:yyyyMMdd_HHmmss}.webm";
// Define the path where you want to save the file
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "voice");
// Create directory if it doesn't exist
if (!Directory.Exists(uploadsFolder))
{
Directory.CreateDirectory(uploadsFolder);
}
var filePath = Path.Combine(uploadsFolder, fileName);
// Save the file locally
using (var stream = new FileStream(filePath, FileMode.Create))
{
await audioFile.CopyToAsync(stream);
}
// Transcribe the audio using OpenAI Whisper API
string transcribedText;
using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, "en"); // or "hu" for Hungarian
}
if (string.IsNullOrEmpty(transcribedText))
{
return Json(new
{
success = false,
message = "Failed to transcribe audio"
});
}
string SystemMessage = "You are an assistant that helps with analyzing data for Fruitbank, a fruit trading company based in Hungary. " +
"Provide insights and suggestions based on the provided data." +
$"Products information: {productDataSummary}" +
$"Orders information: {orderDataSummary}";
var AIResponse = await _aiApiService.GetSimpleResponseAsync(SystemMessage, $"Transcribed Text: {transcribedText}");
return Json(new
{
success = true,
message = $"Audio transcribed successfully",
transcription = AIResponse,
filePath = filePath,
fileSize = audioFile.Length
});
}
catch (Exception ex)
{
return Json(new
{
success = false,
message = $"Error processing audio: {ex.Message}"
});
}
}
}
}

View File

@ -8,7 +8,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models
public record EditShippingModel : BaseNopModel
{
public int Id { get; set; }
public string LicencePlate { get; set; }
@ -61,4 +61,4 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models
public List<int> DocumentIds { get; set; } = new();
public string Operation { get; set; } // "delete", "activate", "deactivate"
}
}
}

View File

@ -0,0 +1,223 @@
@{
Layout = "_ConfigurePlugin";
}
@await Component.InvokeAsync("StoreScopeConfiguration")
<form asp-action="ExtractTextFromImage" asp-controller="FileManager" method="post">
@Html.AntiForgeryToken()
</form>
<div class="card card-default">
<div class="card-body">
<h4>Image & PDF Text Extraction (OCR)</h4>
<p>Upload an image or PDF to extract text using OpenAI Vision API.</p>
<div class="form-group row">
<div class="col-md-9">
<div class="custom-file">
<input type="file" class="custom-file-input" id="imageFile" accept="image/*,.pdf,application/pdf">
<label class="custom-file-label" for="imageFile">Choose image or PDF file...</label>
</div>
<small class="form-text text-muted">Supported formats: JPG, PNG, GIF, WebP, PDF</small>
</div>
</div>
<div class="form-group row">
<div class="col-md-9">
<label for="customPrompt">Custom Prompt (Optional)</label>
<input type="text" class="form-control" id="customPrompt" placeholder="Leave empty for default: 'Olvasd ki a szöveget és add vissza szépen strukturálva.'">
<small class="form-text text-muted">Customize how the AI should extract and format the text</small>
</div>
</div>
<div class="form-group row">
<div class="col-md-9">
<button type="button" id="uploadButton" class="btn btn-primary" disabled>
<i class="fas fa-upload"></i> Extract Text
</button>
</div>
</div>
<div class="form-group row">
<div class="col-md-9">
<div id="responseMessage" class="alert" style="display:none;"></div>
</div>
</div>
<div class="form-group row" id="filePreviewSection" style="display:none;">
<div class="col-md-9">
<h5>File Preview:</h5>
<div id="imagePreviewContainer" style="display:none;">
<img id="imagePreview" src="" alt="Preview" style="max-width: 100%; max-height: 400px; border: 1px solid #ddd; padding: 5px;">
</div>
<div id="pdfPreviewContainer" style="display:none;">
<div class="alert alert-info">
<i class="fas fa-file-pdf"></i> PDF file selected. The first page will be converted to image for text extraction.
</div>
<embed id="pdfPreview" type="application/pdf" style="width: 100%; height: 500px; border: 1px solid #ddd;">
</div>
</div>
</div>
<div class="form-group row" id="extractedTextSection" style="display:none;">
<div class="col-md-9">
<h5>Extracted Text:</h5>
<div class="card">
<div class="card-body">
<pre id="extractedText" style="white-space: pre-wrap; word-wrap: break-word; max-height: 500px; overflow-y: auto;"></pre>
</div>
</div>
<button type="button" id="copyButton" class="btn btn-secondary mt-2">
<i class="fas fa-copy"></i> Copy to Clipboard
</button>
</div>
</div>
</div>
</div>
<script>
const imageFileInput = document.getElementById('imageFile');
const customPromptInput = document.getElementById('customPrompt');
const uploadButton = document.getElementById('uploadButton');
const responseMessage = document.getElementById('responseMessage');
const filePreviewSection = document.getElementById('filePreviewSection');
const imagePreviewContainer = document.getElementById('imagePreviewContainer');
const pdfPreviewContainer = document.getElementById('pdfPreviewContainer');
const imagePreview = document.getElementById('imagePreview');
const pdfPreview = document.getElementById('pdfPreview');
const extractedTextSection = document.getElementById('extractedTextSection');
const extractedText = document.getElementById('extractedText');
const copyButton = document.getElementById('copyButton');
const fileLabel = document.querySelector('.custom-file-label');
let selectedFile = null;
// Update file label and enable upload button when file is selected
imageFileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) {
selectedFile = file;
fileLabel.textContent = file.name;
uploadButton.disabled = false;
// Check file type and show appropriate preview
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
if (isPdf) {
// Show PDF preview
imagePreviewContainer.style.display = 'none';
pdfPreviewContainer.style.display = 'block';
const fileUrl = URL.createObjectURL(file);
pdfPreview.src = fileUrl;
filePreviewSection.style.display = 'block';
} else {
// Show image preview
pdfPreviewContainer.style.display = 'none';
imagePreviewContainer.style.display = 'block';
const reader = new FileReader();
reader.onload = (e) => {
imagePreview.src = e.target.result;
filePreviewSection.style.display = 'block';
};
reader.readAsDataURL(file);
}
} else {
selectedFile = null;
fileLabel.textContent = 'Choose image or PDF file...';
uploadButton.disabled = true;
filePreviewSection.style.display = 'none';
}
extractedTextSection.style.display = 'none';
});
uploadButton.addEventListener('click', async () => {
if (!selectedFile) {
showMessage('Please select a file first!', 'warning');
return;
}
const formData = new FormData();
formData.append('imageFile', selectedFile);
const customPrompt = customPromptInput.value.trim();
if (customPrompt) {
formData.append('customPrompt', customPrompt);
}
try {
uploadButton.disabled = true;
const isPdf = selectedFile.type === 'application/pdf' || selectedFile.name.toLowerCase().endsWith('.pdf');
uploadButton.innerHTML = isPdf
? '<i class="fas fa-spinner fa-spin"></i> Converting PDF & Extracting...'
: '<i class="fas fa-spinner fa-spin"></i> Extracting...';
// Get the antiforgery token
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
const response = await fetch('@Url.Action("ExtractTextFromImage", "FileManager")', {
method: 'POST',
headers: {
'RequestVerificationToken': token
},
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
const message = result.wasConverted
? 'PDF converted and text extracted successfully!'
: 'Text extracted successfully!';
showMessage(message, 'success');
// Display extracted text
if (result.extractedText) {
extractedText.textContent = result.extractedText;
extractedTextSection.style.display = 'block';
}
} else {
showMessage('Error: ' + (result.message || 'Failed to extract text'), 'danger');
}
} catch (error) {
console.error('Error extracting text:', error);
showMessage('Error: Failed to communicate with server', 'danger');
} finally {
uploadButton.disabled = false;
uploadButton.innerHTML = '<i class="fas fa-upload"></i> Extract Text';
}
});
copyButton.addEventListener('click', () => {
const textToCopy = extractedText.textContent;
navigator.clipboard.writeText(textToCopy).then(() => {
const originalText = copyButton.innerHTML;
copyButton.innerHTML = '<i class="fas fa-check"></i> Copied!';
copyButton.classList.remove('btn-secondary');
copyButton.classList.add('btn-success');
setTimeout(() => {
copyButton.innerHTML = originalText;
copyButton.classList.remove('btn-success');
copyButton.classList.add('btn-secondary');
}, 2000);
}).catch(err => {
console.error('Failed to copy text:', err);
showMessage('Failed to copy to clipboard', 'warning');
});
});
function showMessage(message, type) {
responseMessage.textContent = message;
responseMessage.className = 'alert alert-' + type;
responseMessage.style.display = 'block';
setTimeout(() => {
responseMessage.style.display = 'none';
}, 5000);
}
</script>

View File

@ -0,0 +1,176 @@
@{
Layout = "_ConfigurePlugin";
}
@await Component.InvokeAsync("StoreScopeConfiguration")
<form asp-action="ReceiveVoiceRecording" asp-controller="FruitBankAdmin" method="post">
@Html.AntiForgeryToken()
</form>
<div class="card card-default">
<div class="card-body">
<h4>Voice Recorder</h4>
<p>Click the button below to start recording your voice message.</p>
<div class="form-group row">
<div class="col-md-9">
<button type="button" id="recordButton" class="btn btn-primary">
<i class="fas fa-microphone"></i> Start Recording
</button>
<button type="button" id="stopButton" class="btn btn-danger" style="display:none;">
<i class="fas fa-stop"></i> Stop Recording
</button>
<span id="recordingStatus" style="margin-left: 15px; font-weight: bold;"></span>
</div>
</div>
<div class="form-group row" id="audioPlaybackSection" style="display:none;">
<div class="col-md-9">
<audio id="audioPlayback" controls style="width: 100%;"></audio>
</div>
</div>
<div class="form-group row" id="sendSection" style="display:none;">
<div class="col-md-9">
<button type="button" id="sendButton" class="btn btn-success">
<i class="fas fa-paper-plane"></i> Send to API
</button>
</div>
</div>
<div class="form-group row">
<div class="col-md-9">
<div id="responseMessage" class="alert" style="display:none;"></div>
<div id="transcriptionResult" style="display:none; margin-top: 15px;">
<h5>Transcription:</h5>
<div class="card">
<div class="card-body">
<p id="transcriptionText" style="white-space: pre-wrap;"></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let mediaRecorder;
let audioChunks = [];
let audioBlob;
const recordButton = document.getElementById('recordButton');
const stopButton = document.getElementById('stopButton');
const sendButton = document.getElementById('sendButton');
const recordingStatus = document.getElementById('recordingStatus');
const audioPlayback = document.getElementById('audioPlayback');
const audioPlaybackSection = document.getElementById('audioPlaybackSection');
const sendSection = document.getElementById('sendSection');
const responseMessage = document.getElementById('responseMessage');
const transcriptionResult = document.getElementById('transcriptionResult');
const transcriptionText = document.getElementById('transcriptionText');
recordButton.addEventListener('click', async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data);
};
mediaRecorder.onstop = () => {
audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
const audioUrl = URL.createObjectURL(audioBlob);
audioPlayback.src = audioUrl;
audioPlaybackSection.style.display = 'block';
sendSection.style.display = 'block';
// Stop all tracks to release the microphone
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
recordButton.style.display = 'none';
stopButton.style.display = 'inline-block';
recordingStatus.textContent = 'Recording...';
recordingStatus.style.color = 'red';
audioPlaybackSection.style.display = 'none';
sendSection.style.display = 'none';
} catch (error) {
console.error('Error accessing microphone:', error);
showMessage('Error: Could not access microphone. Please check permissions.', 'danger');
}
});
stopButton.addEventListener('click', () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
recordButton.style.display = 'inline-block';
stopButton.style.display = 'none';
recordingStatus.textContent = 'Recording stopped';
recordingStatus.style.color = 'green';
}
});
sendButton.addEventListener('click', async () => {
if (!audioBlob) {
showMessage('No audio recorded yet!', 'warning');
return;
}
const formData = new FormData();
formData.append('audioFile', audioBlob, 'recording.webm');
try {
sendButton.disabled = true;
sendButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Sending...';
// Get the antiforgery token
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
const response = await fetch('@Url.Action("ReceiveVoiceRecording", "FruitBankAudio")', {
method: 'POST',
headers: {
'RequestVerificationToken': token
},
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
showMessage('Audio transcribed successfully!', 'success');
// Display transcription
if (result.transcription) {
transcriptionText.textContent = result.transcription;
transcriptionResult.style.display = 'block';
}
} else {
showMessage('Error: ' + (result.message || 'Failed to transcribe audio'), 'danger');
}
} catch (error) {
console.error('Error sending audio:', error);
showMessage('Error: Failed to send audio to server', 'danger');
} finally {
sendButton.disabled = false;
sendButton.innerHTML = '<i class="fas fa-paper-plane"></i> Send to API';
}
});
function showMessage(message, type) {
responseMessage.textContent = message;
responseMessage.className = 'alert alert-' + type;
responseMessage.style.display = 'block';
setTimeout(() => {
responseMessage.style.display = 'none';
}, 5000);
}
</script>

View File

@ -345,7 +345,7 @@
@{
var gridModel = new DataTablesModel
{
Name = "orders-grid",
Name = "orders-grid",
UrlRead = new DataUrl("OrderList", "CustomOrder", null),
SearchButtonId = "search-orders",
Ordering = true,
@ -400,10 +400,19 @@
gridModel.ColumnCollection.Add(new ColumnProperty(nameof(OrderModelExtended.InnvoiceTechId))
{
Title = "Innvoiceba beküldve",
Width = "150",
Width = "100",
Render = new RenderCustom("renderColumnInnvoiceTechId"),
ClassName = NopColumnClassDefaults.CenterAll
});
gridModel.ColumnCollection.Add(new ColumnProperty(nameof(OrderModelExtended.IsAllOrderItemAvgWeightValid))
{
Title = "Súlyeltérés",
Width = "100",
Render = new RenderCustom("renderColumnAverageWeightError"),
ClassName = NopColumnClassDefaults.CenterAll
});
gridModel.ColumnCollection.Add(new ColumnProperty(nameof(OrderModelExtended.IsMeasurable))
{
Title = T($"FruitBank.{nameof(OrderModelExtended.IsMeasurable)}").Text,
@ -542,7 +551,14 @@
if(data != null) {
return '<span class="badge badge-success" disabled>Igen</span>';
}
return '<span class="badge badge-warning" disabled>Nem</span>';
return '<span class="badge badge-danger" disabled>Nem</span>';
}
function renderColumnAverageWeightError(data, type, row, meta) {
if(data) {
return '<span class="badge badge-success" disabled>OK</span>';
}
return '<span class="badge badge-danger" disabled>!!!</span>';
}
function renderColumnIsMeasurable(data, type, row, meta) {

View File

@ -159,7 +159,12 @@
<th>
Mérés állapota
</th>
<th>
Súlyeltérés
</th>
<th>
Súlyeltérés mértéke
</th>
@* <th>
@T("Admin.Orders.Products.Discount")
</th> *@
@ -336,6 +341,24 @@
</div>
</td>
<td style="width: 80px;" class="text-center">
@if (!item.AverageWeightIsValid)
{
<span class="badge badge-danger" disabled>!!!</span>
}
else
{
<span class="badge badge-success" disabled>OK</span>
}
<input type="hidden" name="pvAverageWeightIsValid@(item.Id)" id="pvAverageWeightIsValid@(item.Id)" value="@(item.AverageWeightIsValid.ToString())" disabled />
</td>
<td style="width: 80px;" class="text-center">
<span class="">Eltérés: @item.AverageWeightDifference (KG), Mért átlag: @item.AverageWeight (KG/rekesz)</span>
<input type="hidden" name="pvAverageWeightDifference@(item.Id)" id="pvAverageWeightDifference@(item.Id)" value="@(item.AverageWeightDifference.ToString())" disabled />
</td>
@* <td style="width: 15%;" class="text-center">
@if (Model.AllowCustomersToSelectTaxDisplayType)
{

View File

@ -285,6 +285,10 @@
{
Title = "Súly(kg)"//T("Admin.Catalog.Products.Fields.NetWeight").Text
},
new ColumnProperty(nameof(ProductModelExtended.AverageWeight))
{
Title = "Átlagsúly (kg)"//T("Admin.Catalog.Products.Fields.AverageWeight").Text
},
new ColumnProperty(nameof(ProductModelExtended.Tare))
{
Title = "Tára(kg)"//T("Admin.Catalog.Products.Fields.Tare").Text

View File

@ -46,6 +46,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components
model.Tare = await _fruitBankAttributeService.GetGenericAttributeValueAsync<Product, double>(model.ProductId, nameof(ITare.Tare));
model.IncomingQuantity = await _fruitBankAttributeService.GetGenericAttributeValueAsync<Product, int>(model.ProductId, nameof(IIncomingQuantity.IncomingQuantity));
model.AverageWeight = await _fruitBankAttributeService.GetGenericAttributeValueAsync<Product, double>(model.ProductId, nameof(IProductDto.AverageWeight));
model.AverageWeightTreshold = await _fruitBankAttributeService.GetGenericAttributeValueAsync<Product, double>(model.ProductId, nameof(IProductDto.AverageWeightTreshold));
}
return View("~/Plugins/Misc.FruitBankPlugin/Views/ProductAttributes.cshtml", model);

View File

@ -129,6 +129,20 @@ public class FruitBankEventConsumer :
if (productDto == null || productDto.Tare != tare)
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Product, double>(product.Id, nameof(ITare.Tare), tare);
//AverageWeight
var averageWeight = double.Round(CommonHelper.To<double>(form[nameof(IProductDto.AverageWeight)].ToString()), 1);
if (averageWeight < 0) throw new Exception($"FruitBankEventConsumer->SaveProductCustomAttributesAsync(); (averageWeight < 0); productId: {product.Id}; averageWeight: {averageWeight}");
if (productDto == null || productDto.AverageWeight != averageWeight)
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Product, double>(product.Id, nameof(IProductDto.AverageWeight), averageWeight);
//AverageWeightTreshold
var averageWeightTreshold = double.Round(CommonHelper.To<double>(form[nameof(IProductDto.AverageWeightTreshold)].ToString()), 1);
if (averageWeightTreshold < 0) throw new Exception($"FruitBankEventConsumer->SaveProductCustomAttributesAsync(); (averageWeightTreshold < 0); productId: {product.Id}; averageWeight: {averageWeightTreshold}");
if (productDto == null || productDto.AverageWeightTreshold != averageWeightTreshold)
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Product, double>(product.Id, nameof(IProductDto.AverageWeightTreshold), averageWeightTreshold);
//IncomingQuantity
var incomingQuantity = CommonHelper.To<int>(form[nameof(IIncomingQuantity.IncomingQuantity)].ToString());
if (incomingQuantity < 0) throw new Exception($"FruitBankEventConsumer->SaveProductCustomAttributesAsync(); (incomingQuantity < 0); productId: {product.Id}; incomingQuantity: {incomingQuantity}");

View File

@ -182,6 +182,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Factories
orderModelExtended.IsMeasurable = orderDto.IsMeasurable;
orderModelExtended.DateOfReceipt = orderDto.DateOfReceipt;
orderModelExtended.OrderTotal = !orderDto.IsComplete && orderDto.IsMeasurable ? "kalkuláció alatt..." : orderModelExtended.OrderTotal;
orderModelExtended.IsAllOrderItemAvgWeightValid = orderDto.IsAllOrderItemAvgWeightValid;
//var fullName = $"{orderDto.Customer.FirstName}_{orderDto.Customer.LastName}".Trim();
orderModelExtended.CustomerCompany = $"{orderDto.Customer.Company} {orderDto.Customer.FirstName}_{orderDto.Customer.LastName}";
@ -226,7 +228,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Factories
orderItemModelExtended.ProductStockQuantity = orderItemDto.ProductDto!.StockQuantity;
orderItemModelExtended.ProductIncomingQuantity = orderItemDto.ProductDto.IncomingQuantity;
orderItemModelExtended.ProductAvailableQuantity = orderItemDto.ProductDto.AvailableQuantity;
orderItemModelExtended.AverageWeight = orderItemDto.AverageWeight;
orderItemModelExtended.AverageWeightIsValid = orderItemDto.AverageWeightIsValid;
orderItemModelExtended.AverageWeightDifference = orderItemDto.AverageWeightDifference;
orderItemModelExtended.SubTotalInclTax = orderItemDto.IsMeasurable && !orderItemDto.IsAudited ? "kalkuláció alatt..." : orderItemModelExtended.SubTotalInclTax;

View File

@ -62,6 +62,8 @@ public class CustomProductModelFactory : MgProductModelFactory<ProductListModelE
productModelExtended.AvailableQuantity = productDto.AvailableQuantity;
productModelExtended.StockQuantityStr = productModelExtended.StockQuantity.ToString();
productModelExtended.AverageWeight = productDto.AverageWeight;
});
return productListModelExtended;

View File

@ -110,6 +110,8 @@ public class PluginNopStartup : INopStartup
services.AddScoped<OpenAIApiService>();
//services.AddScoped<IAIAPIService, OpenAIApiService>();
services.AddScoped<AICalculationService>();
services.AddScoped<PdfToImageService>();
services.AddControllersWithViews(options =>
{
options.Filters.AddService<PendingMeasurementCheckoutFilter>();

View File

@ -171,6 +171,16 @@ public class RouteProvider : IRouteProvider
name: "Plugin.FruitBank.AppDownload.Download",
pattern: "Admin/AppDownload/Download/{version}/{fileName}",
defaults: new { controller = "AppDownload", action = "Download", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.FruitBankAudio",
pattern: "Admin/VoiceRecorder",
defaults: new { controller = "FruitBankAudio", action = "VoiceRecorder", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.ExtractText",
pattern: "Admin/ExtractText",
defaults: new { controller = "FileManager", action = "ImageTextExtraction", area = AreaNames.ADMIN });
}
/// <summary>

View File

@ -14,6 +14,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
public int ProductStockQuantity { get; set; }
public int ProductIncomingQuantity { get; set; }
public int ProductAvailableQuantity { get; set; }
public double AverageWeight { get; set; }
public bool AverageWeightIsValid { get; set; }
public double AverageWeightDifference { get; set; }
}
public partial record OrderModelExtended : MgOrderModelExtended, IOrderModelExtended
@ -28,6 +34,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
public string InnvoiceTechId { get; set; }
public bool IsAllOrderItemAvgWeightValid { get; set; }
public IList<OrderItemModelExtended> ItemExtendeds { get; set; }
}
}

View File

@ -22,5 +22,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models
[NopResourceDisplayName("Plugins.YourCompany.ProductAttributes.Fields.Tare")]
public double Tare { get; set; }
[NopResourceDisplayName("Plugins.YourCompany.ProductAttributes.Fields.AverageWeight")]
public double AverageWeight { get; set; }
[NopResourceDisplayName("Plugins.YourCompany.ProductAttributes.Fields.AverageWeightTreshold")]
public double AverageWeightTreshold { get; set; }
}
}

View File

@ -13,6 +13,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Products
public int IncomingQuantity { get; set; }
public int AvailableQuantity { get; set; }
public double AverageWeight { get; set; }
}
}

View File

@ -28,13 +28,18 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.Json" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<!--<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />-->
<PackageReference Include="PdfPig" Version="0.1.11" />
<PackageReference Include="PdfPig.Rendering.Skia" Version="0.1.11.5" />
<PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="System.Memory.Data" Version="9.0.10" />
<PackageReference Include="Tesseract" Version="5.2.0" />
<PackageReference Include="TesseractOCR" Version="5.5.1" />
<PackageReference Include="TesseractOCR" Version="5.5.1" />
<PackageReference Include="PDFtoImage" Version="4.0.2" />
<PackageReference Include="PDFtoImage" Version="4.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
@ -171,6 +176,12 @@
<None Update="Areas\Admin\Views\AppDownload\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Extras\ImageTextExtraction.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Extras\VoiceRecorder.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Order\FileUploadGridComponent.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@ -655,4 +666,10 @@
<Target Name="NopTarget" AfterTargets="Build">
<MSBuild Projects="@(ClearPluginAssemblies)" Properties="PluginPath=$(OutDir)" Targets="NopClear" />
</Target>
<Target Name="CopyPdfiumDll" AfterTargets="Build">
<ItemGroup>
<PdfiumFiles Include="$(NuGetPackageRoot)pdfium.binaries\**\pdfium.dll" />
</ItemGroup>
<Copy SourceFiles="@(PdfiumFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@ -20,6 +20,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
private const string OpenAiEndpoint = "https://api.openai.com/v1/chat/completions";
private const string OpenAiImageEndpoint = "https://api.openai.com/v1/images/generations";
private const string OpenAiFileEndpoint = "https://api.openai.com/v1/files";
private const string OpenAiAudioEndpoint = "https://api.openai.com/v1/audio/transcriptions";
private const string BaseUrl = "https://api.openai.com/v1";
private string? _assistantId;
@ -43,6 +44,79 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
_onError = onErrorCallback;
}
#region === AUDIO TRANSCRIPTION ===
/// <summary>
/// Transcribe audio file to text using OpenAI Whisper API
/// </summary>
/// <param name="audioStream">The audio file stream</param>
/// <param name="fileName">The original filename (used to determine format)</param>
/// <param name="language">Optional language code (e.g., "en", "hu"). If null, auto-detects.</param>
/// <returns>The transcribed text</returns>
public async Task<string?> TranscribeAudioAsync(Stream audioStream, string fileName, string? language = null)
{
try
{
using var form = new MultipartFormDataContent();
// Add the audio file
var audioContent = new StreamContent(audioStream);
// Determine content type based on file extension
var extension = Path.GetExtension(fileName).ToLowerInvariant();
audioContent.Headers.ContentType = extension switch
{
".mp3" => new MediaTypeHeaderValue("audio/mpeg"),
".mp4" => new MediaTypeHeaderValue("audio/mp4"),
".mpeg" => new MediaTypeHeaderValue("audio/mpeg"),
".mpga" => new MediaTypeHeaderValue("audio/mpeg"),
".m4a" => new MediaTypeHeaderValue("audio/m4a"),
".wav" => new MediaTypeHeaderValue("audio/wav"),
".webm" => new MediaTypeHeaderValue("audio/webm"),
_ => new MediaTypeHeaderValue("application/octet-stream")
};
form.Add(audioContent, "file", fileName);
// Add model
form.Add(new StringContent("whisper-1"), "model");
// Add language if specified
if (!string.IsNullOrEmpty(language))
{
form.Add(new StringContent(language), "language");
}
// Optional: Add response format (json is default, can also be text, srt, verbose_json, or vtt)
form.Add(new StringContent("json"), "response_format");
var response = await _httpClient.PostAsync(OpenAiAudioEndpoint, form);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Audio transcription failed: {error}");
return null;
}
await using var contentStream = await response.Content.ReadAsStreamAsync();
using var json = await JsonDocument.ParseAsync(contentStream);
var transcription = json.RootElement.GetProperty("text").GetString();
Console.WriteLine($"Audio transcription successful. Length: {transcription?.Length ?? 0} characters");
return transcription;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error transcribing audio: {ex}");
return null;
}
}
#endregion
#region === CHAT (TEXT INPUT) ===
public async Task<string?> GetSimpleResponseAsync(string systemMessage, string userMessage, string? assistantMessage = null)
@ -317,24 +391,24 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
Console.WriteLine("Cleaning up all existing vector stores...");
var listRequest = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/vector_stores");
listRequest.Headers.Add("OpenAI-Beta", "assistants=v2");
var response = await _httpClient.SendAsync(listRequest);
if (response.IsSuccessStatusCode)
{
using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
var vectorStores = json.RootElement.GetProperty("data");
foreach (var vectorStore in vectorStores.EnumerateArray())
{
var id = vectorStore.GetProperty("id").GetString();
var name = vectorStore.TryGetProperty("name", out var nameElement) && nameElement.ValueKind != JsonValueKind.Null
? nameElement.GetString()
: "Unnamed";
var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, $"{BaseUrl}/vector_stores/{id}");
deleteRequest.Headers.Add("OpenAI-Beta", "assistants=v2");
await _httpClient.SendAsync(deleteRequest);
Console.WriteLine($"Deleted vector store: {name} ({id})");
}
Console.WriteLine("Vector store cleanup complete!");
@ -682,6 +756,99 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
}
#endregion
}
}
#region === IMAGE TEXT EXTRACTION ===
/// <summary>
/// Extract text from an image using OpenAI Vision API
/// </summary>
/// <param name="imageStream">The image file stream</param>
/// <param name="fileName">The original filename</param>
/// <param name="customPrompt">Optional custom prompt for text extraction</param>
/// <returns>Extracted and structured text from the image</returns>
public async Task<string?> ExtractTextFromImageAsync(Stream imageStream, string fileName, string? customPrompt = null)
{
try
{
// Read image bytes from stream
byte[] imgBytes;
using (var memoryStream = new MemoryStream())
{
await imageStream.CopyToAsync(memoryStream);
imgBytes = memoryStream.ToArray();
}
string base64 = Convert.ToBase64String(imgBytes);
// Determine image format
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var mimeType = extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
var prompt = customPrompt ?? "Olvasd ki a szöveget és add vissza szépen strukturálva.";
var payload = new
{
model = GetModelName(), // Use the configured model
messages = new object[]
{
new {
role = "user",
content = new object[]
{
new { type = "text", text = prompt },
new { type = "image_url", image_url = new { url = $"data:{mimeType};base64,{base64}" } }
}
}
}
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
var response = await _httpClient.PostAsync(
OpenAiEndpoint,
new StringContent(json, Encoding.UTF8, "application/json"));
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Image text extraction failed: {error}");
return null;
}
var resultJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(resultJson);
var inputTokens = doc.RootElement.GetProperty("usage").GetProperty("prompt_tokens").GetInt32();
var outputTokens = doc.RootElement.GetProperty("usage").GetProperty("completion_tokens").GetInt32();
Console.WriteLine($"USAGE STATS - Image OCR Tokens: {inputTokens} + {outputTokens} = {inputTokens + outputTokens}");
string text = doc.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString();
return text;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error extracting text from image: {ex}");
return null;
}
}
#endregion
}
}

View File

@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Hosting;
using PDFtoImage;
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
public class PdfToImageService
{
private readonly PdfiumBootstrapper _pdfiumBootstrapper;
public PdfToImageService(IWebHostEnvironment env)
{
_pdfiumBootstrapper = new PdfiumBootstrapper(env);
}
public async Task<List<string>> ConvertPdfToJpgAsync(string pdfPath, string outputFolder)
{
_pdfiumBootstrapper.EnsurePdfiumLoaded();
var imagePaths = new List<string>();
try
{
Directory.CreateDirectory(outputFolder);
await Task.Run(() =>
{
byte[] pdfBytes = File.ReadAllBytes(pdfPath);
var options = new RenderOptions
{
Dpi = 300,
BackgroundColor = SKColors.White
};
var pdfImages = PDFtoImage.Conversion.ToImages(pdfBytes, options: options);
int pageNumber = 1;
foreach (var page in pdfImages)
{
var outputPath = Path.Combine(outputFolder, $"page_{pageNumber}.jpg");
using (var fileStream = File.Create(outputPath))
{
page.Encode(fileStream, SKEncodedImageFormat.Jpeg, 90);
}
imagePaths.Add(outputPath);
page.Dispose();
pageNumber++;
}
});
return imagePaths;
}
catch (Exception ex)
{
throw new Exception($"Error converting PDF to images: {ex.Message}", ex);
}
}
public class PdfiumBootstrapper
{
private readonly IWebHostEnvironment _env;
private bool _loaded = false;
public PdfiumBootstrapper(IWebHostEnvironment env)
{
_env = env;
}
public void EnsurePdfiumLoaded()
{
if (_loaded)
return;
var pluginPath = Path.Combine(
_env.ContentRootPath,
"Plugins",
"Misc.FruitBankPlugin" // <- change this
);
var archFolder = Environment.Is64BitProcess ? "win-x64" : "win-x86";
var dllPath = Path.Combine(pluginPath, "runtimes", archFolder, "native", "pdfium.dll");
if (!File.Exists(dllPath))
throw new FileNotFoundException($"Pdfium.dll not found: {dllPath}");
NativeLibrary.Load(dllPath);
_loaded = true;
}
}
}

View File

@ -21,7 +21,7 @@
<nop-label asp-for="NetWeight" />
</div>
<div class="col-md-9">
<nop-editor contenteditable="@Model.IsMeasurable" asp-for="NetWeight" />
<nop-editor readonly="@Model.IsMeasurable" asp-for="NetWeight" />
<span asp-validation-for="NetWeight"></span>
</div>
</div>
@ -46,5 +46,24 @@
<span asp-validation-for="Tare"></span>
</div>
</div>
<div class="form-group row">
<div class="col-md-3">
<nop-label asp-for="AverageWeight" />
</div>
<div class="col-md-9">
<nop-editor asp-for="AverageWeight" />
<span asp-validation-for="AverageWeight"></span>
</div>
</div>
<div class="form-group row">
<div class="col-md-3">
<nop-label asp-for="AverageWeightTreshold" />
</div>
<div class="col-md-9">
<nop-editor asp-for="AverageWeightTreshold" />
<span asp-validation-for="AverageWeightTreshold"></span>
</div>
</div>
</div>
</div>