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 ...
|
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). */
|
/** Etichette dei tipi documento (tabella documenti). */
|
||||||
const INVOICE_TYPE_LABELS = [
|
const INVOICE_TYPE_LABELS = [
|
||||||
3407874 => 'Fattura',
|
3407874 => 'Fattura',
|
||||||
|
|||||||
@@ -148,9 +148,9 @@ if ($action === 'data') {
|
|||||||
|
|
||||||
// --- FATTURATO per data documento (fatture + acconti - note di credito) ---
|
// --- FATTURATO per data documento (fatture + acconti - note di credito) ---
|
||||||
$invoiced = ['available' => false, 'summary' => [], 'monthly' => []];
|
$invoiced = ['available' => false, 'summary' => [], 'monthly' => []];
|
||||||
try {
|
|
||||||
$iFrom = MagoSalesStats::invoiceFrom();
|
$iFrom = MagoSalesStats::invoiceFrom();
|
||||||
$iWhere = preg_replace('/\bx\./', 'i.', $where); // stessi filtri cliente/agente/area
|
$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')" : '';
|
$iYtd = $ytd ? " AND DATE_FORMAT(i.document_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d')" : '';
|
||||||
|
|
||||||
$invoiced['summary'] = MagoSalesStats::fetchAllQ($pdo, "
|
$invoiced['summary'] = MagoSalesStats::fetchAllQ($pdo, "
|
||||||
@@ -178,8 +178,84 @@ if ($action === 'data') {
|
|||||||
$invoiced['error'] = 'Fatturato non disponibile: eseguire la migrazione e la sincronizzazione';
|
$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([
|
MagoSalesStats::jsonOut([
|
||||||
'success' => true,
|
'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,
|
'invoiced' => $invoiced,
|
||||||
'metric' => $metric,
|
'metric' => $metric,
|
||||||
'ytd' => $ytd,
|
'ytd' => $ytd,
|
||||||
@@ -513,6 +589,166 @@ if ($action === 'data') {
|
|||||||
text-transform: uppercase;
|
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 {
|
.inv-toggle {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -783,6 +1019,18 @@ if ($action === 'data') {
|
|||||||
<div class="warn-strip mb-3" id="missingAmounts" style="display:none;"></div>
|
<div class="warn-strip mb-3" id="missingAmounts" style="display:none;"></div>
|
||||||
|
|
||||||
<!-- RIEPILOGO ANNI -->
|
<!-- 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>
|
<div class="year-grid mb-4" id="yearCards"></div>
|
||||||
|
|
||||||
<!-- GRAFICI -->
|
<!-- GRAFICI -->
|
||||||
@@ -846,6 +1094,26 @@ if ($action === 'data') {
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- CLIENTI -->
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
|
<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);
|
renderCards(d);
|
||||||
renderMonthly(d);
|
renderMonthly(d);
|
||||||
renderCumul(d);
|
renderCumul(d);
|
||||||
|
renderDash(d);
|
||||||
renderInvoiced(d);
|
renderInvoiced(d);
|
||||||
renderInvTables();
|
renderInvTables();
|
||||||
|
renderDormant(d);
|
||||||
renderCustomers(d);
|
renderCustomers(d);
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
$('#content').css('opacity', 1);
|
$('#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) ---------------- */
|
/* ---------------- TABELLA DATI FATTURATO (a scomparsa) ---------------- */
|
||||||
let invMonthDt = null,
|
let invMonthDt = null,
|
||||||
invDocDt = null,
|
invDocDt = null,
|
||||||
@@ -1307,7 +1880,7 @@ if ($action === 'data') {
|
|||||||
return $('#invToggle').attr('aria-expanded') === 'true';
|
return $('#invToggle').attr('aria-expanded') === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
function invFiltersLabel() {
|
function invFiltersLabel(noYtd) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const n = (id, l) => {
|
const n = (id, l) => {
|
||||||
const v = $(id).val() || [];
|
const v = $(id).val() || [];
|
||||||
@@ -1316,7 +1889,7 @@ if ($action === 'data') {
|
|||||||
n('#fCustomers', 'clienti');
|
n('#fCustomers', 'clienti');
|
||||||
n('#fSalespeople', 'agenti');
|
n('#fSalespeople', 'agenti');
|
||||||
n('#fAreas', 'aree');
|
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(' · ') + ')' : '';
|
return parts.length ? ' (' + parts.join(' · ') + ')' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1715,6 +2288,12 @@ if ($action === 'data') {
|
|||||||
scheduleLoad(0);
|
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
|
// tabella dati fatturato: apri/chiudi (stato ricordato) e schede
|
||||||
$('#invToggle').on('click', function() {
|
$('#invToggle').on('click', function() {
|
||||||
const open = !invOpen();
|
const open = !invOpen();
|
||||||
|
|||||||
Reference in New Issue
Block a user