diff --git a/Nop.Plugin.Misc.AIPlugin/.github/TOPIC_CODES.md b/Nop.Plugin.Misc.AIPlugin/.github/TOPIC_CODES.md index 615b498..46932ff 100644 --- a/Nop.Plugin.Misc.AIPlugin/.github/TOPIC_CODES.md +++ b/Nop.Plugin.Misc.AIPlugin/.github/TOPIC_CODES.md @@ -12,3 +12,4 @@ Per-repo topic registry for this plugin, per the **per-repo extension convention |---------|-----------|------------------------------------------------------------------------------------------------|------------------| | `PREO` | PRE-ORDER | Customer advance orders placed before stock arrives, and their conversion into real nopCommerce orders when an inbound shipping document confirms incoming stock. | `docs/PREORDER/` | | `EKAER` | EKÁER | Server-side / nopCommerce-specific aspects of NAV EKÁER reporting: VTSZ source from `Product`, `Shipping`→`tradeCard` data needs, serving the NAV call. The protocol/base layer lives in AyCode.Core (`AyCode.Services/Nav/`). | `docs/EKAER/` | +| `ORDD` | ORDER-DRAFT | AI-assisted intake channel: inbound free-text messages (Viber/email/dictation) parsed into order drafts, admin review/approval, then PreOrder creation that feeds the existing PRE-ORDER conversion. Covers parsing, product/partner resolution, learning mappings, the draft lifecycle and the approval entry point. | `docs/ORDERDRAFT/` | diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs index cc3a337..4afc1a6 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs @@ -1123,12 +1123,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers productDtosById.TryGetValue(product.Id, out var dto); result.Add(new { - label = $"{product.Name} [KÉSZLET: {(product.StockQuantity + (dto?.IncomingQuantity ?? 0))}] [ÁR: {product.Price}]", - value = product.Id, - sku = product.Sku, - price = product.Price, - stockQuantity = product.StockQuantity, - incomingQuantity = dto?.IncomingQuantity ?? 0 + label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + (dto?.IncomingQuantity ?? 0))} (R:{product.StockQuantity}/K:{dto?.IncomingQuantity ?? 0})] [ÁR: {product.Price}]", + value = product.Id, + sku = product.Sku, + price = product.Price, + stockQuantity = product.StockQuantity, + incomingQuantity = dto?.IncomingQuantity ?? 0, + availableQuantity = product.StockQuantity + (dto?.IncomingQuantity ?? 0) }); } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerAttributesController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerAttributesController.cs new file mode 100644 index 0000000..95d06bd --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerAttributesController.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Nop.Core.Domain.Customers; +using Nop.Plugin.Misc.FruitBankPlugin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Customers; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; + +/// +/// Inline-save endpoints for the FruitBank "Customer attributes" block on the +/// admin customer page (rendered by CustomerCreditWidgetViewComponent). +/// Values are stored as store-agnostic (StoreId 0) customer generic attributes +/// via . +/// +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +public class CustomerAttributesController : BasePluginController +{ + private readonly FruitBankAttributeService _attributeService; + private readonly ICustomerService _customerService; + private readonly IPermissionService _permissionService; + + public CustomerAttributesController( + FruitBankAttributeService attributeService, + ICustomerService customerService, + IPermissionService permissionService) + { + _attributeService = attributeService; + _customerService = customerService; + _permissionService = permissionService; + } + + [HttpPost] + [Route("Admin/CustomerAttributes/SetEkaer")] + public async Task SetEkaer(int customerId, bool value) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + var customer = await _customerService.GetCustomerByIdAsync(customerId); + if (customer == null) + return Json(new { success = false, error = "Customer not found" }); + + await _attributeService.InsertOrUpdateGenericAttributeAsync( + customerId, FruitBankPluginConst.IsEkaerAttribute, value, storeId: 0); + + return Json(new { success = true, value }); + } + + [HttpPost] + [Route("Admin/CustomerAttributes/SaveSites")] + public async Task SaveSites(int customerId, int[] siteAddressIds, int defaultAddressId) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + var customer = await _customerService.GetCustomerByIdAsync(customerId); + if (customer == null) + return Json(new { success = false, error = "Customer not found" }); + + // Only keep ids that actually belong to this customer (guards against tampering / stale ids) + var validIds = (await _customerService.GetAddressesByCustomerIdAsync(customerId)) + .Select(a => a.Id) + .ToHashSet(); + + var selected = (siteAddressIds ?? Array.Empty()) + .Where(validIds.Contains) + .Distinct() + .ToList(); + + // Default must be one of the selected sites; otherwise no default + var effectiveDefault = selected.Contains(defaultAddressId) ? defaultAddressId : 0; + + var entries = selected + .Select(id => new CustomerSiteEntry { AddressId = id, IsDefault = id == effectiveDefault }) + .ToList(); + + var json = JsonSerializer.Serialize(entries); + await _attributeService.InsertOrUpdateGenericAttributeAsync( + customerId, FruitBankPluginConst.CustomerSitesAttribute, json, storeId: 0); + + return Json(new { success = true, siteCount = entries.Count, defaultAddressId = effectiveDefault }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/OrderDraftAdminController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/OrderDraftAdminController.cs new file mode 100644 index 0000000..68a8d3d --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/OrderDraftAdminController.cs @@ -0,0 +1,296 @@ +using FruitBank.Common.Entities; +using FruitBank.Common.Enums; +using LinqToDB; +using Microsoft.AspNetCore.Mvc; +using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Catalog; +using Nop.Services.Customers; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; +using System.Text.Json; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; + +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +public class OrderDraftAdminController : BasePluginController +{ + private readonly IOrderDraftService _draftService; + private readonly FruitBankDbContext _dbContext; + private readonly ICustomerService _customerService; + private readonly IProductService _productService; + private readonly IPermissionService _permissionService; + + public OrderDraftAdminController( + IOrderDraftService draftService, + FruitBankDbContext dbContext, + ICustomerService customerService, + IProductService productService, + IPermissionService permissionService) + { + _draftService = draftService; + _dbContext = dbContext; + _customerService = customerService; + _productService = productService; + _permissionService = permissionService; + } + + // ── LISTA ───────────────────────────────────────────────────────────────── + + [HttpGet] + public IActionResult List() + { + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/OrderDraft/List.cshtml", + new OrderDraftListModel()); + } + + [HttpPost] + public async Task OrderDraftList() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { draw = 1, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); + + _ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1); + _ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0); + _ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 200); + + var statusFilter = Request.Form["statusFilter"].FirstOrDefault() ?? "Pending"; + var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? ""; + + // 1. Draftek lekérése + var query = _dbContext.OrderDrafts.GetAll(false); + + if (Enum.TryParse(statusFilter, out var statusEnum)) + query = query.Where(d => d.Status == statusEnum); + + var allDrafts = await query.OrderByDescending(d => d.CreatedOnUtc).ToListAsync(); + + // 2. Kapcsolódó customer nevek — batch + var customerIds = allDrafts.Where(d => d.CustomerId.HasValue).Select(d => d.CustomerId!.Value).Distinct().ToList(); + var customers = new Dictionary(); + if (customerIds.Any()) + { + var customerList = await _dbContext.Customers.Table + .Where(c => customerIds.Contains(c.Id)) + .Select(c => new { c.Id, c.FirstName, c.LastName, c.Email }) + .ToListAsync(); + customers = customerList.ToDictionary( + c => c.Id, + c => string.IsNullOrWhiteSpace($"{c.FirstName} {c.LastName}".Trim()) + ? c.Email ?? c.Id.ToString() + : $"{c.FirstName} {c.LastName}".Trim()); + } + + // 3. Item count — batch + var draftIds = allDrafts.Select(d => d.Id).ToList(); + var itemCounts = await _dbContext.OrderDraftItems.GetAll() + .Where(i => draftIds.Contains(i.DraftId)) + .GroupBy(i => i.DraftId) + .Select(g => new { DraftId = g.Key, Count = g.Count() }) + .ToListAsync(); + var itemCountDict = itemCounts.ToDictionary(x => x.DraftId, x => x.Count); + + var now = DateTime.UtcNow; + + var rows = allDrafts.Select(d => new OrderDraftListRow + { + Id = d.Id, + ExternalUsername = d.ExternalUsername, + CustomerId = d.CustomerId, + CustomerName = d.CustomerId.HasValue && customers.TryGetValue(d.CustomerId.Value, out var cn) ? cn : "— ismeretlen —", + RawMessageTextShort = d.RawMessageText.Length > 80 ? d.RawMessageText[..80] + "…" : d.RawMessageText, + ParsedDeliveryDate = d.ParsedDeliveryDate.ToString("yyyy-MM-dd"), + ItemCount = itemCountDict.TryGetValue(d.Id, out var cnt) ? cnt : 0, + Status = d.Status.ToString(), + StatusBadgeClass = d.Status switch + { + OrderDraftStatus.Pending => "badge-warning", + OrderDraftStatus.Approved => "badge-success", + OrderDraftStatus.Rejected => "badge-danger", + OrderDraftStatus.Expired => "badge-secondary", + _ => "badge-light" + }, + IsUrgent = d.Status == OrderDraftStatus.Pending && (now - d.CreatedOnUtc).TotalHours > 20, + CreatedOnUtc = d.CreatedOnUtc.ToString("yyyy-MM-dd HH:mm") + }).ToList(); + + int recordsTotal = rows.Count; + + if (!string.IsNullOrWhiteSpace(globalSearch)) + rows = rows.Where(r => + r.ExternalUsername.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + r.RawMessageTextShort.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) + ).ToList(); + + int recordsFiltered = rows.Count; + var page = rows.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } + + // ── RÉSZLETEK / JÓVÁHAGYÓ OLDAL ────────────────────────────────────────── + + [HttpGet] + public async Task Detail(int id) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + var draft = await _draftService.GetByIdAsync(id, true); + if (draft == null) return NotFound(); + + // Customer név + string customerName = "— ismeretlen partner —"; + if (draft.CustomerId.HasValue) + { + var customer = await _customerService.GetCustomerByIdAsync(draft.CustomerId.Value); + if (customer != null) + customerName = string.IsNullOrWhiteSpace($"{customer.FirstName} {customer.LastName}".Trim()) + ? customer.Email ?? customer.Id.ToString() + : $"{customer.FirstName} {customer.LastName}".Trim(); + } + + // Termék nevek és kandidátusok batch lekérése + var allCandidateIds = draft.Items + .SelectMany(i => + { + try { return JsonSerializer.Deserialize>(i.SuggestedProductIdsJson) ?? []; } + catch { return []; } + }) + .Distinct() + .ToList(); + + var productMap = new Dictionary(); + if (allCandidateIds.Any()) + { + var products = await _dbContext.ProductDtos + .GetAllByIds(allCandidateIds, false) + .ToListAsync(); + productMap = products.ToDictionary( + p => p.Id, + p => (p.Name, p.StockQuantity)); + } + + var model = new OrderDraftDetailModel + { + Id = draft.Id, + ExternalUsername = draft.ExternalUsername, + CustomerId = draft.CustomerId, + CustomerName = customerName, + RawMessageText = draft.RawMessageText, + ParsedDeliveryDate = draft.ParsedDeliveryDate, + IsPreorder = draft.ParsedDeliveryDate.Date > DateTime.Today.AddDays(4), + Status = draft.Status.ToString(), + AdminNote = draft.AdminNote ?? string.Empty, + CreatedOnUtc = draft.CreatedOnUtc.ToString("yyyy-MM-dd HH:mm"), + CanApprove = draft.Status == OrderDraftStatus.Pending, + Items = draft.Items.OrderBy(i => i.SortOrder).Select(i => + { + var candidateIds = new List(); + try { candidateIds = JsonSerializer.Deserialize>(i.SuggestedProductIdsJson) ?? []; } + catch { /* */ } + + var selectedName = i.SelectedProductId.HasValue && productMap.TryGetValue(i.SelectedProductId.Value, out var sel) + ? sel.Name : string.Empty; + + return new OrderDraftItemModel + { + Id = i.Id, + RawProductText = i.RawProductText, + AiResolvedProductName = i.AiResolvedProductName, + RequestedQuantity = i.RequestedQuantity, + IsPreorder = i.IsPreorder, + SortOrder = i.SortOrder, + SelectedProductId = i.SelectedProductId, + SelectedProductName = selectedName, + Candidates = candidateIds + .Where(pid => productMap.ContainsKey(pid)) + .Select(pid => new OrderDraftProductCandidate + { + Id = pid, + Name = productMap[pid].Name, + Stock = productMap[pid].Stock.ToString() + }).ToList() + }; + }).ToList() + }; + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/OrderDraft/Detail.cshtml", model); + } + + // ── AJAX: TÉTEL FRISSÍTÉSE ──────────────────────────────────────────────── + + [HttpPost] + public async Task UpdateItem(int draftItemId, int selectedProductId, int quantity) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false }); + + await _draftService.UpdateDraftItemAsync(draftItemId, selectedProductId, quantity); + return Json(new { success = true }); + } + + // ── AJAX: PARTNER HOZZÁRENDELÉSE ────────────────────────────────────────── + + [HttpPost] + public async Task AssignCustomer(int draftId, int customerId) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false }); + + var workContext = HttpContext.RequestServices.GetService(typeof(Nop.Core.IWorkContext)) as Nop.Core.IWorkContext; + var admin = workContext != null ? await workContext.GetCurrentCustomerAsync() : null; + + await _draftService.AssignCustomerAsync(draftId, customerId, admin?.Id ?? 0); + + var customer = await _customerService.GetCustomerByIdAsync(customerId); + var name = customer == null ? customerId.ToString() + : string.IsNullOrWhiteSpace($"{customer.FirstName} {customer.LastName}".Trim()) + ? customer.Email ?? customerId.ToString() + : $"{customer.FirstName} {customer.LastName}".Trim(); + + return Json(new { success = true, customerName = name }); + } + + // ── AJAX: JÓVÁHAGYÁS ───────────────────────────────────────────────────── + + [HttpPost] + public async Task Approve(int draftId) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Hozzáférés megtagadva." }); + + var workContext = HttpContext.RequestServices.GetService(typeof(Nop.Core.IWorkContext)) as Nop.Core.IWorkContext; + var admin = workContext != null ? await workContext.GetCurrentCustomerAsync() : null; + + var result = await _draftService.ApproveAsync(draftId, admin?.Id ?? 0); + + return Json(new + { + success = result.Success, + error = result.ErrorMessage, + preOrderIds = result.PreOrderIds, + orderIds = result.OrderIds + }); + } + + // ── AJAX: ELUTASÍTÁS ────────────────────────────────────────────────────── + + [HttpPost] + public async Task Reject(int draftId, string? adminNote) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false }); + + var workContext = HttpContext.RequestServices.GetService(typeof(Nop.Core.IWorkContext)) as Nop.Core.IWorkContext; + var admin = workContext != null ? await workContext.GetCurrentCustomerAsync() : null; + + await _draftService.RejectAsync(draftId, admin?.Id ?? 0, adminNote); + return Json(new { success = true }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs index a1b2554..e67d334 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/PreorderAdminController.cs @@ -12,6 +12,7 @@ using Nop.Services.Security; using Nop.Web.Framework; using Nop.Web.Framework.Controllers; using Nop.Web.Framework.Mvc.Filters; +using Nop.Services.Helpers; namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers; @@ -27,6 +28,7 @@ public partial class PreOrderAdminController : BasePluginController private readonly PreOrderConversionService _preorderConversionService; private readonly IPriceCalculationService _priceCalculationService; private readonly IStoreContext _storeContext; + private readonly IDateTimeHelper _dateTimeHelper; private static readonly Dictionary StatusLabels = new() { @@ -51,7 +53,8 @@ public partial class PreOrderAdminController : BasePluginController ICustomerService customerService, PreOrderConversionService preorderConversionService, IPriceCalculationService priceCalculationService, - IStoreContext storeContext) + IStoreContext storeContext, + IDateTimeHelper dateTimeHelper) { _permissionService = permissionService; _preorderDbContext = preorderDbContext; @@ -60,6 +63,7 @@ public partial class PreOrderAdminController : BasePluginController _preorderConversionService = preorderConversionService; _priceCalculationService = priceCalculationService; _storeContext = storeContext; + _dateTimeHelper = dateTimeHelper; } // ── LIST PAGE ───────────────────────────────────────────────────────────── @@ -106,7 +110,7 @@ public partial class PreOrderAdminController : BasePluginController var customerIds = preorders.Select(p => p.CustomerId).Distinct().ToList(); var customers = await _dbContext.Customers.Table .Where(c => customerIds.Contains(c.Id)) - .Select(c => new { c.Id, c.Email, c.FirstName, c.LastName }) + .Select(c => new { c.Id, c.Email, c.FirstName, c.LastName, c.Company }) .ToListAsync(); var customerById = customers.ToDictionary(c => c.Id); @@ -116,7 +120,7 @@ public partial class PreOrderAdminController : BasePluginController // For now we surface the link on the detail page only. // 4. Build rows — derive status from quantities, not enum (LinqToDB enum reads unreliable) - var rows = preorders.Select(p => + var rowTasks = preorders.Select(async p => { customerById.TryGetValue(p.CustomerId, out var c); var items = itemsByPreOrder.TryGetValue(p.Id, out var its) ? its : new(); @@ -140,17 +144,18 @@ public partial class PreOrderAdminController : BasePluginController { PreOrderId = p.Id, CustomerId = p.CustomerId, - CustomerName = c != null ? $"{c.FirstName} {c.LastName}".Trim() : $"#{p.CustomerId}", + CustomerName = c != null ? (!string.IsNullOrWhiteSpace(c.Company) ? c.Company : $"{c.FirstName} {c.LastName}".Trim()) : $"#{p.CustomerId}", CustomerEmail = c?.Email ?? string.Empty, - DateOfReceipt = p.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), - CreatedOnUtc = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + DateOfReceipt = (await _dateTimeHelper.ConvertToUserTimeAsync(p.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"), + CreatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(p.CreatedOnUtc, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"), Status = effectiveStatus, StatusLabel = StatusLabels.TryGetValue(effectiveStatus, out var sl) ? sl : effectiveStatus.ToString(), ItemCount = items.Count, FulfilledCount = fulfilledCount, OrderId = p.OrderId }; - }).ToList(); + }); + var rows = (await Task.WhenAll(rowTasks)).ToList(); int recordsTotal = rows.Count; @@ -217,11 +222,14 @@ public partial class PreOrderAdminController : BasePluginController { PreOrderId = preorder.Id, CustomerId = preorder.CustomerId, - CustomerName = customer != null ? $"{customer.FirstName} {customer.LastName}".Trim() : $"#{preorder.CustomerId}", + CustomerName = customer != null + ? (!string.IsNullOrWhiteSpace(customer.Company) ? customer.Company : $"{customer.FirstName} {customer.LastName}".Trim()) + : $"#{preorder.CustomerId}", CustomerEmail = customer?.Email ?? string.Empty, - DateOfReceipt = preorder.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), - CreatedOnUtc = preorder.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), - UpdatedOnUtc = preorder.UpdatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"), + DateOfReceipt = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"), + DateOfReceiptRaw = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy-MM-ddTHH:mm"), + CreatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.CreatedOnUtc, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"), + UpdatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.UpdatedOnUtc, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"), Status = preorder.Status, CustomerNote = preorder.CustomerNote, OrderId = linkedOrderId, @@ -369,13 +377,14 @@ public partial class PreOrderAdminController : BasePluginController if (preorder.Status == PreOrderStatus.Cancelled) return Json(new { success = false, error = "Már visszavont előrendelés" }); - preorder.Status = PreOrderStatus.Cancelled; - preorder.UpdatedOnUtc = DateTime.UtcNow; - await _preorderDbContext.PreOrders.UpdateAsync(preorder); - if (preorder.Status != PreOrderStatus.Pending) - return Json(new { success = false, error = "Only pending preorders can be cancelled" }); + // Only not-yet-fulfilled preorders can be cancelled — once a real order + // exists (OrderId set at first fulfilment), cancelling the preorder must + // not silently undo it. + if (preorder.Status != PreOrderStatus.Pending || preorder.OrderId.HasValue) + return Json(new { success = false, error = "Csak függőben lévő előrendelés vonható vissza" }); - //await _preorderDbContext.CancelPreOrderAsync(id); + // Sets header → Cancelled and drops all still-active items → Dropped + await _preorderDbContext.CancelPreOrderAsync(id); return Json(new { success = true }); } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/OrderDraftModels.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/OrderDraftModels.cs new file mode 100644 index 0000000..af12573 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/OrderDraftModels.cs @@ -0,0 +1,59 @@ +using FruitBank.Common.Enums; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class OrderDraftListModel +{ + public string StatusFilter { get; set; } = "Pending"; +} + +public class OrderDraftListRow +{ + public int Id { get; set; } + public string ExternalUsername { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public int? CustomerId { get; set; } + public string RawMessageTextShort { get; set; } = string.Empty; + public string ParsedDeliveryDate { get; set; } = string.Empty; + public int ItemCount { get; set; } + public string Status { get; set; } = string.Empty; + public string StatusBadgeClass { get; set; } = string.Empty; + public bool IsUrgent { get; set; } // Pending és > 20 óra régi + public string CreatedOnUtc { get; set; } = string.Empty; +} + +public class OrderDraftDetailModel +{ + public int Id { get; set; } + public string ExternalUsername { get; set; } = string.Empty; + public int? CustomerId { get; set; } + public string CustomerName { get; set; } = string.Empty; + public string RawMessageText { get; set; } = string.Empty; + public DateTime ParsedDeliveryDate { get; set; } + public bool IsPreorder { get; set; } + public string Status { get; set; } = string.Empty; + public string AdminNote { get; set; } = string.Empty; + public string CreatedOnUtc { get; set; } = string.Empty; + public bool CanApprove { get; set; } + public List Items { get; set; } = []; +} + +public class OrderDraftItemModel +{ + public int Id { get; set; } + public string RawProductText { get; set; } = string.Empty; + public string AiResolvedProductName{ get; set; } = string.Empty; + public int RequestedQuantity { get; set; } + public bool IsPreorder { get; set; } + public int SortOrder { get; set; } + public int? SelectedProductId { get; set; } + public string SelectedProductName { get; set; } = string.Empty; + public List Candidates { get; set; } = []; +} + +public class OrderDraftProductCandidate +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Stock { get; set; } = string.Empty; +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs index 2f5f0a8..adaea63 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/PreorderAdminModels.cs @@ -23,7 +23,8 @@ public class PreOrderDetailModel public int CustomerId { get; set; } public string CustomerName { get; set; } = string.Empty; public string CustomerEmail { get; set; } = string.Empty; - public string DateOfReceipt { get; set; } = string.Empty; + public string DateOfReceipt { get; set; } = string.Empty; // formatted for display + public string DateOfReceiptRaw { get; set; } = string.Empty; // yyyy-MM-ddTHH:mm for datetime-local input public string CreatedOnUtc { get; set; } = string.Empty; public string UpdatedOnUtc { get; set; } = string.Empty; public PreOrderStatus Status { get; set; } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/Detail.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/Detail.cshtml new file mode 100644 index 0000000..63137d3 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/Detail.cshtml @@ -0,0 +1,377 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.OrderDraftDetailModel +@{ + ViewBag.PageTitle = "Rendelés tervezet #" + Model.Id; +} + + + + + Rendelés tervezet #@Model.Id + @Model.Status + + + + Vissza a listára + + + + + + + @Html.AntiForgeryToken() + + + + {{-- BAL OLDAL: meta + üzenet --}} + + + {{-- Eredeti üzenet --}} + + + Eredeti üzenet + + + @Model.RawMessageText + + + + {{-- Meta adatok --}} + + Adatok + + + + Feladó + @Model.ExternalUsername + + + Partner + + @if (Model.CustomerId.HasValue) + { + @Model.CustomerName + } + else + { + Nincs hozzárendelve + } + + + + Szállítás + + @Model.ParsedDeliveryDate.ToString("yyyy-MM-dd") + @if (Model.IsPreorder) + { + Előrendelés + } + else + { + Azonnali + } + + + + Beérkezett + @Model.CreatedOnUtc + + @if (!string.IsNullOrEmpty(Model.AdminNote)) + { + + Megjegyzés + @Model.AdminNote + + } + + + + + {{-- Partner hozzárendelés (csak ha nincs még) --}} + @if (!Model.CustomerId.HasValue && Model.CanApprove) + { + + Partner hozzárendelése + + + Ismeretlen feladó: @Model.ExternalUsername + Rendeld hozzá a megfelelő partnerhez. A rendszer megjegyzi a kapcsolatot. + + + + + + Hozzárendelés + + + + } + + {{-- /col-md-4 --}} + + {{-- JOBB OLDAL: tételek --}} + + + + Tételek + @Model.Items.Count db + + + + + + Eredeti szöveg + AI javaslat / Termék kiválasztása + Menny. (kt.) + Típus + + + + @foreach (var item in Model.Items) + { + + + @item.RawProductText + @if (!string.IsNullOrEmpty(item.AiResolvedProductName) && item.AiResolvedProductName != item.RawProductText) + { + → @item.AiResolvedProductName + } + + + + — válassz terméket — + @foreach (var c in item.Candidates) + { + var selected = c.Id == item.SelectedProductId ? "selected" : ""; + + @c.Name (készlet: @c.Stock) + + } + @if (item.SelectedProductId.HasValue && !item.Candidates.Any(c => c.Id == item.SelectedProductId)) + { + @item.SelectedProductName + } + + + Mentve + + + + + + + @if (item.IsPreorder) + { + Elő + } + else + { + Azonnali + } + + + } + + + + + + {{-- Akció gombok --}} + @if (Model.CanApprove) + { + + + + + + Jóváhagyás és rendelés/előrendelés létrehozása + + + + + Elutasítás + + + + + + + } + + {{-- /col-md-8 --}} + + {{-- /row --}} + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/List.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/List.cshtml new file mode 100644 index 0000000..dedd8f3 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/OrderDraft/List.cshtml @@ -0,0 +1,138 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.OrderDraftListModel +@{ + ViewBag.PageTitle = "Rendelés tervezetek"; + NopHtml.SetActiveMenuItemSystemName("OrderDraft.List"); +} + + + + + Rendelés tervezetek + + + + + + + {{-- Státusz szűrő --}} + + + + + Függőben + + + Jóváhagyva + + + Elutasítva + + + Lejárt + + + Összes + + + + + + + + @Html.AntiForgeryToken() + + + + # + Feladó + Partner + Üzenet (részlet) + Szállítás + Tételek + Státusz + Beérkezett + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Preorder/Detail.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Preorder/Detail.cshtml index a41426c..b200516 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Preorder/Detail.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Preorder/Detail.cshtml @@ -12,7 +12,6 @@ PreOrderStatus.Cancelled => "po-status-cancelled", _ => "po-status-pending" }; - var statusLabel = Model.Status switch { PreOrderStatus.Confirmed => "Megerősítve", @@ -20,6 +19,7 @@ PreOrderStatus.Cancelled => "Törölve", _ => "Függőben" }; + var canEdit = Model.Status != PreOrderStatus.Cancelled; } @Html.AntiForgeryToken() @@ -35,17 +35,23 @@ .po-meta-item .label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:#6b7c6e; margin-bottom:4px; } .po-meta-item .value { font-size:15px; color:#1a3c22; font-weight:600; } -.item-fulfilled { background:#eaf7ee; } -.item-partial { background:#fffbf0; } -.item-dropped { background:#fdf0f0; color:#888; } -.item-pending { } +.item-fulfilled { background:#eaf7ee; } +.item-partial { background:#fffbf0; } +.item-dropped { background:#fdf0f0; color:#888; } -.qty-bar-wrap { width:100px; display:inline-block; vertical-align:middle; } -.qty-bar { height:6px; background:#dde8da; border-radius:3px; overflow:hidden; display:inline-block; width:100%; } -.qty-bar-fill { height:100%; border-radius:3px; } +.qty-bar-wrap { width:100px; display:inline-block; vertical-align:middle; } +.qty-bar { height:6px; background:#dde8da; border-radius:3px; overflow:hidden; display:inline-block; width:100%; } +.qty-bar-fill { height:100%; border-radius:3px; } + +/* Click-to-edit */ +.editable-cell { cursor:pointer; border-radius:4px; padding:2px 6px; transition:background .15s; } +.editable-cell:hover { background:#f0f8f1; outline:1px dashed #2d7a3a; } +.edit-input { width:90px; } +.btn-swap { padding:2px 7px; font-size:11px; } + +.ui-autocomplete { z-index:1060 !important; max-height:220px; overflow-y:auto; overflow-x:hidden; } - Vissza a listához @@ -56,16 +62,25 @@ Előrendelés #@Model.PreOrderId @statusLabel - + @if (Model.OrderId.HasValue) { Rendelés #@Model.OrderId } + @if (canEdit) + { + + Konvertálás rendeléssé + + + Szerkesztés + + } @if (Model.Status == PreOrderStatus.Pending) { - + Visszavonás } @@ -75,39 +90,61 @@ - + - - - Ügyfél - - @Model.CustomerName + + + + + Ügyfél + @Model.CustomerName + @Model.CustomerEmail - @Model.CustomerEmail - - - Kért szállítási időpont - @Model.DateOfReceipt - - - Leadva - @Model.CreatedOnUtc - - - Utoljára frissítve - @Model.UpdatedOnUtc - - @if (!string.IsNullOrWhiteSpace(Model.CustomerNote)) - { - - Ügyfél megjegyzése - @Model.CustomerNote + + Kért szállítási időpont + @Model.DateOfReceipt - } + + Leadva + @Model.CreatedOnUtc + + + Utoljára frissítve + @Model.UpdatedOnUtc + + @if (!string.IsNullOrWhiteSpace(Model.CustomerNote)) + { + + Ügyfél megjegyzése + @Model.CustomerNote + + } + + + + + + + + Kért szállítási időpont + + + + Ügyfél megjegyzése + + + + + Mentés + + Mégse + + - + Tételek (@Model.Items.Count) @@ -118,7 +155,7 @@ var pending = Model.Items.Count(i => i.Status == PreOrderItemStatus.Pending); } - @if (fulfilled > 0) { @fulfilled teljesítve } + @if (fulfilled > 0) { @fulfilled teljesítve } @if (partial > 0) { @partial részben } @if (dropped > 0) { @dropped ejtve } @if (pending > 0) { @pending függőben } @@ -129,9 +166,9 @@ Termék - Kérve - Teljesítve - Teljesítés + Kérve + Teljesítve + Teljesítés Egységár Becsült ár Állapot @@ -140,34 +177,66 @@ @foreach (var item in Model.Items) { - var rowClass = item.Status switch + var rowClass = item.Status switch { PreOrderItemStatus.Fulfilled => "item-fulfilled", PreOrderItemStatus.PartiallyFulfilled => "item-partial", PreOrderItemStatus.Dropped => "item-dropped", - _ => "item-pending" + _ => "" }; - var pct = item.RequestedQuantity > 0 + var pct = item.RequestedQuantity > 0 ? (int)Math.Round((double)item.FulfilledQuantity / item.RequestedQuantity * 100) : 0; - var barColor = pct == 100 ? "#2d7a3a" : pct > 0 ? "#f4a236" : "#dc3545"; + var barColor = pct == 100 ? "#2d7a3a" : pct > 0 ? "#f4a236" : "#dc3545"; var estimatedPrice = item.IsMeasurable ? "—" : (item.UnitPriceInclTax * item.FulfilledQuantity).ToString("N0") + " Ft"; - var unitPrice = item.IsMeasurable ? "súlymérés" : item.UnitPriceInclTax.ToString("N0") + " Ft"; + var unitPriceStr = item.IsMeasurable ? "súlymérés" : item.UnitPriceInclTax.ToString("N0") + " Ft"; + var isLocked = item.FulfilledQuantity > 0; - + + @item.ProductName @if (item.IsMeasurable) { ⚖️ } + @if (canEdit && !isLocked) + { + + + + } + @if (isLocked) + { + + } - @item.RequestedQuantity db + + - @item.FulfilledQuantity db + @if (canEdit && !isLocked) + { + @item.RequestedQuantity db + + + + } + else + { + @item.RequestedQuantity db + } + + + @item.FulfilledQuantity db + + @@ -176,8 +245,25 @@ @pct% - @unitPrice + + + + @if (canEdit && !isLocked && !item.IsMeasurable) + { + @unitPriceStr + + + + } + else + { + @unitPriceStr + } + + @estimatedPrice + @item.StatusLabel @@ -188,7 +274,8 @@ @{ var totalEstimated = Model.Items - .Where(i => !i.IsMeasurable && (i.Status == PreOrderItemStatus.Fulfilled || i.Status == PreOrderItemStatus.PartiallyFulfilled)) + .Where(i => !i.IsMeasurable && + (i.Status == PreOrderItemStatus.Fulfilled || i.Status == PreOrderItemStatus.PartiallyFulfilled)) .Sum(i => i.UnitPriceInclTax * i.FulfilledQuantity); } @@ -205,25 +292,192 @@ -@if (Model.Status == PreOrderStatus.Pending) -{ - -} + + // ── Click-to-edit: quantity ─────────────────────────────────────────── + $(document).on('click', '.qty-display', function () { + var id = $(this).data('item-id'); + $(this).hide(); + $('[data-item-id="' + id + '"].qty-input').closest('.qty-edit-wrap').show() + .find('.qty-input').focus().select(); + }); + $(document).on('keydown blur', '.qty-input', function (e) { + if (e.type === 'keydown' && e.key === 'Escape') { cancelEdit($(this).data('item-id'), 'qty'); return; } + if (e.type === 'keydown' && e.key !== 'Enter') return; + saveItemField($(this).data('item-id')); + }); + + // ── Click-to-edit: price ────────────────────────────────────────────── + $(document).on('click', '.price-display', function () { + var id = $(this).data('item-id'); + $(this).hide(); + $(this).siblings('.price-edit-wrap').show().find('.price-input').focus().select(); + }); + $(document).on('keydown blur', '.price-input', function (e) { + if (e.type === 'keydown' && e.key === 'Escape') { cancelEdit($(this).data('item-id'), 'price'); return; } + if (e.type === 'keydown' && e.key !== 'Enter') return; + saveItemField($(this).data('item-id')); + }); + + function cancelEdit(id, type) { + if (type === 'qty') { + $('[data-item-id="' + id + '"].qty-display').show(); + $('[data-item-id="' + id + '"].qty-input').closest('.qty-edit-wrap').hide(); + } else { + $('[data-item-id="' + id + '"].price-display').show(); + $('[data-item-id="' + id + '"].price-input').closest('.price-edit-wrap').hide(); + } + } + + function saveItemField(id) { + var qty = parseInt($('[data-item-id="' + id + '"].qty-input').val()) || 1; + var price = parseFloat($('[data-item-id="' + id + '"].price-input').val()) || 0; + $.post('/Admin/PreOrders/UpdateItem/' + id, { + quantity: qty, unitPrice: price, __RequestVerificationToken: _token + }, function (r) { + if (r.success) { + $('[data-item-id="' + id + '"].qty-display').text(qty + ' db').show(); + $('[data-item-id="' + id + '"].qty-input').closest('.qty-edit-wrap').hide(); + $('[data-item-id="' + id + '"].price-display').text(Math.round(price).toLocaleString('hu-HU') + ' Ft').show(); + $('[data-item-id="' + id + '"].price-input').closest('.price-edit-wrap').hide(); + } else { + alert('Hiba: ' + (r.error || 'Ismeretlen hiba')); + } + }); + } + + // ── Product swap ────────────────────────────────────────────────────── + $(document).on('click', '.swap-product-btn', function () { + $('#swapItemId').val($(this).data('item-id')); + $('#swapNewProductId').val(''); + $('#swapProductSearch, #swapSelectedName, #swapNewPrice').val('').text(''); + $('#swapSelectedProduct').hide(); + $('#confirmSwapBtn').prop('disabled', true); + $('#swapProductModal').modal('show'); + }); + + $('#swapProductSearch').autocomplete({ + delay: 400, minLength: 2, + source: '/Admin/CustomOrder/PreorderProductSearchAutoComplete', + select: function (e, ui) { + $('#swapNewProductId').val(ui.item.value); + $('#swapSelectedName').text(ui.item.label); + $('#swapSelectedProduct').show(); + $('#confirmSwapBtn').prop('disabled', false); + $('#swapProductSearch').val(''); + return false; + } + }); + + $('#swapProductModal').on('hidden.bs.modal', function () { + $('#swapNewProductId').val(''); + $('#swapSelectedProduct').hide(); + $('#confirmSwapBtn').prop('disabled', true); + }); + + $('#confirmSwapBtn').click(function () { + var $btn = $(this).prop('disabled', true).html(''); + $.post('/Admin/PreOrders/ReplaceItemProduct/' + $('#swapItemId').val(), { + newProductId: $('#swapNewProductId').val(), + __RequestVerificationToken: _token + }, function (r) { + if (r.success) { + $('#swapProductModal').modal('hide'); + location.reload(); + } else { + alert('Hiba: ' + (r.error || 'Ismeretlen hiba')); + $btn.prop('disabled', false).html(' Csere megerősítése'); + } + }); + }); + + // ── Manual conversion ───────────────────────────────────────────────── + $('#convertNowBtn').click(function () { + if (!confirm('Azonnali konvertálást indítasz — a függőben lévő tételeket megpróbálja rendeléssé alakítani a jelenlegi készlet alapján. Folytatod?')) return; + var $btn = $(this).prop('disabled', true).html(' Konvertálás...'); + $.post('/Admin/PreOrders/ConvertNow/' + preorderId, { + __RequestVerificationToken: _token + }, function (r) { + if (r.success) { + alert(r.orderId + ? 'Sikeresen konvertálva! Rendelés: #' + r.orderId + : 'A konvertálás lefutott, de nincs elegendő készlet.'); + location.reload(); + } else { + alert('Hiba: ' + (r.error || 'Ismeretlen hiba')); + $btn.prop('disabled', false).html(' Konvertálás rendeléssé'); + } + }); + }); + + // ── Cancel preorder ─────────────────────────────────────────────────── + @if (Model.Status == PreOrderStatus.Pending) + { + + $('#cancelBtn').click(function () { + if (!confirm('Biztosan visszavonod ezt az előrendelést? Ez a művelet nem visszafordítható.')) return; + $.post('/Admin/PreOrders/Cancel/@Model.PreOrderId', { + __RequestVerificationToken: _token + }, function (r) { + if (r.success) { location.href = '/Admin/PreOrders'; } + else { alert('Hiba: ' + (r.error || 'Ismeretlen hiba')); } + }); + }); + + } +}); + diff --git a/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs b/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs index 405f26c..3fb892d 100644 --- a/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs +++ b/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs @@ -1,6 +1,10 @@ +using System.Text.Json; using Microsoft.AspNetCore.Mvc; +using Nop.Core.Domain.Common; +using Nop.Core.Domain.Customers; using Nop.Plugin.Misc.FruitBankPlugin.Models; using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Customers; using Nop.Web.Areas.Admin.Models.Customers; using Nop.Web.Framework.Components; @@ -10,10 +14,17 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components; public class CustomerCreditWidgetViewComponent : NopViewComponent { private readonly ICustomerCreditService _customerCreditService; + private readonly FruitBankAttributeService _attributeService; + private readonly ICustomerService _customerService; - public CustomerCreditWidgetViewComponent(ICustomerCreditService customerCreditService) + public CustomerCreditWidgetViewComponent( + ICustomerCreditService customerCreditService, + FruitBankAttributeService attributeService, + ICustomerService customerService) { _customerCreditService = customerCreditService; + _attributeService = attributeService; + _customerService = customerService; } public async Task InvokeAsync(string widgetZone, object additionalData) @@ -24,6 +35,9 @@ public class CustomerCreditWidgetViewComponent : NopViewComponent var credit = await _customerCreditService.GetByCustomerIdAsync(customerId); var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId); + var isEkaer = await _attributeService + .GetGenericAttributeValueAsync(customerId, FruitBankPluginConst.IsEkaerAttribute, storeId: 0); + var model = new CustomerCreditWidgetModel { CustomerId = customerId, @@ -31,9 +45,63 @@ public class CustomerCreditWidgetViewComponent : NopViewComponent CreditLimit = credit?.CreditLimit ?? 0m, OutstandingBalance = outstanding, RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null, - Comment = credit?.Comment + Comment = credit?.Comment, + IsEkaer = isEkaer }; + await PopulateSitesAsync(model, customerId); + return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model); } + + // Merge the customer's current addresses with the saved site list (orphaned + // address ids — deleted addresses — are dropped because we iterate addresses). + private async Task PopulateSitesAsync(CustomerCreditWidgetModel model, int customerId) + { + var addresses = await _customerService.GetAddressesByCustomerIdAsync(customerId); + + var savedJson = await _attributeService + .GetGenericAttributeValueAsync(customerId, FruitBankPluginConst.CustomerSitesAttribute, storeId: 0); + + var saved = ParseSites(savedJson); + var savedById = saved.ToDictionary(s => s.AddressId); + + foreach (var addr in addresses) + { + savedById.TryGetValue(addr.Id, out var entry); + model.SiteAddresses.Add(new CustomerSiteAddressRow + { + AddressId = addr.Id, + Display = FormatAddress(addr), + IsSite = entry != null, + IsDefault = entry?.IsDefault ?? false + }); + } + + var sites = model.SiteAddresses.Where(a => a.IsSite).ToList(); + model.SiteCount = sites.Count; + model.DefaultSiteDisplay = sites.FirstOrDefault(a => a.IsDefault)?.Display; + } + + private static List ParseSites(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(); + try { return JsonSerializer.Deserialize>(json) ?? new(); } + catch { return new(); } + } + + private static string FormatAddress(Address a) + { + var name = !string.IsNullOrWhiteSpace(a.Company) + ? a.Company + : $"{a.FirstName} {a.LastName}".Trim(); + + var locality = $"{a.ZipPostalCode} {a.City}".Trim(); + + var parts = new[] { name, a.Address1, locality } + .Where(p => !string.IsNullOrWhiteSpace(p)); + + var display = string.Join(", ", parts); + return string.IsNullOrWhiteSpace(display) ? $"#{a.Id}" : display; + } } diff --git a/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs index d7dcf78..395fcdf 100644 --- a/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Controllers/FruitBankDataController.cs @@ -342,18 +342,31 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers // Load BEFORE the update to capture previous ProductId and QuantityOnDocument var oldItem = await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, false); - if (!await ctx.UpdateShippingItemSafeAsync(shippingItem)) return null; + // For product swap: move IncomingQuantity BEFORE saving the new ProductId to DB. + // This ensures the EventConsumer (triggered by UpdateShippingItemSafeAsync) already + // sees the correct IncomingQuantity when ConvertPreOrdersForProductsAsync fires. + var productChanged = oldItem != null && oldItem.ProductId != shippingItem.ProductId; + var quantityChanged = oldItem != null && oldItem.QuantityOnDocument != shippingItem.QuantityOnDocument; + + if (productChanged && shippingItem.ProductId.HasValue && + oldItem?.ProductId.HasValue == true && !oldItem.IsMeasured) + { + var qty = oldItem.QuantityOnDocument; + await preorderConversionService.SyncIncomingQuantityAsync(oldItem.ProductId.Value, qty, 0); + await preorderConversionService.SyncIncomingQuantityAsync(shippingItem.ProductId.Value, 0, qty); + } + + if (!await ctx.UpdateShippingItemSafeAsync(shippingItem)) return null; if (oldItem != null) { - var productChanged = oldItem.ProductId != shippingItem.ProductId; - var quantityChanged = oldItem.QuantityOnDocument != shippingItem.QuantityOnDocument; - if (productChanged && shippingItem.ProductId.HasValue) { // Full replacement: swap stock, order items, preorder items + // skipIncomingSync=true because we already moved IncomingQty above await preorderConversionService.ReplaceShippingItemProductAsync( - shippingItem.Id, shippingItem.ProductId.Value, oldItem); + shippingItem.Id, shippingItem.ProductId.Value, oldItem, + skipIncomingSync: !oldItem.IsMeasured); } else if (quantityChanged && shippingItem.ProductId.HasValue) { diff --git a/Nop.Plugin.Misc.AIPlugin/Controllers/OrderDraftApiController.cs b/Nop.Plugin.Misc.AIPlugin/Controllers/OrderDraftApiController.cs new file mode 100644 index 0000000..f917e1f --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Controllers/OrderDraftApiController.cs @@ -0,0 +1,112 @@ +using Microsoft.AspNetCore.Mvc; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Configuration; +using Nop.Web.Framework.Controllers; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers; + +/// +/// Külső rendszerek (Viber bot, email feldolgozó, diktáló app) hívják ezt az endpointot. +/// Authentikáció: X-FruitBank-ApiKey header, értéke megegyezik a FruitBankSettings.OrderDraftApiKey-jel. +/// +public class OrderDraftApiController : BasePluginController +{ + private readonly IOrderDraftService _orderDraftService; + private readonly FruitBankSettings _settings; + + public OrderDraftApiController( + IOrderDraftService orderDraftService, + ISettingService settingService) + { + _orderDraftService = orderDraftService; + _settings = settingService.LoadSetting(); + } + + /// + /// POST /api/order-draft + /// Body: { "externalUsername": "...", "messageText": "..." } + /// Header: X-FruitBank-ApiKey: {key} + /// + [HttpPost] + public async Task Create([FromBody] OrderDraftApiRequest request) + { + // API key validáció + if (!ValidateApiKey()) + return Unauthorized(new { error = "Érvénytelen vagy hiányzó API kulcs." }); + + if (request == null + || string.IsNullOrWhiteSpace(request.ExternalUsername) + || string.IsNullOrWhiteSpace(request.MessageText)) + { + return BadRequest(new { error = "ExternalUsername és MessageText megadása kötelező." }); + } + + try + { + var drafts = await _orderDraftService.CreateDraftsFromMessageAsync( + request.ExternalUsername.Trim(), + request.MessageText.Trim()); + + if (!drafts.Any()) + return Ok(new OrderDraftApiResponse + { + Success = false, + Message = "Az üzenetből nem sikerült rendelési szándékot azonosítani.", + Drafts = [] + }); + + return Ok(new OrderDraftApiResponse + { + Success = true, + Message = $"{drafts.Count} rendelés tervezet létrehozva.", + Drafts = drafts.Select(d => new OrderDraftApiResponseItem + { + DraftId = d.Id, + ParsedDeliveryDate = d.ParsedDeliveryDate, + IsPartnerKnown = d.CustomerId.HasValue, + ItemCount = d.Items?.Count ?? 0 + }).ToList() + }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[OrderDraftApiController] Error: {ex}"); + return StatusCode(500, new { error = "Belső hiba. Próbáld újra később." }); + } + } + + private bool ValidateApiKey() + { + if (string.IsNullOrWhiteSpace(_settings.OrderDraftApiKey)) + return false; + + Request.Headers.TryGetValue("X-FruitBank-ApiKey", out var providedKey); + return string.Equals( + _settings.OrderDraftApiKey, + providedKey.ToString(), + StringComparison.Ordinal); + } +} + +// ── Request / Response modellek ─────────────────────────────────────────── + +public class OrderDraftApiRequest +{ + public string ExternalUsername { get; set; } = string.Empty; + public string MessageText { get; set; } = string.Empty; +} + +public class OrderDraftApiResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public List Drafts { get; set; } = []; +} + +public class OrderDraftApiResponseItem +{ + public int DraftId { get; set; } + public DateTime ParsedDeliveryDate { get; set; } + public bool IsPartnerKnown { get; set; } + public int ItemCount { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ExternalUserPartnerMapDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ExternalUserPartnerMapDbTable.cs new file mode 100644 index 0000000..04c21a9 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ExternalUserPartnerMapDbTable.cs @@ -0,0 +1,24 @@ +using FruitBank.Common.Entities; +using LinqToDB; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; +using Mango.Nop.Core.Loggers; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class ExternalUserPartnerMapDbTable : MgDbTableBase +{ + public ExternalUserPartnerMapDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + public Task GetByUsernameAsync(string externalUsername) + => GetAll().FirstOrDefaultAsync(m => m.ExternalUsername == externalUsername); + + public IQueryable GetByCustomerId(int customerId) + => GetAll().Where(m => m.CustomerId == customerId); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs index 4186529..dec7e6a 100644 --- a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs @@ -70,6 +70,11 @@ public class FruitBankDbContext : MgDbContextBase, public StockQuantityHistoryDtoDbTable StockQuantityHistoryDtos { get; set; } public CustomerCreditDbTable CustomerCredits { get; set; } + public OrderDraftDbTable OrderDrafts { get; set; } + public OrderDraftItemDbTable OrderDraftItems { get; set; } + public ExternalUserPartnerMapDbTable ExternalUserPartnerMaps { get; set; } + public ProductTextMappingDbTable ProductTextMappings { get; set; } + public IRepository Customers { get; set; } public IRepository CustomerRoles { get; set; } public IRepository CustomerRoleMappings { get; set; } @@ -85,6 +90,8 @@ public class FruitBankDbContext : MgDbContextBase, ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable, ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable, StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable, + OrderDraftDbTable orderDraftDbTable, OrderDraftItemDbTable orderDraftItemDbTable, + ExternalUserPartnerMapDbTable externalUserPartnerMapDbTable, ProductTextMappingDbTable productTextMappingDbTable, IProductService productService, IStaticCacheManager staticCacheManager, IRepository orderRepository, IRepository orderItemRepository, @@ -135,6 +142,11 @@ public class FruitBankDbContext : MgDbContextBase, StockQuantityHistoriesExt = stockQuantityHistoriesExt; StockQuantityHistoryDtos = stockQuantityHistoryDtos; CustomerCredits = customerCreditDbTable; + + OrderDrafts = orderDraftDbTable; + OrderDraftItems = orderDraftItemDbTable; + ExternalUserPartnerMaps = externalUserPartnerMapDbTable; + ProductTextMappings = productTextMappingDbTable; } public IQueryable GetCustomersBySystemRoleName(string systemRoleName) diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftDbTable.cs new file mode 100644 index 0000000..abbe589 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftDbTable.cs @@ -0,0 +1,36 @@ +using FruitBank.Common.Entities; +using FruitBank.Common.Enums; +using LinqToDB; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; +using Mango.Nop.Core.Loggers; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class OrderDraftDbTable : MgDbTableBase +{ + public OrderDraftDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + public IQueryable GetAll(bool loadRelations) + { + return loadRelations + ? GetAll().LoadWith(d => d.Items) + : GetAll(); + } + + public Task GetByIdAsync(int id, bool loadRelations) + => GetAll(loadRelations).FirstOrDefaultAsync(d => d.Id == id); + + public IQueryable GetPending() + => GetAll().Where(d => d.Status == OrderDraftStatus.Pending); + + public IQueryable GetExpiredCandidates() + => GetAll().Where(d => d.Status == OrderDraftStatus.Pending + && d.CreatedOnUtc <= DateTime.UtcNow.AddDays(-14)); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftItemDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftItemDbTable.cs new file mode 100644 index 0000000..1291d40 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/OrderDraftItemDbTable.cs @@ -0,0 +1,21 @@ +using FruitBank.Common.Entities; +using LinqToDB; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; +using Mango.Nop.Core.Loggers; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class OrderDraftItemDbTable : MgDbTableBase +{ + public OrderDraftItemDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + public IQueryable GetByDraftId(int draftId) + => GetAll().Where(i => i.DraftId == draftId).OrderBy(i => i.SortOrder); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ProductTextMappingDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ProductTextMappingDbTable.cs new file mode 100644 index 0000000..38c6095 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/ProductTextMappingDbTable.cs @@ -0,0 +1,27 @@ +using FruitBank.Common.Entities; +using LinqToDB; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; +using Mango.Nop.Core.Loggers; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class ProductTextMappingDbTable : MgDbTableBase +{ + public ProductTextMappingDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + public IQueryable GetByRawText(string rawText) + => GetAll().Where(m => m.RawText == rawText.ToLower().Trim()); + + public IQueryable GetByRawText(string rawText, bool isPreorder) + => GetByRawText(rawText).Where(m => m.IsPreorder == isPreorder); + + public IQueryable GetRecentByRawText(string rawText, bool isPreorder, int lastCount = 5) + => GetByRawText(rawText, isPreorder).OrderByDescending(m => m.CreatedOnUtc).Take(lastCount); +} diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs index 3961f51..e70f701 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankConst.cs @@ -25,5 +25,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin /// that delivery date, and its document processing will be the correct trigger. /// public const int PreOrderConversionWindowDays = 4; + + // ── Customer generic-attribute keys (store-agnostic, StoreId 0) ────────── + /// Bool flag: the customer is subject to EKÁER reporting. + public const string IsEkaerAttribute = "isEkaer"; + + /// JSON list of the customer's site (telephely) addresses — see CustomerSiteEntry. + public const string CustomerSitesAttribute = "customerSites"; } } diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs index 85696c6..abe0783 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs @@ -352,6 +352,32 @@ namespace Nop.Plugin.Misc.FruitBankPlugin await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus", "Fizet\u00e9si \u00e1llapot", hu); await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "Total", en); await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "\u00d6sszesen", hu); + // ── Customer attributes widget (generic attributes block) ─────────── + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title", "Customer attributes", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title", "Ügyfél jellemzők", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer", "EKÁER required", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer", "Ekáer köteles", hu); + + // ── Customer sites (telephelyek) ──────────────────────────────────── + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Title", "Sites", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Title", "Telephelyek", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Edit", "Edit sites", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Edit", "Telephelyek szerkesztése", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord", "site(s)", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord", "telephely", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel", "Default", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel", "Alapértelmezett", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel", "Site", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel", "Telephely", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel", "Address", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel", "Cím", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.None", "No sites set", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.None", "Nincs telephely megadva", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle", "Select sites", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle", "Telephelyek kiválasztása", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses", "This customer has no addresses yet (Addresses tab).", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses", "Ehhez az ügyfélhez még nincs cím rögzítve a Címek fülön.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked", "Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.", en); await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked", "A rendel\u00e9st nem lehet leadni, mert a kintlév\u0151 egyenlege el\u00e9rte a hitelkeret\u00e9t. K\u00e9rj\u00fck, el\u0151sz\u00f6r rendezze meglév\u0151 tartoz\u00e1s\u00e1t.", hu); diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankSettings.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankSettings.cs index 3297b36..60e6117 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankSettings.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankSettings.cs @@ -67,6 +67,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin /// Z.ai GLM-OCR modell neve (default: "glm-ocr"). /// public string ZaiModel { get; set; } = "glm-ocr"; + + // ── OrderDraft API ──────────────────────────────────────────────────────────── + + /// + /// API kulcs a külső rendelés tervezet endpointhoz (X-FruitBank-ApiKey header). + /// Viber/email/diktálás integrációkhoz. + /// + public string OrderDraftApiKey { get; set; } = string.Empty; } } diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs index 9c11b76..0a356bc 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs @@ -103,6 +103,14 @@ public class PluginNopStartup : INopStartup services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); @@ -229,7 +237,7 @@ public class PluginNopStartup : INopStartup }) .AddAcBinaryProtocol(opts => { - opts.ProtocolMode = BinaryProtocolMode.AsyncSegment; + opts.ProtocolMode = BinaryProtocolMode.Bytes; opts.FlushPolicy = FlushPolicy.Coalesced; // Explicit AcLogger instance (name-based category, matches the previous setup). diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs index fe27521..1c1df59 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs @@ -378,6 +378,48 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.QuickOrder.GetDeliveryDateTime", pattern: "gyors-rendeles/szallitas-idopont-lekerdezes", defaults: new { controller = "QuickOrder", action = "GetDeliveryDateTime" }); + + // ── API: OrderDraft (külső rendszerek: Viber, email, diktálás) ───────────── + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Api.OrderDraft.Create", + pattern: "api/order-draft", + defaults: new { controller = "OrderDraftApi", action = "Create" }); + + // ── Admin: OrderDraft ───────────────────────────────────────────────── + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.List", + pattern: "Admin/OrderDraft", + defaults: new { controller = "OrderDraftAdmin", action = "List", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.OrderDraftList", + pattern: "Admin/OrderDraft/OrderDraftList", + defaults: new { controller = "OrderDraftAdmin", action = "OrderDraftList", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.Detail", + pattern: "Admin/OrderDraft/Detail/{id:int}", + defaults: new { controller = "OrderDraftAdmin", action = "Detail", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.UpdateItem", + pattern: "Admin/OrderDraft/UpdateItem", + defaults: new { controller = "OrderDraftAdmin", action = "UpdateItem", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.AssignCustomer", + pattern: "Admin/OrderDraft/AssignCustomer", + defaults: new { controller = "OrderDraftAdmin", action = "AssignCustomer", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.Approve", + pattern: "Admin/OrderDraft/Approve", + defaults: new { controller = "OrderDraftAdmin", action = "Approve", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.OrderDraft.Reject", + pattern: "Admin/OrderDraft/Reject", + defaults: new { controller = "OrderDraftAdmin", action = "Reject", area = AreaNames.ADMIN }); } /// diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml index caefd73..515bf8f 100644 --- a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml +++ b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml @@ -81,4 +81,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml index 8bfe77d..260d54f 100644 --- a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml +++ b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml @@ -81,4 +81,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs b/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs index 7570ed7..b5adbb9 100644 --- a/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs +++ b/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs @@ -18,4 +18,26 @@ public record CustomerCreditWidgetModel : BaseNopModel public decimal? RemainingCredit { get; set; } public string? Comment { get; set; } + + // ── Generic attributes block (rendered above the credit card) ────────────── + [NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer")] + public bool IsEkaer { get; set; } + + // ── Sites (telephelyek) ──────────────────────────────────────────────────── + /// All current customer addresses, flagged with site/default state — feeds the edit modal. + public List SiteAddresses { get; set; } = new(); + + /// Number of addresses currently marked as sites. + public int SiteCount { get; set; } + + /// Display string of the default site, if any. + public string? DefaultSiteDisplay { get; set; } +} + +public class CustomerSiteAddressRow +{ + public int AddressId { get; set; } + public string Display { get; set; } = string.Empty; + public bool IsSite { get; set; } + public bool IsDefault { get; set; } } diff --git a/Nop.Plugin.Misc.AIPlugin/Models/CustomerSiteEntry.cs b/Nop.Plugin.Misc.AIPlugin/Models/CustomerSiteEntry.cs new file mode 100644 index 0000000..9784b74 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Models/CustomerSiteEntry.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Models; + +/// +/// One entry in a customer's site (telephely) list, stored as JSON in the +/// customerSites generic attribute. References an existing customer +/// Address by id; extensible with per-site fields (e.g. Note) later. +/// +public class CustomerSiteEntry +{ + [JsonPropertyName("addressId")] + public int AddressId { get; set; } + + [JsonPropertyName("isDefault")] + public bool IsDefault { get; set; } + + [JsonPropertyName("note")] + public string? Note { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/IOrderDraftService.cs b/Nop.Plugin.Misc.AIPlugin/Services/IOrderDraftService.cs new file mode 100644 index 0000000..36f789f --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/IOrderDraftService.cs @@ -0,0 +1,56 @@ +using FruitBank.Common.Entities; +using FruitBank.Common.Enums; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +public interface IOrderDraftService +{ + /// + /// Egy bejövő szöveges üzenet alapján létrehozza a rendelés tervezet(eke)t. + /// Ha az üzenetben több különböző szállítási dátum szerepel, minden dátumhoz külön draft jön létre. + /// + Task> CreateDraftsFromMessageAsync(string externalUsername, string rawMessageText); + + /// Egy draft betöltése tételeivel együtt. + Task GetByIdAsync(int id, bool loadRelations = true); + + /// Pending draftek listája, opcionálisan szűrve partner szerint. + Task> GetPendingAsync(int? customerId = null); + + /// Összes draft lapozhatóan (admin lista). + Task<(List Items, int TotalCount)> GetPagedAsync( + int pageIndex, int pageSize, + OrderDraftStatus? status = null, + int? customerId = null); + + /// + /// Admin jóváhagyja a draftot: minden tétel SelectedProductId-ja be kell legyen állítva. + /// A dátumtól függően PreOrder vagy Order jön létre. + /// Menti a ProductTextMapping rekordokat a tanuláshoz. + /// + Task ApproveAsync(int draftId, int adminId); + + /// Admin elutasítja a draftot. + Task RejectAsync(int draftId, int adminId, string? adminNote = null); + + /// Admin hozzárendeli a partnert egy mappingból még hiányzó drafthoz. + Task AssignCustomerAsync(int draftId, int customerId, int adminId); + + /// Admin frissíti egy tétel kiválasztott termékét és/vagy mennyiségét. + Task UpdateDraftItemAsync(int draftItemId, int selectedProductId, int quantity); + + /// 14 napnál régebbi pending draftek automatikus lejáratása (background job hívja). + Task ExpireOldDraftsAsync(); +} + +public class OrderDraftApproveResult +{ + public bool Success { get; set; } + public string? ErrorMessage { get; set; } + + /// A létrehozott PreOrder ID-k (ha előrendelés lett). + public List PreOrderIds { get; set; } = []; + + /// A létrehozott Order ID-k (ha azonnali rendelés lett). + public List OrderIds { get; set; } = []; +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftParserService.cs b/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftParserService.cs new file mode 100644 index 0000000..87475de --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftParserService.cs @@ -0,0 +1,230 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +/// +/// Szöveges üzenetet parse-ol: kinyeri a szállítási dátumokat és a termékigényeket. +/// Ha egy üzenetben több dátum szerepel, minden dátumhoz külön ParsedDraftGroup-ot ad vissza. +/// +public class OrderDraftParserService +{ + private readonly OpenAIApiService _openAiService; + + // Hány napnál távolabbi dátum számít előrendelésnek + public const int PreorderThresholdDays = 4; + + public OrderDraftParserService(OpenAIApiService openAiService) + { + _openAiService = openAiService; + } + + /// + /// Egy raw üzenetből kinyeri a (dátum → termékigények) csoportokat. + /// Minden visszaadott csoport egy külön OrderDraft-nak felel meg. + /// + public async Task> ParseMessageAsync(string rawMessage) + { + var systemPrompt = """ + Te egy magyar gyümölcs- és zöldségnagyker rendelési asszisztense vagy. + Egy beérkező szöveges üzenetből ki kell nyerned a rendelési szándékokat. + + FELADATOD: + 1. Azonosítsd az összes szállítási dátumot az üzenetben. + 2. Csoportosítsd a termékigényeket dátum szerint. + 3. Ha egy tételhez nincs explicit dátum megadva, rendeld a legközelebbi említett dátumhoz. + 4. Ha egyáltalán nincs dátum az üzenetben, használd a mai napot + 1 nap értéket. + 5. A mennyiségnél mindig rekeszben gondolkodj (ha nem egyértelmű, rekesz az alapértelmezett). + 6. A termék nevét pontosan úgy add vissza, ahogy az üzenetben szerepel (ne fordítsd, ne értelmezd). + + VÁLASZ FORMÁTUM (csak JSON, semmi más): + { + "groups": [ + { + "deliveryDate": "YYYY-MM-DD", + "items": [ + { + "rawProductText": "az eredeti szöveg ahogy szerepelt", + "requestedQuantity": 10, + "sortOrder": 1 + } + ] + } + ] + } + + FONTOS: + - Csak JSON-t adj vissza, semmilyen magyarázatot, bevezető szöveget vagy markdown blokkot ne írj. + - Ha a dátum relatív (pl. "jövő héten", "jövő szerdán", "holnap"), számítsd ki a tényleges dátumot a mai naphoz képest. + - Mai dátum: + """ + " " + DateTime.Today.ToString("yyyy-MM-dd"); + + var userPrompt = $"Üzenet:\n{rawMessage}"; + + var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, userPrompt); + if (string.IsNullOrWhiteSpace(response)) + return []; + + try + { + // Tisztítjuk a markdown code fences-t ha mégis beleírta + var clean = response + .Replace("```json", "") + .Replace("```", "") + .Trim(); + + var parsed = JsonSerializer.Deserialize(clean, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return parsed?.Groups ?? []; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[OrderDraftParser] JSON parse error: {ex.Message}\nRaw response: {response}"); + return []; + } + } + + /// + /// Egy termék raw szövegéből meghatározza a kanonikus terméknevet és + /// a NopCommerce kereséshez használható kulcsszavakat. + /// + public async Task ResolveProductNameAsync(string rawProductText) + { + var systemPrompt = """ + Te egy magyar gyümölcs- és zöldségnagyker asszisztense vagy. + Egy vevő által írt termékmegnevezésből kell meghatároznod: + 1. Mi a termék pontos, kanonikus neve magyarul (pl. "piros kalif" → "Kaliforniai paprika piros") + 2. Milyen kulcsszavakkal érdemes keresni a termékadatbázisban (pl. ["paprika", "kaliforniai", "piros"]) + + VÁLASZ FORMÁTUM (csak JSON): + { + "canonicalName": "Kaliforniai paprika piros", + "searchKeywords": ["paprika", "kaliforniai", "piros"] + } + + Csak JSON-t adj vissza. + """; + + var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, rawProductText); + if (string.IsNullOrWhiteSpace(response)) + return new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] }; + + try + { + var clean = response.Replace("```json", "").Replace("```", "").Trim(); + var hint = JsonSerializer.Deserialize(clean, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + return hint ?? new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] }; + } + catch + { + return new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] }; + } + } + + /// + /// Több termék ID-t sorrendbe rendez az AI segítségével a raw szöveg és a terméknevek alapján. + /// A historikus mapping által már előre rangsorolt lista végső finomhangolása. + /// + public async Task> RankCandidatesAsync( + string rawProductText, + string canonicalName, + List<(int Id, string Name)> candidates) + { + if (candidates.Count <= 1) + return candidates.Select(c => c.Id).ToList(); + + var candidateList = candidates + .Select((c, i) => $"{i + 1}. ID={c.Id} Név=\"{c.Name}\"") + .Aggregate(new StringBuilder(), (sb, s) => sb.AppendLine(s)) + .ToString(); + + var systemPrompt = """ + Te egy magyar gyümölcs- és zöldségnagyker asszisztense vagy. + Rendezd sorrendbe a termékkandidátusokat aszerint, hogy melyik felel meg legjobban a keresett terméknek. + Az első legyen a legjobb találat. + + VÁLASZ FORMÁTUM (csak JSON, semmi más): + { "rankedIds": [123, 456, 789] } + """; + + var userPrompt = $""" + Keresett termék (eredeti szöveg): "{rawProductText}" + Kanonikus név: "{canonicalName}" + + Kandid\u00e1tusok: + {candidateList} + """; + + var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, userPrompt); + if (string.IsNullOrWhiteSpace(response)) + return candidates.Select(c => c.Id).ToList(); + + try + { + var clean = response.Replace("```json", "").Replace("```", "").Trim(); + using var doc = JsonDocument.Parse(clean); + var ranked = doc.RootElement + .GetProperty("rankedIds") + .EnumerateArray() + .Select(e => e.GetInt32()) + .ToList(); + + // Csak valódi kandidátus ID-kat tartunk meg, a maradékot hozzáfűzzük + var validIds = ranked.Where(id => candidates.Any(c => c.Id == id)).ToList(); + var missing = candidates.Select(c => c.Id).Except(validIds).ToList(); + return [.. validIds, .. missing]; + } + catch + { + return candidates.Select(c => c.Id).ToList(); + } + } +} + +// ── DTO-k ────────────────────────────────────────────────────────────────── + +public class ParsedMessageResponse +{ + [JsonPropertyName("groups")] + public List Groups { get; set; } = []; +} + +public class ParsedDraftGroup +{ + [JsonPropertyName("deliveryDate")] + public string DeliveryDate { get; set; } = string.Empty; + + [JsonPropertyName("items")] + public List Items { get; set; } = []; + + public DateTime GetDeliveryDateTime() + => DateTime.TryParse(DeliveryDate, out var dt) ? dt : DateTime.Today.AddDays(1); +} + +public class ParsedDraftItem +{ + [JsonPropertyName("rawProductText")] + public string RawProductText { get; set; } = string.Empty; + + [JsonPropertyName("requestedQuantity")] + public int RequestedQuantity { get; set; } = 1; + + [JsonPropertyName("sortOrder")] + public int SortOrder { get; set; } +} + +public class ProductResolutionHint +{ + [JsonPropertyName("canonicalName")] + public string CanonicalName { get; set; } = string.Empty; + + [JsonPropertyName("searchKeywords")] + public List SearchKeywords { get; set; } = []; +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftService.cs b/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftService.cs new file mode 100644 index 0000000..d5099f0 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/OrderDraftService.cs @@ -0,0 +1,392 @@ +using FruitBank.Common.Entities; +using FruitBank.Common.Enums; +using LinqToDB; +using Nop.Core.Domain.Catalog; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Services.Catalog; +using System.Threading; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +public class OrderDraftService : IOrderDraftService +{ + private readonly FruitBankDbContext _dbContext; + private readonly PreOrderDbContext _preOrderDbContext; + private readonly OrderDraftParserService _parserService; + private readonly IProductService _productService; + private readonly PreOrderConversionService _preOrderConversionService; + + public OrderDraftService( + FruitBankDbContext dbContext, + PreOrderDbContext preOrderDbContext, + OrderDraftParserService parserService, + IProductService productService, + PreOrderConversionService preOrderConversionService) + { + _dbContext = dbContext; + _preOrderDbContext = preOrderDbContext; + _parserService = parserService; + _productService = productService; + _preOrderConversionService = preOrderConversionService; + } + + // ── Létrehozás ──────────────────────────────────────────────────────────── + + public async Task> CreateDraftsFromMessageAsync(string externalUsername, string rawMessageText) + { + // 1. Partner azonosítás + var mapping = await _dbContext.ExternalUserPartnerMaps.GetByUsernameAsync(externalUsername); + + // 2. AI parse: dátumok + termékek kinyerése + var parsedGroups = await _parserService.ParseMessageAsync(rawMessageText); + if (!parsedGroups.Any()) + return []; + + var now = DateTime.UtcNow; + var createdDrafts = new List(); + + foreach (var group in parsedGroups) + { + var deliveryDate = group.GetDeliveryDateTime(); + var isPreorder = deliveryDate.Date > now.Date.AddDays(OrderDraftParserService.PreorderThresholdDays); + + var draft = new OrderDraft + { + ExternalUsername = externalUsername, + CustomerId = mapping?.CustomerId, + RawMessageText = rawMessageText, + ParsedDeliveryDate = deliveryDate, + Status = OrderDraftStatus.Pending, + CreatedOnUtc = now + }; + + await _dbContext.OrderDrafts.InsertAsync(draft); + + // 3. Tételek feldolgozása + var sortOrder = 0; + var itemTasks = group.Items.Select(parsedItem => ProcessDraftItemAsync( + draft.Id, parsedItem, isPreorder, sortOrder++)).ToList(); + + var items = await Task.WhenAll(itemTasks); + draft.Items = items.ToList(); + + createdDrafts.Add(draft); + + Console.WriteLine($"[OrderDraftService] Created draft #{draft.Id} for '{externalUsername}', " + + $"deliveryDate={deliveryDate:yyyy-MM-dd}, isPreorder={isPreorder}, " + + $"items={items.Length}, customerId={mapping?.CustomerId.ToString() ?? "null"}"); + } + + return createdDrafts; + } + + private async Task ProcessDraftItemAsync( + int draftId, ParsedDraftItem parsed, bool isPreorder, int sortOrder) + { + // 1. Historikus súlyozás: utolsó 5 admin döntés ugyanerre a szövegre + var historicMappings = await _dbContext.ProductTextMappings + .GetRecentByRawText(parsed.RawProductText, isPreorder, 5) + .ToListAsync(); + + var historicProductIds = historicMappings.Select(m => m.ResolvedProductId).Distinct().ToList(); + + // 2. AI: kanonikus név + kulcsszavak + var hint = await _parserService.ResolveProductNameAsync(parsed.RawProductText); + + // 3. Termékkeresés az összes kulcsszóra — párhuzamosan + var searchTasks = hint.SearchKeywords + .Select(kw => _productService.SearchProductsAsync(keywords: kw, visibleIndividuallyOnly: true, pageSize: 10)) + .ToList(); + + var searchResults = await Task.WhenAll(searchTasks); + var allFoundProducts = searchResults + .SelectMany(r => r) + .DistinctBy(p => p.Id) + .ToList(); + + // 4. Sorrendezés: historikus találatok elől, majd AI ranking + List rankedIds; + if (allFoundProducts.Count == 0) + { + rankedIds = historicProductIds; + } + else + { + // Historikus ID-k kerülnek előre ha az aktuális keresésben is szerepelnek + var foundIds = allFoundProducts.Select(p => p.Id).ToList(); + var historicAndFound = historicProductIds.Where(id => foundIds.Contains(id)).ToList(); + var restFound = foundIds.Except(historicAndFound).ToList(); + + // Ha több mint 1 nem-historikus találat van, AI rangsorolja + if (restFound.Count > 1) + { + var candidates = allFoundProducts + .Where(p => restFound.Contains(p.Id)) + .Select(p => (p.Id, p.Name)) + .ToList(); + + var aiRanked = await _parserService.RankCandidatesAsync( + parsed.RawProductText, hint.CanonicalName, candidates); + + rankedIds = [.. historicAndFound, .. aiRanked]; + } + else + { + rankedIds = [.. historicAndFound, .. restFound]; + } + } + + var item = new OrderDraftItem + { + DraftId = draftId, + RawProductText = parsed.RawProductText, + AiResolvedProductName = hint.CanonicalName, + SuggestedProductIdsJson = System.Text.Json.JsonSerializer.Serialize(rankedIds), + SelectedProductId = rankedIds.Count > 0 ? rankedIds[0] : null, // első javaslat előre kiválasztva + RequestedQuantity = parsed.RequestedQuantity > 0 ? parsed.RequestedQuantity : 1, + IsPreorder = isPreorder, + SortOrder = sortOrder + }; + + await _dbContext.OrderDraftItems.InsertAsync(item); + return item; + } + + // ── Lekérdezések ────────────────────────────────────────────────────────── + + public Task GetByIdAsync(int id, bool loadRelations = true) + => _dbContext.OrderDrafts.GetByIdAsync(id, loadRelations); + + public async Task> GetPendingAsync(int? customerId = null) + { + var query = _dbContext.OrderDrafts.GetPending(); + if (customerId.HasValue) + query = query.Where(d => d.CustomerId == customerId.Value); + return await query.OrderByDescending(d => d.CreatedOnUtc).ToListAsync(); + } + + public async Task<(List Items, int TotalCount)> GetPagedAsync( + int pageIndex, int pageSize, + OrderDraftStatus? status = null, + int? customerId = null) + { + var query = _dbContext.OrderDrafts.GetAll(false); + + if (status.HasValue) + query = query.Where(d => d.Status == status.Value); + + if (customerId.HasValue) + query = query.Where(d => d.CustomerId == customerId.Value); + + query = query.OrderByDescending(d => d.CreatedOnUtc); + + var totalCount = await query.CountAsync(CancellationToken.None); + var items = await query + .Skip(pageIndex * pageSize) + .Take(pageSize) + .ToListAsync(); + + return (items, totalCount); + } + + // ── Admin műveletek ─────────────────────────────────────────────────────── + + public async Task ApproveAsync(int draftId, int adminId) + { + var result = new OrderDraftApproveResult(); + + var draft = await GetByIdAsync(draftId, true); + if (draft == null) + { + result.ErrorMessage = $"Draft #{draftId} nem található."; + return result; + } + + if (draft.Status != OrderDraftStatus.Pending) + { + result.ErrorMessage = $"Draft #{draftId} már le van zárva (státusz: {draft.Status})."; + return result; + } + + if (!draft.CustomerId.HasValue) + { + result.ErrorMessage = "Jóváhagyás előtt rendelj hozzá partnert a drafthoz."; + return result; + } + + var unresolved = draft.Items.Where(i => i.SelectedProductId == null).ToList(); + if (unresolved.Any()) + { + result.ErrorMessage = $"{unresolved.Count} tétel nincs kitöltve. Minden sorban ki kell választani a terméket."; + return result; + } + + try + { + // Tételek szétválasztása: preorder vs. azonnali + var preorderItems = draft.Items.Where(i => i.IsPreorder).ToList(); + var orderItems = draft.Items.Where(i => !i.IsPreorder).ToList(); + + // Tanulási rekordok mentése (párhuzamosan) + var mappingTasks = draft.Items.Select(item => SaveProductTextMappingAsync(item, adminId)); + await Task.WhenAll(mappingTasks); + + // Előrendelések létrehozása + if (preorderItems.Any()) + { + var preorderNopItems = preorderItems.Select(i => new PreOrderItem + { + ProductId = i.SelectedProductId!.Value, + RequestedQuantity = i.RequestedQuantity, + FulfilledQuantity = 0, + UnitPriceInclTax = 0m, // Árat a konverziókor számítja a service + Status = PreOrderItemStatus.Pending + }).ToList(); + + var preorder = new PreOrder + { + CustomerId = draft.CustomerId!.Value, + StoreId = 1, + DateOfReceipt = draft.ParsedDeliveryDate, + CustomerNote = $"OrderDraft #{draft.Id} alapján létrehozva" + }; + + var saved = await _preOrderDbContext.InsertPreOrderAsync(preorder, preorderNopItems); + result.PreOrderIds.Add(saved.Id); + + // Azonnali konverzió ha van raktáron + await _preOrderConversionService.ConvertPreOrdersForProductsAsync( + preorderNopItems.Select(i => i.ProductId).ToList(), 0); + } + + // Azonnali rendelések: a PreOrderConversionService kezeli ha létrehozunk egy + // preordert aminek dátuma <= 4 nap — itt is ugyanazt a flow-t használjuk + if (orderItems.Any()) + { + var orderNopItems = orderItems.Select(i => new PreOrderItem + { + ProductId = i.SelectedProductId!.Value, + RequestedQuantity = i.RequestedQuantity, + FulfilledQuantity = 0, + UnitPriceInclTax = 0m, + Status = PreOrderItemStatus.Pending + }).ToList(); + + var immediatePreorder = new PreOrder + { + CustomerId = draft.CustomerId!.Value, + StoreId = 1, + DateOfReceipt = draft.ParsedDeliveryDate, + CustomerNote = $"OrderDraft #{draft.Id} alapján létrehozva (azonnali)" + }; + + var saved = await _preOrderDbContext.InsertPreOrderAsync(immediatePreorder, orderNopItems); + + // Az azonnali rendeléseknél inline futtatjuk a konverziót + await _preOrderConversionService.ConvertPreOrdersForProductsAsync( + orderNopItems.Select(i => i.ProductId).ToList(), 0); + + // Ha lett belőle rendelés, azt is visszaadjuk + var converted = await _preOrderDbContext.PreOrders.GetByIdAsync(saved.Id); + if (converted?.OrderId.HasValue == true) + result.OrderIds.Add(converted.OrderId.Value); + else + result.PreOrderIds.Add(saved.Id); + } + + // Draft lezárása + draft.Status = OrderDraftStatus.Approved; + draft.ResolvedByAdminId = adminId; + draft.ResolvedOnUtc = DateTime.UtcNow; + await _dbContext.OrderDrafts.UpdateAsync(draft); + + result.Success = true; + + Console.WriteLine($"[OrderDraftService] Draft #{draftId} approved by admin #{adminId}. " + + $"PreOrders: [{string.Join(",", result.PreOrderIds)}], " + + $"Orders: [{string.Join(",", result.OrderIds)}]"); + } + catch (Exception ex) + { + result.ErrorMessage = $"Hiba a jóváhagyás során: {ex.Message}"; + Console.Error.WriteLine($"[OrderDraftService] ApproveAsync error for draft #{draftId}: {ex}"); + } + + return result; + } + + public async Task RejectAsync(int draftId, int adminId, string? adminNote = null) + { + var draft = await GetByIdAsync(draftId, false); + if (draft == null || draft.Status != OrderDraftStatus.Pending) return; + + draft.Status = OrderDraftStatus.Rejected; + draft.ResolvedByAdminId = adminId; + draft.ResolvedOnUtc = DateTime.UtcNow; + draft.AdminNote = adminNote; + await _dbContext.OrderDrafts.UpdateAsync(draft); + + Console.WriteLine($"[OrderDraftService] Draft #{draftId} rejected by admin #{adminId}."); + } + + public async Task AssignCustomerAsync(int draftId, int customerId, int adminId) + { + var draft = await GetByIdAsync(draftId, false); + if (draft == null) return; + + // Mapping mentése a jövőre + var existingMapping = await _dbContext.ExternalUserPartnerMaps.GetByUsernameAsync(draft.ExternalUsername); + if (existingMapping == null || existingMapping.CustomerId != customerId) + { + await _dbContext.ExternalUserPartnerMaps.InsertAsync(new ExternalUserPartnerMapping + { + ExternalUsername = draft.ExternalUsername, + CustomerId = customerId, + CreatedByAdminId = adminId, + CreatedOnUtc = DateTime.UtcNow + }); + } + + draft.CustomerId = customerId; + await _dbContext.OrderDrafts.UpdateAsync(draft); + } + + public async Task UpdateDraftItemAsync(int draftItemId, int selectedProductId, int quantity) + { + var item = await _dbContext.OrderDraftItems.GetByIdAsync(draftItemId); + if (item == null) return; + + item.SelectedProductId = selectedProductId; + if (quantity > 0) item.RequestedQuantity = quantity; + await _dbContext.OrderDraftItems.UpdateAsync(item); + } + + public async Task ExpireOldDraftsAsync() + { + var candidates = await _dbContext.OrderDrafts.GetExpiredCandidates().ToListAsync(); + foreach (var draft in candidates) + { + draft.Status = OrderDraftStatus.Expired; + await _dbContext.OrderDrafts.UpdateAsync(draft); + } + + if (candidates.Any()) + Console.WriteLine($"[OrderDraftService] Expired {candidates.Count} old drafts."); + } + + // ── Privát segédek ──────────────────────────────────────────────────────── + + private async Task SaveProductTextMappingAsync(OrderDraftItem item, int adminId) + { + if (item.SelectedProductId == null) return; + + await _dbContext.ProductTextMappings.InsertAsync(new ProductTextMapping + { + RawText = item.RawProductText.ToLower().Trim(), + ResolvedProductId = item.SelectedProductId.Value, + IsPreorder = item.IsPreorder, + SelectedByAdminId = adminId, + CreatedOnUtc = DateTime.UtcNow + }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.Replace.cs b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.Replace.cs index 8276df7..140ea66 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.Replace.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.Replace.cs @@ -27,7 +27,8 @@ public partial class PreOrderConversionService public async Task ReplaceShippingItemProductAsync( int shippingItemId, int newProductId, - ShippingItem oldItem) + ShippingItem oldItem, + bool skipIncomingSync = false) { if (oldItem.ProductId == null || oldItem.ProductId.Value == newProductId) return; @@ -52,9 +53,13 @@ public partial class PreOrderConversionService // ── Stock / IncomingQuantity swap ───────────────────────────────────── if (!isMeasured) { - // Item not yet on the truck — only IncomingQuantity moves - await SyncIncomingQuantityAsync(oldProductId, qty, 0); - await SyncIncomingQuantityAsync(newProductId, 0, qty); + if (!skipIncomingSync) + { + // Item not yet on the truck — only IncomingQuantity moves + // (skipped if already done before UpdateShippingItemSafeAsync to avoid race) + await SyncIncomingQuantityAsync(oldProductId, qty, 0); + await SyncIncomingQuantityAsync(newProductId, 0, qty); + } } else { diff --git a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs index 686659b..bed377c 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/PreorderConversionService.cs @@ -45,6 +45,13 @@ public partial class PreOrderConversionService private readonly FruitBankOrderItemService _orderItemService; private readonly IStoreContext _storeContext; + // Per-preorder async locks — static so they're shared across all scoped instances + private static readonly System.Collections.Concurrent.ConcurrentDictionary + _preorderLocks = new(); + + private static SemaphoreSlim GetPreorderLock(int preorderId) => + _preorderLocks.GetOrAdd(preorderId, _ => new SemaphoreSlim(1, 1)); + public PreOrderConversionService( PreOrderDbContext preorderDbContext, FruitBankDbContext dbContext, @@ -183,19 +190,37 @@ public partial class PreOrderConversionService .Where(i => i.Status == PreOrderItemStatus.Dropped) .ToList(); - if (preorder.OrderId == null) + // Acquire a per-preorder lock BEFORE checking OrderId. + // Without this, 3 concurrent conversions (one per shipping item event) + // all read OrderId=null and each creates a separate order. + // Re-reading inside the lock ensures only one creates the order. + var sem = GetPreorderLock(preorderId); + await sem.WaitAsync(); + try { - // First time any items are resolved → create the order - if (newlyFulfilled.Any() || newlyDropped.Any()) + var preorderLocked = await _preorderDbContext.PreOrders.GetByIdAsync(preorderId); + if (preorderLocked == null) continue; + + var newlyFulfilled2 = changedItems + .Where(i => i.FulfilledQuantity > 0 && + (i.Status == PreOrderItemStatus.Fulfilled || + i.Status == PreOrderItemStatus.PartiallyFulfilled)) + .ToList(); + var newlyDropped2 = changedItems + .Where(i => i.Status == PreOrderItemStatus.Dropped) + .ToList(); + + if (preorderLocked.OrderId == null) { - await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId); + if (newlyFulfilled2.Any() || newlyDropped2.Any()) + await CreateOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId); + } + else + { + await AppendItemsToOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId); } } - else - { - // Order already exists from a previous document → append new items only - await AppendItemsToOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId); - } + finally { sem.Release(); } } Console.WriteLine($"[PreOrderConversion] Done. {newlyResolvedByPreOrder.Count} preorders affected."); @@ -365,6 +390,20 @@ public partial class PreOrderConversionService }); await InsertOrderItemsAsync(order, fulfilledItems); + + // Recalculate header totals from the actual inserted order items rather than the + // PreOrderItem snapshot price: that snapshot is 0 for OrderDraft-origin preorders + // (priced at conversion) and may be stale for others (price drift since placement), + // so the order items are the authoritative source — same approach as AppendItemsToOrderAsync. + var insertedItems = await _dbContext.OrderItems.Table + .Where(oi => oi.OrderId == order.Id) + .ToListAsync(); + var recalculatedTotal = insertedItems.Sum(oi => oi.PriceInclTax); + order.OrderSubtotalInclTax = recalculatedTotal; + order.OrderSubtotalExclTax = Math.Round(recalculatedTotal / 1.27m, 2); + order.OrderTotal = recalculatedTotal; + await _dbContext.Orders.UpdateAsync(order); + await InsertOrderNoteAsync(order.Id, preorder.Id, shippingDocumentId, fulfilledItems, droppedItems); // Fire event so existing handlers (EventConsumer etc.) run diff --git a/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml index 1fe1975..73b5961 100644 --- a/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml +++ b/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml @@ -1,4 +1,6 @@ @model Nop.Plugin.Misc.FruitBankPlugin.Models.CustomerCreditWidgetModel +@inject Nop.Services.Common.IGenericAttributeService genericAttributeService +@inject Nop.Core.IWorkContext workContext @{ var remaining = Model.RemainingCredit; @@ -6,78 +8,327 @@ : remaining <= 0 ? "text-danger" : remaining < Model.CreditLimit * 0.2m ? "text-warning" : "text-success"; + + // Per-admin collapse state, persisted like the core nopCommerce cards + var admin = await workContext.GetCurrentCustomerAsync(); + + const string hideAttributesCardName = "FruitBank.CustomerAttributesCard.Hide"; + var hideAttributesCard = await genericAttributeService.GetAttributeAsync(admin, hideAttributesCardName); + + const string hideCreditCardName = "FruitBank.CustomerCreditCard.Hide"; + var hideCreditCard = await genericAttributeService.GetAttributeAsync(admin, hideCreditCardName); } - - - - @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle") - - +@* FruitBank customer-top section: injected into the CustomerDetailsBlock widget zone + (which renders at the BOTTOM of the customer page) and relocated to the top of + #customer-cards by the script below. Future non-credit elements go inside this + same wrapper so the single relocation keeps them all together at the top. *@ + - - - @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit") - - - - @(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text) - + @* ── Customer attributes (generic attributes block) ───────────────────────── *@ + + + + + @T("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title") + + + + + + + + + @T("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer") + + + + @* No name attribute: this must NOT post with the core Customer Edit form; + it is saved via AJAX on change to CustomerAttributesController.SetEkaer. *@ + + + + + - - - @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance") - - - - @Model.OutstandingBalance.ToString("N0") Ft - - - - - - - @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit") - - - - - @if (!Model.HasCreditLimit) + + + @T("Plugins.Misc.FruitBankPlugin.CustomerSites.Title") + + + + @if (Model.SiteCount > 0) { - @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited") + @Model.SiteCount @T("Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord") + if (!string.IsNullOrWhiteSpace(Model.DefaultSiteDisplay)) + { + (@T("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel"): @Model.DefaultSiteDisplay) + } } else { - @(remaining!.Value.ToString("N0")) - Ft + @T("Plugins.Misc.FruitBankPlugin.CustomerSites.None") } - - + + + + @T("Plugins.Misc.FruitBankPlugin.CustomerSites.Edit") + + + + + @* ── Customer credit ──────────────────────────────────────────────────────── *@ + + + + + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle") + + + + + + + + - @if (!string.IsNullOrWhiteSpace(Model.Comment)) - { - @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment") + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit") - @Model.Comment + + @(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text) + - } - - - - - @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle") - + + + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance") + + + + @Model.OutstandingBalance.ToString("N0") Ft + + + + + + + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit") + + + + + @if (!Model.HasCreditLimit) + { + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited") + } + else + { + @(remaining!.Value.ToString("N0")) + Ft + } + + + + + + @if (!string.IsNullOrWhiteSpace(Model.Comment)) + { + + + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment") + + + @Model.Comment + + + } + + + + + + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle") + + + + + + + + + +@* ── Sites edit modal ───────────────────────────────────────────────────────── *@ + + + + + @T("Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle") + × + + + @if (!Model.SiteAddresses.Any()) + { + @T("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses") + } + else + { + + + + @T("Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel") + @T("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel") + @T("Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel") + + + + @foreach (var a in Model.SiteAddresses) + { + + + + + + + + @a.Display + + } + + + } + + - + + diff --git a/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/ORDERDRAFT_ISSUES.md b/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/ORDERDRAFT_ISSUES.md new file mode 100644 index 0000000..553b70b --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/ORDERDRAFT_ISSUES.md @@ -0,0 +1,46 @@ +# ORDER-DRAFT — Known Issues + +> Companion to [`README.md`](README.md). Topic `ORDD`, prefix `MGFBANKPLUG` → entry IDs `MGFBANKPLUG-ORDD-I-` (issue) / `-B-` (confirmed bug) / `-T-` (tech-debt). +> ID format, Status vocabulary, type codes and archival → `../../.github/TOPIC_CODES.md` (→ framework registry). + +Scope: az AI-alapú rendelésfelvevő (OrderDraft) problémái — beérkező üzenet parse-olása, termék/partner feloldás, tanuló mappingek, draft életciklus, jóváhagyási belépési pont a PRE-ORDER folyamatba. + +## Active entries + +## MGFBANKPLUG-ORDD-I-Q4N7: Az `ApproveAsync` fixen `StoreId = 1`-et használ + +**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** I (multi-store helyesség) + +`OrderDraftService.ApproveAsync` ([`Services/OrderDraftService.cs`](../../Services/OrderDraftService.cs)) mindkét ágban (`preorder` és `azonnali`) `StoreId = 1` konstanssal hozza létre a `PreOrder`-t: + +```csharp +var preorder = new PreOrder { CustomerId = draft.CustomerId!.Value, StoreId = 1, ... }; +``` + +**Hatás:** jelenleg egy store-os környezetben nincs gond. Multi-store esetén viszont a draftból mindig az 1-es store-ba kerül a rendelés, függetlenül a partner/üzenet kontextusától — rossz store-hoz rendelt rendelések. + +**Javítási irány:** a store-t a draft kontextusából vagy a partner (`Customer`) alapértelmezett store-jából vezessük le, ne hardcode-oljuk. (Megj.: a `PreOrderAdminController.CreatePreOrder` is hasonló fallback-logikát használ — érdemes közös segédbe emelni.) + +**Affected:** `Services/OrderDraftService.cs` → `ApproveAsync` (mindkét `new PreOrder { StoreId = 1 }`). + +## MGFBANKPLUG-ORDD-T-R8W3: A jóváhagyás preorder/azonnali ága szinte duplikátum + +**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** T (tech-debt / refaktor) + +`ApproveAsync`-ben a `preorderItems` és az `orderItems` (azonnali) ág kódja közel azonos: `PreOrderItem` lista építése (`UnitPriceInclTax = 0`, `Status = Pending`), `PreOrder` létrehozás, `InsertPreOrderAsync`, majd `ConvertPreOrdersForProductsAsync` hívás. Az egyetlen érdemi különbség a `CustomerNote` szövege és hogy az azonnali ág a végén visszaolvassa az `OrderId`-t. + +Mivel mindkét ág ugyanazon a `PreOrder` + konverzió flow-n megy keresztül (a konverziós ablak dönti el, hogy azonnal teljesül-e), a kettő **egyetlen** közös ággá vonható össze, dátum szerinti elágazás nélkül — vagy egy közös privát helperrel (`CreatePreOrderFromDraftItemsAsync(items, note)`). + +**Affected:** `Services/OrderDraftService.cs` → `ApproveAsync`. + +## MGFBANKPLUG-ORDD-I-M2K9: Az API-kulcs összehasonlítása nem konstans idejű + +**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** I (security hardening) + +`OrderDraftApiController.ValidateApiKey` ([`Controllers/OrderDraftApiController.cs`](../../Controllers/OrderDraftApiController.cs)) `string.Equals(..., StringComparison.Ordinal)`-lal hasonlítja össze a beküldött és a tárolt API-kulcsot. Ez **nem konstans idejű** — elvi timing-oldalcsatorna a kulcs kitalálására. + +**Hatás:** alacsony (a kulcs hosszú, és a háló-jitter elnyomja a mikro-eltérést), de egy publikus, csak-API-kulccsal védett végpontnál a fixed-time összehasonlítás az elvárt gyakorlat. + +**Javítási irány:** fixed-time összehasonlítás, pl. `CryptographicOperations.FixedTimeEquals` a két kulcs UTF-8 byte-jaira (hossz-eltérés előzetes kezelésével). Opcionálisan rate-limiting a végponton. + +**Affected:** `Controllers/OrderDraftApiController.cs` → `ValidateApiKey`. diff --git a/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/README.md b/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/README.md new file mode 100644 index 0000000..f72f763 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/docs/ORDERDRAFT/README.md @@ -0,0 +1,69 @@ +# Order-Draft Workflow + +> Part of `Nop.Plugin.Misc.FruitBankPlugin`. See `../README.md` for the docs index, `../../README.md` for project overview. +> Closely coupled to the pre-order subsystem — see [`../PREORDER/README.md`](../PREORDER/README.md): an approved draft creates `PreOrder`s and runs the same conversion. +> Known issues / planned work: [`ORDERDRAFT_ISSUES.md`](ORDERDRAFT_ISSUES.md). + +An **order draft** is an AI-assisted intake channel. External systems (Viber bot, email processor, dictation app) post a raw free-text message; the AI extracts delivery dates and product demands; a draft is created for admin review. On approval the draft becomes one or more `PreOrder`s, which then flow through the existing **PRE-ORDER conversion** — so OrderDraft is a new, admin-gated *placement* channel into that pipeline, not a parallel order path. + +## Entity Hierarchy + +``` +OrderDraft ← one inbound message for one delivery date + └─ OrderDraftItem ← one parsed product-demand line [1:N] + +ExternalUserPartnerMapping ← external username → nopCommerce CustomerId (learned) +ProductTextMapping ← raw text → admin-chosen ProductId (learned, for ranking) +``` + +All four tables live in `FruitBank.Common/Entities/` and are registered on `FruitBankDbContext`. `OrderDraftStatus`: `Pending` → `Approved` / `Rejected` / `Expired`. + +## Workflow + +### 1. Intake +`OrderDraftApiController.Create` (`Controllers/`): auth via `X-FruitBank-ApiKey` header (matched against `FruitBankSettings.OrderDraftApiKey`), body `{ externalUsername, messageText }`. + +### 2. Draft build +`OrderDraftService.CreateDraftsFromMessageAsync`: +- Partner resolution from `ExternalUserPartnerMapping` (may be `null` → admin assigns later). +- AI parse (`OrderDraftParserService.ParseMessageAsync`): one draft per delivery date; `isPreorder = deliveryDate > today + PreorderThresholdDays (4)`. +- Per item (`ProcessDraftItemAsync`): historic `ProductTextMapping` weighting (last 5 admin decisions) + AI canonical name/keywords + `SearchProductsAsync` + AI ranking → `SuggestedProductIdsJson`, first suggestion pre-selected. + +### 3. Admin review +`OrderDraftAdminController` (`Areas/Admin/`): DataTables list (urgent = Pending > 20h), Detail page with candidates, `UpdateItem` / `AssignCustomer`. + +### 4. Approval (the entry point into PRE-ORDER) +`OrderDraftService.ApproveAsync`: +- Validates: partner assigned, every item has `SelectedProductId`. +- Saves `ProductTextMapping` learning records. +- Splits items by `IsPreorder`; **both branches create a `PreOrder`** via `InsertPreOrderAsync`, then call `PreOrderConversionService.ConvertPreOrdersForProductsAsync`. The "immediate" branch relies on the 4-day conversion window to convert at once. + +### 5. Expiry +`OrderDraftService.ExpireOldDraftsAsync`: Pending drafts older than 14 days → `Expired` (background job). + +## AI touchpoints + +Three distinct AI calls, all in `OrderDraftParserService` via `OpenAIApiService.GetSimpleResponseAsync` (OpenAI Chat Completions). OrderDraft uses **OpenAI exclusively** — unlike `QuickOrderController` / `OrderController` parsing, which use `CerebrasApiService`. + +| # | Call | Input → Output | Used in | +|---|---|---|---| +| 1 | `ParseMessageAsync` | raw message → delivery-date groups of `{ rawProductText, requestedQuantity }`; one draft per date | `CreateDraftsFromMessageAsync` | +| 2 | `ResolveProductNameAsync` | one raw product text → `{ canonicalName, searchKeywords[] }` (keywords feed NopCommerce `SearchProductsAsync`) | `ProcessDraftItemAsync` | +| 3 | `RankCandidatesAsync` | raw text + canonical name + candidate `(id, name)[]` → ranked product-id list; only when >1 non-historic search hit | `ProcessDraftItemAsync` | + +Resolution is **hybrid**: AI proposes candidates, but ordering is front-loaded by historic admin decisions (`ProductTextMapping`, last 5 for the same raw text) — a DB heuristic, not AI. Product search itself (`SearchProductsAsync`) is NopCommerce full-text, not AI. + +**Model:** `GetModelName()` from `FruitBankSettings.OpenAIModelName` (default `gpt-3.5-turbo`; code paths for `gpt-4o-mini`/`4.1-mini`/`4.1-nano`/`5-nano` at `temperature=0.2`, and a `gpt-5` path at `reasoning_effort=minimal`). + +**Not yet AI-wired:** partner identification (plain `ExternalUserPartnerMapping` username lookup), quantity/unit normalisation (relies on the parse prompt only), confidence/uncertainty scoring (admin approves everything manually), and the preorder-vs-immediate split (fixed 4-day `PreorderThresholdDays`, not AI). + +## Key Source + +| Type | Location | +|---|---| +| `OrderDraftService` / `IOrderDraftService` | `Services/` | +| `OrderDraftParserService` (AI parse / resolve / rank) | `Services/` | +| `OrderDraftApiController` | `Controllers/` | +| `OrderDraftAdminController` (+ models) | `Areas/Admin/` | +| `OrderDraftDbTable` / `OrderDraftItemDbTable` / `ExternalUserPartnerMapDbTable` / `ProductTextMappingDbTable` | `Domains/DataLayer/` | +| `OrderDraft` / `OrderDraftItem` / `ExternalUserPartnerMapping` / `ProductTextMapping` entities | `FruitBank.Common/Entities/` | diff --git a/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md b/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md index 2bb6ded..af23385 100644 --- a/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md +++ b/Nop.Plugin.Misc.AIPlugin/docs/PREORDER/PREORDER_ISSUES.md @@ -7,4 +7,21 @@ Scope: bugs / inconsistent or observable edge-case behaviour in the pre-order su ## Active entries -*(None yet — to be filled from the code review as needed.)* +## MGFBANKPLUG-PREO-B-K7M2: A konverziós pool dupla-számolja a már teljesített foglalásokat — alul-allokáció + +**Status:** Open (2026-06-15) · **Priority:** P2 · **Type:** B (megerősített bug) + +`PreOrderConversionService.BuildIncomingQuantityPoolAsync` ([`Services/PreorderConversionService.cs`](../../Services/PreorderConversionService.cs)) a rendelkezésre álló mennyiségből **kétszer** vonja le a korábbi futásokban már teljesített (`Fulfilled`/`PartiallyFulfilled`) preorder-tételeket. + +- `AvailableQuantity = StockQuantity + IncomingQuantity` (SCHEMA.md), az élő `Product.StockQuantity`-ből. +- Teljesítéskor `InsertOrderItemsAsync` → `AdjustInventoryAsync(-FulfilledQuantity)` **már csökkenti** a `StockQuantity`-t, így a következő futás `AvailableQuantity`-je **már tartalmazza** a foglalást. +- A pool ezen felül **még levonja** az `alreadyAllocated`-et (a `Fulfilled`/`PartiallyFulfilled` itemek `FulfilledQuantity` összegét) → **futások közötti dupla-levonás**. + +**Reprodukció:** `Stock=0, Incoming=10`. „A" preorder 6-ot teljesít (run 1) → `Stock=-6`, allocated=6. „B" preorder 6-ot kér (run 2): `available = -6+10 = 4`, `committed = 6` → `pool = max(0, 4-6) = 0`. **B nem kap semmit, pedig fizikailag 4 jár neki.** Az alul-allokáció a „még nincs beérkezve" eset — az előrendelés tipikus forgatókönyve. + +**Miért nem puszta törlés a fix:** a `CreateOrderAsync` korai `return`-jei (nincs `Customer` / nincs számlázási cím, [sorok ~300-319](../../Services/PreorderConversionService.cs)) úgy lépnek ki, hogy az item a fő ciklusban (sor ~153) **már `Fulfilled`-re lett állítva és elmentve**, de a készletlevonás nem futott le. Ezeken a hibautakon az `alreadyAllocated` levonás *helyesen* kompenzál; a levonás puszta törlése **túl-allokációt (overselling)** okozna. + +**Javítási irány (ajánlott):** az item csak akkor legyen `Fulfilled`, ha a rendelés + készletlevonás ténylegesen sikerült (korai return esetén maradjon `Pending`/visszaállítás), majd ezután eltávolítható a dupláló `alreadyAllocated` levonás. Így megszűnik az alul-allokáció túl-allokációs kockázat nélkül. + +**Affected:** +- `Services/PreorderConversionService.cs` → `BuildIncomingQuantityPoolAsync` (a `alreadyAllocated` levonás), `ConvertPreOrdersForProductsAsync` fő ciklusa (státusz-mutáció sorrendje), `CreateOrderAsync` korai return ágai. diff --git a/Nop.Plugin.Misc.AIPlugin/docs/README.md b/Nop.Plugin.Misc.AIPlugin/docs/README.md index cf9face..107d5db 100644 --- a/Nop.Plugin.Misc.AIPlugin/docs/README.md +++ b/Nop.Plugin.Misc.AIPlugin/docs/README.md @@ -14,6 +14,7 @@ Topic documentation for the FruitBank-specific NopCommerce plugin (Layer 3 — c - [`SIGNALR/`](SIGNALR/README.md) — SignalR endpoints exposed by this plugin (project-specific variant) - [`PREORDER/`](PREORDER/README.md) — Customer pre-order workflow: advance orders converted to real orders on incoming stock (+ `PREORDER_ISSUES.md` / `PREORDER_TODO.md`) +- [`ORDERDRAFT/`](ORDERDRAFT/README.md) — AI-assisted intake: free-text messages parsed into order drafts, admin-approved into pre-orders (+ `ORDERDRAFT_ISSUES.md`) ## Navigation
@Model.RawMessageText
@Model.ExternalUsername
+ Ismeretlen feladó: @Model.ExternalUsername + Rendeld hozzá a megfelelő partnerhez. A rendszer megjegyzi a kapcsolatot. +