OrderDraft, bugfixes, location, license plate in progress

This commit is contained in:
Adam 2026-06-23 23:38:51 +02:00
parent 1b60684f85
commit 9209b26386
37 changed files with 3019 additions and 168 deletions

View File

@ -12,3 +12,4 @@ Per-repo topic registry for this plugin, per the **per-repo extension convention
|---------|-----------|------------------------------------------------------------------------------------------------|------------------|
| `PREO` | PRE-ORDER | Customer advance orders placed before stock arrives, and their conversion into real nopCommerce orders when an inbound shipping document confirms incoming stock. | `docs/PREORDER/` |
| `EKAER` | EKÁER | Server-side / nopCommerce-specific aspects of NAV EKÁER reporting: VTSZ source from `Product`, `Shipping`→`tradeCard` data needs, serving the NAV call. The protocol/base layer lives in AyCode.Core (`AyCode.Services/Nav/`). | `docs/EKAER/` |
| `ORDD` | ORDER-DRAFT | AI-assisted intake channel: inbound free-text messages (Viber/email/dictation) parsed into order drafts, admin review/approval, then PreOrder creation that feeds the existing PRE-ORDER conversion. Covers parsing, product/partner resolution, learning mappings, the draft lifecycle and the approval entry point. | `docs/ORDERDRAFT/` |

View File

@ -1123,12 +1123,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
productDtosById.TryGetValue(product.Id, out var dto);
result.Add(new
{
label = $"{product.Name} [KÉSZLET: {(product.StockQuantity + (dto?.IncomingQuantity ?? 0))}] [ÁR: {product.Price}]",
label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + (dto?.IncomingQuantity ?? 0))} (R:{product.StockQuantity}/K:{dto?.IncomingQuantity ?? 0})] [ÁR: {product.Price}]",
value = product.Id,
sku = product.Sku,
price = product.Price,
stockQuantity = product.StockQuantity,
incomingQuantity = dto?.IncomingQuantity ?? 0
incomingQuantity = dto?.IncomingQuantity ?? 0,
availableQuantity = product.StockQuantity + (dto?.IncomingQuantity ?? 0)
});
}

View File

@ -0,0 +1,89 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Nop.Core.Domain.Customers;
using Nop.Plugin.Misc.FruitBankPlugin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Customers;
using Nop.Services.Security;
using Nop.Web.Framework;
using Nop.Web.Framework.Controllers;
using Nop.Web.Framework.Mvc.Filters;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
/// <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 });
}
}

View File

@ -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 });
}
}

View File

@ -12,6 +12,7 @@ using Nop.Services.Security;
using Nop.Web.Framework;
using Nop.Web.Framework.Controllers;
using Nop.Web.Framework.Mvc.Filters;
using Nop.Services.Helpers;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
@ -27,6 +28,7 @@ public partial class PreOrderAdminController : BasePluginController
private readonly PreOrderConversionService _preorderConversionService;
private readonly IPriceCalculationService _priceCalculationService;
private readonly IStoreContext _storeContext;
private readonly IDateTimeHelper _dateTimeHelper;
private static readonly Dictionary<PreOrderStatus, string> StatusLabels = new()
{
@ -51,7 +53,8 @@ public partial class PreOrderAdminController : BasePluginController
ICustomerService customerService,
PreOrderConversionService preorderConversionService,
IPriceCalculationService priceCalculationService,
IStoreContext storeContext)
IStoreContext storeContext,
IDateTimeHelper dateTimeHelper)
{
_permissionService = permissionService;
_preorderDbContext = preorderDbContext;
@ -60,6 +63,7 @@ public partial class PreOrderAdminController : BasePluginController
_preorderConversionService = preorderConversionService;
_priceCalculationService = priceCalculationService;
_storeContext = storeContext;
_dateTimeHelper = dateTimeHelper;
}
// ── LIST PAGE ─────────────────────────────────────────────────────────────
@ -106,7 +110,7 @@ public partial class PreOrderAdminController : BasePluginController
var customerIds = preorders.Select(p => p.CustomerId).Distinct().ToList();
var customers = await _dbContext.Customers.Table
.Where(c => customerIds.Contains(c.Id))
.Select(c => new { c.Id, c.Email, c.FirstName, c.LastName })
.Select(c => new { c.Id, c.Email, c.FirstName, c.LastName, c.Company })
.ToListAsync();
var customerById = customers.ToDictionary(c => c.Id);
@ -116,7 +120,7 @@ public partial class PreOrderAdminController : BasePluginController
// For now we surface the link on the detail page only.
// 4. Build rows — derive status from quantities, not enum (LinqToDB enum reads unreliable)
var rows = preorders.Select(p =>
var rowTasks = preorders.Select(async p =>
{
customerById.TryGetValue(p.CustomerId, out var c);
var items = itemsByPreOrder.TryGetValue(p.Id, out var its) ? its : new();
@ -140,17 +144,18 @@ public partial class PreOrderAdminController : BasePluginController
{
PreOrderId = p.Id,
CustomerId = p.CustomerId,
CustomerName = c != null ? $"{c.FirstName} {c.LastName}".Trim() : $"#{p.CustomerId}",
CustomerName = c != null ? (!string.IsNullOrWhiteSpace(c.Company) ? c.Company : $"{c.FirstName} {c.LastName}".Trim()) : $"#{p.CustomerId}",
CustomerEmail = c?.Email ?? string.Empty,
DateOfReceipt = p.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
CreatedOnUtc = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
DateOfReceipt = (await _dateTimeHelper.ConvertToUserTimeAsync(p.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"),
CreatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(p.CreatedOnUtc, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"),
Status = effectiveStatus,
StatusLabel = StatusLabels.TryGetValue(effectiveStatus, out var sl) ? sl : effectiveStatus.ToString(),
ItemCount = items.Count,
FulfilledCount = fulfilledCount,
OrderId = p.OrderId
};
}).ToList();
});
var rows = (await Task.WhenAll(rowTasks)).ToList();
int recordsTotal = rows.Count;
@ -217,11 +222,14 @@ public partial class PreOrderAdminController : BasePluginController
{
PreOrderId = preorder.Id,
CustomerId = preorder.CustomerId,
CustomerName = customer != null ? $"{customer.FirstName} {customer.LastName}".Trim() : $"#{preorder.CustomerId}",
CustomerName = customer != null
? (!string.IsNullOrWhiteSpace(customer.Company) ? customer.Company : $"{customer.FirstName} {customer.LastName}".Trim())
: $"#{preorder.CustomerId}",
CustomerEmail = customer?.Email ?? string.Empty,
DateOfReceipt = preorder.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
CreatedOnUtc = preorder.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
UpdatedOnUtc = preorder.UpdatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
DateOfReceipt = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"),
DateOfReceiptRaw = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.DateOfReceipt, DateTimeKind.Utc)).ToString("yyyy-MM-ddTHH:mm"),
CreatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.CreatedOnUtc, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"),
UpdatedOnUtc = (await _dateTimeHelper.ConvertToUserTimeAsync(preorder.UpdatedOnUtc, DateTimeKind.Utc)).ToString("yyyy.MM.dd HH:mm"),
Status = preorder.Status,
CustomerNote = preorder.CustomerNote,
OrderId = linkedOrderId,
@ -369,13 +377,14 @@ public partial class PreOrderAdminController : BasePluginController
if (preorder.Status == PreOrderStatus.Cancelled)
return Json(new { success = false, error = "Már visszavont előrendelés" });
preorder.Status = PreOrderStatus.Cancelled;
preorder.UpdatedOnUtc = DateTime.UtcNow;
await _preorderDbContext.PreOrders.UpdateAsync(preorder);
if (preorder.Status != PreOrderStatus.Pending)
return Json(new { success = false, error = "Only pending preorders can be cancelled" });
// Only not-yet-fulfilled preorders can be cancelled — once a real order
// exists (OrderId set at first fulfilment), cancelling the preorder must
// not silently undo it.
if (preorder.Status != PreOrderStatus.Pending || preorder.OrderId.HasValue)
return Json(new { success = false, error = "Csak függőben lévő előrendelés vonható vissza" });
//await _preorderDbContext.CancelPreOrderAsync(id);
// Sets header → Cancelled and drops all still-active items → Dropped
await _preorderDbContext.CancelPreOrderAsync(id);
return Json(new { success = true });
}

View File

@ -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;
}

View File

@ -23,7 +23,8 @@ public class PreOrderDetailModel
public int CustomerId { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string DateOfReceipt { get; set; } = string.Empty;
public string DateOfReceipt { get; set; } = string.Empty; // formatted for display
public string DateOfReceiptRaw { get; set; } = string.Empty; // yyyy-MM-ddTHH:mm for datetime-local input
public string CreatedOnUtc { get; set; } = string.Empty;
public string UpdatedOnUtc { get; set; } = string.Empty;
public PreOrderStatus Status { get; set; }

View File

@ -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>

View File

@ -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>

View File

@ -12,7 +12,6 @@
PreOrderStatus.Cancelled => "po-status-cancelled",
_ => "po-status-pending"
};
var statusLabel = Model.Status switch
{
PreOrderStatus.Confirmed => "Megerősítve",
@ -20,6 +19,7 @@
PreOrderStatus.Cancelled => "Törölve",
_ => "Függőben"
};
var canEdit = Model.Status != PreOrderStatus.Cancelled;
}
@Html.AntiForgeryToken()
@ -38,14 +38,20 @@
.item-fulfilled { background:#eaf7ee; }
.item-partial { background:#fffbf0; }
.item-dropped { background:#fdf0f0; color:#888; }
.item-pending { }
.qty-bar-wrap { width:100px; display:inline-block; vertical-align:middle; }
.qty-bar { height:6px; background:#dde8da; border-radius:3px; overflow:hidden; display:inline-block; width:100%; }
.qty-bar-fill { height:100%; border-radius:3px; }
/* Click-to-edit */
.editable-cell { cursor:pointer; border-radius:4px; padding:2px 6px; transition:background .15s; }
.editable-cell:hover { background:#f0f8f1; outline:1px dashed #2d7a3a; }
.edit-input { width:90px; }
.btn-swap { padding:2px 7px; font-size:11px; }
.ui-autocomplete { z-index:1060 !important; max-height:220px; overflow-y:auto; overflow-x:hidden; }
</style>
<!-- Back link -->
<a href="/Admin/PreOrders" class="btn btn-default btn-sm mb-3">
<i class="fas fa-arrow-left"></i> Vissza a listához
</a>
@ -56,16 +62,25 @@
Előrendelés <strong>#@Model.PreOrderId</strong>
<span class="@statusClass ml-2">@statusLabel</span>
</h1>
<div class="float-right">
<div class="float-right d-flex align-items-center" style="gap:8px;">
@if (Model.OrderId.HasValue)
{
<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
</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)
{
<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
</button>
}
@ -75,14 +90,14 @@
<section class="content">
<div class="container-fluid">
<!-- ── Meta cards ─────────────────────────────────────────────────── -->
<!-- ── Meta card ─────────────────────────────────────────────────── -->
<div class="po-meta-card">
<!-- Read-only view -->
<div id="headerView">
<div class="po-meta-grid">
<div class="po-meta-item">
<div class="label">Ügyfél</div>
<div class="value">
<a href="/Admin/Customer/Edit/@Model.CustomerId">@Model.CustomerName</a>
</div>
<div class="value"><a href="/Admin/Customer/Edit/@Model.CustomerId">@Model.CustomerName</a></div>
<small class="text-muted">@Model.CustomerEmail</small>
</div>
<div class="po-meta-item">
@ -107,7 +122,29 @@
</div>
</div>
<!-- ── Items table ─────────────────────────────────────────────────── -->
<!-- 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>
<!-- ── Items table ────────────────────────────────────────────────── -->
<div class="card card-default">
<div class="card-header">
<strong>Tételek (@Model.Items.Count)</strong>
@ -129,9 +166,9 @@
<thead>
<tr>
<th>Termék</th>
<th width="80" class="text-center">Kérve</th>
<th width="80" class="text-center">Teljesítve</th>
<th width="160">Teljesítés</th>
<th width="90" class="text-center">Kérve</th>
<th width="90" class="text-center">Teljesítve</th>
<th width="150">Teljesítés</th>
<th width="130" class="text-right">Egységár</th>
<th width="130" class="text-right">Becsült ár</th>
<th width="110" class="text-center">Állapot</th>
@ -145,7 +182,7 @@
PreOrderItemStatus.Fulfilled => "item-fulfilled",
PreOrderItemStatus.PartiallyFulfilled => "item-partial",
PreOrderItemStatus.Dropped => "item-dropped",
_ => "item-pending"
_ => ""
};
var pct = item.RequestedQuantity > 0
? (int)Math.Round((double)item.FulfilledQuantity / item.RequestedQuantity * 100)
@ -154,20 +191,52 @@
var estimatedPrice = item.IsMeasurable
? "—"
: (item.UnitPriceInclTax * item.FulfilledQuantity).ToString("N0") + " Ft";
var unitPrice = item.IsMeasurable ? "súlymérés" : item.UnitPriceInclTax.ToString("N0") + " Ft";
var unitPriceStr = item.IsMeasurable ? "súlymérés" : item.UnitPriceInclTax.ToString("N0") + " Ft";
var isLocked = item.FulfilledQuantity > 0;
<tr class="@rowClass">
<tr class="@rowClass" data-item-id="@item.ItemId">
<!-- Product + optional swap -->
<td>
<a href="/Admin/Product/Edit/@item.ProductId" target="_blank">@item.ProductName</a>
@if (item.IsMeasurable)
{
<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 class="text-center">@item.RequestedQuantity db</td>
<!-- Requested qty — click-to-edit if pending -->
<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>
<!-- Fulfilled — always read-only -->
<td class="text-center"><strong>@item.FulfilledQuantity db</strong></td>
<!-- Progress bar -->
<td>
<div class="qty-bar-wrap">
<div class="qty-bar">
@ -176,8 +245,25 @@
</div>
<small class="ml-1">@pct%</small>
</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-center">
<span class="po-status-@item.Status.ToString().ToLower()" style="font-size:11px;padding:2px 6px;">
@item.StatusLabel
@ -188,7 +274,8 @@
</tbody>
@{
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);
}
<tfoot>
@ -205,25 +292,192 @@
</div>
</section>
@if (Model.Status == PreOrderStatus.Pending)
{
<script>
$(function () {
<!-- ── Product swap modal ──────────────────────────────────────────────── -->
<div id="swapProductModal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header" style="background:#2d7a3a;color:#fff;">
<h5 class="modal-title"><i class="fas fa-exchange-alt"></i> Termék cseréje</h5>
<button type="button" class="close" data-dismiss="modal" style="color:#fff;"><span>&times;</span></button>
</div>
<div class="modal-body">
<p class="text-muted" style="font-size:13px;">Csak az előrendelési ablakban elérhető termékek jelennek meg.</p>
<input type="text" id="swapProductSearch" autocomplete="off" class="form-control"
placeholder="Termék neve vagy SKU..." />
<div id="swapSelectedProduct" class="mt-2" style="display:none;">
<span class="badge badge-success p-2" id="swapSelectedName"></span>
<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');
}
});
});
// ── 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;
$.ajax({
url : '/Admin/PreOrders/Cancel/@Model.PreOrderId',
type : 'POST',
data : { __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() },
success: function (res) {
if (res.success) {
location.href = '/Admin/PreOrders';
} else {
alert('Hiba: ' + (res.error || 'Ismeretlen hiba'));
$.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>
}
});
</script>

View File

@ -1,6 +1,10 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Customers;
using Nop.Plugin.Misc.FruitBankPlugin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Customers;
using Nop.Web.Areas.Admin.Models.Customers;
using Nop.Web.Framework.Components;
@ -10,10 +14,17 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Components;
public class CustomerCreditWidgetViewComponent : NopViewComponent
{
private readonly ICustomerCreditService _customerCreditService;
private readonly FruitBankAttributeService _attributeService;
private readonly ICustomerService _customerService;
public CustomerCreditWidgetViewComponent(ICustomerCreditService customerCreditService)
public CustomerCreditWidgetViewComponent(
ICustomerCreditService customerCreditService,
FruitBankAttributeService attributeService,
ICustomerService customerService)
{
_customerCreditService = customerCreditService;
_attributeService = attributeService;
_customerService = customerService;
}
public async Task<IViewComponentResult> InvokeAsync(string widgetZone, object additionalData)
@ -24,6 +35,9 @@ public class CustomerCreditWidgetViewComponent : NopViewComponent
var credit = await _customerCreditService.GetByCustomerIdAsync(customerId);
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
var isEkaer = await _attributeService
.GetGenericAttributeValueAsync<Customer, bool>(customerId, FruitBankPluginConst.IsEkaerAttribute, storeId: 0);
var model = new CustomerCreditWidgetModel
{
CustomerId = customerId,
@ -31,9 +45,63 @@ public class CustomerCreditWidgetViewComponent : NopViewComponent
CreditLimit = credit?.CreditLimit ?? 0m,
OutstandingBalance = outstanding,
RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null,
Comment = credit?.Comment
Comment = credit?.Comment,
IsEkaer = isEkaer
};
await PopulateSitesAsync(model, customerId);
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model);
}
// Merge the customer's current addresses with the saved site list (orphaned
// address ids — deleted addresses — are dropped because we iterate addresses).
private async Task PopulateSitesAsync(CustomerCreditWidgetModel model, int customerId)
{
var addresses = await _customerService.GetAddressesByCustomerIdAsync(customerId);
var savedJson = await _attributeService
.GetGenericAttributeValueAsync<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;
}
}

View File

@ -342,18 +342,31 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
// Load BEFORE the update to capture previous ProductId and QuantityOnDocument
var oldItem = await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, false);
// For product swap: move IncomingQuantity BEFORE saving the new ProductId to DB.
// This ensures the EventConsumer (triggered by UpdateShippingItemSafeAsync) already
// sees the correct IncomingQuantity when ConvertPreOrdersForProductsAsync fires.
var productChanged = oldItem != null && oldItem.ProductId != shippingItem.ProductId;
var quantityChanged = oldItem != null && oldItem.QuantityOnDocument != shippingItem.QuantityOnDocument;
if (productChanged && shippingItem.ProductId.HasValue &&
oldItem?.ProductId.HasValue == true && !oldItem.IsMeasured)
{
var qty = oldItem.QuantityOnDocument;
await preorderConversionService.SyncIncomingQuantityAsync(oldItem.ProductId.Value, qty, 0);
await preorderConversionService.SyncIncomingQuantityAsync(shippingItem.ProductId.Value, 0, qty);
}
if (!await ctx.UpdateShippingItemSafeAsync(shippingItem)) return null;
if (oldItem != null)
{
var productChanged = oldItem.ProductId != shippingItem.ProductId;
var quantityChanged = oldItem.QuantityOnDocument != shippingItem.QuantityOnDocument;
if (productChanged && shippingItem.ProductId.HasValue)
{
// Full replacement: swap stock, order items, preorder items
// skipIncomingSync=true because we already moved IncomingQty above
await preorderConversionService.ReplaceShippingItemProductAsync(
shippingItem.Id, shippingItem.ProductId.Value, oldItem);
shippingItem.Id, shippingItem.ProductId.Value, oldItem,
skipIncomingSync: !oldItem.IsMeasured);
}
else if (quantityChanged && shippingItem.ProductId.HasValue)
{

View File

@ -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; }
}

View File

@ -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);
}

View File

@ -70,6 +70,11 @@ public class FruitBankDbContext : MgDbContextBase,
public StockQuantityHistoryDtoDbTable StockQuantityHistoryDtos { get; set; }
public CustomerCreditDbTable CustomerCredits { get; set; }
public OrderDraftDbTable OrderDrafts { get; set; }
public OrderDraftItemDbTable OrderDraftItems { get; set; }
public ExternalUserPartnerMapDbTable ExternalUserPartnerMaps { get; set; }
public ProductTextMappingDbTable ProductTextMappings { get; set; }
public IRepository<Customer> Customers { get; set; }
public IRepository<CustomerRole> CustomerRoles { get; set; }
public IRepository<CustomerCustomerRoleMapping> CustomerRoleMappings { get; set; }
@ -85,6 +90,8 @@ public class FruitBankDbContext : MgDbContextBase,
ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable,
ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable,
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable,
OrderDraftDbTable orderDraftDbTable, OrderDraftItemDbTable orderDraftItemDbTable,
ExternalUserPartnerMapDbTable externalUserPartnerMapDbTable, ProductTextMappingDbTable productTextMappingDbTable,
IProductService productService, IStaticCacheManager staticCacheManager,
IRepository<Order> orderRepository,
IRepository<OrderItem> orderItemRepository,
@ -135,6 +142,11 @@ public class FruitBankDbContext : MgDbContextBase,
StockQuantityHistoriesExt = stockQuantityHistoriesExt;
StockQuantityHistoryDtos = stockQuantityHistoryDtos;
CustomerCredits = customerCreditDbTable;
OrderDrafts = orderDraftDbTable;
OrderDraftItems = orderDraftItemDbTable;
ExternalUserPartnerMaps = externalUserPartnerMapDbTable;
ProductTextMappings = productTextMappingDbTable;
}
public IQueryable<Customer> GetCustomersBySystemRoleName(string systemRoleName)

View File

@ -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));
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -25,5 +25,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
/// that delivery date, and its document processing will be the correct trigger.
/// </summary>
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";
}
}

View File

@ -352,6 +352,32 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus", "Fizet\u00e9si \u00e1llapot", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "Total", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "\u00d6sszesen", hu);
// ── Customer attributes widget (generic attributes block) ───────────
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title", "Customer attributes", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title", "Ügyfél jellemzők", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer", "EKÁER required", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer", "Ekáer köteles", hu);
// ── Customer sites (telephelyek) ────────────────────────────────────
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Title", "Sites", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Title", "Telephelyek", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Edit", "Edit sites", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.Edit", "Telephelyek szerkesztése", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord", "site(s)", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.CountWord", "telephely", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel", "Default", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.DefaultLabel", "Alapértelmezett", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel", "Site", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.IsSiteLabel", "Telephely", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel", "Address", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.AddressLabel", "Cím", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.None", "No sites set", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.None", "Nincs telephely megadva", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle", "Select sites", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.ModalTitle", "Telephelyek kiválasztása", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses", "This customer has no addresses yet (Addresses tab).", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerSites.NoAddresses", "Ehhez az ügyfélhez még nincs cím rögzítve a Címek fülön.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked", "Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked", "A rendel\u00e9st nem lehet leadni, mert a kintlév\u0151 egyenlege el\u00e9rte a hitelkeret\u00e9t. K\u00e9rj\u00fck, el\u0151sz\u00f6r rendezze meglév\u0151 tartoz\u00e1s\u00e1t.", hu);

View File

@ -67,6 +67,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
/// Z.ai GLM-OCR modell neve (default: "glm-ocr").
/// </summary>
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;
}
}

View File

@ -103,6 +103,14 @@ public class PluginNopStartup : INopStartup
services.AddScoped<PreOrderItemDbTable>();
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<FruitBankDbContext>();
@ -229,7 +237,7 @@ public class PluginNopStartup : INopStartup
})
.AddAcBinaryProtocol(opts =>
{
opts.ProtocolMode = BinaryProtocolMode.AsyncSegment;
opts.ProtocolMode = BinaryProtocolMode.Bytes;
opts.FlushPolicy = FlushPolicy.Coalesced;
// Explicit AcLogger instance (name-based category, matches the previous setup).

View File

@ -378,6 +378,48 @@ public class RouteProvider : IRouteProvider
name: "Plugin.FruitBank.QuickOrder.GetDeliveryDateTime",
pattern: "gyors-rendeles/szallitas-idopont-lekerdezes",
defaults: new { controller = "QuickOrder", action = "GetDeliveryDateTime" });
// ── API: OrderDraft (külső rendszerek: Viber, email, diktálás) ─────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Api.OrderDraft.Create",
pattern: "api/order-draft",
defaults: new { controller = "OrderDraftApi", action = "Create" });
// ── Admin: OrderDraft ─────────────────────────────────────────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.OrderDraft.List",
pattern: "Admin/OrderDraft",
defaults: new { controller = "OrderDraftAdmin", action = "List", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.OrderDraft.OrderDraftList",
pattern: "Admin/OrderDraft/OrderDraftList",
defaults: new { controller = "OrderDraftAdmin", action = "OrderDraftList", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.OrderDraft.Detail",
pattern: "Admin/OrderDraft/Detail/{id:int}",
defaults: new { controller = "OrderDraftAdmin", action = "Detail", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.OrderDraft.UpdateItem",
pattern: "Admin/OrderDraft/UpdateItem",
defaults: new { controller = "OrderDraftAdmin", action = "UpdateItem", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.OrderDraft.AssignCustomer",
pattern: "Admin/OrderDraft/AssignCustomer",
defaults: new { controller = "OrderDraftAdmin", action = "AssignCustomer", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.OrderDraft.Approve",
pattern: "Admin/OrderDraft/Approve",
defaults: new { controller = "OrderDraftAdmin", action = "Approve", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.OrderDraft.Reject",
pattern: "Admin/OrderDraft/Reject",
defaults: new { controller = "OrderDraftAdmin", action = "Reject", area = AreaNames.ADMIN });
}
/// <summary>

View File

@ -81,4 +81,41 @@
<Value><![CDATA[Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.]]></Value>
</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>
</Language>

View File

@ -81,4 +81,41 @@
<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>
<!-- Ü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>
</Language>

View File

@ -18,4 +18,26 @@ public record CustomerCreditWidgetModel : BaseNopModel
public decimal? RemainingCredit { get; set; }
public string? Comment { get; set; }
// ── Generic attributes block (rendered above the credit card) ──────────────
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerAttributes.IsEkaer")]
public bool IsEkaer { get; set; }
// ── Sites (telephelyek) ────────────────────────────────────────────────────
/// <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; }
}
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; }
}

View File

@ -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; }
}

View File

@ -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; } = [];
}

View File

@ -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; } = [];
}

View File

@ -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
});
}
}

View File

@ -27,7 +27,8 @@ public partial class PreOrderConversionService
public async Task ReplaceShippingItemProductAsync(
int shippingItemId,
int newProductId,
ShippingItem oldItem)
ShippingItem oldItem,
bool skipIncomingSync = false)
{
if (oldItem.ProductId == null || oldItem.ProductId.Value == newProductId) return;
@ -51,11 +52,15 @@ public partial class PreOrderConversionService
// ── Stock / IncomingQuantity swap ─────────────────────────────────────
if (!isMeasured)
{
if (!skipIncomingSync)
{
// Item not yet on the truck — only IncomingQuantity moves
// (skipped if already done before UpdateShippingItemSafeAsync to avoid race)
await SyncIncomingQuantityAsync(oldProductId, qty, 0);
await SyncIncomingQuantityAsync(newProductId, 0, qty);
}
}
else
{
// Item already measured/received into stock — adjust actual stock

View File

@ -45,6 +45,13 @@ public partial class PreOrderConversionService
private readonly FruitBankOrderItemService _orderItemService;
private readonly IStoreContext _storeContext;
// Per-preorder async locks — static so they're shared across all scoped instances
private static readonly System.Collections.Concurrent.ConcurrentDictionary<int, SemaphoreSlim>
_preorderLocks = new();
private static SemaphoreSlim GetPreorderLock(int preorderId) =>
_preorderLocks.GetOrAdd(preorderId, _ => new SemaphoreSlim(1, 1));
public PreOrderConversionService(
PreOrderDbContext preorderDbContext,
FruitBankDbContext dbContext,
@ -183,20 +190,38 @@ public partial class PreOrderConversionService
.Where(i => i.Status == PreOrderItemStatus.Dropped)
.ToList();
if (preorder.OrderId == null)
// Acquire a per-preorder lock BEFORE checking OrderId.
// Without this, 3 concurrent conversions (one per shipping item event)
// all read OrderId=null and each creates a separate order.
// Re-reading inside the lock ensures only one creates the order.
var sem = GetPreorderLock(preorderId);
await sem.WaitAsync();
try
{
// First time any items are resolved → create the order
if (newlyFulfilled.Any() || newlyDropped.Any())
var preorderLocked = await _preorderDbContext.PreOrders.GetByIdAsync(preorderId);
if (preorderLocked == null) continue;
var newlyFulfilled2 = changedItems
.Where(i => i.FulfilledQuantity > 0 &&
(i.Status == PreOrderItemStatus.Fulfilled ||
i.Status == PreOrderItemStatus.PartiallyFulfilled))
.ToList();
var newlyDropped2 = changedItems
.Where(i => i.Status == PreOrderItemStatus.Dropped)
.ToList();
if (preorderLocked.OrderId == null)
{
await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId);
}
if (newlyFulfilled2.Any() || newlyDropped2.Any())
await CreateOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId);
}
else
{
// Order already exists from a previous document → append new items only
await AppendItemsToOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId);
await AppendItemsToOrderAsync(preorderLocked, newlyFulfilled2, newlyDropped2, shippingDocumentId);
}
}
finally { sem.Release(); }
}
Console.WriteLine($"[PreOrderConversion] Done. {newlyResolvedByPreOrder.Count} preorders affected.");
}
@ -365,6 +390,20 @@ public partial class PreOrderConversionService
});
await InsertOrderItemsAsync(order, fulfilledItems);
// Recalculate header totals from the actual inserted order items rather than the
// PreOrderItem snapshot price: that snapshot is 0 for OrderDraft-origin preorders
// (priced at conversion) and may be stale for others (price drift since placement),
// so the order items are the authoritative source — same approach as AppendItemsToOrderAsync.
var insertedItems = await _dbContext.OrderItems.Table
.Where(oi => oi.OrderId == order.Id)
.ToListAsync();
var recalculatedTotal = insertedItems.Sum(oi => oi.PriceInclTax);
order.OrderSubtotalInclTax = recalculatedTotal;
order.OrderSubtotalExclTax = Math.Round(recalculatedTotal / 1.27m, 2);
order.OrderTotal = recalculatedTotal;
await _dbContext.Orders.UpdateAsync(order);
await InsertOrderNoteAsync(order.Id, preorder.Id, shippingDocumentId, fulfilledItems, droppedItems);
// Fire event so existing handlers (EventConsumer etc.) run

View File

@ -1,4 +1,6 @@
@model Nop.Plugin.Misc.FruitBankPlugin.Models.CustomerCreditWidgetModel
@inject Nop.Services.Common.IGenericAttributeService genericAttributeService
@inject Nop.Core.IWorkContext workContext
@{
var remaining = Model.RemainingCredit;
@ -6,12 +8,91 @@
: remaining <= 0 ? "text-danger"
: remaining < Model.CreditLimit * 0.2m ? "text-warning"
: "text-success";
// Per-admin collapse state, persisted like the core nopCommerce cards
var admin = await workContext.GetCurrentCustomerAsync();
const string hideAttributesCardName = "FruitBank.CustomerAttributesCard.Hide";
var hideAttributesCard = await genericAttributeService.GetAttributeAsync<bool>(admin, hideAttributesCardName);
const string hideCreditCardName = "FruitBank.CustomerCreditCard.Hide";
var hideCreditCard = await genericAttributeService.GetAttributeAsync<bool>(admin, hideCreditCardName);
}
<div class="card card-default">
<div class="card-header">
@* FruitBank customer-top section: injected into the CustomerDetailsBlock widget zone
(which renders at the BOTTOM of the customer page) and relocated to the top of
#customer-cards by the script below. Future non-credit elements go inside this
same wrapper so the single relocation keeps them all together at the top. *@
<div id="fruitbank-customer-top">
@* ── Customer attributes (generic attributes block) ───────────────────────── *@
<div class="card card-primary card-outline @(hideAttributesCard ? "collapsed-card" : "")" id="fruitbank-customer-attributes-card">
<div class="card-header with-border">
<h3 class="card-title">
<i class="fas fa-id-card"></i>
@T("Plugins.Misc.FruitBankPlugin.CustomerAttributes.Title")
</h3>
<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 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="col-md-3">
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerSites.Title")</label>
</div>
<div class="col-md-9">
<span class="form-control-plaintext" id="fruitbank-sites-summary">
@if (Model.SiteCount > 0)
{
<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
{
<span class="text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerSites.None")</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>
@* ── Customer credit ──────────────────────────────────────────────────────── *@
<div class="card card-primary card-outline @(hideCreditCard ? "collapsed-card" : "")" id="fruitbank-customer-credit-card">
<div class="card-header with-border">
<h3 class="card-title">
<i class="fas fa-credit-card"></i>
@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 class="card-body">
@ -80,4 +161,174 @@
</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">&times;</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>
<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);
}
});
});
});
</script>

View File

@ -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`.

View File

@ -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/` |

View File

@ -7,4 +7,21 @@ Scope: bugs / inconsistent or observable edge-case behaviour in the pre-order su
## Active entries
*(None yet — to be filled from the code review as needed.)*
## MGFBANKPLUG-PREO-B-K7M2: A konverziós pool dupla-számolja a már teljesített foglalásokat — alul-allokáció
**Status:** Open (2026-06-15) · **Priority:** P2 · **Type:** B (megerősített bug)
`PreOrderConversionService.BuildIncomingQuantityPoolAsync` ([`Services/PreorderConversionService.cs`](../../Services/PreorderConversionService.cs)) a rendelkezésre álló mennyiségből **kétszer** vonja le a korábbi futásokban már teljesített (`Fulfilled`/`PartiallyFulfilled`) preorder-tételeket.
- `AvailableQuantity = StockQuantity + IncomingQuantity` (SCHEMA.md), az élő `Product.StockQuantity`-ből.
- Teljesítéskor `InsertOrderItemsAsync``AdjustInventoryAsync(-FulfilledQuantity)` **már csökkenti** a `StockQuantity`-t, így a következő futás `AvailableQuantity`-je **már tartalmazza** a foglalást.
- A pool ezen felül **még levonja** az `alreadyAllocated`-et (a `Fulfilled`/`PartiallyFulfilled` itemek `FulfilledQuantity` összegét) → **futások közötti dupla-levonás**.
**Reprodukció:** `Stock=0, Incoming=10`. „A" preorder 6-ot teljesít (run 1) → `Stock=-6`, allocated=6. „B" preorder 6-ot kér (run 2): `available = -6+10 = 4`, `committed = 6``pool = max(0, 4-6) = 0`. **B nem kap semmit, pedig fizikailag 4 jár neki.** Az alul-allokáció a „még nincs beérkezve" eset — az előrendelés tipikus forgatókönyve.
**Miért nem puszta törlés a fix:** a `CreateOrderAsync` korai `return`-jei (nincs `Customer` / nincs számlázási cím, [sorok ~300-319](../../Services/PreorderConversionService.cs)) úgy lépnek ki, hogy az item a fő ciklusban (sor ~153) **már `Fulfilled`-re lett állítva és elmentve**, de a készletlevonás nem futott le. Ezeken a hibautakon az `alreadyAllocated` levonás *helyesen* kompenzál; a levonás puszta törlése **túl-allokációt (overselling)** okozna.
**Javítási irány (ajánlott):** az item csak akkor legyen `Fulfilled`, ha a rendelés + készletlevonás ténylegesen sikerült (korai return esetén maradjon `Pending`/visszaállítás), majd ezután eltávolítható a dupláló `alreadyAllocated` levonás. Így megszűnik az alul-allokáció túl-allokációs kockázat nélkül.
**Affected:**
- `Services/PreorderConversionService.cs``BuildIncomingQuantityPoolAsync` (a `alreadyAllocated` levonás), `ConvertPreOrdersForProductsAsync` fő ciklusa (státusz-mutáció sorrendje), `CreateOrderAsync` korai return ágai.

View File

@ -14,6 +14,7 @@ Topic documentation for the FruitBank-specific NopCommerce plugin (Layer 3 — c
- [`SIGNALR/`](SIGNALR/README.md) — SignalR endpoints exposed by this plugin (project-specific variant)
- [`PREORDER/`](PREORDER/README.md) — Customer pre-order workflow: advance orders converted to real orders on incoming stock (+ `PREORDER_ISSUES.md` / `PREORDER_TODO.md`)
- [`ORDERDRAFT/`](ORDERDRAFT/README.md) — AI-assisted intake: free-text messages parsed into order drafts, admin-approved into pre-orders (+ `ORDERDRAFT_ISSUES.md`)
## Navigation