added book to bill

This commit is contained in:
2026-10-15 10:06:58 +02:00
parent ab7dd0f2f9
commit c3234e3160
2 changed files with 701 additions and 4 deletions
+118
View File
@@ -70,6 +70,124 @@ class MagoSalesStats
3407876 => -1, // Nota di credito (TD04) → in negativo serie D ...
];
/**
* CLIENTI DA RICHIAMARE: clienti "regolari" (almeno DORMANT_MIN_ORDERS giorni d'ordine negli ultimi
* DORMANT_WINDOW_YEARS anni) in ritardo rispetto al loro ritmo abituale (mediana dei giorni tra due ordini).
* ritardo = giorni dall'ultimo ordine / ritmo abituale
*/
const DORMANT_WINDOW_YEARS = 3;
const DORMANT_MIN_ORDERS = 4;
const DORMANT_MIN_DAYS = 30; // sotto i 30 giorni dall'ultimo ordine nessun allarme
const DORMANT_WATCH = 1.5; // da 1,5 volte il ritmo: attenzione
const DORMANT_SLEEP = 2.0; // da 2 volte: dormiente
const DORMANT_LOST = 4.0; // da 4 volte (o oltre 1 anno): probabilmente perso
/**
* Calcola la lista clienti da richiamare a partire dagli ordini (code, name, order_date, v, salesperson, area, ord_no).
*/
public static function dormantCustomers(array $rows)
{
$by = [];
foreach ($rows as $r) {
$c = $r['code'];
if (!isset($by[$c])) $by[$c] = ['code' => $c, 'name' => $r['name'], 'dates' => [], 'orders' => []];
$by[$c]['dates'][$r['order_date']] = true;
$by[$c]['orders'][] = $r;
}
$today = new DateTime('today');
$out = [];
foreach ($by as $c) {
$dates = array_keys($c['dates']);
sort($dates);
$n = count($dates);
if ($n < self::DORMANT_MIN_ORDERS) continue;
$gaps = [];
for ($k = 1; $k < $n; $k++) {
$gaps[] = (int)(new DateTime($dates[$k - 1]))->diff(new DateTime($dates[$k]))->days;
}
sort($gaps);
$m = count($gaps);
$median = $m % 2 ? $gaps[intdiv($m, 2)] : ($gaps[$m / 2 - 1] + $gaps[$m / 2]) / 2;
$rhythm = max(7, (float)$median);
$last = end($dates);
$since = (int)(new DateTime($last))->diff($today)->days;
if ($since < self::DORMANT_MIN_DAYS) continue;
$ratio = $since / $rhythm;
if ($ratio >= self::DORMANT_LOST || $since >= 365) $level = 'lost';
elseif ($ratio >= self::DORMANT_SLEEP) $level = 'dormant';
elseif ($ratio >= self::DORMANT_WATCH) $level = 'watch';
else continue;
// ordinato nei 12 mesi fino all'ultimo ordine = quanto vale il cliente "a regime"
$from12 = (new DateTime($last))->modify('-365 days')->format('Y-m-d');
$v12 = 0.0;
$lastOrder = null;
foreach ($c['orders'] as $o) {
if ($o['order_date'] > $from12) $v12 += (float)$o['v'];
if (
!$lastOrder || $o['order_date'] > $lastOrder['order_date'] ||
($o['order_date'] === $lastOrder['order_date'] && $o['ord_no'] > $lastOrder['ord_no'])
) $lastOrder = $o;
}
$out[] = [
'code' => $c['code'],
'name' => $c['name'],
'salesperson' => $lastOrder['salesperson'],
'area' => $lastOrder['area'],
'last_date' => $last,
'last_no' => $lastOrder['ord_no'],
'days_since' => $since,
'rhythm' => (int)round($rhythm),
'ratio' => round($ratio, 1),
'orders' => $n,
'value12' => round($v12, 2),
'level' => $level,
];
}
usort($out, function ($a, $b) {
return $b['value12'] <=> $a['value12'];
});
return $out;
}
/**
* Previsione di fine anno: valore dell'anno in corso ad oggi diviso la quota che, negli anni
* precedenti (max 3), si era raggiunta alla stessa data. $rows: [y, full, ytd].
*/
public static function yearForecast(array $rows, $curYear)
{
$cur = null;
$shares = [];
$prevFull = null;
foreach ($rows as $r) {
$y = (int)$r['y'];
if ($y === $curYear) $cur = (float)$r['ytd'];
elseif ($y >= $curYear - 3 && $y < $curYear && (float)$r['full'] > 0 && (float)$r['ytd'] > 0) {
$shares[$y] = (float)$r['ytd'] / (float)$r['full'];
}
if ($y === $curYear - 1) $prevFull = (float)$r['full'];
}
if ($cur === null || !$shares) {
return ['ytd' => $cur, 'forecast' => null, 'prev_full' => $prevFull];
}
$avg = array_sum($shares) / count($shares);
ksort($shares);
return [
'ytd' => round($cur, 2),
'forecast' => round($cur / $avg, 2),
'low' => round($cur / max($shares), 2),
'high' => round($cur / min($shares), 2),
'prev_full' => $prevFull,
'years' => array_keys($shares),
'share' => round($avg * 100, 1),
];
}
/** Etichette dei tipi documento (tabella documenti). */
const INVOICE_TYPE_LABELS = [
3407874 => 'Fattura',
+583 -4
View File
@@ -148,9 +148,9 @@ if ($action === 'data') {
// --- FATTURATO per data documento (fatture + acconti - note di credito) ---
$invoiced = ['available' => false, 'summary' => [], 'monthly' => []];
$iFrom = MagoSalesStats::invoiceFrom();
$iWhere = preg_replace('/\bx\./', 'i.', $where); // stessi filtri cliente/agente/area
try {
$iFrom = MagoSalesStats::invoiceFrom();
$iWhere = preg_replace('/\bx\./', 'i.', $where); // stessi filtri cliente/agente/area
$iYtd = $ytd ? " AND DATE_FORMAT(i.document_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d')" : '';
$invoiced['summary'] = MagoSalesStats::fetchAllQ($pdo, "
@@ -178,8 +178,84 @@ if ($action === 'data') {
$invoiced['error'] = 'Fatturato non disponibile: eseguire la migrazione e la sincronizzazione';
}
// ===== CRUSCOTTO DIREZIONE: situazione ad oggi (rispetta solo i filtri cliente / agente / area) =====
$curY = (int)date('Y');
$val = "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END";
$dash = [];
// portafoglio da consegnare = valore residuo delle righe aperte
$rWhere = preg_replace('/\bx\./', 'r.', $where);
$dash['backlog'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT COALESCE(SUM(r.residual_value), 0) AS v,
COUNT(*) AS n_lines,
COUNT(DISTINCT r.sale_ord_id) AS n_orders,
COALESCE(SUM(CASE WHEN r.due_date < CURDATE() THEN r.residual_value END), 0) AS late_v
FROM " . MagoSalesStats::openLinesFrom() . "
WHERE $rWhere
", $params)[0];
// ordinato: ultimi 90 giorni, mesi (24) e anni per la previsione
$dash['ordered_90'] = (float)MagoSalesStats::fetchAllQ($pdo, "
SELECT COALESCE(SUM($val), 0) AS v FROM $from WHERE $where AND x.order_date > CURDATE() - INTERVAL 90 DAY
", $params)[0]['v'];
$since24 = date('Y-m-01', strtotime('first day of -23 months'));
$dash['since'] = $since24;
$dash['ordered_months'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT DATE_FORMAT(x.order_date, '%Y-%m') AS ym, SUM($val) AS v
FROM $from WHERE $where AND x.order_date >= '$since24'
GROUP BY ym ORDER BY ym
", $params);
$ordYears = MagoSalesStats::fetchAllQ($pdo, "
SELECT YEAR(x.order_date) AS y, SUM($val) AS full,
SUM(CASE WHEN DATE_FORMAT(x.order_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d') THEN $val ELSE 0 END) AS ytd
FROM $from WHERE $where AND x.order_date >= '" . ($curY - 3) . "-01-01'
GROUP BY y
", $params);
$dash['ordered_forecast'] = MagoSalesStats::yearForecast($ordYears, $curY);
// fatturato (se i documenti sono sincronizzati)
$dash['invoiced_90'] = null;
$dash['invoiced_months'] = [];
$dash['invoiced_forecast'] = null;
if (!empty($invoiced['available'])) {
$dash['invoiced_90'] = (float)MagoSalesStats::fetchAllQ($pdo, "
SELECT COALESCE(SUM(i.revenue), 0) AS v FROM $iFrom WHERE $iWhere AND i.document_date > CURDATE() - INTERVAL 90 DAY
", $params)[0]['v'];
$dash['invoiced_months'] = MagoSalesStats::fetchAllQ($pdo, "
SELECT DATE_FORMAT(i.document_date, '%Y-%m') AS ym, SUM(i.revenue) AS v
FROM $iFrom WHERE $iWhere AND i.document_date >= '$since24'
GROUP BY ym ORDER BY ym
", $params);
$invYears = MagoSalesStats::fetchAllQ($pdo, "
SELECT YEAR(i.document_date) AS y, SUM(i.revenue) AS full,
SUM(CASE WHEN DATE_FORMAT(i.document_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d') THEN i.revenue ELSE 0 END) AS ytd
FROM $iFrom WHERE $iWhere AND i.document_date >= '" . ($curY - 3) . "-01-01'
GROUP BY y
", $params);
$dash['invoiced_forecast'] = MagoSalesStats::yearForecast($invYears, $curY);
}
// ===== CLIENTI DA RICHIAMARE =====
$dormRows = MagoSalesStats::fetchAllQ($pdo, "
SELECT x.customer AS code, x.company_name AS name, x.order_date, x.taxable_amount AS v,
x.salesperson, x.area, x.internal_ord_no AS ord_no
FROM $from
WHERE $where AND x.status <> 'annullato' AND x.customer <> ''
AND x.order_date >= CURDATE() - INTERVAL " . (int)MagoSalesStats::DORMANT_WINDOW_YEARS . " YEAR
", $params);
$dormant = MagoSalesStats::dormantCustomers($dormRows);
MagoSalesStats::jsonOut([
'success' => true,
'dash' => $dash,
'dormant' => $dormant,
'dormant_cfg' => [
'years' => MagoSalesStats::DORMANT_WINDOW_YEARS,
'min_orders' => MagoSalesStats::DORMANT_MIN_ORDERS,
'watch' => MagoSalesStats::DORMANT_WATCH,
'sleep' => MagoSalesStats::DORMANT_SLEEP,
'lost' => MagoSalesStats::DORMANT_LOST,
],
'invoiced' => $invoiced,
'metric' => $metric,
'ytd' => $ytd,
@@ -513,6 +589,166 @@ if ($action === 'data') {
text-transform: uppercase;
}
/* ---------- CRUSCOTTO ---------- */
.dash-head {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
font-weight: 800;
color: var(--c-ink);
margin-bottom: 10px;
}
.dash-head small {
font-weight: 600;
color: var(--c-muted);
font-size: .8rem;
}
.dash-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 14px;
}
@media (min-width: 1200px) {
.dash-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.dash-card {
background: color-mix(in srgb, var(--k) 12%, #fff);
border: 1px solid color-mix(in srgb, var(--k) 28%, #fff);
border-radius: 16px;
padding: 16px 18px;
position: relative;
}
.dash-card .lbl {
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .5px;
color: color-mix(in srgb, var(--k) 70%, #1f2d3d);
padding-right: 40px;
}
.dash-card .ico {
position: absolute;
right: 14px;
top: 14px;
width: 36px;
height: 36px;
border-radius: 10px;
background: #fff;
color: var(--k);
display: flex;
align-items: center;
justify-content: center;
}
.dash-card .big {
font-size: 1.75rem;
font-weight: 800;
color: var(--c-ink);
line-height: 1.15;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.dash-card .sub {
font-size: .84rem;
color: var(--c-muted);
margin-top: 3px;
line-height: 1.4;
}
.dash-card .sub b {
color: var(--c-ink);
}
.b2b-pill {
display: inline-block;
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 9px;
margin-left: 6px;
vertical-align: middle;
}
.b2b-pill.up {
background: #dcfce7;
color: #166534;
}
.b2b-pill.flat {
background: #fef3c7;
color: #92400e;
}
.b2b-pill.down {
background: #fee2e2;
color: #991b1b;
}
/* ---------- CLIENTI DA RICHIAMARE ---------- */
.dorm-sum {
font-size: .9rem;
color: var(--c-muted);
}
.dorm-sum b {
color: var(--c-ink);
}
.lvl {
display: inline-block;
font-size: .72rem;
font-weight: 800;
border-radius: 999px;
padding: 2px 9px;
white-space: nowrap;
}
.lvl.watch {
background: #fef9c3;
color: #854d0e;
}
.lvl.dormant {
background: #ffedd5;
color: #9a3412;
}
.lvl.lost {
background: #fee2e2;
color: #991b1b;
}
.ratio-bar {
display: inline-block;
width: 54px;
height: 6px;
border-radius: 99px;
background: #eef2f7;
vertical-align: middle;
margin-left: 6px;
overflow: hidden;
}
.ratio-bar i {
display: block;
height: 100%;
border-radius: 99px;
}
#dormTable tbody tr {
cursor: pointer;
}
.inv-toggle {
width: 100%;
display: flex;
@@ -783,6 +1019,18 @@ if ($action === 'data') {
<div class="warn-strip mb-3" id="missingAmounts" style="display:none;"></div>
<!-- RIEPILOGO ANNI -->
<!-- CRUSCOTTO DIREZIONE: situazione ad oggi -->
<div class="dash-head">
<span><i class="fa-solid fa-gauge-high me-2"></i>Situazione ad oggi</span>
<small>non dipende dagli anni scelti · segue i filtri cliente, agente e area</small>
</div>
<div class="dash-grid mb-3" id="dashCards"></div>
<div class="chart-card mb-4">
<div class="chart-title">Ordinato vs fatturato · ultimi 24 mesi</div>
<div class="chart-hint">Barre: valori del mese · linea: book-to-bill su 3 mesi (ordinato ÷ fatturato: sopra 1 il portafoglio cresce, sotto 1 si consuma)</div>
<div id="chB2B" style="min-height:320px;"></div>
</div>
<div class="year-grid mb-4" id="yearCards"></div>
<!-- GRAFICI -->
@@ -846,6 +1094,26 @@ if ($action === 'data') {
</div>
</div>
<!-- CLIENTI DA RICHIAMARE -->
<div class="chart-card mb-3" id="dormCard">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2">
<div>
<div class="chart-title"><i class="fa-solid fa-phone-volume me-2" style="color:#c2410c;"></i>Clienti da richiamare</div>
<div class="chart-hint" id="dormHint">&nbsp;</div>
</div>
<div class="chip-row" id="dormChips">
<button type="button" class="chip active" data-level="" style="--yc:#198754;">Tutti <span class="badge bg-light text-dark" id="dCntAll">0</span></button>
<button type="button" class="chip" data-level="watch" style="--yc:#ca8a04;">Attenzione <span class="badge bg-light text-dark" id="dCntWatch">0</span></button>
<button type="button" class="chip" data-level="dormant" style="--yc:#c2410c;">Dormienti <span class="badge bg-light text-dark" id="dCntDormant">0</span></button>
<button type="button" class="chip" data-level="lost" style="--yc:#b02a37;">Probabilmente persi <span class="badge bg-light text-dark" id="dCntLost">0</span></button>
</div>
</div>
<div class="dorm-sum mb-2" id="dormSum"></div>
<div class="table-responsive">
<table id="dormTable" class="table table-striped align-middle w-100"></table>
</div>
</div>
<!-- CLIENTI -->
<div class="chart-card">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
@@ -1058,8 +1326,10 @@ if ($action === 'data') {
renderCards(d);
renderMonthly(d);
renderCumul(d);
renderDash(d);
renderInvoiced(d);
renderInvTables();
renderDormant(d);
renderCustomers(d);
}).catch(e => {
$('#content').css('opacity', 1);
@@ -1269,6 +1539,309 @@ if ($action === 'data') {
});
}
/* ---------------- CRUSCOTTO DIREZIONE ---------------- */
function renderDash(d) {
const x = d.dash || {};
const b = x.backlog || {};
const invOk = !!(d.invoiced && d.invoiced.available);
const cards = [];
// 1) portafoglio da consegnare
const weekly = invOk && x.invoiced_90 > 0 ? x.invoiced_90 / (90 / 7) : (x.ordered_90 > 0 ? x.ordered_90 / (90 / 7) : 0);
const weeks = weekly > 0 && +b.v > 0 ? (+b.v / weekly) : null;
cards.push(`
<div class="dash-card" style="--k:#1d4ed8;" title="Valore residuo delle righe d'ordine ancora da consegnare">
<div class="ico"><i class="fa-solid fa-boxes-stacked"></i></div>
<div class="lbl">Portafoglio da consegnare</div>
<div class="big">${fmtEurShort(+b.v || 0)}</div>
<div class="sub">${fmtNum(b.n_lines || 0)} righe · ${fmtNum(b.n_orders || 0)} ordini${weeks !== null ? `<br>≈ <b>${fmtNum(Math.round(weeks))} settimane</b> di ${invOk ? 'fatturato' : 'ordinato'} medio` : ''}${+b.late_v > 0 ? `<br><span class="delta-down">${fmtEurShort(+b.late_v)}</span> già oltre data ZIBO` : ''}</div>
</div>`);
// 2) book-to-bill 90 giorni
if (invOk && x.invoiced_90 > 0) {
const r = x.ordered_90 / x.invoiced_90;
const cls = r >= 1.05 ? 'up' : (r <= 0.95 ? 'down' : 'flat');
const lbl = cls === 'up' ? 'portafoglio in crescita' : (cls === 'down' ? 'portafoglio in calo' : 'stabile');
cards.push(`
<div class="dash-card" style="--k:${cls === 'up' ? '#15803d' : (cls === 'down' ? '#b02a37' : '#b45309')};" title="Ordinato ÷ fatturato negli ultimi 90 giorni">
<div class="ico"><i class="fa-solid fa-scale-balanced"></i></div>
<div class="lbl">Book-to-bill · 90 gg</div>
<div class="big">${r.toLocaleString('it-IT', {minimumFractionDigits: 2, maximumFractionDigits: 2})}<span class="b2b-pill ${cls}">${lbl}</span></div>
<div class="sub">Ordinato <b>${fmtEurShort(x.ordered_90)}</b> · fatturato <b>${fmtEurShort(x.invoiced_90)}</b></div>
</div>`);
} else {
cards.push(dashNa('Book-to-bill · 90 gg', 'fa-scale-balanced', invOk ? 'nessun fatturato negli ultimi 90 giorni' : 'serve la sincronizzazione delle fatture'));
}
// 3-4) previsioni di fine anno
cards.push(forecastCard('Previsione fatturato ' + CUR_Y, 'fa-file-invoice-dollar', '#0f766e', invOk ? x.invoiced_forecast : null, invOk ? '' : 'serve la sincronizzazione delle fatture'));
cards.push(forecastCard('Previsione ordinato ' + CUR_Y, 'fa-chart-line', '#6f42c1', x.ordered_forecast, ''));
$('#dashCards').html(cards.join(''));
renderB2BChart(x);
}
function dashNa(label, icon, msg) {
return `<div class="dash-card" style="--k:#64748b;"><div class="ico"><i class="fa-solid ${icon}"></i></div>
<div class="lbl">${label}</div><div class="big">—</div><div class="sub">${esc(msg)}</div></div>`;
}
function forecastCard(label, icon, color, f, naMsg) {
if (!f || f.forecast === null || f.forecast === undefined) {
return dashNa(label, icon, naMsg || 'servono dati degli anni precedenti');
}
const vsPrev = f.prev_full ? pct(f.forecast, f.prev_full) : null;
return `
<div class="dash-card" style="--k:${color};" title="Ad oggi ${esc(fmtEur(f.ytd))}: negli anni ${f.years.join(', ')} a questa data si era in media al ${String(f.share).replace('.', ',')}% dell'anno">
<div class="ico"><i class="fa-solid ${icon}"></i></div>
<div class="lbl">${label}</div>
<div class="big">${fmtEurShort(f.forecast)}</div>
<div class="sub">forbice ${fmtEurShort(f.low)} – ${fmtEurShort(f.high)}<br>
${f.prev_full ? `${CUR_Y - 1}: ${fmtEurShort(f.prev_full)} ${deltaSpan(vsPrev, '')}` : ''}<br>
<span style="font-size:.76rem;">ad oggi ${fmtEurShort(f.ytd)} · stagionalità ${f.years.join('-')}</span></div>
</div>`;
}
function renderB2BChart(x) {
const keys = [];
const start = new Date(x.since + 'T00:00:00');
for (let i = 0; i < 24; i++) {
const dd = new Date(start.getFullYear(), start.getMonth() + i, 1);
keys.push(dd.getFullYear() + '-' + String(dd.getMonth() + 1).padStart(2, '0'));
}
const toMap = rows => {
const m = {};
(rows || []).forEach(r => m[r.ym] = +r.v);
return m;
};
const om = toMap(x.ordered_months),
im = toMap(x.invoiced_months);
const ord = keys.map(k => om[k] || 0);
const inv = keys.map(k => im[k] || 0);
const hasInv = (x.invoiced_months || []).length > 0;
// book-to-bill mobile su 3 mesi
const b2b = keys.map((k, i) => {
if (!hasInv || i < 2) return null;
const o = ord[i] + ord[i - 1] + ord[i - 2],
f = inv[i] + inv[i - 1] + inv[i - 2];
return f > 0 ? Math.round(o / f * 100) / 100 : null;
});
const cats = keys.map(k => MONTHS[+k.substring(5) - 1] + ' ' + k.substring(2, 4));
const series = [{
name: 'Ordinato',
type: 'column',
data: ord
}];
if (hasInv) series.push({
name: 'Fatturato',
type: 'column',
data: inv
}, {
name: 'Book-to-bill 3 mesi',
type: 'line',
data: b2b
});
const yaxis = [{
seriesName: 'Ordinato',
labels: {
formatter: fmtEurShort
}
}];
if (hasInv) yaxis.push({
seriesName: 'Ordinato',
show: false
}, {
seriesName: 'Book-to-bill 3 mesi',
opposite: true,
min: 0,
max: v => Math.max(2, Math.ceil((v || 0) * 2) / 2),
tickAmount: 4,
labels: {
formatter: v => v === null || v === undefined ? '' : v.toLocaleString('it-IT', {
maximumFractionDigits: 2
})
}
});
upsert('b2b', 'chB2B', {
chart: {
...base,
type: 'line',
height: 320,
stacked: false
},
series: series,
colors: ['#6f42c1', '#0f766e', '#f59e0b'],
stroke: {
width: hasInv ? [0, 0, 3] : [0],
curve: 'smooth'
},
plotOptions: {
bar: {
columnWidth: '62%',
borderRadius: 3
}
},
markers: {
size: hasInv ? [0, 0, 3] : [0]
},
dataLabels: {
enabled: false
},
xaxis: {
categories: cats,
labels: {
rotate: -45,
hideOverlappingLabels: true
}
},
yaxis: yaxis,
annotations: hasInv ? {
yAxis: [{
y: 1,
yAxisIndex: 2,
borderColor: '#f59e0b',
strokeDashArray: 4,
label: {
text: 'parità 1,0',
style: {
background: '#fef3c7',
color: '#92400e'
}
}
}]
} : {},
tooltip: {
shared: true,
intersect: false,
y: {
formatter: (v, o) => v === null || v === undefined ? '—' : (o && o.seriesIndex === 2 ? v.toLocaleString('it-IT', {
minimumFractionDigits: 2
}) : fmtEur(v))
}
},
legend: {
position: 'top',
horizontalAlign: 'left'
},
grid: {
borderColor: '#eef2f7'
}
});
}
/* ---------------- CLIENTI DA RICHIAMARE ---------------- */
let dormDt = null,
dormLevel = '';
const LVL = {
watch: 'Attenzione',
dormant: 'Dormiente',
lost: 'Probabilmente perso'
};
function renderDormant(d) {
const all = d.dormant || [];
const cfg = d.dormant_cfg || {};
const cnt = {
watch: 0,
dormant: 0,
lost: 0
};
all.forEach(c => cnt[c.level]++);
$('#dCntAll').text(fmtNum(all.length));
$('#dCntWatch').text(cnt.watch);
$('#dCntDormant').text(cnt.dormant);
$('#dCntLost').text(cnt.lost);
$('#dormChips .chip').removeClass('active');
$(`#dormChips .chip[data-level="${dormLevel}"]`).addClass('active');
$('#dormHint').text(`Clienti con almeno ${cfg.min_orders} ordini negli ultimi ${cfg.years} anni che ora tardano rispetto al loro ritmo abituale · clicca per filtrare la pagina`);
const rows = all.filter(c => !dormLevel || c.level === dormLevel);
const risk = rows.reduce((a, c) => a + (+c.value12 || 0), 0);
$('#dormSum').html(rows.length ?
`<b>${fmtNum(rows.length)}</b> clienti · <b>${fmtEurShort(risk)}</b> di ordinato annuo a rischio (ordinato dei 12 mesi prima del loro ultimo ordine)` :
'Nessun cliente da richiamare con i filtri attuali 👍');
if (dormDt) {
dormDt.destroy();
$('#dormTable').empty();
}
dormDt = $('#dormTable').DataTable({
data: rows,
columns: [{
title: 'Cliente',
data: null,
render: (c, type) => type === 'display' ?
`<div class="fw-semibold">${esc(c.name)}</div><div class="small text-muted">${esc(c.code)}</div>` : (type === 'export' ? `${c.name} (${c.code})` : `${c.name} ${c.code}`)
}, {
title: 'Agente',
data: 'salesperson'
}, {
title: 'Area',
data: 'area'
}, {
title: 'Ultimo ordine',
data: 'last_date',
render: (v, type, c) => {
if (type === 'export') return v;
if (type !== 'display') return v;
const p = v.split('-');
return `<div>${p[2]}/${p[1]}/${p[0]} <span class="small text-muted">${esc(c.last_no)}</span></div><div class="small text-muted">${fmtNum(c.days_since)} giorni fa</div>`;
}
}, {
title: 'Ritmo abituale',
data: 'rhythm',
className: 'text-end',
render: (v, type) => type === 'display' ? `ogni ~${fmtNum(v)} gg` : +v
}, {
title: 'Ritardo',
data: 'ratio',
className: 'text-end text-nowrap',
render: (v, type, c) => {
if (type !== 'display') return +v;
const w = Math.min(100, v / (cfg.lost || 4) * 100);
const col = c.level === 'lost' ? '#dc2626' : (c.level === 'dormant' ? '#ea580c' : '#ca8a04');
return `<b>${String(v).replace('.', ',')}×</b><span class="ratio-bar"><i style="width:${w}%;background:${col};"></i></span>`;
}
}, {
title: `Ordini ${cfg.years} anni`,
data: 'orders',
className: 'text-end'
}, {
title: 'Ordinato 12 mesi',
data: 'value12',
className: 'text-end amount',
render: (v, type) => type === 'display' ? fmtEur(+v) : +v
}, {
title: 'Stato',
data: 'level',
render: (v, type) => type === 'display' ? `<span class="lvl ${v}">${LVL[v]}</span>` : LVL[v]
}],
order: [
[7, 'desc']
],
pageLength: 10,
lengthMenu: [10, 25, 50, 100],
dom: "<'row mb-2'<'col-md-6'B><'col-md-6'f>>rt<'row mt-2'<'col-md-5'i><'col-md-7'p>>",
buttons: xlsButton('Clienti da richiamare' + (dormLevel ? ' - ' + LVL[dormLevel] : '') + invFiltersLabel(true)),
language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json'
}
});
$('#dormTable tbody').off('click').on('click', 'tr', function() {
const c = dormDt.row(this).data();
if (!c) return;
$('#fCustomers').val([c.code]).trigger('change');
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});
}
/* ---------------- TABELLA DATI FATTURATO (a scomparsa) ---------------- */
let invMonthDt = null,
invDocDt = null,
@@ -1307,7 +1880,7 @@ if ($action === 'data') {
return $('#invToggle').attr('aria-expanded') === 'true';
}
function invFiltersLabel() {
function invFiltersLabel(noYtd) {
const parts = [];
const n = (id, l) => {
const v = $(id).val() || [];
@@ -1316,7 +1889,7 @@ if ($action === 'data') {
n('#fCustomers', 'clienti');
n('#fSalespeople', 'agenti');
n('#fAreas', 'aree');
if (lastData && lastData.ytd) parts.push(`a pari data ${todayLabel()}`);
if (!noYtd && lastData && lastData.ytd) parts.push(`a pari data ${todayLabel()}`);
return parts.length ? ' (' + parts.join(' · ') + ')' : '';
}
@@ -1715,6 +2288,12 @@ if ($action === 'data') {
scheduleLoad(0);
});
// clienti da richiamare: filtro per livello
$('#dormChips').on('click', '.chip', function() {
dormLevel = $(this).data('level') || '';
if (lastData) renderDormant(lastData);
});
// tabella dati fatturato: apri/chiudi (stato ricordato) e schede
$('#invToggle').on('click', function() {
const open = !invOpen();