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); } } }