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 '

    Accesso non consentito

    Non hai i permessi per visualizzare questa pagina.

    Torna alla Dashboard
    '; + 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'); ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +
    +
    +
    + + +
    +
    +
    +
    +
    +
    Statistiche Ordini Cliente
    + Dati Mago4 +
    +
    + +
    + +
    + + + +
    + + +
    +
    +
    + +
    + + + + +
    +
    +
    + Filtri +
    +
    + + + + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    + + + + + + + +
    +
    +
    + + +
    +
    +
    + + + + +
    + +
    +
    +
    +
    Ordini
    +
    —
    +
     
    +
    +
    +
    +
    Valore ordinato
    +
    —
    +
     
    +
    +
    +
    +
    Da evadere
    +
    —
    +
     
    +
    +
    +
    +
    Aperti
    +
    —
    +
     
    +
    +
    +
    +
    In ritardo
    +
    —
    +
    consegna prevista superata
    +
    +
    +
    +
    Evasi
    +
    —
    +
     
    +
    +
    +
    +
    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
    +
    +
    +
    +
    + +
    +
    +
    +
    Ordini per area
    +
    +
    +
    +
    +
    +
    Ordini per agente
    +
    +
    +
    +
    + + +
    +
    +
    Elenco ordini
    +
    Clicca una riga per il dettaglio
    +
    +
    + + + + + + + + + + + + + + + + +
    N° ordineDataClienteRif. clienteConsegna prev.ImponibileStatoFatt.AgenteArea
    +
    +
    +
    + +
    +
    +
    +
    + + +
    + + + + +
    +
    +
    +
    Caricamento…
    +
    +
    + + + + + + \ 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 '

    Accesso non consentito

    Non hai i permessi per visualizzare questa pagina.

    Torna alla Dashboard
    '; + 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'); ?> + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +
    +
    +
    + + +
    +
    +
    +
    +
    +
    Statistiche Ordini Cliente
    + Dati Mago4 +
    +
    + +
    +
    + +
    + + + + +
    +
    +
    + +
    +
    +
    + +
    + + +
    +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + + +
    + + + +
    + + +
    +
    +
    +
    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