added book to bill
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -148,9 +148,9 @@ if ($action === 'data') {
|
||||
|
||||
// --- FATTURATO per data documento (fatture + acconti - note di credito) ---
|
||||
$invoiced = ['available' => false, 'summary' => [], 'monthly' => []];
|
||||
try {
|
||||
$iFrom = MagoSalesStats::invoiceFrom();
|
||||
$iWhere = preg_replace('/\bx\./', 'i.', $where); // stessi filtri cliente/agente/area
|
||||
try {
|
||||
$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"> </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();
|
||||
|
||||
Reference in New Issue
Block a user