Files
zibo-dashboard/public/userarea/class/MagoSalesStats.php
T
2026-10-15 10:20:41 +02:00

535 lines
24 KiB
PHP

<?php
/**
* MagoSalesStats - funzioni comuni alle pagine statistiche ordini cliente
* (sales_orders_stats.php e sales_orders_years.php).
* Legge SOLO dalle tabelle locali sincronizzate da Mago.
*/
class MagoSalesStats
{
const CUSTOMER_TYPE = 3211264;
/*
* MAPPATURA DATE DI CONSEGNA (righe e testata usano gli stessi nomi colonna)
*
* Data ZIBO = data comunicata da Zibo al cliente → è la SCADENZA che conta (ritardo in rosso)
* Data cliente = data desiderata dal cliente → riferimento (ritardo in arancione)
*
* Per invertirle basta scambiare i due valori qui sotto.
*/
// Verificato sui dati reali (13/10/2026): la data ZIBO è ExpectedDeliveryDate, la data cliente è ConfirmedDeliveryDate
const ZIBO_DATE_COL = 'expected_delivery_date'; // Mago: ExpectedDeliveryDate ("Data ZIBO")
const CLIENT_DATE_COL = 'confirmed_delivery_date'; // Mago: ConfirmedDeliveryDate ("DATA CLIENTE")
/*
* DATA ZIBO = SETTIMANA DI CONSEGNA
* In Mago la data ZIBO è il primo giorno della settimana: per il cliente significa "entro quella settimana".
* La scadenza reale è quindi la fine di quella settimana; durante la settimana compare un avviso.
* ZIBO_IS_WEEK = false → la data ZIBO torna a valere come giorno preciso
* ZIBO_WEEK_END_DAYS = giorni dopo il lunedì: 6 = domenica, 4 = venerdì
*/
const ZIBO_IS_WEEK = true;
const ZIBO_WEEK_END_DAYS = 6;
/*
* ORDINI ESCLUSI da tutte le statistiche: parole cercate (senza distinzione maiuscole/minuscole) in
* numero ordine esterno, nostro/vostro riferimento, note di testata, descrizioni e note delle righe.
* Lasciare l'elenco vuoto per non escludere nulla.
*/
const EXCLUDE_KEYWORDS = ['listino'];
/** Data ZIBO "valida" di una riga/testata: in Mago, se non compilata, resta uguale alla data ordine. */
public static function ziboDate($a)
{
$c = $a . '.' . self::ZIBO_DATE_COL;
return "CASE WHEN $c > $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),
];
}
/** 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 (['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
'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)];
}
}
}