$a.order_date THEN $c END"; } public static function clientDate($a) { $c = $a . '.' . self::CLIENT_DATE_COL; return "CASE WHEN $c > $a.order_date THEN $c END"; } /** Scadenza della data ZIBO: fine della settimana indicata (o il giorno stesso se ZIBO_IS_WEEK = false). */ public static function ziboDeadline($a) { $z = self::ziboDate($a); if (!self::ZIBO_IS_WEEK) return $z; return "DATE_ADD($z, INTERVAL (" . (int)self::ZIBO_WEEK_END_DAYS . " - WEEKDAY($z)) DAY)"; } /** * FATTURATO: tipi di documento di vendita che contano (verificati sui dati reali il 14/10/2026) * e segno con cui contano. Esclusi: DDT 3407873, DDT non valorizzati 3407881, * proforma 3407888, movimenti deposito 3407890. */ const INVOICE_TYPES = [ 3407874 => 1, // Fattura (immediata / differita) serie A ... 3407883 => 1, // Fattura d'acconto (TD02) serie B 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; } $out = []; foreach ($by as $c) { $info = self::rhythmInfo(array_keys($c['dates'])); if (!$info || !in_array($info['level'], ['watch', 'dormant', 'lost'], true)) continue; $last = $info['last_date']; $since = $info['days_since']; $rhythm = $info['rhythm']; $ratio = $info['ratio']; $level = $info['level']; $n = $info['orders']; // 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' => $rhythm, 'ratio' => $ratio, 'orders' => $n, 'value12' => round($v12, 2), 'level' => $level, ]; } usort($out, function ($a, $b) { return $b['value12'] <=> $a['value12']; }); return $out; } /** * Ritmo d'ordine di un cliente a partire dalle date d'ordine (Y-m-d) degli ultimi DORMANT_WINDOW_YEARS anni. * level: 'ok' | 'watch' | 'dormant' | 'lost' | 'few' (troppo pochi ordini per stimare un ritmo) */ public static function rhythmInfo(array $dates) { $dates = array_values(array_unique($dates)); sort($dates); $n = count($dates); if (!$n) return null; $today = new DateTime('today'); $last = end($dates); $since = (int)(new DateTime($last))->diff($today)->days; $out = ['orders' => $n, 'last_date' => $last, 'days_since' => $since, 'rhythm' => null, 'ratio' => null, 'level' => 'few']; if ($n < self::DORMANT_MIN_ORDERS) return $out; $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); $ratio = $since / $rhythm; if ($since < self::DORMANT_MIN_DAYS) $level = 'ok'; elseif ($ratio >= self::DORMANT_LOST || $since >= 365) $level = 'lost'; elseif ($ratio >= self::DORMANT_SLEEP) $level = 'dormant'; elseif ($ratio >= self::DORMANT_WATCH) $level = 'watch'; else $level = 'ok'; $out['rhythm'] = (int)round($rhythm); $out['ratio'] = round($ratio, 1); $out['level'] = $level; 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), ]; } /** * 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', 3407883 => 'Fattura d\'acconto', 3407876 => 'Nota di credito', ]; /** Vista fatture/note di credito (alias i) con imponibile già col segno giusto (revenue). */ public static function invoiceFrom() { $types = implode(',', array_map('intval', array_keys(self::INVOICE_TYPES))); $sign = 'CASE d.document_type'; foreach (self::INVOICE_TYPES as $t => $sg) $sign .= ' WHEN ' . (int)$t . ' THEN ' . ((int)$sg < 0 ? -1 : 1); $sign .= ' ELSE 0 END'; return "( SELECT d.sale_doc_id, d.document_type, d.doc_no, d.document_date, d.customer, d.salesperson, d.area, COALESCE(NULLIF(c.company_name, ''), d.customer) AS company_name, ($sign) * COALESCE(d.taxable_amount, 0) AS revenue, ($sign) < 0 AS is_credit_note, d.summary_tb_modified IS NULL AS no_amount FROM mago_sale_doc d LEFT JOIN mago_cust_supp c ON c.cust_supp_type = " . self::CUSTOMER_TYPE . " AND c.cust_supp = d.customer WHERE d.document_type IN ($types) AND d.cancelled = 0 ) i"; } /** Scadenza di testata (usata solo per ordini senza righe). */ public static function headerDue($a) { return "COALESCE(" . self::ziboDeadline($a) . ", " . self::clientDate($a) . ", $a.compulsory_delivery_date)"; } /** Scadenza effettiva di una riga: fine settimana ZIBO, se manca la data cliente. */ public static function lineDue($a) { return "COALESCE(" . self::ziboDeadline($a) . ", " . self::clientDate($a) . ")"; } /** * Condizione SQL che ESCLUDE gli ordini contenenti una delle parole in EXCLUDE_KEYWORDS * (alias della testata: $a). Stringa vuota se non c'è nulla da escludere. */ public static function excludeCondition($a) { $words = array_filter(array_map('trim', self::EXCLUDE_KEYWORDS)); if (!$words) return '1=1'; $parts = []; foreach ($words as $w) { $like = "'%" . str_replace(["\\", "'", '%', '_'], ["\\\\", "''", '\\%', '\\_'], $w) . "%'"; foreach (['internal_ord_no', 'external_ord_no', 'our_reference', 'your_reference', 'notes'] as $col) { $parts[] = "COALESCE($a.$col, '') LIKE $like"; // COALESCE: un campo NULL non deve escludere l'ordine } $parts[] = "EXISTS (SELECT 1 FROM mago_sale_ord_detail ex WHERE ex.sale_ord_id = $a.sale_ord_id AND (COALESCE(ex.description, '') LIKE $like OR COALESCE(ex.notes, '') LIKE $like))"; } return 'NOT (' . implode(' OR ', $parts) . ')'; } /** Aggregati per ordine calcolati dalle righe (alias l). */ public static function lineAggregates() { $open = "(d.delivered = 0 AND d.cancelled = 0)"; $due = self::lineDue('d'); return "( SELECT d.sale_ord_id, COUNT(*) AS lines_total, SUM($open) AS open_lines, MIN(CASE WHEN $open THEN " . self::ziboDate('d') . " END) AS next_zibo, MIN(CASE WHEN $open THEN " . self::clientDate('d') . " END) AS next_client, MIN(" . self::ziboDate('d') . ") AS first_zibo, MIN(" . self::clientDate('d') . ") AS first_client, MIN(CASE WHEN $open THEN $due END) AS next_due, MIN($due) AS first_due, COUNT(DISTINCT CASE WHEN $open THEN $due END) AS open_dates, SUM($open AND $due < CURDATE()) AS late_lines, SUM($open AND " . self::clientDate('d') . " < CURDATE()) AS late_lines_client, SUM($open AND $due >= CURDATE() AND YEARWEEK($due, 3) = YEARWEEK(CURDATE(), 3)) AS week_lines FROM mago_sale_ord_detail d WHERE d.item <> '' OR d.qty <> 0 GROUP BY d.sale_ord_id ) l"; } /** Risposta JSON e fine script (pulisce l'output di headscript). */ public static function jsonOut(array $data) { while (ob_get_level()) ob_end_clean(); header('Content-Type: application/json; charset=utf-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; } public static function isValidDate($d) { return is_string($d) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) && strtotime($d) !== false; } /** Parametro GET multiplo (es. customers[]) come array pulito. */ public static function arrParam($key) { $v = $_GET[$key] ?? []; if (!is_array($v)) $v = [$v]; return array_values(array_filter(array_map(function ($x) { return trim((string)$x); }, $v), function ($x) { return $x !== ''; })); } /** * Vista base: ordini + nome cliente + stato + date di consegna (alias x). * * zibo_date = prima data ZIBO tra le righe ancora da consegnare (se tutte consegnate: la prima in assoluto); * senza righe: data ZIBO di testata * client_date = stessa logica con la data cliente * due_date = scadenza dell'ordine: la più vicina tra le righe aperte (riga: data ZIBO, se manca data cliente) * late_lines / open_lines = righe aperte in ritardo / righe aperte (per il "ritardo parziale") */ public static function baseFrom() { return "( SELECT o.*, COALESCE(NULLIF(c.company_name, ''), o.customer) AS company_name, COALESCE(l.next_zibo, l.first_zibo, " . self::ziboDate('o') . ") AS zibo_date, COALESCE(l.next_client, l.first_client, " . self::clientDate('o') . ") AS client_date, -- scadenza = la più vicina tra le righe aperte, dove ogni riga scade alla sua data ZIBO -- (o alla data cliente se la data ZIBO non è compilata) COALESCE( l.next_due, l.first_due, " . self::ziboDeadline('o') . ", " . self::clientDate('o') . ", o.compulsory_delivery_date ) AS due_date, COALESCE(l.lines_total, 0) AS lines_total, COALESCE(l.open_lines, 0) AS open_lines, COALESCE(l.open_dates, 0) AS open_dates, COALESCE(l.late_lines, 0) AS late_lines, COALESCE(l.late_lines_client, 0) AS late_lines_client, COALESCE(l.week_lines, 0) AS week_lines, -- RITARDO calcolato sulle RIGHE: un ordine è in ritardo solo se ha almeno una riga aperta scaduta. -- (Solo se l'ordine non ha righe si usa la data di testata.) CASE WHEN o.cancelled = 0 AND o.delivered = 0 AND ( (COALESCE(l.lines_total, 0) > 0 AND l.late_lines > 0) OR (COALESCE(l.lines_total, 0) = 0 AND " . self::headerDue('o') . " < CURDATE()) ) THEN 1 ELSE 0 END AS is_late, CASE WHEN o.cancelled = 0 AND o.delivered = 0 AND ( (COALESCE(l.lines_total, 0) > 0 AND l.late_lines_client > 0) OR (COALESCE(l.lines_total, 0) = 0 AND " . self::clientDate('o') . " < CURDATE()) ) THEN 1 ELSE 0 END AS is_late_client, -- scadenza delle sole righe ancora aperte (per il grafico scadenze) CASE WHEN COALESCE(l.lines_total, 0) > 0 THEN l.next_due ELSE " . self::headerDue('o') . " END AS open_due, CASE WHEN o.cancelled = 1 THEN 'annullato' WHEN o.delivered = 1 THEN 'evaso' WHEN o.is_blocked = 1 THEN 'bloccato' ELSE 'aperto' END AS status FROM mago_sale_ord o LEFT JOIN mago_cust_supp c ON c.cust_supp_type = " . self::CUSTOMER_TYPE . " AND c.cust_supp = o.customer LEFT JOIN " . self::lineAggregates() . " ON l.sale_ord_id = o.sale_ord_id WHERE " . self::excludeCondition('o') . " ) x"; } /** * Vista righe aperte (alias r) per la pagina Consegne: riga + ordine + date effettive. */ public static function openLinesFrom() { return "( SELECT d.sale_ord_id, d.sub_id, d.line, d.position, d.item, d.description, d.uom, d.qty, d.delivered_qty, GREATEST(d.qty - d.delivered_qty, 0) AS residual_qty, CASE WHEN d.qty > 0 THEN ROUND(d.taxable_amount * GREATEST(d.qty - d.delivered_qty, 0) / d.qty, 2) ELSE d.taxable_amount END AS residual_value, d.taxable_amount, " . self::ziboDate('d') . " AS zibo_date, " . self::clientDate('d') . " AS client_date, " . self::lineDue('d') . " AS due_date, x.internal_ord_no, x.order_date, x.customer, x.company_name, x.salesperson, x.area, x.status, x.your_reference, x.external_ord_no FROM mago_sale_ord_detail d JOIN " . self::baseFrom() . " ON x.sale_ord_id = d.sale_ord_id WHERE d.delivered = 0 AND d.cancelled = 0 AND x.status IN ('aperto','bloccato') AND (d.item <> '' OR d.qty <> 0) ) r"; } /** * WHERE + parametri dai filtri in querystring. * $override sostituisce from/to (stringa vuota = nessun filtro data). */ public static function buildWhere(array $override = []) { $w = ['1=1']; $p = []; $from = array_key_exists('from', $override) ? $override['from'] : ($_GET['from'] ?? ''); $to = array_key_exists('to', $override) ? $override['to'] : ($_GET['to'] ?? ''); if (self::isValidDate($from)) { $w[] = 'x.order_date >= :from'; $p['from'] = $from; } if (self::isValidDate($to)) { $w[] = 'x.order_date <= :to'; $p['to'] = $to; } $multi = [ 'customers' => 'x.customer', 'salespeople' => 'x.salesperson', 'areas' => 'x.area', ]; foreach ($multi as $key => $col) { $vals = self::arrParam($key); if (!$vals) continue; $ph = []; foreach ($vals as $i => $v) { $name = $key . $i; $ph[] = ':' . $name; $p[$name] = $v; } $w[] = "$col IN (" . implode(',', $ph) . ")"; } $statusMap = [ 'aperto' => "x.status = 'aperto'", 'evaso' => "x.status = 'evaso'", 'annullato' => "x.status = 'annullato'", 'bloccato' => "x.status = 'bloccato'", 'ritardo' => "x.is_late = 1", 'ritardo_cliente' => "x.is_late_client = 1", 'settimana' => "(x.status IN ('aperto','bloccato') AND x.week_lines > 0)", // righe da consegnare questa settimana // ordini con righe aperte la cui data ZIBO è nell'anno in corso (ritardi compresi) / nell'anno prossimo 'zibo_anno' => "(x.status IN ('aperto','bloccato') AND EXISTS (SELECT 1 FROM mago_sale_ord_detail zd WHERE zd.sale_ord_id = x.sale_ord_id AND zd.delivered = 0 AND zd.cancelled = 0 AND YEAR(" . self::ziboDate('zd') . ") <= YEAR(CURDATE())))", 'zibo_prossimo' => "(x.status IN ('aperto','bloccato') AND EXISTS (SELECT 1 FROM mago_sale_ord_detail zd WHERE zd.sale_ord_id = x.sale_ord_id AND zd.delivered = 0 AND zd.cancelled = 0 AND YEAR(" . self::ziboDate('zd') . ") = YEAR(CURDATE()) + 1))", 'da_fatturare' => "(x.delivered = 1 AND x.invoiced = 0 AND x.cancelled = 0)", 'fatturato' => "x.invoiced = 1", ]; $statuses = array_intersect(self::arrParam('statuses'), array_keys($statusMap)); if ($statuses) { $w[] = '(' . implode(' OR ', array_map(function ($s) use ($statusMap) { return $statusMap[$s]; }, $statuses)) . ')'; } $q = trim((string)($_GET['q'] ?? '')); if ($q !== '') { $like = '%' . $q . '%'; $w[] = '(x.internal_ord_no LIKE :q1 OR x.external_ord_no LIKE :q2 OR x.your_reference LIKE :q3 OR x.company_name LIKE :q4 OR x.customer LIKE :q5)'; $p['q1'] = $p['q2'] = $p['q3'] = $p['q4'] = $p['q5'] = $like; } return [implode(' AND ', $w), $p]; } public static function fetchAllQ(PDO $pdo, $sql, array $params) { $st = $pdo->prepare($sql); $st->execute($params); return $st->fetchAll(PDO::FETCH_ASSOC); } /** Stato dell'ultima sincronizzazione (per il badge in alto). */ public static function syncInfo(PDO $pdo) { try { $rows = $pdo->query("SELECT * FROM mago_sync_state")->fetchAll(PDO::FETCH_ASSOC); $out = []; foreach ($rows as $r) { $out[$r['entity']] = [ 'last_run_at' => $r['last_run_at'] ? date('d/m/Y H:i', strtotime($r['last_run_at'])) : '', 'last_status' => $r['last_status'], 'last_message' => $r['last_message'], 'records' => (int)$r['records_last_run'], ]; } $out['total_orders'] = (int)$pdo->query("SELECT COUNT(*) FROM mago_sale_ord")->fetchColumn(); return $out; } catch (Throwable $e) { return ['error' => 'Tabelle Mago non presenti: eseguire la migrazione Phinx', 'total_orders' => 0]; } } /** Opzioni dei filtri (clienti, agenti, aree, anni disponibili). */ public static function filterOptions(PDO $pdo) { try { $customers = $pdo->query(" SELECT o.customer AS code, COALESCE(NULLIF(c.company_name,''), o.customer) AS name, COUNT(*) AS n FROM mago_sale_ord o LEFT JOIN mago_cust_supp c ON c.cust_supp_type = " . self::CUSTOMER_TYPE . " AND c.cust_supp = o.customer WHERE o.customer <> '' GROUP BY o.customer, name ORDER BY name ")->fetchAll(PDO::FETCH_ASSOC); $salespeople = $pdo->query("SELECT salesperson AS code, COUNT(*) AS n FROM mago_sale_ord WHERE salesperson <> '' GROUP BY salesperson ORDER BY salesperson")->fetchAll(PDO::FETCH_ASSOC); $areas = $pdo->query("SELECT area AS code, COUNT(*) AS n FROM mago_sale_ord WHERE area <> '' GROUP BY area ORDER BY area")->fetchAll(PDO::FETCH_ASSOC); $range = $pdo->query("SELECT MIN(order_date) AS min_date, MAX(order_date) AS max_date FROM mago_sale_ord")->fetch(PDO::FETCH_ASSOC); $years = array_map('intval', $pdo->query("SELECT DISTINCT YEAR(order_date) FROM mago_sale_ord WHERE order_date IS NOT NULL ORDER BY 1")->fetchAll(PDO::FETCH_COLUMN)); return [ 'success' => true, 'customers' => $customers, 'salespeople' => $salespeople, 'areas' => $areas, 'range' => $range, 'years' => $years, 'sync' => self::syncInfo($pdo), ]; } catch (Throwable $e) { return ['success' => false, 'message' => $e->getMessage(), 'sync' => self::syncInfo($pdo)]; } } }