OrderDraft, bugfixes, location, license plate in progress
This commit is contained in:
parent
1b60684f85
commit
9209b26386
|
|
@ -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/` |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
using FruitBank.Common.Enums;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
|
||||
public class OrderDraftListModel
|
||||
{
|
||||
public string StatusFilter { get; set; } = "Pending";
|
||||
}
|
||||
|
||||
public class OrderDraftListRow
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string ExternalUsername { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public int? CustomerId { get; set; }
|
||||
public string RawMessageTextShort { get; set; } = string.Empty;
|
||||
public string ParsedDeliveryDate { get; set; } = string.Empty;
|
||||
public int ItemCount { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string StatusBadgeClass { get; set; } = string.Empty;
|
||||
public bool IsUrgent { get; set; } // Pending és > 20 óra régi
|
||||
public string CreatedOnUtc { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class OrderDraftDetailModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string ExternalUsername { get; set; } = string.Empty;
|
||||
public int? CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string RawMessageText { get; set; } = string.Empty;
|
||||
public DateTime ParsedDeliveryDate { get; set; }
|
||||
public bool IsPreorder { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string AdminNote { get; set; } = string.Empty;
|
||||
public string CreatedOnUtc { get; set; } = string.Empty;
|
||||
public bool CanApprove { get; set; }
|
||||
public List<OrderDraftItemModel> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
public class OrderDraftItemModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string RawProductText { get; set; } = string.Empty;
|
||||
public string AiResolvedProductName{ get; set; } = string.Empty;
|
||||
public int RequestedQuantity { get; set; }
|
||||
public bool IsPreorder { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public int? SelectedProductId { get; set; }
|
||||
public string SelectedProductName { get; set; } = string.Empty;
|
||||
public List<OrderDraftProductCandidate> Candidates { get; set; } = [];
|
||||
}
|
||||
|
||||
public class OrderDraftProductCandidate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Stock { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -23,7 +23,8 @@ public class PreOrderDetailModel
|
|||
public int CustomerId { get; set; }
|
||||
public 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; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,377 @@
|
|||
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.OrderDraftDetailModel
|
||||
@{
|
||||
ViewBag.PageTitle = "Rendelés tervezet #" + Model.Id;
|
||||
}
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="float-left">
|
||||
<i class="fas fa-inbox" style="color:#f4a236"></i>
|
||||
Rendelés tervezet <strong>#@Model.Id</strong>
|
||||
<span class="badge @(Model.Status == "Pending" ? "badge-warning" : Model.Status == "Approved" ? "badge-success" : "badge-danger")"
|
||||
style="font-size:14px; vertical-align:middle">@Model.Status</span>
|
||||
</h1>
|
||||
<div class="float-right">
|
||||
<a href="/Admin/OrderDraft" class="btn btn-default">
|
||||
<i class="fas fa-arrow-left"></i> Vissza a listára
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="row">
|
||||
|
||||
{{-- BAL OLDAL: meta + üzenet --}}
|
||||
<div class="col-md-4">
|
||||
|
||||
{{-- Eredeti üzenet --}}
|
||||
<div class="card card-default">
|
||||
<div class="card-header" style="background:#1a3c22; color:#fff">
|
||||
<i class="fas fa-envelope"></i> Eredeti üzenet
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre style="white-space:pre-wrap; font-size:13px; background:#f5f7f2; border-radius:6px; padding:12px; border:1px solid #ddd">@Model.RawMessageText</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Meta adatok --}}
|
||||
<div class="card card-default">
|
||||
<div class="card-header"><i class="fas fa-info-circle"></i> Adatok</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm m-0">
|
||||
<tr>
|
||||
<td class="text-muted" style="width:40%">Feladó</td>
|
||||
<td><code>@Model.ExternalUsername</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Partner</td>
|
||||
<td id="partner-cell">
|
||||
@if (Model.CustomerId.HasValue)
|
||||
{
|
||||
<a href="/Admin/Customer/Edit/@Model.CustomerId" target="_blank">@Model.CustomerName</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> Nincs hozzárendelve</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Szállítás</td>
|
||||
<td>
|
||||
<strong>@Model.ParsedDeliveryDate.ToString("yyyy-MM-dd")</strong>
|
||||
@if (Model.IsPreorder)
|
||||
{
|
||||
<span class="badge badge-info ml-1">Előrendelés</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge badge-warning ml-1">Azonnali</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">Beérkezett</td>
|
||||
<td>@Model.CreatedOnUtc</td>
|
||||
</tr>
|
||||
@if (!string.IsNullOrEmpty(Model.AdminNote))
|
||||
{
|
||||
<tr>
|
||||
<td class="text-muted">Megjegyzés</td>
|
||||
<td class="text-danger">@Model.AdminNote</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Partner hozzárendelés (csak ha nincs még) --}}
|
||||
@if (!Model.CustomerId.HasValue && Model.CanApprove)
|
||||
{
|
||||
<div class="card card-warning">
|
||||
<div class="card-header"><i class="fas fa-user-plus"></i> Partner hozzárendelése</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted" style="font-size:13px">
|
||||
Ismeretlen feladó: <code>@Model.ExternalUsername</code><br>
|
||||
Rendeld hozzá a megfelelő partnerhez. A rendszer megjegyzi a kapcsolatot.
|
||||
</p>
|
||||
<input type="text" id="customer-search" class="form-control form-control-sm"
|
||||
placeholder="Ügyfél neve vagy e-mail..." autocomplete="off" />
|
||||
<input type="hidden" id="selected-customer-id" />
|
||||
<div id="customer-search-results" class="list-group mt-1" style="display:none; position:absolute; z-index:999; width:300px"></div>
|
||||
<button type="button" id="btn-assign-customer" class="btn btn-warning btn-sm mt-2" disabled>
|
||||
<i class="fas fa-link"></i> Hozzárendelés
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>{{-- /col-md-4 --}}
|
||||
|
||||
{{-- JOBB OLDAL: tételek --}}
|
||||
<div class="col-md-8">
|
||||
<div class="card card-default">
|
||||
<div class="card-header" style="background:#2d7a3a; color:#fff">
|
||||
<i class="fas fa-list"></i> Tételek
|
||||
<span class="badge badge-light float-right" style="color:#333">@Model.Items.Count db</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-bordered m-0" id="items-table">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th style="width:200px">Eredeti szöveg</th>
|
||||
<th>AI javaslat / Termék kiválasztása</th>
|
||||
<th style="width:90px">Menny. (kt.)</th>
|
||||
<th style="width:80px">Típus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
<tr data-item-id="@item.Id">
|
||||
<td>
|
||||
<strong style="color:#1a3c22">@item.RawProductText</strong>
|
||||
@if (!string.IsNullOrEmpty(item.AiResolvedProductName) && item.AiResolvedProductName != item.RawProductText)
|
||||
{
|
||||
<br><small class="text-muted">→ @item.AiResolvedProductName</small>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-control form-control-sm product-select"
|
||||
data-item-id="@item.Id"
|
||||
@(Model.CanApprove ? "" : "disabled")>
|
||||
<option value="">— válassz terméket —</option>
|
||||
@foreach (var c in item.Candidates)
|
||||
{
|
||||
var selected = c.Id == item.SelectedProductId ? "selected" : "";
|
||||
<option value="@c.Id" @selected>
|
||||
@c.Name (készlet: @c.Stock)
|
||||
</option>
|
||||
}
|
||||
@if (item.SelectedProductId.HasValue && !item.Candidates.Any(c => c.Id == item.SelectedProductId))
|
||||
{
|
||||
<option value="@item.SelectedProductId" selected>@item.SelectedProductName</option>
|
||||
}
|
||||
</select>
|
||||
<div class="save-indicator text-success" style="display:none; font-size:11px; margin-top:2px">
|
||||
<i class="fas fa-check"></i> Mentve
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm qty-input"
|
||||
value="@item.RequestedQuantity" min="1" step="1"
|
||||
data-item-id="@item.Id"
|
||||
@(Model.CanApprove ? "" : "disabled") />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (item.IsPreorder)
|
||||
{
|
||||
<span class="badge badge-info">Elő</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge badge-warning">Azonnali</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Akció gombok --}}
|
||||
@if (Model.CanApprove)
|
||||
{
|
||||
<div class="card card-default">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<button type="button" id="btn-approve" class="btn btn-success btn-lg">
|
||||
<i class="fas fa-check-circle"></i> Jóváhagyás és rendelés/előrendelés létrehozása
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-4 text-right">
|
||||
<button type="button" id="btn-reject" class="btn btn-danger">
|
||||
<i class="fas fa-times-circle"></i> Elutasítás
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="approve-result" class="mt-3" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>{{-- /col-md-8 --}}
|
||||
|
||||
</div>{{-- /row --}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.badge-info { background: #17a2b8; color:#fff; }
|
||||
.badge-warning { background: #f4a236; color:#fff; }
|
||||
.badge-success { background: #2d7a3a; color:#fff; }
|
||||
.badge-danger { background: #c0392b; color:#fff; }
|
||||
.badge-light { background: #eee; }
|
||||
#items-table select { min-width: 200px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
var _token = $('input[name="__RequestVerificationToken"]').val();
|
||||
var _draftId = @Model.Id;
|
||||
|
||||
// ── Termék kiválasztás mentése blur/change-re ─────────────────────────
|
||||
var _saveTimer = {};
|
||||
|
||||
function saveItem(itemId) {
|
||||
var $row = $('tr[data-item-id="' + itemId + '"]');
|
||||
var prodId = $row.find('.product-select').val();
|
||||
var qty = parseInt($row.find('.qty-input').val()) || 1;
|
||||
var $ind = $row.find('.save-indicator');
|
||||
|
||||
if (!prodId) return;
|
||||
|
||||
clearTimeout(_saveTimer[itemId]);
|
||||
_saveTimer[itemId] = setTimeout(function() {
|
||||
$.post('/Admin/OrderDraft/UpdateItem', {
|
||||
__RequestVerificationToken : _token,
|
||||
draftItemId : itemId,
|
||||
selectedProductId: prodId,
|
||||
quantity : qty
|
||||
}).done(function(res) {
|
||||
if (res.success) {
|
||||
$ind.fadeIn(200).delay(1500).fadeOut(400);
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
$(document).on('change', '.product-select', function() {
|
||||
saveItem($(this).data('item-id'));
|
||||
});
|
||||
$(document).on('change', '.qty-input', function() {
|
||||
saveItem($(this).data('item-id'));
|
||||
});
|
||||
|
||||
// ── Partner keresés ──────────────────────────────────────────────────
|
||||
var _searchTimer;
|
||||
$('#customer-search').on('input', function() {
|
||||
var q = $(this).val().trim();
|
||||
if (q.length < 2) { $('#customer-search-results').hide(); return; }
|
||||
clearTimeout(_searchTimer);
|
||||
_searchTimer = setTimeout(function() {
|
||||
$.get('/Admin/CustomOrder/CustomerSearchAutoComplete', { term: q }, function(data) {
|
||||
var $res = $('#customer-search-results').empty().show();
|
||||
if (!data || data.length === 0) {
|
||||
$res.append('<a class="list-group-item list-group-item-action text-muted">Nincs találat</a>');
|
||||
return;
|
||||
}
|
||||
$.each(data, function(i, item) {
|
||||
$res.append(
|
||||
$('<a class="list-group-item list-group-item-action" href="#">')
|
||||
.text(item.label)
|
||||
.on('click', function(e) {
|
||||
e.preventDefault();
|
||||
$('#customer-search').val(item.label);
|
||||
$('#selected-customer-id').val(item.value);
|
||||
$('#btn-assign-customer').prop('disabled', false);
|
||||
$res.hide();
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Partner hozzárendelés
|
||||
$('#btn-assign-customer').on('click', function() {
|
||||
var custId = $('#selected-customer-id').val();
|
||||
if (!custId) return;
|
||||
$.post('/Admin/OrderDraft/AssignCustomer', {
|
||||
__RequestVerificationToken: _token,
|
||||
draftId : _draftId,
|
||||
customerId : custId
|
||||
}).done(function(res) {
|
||||
if (res.success) {
|
||||
$('#partner-cell').html('<a href="/Admin/Customer/Edit/' + custId + '" target="_blank">' + res.customerName + '</a>');
|
||||
$('.card-warning').slideUp();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Elutasítás ───────────────────────────────────────────────────────
|
||||
$('#btn-reject').on('click', function() {
|
||||
var note = prompt('Opcionális megjegyzés az elutasítás okáról:');
|
||||
if (note === null) return; // Cancel
|
||||
$.post('/Admin/OrderDraft/Reject', {
|
||||
__RequestVerificationToken: _token,
|
||||
draftId : _draftId,
|
||||
adminNote: note || ''
|
||||
}).done(function(res) {
|
||||
if (res.success) location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Jóváhagyás ──────────────────────────────────────────────────────
|
||||
$('#btn-approve').on('click', function() {
|
||||
var $btn = $(this);
|
||||
|
||||
// Ellenőrzés: minden tétel ki van töltve?
|
||||
var missingCount = 0;
|
||||
$('.product-select').each(function() {
|
||||
if (!$(this).val()) missingCount++;
|
||||
});
|
||||
if (missingCount > 0) {
|
||||
showResult('danger', missingCount + ' tétel nincs kitöltve. Minden sorban válassz terméket a jóváhagyás előtt.');
|
||||
return;
|
||||
}
|
||||
|
||||
$btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Feldolgozás...');
|
||||
|
||||
$.post('/Admin/OrderDraft/Approve', {
|
||||
__RequestVerificationToken: _token,
|
||||
draftId: _draftId
|
||||
}).done(function(res) {
|
||||
if (res.success) {
|
||||
var lines = ['<strong><i class="fas fa-check-circle"></i> Sikeresen létrehozva!</strong><br>'];
|
||||
if (res.orderIds && res.orderIds.length > 0) {
|
||||
lines.push('Rendelés(ek): ' + res.orderIds.map(function(id) {
|
||||
return '<a href="/Admin/Order/Edit/' + id + '" target="_blank">#' + id + '</a>';
|
||||
}).join(', '));
|
||||
}
|
||||
if (res.preOrderIds && res.preOrderIds.length > 0) {
|
||||
lines.push('Előrendelés(ek): ' + res.preOrderIds.map(function(id) {
|
||||
return '<a href="/Admin/PreOrders/Detail/' + id + '" target="_blank">#' + id + '</a>';
|
||||
}).join(', '));
|
||||
}
|
||||
showResult('success', lines.join('<br>'));
|
||||
setTimeout(function() { location.reload(); }, 2500);
|
||||
} else {
|
||||
showResult('danger', res.error || 'Ismeretlen hiba.');
|
||||
$btn.prop('disabled', false).html('<i class="fas fa-check-circle"></i> Jóváhagyás és rendelés/előrendelés létrehozása');
|
||||
}
|
||||
}).fail(function() {
|
||||
showResult('danger', 'Szerverhiba. Próbáld újra.');
|
||||
$btn.prop('disabled', false).html('<i class="fas fa-check-circle"></i> Jóváhagyás és rendelés/előrendelés létrehozása');
|
||||
});
|
||||
});
|
||||
|
||||
function showResult(type, html) {
|
||||
$('#approve-result')
|
||||
.removeClass('alert-success alert-danger')
|
||||
.addClass('alert alert-' + type)
|
||||
.html(html)
|
||||
.show();
|
||||
}
|
||||
|
||||
// Kattintás máshova: customer search bezárás
|
||||
$(document).on('click', function(e) {
|
||||
if (!$(e.target).closest('#customer-search, #customer-search-results').length)
|
||||
$('#customer-search-results').hide();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.OrderDraftListModel
|
||||
@{
|
||||
ViewBag.PageTitle = "Rendelés tervezetek";
|
||||
NopHtml.SetActiveMenuItemSystemName("OrderDraft.List");
|
||||
}
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="float-left">
|
||||
<i class="fas fa-inbox" style="color:#f4a236"></i>
|
||||
Rendelés tervezetek
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
|
||||
{{-- Státusz szűrő --}}
|
||||
<div class="card card-default mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-warning btn-sm status-filter active" data-status="Pending">
|
||||
<i class="fas fa-clock"></i> Függőben
|
||||
</button>
|
||||
<button type="button" class="btn btn-success btn-sm status-filter" data-status="Approved">
|
||||
<i class="fas fa-check"></i> Jóváhagyva
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger btn-sm status-filter" data-status="Rejected">
|
||||
<i class="fas fa-times"></i> Elutasítva
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm status-filter" data-status="Expired">
|
||||
<i class="fas fa-hourglass-end"></i> Lejárt
|
||||
</button>
|
||||
<button type="button" class="btn btn-light btn-sm status-filter" data-status="">
|
||||
<i class="fas fa-list"></i> Összes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-default">
|
||||
<div class="card-body p-0">
|
||||
@Html.AntiForgeryToken()
|
||||
<table id="od-grid" class="table table-bordered table-hover m-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Feladó</th>
|
||||
<th>Partner</th>
|
||||
<th>Üzenet (részlet)</th>
|
||||
<th>Szállítás</th>
|
||||
<th>Tételek</th>
|
||||
<th>Státusz</th>
|
||||
<th>Beérkezett</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#od-grid tbody tr.od-urgent td { background-color: #fff3cd !important; }
|
||||
#od-grid tbody tr:hover { background-color: #eaf2ff !important; }
|
||||
.badge { font-size: 12px; padding: 4px 8px; }
|
||||
.badge-warning { background: #f4a236; color: #fff; }
|
||||
.badge-success { background: #2d7a3a; color: #fff; }
|
||||
.badge-danger { background: #c0392b; color: #fff; }
|
||||
.badge-secondary{ background: #888; color: #fff; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
var _token = $('input[name="__RequestVerificationToken"]').val();
|
||||
var _statusFilter = 'Pending';
|
||||
|
||||
var table = $('#od-grid').DataTable({
|
||||
serverSide : true,
|
||||
processing : true,
|
||||
pageLength : 25,
|
||||
lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]],
|
||||
order : [[7, 'desc']],
|
||||
language : {
|
||||
processing : 'Betöltés...',
|
||||
search : 'Keresés:',
|
||||
lengthMenu : '_MENU_ sor/oldal',
|
||||
info : '_START_ – _END_ / _TOTAL_ tervezet',
|
||||
infoEmpty : '0 tervezet',
|
||||
paginate : { first:'««', previous:'«', next:'»', last:'»»' },
|
||||
emptyTable : 'Nincs találat',
|
||||
zeroRecords: 'Nincs találat'
|
||||
},
|
||||
ajax: {
|
||||
url : '/Admin/OrderDraft/OrderDraftList',
|
||||
type: 'POST',
|
||||
data: function (d) {
|
||||
d.__RequestVerificationToken = _token;
|
||||
d.statusFilter = _statusFilter;
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
/* 0 */ { data: 'Id', width: '50px', className: 'text-center' },
|
||||
/* 1 */ { data: 'ExternalUsername', render: function(d) { return '<code>' + d + '</code>'; } },
|
||||
/* 2 */ { data: 'CustomerName', render: function(d, t, row) {
|
||||
if (!row.CustomerId) return '<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> ' + d + '</span>';
|
||||
return '<a href="/Admin/Customer/Edit/' + row.CustomerId + '" target="_blank">' + d + '</a>';
|
||||
}},
|
||||
/* 3 */ { data: 'RawMessageTextShort', orderable: false,
|
||||
render: function(d) { return '<span class="text-muted" style="font-size:12px">' + d + '</span>'; } },
|
||||
/* 4 */ { data: 'ParsedDeliveryDate', width: '100px', className: 'text-center' },
|
||||
/* 5 */ { data: 'ItemCount', width: '70px', className: 'text-center',
|
||||
render: function(d) { return '<span class="badge badge-info">' + d + '</span>'; } },
|
||||
/* 6 */ { data: 'Status', width: '110px', className: 'text-center',
|
||||
render: function(d, t, row) {
|
||||
return '<span class="badge ' + row.StatusBadgeClass + '">' + d + '</span>';
|
||||
}},
|
||||
/* 7 */ { data: 'CreatedOnUtc', width: '130px', className: 'text-center' },
|
||||
/* 8 */ { data: 'Id', orderable: false, searchable: false, width: '80px', className: 'text-center',
|
||||
render: function(d) {
|
||||
return '<a href="/Admin/OrderDraft/Detail/' + d + '" class="btn btn-primary btn-xs"><i class="fas fa-eye"></i> Megnyit</a>';
|
||||
}}
|
||||
],
|
||||
createdRow: function(row, data) {
|
||||
if (data.IsUrgent) $(row).addClass('od-urgent');
|
||||
}
|
||||
});
|
||||
|
||||
// Státusz szűrő gombok
|
||||
$('.status-filter').on('click', function() {
|
||||
$('.status-filter').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
_statusFilter = $(this).data('status');
|
||||
table.ajax.reload();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -12,7 +12,6 @@
|
|||
PreOrderStatus.Cancelled => "po-status-cancelled",
|
||||
_ => "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>×</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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Services.Configuration;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Külső rendszerek (Viber bot, email feldolgozó, diktáló app) hívják ezt az endpointot.
|
||||
/// Authentikáció: X-FruitBank-ApiKey header, értéke megegyezik a FruitBankSettings.OrderDraftApiKey-jel.
|
||||
/// </summary>
|
||||
public class OrderDraftApiController : BasePluginController
|
||||
{
|
||||
private readonly IOrderDraftService _orderDraftService;
|
||||
private readonly FruitBankSettings _settings;
|
||||
|
||||
public OrderDraftApiController(
|
||||
IOrderDraftService orderDraftService,
|
||||
ISettingService settingService)
|
||||
{
|
||||
_orderDraftService = orderDraftService;
|
||||
_settings = settingService.LoadSetting<FruitBankSettings>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/order-draft
|
||||
/// Body: { "externalUsername": "...", "messageText": "..." }
|
||||
/// Header: X-FruitBank-ApiKey: {key}
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] OrderDraftApiRequest request)
|
||||
{
|
||||
// API key validáció
|
||||
if (!ValidateApiKey())
|
||||
return Unauthorized(new { error = "Érvénytelen vagy hiányzó API kulcs." });
|
||||
|
||||
if (request == null
|
||||
|| string.IsNullOrWhiteSpace(request.ExternalUsername)
|
||||
|| string.IsNullOrWhiteSpace(request.MessageText))
|
||||
{
|
||||
return BadRequest(new { error = "ExternalUsername és MessageText megadása kötelező." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var drafts = await _orderDraftService.CreateDraftsFromMessageAsync(
|
||||
request.ExternalUsername.Trim(),
|
||||
request.MessageText.Trim());
|
||||
|
||||
if (!drafts.Any())
|
||||
return Ok(new OrderDraftApiResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Az üzenetből nem sikerült rendelési szándékot azonosítani.",
|
||||
Drafts = []
|
||||
});
|
||||
|
||||
return Ok(new OrderDraftApiResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"{drafts.Count} rendelés tervezet létrehozva.",
|
||||
Drafts = drafts.Select(d => new OrderDraftApiResponseItem
|
||||
{
|
||||
DraftId = d.Id,
|
||||
ParsedDeliveryDate = d.ParsedDeliveryDate,
|
||||
IsPartnerKnown = d.CustomerId.HasValue,
|
||||
ItemCount = d.Items?.Count ?? 0
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OrderDraftApiController] Error: {ex}");
|
||||
return StatusCode(500, new { error = "Belső hiba. Próbáld újra később." });
|
||||
}
|
||||
}
|
||||
|
||||
private bool ValidateApiKey()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.OrderDraftApiKey))
|
||||
return false;
|
||||
|
||||
Request.Headers.TryGetValue("X-FruitBank-ApiKey", out var providedKey);
|
||||
return string.Equals(
|
||||
_settings.OrderDraftApiKey,
|
||||
providedKey.ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request / Response modellek ───────────────────────────────────────────
|
||||
|
||||
public class OrderDraftApiRequest
|
||||
{
|
||||
public string ExternalUsername { get; set; } = string.Empty;
|
||||
public string MessageText { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class OrderDraftApiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public List<OrderDraftApiResponseItem> Drafts { get; set; } = [];
|
||||
}
|
||||
|
||||
public class OrderDraftApiResponseItem
|
||||
{
|
||||
public int DraftId { get; set; }
|
||||
public DateTime ParsedDeliveryDate { get; set; }
|
||||
public bool IsPartnerKnown { get; set; }
|
||||
public int ItemCount { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using LinqToDB;
|
||||
using Mango.Nop.Data.Repositories;
|
||||
using Nop.Core.Caching;
|
||||
using Nop.Core.Configuration;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Data;
|
||||
using Mango.Nop.Core.Loggers;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
public class ExternalUserPartnerMapDbTable : MgDbTableBase<ExternalUserPartnerMapping>
|
||||
{
|
||||
public ExternalUserPartnerMapDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
|
||||
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<ExternalUserPartnerMapping?> GetByUsernameAsync(string externalUsername)
|
||||
=> GetAll().FirstOrDefaultAsync(m => m.ExternalUsername == externalUsername);
|
||||
|
||||
public IQueryable<ExternalUserPartnerMapping> GetByCustomerId(int customerId)
|
||||
=> GetAll().Where(m => m.CustomerId == customerId);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
using LinqToDB;
|
||||
using Mango.Nop.Data.Repositories;
|
||||
using Nop.Core.Caching;
|
||||
using Nop.Core.Configuration;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Data;
|
||||
using Mango.Nop.Core.Loggers;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
public class OrderDraftDbTable : MgDbTableBase<OrderDraft>
|
||||
{
|
||||
public OrderDraftDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
|
||||
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public IQueryable<OrderDraft> GetAll(bool loadRelations)
|
||||
{
|
||||
return loadRelations
|
||||
? GetAll().LoadWith(d => d.Items)
|
||||
: GetAll();
|
||||
}
|
||||
|
||||
public Task<OrderDraft?> GetByIdAsync(int id, bool loadRelations)
|
||||
=> GetAll(loadRelations).FirstOrDefaultAsync(d => d.Id == id);
|
||||
|
||||
public IQueryable<OrderDraft> GetPending()
|
||||
=> GetAll().Where(d => d.Status == OrderDraftStatus.Pending);
|
||||
|
||||
public IQueryable<OrderDraft> GetExpiredCandidates()
|
||||
=> GetAll().Where(d => d.Status == OrderDraftStatus.Pending
|
||||
&& d.CreatedOnUtc <= DateTime.UtcNow.AddDays(-14));
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using LinqToDB;
|
||||
using Mango.Nop.Data.Repositories;
|
||||
using Nop.Core.Caching;
|
||||
using Nop.Core.Configuration;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Data;
|
||||
using Mango.Nop.Core.Loggers;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
public class OrderDraftItemDbTable : MgDbTableBase<OrderDraftItem>
|
||||
{
|
||||
public OrderDraftItemDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
|
||||
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public IQueryable<OrderDraftItem> GetByDraftId(int draftId)
|
||||
=> GetAll().Where(i => i.DraftId == draftId).OrderBy(i => i.SortOrder);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using LinqToDB;
|
||||
using Mango.Nop.Data.Repositories;
|
||||
using Nop.Core.Caching;
|
||||
using Nop.Core.Configuration;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Data;
|
||||
using Mango.Nop.Core.Loggers;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
public class ProductTextMappingDbTable : MgDbTableBase<ProductTextMapping>
|
||||
{
|
||||
public ProductTextMappingDbTable(IEventPublisher eventPublisher, INopDataProvider dataProvider, IShortTermCacheManager shortTermCacheManager, IStaticCacheManager staticCacheManager, AppSettings appSettings)
|
||||
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public IQueryable<ProductTextMapping> GetByRawText(string rawText)
|
||||
=> GetAll().Where(m => m.RawText == rawText.ToLower().Trim());
|
||||
|
||||
public IQueryable<ProductTextMapping> GetByRawText(string rawText, bool isPreorder)
|
||||
=> GetByRawText(rawText).Where(m => m.IsPreorder == isPreorder);
|
||||
|
||||
public IQueryable<ProductTextMapping> GetRecentByRawText(string rawText, bool isPreorder, int lastCount = 5)
|
||||
=> GetByRawText(rawText, isPreorder).OrderByDescending(m => m.CreatedOnUtc).Take(lastCount);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
public interface IOrderDraftService
|
||||
{
|
||||
/// <summary>
|
||||
/// Egy bejövő szöveges üzenet alapján létrehozza a rendelés tervezet(eke)t.
|
||||
/// Ha az üzenetben több különböző szállítási dátum szerepel, minden dátumhoz külön draft jön létre.
|
||||
/// </summary>
|
||||
Task<List<OrderDraft>> CreateDraftsFromMessageAsync(string externalUsername, string rawMessageText);
|
||||
|
||||
/// <summary>Egy draft betöltése tételeivel együtt.</summary>
|
||||
Task<OrderDraft?> GetByIdAsync(int id, bool loadRelations = true);
|
||||
|
||||
/// <summary>Pending draftek listája, opcionálisan szűrve partner szerint.</summary>
|
||||
Task<List<OrderDraft>> GetPendingAsync(int? customerId = null);
|
||||
|
||||
/// <summary>Összes draft lapozhatóan (admin lista).</summary>
|
||||
Task<(List<OrderDraft> Items, int TotalCount)> GetPagedAsync(
|
||||
int pageIndex, int pageSize,
|
||||
OrderDraftStatus? status = null,
|
||||
int? customerId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Admin jóváhagyja a draftot: minden tétel SelectedProductId-ja be kell legyen állítva.
|
||||
/// A dátumtól függően PreOrder vagy Order jön létre.
|
||||
/// Menti a ProductTextMapping rekordokat a tanuláshoz.
|
||||
/// </summary>
|
||||
Task<OrderDraftApproveResult> ApproveAsync(int draftId, int adminId);
|
||||
|
||||
/// <summary>Admin elutasítja a draftot.</summary>
|
||||
Task RejectAsync(int draftId, int adminId, string? adminNote = null);
|
||||
|
||||
/// <summary>Admin hozzárendeli a partnert egy mappingból még hiányzó drafthoz.</summary>
|
||||
Task AssignCustomerAsync(int draftId, int customerId, int adminId);
|
||||
|
||||
/// <summary>Admin frissíti egy tétel kiválasztott termékét és/vagy mennyiségét.</summary>
|
||||
Task UpdateDraftItemAsync(int draftItemId, int selectedProductId, int quantity);
|
||||
|
||||
/// <summary>14 napnál régebbi pending draftek automatikus lejáratása (background job hívja).</summary>
|
||||
Task ExpireOldDraftsAsync();
|
||||
}
|
||||
|
||||
public class OrderDraftApproveResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>A létrehozott PreOrder ID-k (ha előrendelés lett).</summary>
|
||||
public List<int> PreOrderIds { get; set; } = [];
|
||||
|
||||
/// <summary>A létrehozott Order ID-k (ha azonnali rendelés lett).</summary>
|
||||
public List<int> OrderIds { get; set; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Szöveges üzenetet parse-ol: kinyeri a szállítási dátumokat és a termékigényeket.
|
||||
/// Ha egy üzenetben több dátum szerepel, minden dátumhoz külön ParsedDraftGroup-ot ad vissza.
|
||||
/// </summary>
|
||||
public class OrderDraftParserService
|
||||
{
|
||||
private readonly OpenAIApiService _openAiService;
|
||||
|
||||
// Hány napnál távolabbi dátum számít előrendelésnek
|
||||
public const int PreorderThresholdDays = 4;
|
||||
|
||||
public OrderDraftParserService(OpenAIApiService openAiService)
|
||||
{
|
||||
_openAiService = openAiService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Egy raw üzenetből kinyeri a (dátum → termékigények) csoportokat.
|
||||
/// Minden visszaadott csoport egy külön OrderDraft-nak felel meg.
|
||||
/// </summary>
|
||||
public async Task<List<ParsedDraftGroup>> ParseMessageAsync(string rawMessage)
|
||||
{
|
||||
var systemPrompt = """
|
||||
Te egy magyar gyümölcs- és zöldségnagyker rendelési asszisztense vagy.
|
||||
Egy beérkező szöveges üzenetből ki kell nyerned a rendelési szándékokat.
|
||||
|
||||
FELADATOD:
|
||||
1. Azonosítsd az összes szállítási dátumot az üzenetben.
|
||||
2. Csoportosítsd a termékigényeket dátum szerint.
|
||||
3. Ha egy tételhez nincs explicit dátum megadva, rendeld a legközelebbi említett dátumhoz.
|
||||
4. Ha egyáltalán nincs dátum az üzenetben, használd a mai napot + 1 nap értéket.
|
||||
5. A mennyiségnél mindig rekeszben gondolkodj (ha nem egyértelmű, rekesz az alapértelmezett).
|
||||
6. A termék nevét pontosan úgy add vissza, ahogy az üzenetben szerepel (ne fordítsd, ne értelmezd).
|
||||
|
||||
VÁLASZ FORMÁTUM (csak JSON, semmi más):
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"deliveryDate": "YYYY-MM-DD",
|
||||
"items": [
|
||||
{
|
||||
"rawProductText": "az eredeti szöveg ahogy szerepelt",
|
||||
"requestedQuantity": 10,
|
||||
"sortOrder": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
FONTOS:
|
||||
- Csak JSON-t adj vissza, semmilyen magyarázatot, bevezető szöveget vagy markdown blokkot ne írj.
|
||||
- Ha a dátum relatív (pl. "jövő héten", "jövő szerdán", "holnap"), számítsd ki a tényleges dátumot a mai naphoz képest.
|
||||
- Mai dátum:
|
||||
""" + " " + DateTime.Today.ToString("yyyy-MM-dd");
|
||||
|
||||
var userPrompt = $"Üzenet:\n{rawMessage}";
|
||||
|
||||
var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, userPrompt);
|
||||
if (string.IsNullOrWhiteSpace(response))
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
// Tisztítjuk a markdown code fences-t ha mégis beleírta
|
||||
var clean = response
|
||||
.Replace("```json", "")
|
||||
.Replace("```", "")
|
||||
.Trim();
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<ParsedMessageResponse>(clean, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
return parsed?.Groups ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OrderDraftParser] JSON parse error: {ex.Message}\nRaw response: {response}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Egy termék raw szövegéből meghatározza a kanonikus terméknevet és
|
||||
/// a NopCommerce kereséshez használható kulcsszavakat.
|
||||
/// </summary>
|
||||
public async Task<ProductResolutionHint> ResolveProductNameAsync(string rawProductText)
|
||||
{
|
||||
var systemPrompt = """
|
||||
Te egy magyar gyümölcs- és zöldségnagyker asszisztense vagy.
|
||||
Egy vevő által írt termékmegnevezésből kell meghatároznod:
|
||||
1. Mi a termék pontos, kanonikus neve magyarul (pl. "piros kalif" → "Kaliforniai paprika piros")
|
||||
2. Milyen kulcsszavakkal érdemes keresni a termékadatbázisban (pl. ["paprika", "kaliforniai", "piros"])
|
||||
|
||||
VÁLASZ FORMÁTUM (csak JSON):
|
||||
{
|
||||
"canonicalName": "Kaliforniai paprika piros",
|
||||
"searchKeywords": ["paprika", "kaliforniai", "piros"]
|
||||
}
|
||||
|
||||
Csak JSON-t adj vissza.
|
||||
""";
|
||||
|
||||
var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, rawProductText);
|
||||
if (string.IsNullOrWhiteSpace(response))
|
||||
return new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] };
|
||||
|
||||
try
|
||||
{
|
||||
var clean = response.Replace("```json", "").Replace("```", "").Trim();
|
||||
var hint = JsonSerializer.Deserialize<ProductResolutionHint>(clean, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
return hint ?? new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] };
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ProductResolutionHint { CanonicalName = rawProductText, SearchKeywords = [rawProductText] };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Több termék ID-t sorrendbe rendez az AI segítségével a raw szöveg és a terméknevek alapján.
|
||||
/// A historikus mapping által már előre rangsorolt lista végső finomhangolása.
|
||||
/// </summary>
|
||||
public async Task<List<int>> RankCandidatesAsync(
|
||||
string rawProductText,
|
||||
string canonicalName,
|
||||
List<(int Id, string Name)> candidates)
|
||||
{
|
||||
if (candidates.Count <= 1)
|
||||
return candidates.Select(c => c.Id).ToList();
|
||||
|
||||
var candidateList = candidates
|
||||
.Select((c, i) => $"{i + 1}. ID={c.Id} Név=\"{c.Name}\"")
|
||||
.Aggregate(new StringBuilder(), (sb, s) => sb.AppendLine(s))
|
||||
.ToString();
|
||||
|
||||
var systemPrompt = """
|
||||
Te egy magyar gyümölcs- és zöldségnagyker asszisztense vagy.
|
||||
Rendezd sorrendbe a termékkandidátusokat aszerint, hogy melyik felel meg legjobban a keresett terméknek.
|
||||
Az első legyen a legjobb találat.
|
||||
|
||||
VÁLASZ FORMÁTUM (csak JSON, semmi más):
|
||||
{ "rankedIds": [123, 456, 789] }
|
||||
""";
|
||||
|
||||
var userPrompt = $"""
|
||||
Keresett termék (eredeti szöveg): "{rawProductText}"
|
||||
Kanonikus név: "{canonicalName}"
|
||||
|
||||
Kandid\u00e1tusok:
|
||||
{candidateList}
|
||||
""";
|
||||
|
||||
var response = await _openAiService.GetSimpleResponseAsync(systemPrompt, userPrompt);
|
||||
if (string.IsNullOrWhiteSpace(response))
|
||||
return candidates.Select(c => c.Id).ToList();
|
||||
|
||||
try
|
||||
{
|
||||
var clean = response.Replace("```json", "").Replace("```", "").Trim();
|
||||
using var doc = JsonDocument.Parse(clean);
|
||||
var ranked = doc.RootElement
|
||||
.GetProperty("rankedIds")
|
||||
.EnumerateArray()
|
||||
.Select(e => e.GetInt32())
|
||||
.ToList();
|
||||
|
||||
// Csak valódi kandidátus ID-kat tartunk meg, a maradékot hozzáfűzzük
|
||||
var validIds = ranked.Where(id => candidates.Any(c => c.Id == id)).ToList();
|
||||
var missing = candidates.Select(c => c.Id).Except(validIds).ToList();
|
||||
return [.. validIds, .. missing];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return candidates.Select(c => c.Id).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTO-k ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public class ParsedMessageResponse
|
||||
{
|
||||
[JsonPropertyName("groups")]
|
||||
public List<ParsedDraftGroup> Groups { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ParsedDraftGroup
|
||||
{
|
||||
[JsonPropertyName("deliveryDate")]
|
||||
public string DeliveryDate { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
public List<ParsedDraftItem> Items { get; set; } = [];
|
||||
|
||||
public DateTime GetDeliveryDateTime()
|
||||
=> DateTime.TryParse(DeliveryDate, out var dt) ? dt : DateTime.Today.AddDays(1);
|
||||
}
|
||||
|
||||
public class ParsedDraftItem
|
||||
{
|
||||
[JsonPropertyName("rawProductText")]
|
||||
public string RawProductText { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("requestedQuantity")]
|
||||
public int RequestedQuantity { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("sortOrder")]
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public class ProductResolutionHint
|
||||
{
|
||||
[JsonPropertyName("canonicalName")]
|
||||
public string CanonicalName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("searchKeywords")]
|
||||
public List<string> SearchKeywords { get; set; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
using LinqToDB;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Services.Catalog;
|
||||
using System.Threading;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
public class OrderDraftService : IOrderDraftService
|
||||
{
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly PreOrderDbContext _preOrderDbContext;
|
||||
private readonly OrderDraftParserService _parserService;
|
||||
private readonly IProductService _productService;
|
||||
private readonly PreOrderConversionService _preOrderConversionService;
|
||||
|
||||
public OrderDraftService(
|
||||
FruitBankDbContext dbContext,
|
||||
PreOrderDbContext preOrderDbContext,
|
||||
OrderDraftParserService parserService,
|
||||
IProductService productService,
|
||||
PreOrderConversionService preOrderConversionService)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_preOrderDbContext = preOrderDbContext;
|
||||
_parserService = parserService;
|
||||
_productService = productService;
|
||||
_preOrderConversionService = preOrderConversionService;
|
||||
}
|
||||
|
||||
// ── Létrehozás ────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<OrderDraft>> CreateDraftsFromMessageAsync(string externalUsername, string rawMessageText)
|
||||
{
|
||||
// 1. Partner azonosítás
|
||||
var mapping = await _dbContext.ExternalUserPartnerMaps.GetByUsernameAsync(externalUsername);
|
||||
|
||||
// 2. AI parse: dátumok + termékek kinyerése
|
||||
var parsedGroups = await _parserService.ParseMessageAsync(rawMessageText);
|
||||
if (!parsedGroups.Any())
|
||||
return [];
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var createdDrafts = new List<OrderDraft>();
|
||||
|
||||
foreach (var group in parsedGroups)
|
||||
{
|
||||
var deliveryDate = group.GetDeliveryDateTime();
|
||||
var isPreorder = deliveryDate.Date > now.Date.AddDays(OrderDraftParserService.PreorderThresholdDays);
|
||||
|
||||
var draft = new OrderDraft
|
||||
{
|
||||
ExternalUsername = externalUsername,
|
||||
CustomerId = mapping?.CustomerId,
|
||||
RawMessageText = rawMessageText,
|
||||
ParsedDeliveryDate = deliveryDate,
|
||||
Status = OrderDraftStatus.Pending,
|
||||
CreatedOnUtc = now
|
||||
};
|
||||
|
||||
await _dbContext.OrderDrafts.InsertAsync(draft);
|
||||
|
||||
// 3. Tételek feldolgozása
|
||||
var sortOrder = 0;
|
||||
var itemTasks = group.Items.Select(parsedItem => ProcessDraftItemAsync(
|
||||
draft.Id, parsedItem, isPreorder, sortOrder++)).ToList();
|
||||
|
||||
var items = await Task.WhenAll(itemTasks);
|
||||
draft.Items = items.ToList();
|
||||
|
||||
createdDrafts.Add(draft);
|
||||
|
||||
Console.WriteLine($"[OrderDraftService] Created draft #{draft.Id} for '{externalUsername}', " +
|
||||
$"deliveryDate={deliveryDate:yyyy-MM-dd}, isPreorder={isPreorder}, " +
|
||||
$"items={items.Length}, customerId={mapping?.CustomerId.ToString() ?? "null"}");
|
||||
}
|
||||
|
||||
return createdDrafts;
|
||||
}
|
||||
|
||||
private async Task<OrderDraftItem> ProcessDraftItemAsync(
|
||||
int draftId, ParsedDraftItem parsed, bool isPreorder, int sortOrder)
|
||||
{
|
||||
// 1. Historikus súlyozás: utolsó 5 admin döntés ugyanerre a szövegre
|
||||
var historicMappings = await _dbContext.ProductTextMappings
|
||||
.GetRecentByRawText(parsed.RawProductText, isPreorder, 5)
|
||||
.ToListAsync();
|
||||
|
||||
var historicProductIds = historicMappings.Select(m => m.ResolvedProductId).Distinct().ToList();
|
||||
|
||||
// 2. AI: kanonikus név + kulcsszavak
|
||||
var hint = await _parserService.ResolveProductNameAsync(parsed.RawProductText);
|
||||
|
||||
// 3. Termékkeresés az összes kulcsszóra — párhuzamosan
|
||||
var searchTasks = hint.SearchKeywords
|
||||
.Select(kw => _productService.SearchProductsAsync(keywords: kw, visibleIndividuallyOnly: true, pageSize: 10))
|
||||
.ToList();
|
||||
|
||||
var searchResults = await Task.WhenAll(searchTasks);
|
||||
var allFoundProducts = searchResults
|
||||
.SelectMany(r => r)
|
||||
.DistinctBy(p => p.Id)
|
||||
.ToList();
|
||||
|
||||
// 4. Sorrendezés: historikus találatok elől, majd AI ranking
|
||||
List<int> rankedIds;
|
||||
if (allFoundProducts.Count == 0)
|
||||
{
|
||||
rankedIds = historicProductIds;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Historikus ID-k kerülnek előre ha az aktuális keresésben is szerepelnek
|
||||
var foundIds = allFoundProducts.Select(p => p.Id).ToList();
|
||||
var historicAndFound = historicProductIds.Where(id => foundIds.Contains(id)).ToList();
|
||||
var restFound = foundIds.Except(historicAndFound).ToList();
|
||||
|
||||
// Ha több mint 1 nem-historikus találat van, AI rangsorolja
|
||||
if (restFound.Count > 1)
|
||||
{
|
||||
var candidates = allFoundProducts
|
||||
.Where(p => restFound.Contains(p.Id))
|
||||
.Select(p => (p.Id, p.Name))
|
||||
.ToList();
|
||||
|
||||
var aiRanked = await _parserService.RankCandidatesAsync(
|
||||
parsed.RawProductText, hint.CanonicalName, candidates);
|
||||
|
||||
rankedIds = [.. historicAndFound, .. aiRanked];
|
||||
}
|
||||
else
|
||||
{
|
||||
rankedIds = [.. historicAndFound, .. restFound];
|
||||
}
|
||||
}
|
||||
|
||||
var item = new OrderDraftItem
|
||||
{
|
||||
DraftId = draftId,
|
||||
RawProductText = parsed.RawProductText,
|
||||
AiResolvedProductName = hint.CanonicalName,
|
||||
SuggestedProductIdsJson = System.Text.Json.JsonSerializer.Serialize(rankedIds),
|
||||
SelectedProductId = rankedIds.Count > 0 ? rankedIds[0] : null, // első javaslat előre kiválasztva
|
||||
RequestedQuantity = parsed.RequestedQuantity > 0 ? parsed.RequestedQuantity : 1,
|
||||
IsPreorder = isPreorder,
|
||||
SortOrder = sortOrder
|
||||
};
|
||||
|
||||
await _dbContext.OrderDraftItems.InsertAsync(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
// ── Lekérdezések ──────────────────────────────────────────────────────────
|
||||
|
||||
public Task<OrderDraft?> GetByIdAsync(int id, bool loadRelations = true)
|
||||
=> _dbContext.OrderDrafts.GetByIdAsync(id, loadRelations);
|
||||
|
||||
public async Task<List<OrderDraft>> GetPendingAsync(int? customerId = null)
|
||||
{
|
||||
var query = _dbContext.OrderDrafts.GetPending();
|
||||
if (customerId.HasValue)
|
||||
query = query.Where(d => d.CustomerId == customerId.Value);
|
||||
return await query.OrderByDescending(d => d.CreatedOnUtc).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<(List<OrderDraft> Items, int TotalCount)> GetPagedAsync(
|
||||
int pageIndex, int pageSize,
|
||||
OrderDraftStatus? status = null,
|
||||
int? customerId = null)
|
||||
{
|
||||
var query = _dbContext.OrderDrafts.GetAll(false);
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(d => d.Status == status.Value);
|
||||
|
||||
if (customerId.HasValue)
|
||||
query = query.Where(d => d.CustomerId == customerId.Value);
|
||||
|
||||
query = query.OrderByDescending(d => d.CreatedOnUtc);
|
||||
|
||||
var totalCount = await query.CountAsync(CancellationToken.None);
|
||||
var items = await query
|
||||
.Skip(pageIndex * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return (items, totalCount);
|
||||
}
|
||||
|
||||
// ── Admin műveletek ───────────────────────────────────────────────────────
|
||||
|
||||
public async Task<OrderDraftApproveResult> ApproveAsync(int draftId, int adminId)
|
||||
{
|
||||
var result = new OrderDraftApproveResult();
|
||||
|
||||
var draft = await GetByIdAsync(draftId, true);
|
||||
if (draft == null)
|
||||
{
|
||||
result.ErrorMessage = $"Draft #{draftId} nem található.";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (draft.Status != OrderDraftStatus.Pending)
|
||||
{
|
||||
result.ErrorMessage = $"Draft #{draftId} már le van zárva (státusz: {draft.Status}).";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!draft.CustomerId.HasValue)
|
||||
{
|
||||
result.ErrorMessage = "Jóváhagyás előtt rendelj hozzá partnert a drafthoz.";
|
||||
return result;
|
||||
}
|
||||
|
||||
var unresolved = draft.Items.Where(i => i.SelectedProductId == null).ToList();
|
||||
if (unresolved.Any())
|
||||
{
|
||||
result.ErrorMessage = $"{unresolved.Count} tétel nincs kitöltve. Minden sorban ki kell választani a terméket.";
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Tételek szétválasztása: preorder vs. azonnali
|
||||
var preorderItems = draft.Items.Where(i => i.IsPreorder).ToList();
|
||||
var orderItems = draft.Items.Where(i => !i.IsPreorder).ToList();
|
||||
|
||||
// Tanulási rekordok mentése (párhuzamosan)
|
||||
var mappingTasks = draft.Items.Select(item => SaveProductTextMappingAsync(item, adminId));
|
||||
await Task.WhenAll(mappingTasks);
|
||||
|
||||
// Előrendelések létrehozása
|
||||
if (preorderItems.Any())
|
||||
{
|
||||
var preorderNopItems = preorderItems.Select(i => new PreOrderItem
|
||||
{
|
||||
ProductId = i.SelectedProductId!.Value,
|
||||
RequestedQuantity = i.RequestedQuantity,
|
||||
FulfilledQuantity = 0,
|
||||
UnitPriceInclTax = 0m, // Árat a konverziókor számítja a service
|
||||
Status = PreOrderItemStatus.Pending
|
||||
}).ToList();
|
||||
|
||||
var preorder = new PreOrder
|
||||
{
|
||||
CustomerId = draft.CustomerId!.Value,
|
||||
StoreId = 1,
|
||||
DateOfReceipt = draft.ParsedDeliveryDate,
|
||||
CustomerNote = $"OrderDraft #{draft.Id} alapján létrehozva"
|
||||
};
|
||||
|
||||
var saved = await _preOrderDbContext.InsertPreOrderAsync(preorder, preorderNopItems);
|
||||
result.PreOrderIds.Add(saved.Id);
|
||||
|
||||
// Azonnali konverzió ha van raktáron
|
||||
await _preOrderConversionService.ConvertPreOrdersForProductsAsync(
|
||||
preorderNopItems.Select(i => i.ProductId).ToList(), 0);
|
||||
}
|
||||
|
||||
// Azonnali rendelések: a PreOrderConversionService kezeli ha létrehozunk egy
|
||||
// preordert aminek dátuma <= 4 nap — itt is ugyanazt a flow-t használjuk
|
||||
if (orderItems.Any())
|
||||
{
|
||||
var orderNopItems = orderItems.Select(i => new PreOrderItem
|
||||
{
|
||||
ProductId = i.SelectedProductId!.Value,
|
||||
RequestedQuantity = i.RequestedQuantity,
|
||||
FulfilledQuantity = 0,
|
||||
UnitPriceInclTax = 0m,
|
||||
Status = PreOrderItemStatus.Pending
|
||||
}).ToList();
|
||||
|
||||
var immediatePreorder = new PreOrder
|
||||
{
|
||||
CustomerId = draft.CustomerId!.Value,
|
||||
StoreId = 1,
|
||||
DateOfReceipt = draft.ParsedDeliveryDate,
|
||||
CustomerNote = $"OrderDraft #{draft.Id} alapján létrehozva (azonnali)"
|
||||
};
|
||||
|
||||
var saved = await _preOrderDbContext.InsertPreOrderAsync(immediatePreorder, orderNopItems);
|
||||
|
||||
// Az azonnali rendeléseknél inline futtatjuk a konverziót
|
||||
await _preOrderConversionService.ConvertPreOrdersForProductsAsync(
|
||||
orderNopItems.Select(i => i.ProductId).ToList(), 0);
|
||||
|
||||
// Ha lett belőle rendelés, azt is visszaadjuk
|
||||
var converted = await _preOrderDbContext.PreOrders.GetByIdAsync(saved.Id);
|
||||
if (converted?.OrderId.HasValue == true)
|
||||
result.OrderIds.Add(converted.OrderId.Value);
|
||||
else
|
||||
result.PreOrderIds.Add(saved.Id);
|
||||
}
|
||||
|
||||
// Draft lezárása
|
||||
draft.Status = OrderDraftStatus.Approved;
|
||||
draft.ResolvedByAdminId = adminId;
|
||||
draft.ResolvedOnUtc = DateTime.UtcNow;
|
||||
await _dbContext.OrderDrafts.UpdateAsync(draft);
|
||||
|
||||
result.Success = true;
|
||||
|
||||
Console.WriteLine($"[OrderDraftService] Draft #{draftId} approved by admin #{adminId}. " +
|
||||
$"PreOrders: [{string.Join(",", result.PreOrderIds)}], " +
|
||||
$"Orders: [{string.Join(",", result.OrderIds)}]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.ErrorMessage = $"Hiba a jóváhagyás során: {ex.Message}";
|
||||
Console.Error.WriteLine($"[OrderDraftService] ApproveAsync error for draft #{draftId}: {ex}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task RejectAsync(int draftId, int adminId, string? adminNote = null)
|
||||
{
|
||||
var draft = await GetByIdAsync(draftId, false);
|
||||
if (draft == null || draft.Status != OrderDraftStatus.Pending) return;
|
||||
|
||||
draft.Status = OrderDraftStatus.Rejected;
|
||||
draft.ResolvedByAdminId = adminId;
|
||||
draft.ResolvedOnUtc = DateTime.UtcNow;
|
||||
draft.AdminNote = adminNote;
|
||||
await _dbContext.OrderDrafts.UpdateAsync(draft);
|
||||
|
||||
Console.WriteLine($"[OrderDraftService] Draft #{draftId} rejected by admin #{adminId}.");
|
||||
}
|
||||
|
||||
public async Task AssignCustomerAsync(int draftId, int customerId, int adminId)
|
||||
{
|
||||
var draft = await GetByIdAsync(draftId, false);
|
||||
if (draft == null) return;
|
||||
|
||||
// Mapping mentése a jövőre
|
||||
var existingMapping = await _dbContext.ExternalUserPartnerMaps.GetByUsernameAsync(draft.ExternalUsername);
|
||||
if (existingMapping == null || existingMapping.CustomerId != customerId)
|
||||
{
|
||||
await _dbContext.ExternalUserPartnerMaps.InsertAsync(new ExternalUserPartnerMapping
|
||||
{
|
||||
ExternalUsername = draft.ExternalUsername,
|
||||
CustomerId = customerId,
|
||||
CreatedByAdminId = adminId,
|
||||
CreatedOnUtc = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
draft.CustomerId = customerId;
|
||||
await _dbContext.OrderDrafts.UpdateAsync(draft);
|
||||
}
|
||||
|
||||
public async Task UpdateDraftItemAsync(int draftItemId, int selectedProductId, int quantity)
|
||||
{
|
||||
var item = await _dbContext.OrderDraftItems.GetByIdAsync(draftItemId);
|
||||
if (item == null) return;
|
||||
|
||||
item.SelectedProductId = selectedProductId;
|
||||
if (quantity > 0) item.RequestedQuantity = quantity;
|
||||
await _dbContext.OrderDraftItems.UpdateAsync(item);
|
||||
}
|
||||
|
||||
public async Task ExpireOldDraftsAsync()
|
||||
{
|
||||
var candidates = await _dbContext.OrderDrafts.GetExpiredCandidates().ToListAsync();
|
||||
foreach (var draft in candidates)
|
||||
{
|
||||
draft.Status = OrderDraftStatus.Expired;
|
||||
await _dbContext.OrderDrafts.UpdateAsync(draft);
|
||||
}
|
||||
|
||||
if (candidates.Any())
|
||||
Console.WriteLine($"[OrderDraftService] Expired {candidates.Count} old drafts.");
|
||||
}
|
||||
|
||||
// ── Privát segédek ────────────────────────────────────────────────────────
|
||||
|
||||
private async Task SaveProductTextMappingAsync(OrderDraftItem item, int adminId)
|
||||
{
|
||||
if (item.SelectedProductId == null) return;
|
||||
|
||||
await _dbContext.ProductTextMappings.InsertAsync(new ProductTextMapping
|
||||
{
|
||||
RawText = item.RawProductText.ToLower().Trim(),
|
||||
ResolvedProductId = item.SelectedProductId.Value,
|
||||
IsPreorder = item.IsPreorder,
|
||||
SelectedByAdminId = adminId,
|
||||
CreatedOnUtc = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">×</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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
# ORDER-DRAFT — Known Issues
|
||||
|
||||
> Companion to [`README.md`](README.md). Topic `ORDD`, prefix `MGFBANKPLUG` → entry IDs `MGFBANKPLUG-ORDD-I-<RAND>` (issue) / `-B-` (confirmed bug) / `-T-` (tech-debt).
|
||||
> ID format, Status vocabulary, type codes and archival → `../../.github/TOPIC_CODES.md` (→ framework registry).
|
||||
|
||||
Scope: az AI-alapú rendelésfelvevő (OrderDraft) problémái — beérkező üzenet parse-olása, termék/partner feloldás, tanuló mappingek, draft életciklus, jóváhagyási belépési pont a PRE-ORDER folyamatba.
|
||||
|
||||
## Active entries
|
||||
|
||||
## MGFBANKPLUG-ORDD-I-Q4N7: Az `ApproveAsync` fixen `StoreId = 1`-et használ
|
||||
|
||||
**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** I (multi-store helyesség)
|
||||
|
||||
`OrderDraftService.ApproveAsync` ([`Services/OrderDraftService.cs`](../../Services/OrderDraftService.cs)) mindkét ágban (`preorder` és `azonnali`) `StoreId = 1` konstanssal hozza létre a `PreOrder`-t:
|
||||
|
||||
```csharp
|
||||
var preorder = new PreOrder { CustomerId = draft.CustomerId!.Value, StoreId = 1, ... };
|
||||
```
|
||||
|
||||
**Hatás:** jelenleg egy store-os környezetben nincs gond. Multi-store esetén viszont a draftból mindig az 1-es store-ba kerül a rendelés, függetlenül a partner/üzenet kontextusától — rossz store-hoz rendelt rendelések.
|
||||
|
||||
**Javítási irány:** a store-t a draft kontextusából vagy a partner (`Customer`) alapértelmezett store-jából vezessük le, ne hardcode-oljuk. (Megj.: a `PreOrderAdminController.CreatePreOrder` is hasonló fallback-logikát használ — érdemes közös segédbe emelni.)
|
||||
|
||||
**Affected:** `Services/OrderDraftService.cs` → `ApproveAsync` (mindkét `new PreOrder { StoreId = 1 }`).
|
||||
|
||||
## MGFBANKPLUG-ORDD-T-R8W3: A jóváhagyás preorder/azonnali ága szinte duplikátum
|
||||
|
||||
**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** T (tech-debt / refaktor)
|
||||
|
||||
`ApproveAsync`-ben a `preorderItems` és az `orderItems` (azonnali) ág kódja közel azonos: `PreOrderItem` lista építése (`UnitPriceInclTax = 0`, `Status = Pending`), `PreOrder` létrehozás, `InsertPreOrderAsync`, majd `ConvertPreOrdersForProductsAsync` hívás. Az egyetlen érdemi különbség a `CustomerNote` szövege és hogy az azonnali ág a végén visszaolvassa az `OrderId`-t.
|
||||
|
||||
Mivel mindkét ág ugyanazon a `PreOrder` + konverzió flow-n megy keresztül (a konverziós ablak dönti el, hogy azonnal teljesül-e), a kettő **egyetlen** közös ággá vonható össze, dátum szerinti elágazás nélkül — vagy egy közös privát helperrel (`CreatePreOrderFromDraftItemsAsync(items, note)`).
|
||||
|
||||
**Affected:** `Services/OrderDraftService.cs` → `ApproveAsync`.
|
||||
|
||||
## MGFBANKPLUG-ORDD-I-M2K9: Az API-kulcs összehasonlítása nem konstans idejű
|
||||
|
||||
**Status:** Open (2026-06-15) · **Priority:** P3 · **Type:** I (security hardening)
|
||||
|
||||
`OrderDraftApiController.ValidateApiKey` ([`Controllers/OrderDraftApiController.cs`](../../Controllers/OrderDraftApiController.cs)) `string.Equals(..., StringComparison.Ordinal)`-lal hasonlítja össze a beküldött és a tárolt API-kulcsot. Ez **nem konstans idejű** — elvi timing-oldalcsatorna a kulcs kitalálására.
|
||||
|
||||
**Hatás:** alacsony (a kulcs hosszú, és a háló-jitter elnyomja a mikro-eltérést), de egy publikus, csak-API-kulccsal védett végpontnál a fixed-time összehasonlítás az elvárt gyakorlat.
|
||||
|
||||
**Javítási irány:** fixed-time összehasonlítás, pl. `CryptographicOperations.FixedTimeEquals` a két kulcs UTF-8 byte-jaira (hossz-eltérés előzetes kezelésével). Opcionálisan rate-limiting a végponton.
|
||||
|
||||
**Affected:** `Controllers/OrderDraftApiController.cs` → `ValidateApiKey`.
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# Order-Draft Workflow
|
||||
|
||||
> Part of `Nop.Plugin.Misc.FruitBankPlugin`. See `../README.md` for the docs index, `../../README.md` for project overview.
|
||||
> Closely coupled to the pre-order subsystem — see [`../PREORDER/README.md`](../PREORDER/README.md): an approved draft creates `PreOrder`s and runs the same conversion.
|
||||
> Known issues / planned work: [`ORDERDRAFT_ISSUES.md`](ORDERDRAFT_ISSUES.md).
|
||||
|
||||
An **order draft** is an AI-assisted intake channel. External systems (Viber bot, email processor, dictation app) post a raw free-text message; the AI extracts delivery dates and product demands; a draft is created for admin review. On approval the draft becomes one or more `PreOrder`s, which then flow through the existing **PRE-ORDER conversion** — so OrderDraft is a new, admin-gated *placement* channel into that pipeline, not a parallel order path.
|
||||
|
||||
## Entity Hierarchy
|
||||
|
||||
```
|
||||
OrderDraft ← one inbound message for one delivery date
|
||||
└─ OrderDraftItem ← one parsed product-demand line [1:N]
|
||||
|
||||
ExternalUserPartnerMapping ← external username → nopCommerce CustomerId (learned)
|
||||
ProductTextMapping ← raw text → admin-chosen ProductId (learned, for ranking)
|
||||
```
|
||||
|
||||
All four tables live in `FruitBank.Common/Entities/` and are registered on `FruitBankDbContext`. `OrderDraftStatus`: `Pending` → `Approved` / `Rejected` / `Expired`.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Intake
|
||||
`OrderDraftApiController.Create` (`Controllers/`): auth via `X-FruitBank-ApiKey` header (matched against `FruitBankSettings.OrderDraftApiKey`), body `{ externalUsername, messageText }`.
|
||||
|
||||
### 2. Draft build
|
||||
`OrderDraftService.CreateDraftsFromMessageAsync`:
|
||||
- Partner resolution from `ExternalUserPartnerMapping` (may be `null` → admin assigns later).
|
||||
- AI parse (`OrderDraftParserService.ParseMessageAsync`): one draft per delivery date; `isPreorder = deliveryDate > today + PreorderThresholdDays (4)`.
|
||||
- Per item (`ProcessDraftItemAsync`): historic `ProductTextMapping` weighting (last 5 admin decisions) + AI canonical name/keywords + `SearchProductsAsync` + AI ranking → `SuggestedProductIdsJson`, first suggestion pre-selected.
|
||||
|
||||
### 3. Admin review
|
||||
`OrderDraftAdminController` (`Areas/Admin/`): DataTables list (urgent = Pending > 20h), Detail page with candidates, `UpdateItem` / `AssignCustomer`.
|
||||
|
||||
### 4. Approval (the entry point into PRE-ORDER)
|
||||
`OrderDraftService.ApproveAsync`:
|
||||
- Validates: partner assigned, every item has `SelectedProductId`.
|
||||
- Saves `ProductTextMapping` learning records.
|
||||
- Splits items by `IsPreorder`; **both branches create a `PreOrder`** via `InsertPreOrderAsync`, then call `PreOrderConversionService.ConvertPreOrdersForProductsAsync`. The "immediate" branch relies on the 4-day conversion window to convert at once.
|
||||
|
||||
### 5. Expiry
|
||||
`OrderDraftService.ExpireOldDraftsAsync`: Pending drafts older than 14 days → `Expired` (background job).
|
||||
|
||||
## AI touchpoints
|
||||
|
||||
Three distinct AI calls, all in `OrderDraftParserService` via `OpenAIApiService.GetSimpleResponseAsync` (OpenAI Chat Completions). OrderDraft uses **OpenAI exclusively** — unlike `QuickOrderController` / `OrderController` parsing, which use `CerebrasApiService`.
|
||||
|
||||
| # | Call | Input → Output | Used in |
|
||||
|---|---|---|---|
|
||||
| 1 | `ParseMessageAsync` | raw message → delivery-date groups of `{ rawProductText, requestedQuantity }`; one draft per date | `CreateDraftsFromMessageAsync` |
|
||||
| 2 | `ResolveProductNameAsync` | one raw product text → `{ canonicalName, searchKeywords[] }` (keywords feed NopCommerce `SearchProductsAsync`) | `ProcessDraftItemAsync` |
|
||||
| 3 | `RankCandidatesAsync` | raw text + canonical name + candidate `(id, name)[]` → ranked product-id list; only when >1 non-historic search hit | `ProcessDraftItemAsync` |
|
||||
|
||||
Resolution is **hybrid**: AI proposes candidates, but ordering is front-loaded by historic admin decisions (`ProductTextMapping`, last 5 for the same raw text) — a DB heuristic, not AI. Product search itself (`SearchProductsAsync`) is NopCommerce full-text, not AI.
|
||||
|
||||
**Model:** `GetModelName()` from `FruitBankSettings.OpenAIModelName` (default `gpt-3.5-turbo`; code paths for `gpt-4o-mini`/`4.1-mini`/`4.1-nano`/`5-nano` at `temperature=0.2`, and a `gpt-5` path at `reasoning_effort=minimal`).
|
||||
|
||||
**Not yet AI-wired:** partner identification (plain `ExternalUserPartnerMapping` username lookup), quantity/unit normalisation (relies on the parse prompt only), confidence/uncertainty scoring (admin approves everything manually), and the preorder-vs-immediate split (fixed 4-day `PreorderThresholdDays`, not AI).
|
||||
|
||||
## Key Source
|
||||
|
||||
| Type | Location |
|
||||
|---|---|
|
||||
| `OrderDraftService` / `IOrderDraftService` | `Services/` |
|
||||
| `OrderDraftParserService` (AI parse / resolve / rank) | `Services/` |
|
||||
| `OrderDraftApiController` | `Controllers/` |
|
||||
| `OrderDraftAdminController` (+ models) | `Areas/Admin/` |
|
||||
| `OrderDraftDbTable` / `OrderDraftItemDbTable` / `ExternalUserPartnerMapDbTable` / `ProductTextMappingDbTable` | `Domains/DataLayer/` |
|
||||
| `OrderDraft` / `OrderDraftItem` / `ExternalUserPartnerMapping` / `ProductTextMapping` entities | `FruitBank.Common/Entities/` |
|
||||
|
|
@ -7,4 +7,21 @@ Scope: bugs / inconsistent or observable edge-case behaviour in the pre-order su
|
|||
|
||||
## Active entries
|
||||
|
||||
*(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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue