site, license plate

This commit is contained in:
Adam 2026-06-27 01:57:56 +02:00
parent d568931bd9
commit d5f151b5ac
17 changed files with 766 additions and 17 deletions

View File

@ -86,4 +86,35 @@ public class CustomerAttributesController : BasePluginController
return Json(new { success = true, siteCount = entries.Count, defaultAddressId = effectiveDefault });
}
[HttpPost]
[Route("Admin/CustomerAttributes/SaveLicensePlates")]
public async Task<IActionResult> SaveLicensePlates(int customerId, string[] plates, string? defaultPlate)
{
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" });
var cleaned = (plates ?? Array.Empty<string>())
.Select(p => (p ?? string.Empty).Trim().ToUpperInvariant())
.Where(p => p.Length > 0)
.Distinct()
.ToList();
var def = (defaultPlate ?? string.Empty).Trim().ToUpperInvariant();
var effectiveDefault = cleaned.Contains(def) ? def : null;
var entries = cleaned
.Select(p => new CustomerLicensePlateEntry { Plate = p, IsDefault = p == effectiveDefault })
.ToList();
var json = JsonSerializer.Serialize(entries);
await _attributeService.InsertOrUpdateGenericAttributeAsync<Customer, string>(
customerId, FruitBankPluginConst.CustomerLicensePlatesAttribute, json, storeId: 0);
return Json(new { success = true, plateCount = entries.Count, defaultPlate = effectiveDefault });
}
}

View File

@ -0,0 +1,114 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Orders;
using Nop.Plugin.Misc.FruitBankPlugin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Customers;
using Nop.Services.Orders;
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;
/// <summary>
/// Inline-save endpoints for the FruitBank order attributes (site / license plate)
/// on the admin order page (rendered by OrderAttributesViewComponent). Stored as
/// store-agnostic (StoreId 0) order generic attributes via <see cref="FruitBankAttributeService"/>,
/// using the same keys the OrderDto exposes.
/// </summary>
[AuthorizeAdmin]
[Area(AreaNames.ADMIN)]
public class OrderAttributesController : BasePluginController
{
private readonly FruitBankAttributeService _attributeService;
private readonly ICustomerService _customerService;
private readonly IOrderService _orderService;
private readonly IPermissionService _permissionService;
public OrderAttributesController(
FruitBankAttributeService attributeService,
ICustomerService customerService,
IOrderService orderService,
IPermissionService permissionService)
{
_attributeService = attributeService;
_customerService = customerService;
_orderService = orderService;
_permissionService = permissionService;
}
[HttpPost]
[Route("Admin/OrderAttributes/SaveSite")]
public async Task<IActionResult> SaveSite(int orderId, int addressId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, error = "Access denied" });
var order = await _orderService.GetOrderByIdAsync(orderId);
if (order == null) return Json(new { success = false, error = "Rendelés nem található" });
// Clear
if (addressId <= 0)
{
await _attributeService.DeleteGenericAttributeAsync<Order>(orderId, FruitBankPluginConst.OrderSiteAttribute, 0);
return Json(new { success = true, cleared = true });
}
// The chosen address MUST be one of the customer's sites (telephely) — not a
// shipping/billing address.
var sitesJson = await _attributeService
.GetGenericAttributeValueAsync<Customer, string>(order.CustomerId, FruitBankPluginConst.CustomerSitesAttribute, 0);
if (!ParseSiteAddressIds(sitesJson).Contains(addressId))
return Json(new { success = false, error = "A cím nem a vevő telephelye." });
var address = (await _customerService.GetAddressesByCustomerIdAsync(order.CustomerId))
.FirstOrDefault(a => a.Id == addressId);
if (address == null) return Json(new { success = false, error = "Cím nem található" });
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
var snapshot = OrderSiteSnapshotBuilder.FromAddress(address, customer?.VatNumber);
var json = Newtonsoft.Json.JsonConvert.SerializeObject(snapshot);
await _attributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(
orderId, FruitBankPluginConst.OrderSiteAttribute, json, 0);
return Json(new { success = true, display = snapshot.Display });
}
[HttpPost]
[Route("Admin/OrderAttributes/SaveLicensePlate")]
public async Task<IActionResult> SaveLicensePlate(int orderId, string? licensePlate)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, error = "Access denied" });
var order = await _orderService.GetOrderByIdAsync(orderId);
if (order == null) return Json(new { success = false, error = "Rendelés nem található" });
var plate = licensePlate?.Trim().ToUpperInvariant();
if (string.IsNullOrWhiteSpace(plate))
{
await _attributeService.DeleteGenericAttributeAsync<Order>(orderId, FruitBankPluginConst.OrderLicensePlateAttribute, 0);
return Json(new { success = true, cleared = true });
}
await _attributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(
orderId, FruitBankPluginConst.OrderLicensePlateAttribute, plate, 0);
return Json(new { success = true, licensePlate = plate });
}
private static HashSet<int> ParseSiteAddressIds(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
var entries = JsonSerializer.Deserialize<List<CustomerSiteEntry>>(json) ?? new();
return entries.Select(e => e.AddressId).ToHashSet();
}
catch { return new(); }
}
}

View File

@ -50,10 +50,31 @@ public class CustomerCreditWidgetViewComponent : NopViewComponent
};
await PopulateSitesAsync(model, customerId);
await PopulatePlatesAsync(model, customerId);
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model);
}
private async Task PopulatePlatesAsync(CustomerCreditWidgetModel model, int customerId)
{
var json = await _attributeService
.GetGenericAttributeValueAsync<Customer, string>(customerId, FruitBankPluginConst.CustomerLicensePlatesAttribute, storeId: 0);
var entries = ParsePlates(json);
model.LicensePlates = entries
.Select(e => new CustomerLicensePlateRow { Plate = e.Plate, IsDefault = e.IsDefault })
.ToList();
model.PlateCount = model.LicensePlates.Count;
model.DefaultPlate = model.LicensePlates.FirstOrDefault(p => p.IsDefault)?.Plate;
}
private static List<CustomerLicensePlateEntry> ParsePlates(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try { return JsonSerializer.Deserialize<List<CustomerLicensePlateEntry>>(json) ?? new(); }
catch { return new(); }
}
// 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)

View File

@ -1,14 +1,17 @@
// File: Plugins/YourCompany.ProductAttributes/Components/ProductAttributesViewComponent.cs
using System.Text.Json;
using FruitBank.Common.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Orders;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Common;
using Nop.Services.Customers;
using Nop.Web.Areas.Admin.Models.Catalog;
using Nop.Web.Areas.Admin.Models.Orders;
using Nop.Web.Framework.Components;
@ -21,15 +24,18 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly IWorkContext _workContext;
private readonly IStoreContext _storeContext;
private readonly ICustomerService _customerService;
private FruitBankDbContext _dbContext;
public OrderAttributesViewComponent(FruitBankDbContext dbContext, FruitBankAttributeService fruitBankAttributeService, IWorkContext workContext, IStoreContext storeContext)
public OrderAttributesViewComponent(FruitBankDbContext dbContext, FruitBankAttributeService fruitBankAttributeService,
IWorkContext workContext, IStoreContext storeContext, ICustomerService customerService)
{
_workContext = workContext;
_storeContext = storeContext;
_fruitBankAttributeService = fruitBankAttributeService;
_customerService = customerService;
_dbContext= dbContext;
_dbContext = dbContext;
}
public async Task<IViewComponentResult> InvokeAsync(string widgetZone, object additionalData)
@ -45,19 +51,69 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components
model.IsMeasurable = orderDto.IsMeasurable;
model.DateOfReceipt = orderDto.DateOfReceipt;
model.OrderDto = orderDto;
//var orderPickupAttributeValue = await _fruitBankAttributeService.GetGenericAttributeValueAsync<Order, DateTime?>(model.OrderId, nameof(IOrderDto.DateOfReceipt));
//if (orderPickupAttributeValue.HasValue && orderPickupAttributeValue.Value != DateTime.MinValue)
//{
// model.DateOfReceipt = orderPickupAttributeValue;
//}
//else
//{
// model.DateOfReceipt = null;
//}
// Site (telephely) + license plate (rendszám)
model.CustomerId = orderDto.CustomerId;
model.Site = orderDto.Site;
model.LicensePlate = orderDto.LicensePlate;
model.CustomerSites = await BuildCustomerSiteOptionsAsync(orderDto.CustomerId);
model.CustomerLicensePlateOptions = await BuildCustomerPlateOptionsAsync(orderDto.CustomerId);
}
return View("~/Plugins/Misc.FruitBankPlugin/Views/OrderAttributes.cshtml", model);
}
// The customer's current sites (telephely) → selector options. Orphaned site
// ids (deleted addresses) drop out because we iterate the live addresses.
private async Task<List<OrderSiteOption>> BuildCustomerSiteOptionsAsync(int customerId)
{
if (customerId <= 0) return new();
var json = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Customer, string>(customerId, FruitBankPluginConst.CustomerSitesAttribute, 0);
if (string.IsNullOrWhiteSpace(json)) return new();
List<CustomerSiteEntry> entries;
try { entries = JsonSerializer.Deserialize<List<CustomerSiteEntry>>(json) ?? new(); }
catch { return new(); }
if (entries.Count == 0) return new();
var byId = entries.GroupBy(e => e.AddressId).ToDictionary(g => g.Key, g => g.First());
var addresses = await _customerService.GetAddressesByCustomerIdAsync(customerId);
return addresses
.Where(a => byId.ContainsKey(a.Id))
.Select(a => new OrderSiteOption
{
AddressId = a.Id,
Display = FormatAddress(a),
IsDefault = byId[a.Id].IsDefault
})
.ToList();
}
private async Task<List<string>> BuildCustomerPlateOptionsAsync(int customerId)
{
if (customerId <= 0) return new();
var json = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Customer, string>(customerId, FruitBankPluginConst.CustomerLicensePlatesAttribute, 0);
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
var entries = JsonSerializer.Deserialize<List<CustomerLicensePlateEntry>>(json) ?? new();
return entries.Select(e => e.Plate).Where(p => !string.IsNullOrWhiteSpace(p)).Distinct().ToList();
}
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 display = string.Join(", ", new[] { name, a.Address1, locality }.Where(p => !string.IsNullOrWhiteSpace(p)));
return string.IsNullOrWhiteSpace(display) ? $"#{a.Id}" : display;
}
}
}

View File

@ -13,6 +13,9 @@ using Nop.Services.Events;
using Mango.Nop.Core.Extensions;
using Nop.Core.Domain.Orders;
using Nop.Services.Catalog;
using Nop.Services.Customers;
using Nop.Core.Domain.Customers;
using Nop.Plugin.Misc.FruitBankPlugin.Models;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.EventConsumers;
@ -31,7 +34,9 @@ public class FruitBankEventConsumer :
IConsumer<EntityInsertedEvent<OrderItem>>,
IConsumer<EntityUpdatedEvent<OrderItem>>,
IConsumer<EntityDeletedEvent<OrderItem>>
IConsumer<EntityDeletedEvent<OrderItem>>,
IConsumer<OrderPlacedEvent>
{
//private readonly CustomPriceCalculationService _customPriceCalculationService;
@ -40,16 +45,100 @@ public class FruitBankEventConsumer :
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly PreOrderConversionService _preorderConversionService;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ICustomerService _customerService;
public FruitBankEventConsumer(IHttpContextAccessor httpContextAcc, FruitBankDbContext ctx, MeasurementService measurementService,
FruitBankAttributeService fruitBankAttributeService, PreOrderConversionService preorderConversionService,
IServiceScopeFactory serviceScopeFactory, IEnumerable<IAcLogWriterBase> logWriters) : base(ctx, httpContextAcc, logWriters)
IServiceScopeFactory serviceScopeFactory, ICustomerService customerService, IEnumerable<IAcLogWriterBase> logWriters) : base(ctx, httpContextAcc, logWriters)
{
_ctx = ctx;
_measurementService = measurementService;
_fruitBankAttributeService = fruitBankAttributeService;
_preorderConversionService = preorderConversionService;
_serviceScopeFactory = serviceScopeFactory;
_customerService = customerService;
}
// ── Order placed: auto-assign the customer's DEFAULT site (telephely) and DEFAULT
// license plate (rendszám) to the order. Covers every creation path that fires
// OrderPlacedEvent (checkout, admin, preorder conversion, OrderDraft). No default
// → left empty. The two are independent (one missing must not skip the other).
public async Task HandleEventAsync(OrderPlacedEvent eventMessage)
{
var order = eventMessage.Order;
if (order == null || order.CustomerId <= 0) return;
await TryAssignDefaultSiteAsync(order);
await TryAssignDefaultPlateAsync(order);
}
private async Task TryAssignDefaultSiteAsync(Order order)
{
try
{
// Don't overwrite an already-set site (e.g. event re-publish).
var existing = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Order, string>(order.Id, FruitBankPluginConst.OrderSiteAttribute, 0);
if (!string.IsNullOrWhiteSpace(existing)) return;
var sitesJson = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Customer, string>(order.CustomerId, FruitBankPluginConst.CustomerSitesAttribute, 0);
if (string.IsNullOrWhiteSpace(sitesJson)) return;
List<CustomerSiteEntry> sites;
try { sites = System.Text.Json.JsonSerializer.Deserialize<List<CustomerSiteEntry>>(sitesJson) ?? new(); }
catch { return; }
var defaultSite = sites.FirstOrDefault(s => s.IsDefault);
if (defaultSite == null) return;
var address = (await _customerService.GetAddressesByCustomerIdAsync(order.CustomerId))
.FirstOrDefault(a => a.Id == defaultSite.AddressId);
if (address == null) return;
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
var snapshot = OrderSiteSnapshotBuilder.FromAddress(address, customer?.VatNumber);
var json = Newtonsoft.Json.JsonConvert.SerializeObject(snapshot);
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(
order.Id, FruitBankPluginConst.OrderSiteAttribute, json, 0);
Logger.Info($"[FruitBankEventConsumer] Order #{order.Id}: auto-assigned default site (address #{address.Id}).");
}
catch (Exception ex)
{
Logger.Error($"[FruitBankEventConsumer] OrderPlaced site auto-assign failed for order #{order.Id}: {ex.Message}", ex);
}
}
private async Task TryAssignDefaultPlateAsync(Order order)
{
try
{
var existing = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Order, string>(order.Id, FruitBankPluginConst.OrderLicensePlateAttribute, 0);
if (!string.IsNullOrWhiteSpace(existing)) return;
var platesJson = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Customer, string>(order.CustomerId, FruitBankPluginConst.CustomerLicensePlatesAttribute, 0);
if (string.IsNullOrWhiteSpace(platesJson)) return;
List<CustomerLicensePlateEntry> plates;
try { plates = System.Text.Json.JsonSerializer.Deserialize<List<CustomerLicensePlateEntry>>(platesJson) ?? new(); }
catch { return; }
var defaultPlate = plates.FirstOrDefault(p => p.IsDefault)?.Plate;
if (string.IsNullOrWhiteSpace(defaultPlate)) return;
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(
order.Id, FruitBankPluginConst.OrderLicensePlateAttribute, defaultPlate, 0);
Logger.Info($"[FruitBankEventConsumer] Order #{order.Id}: auto-assigned default license plate '{defaultPlate}'.");
}
catch (Exception ex)
{
Logger.Error($"[FruitBankEventConsumer] OrderPlaced plate auto-assign failed for order #{order.Id}: {ex.Message}", ex);
}
}
public override async Task HandleEventAsync(EntityUpdatedEvent<Product> eventMessage)

View File

@ -32,5 +32,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
/// <summary>JSON list of the customer's site (telephely) addresses — see CustomerSiteEntry.</summary>
public const string CustomerSitesAttribute = "customerSites";
/// <summary>JSON list of the customer's selectable license plates — see CustomerLicensePlateEntry.</summary>
public const string CustomerLicensePlatesAttribute = "customerLicensePlates";
// ── Order generic-attribute keys (KeyGroup "Order", StoreId 0) ───────────
// MUST match the IOrderDto/OrderDto property names (the DTO reads them by nameof).
/// <summary>JSON snapshot of the order's site (telephely) — see FruitBank.Common OrderSiteSnapshot.</summary>
public const string OrderSiteAttribute = "Site";
/// <summary>License plate (rendszám) of the vehicle picking up the order. Raw; the NAV mapper normalizes.</summary>
public const string OrderLicensePlateAttribute = "LicensePlate";
}
}

View File

@ -378,6 +378,20 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
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);
// ── Customer license plates (rendszámok) ────────────────────────────
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Title", "License plates", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Title", "Rendszámok", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Edit", "Edit plates", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Edit", "Rendszámok szerkesztése", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.CountWord", "plate(s)", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.CountWord", "rendszám", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.None", "No plates set", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.None", "Nincs rendszám megadva", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.ModalTitle", "Manage plates", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.ModalTitle", "Rendszámok kezelése", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Add", "Add", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerPlates.Add", "Hozzáadás", 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);

View File

@ -118,4 +118,24 @@
<Value><![CDATA[This customer has no addresses yet (Addresses tab).]]></Value>
</LocaleResource>
<!-- License plates — Plugins.Misc.FruitBankPlugin.CustomerPlates.* -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.Title">
<Value><![CDATA[License plates]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.Edit">
<Value><![CDATA[Edit plates]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.CountWord">
<Value><![CDATA[plate(s)]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.None">
<Value><![CDATA[No plates set]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.ModalTitle">
<Value><![CDATA[Manage plates]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.Add">
<Value><![CDATA[Add]]></Value>
</LocaleResource>
</Language>

View File

@ -118,4 +118,24 @@
<Value><![CDATA[Ehhez az ügyfélhez még nincs cím rögzítve a Címek fülön.]]></Value>
</LocaleResource>
<!-- Rendszámok — Plugins.Misc.FruitBankPlugin.CustomerPlates.* -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.Title">
<Value><![CDATA[Rendszámok]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.Edit">
<Value><![CDATA[Rendszámok szerkesztése]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.CountWord">
<Value><![CDATA[rendszám]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.None">
<Value><![CDATA[Nincs rendszám megadva]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.ModalTitle">
<Value><![CDATA[Rendszámok kezelése]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerPlates.Add">
<Value><![CDATA[Hozzáadás]]></Value>
</LocaleResource>
</Language>

View File

@ -32,6 +32,15 @@ public record CustomerCreditWidgetModel : BaseNopModel
/// <summary>Display string of the default site, if any.</summary>
public string? DefaultSiteDisplay { get; set; }
// ── License plates (rendszámok) ────────────────────────────────────────────
/// <summary>The customer's selectable plates — feeds the summary and the edit modal.</summary>
public List<CustomerLicensePlateRow> LicensePlates { get; set; } = new();
public int PlateCount { get; set; }
/// <summary>The default plate, if any (auto-assigned to new orders).</summary>
public string? DefaultPlate { get; set; }
}
public class CustomerSiteAddressRow
@ -41,3 +50,9 @@ public class CustomerSiteAddressRow
public bool IsSite { get; set; }
public bool IsDefault { get; set; }
}
public class CustomerLicensePlateRow
{
public string Plate { get; set; } = string.Empty;
public bool IsDefault { get; set; }
}

View File

@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace Nop.Plugin.Misc.FruitBankPlugin.Models;
/// <summary>
/// One selectable license plate (rendszám) in a customer's plate list, stored as JSON
/// in the <c>customerLicensePlates</c> generic attribute. Extensible with per-plate
/// fields (e.g. Note) later. One entry may be the default, auto-assigned to new orders.
/// </summary>
public class CustomerLicensePlateEntry
{
[JsonPropertyName("plate")]
public string Plate { get; set; } = string.Empty;
[JsonPropertyName("isDefault")]
public bool IsDefault { get; set; }
[JsonPropertyName("note")]
public string? Note { get; set; }
}

View File

@ -17,5 +17,25 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
public OrderDto OrderDto { get; set; }
// ── Site (telephely) + license plate (rendszám) ────────────────────────
public int CustomerId { get; set; }
/// <summary>The order's current frozen site snapshot, if any.</summary>
public OrderSiteSnapshot? Site { get; set; }
public string? LicensePlate { get; set; }
/// <summary>The customer's current sites — choices for the order-site selector.</summary>
public List<OrderSiteOption> CustomerSites { get; set; } = new();
/// <summary>The customer's saved license plates — suggestions (datalist) for the order plate field.</summary>
public List<string> CustomerLicensePlateOptions { get; set; } = new();
}
public class OrderSiteOption
{
public int AddressId { get; set; }
public string Display { get; set; } = string.Empty;
public bool IsDefault { get; set; }
}
}

View File

@ -0,0 +1,39 @@
using FruitBank.Common.Dtos;
using Nop.Core.Domain.Common;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// <summary>
/// Builds the frozen <see cref="OrderSiteSnapshot"/> for an order from a customer
/// Address. Shaped to the NAV EKÁER LocationType (UnloadLocation). Stateless — used
/// by both the OrderPlacedEvent auto-assign and the manual save endpoint.
/// </summary>
public static class OrderSiteSnapshotBuilder
{
public static OrderSiteSnapshot FromAddress(Address a, string? customerVatNumber)
{
var name = !string.IsNullOrWhiteSpace(a.Company)
? a.Company
: $"{a.FirstName} {a.LastName}".Trim();
var street = string.Join(" ", new[] { a.Address1, a.Address2 }
.Where(s => !string.IsNullOrWhiteSpace(s)));
var locality = $"{a.ZipPostalCode} {a.City}".Trim();
var display = string.Join(", ", new[] { name, street, locality }
.Where(s => !string.IsNullOrWhiteSpace(s)));
return new OrderSiteSnapshot
{
SourceAddressId = a.Id,
Name = string.IsNullOrWhiteSpace(name) ? null : name,
VatNumber = customerVatNumber,
CountryCode = "HU", // outgoing EKÁER is domestic; export would resolve a.CountryId
ZipCode = a.ZipPostalCode,
City = a.City,
Street = string.IsNullOrWhiteSpace(street) ? null : street,
Display = string.IsNullOrWhiteSpace(display) ? $"#{a.Id}" : display
};
}
}

View File

@ -78,6 +78,32 @@
</button>
</div>
</div>
<div class="form-group row">
<div class="col-md-3">
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerPlates.Title")</label>
</div>
<div class="col-md-9">
<span class="form-control-plaintext" id="fruitbank-plates-summary">
@if (Model.PlateCount > 0)
{
<text>@Model.PlateCount @T("Plugins.Misc.FruitBankPlugin.CustomerPlates.CountWord")</text>
if (!string.IsNullOrWhiteSpace(Model.DefaultPlate))
{
<text> (@T("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel"): @Model.DefaultPlate)</text>
}
}
else
{
<span class="text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerPlates.None")</span>
}
</span>
<button type="button" class="btn btn-default mt-1" data-toggle="modal" data-target="#fruitbank-plates-modal">
<i class="fas fa-car"></i>
@T("Plugins.Misc.FruitBankPlugin.CustomerPlates.Edit")
</button>
</div>
</div>
</div>
</div>
@ -218,6 +244,51 @@
</div>
</div>
@* ── License plates edit modal ─────────────────────────────────────────────── *@
<div id="fruitbank-plates-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="fruitbank-plates-modal-title">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="fruitbank-plates-modal-title">@T("Plugins.Misc.FruitBankPlugin.CustomerPlates.ModalTitle")</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input type="text" id="fb-plate-new" class="form-control" placeholder="pl. ABC-123" />
<div class="input-group-append">
<button type="button" id="fb-plate-add" class="btn btn-secondary">@T("Plugins.Misc.FruitBankPlugin.CustomerPlates.Add")</button>
</div>
</div>
<table class="table table-sm table-hover" id="fb-plate-table">
<thead>
<tr>
<th style="width:120px" class="text-center">@T("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerPlates.Title")</th>
<th style="width:50px"></th>
</tr>
</thead>
<tbody id="fb-plate-tbody">
@foreach (var p in Model.LicensePlates)
{
<tr>
<td class="text-center"><input type="radio" name="fb-plate-default" value="@p.Plate" @(p.IsDefault ? "checked" : "") /></td>
<td><span class="fb-plate-text">@p.Plate</span></td>
<td class="text-center"><button type="button" class="btn btn-sm btn-link text-danger fb-plate-remove" title="Törlés">&times;</button></td>
</tr>
}
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">@T("Admin.Common.Cancel")</button>
<button type="button" class="btn btn-primary" id="fruitbank-plates-save" data-customer-id="@Model.CustomerId">
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save")
</button>
</div>
</div>
</div>
</div>
<script>
(function () {
// The widget is rendered into the CustomerDetailsBlock zone at the bottom of
@ -330,5 +401,62 @@
}
});
});
// ── License plates edit modal ──────────────────────────────────────────
function fbNormalizePlate(v) { return (v || '').trim().toUpperCase(); }
function fbAddPlateRow(plate, isDefault) {
var $tbody = $('#fb-plate-tbody');
var exists = false;
$tbody.find('.fb-plate-text').each(function () { if ($(this).text() === plate) exists = true; });
if (exists) return;
var row = '<tr>' +
'<td class="text-center"><input type="radio" name="fb-plate-default" value="' + plate + '"' + (isDefault ? ' checked' : '') + ' /></td>' +
'<td><span class="fb-plate-text">' + plate + '</span></td>' +
'<td class="text-center"><button type="button" class="btn btn-sm btn-link text-danger fb-plate-remove" title="Törlés">&times;</button></td>' +
'</tr>';
$tbody.append(row);
}
$('#fb-plate-add').on('click', function () {
var plate = fbNormalizePlate($('#fb-plate-new').val());
if (!plate) return;
fbAddPlateRow(plate, false);
$('#fb-plate-new').val('').focus();
});
$('#fb-plate-new').on('keypress', function (e) {
if (e.which === 13) { e.preventDefault(); $('#fb-plate-add').trigger('click'); }
});
$('#fruitbank-plates-modal').on('click', '.fb-plate-remove', function () {
$(this).closest('tr').remove();
});
$('#fruitbank-plates-save').on('click', function () {
var $btn = $(this);
var plates = [];
$('#fb-plate-tbody .fb-plate-text').each(function () { plates.push($(this).text()); });
var def = $('#fruitbank-plates-modal input[name="fb-plate-default"]:checked').val() || '';
var token = $('input[name="__RequestVerificationToken"]').first().val();
$btn.prop('disabled', true);
$.ajax({
url: '@Url.Action("SaveLicensePlates", "CustomerAttributes")',
type: 'POST',
traditional: true, // serialize plates as repeated keys for string[] binding
data: {
customerId: $btn.data('customer-id'),
plates: plates,
defaultPlate: def,
__RequestVerificationToken: token
},
success: function (res) {
if (res && res.success) { location.reload(); }
else { alert(res && res.error ? res.error : 'Hiba a mentés során.'); $btn.prop('disabled', false); }
},
error: function () { alert('Hiba a mentés során.'); $btn.prop('disabled', false); }
});
});
});
</script>

View File

@ -1,4 +1,5 @@
@using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
@using System.Linq
@using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
@model OrderAttributesModel
@ -37,6 +38,69 @@
</div>
</div>
<hr />
@* ── Telephely ─────────────────────────────────────────────── *@
<div class="form-group row">
<div class="col-md-3">
<label>Telephely</label>
</div>
<div class="col-md-6">
@if (Model.CustomerSites.Any())
{
<select id="orderSiteSelect" class="form-control" data-current="@(Model.Site?.SourceAddressId ?? 0)">
<option value="0">— nincs —</option>
@foreach (var s in Model.CustomerSites)
{
<option value="@s.AddressId">@s.Display @(s.IsDefault ? "(alapértelmezett)" : "")</option>
}
</select>
@if (Model.Site != null && Model.CustomerSites.All(s => s.AddressId != Model.Site.SourceAddressId))
{
<small class="text-muted">Jelenlegi (már nem a vevő telephelye): @Model.Site.Display</small>
}
else if (Model.Site == null)
{
<small class="text-warning"><i class="fas fa-exclamation-circle"></i> Nincs telephely beállítva ehhez a rendeléshez.</small>
}
}
else
{
<div class="alert alert-warning mb-0 py-1 px-2">
<i class="fas fa-exclamation-triangle"></i> A vevőnek nincs telephelye beállítva.
<a href="/Admin/Customer/Edit/@Model.CustomerId">Beállítás a vevőnél</a>
</div>
}
</div>
<div class="col-md-3 text-right">
@if (Model.CustomerSites.Any())
{
<button type="button" id="saveOrderSiteBtn" class="btn btn-primary">
<i class="fa fa-save"></i> Mentés
</button>
}
</div>
</div>
<hr />
@* ── Rendszám ──────────────────────────────────────────────── *@
<div class="form-group row">
<div class="col-md-3">
<label>Rendszám</label>
</div>
<div class="col-md-6">
<input type="text" id="orderLicensePlate" class="form-control" value="@Model.LicensePlate" placeholder="pl. ABC-123" list="orderPlateList" autocomplete="off" />
<datalist id="orderPlateList">
@foreach (var p in Model.CustomerLicensePlateOptions)
{
<option value="@p"></option>
}
</datalist>
</div>
<div class="col-md-3 text-right">
<button type="button" id="saveLicensePlateBtn" class="btn btn-primary">
<i class="fa fa-save"></i> Mentés
</button>
</div>
</div>
<hr />
<div class="form-group row">
<div class="col-md-2">
<button type="button" class="btn btn-warning btn-block" data-toggle="modal" data-target="#allowRevisionModal">
@ -1336,3 +1400,48 @@
});
});
</script>
@* ── Site (telephely) + license plate (rendszám) save ───────────────────────── *@
<script>
$(function () {
// Preselect the order's current site (avoids C# in the <option> attribute area)
var $siteSel = $('#orderSiteSelect');
if ($siteSel.length) { $siteSel.val(String($siteSel.data('current'))); }
$('#saveOrderSiteBtn').on('click', function () {
var $btn = $(this).prop('disabled', true);
$.ajax({
type: 'POST',
url: '/Admin/OrderAttributes/SaveSite',
data: {
orderId: @Model.OrderId,
addressId: $('#orderSiteSelect').val(),
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
},
success: function (r) {
if (r && r.success) { location.reload(); }
else { alert((r && r.error) || 'Hiba a mentés során.'); $btn.prop('disabled', false); }
},
error: function () { alert('Hiba a mentés során.'); $btn.prop('disabled', false); }
});
});
$('#saveLicensePlateBtn').on('click', function () {
var $btn = $(this).prop('disabled', true);
$.ajax({
type: 'POST',
url: '/Admin/OrderAttributes/SaveLicensePlate',
data: {
orderId: @Model.OrderId,
licensePlate: $('#orderLicensePlate').val(),
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
},
success: function (r) {
if (r && r.success) { $('#orderLicensePlate').val(r.licensePlate || ''); $btn.prop('disabled', false); }
else { alert((r && r.error) || 'Hiba a mentés során.'); $btn.prop('disabled', false); }
},
error: function () { alert('Hiba a mentés során.'); $btn.prop('disabled', false); }
});
});
});
</script>

View File

@ -0,0 +1,41 @@
# InnVoice integration — current state & planned migration
> Reference doc for `Nop.Plugin.Misc.FruitBankPlugin`. See [`README.md`](README.md) for the docs index.
> Scope: how the plugin pushes orders/invoices to the external **InnVoice** system from the admin order page.
## Where it lives
The admin **order edit** widget ([`Views/OrderAttributes.cshtml`](../Views/OrderAttributes.cshtml), rendered by `OrderAttributesViewComponent` into `AdminWidgetZones.OrderDetailsBlock`) hosts the InnVoice UI in its right-hand card *"Megrendelés beküldése Innvoice-ba"*.
Related server side: `InnVoiceApiService` (`Services/`), `InnVoiceOrderController` and `InvoiceController` (`Areas/Admin/Controllers/`).
## Current behaviour (order submission)
The active flow submits an **order** to InnVoice:
| UI | Endpoint |
|---|---|
| "Megrendelés beküldése" | `InnVoiceOrder/CreateOrder` |
| "Invoice adat ellenőrzése" | `InnVoiceOrder/GetOrderStatus` (also auto-run on page load) |
On success the card shows the InnVoice Order Table ID / Tech ID and a PDF link.
## Orphaned invoice JS (intentional, kept for the migration)
`OrderAttributes.cshtml` also contains a **full invoice-handling JS block** — handlers for `#createInvoiceBtn` / `#checkInvoiceBtn`, `displayInvoiceDetails(...)`, calls to `Invoice/CreateInvoice` and `Invoice/GetInvoiceStatus`, plus a page-load `GetInvoiceStatus` AJAX.
**These have no matching markup** (`#createInvoiceBtn`, `#invoiceDetails`, `#invoiceNumber`, `#invoicePdfLink`, … do not exist in the view), so the code is currently dead — the only live side effect is a silent, failing `GetInvoiceStatus` call on every order page load.
This is **not a bug to clean up yet**: it is groundwork for the planned migration below.
## Planned migration — direct invoice submission (draft)
We will switch the InnVoice integration from the current **order submission** to **direct invoice submission**, creating the invoice in **draft ("piszkozat") status** in InnVoice.
When we return to this:
- Wire the existing invoice JS to real buttons/markup in the InnVoice card (replacing or complementing the current order-submission buttons).
- Confirm `Invoice/CreateInvoice` creates the invoice in draft status.
- Decide the fate of the current `InnVoiceOrder/CreateOrder` order-submission path (keep, deprecate, or remove).
- Remove the wasted page-load `GetInvoiceStatus` call if it is not needed in the final design.
_Status: planned, not started. Recorded so the orphaned invoice code is understood as intentional._

View File

@ -9,6 +9,7 @@ Topic documentation for the FruitBank-specific NopCommerce plugin (Layer 3 — c
- [`SCHEMA.md`](SCHEMA.md) — Database schema (Toon format — LLM-optimized)
- [`DOMAIN_MODEL.md`](DOMAIN_MODEL.md) — Business domain behavior
- [`MEASUREMENT.md`](MEASUREMENT.md) — Weight measurement logic (NetWeight formula, MeasuringStatus lifecycle)
- [`INNVOICE.md`](INNVOICE.md) — InnVoice integration: current order-submission flow, the orphaned invoice JS, and the planned migration to direct draft-invoice submission
## Topic folders