From 98b1ba9b22684220bfd428766f3084c6565c07a2 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 23 Feb 2026 17:06:21 +0100 Subject: [PATCH] =?UTF-8?q?Rendel=C3=A9sek=20feldarabol=C3=A1sa=20m=C3=A9r?= =?UTF-8?q?=C3=A9si=20st=C3=A1tusz=20alapj=C3=A1n,=20=C3=A9s=20szabad=20sz?= =?UTF-8?q?=C3=A9tv=C3=A1laszt=C3=A1s,=20deign=20update,=20ad=C3=B3sz?= =?UTF-8?q?=C3=A1m=20bug-fix,=20sz=C3=A1ll=C3=ADt=C3=A1s=20alatt=20mez?= =?UTF-8?q?=C5=91=20automatikus=20kit=C3=B6lt=C3=A9se=20AI=20=C3=A1ltal=20?= =?UTF-8?q?term=C3=A9kben,=20=C3=A1tlags=C3=BAly=20kisz=C3=A1m=C3=ADt?= =?UTF-8?q?=C3=A1sa=20=C3=A9s=20be=C3=ADr=C3=A1sa=20term=C3=A9kbe=20bev?= =?UTF-8?q?=C3=A9telez=C3=A9skor.=20AI=20=C3=BCdv=C3=B6zl=C5=91=20sz=C3=B6?= =?UTF-8?q?veg=20alapvet=C5=91=20elemz=C3=A9ssel.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CustomOrderController.cs | 112 ++-- .../Controllers/FileManagerController.cs | 19 +- .../Controllers/ManagementPageController.cs | 238 +-------- .../Admin/Controllers/ShippingController.cs | 58 +-- .../Controllers/FruitBankDataController.cs | 9 +- .../Services/AICalculationService.cs | 243 +++++++-- .../Services/EventConsumer.cs | 27 +- .../Services/MeasurementService.cs | 52 +- .../Views/OrderAttributes.cshtml | 481 ++++++++++++++---- 9 files changed, 791 insertions(+), 448 deletions(-) diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs index 23b130d..b160af8 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs @@ -1454,11 +1454,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers [HttpPost] [ValidateAntiForgeryToken] [CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)] - public async Task SplitOrder(int orderId) + public async Task SplitOrder(int orderId, string mode = "audit", string orderItemIds = "") { try { - _logger.Info($"SplitOrder - OrderId: {orderId} - STARTED"); + _logger.Info($"SplitOrder - OrderId: {orderId}, Mode: {mode}, OrderItemIds: {orderItemIds} - STARTED"); var order = await _orderService.GetOrderByIdAsync(orderId); if (order == null || order.Deleted) @@ -1496,43 +1496,72 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers }); } - // 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!" - }); - } + // REMOVED: NotStarted check - we allow splitting at any stage except Audited + // Manual mode is always available, audit mode is controlled by the UI _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(); + List itemsToMove; - _logger.Info($"SplitOrder - Audited items: {auditedItems.Count}, Non-audited items: {nonAuditedItems.Count}"); - - if (nonAuditedItems.Count == 0) + if (mode == "manual") { - _logger.Warning($"SplitOrder - No non-audited items in order {orderId}"); - return Json(new + // Manual mode - use provided order item IDs + if (string.IsNullOrWhiteSpace(orderItemIds)) { - success = false, - message = "Nincs nem auditált termék a rendelésben. Szétválasztás nem szükséges." - }); + _logger.Warning($"SplitOrder - Manual mode selected but no order item IDs provided"); + return Json(new { success = false, message = "Nem lettek termékek kiválasztva" }); + } + + var selectedIds = orderItemIds.Split(',') + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => int.Parse(id.Trim())) + .ToList(); + + if (selectedIds.Count == 0) + { + _logger.Warning($"SplitOrder - No valid order item IDs provided"); + return Json(new { success = false, message = "Nem lettek érvényes termékek kiválasztva" }); + } + + if (selectedIds.Count == orderDto.OrderItemDtos.Count) + { + _logger.Warning($"SplitOrder - All items selected for move"); + return Json(new { success = false, message = "Legalább egy terméknek maradnia kell az eredeti rendelésben" }); + } + + itemsToMove = orderDto.OrderItemDtos.Where(oi => selectedIds.Contains(oi.Id)).ToList(); + + _logger.Info($"SplitOrder - Manual mode: {itemsToMove.Count} items selected to move out of {orderDto.OrderItemDtos.Count}"); } - - if (auditedItems.Count == 0) + else { - _logger.Warning($"SplitOrder - All items are non-audited in order {orderId}"); - return Json(new + // Audit mode - separate by measuring status (started vs not started) + // Items with MeasuringStatus > NotStarted stay in original order + // Items with MeasuringStatus = NotStarted move to new order + var startedItems = orderDto.OrderItemDtos.Where(oi => oi.MeasuringStatus > MeasuringStatus.NotStarted).ToList(); + itemsToMove = orderDto.OrderItemDtos.Where(oi => oi.MeasuringStatus == MeasuringStatus.NotStarted).ToList(); + + _logger.Info($"SplitOrder - Audit mode: Started/Audited items: {startedItems.Count}, Not started items: {itemsToMove.Count}"); + + if (itemsToMove.Count == 0) { - success = false, - message = "Minden termék nem auditált. Szétválasztás nem szükséges." - }); + _logger.Warning($"SplitOrder - No not-started items in order {orderId}"); + return Json(new + { + success = false, + message = "Nincs nem elindított termék a rendelésben. Szétválasztás nem szükséges." + }); + } + + if (startedItems.Count == 0) + { + _logger.Warning($"SplitOrder - All items are not-started in order {orderId}"); + return Json(new + { + success = false, + message = "Minden termék még nem lett elindítva. Használja a kézi módot." + }); + } } _logger.Info($"SplitOrder - Getting customer, store, and admin"); @@ -1542,9 +1571,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers 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)"); + _logger.Info($"SplitOrder - Creating new order"); - // Create new order for non-audited items + // Create new order for items to move var newOrder = new Order { OrderGuid = Guid.NewGuid(), @@ -1582,10 +1611,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers var orderItemsToMove = new List(); - // Find order items to move based on non-audited OrderItemDtos - foreach (var nonAuditedDto in nonAuditedItems) + // Find order items to move based on itemsToMove DTOs + foreach (var itemDto in itemsToMove) { - var orderItemToMove = originalOrderItems.FirstOrDefault(oi => oi.Id == nonAuditedDto.Id); + var orderItemToMove = originalOrderItems.FirstOrDefault(oi => oi.Id == itemDto.Id); if (orderItemToMove != null) { orderItemsToMove.Add(orderItemToMove); @@ -1594,7 +1623,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers _logger.Info($"SplitOrder - Found {orderItemsToMove.Count} items to move"); - // Move non-audited items to new order + // Move items to new order foreach (var orderItem in orderItemsToMove) { _logger.Info($"SplitOrder - Processing order item {orderItem.Id}"); @@ -1675,20 +1704,22 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers _logger.Info($"SplitOrder - Adding order notes"); + var splitModeText = mode == "manual" ? "kézi kiválasztással" : "mérési státusz alapján"; + // 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})" + $"* Rendelés szétválasztva ({splitModeText}). {orderItemsToMove.Count} termék átkerült 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})" + $"* Új rendelés létrehozva a #{order.Id} rendelés szétválasztásával ({splitModeText}). {orderItemsToMove.Count} termék. 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."); + _logger.Info($"Order {orderId} split successfully using {mode} mode. New order created: {newOrder.Id}. Moved {orderItemsToMove.Count} items."); // Send notifications _logger.Info($"SplitOrder - Sending notifications"); @@ -1706,7 +1737,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers success = true, message = "Rendelés sikeresen szétválasztva", newOrderId = newOrder.Id, - originalOrderId = order.Id + originalOrderId = order.Id, + movedItemsCount = orderItemsToMove.Count }); } catch (Exception ex) diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs index 4c3354d..fd0742d 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/FileManagerController.cs @@ -40,6 +40,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers private readonly IWorkContext _workContext; private readonly FileStorageService _fileStorageService; private readonly FruitBankAttributeService _fruitBankAttributeService; + private readonly IStoreContext _storeContext; public FileManagerController( IPermissionService permissionService, @@ -51,7 +52,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers PdfToImageService pdfToImageService, IWorkContext workContext, FileStorageService fileStorageService, - FruitBankAttributeService fruitBankAttributeService) + FruitBankAttributeService fruitBankAttributeService, + IStoreContext storeContext) { _permissionService = permissionService; _aiApiService = aiApiService; @@ -63,6 +65,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers _workContext = workContext; _fileStorageService = fileStorageService; _fruitBankAttributeService = fruitBankAttributeService; + _storeContext = storeContext; } /// @@ -1028,7 +1031,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers foreach (var itemDto in request.ShippingItems) { var productDto = await _dbContext.ProductDtos.GetByIdAsync(itemDto.ProductId ?? 0, true); - if (productDto != null && string.IsNullOrEmpty(itemDto.Name)) + if (productDto != null && !string.IsNullOrEmpty(itemDto.Name)) { itemDto.IsMeasurable = productDto.IsMeasurable; } @@ -1101,10 +1104,20 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers //everything done, let's update the genericattribute "IncomingQuantity" of the product foreach (var item in shippingDocument.ShippingItems.Where(x => x.ProductId != null)) { + //var stockWeight = double.Round(await _fruitBankAttributeService.GetGenericAttributeValueAsync(product.Id, nameof(IMeasuringNetWeight.NetWeight), storeId), 1); + var alreadyIncomingQuantityAttribute = await _fruitBankAttributeService.GetGenericAttributeValueAsync(item.ProductId.Value, nameof(IIncomingQuantity.IncomingQuantity), _storeContext.GetCurrentStore().Id); + int alreadyIncomingQuantity = 0; + if (alreadyIncomingQuantityAttribute != null) + { + Console.WriteLine($"Existing IncomingQuantity for Product ID {item.ProductId.Value}: {alreadyIncomingQuantityAttribute}"); + alreadyIncomingQuantity = Convert.ToInt32(alreadyIncomingQuantityAttribute); + } + var newIncomingQuantity = alreadyIncomingQuantity + item.QuantityOnDocument; + await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync( item.ProductId.Value, "IncomingQuantity", - item.QuantityOnDocument + newIncomingQuantity, _storeContext.GetCurrentStore().Id ); } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ManagementPageController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ManagementPageController.cs index 8b63095..075e39b 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ManagementPageController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ManagementPageController.cs @@ -460,240 +460,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers private async Task ProcessRawText(int shippingDocumentId, int? partnerId, string pdfText, ShippingDocumentAnalysisResult shippingDocumentAnalysisResult, List filesList, Files dbFile) { - var transactionSuccess = await SaveFileInfoToDb(shippingDocumentId, shippingDocumentAnalysisResult, filesList, dbFile, pdfText); - if (!transactionSuccess) _logger.Error($"(transactionSuccess == false)"); - - // - IF WE DON'T HAVE PARTNERID ALREADY: read partner information - // (check if all 3 refers to the same partner) - // save partner information to partners table { Id, Name, TaxId, CertificationNumber, PostalCode, Country, State, County, City, Street } - if (partnerId == null) - { - //find partner in DB... there is a serious chance that the partner Name and Taxid determines the partner - - var partners = await _dbContext.Partners.GetAll().ToListAsync(); - foreach (var dbpartner in partners.Where(dbpartner => pdfText.Contains(dbpartner.Name) || (dbpartner.TaxId != null && pdfText.Contains(dbpartner.TaxId)))) - { - partnerId = dbpartner.Id; - _logger.Detail($"Found existing partner in DB: {dbpartner.Name} (ID: {dbpartner.Id})"); - break; - } - - if (partnerId == null) - { - _logger.Detail("No existing partner found in DB, proceeding to extract partner info via AI."); - - // string partnerAnalysisPrompt = "You are an agent of Fruitbank, helping to analyze pdf content. Determine which partner of Fruitbank sent this document, extract their data and return them as JSON: name, taxId, certificationNumber, postalCode, country, state, county, city, street. " + - //"If you can't find information of any of these, return null value for that field."; - - // //here I can start preparing the file entity - // var partnerAnalyzis = await _aiCalculationService.GetOpenAIPDFAnalysisFromText(pdfText.ToString(), partnerAnalysisPrompt); - - var partnerPrompt = @"Extract the partner/company information from the following text and return ONLY a valid JSON object with these exact fields: - { - ""Name"": ""company name"", - ""TaxId"": ""tax identification number"", - ""CertificationNumber"": ""certification number if exists, otherwise empty string"", - ""PostalCode"": ""postal code"", - ""Country"": ""country name"", - ""State"": ""state if exists, otherwise empty string"", - ""County"": ""county if exists, otherwise empty string"", - ""City"": ""city name"", - ""Street"": ""street address"" - } - - If a field is not found in the text, use an empty string. Return ONLY the JSON object, no additional text or explanation. - - Text to analyze: - " + pdfText; - - var partnerResponse = await _aiCalculationService.GetOpenAIPDFAnalysisFromText( - pdfText, - partnerPrompt - ); - - shippingDocumentAnalysisResult.Partner = JsonSerializer.Deserialize(CleanJsonResponse(partnerResponse), - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - - if (shippingDocumentAnalysisResult.Partner.Name != null) - { - _logger.Detail("AI Analysis Partner Result:"); - _logger.Detail(shippingDocumentAnalysisResult.Partner.Name); - } - - if (shippingDocumentAnalysisResult.Partner.TaxId != null) - { - _logger.Detail(shippingDocumentAnalysisResult.Partner.TaxId); - } - - if (shippingDocumentAnalysisResult.Partner.Country != null) - { - - _logger.Detail(shippingDocumentAnalysisResult.Partner.Country); - } - - if (shippingDocumentAnalysisResult.Partner.State != null) - { - _logger.Detail(shippingDocumentAnalysisResult.Partner.State); - } - - if (shippingDocumentAnalysisResult.Partner != null) - { - shippingDocumentAnalysisResult.Partner = shippingDocumentAnalysisResult.Partner; - } - } - } - - //shortcut - try - { - // Step 2: Extract Shipping Document Information - var shippingDocPrompt = @"Extract the shipping document information from the following text and return ONLY a valid JSON object with these exact fields: - { - ""DocumentIdNumber"": ""document ID or reference number if exists, otherwise empty string"", - ""ShippingDate"": ""shipping date in ISO format (yyyy-MM-dd)"", - ""Country"": ""country of origin for pickup"", - ""TotalPallets"": number_of_total_pallets - } - - If a field is not found, use empty string for text fields, current date for ShippingDate, and 0 for TotalPallets. Return ONLY the JSON object, no additional text or explanation. - - Text to analyze: - " + pdfText; - - var shippingDocResponse = await _aiCalculationService.GetOpenAIPDFAnalysisFromText( - pdfText, - shippingDocPrompt - ); - - var shippingDocData = JsonSerializer.Deserialize(CleanJsonResponse(shippingDocResponse), - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - // Step 3: Extract Shipping Items - var itemsPrompt = @"Extract all shipping items (fruits, vegetables, or products) from the following text and return ONLY a valid JSON array with objects having these exact fields: - [ - { - ""Name"": ""product name"", - ""PalletsOnDocument"": number_of_pallets, - ""QuantityOnDocument"": quantity_count, - ""NetWeightOnDocument"": net_weight_in_kg, - ""GrossWeightOnDocument"": gross_weight_in_kg - } - ] - - If a numeric field is not found, use 0. Return ONLY the JSON array, no additional text or explanation. - - Text to analyze: - " + pdfText; - - var itemsResponse = await _aiCalculationService.GetOpenAIPDFAnalysisFromText( - pdfText, - itemsPrompt - ); - - var items = JsonSerializer.Deserialize>(CleanJsonResponse(itemsResponse), - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List(); - - // Prepare result - var result = new ShippingDocumentAnalysisResult - { - Partner = shippingDocumentAnalysisResult.Partner, - ShippingDocument = new ShippingDocument - { - DocumentIdNumber = shippingDocData.DocumentIdNumber, - ShippingDate = shippingDocData.ShippingDate, - Country = shippingDocData.Country, - TotalPallets = shippingDocData.TotalPallets - }, - ShippingItems = items - }; - - result.ShippingDocument.ShippingDocumentToFiles = shippingDocumentAnalysisResult.ShippingDocument.ShippingDocumentToFiles; - - return result; - //return Ok(result); - } - catch (JsonException ex) - { - _logger.Error($"ProcessRawText ERROR; {ex.Message}", ex); - } - return null; - - // - create a list of documents to read information and to save the document's information to DB - // - INSIDE ITERATION: - // - IF SHIPPINGDOCUMENT: read shipping information - // save document information into DB {Id, PartnerId, ShippingId, DocumentIdNumber, ShippingDate, Country, TotalPallets, IsAllMeasured} - // - IF Orderdocument: try reading shipping information and financial information - // save document information into DB {Id, PartnerId, ShippingId, DocumentIdNumber, ShippingDate, Country, TotalPallets, IsAllMeasured} - // - IF Invoice: try reading financial information - // save document information into DB {financial db table not created yet} - // - save the documents to Files Table - { name, extension, type, rawtext } - - //try - //{ - - // // Analyze PDF with AI to extract structured data - // var lastPrompt = "You work for FruitBank. Extract the following information from this shipping document and return as JSON: documentDate, recipientName, senderName, invoiceNumber, totalAmount, itemCount, notes. If a field is not found, return null for that field."; - - // var aiAnalysis = await _aiCalculationService.GetOpenAIPDFAnalysisFromText( - // pdfText, - // lastPrompt - // ); - - // // Parse AI response (assuming it returns JSON) - // var extractedData = ParseShippingDocumentAIResponse(aiAnalysis); - - // //TODO: Save document record to database - // _logger.Detail("AI Analysis Result:"); - // _logger.Detail(extractedData.RecipientName); - // _logger.Detail(extractedData.SenderName); - // _logger.Detail(extractedData.InvoiceNumber); - // _logger.Detail(extractedData.TotalAmount.ToString()); - // _logger.Detail(extractedData.ItemCount.ToString()); - // _logger.Detail(extractedData.Notes); - - - // var documentId = 1; // Replace with: savedDocument.Id - - // // Return structured document model - // var documentModel = new ShippingDocumentModel - // { - // Id = documentId, - // ShippingId = shippingDocumentId, - // FileName = file.FileName, - // FilePath = $"/uploads/shippingDocuments/{fileName}", - // FileSize = (int)(file.Length / 1024), - // DocumentDate = extractedData.DocumentDate, - // RecipientName = extractedData.RecipientName, - // SenderName = extractedData.SenderName, - // InvoiceNumber = extractedData.InvoiceNumber, - // TotalAmount = extractedData.TotalAmount, - // ItemCount = extractedData.ItemCount, - // Notes = extractedData.Notes, - // RawAIAnalysis = aiAnalysis // Store the raw AI response for debugging - // }; - - - - // // var savedDocument = await _documentService.InsertDocumentAsync(document); - - // // Mock saved document ID - - - // //return Json(new - // //{ - // // success = true, - // // document = documentModel - // //}); - - //} - //catch (Exception ex) - //{ - // _logger.Error($"Error uploading file: {ex.Message}", ex); - // //return Json(new { success = false, errorMessage = ex.Message }); - // return BadRequest("No files were uploaded."); - //} } /// @@ -738,9 +505,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers "If you can't find information of any of these, return null value for that field."; //here I can start preparing the file entity - var metaAnalyzis = await _aiCalculationService.GetOpenAIPDFAnalysisFromText(pdfText, analysisPrompt); + //var metaAnalyzis = await _aiCalculationService.GetOpenAIPDFAnalysisFromText(pdfText, analysisPrompt); - return ParseMetaDataAIResponse(metaAnalyzis); + //return ParseMetaDataAIResponse(metaAnalyzis); + return null; } private async Task SaveFileInfoToDb(int shippingDocumentId, ShippingDocumentAnalysisResult shippingDocumentAnalysisResult, List filesList, Files dbFile, string pdfText) diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ShippingController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ShippingController.cs index 4e1ee9c..a8235d3 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ShippingController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/ShippingController.cs @@ -329,22 +329,22 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers Console.WriteLine(pdfText.ToString()); // Analyze PDF with AI to extract structured data - var aiAnalysis = await _aiCalculationService.GetOpenAIPDFAnalysisFromText( - pdfText.ToString(), - "Extract the following information from this shipping document and return as JSON: documentDate, recipientName, senderName, invoiceNumber, totalAmount, itemCount, notes. If a field is not found, return null for that field." - ); + //var aiAnalysis = await _aiCalculationService.GetOpenAIPDFAnalysisFromText( + // pdfText.ToString(), + // "Extract the following information from this shipping document and return as JSON: documentDate, recipientName, senderName, invoiceNumber, totalAmount, itemCount, notes. If a field is not found, return null for that field." + //); - // Parse AI response (assuming it returns JSON) - var extractedData = ParseShippingDocumentAIResponse(aiAnalysis); + //// Parse AI response (assuming it returns JSON) + //var extractedData = ParseShippingDocumentAIResponse(aiAnalysis); //TODO: Save document record to database - Console.WriteLine("AI Analysis Result:"); - Console.WriteLine(extractedData.RecipientName); - Console.WriteLine(extractedData.SenderName); - Console.WriteLine(extractedData.InvoiceNumber); - Console.WriteLine(extractedData.TotalAmount); - Console.WriteLine(extractedData.ItemCount); - Console.WriteLine(extractedData.Notes); + //Console.WriteLine("AI Analysis Result:"); + //Console.WriteLine(extractedData.RecipientName); + //Console.WriteLine(extractedData.SenderName); + //Console.WriteLine(extractedData.InvoiceNumber); + //Console.WriteLine(extractedData.TotalAmount); + //Console.WriteLine(extractedData.ItemCount); + //Console.WriteLine(extractedData.Notes); // var savedDocument = await _documentService.InsertDocumentAsync(document); @@ -352,22 +352,22 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers var documentId = 1; // Replace with: savedDocument.Id // Return structured document model - var documentModel = new ShippingDocumentModel - { - Id = documentId, - ShippingId = shippingId, - FileName = file.FileName, - FilePath = $"/uploads/shippingDocuments/{fileName}", - FileSize = (int)(file.Length / 1024), - DocumentDate = extractedData.DocumentDate, - RecipientName = extractedData.RecipientName, - SenderName = extractedData.SenderName, - InvoiceNumber = extractedData.InvoiceNumber, - TotalAmount = extractedData.TotalAmount, - ItemCount = extractedData.ItemCount, - Notes = extractedData.Notes, - RawAIAnalysis = aiAnalysis // Store the raw AI response for debugging - }; + var documentModel = new ShippingDocumentModel(); + //{ + // Id = documentId, + // ShippingId = shippingId, + // FileName = file.FileName, + // FilePath = $"/uploads/shippingDocuments/{fileName}", + // FileSize = (int)(file.Length / 1024), + // DocumentDate = extractedData.DocumentDate, + // RecipientName = extractedData.RecipientName, + // SenderName = extractedData.SenderName, + // InvoiceNumber = extractedData.InvoiceNumber, + // TotalAmount = extractedData.TotalAmount, + // ItemCount = extractedData.ItemCount, + // Notes = extractedData.Notes, + // RawAIAnalysis = aiAnalysis // Store the raw AI response for debugging + //}; return Json(new { diff --git a/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs index 9842302..90b2490 100644 --- a/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs @@ -33,6 +33,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers //https://linq2db.github.io/articles/sql/Join-Operators.html public class FruitBankDataController( FruitBankDbContext ctx, + FruitBankAttributeService fruitBankAttributeService, MeasurementService measurementService, IWorkContext workContext, ICustomerService customerService, @@ -326,7 +327,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers _logger.Detail($"AddOrUpdateMeasuredShippingItemPallet invoked; {shippingItemPallet}"); if (!await ctx.AddOrUpdateShippingItemPalletSafeAsync(shippingItemPallet)) return null; - return await ctx.ShippingItemPallets.GetByIdAsync(shippingItemPallet.Id, false); + + var savedShippingItemPallet = await ctx.ShippingItemPallets.GetByIdAsync(shippingItemPallet.Id, true); + //update average weight and quantity on Product + await measurementService.CalculateAndSetAverageWeight(savedShippingItemPallet); + + //return await ctx.ShippingItemPallets.GetByIdAsync(shippingItemPallet.Id, false); + return shippingItemPallet; } [SignalR(SignalRTags.GetShippingDocuments)] diff --git a/Nop.Plugin.Misc.AIPlugin/Services/AICalculationService.cs b/Nop.Plugin.Misc.AIPlugin/Services/AICalculationService.cs index b2be176..e8e67c7 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/AICalculationService.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/AICalculationService.cs @@ -1,6 +1,11 @@ -using Nop.Core; + +using Nop.Core; using Nop.Core.Domain.Customers; -using Nop.Plugin.Misc.FruitBankPlugin.Helpers; +using Nop.Core.Domain.Orders; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +using System.Text.Json; +using System.Text.Json.Serialization; namespace Nop.Plugin.Misc.FruitBankPlugin.Services { @@ -9,53 +14,231 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services private readonly CerebrasAPIService _cerebrasApiService; private readonly OpenAIApiService _openAIApiService; private readonly IStoreContext _storeContext; - public AICalculationService(CerebrasAPIService cerebrasApiService, IStoreContext storeContext, OpenAIApiService openAIApiService) + private readonly FruitBankDbContext _fruitBankDbContext; + private readonly StockTakingDbContext _stockTakingDbContext; + + public AICalculationService( + CerebrasAPIService cerebrasApiService, + IStoreContext storeContext, + OpenAIApiService openAIApiService, + FruitBankDbContext fruitBankDbContext, + StockTakingDbContext stockTakingDbContext) { _cerebrasApiService = cerebrasApiService; _storeContext = storeContext; _openAIApiService = openAIApiService; + _fruitBankDbContext = fruitBankDbContext; + _stockTakingDbContext = stockTakingDbContext; } + private record OrderSummary( + string Partner, + decimal Total, + bool Audited, + [property: JsonPropertyName("weight_ok")] bool? WeightOk, + [property: JsonPropertyName("invoice_created")] bool? InvoiceCreated, + bool? Completed); + + private record StockSummary( + string Product, + int Diff, + bool Consistent); + public async Task GetWelcomeMessageAsync(Customer customer) { - var store = await _storeContext.GetCurrentStoreAsync(); - var storeName = store.Name; - var storeCompanyName = store.CompanyName; - var systemMessage = $"You are a helpful assistant of a webshop called {storeName}, of the company {storeCompanyName}, you work in the administration area, with the ADMIN user. The ADMIN user is {customer.FirstName}. Date and time is {DateTime.Now} When the user greets you, answer with a warm HUNGARIAN welcome message, incorporating some reference of the time, and maybe the weather too, making it pleasant for the ADMIN user to start working with the shop system. Assure the user that AI connection is established and you are ready to assist them."; + // ── Parallel data fetching ──────────────────────────────────────────────── + + var storeTask = _storeContext.GetCurrentStoreAsync(); + + var lastStockTakingTask = _stockTakingDbContext.StockTakings.GetAll() + .OrderByDescending(st => st.Created) + .FirstOrDefaultAsync(); + + var secondLastStockTakingTask = _stockTakingDbContext.StockTakings.GetAll(true) + .OrderByDescending(st => st.Created) + .Skip(1) + .FirstOrDefaultAsync(); + + var allOrdersTask = _fruitBankDbContext.OrderDtos.GetAll(true).ToListAsync(); + + var weatherTask = GetWeatherAsync(); // see method below + + await Task.WhenAll( + storeTask, + lastStockTakingTask, + secondLastStockTakingTask, + allOrdersTask, + weatherTask); + + var store = await storeTask; + var lastStockTaking = await lastStockTakingTask; + var secondLastStockTaking = await secondLastStockTakingTask; + var allOrders = await allOrdersTask; + var weatherInfo = await weatherTask; + + // ── Null guards ─────────────────────────────────────────────────────────── + + if (lastStockTaking == null || secondLastStockTaking == null) + return await _openAIApiService.GetSimpleResponseAsync( + $"You are a helpful assistant. Greet {customer.FirstName} warmly in Hungarian and let them know there is no stock taking data available yet.", + "Hello") ?? string.Empty; + + // ── Today's orders ──────────────────────────────────────────────────────── + + var today = DateTime.Now.Date; + var allTodaysOrders = allOrders + .Where(order => order.DateOfReceiptOrCreated.Date == today) + .ToList(); + + int todaysOrderNumber = allTodaysOrders.Count; + + // ── Stock taking summary ────────────────────────────────────────────────── + + var lastStockTakingList = await _stockTakingDbContext.StockTakingItems + .GetAll() + .Where(item => item.StockTakingId == lastStockTaking.Id) + .ToListAsync(); + + var problematicStockItems = lastStockTakingList + .Where(item => item.QuantityDiff != 0) + .ToList(); + + var productIds = problematicStockItems.Select(i => i.ProductId).ToList(); + + var allProductHistories = await _fruitBankDbContext.StockQuantityHistories + .GetAll() + .Where(ph => productIds.Contains(ph.ProductId) + && ph.CreatedOnUtc > secondLastStockTaking.Created.ToUniversalTime()) + .ToListAsync(); + + var historiesByProduct = allProductHistories + .GroupBy(ph => ph.ProductId) + .ToDictionary(g => g.Key, g => g.Sum(ph => ph.QuantityAdjustment)); + + var productDtos = await _fruitBankDbContext.ProductDtos.GetByIdsAsync(productIds); + var fy = productDtos.ToAsyncEnumerable(); + var productDtosDictionary = await fy.ToDictionaryAsync(p => p.Id, p => p.Name); + + var stockSummaries = new List(); + + foreach (var stockItem in problematicStockItems) + { + var formerDetails = secondLastStockTaking.StockTakingItems + .FirstOrDefault(sti => sti.ProductId == stockItem.ProductId); + + if (formerDetails == null) continue; + + int salesAdjustmentSum = historiesByProduct.GetValueOrDefault(stockItem.ProductId, 0); + string productName = productDtosDictionary.GetValueOrDefault(stockItem.ProductId, "sfgh"); + + int adjustedFormerValue = formerDetails.OriginalStockQuantity + formerDetails.QuantityDiff; + int expectedCurrent = adjustedFormerValue - salesAdjustmentSum; + int actualCurrent = stockItem.OriginalStockQuantity + stockItem.QuantityDiff; + bool isConsistent = expectedCurrent == actualCurrent; + + var productDto = _fruitBankDbContext.ProductDtos.GetById(stockItem.ProductId); + + stockSummaries.Add(new StockSummary( + Product: productName, + Diff: stockItem.QuantityDiff, + Consistent: isConsistent)); + } + + // ── Order summary ───────────────────────────────────────────────────────── + + var orderSummaries = allTodaysOrders.Select(order => + { + var audited = order.IsAllOrderItemAudited; + return new OrderSummary( + Partner: order.Customer.Company, + Total: order.OrderTotal, + Audited: audited, + WeightOk: audited ? order.IsValidMeasuringValues() : null, + InvoiceCreated: audited ? order.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId") : null, + Completed: audited ? order.OrderStatus == OrderStatus.Complete : null); + }).ToList(); + + // ── System message ──────────────────────────────────────────────────────── + + var serializerOptions = new JsonSerializerOptions { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + var systemMessage = $""" + You are a helpful assistant of a webshop called {store.Name}, of the company {store.CompanyName}. + You work in the administration area with the ADMIN user whose name is {customer.FirstName}. + Current date and time: {DateTime.Now}. + + When the user greets you, respond with a warm HUNGARIAN welcome message. Reference the time of day + naturally and make it pleasant and motivating to start the workday. + Confirm that the AI connection is established and you are ready to assist. + + After the greeting, give a concise Hungarian morning briefing based on the data below. + Keep it brief — only highlight things that need attention or are noteworthy. + Do not list every order one by one; summarize and flag issues. + + --- CURRENT WEATHER --- + {weatherInfo} + + --- ORDER DATA FIELD GUIDE --- + audited: all items have been physically measured/checked + weight_ok: measured weights within acceptable thresholds (null if not audited) + invoice_created: order sent to InnVoice invoicing system (null if not audited) + completed: order status is Complete (null if not audited) + ⚠ Flag: audited=true but completed=false → needs attention + ⚠ Flag: audited=true but invoice_created=false → needs attention + + --- STOCK DATA FIELD GUIDE --- + diff: quantity difference found during stock taking (non-zero = discrepancy) + consistent: discrepancy explainable by sales history since last stock taking + ⚠ Flag: consistent=false → serious unexplained inventory discrepancy, highlight clearly + + --- TODAY'S ORDERS ({todaysOrderNumber} total) --- + {JsonSerializer.Serialize(orderSummaries, serializerOptions)} + + --- LAST STOCK TAKING DISCREPANCIES ({stockSummaries.Count} items) --- + {JsonSerializer.Serialize(stockSummaries, serializerOptions)} + """; const string userMessage = "Hello"; - + var response = await _openAIApiService.GetSimpleResponseAsync(systemMessage, userMessage) ?? string.Empty; return response; } - /// - /// OSOLETED - /// - /// - /// - /// - public async Task GetOpenAIPDFAnalysisFromText(string pdfText, string userQuestion) + // ── Weather helper ──────────────────────────────────────────────────────────── + + private async Task GetWeatherAsync() { - var systemMessage = $"You are a pdf analyzis assistant of FRUITBANK, the user is asking you questions about a PDF document, that you have access to. The content of the PDF document is the following: {pdfText}"; - var response = await _openAIApiService.GetSimpleResponseAsync(systemMessage, userQuestion); + try + { + // TODO: move these to config/appsettings + const string apiKey = "de87827f73de5a204c61e1412a0e0b4e"; + const string city = "Budapest"; // change to your city + const string units = "metric"; + const string lang = "hu"; - if (response == null) return string.Empty; + using var http = new HttpClient(); + var url = $"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={apiKey}&units={units}&lang={lang}"; + var json = await http.GetStringAsync(url); - var fixedResponse = TextHelper.FixJsonWithoutAI(response); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; - return fixedResponse; + var description = root.GetProperty("weather")[0].GetProperty("description").GetString(); + var temp = root.GetProperty("main").GetProperty("temp").GetDouble(); + var feelsLike = root.GetProperty("main").GetProperty("feels_like").GetDouble(); + var humidity = root.GetProperty("main").GetProperty("humidity").GetInt32(); + + return $"{description}, {temp:F1}°C (feels like {feelsLike:F1}°C), humidity: {humidity}%"; + } + catch (Exception ex) + { + Console.WriteLine(ex + "Weather fetch failed, skipping weather data."); + return "Weather data unavailable."; + } } - - //public async Task ExtractProducts(string extractedText) - //{ - // //analyze document for product references - // return await _openAIApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted from a pdf document, " + - // "and find any product references mentioned in the document. Do not translate or modify the product information. " + - // "Format your response as JSON object named 'products' with the following fields in the child objects: " + - // "'name' (string), 'quantity' (int), 'netWeight' (double), 'grossWeight' (double), ", - // $"What product references are mentioned in this document: {extractedText}"); - //} + } + + } diff --git a/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs b/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs index e7226cc..2eba046 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs @@ -14,8 +14,10 @@ using Nop.Core.Events; using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; using Nop.Plugin.Misc.FruitBankPlugin.Models; using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders; +using Nop.Services.Attributes; using Nop.Services.Catalog; using Nop.Services.Common; +using Nop.Services.Customers; using Nop.Services.Events; using Nop.Services.Localization; using Nop.Services.Orders; @@ -24,6 +26,7 @@ using Nop.Web.Framework.Events; using Nop.Web.Framework.Menu; using Nop.Web.Models.Sitemap; using System.Linq; +using System.Xml.Linq; namespace Nop.Plugin.Misc.FruitBankPlugin.Services { @@ -42,6 +45,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services private readonly IAddressService _addressService; private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly FruitBankDbContext _dbContext; + private readonly IAttributeParser _attributeParser; + private readonly ICustomerService _customerService; public EventConsumer( IGenericAttributeService genericAttributeService, @@ -57,7 +62,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services IHttpContextAccessor httpContextAccessor, IAddressService addressService, FruitBankAttributeService fruitBankAttributeService, - FruitBankDbContext dbContext) : base(pluginManager) + FruitBankDbContext dbContext, + IAttributeParser attributeParser, + ICustomerService customerService + ) : base(pluginManager) { _genericAttributeService = genericAttributeService; _productService = productService; @@ -72,6 +80,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services _addressService = addressService; _fruitBankAttributeService = fruitBankAttributeService; _dbContext = dbContext; + _attributeParser = attributeParser; + _customerService = customerService; } protected override string PluginSystemName => "Misc.FruitBankPlugin"; @@ -320,7 +330,19 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services private async Task SynchronizeTaxInformationAsync(Customer customer) { - var taxId = await _fruitBankAttributeService.GetGenericAttributeValueAsync(customer.Id, "TaxId"); + string attributesXml = customer.CustomCustomerAttributesXML; + + string taxId = null; + + if (!string.IsNullOrWhiteSpace(attributesXml)) + { + var doc = XDocument.Parse(attributesXml); + taxId = doc.Descendants("CustomerAttribute") + .FirstOrDefault(el => (int?)el.Attribute("ID") == 1) // your TaxId attribute ID + ?.Descendants("Value") + .FirstOrDefault() + ?.Value; + } if (!string.IsNullOrWhiteSpace(taxId) && string.IsNullOrWhiteSpace(customer.VatNumber)) { @@ -333,6 +355,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services "TaxId", customer.VatNumber); } + _dbContext.Customers.Update(customer, false); } } diff --git a/Nop.Plugin.Misc.AIPlugin/Services/MeasurementService.cs b/Nop.Plugin.Misc.AIPlugin/Services/MeasurementService.cs index 49873e8..3ed14c6 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/MeasurementService.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/MeasurementService.cs @@ -26,7 +26,7 @@ public class MeasurementService : MeasurementServiceBase, IMeasurementSe private readonly IEventPublisher _eventPublisher; private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly SignalRSendToClientService _signalRSendToClientService; - private readonly CustomPriceCalculationService _customPriceCalculationService; + private readonly CustomPriceCalculationService _customPriceCalculationService; public MeasurementService(FruitBankDbContext dbContext, SignalRSendToClientService signalRSendToClientService, FruitBankAttributeService fruitBankAttributeService, IPriceCalculationService customPriceCalculationService, IEventPublisher eventPublisher, IEnumerable logWriters) : base(new Logger(logWriters.ToArray())) @@ -150,4 +150,54 @@ public class MeasurementService : MeasurementServiceBase, IMeasurementSe return result ? partners : null; } + + public async Task CalculateAndSetAverageWeight(ShippingItemPallet shippingItemPallet) + { + var productDto = await _dbContext.ProductDtos.GetByIdAsync(shippingItemPallet.ShippingItem.ProductId); + if (productDto != null) + { + double averageWeight = 0; + + if (shippingItemPallet.IsValidMeasuringValues(shippingItemPallet.ShippingItem.IsMeasurable)) + { + //this is only for the current pallet, the average weight should be calculated from all pallets of the product, + //so we check if there are any pallets with valid measuring values and calculate the average weight from them, + //otherwise we use the current pallet's weight as the average weight + + var allPalletsInThisShippingItem = shippingItemPallet.ShippingItem.ShippingItemPallets; + + //we need an object to store the netweight and trayquantity for average calculation + var validPalletsForAverageCalculation = new List<(double NetWeight, int TrayQuantity)>(); + + + //get all pallets with valid measuring values + for (int i = 0; i < allPalletsInThisShippingItem.Count; i++) + { + if (allPalletsInThisShippingItem[i].IsValidMeasuringValues(shippingItemPallet.ShippingItem.IsMeasurable)) + { + validPalletsForAverageCalculation.Add((allPalletsInThisShippingItem[i].NetWeight, allPalletsInThisShippingItem[i].TrayQuantity)); + } + } + + //add current pallet to the valid pallets if it has valid measuring values + + validPalletsForAverageCalculation.Add(new(shippingItemPallet.NetWeight, shippingItemPallet.TrayQuantity)); + + //calculate the average weight from the valid pallets + var totalNetWeight = validPalletsForAverageCalculation.Sum(x => x.NetWeight); + var totalTrayQuantity = validPalletsForAverageCalculation.Sum(x => x.TrayQuantity); + + var TotalAverageWeight = totalTrayQuantity > 0 ? totalNetWeight / totalTrayQuantity : 0; + + } + await SetProductAverageWeight(productDto, averageWeight); + await _dbContext.ProductDtos.UpdateAsync(productDto); + } + + } + + public async Task SetProductAverageWeight(ProductDto productDto, double averageWeight) + { + await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync(productDto.Id, nameof(IProductDto.AverageWeight), averageWeight); + } } \ No newline at end of file diff --git a/Nop.Plugin.Misc.AIPlugin/Views/OrderAttributes.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/OrderAttributes.cshtml index f3fe09c..f4d4edf 100644 --- a/Nop.Plugin.Misc.AIPlugin/Views/OrderAttributes.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Views/OrderAttributes.cshtml @@ -54,18 +54,18 @@
- -
+ @@ -215,7 +215,7 @@