fixed stats

This commit is contained in:
2026-10-13 16:30:47 +02:00
parent bb3d1b1aa1
commit c96717b051
7 changed files with 1894 additions and 20 deletions
+296 -13
View File
@@ -73,6 +73,8 @@ if ($action === 'data') {
SUM(x.status = 'annullato') AS cancelled,
SUM(x.status = 'bloccato') AS blocked,
SUM(x.status IN ('aperto','bloccato') AND x.due_date < CURDATE()) AS late,
SUM(x.status IN ('aperto','bloccato') AND x.client_date < CURDATE()) AS late_client,
SUM(x.status IN ('aperto','bloccato') AND x.late_lines > 0 AND x.late_lines < x.open_lines) AS late_partial,
SUM(x.delivered = 1 AND x.invoiced = 0 AND x.cancelled = 0) AS to_invoice,
COUNT(DISTINCT x.customer) AS customers,
AVG(CASE WHEN x.status <> 'annullato' AND x.due_date IS NOT NULL AND x.due_date >= x.order_date
@@ -160,12 +162,30 @@ if ($action === 'data') {
GROUP BY bucket ORDER BY bucket
", $params);
// --- Top articoli (dalle righe, annullati esclusi) ---
$topItems = MagoSalesStats::fetchAllQ($pdo, "
SELECT d.item AS code,
MAX(d.description) AS name,
MAX(d.uom) AS uom,
SUM(d.qty) AS qty,
SUM(d.taxable_amount) AS value,
COUNT(DISTINCT d.sale_ord_id) AS orders
FROM mago_sale_ord_detail d
JOIN $from ON x.sale_ord_id = d.sale_ord_id
WHERE $where AND d.item <> '' AND d.cancelled = 0 AND x.status <> 'annullato'
GROUP BY d.item
ORDER BY " . ($metric === 'value' ? 'value' : 'orders') . " DESC
LIMIT 10
", $params);
// --- Elenco ordini ---
$rows = MagoSalesStats::fetchAllQ($pdo, "
SELECT x.sale_ord_id, x.internal_ord_no, x.external_ord_no,
x.order_date, x.due_date, x.customer, x.company_name,
x.salesperson, x.area, x.status, x.invoiced, x.your_reference,
x.taxable_amount, x.total_amount,
x.zibo_date, x.client_date, x.open_lines, x.late_lines, x.late_lines_client, x.open_dates,
(x.status IN ('aperto','bloccato') AND x.client_date < CURDATE()) AS is_late_client,
(x.summary_tb_modified IS NULL) AS no_amount,
(x.status IN ('aperto','bloccato') AND x.due_date < CURDATE()) AS is_late
FROM $from
@@ -183,6 +203,7 @@ if ($action === 'data') {
'by_area' => $byArea,
'by_salesperson' => $bySalesperson,
'due_buckets' => $dueBuckets,
'top_items' => $topItems,
'rows' => $rows,
]);
} catch (Throwable $e) {
@@ -201,8 +222,22 @@ if ($action === 'order') {
$raw = json_decode((string)$o['raw_json'], true) ?: [];
unset($o['raw_json']);
$stL = $pdo->prepare("
SELECT d.line, d.item, d.description, d.uom, d.qty, d.delivered_qty, d.invoiced_qty, d.unit_value,
GREATEST(d.qty - d.delivered_qty, 0) AS residual_qty,
d.taxable_amount, d.delivered, d.invoiced, d.cancelled, d.notes,
" . MagoSalesStats::ziboDate('d') . " AS zibo_date,
" . MagoSalesStats::clientDate('d') . " AS client_date
FROM mago_sale_ord_detail d
WHERE d.sale_ord_id = ?
ORDER BY d.position, d.line, d.sub_id
");
$stL->execute([$id]);
$lines = $stL->fetchAll(PDO::FETCH_ASSOC);
MagoSalesStats::jsonOut([
'success' => true,
'lines' => $lines,
'order' => $o,
'extra' => [
'Cliente fatturazione' => $raw['InvoicingCustomer'] ?? '',
@@ -654,6 +689,83 @@ if ($action === 'order') {
margin-left: 6px;
}
/* date di consegna: ZIBO (scadenza) rossa se superata, cliente (desiderata) arancione */
.dt-zibo {
font-weight: 700;
color: var(--c-ink);
white-space: nowrap;
}
.dt-zibo.late {
color: #dc3545;
}
.dt-client {
font-size: .82rem;
color: #64748b;
white-space: nowrap;
}
.dt-client.late {
color: #ea580c;
font-weight: 700;
}
.dt-tag {
display: inline-block;
font-size: .68rem;
font-weight: 800;
letter-spacing: .3px;
text-transform: uppercase;
margin-right: 4px;
opacity: .75;
}
.dt-fallback {
font-size: .72rem;
color: #94a3b8;
font-style: italic;
}
.more-dates {
display: inline-block;
background: #eef2ff;
color: #4338ca;
font-size: .7rem;
font-weight: 800;
border-radius: 999px;
padding: 1px 7px;
margin-left: 4px;
}
.partial-badge {
background: #fff1e6;
color: #c2410c;
font-weight: 800;
font-size: .75rem;
padding: 3px 8px;
border-radius: 999px;
margin-left: 6px;
}
.late-badge,
.partial-badge,
.late-client-badge {
white-space: nowrap;
display: inline-block;
margin-top: 2px;
}
.late-client-badge {
background: #fff7ed;
color: #ea580c;
font-weight: 800;
font-size: .72rem;
padding: 3px 8px;
border-radius: 999px;
margin-left: 6px;
}
.inv-badge {
background: #efe9fb;
color: var(--c-invoice);
@@ -771,6 +883,7 @@ if ($action === 'order') {
<div class="stats-tabs">
<a href="sales_orders_stats.php" class="active"><i class="fa-solid fa-list-check me-1"></i> Andamento ordini</a>
<a href="sales_orders_years.php"><i class="fa-solid fa-chart-column me-1"></i> Confronto anni</a>
<a href="sales_orders_deliveries.php"><i class="fa-solid fa-truck-fast me-1"></i> Consegne</a>
</div>
<!-- FILTRI -->
@@ -816,7 +929,8 @@ if ($action === 'order') {
<label>Stato</label>
<div class="chip-row" id="statusChips">
<button type="button" class="chip" data-status="aperto" style="--chip-c:var(--c-open);">Aperti</button>
<button type="button" class="chip" data-status="ritardo" style="--chip-c:var(--c-late);">In ritardo</button>
<button type="button" class="chip" data-status="ritardo" style="--chip-c:var(--c-late);">In ritardo (ZIBO)</button>
<button type="button" class="chip" data-status="ritardo_cliente" style="--chip-c:#ea580c;">Oltre data cliente</button>
<button type="button" class="chip" data-status="bloccato" style="--chip-c:var(--c-block);">Bloccati</button>
<button type="button" class="chip" data-status="evaso" style="--chip-c:var(--c-done);">Evasi</button>
<button type="button" class="chip" data-status="da_fatturare" style="--chip-c:var(--c-invoice);">Da fatturare</button>
@@ -876,7 +990,7 @@ if ($action === 'order') {
<div class="kpi-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div class="kpi-label">In ritardo</div>
<div class="kpi-value" id="kLate">—</div>
<div class="kpi-sub">consegna prevista superata</div>
<div class="kpi-sub" id="kLateSub">data ZIBO superata</div>
</div>
<div class="kpi" data-status="evaso" style="--k:var(--c-done);">
<div class="kpi-icon"><i class="fa-solid fa-truck"></i></div>
@@ -947,6 +1061,16 @@ if ($action === 'order') {
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-12">
<div class="chart-card">
<div class="chart-title" id="ttlItems">Top 10 articoli per numero ordini</div>
<div class="chart-hint">Dalle righe ordine · passa sopra una barra per quantità e valore</div>
<div id="chItems" style="min-height:380px;"></div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-lg-6">
<div class="chart-card">
@@ -976,7 +1100,7 @@ if ($action === 'order') {
<th>Data</th>
<th>Cliente</th>
<th>Rif. cliente</th>
<th>Consegna prev.</th>
<th>Consegna <span class="small fw-normal">(ZIBO / cliente)</span></th>
<th class="text-end">Imponibile</th>
<th>Stato</th>
<th>Fatt.</th>
@@ -1198,7 +1322,11 @@ if ($action === 'order') {
if (f.statuses.length === 1) $(`.kpi[data-status="${f.statuses[0]}"]`).addClass('selected');
}
// aperta da link (?q=numero ordine): non sovrascrivo i filtri salvati dell'utente
const URL_Q = new URLSearchParams(location.search).get('q');
function saveFilters(f) {
if (URL_Q) return;
try {
localStorage.setItem(STORE_KEY, JSON.stringify(Object.assign({}, f, {
metric: metric
@@ -1319,7 +1447,13 @@ if ($action === 'order') {
renderDue(d.due_buckets);
renderSimpleBar('chArea', d.by_area, '#0ea5e9', 'areas');
renderSimpleBar('chSalesperson', d.by_salesperson, '#6f42c1', 'salespeople');
renderItems(d.top_items || []);
renderTable(d.rows);
if (window.__autoOpen) {
const hit = d.rows.find(r => r.internal_ord_no === window.__autoOpen) || (d.rows.length === 1 ? d.rows[0] : null);
window.__autoOpen = null;
if (hit) openOrder(hit.sale_ord_id);
}
})
.catch(err => {
$('#statsContent').css('opacity', 1);
@@ -1336,6 +1470,7 @@ if ($action === 'order') {
$('#ttlDue').text(v ? 'Scadenze ordini aperti (valore)' : 'Scadenze ordini aperti');
$('#ttlArea').text(v ? 'Valore per area' : 'Ordini per area');
$('#ttlSalesperson').text(v ? 'Valore per agente' : 'Ordini per agente');
$('#ttlItems').text(v ? 'Top 10 articoli per valore ordinato' : 'Top 10 articoli per numero ordini');
}
function renderKpi(k) {
@@ -1343,6 +1478,9 @@ if ($action === 'order') {
$('#kOrders').text(fmtNum(orders));
$('#kOpen').text(fmtNum(k.open_orders));
$('#kLate').text(fmtNum(k.late));
$('#kLateSub').html(`data ZIBO superata` +
(+k.late_partial ? ` · ${fmtNum(k.late_partial)} parziali` : '') +
`<br><span style="color:#ea580c;font-weight:700;">${fmtNum(k.late_client)}</span> oltre data cliente`);
$('#kDelivered').text(fmtNum(k.delivered));
$('#kToInvoice').text(fmtNum(k.to_invoice));
$('#kCustomers').text(fmtNum(k.customers));
@@ -1695,16 +1833,113 @@ if ($action === 'order') {
});
}
function renderItems(rows) {
const v = metric === 'value';
const label = r => {
const n = (r.name || '').trim();
const t = n ? `${r.code} · ${n}` : r.code;
return t.length > 60 ? t.substring(0, 58) + '…' : t;
};
upsertChart('items', 'chItems', {
chart: {
...baseChart,
type: 'bar',
height: 380
},
series: [{
name: v ? 'Valore' : 'Ordini',
data: rows.map(r => v ? +r.value : +r.orders)
}],
colors: ['#0f766e'],
xaxis: {
categories: rows.map(label),
labels: {
formatter: fmtMetricAxis
}
},
yaxis: {
labels: {
maxWidth: 380
}
},
plotOptions: {
bar: {
horizontal: true,
borderRadius: 4,
barHeight: '65%'
}
},
dataLabels: {
enabled: false
},
grid: {
borderColor: '#eef2f7'
},
tooltip: {
custom: ({
dataPointIndex
}) => {
const r = rows[dataPointIndex];
if (!r) return '';
return `<div style="padding:8px 12px;font-size:.85rem;">
<div style="font-weight:800;">${esc(r.code)}</div>
<div style="color:#64748b;max-width:320px;white-space:normal;">${esc(r.name)}</div>
<div style="margin-top:6px;">Ordini: <b>${fmtNum(r.orders)}</b></div>
<div>Quantità: <b>${fmtNum(Math.round(r.qty))} ${esc(r.uom)}</b></div>
<div>Valore: <b>${fmtEur(r.value)}</b></div>
</div>`;
}
},
noData: {
text: 'Nessuna riga articolo nel periodo'
}
});
}
// cella consegna: data ZIBO (rossa se superata) + data cliente (arancione se superata)
function dateCell(zibo, client, opts) {
opts = opts || {};
const today = iso(new Date());
const open = opts.open !== false;
const zLate = open && zibo && zibo < today;
const cLate = open && client && client < today;
let html = '';
if (zibo) {
html += `<div class="dt-zibo${zLate ? ' late' : ''}"><span class="dt-tag">ZIBO</span>${fmtDate(zibo)}${opts.more ? `<span class="more-dates" title="Le righe aperte hanno date di consegna diverse">+${opts.more} date</span>` : ''}</div>`;
} else {
html += `<div class="dt-fallback">data ZIBO non indicata</div>`;
}
if (client) {
html += `<div class="dt-client${cLate ? ' late' : ''}"><span class="dt-tag">Cliente</span>${fmtDate(client)}</div>`;
}
return html || '—';
}
function lateBadges(r) {
if (r.status !== 'aperto' && r.status !== 'bloccato') return '';
const lateL = +r.late_lines,
openL = +r.open_lines;
if (+r.is_late) {
return lateL > 0 && openL > lateL ?
`<span class="partial-badge" title="Righe aperte in ritardo sulla data ZIBO">PARZIALE ${lateL}/${openL} righe</span>` :
'<span class="late-badge">IN RITARDO</span>';
}
return +r.is_late_client ? '<span class="late-client-badge" title="Data ZIBO non ancora superata">OLTRE DATA CLIENTE</span>' : '';
}
function renderTable(rows) {
const data = rows.map(r => [
`<b>${esc(r.internal_ord_no)}</b>${r.external_ord_no ? `<div class="small text-muted">${esc(r.external_ord_no)}</div>` : ''}`,
`<span data-order="${esc(r.order_date || '')}">${fmtDate(r.order_date)}</span>`,
`<div class="fw-semibold">${esc(r.company_name)}</div><div class="small text-muted">${esc(r.customer)}</div>`,
esc(r.your_reference),
`<span data-order="${esc(r.due_date || '')}">${fmtDate(r.due_date) || '—'}</span>`,
`<span data-order="${esc(r.due_date || '')}">${dateCell(r.zibo_date, r.client_date, {
open: r.status === 'aperto' || r.status === 'bloccato',
more: +r.open_dates > 1 ? (+r.open_dates - 1) : 0
})}</span>`,
+r.no_amount ? '<span data-order="-1" class="text-muted">n.d.</span>' :
`<span data-order="${+r.taxable_amount}" class="amount${r.status === 'annullato' ? ' text-muted text-decoration-line-through' : ''}">${fmtEur(r.taxable_amount, 2)}</span>`,
`<span class="st-badge st-${esc(r.status)}">${STATUS_LABEL[r.status] || esc(r.status)}</span>${+r.is_late ? '<span class="late-badge">IN RITARDO</span>' : ''}`,
`<span class="st-badge st-${esc(r.status)}">${STATUS_LABEL[r.status] || esc(r.status)}</span>` + lateBadges(r),
+r.invoiced ? '<span class="inv-badge">Sì</span>' : '<span class="text-muted">—</span>',
esc(r.salesperson),
esc(r.area),
@@ -1756,7 +1991,7 @@ if ($action === 'order') {
columns: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
format: {
// importo esportato come numero, il resto come testo
body: (d, row, col) => col === 5 ? (+$(d).data('order') >= 0 ? +$(d).data('order') : '') : $('<div>').html(String(d).replace(/<(div|span)/g, ' <$1')).text().replace(/\s+/g, ' ').trim()
body: (d, row, col) => col === 5 ? (+$(d).data('order') >= 0 ? +$(d).data('order') : '') : $('<div>').html(String(d).replace(/<(div|span)/g, ' <$1').replace(/<\/span>/g, '</span> ')).text().replace(/\s+/g, ' ').trim()
}
}
},
@@ -1769,7 +2004,7 @@ if ($action === 'order') {
columns: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
format: {
// importo esportato come numero, il resto come testo
body: (d, row, col) => col === 5 ? (+$(d).data('order') >= 0 ? +$(d).data('order') : '') : $('<div>').html(String(d).replace(/<(div|span)/g, ' <$1')).text().replace(/\s+/g, ' ').trim()
body: (d, row, col) => col === 5 ? (+$(d).data('order') >= 0 ? +$(d).data('order') : '') : $('<div>').html(String(d).replace(/<(div|span)/g, ' <$1').replace(/<\/span>/g, '</span> ')).text().replace(/\s+/g, ' ').trim()
}
}
}
@@ -1825,19 +2060,58 @@ if ($action === 'order') {
</div>` : '<div class="warn-strip mb-3">Importi non ancora letti da Mago.</div>'}
<div class="ro-grid mb-3">
<div class="l">Data ordine</div><div>${fmtDate(o.order_date) || '—'}</div>
<div class="l">Consegna prevista</div><div>${fmtDate(o.expected_delivery_date) || '—'}</div>
<div class="l">Consegna confermata</div><div>${fmtDate(o.confirmed_delivery_date) || '—'}</div>
<div class="l">Consegna</div><div>${dateCell(o.zibo_date, o.client_date, {open: o.status === 'aperto' || o.status === 'bloccato'})}${+o.open_lines ? ` <span class="small text-muted">prima riga da consegnare · ${fmtNum(o.open_lines)} righe aperte${+o.late_lines ? `, <b style="color:#dc3545;">${fmtNum(o.late_lines)} in ritardo</b>` : ''}</span>` : ''}</div>
<div class="l">N° ordine cliente</div><div>${esc(o.external_ord_no) || '—'}</div>
<div class="l">Agente</div><div>${esc(o.salesperson) || '—'}</div>
<div class="l">Area</div><div>${esc(o.area) || '—'}</div>
${extra}
</div>
${o.notes ? `<div class="p-3 rounded" style="background:#f8fafc;border:1px solid #e2e8f0;white-space:pre-wrap;"><b>Note:</b><br>${esc(o.notes)}</div>` : ''}
<div class="mt-3 small text-muted"><i class="fa-solid fa-circle-info me-1"></i>Le righe articolo non sono disponibili: la tabella righe ordine non è esposta dalle API di Mago.</div>
${renderLines(d.lines || [])}
${o.notes ? `<div class="p-3 rounded mt-3" style="background:#f8fafc;border:1px solid #e2e8f0;white-space:pre-wrap;"><b>Note:</b><br>${esc(o.notes)}</div>` : ''}
`);
});
}
function renderLines(lines) {
if (!lines.length) {
return '<div class="warn-strip">Righe non ancora sincronizzate: premi <b>Aggiorna da Mago</b>.</div>';
}
const today = iso(new Date());
const rows = lines.map(l => {
const open = !+l.delivered && !+l.cancelled;
const due = l.zibo_date || l.client_date;
const late = open && due && due < today;
const days = late ? Math.round((new Date(today) - new Date(due)) / 86400000) : 0;
const st = +l.cancelled ? '<span class="st-badge st-annullato">Annullata</span>' :
(+l.delivered ? '<span class="st-badge st-evaso">Consegnata</span>' :
'<span class="st-badge st-aperto">Aperta</span>' + (late ? `<span class="late-badge">+${days} gg</span>` : ''));
const qtyTxt = n => fmtNum(Math.round(+n * 100) / 100);
return `<tr>
<td><b>${esc(l.item)}</b><div class="small text-muted" style="white-space:normal;max-width:320px;">${esc(l.description)}</div></td>
<td class="text-end amount">${qtyTxt(l.qty)} ${esc(l.uom)}</td>
<td class="text-end amount">${qtyTxt(l.delivered_qty)}</td>
<td class="text-end amount">${open ? '<b>' + qtyTxt(l.residual_qty) + '</b>' : '<span class="text-muted">—</span>'}</td>
<td>${dateCell(l.zibo_date, l.client_date, {open: open})}</td>
<td class="text-end amount">${fmtEur(l.taxable_amount, 2)}</td>
<td>${st}</td>
</tr>`;
}).join('');
return `
<div class="fw-bold mb-2" style="color:var(--c-ink);">Righe ordine (${lines.length})</div>
<div class="table-responsive">
<table class="table table-sm align-middle" style="font-size:.9rem;">
<thead style="background:#f1f5f9;">
<tr>
<th>Articolo</th><th class="text-end">Qtà</th><th class="text-end">Consegnata</th><th class="text-end">Residuo</th>
<th>Consegna (ZIBO / cliente)</th><th class="text-end">Imponibile</th><th>Stato</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
/* ---------------- SINCRONIZZAZIONE ---------------- */
function runSync(mode) {
const labels = {
@@ -1875,6 +2149,7 @@ if ($action === 'order') {
icon: 'success',
title: 'Dati aggiornati',
html: `Ordini letti: <b>${fmtNum(o.records)}</b>` +
(d.righe ? `<br>Righe lette: <b>${fmtNum(d.righe.records)}</b>` : '') +
(d.importi ? `<br>Totali letti: <b>${fmtNum(d.importi.records)}</b>` : '') +
(c.records !== undefined ? `<br>Clienti aggiornati: <b>${fmtNum(c.records)}</b>` : '') +
(d.logoff !== 'OK' ? `<div class="small text-muted mt-2">Logoff Mago: ${esc(d.logoff)}</div>` : ''),
@@ -1958,8 +2233,16 @@ if ($action === 'order') {
loadFilterOptions(false).then(d => {
window.__totalOrders = d && d.sync ? d.sync.total_orders : 0;
const saved = restoreFilters();
const saved = URL_Q ? null : restoreFilters();
if (saved && saved.metric === 'value') metric = 'value';
if (URL_Q) {
// tutto lo storico, solo l'ordine cercato; se è uno solo apro il dettaglio
$('#fFrom').val((d && d.range && d.range.min_date) || '2020-01-01');
$('#fTo').val(iso(new Date()));
$('#periodChips .chip').removeClass('active');
$('#fSearch').val(URL_Q);
window.__autoOpen = URL_Q;
}
if (saved && saved.from) {
$('#fFrom').val(saved.from);
$('#fTo').val(saved.to);
@@ -1968,7 +2251,7 @@ if ($action === 'order') {
$('#fAreas').val(saved.areas || []).trigger('change.select2');
(saved.statuses || []).forEach(s => $(`#statusChips .chip[data-status="${s}"]`).addClass('active'));
$('#fSearch').val(saved.q || '');
} else {
} else if (!URL_Q) {
setPeriod('y' + new Date().getFullYear(), true);
}
loadData();