stats sales with mago
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Tabelle locali (copia in sola lettura) dei dati Mago4 per le statistiche ordini cliente.
|
||||
*
|
||||
* - mago_sale_ord : testate ordini cliente (MA_SaleOrd)
|
||||
* - mago_cust_supp : anagrafica clienti (MA_CustSupp, solo clienti)
|
||||
* - mago_sync_state : stato dell'ultima sincronizzazione per ogni entità
|
||||
*/
|
||||
final class CreateMagoSalesTables extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
// ---------- ORDINI CLIENTE (testate) ----------
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Importi degli ordini cliente da MA_SaleOrdSummary (Mago4).
|
||||
*/
|
||||
final class AddAmountsToMagoSaleOrd extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* MagoClient - client REST in SOLA LETTURA per Mago4 (Zucchetti) - ERPServiceProvider
|
||||
*
|
||||
* Uso tipico:
|
||||
* $mago = MagoClient::fromEnv();
|
||||
* foreach ($mago->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* MagoSalesStats - funzioni comuni alle pagine statistiche ordini cliente
|
||||
* (sales_orders_stats.php e sales_orders_years.php).
|
||||
* Legge SOLO dalle tabelle locali sincronizzate da Mago.
|
||||
*/
|
||||
class MagoSalesStats
|
||||
{
|
||||
const CUSTOMER_TYPE = 3211264;
|
||||
|
||||
/** Risposta JSON e fine script (pulisce l'output di headscript). */
|
||||
public static function jsonOut(array $data)
|
||||
{
|
||||
while (ob_get_level()) ob_end_clean();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function isValidDate($d)
|
||||
{
|
||||
return is_string($d) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) && strtotime($d) !== false;
|
||||
}
|
||||
|
||||
/** Parametro GET multiplo (es. customers[]) come array pulito. */
|
||||
public static function arrParam($key)
|
||||
{
|
||||
$v = $_GET[$key] ?? [];
|
||||
if (!is_array($v)) $v = [$v];
|
||||
return array_values(array_filter(array_map(function ($x) {
|
||||
return trim((string)$x);
|
||||
}, $v), function ($x) {
|
||||
return $x !== '';
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vista base: ordini + nome cliente + stato calcolato + data consegna di riferimento.
|
||||
* Le statistiche filtrano su questa vista (alias x).
|
||||
*/
|
||||
public static function baseFrom()
|
||||
{
|
||||
return "(
|
||||
SELECT
|
||||
o.*,
|
||||
COALESCE(NULLIF(c.company_name, ''), o.customer) AS company_name,
|
||||
-- Data consegna di riferimento: prevista; la 'confermata' solo se successiva all'ordine
|
||||
-- (in Mago spesso coincide con la data ordine e farebbe risultare tutto in ritardo)
|
||||
COALESCE(
|
||||
o.expected_delivery_date,
|
||||
CASE WHEN o.confirmed_delivery_date > 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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* MagoSync - sincronizza dati Mago4 → tabelle MySQL locali (Mago in SOLA LETTURA).
|
||||
*
|
||||
* $sync = new MagoSync($pdo, MagoClient::fromEnv());
|
||||
* $report = $sync->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,31 @@
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<?php if (userCan('sales.stats.view')) : ?>
|
||||
<li>
|
||||
<a href="javascript:;" class="has-arrow">
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-bar-chart-alt-2"></i>
|
||||
</div>
|
||||
<div class="menu-title">Vendite</div>
|
||||
</a>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="sales_orders_stats.php">
|
||||
<i class='bx bx-radio-circle'></i>Andamento ordini
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="sales_orders_years.php">
|
||||
<i class='bx bx-radio-circle'></i>Confronto anni
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<?php
|
||||
$canSeeServices =
|
||||
userCan('services.status.view')
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* mago_probe.php - SCRIPT DI TEST (sola lettura) per l'integrazione Mago4
|
||||
*
|
||||
* Da lanciare UNA volta dal browser, da utente loggato, con la VPN attiva.
|
||||
* Mostra: colonne reali delle tabelle, operatori dei filtri supportati,
|
||||
* collegamento testata/righe, filtro incrementale TbModified, logoff.
|
||||
*
|
||||
* Copia l'output e incollalo in chat. Poi questo file si può cancellare.
|
||||
*/
|
||||
|
||||
ob_start();
|
||||
ini_set('display_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
include('include/headscript.php'); // sessione/login della piattaforma
|
||||
require_once __DIR__ . '/class/MagoClient.php';
|
||||
|
||||
// Se dotenv non è già caricato da headscript, provo a caricarlo qui
|
||||
if (empty($_ENV['MAGO_BASE_URL']) && getenv('MAGO_BASE_URL') === false) {
|
||||
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
}
|
||||
if (class_exists('Dotenv\Dotenv')) {
|
||||
Dotenv\Dotenv::createImmutable(__DIR__)->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);
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* mago_sync_cli.php - Sincronizzazione Mago4 → MySQL da riga di comando (cron).
|
||||
*
|
||||
* Uso:
|
||||
* php mago_sync_cli.php incrementale (solo modificati, TbModified)
|
||||
* php mago_sync_cli.php --limit=50 PROVA: solo 50 ordini
|
||||
* php mago_sync_cli.php --full rilegge tutti gli ordini dal 2026-01-01
|
||||
* php mago_sync_cli.php --from=2026-06-01 --full
|
||||
*
|
||||
* Cron consigliato (ogni 30 minuti, nei giorni lavorativi 7-20):
|
||||
* *\/30 7-20 * * 1-5 php /percorso/progetto/mago_sync_cli.php >> /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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user