diff --git a/public/userarea/class/MagoSalesStats.php b/public/userarea/class/MagoSalesStats.php index 6a68b280..b1a047ea 100644 --- a/public/userarea/class/MagoSalesStats.php +++ b/public/userarea/class/MagoSalesStats.php @@ -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', diff --git a/public/userarea/sales_orders_years.php b/public/userarea/sales_orders_years.php index 3bd6f072..0581f6b0 100644 --- a/public/userarea/sales_orders_years.php +++ b/public/userarea/sales_orders_years.php @@ -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') {
+ +