bug fixes on preorder, modified logic

This commit is contained in:
Adam 2026-05-26 14:48:57 +02:00
parent c86ef0e416
commit 2e7619b621
19 changed files with 1837 additions and 451 deletions

View File

@ -0,0 +1,428 @@
@* FruitBank operational dashboard — loaded via AJAX, never blocks page render *@
<div id="fb-dashboard-loading" class="text-center py-3">
<i class="fas fa-spinner fa-spin fa-lg text-muted"></i>
<span class="text-muted ml-2">Operatív adatok betöltése...</span>
</div>
<div id="fb-dashboard-content" style="display:none;">
<div class="row" id="fb-pipeline-stats">
<div class="col-6 col-md col-sm-4">
<div class="info-box mb-3">
<span class="info-box-icon bg-info elevation-1"><i class="fas fa-shopping-basket"></i></span>
<div class="info-box-content">
<span class="info-box-text">Mai rendelés</span>
<span class="info-box-number" id="fb-stat-total"></span>
</div>
</div>
</div>
<div class="col-6 col-md col-sm-4">
<div class="info-box mb-3">
<span class="info-box-icon bg-warning elevation-1"><i class="fas fa-weight-hanging"></i></span>
<div class="info-box-content">
<span class="info-box-text">Mérés alatt</span>
<span class="info-box-number" id="fb-stat-measuring"></span>
</div>
</div>
</div>
<div class="col-6 col-md col-sm-4">
<div class="info-box mb-3">
<span class="info-box-icon bg-success elevation-1"><i class="fas fa-check-circle"></i></span>
<div class="info-box-content">
<span class="info-box-text">Auditálva</span>
<span class="info-box-number" id="fb-stat-audited"></span>
</div>
</div>
</div>
<div class="col-6 col-md col-sm-4">
<div class="info-box mb-3">
<span class="info-box-icon bg-danger elevation-1"><i class="fas fa-file-invoice-dollar"></i></span>
<div class="info-box-content">
<span class="info-box-text">InnVoice hiányzik</span>
<span class="info-box-number" id="fb-stat-innvoice"></span>
</div>
</div>
</div>
<div class="col-6 col-md col-sm-4">
<div class="info-box mb-3">
<span class="info-box-icon bg-secondary elevation-1"><i class="fas fa-flag-checkered"></i></span>
<div class="info-box-content">
<span class="info-box-text">Befejezve</span>
<span class="info-box-number" id="fb-stat-completed"></span>
</div>
</div>
</div>
</div>
<div class="card card-primary" id="fb-orders-card">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-list mr-1"></i>
Mai rendelések
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped table-hover mb-0" id="fb-orders-table">
<thead class="thead-light">
<tr>
<th>Rendelés</th>
<th>Partner</th>
<th class="text-right">Összeg</th>
<th class="text-center">Mérés</th>
<th class="text-center">InnVoice</th>
<th class="text-center">Státusz</th>
<th>Átvétel</th>
</tr>
</thead>
<tbody id="fb-orders-body">
<tr><td colspan="7" class="text-center text-muted py-3">Betöltés...</td></tr>
</tbody>
</table>
</div>
<div class="card-footer text-muted small" id="fb-orders-footer" style="display:none;">
<a href="/Admin/CustomOrder/List">Összes rendelés &rarr;</a>
</div>
</div>
<div class="card" id="fb-alerts-card" style="display:none;">
<div class="card-header bg-danger">
<h3 class="card-title text-white">
<i class="fas fa-exclamation-triangle mr-1"></i>
Figyelmet igénylő tételek
<span class="badge badge-light ml-2" id="fb-alerts-count">0</span>
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool text-white" data-card-widget="collapse">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead class="thead-light">
<tr>
<th>Típus</th>
<th>Partner</th>
<th>Részlet</th>
<th>Link</th>
</tr>
</thead>
<tbody id="fb-alerts-body"></tbody>
</table>
</div>
</div>
<div class="card card-warning collapsed-card" id="fb-credits-card" style="display:none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-credit-card mr-1"></i>
Hitelkeret státusz
<span class="badge badge-secondary ml-2" id="fb-credits-count">0</span>
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead class="thead-light">
<tr>
<th>Partner</th>
<th class="text-right">Hitelkeret</th>
<th class="text-right">Kint lévő</th>
<th class="text-right">Szabad</th>
<th class="text-center">Státusz</th>
</tr>
</thead>
<tbody id="fb-credits-body"></tbody>
</table>
</div>
</div>
<div class="card card-info collapsed-card" id="fb-preorders-card" style="display:none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-clock mr-1"></i>
Nyitott előrendelések
<span class="badge badge-secondary ml-2" id="fb-preorders-count">0</span>
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead class="thead-light">
<tr>
<th>Partner</th>
<th>Létrehozva</th>
<th class="text-center">Tételek</th>
<th class="text-center">Teljesített</th>
<th class="text-center">Státusz</th>
<th></th>
</tr>
</thead>
<tbody id="fb-preorders-body"></tbody>
</table>
</div>
<div class="card-footer text-muted small">
<a href="/Admin/Preorders">Összes előrendelés &rarr;</a>
</div>
</div>
<div class="card card-secondary collapsed-card" id="fb-docs-card" style="display:none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-truck-loading mr-1"></i>
Feldolgozatlan szállítmányok
<span class="badge badge-secondary ml-2" id="fb-docs-count">0</span>
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead class="thead-light">
<tr>
<th>Partner</th>
<th>Szállítás dátuma</th>
<th class="text-center">Tételek</th>
<th class="text-center">Feldolgozva</th>
<th></th>
</tr>
</thead>
<tbody id="fb-docs-body"></tbody>
</table>
</div>
<div class="card-footer text-muted small">
<a href="/Admin/Shipping">Összes szállítmány &rarr;</a>
</div>
</div>
</div>
<script>
(function () {
// ── Helpers ──────────────────────────────────────────────────────────────
function fmt(num) {
return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: 'HUF', maximumFractionDigits: 0 }).format(num);
}
function measuringBadge(status) {
var map = {
'NotStarted': '<span class="badge badge-secondary">Nem kezdett</span>',
'Started': '<span class="badge badge-warning">Mérés alatt</span>',
'Audited': '<span class="badge badge-success">Auditálva</span>'
};
return map[status] || '<span class="badge badge-light">' + status + '</span>';
}
function orderStatusBadge(status) {
var map = {
'Pending': '<span class="badge badge-secondary">Függőben</span>',
'Processing': '<span class="badge badge-info">Folyamatban</span>',
'Complete': '<span class="badge badge-success">Befejezve</span>',
'Cancelled': '<span class="badge badge-danger">Törölve</span>'
};
return map[status] || '<span class="badge badge-light">' + status + '</span>';
}
function creditBadge(status) {
var map = {
'ok': '<span class="badge badge-success">OK</span>',
'warning': '<span class="badge badge-warning">Közel a limithez</span>',
'exceeded': '<span class="badge badge-danger">Túllépve</span>'
};
return map[status] || status;
}
function preorderStatusBadge(status) {
var map = {
'Pending': '<span class="badge badge-warning">Várakozik</span>',
'PartiallyFulfilled': '<span class="badge badge-info">Részben teljesítve</span>',
'Confirmed': '<span class="badge badge-success">Megerősítve</span>',
'Cancelled': '<span class="badge badge-secondary">Törölve</span>'
};
return map[status] || status;
}
function alertTypeBadge(type) {
var map = {
'missing_innvoice': '<span class="badge badge-danger">InnVoice</span>',
'stale_measuring': '<span class="badge badge-warning">Régi mérés</span>',
'credit_exceeded': '<span class="badge badge-danger">Hitelkeret</span>',
'old_preorder': '<span class="badge badge-warning">Előrendelés</span>'
};
return map[type] || '<span class="badge badge-secondary">' + type + '</span>';
}
function alertLink(alert) {
if (alert.type === 'missing_innvoice' || alert.type === 'stale_measuring')
return '<a href="/Admin/Order/Edit/' + alert.orderId + '" class="btn btn-xs btn-outline-primary">Rendelés</a>';
if (alert.type === 'credit_exceeded')
return '<a href="/Admin/CustomerCredit/Details/' + alert.customerId + '" class="btn btn-xs btn-outline-warning">Hitelkeret</a>';
if (alert.type === 'old_preorder')
return '<a href="/Admin/Preorders" class="btn btn-xs btn-outline-info">Előrendelések</a>';
return '';
}
function alertDetail(alert) {
if (alert.type === 'missing_innvoice' || alert.type === 'stale_measuring')
return '#' + (alert.orderNumber || alert.orderId) + (alert.dateOfReceipt ? ' &nbsp;<small class="text-muted">' + alert.dateOfReceipt + '</small>' : '');
if (alert.type === 'credit_exceeded')
return fmt(alert.outstanding) + ' / ' + fmt(alert.creditLimit);
if (alert.type === 'old_preorder')
return alert.createdAt;
return alert.message || '';
}
// ── Main load ────────────────────────────────────────────────────────────
$.ajax({
url: '/Admin/CustomDashboard/GetDashboardData',
type: 'GET',
timeout: 60000,
success: function (data) {
// ── Pipeline stat boxes ──────────────────────────────────────────
var p = data.pipeline;
$('#fb-stat-total').text(p.total);
$('#fb-stat-measuring').text(p.measuring);
$('#fb-stat-audited').text(p.audited);
$('#fb-stat-innvoice').text(p.missingInnVoice);
$('#fb-stat-completed').text(p.completed);
// Highlight the InnVoice box red if there are missing syncs
if (p.missingInnVoice > 0)
$('#fb-stat-innvoice').closest('.info-box').addClass('bg-danger text-white').find('.info-box-text').addClass('text-white');
// ── Today's order rows ───────────────────────────────────────────
var $ob = $('#fb-orders-body').empty();
if (p.rows && p.rows.length > 0) {
p.rows.forEach(function (o) {
var innVoiceIcon = o.hasInnVoice
? '<i class="fas fa-check text-success"></i>'
: '<i class="fas fa-times text-danger"></i>';
$ob.append(
'<tr>' +
'<td><a href="/Admin/Order/Edit/' + o.id + '">#' + (o.orderNumber || o.id) + '</a></td>' +
'<td>' + (o.company || '') + '</td>' +
'<td class="text-right text-nowrap">' + fmt(o.total) + '</td>' +
'<td class="text-center">' + measuringBadge(o.measuringStatus) + '</td>' +
'<td class="text-center">' + innVoiceIcon + '</td>' +
'<td class="text-center">' + orderStatusBadge(o.orderStatus) + '</td>' +
'<td class="text-nowrap small text-muted">' + (o.dateOfReceipt || '') + '</td>' +
'</tr>'
);
});
if (p.total > 20) {
$('#fb-orders-footer').show();
}
} else {
$ob.append('<tr><td colspan="7" class="text-center text-muted py-3">Nincs mai rendelés</td></tr>');
}
// ── Alerts ───────────────────────────────────────────────────────
if (data.alerts && data.alerts.length > 0) {
$('#fb-alerts-count').text(data.alerts.length);
var $ab = $('#fb-alerts-body').empty();
data.alerts.forEach(function (a) {
$ab.append(
'<tr>' +
'<td>' + alertTypeBadge(a.type) + '</td>' +
'<td>' + (a.company || '') + '</td>' +
'<td class="small">' + alertDetail(a) + '</td>' +
'<td>' + alertLink(a) + '</td>' +
'</tr>'
);
});
$('#fb-alerts-card').show();
}
// ── Credit status ────────────────────────────────────────────────
if (data.creditStatus && data.creditStatus.length > 0) {
$('#fb-credits-count').text(data.creditStatus.length);
var $cb = $('#fb-credits-body').empty();
data.creditStatus.forEach(function (c) {
var rowClass = c.status === 'exceeded' ? 'table-danger' : c.status === 'warning' ? 'table-warning' : '';
$cb.append(
'<tr class="' + rowClass + '">' +
'<td><a href="/Admin/CustomerCredit/Details/' + c.customerId + '">' + c.company + '</a></td>' +
'<td class="text-right text-nowrap">' + fmt(c.creditLimit) + '</td>' +
'<td class="text-right text-nowrap">' + fmt(c.outstanding) + '</td>' +
'<td class="text-right text-nowrap">' + fmt(c.remaining) + '</td>' +
'<td class="text-center">' + creditBadge(c.status) + '</td>' +
'</tr>'
);
});
$('#fb-credits-card').show();
}
// ── Pending preorders ────────────────────────────────────────────
if (data.pendingPreorders && data.pendingPreorders.length > 0) {
$('#fb-preorders-count').text(data.pendingPreorders.length);
var $pb = $('#fb-preorders-body').empty();
data.pendingPreorders.forEach(function (p) {
$pb.append(
'<tr>' +
'<td>' + p.company + '</td>' +
'<td class="small text-muted">' + p.createdAt + '</td>' +
'<td class="text-center">' + p.itemCount + '</td>' +
'<td class="text-center">' + p.fulfilledCount + ' / ' + p.itemCount + '</td>' +
'<td class="text-center">' + preorderStatusBadge(p.status) + '</td>' +
'<td><a href="/Admin/Preorders/Details/' + p.id + '" class="btn btn-xs btn-outline-secondary">Részletek</a></td>' +
'</tr>'
);
});
$('#fb-preorders-card').show();
}
// ── Unprocessed shipping docs ────────────────────────────────────
if (data.unprocessedDocs && data.unprocessedDocs.length > 0) {
$('#fb-docs-count').text(data.unprocessedDocs.length);
var $db = $('#fb-docs-body').empty();
data.unprocessedDocs.forEach(function (d) {
var progress = d.totalItems > 0
? d.measuredItems + ' / ' + d.totalItems
: '';
$db.append(
'<tr>' +
'<td>' + (d.partnerName || '') + '</td>' +
'<td class="small text-muted">' + (d.shippingDate || '') + '</td>' +
'<td class="text-center">' + d.totalItems + '</td>' +
'<td class="text-center">' + progress + '</td>' +
'<td><a href="/Admin/Shipping/ShippingDocument/' + d.id + '" class="btn btn-xs btn-outline-secondary">Megnyitás</a></td>' +
'</tr>'
);
});
$('#fb-docs-card').show();
}
// ── Show everything, hide spinner ────────────────────────────────
$('#fb-dashboard-loading').hide();
$('#fb-dashboard-content').show();
},
error: function (xhr, status, error) {
$('#fb-dashboard-loading').html(
'<p class="text-muted"><i class="fas fa-exclamation-circle mr-1"></i>Nem sikerült betölteni az operatív adatokat. (' + (xhr.status || status) + ')</p>'
);
}
});
}());
</script>

View File

@ -1,62 +1,59 @@
@using AyCode.Utils.Extensions
@using Nop.Core;
@using Nop.Plugin.Misc.FruitBankPlugin.Services
@using Nop.Core.Domain.Customers
@inject IWorkContext workContext
@inject AICalculationService aiCalculationService
@{
var customer = await workContext.GetCurrentCustomerAsync();
}
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-leaf"></i>
Üdvözöljük a Fruit Bank rendszerben!
</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
@{
Customer customer = await workContext.GetCurrentCustomerAsync();
var welcomeMessage = await aiCalculationService.GetWelcomeMessageAsync(customer);
if (welcomeMessage.IsNullOrWhiteSpace())
//if(string.IsNullOrWhiteSpace(welcomeMessage))
{
<p class="lead">Nincs kapcsolat az AI szerverrel...</p>
}
else
{
var email = customer.Email;
<h4>Ssytem check</h4>
<p class="lead">@welcomeMessage</p>
}
}
<p>
Mai dátum: <strong>@DateTime.Now.ToString("yyyy. MMMM dd., dddd", new System.Globalization.CultureInfo("hu-HU"))</strong>
</p>
@*
<hr>
<div class="row">
<div class="col-sm-6">
<h5><i class="fas fa-apple-alt text-success"></i> Gyümölcsök</h5>
<p class="text-muted">Friss, minőségi gyümölcsök széles választéka</p>
</div>
<div class="col-sm-6">
<h5><i class="fas fa-shipping-fast text-primary"></i> Gyors szállítás</h5>
<p class="text-muted">Megbízható kiszállítás országszerte</p>
</div>
</div> *@
</div>
<div class="col-md-4">
<div class="bg-light p-3 rounded">
<h5><i class="fas fa-chart-line text-warning"></i> Mai összefoglaló</h5>
<ul class="list-unstyled">
<li><i class="fas fa-clock text-info"></i> Bejelentkezés ideje: @DateTime.Now.ToString("HH:mm")</li>
<li><i class="fas fa-calendar text-success"></i> Aktív napok: @DateTime.Now.DayOfYear</li>
<li><i class="fas fa-sun text-warning"></i> Szép napot kívánunk!</li>
</ul>
</div>
</div>
</div>
</div>
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-leaf"></i>
Üdvözöljük a Fruit Bank rendszerben!
</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<div id="welcome-message-content">
<p class="lead text-muted">
<i class="fas fa-spinner fa-spin mr-2"></i>
Összefoglaló betöltése...
</p>
</div>
<p>
Mai dátum: <strong>@DateTime.Now.ToString("yyyy. MMMM dd., dddd", new System.Globalization.CultureInfo("hu-HU"))</strong>
</p>
</div>
<div class="col-md-4">
<div class="bg-light p-3 rounded">
<h5><i class="fas fa-chart-line text-warning"></i> Mai összefoglaló</h5>
<ul class="list-unstyled">
<li><i class="fas fa-clock text-info"></i> Bejelentkezés ideje: @DateTime.Now.ToString("HH:mm")</li>
<li><i class="fas fa-calendar text-success"></i> Aktív napok: @DateTime.Now.DayOfYear</li>
<li><i class="fas fa-sun text-warning"></i> Szép napot kívánunk!</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
$(function () {
$.ajax({
url: '/Admin/CustomDashboard/GetWelcomeMessage',
type: 'GET',
timeout: 30000,
success: function (html) {
$('#welcome-message-content').html(html);
},
error: function () {
$('#welcome-message-content').html(
'<p class="lead text-muted"><i class="fas fa-exclamation-circle mr-2"></i>Nincs kapcsolat az AI szerverrel...</p>'
);
}
});
});
</script>

View File

@ -1,8 +1,14 @@
using ExCSS;
using ExCSS;
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Common;
using Nop.Services.Configuration;
using Nop.Services.Localization;
@ -29,12 +35,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
protected readonly ISettingService _settingService;
protected readonly IGenericAttributeService _genericAttributeService;
protected readonly IWorkContext _workContext;
protected readonly AICalculationService _aiCalculationService;
protected readonly FruitBankDbContext _fruitBankDbContext;
protected readonly PreorderDbContext _preorderDbContext;
#endregion
#region Ctor
public CustomDashboardController(AdminAreaSettings adminAreaSettings,
public CustomDashboardController(
AdminAreaSettings adminAreaSettings,
ICommonModelFactory commonModelFactory,
IHomeModelFactory homeModelFactory,
ILocalizationService localizationService,
@ -42,7 +52,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
IPermissionService permissionService,
ISettingService settingService,
IGenericAttributeService genericAttributeService,
IWorkContext workContext)
IWorkContext workContext,
AICalculationService aiCalculationService,
FruitBankDbContext fruitBankDbContext,
PreorderDbContext preorderDbContext)
{
_adminAreaSettings = adminAreaSettings;
_commonModelFactory = commonModelFactory;
@ -53,6 +66,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
_settingService = settingService;
_workContext = workContext;
_genericAttributeService = genericAttributeService;
_aiCalculationService = aiCalculationService;
_fruitBankDbContext = fruitBankDbContext;
_preorderDbContext = preorderDbContext;
}
#endregion
@ -61,9 +77,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
public virtual async Task<IActionResult> Index()
{
//display a warning to a store owner if there are some error
var customer = await _workContext.GetCurrentCustomerAsync();
var email = customer.Email;
var hideCard = await _genericAttributeService.GetAttributeAsync<bool>(customer, NopCustomerDefaults.HideConfigurationStepsAttribute);
var closeCard = await _genericAttributeService.GetAttributeAsync<bool>(customer, NopCustomerDefaults.CloseConfigurationStepsAttribute);
@ -73,11 +87,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
if (warnings.Any(warning => warning.Level == SystemWarningLevel.Fail || warning.Level == SystemWarningLevel.Warning))
{
var locale = await _localizationService.GetResourceAsync("Admin.System.Warnings.Errors");
_notificationService.WarningNotification(string.Format(locale, Url.Action("Warnings", "Common")), false); //do not encode URLs
_notificationService.WarningNotification(string.Format(locale, Url.Action("Warnings", "Common")), false);
}
}
//progress of localization
var currentLanguage = await _workContext.GetWorkingLanguageAsync();
var progress = await _genericAttributeService.GetAttributeAsync<string>(currentLanguage, NopCommonDefaults.LanguagePackProgressAttribute);
if (!string.IsNullOrEmpty(progress))
@ -87,10 +100,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
await _genericAttributeService.SaveAttributeAsync(currentLanguage, NopCommonDefaults.LanguagePackProgressAttribute, string.Empty);
}
//prepare model
var model = await _homeModelFactory.PrepareDashboardModelAsync(new DashboardModel());
//return View(model);
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Index.cshtml", model);
}
@ -99,7 +109,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{
_adminAreaSettings.HideAdvertisementsOnAdminArea = !_adminAreaSettings.HideAdvertisementsOnAdminArea;
await _settingService.SaveSettingAsync(_adminAreaSettings);
return Content("Setting changed");
}
@ -145,7 +154,300 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
return PartialView("Table", model);
}
[HttpGet]
public virtual async Task<IActionResult> GetWelcomeMessage()
{
try
{
var customer = await _workContext.GetCurrentCustomerAsync();
var message = await _aiCalculationService.GetWelcomeMessageAsync(customer);
if (string.IsNullOrWhiteSpace(message))
return Content("<p class=\"lead text-muted\">Nincs kapcsolat az AI szerverrel...</p>");
return Content($"<p class=\"lead\">{System.Net.WebUtility.HtmlEncode(message)}</p>");
}
catch (Exception ex)
{
return Content($"<p class=\"lead text-muted\"><i class=\"fas fa-exclamation-circle mr-2\"></i>{System.Net.WebUtility.HtmlEncode(ex.Message)}</p>");
}
}
/// <summary>
/// Returns all FruitBank dashboard data as a single JSON payload.
/// Called via AJAX after page load — never blocks the dashboard render.
/// </summary>
[HttpGet]
public virtual async Task<IActionResult> GetDashboardData()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_VIEW))
return Forbid();
var today = DateTime.Now.Date;
// ── Batch 1: parallel queries ─────────────────────────────────────
// NOTE: PreorderItems is not a LinqToDB [Association] — never use LoadWith on it.
// Preorders and items are loaded separately and joined in memory below.
var allOrdersTask = _fruitBankDbContext.OrderDtos.GetAll(true).ToListAsync();
var allCreditsTask = _fruitBankDbContext.CustomerCredits.GetAll().ToListAsync();
var allPreordersTask = _preorderDbContext.Preorders.GetAll().ToListAsync();
var unprocessedDocsTask = _fruitBankDbContext.ShippingDocuments.GetAllNotMeasured(true).ToListAsync();
await Task.WhenAll(allOrdersTask, allCreditsTask, allPreordersTask, unprocessedDocsTask);
var allOrders = await allOrdersTask;
var credits = await allCreditsTask;
var unprocessedDocs = await unprocessedDocsTask;
// Filter pending preorders in memory — LinqToDB cannot translate enum comparisons to SQL
var pendingStatuses = new[] { PreorderStatus.Pending, PreorderStatus.PartiallyFulfilled };
var pendingPreorders = (await allPreordersTask)
.Where(p => pendingStatuses.Contains(p.Status))
.ToList();
// ── Batch 1b: preorder items for pending preorders only ────────────
var pendingPreorderIds = pendingPreorders.Select(p => p.Id).ToList();
var pendingItems = pendingPreorderIds.Any()
? await _preorderDbContext.PreorderItems.GetAll()
.Where(i => pendingPreorderIds.Contains(i.PreorderId))
.ToListAsync()
: new List<PreorderItem>();
// ── Today's orders (in-memory filter, same pattern as AICalculationService)
var todaysOrders = allOrders
.Where(o => o.DateOfReceiptOrCreated.Date == today)
.OrderByDescending(o => o.DateOfReceiptOrCreated)
.ToList();
// ── Batch 2: unpaid balances for credit customers ──────────────────
var creditCustomerIds = credits.Select(c => c.CustomerId).ToList();
var unpaidByCustomer = new Dictionary<int, decimal>();
if (creditCustomerIds.Any())
{
var unpaid = await _fruitBankDbContext.OrderDtos.GetAll(false)
.Where(o => creditCustomerIds.Contains(o.CustomerId)
&& o.OrderStatusId != (int)OrderStatus.Cancelled
&& o.PaymentStatusId < (int)PaymentStatus.Paid)
.Select(o => new { o.CustomerId, o.OrderTotal })
.ToListAsync();
unpaidByCustomer = unpaid
.GroupBy(o => o.CustomerId)
.ToDictionary(g => g.Key, g => g.Sum(o => o.OrderTotal));
}
// ── Customer name helper: orders cache first, then ID fallback ─────
string CustomerName(int customerId)
{
var fromOrder = allOrders.FirstOrDefault(o => o.CustomerId == customerId)?.Customer;
if (fromOrder != null)
{
if (!string.IsNullOrEmpty(fromOrder.Company)) return fromOrder.Company;
if (!string.IsNullOrEmpty(fromOrder.Email)) return fromOrder.Email;
}
return $"#{customerId}";
}
// ── Preorder customer names (may not appear in allOrders) ──────────
var preorderCustomerIds = pendingPreorders
.Select(p => p.CustomerId).Distinct()
.Where(id => allOrders.All(o => o.CustomerId != id))
.ToList();
var preorderCustomerLookup = new Dictionary<int, string>();
if (preorderCustomerIds.Any())
{
var customers = await _preorderDbContext.Customers.Table
.Where(c => preorderCustomerIds.Contains(c.Id))
.Select(c => new { c.Id, c.Company, c.Email })
.ToListAsync();
foreach (var c in customers)
preorderCustomerLookup[c.Id] = !string.IsNullOrEmpty(c.Company) ? c.Company : c.Email;
}
string PreorderCustomerName(int customerId)
=> preorderCustomerLookup.TryGetValue(customerId, out var name)
? name
: CustomerName(customerId);
// ─────────────────────────────────────────────────────────────────
// Section 1: Today's pipeline
// ─────────────────────────────────────────────────────────────────
var todayRows = todaysOrders.Take(20).Select(o =>
{
var hasInnVoice = o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId");
return new
{
id = o.Id,
orderNumber = o.CustomOrderNumber,
company = CustomerName(o.CustomerId),
total = o.OrderTotal,
measuringStatus = o.MeasuringStatus.ToString(),
audited = o.IsAllOrderItemAudited,
hasInnVoice,
orderStatus = o.OrderStatus.ToString(),
dateOfReceipt = o.DateOfReceiptOrCreated.ToString("yyyy.MM.dd HH:mm")
};
}).ToList();
var pipeline = new
{
total = todaysOrders.Count,
measuring = todaysOrders.Count(o => o.MeasuringStatus == MeasuringStatus.Started),
audited = todaysOrders.Count(o => o.IsAllOrderItemAudited),
missingInnVoice = todaysOrders.Count(o =>
o.IsAllOrderItemAudited &&
!o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId")),
completed = todaysOrders.Count(o => o.OrderStatus == OrderStatus.Complete),
rows = todayRows
};
// ─────────────────────────────────────────────────────────────────
// Section 2: Alerts
// ─────────────────────────────────────────────────────────────────
var alerts = new List<object>();
// Audited today but InnVoice sync missing and not yet completed
foreach (var o in todaysOrders.Where(o =>
o.IsAllOrderItemAudited &&
o.OrderStatus != OrderStatus.Complete &&
!o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId")))
{
alerts.Add(new
{
type = "missing_innvoice",
orderId = o.Id,
orderNumber = o.CustomOrderNumber,
company = CustomerName(o.CustomerId),
message = "Auditálva, InnVoice szinkron hiányzik"
});
}
// Measuring started but DateOfReceipt is past (stale, stuck in progress)
foreach (var o in allOrders.Where(o =>
o.MeasuringStatus == MeasuringStatus.Started &&
o.DateOfReceiptOrCreated.Date < today))
{
alerts.Add(new
{
type = "stale_measuring",
orderId = o.Id,
orderNumber = o.CustomOrderNumber,
company = CustomerName(o.CustomerId),
dateOfReceipt = o.DateOfReceiptOrCreated.ToString("yyyy.MM.dd"),
message = "Régi, befejezetlen mérés"
});
}
// Partners over their credit limit
foreach (var credit in credits)
{
var outstanding = unpaidByCustomer.GetValueOrDefault(credit.CustomerId, 0m);
if (outstanding > credit.CreditLimit)
{
alerts.Add(new
{
type = "credit_exceeded",
customerId = credit.CustomerId,
company = CustomerName(credit.CustomerId),
outstanding,
creditLimit = credit.CreditLimit,
message = "Hitelkeret túllépve"
});
}
}
// Pending preorders older than 7 days
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
foreach (var p in pendingPreorders.Where(p =>
p.Status == PreorderStatus.Pending &&
p.CreatedOnUtc < sevenDaysAgo))
{
alerts.Add(new
{
type = "old_preorder",
preorderId = p.Id,
company = PreorderCustomerName(p.CustomerId),
createdAt = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd"),
message = "Régi, nyitott előrendelés"
});
}
// ─────────────────────────────────────────────────────────────────
// Section 3: Credit status (only partners with a limit set)
// ─────────────────────────────────────────────────────────────────
var creditRows = credits
.Select(credit =>
{
var outstanding = unpaidByCustomer.GetValueOrDefault(credit.CustomerId, 0m);
var remaining = credit.CreditLimit - outstanding;
var status = outstanding > credit.CreditLimit
? "exceeded"
: credit.CreditLimit > 0 && outstanding > credit.CreditLimit * 0.8m
? "warning"
: "ok";
return new
{
customerId = credit.CustomerId,
company = CustomerName(credit.CustomerId),
creditLimit = credit.CreditLimit,
outstanding,
remaining,
status
};
})
.OrderByDescending(c => c.outstanding)
.ToList();
// ─────────────────────────────────────────────────────────────────
// Section 4: Pending preorders (items joined in memory)
// ─────────────────────────────────────────────────────────────────
var preorderRows = pendingPreorders
.OrderBy(p => p.CreatedOnUtc)
.Select(p =>
{
var items = pendingItems.Where(i => i.PreorderId == p.Id).ToList();
return new
{
id = p.Id,
customerId = p.CustomerId,
company = PreorderCustomerName(p.CustomerId),
status = p.Status.ToString(),
createdAt = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd"),
itemCount = items.Count,
fulfilledCount = items.Count(i => i.Status == PreorderItemStatus.Fulfilled)
};
})
.ToList();
// ─────────────────────────────────────────────────────────────────
// Section 5: Unprocessed shipping documents
// ─────────────────────────────────────────────────────────────────
var docRows = unprocessedDocs
.OrderBy(d => d.ShippingDate)
.Select(doc => new
{
id = doc.Id,
partnerName = doc.Partner?.Name ?? "",
shippingDate = doc.ShippingDate.ToString("yyyy.MM.dd"),
totalItems = doc.ShippingItems?.Count ?? 0,
measuredItems = doc.ShippingItems?.Count(si => si.IsMeasured) ?? 0
})
.ToList();
// ─────────────────────────────────────────────────────────────────
return Json(new
{
pipeline,
alerts,
creditStatus = creditRows,
pendingPreorders = preorderRows,
unprocessedDocs = docRows
});
}
#endregion
}
}

View File

@ -93,6 +93,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
protected readonly IWorkflowMessageService _workflowMessageService;
protected readonly FruitBankNotificationService _fruitBankNotificationService;
protected readonly IAddressService _addressService;
private readonly FruitBankOrderItemService _orderItemService;
private static readonly char[] _separator = [','];
// ... other dependencies
@ -144,7 +145,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
MeasurementService measurementService,
IWorkflowMessageService workflowMessageService,
FruitBankNotificationService fruitBankNotificationService,
IAddressService addressService)
IAddressService addressService,
FruitBankOrderItemService orderItemService)
{
_logger = new Logger<CustomOrderController>(logWriters.ToArray());
@ -176,6 +178,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
_workflowMessageService = workflowMessageService;
_fruitBankNotificationService = fruitBankNotificationService;
_addressService = addressService;
_orderItemService = orderItemService;
// ... initialize other deps
@ -260,7 +263,77 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/List.cshtml", model);
}
[HttpPost]
[HttpPost]
public virtual async Task<IActionResult> AdminQuickCreateOrder(int customerId, string orderProductsJson, string deliveryDateTime)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE))
return Json(new { success = false, error = "Hozzáférés megtagadva" });
try
{
var customer = await _customerService.GetCustomerByIdAsync(customerId);
if (customer == null) return Json(new { success = false, error = "Az ügyfél nem található" });
var billingAddress = await _customerService.GetCustomerBillingAddressAsync(customer);
if (billingAddress == null)
{
var addresses = await _customerService.GetAddressesByCustomerIdAsync(customer.Id);
if (addresses?.Count > 0) { billingAddress = addresses[0]; customer.BillingAddressId = billingAddress.Id; await _customerService.UpdateCustomerAsync(customer); }
else return Json(new { success = false, error = "Az ügyfélnek nincs számlázási címe" });
}
var orderProducts = string.IsNullOrEmpty(orderProductsJson)
? new List<OrderProductItem>()
: Newtonsoft.Json.JsonConvert.DeserializeObject<List<OrderProductItem>>(orderProductsJson);
var store = await _storeContext.GetCurrentStoreAsync();
var admin = await _workContext.GetCurrentCustomerAsync();
var order = new Order
{
OrderGuid = Guid.NewGuid(), CustomOrderNumber = "", CustomerId = customerId,
CustomerLanguageId = customer.LanguageId ?? 2,
CustomerTaxDisplayType = Nop.Core.Domain.Tax.TaxDisplayType.IncludingTax,
CustomerIp = string.Empty,
OrderStatus = Nop.Core.Domain.Orders.OrderStatus.Pending,
PaymentStatus = Nop.Core.Domain.Payments.PaymentStatus.Pending,
ShippingStatus = Nop.Core.Domain.Shipping.ShippingStatus.ShippingNotRequired,
CreatedOnUtc = DateTime.UtcNow,
BillingAddressId = customer.BillingAddressId ?? 0,
ShippingAddressId = customer.ShippingAddressId,
PaymentMethodSystemName = "Payments.CheckMoneyOrder",
CustomerCurrencyCode = "HUF", CurrencyRate = 1,
OrderTotal = 0, OrderSubtotalInclTax = 0, OrderSubtotalExclTax = 0,
OrderSubTotalDiscountInclTax = 0, OrderSubTotalDiscountExclTax = 0,
};
var ok = await _dbContext.TransactionSafeAsync(async _ =>
{
await _orderService.InsertOrderAsync(order);
order.CustomOrderNumber = order.Id.ToString();
await AddOrderItemsThenUpdateOrder(order, orderProducts, true, customer, store, admin);
return true;
});
if (!ok) return Json(new { success = false, error = "Rendelés létrehozása meghiúsult" });
if (!string.IsNullOrWhiteSpace(deliveryDateTime) && DateTime.TryParse(deliveryDateTime, out var deliveryDate))
{
var formatted = deliveryDate.ToString("MM/dd/yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(
order.Id, nameof(IOrderDto.DateOfReceipt), formatted, store.Id);
}
_logger.Info($"[AdminQuickCreateOrder] Order #{order.Id} for customer #{customerId}");
return Json(new { success = true, orderId = order.Id });
}
catch (Exception ex)
{
_logger.Error($"[AdminQuickCreateOrder] {ex.Message}", ex);
return Json(new { success = false, error = ex.Message });
}
}
[HttpPost]
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
public async Task<IActionResult> OrderList(OrderSearchModelExtended searchModel)
{
@ -654,25 +727,24 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
await _orderService.InsertOrderItemAsync(orderItem);
await _productService.AdjustInventoryAsync(product, -orderItem.Quantity, orderItem.AttributesXml, string.Format(await _localizationService.GetResourceAsync("Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id));
var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: true);
var unitPriceInclTaxValue = priceCalculation.finalPrice;
// Use the order item values directly — these already reflect any manual
// price override from CreateOrderItem, unlike a fresh GetFinalPriceAsync call.
order.OrderSubtotalInclTax += orderItem.UnitPriceInclTax * orderItem.Quantity;
order.OrderSubtotalExclTax += orderItem.UnitPriceExclTax * orderItem.Quantity;
var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer);
// Discount only applies when price was NOT manually overridden.
if (unitPricesIncludeDiscounts)
{
var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: true);
var appliedDiscounts = priceCalculation.appliedDiscountAmount;
order.OrderSubTotalDiscountInclTax += appliedDiscounts * orderItem.Quantity;
order.OrderSubTotalDiscountExclTax += appliedDiscounts * orderItem.Quantity;
}
order.OrderSubtotalInclTax += unitPriceInclTaxValue * orderItem.Quantity;
order.OrderSubtotalExclTax += unitPriceExclTaxValue * orderItem.Quantity;
var appliedDiscounts = priceCalculation.appliedDiscountAmount;
var totalDiscountInclTax = appliedDiscounts * orderProductItem.Quantity;
var totalDiscountExclTax = appliedDiscounts * orderProductItem.Quantity;
order.OrderSubTotalDiscountInclTax += totalDiscountInclTax;
order.OrderSubTotalDiscountExclTax += totalDiscountExclTax;
//order.OrderTax
//order.TaxRates
order.OrderTotal += orderItem.PriceInclTax + order.OrderShippingInclTax + order.PaymentMethodAdditionalFeeInclTax;
// OrderTotal: add item price only. Shipping and payment fees are NOT added
// per item — they are already in the order total from creation and should
// not be multiplied by the number of items being added.
order.OrderTotal += orderItem.PriceInclTax;
}
await _orderService.UpdateOrderAsync(order);
@ -737,15 +809,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
};
}
public interface IOrderProductItemBase
{
/// <summary>
/// ProductId
/// </summary>
public int Id { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
}
// IOrderProductItemBase is defined in Models/Orders/IOrderProductItemBase.cs
// and used as the shared contract across CustomOrderController and FruitBankOrderItemService.
public class OrderProductItem : IOrderProductItemBase
{
@ -995,12 +1060,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{
result.Add(new
{
label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]",
label = $"{product.Name} [RENDELHETŐ: {productDto.AvailableQuantity} (R:{productDto.StockQuantity}/K:{productDto.IncomingQuantity})] [ÁR: {product.Price}]",
value = product.Id,
sku = product.Sku,
price = product.Price,
stockQuantity = product.StockQuantity,
incomingQuantity = productDto.IncomingQuantity,
availableQuantity = productDto.AvailableQuantity,
});
}
}

View File

@ -41,17 +41,15 @@
@await Html.PartialAsync("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Components/_WelcomeMessage.cshtml")
</div>
</div>
}
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardNewsAfter, additionalData = Model })
@if (!Model.IsLoggedInAsVendor && canManageOrders && canManageCustomers && canManageProducts && canManageReturnRequests)
{
<div class="row">
<div class="col-md-12">
@await Component.InvokeAsync(typeof(CommonStatisticsViewComponent))
@await Html.PartialAsync("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Components/_FruitBankDashboard.cshtml")
</div>
</div>
}
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardNewsAfter, additionalData = Model })
@* CommonStatisticsViewComponent removed — runs GetLowStockProductsAsync full table scan, causes SQL timeout *@
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardCommonstatisticsAfter, additionalData = Model })
@if (!Model.IsLoggedInAsVendor && (canManageOrders || canManageCustomers))
{
@ -83,35 +81,16 @@
</div>
}
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardOrderreportsAfter, additionalData = Model })
@if (!Model.IsLoggedInAsVendor && (canManageOrders || canManageProducts))
@if (!Model.IsLoggedInAsVendor && canManageOrders)
{
<div class="row">
@if (canManageOrders)
{
<div class="col-md-8">
@await Html.PartialAsync("~/Areas/Admin/Views/Home/_LatestOrders.cshtml")
</div>
}
<div class="col-md-4">
@if (canManageProducts)
{
@await Html.PartialAsync("~/Areas/Admin/Views/Home/_PopularSearchTermsReport.cshtml")
}
<div class="col-md-12">
@await Html.PartialAsync("~/Areas/Admin/Views/Home/_LatestOrders.cshtml")
</div>
</div>
}
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardLatestordersSearchtermsAfter, additionalData = Model })
@if (canManageOrders)
{
<div class="row">
<div class="col-md-6">
@await Html.PartialAsync("~/Areas/Admin/Views/Home/_BestsellersBriefReportByQuantity.cshtml")
</div>
<div class="col-md-6">
@await Html.PartialAsync("~/Areas/Admin/Views/Home/_BestsellersBriefReportByAmount.cshtml")
</div>
</div>
}
@* PopularSearchTermsReport and BestsellersBriefReports removed — not relevant to FruitBank warehouse workflow *@
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardBottom, additionalData = Model })
</div>
</div>

View File

@ -591,7 +591,7 @@ $(function () {
function addCreateProduct(item) {
if (createProducts.find(function (p) { return p.id === item.value; })) return;
createProducts.push({ id: item.value, name: item.label, quantity: 1, price: item.price || 0 });
createProducts.push({ id: item.value, name: item.label, quantity: 1, price: item.price || 0, maxQuantity: item.availableQuantity || 9999 });
renderCreateProducts();
}
@ -600,10 +600,12 @@ $(function () {
if (!createProducts.length) { $('#create-selected-products-section').hide(); return; }
$('#create-selected-products-section').show();
createProducts.forEach(function (p, i) {
var maxAttr = p.maxQuantity < 9999 ? ' max="' + p.maxQuantity + '"' : '';
var maxHint = p.maxQuantity < 9999 ? '<br><small class="text-muted">max: ' + p.maxQuantity + ' db</small>' : '';
$body.append(
'<tr>' +
'<td><strong>' + p.name + '</strong></td>' +
'<td><input type="number" class="form-control form-control-sm" min="1" value="' + p.quantity + '" data-idx="' + i + '" onchange="window._fbUpdateQty(this)"></td>' +
'<td><input type="number" class="form-control form-control-sm" min="1"' + maxAttr + ' value="' + p.quantity + '" data-idx="' + i + '" onchange="window._fbUpdateQty(this)">' + maxHint + '</td>' +
'<td><input type="text" class="form-control form-control-sm" value="' + p.price + '" data-idx="' + i + '" onchange="window._fbUpdatePrice(this)"></td>' +
'<td class="text-center"><button type="button" class="btn btn-danger btn-xs" onclick="window._fbRemoveProduct(' + i + ')"><i class="fas fa-trash"></i></button></td>' +
'</tr>'
@ -612,7 +614,13 @@ $(function () {
$('#create-order-products-json').val(JSON.stringify(createProducts));
}
window._fbUpdateQty = function (el) { createProducts[+el.dataset.idx].quantity = +el.value; renderCreateProducts(); };
window._fbUpdateQty = function (el) {
var idx = +el.dataset.idx, val = +el.value, max = createProducts[idx].maxQuantity || 9999;
if (val > max) { val = max; el.value = max; }
if (val < 1) { val = 1; el.value = 1; }
createProducts[idx].quantity = val;
$('#create-order-products-json').val(JSON.stringify(createProducts));
};
window._fbUpdatePrice = function (el) { createProducts[+el.dataset.idx].price = +el.value; renderCreateProducts(); };
window._fbRemoveProduct = function (i) { createProducts.splice(i, 1); renderCreateProducts(); };

View File

@ -13,7 +13,7 @@
</h1>
<div class="float-right">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#create-preorder-window">
<i class="fas fa-plus"></i> Előrendelés rögzítése
<i class="fas fa-plus"></i> Rendelés / Előrendelés rögzítése
</button>
</div>
</div>
@ -21,7 +21,6 @@
<section class="content">
<div class="container-fluid">
<!-- ── Tabs ─────────────────────────────────────────────────────── -->
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-list-link" data-toggle="tab" href="#tab-list" role="tab">
@ -38,10 +37,7 @@
<div class="tab-content">
<!-- ══ TAB 1: Preorder list ══════════════════════════════════ -->
<div class="tab-pane fade show active" id="tab-list" role="tabpanel">
<!-- Status filter bar -->
<div class="card card-default mb-3">
<div class="card-body py-2">
<div class="d-flex align-items-center" style="gap:8px; flex-wrap:wrap;">
@ -54,8 +50,6 @@
</div>
</div>
</div>
<!-- Main grid -->
<div class="card card-default">
<div class="card-body p-0">
<table id="po-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
@ -63,23 +57,19 @@
<tr>
<th width="60">#</th>
<th>Ügyfél</th>
<th width="160" name="DateOfReceipt">Kért szállítás</th>
<th width="180" name="DateOfReceipt">Kért szállítás</th>
<th width="160" name="CreatedOnUtc">Leadva</th>
<th width="120" name="Status">Állapot</th>
<th width="100" class="text-center">Tételek</th>
<th width="70" class="text-center"></th>
<th width="70" class="text-center"></th>
</tr>
</thead>
</table>
</div>
</div>
</div>
</div><!-- /#tab-list -->
<!-- ══ TAB 2: Demand overview ════════════════════════════════ -->
<div class="tab-pane fade" id="tab-demand" role="tabpanel">
<!-- Toggle: open only / all time -->
<div class="card card-default mb-3">
<div class="card-body py-2">
<div class="d-flex align-items-center" style="gap:8px;">
@ -90,13 +80,10 @@
<button class="btn btn-sm btn-outline-secondary demand-scope" data-open="false">
<i class="fas fa-history"></i> Összes idő
</button>
<small class="text-muted ml-3" id="demandScopeLabel">
Termékek amelyekre még van teljesítetlen igény
</small>
<small class="text-muted ml-3" id="demandScopeLabel">Termékek amelyekre még van teljesítetlen igény</small>
</div>
</div>
</div>
<div class="card card-default">
<div class="card-body p-0">
<table id="demand-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
@ -114,18 +101,14 @@
</table>
</div>
</div>
</div>
</div><!-- /#tab-demand -->
</div><!-- /.tab-content -->
</div>
</div>
</section>
<style>
/* Fix: jQuery UI autocomplete must appear above Bootstrap modals (z-index 1050) */
.ui-autocomplete { z-index: 1060 !important; }
.ui-autocomplete { z-index:1060 !important; max-height:220px; overflow-y:auto; overflow-x:hidden; }
.po-status-pending { background:#fff3cd; color:#856404; border-radius:4px; padding:2px 8px; font-size:12px; font-weight:600; }
.po-status-confirmed { background:#d4edda; color:#155724; border-radius:4px; padding:2px 8px; font-size:12px; font-weight:600; }
.po-status-partial { background:#fff8ee; color:#c87500; border-radius:4px; padding:2px 8px; font-size:12px; font-weight:600; }
@ -135,6 +118,36 @@
.demand-unfulfilled-high { color:#dc3545; font-weight:700; }
.demand-unfulfilled-mid { color:#c87500; font-weight:600; }
.demand-unfulfilled-ok { color:#6b7c6e; }
/* ── Urgency flag ─────────────────────────────────────────── */
.po-urgent-row td { background:#fff8e1 !important; }
.po-urgent-badge {
display:inline-block; background:#dc3545; color:#fff;
border-radius:4px; padding:1px 7px; font-size:11px;
font-weight:700; margin-left:6px; vertical-align:middle;
animation: urgentPulse 1.5s ease-in-out infinite;
}
@@keyframes urgentPulse {
0%,100% { opacity:1; }
50% { opacity:.55; }
}
/* ── Modal mode banners ───────────────────────────────────── */
.cp-mode-order {
background:#d4edda; border:2px solid #28a745; border-radius:8px;
padding:14px 18px; margin-bottom:16px;
display:flex; align-items:center; gap:14px;
font-size:14px; color:#155724;
}
.cp-mode-preorder {
background:#fff8ee; border:2px solid #f4a236; border-radius:8px;
padding:14px 18px; margin-bottom:16px;
display:flex; align-items:center; gap:14px;
font-size:14px; color:#7a4200;
}
.cp-mode-icon { font-size:26px; flex-shrink:0; }
.cp-mode-title { font-size:16px; font-weight:800; display:block; margin-bottom:3px; }
.cp-mode-desc { font-size:12px; line-height:1.5; opacity:.9; }
</style>
<script>
@ -143,7 +156,6 @@ $(function () {
var activeStatus = '';
var demandOpenOnly = true;
// ── Helpers ──────────────────────────────────────────────────────────────
function statusBadge(row) {
switch (row.Status) {
case 0: return '<span class="po-status-pending">' + row.StatusLabel + '</span>';
@ -153,343 +165,348 @@ $(function () {
default: return row.StatusLabel;
}
}
function itemProgress(row) {
var total = row.ItemCount;
var done = row.FulfilledCount;
if (total === 0) return '—';
var total = row.ItemCount, done = row.FulfilledCount;
if (!total) return '—';
var pct = Math.round(done / total * 100);
var cls = pct === 100 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger';
return '<div style="min-width:80px">' +
'<div class="progress" style="height:6px;margin-bottom:3px;">' +
return '<div style="min-width:80px"><div class="progress" style="height:6px;margin-bottom:3px;">' +
'<div class="progress-bar ' + cls + '" style="width:' + pct + '%"></div></div>' +
'<small>' + done + '/' + total + ' tétel</small></div>';
}
function fmtQty(n) { return n.toLocaleString('hu-HU') + ' db'; }
function fmtQty(n) { return n.toLocaleString('hu-HU') + ' db'; }
function fmtPrice(n) { return n > 0 ? Math.round(n).toLocaleString('hu-HU') + ' Ft' : '—'; }
function unfulfilledCell(n) {
var cls = n > 100 ? 'demand-unfulfilled-high' : n > 20 ? 'demand-unfulfilled-mid' : 'demand-unfulfilled-ok';
return '<span class="' + cls + '">' + fmtQty(n) + '</span>';
}
// ── TAB 1: Preorder list ─────────────────────────────────────────────────
// Urgency: not fully fulfilled + DateOfReceipt within 4 days
function isUrgentRow(row) {
if (row.Status === 30) return false;
if (row.ItemCount > 0 && row.FulfilledCount >= row.ItemCount) return false;
if (!row.DateOfReceipt) return false;
var m = row.DateOfReceipt.match(/(\d{4})\.(\d{2})\.(\d{2})/);
if (!m) return false;
var delivery = new Date(+m[1], +m[2]-1, +m[3]);
var today = new Date(); today.setHours(0,0,0,0);
var diff = Math.ceil((delivery - today) / 86400000);
return diff >= 0 && diff <= 4;
}
// ── Preorder list grid ──────────────────────────────────────────────────
var poTable = $('#po-grid').DataTable({
serverSide : true,
processing : true,
pageLength : 25,
lengthMenu : [[25, 50, 100], [25, 50, 100]],
order : [[3, 'desc']],
language : {
processing : 'Betöltés...',
search : 'Keresés:',
lengthMenu : '_MENU_ sor/oldal',
info : '_START__END_ / _TOTAL_ előrendelés',
infoEmpty : '0 előrendelés',
infoFiltered : '(szűrve _MAX_-ból)',
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
emptyTable : 'Nincs előrendelés',
zeroRecords : 'Nincs találat'
},
ajax: {
url : '/Admin/Preorders/PreorderList',
type: 'POST',
data: function (d) {
d.__RequestVerificationToken = _token;
d.statusFilter = activeStatus;
}
serverSide: true, processing: true, pageLength: 25,
lengthMenu: [[25,50,100],[25,50,100]], order: [[3,'desc']],
language: { processing:'Betöltés...', search:'Keresés:', lengthMenu:'_MENU_ sor/oldal',
info:'_START__END_ / _TOTAL_ előrendelés', infoEmpty:'0 előrendelés',
infoFiltered:'(szűrve _MAX_-ból)', emptyTable:'Nincs előrendelés', zeroRecords:'Nincs találat',
paginate:{first:'««',previous:'«',next:'»',last:'»»'} },
ajax: { url:'/Admin/Preorders/PreorderList', type:'POST',
data: function(d){ d.__RequestVerificationToken=_token; d.statusFilter=activeStatus; } },
createdRow: function(row, data) {
if (isUrgentRow(data)) $(row).addClass('po-urgent-row');
},
columns: [
{ data: 'PreorderId', name: 'PreorderId',
render: function(d) { return '<strong>#' + d + '</strong>'; } },
{ data: 'CustomerName', name: 'CustomerName',
render: function(d, t, row) {
return '<div>' + d + '</div><small class="text-muted">' + row.CustomerEmail + '</small>';
}},
{ data: 'DateOfReceipt', name: 'DateOfReceipt',
render: function(d) { return '<i class="fas fa-calendar-day text-muted mr-1"></i>' + d; }},
{ data: 'CreatedOnUtc', name: 'CreatedOnUtc',
render: function(d) { return '<small>' + d + '</small>'; }},
{ data: 'Status', name: 'Status', orderable: false,
render: function(d, t, row) { return statusBadge(row); }},
{ data: 'ItemCount', orderable: false, className: 'text-center',
render: function(d, t, row) { return itemProgress(row); }},
{ data: 'PreorderId', orderable: false, searchable: false,
className: 'text-center', width: '60px',
render: function(d) {
return '<a href="/Admin/Preorders/Detail/' + d + '" class="btn btn-xs btn-default" title="Részletek">' +
'<i class="fas fa-eye"></i></a>';
}}
{ data:'PreorderId', name:'PreorderId', render:function(d){ return '<strong>#'+d+'</strong>'; } },
{ data:'CustomerName', name:'CustomerName', render:function(d,t,row){ return '<div>'+d+'</div><small class="text-muted">'+row.CustomerEmail+'</small>'; } },
{ data:'DateOfReceipt',name:'DateOfReceipt',render:function(d,t,row){
var icon = '<i class="fas fa-calendar-day text-muted mr-1"></i>';
var urgent = isUrgentRow(row) ? '<span class="po-urgent-badge">⚠ Azonnali figyelmet igényel</span>' : '';
return icon + d + urgent;
} },
{ data:'CreatedOnUtc', name:'CreatedOnUtc', render:function(d){ return '<small>'+d+'</small>'; } },
{ data:'Status', name:'Status', orderable:false, render:function(d,t,row){ return statusBadge(row); } },
{ data:'ItemCount', orderable:false, className:'text-center', render:function(d,t,row){ return itemProgress(row); } },
{ data:'PreorderId', orderable:false, searchable:false, className:'text-center', width:'60px',
render:function(d){ return '<a href="/Admin/Preorders/Detail/'+d+'" class="btn btn-xs btn-default" title="Részletek"><i class="fas fa-eye"></i></a>'; } }
]
});
$(document).on('click', '.po-filter', function () {
$('.po-filter').removeClass('active');
$(this).addClass('active');
activeStatus = $(this).data('status').toString();
poTable.ajax.reload();
$(document).on('click','.po-filter',function(){
$('.po-filter').removeClass('active'); $(this).addClass('active');
activeStatus = $(this).data('status').toString(); poTable.ajax.reload();
});
// ── TAB 2: Demand grid ───────────────────────────────────────────────────
// ── Demand grid ─────────────────────────────────────────────────────────
var demandTable = $('#demand-grid').DataTable({
serverSide : true,
processing : true,
pageLength : 50,
lengthMenu : [[25, 50, 100, 250], [25, 50, 100, 250]],
order : [[4, 'desc']],
language : {
processing : 'Betöltés...',
search : 'Keresés:',
lengthMenu : '_MENU_ sor/oldal',
info : '_START__END_ / _TOTAL_ termék',
infoEmpty : 'Nincs adat',
infoFiltered : '(szűrve _MAX_-ból)',
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
emptyTable : 'Nincs előrendelési igény',
zeroRecords : 'Nincs találat'
},
ajax: {
url : '/Admin/Preorders/DemandList',
type: 'POST',
data: function (d) {
d.__RequestVerificationToken = _token;
d.openOnly = demandOpenOnly ? 'true' : 'false';
},
dataSrc: function (json) {
// Update badge with number of products that have unfulfilled demand
var withDemand = (json.data || []).filter(function (r) { return r.TotalUnfulfilled > 0; }).length;
if (withDemand > 0) { $('#demandBadge').text(withDemand).show(); }
else { $('#demandBadge').hide(); }
return json.data;
}
},
columns: [
{ data: 'ProductName', name: 'ProductName',
render: function(d, t, row) {
var badge = row.IsMeasurable ? ' <span class="badge badge-light" title="Súlymérést igényel">⚖️</span>' : '';
return '<a href="/Admin/Product/Edit/' + row.ProductId + '" target="_blank">' + d + '</a>' + badge;
}},
{ data: 'Sku', orderable: false,
render: function(d) { return d ? '<code>' + d + '</code>' : ''; }},
{ data: 'TotalRequested', orderable: false, className: 'text-center',
render: function(d) { return fmtQty(d); }},
{ data: 'TotalFulfilled', orderable: false, className: 'text-center',
render: function(d, t, row) {
var pct = row.TotalRequested > 0 ? Math.round(d / row.TotalRequested * 100) : 0;
var cls = pct === 100 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-secondary';
return '<div>' + fmtQty(d) + '</div>' +
'<div class="progress mt-1" style="height:4px;">' +
'<div class="progress-bar ' + cls + '" style="width:' + pct + '%"></div></div>';
}},
{ data: 'TotalUnfulfilled', orderable: false, className: 'text-center',
render: function(d) { return unfulfilledCell(d); }},
{ data: 'PreorderCount', orderable: false, className: 'text-center',
render: function(d) { return '<span class="badge badge-secondary">' + d + '</span>'; }},
{ data: 'AvgUnitPrice', orderable: false, className: 'text-right',
render: function(d) { return fmtPrice(d); }}
serverSide:true, processing:true, pageLength:50,
lengthMenu:[[25,50,100,250],[25,50,100,250]], order:[[4,'desc']],
language:{ processing:'Betöltés...', search:'Keresés:', lengthMenu:'_MENU_ sor/oldal',
info:'_START__END_ / _TOTAL_ termék', infoEmpty:'Nincs adat',
infoFiltered:'(szűrve _MAX_-ból)', emptyTable:'Nincs előrendelési igény', zeroRecords:'Nincs találat',
paginate:{first:'««',previous:'«',next:'»',last:'»»'} },
ajax:{ url:'/Admin/Preorders/DemandList', type:'POST',
data:function(d){ d.__RequestVerificationToken=_token; d.openOnly=demandOpenOnly?'true':'false'; },
dataSrc:function(json){
var n=(json.data||[]).filter(function(r){return r.TotalUnfulfilled>0;}).length;
n>0?$('#demandBadge').text(n).show():$('#demandBadge').hide();
return json.data;
} },
columns:[
{ data:'ProductName', name:'ProductName',
render:function(d,t,row){ var b=row.IsMeasurable?' <span class="badge badge-light" title="Súlymérést igényel">⚖️</span>':'';
return '<a href="/Admin/Product/Edit/'+row.ProductId+'" target="_blank">'+d+'</a>'+b; } },
{ data:'Sku', orderable:false, render:function(d){ return d?'<code>'+d+'</code>':''; } },
{ data:'TotalRequested', orderable:false, className:'text-center', render:function(d){ return fmtQty(d); } },
{ data:'TotalFulfilled', orderable:false, className:'text-center',
render:function(d,t,row){ var pct=row.TotalRequested>0?Math.round(d/row.TotalRequested*100):0;
var cls=pct===100?'bg-success':pct>0?'bg-warning':'bg-secondary';
return '<div>'+fmtQty(d)+'</div><div class="progress mt-1" style="height:4px;"><div class="progress-bar '+cls+'" style="width:'+pct+'%"></div></div>'; } },
{ data:'TotalUnfulfilled',orderable:false, className:'text-center', render:function(d){ return unfulfilledCell(d); } },
{ data:'PreorderCount', orderable:false, className:'text-center', render:function(d){ return '<span class="badge badge-secondary">'+d+'</span>'; } },
{ data:'AvgUnitPrice', orderable:false, className:'text-right', render:function(d){ return fmtPrice(d); } }
]
});
// Load demand tab lazily on first click, reload on subsequent
var demandLoaded = false;
$('#tab-demand-link').on('shown.bs.tab', function () {
if (!demandLoaded) { demandTable.ajax.reload(); demandLoaded = true; }
else { demandTable.ajax.reload(); }
$('#tab-demand-link').on('shown.bs.tab',function(){
if (!demandLoaded){ demandTable.ajax.reload(); demandLoaded=true; } else demandTable.ajax.reload();
});
// Scope toggle
$(document).on('click', '.demand-scope', function () {
$('.demand-scope').removeClass('active');
$(this).addClass('active');
demandOpenOnly = $(this).data('open') === true;
$(document).on('click','.demand-scope',function(){
$('.demand-scope').removeClass('active'); $(this).addClass('active');
demandOpenOnly = $(this).data('open')===true;
$('#demandScopeLabel').text(demandOpenOnly
? 'Termékek amelyekre még van teljesítetlen igény'
: 'Összesített kereslet az összes előrendelésből');
?'Termékek amelyekre még van teljesítetlen igény'
:'Összesített kereslet az összes előrendelésből');
demandTable.ajax.reload();
});
/* ── Create Preorder Modal ───────────────────────────────────── */
// ── Create Order / Preorder Modal (mode-aware) ──────────────────────────
var cpProducts = [];
var cpMode = null;
var CP_CUTOFF = 4; // ≤4 days → order, >4 days → preorder
function cpComputeMode(s) {
if (!s) return null;
var d=new Date(s), t=new Date(); t.setHours(0,0,0,0);
return Math.ceil((d-t)/86400000) <= CP_CUTOFF ? 'order' : 'preorder';
}
function cpApplyMode(mode) {
cpMode = mode;
var isOrder = mode==='order';
$('.modal-header','#create-preorder-window').css('background', isOrder ? '#155724' : '#2d7a3a');
if (isOrder) {
$('#cp-mode-banner').attr('class','cp-mode-order')
.html('<div class="cp-mode-icon">🛒</div><div>' +
'<span class="cp-mode-title">RENDELÉS</span>' +
'<span class="cp-mode-desc">4 napon belül — csak raktáron lévő termékek. Rendelés azonnal létrejön.</span></div>').show();
$('#cp-product-search-hint').text('Csak az elérhető készlettel rendelkező termékek');
$('#cp-submit-btn').html('<i class="fas fa-shopping-cart"></i> Rendelés létrehozása')
.removeClass('btn-primary').addClass('btn-success');
} else {
$('#cp-mode-banner').attr('class','cp-mode-preorder')
.html('<div class="cp-mode-icon">📋</div><div>' +
'<span class="cp-mode-title">ELŐRENDELÉS</span>' +
'<span class="cp-mode-desc">Több mint 4 nap — előrendelési ablak termékei. A szállítmány után automatikusan rendeléssé alakul.</span></div>').show();
$('#cp-product-search-hint').text('Előrendelési ablakban elérhető termékek');
$('#cp-submit-btn').html('<i class="fas fa-calendar-plus"></i> Előrendelés rögzítése')
.removeClass('btn-success').addClass('btn-primary');
}
if ($('#cp-customer-id').val()) $('#cp-product-search-section').slideDown();
}
$('#cp-delivery').on('change input', function () {
var nm = cpComputeMode($(this).val());
if (nm !== cpMode) {
if (cpProducts.length) { cpProducts=[]; renderCpProducts(); $('#cp-product-search-section').hide(); }
if (nm) cpApplyMode(nm); else { $('#cp-mode-banner').hide(); cpMode=null; }
}
});
// Customer autocomplete
$('#cp-customer-search').autocomplete({
delay : 400,
minLength: 2,
source : '/Admin/CustomOrder/CustomerSearchAutoComplete',
select : function (e, ui) {
delay:400, minLength:2,
source:'/Admin/CustomOrder/CustomerSearchAutoComplete',
select:function(e,ui){
$('#cp-customer-id').val(ui.item.value);
$('#cp-customer-name').html('<strong>' + ui.item.label + '</strong>');
$('#cp-customer-name').html('<strong>'+ui.item.label+'</strong>');
$('#cp-customer-search').val('');
$('#cp-product-search-section').slideDown();
$('#cp-customer-error').hide();
if (cpMode) $('#cp-product-search-section').slideDown();
return false;
}
});
// Product autocomplete — filtered by active preorder window (same logic as customer-facing page)
$('#cp-product-search').autocomplete({
delay : 400,
minLength: 2,
source : '/Admin/CustomOrder/PreorderProductSearchAutoComplete',
select : function (e, ui) {
addCpProduct(ui.item);
$('#cp-product-search').val('');
return false;
}
delay:400, minLength:2,
source:function(req,resp){
if (!cpMode){ resp([]); return; }
$.get(cpMode==='order'
?'/Admin/CustomOrder/ProductSearchAutoComplete'
:'/Admin/CustomOrder/PreorderProductSearchAutoComplete',
{term:req.term}, resp);
},
select:function(e,ui){ addCpProduct(ui.item); $('#cp-product-search').val(''); return false; }
});
function addCpProduct(item) {
if (cpProducts.find(function (p) { return p.id === item.value; })) return;
cpProducts.push({ id: item.value, name: item.label, quantity: 1, price: item.price || 0 });
if (cpProducts.find(function(p){ return p.id===item.value; })) return;
cpProducts.push({
id: item.value,
name: item.label,
sku: item.sku || '',
quantity: 1,
price: item.price || 0,
maxQuantity: item.availableQuantity || 9999
});
renderCpProducts();
}
function renderCpProducts() {
var $body = $('#cp-products-body').empty();
if (!cpProducts.length) { $('#cp-products-section').hide(); return; }
var $b=$('#cp-products-body').empty();
if (!cpProducts.length){
$('#cp-products-section').hide();
$('#cp-submit-btn').prop('disabled', true);
return;
}
$('#cp-products-section').show();
cpProducts.forEach(function (p, i) {
$body.append(
'<tr>' +
'<td><strong>' + p.name + '</strong></td>' +
'<td><input type="number" class="form-control form-control-sm" min="1" value="' + p.quantity + '" data-idx="' + i + '" onchange="window._cpUpdateQty(this)"></td>' +
'<td><input type="text" class="form-control form-control-sm" value="' + p.price + '" data-idx="' + i + '" onchange="window._cpUpdatePrice(this)"></td>' +
'<td class="text-center"><button type="button" class="btn btn-danger btn-xs" onclick="window._cpRemove(' + i + ')"><i class="fas fa-trash"></i></button></td>' +
'</tr>'
);
$('#cp-submit-btn').prop('disabled', false);
cpProducts.forEach(function(p,i){
var maxAttr = p.maxQuantity < 9999 ? ' max="'+p.maxQuantity+'"' : '';
var maxHint = p.maxQuantity < 9999 ? '<br><small class="text-muted">max: '+p.maxQuantity+' db</small>' : '';
$b.append('<tr>' +
'<td><strong>'+p.name+'</strong>'+(p.sku?'<br><small class="text-muted">'+p.sku+'</small>':'')+'</td>'+
'<td><input type="number" class="form-control form-control-sm" min="1"'+maxAttr+' value="'+p.quantity+'" data-idx="'+i+'" onchange="window._cpUpdateQty(this)">'+maxHint+'</td>'+
'<td><input type="text" class="form-control form-control-sm" value="'+p.price+'" data-idx="'+i+'" onchange="window._cpUpdatePrice(this)"></td>'+
'<td class="text-center"><button type="button" class="btn btn-danger btn-xs" onclick="window._cpRemove('+i+')"><i class="fas fa-trash"></i></button></td>'+
'</tr>');
});
$('#cp-products-json').val(JSON.stringify(cpProducts));
}
window._cpUpdateQty = function(el) {
var idx=+el.dataset.idx, val=+el.value, max=cpProducts[idx].maxQuantity||9999;
if (val>max){val=max;el.value=max;} if (val<1){val=1;el.value=1;}
cpProducts[idx].quantity=val; $('#cp-products-json').val(JSON.stringify(cpProducts));
};
window._cpUpdatePrice = function(el){ cpProducts[+el.dataset.idx].price=+el.value; $('#cp-products-json').val(JSON.stringify(cpProducts)); };
window._cpRemove = function(i) { cpProducts.splice(i,1); renderCpProducts(); };
window._cpUpdateQty = function (el) { cpProducts[+el.dataset.idx].quantity = +el.value; renderCpProducts(); };
window._cpUpdatePrice = function (el) { cpProducts[+el.dataset.idx].price = +el.value; renderCpProducts(); };
window._cpRemove = function (i) { cpProducts.splice(i, 1); renderCpProducts(); };
$('#cp-form').on('submit', function (e) {
$('#cp-form').on('submit', function(e){
e.preventDefault();
if (!$('#cp-customer-id').val()) {
$('#cp-customer-error').show(); return;
}
if (!cpProducts.length) {
alert('Legalább egy terméket adj hozzá!'); return;
}
if (!$('#cp-delivery').val()) {
alert('Add meg a szállítási időpontot!'); return;
}
var btn = $(this).find('[type=submit]').prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Mentés...');
$.ajax({
url : '/Admin/Preorders/CreatePreorder',
type: 'POST',
data: {
customerId : $('#cp-customer-id').val(),
deliveryDateTime : $('#cp-delivery').val(),
customerNote : $('#cp-note').val().trim(),
productsJson : $('#cp-products-json').val(),
__RequestVerificationToken: _token
},
success: function (r) {
if (r.success) {
if (!cpMode) { alert('Válassz szállítási időpontot!'); return; }
if (!$('#cp-customer-id').val()) { $('#cp-customer-error').show(); return; }
if (!cpProducts.length) { alert('Legalább egy terméket adj hozzá!'); return; }
var $btn=$('#cp-submit-btn').prop('disabled',true).html('<i class="fas fa-spinner fa-spin"></i> Mentés...');
if (cpMode==='order') {
$.post('/Admin/CustomOrder/AdminQuickCreateOrder',{
customerId: $('#cp-customer-id').val(),
orderProductsJson:$('#cp-products-json').val(),
deliveryDateTime: $('#cp-delivery').val(),
__RequestVerificationToken:_token
},function(r){
if (r&&(r.success||r.orderId)){
$('#create-preorder-window').modal('hide');
poTable.ajax.reload();
demandTable.ajax.reload();
if (r.orderId) {
alert('Előrendelés rögzítve (#' + r.preorderId + '). Az azonnal elérhető tételek alapján rendelés is készült: #' + r.orderId);
}
alert('Rendelés létrehozva: #'+(r.orderId||r.id||''));
} else {
alert('Hiba: ' + (r.error || 'Ismeretlen hiba'));
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Mentés');
alert('Hiba: '+((r&&(r.error||r.message))||'Ismeretlen hiba'));
$btn.prop('disabled',false).html('<i class="fas fa-shopping-cart"></i> Rendelés létrehozása');
}
},
error: function () {
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Mentés');
}
});
});
} else {
$.ajax({url:'/Admin/Preorders/CreatePreorder', type:'POST',
data:{ customerId:$('#cp-customer-id').val(), deliveryDateTime:$('#cp-delivery').val(),
customerNote:$('#cp-note').val().trim(), productsJson:$('#cp-products-json').val(),
__RequestVerificationToken:_token },
success:function(r){
if (r.success){
$('#create-preorder-window').modal('hide');
poTable.ajax.reload(); demandTable.ajax.reload();
alert('Előrendelés rögzítve (#'+r.preorderId+').'+
(r.orderId?' Rendelés is készült: #'+r.orderId:''));
} else {
alert('Hiba: '+(r.error||'Ismeretlen hiba'));
$btn.prop('disabled',false).html('<i class="fas fa-calendar-plus"></i> Előrendelés rögzítése');
}
},
error:function(){ $btn.prop('disabled',false).html('<i class="fas fa-calendar-plus"></i> Előrendelés rögzítése'); }
});
}
});
$('#create-preorder-window').on('hidden.bs.modal', function () {
$('#cp-customer-search').val('');
$('#cp-customer-id, #cp-customer-name').val('').html('');
$('#cp-customer-error').hide();
$('#cp-product-search-section').hide();
$('#cp-delivery').val('');
$('#cp-note').val('');
cpProducts = [];
renderCpProducts();
$('#create-preorder-window').on('hidden.bs.modal', function(){
$('#cp-customer-search').val(''); $('#cp-customer-id').val(''); $('#cp-customer-name').html('');
$('#cp-customer-error').hide(); $('#cp-mode-banner').hide();
$('#cp-product-search-section,#cp-products-section').hide();
$('#cp-delivery,#cp-note').val('');
cpProducts=[]; cpMode=null; renderCpProducts();
$('.modal-header','#create-preorder-window').css('background','#2d7a3a');
$('#cp-submit-btn').html('<i class="fas fa-save"></i> Mentés')
.removeClass('btn-success btn-primary').addClass('btn-secondary').prop('disabled',false);
});
});
</script>
@* ── Create Preorder Modal ──────────────────────────────────────────────── *@
@* ── Modal ─────────────────────────────────────────────────────────────── *@
<div id="create-preorder-window" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header" style="background:#2d7a3a;color:#fff;">
<h4 class="modal-title"><i class="fas fa-calendar-plus"></i> Előrendelés rögzítése (telefónos)</h4>
<h4 class="modal-title"><i class="fas fa-receipt"></i> Rendelés / Előrendelés rögzítése</h4>
<button type="button" class="close" data-dismiss="modal" style="color:#fff;"><span>&times;</span></button>
</div>
<form id="cp-form">
<div class="form-horizontal">
<div class="modal-body">
<div class="modal-body">
@* ─ Customer ─ *@
<div class="form-group row">
<div class="col-md-3"><label class="col-form-label">Ugyfél</label></div>
<div class="col-md-9">
<input type="text" id="cp-customer-search" autocomplete="off" class="form-control" placeholder="Ugyfél neve, e-mail vagy cég neve..." />
<span id="cp-customer-name" class="mt-1 d-block text-success"></span>
<input type="hidden" id="cp-customer-id" />
<span class="field-validation-error" id="cp-customer-error" style="display:none;">Kérjük válasszon ügyfelet</span>
</div>
<div class="form-group row">
<label class="col-md-3 col-form-label">Ügyfél</label>
<div class="col-md-9">
<input type="text" id="cp-customer-search" autocomplete="off" class="form-control"
placeholder="Ügyfél neve, e-mail vagy cég neve..." />
<span id="cp-customer-name" class="mt-1 d-block text-success"></span>
<input type="hidden" id="cp-customer-id" />
<span class="field-validation-error" id="cp-customer-error" style="display:none;">Kérjük válasszon ügyfelet</span>
</div>
@* ─ Delivery date+time ─ *@
<div class="form-group row">
<div class="col-md-3"><label class="col-form-label">Szállítási időpont</label></div>
<div class="col-md-9">
<input type="datetime-local" id="cp-delivery" class="form-control" />
<small class="text-muted">Kívánt szállítási nap és időpont</small>
</div>
</div>
@* ─ Product search ─ *@
<div class="form-group row" id="cp-product-search-section" style="display:none;">
<div class="col-md-3"><label class="col-form-label">Termék hozzáadása</label></div>
<div class="col-md-9">
<input type="text" id="cp-product-search" autocomplete="off" class="form-control" placeholder="Termék neve vagy SKU..." />
<small class="text-muted">Csak az előrendelési ablakban szereplő termékek jelennek meg</small>
</div>
</div>
@* ─ Products table ─ *@
<div id="cp-products-section" style="display:none;">
<table class="table table-sm table-bordered" id="cp-products-table">
<thead>
<tr>
<th>Termék</th>
<th style="width:100px">Mennyiség</th>
<th style="width:120px">Egységár</th>
<th style="width:40px"></th>
</tr>
</thead>
<tbody id="cp-products-body"></tbody>
</table>
<input type="hidden" id="cp-products-json" />
</div>
@* ─ Note ─ *@
<div class="form-group row">
<div class="col-md-3"><label class="col-form-label">Megjegyzés <small class="text-muted">(nem köt.)</small></label></div>
<div class="col-md-9">
<textarea id="cp-note" class="form-control" rows="2" maxlength="1000" placeholder="Esetleges megjegyzés az előrendeléssel kapcsolatban..."></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Mégse</button>
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Mentés</button>
<div class="form-group row">
<label class="col-md-3 col-form-label">Szállítási időpont</label>
<div class="col-md-9">
<input type="datetime-local" id="cp-delivery" class="form-control" />
<small class="text-muted">
≤4 nap → <strong>Rendelés</strong> &nbsp;|&nbsp; &gt;4 nap → <strong>Előrendelés</strong>
</small>
</div>
</div>
<div id="cp-mode-banner" style="display:none;"></div>
<div class="form-group row" id="cp-product-search-section" style="display:none;">
<label class="col-md-3 col-form-label">Termék hozzáadása</label>
<div class="col-md-9">
<input type="text" id="cp-product-search" autocomplete="off" class="form-control"
placeholder="Termék neve vagy SKU..." />
<small class="text-muted" id="cp-product-search-hint"></small>
</div>
</div>
<div id="cp-products-section" style="display:none;">
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Termék</th>
<th style="width:120px">Mennyiség</th>
<th style="width:120px">Egységár</th>
<th style="width:40px"></th>
</tr>
</thead>
<tbody id="cp-products-body"></tbody>
</table>
<input type="hidden" id="cp-products-json" />
</div>
<div class="form-group row">
<label class="col-md-3 col-form-label">Megjegyzés <small class="text-muted">(nem köt.)</small></label>
<div class="col-md-9">
<textarea id="cp-note" class="form-control" rows="2" maxlength="1000"
placeholder="Esetleges megjegyzés..."></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Mégse</button>
<button type="submit" id="cp-submit-btn" class="btn btn-secondary" disabled>
<i class="fas fa-save"></i> Mentés
</button>
</div>
</form>
</div>

View File

@ -39,6 +39,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
ICustomerService customerService,
ICustomerRegistrationService customerRegistrationService,
ILocalizationService localizationService,
PreorderConversionService preorderConversionService,
IEnumerable<IAcLogWriterBase> logWriters)
: BasePluginController, IFruitBankDataControllerServer
{
@ -241,6 +242,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
_logger.Detail($"AddShippingItem invoked; id: {shippingItem.Id}");
if (!await ctx.AddShippingItemAsync(shippingItem)) return null;
// Update IncomingQuantity — EventConsumer handles conversion separately
if (shippingItem.ProductId.HasValue && shippingItem.QuantityOnDocument > 0)
await preorderConversionService.SyncIncomingQuantityAsync(
shippingItem.ProductId.Value, 0, shippingItem.QuantityOnDocument);
return await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, true);
}
@ -251,7 +258,40 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
_logger.Detail($"UpdateShippingItem invoked; id: {shippingItem.Id}");
// Load BEFORE the update to capture previous ProductId and QuantityOnDocument
var oldItem = await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, false);
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
await preorderConversionService.ReplaceShippingItemProductAsync(
shippingItem.Id, shippingItem.ProductId.Value, oldItem);
}
else if (quantityChanged && shippingItem.ProductId.HasValue)
{
// Only quantity changed: sync IncomingQuantity delta
await preorderConversionService.SyncIncomingQuantityAsync(
shippingItem.ProductId.Value,
oldItem.QuantityOnDocument,
shippingItem.QuantityOnDocument);
// If quantity increased, trigger conversion
// (EventConsumer also fires this, double-call is idempotent)
if (shippingItem.QuantityOnDocument > oldItem.QuantityOnDocument)
_ = Task.Run(async () => await preorderConversionService
.ConvertPreordersForProductsAsync(
new List<int> { shippingItem.ProductId.Value },
shippingItem.ShippingDocumentId));
}
}
return await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, shippingItem.ShippingDocument != null);
}

View File

@ -3,6 +3,7 @@ using FruitBank.Common.Entities;
using FruitBank.Common.Interfaces;
using Mango.Nop.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Core.Events;
@ -38,14 +39,17 @@ public class FruitBankEventConsumer :
private readonly MeasurementService _measurementService;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly PreorderConversionService _preorderConversionService;
private readonly IServiceScopeFactory _serviceScopeFactory;
public FruitBankEventConsumer(IHttpContextAccessor httpContextAcc, FruitBankDbContext ctx, MeasurementService measurementService,
FruitBankAttributeService fruitBankAttributeService, PreorderConversionService preorderConversionService, IEnumerable<IAcLogWriterBase> logWriters) : base(ctx, httpContextAcc, logWriters)
FruitBankAttributeService fruitBankAttributeService, PreorderConversionService preorderConversionService,
IServiceScopeFactory serviceScopeFactory, IEnumerable<IAcLogWriterBase> logWriters) : base(ctx, httpContextAcc, logWriters)
{
_ctx = ctx;
_measurementService = measurementService;
_fruitBankAttributeService = fruitBankAttributeService;
_preorderConversionService = preorderConversionService;
_serviceScopeFactory = serviceScopeFactory;
}
public override async Task HandleEventAsync(EntityUpdatedEvent<Product> eventMessage)
@ -201,15 +205,16 @@ public class FruitBankEventConsumer :
{
_ = Task.Run(async () =>
{
try
{
await _preorderConversionService.ConvertPreordersForProductsAsync(
new List<int> { item.ProductId.Value }, item.ShippingDocumentId);
}
catch (Exception ex)
{
Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={item.ProductId}: {ex.Message}", ex);
}
// Suppress the ambient TransactionScope from the parent context —
// TransactionScope flows through async by default and would cause
// MSDTC promotion failures if the background task enlists in it.
using var suppress = new System.Transactions.TransactionScope(
System.Transactions.TransactionScopeOption.Suppress,
System.Transactions.TransactionScopeAsyncFlowOption.Enabled);
using var scope = _serviceScopeFactory.CreateScope();
var conversion = scope.ServiceProvider.GetRequiredService<PreorderConversionService>();
try { await conversion.ConvertPreordersForProductsAsync(new List<int> { item.ProductId.Value }, item.ShippingDocumentId); }
catch (Exception ex) { Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={item.ProductId}: {ex.Message}", ex); }
});
}
}
@ -229,15 +234,13 @@ public class FruitBankEventConsumer :
{
_ = Task.Run(async () =>
{
try
{
await _preorderConversionService.ConvertPreordersForProductsAsync(
new List<int> { shippingItem.ProductId.Value }, shippingItem.ShippingDocumentId);
}
catch (Exception ex)
{
Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={shippingItem.ProductId}: {ex.Message}", ex);
}
using var suppress = new System.Transactions.TransactionScope(
System.Transactions.TransactionScopeOption.Suppress,
System.Transactions.TransactionScopeAsyncFlowOption.Enabled);
using var scope = _serviceScopeFactory.CreateScope();
var conversion = scope.ServiceProvider.GetRequiredService<PreorderConversionService>();
try { await conversion.ConvertPreordersForProductsAsync(new List<int> { shippingItem.ProductId.Value }, shippingItem.ShippingDocumentId); }
catch (Exception ex) { Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={shippingItem.ProductId}: {ex.Message}", ex); }
});
}
}

View File

@ -1,4 +1,4 @@
//using AyCode.Core.Consts;
//using AyCode.Core.Consts;
//using Mango.Nop.Core;
namespace Nop.Plugin.Misc.FruitBankPlugin
@ -15,4 +15,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
// }
//}
public static class FruitBankPluginConst
{
/// <summary>
/// Preorders whose DateOfReceipt is further than this many days in the future
/// are NOT converted at the current conversion run.
/// Based on the bi-weekly truck cycle (~3-4 days between arrivals):
/// if delivery is more than 4 days away, the next truck will arrive before
/// that delivery date, and its document processing will be the correct trigger.
/// </summary>
public const int PreorderConversionWindowDays = 4;
}
}

View File

@ -138,6 +138,7 @@ public class PluginNopStartup : INopStartup
});
services.AddScoped<PdfToImageService>();
services.AddScoped<FruitBankNotificationService>();
services.AddScoped<FruitBankOrderItemService>();
services.AddScoped<PreorderConversionService>();
services.AddSingleton<IFileStorageProvider>(sp =>
new LocalFileStorageProvider() // Uses default wwwroot/uploads

View File

@ -0,0 +1,14 @@
namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders;
/// <summary>
/// Minimal contract for adding a product to an order.
/// Implemented by CustomOrderController.OrderProductItem, AddProductModel,
/// and any other DTO that needs to be passed to FruitBankOrderItemService.
/// </summary>
public interface IOrderProductItemBase
{
/// <summary>ProductId</summary>
int Id { get; set; }
int Quantity { get; set; }
decimal Price { get; set; }
}

View File

@ -170,6 +170,9 @@
</ItemGroup>
<ItemGroup>
<None Update="Areas\Admin\Components\_FruitBankDashboard.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\AppDownload\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

View File

@ -97,6 +97,16 @@ public class CustomPriceCalculationService : PriceCalculationService
{
_logger.Info($"orderItem.Id: {orderItem.Id}");
if (orderItem.UnitPriceInclTax == 0 || orderItem.UnitPriceExclTax == 0)
{
var orderDto = await _dbContext.OrderDtos.GetByIdAsync(orderItem.OrderId, false);
var customer = await _dbContext.Customers.GetByIdAsync(orderDto.CustomerId);
var product = await _dbContext.Products.GetByIdAsync(orderItem.ProductId);
var pr = await GetFinalPriceAsync(product, customer, _storeContext.GetCurrentStore(), null, 0, true, 1, null, null);
orderItem.UnitPriceInclTax = pr.finalPrice;
orderItem.UnitPriceExclTax = pr.finalPrice / (decimal)1.27;
}
var finalPrices = CalculateOrderItemFinalPrices(orderItem.Quantity, orderItem.UnitPriceInclTax, orderItem.UnitPriceExclTax, isMeasurable, netWeight);
if (finalPrices.finalPriceInclTax == orderItem.PriceInclTax && finalPrices.finalPriceExclTax == orderItem.PriceExclTax) return false;
@ -181,7 +191,12 @@ public class CustomPriceCalculationService : PriceCalculationService
// physical weighing. Until then we expose 0 so the cart and checkout total are honest.
// The actual PriceInclTax / PriceExclTax on OrderItem is set by
// CheckAndUpdateOrderItemFinalPricesAsync after the order is weighed.
return (0m, 0m, 0m, new System.Collections.Generic.List<Nop.Core.Domain.Discounts.Discount>());
//return (0m, 0m, 0m, new System.Collections.Generic.List<Nop.Core.Domain.Discounts.Discount>());
//finalPrice.priceWithoutDiscounts = 0;
//return (0, finalPrice.finalPrice, finalPrice.appliedDiscountAmount, []);
return finalPrice;
//return (overriddenProductPrice.GetValueOrDefault(0), overriddenProductPrice.GetValueOrDefault(0), 0m, []);
}
//var productAttributeMappings = await _specificationAttributeService.GetProductSpecificationAttributesAsync(product.Id);
////Product Attributes

View File

@ -266,7 +266,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
Url = _adminMenu.GetMenuItemUrl("PreorderAvailability", "Index")
};
shippingConfigurationItem.ChildNodes.Insert(4, preorderAvailabilityMenuItem);
//shippingConfigurationItem.ChildNodes.Insert(4, preorderAvailabilityMenuItem);
var preorderListMenuItem = new AdminMenuItem
{
@ -277,7 +277,21 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
Url = _adminMenu.GetMenuItemUrl("PreorderAdmin", "List")
};
shippingConfigurationItem.ChildNodes.Insert(5, preorderListMenuItem);
var preordersRootMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "FruitBank",
Title = "Előrendelés",
//Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.Preorders"), // You can localize this with await _localizationService.GetResourceAsync("...")
IconClass = "fas fa-heart",
//Url = _adminMenu.GetMenuItemUrl("Shipping", "List")
//ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
ChildNodes = [preorderAvailabilityMenuItem, preorderListMenuItem]
};
rootNode.ChildNodes.Insert(3, preordersRootMenuItem);
//shippingConfigurationItem.ChildNodes.Insert(5, preorderListMenuItem);
// Create a new top-level menu item

View File

@ -0,0 +1,231 @@
using AyCode.Core.Loggers;
using FruitBank.Common.Dtos;
using FruitBank.Common.Interfaces;
using Mango.Nop.Core.Loggers;
using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Stores;
using Nop.Core.Domain.Tax;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders;
using Nop.Services.Catalog;
using Nop.Services.Localization;
using Nop.Services.Orders;
using Nop.Services.Tax;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// <summary>
/// Shared service for creating, adding and removing order items.
/// Extracted from CustomOrderController so the same logic can be reused
/// by PreorderConversionService without duplication.
/// </summary>
public class FruitBankOrderItemService
{
private readonly FruitBankDbContext _dbContext;
private readonly IProductService _productService;
private readonly IPriceCalculationService _priceCalculationService;
private readonly ITaxService _taxService;
private readonly IOrderService _orderService;
private readonly IStoreContext _storeContext;
private readonly ILocalizationService _localizationService;
private readonly ILogger _logger;
public FruitBankOrderItemService(
FruitBankDbContext dbContext,
IProductService productService,
IPriceCalculationService priceCalculationService,
ITaxService taxService,
IOrderService orderService,
IStoreContext storeContext,
ILocalizationService localizationService,
IEnumerable<IAcLogWriterBase> logWriters)
{
_dbContext = dbContext;
_productService = productService;
_priceCalculationService = priceCalculationService;
_taxService = taxService;
_orderService = orderService;
_storeContext = storeContext;
_localizationService = localizationService;
_logger = new Logger<FruitBankOrderItemService>(logWriters.ToArray());
}
// ── Create ────────────────────────────────────────────────────────────────
/// <summary>
/// Builds an OrderItem entity — no DB writes.
/// unitPricesIncludeDiscounts=true recalculates price from product;
/// false uses the supplied price as-is (manual override).
/// </summary>
public async Task<OrderItem> CreateOrderItemAsync(
Product product,
Order order,
int productId,
int quantity,
decimal price,
bool isMeasurable,
bool unitPricesIncludeDiscounts,
Customer? customer = null,
Store? store = null)
{
store ??= await _storeContext.GetCurrentStoreAsync();
customer ??= new Customer { Id = order.CustomerId };
decimal unitPriceInclTax;
if (unitPricesIncludeDiscounts)
{
var calc = await _priceCalculationService.GetFinalPriceAsync(
product, customer, store, includeDiscounts: true);
unitPriceInclTax = calc.finalPrice;
}
else
{
unitPriceInclTax = price;
}
var (unitPriceExclTax, _) = await _taxService.GetProductPriceAsync(
product, unitPriceInclTax, includingTax: false, customer);
return new OrderItem
{
OrderId = order.Id,
ProductId = productId,
Quantity = quantity,
OrderItemGuid = Guid.NewGuid(),
UnitPriceInclTax = unitPriceInclTax,
UnitPriceExclTax = unitPriceExclTax,
PriceInclTax = isMeasurable ? 0m : unitPriceInclTax * quantity,
PriceExclTax = isMeasurable ? 0m : unitPriceExclTax * quantity,
OriginalProductCost = await _priceCalculationService.GetProductCostAsync(product, null),
AttributeDescription = string.Empty,
AttributesXml = string.Empty,
DiscountAmountInclTax = decimal.Zero,
DiscountAmountExclTax = decimal.Zero,
DownloadCount = 0,
IsDownloadActivated = false,
LicenseDownloadId = 0,
ItemWeight = product.Weight * quantity
};
}
// ── Add ───────────────────────────────────────────────────────────────────
/// <summary>
/// Inserts a single OrderItem, deducts inventory, and updates order totals.
/// Does NOT check availability — caller is responsible for that guard.
/// Does NOT call UpdateOrderAsync — caller must do that after all items are added.
/// </summary>
public async Task AddOrderItemToOrderAsync(
Order order,
OrderItem orderItem,
string inventoryMessage)
{
await _orderService.InsertOrderItemAsync(orderItem);
var product = await _productService.GetProductByIdAsync(orderItem.ProductId);
if (product != null)
await _productService.AdjustInventoryAsync(
product, -orderItem.Quantity, orderItem.AttributesXml, inventoryMessage);
order.OrderSubtotalInclTax += orderItem.UnitPriceInclTax * orderItem.Quantity;
order.OrderSubtotalExclTax += orderItem.UnitPriceExclTax * orderItem.Quantity;
order.OrderTotal += orderItem.PriceInclTax;
}
/// <summary>
/// Batch add — mirrors the original CustomOrderController.AddOrderItemsThenUpdateOrder.
/// Checks availability, creates each OrderItem, adjusts inventory,
/// updates order totals, then persists the order and writes an order note.
/// </summary>
public async Task AddOrderItemsThenUpdateOrderAsync(
Order order,
IReadOnlyList<IOrderProductItemBase> items,
Customer? customer = null,
Store? store = null,
Customer? admin = null)
{
store ??= await _storeContext.GetCurrentStoreAsync();
customer ??= new Customer { Id = order.CustomerId };
admin ??= customer;
var productDtosById = await _dbContext.ProductDtos
.GetAllByIds(items.Select(x => x.Id).ToArray())
.ToDictionaryAsync(k => k.Id, v => v);
foreach (var item in items)
{
var product = await _productService.GetProductByIdAsync(item.Id);
if (product == null)
{
_logger.Warning($"AddOrderItemsThenUpdateOrderAsync: product #{item.Id} not found, skipped");
continue;
}
productDtosById.TryGetValue(item.Id, out var dto);
var isMeasurable = dto?.IsMeasurable ?? false;
var available = product.StockQuantity + (dto?.IncomingQuantity ?? 0);
if (available - item.Quantity < 0)
throw new Exception(
$"Insufficient stock for product #{product.Id} " +
$"(available: {available}, requested: {item.Quantity})");
bool useDiscountPrice = item.Price == product.Price;
var orderItem = await CreateOrderItemAsync(
product, order, item.Id, item.Quantity, item.Price,
isMeasurable, useDiscountPrice, customer, store);
var msg = string.Format(
await _localizationService.GetResourceAsync(
"Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id);
await AddOrderItemToOrderAsync(order, orderItem, msg);
}
await _orderService.UpdateOrderAsync(order);
await InsertOrderNoteAsync(order.Id, false,
$"Products added ({items.Count} item(s)) by " +
$"{admin.FirstName} {admin.LastName} (Id: {admin.Id})");
}
// ── Remove ────────────────────────────────────────────────────────────────
/// <summary>
/// Removes an OrderItem: deletes pallets/GAs/weight constraints,
/// restores inventory, deletes the item, adjusts order totals.
/// Caller must call UpdateOrderAsync after.
/// </summary>
public async Task RemoveOrderItemFromOrderAsync(
Order order,
OrderItem orderItem,
string inventoryMessage)
{
await _dbContext.DeleteOrderItemConstraintsAsync(orderItem);
var product = await _productService.GetProductByIdAsync(orderItem.ProductId);
if (product != null)
await _productService.AdjustInventoryAsync(
product, +orderItem.Quantity, orderItem.AttributesXml, inventoryMessage);
await _orderService.DeleteOrderItemAsync(orderItem);
order.OrderSubtotalInclTax -= orderItem.UnitPriceInclTax * orderItem.Quantity;
order.OrderSubtotalExclTax -= orderItem.UnitPriceExclTax * orderItem.Quantity;
order.OrderTotal -= orderItem.PriceInclTax;
}
// ── Helpers ───────────────────────────────────────────────────────────────
public Task InsertOrderNoteAsync(int orderId, bool displayToCustomer, string note)
=> _orderService.InsertOrderNoteAsync(new OrderNote
{
OrderId = orderId,
Note = note,
DisplayToCustomer = displayToCustomer,
CreatedOnUtc = DateTime.UtcNow
});
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Threading.Tasks;
using System.Xml.Linq;
@ -156,8 +157,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
tetelElement.Add(new XElement("TetelNev", item.TetelNev ?? ""));
tetelElement.Add(new XElement("AfaSzoveg", item.AfaSzoveg ?? ""));
tetelElement.Add(new XElement("Brutto", item.Brutto ? "1" : "0"));
tetelElement.Add(new XElement("EgysegAr", item.EgysegAr.ToString()));
tetelElement.Add(new XElement("Mennyiseg", item.Mennyiseg.ToString()));
tetelElement.Add(new XElement("EgysegAr", item.EgysegAr.ToString("0.##", CultureInfo.InvariantCulture)));
tetelElement.Add(new XElement("Mennyiseg", item.Mennyiseg.ToString("0.##", CultureInfo.InvariantCulture)));
tetelElement.Add(new XElement("MennyisegEgyseg", new XCData(item.MennyisegEgyseg ?? "")));
if (!string.IsNullOrEmpty(item.CikkSzam))

View File

@ -0,0 +1,184 @@
using FruitBank.Common.Dtos;
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using FruitBank.Common.Interfaces;
using Nop.Core.Domain.Catalog;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Services.Catalog;
using Nop.Services.Orders;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// <summary>
/// Extension methods for product replacement logic on PreorderConversionService.
///
/// SETUP REQUIRED: Add this as a partial class by:
/// 1. Add `partial` keyword to PreorderConversionService class declaration
/// 2. This file uses the same injected fields — no extra DI needed
/// </summary>
public partial class PreorderConversionService
{
// ── Product replacement ───────────────────────────────────────────────────
/// <summary>
/// Called from FruitBankDataController.UpdateShippingItem when ProductId changes.
/// oldItem must be loaded BEFORE the DB update to capture the previous state.
/// </summary>
public async Task ReplaceShippingItemProductAsync(
int shippingItemId,
int newProductId,
ShippingItem oldItem)
{
if (oldItem.ProductId == null || oldItem.ProductId.Value == newProductId) return;
var oldProductId = oldItem.ProductId.Value;
var qty = oldItem.QuantityOnDocument;
var isMeasured = oldItem.IsMeasured;
Console.WriteLine($"[ReplaceShippingItemProduct] #{shippingItemId}: {oldProductId}→{newProductId}, qty={qty}, measured={isMeasured}");
// ── Guard: reject if any linked order is being measured ───────────────
var affectedOrders = await GetAffectedOpenOrdersAsync(oldProductId);
var startedOrders = affectedOrders
.Where(o => o.MeasuringStatus > MeasuringStatus.NotStarted)
.ToList();
if (startedOrders.Any())
throw new InvalidOperationException(
$"Mérés folyamatban a következő rendeléseken: " +
$"#{string.Join(", #", startedOrders.Select(o => o.Id))}. " +
$"Termékcsere nem lehetséges.");
// ── Stock / IncomingQuantity swap ─────────────────────────────────────
if (!isMeasured)
{
// Item not yet on the truck — only IncomingQuantity moves
await SyncIncomingQuantityAsync(oldProductId, qty, 0);
await SyncIncomingQuantityAsync(newProductId, 0, qty);
}
else
{
// Item already measured/received into stock — adjust actual stock
await _dbContext.UpdateStockQuantityAndWeightAsync(
oldProductId, -oldItem.MeasuredQuantity,
$"Termék csere: shippingItem #{shippingItemId} — {oldProductId}→{newProductId}",
oldItem.IsMeasurable ? -oldItem.MeasuredNetWeight : 0d);
await _dbContext.UpdateStockQuantityAndWeightAsync(
newProductId, +oldItem.MeasuredQuantity,
$"Termék csere: shippingItem #{shippingItemId} — {oldProductId}→{newProductId}",
oldItem.IsMeasurable ? +oldItem.MeasuredNetWeight : 0d);
}
// ── Swap order items ──────────────────────────────────────────────────
var openOrders = affectedOrders
.Where(o => o.MeasuringStatus == MeasuringStatus.NotStarted)
.OrderBy(o => o.Id)
.ToList();
var replacementBudget = qty;
var affectedOrderIds = new List<int>();
foreach (var orderDto in openOrders)
{
if (replacementBudget <= 0) break;
var order = await _dbContext.Orders.GetByIdAsync(orderDto.Id);
if (order == null) continue;
var allItems = await _orderService.GetOrderItemsAsync(order.Id);
var oldOrderItem = allItems.FirstOrDefault(oi => oi.ProductId == oldProductId);
if (oldOrderItem == null) continue;
var swapQty = Math.Min(oldOrderItem.Quantity, replacementBudget);
// Remove old item — restores stock, deletes pallets/GAs, deletes item
await _orderItemService.RemoveOrderItemFromOrderAsync(
order, oldOrderItem,
$"Termék csere: #{oldProductId}→#{newProductId}, rendelés #{order.Id}");
// Add new item — direct insert without availability check
var newProduct = await _productService.GetProductByIdAsync(newProductId);
if (newProduct != null)
{
var newProductDto = await _dbContext.ProductDtos.GetByIdAsync(newProductId, true);
var newOrderItem = await _orderItemService.CreateOrderItemAsync(
newProduct, order,
productId : newProductId,
quantity : swapQty,
price : oldOrderItem.UnitPriceInclTax,
isMeasurable : newProductDto?.IsMeasurable ?? false,
unitPricesIncludeDiscounts : false);
await _orderItemService.AddOrderItemToOrderAsync(
order, newOrderItem,
$"Termék csere felvitel: #{newProductId}, rendelés #{order.Id}");
}
await _orderService.UpdateOrderAsync(order);
await _orderItemService.InsertOrderNoteAsync(order.Id, false,
$"Termék cserélve: #{oldProductId}→#{newProductId} ({swapQty} db), " +
$"szállítói dok. #{oldItem.ShippingDocumentId}");
replacementBudget -= swapQty;
affectedOrderIds.Add(order.Id);
}
// ── Swap preorder items ───────────────────────────────────────────────
if (affectedOrderIds.Any())
{
var preorders = (await _preorderDbContext.Preorders.GetAll(false).ToListAsync())
.Where(p => p.OrderId.HasValue && affectedOrderIds.Contains(p.OrderId.Value))
.ToList();
foreach (var preorder in preorders)
{
var piList = await _preorderDbContext.PreorderItems
.GetAllByPreorderIdAsync(preorder.Id)
.ToListAsync();
foreach (var pi in piList.Where(i => i.ProductId == oldProductId))
{
pi.ProductId = newProductId;
await _preorderDbContext.PreorderItems.UpdateAsync(pi);
}
}
}
// ── Trigger conversion for new product ────────────────────────────────
// New product may now have pending preorders that can be fulfilled
await ConvertPreordersForProductsAsync(
new List<int> { newProductId },
oldItem.ShippingDocumentId);
// TODO: SignalR notification to admin hub
// TODO: SendPreorderProductReplacedNotificationAsync per affected customer
Console.WriteLine($"[ReplaceShippingItemProduct] Complete: " +
$"{affectedOrderIds.Count} orders swapped, budget remaining={replacementBudget}");
}
private async Task<List<OrderDto>> GetAffectedOpenOrdersAsync(int oldProductId)
{
// Orders that are referenced by a preorder AND contain the old product
var preorderOrderIds = (await _preorderDbContext.Preorders.GetAll(false).ToListAsync())
.Where(p => p.OrderId.HasValue)
.Select(p => p.OrderId!.Value)
.ToHashSet();
if (!preorderOrderIds.Any()) return new();
var matchingOrderIds = await _dbContext.OrderItems.Table
.Where(oi => oi.ProductId == oldProductId && preorderOrderIds.Contains(oi.OrderId))
.Select(oi => oi.OrderId)
.Distinct()
.ToListAsync();
if (!matchingOrderIds.Any()) return new();
return await _dbContext.OrderDtos
.GetAll(false)
.Where(o => matchingOrderIds.Contains(o.Id) && !o.Deleted)
.ToListAsync();
}
}

View File

@ -1,5 +1,10 @@
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using FruitBank.Common.Interfaces;
using Nop.Core;
using Nop.Core.Domain.Catalog;
//using LinqToDB;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
@ -27,7 +32,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// - Subsequent documents → appends only newly-fulfilled items to that same order.
/// - Dropped items are recorded in an order note but never become OrderItems.
/// </summary>
public class PreorderConversionService
public partial class PreorderConversionService
{
private readonly PreorderDbContext _preorderDbContext;
private readonly FruitBankDbContext _dbContext;
@ -36,6 +41,9 @@ public class PreorderConversionService
private readonly IEventPublisher _eventPublisher;
private readonly CustomPriceCalculationService _customPriceCalculationService;
private readonly IOrderService _orderService;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly FruitBankOrderItemService _orderItemService;
private readonly IStoreContext _storeContext;
public PreorderConversionService(
PreorderDbContext preorderDbContext,
@ -44,7 +52,10 @@ public class PreorderConversionService
IProductService productService,
IEventPublisher eventPublisher,
IPriceCalculationService priceCalculationService,
IOrderService orderService)
IOrderService orderService,
FruitBankAttributeService fruitBankAttributeService,
FruitBankOrderItemService orderItemService,
IStoreContext storeContext)
{
_preorderDbContext = preorderDbContext;
_dbContext = dbContext;
@ -53,6 +64,9 @@ public class PreorderConversionService
_eventPublisher = eventPublisher;
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
_orderService = orderService;
_fruitBankAttributeService = fruitBankAttributeService;
_orderItemService = orderItemService;
_storeContext = storeContext;
}
// ── Entry point ───────────────────────────────────────────────────────────
@ -72,6 +86,34 @@ public class PreorderConversionService
return;
}
// Filter out preorders whose delivery date is more than PreorderConversionWindowDays
// (4 days) away. With bi-weekly trucks, a delivery that far out will be served
// by the next truck's document — converting now would steal stock from
// earlier deliveries that legitimately need it.
var conversionCutoff = DateTime.UtcNow.Date.AddDays(FruitBankPluginConst.PreorderConversionWindowDays);
var pendingPreorderIds = pendingItems.Select(i => i.PreorderId).Distinct().ToList();
var parentPreorders = await _preorderDbContext.Preorders
.GetAll(false)
.Where(p => pendingPreorderIds.Contains(p.Id))
.ToListAsync();
var eligiblePreorderIds = parentPreorders
.Where(p => p.DateOfReceipt.Date <= conversionCutoff)
.Select(p => p.Id)
.ToHashSet();
pendingItems = pendingItems.Where(i => eligiblePreorderIds.Contains(i.PreorderId)).ToList();
if (!pendingItems.Any())
{
Console.WriteLine($"[PreorderConversion] All pending preorders are beyond the " +
$"{FruitBankPluginConst.PreorderConversionWindowDays}-day window — skipped.");
return;
}
Console.WriteLine($"[PreorderConversion] {pendingItems.Count} items eligible " +
$"(within {FruitBankPluginConst.PreorderConversionWindowDays}-day window).");
var incomingPool = await BuildIncomingQuantityPoolAsync(productIds);
// Track which items were newly resolved in THIS run, grouped by preorder
@ -396,11 +438,14 @@ public class PreorderConversionService
var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true);
if (productDto == null) continue;
var product = await _productService.GetProductByIdAsync(item.ProductId);
if (product == null) continue;
var unitPriceExclTax = Math.Round(item.UnitPriceInclTax / 1.27m, 4);
var priceInclTax = productDto.IsMeasurable ? 0m : item.UnitPriceInclTax * item.FulfilledQuantity;
var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * item.FulfilledQuantity;
await _dbContext.OrderItems.InsertAsync(new OrderItem
var orderItem = new OrderItem
{
OrderItemGuid = Guid.NewGuid(),
OrderId = order.Id,
@ -420,7 +465,17 @@ public class PreorderConversionService
LicenseDownloadId = 0,
RentalStartDateUtc = null,
RentalEndDateUtc = null
});
};
// Use the service (fires NopCommerce events) instead of direct DB insert
await _orderService.InsertOrderItemAsync(orderItem);
// Deduct from stock — same as CustomOrderController and FruitBankOrderItemService
await _productService.AdjustInventoryAsync(
product,
-item.FulfilledQuantity,
string.Empty,
$"Előrendelés #{item.PreorderId} — rendelés #{order.Id} létrehozása");
}
}
@ -451,6 +506,24 @@ public class PreorderConversionService
await _orderService.InsertOrderNoteAsync(note);
}
// ── IncomingQuantity sync ────────────────────────────────────────
public async Task SyncIncomingQuantityAsync(int productId, int oldQty, int newQty)
{
var delta = newQty - oldQty;
if (delta == 0 || productId <= 0) return;
var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
var current = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Product, int>(
productId, nameof(IIncomingQuantity.IncomingQuantity), storeId);
var updated = Math.Max(0, current + delta);
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Product, int>(
productId, nameof(IIncomingQuantity.IncomingQuantity), updated, storeId);
Console.WriteLine($"[PreorderConversion] SyncIncomingQty product #{productId}: {current}+({delta})={updated}");
}
private async Task<decimal> CalculateTotalAsync(List<PreorderItem> items)
{
var total = 0m;