From 51f546caeca2e8c726f83033adae4b1da464baa3 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 27 Mar 2026 17:14:40 +0100 Subject: [PATCH] CustomerCredit, new order --- .../Controllers/CustomOrderController.cs | 228 +++++++ .../Controllers/CustomerCreditController.cs | 285 ++++++++ .../Admin/Models/CustomerCreditListRow.cs | 13 + .../Areas/Admin/Models/CustomerCreditModel.cs | 30 + .../Admin/Views/CustomerCredit/Details.cshtml | 151 +++++ .../Admin/Views/CustomerCredit/List.cshtml | 200 ++++++ .../Views/Order/FruitBankOrderList.cshtml | 634 ++++++++++++++++++ .../CustomerCreditWidgetViewComponent.cs | 39 ++ .../DataLayer/CustomerCreditDbTable.cs | 27 + .../Domains/DataLayer/FruitBankDbContext.cs | 7 +- .../Interfaces/ICustomerCreditDbSet.cs | 10 + .../Factories/MgBase/MgOrderModelFactory.cs | 4 +- Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs | 112 +++- .../Infrastructure/PluginNopStartup.cs | 5 +- .../Infrastructure/RouteProvider.cs | 66 +- .../Localization/customercredit.en.xml | 84 +++ .../Localization/customercredit.hu.xml | 84 +++ .../Mapping/NameCompatibility.cs | 1 + .../Models/CustomerCreditWidgetModel.cs | 21 + .../Models/Orders/FruitBankOrderRowDto.cs | 35 + .../Nop.Plugin.Misc.FruitBankPlugin.csproj | 12 + .../Services/CustomerCreditService.cs | 71 ++ .../Services/EventConsumer.cs | 55 +- .../Services/FruitBankNotificationService.cs | 84 +++ .../Services/ICustomerCreditService.cs | 32 + .../Views/CustomerCreditWidget.cshtml | 83 +++ 26 files changed, 2335 insertions(+), 38 deletions(-) create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerCreditController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditListRow.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditModel.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/CustomerCreditDbTable.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/Interfaces/ICustomerCreditDbSet.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Models/Orders/FruitBankOrderRowDto.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/CustomerCreditService.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/FruitBankNotificationService.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Services/ICustomerCreditService.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs index 09744fc..4856394 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs @@ -1759,7 +1759,235 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers } } + + + // ═══════════════════════════════════════════════════════════════════ + // FruitBank Order Grid – new server-side DataTables endpoint + // ═══════════════════════════════════════════════════════════════════ + + /// + /// Returns the new FruitBank order list view (replaces the default NopCommerce grid). + /// + [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)] + public async Task NewList( + List orderStatuses = null, + List paymentStatuses = null, + List shippingStatuses = null) + { + var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended + { + OrderStatusIds = orderStatuses, + PaymentStatusIds = paymentStatuses, + ShippingStatusIds = shippingStatuses, + Length = 50, + AvailablePageSizes = "20,50,100,500", + SortColumn = "Id", + SortColumnDirection = "desc", + }); + model.SetGridSort("Id", "desc"); + model.SetGridPageSize(50, "20,50,100,500"); + + return View( + "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml", + model); } + + /// + /// DataTables server-side endpoint for the FruitBank order grid. + /// Handles NopCommerce base filters + FruitBank-specific filters + per-column search + sort + pagination. + /// + [HttpPost] + [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)] + public async Task FruitBankOrderList() + { + // ── 1. Parse DataTables protocol params ──────────────────────── + _ = 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 ? 50 : Math.Min(length, 500); + + // Sort column + _ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx); + var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc"; + var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "Id"; + + // Per-column search values keyed by column data-field name + var colSearch = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int ci = 0; Request.Form.ContainsKey($"columns[{ci}][data]"); ci++) + { + var cData = Request.Form[$"columns[{ci}][data]"].FirstOrDefault(); + var cVal = Request.Form[$"columns[{ci}][search][value]"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(cData) && !string.IsNullOrWhiteSpace(cVal)) + colSearch[cData] = cVal.Trim(); + } + + // ── 2. Parse custom filter params ───────────────────────────── + DateTime? startDate = null, endDate = null; + if (DateTime.TryParse(Request.Form["StartDate"].FirstOrDefault(), out var sd)) startDate = sd; + if (DateTime.TryParse(Request.Form["EndDate"].FirstOrDefault(), out var ed)) endDate = ed; + + var orderStatusIds = Request.Form["OrderStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + var paymentStatusIds = Request.Form["PaymentStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + var shippingStatusIds = Request.Form["ShippingStatusIds"] + .Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList(); + + var billingCompany = Request.Form["BillingCompany"].FirstOrDefault(); // holds customer ID (string) + + bool? isMeasurableFilter = null; + var imStr = Request.Form["IsMeasurable"].FirstOrDefault(); + if (imStr == "true") isMeasurableFilter = true; + if (imStr == "false") isMeasurableFilter = false; + + bool? hasInnvoiceFilter = null; + var hiStr = Request.Form["HasInnvoiceTechId"].FirstOrDefault(); + if (hiStr == "true") hasInnvoiceFilter = true; + if (hiStr == "false") hasInnvoiceFilter = false; + + // ── 3. Fetch data via factory (applies NopCommerce base filters) + // We ask for a large page so all matching records come back in one shot; + // FruitBank-specific filtering + pagination happen below in-process. + var searchModel = new OrderSearchModelExtended + { + StartDate = startDate, + EndDate = endDate, + OrderStatusIds = orderStatusIds.Any() ? orderStatusIds : null, + PaymentStatusIds = paymentStatusIds.Any() ? paymentStatusIds : null, + ShippingStatusIds = shippingStatusIds.Any() ? shippingStatusIds : null, + BillingCompany = billingCompany, + SortColumn = "Id", + SortColumnDirection = "desc" + }; + // SetGridPageSize is the proper NopCommerce way to override Page/PageSize + searchModel.SetGridPageSize(5000, "5000"); + + OrderListModelExtended orderListModel; + try + { + orderListModel = await _orderModelFactory.PrepareOrderListModelExtendedAsync(searchModel); + } + catch (Exception ex) + { + _logger.Error($"FruitBankOrderList – factory error: {ex.Message}", ex); + return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty() }); + } + + var rows = orderListModel.Data?.ToList() ?? new List(); + int total = orderListModel.RecordsTotal; + + // ── 4. Map to lightweight DTO ────────────────────────────────── + var dtos = rows.Select(o => new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto + { + Id = o.Id, + CustomOrderNumber = o.CustomOrderNumber, + CustomerCompany = o.CustomerCompany, + CustomerId = o.CustomerId, + InnvoiceTechId = o.InnvoiceTechId, + IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid, + IsMeasurable = o.IsMeasurable, + MeasuringStatus = (int)o.MeasuringStatus, + MeasuringStatusString = o.MeasuringStatusString, + DateOfReceipt = o.DateOfReceipt, + OrderStatusId = o.OrderStatusId, + OrderStatus = o.OrderStatus, + PaymentStatusId = o.PaymentStatusId, + PaymentStatus = o.PaymentStatus, + ShippingStatusId = o.ShippingStatusId, + ShippingStatus = o.ShippingStatus, + StoreName = o.StoreName, + CreatedOn = o.CreatedOn, + OrderTotal = o.OrderTotal + }).ToList(); + + // ── 5. Apply FruitBank-specific top-level filters ────────────── + if (isMeasurableFilter.HasValue) + dtos = dtos.Where(o => o.IsMeasurable == isMeasurableFilter.Value).ToList(); + + if (hasInnvoiceFilter.HasValue) + dtos = hasInnvoiceFilter.Value + ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList(); + + // ── 6. Apply per-column search ───────────────────────────────── + foreach (var (col, val) in colSearch) + { + dtos = col.ToLowerInvariant() switch + { + "customordernumber" => dtos.Where(o => o.CustomOrderNumber?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(), + "customercompany" => dtos.Where(o => o.CustomerCompany?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(), + "orderstatusid" => int.TryParse(val, out int osId) ? dtos.Where(o => o.OrderStatusId == osId).ToList() : dtos, + "measuringstatus" => int.TryParse(val, out int msId) ? dtos.Where(o => o.MeasuringStatus == msId).ToList() : dtos, + "ismeasurable" => bool.TryParse(val, out bool bm) ? dtos.Where(o => o.IsMeasurable == bm).ToList() : dtos, + // InnVoice column sends 'has' or 'none' strings + "innvoicetechid" => val == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : val == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() + : dtos, + _ => dtos + }; + } + + int recordsFiltered = dtos.Count; + + // ── 7. Sort ──────────────────────────────────────────────────── + bool asc = sortDir == "asc"; + dtos = sortColName.ToLowerInvariant() switch + { + "id" => asc ? dtos.OrderBy(o => o.Id).ToList() : dtos.OrderByDescending(o => o.Id).ToList(), + "customordernumber" => asc ? dtos.OrderBy(o => o.CustomOrderNumber).ToList() : dtos.OrderByDescending(o => o.CustomOrderNumber).ToList(), + "customercompany" => asc ? dtos.OrderBy(o => o.CustomerCompany).ToList() : dtos.OrderByDescending(o => o.CustomerCompany).ToList(), + "dateofreceipt" => asc ? dtos.OrderBy(o => o.DateOfReceipt ?? DateTime.MinValue).ToList() : dtos.OrderByDescending(o => o.DateOfReceipt ?? DateTime.MinValue).ToList(), + "createdon" => asc ? dtos.OrderBy(o => o.CreatedOn).ToList() : dtos.OrderByDescending(o => o.CreatedOn).ToList(), + "orderstatusid" => asc ? dtos.OrderBy(o => o.OrderStatusId).ToList() : dtos.OrderByDescending(o => o.OrderStatusId).ToList(), + "measuringstatus" => asc ? dtos.OrderBy(o => o.MeasuringStatus).ToList() : dtos.OrderByDescending(o => o.MeasuringStatus).ToList(), + _ => dtos.OrderByDescending(o => o.Id).ToList() + }; + + // ── 8. Paginate ──────────────────────────────────────────────── + var page = dtos.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal = total, recordsFiltered, data = page }); + } + + /// + /// Inline-edit save endpoint. Currently supports DateOfReceipt. + /// + [HttpPost] + [CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)] + public async Task UpdateOrderField(int orderId, string field, string value) + { + try + { + var order = await _orderService.GetOrderByIdAsync(orderId); + if (order == null) + return Json(new { success = false, error = "Rendelés nem található" }); + + switch (field?.ToUpperInvariant()) + { + case "DATEOFRECEIPT": + if (string.IsNullOrWhiteSpace(value)) + { + await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", null); + return Json(new { success = true, displayValue = (string)null }); + } + if (DateTime.TryParse(value, out var newDate)) + { + await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", newDate); + return Json(new { success = true, displayValue = newDate.ToString("yyyy. MM. dd. HH:mm") }); + } + return Json(new { success = false, error = "Érvénytelen dátum formátum" }); + + default: + return Json(new { success = false, error = $"Ismeretlen mező: {field}" }); + } + } + catch (Exception ex) + { + _logger.Error($"UpdateOrderField error – orderId={orderId} field={field}: {ex.Message}", ex); + return Json(new { success = false, error = ex.Message }); + } + } + +} } diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerCreditController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerCreditController.cs new file mode 100644 index 0000000..2b4de66 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomerCreditController.cs @@ -0,0 +1,285 @@ +using FruitBank.Common.Entities; +using Microsoft.AspNetCore.Mvc; +using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Orders; +using Nop.Core.Domain.Payments; +using Nop.Data; +using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Customers; +using Nop.Services.Localization; +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; + +[AuthorizeAdmin] +[Area(AreaNames.ADMIN)] +public class CustomerCreditController : BasePluginController +{ + private readonly ICustomerCreditService _customerCreditService; + private readonly ICustomerService _customerService; + private readonly IRepository _orderRepository; + private readonly IRepository _customerRepository; + private readonly CustomerCreditDbTable _customerCreditDbTable; + private readonly IPermissionService _permissionService; + private readonly ILocalizationService _localizationService; + + public CustomerCreditController( + ICustomerCreditService customerCreditService, + ICustomerService customerService, + IRepository orderRepository, + IRepository customerRepository, + CustomerCreditDbTable customerCreditDbTable, + IPermissionService permissionService, + ILocalizationService localizationService) + { + _customerCreditService = customerCreditService; + _customerService = customerService; + _orderRepository = orderRepository; + _customerRepository = customerRepository; + _customerCreditDbTable = customerCreditDbTable; + _permissionService = permissionService; + _localizationService = localizationService; + } + + // ── LIST PAGE ───────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/CustomerCredit/List")] + public async Task List() + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml"); + } + + // ── DATATABLES SERVER-SIDE ENDPOINT ────────────────────────────────────── + + [HttpPost] + [Route("Admin/CustomerCredit/CustomerCreditList")] + public async Task CustomerCreditList() + { + 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, 500); + + _ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx); + var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc"; + var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "OutstandingBalance"; + + var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? ""; + + // 1. Customers — single query + var customers = await _customerRepository.Table + .Where(c => !c.Deleted && c.Active && c.Email != null) + .Select(c => new { c.Id, c.Email, c.FirstName, c.LastName }) + .ToListAsync(); + + // 2. Credit records — single query + var credits = await _customerCreditDbTable.GetAll().ToListAsync(); + var creditByCustomer = credits.ToDictionary(x => x.CustomerId); + + // 3. Outstanding balances — single grouped query, no N+1 + var outstandingByCustomer = await _orderRepository.Table + .Where(o => + o.OrderStatusId != (int)OrderStatus.Cancelled && + (o.PaymentStatusId == (int)PaymentStatus.Pending || + o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded)) + .GroupBy(o => o.CustomerId) + .Select(g => new { CustomerId = g.Key, Total = g.Sum(o => (decimal?)o.OrderTotal) ?? 0m }) + .ToListAsync(); + var outstandingDict = outstandingByCustomer.ToDictionary(x => x.CustomerId, x => x.Total); + + // 4. Build rows + var rows = customers.Select(c => + { + creditByCustomer.TryGetValue(c.Id, out var credit); + outstandingDict.TryGetValue(c.Id, out var outstanding); + var hasLimit = credit != null; + var remaining = hasLimit ? credit!.CreditLimit - outstanding : (decimal?)null; + + return new CustomerCreditListRow + { + CustomerId = c.Id, + CustomerEmail = c.Email ?? string.Empty, + CustomerName = $"{c.FirstName} {c.LastName}".Trim(), + HasCreditLimit = hasLimit, + CreditLimit = credit?.CreditLimit ?? 0m, + OutstandingBalance = outstanding, + RemainingCredit = remaining, + Comment = credit?.Comment + }; + }).ToList(); + + int recordsTotal = rows.Count; + + // 5. Global search + if (!string.IsNullOrWhiteSpace(globalSearch)) + { + rows = rows.Where(r => + r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + r.CustomerEmail.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) || + (r.Comment?.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ?? false) + ).ToList(); + } + + int recordsFiltered = rows.Count; + + // 6. Sort + bool asc = sortDir == "asc"; + rows = sortColName.ToLowerInvariant() switch + { + "customername" => asc ? rows.OrderBy(r => r.CustomerName).ToList() : rows.OrderByDescending(r => r.CustomerName).ToList(), + "customeremail" => asc ? rows.OrderBy(r => r.CustomerEmail).ToList() : rows.OrderByDescending(r => r.CustomerEmail).ToList(), + "creditlimit" => asc ? rows.OrderBy(r => r.CreditLimit).ToList() : rows.OrderByDescending(r => r.CreditLimit).ToList(), + "outstandingbalance" => asc ? rows.OrderBy(r => r.OutstandingBalance).ToList() : rows.OrderByDescending(r => r.OutstandingBalance).ToList(), + "remainingcredit" => asc ? rows.OrderBy(r => r.RemainingCredit ?? decimal.MaxValue).ToList() : rows.OrderByDescending(r => r.RemainingCredit ?? decimal.MinValue).ToList(), + _ => rows.OrderByDescending(r => r.OutstandingBalance).ToList() + }; + + // 7. Paginate + var page = rows.Skip(start).Take(length).ToList(); + + return Json(new { draw, recordsTotal, recordsFiltered, data = page }); + } + + // ── INLINE EDIT: CREDIT LIMIT ───────────────────────────────────────────── + + [HttpPost] + [Route("Admin/CustomerCredit/UpdateCreditLimit")] + public async Task UpdateCreditLimit(int customerId, string? creditLimit, bool removeLimit, string? comment) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return Json(new { success = false, error = "Access denied" }); + + try + { + var existing = await _customerCreditService.GetByCustomerIdAsync(customerId); + var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId); + + // Empty input or explicit removeLimit flag → delete record = unlimited + if (removeLimit || string.IsNullOrWhiteSpace(creditLimit)) + { + if (existing != null) + await _customerCreditService.DeleteAsync(existing); + + return Json(new + { + success = true, + hasLimit = false, + creditLimit = (decimal?)null, + outstanding, + remaining = (decimal?)null + }); + } + + // Parse the value (JS sends invariant decimal) + if (!decimal.TryParse(creditLimit, + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, + out var limit) || limit < 0) + return Json(new { success = false, error = "Érvénytelen összeg" }); + + var entity = existing ?? new CustomerCredit { CustomerId = customerId }; + entity.CreditLimit = limit; + if (comment != null) entity.Comment = comment; + + await _customerCreditService.SaveAsync(entity); + + return Json(new + { + success = true, + hasLimit = true, + creditLimit = limit, + outstanding, + remaining = limit - outstanding + }); + } + catch (Exception ex) + { + return Json(new { success = false, error = ex.Message }); + } + } + + // ── DETAILS ─────────────────────────────────────────────────────────────── + + [HttpGet] + [Route("Admin/CustomerCredit/Details/{customerId:int}")] + public async Task Details(int customerId) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + var customer = await _customerService.GetCustomerByIdAsync(customerId); + if (customer == null) + return NotFound(); + + var credit = await _customerCreditService.GetByCustomerIdAsync(customerId); + var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId); + + var unpaidOrders = await _orderRepository.Table + .Where(o => + o.CustomerId == customerId && + o.OrderStatusId != (int)OrderStatus.Cancelled && + (o.PaymentStatusId == (int)PaymentStatus.Pending || + o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded)) + .OrderByDescending(o => o.CreatedOnUtc) + .ToListAsync(); + + var model = new CustomerCreditModel + { + CustomerId = customerId, + CustomerEmail = customer.Email, + CustomerName = $"{customer.FirstName} {customer.LastName}".Trim(), + CreditId = credit?.Id ?? 0, + CreditLimit = credit?.CreditLimit ?? 0m, + Comment = credit?.Comment, + OutstandingBalance = outstanding, + RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null, + HasCreditLimit = credit != null, + UnpaidOrders = unpaidOrders.Select(o => new CustomerCreditOrderRow + { + OrderId = o.Id, + OrderTotal = o.OrderTotal, + CreatedOnUtc = o.CreatedOnUtc, + OrderStatus = o.OrderStatus.ToString(), + PaymentStatus = o.PaymentStatus.ToString() + }).ToList() + }; + + return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml", model); + } + + // ── SAVE (from Details page) ────────────────────────────────────────────── + + [HttpPost] + [Route("Admin/CustomerCredit/Save")] + public async Task Save(CustomerCreditModel model) + { + if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) + return AccessDeniedView(); + + if (!ModelState.IsValid) + return RedirectToAction("Details", new { customerId = model.CustomerId }); + + var entity = model.CreditId > 0 + ? await _customerCreditService.GetByCustomerIdAsync(model.CustomerId) ?? new CustomerCredit() + : new CustomerCredit(); + + entity.CustomerId = model.CustomerId; + entity.CreditLimit = model.CreditLimit; + entity.Comment = model.Comment; + + await _customerCreditService.SaveAsync(entity); + + return RedirectToAction("Details", new { customerId = model.CustomerId }); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditListRow.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditListRow.cs new file mode 100644 index 0000000..fd05fee --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditListRow.cs @@ -0,0 +1,13 @@ +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class CustomerCreditListRow +{ + public int CustomerId { get; set; } + public string CustomerEmail { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public bool HasCreditLimit { get; set; } + public decimal CreditLimit { get; set; } + public decimal OutstandingBalance { get; set; } + public decimal? RemainingCredit { get; set; } + public string? Comment { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditModel.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditModel.cs new file mode 100644 index 0000000..3fff84c --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Models/CustomerCreditModel.cs @@ -0,0 +1,30 @@ +namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models; + +public class CustomerCreditModel +{ + public int CustomerId { get; set; } + public string CustomerEmail { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + + // Credit record + public int CreditId { get; set; } + public decimal CreditLimit { get; set; } + public string? Comment { get; set; } + public bool HasCreditLimit { get; set; } + + // Calculated + public decimal OutstandingBalance { get; set; } + public decimal? RemainingCredit { get; set; } + + // Unpaid orders table + public List UnpaidOrders { get; set; } = new(); +} + +public class CustomerCreditOrderRow +{ + public int OrderId { get; set; } + public decimal OrderTotal { get; set; } + public DateTime CreatedOnUtc { get; set; } + public string OrderStatus { get; set; } = string.Empty; + public string PaymentStatus { get; set; } = string.Empty; +} diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml new file mode 100644 index 0000000..130b1d3 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml @@ -0,0 +1,151 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.CustomerCreditModel +@using Nop.Web.Framework.UI + +@{ + // Layout = "_FruitBankAdminLayout"; + var remaining = Model.RemainingCredit; + var statusClass = !Model.HasCreditLimit ? "status-unlimited" + : remaining <= 0 ? "status-blocked" + : remaining < Model.CreditLimit * 0.2m ? "status-warning" + : "status-ok"; +} + + + + + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer") + + +
+

+ @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle") — @Model.CustomerName (@Model.CustomerEmail) +

+
+ +@* ── Summary cards ── *@ +
+
+
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")
+
@(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : "—")
+
+
+
@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 + } +
+
+
+ +@* ── Edit form ── *@ +
+
+

@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle")

+
+
+
+ + + +
+
+ +
+
+ + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint") +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +@* ── Unpaid orders table ── *@ +
+
+

@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle") (@Model.UnpaidOrders.Count)

+
+
+ @if (!Model.UnpaidOrders.Any()) + { +

@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders")

+ } + else + { + + + + + + + + + + + + + @foreach (var o in Model.UnpaidOrders) + { + + + + + + + + + } + + + + + + + + +
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus")
#@o.OrderId@o.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm")@o.OrderTotal.ToString("N0") Ft@o.OrderStatus@o.PaymentStatus + + + +
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total")@Model.OutstandingBalance.ToString("N0") Ft
+ } +
+
diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml new file mode 100644 index 0000000..2a918eb --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml @@ -0,0 +1,200 @@ +@{ + ViewBag.PageTitle = "Hitelkeretek"; + NopHtml.SetActiveMenuItemSystemName("CustomerCredit.List"); +} + +
+

@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle")

+
+ +
+
+
+
+ @Html.AntiForgeryToken() + + + + + + + + + + + + +
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit") ✏️@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")
+
+
+
+
+ + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml new file mode 100644 index 0000000..8477c55 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml @@ -0,0 +1,634 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.Order.OrderSearchModelExtended + +@using FruitBank.Common.Interfaces +@using Nop.Services.Stores +@using Nop.Web.Areas.Admin.Components +@using Nop.Web.Areas.Admin.Models.Orders +@using Nop.Web.Framework.Infrastructure +@inject IStoreService storeService + +@{ + // Layout = "~/Areas/Admin/Views/Shared/_LayoutAdmin.cshtml"; + ViewBag.PageTitle = "FruitBank Rendelések"; + NopHtml.SetActiveMenuItemSystemName("Orders"); +} + +@* ── Action buttons ─────────────────────────────────────────────── *@ +
+
+

Rendelések

+
+ +
+ + + +
+
+ + + +
+
+
+
+ +
+
+ + @* ── Filter Panel ─────────────────────────────────────────────── *@ + + + @* ── Grid ─────────────────────────────────────────────────────── *@ +
+
+ @* Anti-forgery token for AJAX POSTs *@ + @Html.AntiForgeryToken() + + + + + + + + + + + + + + + + + + + +
Rendelés #PartnerInnVoiceSúlyMérhetőMérésÁtvétel ✏️StátuszFizetésSzállításLétrehozvaÖsszeg
+
+ +
+ +
+
+ + +@* ── Export selected – XML ──────────────────────────────────────── *@ +
+ +
+@* ── Export selected – Excel ────────────────────────────────────── *@ +
+ +
+@* ── PDF selected ───────────────────────────────────────────────── *@ +
+ +
+ +@* ── Create Order Modal ─────────────────────────────────────────── *@ + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs b/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs new file mode 100644 index 0000000..405f26c --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Components/CustomerCreditWidgetViewComponent.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Mvc; +using Nop.Plugin.Misc.FruitBankPlugin.Models; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Web.Areas.Admin.Models.Customers; +using Nop.Web.Framework.Components; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Components; + +[ViewComponent(Name = "CustomerCreditWidget")] +public class CustomerCreditWidgetViewComponent : NopViewComponent +{ + private readonly ICustomerCreditService _customerCreditService; + + public CustomerCreditWidgetViewComponent(ICustomerCreditService customerCreditService) + { + _customerCreditService = customerCreditService; + } + + public async Task InvokeAsync(string widgetZone, object additionalData) + { + if (additionalData is not CustomerModel customerModel) return Content(""); + + var customerId = customerModel.Id; + var credit = await _customerCreditService.GetByCustomerIdAsync(customerId); + var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId); + + var model = new CustomerCreditWidgetModel + { + CustomerId = customerId, + HasCreditLimit = credit != null, + CreditLimit = credit?.CreditLimit ?? 0m, + OutstandingBalance = outstanding, + RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null, + Comment = credit?.Comment + }; + + return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/CustomerCreditDbTable.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/CustomerCreditDbTable.cs new file mode 100644 index 0000000..2ce2714 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/CustomerCreditDbTable.cs @@ -0,0 +1,27 @@ +using FruitBank.Common.Entities; +using LinqToDB; +using Mango.Nop.Core.Loggers; +using Mango.Nop.Data.Repositories; +using Nop.Core.Caching; +using Nop.Core.Configuration; +using Nop.Core.Events; +using Nop.Data; + + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +public class CustomerCreditDbTable : MgDbTableBase +{ + public CustomerCreditDbTable( + IEventPublisher eventPublisher, + INopDataProvider dataProvider, + IShortTermCacheManager shortTermCacheManager, + IStaticCacheManager staticCacheManager, + AppSettings appSettings) + : base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings) + { + } + + public Task GetByCustomerIdAsync(int customerId) + => GetAll().FirstOrDefaultAsync(x => x.CustomerId == customerId); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs index 8b05e30..ff35b13 100644 --- a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/FruitBankDbContext.cs @@ -40,7 +40,8 @@ public class FruitBankDbContext : MgDbContextBase, IShippingItemPalletDbSet, IOrderItemPalletDbSet, IShippingDocumentToFilesDbSet, - IFilesDbSet + IFilesDbSet, + ICustomerCreditDbSet { private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly IStoreContext _storeContext; @@ -64,6 +65,7 @@ public class FruitBankDbContext : MgDbContextBase, public FilesDbTable Files { get; set; } public ShippingDocumentToFilesDbTable ShippingDocumentToFiles { get; set; } public StockQuantityHistoryDtoDbTable StockQuantityHistoryDtos { get; set; } + public CustomerCreditDbTable CustomerCredits { get; set; } public IRepository Customers { get; set; } public IRepository CustomerRoles { get; set; } @@ -79,7 +81,7 @@ public class FruitBankDbContext : MgDbContextBase, PartnerDbTable partnerDbTable, ShippingDbTable shippingDbTable, ShippingDocumentDbTable shippingDocumentDbTable, ShippingItemDbTable shippingItemDbTable, ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable, ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable, - StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, + StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable, IProductService productService, IStaticCacheManager staticCacheManager, IRepository orderRepository, IRepository orderItemRepository, @@ -127,6 +129,7 @@ public class FruitBankDbContext : MgDbContextBase, StockQuantityHistories = stockQuantityHistories; StockQuantityHistoriesExt = stockQuantityHistoriesExt; StockQuantityHistoryDtos = stockQuantityHistoryDtos; + CustomerCredits = customerCreditDbTable; } public IQueryable GetCustomersBySystemRoleName(string systemRoleName) diff --git a/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/Interfaces/ICustomerCreditDbSet.cs b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/Interfaces/ICustomerCreditDbSet.cs new file mode 100644 index 0000000..b565f75 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Domains/DataLayer/Interfaces/ICustomerCreditDbSet.cs @@ -0,0 +1,10 @@ +using FruitBank.Common.Entities; +using Mango.Nop.Data.Interfaces; +using Nop.Data; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces; + +public interface ICustomerCreditDbSet : IMgDbTableBase where TDbTable : IRepository +{ + public TDbTable CustomerCredits { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Factories/MgBase/MgOrderModelFactory.cs b/Nop.Plugin.Misc.AIPlugin/Factories/MgBase/MgOrderModelFactory.cs index eb126c8..6fb4141 100644 --- a/Nop.Plugin.Misc.AIPlugin/Factories/MgBase/MgOrderModelFactory.cs +++ b/Nop.Plugin.Misc.AIPlugin/Factories/MgBase/MgOrderModelFactory.cs @@ -331,7 +331,9 @@ public class MgOrderModelFactory : OrderMode public virtual async Task PrepareOrderListModelExtendedAsync(OrderSearchModelExtended searchModel, Func dataItemCopiedCallback) { var customerCompany = searchModel.BillingCompany; - var customer = await _customerService.GetCustomerByIdAsync(Convert.ToInt32(customerCompany)); + var customer = int.TryParse(customerCompany, out var customerId) && customerId > 0 + ? await _customerService.GetCustomerByIdAsync(customerId) + : null; //var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase)); //var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase)); OrderListModel prefiltered; diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs index 49f2c2f..3b4c253 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs @@ -3,13 +3,16 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; using Nop.Plugin.Misc.FruitBankPlugin.Components; +using Nop.Core.Domain.Messages; using Nop.Services.Cms; using Nop.Services.Configuration; using Nop.Services.Localization; +using Nop.Services.Messages; using Nop.Services.Plugins; using Nop.Services.Security; using Nop.Web.Framework.Infrastructure; using Nop.Web.Framework.Menu; +using Nop.Plugin.Misc.FruitBankPlugin.Services; namespace Nop.Plugin.Misc.FruitBankPlugin @@ -28,6 +31,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin protected readonly ILocalizationService _localizationService; protected readonly IUrlHelperFactory _urlHelperFactory; private readonly IAdminMenu _adminMenu; + private readonly IMessageTemplateService _messageTemplateService; //handle AdminMenuCreatedEvent @@ -38,7 +42,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin ILocalizationService localizationService, IPermissionService permissionService, IUrlHelperFactory urlHelperFactory, - IAdminMenu adminMenu) + IAdminMenu adminMenu, + IMessageTemplateService messageTemplateService) { _actionContextAccessor = actionContextAccessor; _settingService = settingService; @@ -47,6 +52,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin _urlHelperFactory = urlHelperFactory; _adminMenu = adminMenu; _permissionService = permissionService; + _messageTemplateService = messageTemplateService; } // --- INSTALL --- @@ -192,6 +198,91 @@ namespace Nop.Plugin.Misc.FruitBankPlugin await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "Invalid product or quantity", en); await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "\u00c9rv\u00e9nytelen term\u00e9k vagy mennyis\u00e9g", hu); + // ── Customer Credit ──────────────────────────────────────────────────── + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "Customer Credit Management", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "\u00dcgyf\u00e9l hitelkeret kezel\u00e9s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer", "Back to customer", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer", "Vissza az \u00fcgyf\u00e9lhez", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle", "Set Credit Limit", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle", "Hitelkeret be\u00e1ll\u00edt\u00e1sa", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit", "Credit Limit", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit", "Hitelkeret", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint", "Set to 0 to block all orders. Leave the record absent to allow unlimited.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint", "0 eset\u00e9n minden rendel\u00e9s le van tiltva. Ha nincs rekord, a limit korl\u00e1tlan.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance", "Outstanding Balance", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance", "Kintlév\u0151 egyenleg", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit", "Remaining Credit", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit", "Szabad keret", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited", "Unlimited", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited", "Korl\u00e1tlan", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment", "Notes", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment", "Megjegyz\u00e9s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save", "Save", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save", "Ment\u00e9s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle", "Unpaid / Pending Orders", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle", "Kifizetetlen / f\u00fcgg\u0151 rendel\u00e9sek", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders", "No unpaid orders.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders", "Nincs kifizetetlen rendel\u00e9s.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId", "Order #", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId", "Rendel\u00e9s #", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate", "Date", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate", "D\u00e1tum", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal", "Total", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal", "\u00d6sszeg", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus", "Order Status", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus", "Rendel\u00e9s \u00e1llapot", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus", "Payment Status", en); + 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); + 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); + + // ── Order Started email template ──────────────────────────────────── + var existingStartedTemplate = await _messageTemplateService + .GetMessageTemplatesByNameAsync(FruitBankNotificationService.ORDER_STARTED_TEMPLATE_NAME, 0); + if (!existingStartedTemplate.Any()) + { + await _messageTemplateService.InsertMessageTemplateAsync(new MessageTemplate + { + Name = FruitBankNotificationService.ORDER_STARTED_TEMPLATE_NAME, + Subject = "%Store.Name% - Rendelésed feldolgozás alatt (#%Order.OrderNumber%)", + Body = "

Kedves %Order.CustomerFullName%,

" + + "

Rendelésedet (#%Order.OrderNumber%) elkezdtük feldolgozni.

" + + "%Order.MeasurableNote%" + + "

Amint elkészül, értesítünk!

" + + "

%Store.Name% csapata

", + IsActive = true, + EmailAccountId = 0, + LimitedToStores = false, + AllowDirectReply = false, + AttachedDownloadId = 0, + }); + } + + // ── Order Audited email template ───────────────────────────────────── + var existingTemplate = await _messageTemplateService + .GetMessageTemplatesByNameAsync(FruitBankNotificationService.ORDER_AUDITED_TEMPLATE_NAME, 0); + if (existingTemplate.Count == 0) + { + await _messageTemplateService.InsertMessageTemplateAsync(new MessageTemplate + { + Name = FruitBankNotificationService.ORDER_AUDITED_TEMPLATE_NAME, + Subject = "%Store.Name% - Rendelésed elkészült (#%Order.OrderNumber%)", + Body = "

Kedves %Order.CustomerFullName%,

" + + "

Rendelésed (#%Order.OrderNumber%) elkészült és átvételre vár.

" + + "%Order.MeasurableNote%" + + "

Végleges összeg: %Order.OrderTotal%

" + + "

Köszönjük a rendelésedet!

" + + "

%Store.Name% csapata

", + IsActive = true, + EmailAccountId = 0, // 0 = use store default + LimitedToStores = false, + AllowDirectReply = false, + AttachedDownloadId = 0, + }); + } + await base.InstallAsync(); } @@ -207,7 +298,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin public Task> GetWidgetZonesAsync() { - return Task.FromResult>(new List { PublicWidgetZones.ProductBoxAddinfoBefore, PublicWidgetZones.ProductDetailsBottom, AdminWidgetZones.ProductDetailsBlock, AdminWidgetZones.OrderDetailsBlock }); + return Task.FromResult>(new List + { + PublicWidgetZones.ProductBoxAddinfoBefore, + PublicWidgetZones.ProductDetailsBottom, + AdminWidgetZones.ProductDetailsBlock, + AdminWidgetZones.OrderDetailsBlock, + AdminWidgetZones.CustomerDetailsBlock + }); } public async Task ManageSiteMapAsync(AdminMenuItem rootNode) @@ -232,15 +330,19 @@ namespace Nop.Plugin.Misc.FruitBankPlugin { if (widgetZone == PublicWidgetZones.ProductBoxAddinfoBefore || widgetZone == PublicWidgetZones.ProductDetailsBottom) { - return zones.Any(widgetZone.Equals) ? typeof(ProductAIWidgetViewComponent) : null; + return typeof(ProductAIWidgetViewComponent); } else if (widgetZone == AdminWidgetZones.ProductDetailsBlock) { - return zones.Any(widgetZone.Equals) ? typeof(ProductAttributesViewComponent) : null; + return typeof(ProductAttributesViewComponent); } else if (widgetZone == AdminWidgetZones.OrderDetailsBlock) { - return zones.Any(widgetZone.Equals) ? typeof(OrderAttributesViewComponent) : null; + return typeof(OrderAttributesViewComponent); + } + else if (widgetZone == AdminWidgetZones.CustomerDetailsBlock) + { + return typeof(CustomerCreditWidgetViewComponent); } } diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs index 2d81000..f46ebf6 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs @@ -81,6 +81,7 @@ public class PluginNopStartup : INopStartup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -127,8 +128,7 @@ public class PluginNopStartup : INopStartup //services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); - + services.AddScoped(); services.AddSingleton(sp => new LocalFileStorageProvider() // Uses default wwwroot/uploads // Or specify custom path: @@ -137,6 +137,7 @@ public class PluginNopStartup : INopStartup // Register the file storage service services.AddScoped(); + services.AddScoped(); services.AddControllersWithViews(options => { diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs index 6e2d201..28a72ea 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs @@ -21,58 +21,60 @@ public class RouteProvider : IRouteProvider pattern: "Admin/FruitBankPlugin/Configure", defaults: new { controller = "FruitBankPluginAdmin", action = "Configure", area = AreaNames.ADMIN }); - //endpointRouteBuilder.MapHub("/fbhub");//.RequireCors("AllowBlazorClient"); - endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Order.List", pattern: "Admin/Order/List", - defaults: new { controller = "CustomOrder", action = "List", area = AreaNames.ADMIN } - ); + defaults: new { controller = "CustomOrder", action = "NewList", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Order.OrderList", pattern: "Admin/Order/OrderList", defaults: new { controller = "CustomOrder", action = "OrderList", area = AreaNames.ADMIN }); + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.Order.FruitBankOrderList", + pattern: "Admin/CustomOrder/FruitBankOrderList", + defaults: new { controller = "CustomOrder", action = "FruitBankOrderList", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.Order.UpdateOrderField", + pattern: "Admin/CustomOrder/UpdateOrderField", + defaults: new { controller = "CustomOrder", action = "UpdateOrderField", area = AreaNames.ADMIN }); + endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Order.Test", pattern: "Admin/Order/Test", - defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN } - ); + defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Index", pattern: "Admin", - defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN } - ); + defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Shipping.List", pattern: "Admin/Shipping/List", - defaults: new { controller = "Shipping", action = "List", area = AreaNames.ADMIN } - ); + defaults: new { controller = "Shipping", action = "List", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Shipping.ShippingList", pattern: "Admin/Shipping/ShippingList", - defaults: new { controller = "Shipping", action = "ShippingList", area = AreaNames.ADMIN } - ); + defaults: new { controller = "Shipping", action = "ShippingList", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Invoices.List", pattern: "Admin/Invoices/List", - defaults: new { controller = "Invoice", action = "List", area = AreaNames.ADMIN } - ); + defaults: new { controller = "Invoice", action = "List", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( - name: "Plugin.FruitBank.Admin.Shipping.Create", - pattern: "Admin/Shipping/Create", - defaults: new { controller = "Shipping", action = "Create", area = AreaNames.ADMIN }); + name: "Plugin.FruitBank.Admin.Shipping.Create", + pattern: "Admin/Shipping/Create", + defaults: new { controller = "Shipping", action = "Create", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( - name: "Plugin.FruitBank.Admin.Shipping.Edit", - pattern: "Admin/Shipping/Edit", - defaults: new { controller = "Shipping", action = "Edit", area = AreaNames.ADMIN }); + name: "Plugin.FruitBank.Admin.Shipping.Edit", + pattern: "Admin/Shipping/Edit", + defaults: new { controller = "Shipping", action = "Edit", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Shipping.UploadFile", @@ -118,7 +120,7 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.Admin.Products.List", pattern: "Admin/Product/List", defaults: new { controller = "CustomProduct", action = "List", area = AreaNames.ADMIN }); - + endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Products.ProductList", pattern: "Admin/Product/ProductList", @@ -150,9 +152,9 @@ public class RouteProvider : IRouteProvider defaults: new { controller = "CustomOrder", action = "Edit", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( - name: "Plugin.FruitBank.Admin.Order.AddProduct", - pattern: "Admin/CustomOrder/FruitBankAddProductToOrder", - defaults: new { controller = "CustomOrder", action = "FruitBankAddProductToOrder", area = AreaNames.ADMIN }); + name: "Plugin.FruitBank.Admin.Order.AddProduct", + pattern: "Admin/CustomOrder/FruitBankAddProductToOrder", + defaults: new { controller = "CustomOrder", action = "FruitBankAddProductToOrder", area = AreaNames.ADMIN }); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.ManagementPage.ProcessShippingDocument", @@ -179,6 +181,22 @@ public class RouteProvider : IRouteProvider pattern: "Admin/ExtractText", defaults: new { controller = "FileManager", action = "ImageTextExtraction", area = AreaNames.ADMIN }); + // ── Customer Credit ────────────────────────────────────────────────── + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.CustomerCredit.List", + pattern: "Admin/CustomerCredit/List", + defaults: new { controller = "CustomerCredit", action = "List", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.CustomerCredit.CustomerCreditList", + pattern: "Admin/CustomerCredit/CustomerCreditList", + defaults: new { controller = "CustomerCredit", action = "CustomerCreditList", area = AreaNames.ADMIN }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.Admin.CustomerCredit.UpdateCreditLimit", + pattern: "Admin/CustomerCredit/UpdateCreditLimit", + defaults: new { controller = "CustomerCredit", action = "UpdateCreditLimit", area = AreaNames.ADMIN }); + // ── Public: Quick Order ────────────────────────────────────────────── endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.QuickOrder.Index", diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml new file mode 100644 index 0000000..caefd73 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.en.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml new file mode 100644 index 0000000..8bfe77d --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Localization/customercredit.hu.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs b/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs index e4d2e6f..a38de4c 100644 --- a/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs +++ b/Nop.Plugin.Misc.AIPlugin/Mapping/NameCompatibility.cs @@ -42,6 +42,7 @@ public partial class NameCompatibility : INameCompatibility { typeof(StockTaking), FruitBankConstClient.StockTakingDbTableName}, { typeof(StockTakingItem), FruitBankConstClient.StockTakingItemDbTableName}, { typeof(StockTakingItemPallet), FruitBankConstClient.StockTakingItemPalletDbTableName}, + { typeof(CustomerCredit), FruitBankConstClient.CustomerCreditDbTableName}, }; diff --git a/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs b/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs new file mode 100644 index 0000000..7570ed7 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Models/CustomerCreditWidgetModel.cs @@ -0,0 +1,21 @@ +using Nop.Web.Framework.Models; +using Nop.Web.Framework.Mvc.ModelBinding; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Models; + +public record CustomerCreditWidgetModel : BaseNopModel +{ + public int CustomerId { get; set; } + public bool HasCreditLimit { get; set; } + + [NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")] + public decimal CreditLimit { get; set; } + + [NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")] + public decimal OutstandingBalance { get; set; } + + [NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")] + public decimal? RemainingCredit { get; set; } + + public string? Comment { get; set; } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Models/Orders/FruitBankOrderRowDto.cs b/Nop.Plugin.Misc.AIPlugin/Models/Orders/FruitBankOrderRowDto.cs new file mode 100644 index 0000000..46a5671 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Models/Orders/FruitBankOrderRowDto.cs @@ -0,0 +1,35 @@ +using System; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders +{ + /// + /// Lightweight DTO returned by the FruitBankOrderList endpoint. + /// Contains only what the grid needs – avoids serialising heavy OrderModel navigation properties. + /// + public record FruitBankOrderRowDto + { + public int Id { get; init; } + public string CustomOrderNumber { get; init; } + public string CustomerCompany { get; init; } + public int CustomerId { get; init; } + + // FruitBank-specific fields + public string InnvoiceTechId { get; init; } + public bool IsAllOrderItemAvgWeightValid { get; init; } + public bool IsMeasurable { get; init; } + public int MeasuringStatus { get; init; } + public string MeasuringStatusString { get; init; } + public DateTime? DateOfReceipt { get; init; } + + // NopCommerce order fields + public int OrderStatusId { get; init; } + public string OrderStatus { get; init; } + public int PaymentStatusId { get; init; } + public string PaymentStatus { get; init; } + public int ShippingStatusId { get; init; } + public string ShippingStatus { get; init; } + public string StoreName { get; init; } + public DateTime CreatedOn { get; init; } + public string OrderTotal { get; init; } + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj index a5ecaa7..50ae569 100644 --- a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj +++ b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj @@ -173,6 +173,12 @@ Always + + Always + + + Always + Always @@ -185,6 +191,9 @@ Always + + Always + Always @@ -650,6 +659,9 @@ Always + + Always + Always diff --git a/Nop.Plugin.Misc.AIPlugin/Services/CustomerCreditService.cs b/Nop.Plugin.Misc.AIPlugin/Services/CustomerCreditService.cs new file mode 100644 index 0000000..1bdce5b --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/CustomerCreditService.cs @@ -0,0 +1,71 @@ +using FruitBank.Common.Entities; +using Nop.Core.Domain.Orders; +using Nop.Core.Domain.Payments; +using Nop.Data; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +public class CustomerCreditService : ICustomerCreditService +{ + private readonly CustomerCreditDbTable _customerCreditDbTable; + private readonly IRepository _orderRepository; + + public CustomerCreditService( + CustomerCreditDbTable customerCreditDbTable, + IRepository orderRepository) + { + _customerCreditDbTable = customerCreditDbTable; + _orderRepository = orderRepository; + } + + public Task GetByCustomerIdAsync(int customerId) + => _customerCreditDbTable.GetByCustomerIdAsync(customerId); + + public async Task SaveAsync(CustomerCredit entity) + { + entity.UpdatedOnUtc = DateTime.UtcNow; + + if (entity.Id <= 0) + { + entity.CreatedOnUtc = DateTime.UtcNow; + await _customerCreditDbTable.InsertAsync(entity); + } + else + { + await _customerCreditDbTable.UpdateAsync(entity); + } + } + + public Task DeleteAsync(CustomerCredit entity) + => _customerCreditDbTable.DeleteAsync(entity); + + public async Task GetOutstandingBalanceAsync(int customerId) + { + return await _orderRepository.Table + .Where(o => + o.CustomerId == customerId && + o.OrderStatusId != (int)OrderStatus.Cancelled && + (o.PaymentStatusId == (int)PaymentStatus.Pending || + o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded)) + .SumAsync(o => (decimal?)o.OrderTotal) ?? 0m; + } + + public async Task GetRemainingCreditAsync(int customerId) + { + var credit = await GetByCustomerIdAsync(customerId); + if (credit == null) return null; + + var outstanding = await GetOutstandingBalanceAsync(customerId); + return credit.CreditLimit - outstanding; + } + + public async Task IsOrderAllowedAsync(int customerId, decimal newOrderTotal) + { + var credit = await GetByCustomerIdAsync(customerId); + if (credit == null) return true; + + var outstanding = await GetOutstandingBalanceAsync(customerId); + return outstanding + newOrderTotal <= credit.CreditLimit; + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs b/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs index febb2de..aecf2cd 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/EventConsumer.cs @@ -1,4 +1,5 @@ -using FruitBank.Common.Interfaces; +using FruitBank.Common.Enums; +using FruitBank.Common.Interfaces; using FruitBank.Common.Server; using Mango.Nop.Core.Dtos; using Microsoft.AspNetCore.Http; @@ -8,6 +9,7 @@ using Nop.Core; using Nop.Core.Domain.Catalog; using Nop.Core.Domain.Common; using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Messages; using Nop.Core.Domain.Orders; using Nop.Core.Domain.Tax; using Nop.Core.Events; @@ -20,11 +22,13 @@ using Nop.Services.Common; using Nop.Services.Customers; using Nop.Services.Events; using Nop.Services.Localization; +using Nop.Services.Messages; using Nop.Services.Orders; using Nop.Services.Plugins; using Nop.Web.Framework.Events; using Nop.Web.Framework.Menu; using Nop.Web.Models.Sitemap; +using NUglify.JavaScript.Syntax; using System.Linq; using System.Xml.Linq; @@ -47,6 +51,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services private readonly FruitBankDbContext _dbContext; private readonly IAttributeParser _attributeParser; private readonly ICustomerService _customerService; + private readonly IWorkflowMessageService _workflowMessageService; + private readonly FruitBankNotificationService _fruitBankNotificationService; public EventConsumer( IGenericAttributeService genericAttributeService, @@ -64,7 +70,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services FruitBankAttributeService fruitBankAttributeService, FruitBankDbContext dbContext, IAttributeParser attributeParser, - ICustomerService customerService + ICustomerService customerService, + IWorkflowMessageService workflowMessageService, + FruitBankNotificationService fruitBankNotificationService ) : base(pluginManager) { _genericAttributeService = genericAttributeService; @@ -82,6 +90,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services _dbContext = dbContext; _attributeParser = attributeParser; _customerService = customerService; + _workflowMessageService = workflowMessageService; + _fruitBankNotificationService = fruitBankNotificationService; } protected override string PluginSystemName => "Misc.FruitBankPlugin"; @@ -93,6 +103,43 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services public async Task HandleEventAsync(EntityUpdatedEvent eventMessage) { await SaveOrderCustomAttributesAsync(eventMessage.Entity); + if (eventMessage.Entity == null) return; + + var orderDto = await _dbContext.OrderDtos.GetByIdAsync(eventMessage.Entity.Id, true); + if (orderDto == null) return; + + if (orderDto.MeasuringStatus == MeasuringStatus.Audited) + { + var alreadySent = await _fruitBankAttributeService + .GetGenericAttributeValueAsync(eventMessage.Entity.Id, "OrderAuditedNotificationSent"); + + if (!alreadySent) + { + await _fruitBankNotificationService + .SendOrderAuditedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable); + + await _fruitBankAttributeService + .InsertOrUpdateGenericAttributeAsync( + eventMessage.Entity.Id, "OrderAuditedNotificationSent", true); + } + } + else if (orderDto.MeasuringStatus == MeasuringStatus.Started) + { + var alreadySent = await _fruitBankAttributeService + .GetGenericAttributeValueAsync(eventMessage.Entity.Id, "OrderStartedNotificationSent"); + + if (!alreadySent) + { + await _fruitBankNotificationService + .SendOrderStartedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable); + + await _fruitBankAttributeService + .InsertOrUpdateGenericAttributeAsync( + eventMessage.Entity.Id, "OrderStartedNotificationSent", true); + } + } + + } @@ -182,10 +229,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services IconClass = "fas fa-microphone", Url = _adminMenu.GetMenuItemUrl("VoiceOrder", "Create") //ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem] - + }; - + shippingConfigurationItem.ChildNodes.Insert(3, voiceOrderMenuItem); diff --git a/Nop.Plugin.Misc.AIPlugin/Services/FruitBankNotificationService.cs b/Nop.Plugin.Misc.AIPlugin/Services/FruitBankNotificationService.cs new file mode 100644 index 0000000..61be177 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/FruitBankNotificationService.cs @@ -0,0 +1,84 @@ +using Nop.Core; +using Nop.Core.Domain.Messages; +using Nop.Core.Domain.Orders; +using Nop.Services.Customers; +using Nop.Services.Messages; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +public class FruitBankNotificationService( + IMessageTemplateService messageTemplateService, + IEmailAccountService emailAccountService, + EmailAccountSettings emailAccountSettings, + IMessageTokenProvider messageTokenProvider, + IWorkflowMessageService workflowMessageService, + ICustomerService customerService, + IStoreContext storeContext) +{ + public const string ORDER_AUDITED_TEMPLATE_NAME = "FruitBank.OrderAudited.CustomerNotification"; + public const string ORDER_STARTED_TEMPLATE_NAME = "FruitBank.OrderStarted.CustomerNotification"; + + /// + /// Sends the "order started" (being prepared) customer notification. + /// For measurable orders, informs the customer that final prices will be + /// confirmed after weighing. Fires once when MeasuringStatus transitions to Started. + /// + public async Task SendOrderStartedCustomerNotificationAsync(Order order, bool isMeasurable) + { + var measurableNote = isMeasurable + ? "

Rendelésed mérhető tételeket tartalmaz. A végleges ár a mérés után kerül megerősítésre.

" + : string.Empty; + + return await SendNotificationAsync(ORDER_STARTED_TEMPLATE_NAME, order, measurableNote); + } + + /// + /// Sends the "order audited" customer notification. + /// For measurable orders, confirms that weights have been recorded and + /// the final price is as shown on the order. + /// Fires once when MeasuringStatus transitions to Audited. + /// + public async Task SendOrderAuditedCustomerNotificationAsync(Order order, bool isMeasurable) + { + var measurableNote = isMeasurable + ? "

A mért tételek súlyait rögzítettük, a végleges ár a rendelésen feltüntetett összeg.

" + : string.Empty; + + return await SendNotificationAsync(ORDER_AUDITED_TEMPLATE_NAME, order, measurableNote); + } + + // ── shared core ───────────────────────────────────────────────────────── + + private async Task SendNotificationAsync(string templateName, Order order, string measurableNote) + { + var store = await storeContext.GetCurrentStoreAsync(); + + var templates = await messageTemplateService.GetMessageTemplatesByNameAsync(templateName, store.Id); + var messageTemplate = templates?.FirstOrDefault(); + + if (messageTemplate is null || !messageTemplate.IsActive) + return 0; + + var emailAccount = await emailAccountService.GetEmailAccountByIdAsync(messageTemplate.EmailAccountId) + ?? await emailAccountService.GetEmailAccountByIdAsync(emailAccountSettings.DefaultEmailAccountId); + + var tokens = new List(); + await messageTokenProvider.AddStoreTokensAsync(tokens, store, emailAccount, order.CustomerLanguageId); + await messageTokenProvider.AddOrderTokensAsync(tokens, order, order.CustomerLanguageId); + + var customer = await customerService.GetCustomerByIdAsync(order.CustomerId); + await messageTokenProvider.AddCustomerTokensAsync(tokens, customer); + + tokens.Add(new Token("Order.MeasurableNote", measurableNote, true)); + + var toEmail = customer.Email; + var toName = $"{customer.FirstName} {customer.LastName}".Trim(); + if (string.IsNullOrWhiteSpace(toName)) toName = customer.Email; + + return await workflowMessageService.SendNotificationAsync( + messageTemplate, emailAccount, + order.CustomerLanguageId, + tokens, + toEmail, toName); + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/Services/ICustomerCreditService.cs b/Nop.Plugin.Misc.AIPlugin/Services/ICustomerCreditService.cs new file mode 100644 index 0000000..e08c4f9 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Services/ICustomerCreditService.cs @@ -0,0 +1,32 @@ +using FruitBank.Common.Entities; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Services; + +public interface ICustomerCreditService +{ + /// Gets the credit record for a customer, or null if none exists (= unlimited). + Task GetByCustomerIdAsync(int customerId); + + /// Insert or update a customer credit record. + Task SaveAsync(CustomerCredit entity); + + /// Delete the credit record for a customer, restoring unlimited access. + Task DeleteAsync(CustomerCredit entity); + + /// + /// Sum of OrderTotal for all pending/unpaid, non-cancelled orders for the customer. + /// + Task GetOutstandingBalanceAsync(int customerId); + + /// + /// CreditLimit - OutstandingBalance. Returns null if no credit record exists (= unlimited). + /// + Task GetRemainingCreditAsync(int customerId); + + /// + /// Returns true if the customer is allowed to place a new order with the given total. + /// Rule: no credit record = always allowed. + /// Otherwise: OutstandingBalance + newOrderTotal must be <= CreditLimit. + /// + Task IsOrderAllowedAsync(int customerId, decimal newOrderTotal); +} diff --git a/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml new file mode 100644 index 0000000..1fe1975 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Views/CustomerCreditWidget.cshtml @@ -0,0 +1,83 @@ +@model Nop.Plugin.Misc.FruitBankPlugin.Models.CustomerCreditWidgetModel + +@{ + var remaining = Model.RemainingCredit; + var statusClass = !Model.HasCreditLimit ? "text-muted" + : remaining <= 0 ? "text-danger" + : remaining < Model.CreditLimit * 0.2m ? "text-warning" + : "text-success"; +} + +
+
+ + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle") +
+
+ +
+
+ +
+
+ + @(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text) + +
+
+ +
+
+ +
+
+ + @Model.OutstandingBalance.ToString("N0") Ft + +
+
+ +
+
+ +
+
+ + + @if (!Model.HasCreditLimit) + { + @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited") + } + else + { + @(remaining!.Value.ToString("N0")) + Ft + } + + +
+
+ + @if (!string.IsNullOrWhiteSpace(Model.Comment)) + { +
+
+ +
+
+ @Model.Comment +
+
+ } + + + +
+