diff --git a/db/migrations/20261013091004_create_mago_sales_tables.php b/db/migrations/20261013091004_create_mago_sales_tables.php
new file mode 100644
index 00000000..09762bae
--- /dev/null
+++ b/db/migrations/20261013091004_create_mago_sales_tables.php
@@ -0,0 +1,100 @@
+table('mago_sale_ord', [
+ 'id' => false,
+ 'primary_key' => ['sale_ord_id'],
+ 'comment' => 'Copia locale MA_SaleOrd (Mago4) - sola lettura',
+ ])
+ ->addColumn('sale_ord_id', 'integer', ['signed' => false])
+ ->addColumn('internal_ord_no', 'string', ['limit' => 30, 'default' => ''])
+ ->addColumn('external_ord_no', 'string', ['limit' => 60, 'default' => ''])
+ ->addColumn('order_date', 'date', ['null' => true])
+ ->addColumn('expected_delivery_date', 'date', ['null' => true])
+ ->addColumn('confirmed_delivery_date', 'date', ['null' => true])
+ ->addColumn('compulsory_delivery_date', 'date', ['null' => true])
+ ->addColumn('customer', 'string', ['limit' => 20, 'default' => ''])
+ ->addColumn('invoicing_customer', 'string', ['limit' => 20, 'default' => ''])
+ ->addColumn('salesperson', 'string', ['limit' => 20, 'default' => ''])
+ ->addColumn('area_manager', 'string', ['limit' => 20, 'default' => ''])
+ ->addColumn('area', 'string', ['limit' => 20, 'default' => ''])
+ ->addColumn('payment', 'string', ['limit' => 20, 'default' => ''])
+ ->addColumn('currency', 'string', ['limit' => 8, 'default' => ''])
+ ->addColumn('our_reference', 'string', ['limit' => 150, 'default' => ''])
+ ->addColumn('your_reference', 'string', ['limit' => 150, 'default' => ''])
+ ->addColumn('transport', 'string', ['limit' => 20, 'default' => ''])
+ ->addColumn('port', 'string', ['limit' => 20, 'default' => ''])
+ ->addColumn('package', 'string', ['limit' => 40, 'default' => ''])
+ ->addColumn('storage', 'string', ['limit' => 30, 'default' => ''])
+ ->addColumn('job', 'string', ['limit' => 30, 'default' => ''])
+ ->addColumn('delivered', 'boolean', ['default' => false])
+ ->addColumn('invoiced', 'boolean', ['default' => false])
+ ->addColumn('cancelled', 'boolean', ['default' => false])
+ ->addColumn('is_blocked', 'boolean', ['default' => false])
+ ->addColumn('printed', 'boolean', ['default' => false])
+ ->addColumn('archived', 'boolean', ['default' => false])
+ ->addColumn('open_order', 'boolean', ['default' => false])
+ ->addColumn('priority', 'integer', ['default' => 0])
+ ->addColumn('notes', 'text', ['null' => true])
+ ->addColumn('tb_created', 'datetime', ['null' => true])
+ ->addColumn('tb_modified', 'datetime', ['null' => true])
+ ->addColumn('raw_json', 'text', ['null' => true, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG])
+ ->addColumn('synced_at', 'datetime', ['null' => true])
+ ->addIndex(['order_date'])
+ ->addIndex(['customer'])
+ ->addIndex(['salesperson'])
+ ->addIndex(['area'])
+ ->addIndex(['tb_modified'])
+ ->addIndex(['internal_ord_no'])
+ ->create();
+
+ // ---------- ANAGRAFICA CLIENTI ----------
+ $this->table('mago_cust_supp', [
+ 'id' => false,
+ 'primary_key' => ['cust_supp_type', 'cust_supp'],
+ 'comment' => 'Copia locale MA_CustSupp (Mago4) - sola lettura',
+ ])
+ ->addColumn('cust_supp_type', 'integer')
+ ->addColumn('cust_supp', 'string', ['limit' => 20])
+ ->addColumn('company_name', 'string', ['limit' => 200, 'default' => ''])
+ ->addColumn('city', 'string', ['limit' => 100, 'default' => ''])
+ ->addColumn('county', 'string', ['limit' => 10, 'default' => ''])
+ ->addColumn('region', 'string', ['limit' => 60, 'default' => ''])
+ ->addColumn('country', 'string', ['limit' => 60, 'default' => ''])
+ ->addColumn('iso_country_code', 'string', ['limit' => 4, 'default' => ''])
+ ->addColumn('disabled', 'boolean', ['default' => false])
+ ->addColumn('tb_modified', 'datetime', ['null' => true])
+ ->addColumn('synced_at', 'datetime', ['null' => true])
+ ->addIndex(['company_name'])
+ ->create();
+
+ // ---------- STATO SINCRONIZZAZIONE ----------
+ $this->table('mago_sync_state', [
+ 'id' => false,
+ 'primary_key' => ['entity'],
+ ])
+ ->addColumn('entity', 'string', ['limit' => 50])
+ ->addColumn('last_tb_modified', 'datetime', ['null' => true])
+ ->addColumn('last_run_at', 'datetime', ['null' => true])
+ ->addColumn('last_status', 'string', ['limit' => 20, 'default' => ''])
+ ->addColumn('last_message', 'text', ['null' => true])
+ ->addColumn('records_last_run', 'integer', ['default' => 0])
+ ->addColumn('duration_ms', 'integer', ['default' => 0])
+ ->create();
+ }
+}
diff --git a/db/migrations/20261013092158_add_amounts_to_mago_sale_ord.php b/db/migrations/20261013092158_add_amounts_to_mago_sale_ord.php
new file mode 100644
index 00000000..6bd4d576
--- /dev/null
+++ b/db/migrations/20261013092158_add_amounts_to_mago_sale_ord.php
@@ -0,0 +1,24 @@
+table('mago_sale_ord')
+ ->addColumn('taxable_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'notes', 'comment' => 'Imponibile'])
+ ->addColumn('tax_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'taxable_amount', 'comment' => 'IVA'])
+ ->addColumn('total_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'tax_amount', 'comment' => 'Totale documento'])
+ ->addColumn('goods_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'total_amount', 'comment' => 'Valore merce'])
+ ->addColumn('discounts', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'goods_amount', 'comment' => 'Sconti'])
+ ->addColumn('charges', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'discounts', 'comment' => 'Spese (trasporto, imballo, incasso, bolli, varie)'])
+ ->addColumn('summary_tb_modified', 'datetime', ['null' => true, 'after' => 'charges', 'comment' => 'NULL = totali non ancora letti'])
+ ->update();
+ }
+}
diff --git a/public/userarea/class/MagoClient.php b/public/userarea/class/MagoClient.php
new file mode 100644
index 00000000..774a54c5
--- /dev/null
+++ b/public/userarea/class/MagoClient.php
@@ -0,0 +1,402 @@
+iterate('MA_SaleOrd', ['filters' => [...]]) as $rec) { ... }
+ * $mago->logoff(); // SEMPRE: le sessioni occupano licenza
+ *
+ * Variabili .env richieste:
+ * MAGO_BASE_URL, MAGO_USER, MAGO_PASSWORD, MAGO_COMPANY
+ * Opzionale:
+ * MAGO_TIMEOUT (secondi, default 60)
+ *
+ * Compatibile PHP 7.4+
+ */
+
+class MagoApiException extends RuntimeException {}
+
+class MagoClient
+{
+ /** @var string */
+ private $baseUrl;
+ /** @var string */
+ private $username;
+ /** @var string */
+ private $password;
+ /** @var string */
+ private $company;
+ /** @var int */
+ private $timeout;
+ /** @var string|null */
+ private $token = null;
+ /** @var string|null */
+ private $lastLogoffError = null;
+ /** @var string|null percorso di logoff che ha risposto correttamente */
+ private $logoffPath = null;
+
+ public function __construct($baseUrl, $username, $password, $company, $timeout = 60)
+ {
+ $this->baseUrl = rtrim((string)$baseUrl, '/') . '/';
+ $this->username = (string)$username;
+ $this->password = (string)$password;
+ $this->company = (string)$company;
+ $this->timeout = max(5, (int)$timeout);
+ }
+
+ /**
+ * Crea il client leggendo le credenziali da .env (dotenv) / variabili d'ambiente.
+ */
+ public static function fromEnv()
+ {
+ $get = function ($k, $default = null) {
+ if (isset($_ENV[$k]) && $_ENV[$k] !== '') return $_ENV[$k];
+ if (isset($_SERVER[$k]) && $_SERVER[$k] !== '') return $_SERVER[$k];
+ $v = getenv($k);
+ return ($v !== false && $v !== '') ? $v : $default;
+ };
+
+ $cfg = [
+ 'MAGO_BASE_URL' => $get('MAGO_BASE_URL'),
+ 'MAGO_USER' => $get('MAGO_USER'),
+ 'MAGO_PASSWORD' => $get('MAGO_PASSWORD'),
+ 'MAGO_COMPANY' => $get('MAGO_COMPANY'),
+ ];
+
+ $missing = array_keys(array_filter($cfg, function ($v) {
+ return $v === null;
+ }));
+ if ($missing) {
+ throw new MagoApiException('Variabili .env mancanti: ' . implode(', ', $missing));
+ }
+
+ return new self(
+ $cfg['MAGO_BASE_URL'],
+ $cfg['MAGO_USER'],
+ $cfg['MAGO_PASSWORD'],
+ $cfg['MAGO_COMPANY'],
+ (int)$get('MAGO_TIMEOUT', 60)
+ );
+ }
+
+ public function __destruct()
+ {
+ // Rete di sicurezza: se lo script dimentica il logoff, libero comunque la licenza
+ $this->logoff();
+ }
+
+ public function isLoggedIn()
+ {
+ return $this->token !== null;
+ }
+
+ public function getLastLogoffError()
+ {
+ return $this->lastLogoffError;
+ }
+
+ public function getLogoffPath()
+ {
+ return $this->logoffPath;
+ }
+
+ /* =========================================================
+ * SESSIONE
+ * ========================================================= */
+
+ public function login()
+ {
+ $r = $this->request('api/account-manager/login', [
+ 'username' => $this->username,
+ 'password' => $this->password,
+ 'company' => $this->company,
+ ], false);
+
+ $token = $r['data']['token'] ?? null;
+ if (!is_string($token) || $token === '') {
+ throw new MagoApiException('Login Mago fallito (HTTP ' . $r['status'] . '): ' . $this->extractMessage($r));
+ }
+
+ $this->token = $token;
+ return $this->token;
+ }
+
+ /**
+ * Chiude la sessione e libera lo slot di licenza.
+ * Non lancia eccezioni: ritorna true/false (dettaglio in getLastLogoffError()).
+ */
+ public function logoff()
+ {
+ if ($this->token === null) return true;
+
+ $this->lastLogoffError = null;
+
+ // Il percorso "logoff" ha risposto 404: provo le varianti note e memorizzo quella buona
+ $candidates = $this->logoffPath
+ ? [$this->logoffPath]
+ : ['api/account-manager/logoff', 'api/account-manager/logout', 'api/account-manager/logOff'];
+
+ $errors = [];
+ try {
+ foreach ($candidates as $path) {
+ $r = $this->request($path, ['token' => $this->token], true);
+ if ($r['status'] >= 400) {
+ $errors[] = $path . ' → HTTP ' . $r['status'];
+ continue;
+ }
+ $this->logoffPath = $path;
+ return true;
+ }
+ $this->lastLogoffError = implode(' | ', $errors);
+ return false;
+ } catch (Throwable $e) {
+ $this->lastLogoffError = $e->getMessage();
+ return false;
+ } finally {
+ $this->token = null;
+ }
+ }
+
+ /* =========================================================
+ * LETTURA DATI
+ * ========================================================= */
+
+ /**
+ * Legge UNA pagina di una tabella.
+ *
+ * $opts:
+ * 'fields' => ['Campo1', 'Campo2'] (opzionale)
+ * 'filters' => [['key'=>'OrderDate','value'=>'2026-01-01T00:00:00','operator'=>'>=']]
+ * value può essere anche DateTimeInterface
+ * 'recordsPerPage' => 200 (massimo accettato da Mago)
+ * 'pageNumber' => 1
+ *
+ * Ritorna: ['total'=>int, 'count'=>int, 'page'=>int, 'records'=>array]
+ */
+ public function getDataTable($tableName, array $opts = [])
+ {
+ $body = [
+ 'tableName' => (string)$tableName,
+ 'recordsPerPage' => max(1, min(200, (int)($opts['recordsPerPage'] ?? 200))), // Mago restituisce max 200 per pagina
+ 'pageNumber' => max(1, (int)($opts['pageNumber'] ?? 1)),
+ ];
+
+ if (!empty($opts['fields'])) {
+ $body['fieldsName'] = array_values($opts['fields']);
+ }
+ if (!empty($opts['filters'])) {
+ $body['filters'] = $this->normalizeFilters($opts['filters']);
+ }
+
+ $data = $this->callWithRelogin('api/magoservices/getDataTable', $body);
+
+ if (($data['response'] ?? '') !== 'OK') {
+ throw new MagoApiException("getDataTable($tableName) risposta non OK: " . $this->extractMessage(['data' => $data, 'raw' => json_encode($data)]));
+ }
+
+ $d = $data['responseData'] ?? [];
+
+ return [
+ 'total' => (int)($d['totalRecordsNumber'] ?? 0),
+ 'count' => (int)($d['currentPageRecordsNumber'] ?? 0),
+ 'page' => (int)($d['currentPageNumber'] ?? $body['pageNumber']),
+ 'records' => is_array($d['records'] ?? null) ? $d['records'] : [],
+ ];
+ }
+
+ /**
+ * Itera TUTTI i record di una tabella paginando in automatico (generator, poca memoria).
+ *
+ * foreach ($mago->iterate('MA_SaleOrd', ['filters'=>[...], 'recordsPerPage'=>500]) as $rec) {...}
+ */
+ public function iterate($tableName, array $opts = [], $maxPages = 100000)
+ {
+ $perPage = max(1, min(200, (int)($opts['recordsPerPage'] ?? 200)));
+ $page = 1;
+ $read = 0;
+
+ do {
+ $r = $this->getDataTable($tableName, array_merge($opts, [
+ 'recordsPerPage' => $perPage,
+ 'pageNumber' => $page,
+ ]));
+
+ if (empty($r['records'])) break;
+
+ foreach ($r['records'] as $rec) {
+ $read++;
+ yield $rec;
+ }
+
+ $page++;
+ } while ($read < $r['total'] && $page <= $maxPages);
+ }
+
+ /**
+ * Conta i record (chiede 1 solo record e legge totalRecordsNumber).
+ */
+ public function count($tableName, array $filters = [])
+ {
+ $r = $this->getDataTable($tableName, [
+ 'filters' => $filters,
+ 'recordsPerPage' => 1,
+ 'pageNumber' => 1,
+ ]);
+ return $r['total'];
+ }
+
+ /* =========================================================
+ * HELPER DI CONVERSIONE (per l'import in MySQL)
+ * ========================================================= */
+
+ /**
+ * Converte una data Mago in formato MySQL. Le date "vuote" di Mago (1799-12-31) diventano null.
+ */
+ public static function toMysqlDate($value, $withTime = false)
+ {
+ if ($value === null) return null;
+ $value = trim((string)$value);
+ if ($value === '' || strpos($value, '1799-12-31') === 0) return null;
+
+ $ts = strtotime($value);
+ if ($ts === false) return null;
+
+ return date($withTime ? 'Y-m-d H:i:s' : 'Y-m-d', $ts);
+ }
+
+ /**
+ * Converte i booleani di Mago (true/false, 1/0, "1"/"0", "true"/"false", "Y"/"N") in 0/1.
+ */
+ public static function toBool($value)
+ {
+ if (is_bool($value)) return $value ? 1 : 0;
+ $v = strtolower(trim((string)$value));
+ return in_array($v, ['1', 'true', 'y', 'yes', 's', 'si'], true) ? 1 : 0;
+ }
+
+ public static function toDecimal($value)
+ {
+ if ($value === null || $value === '') return 0.0;
+ return (float)str_replace(',', '.', (string)$value);
+ }
+
+ /* =========================================================
+ * INTERNI
+ * ========================================================= */
+
+ private function normalizeFilters(array $filters)
+ {
+ $out = [];
+ foreach ($filters as $f) {
+ if (!isset($f['key'])) continue;
+
+ $value = $f['value'] ?? '';
+ if ($value instanceof DateTimeInterface) {
+ $value = $value->format('Y-m-d\TH:i:s');
+ }
+ // Verificato: una data SENZA orario dà risultati errati → aggiungo sempre T00:00:00
+ if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
+ $value .= 'T00:00:00';
+ }
+
+ $out[] = [
+ 'key' => (string)$f['key'],
+ 'value' => is_bool($value) ? ($value ? 'true' : 'false') : (string)$value,
+ 'operator' => (string)($f['operator'] ?? '='),
+ ];
+ }
+ return $out;
+ }
+
+ private function callWithRelogin($path, $body)
+ {
+ if ($this->token === null) {
+ $this->login();
+ }
+
+ $r = $this->request($path, $body, true);
+
+ // Token scaduto → rifaccio login UNA volta e ritento
+ if ($this->isAuthError($r)) {
+ $this->token = null;
+ $this->login();
+ $r = $this->request($path, $body, true);
+ }
+
+ if ($r['status'] >= 400 || $r['data'] === null) {
+ throw new MagoApiException('Errore Mago (HTTP ' . $r['status'] . ') su ' . $path . ': ' . $this->extractMessage($r));
+ }
+
+ return $r['data'];
+ }
+
+ private function isAuthError(array $r)
+ {
+ $raw = (string)$r['raw'];
+ return stripos($raw, 'LoginInfo Not Found') !== false
+ || stripos($raw, 'Missing token') !== false
+ || $r['status'] === 401;
+ }
+
+ private function request($path, $body, $auth)
+ {
+ if (!function_exists('curl_init')) {
+ throw new MagoApiException('Estensione PHP cURL non disponibile');
+ }
+
+ $headers = [
+ 'Content-Type: application/json',
+ 'Accept: application/json',
+ ];
+
+ if ($auth) {
+ if ($this->token === null) {
+ throw new MagoApiException('Chiamata autenticata senza token (login non eseguito)');
+ }
+ $headers[] = 'Authorization: ' . $this->token; // token nudo, senza "Bearer"
+ }
+
+ $ch = curl_init($this->baseUrl . ltrim($path, '/'));
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CONNECTTIMEOUT => 10,
+ CURLOPT_TIMEOUT => $this->timeout,
+ ]);
+
+ $raw = curl_exec($ch);
+
+ if ($raw === false) {
+ $err = curl_error($ch);
+ curl_close($ch);
+ throw new MagoApiException('Connessione a Mago fallita (VPN attiva?): ' . $err);
+ }
+
+ $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ $data = json_decode($raw, true);
+
+ return [
+ 'status' => $status,
+ 'data' => is_array($data) ? $data : null,
+ 'raw' => $raw,
+ ];
+ }
+
+ private function extractMessage(array $r)
+ {
+ $d = $r['data'] ?? null;
+ if (is_array($d)) {
+ foreach (['message', 'Message', 'error', 'errorMessage', 'responseMessage', 'response'] as $k) {
+ if (!empty($d[$k]) && is_string($d[$k])) return $d[$k];
+ }
+ }
+ $raw = trim((string)($r['raw'] ?? ''));
+ return $raw !== '' ? mb_substr($raw, 0, 300) : 'risposta vuota';
+ }
+}
diff --git a/public/userarea/class/MagoSalesStats.php b/public/userarea/class/MagoSalesStats.php
new file mode 100644
index 00000000..76f50669
--- /dev/null
+++ b/public/userarea/class/MagoSalesStats.php
@@ -0,0 +1,190 @@
+ o.order_date THEN o.confirmed_delivery_date END,
+ o.compulsory_delivery_date
+ ) AS due_date,
+ 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
+ ) x";
+ }
+
+ /**
+ * 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.status IN ('aperto','bloccato') AND x.due_date < CURDATE())",
+ '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)];
+ }
+ }
+}
diff --git a/public/userarea/class/MagoSync.php b/public/userarea/class/MagoSync.php
new file mode 100644
index 00000000..56f87304
--- /dev/null
+++ b/public/userarea/class/MagoSync.php
@@ -0,0 +1,464 @@
+run(['limit' => 50]); // prova con pochi ordini
+ * $report = $sync->run(); // incrementale (TbModified)
+ * $report = $sync->run(['full' => true]); // tutto dal 2026-01-01
+ *
+ * Il logoff da Mago viene fatto sempre, anche in caso di errore.
+ */
+
+require_once __DIR__ . '/MagoClient.php';
+
+class MagoSync
+{
+ const CUSTOMER_TYPE = 3211264; // CustSuppType "Cliente" (verificato sui dati)
+ const DEFAULT_FROM = '2020-01-01'; // storico dal 2020 per confronto anni
+ const ENTITY_ORDERS = 'sale_ord';
+ const ENTITY_CUSTOMER = 'cust_supp';
+ const ENTITY_SUMMARY = 'sale_ord_summary';
+
+ /** @var PDO */
+ private $pdo;
+ /** @var MagoClient */
+ private $mago;
+ /** @var callable|null */
+ private $logger;
+
+ public function __construct(PDO $pdo, MagoClient $mago, ?callable $logger = null)
+ {
+ $this->pdo = $pdo;
+ $this->mago = $mago;
+ $this->logger = $logger;
+ $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ }
+
+ /**
+ * Opzioni:
+ * limit int|null numero massimo di ordini (modalità prova)
+ * full bool ignora l'incrementale e rilegge tutto dal 'from'
+ * from string data minima ordini (default 2026-01-01)
+ * skip_customers bool non sincronizzare l'anagrafica clienti
+ */
+ public function run(array $opts = [])
+ {
+ $limit = isset($opts['limit']) && (int)$opts['limit'] > 0 ? (int)$opts['limit'] : null;
+ $full = !empty($opts['full']);
+ $from = !empty($opts['from']) ? $opts['from'] : self::DEFAULT_FROM;
+
+ $report = ['ok' => true, 'limit' => $limit, 'full' => $full, 'from' => $from];
+
+ if (!$this->acquireLock()) {
+ return ['ok' => false, 'message' => 'Sincronizzazione già in corso, riprova tra poco.'];
+ }
+
+ try {
+ $this->mago->login();
+
+ if (empty($opts['skip_customers'])) {
+ $report['clienti'] = $this->syncCustomers($full);
+ }
+ $report['ordini'] = $this->syncSaleOrders($from, $limit, $full);
+ $report['importi'] = $this->syncSaleOrderSummaries($from, $full, (bool)$limit);
+ } catch (Throwable $e) {
+ $report['ok'] = false;
+ $report['message'] = $e->getMessage();
+ $this->log('ERRORE: ' . $e->getMessage());
+ } finally {
+ $logoffOk = $this->mago->logoff();
+ $report['logoff'] = $logoffOk ? 'OK' : ('non riuscito: ' . $this->mago->getLastLogoffError());
+ $this->releaseLock();
+ }
+
+ return $report;
+ }
+
+ /* =========================================================
+ * ORDINI CLIENTE
+ * ========================================================= */
+
+ public function syncSaleOrders($from, $limit = null, $full = false)
+ {
+ $t0 = microtime(true);
+ $state = $this->getState(self::ENTITY_ORDERS);
+
+ $filters = [['key' => 'OrderDate', 'value' => $from . 'T00:00:00', 'operator' => '>=']];
+
+ // Incrementale: solo i record modificati dall'ultima sincronizzazione completa
+ $incremental = !$full && !$limit && !empty($state['last_tb_modified']);
+ if ($incremental) {
+ $filters[] = [
+ 'key' => 'TbModified',
+ 'value' => date('Y-m-d\TH:i:s', strtotime($state['last_tb_modified'])),
+ 'operator' => '>=',
+ ];
+ }
+
+ $this->log('Ordini: ' . ($incremental ? 'incrementale da ' . $state['last_tb_modified'] : 'completa dal ' . $from) . ($limit ? " (prova: max $limit)" : ''));
+
+ $sql = "INSERT INTO mago_sale_ord (
+ sale_ord_id, internal_ord_no, external_ord_no, order_date,
+ expected_delivery_date, confirmed_delivery_date, compulsory_delivery_date,
+ customer, invoicing_customer, salesperson, area_manager, area,
+ payment, currency, our_reference, your_reference,
+ transport, port, package, storage, job,
+ delivered, invoiced, cancelled, is_blocked, printed, archived, open_order,
+ priority, notes, tb_created, tb_modified, raw_json, synced_at
+ ) VALUES (
+ :sale_ord_id, :internal_ord_no, :external_ord_no, :order_date,
+ :expected_delivery_date, :confirmed_delivery_date, :compulsory_delivery_date,
+ :customer, :invoicing_customer, :salesperson, :area_manager, :area,
+ :payment, :currency, :our_reference, :your_reference,
+ :transport, :port, :package, :storage, :job,
+ :delivered, :invoiced, :cancelled, :is_blocked, :printed, :archived, :open_order,
+ :priority, :notes, :tb_created, :tb_modified, :raw_json, NOW()
+ )
+ ON DUPLICATE KEY UPDATE
+ internal_ord_no = VALUES(internal_ord_no),
+ external_ord_no = VALUES(external_ord_no),
+ order_date = VALUES(order_date),
+ expected_delivery_date = VALUES(expected_delivery_date),
+ confirmed_delivery_date = VALUES(confirmed_delivery_date),
+ compulsory_delivery_date = VALUES(compulsory_delivery_date),
+ customer = VALUES(customer),
+ invoicing_customer = VALUES(invoicing_customer),
+ salesperson = VALUES(salesperson),
+ area_manager = VALUES(area_manager),
+ area = VALUES(area),
+ payment = VALUES(payment),
+ currency = VALUES(currency),
+ our_reference = VALUES(our_reference),
+ your_reference = VALUES(your_reference),
+ transport = VALUES(transport),
+ port = VALUES(port),
+ package = VALUES(package),
+ storage = VALUES(storage),
+ job = VALUES(job),
+ delivered = VALUES(delivered),
+ invoiced = VALUES(invoiced),
+ cancelled = VALUES(cancelled),
+ is_blocked = VALUES(is_blocked),
+ printed = VALUES(printed),
+ archived = VALUES(archived),
+ open_order = VALUES(open_order),
+ priority = VALUES(priority),
+ notes = VALUES(notes),
+ tb_created = VALUES(tb_created),
+ tb_modified = VALUES(tb_modified),
+ raw_json = VALUES(raw_json),
+ synced_at = NOW()";
+
+ $stmt = $this->pdo->prepare($sql);
+
+ $count = 0;
+ $maxModified = $state['last_tb_modified'] ?? null;
+
+ $this->pdo->beginTransaction();
+ try {
+ foreach ($this->mago->iterate('MA_SaleOrd', ['filters' => $filters]) as $r) {
+ $stmt->execute($this->mapSaleOrd($r));
+ $count++;
+
+ $mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true);
+ if ($mod && (!$maxModified || $mod > $maxModified)) {
+ $maxModified = $mod;
+ }
+
+ if ($count % 200 === 0) {
+ $this->pdo->commit();
+ $this->pdo->beginTransaction();
+ $this->log(" ordini salvati: $count");
+ }
+
+ if ($limit && $count >= $limit) break;
+ }
+ $this->pdo->commit();
+ } catch (Throwable $e) {
+ if ($this->pdo->inTransaction()) $this->pdo->rollBack();
+ $this->saveState(self::ENTITY_ORDERS, null, 'errore', $e->getMessage(), $count, $t0, false);
+ throw $e;
+ }
+
+ // In modalità prova NON avanzo il punto di ripresa (i record non arrivano in ordine di modifica)
+ $this->saveState(
+ self::ENTITY_ORDERS,
+ $limit ? null : $maxModified,
+ 'ok',
+ ($limit ? "Prova: $count ordini" : ($incremental ? "Incrementale: $count ordini" : "Completa: $count ordini")),
+ $count,
+ $t0,
+ !$limit
+ );
+
+ $this->log("Ordini sincronizzati: $count");
+ return ['records' => $count, 'incrementale' => $incremental, 'ms' => $this->ms($t0)];
+ }
+
+ private function mapSaleOrd(array $r)
+ {
+ $s = function ($k, $len = 255) use ($r) {
+ return mb_substr(trim((string)($r[$k] ?? '')), 0, $len);
+ };
+
+ return [
+ 'sale_ord_id' => (int)($r['SaleOrdId'] ?? 0),
+ 'internal_ord_no' => $s('InternalOrdNo', 30),
+ 'external_ord_no' => $s('ExternalOrdNo', 60),
+ 'order_date' => MagoClient::toMysqlDate($r['OrderDate'] ?? null),
+ 'expected_delivery_date' => MagoClient::toMysqlDate($r['ExpectedDeliveryDate'] ?? null),
+ 'confirmed_delivery_date' => MagoClient::toMysqlDate($r['ConfirmedDeliveryDate'] ?? null),
+ 'compulsory_delivery_date' => MagoClient::toMysqlDate($r['CompulsoryDeliveryDate'] ?? null),
+ 'customer' => $s('Customer', 20),
+ 'invoicing_customer' => $s('InvoicingCustomer', 20),
+ 'salesperson' => $s('Salesperson', 20),
+ 'area_manager' => $s('AreaManager', 20),
+ 'area' => $s('Area', 20),
+ 'payment' => $s('Payment', 20),
+ 'currency' => $s('Currency', 8),
+ 'our_reference' => $s('OurReference', 150),
+ 'your_reference' => $s('YourReference', 150),
+ 'transport' => $s('Transport', 20),
+ 'port' => $s('Port', 20),
+ 'package' => $s('Package', 40),
+ 'storage' => $s('StoragePhase1', 30),
+ 'job' => $s('Job', 30),
+ 'delivered' => MagoClient::toBool($r['Delivered'] ?? false),
+ 'invoiced' => MagoClient::toBool($r['Invoiced'] ?? false),
+ 'cancelled' => MagoClient::toBool($r['Cancelled'] ?? false),
+ 'is_blocked' => MagoClient::toBool($r['IsBlocked'] ?? false),
+ 'printed' => MagoClient::toBool($r['Printed'] ?? false),
+ 'archived' => MagoClient::toBool($r['Archived'] ?? false),
+ 'open_order' => MagoClient::toBool($r['OpenOrder'] ?? false),
+ 'priority' => (int)($r['Priority'] ?? 0),
+ 'notes' => trim((string)($r['Notes'] ?? '')) ?: null,
+ 'tb_created' => MagoClient::toMysqlDate($r['TbCreated'] ?? null, true),
+ 'tb_modified' => MagoClient::toMysqlDate($r['TbModified'] ?? null, true),
+ 'raw_json' => json_encode($r, JSON_UNESCAPED_UNICODE),
+ ];
+ }
+
+ /* =========================================================
+ * IMPORTI (MA_SaleOrdSummary → colonne importo di mago_sale_ord)
+ * ========================================================= */
+
+ /**
+ * MA_SaleOrdSummary non ha la data ordine: filtro per TbModified, che è
+ * l'unico filtro data verificato. Parto dalla creazione del primo ordine
+ * locale del periodo (i totali nascono insieme all'ordine) e aggiorno
+ * solo gli ordini già presenti in locale.
+ */
+ public function syncSaleOrderSummaries($from, $full = false, $testMode = false)
+ {
+ $t0 = microtime(true);
+ $state = $this->getState(self::ENTITY_SUMMARY);
+
+ $info = $this->pdo->query(
+ "
+ SELECT MIN(COALESCE(tb_created, order_date)) AS first_created,
+ SUM(summary_tb_modified IS NULL) AS missing,
+ COUNT(*) AS n
+ FROM mago_sale_ord
+ WHERE order_date >= " . $this->pdo->quote($from)
+ )->fetch(PDO::FETCH_ASSOC);
+
+ if (empty($info['n'])) {
+ return ['records' => 0, 'aggiornati' => 0, 'incrementale' => false, 'ms' => $this->ms($t0)];
+ }
+
+ // Incrementale solo se tutti gli ordini locali hanno già i totali
+ $incremental = !$full && !$testMode && !empty($state['last_tb_modified']) && (int)$info['missing'] === 0;
+ $since = $incremental
+ ? $state['last_tb_modified']
+ : date('Y-m-d 00:00:00', strtotime($info['first_created'] . ' -1 day'));
+
+ $this->log('Importi: ' . ($incremental ? 'incrementale' : 'completa') . ' da ' . $since);
+
+ $filters = [['key' => 'TbModified', 'value' => date('Y-m-d\TH:i:s', strtotime($since)), 'operator' => '>=']];
+
+ $stmt = $this->pdo->prepare("
+ UPDATE mago_sale_ord SET
+ taxable_amount = :taxable,
+ tax_amount = :tax,
+ total_amount = :total,
+ goods_amount = :goods,
+ discounts = :discounts,
+ charges = :charges,
+ summary_tb_modified = :mod
+ WHERE sale_ord_id = :id
+ ");
+
+ $read = 0;
+ $updated = 0;
+ $maxModified = $state['last_tb_modified'] ?? null;
+
+ $this->pdo->beginTransaction();
+ try {
+ foreach ($this->mago->iterate('MA_SaleOrdSummary', ['filters' => $filters]) as $r) {
+ $read++;
+ $mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true);
+ $d = function ($k) use ($r) {
+ return round(MagoClient::toDecimal($r[$k] ?? 0), 2);
+ };
+
+ $stmt->execute([
+ 'taxable' => $d('TaxableAmount'),
+ 'tax' => $d('TaxAmount'),
+ 'total' => $d('TotalAmount'),
+ 'goods' => $d('GoodsAmount'),
+ 'discounts' => $d('Discounts'),
+ 'charges' => $d('ShippingCharges') + $d('PackagingCharges') + $d('CollectionCharges')
+ + $d('StampsCharges') + $d('AdditionalCharges') + $d('InsuranceCharges'),
+ 'mod' => $mod ?: date('Y-m-d H:i:s'),
+ 'id' => (int)($r['SaleOrdId'] ?? 0),
+ ]);
+ $updated += $stmt->rowCount() > 0 ? 1 : 0;
+
+ if ($mod && (!$maxModified || $mod > $maxModified)) $maxModified = $mod;
+
+ if ($read % 200 === 0) {
+ $this->pdo->commit();
+ $this->pdo->beginTransaction();
+ $this->log(" totali letti: $read");
+ }
+ }
+ $this->pdo->commit();
+ } catch (Throwable $e) {
+ if ($this->pdo->inTransaction()) $this->pdo->rollBack();
+ $this->saveState(self::ENTITY_SUMMARY, null, 'errore', $e->getMessage(), $read, $t0, false);
+ throw $e;
+ }
+
+ $this->saveState(self::ENTITY_SUMMARY, $testMode ? null : $maxModified, 'ok', "Letti $read totali, $updated ordini aggiornati", $read, $t0, !$testMode);
+ $this->log("Importi: letti $read, ordini aggiornati $updated");
+
+ return ['records' => $read, 'aggiornati' => $updated, 'incrementale' => $incremental, 'ms' => $this->ms($t0)];
+ }
+
+ /* =========================================================
+ * ANAGRAFICA CLIENTI (per decodificare i codici cliente)
+ * ========================================================= */
+
+ public function syncCustomers($full = false)
+ {
+ $t0 = microtime(true);
+ $state = $this->getState(self::ENTITY_CUSTOMER);
+
+ $filters = [['key' => 'CustSuppType', 'value' => (string)self::CUSTOMER_TYPE, 'operator' => '=']];
+ $incremental = !$full && !empty($state['last_tb_modified']);
+ if ($incremental) {
+ $filters[] = [
+ 'key' => 'TbModified',
+ 'value' => date('Y-m-d\TH:i:s', strtotime($state['last_tb_modified'])),
+ 'operator' => '>=',
+ ];
+ }
+
+ $this->log('Clienti: ' . ($incremental ? 'incrementale' : 'completa'));
+
+ $stmt = $this->pdo->prepare("
+ INSERT INTO mago_cust_supp
+ (cust_supp_type, cust_supp, company_name, city, county, region, country, iso_country_code, disabled, tb_modified, synced_at)
+ VALUES
+ (:t, :c, :name, :city, :county, :region, :country, :iso, :disabled, :mod, NOW())
+ ON DUPLICATE KEY UPDATE
+ company_name = VALUES(company_name), city = VALUES(city), county = VALUES(county),
+ region = VALUES(region), country = VALUES(country), iso_country_code = VALUES(iso_country_code),
+ disabled = VALUES(disabled), tb_modified = VALUES(tb_modified), synced_at = NOW()
+ ");
+
+ $count = 0;
+ $maxModified = $state['last_tb_modified'] ?? null;
+
+ $this->pdo->beginTransaction();
+ try {
+ foreach ($this->mago->iterate('MA_CustSupp', ['filters' => $filters]) as $r) {
+ $mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true);
+ $stmt->execute([
+ 't' => (int)($r['CustSuppType'] ?? self::CUSTOMER_TYPE),
+ 'c' => mb_substr(trim((string)($r['CustSupp'] ?? '')), 0, 20),
+ 'name' => mb_substr(trim((string)($r['CompanyName'] ?? '')), 0, 200),
+ 'city' => mb_substr(trim((string)($r['City'] ?? '')), 0, 100),
+ 'county' => mb_substr(trim((string)($r['County'] ?? '')), 0, 10),
+ 'region' => mb_substr(trim((string)($r['Region'] ?? '')), 0, 60),
+ 'country' => mb_substr(trim((string)($r['Country'] ?? '')), 0, 60),
+ 'iso' => mb_substr(trim((string)($r['ISOCountryCode'] ?? '')), 0, 4),
+ 'disabled' => MagoClient::toBool($r['Disabled'] ?? false),
+ 'mod' => $mod,
+ ]);
+ $count++;
+ if ($mod && (!$maxModified || $mod > $maxModified)) $maxModified = $mod;
+ }
+ $this->pdo->commit();
+ } catch (Throwable $e) {
+ if ($this->pdo->inTransaction()) $this->pdo->rollBack();
+ $this->saveState(self::ENTITY_CUSTOMER, null, 'errore', $e->getMessage(), $count, $t0, false);
+ throw $e;
+ }
+
+ $this->saveState(self::ENTITY_CUSTOMER, $maxModified, 'ok', "$count clienti", $count, $t0, true);
+ $this->log("Clienti sincronizzati: $count");
+
+ return ['records' => $count, 'incrementale' => $incremental, 'ms' => $this->ms($t0)];
+ }
+
+ /* =========================================================
+ * STATO / LOCK / LOG
+ * ========================================================= */
+
+ private function getState($entity)
+ {
+ $st = $this->pdo->prepare("SELECT * FROM mago_sync_state WHERE entity = ?");
+ $st->execute([$entity]);
+ return $st->fetch(PDO::FETCH_ASSOC) ?: [];
+ }
+
+ private function saveState($entity, $lastModified, $status, $message, $records, $t0, $updateCursor)
+ {
+ $sql = "INSERT INTO mago_sync_state (entity, last_tb_modified, last_run_at, last_status, last_message, records_last_run, duration_ms)
+ VALUES (:e, :m, NOW(), :s, :msg, :r, :d)
+ ON DUPLICATE KEY UPDATE
+ " . ($updateCursor ? "last_tb_modified = COALESCE(VALUES(last_tb_modified), last_tb_modified)," : "") . "
+ last_run_at = NOW(),
+ last_status = VALUES(last_status),
+ last_message = VALUES(last_message),
+ records_last_run = VALUES(records_last_run),
+ duration_ms = VALUES(duration_ms)";
+
+ $this->pdo->prepare($sql)->execute([
+ 'e' => $entity,
+ 'm' => $updateCursor ? $lastModified : null,
+ 's' => $status,
+ 'msg' => mb_substr((string)$message, 0, 2000),
+ 'r' => (int)$records,
+ 'd' => (int)round((microtime(true) - $t0) * 1000),
+ ]);
+ }
+
+ private function acquireLock()
+ {
+ return (int)$this->pdo->query("SELECT GET_LOCK('mago_sync', 0)")->fetchColumn() === 1;
+ }
+
+ private function releaseLock()
+ {
+ try {
+ $this->pdo->query("SELECT RELEASE_LOCK('mago_sync')");
+ } catch (Throwable $e) {
+ }
+ }
+
+ private function ms($t0)
+ {
+ return (int)round((microtime(true) - $t0) * 1000);
+ }
+
+ private function log($msg)
+ {
+ if ($this->logger) {
+ call_user_func($this->logger, '[' . date('H:i:s') . '] ' . $msg);
+ }
+ }
+}
diff --git a/public/userarea/include/navbar.php b/public/userarea/include/navbar.php
index ed63cfae..6cbc2a62 100644
--- a/public/userarea/include/navbar.php
+++ b/public/userarea/include/navbar.php
@@ -215,6 +215,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
safeLoad();
+ }
+}
+
+while (ob_get_level()) ob_end_clean();
+header('Content-Type: text/plain; charset=utf-8');
+set_time_limit(300);
+
+$report = [
+ 'php_version' => PHP_VERSION,
+ 'env_letto' => !empty($_ENV['MAGO_BASE_URL']) || getenv('MAGO_BASE_URL') !== false,
+];
+
+function ms($t0)
+{
+ return round((microtime(true) - $t0) * 1000) . ' ms';
+}
+
+function describeRecord($rec)
+{
+ $out = [];
+ foreach ((array)$rec as $k => $v) {
+ $out[$k] = gettype($v) . ' | ' . (is_scalar($v) || $v === null ? var_export($v, true) : json_encode($v));
+ }
+ return $out;
+}
+
+function testCount(MagoClient $mago, $label, $table, array $filters)
+{
+ $t0 = microtime(true);
+ try {
+ return ['test' => $label, 'totale' => $mago->count($table, $filters), 'tempo' => ms($t0)];
+ } catch (Throwable $e) {
+ return ['test' => $label, 'ERRORE' => $e->getMessage(), 'tempo' => ms($t0)];
+ }
+}
+
+try {
+ $mago = MagoClient::fromEnv();
+
+ // 1) LOGIN
+ $t0 = microtime(true);
+ $mago->login();
+ $report['login'] = 'OK (' . ms($t0) . ')';
+
+ // 2) COLONNE REALI + record di esempio
+ $samples = [];
+ foreach (['MA_SaleOrd', 'MA_SaleOrdDetail', 'MA_CustSupp', 'MA_Items'] as $table) {
+ $t0 = microtime(true);
+ try {
+ $r = $mago->getDataTable($table, ['recordsPerPage' => 1, 'pageNumber' => 1]);
+ $samples[$table] = $r['records'][0] ?? [];
+ $report['tabelle'][$table] = [
+ 'totale_record' => $r['total'],
+ 'tempo' => ms($t0),
+ 'colonne' => describeRecord($samples[$table]),
+ ];
+ } catch (Throwable $e) {
+ $report['tabelle'][$table] = ['ERRORE' => $e->getMessage()];
+ }
+ }
+
+ // 3) OPERATORI DEI FILTRI su MA_SaleOrd.OrderDate
+ $f2026 = ['key' => 'OrderDate', 'value' => '2026-01-01T00:00:00', 'operator' => '>='];
+ $fBefore27 = ['key' => 'OrderDate', 'value' => '2027-01-01T00:00:00', 'operator' => '<'];
+
+ $report['filtri'] = [
+ testCount($mago, 'nessun filtro', 'MA_SaleOrd', []),
+ testCount($mago, 'OrderDate >= 2026-01-01', 'MA_SaleOrd', [$f2026]),
+ testCount($mago, 'OrderDate < 2027-01-01', 'MA_SaleOrd', [$fBefore27]),
+ testCount($mago, 'OrderDate <= 2025-12-31', 'MA_SaleOrd', [['key' => 'OrderDate', 'value' => '2025-12-31T23:59:59', 'operator' => '<=']]),
+ testCount($mago, 'DUE filtri (>= 2026 AND < 2027) → sono in AND?', 'MA_SaleOrd', [$f2026, $fBefore27]),
+ testCount($mago, 'OrderDate >= "2026-01-01" (solo data, senza ora)', 'MA_SaleOrd', [['key' => 'OrderDate', 'value' => '2026-01-01', 'operator' => '>=']]),
+ testCount($mago, 'TbModified >= ultimi 7 giorni (sync incrementale)', 'MA_SaleOrd', [['key' => 'TbModified', 'value' => date('Y-m-d\T00:00:00', strtotime('-7 days')), 'operator' => '>=']]),
+ ];
+
+ // like sul numero ordine (prefisso del record di esempio)
+ $ordNo = (string)($samples['MA_SaleOrd']['InternalOrdNo'] ?? '');
+ if ($ordNo !== '') {
+ $prefix = mb_substr($ordNo, 0, 3);
+ $report['filtri'][] = testCount($mago, "InternalOrdNo like '{$prefix}%'", 'MA_SaleOrd', [['key' => 'InternalOrdNo', 'value' => $prefix . '%', 'operator' => 'like']]);
+ $report['filtri'][] = testCount($mago, "InternalOrdNo LIKE '{$prefix}%' (maiuscolo)", 'MA_SaleOrd', [['key' => 'InternalOrdNo', 'value' => $prefix . '%', 'operator' => 'LIKE']]);
+ }
+
+ // 4) COLLEGAMENTO TESTATA → RIGHE
+ $saleOrdId = $samples['MA_SaleOrd']['SaleOrdId'] ?? null;
+ if ($saleOrdId !== null) {
+ $report['collegamento_righe'] = testCount($mago, "MA_SaleOrdDetail con SaleOrdId = $saleOrdId", 'MA_SaleOrdDetail', [['key' => 'SaleOrdId', 'value' => $saleOrdId, 'operator' => '=']]);
+
+ // provo anche un filtro per intervallo di id (utile per sincronizzare le righe solo del 2026)
+ $report['collegamento_righe_range'] = testCount($mago, "MA_SaleOrdDetail con SaleOrdId >= $saleOrdId", 'MA_SaleOrdDetail', [['key' => 'SaleOrdId', 'value' => $saleOrdId, 'operator' => '>=']]);
+ } else {
+ $report['collegamento_righe'] = 'SaleOrdId non presente nel record di testata';
+ }
+
+ // 5) DIMENSIONE MASSIMA PAGINA (velocità del sync)
+ foreach ([500, 1000, 2000] as $pp) {
+ $t0 = microtime(true);
+ try {
+ $r = $mago->getDataTable('MA_SaleOrd', ['filters' => [$f2026], 'recordsPerPage' => $pp, 'pageNumber' => 1]);
+ $report['pagine'][] = ['richiesti' => $pp, 'restituiti' => $r['count'], 'tempo' => ms($t0)];
+ } catch (Throwable $e) {
+ $report['pagine'][] = ['richiesti' => $pp, 'ERRORE' => $e->getMessage()];
+ }
+ }
+
+ // 6) Primo ordine 2026 completo (per vedere valori reali di stato/importi)
+ try {
+ $r = $mago->getDataTable('MA_SaleOrd', ['filters' => [$f2026], 'recordsPerPage' => 1, 'pageNumber' => 1]);
+ $ord2026 = $r['records'][0] ?? null;
+ $report['esempio_ordine_2026'] = $ord2026;
+
+ if ($ord2026 && isset($ord2026['SaleOrdId'])) {
+ $rd = $mago->getDataTable('MA_SaleOrdDetail', [
+ 'filters' => [['key' => 'SaleOrdId', 'value' => $ord2026['SaleOrdId'], 'operator' => '=']],
+ 'recordsPerPage' => 3,
+ 'pageNumber' => 1,
+ ]);
+ $report['esempio_righe_ordine_2026'] = $rd['records'];
+ }
+ } catch (Throwable $e) {
+ $report['esempio_ordine_2026'] = ['ERRORE' => $e->getMessage()];
+ }
+} catch (Throwable $e) {
+ $report['ERRORE_GENERALE'] = $e->getMessage();
+} finally {
+ if (isset($mago)) {
+ $ok = $mago->logoff();
+ $report['logoff'] = $ok ? 'OK' : ('FALLITO: ' . $mago->getLastLogoffError());
+ }
+}
+
+echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
diff --git a/public/userarea/mago_sync_cli.php b/public/userarea/mago_sync_cli.php
new file mode 100644
index 00000000..b23a60e6
--- /dev/null
+++ b/public/userarea/mago_sync_cli.php
@@ -0,0 +1,71 @@
+> /percorso/log/mago_sync.log 2>&1
+ */
+
+if (PHP_SAPI !== 'cli') {
+ http_response_code(403);
+ exit('Solo da riga di comando');
+}
+
+set_time_limit(0);
+date_default_timezone_set('Europe/Rome');
+
+// --- vendor e .env: li cerco in userarea e fino a 3 cartelle sopra ---
+$dirs = [__DIR__, dirname(__DIR__), dirname(__DIR__, 2), dirname(__DIR__, 3)];
+
+foreach ($dirs as $d) {
+ if (file_exists($d . '/vendor/autoload.php')) {
+ require_once $d . '/vendor/autoload.php';
+ break;
+ }
+}
+
+if (class_exists('Dotenv\Dotenv')) {
+ foreach ($dirs as $d) {
+ if (file_exists($d . '/.env')) {
+ Dotenv\Dotenv::createImmutable($d)->safeLoad();
+ break;
+ }
+ }
+}
+
+// --- DB della piattaforma ---
+require_once __DIR__ . '/class/db-functions.php';
+require_once __DIR__ . '/class/MagoSync.php';
+
+$opts = getopt('', ['limit::', 'full', 'from::', 'skip-customers']);
+
+$runOpts = [
+ 'limit' => isset($opts['limit']) ? (int)$opts['limit'] : null,
+ 'full' => isset($opts['full']),
+ 'from' => $opts['from'] ?? null,
+ 'skip_customers' => isset($opts['skip-customers']),
+];
+
+try {
+ $pdo = DBHandlerSelect::getInstance()->getConnection();
+
+ $sync = new MagoSync($pdo, MagoClient::fromEnv(), function ($msg) {
+ echo $msg . PHP_EOL;
+ });
+
+ echo '=== Sync Mago ' . date('Y-m-d H:i:s') . ' ===' . PHP_EOL;
+ $report = $sync->run($runOpts);
+ echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
+
+ exit(!empty($report['ok']) ? 0 : 1);
+} catch (Throwable $e) {
+ fwrite(STDERR, 'ERRORE: ' . $e->getMessage() . PHP_EOL);
+ exit(1);
+}
diff --git a/public/userarea/sales_orders_stats.php b/public/userarea/sales_orders_stats.php
new file mode 100644
index 00000000..d13a2522
--- /dev/null
+++ b/public/userarea/sales_orders_stats.php
@@ -0,0 +1,1968 @@
+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 '';
+ exit;
+}
+$canSync = userCan('sales.sync.manage');
+
+/* ============================================================
+ * AJAX
+ * ============================================================ */
+
+$action = $_GET['action'] ?? ($_POST['action'] ?? '');
+
+if ($action === 'sync' && $_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!$canSync) {
+ MagoSalesStats::jsonOut(['ok' => false, 'message' => 'Non hai i permessi per sincronizzare i dati da Mago']);
+ }
+ set_time_limit(600);
+ try {
+ $mode = $_POST['mode'] ?? 'incremental';
+ $opts = [];
+ if ($mode === 'test') $opts['limit'] = max(1, min(500, (int)($_POST['limit'] ?? 50)));
+ if ($mode === 'full') $opts['full'] = true;
+
+ $sync = new MagoSync($pdo, MagoClient::fromEnv());
+ $report = $sync->run($opts);
+ $report['info'] = MagoSalesStats::syncInfo($pdo);
+ MagoSalesStats::jsonOut($report);
+ } catch (Throwable $e) {
+ MagoSalesStats::jsonOut(['ok' => false, 'message' => $e->getMessage()]);
+ }
+}
+
+if ($action === 'filters') {
+ MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo));
+}
+
+if ($action === 'data') {
+ try {
+ [$where, $params] = MagoSalesStats::buildWhere();
+ $from = MagoSalesStats::baseFrom();
+
+ // Metrica dei grafici: numero ordini oppure valore (imponibile, annullati esclusi)
+ $metric = (($_GET['metric'] ?? '') === 'value') ? 'value' : 'count';
+ $m = $metric === 'value' ? "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END" : "1";
+ $agg = "SUM($m)";
+ $val = "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END";
+
+ // --- KPI ---
+ $kpi = MagoSalesStats::fetchAllQ($pdo, "
+ SELECT
+ COUNT(*) AS orders,
+ SUM(x.status = 'aperto') AS open_orders,
+ SUM(x.status = 'evaso') AS delivered,
+ SUM(x.status = 'annullato') AS cancelled,
+ SUM(x.status = 'bloccato') AS blocked,
+ SUM(x.status IN ('aperto','bloccato') AND x.due_date < CURDATE()) AS late,
+ SUM(x.delivered = 1 AND x.invoiced = 0 AND x.cancelled = 0) AS to_invoice,
+ COUNT(DISTINCT x.customer) AS customers,
+ AVG(CASE WHEN x.status <> 'annullato' AND x.due_date IS NOT NULL AND x.due_date >= x.order_date
+ THEN DATEDIFF(x.due_date, x.order_date) END) AS avg_lead_days,
+ COALESCE(SUM($val), 0) AS value_total,
+ COALESCE(SUM(CASE WHEN x.status IN ('aperto','bloccato') THEN x.taxable_amount ELSE 0 END), 0) AS value_open,
+ COALESCE(SUM(CASE WHEN x.status IN ('aperto','bloccato') AND x.due_date < CURDATE() THEN x.taxable_amount ELSE 0 END), 0) AS value_late,
+ COALESCE(SUM(CASE WHEN x.delivered = 1 AND x.invoiced = 0 AND x.cancelled = 0 THEN x.taxable_amount ELSE 0 END), 0) AS value_to_invoice,
+ AVG(CASE WHEN x.status <> 'annullato' THEN x.taxable_amount END) AS value_avg,
+ SUM(x.summary_tb_modified IS NULL) AS missing_amounts
+ FROM $from
+ WHERE $where
+ ", $params)[0];
+
+ // --- Confronto con lo STESSO PERIODO dell'anno precedente ---
+ $kpi['prev_orders'] = null;
+ $kpi['prev_value'] = null;
+ $fromD = $_GET['from'] ?? '';
+ $toD = $_GET['to'] ?? '';
+ if (MagoSalesStats::isValidDate($fromD) && MagoSalesStats::isValidDate($toD) && $fromD <= $toD) {
+ $prevFrom = date('Y-m-d', strtotime($fromD . ' -1 year'));
+ $prevTo = date('Y-m-d', strtotime($toD . ' -1 year'));
+ [$pw, $pp] = MagoSalesStats::buildWhere(['from' => $prevFrom, 'to' => $prevTo]);
+ $prev = MagoSalesStats::fetchAllQ($pdo, "SELECT COUNT(*) AS n, COALESCE(SUM($val), 0) AS v FROM $from WHERE $pw", $pp)[0];
+ $kpi['prev_orders'] = (int)$prev['n'];
+ $kpi['prev_value'] = (float)$prev['v'];
+ $kpi['prev_from'] = date('d/m/Y', strtotime($prevFrom));
+ $kpi['prev_to'] = date('d/m/Y', strtotime($prevTo));
+ }
+
+ // --- Andamento mensile per stato ---
+ $monthly = MagoSalesStats::fetchAllQ($pdo, "
+ SELECT DATE_FORMAT(x.order_date, '%Y-%m') AS ym,
+ SUM(CASE WHEN x.status = 'aperto' THEN $m ELSE 0 END) AS aperto,
+ SUM(CASE WHEN x.status = 'bloccato' THEN $m ELSE 0 END) AS bloccato,
+ SUM(CASE WHEN x.status = 'evaso' THEN $m ELSE 0 END) AS evaso,
+ SUM(CASE WHEN x.status = 'annullato' THEN " . ($metric === 'value' ? '0' : '1') . " ELSE 0 END) AS annullato,
+ $agg AS totale
+ FROM $from
+ WHERE $where AND x.order_date IS NOT NULL
+ GROUP BY ym
+ ORDER BY ym
+ ", $params);
+
+ // --- Top clienti ---
+ $topCustomers = MagoSalesStats::fetchAllQ($pdo, "
+ SELECT x.customer AS code, x.company_name AS name,
+ $agg AS n,
+ SUM(CASE WHEN x.status IN ('aperto','bloccato') THEN $m ELSE 0 END) AS open_n
+ FROM $from
+ WHERE $where
+ GROUP BY x.customer, x.company_name
+ ORDER BY n DESC
+ LIMIT 10
+ ", $params);
+
+ // --- Per area / agente ---
+ $byArea = MagoSalesStats::fetchAllQ($pdo, "
+ SELECT CASE WHEN x.area = '' THEN '(nessuna)' ELSE x.area END AS label, x.area AS code, $agg AS n
+ FROM $from WHERE $where
+ GROUP BY x.area ORDER BY n DESC LIMIT 12
+ ", $params);
+
+ $bySalesperson = MagoSalesStats::fetchAllQ($pdo, "
+ SELECT CASE WHEN x.salesperson = '' THEN '(nessuno)' ELSE x.salesperson END AS label, x.salesperson AS code, $agg AS n
+ FROM $from WHERE $where
+ GROUP BY x.salesperson ORDER BY n DESC LIMIT 12
+ ", $params);
+
+ // --- Scadenze ordini aperti ---
+ $dueBuckets = MagoSalesStats::fetchAllQ($pdo, "
+ SELECT bucket, SUM(mv) AS n FROM (
+ SELECT CASE
+ WHEN x.due_date IS NULL THEN '6_senza'
+ WHEN x.due_date < CURDATE() THEN '1_scaduti'
+ WHEN YEARWEEK(x.due_date, 3) = YEARWEEK(CURDATE(), 3) THEN '2_settimana'
+ WHEN YEARWEEK(x.due_date, 3) = YEARWEEK(CURDATE() + INTERVAL 7 DAY, 3) THEN '3_prossima'
+ WHEN x.due_date <= CURDATE() + INTERVAL 30 DAY THEN '4_30gg'
+ ELSE '5_oltre'
+ END AS bucket,
+ $m AS mv
+ FROM $from
+ WHERE $where AND x.status IN ('aperto','bloccato')
+ ) b
+ GROUP BY bucket ORDER BY bucket
+ ", $params);
+
+ // --- Elenco ordini ---
+ $rows = MagoSalesStats::fetchAllQ($pdo, "
+ SELECT x.sale_ord_id, x.internal_ord_no, x.external_ord_no,
+ x.order_date, x.due_date, x.customer, x.company_name,
+ x.salesperson, x.area, x.status, x.invoiced, x.your_reference,
+ x.taxable_amount, x.total_amount,
+ (x.summary_tb_modified IS NULL) AS no_amount,
+ (x.status IN ('aperto','bloccato') AND x.due_date < CURDATE()) AS is_late
+ FROM $from
+ WHERE $where
+ ORDER BY x.order_date DESC, x.sale_ord_id DESC
+ LIMIT 5000
+ ", $params);
+
+ MagoSalesStats::jsonOut([
+ 'success' => true,
+ 'metric' => $metric,
+ 'kpi' => $kpi,
+ 'monthly' => $monthly,
+ 'top_customers' => $topCustomers,
+ 'by_area' => $byArea,
+ 'by_salesperson' => $bySalesperson,
+ 'due_buckets' => $dueBuckets,
+ 'rows' => $rows,
+ ]);
+ } catch (Throwable $e) {
+ MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]);
+ }
+}
+
+if ($action === 'order') {
+ try {
+ $id = (int)($_GET['id'] ?? 0);
+ $st = $pdo->prepare("SELECT x.* FROM " . MagoSalesStats::baseFrom() . " WHERE x.sale_ord_id = ?");
+ $st->execute([$id]);
+ $o = $st->fetch(PDO::FETCH_ASSOC);
+ if (!$o) MagoSalesStats::jsonOut(['success' => false, 'message' => 'Ordine non trovato']);
+
+ $raw = json_decode((string)$o['raw_json'], true) ?: [];
+ unset($o['raw_json']);
+
+ MagoSalesStats::jsonOut([
+ 'success' => true,
+ 'order' => $o,
+ 'extra' => [
+ 'Cliente fatturazione' => $raw['InvoicingCustomer'] ?? '',
+ 'Pagamento' => $raw['Payment'] ?? '',
+ 'Listino' => $raw['PriceList'] ?? '',
+ 'Trasporto' => $raw['Transport'] ?? '',
+ 'Porto' => $raw['Port'] ?? '',
+ 'Imballo' => $raw['Package'] ?? '',
+ 'Magazzino' => $raw['StoragePhase1'] ?? '',
+ 'Nostro riferimento' => $raw['OurReference'] ?? '',
+ 'Vostro riferimento' => $raw['YourReference'] ?? '',
+ 'Commessa' => $raw['Job'] ?? '',
+ 'Stampato' => !empty($raw['Printed']) ? 'Sì' : 'No',
+ 'Fatturato' => !empty($raw['Invoiced']) ? 'Sì' : 'No',
+ 'Consegna tassativa' => MagoClient::toMysqlDate($raw['CompulsoryDeliveryDate'] ?? null),
+ 'Creato in Mago' => MagoClient::toMysqlDate($raw['TbCreated'] ?? null, true),
+ 'Modificato in Mago' => MagoClient::toMysqlDate($raw['TbModified'] ?? null, true),
+ ],
+ ]);
+ } catch (Throwable $e) {
+ MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]);
+ }
+}
+
+?>
+
+
+
+
+
+
+
+
+ Statistiche Ordini Cliente - = htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Filtri 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📦
+
Nessun ordine sincronizzato
+
Esegui una prima sincronizzazione di prova per importare alcuni ordini da Mago.
+
+
+
+
Chiedi a un amministratore di avviare la sincronizzazione.
+
+
+
+
+
+
+
+
+
+
Valore ordinato
+
—
+
+
+
+
+
+
+
In ritardo
+
—
+
consegna prevista superata
+
+
+
+
+
Da fatturare
+
—
+
evasi non fatturati
+
+
+
+
Clienti attivi
+
—
+
+
+
+
+
+
+
+
Analisi
+
+
+
+
+
+
+
+
+
+
+
+
+
Andamento mensile ordini
+
Clicca un mese per filtrarlo
+
+
+
+
+
+
+
+
Ripartizione per stato
+
Clicca una fetta per filtrare
+
+
+
+
+
+
+
+
+
Top 10 clienti per numero ordini
+
Clicca un cliente per vedere solo i suoi ordini
+
+
+
+
+
+
Scadenze ordini aperti
+
In base alla data di consegna prevista
+
+
+
+
+
+
+
+
+
+
+
Elenco ordini
+
Clicca una riga per il dettaglio
+
+
+
+
+
+ | N° ordine |
+ Data |
+ Cliente |
+ Rif. cliente |
+ Consegna prev. |
+ Imponibile |
+ Stato |
+ Fatt. |
+ Agente |
+ Area |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/userarea/sales_orders_years.php b/public/userarea/sales_orders_years.php
new file mode 100644
index 00000000..b789e730
--- /dev/null
+++ b/public/userarea/sales_orders_years.php
@@ -0,0 +1,1273 @@
+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 '';
+ exit;
+}
+
+/* ============================================================
+ * AJAX
+ * ============================================================ */
+
+$action = $_GET['action'] ?? '';
+
+if ($action === 'filters') {
+ MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo));
+}
+
+if ($action === 'data') {
+ try {
+ $from = MagoSalesStats::baseFrom();
+
+ // Filtri cliente / agente / area (le date qui non si usano: si ragiona per anni)
+ [$where, $params] = MagoSalesStats::buildWhere(['from' => '', 'to' => '']);
+
+ // "A pari data": ogni anno considerato solo fino al giorno/mese di oggi
+ $ytd = !empty($_GET['ytd']);
+ $ytdCond = $ytd ? " AND DATE_FORMAT(x.order_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d')" : '';
+
+ // Metrica: numero ordini o valore (imponibile); gli annullati sono sempre esclusi
+ $metric = (($_GET['metric'] ?? '') === 'value') ? 'value' : 'count';
+ $m = $metric === 'value'
+ ? "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END"
+ : "CASE WHEN x.status <> 'annullato' THEN 1 ELSE 0 END";
+
+ // --- Riepilogo per TUTTI gli anni (serve anche l'anno prima del primo selezionato per la variazione) ---
+ $summary = MagoSalesStats::fetchAllQ($pdo, "
+ SELECT YEAR(x.order_date) AS y,
+ SUM(x.status <> 'annullato') AS orders,
+ COALESCE(SUM(CASE WHEN x.status <> 'annullato' THEN x.taxable_amount END), 0) AS value,
+ COUNT(DISTINCT CASE WHEN x.status <> 'annullato' THEN x.customer END) AS customers,
+ SUM(x.status = 'annullato') AS cancelled,
+ SUM(x.summary_tb_modified IS NULL) AS missing_amounts
+ FROM $from
+ WHERE $where AND x.order_date IS NOT NULL $ytdCond
+ GROUP BY y
+ ORDER BY y
+ ", $params);
+
+ $available = array_map(function ($r) {
+ return (int)$r['y'];
+ }, $summary);
+
+ // Anni scelti (validati); default: gli ultimi 3 disponibili
+ $years = array_values(array_intersect(array_map('intval', MagoSalesStats::arrParam('years')), $available));
+ if (!$years) $years = array_slice($available, -3);
+ sort($years);
+
+ $monthly = [];
+ $customers = [];
+
+ if ($years) {
+ $ph = [];
+ $yp = $params;
+ foreach ($years as $i => $y) {
+ $ph[] = ':yy' . $i;
+ $yp['yy' . $i] = $y;
+ }
+ $yearIn = 'YEAR(x.order_date) IN (' . implode(',', $ph) . ')';
+
+ // --- Mensile per anno ---
+ $monthly = MagoSalesStats::fetchAllQ($pdo, "
+ SELECT YEAR(x.order_date) AS y, MONTH(x.order_date) AS m, SUM($m) AS v
+ FROM $from
+ WHERE $where AND $yearIn $ytdCond
+ GROUP BY y, m
+ ORDER BY y, m
+ ", $yp);
+
+ // --- Clienti per anno ---
+ $rows = MagoSalesStats::fetchAllQ($pdo, "
+ SELECT x.customer AS code, x.company_name AS name, YEAR(x.order_date) AS y, SUM($m) AS v
+ FROM $from
+ WHERE $where AND $yearIn $ytdCond AND x.customer <> ''
+ GROUP BY x.customer, x.company_name, y
+ ", $yp);
+
+ foreach ($rows as $r) {
+ $k = $r['code'];
+ if (!isset($customers[$k])) {
+ $customers[$k] = ['code' => $r['code'], 'name' => $r['name'], 'v' => []];
+ }
+ $customers[$k]['v'][(int)$r['y']] = (float)$r['v'];
+ }
+ $customers = array_values($customers);
+ }
+
+ MagoSalesStats::jsonOut([
+ 'success' => true,
+ 'metric' => $metric,
+ 'ytd' => $ytd,
+ 'today' => date('Y-m-d'),
+ 'available' => $available,
+ 'years' => $years,
+ 'summary' => $summary,
+ 'monthly' => $monthly,
+ 'customers' => $customers,
+ ]);
+ } catch (Throwable $e) {
+ MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]);
+ }
+}
+
+?>
+
+
+
+
+
+
+
+
+ Confronto anni - = htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📊
+
Nessun dato da confrontare
+
Sincronizza gli ordini da Mago dalla pagina Andamento ordini.
+
+
+
+
+
+
+
+
+
+
+
+
+
Andamento mensile
+
Stagionalità: un colore per anno
+
+
+
+
+
+
Progressivo dell'anno
+
Somma da gennaio: dice se si è avanti o indietro rispetto agli anni scorsi
+
+
+
+
+
+
+
+
+
+
Clienti a confronto
+
Clicca un cliente per filtrare tutta la pagina
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file