Compare commits
8 Commits
fdde2b3c6b
...
d5f151b5ac
| Author | SHA1 | Date |
|---|---|---|
|
|
d5f151b5ac | |
|
|
d568931bd9 | |
|
|
330aee3e5f | |
|
|
9209b26386 | |
|
|
1b60684f85 | |
|
|
cab7d1c2aa | |
|
|
40c9018513 | |
|
|
c1e27e12b4 |
|
|
@ -12,3 +12,4 @@ Per-repo topic registry for this plugin, per the **per-repo extension convention
|
||||||
|---------|-----------|------------------------------------------------------------------------------------------------|------------------|
|
|---------|-----------|------------------------------------------------------------------------------------------------|------------------|
|
||||||
| `PREO` | PRE-ORDER | Customer advance orders placed before stock arrives, and their conversion into real nopCommerce orders when an inbound shipping document confirms incoming stock. | `docs/PREORDER/` |
|
| `PREO` | PRE-ORDER | Customer advance orders placed before stock arrives, and their conversion into real nopCommerce orders when an inbound shipping document confirms incoming stock. | `docs/PREORDER/` |
|
||||||
| `EKAER` | EKÁER | Server-side / nopCommerce-specific aspects of NAV EKÁER reporting: VTSZ source from `Product`, `Shipping`→`tradeCard` data needs, serving the NAV call. The protocol/base layer lives in AyCode.Core (`AyCode.Services/Nav/`). | `docs/EKAER/` |
|
| `EKAER` | EKÁER | Server-side / nopCommerce-specific aspects of NAV EKÁER reporting: VTSZ source from `Product`, `Shipping`→`tradeCard` data needs, serving the NAV call. The protocol/base layer lives in AyCode.Core (`AyCode.Services/Nav/`). | `docs/EKAER/` |
|
||||||
|
| `ORDD` | ORDER-DRAFT | AI-assisted intake channel: inbound free-text messages (Viber/email/dictation) parsed into order drafts, admin review/approval, then PreOrder creation that feeds the existing PRE-ORDER conversion. Covers parsing, product/partner resolution, learning mappings, the draft lifecycle and the approval entry point. | `docs/ORDERDRAFT/` |
|
||||||
|
|
|
||||||
|
|
@ -1123,12 +1123,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
||||||
productDtosById.TryGetValue(product.Id, out var dto);
|
productDtosById.TryGetValue(product.Id, out var dto);
|
||||||
result.Add(new
|
result.Add(new
|
||||||
{
|
{
|
||||||
label = $"{product.Name} [KÉSZLET: {(product.StockQuantity + (dto?.IncomingQuantity ?? 0))}] [ÁR: {product.Price}]",
|
label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + (dto?.IncomingQuantity ?? 0))} (R:{product.StockQuantity}/K:{dto?.IncomingQuantity ?? 0})] [ÁR: {product.Price}]",
|
||||||
value = product.Id,
|
value = product.Id,
|
||||||
sku = product.Sku,
|
sku = product.Sku,
|
||||||
price = product.Price,
|
price = product.Price,
|
||||||
stockQuantity = product.StockQuantity,
|
stockQuantity = product.StockQuantity,
|
||||||
incomingQuantity = dto?.IncomingQuantity ?? 0
|
incomingQuantity = dto?.IncomingQuantity ?? 0,
|
||||||
|
availableQuantity = product.StockQuantity + (dto?.IncomingQuantity ?? 0)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nop.Core.Domain.Customers;
|
||||||
|
using Nop.Plugin.Misc.FruitBankPlugin.Models;
|
||||||
|
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||||
|
using Nop.Services.Customers;
|
||||||
|
using Nop.Services.Security;
|
||||||
|
using Nop.Web.Framework;
|
||||||
|
using Nop.Web.Framework.Controllers;
|
||||||
|
using Nop.Web.Framework.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline-save endpoints for the FruitBank "Customer attributes" block on the
|
||||||
|
/// admin customer page (rendered by CustomerCreditWidgetViewComponent).
|
||||||
|
/// Values are stored as store-agnostic (StoreId 0) customer generic attributes
|
||||||
|
/// via <see cref="FruitBankAttributeService"/>.
|
||||||
|
/// </summary>
|
||||||
|
[AuthorizeAdmin]
|
||||||
|
[Area(AreaNames.ADMIN)]
|
||||||
|
public class CustomerAttributesController : BasePluginController
|
||||||
|
{
|
||||||
|
private readonly FruitBankAttributeService _attributeService;
|
||||||
|
private readonly ICustomerService _customerService;
|
||||||
|
private readonly IPermissionService _permissionService;
|
||||||
|
|
||||||
|
public CustomerAttributesController(
|
||||||
|
FruitBankAttributeService attributeService,
|
||||||
|
ICustomerService customerService,
|
||||||
|
IPermissionService permissionService)
|
||||||
|
{
|
||||||
|
_attributeService = attributeService;
|
||||||
|
_customerService = customerService;
|
||||||
|
_permissionService = permissionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Route("Admin/CustomerAttributes/SetEkaer")]
|
||||||
|
public async Task<IActionResult> SetEkaer(int customerId, bool value)
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||||
|
return Json(new { success = false, error = "Access denied" });
|
||||||
|
|
||||||
|
var customer = await _customerService.GetCustomerByIdAsync(customerId);
|
||||||
|
if (customer == null)
|
||||||
|
return Json(new { success = false, error = "Customer not found" });
|
||||||
|
|
||||||
|
await _attributeService.InsertOrUpdateGenericAttributeAsync<Customer, bool>(
|
||||||
|
customerId, FruitBankPluginConst.IsEkaerAttribute, value, storeId: 0);
|
||||||
|
|
||||||
|
return Json(new { success = true, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Route("Admin/CustomerAttributes/SaveSites")]
|
||||||
|
public async Task<IActionResult> SaveSites(int customerId, int[] siteAddressIds, int defaultAddressId)
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||||
|
return Json(new { success = false, error = "Access denied" });
|
||||||
|
|
||||||
|
var customer = await _customerService.GetCustomerByIdAsync(customerId);
|
||||||
|
if (customer == null)
|
||||||
|
return Json(new { success = false, error = "Customer not found" });
|
||||||
|
|
||||||
|
// Only keep ids that actually belong to this customer (guards against tampering / stale ids)
|
||||||
|
var validIds = (await _customerService.GetAddressesByCustomerIdAsync(customerId))
|
||||||
|
.Select(a => a.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var selected = (siteAddressIds ?? Array.Empty<int>())
|
||||||
|
.Where(validIds.Contains)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Default must be one of the selected sites; otherwise no default
|
||||||
|
var effectiveDefault = selected.Contains(defaultAddressId) ? defaultAddressId : 0;
|
||||||
|
|
||||||
|
var entries = selected
|
||||||
|
.Select(id => new CustomerSiteEntry { AddressId = id, IsDefault = id == effectiveDefault })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(entries);
|
||||||
|
await _attributeService.InsertOrUpdateGenericAttributeAsync<Customer, string>(
|
||||||
|
customerId, FruitBankPluginConst.CustomerSitesAttribute, json, storeId: 0);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
using FruitBank.Common.Entities;
|
||||||
|
using FruitBank.Common.Enums;
|
||||||
|
using LinqToDB;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||||
|
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||||
|
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||||
|
using Nop.Services.Catalog;
|
||||||
|
using Nop.Services.Customers;
|
||||||
|
using Nop.Services.Security;
|
||||||
|
using Nop.Web.Framework;
|
||||||
|
using Nop.Web.Framework.Controllers;
|
||||||
|
using Nop.Web.Framework.Mvc.Filters;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
|
||||||
|
|
||||||
|
[AuthorizeAdmin]
|
||||||
|
[Area(AreaNames.ADMIN)]
|
||||||
|
public class OrderDraftAdminController : BasePluginController
|
||||||
|
{
|
||||||
|
private readonly IOrderDraftService _draftService;
|
||||||
|
private readonly FruitBankDbContext _dbContext;
|
||||||
|
private readonly ICustomerService _customerService;
|
||||||
|
private readonly IProductService _productService;
|
||||||
|
private readonly IPermissionService _permissionService;
|
||||||
|
|
||||||
|
public OrderDraftAdminController(
|
||||||
|
IOrderDraftService draftService,
|
||||||
|
FruitBankDbContext dbContext,
|
||||||
|
ICustomerService customerService,
|
||||||
|
IProductService productService,
|
||||||
|
IPermissionService permissionService)
|
||||||
|
{
|
||||||
|
_draftService = draftService;
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_customerService = customerService;
|
||||||
|
_productService = productService;
|
||||||
|
_permissionService = permissionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LISTA ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult List()
|
||||||
|
{
|
||||||
|
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/OrderDraft/List.cshtml",
|
||||||
|
new OrderDraftListModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> OrderDraftList()
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||||
|
return Json(new { draw = 1, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
|
||||||
|
|
||||||
|
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||||
|
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||||
|
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 200);
|
||||||
|
|
||||||
|
var statusFilter = Request.Form["statusFilter"].FirstOrDefault() ?? "Pending";
|
||||||
|
var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? "";
|
||||||
|
|
||||||
|
// 1. Draftek lekérése
|
||||||
|
var query = _dbContext.OrderDrafts.GetAll(false);
|
||||||
|
|
||||||
|
if (Enum.TryParse<OrderDraftStatus>(statusFilter, out var statusEnum))
|
||||||
|
query = query.Where(d => d.Status == statusEnum);
|
||||||
|
|
||||||
|
var allDrafts = await query.OrderByDescending(d => d.CreatedOnUtc).ToListAsync();
|
||||||
|
|
||||||
|
// 2. Kapcsolódó customer nevek — batch
|
||||||
|
var customerIds = allDrafts.Where(d => d.CustomerId.HasValue).Select(d => d.CustomerId!.Value).Distinct().ToList();
|
||||||
|
var customers = new Dictionary<int, string>();
|
||||||
|
if (customerIds.Any())
|
||||||
|
{
|
||||||
|
var customerList = await _dbContext.Customers.Table
|
||||||
|
.Where(c => customerIds.Contains(c.Id))
|
||||||
|
.Select(c => new { c.Id, c.FirstName, c.LastName, c.Email })
|
||||||
|
.ToListAsync();
|
||||||
|
customers = customerList.ToDictionary(
|
||||||
|
c => c.Id,
|
||||||
|
c => string.IsNullOrWhiteSpace($"{c.FirstName} {c.LastName}".Trim())
|
||||||
|
? c.Email ?? c.Id.ToString()
|
||||||
|
: $"{c.FirstName} {c.LastName}".Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Item count — batch
|
||||||
|
var draftIds = allDrafts.Select(d => d.Id).ToList();
|
||||||
|
var itemCounts = await _dbContext.OrderDraftItems.GetAll()
|
||||||
|
.Where(i => draftIds.Contains(i.DraftId))
|
||||||
|
.GroupBy(i => i.DraftId)
|
||||||
|
.Select(g => new { DraftId = g.Key, Count = g.Count() })
|
||||||
|
.ToListAsync();
|
||||||
|
var itemCountDict = itemCounts.ToDictionary(x => x.DraftId, x => x.Count);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var rows = allDrafts.Select(d => new OrderDraftListRow
|
||||||
|
{
|
||||||
|
Id = d.Id,
|
||||||
|
ExternalUsername = d.ExternalUsername,
|
||||||
|
CustomerId = d.CustomerId,
|
||||||
|
CustomerName = d.CustomerId.HasValue && customers.TryGetValue(d.CustomerId.Value, out var cn) ? cn : "— ismeretlen —",
|
||||||
|
RawMessageTextShort = d.RawMessageText.Length > 80 ? d.RawMessageText[..80] + "…" : d.RawMessageText,
|
||||||
|
ParsedDeliveryDate = d.ParsedDeliveryDate.ToString("yyyy-MM-dd"),
|
||||||
|
ItemCount = itemCountDict.TryGetValue(d.Id, out var cnt) ? cnt : 0,
|
||||||
|
Status = d.Status.ToString(),
|
||||||
|
StatusBadgeClass = d.Status switch
|
||||||
|
{
|
||||||
|
OrderDraftStatus.Pending => "badge-warning",
|
||||||
|
OrderDraftStatus.Approved => "badge-success",
|
||||||
|
OrderDraftStatus.Rejected => "badge-danger",
|
||||||
|
OrderDraftStatus.Expired => "badge-secondary",
|
||||||
|
_ => "badge-light"
|
||||||
|
},
|
||||||
|
IsUrgent = d.Status == OrderDraftStatus.Pending && (now - d.CreatedOnUtc).TotalHours > 20,
|
||||||
|
CreatedOnUtc = d.CreatedOnUtc.ToString("yyyy-MM-dd HH:mm")
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
int recordsTotal = rows.Count;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(globalSearch))
|
||||||
|
rows = rows.Where(r =>
|
||||||
|
r.ExternalUsername.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
r.RawMessageTextShort.Contains(globalSearch, StringComparison.OrdinalIgnoreCase)
|
||||||
|
).ToList();
|
||||||
|
|
||||||
|
int recordsFiltered = rows.Count;
|
||||||
|
var page = rows.Skip(start).Take(length).ToList();
|
||||||
|
|
||||||
|
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RÉSZLETEK / JÓVÁHAGYÓ OLDAL ──────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Detail(int id)
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||||
|
return AccessDeniedView();
|
||||||
|
|
||||||
|
var draft = await _draftService.GetByIdAsync(id, true);
|
||||||
|
if (draft == null) return NotFound();
|
||||||
|
|
||||||
|
// Customer név
|
||||||
|
string customerName = "— ismeretlen partner —";
|
||||||
|
if (draft.CustomerId.HasValue)
|
||||||
|
{
|
||||||
|
var customer = await _customerService.GetCustomerByIdAsync(draft.CustomerId.Value);
|
||||||
|
if (customer != null)
|
||||||
|
customerName = string.IsNullOrWhiteSpace($"{customer.FirstName} {customer.LastName}".Trim())
|
||||||
|
? customer.Email ?? customer.Id.ToString()
|
||||||
|
: $"{customer.FirstName} {customer.LastName}".Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Termék nevek és kandidátusok batch lekérése
|
||||||
|
var allCandidateIds = draft.Items
|
||||||
|
.SelectMany(i =>
|
||||||
|
{
|
||||||
|
try { return JsonSerializer.Deserialize<List<int>>(i.SuggestedProductIdsJson) ?? []; }
|
||||||
|
catch { return []; }
|
||||||
|
})
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var productMap = new Dictionary<int, (string Name, int Stock)>();
|
||||||
|
if (allCandidateIds.Any())
|
||||||
|
{
|
||||||
|
var products = await _dbContext.ProductDtos
|
||||||
|
.GetAllByIds(allCandidateIds, false)
|
||||||
|
.ToListAsync();
|
||||||
|
productMap = products.ToDictionary(
|
||||||
|
p => p.Id,
|
||||||
|
p => (p.Name, p.StockQuantity));
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = new OrderDraftDetailModel
|
||||||
|
{
|
||||||
|
Id = draft.Id,
|
||||||
|
ExternalUsername = draft.ExternalUsername,
|
||||||
|
CustomerId = draft.CustomerId,
|
||||||
|
CustomerName = customerName,
|
||||||
|
RawMessageText = draft.RawMessageText,
|
||||||
|
ParsedDeliveryDate = draft.ParsedDeliveryDate,
|
||||||
|
IsPreorder = draft.ParsedDeliveryDate.Date > DateTime.Today.AddDays(4),
|
||||||
|
Status = draft.Status.ToString(),
|
||||||
|
AdminNote = draft.AdminNote ?? string.Empty,
|
||||||
|
CreatedOnUtc = draft.CreatedOnUtc.ToString("yyyy-MM-dd HH:mm"),
|
||||||
|
CanApprove = draft.Status == OrderDraftStatus.Pending,
|
||||||
|
Items = draft.Items.OrderBy(i => i.SortOrder).Select(i =>
|
||||||
|
{
|
||||||
|
var candidateIds = new List<int>();
|
||||||
|
try { candidateIds = JsonSerializer.Deserialize<List<int>>(i.SuggestedProductIdsJson) ?? []; }
|
||||||
|
catch { /* */ }
|
||||||
|
|
||||||
|
var selectedName = i.SelectedProductId.HasValue && productMap.TryGetValue(i.SelectedProductId.Value, out var sel)
|
||||||
|
? sel.Name : string.Empty;
|
||||||
|
|
||||||
|
return new OrderDraftItemModel
|
||||||
|
{
|
||||||
|
Id = i.Id,
|
||||||
|
RawProductText = i.RawProductText,
|
||||||
|
AiResolvedProductName = i.AiResolvedProductName,
|
||||||
|
RequestedQuantity = i.RequestedQuantity,
|
||||||
|
IsPreorder = i.IsPreorder,
|
||||||
|
SortOrder = i.SortOrder,
|
||||||
|
SelectedProductId = i.SelectedProductId,
|
||||||
|
SelectedProductName = selectedName,
|
||||||
|
Candidates = candidateIds
|
||||||
|
.Where(pid => productMap.ContainsKey(pid))
|
||||||
|
.Select(pid => new OrderDraftProductCandidate
|
||||||
|
{
|
||||||
|
Id = pid,
|
||||||
|
Name = productMap[pid].Name,
|
||||||
|
Stock = productMap[pid].Stock.ToString()
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/OrderDraft/Detail.cshtml", model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AJAX: TÉTEL FRISSÍTÉSE ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> UpdateItem(int draftItemId, int selectedProductId, int quantity)
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||||
|
return Json(new { success = false });
|
||||||
|
|
||||||
|
await _draftService.UpdateDraftItemAsync(draftItemId, selectedProductId, quantity);
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AJAX: PARTNER HOZZÁRENDELÉSE ──────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> AssignCustomer(int draftId, int customerId)
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||||
|
return Json(new { success = false });
|
||||||
|
|
||||||
|
var workContext = HttpContext.RequestServices.GetService(typeof(Nop.Core.IWorkContext)) as Nop.Core.IWorkContext;
|
||||||
|
var admin = workContext != null ? await workContext.GetCurrentCustomerAsync() : null;
|
||||||
|
|
||||||
|
await _draftService.AssignCustomerAsync(draftId, customerId, admin?.Id ?? 0);
|
||||||
|
|
||||||
|
var customer = await _customerService.GetCustomerByIdAsync(customerId);
|
||||||
|
var name = customer == null ? customerId.ToString()
|
||||||
|
: string.IsNullOrWhiteSpace($"{customer.FirstName} {customer.LastName}".Trim())
|
||||||
|
? customer.Email ?? customerId.ToString()
|
||||||
|
: $"{customer.FirstName} {customer.LastName}".Trim();
|
||||||
|
|
||||||
|
return Json(new { success = true, customerName = name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AJAX: JÓVÁHAGYÁS ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Approve(int draftId)
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||||
|
return Json(new { success = false, error = "Hozzáférés megtagadva." });
|
||||||
|
|
||||||
|
var workContext = HttpContext.RequestServices.GetService(typeof(Nop.Core.IWorkContext)) as Nop.Core.IWorkContext;
|
||||||
|
var admin = workContext != null ? await workContext.GetCurrentCustomerAsync() : null;
|
||||||
|
|
||||||
|
var result = await _draftService.ApproveAsync(draftId, admin?.Id ?? 0);
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
error = result.ErrorMessage,
|
||||||
|
preOrderIds = result.PreOrderIds,
|
||||||
|
orderIds = result.OrderIds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AJAX: ELUTASÍTÁS ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Reject(int draftId, string? adminNote)
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||||
|
return Json(new { success = false });
|
||||||
|
|
||||||
|
var workContext = HttpContext.RequestServices.GetService(typeof(Nop.Core.IWorkContext)) as Nop.Core.IWorkContext;
|
||||||
|
var admin = workContext != null ? await workContext.GetCurrentCustomerAsync() : null;
|
||||||
|
|
||||||
|
await _draftService.RejectAsync(draftId, admin?.Id ?? 0, adminNote);
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
using FruitBank.Common.Enums;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||||
|
using Nop.Services.Security;
|
||||||
|
using Nop.Web.Framework.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
|
||||||
|
|
||||||
|
public partial class PreOrderAdminController
|
||||||
|
{
|
||||||
|
[HttpPost, Route("Admin/PreOrders/UpdateHeader/{id:int}")]
|
||||||
|
public async Task<IActionResult> UpdateHeader(int id, string deliveryDateTime, string? customerNote)
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid();
|
||||||
|
var p = await _preorderDbContext.PreOrders.GetByIdAsync(id);
|
||||||
|
if (p == null || p.Status == PreOrderStatus.Cancelled) return Json(new { success = false });
|
||||||
|
if (!DateTime.TryParse(deliveryDateTime, out var d)) return Json(new { success = false, error = "Érvénytelen dátum" });
|
||||||
|
p.DateOfReceipt = d; p.CustomerNote = customerNote?.Trim(); p.UpdatedOnUtc = DateTime.UtcNow;
|
||||||
|
await _preorderDbContext.PreOrders.UpdateAsync(p);
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Route("Admin/PreOrders/UpdateItem/{id:int}")]
|
||||||
|
public async Task<IActionResult> UpdateItem(int id, int quantity, decimal unitPrice)
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid();
|
||||||
|
var item = await _preorderDbContext.PreOrderItems.GetByIdAsync(id);
|
||||||
|
if (item == null || item.FulfilledQuantity > 0) return Json(new { success = false, error = "Módosítás nem lehetséges" });
|
||||||
|
item.RequestedQuantity = Math.Max(1, quantity);
|
||||||
|
item.UnitPriceInclTax = Math.Max(0, unitPrice);
|
||||||
|
await _preorderDbContext.PreOrderItems.UpdateAsync(item);
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Route("Admin/PreOrders/ReplaceItemProduct/{id:int}")]
|
||||||
|
public async Task<IActionResult> ReplaceItemProduct(int id, int newProductId)
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid();
|
||||||
|
var item = await _preorderDbContext.PreOrderItems.GetByIdAsync(id);
|
||||||
|
if (item == null || item.FulfilledQuantity > 0) return Json(new { success = false, error = "Csere nem lehetséges" });
|
||||||
|
var product = await _dbContext.Products.GetByIdAsync(newProductId);
|
||||||
|
if (product == null || product.Deleted) return Json(new { success = false, error = "Termék nem található" });
|
||||||
|
|
||||||
|
item.ProductId = newProductId;
|
||||||
|
|
||||||
|
// Recalculate price for the preorder's customer using CustomPriceCalculationService
|
||||||
|
var preorder = await _preorderDbContext.PreOrders.GetByIdAsync(item.PreOrderId);
|
||||||
|
var customer = preorder != null ? await _customerService.GetCustomerByIdAsync(preorder.CustomerId) : null;
|
||||||
|
if (customer != null)
|
||||||
|
{
|
||||||
|
var store = await _storeContext.GetCurrentStoreAsync();
|
||||||
|
var calc = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: true);
|
||||||
|
item.UnitPriceInclTax = calc.finalPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _preorderDbContext.PreOrderItems.UpdateAsync(item);
|
||||||
|
return Json(new { success = true, productName = product.Name, newPrice = item.UnitPriceInclTax });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Route("Admin/PreOrders/ConvertNow/{id:int}")]
|
||||||
|
public async Task<IActionResult> ConvertNow(int id)
|
||||||
|
{
|
||||||
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL)) return Forbid();
|
||||||
|
var preorder = await _preorderDbContext.PreOrders.GetByIdAsync(id);
|
||||||
|
if (preorder == null || preorder.Status == PreOrderStatus.Cancelled)
|
||||||
|
return Json(new { success = false, error = "Konvertálás nem lehetséges" });
|
||||||
|
var pending = await _preorderDbContext.PreOrderItems.GetAll()
|
||||||
|
.Where(i => i.PreOrderId == id && i.FulfilledQuantity < i.RequestedQuantity).ToListAsync();
|
||||||
|
if (!pending.Any()) return Json(new { success = false, error = "Nincs teljesítetlen tétel" });
|
||||||
|
await _preorderConversionService.ConvertPreOrdersForProductsAsync(
|
||||||
|
pending.Select(i => i.ProductId).Distinct().ToList(), 0);
|
||||||
|
var refreshed = await _preorderDbContext.PreOrders.GetByIdAsync(id);
|
||||||
|
return Json(new { success = true, orderId = refreshed?.OrderId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Nop.Core;
|
||||||
using FruitBank.Common.Entities;
|
using FruitBank.Common.Entities;
|
||||||
using FruitBank.Common.Enums;
|
using FruitBank.Common.Enums;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
@ -5,24 +6,29 @@ using Nop.Core.Domain.Customers;
|
||||||
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||||
|
using Nop.Services.Catalog;
|
||||||
using Nop.Services.Customers;
|
using Nop.Services.Customers;
|
||||||
using Nop.Services.Security;
|
using Nop.Services.Security;
|
||||||
using Nop.Web.Framework;
|
using Nop.Web.Framework;
|
||||||
using Nop.Web.Framework.Controllers;
|
using Nop.Web.Framework.Controllers;
|
||||||
using Nop.Web.Framework.Mvc.Filters;
|
using Nop.Web.Framework.Mvc.Filters;
|
||||||
|
using Nop.Services.Helpers;
|
||||||
|
|
||||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
|
||||||
|
|
||||||
[AuthorizeAdmin]
|
[AuthorizeAdmin]
|
||||||
[Area(AreaNames.ADMIN)]
|
[Area(AreaNames.ADMIN)]
|
||||||
[AutoValidateAntiforgeryToken]
|
[AutoValidateAntiforgeryToken]
|
||||||
public class PreOrderAdminController : BasePluginController
|
public partial class PreOrderAdminController : BasePluginController
|
||||||
{
|
{
|
||||||
private readonly IPermissionService _permissionService;
|
private readonly IPermissionService _permissionService;
|
||||||
private readonly PreOrderDbContext _preorderDbContext;
|
private readonly PreOrderDbContext _preorderDbContext;
|
||||||
private readonly FruitBankDbContext _dbContext;
|
private readonly FruitBankDbContext _dbContext;
|
||||||
private readonly ICustomerService _customerService;
|
private readonly ICustomerService _customerService;
|
||||||
private readonly PreOrderConversionService _preorderConversionService;
|
private readonly PreOrderConversionService _preorderConversionService;
|
||||||
|
private readonly IPriceCalculationService _priceCalculationService;
|
||||||
|
private readonly IStoreContext _storeContext;
|
||||||
|
private readonly IDateTimeHelper _dateTimeHelper;
|
||||||
|
|
||||||
private static readonly Dictionary<PreOrderStatus, string> StatusLabels = new()
|
private static readonly Dictionary<PreOrderStatus, string> StatusLabels = new()
|
||||||
{
|
{
|
||||||
|
|
@ -45,13 +51,19 @@ public class PreOrderAdminController : BasePluginController
|
||||||
PreOrderDbContext preorderDbContext,
|
PreOrderDbContext preorderDbContext,
|
||||||
FruitBankDbContext dbContext,
|
FruitBankDbContext dbContext,
|
||||||
ICustomerService customerService,
|
ICustomerService customerService,
|
||||||
PreOrderConversionService preorderConversionService)
|
PreOrderConversionService preorderConversionService,
|
||||||
|
IPriceCalculationService priceCalculationService,
|
||||||
|
IStoreContext storeContext,
|
||||||
|
IDateTimeHelper dateTimeHelper)
|
||||||
{
|
{
|
||||||
_permissionService = permissionService;
|
_permissionService = permissionService;
|
||||||
_preorderDbContext = preorderDbContext;
|
_preorderDbContext = preorderDbContext;
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_customerService = customerService;
|
_customerService = customerService;
|
||||||
_preorderConversionService = preorderConversionService;
|
_preorderConversionService = preorderConversionService;
|
||||||
|
_priceCalculationService = priceCalculationService;
|
||||||
|
_storeContext = storeContext;
|
||||||
|
_dateTimeHelper = dateTimeHelper;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── LIST PAGE ─────────────────────────────────────────────────────────────
|
// ── LIST PAGE ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -98,7 +110,7 @@ public class PreOrderAdminController : BasePluginController
|
||||||
var customerIds = preorders.Select(p => p.CustomerId).Distinct().ToList();
|
var customerIds = preorders.Select(p => p.CustomerId).Distinct().ToList();
|
||||||
var customers = await _dbContext.Customers.Table
|
var customers = await _dbContext.Customers.Table
|
||||||
.Where(c => customerIds.Contains(c.Id))
|
.Where(c => customerIds.Contains(c.Id))
|
||||||
.Select(c => new { c.Id, c.Email, c.FirstName, c.LastName })
|
.Select(c => new { c.Id, c.Email, c.FirstName, c.LastName, c.Company })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var customerById = customers.ToDictionary(c => c.Id);
|
var customerById = customers.ToDictionary(c => c.Id);
|
||||||
|
|
||||||
|
|
@ -108,7 +120,7 @@ public class PreOrderAdminController : BasePluginController
|
||||||
// For now we surface the link on the detail page only.
|
// For now we surface the link on the detail page only.
|
||||||
|
|
||||||
// 4. Build rows — derive status from quantities, not enum (LinqToDB enum reads unreliable)
|
// 4. Build rows — derive status from quantities, not enum (LinqToDB enum reads unreliable)
|
||||||
var rows = preorders.Select(p =>
|
var rowTasks = preorders.Select(async p =>
|
||||||
{
|
{
|
||||||
customerById.TryGetValue(p.CustomerId, out var c);
|
customerById.TryGetValue(p.CustomerId, out var c);
|
||||||
var items = itemsByPreOrder.TryGetValue(p.Id, out var its) ? its : new();
|
var items = itemsByPreOrder.TryGetValue(p.Id, out var its) ? its : new();
|
||||||
|
|
@ -121,27 +133,29 @@ public class PreOrderAdminController : BasePluginController
|
||||||
|
|
||||||
// Derive a display status: use the DB enum if it looks valid (non-zero),
|
// Derive a display status: use the DB enum if it looks valid (non-zero),
|
||||||
// otherwise infer from quantities
|
// otherwise infer from quantities
|
||||||
var effectiveStatus = (int)p.Status != 0
|
var effectiveStatus =
|
||||||
? p.Status
|
p.Status == PreOrderStatus.Cancelled ? PreOrderStatus.Cancelled :
|
||||||
: allFulfilled ? PreOrderStatus.Confirmed
|
(int)p.Status != 0 ? p.Status :
|
||||||
: anyFulfilled ? PreOrderStatus.PartiallyFulfilled
|
allFulfilled ? PreOrderStatus.Confirmed :
|
||||||
: PreOrderStatus.Pending;
|
anyFulfilled || p.OrderId.HasValue ? PreOrderStatus.PartiallyFulfilled :
|
||||||
|
PreOrderStatus.Pending;
|
||||||
|
|
||||||
return new PreOrderListRow
|
return new PreOrderListRow
|
||||||
{
|
{
|
||||||
PreOrderId = p.Id,
|
PreOrderId = p.Id,
|
||||||
CustomerId = p.CustomerId,
|
CustomerId = p.CustomerId,
|
||||||
CustomerName = c != null ? $"{c.FirstName} {c.LastName}".Trim() : $"#{p.CustomerId}",
|
CustomerName = c != null ? (!string.IsNullOrWhiteSpace(c.Company) ? c.Company : $"{c.FirstName} {c.LastName}".Trim()) : $"#{p.CustomerId}",
|
||||||
CustomerEmail = c?.Email ?? string.Empty,
|
CustomerEmail = c?.Email ?? string.Empty,
|
||||||
DateOfReceipt = p.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
DateOfReceipt = (await _dateTimeHelper.ConvertToUserTimeAsync(p.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"),
|
||||||
CreatedOnUtc = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
CreatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(p.CreatedOnUtc, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"),
|
||||||
Status = effectiveStatus,
|
Status = effectiveStatus,
|
||||||
StatusLabel = StatusLabels.TryGetValue(effectiveStatus, out var sl) ? sl : effectiveStatus.ToString(),
|
StatusLabel = StatusLabels.TryGetValue(effectiveStatus, out var sl) ? sl : effectiveStatus.ToString(),
|
||||||
ItemCount = items.Count,
|
ItemCount = items.Count,
|
||||||
FulfilledCount = fulfilledCount,
|
FulfilledCount = fulfilledCount,
|
||||||
OrderId = p.OrderId
|
OrderId = p.OrderId
|
||||||
};
|
};
|
||||||
}).ToList();
|
});
|
||||||
|
var rows = (await Task.WhenAll(rowTasks)).ToList();
|
||||||
|
|
||||||
int recordsTotal = rows.Count;
|
int recordsTotal = rows.Count;
|
||||||
|
|
||||||
|
|
@ -208,11 +222,14 @@ public class PreOrderAdminController : BasePluginController
|
||||||
{
|
{
|
||||||
PreOrderId = preorder.Id,
|
PreOrderId = preorder.Id,
|
||||||
CustomerId = preorder.CustomerId,
|
CustomerId = preorder.CustomerId,
|
||||||
CustomerName = customer != null ? $"{customer.FirstName} {customer.LastName}".Trim() : $"#{preorder.CustomerId}",
|
CustomerName = customer != null
|
||||||
|
? (!string.IsNullOrWhiteSpace(customer.Company) ? customer.Company : $"{customer.FirstName} {customer.LastName}".Trim())
|
||||||
|
: $"#{preorder.CustomerId}",
|
||||||
CustomerEmail = customer?.Email ?? string.Empty,
|
CustomerEmail = customer?.Email ?? string.Empty,
|
||||||
DateOfReceipt = preorder.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
DateOfReceipt = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"),
|
||||||
CreatedOnUtc = preorder.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
DateOfReceiptRaw = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy-MM-ddTHH:mm"),
|
||||||
UpdatedOnUtc = preorder.UpdatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
CreatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.CreatedOnUtc, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"),
|
||||||
|
UpdatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.UpdatedOnUtc, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"),
|
||||||
Status = preorder.Status,
|
Status = preorder.Status,
|
||||||
CustomerNote = preorder.CustomerNote,
|
CustomerNote = preorder.CustomerNote,
|
||||||
OrderId = linkedOrderId,
|
OrderId = linkedOrderId,
|
||||||
|
|
@ -227,8 +244,8 @@ public class PreOrderAdminController : BasePluginController
|
||||||
? PreOrderItemStatus.Fulfilled
|
? PreOrderItemStatus.Fulfilled
|
||||||
: PreOrderItemStatus.PartiallyFulfilled;
|
: PreOrderItemStatus.PartiallyFulfilled;
|
||||||
|
|
||||||
// If DB enum read as non-zero, prefer it; otherwise use derived
|
// If DB enum read as non-zero, prefer it; otherwise use derived
|
||||||
var effectiveItemStatus = (int)i.Status != 0 ? i.Status : derivedStatus;
|
var effectiveItemStatus = (int)i.Status != 0 ? i.Status : derivedStatus;
|
||||||
|
|
||||||
return new PreOrderDetailItemRow
|
return new PreOrderDetailItemRow
|
||||||
{
|
{
|
||||||
|
|
@ -357,9 +374,16 @@ public class PreOrderAdminController : BasePluginController
|
||||||
if (preorder == null)
|
if (preorder == null)
|
||||||
return Json(new { success = false, error = "PreOrder not found" });
|
return Json(new { success = false, error = "PreOrder not found" });
|
||||||
|
|
||||||
if (preorder.Status != PreOrderStatus.Pending)
|
if (preorder.Status == PreOrderStatus.Cancelled)
|
||||||
return Json(new { success = false, error = "Only pending preorders can be cancelled" });
|
return Json(new { success = false, error = "Már visszavont előrendelés" });
|
||||||
|
|
||||||
|
// Only not-yet-fulfilled preorders can be cancelled — once a real order
|
||||||
|
// exists (OrderId set at first fulfilment), cancelling the preorder must
|
||||||
|
// not silently undo it.
|
||||||
|
if (preorder.Status != PreOrderStatus.Pending || preorder.OrderId.HasValue)
|
||||||
|
return Json(new { success = false, error = "Csak függőben lévő előrendelés vonható vissza" });
|
||||||
|
|
||||||
|
// Sets header → Cancelled and drops all still-active items → Dropped
|
||||||
await _preorderDbContext.CancelPreOrderAsync(id);
|
await _preorderDbContext.CancelPreOrderAsync(id);
|
||||||
return Json(new { success = true });
|
return Json(new { success = true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
using FruitBank.Common.Enums;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||||
|
|
||||||
|
public class OrderDraftListModel
|
||||||
|
{
|
||||||
|
public string StatusFilter { get; set; } = "Pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderDraftListRow
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ExternalUsername { get; set; } = string.Empty;
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
public int? CustomerId { get; set; }
|
||||||
|
public string RawMessageTextShort { get; set; } = string.Empty;
|
||||||
|
public string ParsedDeliveryDate { get; set; } = string.Empty;
|
||||||
|
public int ItemCount { get; set; }
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public string StatusBadgeClass { get; set; } = string.Empty;
|
||||||
|
public bool IsUrgent { get; set; } // Pending és > 20 óra régi
|
||||||
|
public string CreatedOnUtc { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderDraftDetailModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ExternalUsername { get; set; } = string.Empty;
|
||||||
|
public int? CustomerId { get; set; }
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
public string RawMessageText { get; set; } = string.Empty;
|
||||||
|
public DateTime ParsedDeliveryDate { get; set; }
|
||||||
|
public bool IsPreorder { get; set; }
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public string AdminNote { get; set; } = string.Empty;
|
||||||
|
public string CreatedOnUtc { get; set; } = string.Empty;
|
||||||
|
public bool CanApprove { get; set; }
|
||||||
|
public List<OrderDraftItemModel> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderDraftItemModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string RawProductText { get; set; } = string.Empty;
|
||||||
|
public string AiResolvedProductName{ get; set; } = string.Empty;
|
||||||
|
public int RequestedQuantity { get; set; }
|
||||||
|
public bool IsPreorder { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public int? SelectedProductId { get; set; }
|
||||||
|
public string SelectedProductName { get; set; } = string.Empty;
|
||||||
|
public List<OrderDraftProductCandidate> Candidates { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderDraftProductCandidate
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Stock { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,8 @@ public class PreOrderDetailModel
|
||||||
public int CustomerId { get; set; }
|
public int CustomerId { get; set; }
|
||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
public string CustomerEmail { get; set; } = string.Empty;
|
public string CustomerEmail { get; set; } = string.Empty;
|
||||||
public string DateOfReceipt { get; set; } = string.Empty;
|
public string DateOfReceipt { get; set; } = string.Empty; // formatted for display
|
||||||
|
public string DateOfReceiptRaw { get; set; } = string.Empty; // yyyy-MM-ddTHH:mm for datetime-local input
|
||||||
public string CreatedOnUtc { get; set; } = string.Empty;
|
public string CreatedOnUtc { get; set; } = string.Empty;
|
||||||
public string UpdatedOnUtc { get; set; } = string.Empty;
|
public string UpdatedOnUtc { get; set; } = string.Empty;
|
||||||
public PreOrderStatus Status { get; set; }
|
public PreOrderStatus Status { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.OrderDraftDetailModel
|
||||||
|
@{
|
||||||
|
ViewBag.PageTitle = "Rendelés tervezet #" + Model.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="content-header clearfix">
|
||||||
|
<h1 class="float-left">
|
||||||
|
<i class="fas fa-inbox" style="color:#f4a236"></i>
|
||||||
|
Rendelés tervezet <strong>#@Model.Id</strong>
|
||||||
|
<span class="badge @(Model.Status == "Pending" ? "badge-warning" : Model.Status == "Approved" ? "badge-success" : "badge-danger")"
|
||||||
|
style="font-size:14px; vertical-align:middle">@Model.Status</span>
|
||||||
|
</h1>
|
||||||
|
<div class="float-right">
|
||||||
|
<a href="/Admin/OrderDraft" class="btn btn-default">
|
||||||
|
<i class="fas fa-arrow-left"></i> Vissza a listára
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="content">
|
||||||
|
<div class="container-fluid">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
{{-- BAL OLDAL: meta + üzenet --}}
|
||||||
|
<div class="col-md-4">
|
||||||
|
|
||||||
|
{{-- Eredeti üzenet --}}
|
||||||
|
<div class="card card-default">
|
||||||
|
<div class="card-header" style="background:#1a3c22; color:#fff">
|
||||||
|
<i class="fas fa-envelope"></i> Eredeti üzenet
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<pre style="white-space:pre-wrap; font-size:13px; background:#f5f7f2; border-radius:6px; padding:12px; border:1px solid #ddd">@Model.RawMessageText</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Meta adatok --}}
|
||||||
|
<div class="card card-default">
|
||||||
|
<div class="card-header"><i class="fas fa-info-circle"></i> Adatok</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm m-0">
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted" style="width:40%">Feladó</td>
|
||||||
|
<td><code>@Model.ExternalUsername</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">Partner</td>
|
||||||
|
<td id="partner-cell">
|
||||||
|
@if (Model.CustomerId.HasValue)
|
||||||
|
{
|
||||||
|
<a href="/Admin/Customer/Edit/@Model.CustomerId" target="_blank">@Model.CustomerName</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> Nincs hozzárendelve</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">Szállítás</td>
|
||||||
|
<td>
|
||||||
|
<strong>@Model.ParsedDeliveryDate.ToString("yyyy-MM-dd")</strong>
|
||||||
|
@if (Model.IsPreorder)
|
||||||
|
{
|
||||||
|
<span class="badge badge-info ml-1">Előrendelés</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge badge-warning ml-1">Azonnali</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">Beérkezett</td>
|
||||||
|
<td>@Model.CreatedOnUtc</td>
|
||||||
|
</tr>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.AdminNote))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">Megjegyzés</td>
|
||||||
|
<td class="text-danger">@Model.AdminNote</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Partner hozzárendelés (csak ha nincs még) --}}
|
||||||
|
@if (!Model.CustomerId.HasValue && Model.CanApprove)
|
||||||
|
{
|
||||||
|
<div class="card card-warning">
|
||||||
|
<div class="card-header"><i class="fas fa-user-plus"></i> Partner hozzárendelése</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted" style="font-size:13px">
|
||||||
|
Ismeretlen feladó: <code>@Model.ExternalUsername</code><br>
|
||||||
|
Rendeld hozzá a megfelelő partnerhez. A rendszer megjegyzi a kapcsolatot.
|
||||||
|
</p>
|
||||||
|
<input type="text" id="customer-search" class="form-control form-control-sm"
|
||||||
|
placeholder="Ügyfél neve vagy e-mail..." autocomplete="off" />
|
||||||
|
<input type="hidden" id="selected-customer-id" />
|
||||||
|
<div id="customer-search-results" class="list-group mt-1" style="display:none; position:absolute; z-index:999; width:300px"></div>
|
||||||
|
<button type="button" id="btn-assign-customer" class="btn btn-warning btn-sm mt-2" disabled>
|
||||||
|
<i class="fas fa-link"></i> Hozzárendelés
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>{{-- /col-md-4 --}}
|
||||||
|
|
||||||
|
{{-- JOBB OLDAL: tételek --}}
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card card-default">
|
||||||
|
<div class="card-header" style="background:#2d7a3a; color:#fff">
|
||||||
|
<i class="fas fa-list"></i> Tételek
|
||||||
|
<span class="badge badge-light float-right" style="color:#333">@Model.Items.Count db</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-bordered m-0" id="items-table">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:200px">Eredeti szöveg</th>
|
||||||
|
<th>AI javaslat / Termék kiválasztása</th>
|
||||||
|
<th style="width:90px">Menny. (kt.)</th>
|
||||||
|
<th style="width:80px">Típus</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in Model.Items)
|
||||||
|
{
|
||||||
|
<tr data-item-id="@item.Id">
|
||||||
|
<td>
|
||||||
|
<strong style="color:#1a3c22">@item.RawProductText</strong>
|
||||||
|
@if (!string.IsNullOrEmpty(item.AiResolvedProductName) && item.AiResolvedProductName != item.RawProductText)
|
||||||
|
{
|
||||||
|
<br><small class="text-muted">→ @item.AiResolvedProductName</small>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select class="form-control form-control-sm product-select"
|
||||||
|
data-item-id="@item.Id"
|
||||||
|
@(Model.CanApprove ? "" : "disabled")>
|
||||||
|
<option value="">— válassz terméket —</option>
|
||||||
|
@foreach (var c in item.Candidates)
|
||||||
|
{
|
||||||
|
var selected = c.Id == item.SelectedProductId ? "selected" : "";
|
||||||
|
<option value="@c.Id" @selected>
|
||||||
|
@c.Name (készlet: @c.Stock)
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
@if (item.SelectedProductId.HasValue && !item.Candidates.Any(c => c.Id == item.SelectedProductId))
|
||||||
|
{
|
||||||
|
<option value="@item.SelectedProductId" selected>@item.SelectedProductName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<div class="save-indicator text-success" style="display:none; font-size:11px; margin-top:2px">
|
||||||
|
<i class="fas fa-check"></i> Mentve
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" class="form-control form-control-sm qty-input"
|
||||||
|
value="@item.RequestedQuantity" min="1" step="1"
|
||||||
|
data-item-id="@item.Id"
|
||||||
|
@(Model.CanApprove ? "" : "disabled") />
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
@if (item.IsPreorder)
|
||||||
|
{
|
||||||
|
<span class="badge badge-info">Elő</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge badge-warning">Azonnali</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Akció gombok --}}
|
||||||
|
@if (Model.CanApprove)
|
||||||
|
{
|
||||||
|
<div class="card card-default">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<button type="button" id="btn-approve" class="btn btn-success btn-lg">
|
||||||
|
<i class="fas fa-check-circle"></i> Jóváhagyás és rendelés/előrendelés létrehozása
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-right">
|
||||||
|
<button type="button" id="btn-reject" class="btn btn-danger">
|
||||||
|
<i class="fas fa-times-circle"></i> Elutasítás
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="approve-result" class="mt-3" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>{{-- /col-md-8 --}}
|
||||||
|
|
||||||
|
</div>{{-- /row --}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge-info { background: #17a2b8; color:#fff; }
|
||||||
|
.badge-warning { background: #f4a236; color:#fff; }
|
||||||
|
.badge-success { background: #2d7a3a; color:#fff; }
|
||||||
|
.badge-danger { background: #c0392b; color:#fff; }
|
||||||
|
.badge-light { background: #eee; }
|
||||||
|
#items-table select { min-width: 200px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$(function () {
|
||||||
|
var _token = $('input[name="__RequestVerificationToken"]').val();
|
||||||
|
var _draftId = @Model.Id;
|
||||||
|
|
||||||
|
// ── Termék kiválasztás mentése blur/change-re ─────────────────────────
|
||||||
|
var _saveTimer = {};
|
||||||
|
|
||||||
|
function saveItem(itemId) {
|
||||||
|
var $row = $('tr[data-item-id="' + itemId + '"]');
|
||||||
|
var prodId = $row.find('.product-select').val();
|
||||||
|
var qty = parseInt($row.find('.qty-input').val()) || 1;
|
||||||
|
var $ind = $row.find('.save-indicator');
|
||||||
|
|
||||||
|
if (!prodId) return;
|
||||||
|
|
||||||
|
clearTimeout(_saveTimer[itemId]);
|
||||||
|
_saveTimer[itemId] = setTimeout(function() {
|
||||||
|
$.post('/Admin/OrderDraft/UpdateItem', {
|
||||||
|
__RequestVerificationToken : _token,
|
||||||
|
draftItemId : itemId,
|
||||||
|
selectedProductId: prodId,
|
||||||
|
quantity : qty
|
||||||
|
}).done(function(res) {
|
||||||
|
if (res.success) {
|
||||||
|
$ind.fadeIn(200).delay(1500).fadeOut(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).on('change', '.product-select', function() {
|
||||||
|
saveItem($(this).data('item-id'));
|
||||||
|
});
|
||||||
|
$(document).on('change', '.qty-input', function() {
|
||||||
|
saveItem($(this).data('item-id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Partner keresés ──────────────────────────────────────────────────
|
||||||
|
var _searchTimer;
|
||||||
|
$('#customer-search').on('input', function() {
|
||||||
|
var q = $(this).val().trim();
|
||||||
|
if (q.length < 2) { $('#customer-search-results').hide(); return; }
|
||||||
|
clearTimeout(_searchTimer);
|
||||||
|
_searchTimer = setTimeout(function() {
|
||||||
|
$.get('/Admin/CustomOrder/CustomerSearchAutoComplete', { term: q }, function(data) {
|
||||||
|
var $res = $('#customer-search-results').empty().show();
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
$res.append('<a class="list-group-item list-group-item-action text-muted">Nincs találat</a>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$.each(data, function(i, item) {
|
||||||
|
$res.append(
|
||||||
|
$('<a class="list-group-item list-group-item-action" href="#">')
|
||||||
|
.text(item.label)
|
||||||
|
.on('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
$('#customer-search').val(item.label);
|
||||||
|
$('#selected-customer-id').val(item.value);
|
||||||
|
$('#btn-assign-customer').prop('disabled', false);
|
||||||
|
$res.hide();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Partner hozzárendelés
|
||||||
|
$('#btn-assign-customer').on('click', function() {
|
||||||
|
var custId = $('#selected-customer-id').val();
|
||||||
|
if (!custId) return;
|
||||||
|
$.post('/Admin/OrderDraft/AssignCustomer', {
|
||||||
|
__RequestVerificationToken: _token,
|
||||||
|
draftId : _draftId,
|
||||||
|
customerId : custId
|
||||||
|
}).done(function(res) {
|
||||||
|
if (res.success) {
|
||||||
|
$('#partner-cell').html('<a href="/Admin/Customer/Edit/' + custId + '" target="_blank">' + res.customerName + '</a>');
|
||||||
|
$('.card-warning').slideUp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Elutasítás ───────────────────────────────────────────────────────
|
||||||
|
$('#btn-reject').on('click', function() {
|
||||||
|
var note = prompt('Opcionális megjegyzés az elutasítás okáról:');
|
||||||
|
if (note === null) return; // Cancel
|
||||||
|
$.post('/Admin/OrderDraft/Reject', {
|
||||||
|
__RequestVerificationToken: _token,
|
||||||
|
draftId : _draftId,
|
||||||
|
adminNote: note || ''
|
||||||
|
}).done(function(res) {
|
||||||
|
if (res.success) location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Jóváhagyás ──────────────────────────────────────────────────────
|
||||||
|
$('#btn-approve').on('click', function() {
|
||||||
|
var $btn = $(this);
|
||||||
|
|
||||||
|
// Ellenőrzés: minden tétel ki van töltve?
|
||||||
|
var missingCount = 0;
|
||||||
|
$('.product-select').each(function() {
|
||||||
|
if (!$(this).val()) missingCount++;
|
||||||
|
});
|
||||||
|
if (missingCount > 0) {
|
||||||
|
showResult('danger', missingCount + ' tétel nincs kitöltve. Minden sorban válassz terméket a jóváhagyás előtt.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Feldolgozás...');
|
||||||
|
|
||||||
|
$.post('/Admin/OrderDraft/Approve', {
|
||||||
|
__RequestVerificationToken: _token,
|
||||||
|
draftId: _draftId
|
||||||
|
}).done(function(res) {
|
||||||
|
if (res.success) {
|
||||||
|
var lines = ['<strong><i class="fas fa-check-circle"></i> Sikeresen létrehozva!</strong><br>'];
|
||||||
|
if (res.orderIds && res.orderIds.length > 0) {
|
||||||
|
lines.push('Rendelés(ek): ' + res.orderIds.map(function(id) {
|
||||||
|
return '<a href="/Admin/Order/Edit/' + id + '" target="_blank">#' + id + '</a>';
|
||||||
|
}).join(', '));
|
||||||
|
}
|
||||||
|
if (res.preOrderIds && res.preOrderIds.length > 0) {
|
||||||
|
lines.push('Előrendelés(ek): ' + res.preOrderIds.map(function(id) {
|
||||||
|
return '<a href="/Admin/PreOrders/Detail/' + id + '" target="_blank">#' + id + '</a>';
|
||||||
|
}).join(', '));
|
||||||
|
}
|
||||||
|
showResult('success', lines.join('<br>'));
|
||||||
|
setTimeout(function() { location.reload(); }, 2500);
|
||||||
|
} else {
|
||||||
|
showResult('danger', res.error || 'Ismeretlen hiba.');
|
||||||
|
$btn.prop('disabled', false).html('<i class="fas fa-check-circle"></i> Jóváhagyás és rendelés/előrendelés létrehozása');
|
||||||
|
}
|
||||||
|
}).fail(function() {
|
||||||
|
showResult('danger', 'Szerverhiba. Próbáld újra.');
|
||||||
|
$btn.prop('disabled', false).html('<i class="fas fa-check-circle"></i> Jóváhagyás és rendelés/előrendelés létrehozása');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function showResult(type, html) {
|
||||||
|
$('#approve-result')
|
||||||
|
.removeClass('alert-success alert-danger')
|
||||||
|
.addClass('alert alert-' + type)
|
||||||
|
.html(html)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kattintás máshova: customer search bezárás
|
||||||
|
$(document).on('click', function(e) {
|
||||||
|
if (!$(e.target).closest('#customer-search, #customer-search-results').length)
|
||||||
|
$('#customer-search-results').hide();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.OrderDraftListModel
|
||||||
|
@{
|
||||||
|
ViewBag.PageTitle = "Rendelés tervezetek";
|
||||||
|
NopHtml.SetActiveMenuItemSystemName("OrderDraft.List");
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="content-header clearfix">
|
||||||
|
<h1 class="float-left">
|
||||||
|
<i class="fas fa-inbox" style="color:#f4a236"></i>
|
||||||
|
Rendelés tervezetek
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="content">
|
||||||
|
<div class="container-fluid">
|
||||||
|
|
||||||
|
{{-- Státusz szűrő --}}
|
||||||
|
<div class="card card-default mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-warning btn-sm status-filter active" data-status="Pending">
|
||||||
|
<i class="fas fa-clock"></i> Függőben
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-success btn-sm status-filter" data-status="Approved">
|
||||||
|
<i class="fas fa-check"></i> Jóváhagyva
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm status-filter" data-status="Rejected">
|
||||||
|
<i class="fas fa-times"></i> Elutasítva
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm status-filter" data-status="Expired">
|
||||||
|
<i class="fas fa-hourglass-end"></i> Lejárt
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-light btn-sm status-filter" data-status="">
|
||||||
|
<i class="fas fa-list"></i> Összes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-default">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<table id="od-grid" class="table table-bordered table-hover m-0" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Feladó</th>
|
||||||
|
<th>Partner</th>
|
||||||
|
<th>Üzenet (részlet)</th>
|
||||||
|
<th>Szállítás</th>
|
||||||
|
<th>Tételek</th>
|
||||||
|
<th>Státusz</th>
|
||||||
|
<th>Beérkezett</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#od-grid tbody tr.od-urgent td { background-color: #fff3cd !important; }
|
||||||
|
#od-grid tbody tr:hover { background-color: #eaf2ff !important; }
|
||||||
|
.badge { font-size: 12px; padding: 4px 8px; }
|
||||||
|
.badge-warning { background: #f4a236; color: #fff; }
|
||||||
|
.badge-success { background: #2d7a3a; color: #fff; }
|
||||||
|
.badge-danger { background: #c0392b; color: #fff; }
|
||||||
|
.badge-secondary{ background: #888; color: #fff; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$(function () {
|
||||||
|
var _token = $('input[name="__RequestVerificationToken"]').val();
|
||||||
|
var _statusFilter = 'Pending';
|
||||||
|
|
||||||
|
var table = $('#od-grid').DataTable({
|
||||||
|
serverSide : true,
|
||||||
|
processing : true,
|
||||||
|
pageLength : 25,
|
||||||
|
lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]],
|
||||||
|
order : [[7, 'desc']],
|
||||||
|
language : {
|
||||||
|
processing : 'Betöltés...',
|
||||||
|
search : 'Keresés:',
|
||||||
|
lengthMenu : '_MENU_ sor/oldal',
|
||||||
|
info : '_START_ – _END_ / _TOTAL_ tervezet',
|
||||||
|
infoEmpty : '0 tervezet',
|
||||||
|
paginate : { first:'««', previous:'«', next:'»', last:'»»' },
|
||||||
|
emptyTable : 'Nincs találat',
|
||||||
|
zeroRecords: 'Nincs találat'
|
||||||
|
},
|
||||||
|
ajax: {
|
||||||
|
url : '/Admin/OrderDraft/OrderDraftList',
|
||||||
|
type: 'POST',
|
||||||
|
data: function (d) {
|
||||||
|
d.__RequestVerificationToken = _token;
|
||||||
|
d.statusFilter = _statusFilter;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
/* 0 */ { data: 'Id', width: '50px', className: 'text-center' },
|
||||||
|
/* 1 */ { data: 'ExternalUsername', render: function(d) { return '<code>' + d + '</code>'; } },
|
||||||
|
/* 2 */ { data: 'CustomerName', render: function(d, t, row) {
|
||||||
|
if (!row.CustomerId) return '<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> ' + d + '</span>';
|
||||||
|
return '<a href="/Admin/Customer/Edit/' + row.CustomerId + '" target="_blank">' + d + '</a>';
|
||||||
|
}},
|
||||||
|
/* 3 */ { data: 'RawMessageTextShort', orderable: false,
|
||||||
|
render: function(d) { return '<span class="text-muted" style="font-size:12px">' + d + '</span>'; } },
|
||||||
|
/* 4 */ { data: 'ParsedDeliveryDate', width: '100px', className: 'text-center' },
|
||||||
|
/* 5 */ { data: 'ItemCount', width: '70px', className: 'text-center',
|
||||||
|
render: function(d) { return '<span class="badge badge-info">' + d + '</span>'; } },
|
||||||
|
/* 6 */ { data: 'Status', width: '110px', className: 'text-center',
|
||||||
|
render: function(d, t, row) {
|
||||||
|
return '<span class="badge ' + row.StatusBadgeClass + '">' + d + '</span>';
|
||||||
|
}},
|
||||||
|
/* 7 */ { data: 'CreatedOnUtc', width: '130px', className: 'text-center' },
|
||||||
|
/* 8 */ { data: 'Id', orderable: false, searchable: false, width: '80px', className: 'text-center',
|
||||||
|
render: function(d) {
|
||||||
|
return '<a href="/Admin/OrderDraft/Detail/' + d + '" class="btn btn-primary btn-xs"><i class="fas fa-eye"></i> Megnyit</a>';
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
createdRow: function(row, data) {
|
||||||
|
if (data.IsUrgent) $(row).addClass('od-urgent');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Státusz szűrő gombok
|
||||||
|
$('.status-filter').on('click', function() {
|
||||||
|
$('.status-filter').removeClass('active');
|
||||||
|
$(this).addClass('active');
|
||||||
|
_statusFilter = $(this).data('status');
|
||||||
|
table.ajax.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -12,7 +12,6 @@
|
||||||
PreOrderStatus.Cancelled => "po-status-cancelled",
|
PreOrderStatus.Cancelled => "po-status-cancelled",
|
||||||
_ => "po-status-pending"
|
_ => "po-status-pending"
|
||||||
};
|
};
|
||||||
|
|
||||||
var statusLabel = Model.Status switch
|
var statusLabel = Model.Status switch
|
||||||
{
|
{
|
||||||
PreOrderStatus.Confirmed => "Megerősítve",
|
PreOrderStatus.Confirmed => "Megerősítve",
|
||||||
|
|
@ -20,6 +19,7 @@
|
||||||
PreOrderStatus.Cancelled => "Törölve",
|
PreOrderStatus.Cancelled => "Törölve",
|
||||||
_ => "Függőben"
|
_ => "Függőben"
|
||||||
};
|
};
|
||||||
|
var canEdit = Model.Status != PreOrderStatus.Cancelled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
|
|
@ -35,17 +35,23 @@
|
||||||
.po-meta-item .label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:#6b7c6e; margin-bottom:4px; }
|
.po-meta-item .label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:#6b7c6e; margin-bottom:4px; }
|
||||||
.po-meta-item .value { font-size:15px; color:#1a3c22; font-weight:600; }
|
.po-meta-item .value { font-size:15px; color:#1a3c22; font-weight:600; }
|
||||||
|
|
||||||
.item-fulfilled { background:#eaf7ee; }
|
.item-fulfilled { background:#eaf7ee; }
|
||||||
.item-partial { background:#fffbf0; }
|
.item-partial { background:#fffbf0; }
|
||||||
.item-dropped { background:#fdf0f0; color:#888; }
|
.item-dropped { background:#fdf0f0; color:#888; }
|
||||||
.item-pending { }
|
|
||||||
|
|
||||||
.qty-bar-wrap { width:100px; display:inline-block; vertical-align:middle; }
|
.qty-bar-wrap { width:100px; display:inline-block; vertical-align:middle; }
|
||||||
.qty-bar { height:6px; background:#dde8da; border-radius:3px; overflow:hidden; display:inline-block; width:100%; }
|
.qty-bar { height:6px; background:#dde8da; border-radius:3px; overflow:hidden; display:inline-block; width:100%; }
|
||||||
.qty-bar-fill { height:100%; border-radius:3px; }
|
.qty-bar-fill { height:100%; border-radius:3px; }
|
||||||
|
|
||||||
|
/* Click-to-edit */
|
||||||
|
.editable-cell { cursor:pointer; border-radius:4px; padding:2px 6px; transition:background .15s; }
|
||||||
|
.editable-cell:hover { background:#f0f8f1; outline:1px dashed #2d7a3a; }
|
||||||
|
.edit-input { width:90px; }
|
||||||
|
.btn-swap { padding:2px 7px; font-size:11px; }
|
||||||
|
|
||||||
|
.ui-autocomplete { z-index:1060 !important; max-height:220px; overflow-y:auto; overflow-x:hidden; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Back link -->
|
|
||||||
<a href="/Admin/PreOrders" class="btn btn-default btn-sm mb-3">
|
<a href="/Admin/PreOrders" class="btn btn-default btn-sm mb-3">
|
||||||
<i class="fas fa-arrow-left"></i> Vissza a listához
|
<i class="fas fa-arrow-left"></i> Vissza a listához
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -56,16 +62,25 @@
|
||||||
Előrendelés <strong>#@Model.PreOrderId</strong>
|
Előrendelés <strong>#@Model.PreOrderId</strong>
|
||||||
<span class="@statusClass ml-2">@statusLabel</span>
|
<span class="@statusClass ml-2">@statusLabel</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="float-right">
|
<div class="float-right d-flex align-items-center" style="gap:8px;">
|
||||||
@if (Model.OrderId.HasValue)
|
@if (Model.OrderId.HasValue)
|
||||||
{
|
{
|
||||||
<a href="/Admin/Order/Edit/@Model.OrderId" class="btn btn-success btn-sm" target="_blank">
|
<a href="/Admin/Order/Edit/@Model.OrderId" class="btn btn-success btn-sm" target="_blank">
|
||||||
<i class="fas fa-external-link-alt"></i> Rendelés #@Model.OrderId
|
<i class="fas fa-external-link-alt"></i> Rendelés #@Model.OrderId
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
@if (canEdit)
|
||||||
|
{
|
||||||
|
<button id="convertNowBtn" class="btn btn-warning btn-sm">
|
||||||
|
<i class="fas fa-bolt"></i> Konvertálás rendeléssé
|
||||||
|
</button>
|
||||||
|
<button id="editHeaderBtn" class="btn btn-default btn-sm">
|
||||||
|
<i class="fas fa-pencil-alt"></i> Szerkesztés
|
||||||
|
</button>
|
||||||
|
}
|
||||||
@if (Model.Status == PreOrderStatus.Pending)
|
@if (Model.Status == PreOrderStatus.Pending)
|
||||||
{
|
{
|
||||||
<button id="cancelBtn" class="btn btn-danger btn-sm ml-2">
|
<button id="cancelBtn" class="btn btn-danger btn-sm">
|
||||||
<i class="fas fa-times"></i> Visszavonás
|
<i class="fas fa-times"></i> Visszavonás
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
@ -75,39 +90,61 @@
|
||||||
<section class="content">
|
<section class="content">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
|
||||||
<!-- ── Meta cards ──────────────────────────────────────────────────── -->
|
<!-- ── Meta card ─────────────────────────────────────────────────── -->
|
||||||
<div class="po-meta-card">
|
<div class="po-meta-card">
|
||||||
<div class="po-meta-grid">
|
<!-- Read-only view -->
|
||||||
<div class="po-meta-item">
|
<div id="headerView">
|
||||||
<div class="label">Ügyfél</div>
|
<div class="po-meta-grid">
|
||||||
<div class="value">
|
<div class="po-meta-item">
|
||||||
<a href="/Admin/Customer/Edit/@Model.CustomerId">@Model.CustomerName</a>
|
<div class="label">Ügyfél</div>
|
||||||
|
<div class="value"><a href="/Admin/Customer/Edit/@Model.CustomerId">@Model.CustomerName</a></div>
|
||||||
|
<small class="text-muted">@Model.CustomerEmail</small>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">@Model.CustomerEmail</small>
|
<div class="po-meta-item">
|
||||||
</div>
|
<div class="label">Kért szállítási időpont</div>
|
||||||
<div class="po-meta-item">
|
<div class="value"><i class="fas fa-calendar-day text-muted mr-1"></i>@Model.DateOfReceipt</div>
|
||||||
<div class="label">Kért szállítási időpont</div>
|
|
||||||
<div class="value"><i class="fas fa-calendar-day text-muted mr-1"></i>@Model.DateOfReceipt</div>
|
|
||||||
</div>
|
|
||||||
<div class="po-meta-item">
|
|
||||||
<div class="label">Leadva</div>
|
|
||||||
<div class="value">@Model.CreatedOnUtc</div>
|
|
||||||
</div>
|
|
||||||
<div class="po-meta-item">
|
|
||||||
<div class="label">Utoljára frissítve</div>
|
|
||||||
<div class="value">@Model.UpdatedOnUtc</div>
|
|
||||||
</div>
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.CustomerNote))
|
|
||||||
{
|
|
||||||
<div class="po-meta-item" style="grid-column:1/-1;">
|
|
||||||
<div class="label">Ügyfél megjegyzése</div>
|
|
||||||
<div class="value" style="font-weight:400;font-size:14px;color:#444;">@Model.CustomerNote</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
<div class="po-meta-item">
|
||||||
|
<div class="label">Leadva</div>
|
||||||
|
<div class="value">@Model.CreatedOnUtc</div>
|
||||||
|
</div>
|
||||||
|
<div class="po-meta-item">
|
||||||
|
<div class="label">Utoljára frissítve</div>
|
||||||
|
<div class="value">@Model.UpdatedOnUtc</div>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.CustomerNote))
|
||||||
|
{
|
||||||
|
<div class="po-meta-item" style="grid-column:1/-1;">
|
||||||
|
<div class="label">Ügyfél megjegyzése</div>
|
||||||
|
<div class="value" style="font-weight:400;font-size:14px;color:#444;">@Model.CustomerNote</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit form -->
|
||||||
|
<div id="headerEdit" style="display:none;">
|
||||||
|
<div class="form-row align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="small font-weight-bold text-muted text-uppercase">Kért szállítási időpont</label>
|
||||||
|
<input type="datetime-local" id="editDelivery" class="form-control" value="@Model.DateOfReceiptRaw" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small font-weight-bold text-muted text-uppercase">Ügyfél megjegyzése</label>
|
||||||
|
<input type="text" id="editNote" class="form-control" value="@Model.CustomerNote"
|
||||||
|
maxlength="1000" placeholder="Megjegyzés..." />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex" style="gap:8px;">
|
||||||
|
<button id="saveHeaderBtn" class="btn btn-success btn-sm flex-grow-1">
|
||||||
|
<i class="fas fa-save"></i> Mentés
|
||||||
|
</button>
|
||||||
|
<button id="cancelHeaderBtn" class="btn btn-secondary btn-sm">Mégse</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Items table ─────────────────────────────────────────────────── -->
|
<!-- ── Items table ────────────────────────────────────────────────── -->
|
||||||
<div class="card card-default">
|
<div class="card card-default">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<strong>Tételek (@Model.Items.Count)</strong>
|
<strong>Tételek (@Model.Items.Count)</strong>
|
||||||
|
|
@ -118,7 +155,7 @@
|
||||||
var pending = Model.Items.Count(i => i.Status == PreOrderItemStatus.Pending);
|
var pending = Model.Items.Count(i => i.Status == PreOrderItemStatus.Pending);
|
||||||
}
|
}
|
||||||
<span class="ml-2 text-muted" style="font-size:13px;">
|
<span class="ml-2 text-muted" style="font-size:13px;">
|
||||||
@if (fulfilled > 0) { <span class="badge badge-success">@fulfilled teljesítve</span> }
|
@if (fulfilled > 0) { <span class="badge badge-success">@fulfilled teljesítve</span> }
|
||||||
@if (partial > 0) { <span class="badge badge-warning ml-1">@partial részben</span> }
|
@if (partial > 0) { <span class="badge badge-warning ml-1">@partial részben</span> }
|
||||||
@if (dropped > 0) { <span class="badge badge-danger ml-1">@dropped ejtve</span> }
|
@if (dropped > 0) { <span class="badge badge-danger ml-1">@dropped ejtve</span> }
|
||||||
@if (pending > 0) { <span class="badge badge-secondary ml-1">@pending függőben</span> }
|
@if (pending > 0) { <span class="badge badge-secondary ml-1">@pending függőben</span> }
|
||||||
|
|
@ -129,9 +166,9 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Termék</th>
|
<th>Termék</th>
|
||||||
<th width="80" class="text-center">Kérve</th>
|
<th width="90" class="text-center">Kérve</th>
|
||||||
<th width="80" class="text-center">Teljesítve</th>
|
<th width="90" class="text-center">Teljesítve</th>
|
||||||
<th width="160">Teljesítés</th>
|
<th width="150">Teljesítés</th>
|
||||||
<th width="130" class="text-right">Egységár</th>
|
<th width="130" class="text-right">Egységár</th>
|
||||||
<th width="130" class="text-right">Becsült ár</th>
|
<th width="130" class="text-right">Becsült ár</th>
|
||||||
<th width="110" class="text-center">Állapot</th>
|
<th width="110" class="text-center">Állapot</th>
|
||||||
|
|
@ -140,34 +177,66 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var item in Model.Items)
|
@foreach (var item in Model.Items)
|
||||||
{
|
{
|
||||||
var rowClass = item.Status switch
|
var rowClass = item.Status switch
|
||||||
{
|
{
|
||||||
PreOrderItemStatus.Fulfilled => "item-fulfilled",
|
PreOrderItemStatus.Fulfilled => "item-fulfilled",
|
||||||
PreOrderItemStatus.PartiallyFulfilled => "item-partial",
|
PreOrderItemStatus.PartiallyFulfilled => "item-partial",
|
||||||
PreOrderItemStatus.Dropped => "item-dropped",
|
PreOrderItemStatus.Dropped => "item-dropped",
|
||||||
_ => "item-pending"
|
_ => ""
|
||||||
};
|
};
|
||||||
var pct = item.RequestedQuantity > 0
|
var pct = item.RequestedQuantity > 0
|
||||||
? (int)Math.Round((double)item.FulfilledQuantity / item.RequestedQuantity * 100)
|
? (int)Math.Round((double)item.FulfilledQuantity / item.RequestedQuantity * 100)
|
||||||
: 0;
|
: 0;
|
||||||
var barColor = pct == 100 ? "#2d7a3a" : pct > 0 ? "#f4a236" : "#dc3545";
|
var barColor = pct == 100 ? "#2d7a3a" : pct > 0 ? "#f4a236" : "#dc3545";
|
||||||
var estimatedPrice = item.IsMeasurable
|
var estimatedPrice = item.IsMeasurable
|
||||||
? "—"
|
? "—"
|
||||||
: (item.UnitPriceInclTax * item.FulfilledQuantity).ToString("N0") + " Ft";
|
: (item.UnitPriceInclTax * item.FulfilledQuantity).ToString("N0") + " Ft";
|
||||||
var unitPrice = item.IsMeasurable ? "súlymérés" : item.UnitPriceInclTax.ToString("N0") + " Ft";
|
var unitPriceStr = item.IsMeasurable ? "súlymérés" : item.UnitPriceInclTax.ToString("N0") + " Ft";
|
||||||
|
var isLocked = item.FulfilledQuantity > 0;
|
||||||
|
|
||||||
<tr class="@rowClass">
|
<tr class="@rowClass" data-item-id="@item.ItemId">
|
||||||
|
<!-- Product + optional swap -->
|
||||||
<td>
|
<td>
|
||||||
<a href="/Admin/Product/Edit/@item.ProductId" target="_blank">@item.ProductName</a>
|
<a href="/Admin/Product/Edit/@item.ProductId" target="_blank">@item.ProductName</a>
|
||||||
@if (item.IsMeasurable)
|
@if (item.IsMeasurable)
|
||||||
{
|
{
|
||||||
<span class="badge badge-light ml-1" title="Súlymérést igényel">⚖️</span>
|
<span class="badge badge-light ml-1" title="Súlymérést igényel">⚖️</span>
|
||||||
}
|
}
|
||||||
|
@if (canEdit && !isLocked)
|
||||||
|
{
|
||||||
|
<button class="btn btn-default btn-swap ml-1 swap-product-btn"
|
||||||
|
data-item-id="@item.ItemId"
|
||||||
|
data-product-id="@item.ProductId"
|
||||||
|
title="Termék cseréje">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (isLocked)
|
||||||
|
{
|
||||||
|
<i class="fas fa-lock text-muted ml-1" title="Már teljesített" style="font-size:11px;"></i>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">@item.RequestedQuantity db</td>
|
|
||||||
|
<!-- Requested qty — click-to-edit if pending -->
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<strong>@item.FulfilledQuantity db</strong>
|
@if (canEdit && !isLocked)
|
||||||
|
{
|
||||||
|
<span class="editable-cell qty-display" data-item-id="@item.ItemId">@item.RequestedQuantity db</span>
|
||||||
|
<span class="qty-edit-wrap" style="display:none;">
|
||||||
|
<input type="number" class="form-control form-control-sm edit-input qty-input"
|
||||||
|
min="1" value="@item.RequestedQuantity" data-item-id="@item.ItemId" />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@item.RequestedQuantity<text> db</text>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<!-- Fulfilled — always read-only -->
|
||||||
|
<td class="text-center"><strong>@item.FulfilledQuantity db</strong></td>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
<td>
|
<td>
|
||||||
<div class="qty-bar-wrap">
|
<div class="qty-bar-wrap">
|
||||||
<div class="qty-bar">
|
<div class="qty-bar">
|
||||||
|
|
@ -176,8 +245,25 @@
|
||||||
</div>
|
</div>
|
||||||
<small class="ml-1">@pct%</small>
|
<small class="ml-1">@pct%</small>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">@unitPrice</td>
|
|
||||||
|
<!-- Unit price — click-to-edit if pending and not measurable -->
|
||||||
|
<td class="text-right">
|
||||||
|
@if (canEdit && !isLocked && !item.IsMeasurable)
|
||||||
|
{
|
||||||
|
<span class="editable-cell price-display" data-item-id="@item.ItemId">@unitPriceStr</span>
|
||||||
|
<span class="price-edit-wrap" style="display:none;">
|
||||||
|
<input type="number" class="form-control form-control-sm edit-input price-input"
|
||||||
|
min="0" value="@item.UnitPriceInclTax" data-item-id="@item.ItemId" />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@unitPriceStr
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
|
||||||
<td class="text-right">@estimatedPrice</td>
|
<td class="text-right">@estimatedPrice</td>
|
||||||
|
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<span class="po-status-@item.Status.ToString().ToLower()" style="font-size:11px;padding:2px 6px;">
|
<span class="po-status-@item.Status.ToString().ToLower()" style="font-size:11px;padding:2px 6px;">
|
||||||
@item.StatusLabel
|
@item.StatusLabel
|
||||||
|
|
@ -188,7 +274,8 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
@{
|
@{
|
||||||
var totalEstimated = Model.Items
|
var totalEstimated = Model.Items
|
||||||
.Where(i => !i.IsMeasurable && (i.Status == PreOrderItemStatus.Fulfilled || i.Status == PreOrderItemStatus.PartiallyFulfilled))
|
.Where(i => !i.IsMeasurable &&
|
||||||
|
(i.Status == PreOrderItemStatus.Fulfilled || i.Status == PreOrderItemStatus.PartiallyFulfilled))
|
||||||
.Sum(i => i.UnitPriceInclTax * i.FulfilledQuantity);
|
.Sum(i => i.UnitPriceInclTax * i.FulfilledQuantity);
|
||||||
}
|
}
|
||||||
<tfoot>
|
<tfoot>
|
||||||
|
|
@ -205,25 +292,192 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (Model.Status == PreOrderStatus.Pending)
|
<!-- ── Product swap modal ──────────────────────────────────────────────── -->
|
||||||
{
|
<div id="swapProductModal" class="modal fade" tabindex="-1" role="dialog">
|
||||||
<script>
|
<div class="modal-dialog">
|
||||||
$(function () {
|
<div class="modal-content">
|
||||||
$('#cancelBtn').click(function () {
|
<div class="modal-header" style="background:#2d7a3a;color:#fff;">
|
||||||
if (!confirm('Biztosan visszavonod ezt az előrendelést? Ez a művelet nem visszafordítható.')) return;
|
<h5 class="modal-title"><i class="fas fa-exchange-alt"></i> Termék cseréje</h5>
|
||||||
$.ajax({
|
<button type="button" class="close" data-dismiss="modal" style="color:#fff;"><span>×</span></button>
|
||||||
url : '/Admin/PreOrders/Cancel/@Model.PreOrderId',
|
</div>
|
||||||
type : 'POST',
|
<div class="modal-body">
|
||||||
data : { __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() },
|
<p class="text-muted" style="font-size:13px;">Csak az előrendelési ablakban elérhető termékek jelennek meg.</p>
|
||||||
success: function (res) {
|
<input type="text" id="swapProductSearch" autocomplete="off" class="form-control"
|
||||||
if (res.success) {
|
placeholder="Termék neve vagy SKU..." />
|
||||||
location.href = '/Admin/PreOrders';
|
<div id="swapSelectedProduct" class="mt-2" style="display:none;">
|
||||||
} else {
|
<span class="badge badge-success p-2" id="swapSelectedName"></span>
|
||||||
alert('Hiba: ' + (res.error || 'Ismeretlen hiba'));
|
<span class="ml-2 text-muted" id="swapNewPrice"></span>
|
||||||
}
|
</div>
|
||||||
}
|
<input type="hidden" id="swapNewProductId" />
|
||||||
});
|
<input type="hidden" id="swapItemId" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">Mégse</button>
|
||||||
|
<button type="button" id="confirmSwapBtn" class="btn btn-primary" disabled>
|
||||||
|
<i class="fas fa-check"></i> Csere megerősítése
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$(function () {
|
||||||
|
var _token = $('input[name="__RequestVerificationToken"]').val();
|
||||||
|
var preorderId = @Model.PreOrderId;
|
||||||
|
|
||||||
|
// ── Header edit toggle ────────────────────────────────────────────────
|
||||||
|
$('#editHeaderBtn').click(function () {
|
||||||
|
$('#headerView').hide(); $('#headerEdit').show(); $(this).hide();
|
||||||
|
});
|
||||||
|
$('#cancelHeaderBtn').click(function () {
|
||||||
|
$('#headerEdit').hide(); $('#headerView').show(); $('#editHeaderBtn').show();
|
||||||
|
});
|
||||||
|
$('#saveHeaderBtn').click(function () {
|
||||||
|
var $btn = $(this).prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
|
||||||
|
$.post('/Admin/PreOrders/UpdateHeader/' + preorderId, {
|
||||||
|
deliveryDateTime: $('#editDelivery').val(),
|
||||||
|
customerNote : $('#editNote').val(),
|
||||||
|
__RequestVerificationToken: _token
|
||||||
|
}, function (r) {
|
||||||
|
if (r.success) { location.reload(); }
|
||||||
|
else {
|
||||||
|
alert('Hiba: ' + (r.error || 'Ismeretlen hiba'));
|
||||||
|
$btn.prop('disabled', false).html('<i class="fas fa-save"></i> Mentés');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
}
|
// ── Click-to-edit: quantity ───────────────────────────────────────────
|
||||||
|
$(document).on('click', '.qty-display', function () {
|
||||||
|
var id = $(this).data('item-id');
|
||||||
|
$(this).hide();
|
||||||
|
$('[data-item-id="' + id + '"].qty-input').closest('.qty-edit-wrap').show()
|
||||||
|
.find('.qty-input').focus().select();
|
||||||
|
});
|
||||||
|
$(document).on('keydown blur', '.qty-input', function (e) {
|
||||||
|
if (e.type === 'keydown' && e.key === 'Escape') { cancelEdit($(this).data('item-id'), 'qty'); return; }
|
||||||
|
if (e.type === 'keydown' && e.key !== 'Enter') return;
|
||||||
|
saveItemField($(this).data('item-id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Click-to-edit: price ──────────────────────────────────────────────
|
||||||
|
$(document).on('click', '.price-display', function () {
|
||||||
|
var id = $(this).data('item-id');
|
||||||
|
$(this).hide();
|
||||||
|
$(this).siblings('.price-edit-wrap').show().find('.price-input').focus().select();
|
||||||
|
});
|
||||||
|
$(document).on('keydown blur', '.price-input', function (e) {
|
||||||
|
if (e.type === 'keydown' && e.key === 'Escape') { cancelEdit($(this).data('item-id'), 'price'); return; }
|
||||||
|
if (e.type === 'keydown' && e.key !== 'Enter') return;
|
||||||
|
saveItemField($(this).data('item-id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
function cancelEdit(id, type) {
|
||||||
|
if (type === 'qty') {
|
||||||
|
$('[data-item-id="' + id + '"].qty-display').show();
|
||||||
|
$('[data-item-id="' + id + '"].qty-input').closest('.qty-edit-wrap').hide();
|
||||||
|
} else {
|
||||||
|
$('[data-item-id="' + id + '"].price-display').show();
|
||||||
|
$('[data-item-id="' + id + '"].price-input').closest('.price-edit-wrap').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveItemField(id) {
|
||||||
|
var qty = parseInt($('[data-item-id="' + id + '"].qty-input').val()) || 1;
|
||||||
|
var price = parseFloat($('[data-item-id="' + id + '"].price-input').val()) || 0;
|
||||||
|
$.post('/Admin/PreOrders/UpdateItem/' + id, {
|
||||||
|
quantity: qty, unitPrice: price, __RequestVerificationToken: _token
|
||||||
|
}, function (r) {
|
||||||
|
if (r.success) {
|
||||||
|
$('[data-item-id="' + id + '"].qty-display').text(qty + ' db').show();
|
||||||
|
$('[data-item-id="' + id + '"].qty-input').closest('.qty-edit-wrap').hide();
|
||||||
|
$('[data-item-id="' + id + '"].price-display').text(Math.round(price).toLocaleString('hu-HU') + ' Ft').show();
|
||||||
|
$('[data-item-id="' + id + '"].price-input').closest('.price-edit-wrap').hide();
|
||||||
|
} else {
|
||||||
|
alert('Hiba: ' + (r.error || 'Ismeretlen hiba'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Product swap ──────────────────────────────────────────────────────
|
||||||
|
$(document).on('click', '.swap-product-btn', function () {
|
||||||
|
$('#swapItemId').val($(this).data('item-id'));
|
||||||
|
$('#swapNewProductId').val('');
|
||||||
|
$('#swapProductSearch, #swapSelectedName, #swapNewPrice').val('').text('');
|
||||||
|
$('#swapSelectedProduct').hide();
|
||||||
|
$('#confirmSwapBtn').prop('disabled', true);
|
||||||
|
$('#swapProductModal').modal('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#swapProductSearch').autocomplete({
|
||||||
|
delay: 400, minLength: 2,
|
||||||
|
source: '/Admin/CustomOrder/PreorderProductSearchAutoComplete',
|
||||||
|
select: function (e, ui) {
|
||||||
|
$('#swapNewProductId').val(ui.item.value);
|
||||||
|
$('#swapSelectedName').text(ui.item.label);
|
||||||
|
$('#swapSelectedProduct').show();
|
||||||
|
$('#confirmSwapBtn').prop('disabled', false);
|
||||||
|
$('#swapProductSearch').val('');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#swapProductModal').on('hidden.bs.modal', function () {
|
||||||
|
$('#swapNewProductId').val('');
|
||||||
|
$('#swapSelectedProduct').hide();
|
||||||
|
$('#confirmSwapBtn').prop('disabled', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#confirmSwapBtn').click(function () {
|
||||||
|
var $btn = $(this).prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
|
||||||
|
$.post('/Admin/PreOrders/ReplaceItemProduct/' + $('#swapItemId').val(), {
|
||||||
|
newProductId: $('#swapNewProductId').val(),
|
||||||
|
__RequestVerificationToken: _token
|
||||||
|
}, function (r) {
|
||||||
|
if (r.success) {
|
||||||
|
$('#swapProductModal').modal('hide');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Hiba: ' + (r.error || 'Ismeretlen hiba'));
|
||||||
|
$btn.prop('disabled', false).html('<i class="fas fa-check"></i> Csere megerősítése');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Manual conversion ─────────────────────────────────────────────────
|
||||||
|
$('#convertNowBtn').click(function () {
|
||||||
|
if (!confirm('Azonnali konvertálást indítasz — a függőben lévő tételeket megpróbálja rendeléssé alakítani a jelenlegi készlet alapján. Folytatod?')) return;
|
||||||
|
var $btn = $(this).prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Konvertálás...');
|
||||||
|
$.post('/Admin/PreOrders/ConvertNow/' + preorderId, {
|
||||||
|
__RequestVerificationToken: _token
|
||||||
|
}, function (r) {
|
||||||
|
if (r.success) {
|
||||||
|
alert(r.orderId
|
||||||
|
? 'Sikeresen konvertálva! Rendelés: #' + r.orderId
|
||||||
|
: 'A konvertálás lefutott, de nincs elegendő készlet.');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Hiba: ' + (r.error || 'Ismeretlen hiba'));
|
||||||
|
$btn.prop('disabled', false).html('<i class="fas fa-bolt"></i> Konvertálás rendeléssé');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Cancel preorder ───────────────────────────────────────────────────
|
||||||
|
@if (Model.Status == PreOrderStatus.Pending)
|
||||||
|
{
|
||||||
|
<text>
|
||||||
|
$('#cancelBtn').click(function () {
|
||||||
|
if (!confirm('Biztosan visszavonod ezt az előrendelést? Ez a művelet nem visszafordítható.')) return;
|
||||||
|
$.post('/Admin/PreOrders/Cancel/@Model.PreOrderId', {
|
||||||
|
__RequestVerificationToken: _token
|
||||||
|
}, function (r) {
|
||||||
|
if (r.success) { location.href = '/Admin/PreOrders'; }
|
||||||
|
else { alert('Hiba: ' + (r.error || 'Ismeretlen hiba')); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nop.Core.Domain.Common;
|
||||||
|
using Nop.Core.Domain.Customers;
|
||||||
using Nop.Plugin.Misc.FruitBankPlugin.Models;
|
using Nop.Plugin.Misc.FruitBankPlugin.Models;
|
||||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||||
|
using Nop.Services.Customers;
|
||||||
using Nop.Web.Areas.Admin.Models.Customers;
|
using Nop.Web.Areas.Admin.Models.Customers;
|
||||||
using Nop.Web.Framework.Components;
|
using Nop.Web.Framework.Components;
|
||||||
|
|
||||||
|
|
@ -10,10 +14,17 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components;
|
||||||
public class CustomerCreditWidgetViewComponent : NopViewComponent
|
public class CustomerCreditWidgetViewComponent : NopViewComponent
|
||||||
{
|
{
|
||||||
private readonly ICustomerCreditService _customerCreditService;
|
private readonly ICustomerCreditService _customerCreditService;
|
||||||
|
private readonly FruitBankAttributeService _attributeService;
|
||||||
|
private readonly ICustomerService _customerService;
|
||||||
|
|
||||||
public CustomerCreditWidgetViewComponent(ICustomerCreditService customerCreditService)
|
public CustomerCreditWidgetViewComponent(
|
||||||
|
ICustomerCreditService customerCreditService,
|
||||||
|
FruitBankAttributeService attributeService,
|
||||||
|
ICustomerService customerService)
|
||||||
{
|
{
|
||||||
_customerCreditService = customerCreditService;
|
_customerCreditService = customerCreditService;
|
||||||
|
_attributeService = attributeService;
|
||||||
|
_customerService = customerService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IViewComponentResult> InvokeAsync(string widgetZone, object additionalData)
|
public async Task<IViewComponentResult> InvokeAsync(string widgetZone, object additionalData)
|
||||||
|
|
@ -24,6 +35,9 @@ public class CustomerCreditWidgetViewComponent : NopViewComponent
|
||||||
var credit = await _customerCreditService.GetByCustomerIdAsync(customerId);
|
var credit = await _customerCreditService.GetByCustomerIdAsync(customerId);
|
||||||
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
|
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
|
||||||
|
|
||||||
|
var isEkaer = await _attributeService
|
||||||
|
.GetGenericAttributeValueAsync<Customer, bool>(customerId, FruitBankPluginConst.IsEkaerAttribute, storeId: 0);
|
||||||
|
|
||||||
var model = new CustomerCreditWidgetModel
|
var model = new CustomerCreditWidgetModel
|
||||||
{
|
{
|
||||||
CustomerId = customerId,
|
CustomerId = customerId,
|
||||||
|
|
@ -31,9 +45,84 @@ public class CustomerCreditWidgetViewComponent : NopViewComponent
|
||||||
CreditLimit = credit?.CreditLimit ?? 0m,
|
CreditLimit = credit?.CreditLimit ?? 0m,
|
||||||
OutstandingBalance = outstanding,
|
OutstandingBalance = outstanding,
|
||||||
RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null,
|
RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null,
|
||||||
Comment = credit?.Comment
|
Comment = credit?.Comment,
|
||||||
|
IsEkaer = isEkaer
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await PopulateSitesAsync(model, customerId);
|
||||||
|
await PopulatePlatesAsync(model, customerId);
|
||||||
|
|
||||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model);
|
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)
|
||||||
|
{
|
||||||
|
var addresses = await _customerService.GetAddressesByCustomerIdAsync(customerId);
|
||||||
|
|
||||||
|
var savedJson = await _attributeService
|
||||||
|
.GetGenericAttributeValueAsync<Customer, string>(customerId, FruitBankPluginConst.CustomerSitesAttribute, storeId: 0);
|
||||||
|
|
||||||
|
var saved = ParseSites(savedJson);
|
||||||
|
var savedById = saved.ToDictionary(s => s.AddressId);
|
||||||
|
|
||||||
|
foreach (var addr in addresses)
|
||||||
|
{
|
||||||
|
savedById.TryGetValue(addr.Id, out var entry);
|
||||||
|
model.SiteAddresses.Add(new CustomerSiteAddressRow
|
||||||
|
{
|
||||||
|
AddressId = addr.Id,
|
||||||
|
Display = FormatAddress(addr),
|
||||||
|
IsSite = entry != null,
|
||||||
|
IsDefault = entry?.IsDefault ?? false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var sites = model.SiteAddresses.Where(a => a.IsSite).ToList();
|
||||||
|
model.SiteCount = sites.Count;
|
||||||
|
model.DefaultSiteDisplay = sites.FirstOrDefault(a => a.IsDefault)?.Display;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CustomerSiteEntry> ParseSites(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||||
|
try { return JsonSerializer.Deserialize<List<CustomerSiteEntry>>(json) ?? new(); }
|
||||||
|
catch { return new(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatAddress(Address a)
|
||||||
|
{
|
||||||
|
var name = !string.IsNullOrWhiteSpace(a.Company)
|
||||||
|
? a.Company
|
||||||
|
: $"{a.FirstName} {a.LastName}".Trim();
|
||||||
|
|
||||||
|
var locality = $"{a.ZipPostalCode} {a.City}".Trim();
|
||||||
|
|
||||||
|
var parts = new[] { name, a.Address1, locality }
|
||||||
|
.Where(p => !string.IsNullOrWhiteSpace(p));
|
||||||
|
|
||||||
|
var display = string.Join(", ", parts);
|
||||||
|
return string.IsNullOrWhiteSpace(display) ? $"#{a.Id}" : display;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
// File: Plugins/YourCompany.ProductAttributes/Components/ProductAttributesViewComponent.cs
|
using System.Text.Json;
|
||||||
|
|
||||||
using FruitBank.Common.Interfaces;
|
using FruitBank.Common.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nop.Core;
|
using Nop.Core;
|
||||||
using Nop.Core.Domain.Catalog;
|
using Nop.Core.Domain.Catalog;
|
||||||
|
using Nop.Core.Domain.Common;
|
||||||
|
using Nop.Core.Domain.Customers;
|
||||||
using Nop.Core.Domain.Orders;
|
using Nop.Core.Domain.Orders;
|
||||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
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.Models.Orders;
|
||||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||||
using Nop.Services.Common;
|
using Nop.Services.Common;
|
||||||
|
using Nop.Services.Customers;
|
||||||
using Nop.Web.Areas.Admin.Models.Catalog;
|
using Nop.Web.Areas.Admin.Models.Catalog;
|
||||||
using Nop.Web.Areas.Admin.Models.Orders;
|
using Nop.Web.Areas.Admin.Models.Orders;
|
||||||
using Nop.Web.Framework.Components;
|
using Nop.Web.Framework.Components;
|
||||||
|
|
@ -21,15 +24,18 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components
|
||||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||||
private readonly IWorkContext _workContext;
|
private readonly IWorkContext _workContext;
|
||||||
private readonly IStoreContext _storeContext;
|
private readonly IStoreContext _storeContext;
|
||||||
|
private readonly ICustomerService _customerService;
|
||||||
private FruitBankDbContext _dbContext;
|
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;
|
_workContext = workContext;
|
||||||
_storeContext = storeContext;
|
_storeContext = storeContext;
|
||||||
_fruitBankAttributeService = fruitBankAttributeService;
|
_fruitBankAttributeService = fruitBankAttributeService;
|
||||||
|
_customerService = customerService;
|
||||||
|
|
||||||
_dbContext= dbContext;
|
_dbContext = dbContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IViewComponentResult> InvokeAsync(string widgetZone, object additionalData)
|
public async Task<IViewComponentResult> InvokeAsync(string widgetZone, object additionalData)
|
||||||
|
|
@ -45,19 +51,69 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components
|
||||||
model.IsMeasurable = orderDto.IsMeasurable;
|
model.IsMeasurable = orderDto.IsMeasurable;
|
||||||
model.DateOfReceipt = orderDto.DateOfReceipt;
|
model.DateOfReceipt = orderDto.DateOfReceipt;
|
||||||
model.OrderDto = orderDto;
|
model.OrderDto = orderDto;
|
||||||
//var orderPickupAttributeValue = await _fruitBankAttributeService.GetGenericAttributeValueAsync<Order, DateTime?>(model.OrderId, nameof(IOrderDto.DateOfReceipt));
|
|
||||||
|
|
||||||
//if (orderPickupAttributeValue.HasValue && orderPickupAttributeValue.Value != DateTime.MinValue)
|
// Site (telephely) + license plate (rendszám)
|
||||||
//{
|
model.CustomerId = orderDto.CustomerId;
|
||||||
// model.DateOfReceipt = orderPickupAttributeValue;
|
model.Site = orderDto.Site;
|
||||||
//}
|
model.LicensePlate = orderDto.LicensePlate;
|
||||||
//else
|
model.CustomerSites = await BuildCustomerSiteOptionsAsync(orderDto.CustomerId);
|
||||||
//{
|
model.CustomerLicensePlateOptions = await BuildCustomerPlateOptionsAsync(orderDto.CustomerId);
|
||||||
// model.DateOfReceipt = null;
|
|
||||||
//}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/OrderAttributes.cshtml", model);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ using DocumentFormat.OpenXml.Office2010.Excel;
|
||||||
using FruitBank.Common.Dtos;
|
using FruitBank.Common.Dtos;
|
||||||
using FruitBank.Common.Entities;
|
using FruitBank.Common.Entities;
|
||||||
using FruitBank.Common.Interfaces;
|
using FruitBank.Common.Interfaces;
|
||||||
using FruitBank.Common.Loggers;
|
//using FruitBank.Common.Loggers;
|
||||||
using FruitBank.Common.Models;
|
using FruitBank.Common.Models;
|
||||||
using FruitBank.Common.Server;
|
using FruitBank.Common.Server;
|
||||||
using FruitBank.Common.Server.Interfaces;
|
using FruitBank.Common.Server.Interfaces;
|
||||||
|
|
@ -618,18 +618,31 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
||||||
// Load BEFORE the update to capture previous ProductId and QuantityOnDocument
|
// Load BEFORE the update to capture previous ProductId and QuantityOnDocument
|
||||||
var oldItem = await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, false);
|
var oldItem = await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, false);
|
||||||
|
|
||||||
if (!await ctx.UpdateShippingItemSafeAsync(shippingItem)) return null;
|
// For product swap: move IncomingQuantity BEFORE saving the new ProductId to DB.
|
||||||
|
// This ensures the EventConsumer (triggered by UpdateShippingItemSafeAsync) already
|
||||||
|
// sees the correct IncomingQuantity when ConvertPreOrdersForProductsAsync fires.
|
||||||
|
var productChanged = oldItem != null && oldItem.ProductId != shippingItem.ProductId;
|
||||||
|
var quantityChanged = oldItem != null && oldItem.QuantityOnDocument != shippingItem.QuantityOnDocument;
|
||||||
|
|
||||||
|
if (productChanged && shippingItem.ProductId.HasValue &&
|
||||||
|
oldItem?.ProductId.HasValue == true && !oldItem.IsMeasured)
|
||||||
|
{
|
||||||
|
var qty = oldItem.QuantityOnDocument;
|
||||||
|
await preorderConversionService.SyncIncomingQuantityAsync(oldItem.ProductId.Value, qty, 0);
|
||||||
|
await preorderConversionService.SyncIncomingQuantityAsync(shippingItem.ProductId.Value, 0, qty);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await ctx.UpdateShippingItemSafeAsync(shippingItem)) return null;
|
||||||
|
|
||||||
if (oldItem != null)
|
if (oldItem != null)
|
||||||
{
|
{
|
||||||
var productChanged = oldItem.ProductId != shippingItem.ProductId;
|
|
||||||
var quantityChanged = oldItem.QuantityOnDocument != shippingItem.QuantityOnDocument;
|
|
||||||
|
|
||||||
if (productChanged && shippingItem.ProductId.HasValue)
|
if (productChanged && shippingItem.ProductId.HasValue)
|
||||||
{
|
{
|
||||||
// Full replacement: swap stock, order items, preorder items
|
// Full replacement: swap stock, order items, preorder items
|
||||||
|
// skipIncomingSync=true because we already moved IncomingQty above
|
||||||
await preorderConversionService.ReplaceShippingItemProductAsync(
|
await preorderConversionService.ReplaceShippingItemProductAsync(
|
||||||
shippingItem.Id, shippingItem.ProductId.Value, oldItem);
|
shippingItem.Id, shippingItem.ProductId.Value, oldItem,
|
||||||
|
skipIncomingSync: !oldItem.IsMeasured);
|
||||||
}
|
}
|
||||||
else if (quantityChanged && shippingItem.ProductId.HasValue)
|
else if (quantityChanged && shippingItem.ProductId.HasValue)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,32 @@ public class OrderController : BasePluginController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TRANSCRIBE ONLY (for preorder history page filter) ─────────────────
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> TranscribeOnly(Microsoft.AspNetCore.Http.IFormFile audioFile)
|
||||||
|
{
|
||||||
|
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||||
|
if (await _customerService.IsGuestAsync(customer))
|
||||||
|
return Json(new { success = false });
|
||||||
|
|
||||||
|
if (audioFile == null || audioFile.Length == 0)
|
||||||
|
return Json(new { success = false, message = "Nem érkezett hangfájl" });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var text = await TranscribeAudioFile(audioFile, "hu");
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return Json(new { success = false, message = "Nem sikerült a hangfelismerés" });
|
||||||
|
|
||||||
|
return Json(new { success = true, transcription = text });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── ADD TO CART (Quick Order flow) ────────────────────────────────────────
|
// ── ADD TO CART (Quick Order flow) ────────────────────────────────────────
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||||
|
using Nop.Services.Configuration;
|
||||||
|
using Nop.Web.Framework.Controllers;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Külső rendszerek (Viber bot, email feldolgozó, diktáló app) hívják ezt az endpointot.
|
||||||
|
/// Authentikáció: X-FruitBank-ApiKey header, értéke megegyezik a FruitBankSettings.OrderDraftApiKey-jel.
|
||||||
|
/// </summary>
|
||||||
|
public class OrderDraftApiController : BasePluginController
|
||||||
|
{
|
||||||
|
private readonly IOrderDraftService _orderDraftService;
|
||||||
|
private readonly FruitBankSettings _settings;
|
||||||
|
|
||||||
|
public OrderDraftApiController(
|
||||||
|
IOrderDraftService orderDraftService,
|
||||||
|
ISettingService settingService)
|
||||||
|
{
|
||||||
|
_orderDraftService = orderDraftService;
|
||||||
|
_settings = settingService.LoadSetting<FruitBankSettings>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POST /api/order-draft
|
||||||
|
/// Body: { "externalUsername": "...", "messageText": "..." }
|
||||||
|
/// Header: X-FruitBank-ApiKey: {key}
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] OrderDraftApiRequest request)
|
||||||
|
{
|
||||||
|
// API key validáció
|
||||||
|
if (!ValidateApiKey())
|
||||||
|
return Unauthorized(new { error = "Érvénytelen vagy hiányzó API kulcs." });
|
||||||
|
|
||||||
|
if (request == null
|
||||||
|
|| string.IsNullOrWhiteSpace(request.ExternalUsername)
|
||||||
|
|| string.IsNullOrWhiteSpace(request.MessageText))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "ExternalUsername és MessageText megadása kötelező." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var drafts = await _orderDraftService.CreateDraftsFromMessageAsync(
|
||||||
|
request.ExternalUsername.Trim(),
|
||||||
|
request.MessageText.Trim());
|
||||||
|
|
||||||
|
if (!drafts.Any())
|
||||||
|
return Ok(new OrderDraftApiResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Az üzenetből nem sikerült rendelési szándékot azonosítani.",
|
||||||
|
Drafts = []
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(new OrderDraftApiResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = $"{drafts.Count} rendelés tervezet létrehozva.",
|
||||||
|
Drafts = drafts.Select(d => new OrderDraftApiResponseItem
|
||||||
|
{
|
||||||
|
DraftId = d.Id,
|
||||||
|
ParsedDeliveryDate = d.ParsedDeliveryDate,
|
||||||
|
IsPartnerKnown = d.CustomerId.HasValue,
|
||||||
|
ItemCount = d.Items?.Count ?? 0
|
||||||
|
}).ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[OrderDraftApiController] Error: {ex}");
|
||||||
|
return StatusCode(500, new { error = "Belső hiba. Próbáld újra később." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ValidateApiKey()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_settings.OrderDraftApiKey))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Request.Headers.TryGetValue("X-FruitBank-ApiKey", out var providedKey);
|
||||||
|
return string.Equals(
|
||||||
|
_settings.OrderDraftApiKey,
|
||||||
|
providedKey.ToString(),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request / Response modellek ───────────────────────────────────────────
|
||||||
|
|
||||||
|
public class OrderDraftApiRequest
|
||||||
|
{
|
||||||
|
public string ExternalUsername { get; set; } = string.Empty;
|
||||||
|
public string MessageText { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderDraftApiResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public List<OrderDraftApiResponseItem> Drafts { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderDraftApiResponseItem
|
||||||
|
{
|
||||||
|
public int DraftId { get; set; }
|
||||||
|
public DateTime ParsedDeliveryDate { get; set; }
|
||||||
|
public bool IsPartnerKnown { get; set; }
|
||||||
|
public int ItemCount { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
using FruitBank.Common.Entities;
|
||||||
|
using LinqToDB;
|
||||||
|
using Mango.Nop.Data.Repositories;
|
||||||
|
using Nop.Core.Caching;
|
||||||
|
using Nop.Core.Configuration;
|
||||||
|
using Nop.Core.Events;
|
||||||
|
using Nop.Data;
|
||||||
|
using Mango.Nop.Core.Loggers;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||||
|
|
||||||
|
public class ExternalUserPartnerMapDbTable : MgDbTableBase<ExternalUserPartnerMapping>
|
||||||
|
{
|
||||||
|
public ExternalUserPartnerMapDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
|
||||||
|
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ExternalUserPartnerMapping?> GetByUsernameAsync(string externalUsername)
|
||||||
|
=> GetAll().FirstOrDefaultAsync(m => m.ExternalUsername == externalUsername);
|
||||||
|
|
||||||
|
public IQueryable<ExternalUserPartnerMapping> GetByCustomerId(int customerId)
|
||||||
|
=> GetAll().Where(m => m.CustomerId == customerId);
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,11 @@ public class FruitBankDbContext : MgDbContextBase,
|
||||||
public StockQuantityHistoryDtoDbTable StockQuantityHistoryDtos { get; set; }
|
public StockQuantityHistoryDtoDbTable StockQuantityHistoryDtos { get; set; }
|
||||||
public CustomerCreditDbTable CustomerCredits { get; set; }
|
public CustomerCreditDbTable CustomerCredits { get; set; }
|
||||||
|
|
||||||
|
public OrderDraftDbTable OrderDrafts { get; set; }
|
||||||
|
public OrderDraftItemDbTable OrderDraftItems { get; set; }
|
||||||
|
public ExternalUserPartnerMapDbTable ExternalUserPartnerMaps { get; set; }
|
||||||
|
public ProductTextMappingDbTable ProductTextMappings { get; set; }
|
||||||
|
|
||||||
public IRepository<Customer> Customers { get; set; }
|
public IRepository<Customer> Customers { get; set; }
|
||||||
public IRepository<CustomerRole> CustomerRoles { get; set; }
|
public IRepository<CustomerRole> CustomerRoles { get; set; }
|
||||||
public IRepository<CustomerCustomerRoleMapping> CustomerRoleMappings { get; set; }
|
public IRepository<CustomerCustomerRoleMapping> CustomerRoleMappings { get; set; }
|
||||||
|
|
@ -88,6 +93,8 @@ public class FruitBankDbContext : MgDbContextBase,
|
||||||
ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable,
|
ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable,
|
||||||
ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable,
|
ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable,
|
||||||
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable,
|
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable,
|
||||||
|
OrderDraftDbTable orderDraftDbTable, OrderDraftItemDbTable orderDraftItemDbTable,
|
||||||
|
ExternalUserPartnerMapDbTable externalUserPartnerMapDbTable, ProductTextMappingDbTable productTextMappingDbTable,
|
||||||
IProductService productService, IStaticCacheManager staticCacheManager,
|
IProductService productService, IStaticCacheManager staticCacheManager,
|
||||||
IRepository<Order> orderRepository,
|
IRepository<Order> orderRepository,
|
||||||
IRepository<OrderItem> orderItemRepository,
|
IRepository<OrderItem> orderItemRepository,
|
||||||
|
|
@ -141,6 +148,11 @@ public class FruitBankDbContext : MgDbContextBase,
|
||||||
StockQuantityHistoriesExt = stockQuantityHistoriesExt;
|
StockQuantityHistoriesExt = stockQuantityHistoriesExt;
|
||||||
StockQuantityHistoryDtos = stockQuantityHistoryDtos;
|
StockQuantityHistoryDtos = stockQuantityHistoryDtos;
|
||||||
CustomerCredits = customerCreditDbTable;
|
CustomerCredits = customerCreditDbTable;
|
||||||
|
|
||||||
|
OrderDrafts = orderDraftDbTable;
|
||||||
|
OrderDraftItems = orderDraftItemDbTable;
|
||||||
|
ExternalUserPartnerMaps = externalUserPartnerMapDbTable;
|
||||||
|
ProductTextMappings = productTextMappingDbTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IQueryable<Customer> GetCustomersBySystemRoleName(string systemRoleName)
|
public IQueryable<Customer> GetCustomersBySystemRoleName(string systemRoleName)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
using FruitBank.Common.Entities;
|
||||||
|
using FruitBank.Common.Enums;
|
||||||
|
using LinqToDB;
|
||||||
|
using Mango.Nop.Data.Repositories;
|
||||||
|
using Nop.Core.Caching;
|
||||||
|
using Nop.Core.Configuration;
|
||||||
|
using Nop.Core.Events;
|
||||||
|
using Nop.Data;
|
||||||
|
using Mango.Nop.Core.Loggers;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||||
|
|
||||||
|
public class OrderDraftDbTable : MgDbTableBase<OrderDraft>
|
||||||
|
{
|
||||||
|
public OrderDraftDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
|
||||||
|
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public IQueryable<OrderDraft> GetAll(bool loadRelations)
|
||||||
|
{
|
||||||
|
return loadRelations
|
||||||
|
? GetAll().LoadWith(d => d.Items)
|
||||||
|
: GetAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<OrderDraft?> GetByIdAsync(int id, bool loadRelations)
|
||||||
|
=> GetAll(loadRelations).FirstOrDefaultAsync(d => d.Id == id);
|
||||||
|
|
||||||
|
public IQueryable<OrderDraft> GetPending()
|
||||||
|
=> GetAll().Where(d => d.Status == OrderDraftStatus.Pending);
|
||||||
|
|
||||||
|
public IQueryable<OrderDraft> GetExpiredCandidates()
|
||||||
|
=> GetAll().Where(d => d.Status == OrderDraftStatus.Pending
|
||||||
|
&& d.CreatedOnUtc <= DateTime.UtcNow.AddDays(-14));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
using FruitBank.Common.Entities;
|
||||||
|
using LinqToDB;
|
||||||
|
using Mango.Nop.Data.Repositories;
|
||||||
|
using Nop.Core.Caching;
|
||||||
|
using Nop.Core.Configuration;
|
||||||
|
using Nop.Core.Events;
|
||||||
|
using Nop.Data;
|
||||||
|
using Mango.Nop.Core.Loggers;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||||
|
|
||||||
|
public class OrderDraftItemDbTable : MgDbTableBase<OrderDraftItem>
|
||||||
|
{
|
||||||
|
public OrderDraftItemDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
|
||||||
|
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public IQueryable<OrderDraftItem> GetByDraftId(int draftId)
|
||||||
|
=> GetAll().Where(i => i.DraftId == draftId).OrderBy(i => i.SortOrder);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
using FruitBank.Common.Entities;
|
||||||
|
using LinqToDB;
|
||||||
|
using Mango.Nop.Data.Repositories;
|
||||||
|
using Nop.Core.Caching;
|
||||||
|
using Nop.Core.Configuration;
|
||||||
|
using Nop.Core.Events;
|
||||||
|
using Nop.Data;
|
||||||
|
using Mango.Nop.Core.Loggers;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||||
|
|
||||||
|
public class ProductTextMappingDbTable : MgDbTableBase<ProductTextMapping>
|
||||||
|
{
|
||||||
|
public ProductTextMappingDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
|
||||||
|
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public IQueryable<ProductTextMapping> GetByRawText(string rawText)
|
||||||
|
=> GetAll().Where(m => m.RawText == rawText.ToLower().Trim());
|
||||||
|
|
||||||
|
public IQueryable<ProductTextMapping> GetByRawText(string rawText, bool isPreorder)
|
||||||
|
=> GetByRawText(rawText).Where(m => m.IsPreorder == isPreorder);
|
||||||
|
|
||||||
|
public IQueryable<ProductTextMapping> GetRecentByRawText(string rawText, bool isPreorder, int lastCount = 5)
|
||||||
|
=> GetByRawText(rawText, isPreorder).OrderByDescending(m => m.CreatedOnUtc).Take(lastCount);
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,9 @@ using Nop.Services.Events;
|
||||||
using Mango.Nop.Core.Extensions;
|
using Mango.Nop.Core.Extensions;
|
||||||
using Nop.Core.Domain.Orders;
|
using Nop.Core.Domain.Orders;
|
||||||
using Nop.Services.Catalog;
|
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;
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.EventConsumers;
|
||||||
|
|
||||||
|
|
@ -31,7 +34,9 @@ public class FruitBankEventConsumer :
|
||||||
|
|
||||||
IConsumer<EntityInsertedEvent<OrderItem>>,
|
IConsumer<EntityInsertedEvent<OrderItem>>,
|
||||||
IConsumer<EntityUpdatedEvent<OrderItem>>,
|
IConsumer<EntityUpdatedEvent<OrderItem>>,
|
||||||
IConsumer<EntityDeletedEvent<OrderItem>>
|
IConsumer<EntityDeletedEvent<OrderItem>>,
|
||||||
|
|
||||||
|
IConsumer<OrderPlacedEvent>
|
||||||
{
|
{
|
||||||
//private readonly CustomPriceCalculationService _customPriceCalculationService;
|
//private readonly CustomPriceCalculationService _customPriceCalculationService;
|
||||||
|
|
||||||
|
|
@ -40,16 +45,100 @@ public class FruitBankEventConsumer :
|
||||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||||
private readonly PreOrderConversionService _preorderConversionService;
|
private readonly PreOrderConversionService _preorderConversionService;
|
||||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||||
|
private readonly ICustomerService _customerService;
|
||||||
|
|
||||||
public FruitBankEventConsumer(IHttpContextAccessor httpContextAcc, FruitBankDbContext ctx, MeasurementService measurementService,
|
public FruitBankEventConsumer(IHttpContextAccessor httpContextAcc, FruitBankDbContext ctx, MeasurementService measurementService,
|
||||||
FruitBankAttributeService fruitBankAttributeService, PreOrderConversionService preorderConversionService,
|
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;
|
_ctx = ctx;
|
||||||
_measurementService = measurementService;
|
_measurementService = measurementService;
|
||||||
_fruitBankAttributeService = fruitBankAttributeService;
|
_fruitBankAttributeService = fruitBankAttributeService;
|
||||||
_preorderConversionService = preorderConversionService;
|
_preorderConversionService = preorderConversionService;
|
||||||
_serviceScopeFactory = serviceScopeFactory;
|
_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)
|
public override async Task HandleEventAsync(EntityUpdatedEvent<Product> eventMessage)
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,23 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
||||||
/// that delivery date, and its document processing will be the correct trigger.
|
/// that delivery date, and its document processing will be the correct trigger.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int PreOrderConversionWindowDays = 4;
|
public const int PreOrderConversionWindowDays = 4;
|
||||||
|
|
||||||
|
// ── Customer generic-attribute keys (store-agnostic, StoreId 0) ──────────
|
||||||
|
/// <summary>Bool flag: the customer is subject to EKÁER reporting.</summary>
|
||||||
|
public const string IsEkaerAttribute = "isEkaer";
|
||||||
|
|
||||||
|
/// <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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,46 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
||||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus", "Fizet\u00e9si \u00e1llapot", hu);
|
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", "Total", en);
|
||||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "\u00d6sszesen", hu);
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "\u00d6sszesen", hu);
|
||||||
|
// ── Customer attributes widget (generic attributes block) ───────────
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title", "Customer attributes", en);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title", "Ügyfél jellemzők", hu);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer", "EKÁER required", en);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer", "Ekáer köteles", hu);
|
||||||
|
|
||||||
|
// ── Customer sites (telephelyek) ────────────────────────────────────
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Title", "Sites", en);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Title", "Telephelyek", hu);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Edit", "Edit sites", en);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Edit", "Telephelyek szerkesztése", hu);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord", "site(s)", en);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord", "telephely", hu);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel", "Default", en);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel", "Alapértelmezett", hu);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel", "Site", en);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel", "Telephely", hu);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel", "Address", en);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel", "Cím", hu);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.None", "No sites set", en);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.None", "Nincs telephely megadva", hu);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle", "Select sites", en);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle", "Telephelyek kiválasztása", hu);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses", "This customer has no addresses yet (Addresses tab).", en);
|
||||||
|
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses", "Ehhez az ügyfélhez még nincs cím rögzítve a Címek fülön.", hu);
|
||||||
|
|
||||||
|
// ── 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", "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);
|
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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
||||||
/// Z.ai GLM-OCR modell neve (default: "glm-ocr").
|
/// Z.ai GLM-OCR modell neve (default: "glm-ocr").
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ZaiModel { get; set; } = "glm-ocr";
|
public string ZaiModel { get; set; } = "glm-ocr";
|
||||||
|
|
||||||
|
// ── OrderDraft API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API kulcs a külső rendelés tervezet endpointhoz (X-FruitBank-ApiKey header).
|
||||||
|
/// Viber/email/diktálás integrációkhoz.
|
||||||
|
/// </summary>
|
||||||
|
public string OrderDraftApiKey { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,14 @@ public class PluginNopStartup : INopStartup
|
||||||
services.AddScoped<PreOrderItemDbTable>();
|
services.AddScoped<PreOrderItemDbTable>();
|
||||||
services.AddScoped<PreOrderDbContext>();
|
services.AddScoped<PreOrderDbContext>();
|
||||||
|
|
||||||
|
services.AddScoped<OrderDraftDbTable>();
|
||||||
|
services.AddScoped<OrderDraftItemDbTable>();
|
||||||
|
services.AddScoped<ExternalUserPartnerMapDbTable>();
|
||||||
|
services.AddScoped<ProductTextMappingDbTable>();
|
||||||
|
|
||||||
|
services.AddScoped<OrderDraftParserService>();
|
||||||
|
services.AddScoped<IOrderDraftService, OrderDraftService>();
|
||||||
|
|
||||||
services.AddScoped<StockTakingDbContext>();
|
services.AddScoped<StockTakingDbContext>();
|
||||||
services.AddScoped<FruitBankDbContext>();
|
services.AddScoped<FruitBankDbContext>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,48 @@ public class RouteProvider : IRouteProvider
|
||||||
name: "Plugin.FruitBank.QuickOrder.GetDeliveryDateTime",
|
name: "Plugin.FruitBank.QuickOrder.GetDeliveryDateTime",
|
||||||
pattern: "gyors-rendeles/szallitas-idopont-lekerdezes",
|
pattern: "gyors-rendeles/szallitas-idopont-lekerdezes",
|
||||||
defaults: new { controller = "QuickOrder", action = "GetDeliveryDateTime" });
|
defaults: new { controller = "QuickOrder", action = "GetDeliveryDateTime" });
|
||||||
|
|
||||||
|
// ── API: OrderDraft (külső rendszerek: Viber, email, diktálás) ─────────────
|
||||||
|
endpointRouteBuilder.MapControllerRoute(
|
||||||
|
name: "Plugin.FruitBank.Api.OrderDraft.Create",
|
||||||
|
pattern: "api/order-draft",
|
||||||
|
defaults: new { controller = "OrderDraftApi", action = "Create" });
|
||||||
|
|
||||||
|
// ── Admin: OrderDraft ─────────────────────────────────────────────────
|
||||||
|
endpointRouteBuilder.MapControllerRoute(
|
||||||
|
name: "Plugin.FruitBank.Admin.OrderDraft.List",
|
||||||
|
pattern: "Admin/OrderDraft",
|
||||||
|
defaults: new { controller = "OrderDraftAdmin", action = "List", area = AreaNames.ADMIN });
|
||||||
|
|
||||||
|
endpointRouteBuilder.MapControllerRoute(
|
||||||
|
name: "Plugin.FruitBank.Admin.OrderDraft.OrderDraftList",
|
||||||
|
pattern: "Admin/OrderDraft/OrderDraftList",
|
||||||
|
defaults: new { controller = "OrderDraftAdmin", action = "OrderDraftList", area = AreaNames.ADMIN });
|
||||||
|
|
||||||
|
endpointRouteBuilder.MapControllerRoute(
|
||||||
|
name: "Plugin.FruitBank.Admin.OrderDraft.Detail",
|
||||||
|
pattern: "Admin/OrderDraft/Detail/{id:int}",
|
||||||
|
defaults: new { controller = "OrderDraftAdmin", action = "Detail", area = AreaNames.ADMIN });
|
||||||
|
|
||||||
|
endpointRouteBuilder.MapControllerRoute(
|
||||||
|
name: "Plugin.FruitBank.Admin.OrderDraft.UpdateItem",
|
||||||
|
pattern: "Admin/OrderDraft/UpdateItem",
|
||||||
|
defaults: new { controller = "OrderDraftAdmin", action = "UpdateItem", area = AreaNames.ADMIN });
|
||||||
|
|
||||||
|
endpointRouteBuilder.MapControllerRoute(
|
||||||
|
name: "Plugin.FruitBank.Admin.OrderDraft.AssignCustomer",
|
||||||
|
pattern: "Admin/OrderDraft/AssignCustomer",
|
||||||
|
defaults: new { controller = "OrderDraftAdmin", action = "AssignCustomer", area = AreaNames.ADMIN });
|
||||||
|
|
||||||
|
endpointRouteBuilder.MapControllerRoute(
|
||||||
|
name: "Plugin.FruitBank.Admin.OrderDraft.Approve",
|
||||||
|
pattern: "Admin/OrderDraft/Approve",
|
||||||
|
defaults: new { controller = "OrderDraftAdmin", action = "Approve", area = AreaNames.ADMIN });
|
||||||
|
|
||||||
|
endpointRouteBuilder.MapControllerRoute(
|
||||||
|
name: "Plugin.FruitBank.Admin.OrderDraft.Reject",
|
||||||
|
pattern: "Admin/OrderDraft/Reject",
|
||||||
|
defaults: new { controller = "OrderDraftAdmin", action = "Reject", area = AreaNames.ADMIN });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -81,4 +81,61 @@
|
||||||
<Value><![CDATA[Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.]]></Value>
|
<Value><![CDATA[Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.]]></Value>
|
||||||
</LocaleResource>
|
</LocaleResource>
|
||||||
|
|
||||||
|
<!-- Customer attributes (generic attribute block) — Plugins.Misc.FruitBankPlugin.CustomerAttributes.* -->
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title">
|
||||||
|
<Value><![CDATA[Customer attributes]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer">
|
||||||
|
<Value><![CDATA[EKÁER required]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
|
||||||
|
<!-- Sites — Plugins.Misc.FruitBankPlugin.CustomerSites.* -->
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.Title">
|
||||||
|
<Value><![CDATA[Sites]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.Edit">
|
||||||
|
<Value><![CDATA[Edit sites]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord">
|
||||||
|
<Value><![CDATA[site(s)]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel">
|
||||||
|
<Value><![CDATA[Default]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel">
|
||||||
|
<Value><![CDATA[Site]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel">
|
||||||
|
<Value><![CDATA[Address]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.None">
|
||||||
|
<Value><![CDATA[No sites set]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle">
|
||||||
|
<Value><![CDATA[Select sites]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses">
|
||||||
|
<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>
|
</Language>
|
||||||
|
|
|
||||||
|
|
@ -81,4 +81,61 @@
|
||||||
<Value><![CDATA[A rendelést nem lehet leadni, mert a kintlévő egyenlege elérte a hitelkeretét. Kérjük, először rendezze meglévő tartozását.]]></Value>
|
<Value><![CDATA[A rendelést nem lehet leadni, mert a kintlévő egyenlege elérte a hitelkeretét. Kérjük, először rendezze meglévő tartozását.]]></Value>
|
||||||
</LocaleResource>
|
</LocaleResource>
|
||||||
|
|
||||||
|
<!-- Ügyfél jellemzők (generic attribute blokk) — Plugins.Misc.FruitBankPlugin.CustomerAttributes.* -->
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title">
|
||||||
|
<Value><![CDATA[Ügyfél jellemzők]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer">
|
||||||
|
<Value><![CDATA[Ekáer köteles]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
|
||||||
|
<!-- Telephelyek — Plugins.Misc.FruitBankPlugin.CustomerSites.* -->
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.Title">
|
||||||
|
<Value><![CDATA[Telephelyek]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.Edit">
|
||||||
|
<Value><![CDATA[Telephelyek szerkesztése]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord">
|
||||||
|
<Value><![CDATA[telephely]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel">
|
||||||
|
<Value><![CDATA[Alapértelmezett]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel">
|
||||||
|
<Value><![CDATA[Telephely]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel">
|
||||||
|
<Value><![CDATA[Cím]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.None">
|
||||||
|
<Value><![CDATA[Nincs telephely megadva]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle">
|
||||||
|
<Value><![CDATA[Telephelyek kiválasztása]]></Value>
|
||||||
|
</LocaleResource>
|
||||||
|
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses">
|
||||||
|
<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>
|
</Language>
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,41 @@ public record CustomerCreditWidgetModel : BaseNopModel
|
||||||
public decimal? RemainingCredit { get; set; }
|
public decimal? RemainingCredit { get; set; }
|
||||||
|
|
||||||
public string? Comment { get; set; }
|
public string? Comment { get; set; }
|
||||||
|
|
||||||
|
// ── Generic attributes block (rendered above the credit card) ──────────────
|
||||||
|
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer")]
|
||||||
|
public bool IsEkaer { get; set; }
|
||||||
|
|
||||||
|
// ── Sites (telephelyek) ────────────────────────────────────────────────────
|
||||||
|
/// <summary>All current customer addresses, flagged with site/default state — feeds the edit modal.</summary>
|
||||||
|
public List<CustomerSiteAddressRow> SiteAddresses { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Number of addresses currently marked as sites.</summary>
|
||||||
|
public int SiteCount { get; set; }
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
public int AddressId { get; set; }
|
||||||
|
public string Display { get; set; } = string.Empty;
|
||||||
|
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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One entry in a customer's site (telephely) list, stored as JSON in the
|
||||||
|
/// <c>customerSites</c> generic attribute. References an existing customer
|
||||||
|
/// Address by id; extensible with per-site fields (e.g. Note) later.
|
||||||
|
/// </summary>
|
||||||
|
public class CustomerSiteEntry
|
||||||
|
{
|
||||||
|
[JsonPropertyName("addressId")]
|
||||||
|
public int AddressId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("isDefault")]
|
||||||
|
public bool IsDefault { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("note")]
|
||||||
|
public string? Note { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,28 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
|
||||||
|
|
||||||
[NopResourceDisplayName("Plugins.YourCompany.ProductAttributes.Fields.DateOfReceipt")]
|
[NopResourceDisplayName("Plugins.YourCompany.ProductAttributes.Fields.DateOfReceipt")]
|
||||||
public DateTime? DateOfReceipt { get; set; }
|
public DateTime? DateOfReceipt { get; set; }
|
||||||
|
|
||||||
public OrderDto OrderDto { get; set; }
|
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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,12 +151,15 @@
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Microsoft.AspNetCore.Http.Connections.Client">
|
<Reference Include="Microsoft.AspNetCore.Http.Connections.Client">
|
||||||
<HintPath>..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.Http.Connections.Client.dll</HintPath>
|
<HintPath>..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.Http.Connections.Client.dll</HintPath>
|
||||||
|
<Private>True</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Microsoft.AspNetCore.SignalR.Client">
|
<Reference Include="Microsoft.AspNetCore.SignalR.Client">
|
||||||
<HintPath>..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.SignalR.Client.dll</HintPath>
|
<HintPath>..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.SignalR.Client.dll</HintPath>
|
||||||
|
<Private>True</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Microsoft.AspNetCore.SignalR.Client.Core">
|
<Reference Include="Microsoft.AspNetCore.SignalR.Client.Core">
|
||||||
<HintPath>..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.SignalR.Client.Core.dll</HintPath>
|
<HintPath>..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.SignalR.Client.Core.dll</HintPath>
|
||||||
|
<Private>True</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson">
|
<Reference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson">
|
||||||
<HintPath>..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson.dll</HintPath>
|
<HintPath>..\..\..\..\FruitBankHybridApp\FruitBank.Common.Server\bin\$(Configuration)\net9.0\Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson.dll</HintPath>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
using FruitBank.Common.Entities;
|
||||||
|
using FruitBank.Common.Enums;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||||
|
|
||||||
|
public interface IOrderDraftService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Egy bejövő szöveges üzenet alapján létrehozza a rendelés tervezet(eke)t.
|
||||||
|
/// Ha az üzenetben több különböző szállítási dátum szerepel, minden dátumhoz külön draft jön létre.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<OrderDraft>> CreateDraftsFromMessageAsync(string externalUsername, string rawMessageText);
|
||||||
|
|
||||||
|
/// <summary>Egy draft betöltése tételeivel együtt.</summary>
|
||||||
|
Task<OrderDraft?> GetByIdAsync(int id, bool loadRelations = true);
|
||||||
|
|
||||||
|
/// <summary>Pending draftek listája, opcionálisan szűrve partner szerint.</summary>
|
||||||
|
Task<List<OrderDraft>> GetPendingAsync(int? customerId = null);
|
||||||
|
|
||||||
|
/// <summary>Összes draft lapozhatóan (admin lista).</summary>
|
||||||
|
Task<(List<OrderDraft> Items, int TotalCount)> GetPagedAsync(
|
||||||
|
int pageIndex, int pageSize,
|
||||||
|
OrderDraftStatus? status = null,
|
||||||
|
int? customerId = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin jóváhagyja a draftot: minden tétel SelectedProductId-ja be kell legyen állítva.
|
||||||
|
/// A dátumtól függően PreOrder vagy Order jön létre.
|
||||||
|
/// Menti a ProductTextMapping rekordokat a tanuláshoz.
|
||||||
|
/// </summary>
|
||||||
|
Task<OrderDraftApproveResult> ApproveAsync(int draftId, int adminId);
|
||||||
|
|
||||||
|
/// <summary>Admin elutasítja a draftot.</summary>
|
||||||
|
Task RejectAsync(int draftId, int adminId, string? adminNote = null);
|
||||||
|
|
||||||
|
/// <summary>Admin hozzárendeli a partnert egy mappingból még hiányzó drafthoz.</summary>
|
||||||
|
Task AssignCustomerAsync(int draftId, int customerId, int adminId);
|
||||||
|
|
||||||
|
/// <summary>Admin frissíti egy tétel kiválasztott termékét és/vagy mennyiségét.</summary>
|
||||||
|
Task UpdateDraftItemAsync(int draftItemId, int selectedProductId, int quantity);
|
||||||
|
|
||||||
|
/// <summary>14 napnál régebbi pending draftek automatikus lejáratása (background job hívja).</summary>
|
||||||
|
Task ExpireOldDraftsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderDraftApproveResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A létrehozott PreOrder ID-k (ha előrendelés lett).</summary>
|
||||||
|
public List<int> PreOrderIds { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>A létrehozott Order ID-k (ha azonnali rendelés lett).</summary>
|
||||||
|
public List<int> OrderIds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Szöveges üzenetet parse-ol: kinyeri a szállítási dátumokat és a termékigényeket.
|
||||||
|
/// Ha egy üzenetben több dátum szerepel, minden dátumhoz külön ParsedDraftGroup-ot ad vissza.
|
||||||
|
/// </summary>
|
||||||
|
public class OrderDraftParserService
|
||||||
|
{
|
||||||
|
private readonly OpenAIApiService _openAiService;
|
||||||
|
|
||||||
|
// Hány napnál távolabbi dátum számít előrendelésnek
|
||||||
|
public const int PreorderThresholdDays = 4;
|
||||||
|
|
||||||
|
public OrderDraftParserService(OpenAIApiService openAiService)
|
||||||
|
{
|
||||||
|
_openAiService = openAiService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Egy raw üzenetből kinyeri a (dátum → termékigények) csoportokat.
|
||||||
|
/// Minden visszaadott csoport egy külön OrderDraft-nak felel meg.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<ParsedDraftGroup>> ParseMessageAsync(string rawMessage)
|
||||||
|
{
|
||||||
|
var systemPrompt = """
|
||||||
|
Te egy magyar gyümölcs- és zöldségnagyker rendelési asszisztense vagy.
|
||||||
|
Egy beérkező szöveges üzenetből ki kell nyerned a rendelési szándékokat.
|
||||||
|
|
||||||
|
FELADATOD:
|
||||||
|
1. Azonosítsd az összes szállítási dátumot az üzenetben.
|
||||||
|
2. Csoportosítsd a termékigényeket dátum szerint.
|
||||||
|
3. Ha egy tételhez nincs explicit dátum megadva, rendeld a legközelebbi említett dátumhoz.
|
||||||
|
4. Ha egyáltalán nincs dátum az üzenetben, használd a mai napot + 1 nap értéket.
|
||||||
|
5. A mennyiségnél mindig rekeszben gondolkodj (ha nem egyértelmű, rekesz az alapértelmezett).
|
||||||
|
6. A termék nevét pontosan úgy add vissza, ahogy az üzenetben szerepel (ne fordítsd, ne értelmezd).
|
||||||
|
|
||||||
|
VÁLASZ FORMÁTUM (csak JSON, semmi más):
|
||||||
|
{
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"deliveryDate": "YYYY-MM-DD",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"rawProductText": "az eredeti szöveg ahogy szerepelt",
|
||||||
|
"requestedQuantity": 10,
|
||||||
|
"sortOrder": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
FONTOS:
|
||||||
|
- Csak JSON-t adj vissza, semmilyen magyarázatot, bevezető szöveget vagy markdown blokkot ne írj.
|
||||||
|
- Ha a dátum relatív (pl. "jövő héten", "jövő szerdán", "holnap"), számítsd ki a tényleges dátumot a mai naphoz képest.
|
||||||
|
- Mai dátum:
|
||||||
|
""" + " " + DateTime.Today.ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
|
var userPrompt = $"Üzenet:\n{rawMessage}";
|
||||||
|
|
||||||
|
var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, userPrompt);
|
||||||
|
if (string.IsNullOrWhiteSpace(response))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Tisztítjuk a markdown code fences-t ha mégis beleírta
|
||||||
|
var clean = response
|
||||||
|
.Replace("```json", "")
|
||||||
|
.Replace("```", "")
|
||||||
|
.Trim();
|
||||||
|
|
||||||
|
var parsed = JsonSerializer.Deserialize<ParsedMessageResponse>(clean, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsed?.Groups ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[OrderDraftParser] JSON parse error: {ex.Message}\nRaw response: {response}");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Egy termék raw szövegéből meghatározza a kanonikus terméknevet és
|
||||||
|
/// a NopCommerce kereséshez használható kulcsszavakat.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ProductResolutionHint> ResolveProductNameAsync(string rawProductText)
|
||||||
|
{
|
||||||
|
var systemPrompt = """
|
||||||
|
Te egy magyar gyümölcs- és zöldségnagyker asszisztense vagy.
|
||||||
|
Egy vevő által írt termékmegnevezésből kell meghatároznod:
|
||||||
|
1. Mi a termék pontos, kanonikus neve magyarul (pl. "piros kalif" → "Kaliforniai paprika piros")
|
||||||
|
2. Milyen kulcsszavakkal érdemes keresni a termékadatbázisban (pl. ["paprika", "kaliforniai", "piros"])
|
||||||
|
|
||||||
|
VÁLASZ FORMÁTUM (csak JSON):
|
||||||
|
{
|
||||||
|
"canonicalName": "Kaliforniai paprika piros",
|
||||||
|
"searchKeywords": ["paprika", "kaliforniai", "piros"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Csak JSON-t adj vissza.
|
||||||
|
""";
|
||||||
|
|
||||||
|
var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, rawProductText);
|
||||||
|
if (string.IsNullOrWhiteSpace(response))
|
||||||
|
return new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var clean = response.Replace("```json", "").Replace("```", "").Trim();
|
||||||
|
var hint = JsonSerializer.Deserialize<ProductResolutionHint>(clean, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
});
|
||||||
|
return hint ?? new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] };
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Több termék ID-t sorrendbe rendez az AI segítségével a raw szöveg és a terméknevek alapján.
|
||||||
|
/// A historikus mapping által már előre rangsorolt lista végső finomhangolása.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<int>> RankCandidatesAsync(
|
||||||
|
string rawProductText,
|
||||||
|
string canonicalName,
|
||||||
|
List<(int Id, string Name)> candidates)
|
||||||
|
{
|
||||||
|
if (candidates.Count <= 1)
|
||||||
|
return candidates.Select(c => c.Id).ToList();
|
||||||
|
|
||||||
|
var candidateList = candidates
|
||||||
|
.Select((c, i) => $"{i + 1}. ID={c.Id} Név=\"{c.Name}\"")
|
||||||
|
.Aggregate(new StringBuilder(), (sb, s) => sb.AppendLine(s))
|
||||||
|
.ToString();
|
||||||
|
|
||||||
|
var systemPrompt = """
|
||||||
|
Te egy magyar gyümölcs- és zöldségnagyker asszisztense vagy.
|
||||||
|
Rendezd sorrendbe a termékkandidátusokat aszerint, hogy melyik felel meg legjobban a keresett terméknek.
|
||||||
|
Az első legyen a legjobb találat.
|
||||||
|
|
||||||
|
VÁLASZ FORMÁTUM (csak JSON, semmi más):
|
||||||
|
{ "rankedIds": [123, 456, 789] }
|
||||||
|
""";
|
||||||
|
|
||||||
|
var userPrompt = $"""
|
||||||
|
Keresett termék (eredeti szöveg): "{rawProductText}"
|
||||||
|
Kanonikus név: "{canonicalName}"
|
||||||
|
|
||||||
|
Kandid\u00e1tusok:
|
||||||
|
{candidateList}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, userPrompt);
|
||||||
|
if (string.IsNullOrWhiteSpace(response))
|
||||||
|
return candidates.Select(c => c.Id).ToList();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var clean = response.Replace("```json", "").Replace("```", "").Trim();
|
||||||
|
using var doc = JsonDocument.Parse(clean);
|
||||||
|
var ranked = doc.RootElement
|
||||||
|
.GetProperty("rankedIds")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(e => e.GetInt32())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Csak valódi kandidátus ID-kat tartunk meg, a maradékot hozzáfűzzük
|
||||||
|
var validIds = ranked.Where(id => candidates.Any(c => c.Id == id)).ToList();
|
||||||
|
var missing = candidates.Select(c => c.Id).Except(validIds).ToList();
|
||||||
|
return [.. validIds, .. missing];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return candidates.Select(c => c.Id).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DTO-k ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class ParsedMessageResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("groups")]
|
||||||
|
public List<ParsedDraftGroup> Groups { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ParsedDraftGroup
|
||||||
|
{
|
||||||
|
[JsonPropertyName("deliveryDate")]
|
||||||
|
public string DeliveryDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public List<ParsedDraftItem> Items { get; set; } = [];
|
||||||
|
|
||||||
|
public DateTime GetDeliveryDateTime()
|
||||||
|
=> DateTime.TryParse(DeliveryDate, out var dt) ? dt : DateTime.Today.AddDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ParsedDraftItem
|
||||||
|
{
|
||||||
|
[JsonPropertyName("rawProductText")]
|
||||||
|
public string RawProductText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("requestedQuantity")]
|
||||||
|
public int RequestedQuantity { get; set; } = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("sortOrder")]
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProductResolutionHint
|
||||||
|
{
|
||||||
|
[JsonPropertyName("canonicalName")]
|
||||||
|
public string CanonicalName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("searchKeywords")]
|
||||||
|
public List<string> SearchKeywords { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,392 @@
|
||||||
|
using FruitBank.Common.Entities;
|
||||||
|
using FruitBank.Common.Enums;
|
||||||
|
using LinqToDB;
|
||||||
|
using Nop.Core.Domain.Catalog;
|
||||||
|
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||||
|
using Nop.Services.Catalog;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||||
|
|
||||||
|
public class OrderDraftService : IOrderDraftService
|
||||||
|
{
|
||||||
|
private readonly FruitBankDbContext _dbContext;
|
||||||
|
private readonly PreOrderDbContext _preOrderDbContext;
|
||||||
|
private readonly OrderDraftParserService _parserService;
|
||||||
|
private readonly IProductService _productService;
|
||||||
|
private readonly PreOrderConversionService _preOrderConversionService;
|
||||||
|
|
||||||
|
public OrderDraftService(
|
||||||
|
FruitBankDbContext dbContext,
|
||||||
|
PreOrderDbContext preOrderDbContext,
|
||||||
|
OrderDraftParserService parserService,
|
||||||
|
IProductService productService,
|
||||||
|
PreOrderConversionService preOrderConversionService)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_preOrderDbContext = preOrderDbContext;
|
||||||
|
_parserService = parserService;
|
||||||
|
_productService = productService;
|
||||||
|
_preOrderConversionService = preOrderConversionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Létrehozás ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<List<OrderDraft>> CreateDraftsFromMessageAsync(string externalUsername, string rawMessageText)
|
||||||
|
{
|
||||||
|
// 1. Partner azonosítás
|
||||||
|
var mapping = await _dbContext.ExternalUserPartnerMaps.GetByUsernameAsync(externalUsername);
|
||||||
|
|
||||||
|
// 2. AI parse: dátumok + termékek kinyerése
|
||||||
|
var parsedGroups = await _parserService.ParseMessageAsync(rawMessageText);
|
||||||
|
if (!parsedGroups.Any())
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var createdDrafts = new List<OrderDraft>();
|
||||||
|
|
||||||
|
foreach (var group in parsedGroups)
|
||||||
|
{
|
||||||
|
var deliveryDate = group.GetDeliveryDateTime();
|
||||||
|
var isPreorder = deliveryDate.Date > now.Date.AddDays(OrderDraftParserService.PreorderThresholdDays);
|
||||||
|
|
||||||
|
var draft = new OrderDraft
|
||||||
|
{
|
||||||
|
ExternalUsername = externalUsername,
|
||||||
|
CustomerId = mapping?.CustomerId,
|
||||||
|
RawMessageText = rawMessageText,
|
||||||
|
ParsedDeliveryDate = deliveryDate,
|
||||||
|
Status = OrderDraftStatus.Pending,
|
||||||
|
CreatedOnUtc = now
|
||||||
|
};
|
||||||
|
|
||||||
|
await _dbContext.OrderDrafts.InsertAsync(draft);
|
||||||
|
|
||||||
|
// 3. Tételek feldolgozása
|
||||||
|
var sortOrder = 0;
|
||||||
|
var itemTasks = group.Items.Select(parsedItem => ProcessDraftItemAsync(
|
||||||
|
draft.Id, parsedItem, isPreorder, sortOrder++)).ToList();
|
||||||
|
|
||||||
|
var items = await Task.WhenAll(itemTasks);
|
||||||
|
draft.Items = items.ToList();
|
||||||
|
|
||||||
|
createdDrafts.Add(draft);
|
||||||
|
|
||||||
|
Console.WriteLine($"[OrderDraftService] Created draft #{draft.Id} for '{externalUsername}', " +
|
||||||
|
$"deliveryDate={deliveryDate:yyyy-MM-dd}, isPreorder={isPreorder}, " +
|
||||||
|
$"items={items.Length}, customerId={mapping?.CustomerId.ToString() ?? "null"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdDrafts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrderDraftItem> ProcessDraftItemAsync(
|
||||||
|
int draftId, ParsedDraftItem parsed, bool isPreorder, int sortOrder)
|
||||||
|
{
|
||||||
|
// 1. Historikus súlyozás: utolsó 5 admin döntés ugyanerre a szövegre
|
||||||
|
var historicMappings = await _dbContext.ProductTextMappings
|
||||||
|
.GetRecentByRawText(parsed.RawProductText, isPreorder, 5)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var historicProductIds = historicMappings.Select(m => m.ResolvedProductId).Distinct().ToList();
|
||||||
|
|
||||||
|
// 2. AI: kanonikus név + kulcsszavak
|
||||||
|
var hint = await _parserService.ResolveProductNameAsync(parsed.RawProductText);
|
||||||
|
|
||||||
|
// 3. Termékkeresés az összes kulcsszóra — párhuzamosan
|
||||||
|
var searchTasks = hint.SearchKeywords
|
||||||
|
.Select(kw => _productService.SearchProductsAsync(keywords: kw, visibleIndividuallyOnly: true, pageSize: 10))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var searchResults = await Task.WhenAll(searchTasks);
|
||||||
|
var allFoundProducts = searchResults
|
||||||
|
.SelectMany(r => r)
|
||||||
|
.DistinctBy(p => p.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 4. Sorrendezés: historikus találatok elől, majd AI ranking
|
||||||
|
List<int> rankedIds;
|
||||||
|
if (allFoundProducts.Count == 0)
|
||||||
|
{
|
||||||
|
rankedIds = historicProductIds;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Historikus ID-k kerülnek előre ha az aktuális keresésben is szerepelnek
|
||||||
|
var foundIds = allFoundProducts.Select(p => p.Id).ToList();
|
||||||
|
var historicAndFound = historicProductIds.Where(id => foundIds.Contains(id)).ToList();
|
||||||
|
var restFound = foundIds.Except(historicAndFound).ToList();
|
||||||
|
|
||||||
|
// Ha több mint 1 nem-historikus találat van, AI rangsorolja
|
||||||
|
if (restFound.Count > 1)
|
||||||
|
{
|
||||||
|
var candidates = allFoundProducts
|
||||||
|
.Where(p => restFound.Contains(p.Id))
|
||||||
|
.Select(p => (p.Id, p.Name))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var aiRanked = await _parserService.RankCandidatesAsync(
|
||||||
|
parsed.RawProductText, hint.CanonicalName, candidates);
|
||||||
|
|
||||||
|
rankedIds = [.. historicAndFound, .. aiRanked];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rankedIds = [.. historicAndFound, .. restFound];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = new OrderDraftItem
|
||||||
|
{
|
||||||
|
DraftId = draftId,
|
||||||
|
RawProductText = parsed.RawProductText,
|
||||||
|
AiResolvedProductName = hint.CanonicalName,
|
||||||
|
SuggestedProductIdsJson = System.Text.Json.JsonSerializer.Serialize(rankedIds),
|
||||||
|
SelectedProductId = rankedIds.Count > 0 ? rankedIds[0] : null, // első javaslat előre kiválasztva
|
||||||
|
RequestedQuantity = parsed.RequestedQuantity > 0 ? parsed.RequestedQuantity : 1,
|
||||||
|
IsPreorder = isPreorder,
|
||||||
|
SortOrder = sortOrder
|
||||||
|
};
|
||||||
|
|
||||||
|
await _dbContext.OrderDraftItems.InsertAsync(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lekérdezések ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<OrderDraft?> GetByIdAsync(int id, bool loadRelations = true)
|
||||||
|
=> _dbContext.OrderDrafts.GetByIdAsync(id, loadRelations);
|
||||||
|
|
||||||
|
public async Task<List<OrderDraft>> GetPendingAsync(int? customerId = null)
|
||||||
|
{
|
||||||
|
var query = _dbContext.OrderDrafts.GetPending();
|
||||||
|
if (customerId.HasValue)
|
||||||
|
query = query.Where(d => d.CustomerId == customerId.Value);
|
||||||
|
return await query.OrderByDescending(d => d.CreatedOnUtc).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(List<OrderDraft> Items, int TotalCount)> GetPagedAsync(
|
||||||
|
int pageIndex, int pageSize,
|
||||||
|
OrderDraftStatus? status = null,
|
||||||
|
int? customerId = null)
|
||||||
|
{
|
||||||
|
var query = _dbContext.OrderDrafts.GetAll(false);
|
||||||
|
|
||||||
|
if (status.HasValue)
|
||||||
|
query = query.Where(d => d.Status == status.Value);
|
||||||
|
|
||||||
|
if (customerId.HasValue)
|
||||||
|
query = query.Where(d => d.CustomerId == customerId.Value);
|
||||||
|
|
||||||
|
query = query.OrderByDescending(d => d.CreatedOnUtc);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(CancellationToken.None);
|
||||||
|
var items = await query
|
||||||
|
.Skip(pageIndex * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return (items, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin műveletek ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<OrderDraftApproveResult> ApproveAsync(int draftId, int adminId)
|
||||||
|
{
|
||||||
|
var result = new OrderDraftApproveResult();
|
||||||
|
|
||||||
|
var draft = await GetByIdAsync(draftId, true);
|
||||||
|
if (draft == null)
|
||||||
|
{
|
||||||
|
result.ErrorMessage = $"Draft #{draftId} nem található.";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.Status != OrderDraftStatus.Pending)
|
||||||
|
{
|
||||||
|
result.ErrorMessage = $"Draft #{draftId} már le van zárva (státusz: {draft.Status}).";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draft.CustomerId.HasValue)
|
||||||
|
{
|
||||||
|
result.ErrorMessage = "Jóváhagyás előtt rendelj hozzá partnert a drafthoz.";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var unresolved = draft.Items.Where(i => i.SelectedProductId == null).ToList();
|
||||||
|
if (unresolved.Any())
|
||||||
|
{
|
||||||
|
result.ErrorMessage = $"{unresolved.Count} tétel nincs kitöltve. Minden sorban ki kell választani a terméket.";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Tételek szétválasztása: preorder vs. azonnali
|
||||||
|
var preorderItems = draft.Items.Where(i => i.IsPreorder).ToList();
|
||||||
|
var orderItems = draft.Items.Where(i => !i.IsPreorder).ToList();
|
||||||
|
|
||||||
|
// Tanulási rekordok mentése (párhuzamosan)
|
||||||
|
var mappingTasks = draft.Items.Select(item => SaveProductTextMappingAsync(item, adminId));
|
||||||
|
await Task.WhenAll(mappingTasks);
|
||||||
|
|
||||||
|
// Előrendelések létrehozása
|
||||||
|
if (preorderItems.Any())
|
||||||
|
{
|
||||||
|
var preorderNopItems = preorderItems.Select(i => new PreOrderItem
|
||||||
|
{
|
||||||
|
ProductId = i.SelectedProductId!.Value,
|
||||||
|
RequestedQuantity = i.RequestedQuantity,
|
||||||
|
FulfilledQuantity = 0,
|
||||||
|
UnitPriceInclTax = 0m, // Árat a konverziókor számítja a service
|
||||||
|
Status = PreOrderItemStatus.Pending
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var preorder = new PreOrder
|
||||||
|
{
|
||||||
|
CustomerId = draft.CustomerId!.Value,
|
||||||
|
StoreId = 1,
|
||||||
|
DateOfReceipt = draft.ParsedDeliveryDate,
|
||||||
|
CustomerNote = $"OrderDraft #{draft.Id} alapján létrehozva"
|
||||||
|
};
|
||||||
|
|
||||||
|
var saved = await _preOrderDbContext.InsertPreOrderAsync(preorder, preorderNopItems);
|
||||||
|
result.PreOrderIds.Add(saved.Id);
|
||||||
|
|
||||||
|
// Azonnali konverzió ha van raktáron
|
||||||
|
await _preOrderConversionService.ConvertPreOrdersForProductsAsync(
|
||||||
|
preorderNopItems.Select(i => i.ProductId).ToList(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Azonnali rendelések: a PreOrderConversionService kezeli ha létrehozunk egy
|
||||||
|
// preordert aminek dátuma <= 4 nap — itt is ugyanazt a flow-t használjuk
|
||||||
|
if (orderItems.Any())
|
||||||
|
{
|
||||||
|
var orderNopItems = orderItems.Select(i => new PreOrderItem
|
||||||
|
{
|
||||||
|
ProductId = i.SelectedProductId!.Value,
|
||||||
|
RequestedQuantity = i.RequestedQuantity,
|
||||||
|
FulfilledQuantity = 0,
|
||||||
|
UnitPriceInclTax = 0m,
|
||||||
|
Status = PreOrderItemStatus.Pending
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var immediatePreorder = new PreOrder
|
||||||
|
{
|
||||||
|
CustomerId = draft.CustomerId!.Value,
|
||||||
|
StoreId = 1,
|
||||||
|
DateOfReceipt = draft.ParsedDeliveryDate,
|
||||||
|
CustomerNote = $"OrderDraft #{draft.Id} alapján létrehozva (azonnali)"
|
||||||
|
};
|
||||||
|
|
||||||
|
var saved = await _preOrderDbContext.InsertPreOrderAsync(immediatePreorder, orderNopItems);
|
||||||
|
|
||||||
|
// Az azonnali rendeléseknél inline futtatjuk a konverziót
|
||||||
|
await _preOrderConversionService.ConvertPreOrdersForProductsAsync(
|
||||||
|
orderNopItems.Select(i => i.ProductId).ToList(), 0);
|
||||||
|
|
||||||
|
// Ha lett belőle rendelés, azt is visszaadjuk
|
||||||
|
var converted = await _preOrderDbContext.PreOrders.GetByIdAsync(saved.Id);
|
||||||
|
if (converted?.OrderId.HasValue == true)
|
||||||
|
result.OrderIds.Add(converted.OrderId.Value);
|
||||||
|
else
|
||||||
|
result.PreOrderIds.Add(saved.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft lezárása
|
||||||
|
draft.Status = OrderDraftStatus.Approved;
|
||||||
|
draft.ResolvedByAdminId = adminId;
|
||||||
|
draft.ResolvedOnUtc = DateTime.UtcNow;
|
||||||
|
await _dbContext.OrderDrafts.UpdateAsync(draft);
|
||||||
|
|
||||||
|
result.Success = true;
|
||||||
|
|
||||||
|
Console.WriteLine($"[OrderDraftService] Draft #{draftId} approved by admin #{adminId}. " +
|
||||||
|
$"PreOrders: [{string.Join(",", result.PreOrderIds)}], " +
|
||||||
|
$"Orders: [{string.Join(",", result.OrderIds)}]");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.ErrorMessage = $"Hiba a jóváhagyás során: {ex.Message}";
|
||||||
|
Console.Error.WriteLine($"[OrderDraftService] ApproveAsync error for draft #{draftId}: {ex}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RejectAsync(int draftId, int adminId, string? adminNote = null)
|
||||||
|
{
|
||||||
|
var draft = await GetByIdAsync(draftId, false);
|
||||||
|
if (draft == null || draft.Status != OrderDraftStatus.Pending) return;
|
||||||
|
|
||||||
|
draft.Status = OrderDraftStatus.Rejected;
|
||||||
|
draft.ResolvedByAdminId = adminId;
|
||||||
|
draft.ResolvedOnUtc = DateTime.UtcNow;
|
||||||
|
draft.AdminNote = adminNote;
|
||||||
|
await _dbContext.OrderDrafts.UpdateAsync(draft);
|
||||||
|
|
||||||
|
Console.WriteLine($"[OrderDraftService] Draft #{draftId} rejected by admin #{adminId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AssignCustomerAsync(int draftId, int customerId, int adminId)
|
||||||
|
{
|
||||||
|
var draft = await GetByIdAsync(draftId, false);
|
||||||
|
if (draft == null) return;
|
||||||
|
|
||||||
|
// Mapping mentése a jövőre
|
||||||
|
var existingMapping = await _dbContext.ExternalUserPartnerMaps.GetByUsernameAsync(draft.ExternalUsername);
|
||||||
|
if (existingMapping == null || existingMapping.CustomerId != customerId)
|
||||||
|
{
|
||||||
|
await _dbContext.ExternalUserPartnerMaps.InsertAsync(new ExternalUserPartnerMapping
|
||||||
|
{
|
||||||
|
ExternalUsername = draft.ExternalUsername,
|
||||||
|
CustomerId = customerId,
|
||||||
|
CreatedByAdminId = adminId,
|
||||||
|
CreatedOnUtc = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.CustomerId = customerId;
|
||||||
|
await _dbContext.OrderDrafts.UpdateAsync(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateDraftItemAsync(int draftItemId, int selectedProductId, int quantity)
|
||||||
|
{
|
||||||
|
var item = await _dbContext.OrderDraftItems.GetByIdAsync(draftItemId);
|
||||||
|
if (item == null) return;
|
||||||
|
|
||||||
|
item.SelectedProductId = selectedProductId;
|
||||||
|
if (quantity > 0) item.RequestedQuantity = quantity;
|
||||||
|
await _dbContext.OrderDraftItems.UpdateAsync(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExpireOldDraftsAsync()
|
||||||
|
{
|
||||||
|
var candidates = await _dbContext.OrderDrafts.GetExpiredCandidates().ToListAsync();
|
||||||
|
foreach (var draft in candidates)
|
||||||
|
{
|
||||||
|
draft.Status = OrderDraftStatus.Expired;
|
||||||
|
await _dbContext.OrderDrafts.UpdateAsync(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.Any())
|
||||||
|
Console.WriteLine($"[OrderDraftService] Expired {candidates.Count} old drafts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Privát segédek ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task SaveProductTextMappingAsync(OrderDraftItem item, int adminId)
|
||||||
|
{
|
||||||
|
if (item.SelectedProductId == null) return;
|
||||||
|
|
||||||
|
await _dbContext.ProductTextMappings.InsertAsync(new ProductTextMapping
|
||||||
|
{
|
||||||
|
RawText = item.RawProductText.ToLower().Trim(),
|
||||||
|
ResolvedProductId = item.SelectedProductId.Value,
|
||||||
|
IsPreorder = item.IsPreorder,
|
||||||
|
SelectedByAdminId = adminId,
|
||||||
|
CreatedOnUtc = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,8 @@ public partial class PreOrderConversionService
|
||||||
public async Task ReplaceShippingItemProductAsync(
|
public async Task ReplaceShippingItemProductAsync(
|
||||||
int shippingItemId,
|
int shippingItemId,
|
||||||
int newProductId,
|
int newProductId,
|
||||||
ShippingItem oldItem)
|
ShippingItem oldItem,
|
||||||
|
bool skipIncomingSync = false)
|
||||||
{
|
{
|
||||||
if (oldItem.ProductId == null || oldItem.ProductId.Value == newProductId) return;
|
if (oldItem.ProductId == null || oldItem.ProductId.Value == newProductId) return;
|
||||||
|
|
||||||
|
|
@ -52,9 +53,13 @@ public partial class PreOrderConversionService
|
||||||
// ── Stock / IncomingQuantity swap ─────────────────────────────────────
|
// ── Stock / IncomingQuantity swap ─────────────────────────────────────
|
||||||
if (!isMeasured)
|
if (!isMeasured)
|
||||||
{
|
{
|
||||||
// Item not yet on the truck — only IncomingQuantity moves
|
if (!skipIncomingSync)
|
||||||
await SyncIncomingQuantityAsync(oldProductId, qty, 0);
|
{
|
||||||
await SyncIncomingQuantityAsync(newProductId, 0, qty);
|
// Item not yet on the truck — only IncomingQuantity moves
|
||||||
|
// (skipped if already done before UpdateShippingItemSafeAsync to avoid race)
|
||||||
|
await SyncIncomingQuantityAsync(oldProductId, qty, 0);
|
||||||
|
await SyncIncomingQuantityAsync(newProductId, 0, qty);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,13 @@ public partial class PreOrderConversionService
|
||||||
private readonly FruitBankOrderItemService _orderItemService;
|
private readonly FruitBankOrderItemService _orderItemService;
|
||||||
private readonly IStoreContext _storeContext;
|
private readonly IStoreContext _storeContext;
|
||||||
|
|
||||||
|
// Per-preorder async locks — static so they're shared across all scoped instances
|
||||||
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<int, SemaphoreSlim>
|
||||||
|
_preorderLocks = new();
|
||||||
|
|
||||||
|
private static SemaphoreSlim GetPreorderLock(int preorderId) =>
|
||||||
|
_preorderLocks.GetOrAdd(preorderId, _ => new SemaphoreSlim(1, 1));
|
||||||
|
|
||||||
public PreOrderConversionService(
|
public PreOrderConversionService(
|
||||||
PreOrderDbContext preorderDbContext,
|
PreOrderDbContext preorderDbContext,
|
||||||
FruitBankDbContext dbContext,
|
FruitBankDbContext dbContext,
|
||||||
|
|
@ -183,19 +190,37 @@ public partial class PreOrderConversionService
|
||||||
.Where(i => i.Status == PreOrderItemStatus.Dropped)
|
.Where(i => i.Status == PreOrderItemStatus.Dropped)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (preorder.OrderId == null)
|
// Acquire a per-preorder lock BEFORE checking OrderId.
|
||||||
|
// Without this, 3 concurrent conversions (one per shipping item event)
|
||||||
|
// all read OrderId=null and each creates a separate order.
|
||||||
|
// Re-reading inside the lock ensures only one creates the order.
|
||||||
|
var sem = GetPreorderLock(preorderId);
|
||||||
|
await sem.WaitAsync();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
// First time any items are resolved → create the order
|
var preorderLocked = await _preorderDbContext.PreOrders.GetByIdAsync(preorderId);
|
||||||
if (newlyFulfilled.Any() || newlyDropped.Any())
|
if (preorderLocked == null) continue;
|
||||||
|
|
||||||
|
var newlyFulfilled2 = changedItems
|
||||||
|
.Where(i => i.FulfilledQuantity > 0 &&
|
||||||
|
(i.Status == PreOrderItemStatus.Fulfilled ||
|
||||||
|
i.Status == PreOrderItemStatus.PartiallyFulfilled))
|
||||||
|
.ToList();
|
||||||
|
var newlyDropped2 = changedItems
|
||||||
|
.Where(i => i.Status == PreOrderItemStatus.Dropped)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (preorderLocked.OrderId == null)
|
||||||
{
|
{
|
||||||
await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId);
|
if (newlyFulfilled2.Any() || newlyDropped2.Any())
|
||||||
|
await CreateOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await AppendItemsToOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
finally { sem.Release(); }
|
||||||
{
|
|
||||||
// Order already exists from a previous document → append new items only
|
|
||||||
await AppendItemsToOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"[PreOrderConversion] Done. {newlyResolvedByPreOrder.Count} preorders affected.");
|
Console.WriteLine($"[PreOrderConversion] Done. {newlyResolvedByPreOrder.Count} preorders affected.");
|
||||||
|
|
@ -303,7 +328,7 @@ public partial class PreOrderConversionService
|
||||||
BillingAddressId = billingAddressId,
|
BillingAddressId = billingAddressId,
|
||||||
OrderStatusId = (int)OrderStatus.Pending,
|
OrderStatusId = (int)OrderStatus.Pending,
|
||||||
PaymentStatusId = (int)PaymentStatus.Pending,
|
PaymentStatusId = (int)PaymentStatus.Pending,
|
||||||
ShippingStatusId = (int)ShippingStatus.NotYetShipped,
|
ShippingStatusId = (int)ShippingStatus.ShippingNotRequired,
|
||||||
PaymentMethodSystemName = "Payments.CheckMoneyOrder",
|
PaymentMethodSystemName = "Payments.CheckMoneyOrder",
|
||||||
CustomerLanguageId = 1,
|
CustomerLanguageId = 1,
|
||||||
CustomerTaxDisplayTypeId = 0,
|
CustomerTaxDisplayTypeId = 0,
|
||||||
|
|
@ -365,6 +390,20 @@ public partial class PreOrderConversionService
|
||||||
});
|
});
|
||||||
|
|
||||||
await InsertOrderItemsAsync(order, fulfilledItems);
|
await InsertOrderItemsAsync(order, fulfilledItems);
|
||||||
|
|
||||||
|
// Recalculate header totals from the actual inserted order items rather than the
|
||||||
|
// PreOrderItem snapshot price: that snapshot is 0 for OrderDraft-origin preorders
|
||||||
|
// (priced at conversion) and may be stale for others (price drift since placement),
|
||||||
|
// so the order items are the authoritative source — same approach as AppendItemsToOrderAsync.
|
||||||
|
var insertedItems = await _dbContext.OrderItems.Table
|
||||||
|
.Where(oi => oi.OrderId == order.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
var recalculatedTotal = insertedItems.Sum(oi => oi.PriceInclTax);
|
||||||
|
order.OrderSubtotalInclTax = recalculatedTotal;
|
||||||
|
order.OrderSubtotalExclTax = Math.Round(recalculatedTotal / 1.27m, 2);
|
||||||
|
order.OrderTotal = recalculatedTotal;
|
||||||
|
await _dbContext.Orders.UpdateAsync(order);
|
||||||
|
|
||||||
await InsertOrderNoteAsync(order.Id, preorder.Id, shippingDocumentId, fulfilledItems, droppedItems);
|
await InsertOrderNoteAsync(order.Id, preorder.Id, shippingDocumentId, fulfilledItems, droppedItems);
|
||||||
|
|
||||||
// Fire event so existing handlers (EventConsumer etc.) run
|
// Fire event so existing handlers (EventConsumer etc.) run
|
||||||
|
|
@ -433,6 +472,9 @@ public partial class PreOrderConversionService
|
||||||
|
|
||||||
private async Task InsertOrderItemsAsync(Order order, List<PreOrderItem> items)
|
private async Task InsertOrderItemsAsync(Order order, List<PreOrderItem> items)
|
||||||
{
|
{
|
||||||
|
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
|
||||||
|
var store = await _storeContext.GetCurrentStoreAsync();
|
||||||
|
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true);
|
var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true);
|
||||||
|
|
@ -441,8 +483,20 @@ public partial class PreOrderConversionService
|
||||||
var product = await _productService.GetProductByIdAsync(item.ProductId);
|
var product = await _productService.GetProductByIdAsync(item.ProductId);
|
||||||
if (product == null) continue;
|
if (product == null) continue;
|
||||||
|
|
||||||
var unitPriceExclTax = Math.Round(item.UnitPriceInclTax / 1.27m, 4);
|
// Recalculate price at conversion time — may have changed since preorder was placed
|
||||||
var priceInclTax = productDto.IsMeasurable ? 0m : item.UnitPriceInclTax * item.FulfilledQuantity;
|
decimal unitPriceInclTax;
|
||||||
|
if (customer != null && _customPriceCalculationService != null)
|
||||||
|
{
|
||||||
|
var calc = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||||
|
product, customer, store, includeDiscounts: true);
|
||||||
|
unitPriceInclTax = calc.finalPrice;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
unitPriceInclTax = item.UnitPriceInclTax;
|
||||||
|
}
|
||||||
|
var unitPriceExclTax = Math.Round(unitPriceInclTax / 1.27m, 4);
|
||||||
|
var priceInclTax = productDto.IsMeasurable ? 0m : unitPriceInclTax * item.FulfilledQuantity;
|
||||||
var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * item.FulfilledQuantity;
|
var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * item.FulfilledQuantity;
|
||||||
|
|
||||||
var orderItem = new OrderItem
|
var orderItem = new OrderItem
|
||||||
|
|
@ -451,7 +505,7 @@ public partial class PreOrderConversionService
|
||||||
OrderId = order.Id,
|
OrderId = order.Id,
|
||||||
ProductId = item.ProductId,
|
ProductId = item.ProductId,
|
||||||
Quantity = item.FulfilledQuantity,
|
Quantity = item.FulfilledQuantity,
|
||||||
UnitPriceInclTax = item.UnitPriceInclTax,
|
UnitPriceInclTax = unitPriceInclTax,
|
||||||
UnitPriceExclTax = unitPriceExclTax,
|
UnitPriceExclTax = unitPriceExclTax,
|
||||||
PriceInclTax = priceInclTax,
|
PriceInclTax = priceInclTax,
|
||||||
PriceExclTax = priceExclTax,
|
PriceExclTax = priceExclTax,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
@model Nop.Plugin.Misc.FruitBankPlugin.Models.CustomerCreditWidgetModel
|
@model Nop.Plugin.Misc.FruitBankPlugin.Models.CustomerCreditWidgetModel
|
||||||
|
@inject Nop.Services.Common.IGenericAttributeService genericAttributeService
|
||||||
|
@inject Nop.Core.IWorkContext workContext
|
||||||
|
|
||||||
@{
|
@{
|
||||||
var remaining = Model.RemainingCredit;
|
var remaining = Model.RemainingCredit;
|
||||||
|
|
@ -6,78 +8,455 @@
|
||||||
: remaining <= 0 ? "text-danger"
|
: remaining <= 0 ? "text-danger"
|
||||||
: remaining < Model.CreditLimit * 0.2m ? "text-warning"
|
: remaining < Model.CreditLimit * 0.2m ? "text-warning"
|
||||||
: "text-success";
|
: "text-success";
|
||||||
|
|
||||||
|
// Per-admin collapse state, persisted like the core nopCommerce cards
|
||||||
|
var admin = await workContext.GetCurrentCustomerAsync();
|
||||||
|
|
||||||
|
const string hideAttributesCardName = "FruitBank.CustomerAttributesCard.Hide";
|
||||||
|
var hideAttributesCard = await genericAttributeService.GetAttributeAsync<bool>(admin, hideAttributesCardName);
|
||||||
|
|
||||||
|
const string hideCreditCardName = "FruitBank.CustomerCreditCard.Hide";
|
||||||
|
var hideCreditCard = await genericAttributeService.GetAttributeAsync<bool>(admin, hideCreditCardName);
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="card card-default">
|
@* FruitBank customer-top section: injected into the CustomerDetailsBlock widget zone
|
||||||
<div class="card-header">
|
(which renders at the BOTTOM of the customer page) and relocated to the top of
|
||||||
<i class="fas fa-credit-card"></i>
|
#customer-cards by the script below. Future non-credit elements go inside this
|
||||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle")
|
same wrapper so the single relocation keeps them all together at the top. *@
|
||||||
</div>
|
<div id="fruitbank-customer-top">
|
||||||
<div class="card-body">
|
|
||||||
|
|
||||||
<div class="form-group row">
|
@* ── Customer attributes (generic attributes block) ───────────────────────── *@
|
||||||
<div class="col-md-3">
|
<div class="card card-primary card-outline @(hideAttributesCard ? "collapsed-card" : "")" id="fruitbank-customer-attributes-card">
|
||||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</label>
|
<div class="card-header with-border">
|
||||||
</div>
|
<h3 class="card-title">
|
||||||
<div class="col-md-9">
|
<i class="fas fa-id-card"></i>
|
||||||
<span class="form-control-plaintext">
|
@T("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title")
|
||||||
@(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text)
|
</h3>
|
||||||
</span>
|
<div class="card-tools float-right">
|
||||||
|
<button type="button" class="btn btn-tool" data-card-widget="collapse">
|
||||||
|
<i class="fas @(hideAttributesCard ? "fa-plus" : "fa-minus")"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer")</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<span class="form-control-plaintext">
|
||||||
|
@* No name attribute: this must NOT post with the core Customer Edit form;
|
||||||
|
it is saved via AJAX on change to CustomerAttributesController.SetEkaer. *@
|
||||||
|
<input type="checkbox" id="fruitbank-isekaer" data-customer-id="@Model.CustomerId" @(Model.IsEkaer ? "checked" : "") />
|
||||||
|
<span id="fruitbank-isekaer-status" class="ml-2"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</label>
|
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerSites.Title")</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-9">
|
<div class="col-md-9">
|
||||||
<span class="form-control-plaintext">
|
<span class="form-control-plaintext" id="fruitbank-sites-summary">
|
||||||
@Model.OutstandingBalance.ToString("N0") Ft
|
@if (Model.SiteCount > 0)
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group row">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</label>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-9">
|
|
||||||
<span class="form-control-plaintext @statusClass">
|
|
||||||
<strong>
|
|
||||||
@if (!Model.HasCreditLimit)
|
|
||||||
{
|
{
|
||||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited")
|
<text>@Model.SiteCount @T("Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord")</text>
|
||||||
|
if (!string.IsNullOrWhiteSpace(Model.DefaultSiteDisplay))
|
||||||
|
{
|
||||||
|
<text> (@T("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel"): @Model.DefaultSiteDisplay)</text>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@(remaining!.Value.ToString("N0"))
|
<span class="text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerSites.None")</span>
|
||||||
<span>Ft</span>
|
|
||||||
}
|
}
|
||||||
</strong>
|
</span>
|
||||||
</span>
|
<button type="button" class="btn btn-default mt-1" data-toggle="modal" data-target="#fruitbank-sites-modal">
|
||||||
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
|
@T("Plugins.Misc.FruitBankPlugin.CustomerSites.Edit")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.Comment))
|
|
||||||
{
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</label>
|
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerPlates.Title")</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-9">
|
<div class="col-md-9">
|
||||||
<span class="form-control-plaintext text-muted">@Model.Comment</span>
|
<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>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group row">
|
@* ── Customer credit ──────────────────────────────────────────────────────── *@
|
||||||
<div class="col-md-9 offset-md-3">
|
<div class="card card-primary card-outline @(hideCreditCard ? "collapsed-card" : "")" id="fruitbank-customer-credit-card">
|
||||||
<a href="/Admin/CustomerCredit/Details/@Model.CustomerId" class="btn btn-default">
|
<div class="card-header with-border">
|
||||||
<i class="fas fa-edit"></i>
|
<h3 class="card-title">
|
||||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle")
|
<i class="fas fa-credit-card"></i>
|
||||||
</a>
|
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle")
|
||||||
|
</h3>
|
||||||
|
<div class="card-tools float-right">
|
||||||
|
<button type="button" class="btn btn-tool" data-card-widget="collapse">
|
||||||
|
<i class="fas @(hideCreditCard ? "fa-plus" : "fa-minus")"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<span class="form-control-plaintext">
|
||||||
|
@(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<span class="form-control-plaintext">
|
||||||
|
@Model.OutstandingBalance.ToString("N0") Ft
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<span class="form-control-plaintext @statusClass">
|
||||||
|
<strong>
|
||||||
|
@if (!Model.HasCreditLimit)
|
||||||
|
{
|
||||||
|
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@(remaining!.Value.ToString("N0"))
|
||||||
|
<span>Ft</span>
|
||||||
|
}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Comment))
|
||||||
|
{
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<span class="form-control-plaintext text-muted">@Model.Comment</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-md-9 offset-md-3">
|
||||||
|
<a href="/Admin/CustomerCredit/Details/@Model.CustomerId" class="btn btn-default">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle")
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Sites edit modal ───────────────────────────────────────────────────────── *@
|
||||||
|
<div id="fruitbank-sites-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="fruitbank-sites-modal-title">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="fruitbank-sites-modal-title">@T("Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle")</h4>
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (!Model.SiteAddresses.Any())
|
||||||
|
{
|
||||||
|
<div class="alert alert-info mb-0">@T("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses")</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:90px">@T("Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel")</th>
|
||||||
|
<th style="width:120px">@T("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel")</th>
|
||||||
|
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel")</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var a in Model.SiteAddresses)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">
|
||||||
|
<input type="checkbox" class="fb-site-check" data-address-id="@a.AddressId" @(a.IsSite ? "checked" : "") />
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<input type="radio" name="fb-site-default" class="fb-site-default" value="@a.AddressId" @(a.IsDefault ? "checked" : "") @(a.IsSite ? "" : "disabled") />
|
||||||
|
</td>
|
||||||
|
<td>@a.Display</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">@T("Admin.Common.Cancel")</button>
|
||||||
|
@if (Model.SiteAddresses.Any())
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-primary" id="fruitbank-sites-save" data-customer-id="@Model.CustomerId">
|
||||||
|
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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">×</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">×</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
|
||||||
|
// #customer-cards; move the whole FruitBank section to the top so it shows first.
|
||||||
|
function moveFruitBankTopSection() {
|
||||||
|
var section = document.getElementById('fruitbank-customer-top');
|
||||||
|
var cards = document.getElementById('customer-cards');
|
||||||
|
if (section && cards && cards.firstElementChild !== section) {
|
||||||
|
cards.prepend(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (document.readyState !== 'loading') {
|
||||||
|
moveFruitBankTopSection();
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', moveFruitBankTopSection);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
$(function () {
|
||||||
|
// Persist collapse state per admin, same as the core nopCommerce cards.
|
||||||
|
// AdminLTE handles the visual collapse via data-card-widget="collapse";
|
||||||
|
// this only saves the new state (class is toggled after this handler runs).
|
||||||
|
function bindCollapsePersist(cardId, attributeName) {
|
||||||
|
$('#' + cardId).on('click', 'button[data-card-widget="collapse"]', function () {
|
||||||
|
var collapsed = !$('#' + cardId).hasClass('collapsed-card');
|
||||||
|
saveUserPreferences('@(Url.Action("SavePreference", "Preferences"))', attributeName, collapsed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bindCollapsePersist('fruitbank-customer-attributes-card', '@hideAttributesCardName');
|
||||||
|
bindCollapsePersist('fruitbank-customer-credit-card', '@hideCreditCardName');
|
||||||
|
|
||||||
|
// isEkaer: save on toggle via AJAX (the widget can't post through the core
|
||||||
|
// Customer Edit action, which only binds CustomerModel).
|
||||||
|
$('#fruitbank-isekaer').on('change', function () {
|
||||||
|
var $cb = $(this);
|
||||||
|
var value = $cb.is(':checked');
|
||||||
|
var $status = $('#fruitbank-isekaer-status');
|
||||||
|
var token = $('input[name="__RequestVerificationToken"]').first().val();
|
||||||
|
|
||||||
|
$cb.prop('disabled', true);
|
||||||
|
$status.html('<i class="fas fa-spinner fa-spin text-muted"></i>');
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '@Url.Action("SetEkaer", "CustomerAttributes")',
|
||||||
|
type: 'POST',
|
||||||
|
data: { customerId: $cb.data('customer-id'), value: value, __RequestVerificationToken: token },
|
||||||
|
success: function (res) {
|
||||||
|
if (res && res.success) {
|
||||||
|
$status.html('<i class="fas fa-check text-success"></i>');
|
||||||
|
} else {
|
||||||
|
$status.html('<i class="fas fa-times text-danger"></i>');
|
||||||
|
$cb.prop('checked', !value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
$status.html('<i class="fas fa-times text-danger"></i>');
|
||||||
|
$cb.prop('checked', !value);
|
||||||
|
},
|
||||||
|
complete: function () {
|
||||||
|
$cb.prop('disabled', false);
|
||||||
|
setTimeout(function () { $status.empty(); }, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sites edit modal ──────────────────────────────────────────────────
|
||||||
|
// The default radio is only valid for rows checked as a site.
|
||||||
|
$('#fruitbank-sites-modal').on('change', '.fb-site-check', function () {
|
||||||
|
var id = $(this).data('address-id');
|
||||||
|
var $radio = $('.fb-site-default[value="' + id + '"]');
|
||||||
|
if ($(this).is(':checked')) {
|
||||||
|
$radio.prop('disabled', false);
|
||||||
|
} else {
|
||||||
|
$radio.prop('disabled', true).prop('checked', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#fruitbank-sites-save').on('click', function () {
|
||||||
|
var $btn = $(this);
|
||||||
|
var ids = [];
|
||||||
|
$('#fruitbank-sites-modal .fb-site-check:checked').each(function () {
|
||||||
|
ids.push($(this).data('address-id'));
|
||||||
|
});
|
||||||
|
var def = $('#fruitbank-sites-modal .fb-site-default:checked').val() || 0;
|
||||||
|
var token = $('input[name="__RequestVerificationToken"]').first().val();
|
||||||
|
|
||||||
|
$btn.prop('disabled', true);
|
||||||
|
$.ajax({
|
||||||
|
url: '@Url.Action("SaveSites", "CustomerAttributes")',
|
||||||
|
type: 'POST',
|
||||||
|
traditional: true, // serialize siteAddressIds as repeated keys for int[] binding
|
||||||
|
data: {
|
||||||
|
customerId: $btn.data('customer-id'),
|
||||||
|
siteAddressIds: ids,
|
||||||
|
defaultAddressId: def,
|
||||||
|
__RequestVerificationToken: token
|
||||||
|
},
|
||||||
|
success: function (res) {
|
||||||
|
if (res && res.success) {
|
||||||
|
// Reload to refresh the summary line with the saved state.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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">×</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>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,5 @@
|
||||||
@using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
|
@using System.Linq
|
||||||
|
@using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
|
||||||
@model OrderAttributesModel
|
@model OrderAttributesModel
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -37,6 +38,69 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<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="form-group row">
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<button type="button" class="btn btn-warning btn-block" data-toggle="modal" data-target="#allowRevisionModal">
|
<button type="button" class="btn btn-warning btn-block" data-toggle="modal" data-target="#allowRevisionModal">
|
||||||
|
|
@ -1336,3 +1400,48 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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._
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
# ORDER-DRAFT — Known Issues
|
||||||
|
|
||||||
|
> Companion to [`README.md`](README.md). Topic `ORDD`, prefix `MGFBANKPLUG` → entry IDs `MGFBANKPLUG-ORDD-I-<RAND>` (issue) / `-B-` (confirmed bug) / `-T-` (tech-debt).
|
||||||
|
> ID format, Status vocabulary, type codes and archival → `../../.github/TOPIC_CODES.md` (→ framework registry).
|
||||||
|
|
||||||
|
Scope: az AI-alapú rendelésfelvevő (OrderDraft) problémái — beérkező üzenet parse-olása, termék/partner feloldás, tanuló mappingek, draft életciklus, jóváhagyási belépési pont a PRE-ORDER folyamatba.
|
||||||
|
|
||||||
|
## Active entries
|
||||||
|
|
||||||
|
## MGFBANKPLUG-ORDD-I-Q4N7: Az `ApproveAsync` fixen `StoreId = 1`-et használ
|
||||||
|
|
||||||
|
**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** I (multi-store helyesség)
|
||||||
|
|
||||||
|
`OrderDraftService.ApproveAsync` ([`Services/OrderDraftService.cs`](../../Services/OrderDraftService.cs)) mindkét ágban (`preorder` és `azonnali`) `StoreId = 1` konstanssal hozza létre a `PreOrder`-t:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var preorder = new PreOrder { CustomerId = draft.CustomerId!.Value, StoreId = 1, ... };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hatás:** jelenleg egy store-os környezetben nincs gond. Multi-store esetén viszont a draftból mindig az 1-es store-ba kerül a rendelés, függetlenül a partner/üzenet kontextusától — rossz store-hoz rendelt rendelések.
|
||||||
|
|
||||||
|
**Javítási irány:** a store-t a draft kontextusából vagy a partner (`Customer`) alapértelmezett store-jából vezessük le, ne hardcode-oljuk. (Megj.: a `PreOrderAdminController.CreatePreOrder` is hasonló fallback-logikát használ — érdemes közös segédbe emelni.)
|
||||||
|
|
||||||
|
**Affected:** `Services/OrderDraftService.cs` → `ApproveAsync` (mindkét `new PreOrder { StoreId = 1 }`).
|
||||||
|
|
||||||
|
## MGFBANKPLUG-ORDD-T-R8W3: A jóváhagyás preorder/azonnali ága szinte duplikátum
|
||||||
|
|
||||||
|
**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** T (tech-debt / refaktor)
|
||||||
|
|
||||||
|
`ApproveAsync`-ben a `preorderItems` és az `orderItems` (azonnali) ág kódja közel azonos: `PreOrderItem` lista építése (`UnitPriceInclTax = 0`, `Status = Pending`), `PreOrder` létrehozás, `InsertPreOrderAsync`, majd `ConvertPreOrdersForProductsAsync` hívás. Az egyetlen érdemi különbség a `CustomerNote` szövege és hogy az azonnali ág a végén visszaolvassa az `OrderId`-t.
|
||||||
|
|
||||||
|
Mivel mindkét ág ugyanazon a `PreOrder` + konverzió flow-n megy keresztül (a konverziós ablak dönti el, hogy azonnal teljesül-e), a kettő **egyetlen** közös ággá vonható össze, dátum szerinti elágazás nélkül — vagy egy közös privát helperrel (`CreatePreOrderFromDraftItemsAsync(items, note)`).
|
||||||
|
|
||||||
|
**Affected:** `Services/OrderDraftService.cs` → `ApproveAsync`.
|
||||||
|
|
||||||
|
## MGFBANKPLUG-ORDD-I-M2K9: Az API-kulcs összehasonlítása nem konstans idejű
|
||||||
|
|
||||||
|
**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** I (security hardening)
|
||||||
|
|
||||||
|
`OrderDraftApiController.ValidateApiKey` ([`Controllers/OrderDraftApiController.cs`](../../Controllers/OrderDraftApiController.cs)) `string.Equals(..., StringComparison.Ordinal)`-lal hasonlítja össze a beküldött és a tárolt API-kulcsot. Ez **nem konstans idejű** — elvi timing-oldalcsatorna a kulcs kitalálására.
|
||||||
|
|
||||||
|
**Hatás:** alacsony (a kulcs hosszú, és a háló-jitter elnyomja a mikro-eltérést), de egy publikus, csak-API-kulccsal védett végpontnál a fixed-time összehasonlítás az elvárt gyakorlat.
|
||||||
|
|
||||||
|
**Javítási irány:** fixed-time összehasonlítás, pl. `CryptographicOperations.FixedTimeEquals` a két kulcs UTF-8 byte-jaira (hossz-eltérés előzetes kezelésével). Opcionálisan rate-limiting a végponton.
|
||||||
|
|
||||||
|
**Affected:** `Controllers/OrderDraftApiController.cs` → `ValidateApiKey`.
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Order-Draft Workflow
|
||||||
|
|
||||||
|
> Part of `Nop.Plugin.Misc.FruitBankPlugin`. See `../README.md` for the docs index, `../../README.md` for project overview.
|
||||||
|
> Closely coupled to the pre-order subsystem — see [`../PREORDER/README.md`](../PREORDER/README.md): an approved draft creates `PreOrder`s and runs the same conversion.
|
||||||
|
> Known issues / planned work: [`ORDERDRAFT_ISSUES.md`](ORDERDRAFT_ISSUES.md).
|
||||||
|
|
||||||
|
An **order draft** is an AI-assisted intake channel. External systems (Viber bot, email processor, dictation app) post a raw free-text message; the AI extracts delivery dates and product demands; a draft is created for admin review. On approval the draft becomes one or more `PreOrder`s, which then flow through the existing **PRE-ORDER conversion** — so OrderDraft is a new, admin-gated *placement* channel into that pipeline, not a parallel order path.
|
||||||
|
|
||||||
|
## Entity Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
OrderDraft ← one inbound message for one delivery date
|
||||||
|
└─ OrderDraftItem ← one parsed product-demand line [1:N]
|
||||||
|
|
||||||
|
ExternalUserPartnerMapping ← external username → nopCommerce CustomerId (learned)
|
||||||
|
ProductTextMapping ← raw text → admin-chosen ProductId (learned, for ranking)
|
||||||
|
```
|
||||||
|
|
||||||
|
All four tables live in `FruitBank.Common/Entities/` and are registered on `FruitBankDbContext`. `OrderDraftStatus`: `Pending` → `Approved` / `Rejected` / `Expired`.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Intake
|
||||||
|
`OrderDraftApiController.Create` (`Controllers/`): auth via `X-FruitBank-ApiKey` header (matched against `FruitBankSettings.OrderDraftApiKey`), body `{ externalUsername, messageText }`.
|
||||||
|
|
||||||
|
### 2. Draft build
|
||||||
|
`OrderDraftService.CreateDraftsFromMessageAsync`:
|
||||||
|
- Partner resolution from `ExternalUserPartnerMapping` (may be `null` → admin assigns later).
|
||||||
|
- AI parse (`OrderDraftParserService.ParseMessageAsync`): one draft per delivery date; `isPreorder = deliveryDate > today + PreorderThresholdDays (4)`.
|
||||||
|
- Per item (`ProcessDraftItemAsync`): historic `ProductTextMapping` weighting (last 5 admin decisions) + AI canonical name/keywords + `SearchProductsAsync` + AI ranking → `SuggestedProductIdsJson`, first suggestion pre-selected.
|
||||||
|
|
||||||
|
### 3. Admin review
|
||||||
|
`OrderDraftAdminController` (`Areas/Admin/`): DataTables list (urgent = Pending > 20h), Detail page with candidates, `UpdateItem` / `AssignCustomer`.
|
||||||
|
|
||||||
|
### 4. Approval (the entry point into PRE-ORDER)
|
||||||
|
`OrderDraftService.ApproveAsync`:
|
||||||
|
- Validates: partner assigned, every item has `SelectedProductId`.
|
||||||
|
- Saves `ProductTextMapping` learning records.
|
||||||
|
- Splits items by `IsPreorder`; **both branches create a `PreOrder`** via `InsertPreOrderAsync`, then call `PreOrderConversionService.ConvertPreOrdersForProductsAsync`. The "immediate" branch relies on the 4-day conversion window to convert at once.
|
||||||
|
|
||||||
|
### 5. Expiry
|
||||||
|
`OrderDraftService.ExpireOldDraftsAsync`: Pending drafts older than 14 days → `Expired` (background job).
|
||||||
|
|
||||||
|
## AI touchpoints
|
||||||
|
|
||||||
|
Three distinct AI calls, all in `OrderDraftParserService` via `OpenAIApiService.GetSimpleResponseAsync` (OpenAI Chat Completions). OrderDraft uses **OpenAI exclusively** — unlike `QuickOrderController` / `OrderController` parsing, which use `CerebrasApiService`.
|
||||||
|
|
||||||
|
| # | Call | Input → Output | Used in |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `ParseMessageAsync` | raw message → delivery-date groups of `{ rawProductText, requestedQuantity }`; one draft per date | `CreateDraftsFromMessageAsync` |
|
||||||
|
| 2 | `ResolveProductNameAsync` | one raw product text → `{ canonicalName, searchKeywords[] }` (keywords feed NopCommerce `SearchProductsAsync`) | `ProcessDraftItemAsync` |
|
||||||
|
| 3 | `RankCandidatesAsync` | raw text + canonical name + candidate `(id, name)[]` → ranked product-id list; only when >1 non-historic search hit | `ProcessDraftItemAsync` |
|
||||||
|
|
||||||
|
Resolution is **hybrid**: AI proposes candidates, but ordering is front-loaded by historic admin decisions (`ProductTextMapping`, last 5 for the same raw text) — a DB heuristic, not AI. Product search itself (`SearchProductsAsync`) is NopCommerce full-text, not AI.
|
||||||
|
|
||||||
|
**Model:** `GetModelName()` from `FruitBankSettings.OpenAIModelName` (default `gpt-3.5-turbo`; code paths for `gpt-4o-mini`/`4.1-mini`/`4.1-nano`/`5-nano` at `temperature=0.2`, and a `gpt-5` path at `reasoning_effort=minimal`).
|
||||||
|
|
||||||
|
**Not yet AI-wired:** partner identification (plain `ExternalUserPartnerMapping` username lookup), quantity/unit normalisation (relies on the parse prompt only), confidence/uncertainty scoring (admin approves everything manually), and the preorder-vs-immediate split (fixed 4-day `PreorderThresholdDays`, not AI).
|
||||||
|
|
||||||
|
## Key Source
|
||||||
|
|
||||||
|
| Type | Location |
|
||||||
|
|---|---|
|
||||||
|
| `OrderDraftService` / `IOrderDraftService` | `Services/` |
|
||||||
|
| `OrderDraftParserService` (AI parse / resolve / rank) | `Services/` |
|
||||||
|
| `OrderDraftApiController` | `Controllers/` |
|
||||||
|
| `OrderDraftAdminController` (+ models) | `Areas/Admin/` |
|
||||||
|
| `OrderDraftDbTable` / `OrderDraftItemDbTable` / `ExternalUserPartnerMapDbTable` / `ProductTextMappingDbTable` | `Domains/DataLayer/` |
|
||||||
|
| `OrderDraft` / `OrderDraftItem` / `ExternalUserPartnerMapping` / `ProductTextMapping` entities | `FruitBank.Common/Entities/` |
|
||||||
|
|
@ -7,4 +7,21 @@ Scope: bugs / inconsistent or observable edge-case behaviour in the pre-order su
|
||||||
|
|
||||||
## Active entries
|
## Active entries
|
||||||
|
|
||||||
*(None yet — to be filled from the code review as needed.)*
|
## MGFBANKPLUG-PREO-B-K7M2: A konverziós pool dupla-számolja a már teljesített foglalásokat — alul-allokáció
|
||||||
|
|
||||||
|
**Status:** Open (2026-06-15) · **Priority:** P2 · **Type:** B (megerősített bug)
|
||||||
|
|
||||||
|
`PreOrderConversionService.BuildIncomingQuantityPoolAsync` ([`Services/PreorderConversionService.cs`](../../Services/PreorderConversionService.cs)) a rendelkezésre álló mennyiségből **kétszer** vonja le a korábbi futásokban már teljesített (`Fulfilled`/`PartiallyFulfilled`) preorder-tételeket.
|
||||||
|
|
||||||
|
- `AvailableQuantity = StockQuantity + IncomingQuantity` (SCHEMA.md), az élő `Product.StockQuantity`-ből.
|
||||||
|
- Teljesítéskor `InsertOrderItemsAsync` → `AdjustInventoryAsync(-FulfilledQuantity)` **már csökkenti** a `StockQuantity`-t, így a következő futás `AvailableQuantity`-je **már tartalmazza** a foglalást.
|
||||||
|
- A pool ezen felül **még levonja** az `alreadyAllocated`-et (a `Fulfilled`/`PartiallyFulfilled` itemek `FulfilledQuantity` összegét) → **futások közötti dupla-levonás**.
|
||||||
|
|
||||||
|
**Reprodukció:** `Stock=0, Incoming=10`. „A" preorder 6-ot teljesít (run 1) → `Stock=-6`, allocated=6. „B" preorder 6-ot kér (run 2): `available = -6+10 = 4`, `committed = 6` → `pool = max(0, 4-6) = 0`. **B nem kap semmit, pedig fizikailag 4 jár neki.** Az alul-allokáció a „még nincs beérkezve" eset — az előrendelés tipikus forgatókönyve.
|
||||||
|
|
||||||
|
**Miért nem puszta törlés a fix:** a `CreateOrderAsync` korai `return`-jei (nincs `Customer` / nincs számlázási cím, [sorok ~300-319](../../Services/PreorderConversionService.cs)) úgy lépnek ki, hogy az item a fő ciklusban (sor ~153) **már `Fulfilled`-re lett állítva és elmentve**, de a készletlevonás nem futott le. Ezeken a hibautakon az `alreadyAllocated` levonás *helyesen* kompenzál; a levonás puszta törlése **túl-allokációt (overselling)** okozna.
|
||||||
|
|
||||||
|
**Javítási irány (ajánlott):** az item csak akkor legyen `Fulfilled`, ha a rendelés + készletlevonás ténylegesen sikerült (korai return esetén maradjon `Pending`/visszaállítás), majd ezután eltávolítható a dupláló `alreadyAllocated` levonás. Így megszűnik az alul-allokáció túl-allokációs kockázat nélkül.
|
||||||
|
|
||||||
|
**Affected:**
|
||||||
|
- `Services/PreorderConversionService.cs` → `BuildIncomingQuantityPoolAsync` (a `alreadyAllocated` levonás), `ConvertPreOrdersForProductsAsync` fő ciklusa (státusz-mutáció sorrendje), `CreateOrderAsync` korai return ágai.
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ Topic documentation for the FruitBank-specific NopCommerce plugin (Layer 3 — c
|
||||||
- [`SCHEMA.md`](SCHEMA.md) — Database schema (Toon format — LLM-optimized)
|
- [`SCHEMA.md`](SCHEMA.md) — Database schema (Toon format — LLM-optimized)
|
||||||
- [`DOMAIN_MODEL.md`](DOMAIN_MODEL.md) — Business domain behavior
|
- [`DOMAIN_MODEL.md`](DOMAIN_MODEL.md) — Business domain behavior
|
||||||
- [`MEASUREMENT.md`](MEASUREMENT.md) — Weight measurement logic (NetWeight formula, MeasuringStatus lifecycle)
|
- [`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
|
## Topic folders
|
||||||
|
|
||||||
- [`SIGNALR/`](SIGNALR/README.md) — SignalR endpoints exposed by this plugin (project-specific variant)
|
- [`SIGNALR/`](SIGNALR/README.md) — SignalR endpoints exposed by this plugin (project-specific variant)
|
||||||
- [`PREORDER/`](PREORDER/README.md) — Customer pre-order workflow: advance orders converted to real orders on incoming stock (+ `PREORDER_ISSUES.md` / `PREORDER_TODO.md`)
|
- [`PREORDER/`](PREORDER/README.md) — Customer pre-order workflow: advance orders converted to real orders on incoming stock (+ `PREORDER_ISSUES.md` / `PREORDER_TODO.md`)
|
||||||
|
- [`ORDERDRAFT/`](ORDERDRAFT/README.md) — AI-assisted intake: free-text messages parsed into order drafts, admin-approved into pre-orders (+ `ORDERDRAFT_ISSUES.md`)
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Loading…
Reference in New Issue