bug fixes on preorder, modified logic
This commit is contained in:
parent
c86ef0e416
commit
2e7619b621
|
|
@ -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 →</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 →</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 →</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 ? ' <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>
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
@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">
|
||||
|
|
@ -15,37 +16,15 @@
|
|||
<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>
|
||||
}
|
||||
}
|
||||
<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>
|
||||
@*
|
||||
<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">
|
||||
|
|
@ -60,3 +39,21 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
#endregion
|
||||
[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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +263,76 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/List.cshtml", model);
|
||||
}
|
||||
|
||||
[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));
|
||||
|
||||
// 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;
|
||||
|
||||
// Discount only applies when price was NOT manually overridden.
|
||||
if (unitPricesIncludeDiscounts)
|
||||
{
|
||||
var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: true);
|
||||
var unitPriceInclTaxValue = priceCalculation.finalPrice;
|
||||
|
||||
var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer);
|
||||
|
||||
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 += appliedDiscounts * orderItem.Quantity;
|
||||
order.OrderSubTotalDiscountExclTax += appliedDiscounts * orderItem.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,17 +41,15 @@
|
|||
@await Html.PartialAsync("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Components/_WelcomeMessage.cshtml")
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
@await Html.PartialAsync("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Components/_FruitBankDashboard.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))
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@* 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">
|
||||
<div class="col-md-12">
|
||||
@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>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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(); };
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +57,7 @@
|
|||
<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>
|
||||
|
|
@ -73,13 +67,9 @@
|
|||
</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,155 +165,104 @@ $(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 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 ─────────────────────────────────────────────────
|
||||
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;
|
||||
// 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)', 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:'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: '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:'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();
|
||||
$('.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';
|
||||
},
|
||||
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){
|
||||
// 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(); }
|
||||
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 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); }},
|
||||
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;
|
||||
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); }}
|
||||
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(); }
|
||||
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');
|
||||
$('.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'
|
||||
|
|
@ -309,165 +270,220 @@ $(function () {
|
|||
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,
|
||||
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-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 });
|
||||
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();
|
||||
$('#cp-submit-btn').prop('disabled', false);
|
||||
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>' +
|
||||
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>'
|
||||
);
|
||||
'</tr>');
|
||||
});
|
||||
$('#cp-products-json').val(JSON.stringify(cpProducts));
|
||||
}
|
||||
|
||||
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._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(); };
|
||||
|
||||
$('#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: {
|
||||
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(),
|
||||
customerNote : $('#cp-note').val().trim(),
|
||||
productsJson : $('#cp-products-json').val(),
|
||||
__RequestVerificationToken:_token
|
||||
},
|
||||
},function(r){
|
||||
if (r&&(r.success||r.orderId)){
|
||||
$('#create-preorder-window').modal('hide');
|
||||
alert('Rendelés létrehozva: #'+(r.orderId||r.id||''));
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
});
|
||||
} 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();
|
||||
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);
|
||||
}
|
||||
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-save"></i> Mentés');
|
||||
$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-save"></i> Mentés');
|
||||
}
|
||||
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();
|
||||
$('#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>×</span></button>
|
||||
</div>
|
||||
<form id="cp-form">
|
||||
<div class="form-horizontal">
|
||||
<div class="modal-body">
|
||||
|
||||
@* ─ Customer ─ *@
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label class="col-form-label">Ugyfél</label></div>
|
||||
<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="Ugyfél neve, e-mail vagy cég neve..." />
|
||||
<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>
|
||||
</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>
|
||||
<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">Kívánt szállítási nap és időpont</small>
|
||||
<small class="text-muted">
|
||||
≤4 nap → <strong>Rendelés</strong> | >4 nap → <strong>Előrendelés</strong>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ─ Product search ─ *@
|
||||
<div id="cp-mode-banner" style="display:none;"></div>
|
||||
|
||||
<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>
|
||||
<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">Csak az előrendelési ablakban szereplő termékek jelennek meg</small>
|
||||
<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>
|
||||
|
||||
@* ─ Products table ─ *@
|
||||
<div id="cp-products-section" style="display:none;">
|
||||
<table class="table table-sm table-bordered" id="cp-products-table">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Termék</th>
|
||||
<th style="width:100px">Mennyiség</th>
|
||||
<th style="width:120px">Mennyiség</th>
|
||||
<th style="width:120px">Egységár</th>
|
||||
<th style="width:40px"></th>
|
||||
</tr>
|
||||
|
|
@ -477,19 +493,20 @@ $(function () {
|
|||
<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>
|
||||
<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 az előrendeléssel kapcsolatban..."></textarea>
|
||||
<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" class="btn btn-primary"><i class="fas fa-save"></i> Mentés</button>
|
||||
</div>
|
||||
<button type="submit" id="cp-submit-btn" class="btn btn-secondary" disabled>
|
||||
<i class="fas fa-save"></i> Mentés
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue