diff --git a/public/userarea/class/MagoSalesStats.php b/public/userarea/class/MagoSalesStats.php index 3ac57efe..0dda9934 100644 --- a/public/userarea/class/MagoSalesStats.php +++ b/public/userarea/class/MagoSalesStats.php @@ -211,6 +211,32 @@ class MagoSalesStats ]; } + /** + * SOMMARIO (semafori): soglie [verde, rosso]. Tra le due = giallo. + * Per gli indicatori "più alto è meglio" verde se >= primo valore, rosso se < secondo. + * Per quelli "più basso è meglio" verde se <= primo valore, rosso se > secondo. + */ + const SUMMARY_THRESHOLDS = [ + 'growth' => [3, -3], // % crescita vs stessa data anno prima (fatturato, ordinato, previsione) + 'b2b' => [1.05, 0.95], // book-to-bill 90 gg + 'cover_weeks' => [8, 4], // settimane di lavoro in portafoglio + 'late_pct' => [10, 25], // % del portafoglio già oltre data ZIBO (più basso è meglio) + 'risk_pct' => [3, 8], // % fatturato 12 mesi di clienti dormienti/persi (più basso è meglio) + 'top5_pct' => [40, 60], // % fatturato 12 mesi dei primi 5 clienti (più basso è meglio) + 'cn_pct' => [1.5, 3], // % note di credito su fatturato 12 mesi (più basso è meglio) + 'customers' => [0, -5], // % variazione clienti attivi 12 mesi + ]; + + /** Semaforo: 'green' | 'yellow' | 'red' | 'grey' (dato non disponibile). */ + public static function light($value, $key, $lowerIsBetter = false) + { + if ($value === null || !is_finite((float)$value)) return 'grey'; + [$g, $r] = self::SUMMARY_THRESHOLDS[$key]; + $v = (float)$value; + if ($lowerIsBetter) return $v <= $g ? 'green' : ($v > $r ? 'red' : 'yellow'); + return $v >= $g ? 'green' : ($v < $r ? 'red' : 'yellow'); + } + /** Etichette dei tipi documento (tabella documenti). */ const INVOICE_TYPE_LABELS = [ 3407874 => 'Fattura', diff --git a/public/userarea/sales_customer.php b/public/userarea/sales_customer.php index 823e8538..bcdfd2bc 100644 --- a/public/userarea/sales_customer.php +++ b/public/userarea/sales_customer.php @@ -1198,6 +1198,7 @@ if ($action === 'data') {
+ Sommario Andamento ordini Confronto anni Scheda cliente @@ -1925,7 +1926,7 @@ if ($action === 'data') { }); })(); - + \ No newline at end of file diff --git a/public/userarea/sales_help.js b/public/userarea/sales_help.js index 93843fe9..30495433 100644 --- a/public/userarea/sales_help.js +++ b/public/userarea/sales_help.js @@ -57,6 +57,87 @@

Utile prima di una telefonata o di una visita. I clienti visti di recente restano a portata di clic.

`, }, + "page.summary": { + t: "Sommario: come si legge", + b: `

Il riepilogo per la direzione: in un colpo d'occhio cosa va bene e cosa no.

+ +

Il sommario guarda sempre tutta l'azienda ad oggi: non ha filtri. Per approfondire usa le altre pagine.

`, + }, + + /* ===================== SOMMARIO ===================== */ + "sm.invYtd": { + t: "Fatturato dell'anno", + b: `

Fatturato da gennaio a oggi rispetto allo stesso periodo dell'anno scorso.

+

🟢 almeno +3% · 🟡 tra −3% e +3% · 🔴 sotto −3%.

`, + }, + "sm.ordYtd": { + t: "Ordinato dell'anno", + b: `

Ordini ricevuti da gennaio a oggi rispetto allo stesso periodo dell'anno scorso. È il fatturato dei prossimi mesi.

+

🟢 almeno +3% · 🟡 tra −3% e +3% · 🔴 sotto −3%.

`, + }, + "sm.forecast": { + t: "Chiusura prevista", + b: `

Come chiuderà l'anno se si continua con lo stesso ritmo degli anni scorsi, confrontata con l'anno scorso intero.

+

🟢 almeno +3% · 🟡 tra −3% e +3% · 🔴 sotto −3%.

+

È una stima: diventa più affidabile man mano che l'anno avanza.

`, + }, + "sm.b2b": { + t: "Book-to-bill", + b: `

Ordini entrati ÷ fatture emesse negli ultimi 90 giorni. Dice se il lavoro in arrivo tiene il passo con quello che consegniamo.

+

🟢 da 1,05 (il portafoglio cresce) · 🟡 tra 0,95 e 1,05 · 🔴 sotto 0,95 (lo stiamo consumando).

+

Il grafico mostra lo stesso valore mese per mese (su 3 mesi): la riga tratteggiata è 1.

`, + }, + "sm.cover": { + t: "Lavoro in portafoglio", + b: `

Per quante settimane di lavoro basta quello che abbiamo da consegnare, al ritmo di fatturato degli ultimi 3 mesi.

+

🟢 almeno 8 settimane · 🟡 da 4 a 8 · 🔴 meno di 4.

`, + }, + "sm.late": { + t: "Consegne in ritardo", + b: `

Quanta parte del portafoglio è già oltre la settimana ZIBO comunicata al cliente.

+

🟢 fino al 10% · 🟡 dal 10 al 25% · 🔴 oltre il 25%.

+

Cosa fare: apri Consegne e parti dalle righe scadute.

`, + }, + "sm.risk": { + t: "Clienti a rischio", + b: `

Clienti abituali dormienti o probabilmente persi e quanto valevano in un anno, rispetto al giro d'affari totale.

+

🟢 fino al 3% · 🟡 dal 3 all'8% · 🔴 oltre l'8%.

+

Cosa fare: lista "Da richiamare subito" qui sotto, o quella completa in Confronto anni.

`, + }, + "sm.top5": { + t: "Peso dei primi 5 clienti", + b: `

Quanto del fatturato degli ultimi 12 mesi viene dai 5 clienti più grandi. Più è alto, più dipendiamo da pochi nomi.

+

🟢 fino al 40% · 🟡 dal 40 al 60% · 🔴 oltre il 60%.

`, + }, + "sm.customers": { + t: "Clienti attivi", + b: `

Clienti che hanno ordinato negli ultimi 12 mesi, confrontati con i 12 mesi prima. "Nuovi" = primo ordine nell'ultimo anno.

+

🟢 stabili o in crescita · 🟡 fino a −5% · 🔴 oltre −5%.

`, + }, + "sm.cn": { + t: "Note di credito", + b: `

Note di credito degli ultimi 12 mesi in percentuale sul fatturato: misura resi, sconti e contestazioni.

+

🟢 fino all'1,5% · 🟡 dall'1,5 al 3% · 🔴 oltre il 3%.

`, + }, + "sm.up": { + t: "Clienti che crescono", + b: `

I clienti che hanno aumentato di più (in euro) negli ultimi 12 mesi rispetto ai 12 precedenti. Da coltivare.

`, + }, + "sm.down": { + t: "Clienti che calano", + b: `

I clienti che sono diminuiti di più (in euro) negli ultimi 12 mesi rispetto ai 12 precedenti.

+

Cosa fare: capire il motivo (concorrenza, prezzi, problemi di consegna) con una telefonata.

`, + }, + "sm.call": { + t: "Da richiamare subito", + b: `

I clienti abituali più importanti che si sono fermati (dormienti o probabilmente persi), ordinati per valore.

`, + }, + /* ===================== ANDAMENTO ORDINI ===================== */ "st.orders": { t: "Ordini", diff --git a/public/userarea/sales_orders_deliveries.php b/public/userarea/sales_orders_deliveries.php index 680eb2c7..7e4259e3 100644 --- a/public/userarea/sales_orders_deliveries.php +++ b/public/userarea/sales_orders_deliveries.php @@ -630,6 +630,7 @@ if ($action === 'data') {
+ Sommario Andamento ordini Confronto anni Scheda cliente @@ -1299,7 +1300,7 @@ if ($action === 'data') { }); })(); - + \ No newline at end of file diff --git a/public/userarea/sales_orders_stats.php b/public/userarea/sales_orders_stats.php index e7327442..ddb09a5b 100644 --- a/public/userarea/sales_orders_stats.php +++ b/public/userarea/sales_orders_stats.php @@ -932,6 +932,7 @@ if ($action === 'order') {
+ Sommario Andamento ordini Confronto anni Scheda cliente @@ -2353,7 +2354,7 @@ if ($action === 'order') { }); })(); - + \ No newline at end of file diff --git a/public/userarea/sales_orders_years.php b/public/userarea/sales_orders_years.php index 3844fc06..04350aac 100644 --- a/public/userarea/sales_orders_years.php +++ b/public/userarea/sales_orders_years.php @@ -974,6 +974,7 @@ if ($action === 'data') {
+ Sommario Andamento ordini Confronto anni Scheda cliente @@ -2387,7 +2388,7 @@ if ($action === 'data') { }); })(); - + \ No newline at end of file diff --git a/public/userarea/sales_summary.php b/public/userarea/sales_summary.php new file mode 100644 index 00000000..5da54dd8 --- /dev/null +++ b/public/userarea/sales_summary.php @@ -0,0 +1,2025 @@ +getConnection(); +$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +// --- Permessi: pagina visibile solo con 'sales.stats.view' --- +if (!userCan('sales.stats.view')) { + if (isset($_GET['action']) || isset($_POST['action'])) { + MagoSalesStats::jsonOut(['success' => false, 'ok' => false, 'message' => 'Non hai i permessi per questa pagina']); + } + while (ob_get_level()) ob_end_clean(); + http_response_code(403); + echo '

Accesso non consentito

Non hai i permessi per visualizzare questa pagina.

Torna alla Dashboard
'; + exit; +} +$canDeliveries = userCan('sales.deliveries.view'); + +/* ============================================================ + * AJAX + * ============================================================ */ + +/** Importo breve per le frasi: 1,23 M€ / 456,7 k€ / 890 € */ +function eurShort($v) +{ + $v = (float)$v; + $a = abs($v); + if ($a >= 1e6) return number_format($v / 1e6, 2, ',', '.') . ' M€'; + if ($a >= 1e3) return number_format($v / 1e3, 1, ',', '.') . ' k€'; + return number_format($v, 0, ',', '.') . ' €'; +} +function pctTxt($p, $dec = 0) +{ + return ($p >= 0 ? '+' : '') . number_format($p, $dec, ',', '.') . '%'; +} +function pctOf($cur, $prev) +{ + return $prev ? ($cur - $prev) / abs($prev) * 100 : null; +} + +$action = $_GET['action'] ?? ''; + +if ($action === 'filters') { + MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo)); +} + +if ($action === 'data') { + try { + $curY = (int)date('Y'); + $from = MagoSalesStats::baseFrom(); + $val = "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END"; + $T = []; // tessere (indicatori) + + // ---------------- ORDINATO ---------------- + $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 x.order_date >= '" . ($curY - 3) . "-01-01' GROUP BY y + ", []); + $oy = []; + foreach ($ordYears as $r) $oy[(int)$r['y']] = $r; + $ordCur = (float)($oy[$curY]['ytd'] ?? 0); + $ordPrev = (float)($oy[$curY - 1]['ytd'] ?? 0); + $ordFc = MagoSalesStats::yearForecast($ordYears, $curY); + + $since13 = date('Y-m-01', strtotime('first day of -12 months')); + $ordMonths = MagoSalesStats::fetchAllQ($pdo, " + SELECT DATE_FORMAT(x.order_date, '%Y-%m') AS ym, SUM($val) AS v + FROM $from WHERE x.order_date >= '$since13' GROUP BY ym ORDER BY ym + ", []); + $ord90 = (float)MagoSalesStats::fetchAllQ($pdo, "SELECT COALESCE(SUM($val),0) AS v FROM $from WHERE x.order_date > CURDATE() - INTERVAL 90 DAY", [])[0]['v']; + + // ---------------- FATTURATO ---------------- + $invOk = false; + $invCur = $invPrev = $inv90 = $inv12 = $invP12 = $cn12 = 0.0; + $invFc = null; + $invMonths = []; + $iFrom = MagoSalesStats::invoiceFrom(); + try { + $invOk = (bool)$pdo->query("SELECT 1 FROM mago_sale_doc LIMIT 1")->fetchColumn(); + } catch (Throwable $e) { + $invOk = false; + } + if ($invOk) { + $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 i.document_date >= '" . ($curY - 3) . "-01-01' GROUP BY y + ", []); + $iy = []; + foreach ($invYears as $r) $iy[(int)$r['y']] = $r; + $invCur = (float)($iy[$curY]['ytd'] ?? 0); + $invPrev = (float)($iy[$curY - 1]['ytd'] ?? 0); + $invFc = MagoSalesStats::yearForecast($invYears, $curY); + $invMonths = MagoSalesStats::fetchAllQ($pdo, " + SELECT DATE_FORMAT(i.document_date, '%Y-%m') AS ym, SUM(i.revenue) AS v + FROM $iFrom WHERE i.document_date >= '$since13' GROUP BY ym ORDER BY ym + ", []); + $r = MagoSalesStats::fetchAllQ($pdo, " + SELECT COALESCE(SUM(CASE WHEN i.document_date > CURDATE() - INTERVAL 90 DAY THEN i.revenue END), 0) AS v90, + COALESCE(SUM(CASE WHEN i.document_date > CURDATE() - INTERVAL 365 DAY THEN i.revenue END), 0) AS v12, + COALESCE(SUM(CASE WHEN i.document_date <= CURDATE() - INTERVAL 365 DAY THEN i.revenue END), 0) AS p12, + COALESCE(SUM(CASE WHEN i.document_date > CURDATE() - INTERVAL 365 DAY AND i.is_credit_note = 1 THEN -i.revenue END), 0) AS cn12, + COALESCE(SUM(CASE WHEN i.document_date > CURDATE() - INTERVAL 365 DAY AND i.is_credit_note = 0 THEN i.revenue END), 0) AS gross12 + FROM $iFrom WHERE i.document_date > CURDATE() - INTERVAL 730 DAY + ", [])[0]; + $inv90 = (float)$r['v90']; + $inv12 = (float)$r['v12']; + $invP12 = (float)$r['p12']; + $cn12 = (float)$r['cn12']; + $gross12 = (float)$r['gross12']; + } + + // 1) FATTURATO DELL'ANNO + if ($invOk && $invPrev > 0) { + $p = pctOf($invCur, $invPrev); + $T[] = [ + 'key' => 'invYtd', + 'group' => 'Risultati', + 'title' => "Fatturato $curY", + 'value' => eurShort($invCur), + 'light' => MagoSalesStats::light($p, 'growth'), + 'delta' => $p, + 'sub' => 'ad oggi · ' . ($curY - 1) . ' stessa data ' . eurShort($invPrev), + 'verdict' => $p >= 0 ? 'Stiamo fatturando ' . pctTxt($p) . ' rispetto all\'anno scorso' : 'Fatturato sotto l\'anno scorso del ' . number_format(abs($p), 0, ',', '.') . '%', + 'spark' => 'inv', + 'link' => 'sales_orders_years.php' + ]; + } else { + $T[] = [ + 'key' => 'invYtd', + 'group' => 'Risultati', + 'title' => "Fatturato $curY", + 'value' => $invOk ? eurShort($invCur) : 'n.d.', + 'light' => 'grey', + 'sub' => $invOk ? 'nessun confronto con l\'anno scorso' : 'fatture non ancora sincronizzate', + 'verdict' => '', + 'link' => 'sales_orders_years.php' + ]; + } + + // 2) ORDINATO DELL'ANNO + $p = pctOf($ordCur, $ordPrev); + $T[] = [ + 'key' => 'ordYtd', + 'group' => 'Risultati', + 'title' => "Ordinato $curY", + 'value' => eurShort($ordCur), + 'light' => MagoSalesStats::light($p, 'growth'), + 'delta' => $p, + 'sub' => 'ad oggi · ' . ($curY - 1) . ' stessa data ' . eurShort($ordPrev), + 'verdict' => $p === null ? '' : ($p >= 0 ? 'Entrano più ordini dell\'anno scorso (' . pctTxt($p) . ')' : 'Entrano meno ordini dell\'anno scorso (' . pctTxt($p) . ')'), + 'spark' => 'ord', + 'link' => 'sales_orders_years.php' + ]; + + // 3) PREVISIONE DI FINE ANNO (fatturato, altrimenti ordinato) + $fc = ($invOk && $invFc && $invFc['forecast'] !== null) ? $invFc : $ordFc; + $fcWhat = ($fc === $invFc) ? 'fatturato' : 'ordinato'; + if ($fc && $fc['forecast'] !== null && $fc['prev_full']) { + $p = pctOf($fc['forecast'], $fc['prev_full']); + $T[] = [ + 'key' => 'forecast', + 'group' => 'Risultati', + 'title' => "Chiusura $curY prevista", + 'value' => eurShort($fc['forecast']), + 'light' => MagoSalesStats::light($p, 'growth'), + 'delta' => $p, + 'sub' => "$fcWhat · forbice " . eurShort($fc['low']) . ' – ' . eurShort($fc['high']) . ' · ' . ($curY - 1) . ': ' . eurShort($fc['prev_full']), + 'verdict' => $p >= 0 ? 'Se si continua così chiudiamo sopra l\'anno scorso' : 'Se si continua così chiudiamo sotto l\'anno scorso', + 'link' => 'sales_orders_years.php' + ]; + } else { + $T[] = [ + 'key' => 'forecast', + 'group' => 'Risultati', + 'title' => "Chiusura $curY prevista", + 'value' => 'n.d.', + 'light' => 'grey', + 'sub' => 'servono i dati degli anni precedenti', + 'verdict' => '', + 'link' => 'sales_orders_years.php' + ]; + } + + // 4) BOOK-TO-BILL + if ($invOk && $inv90 > 0) { + $b2b = $ord90 / $inv90; + $T[] = [ + 'key' => 'b2b', + 'group' => 'Futuro', + 'title' => 'Book-to-bill 90 gg', + 'value' => number_format($b2b, 2, ',', '.'), + 'light' => MagoSalesStats::light($b2b, 'b2b'), + 'sub' => 'ordinato ' . eurShort($ord90) . ' · fatturato ' . eurShort($inv90), + 'verdict' => $b2b >= 1.05 ? 'Entra più lavoro di quanto ne esce: il portafoglio cresce' : ($b2b < 0.95 ? 'Esce più lavoro di quanto ne entra: il portafoglio si svuota' : 'Lavoro in entrata e in uscita in equilibrio'), + 'spark' => 'b2b', + 'link' => 'sales_orders_years.php' + ]; + } else { + $T[] = [ + 'key' => 'b2b', + 'group' => 'Futuro', + 'title' => 'Book-to-bill 90 gg', + 'value' => 'n.d.', + 'light' => 'grey', + 'sub' => $invOk ? 'nessun fatturato negli ultimi 90 giorni' : 'fatture non ancora sincronizzate', + 'verdict' => '', + 'link' => 'sales_orders_years.php' + ]; + } + + // 5-6) PORTAFOGLIO: copertura e ritardi + $bk = MagoSalesStats::fetchAllQ($pdo, " + SELECT COALESCE(SUM(r.residual_value), 0) AS v, COUNT(*) AS n_lines, + COALESCE(SUM(CASE WHEN r.due_date < CURDATE() THEN r.residual_value END), 0) AS late_v, + SUM(r.due_date < CURDATE()) AS late_n, + COUNT(DISTINCT CASE WHEN r.due_date < CURDATE() THEN r.customer END) AS late_c + FROM " . MagoSalesStats::openLinesFrom() . " + ", [])[0]; + $weekly = ($invOk && $inv90 > 0 ? $inv90 : $ord90) / (90 / 7); + $weeks = $weekly > 0 ? (float)$bk['v'] / $weekly : null; + $T[] = [ + 'key' => 'cover', + 'group' => 'Futuro', + 'title' => 'Lavoro in portafoglio', + 'value' => $weeks === null ? 'n.d.' : number_format($weeks, 0, ',', '.') . ' sett.', + 'light' => MagoSalesStats::light($weeks, 'cover_weeks'), + 'sub' => eurShort($bk['v']) . ' da consegnare · ' . number_format((int)$bk['n_lines'], 0, ',', '.') . ' righe', + 'verdict' => $weeks === null ? '' : ($weeks >= 8 ? 'Lavoro assicurato per circa ' . round($weeks) . ' settimane' : ($weeks < 4 ? 'Portafoglio corto: servono ordini nuovi' : 'Portafoglio sufficiente, da tenere d\'occhio')), + 'link' => $canDeliveries ? 'sales_orders_deliveries.php' : 'sales_orders_years.php' + ]; + + $latePct = (float)$bk['v'] > 0 ? (float)$bk['late_v'] / (float)$bk['v'] * 100 : 0; + $T[] = [ + 'key' => 'late', + 'group' => 'Clienti e servizio', + 'title' => 'Consegne in ritardo', + 'value' => number_format($latePct, 0, ',', '.') . '%', + 'light' => MagoSalesStats::light($latePct, 'late_pct', true), + 'sub' => eurShort($bk['late_v']) . ' · ' . number_format((int)$bk['late_n'], 0, ',', '.') . ' righe · ' . (int)$bk['late_c'] . ' clienti', + 'verdict' => $latePct <= 10 ? 'Consegne in linea con le date comunicate' : ($latePct > 25 ? 'Troppa merce oltre la data comunicata ai clienti' : 'Una parte delle consegne è in ritardo'), + 'link' => $canDeliveries ? 'sales_orders_deliveries.php' : 'sales_orders_stats.php' + ]; + + // 7) CLIENTI A RISCHIO (dormienti / persi) + $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 x.status <> 'annullato' AND x.customer <> '' + AND x.order_date >= CURDATE() - INTERVAL " . (int)MagoSalesStats::DORMANT_WINDOW_YEARS . " YEAR + ", []); + $dormant = MagoSalesStats::dormantCustomers($dormRows); + $atRisk = array_values(array_filter($dormant, function ($c) { + return $c['level'] !== 'watch'; + })); + $riskV = array_sum(array_column($atRisk, 'value12')); + $base12 = $invOk && $inv12 > 0 ? $inv12 : (float)MagoSalesStats::fetchAllQ($pdo, "SELECT COALESCE(SUM($val),0) AS v FROM $from WHERE x.order_date > CURDATE() - INTERVAL 365 DAY", [])[0]['v']; + $riskPct = $base12 > 0 ? $riskV / $base12 * 100 : null; + $T[] = [ + 'key' => 'risk', + 'group' => 'Clienti e servizio', + 'title' => 'Clienti a rischio', + 'value' => count($atRisk) . ' clienti', + 'light' => MagoSalesStats::light($riskPct, 'risk_pct', true), + 'sub' => eurShort($riskV) . ' di ordinato annuo · ' . ($riskPct === null ? '' : number_format($riskPct, 1, ',', '.') . '% del giro d\'affari') . ' · ' . (count($dormant) - count($atRisk)) . ' in attenzione', + 'verdict' => !$atRisk ? 'Nessun cliente abituale si è fermato' : (MagoSalesStats::light($riskPct, 'risk_pct', true) === 'green' ? 'Pochi clienti abituali fermi, con un peso ridotto sul giro d\'affari' : 'Clienti abituali importanti hanno smesso di ordinare: da richiamare'), + 'link' => 'sales_orders_years.php' + ]; + + // 8) CONCENTRAZIONE (primi 5 clienti, ultimi 12 mesi) + if ($invOk && $inv12 > 0) { + $top = MagoSalesStats::fetchAllQ($pdo, " + SELECT i.customer AS code, MAX(i.company_name) AS name, SUM(i.revenue) AS v + FROM $iFrom WHERE i.document_date > CURDATE() - INTERVAL 365 DAY + GROUP BY i.customer ORDER BY v DESC LIMIT 5 + ", []); + $tot12 = $inv12; + } else { + $top = MagoSalesStats::fetchAllQ($pdo, " + SELECT x.customer AS code, MAX(x.company_name) AS name, SUM($val) AS v + FROM $from WHERE x.order_date > CURDATE() - INTERVAL 365 DAY + GROUP BY x.customer ORDER BY v DESC LIMIT 5 + ", []); + $tot12 = $base12; + } + $top5 = array_sum(array_map('floatval', array_column($top, 'v'))); + $topPct = $tot12 > 0 ? $top5 / $tot12 * 100 : null; + $T[] = [ + 'key' => 'top5', + 'group' => 'Clienti e servizio', + 'title' => 'Peso primi 5 clienti', + 'value' => $topPct === null ? 'n.d.' : number_format($topPct, 0, ',', '.') . '%', + 'light' => MagoSalesStats::light($topPct, 'top5_pct', true), + 'sub' => 'del ' . ($invOk ? 'fatturato' : 'ordinato') . ' degli ultimi 12 mesi' . ($top ? ' · 1° ' . $top[0]['name'] : ''), + 'verdict' => $topPct === null ? '' : ($topPct <= 40 ? 'Giro d\'affari ben distribuito tra i clienti' : ($topPct > 60 ? 'Dipendiamo molto da pochi clienti' : 'Dipendenza dai clienti principali da tenere d\'occhio')), + 'link' => 'sales_orders_years.php', + 'list' => array_map(function ($r) use ($tot12) { + return ['code' => $r['code'], 'name' => $r['name'], 'v' => (float)$r['v'], 'pct' => $tot12 > 0 ? (float)$r['v'] / $tot12 * 100 : 0]; + }, $top) + ]; + + // 9) BASE CLIENTI (attivi 12 mesi vs 12 precedenti, nuovi) + $cb = MagoSalesStats::fetchAllQ($pdo, " + SELECT COUNT(DISTINCT CASE WHEN x.order_date > CURDATE() - INTERVAL 365 DAY THEN x.customer END) AS cur, + COUNT(DISTINCT CASE WHEN x.order_date <= CURDATE() - INTERVAL 365 DAY THEN x.customer END) AS prev + FROM $from WHERE x.status <> 'annullato' AND x.customer <> '' AND x.order_date > CURDATE() - INTERVAL 730 DAY + ", [])[0]; + $newC = (int)MagoSalesStats::fetchAllQ($pdo, " + SELECT COUNT(*) AS n FROM ( + SELECT x.customer FROM $from WHERE x.status <> 'annullato' AND x.customer <> '' + GROUP BY x.customer HAVING MIN(x.order_date) > CURDATE() - INTERVAL 365 DAY + ) t + ", [])[0]['n']; + $p = pctOf((int)$cb['cur'], (int)$cb['prev']); + $T[] = [ + 'key' => 'customers', + 'group' => 'Clienti e servizio', + 'title' => 'Clienti attivi', + 'value' => number_format((int)$cb['cur'], 0, ',', '.'), + 'light' => MagoSalesStats::light($p, 'customers'), + 'delta' => $p, + 'sub' => 'ultimi 12 mesi · prima ' . (int)$cb['prev'] . ' · ' . $newC . ' nuovi', + 'verdict' => $p === null ? '' : ($p >= 0 ? 'La base clienti tiene' . ($newC ? " ($newC clienti nuovi)" : '') : 'Stiamo perdendo clienti rispetto all\'anno prima'), + 'link' => 'sales_orders_years.php' + ]; + + // 10) NOTE DI CREDITO + if ($invOk && !empty($gross12)) { + $cnPct = $cn12 / $gross12 * 100; + $T[] = [ + 'key' => 'cn', + 'group' => 'Clienti e servizio', + 'title' => 'Note di credito', + 'value' => number_format($cnPct, 1, ',', '.') . '%', + 'light' => MagoSalesStats::light($cnPct, 'cn_pct', true), + 'sub' => eurShort($cn12) . ' negli ultimi 12 mesi', + 'verdict' => $cnPct <= 1.5 ? 'Poche contestazioni e resi' : ($cnPct > 3 ? 'Troppe note di credito: verificare resi e contestazioni' : 'Note di credito da tenere d\'occhio'), + 'link' => 'sales_orders_years.php' + ]; + } else { + $T[] = [ + 'key' => 'cn', + 'group' => 'Clienti e servizio', + 'title' => 'Note di credito', + 'value' => 'n.d.', + 'light' => 'grey', + 'sub' => 'fatture non ancora sincronizzate', + 'verdict' => '', + 'link' => 'sales_orders_years.php' + ]; + } + + // ---------------- CLIENTI CHE CRESCONO / CALANO (12 mesi vs 12 precedenti) ---------------- + $mv = $invOk && $inv12 > 0 + ? MagoSalesStats::fetchAllQ($pdo, " + SELECT i.customer AS code, MAX(i.company_name) AS name, + SUM(CASE WHEN i.document_date > CURDATE() - INTERVAL 365 DAY THEN i.revenue ELSE 0 END) AS cur, + SUM(CASE WHEN i.document_date <= CURDATE() - INTERVAL 365 DAY THEN i.revenue ELSE 0 END) AS prev + FROM $iFrom WHERE i.document_date > CURDATE() - INTERVAL 730 DAY AND i.customer <> '' + GROUP BY i.customer", []) + : MagoSalesStats::fetchAllQ($pdo, " + SELECT x.customer AS code, MAX(x.company_name) AS name, + SUM(CASE WHEN x.order_date > CURDATE() - INTERVAL 365 DAY THEN $val ELSE 0 END) AS cur, + SUM(CASE WHEN x.order_date <= CURDATE() - INTERVAL 365 DAY THEN $val ELSE 0 END) AS prev + FROM $from WHERE x.order_date > CURDATE() - INTERVAL 730 DAY AND x.customer <> '' + GROUP BY x.customer", []); + foreach ($mv as &$m) { + $m['cur'] = (float)$m['cur']; + $m['prev'] = (float)$m['prev']; + $m['delta'] = $m['cur'] - $m['prev']; + $m['pct'] = pctOf($m['cur'], $m['prev']); + } + unset($m); + usort($mv, function ($a, $b) { + return $b['delta'] <=> $a['delta']; + }); + $up = array_values(array_filter(array_slice($mv, 0, 5), function ($m) { + return $m['delta'] > 0; + })); + $down = array_values(array_filter(array_slice(array_reverse($mv), 0, 5), function ($m) { + return $m['delta'] < 0; + })); + + // ---------------- GIUDIZIO COMPLESSIVO ---------------- + $score = ['green' => 0, 'yellow' => 0, 'red' => 0, 'grey' => 0]; + foreach ($T as $t) $score[$t['light']]++; + $valid = $score['green'] + $score['yellow'] + $score['red']; + $points = $valid ? ($score['green'] * 100 + $score['yellow'] * 50) / $valid : null; + $overall = $points === null ? 'grey' : ($points >= 70 ? 'green' : ($points >= 45 ? 'yellow' : 'red')); + + MagoSalesStats::jsonOut([ + 'success' => true, + 'today' => date('Y-m-d'), + 'year' => $curY, + 'inv_ok' => $invOk, + 'tiles' => $T, + 'score' => $score, + 'points' => $points === null ? null : round($points), + 'overall' => $overall, + 'spark' => ['since' => $since13, 'ord' => $ordMonths, 'inv' => $invMonths], + 'risk' => array_slice($atRisk, 0, 5), + 'up' => $up, + 'down' => $down, + 'movers_on' => $invOk && $inv12 > 0 ? 'fatturato' : 'ordinato', + 'can_deliveries' => $canDeliveries, + ]); + } catch (Throwable $e) { + MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]); + } +} + +?> + + + + + + + + + + Sommario vendite - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?> + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+ + +
+
+
+
+
+
Statistiche Ordini Cliente
+ Dati Mago4 +
+
+ +
+
+ +
+ + + +
+
Calcolo degli indicatori…
+
+ + + +
+
+
+
+ + +
+ + + + + + + + \ No newline at end of file