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'; const ENTITY_DETAILS = 'sale_ord_details'; const ENTITY_DOCS = 'sale_doc'; const ENTITY_DOC_SUMM = 'sale_doc_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['righe'] = $this->syncSaleOrderDetails($from, $full, (bool)$limit); $report['importi'] = $this->syncSaleOrderSummaries($from, $full, (bool)$limit); // Documenti di vendita (fatture / note di credito) per il fatturato if ($this->tableExists('mago_sale_doc')) { $report['documenti'] = $this->syncSaleDocs($from, $limit, $full); $report['importi_documenti'] = $this->syncSaleDocSummaries($from, $full, (bool)$limit); } else { $report['documenti'] = 'tabella mago_sale_doc mancante: eseguire la migrazione Phinx'; $this->log('Documenti: tabella mago_sale_doc mancante, salto (eseguire la migrazione)'); } // Coda "Nuovi da Mago" per la programmazione produzione (solo dati locali, nessuna chiamata a Mago) if ($this->tableExists('mago_prod_queue')) { require_once __DIR__ . '/MagoProduction.php'; $q = MagoProduction::refreshQueue($this->pdo); $report['coda_produzione'] = $q; $this->log("Coda produzione: {$q['nuove']} nuove, {$q['aggiornate']} aggiornate, {$q['chiuse']} chiuse, {$q['modificate']} modificate dopo l'import"); } } 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), ]; } /* ========================================================= * RIGHE ORDINE (MA_SaleOrdDetails → mago_sale_ord_detail) * ========================================================= */ /** * Le righe hanno OrderDate, quindi si filtrano come le testate. * Vengono salvate solo le righe di ordini già presenti in locale. * Nelle letture complete le righe degli ordini del periodo vengono * prima cancellate, così spariscono anche le righe eliminate in Mago. */ public function syncSaleOrderDetails($from, $full = false, $testMode = false) { $t0 = microtime(true); $state = $this->getState(self::ENTITY_DETAILS); // ordini locali del periodo (per scartare righe di ordini non sincronizzati) $st = $this->pdo->prepare("SELECT sale_ord_id FROM mago_sale_ord WHERE order_date >= ?"); $st->execute([$from]); $localIds = array_flip(array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN))); if (!$localIds) { return ['records' => 0, 'salvate' => 0, 'incrementale' => false, 'ms' => $this->ms($t0)]; } $incremental = !$full && !$testMode && !empty($state['last_tb_modified']); $filters = [['key' => 'OrderDate', 'value' => $from . 'T00:00:00', 'operator' => '>=']]; if ($incremental) { $filters[] = [ 'key' => 'TbModified', 'value' => date('Y-m-d\TH:i:s', strtotime($state['last_tb_modified'])), 'operator' => '>=', ]; } if ($testMode) { // prova: leggo solo le righe nel periodo degli ordini importati $range = $this->pdo->query("SELECT MIN(order_date), MAX(order_date) FROM mago_sale_ord WHERE order_date >= " . $this->pdo->quote($from))->fetch(PDO::FETCH_NUM); $filters = [ ['key' => 'OrderDate', 'value' => $range[0] . 'T00:00:00', 'operator' => '>='], ['key' => 'OrderDate', 'value' => $range[1] . 'T00:00:00', 'operator' => '<='], ]; } $this->log('Righe: ' . ($incremental ? 'incrementale da ' . $state['last_tb_modified'] : 'completa dal ' . $from)); $stmt = $this->pdo->prepare(" INSERT INTO mago_sale_ord_detail ( sale_ord_id, sub_id, line, position, line_type, item, description, uom, qty, delivered_qty, invoiced_qty, unit_value, discount_amount, taxable_amount, total_amount, expected_delivery_date, confirmed_delivery_date, delivered, invoiced, cancelled, customer, order_date, internal_ord_no, notes, matrice_misura, cod_art_cli, tb_modified, synced_at ) VALUES ( :sale_ord_id, :sub_id, :line, :position, :line_type, :item, :description, :uom, :qty, :delivered_qty, :invoiced_qty, :unit_value, :discount_amount, :taxable_amount, :total_amount, :expected, :confirmed, :delivered, :invoiced, :cancelled, :customer, :order_date, :internal_ord_no, :notes, :matrice_misura, :cod_art_cli, :tb_modified, NOW() ) ON DUPLICATE KEY UPDATE line = VALUES(line), position = VALUES(position), line_type = VALUES(line_type), item = VALUES(item), description = VALUES(description), uom = VALUES(uom), qty = VALUES(qty), delivered_qty = VALUES(delivered_qty), invoiced_qty = VALUES(invoiced_qty), unit_value = VALUES(unit_value), discount_amount = VALUES(discount_amount), taxable_amount = VALUES(taxable_amount), total_amount = VALUES(total_amount), expected_delivery_date = VALUES(expected_delivery_date), confirmed_delivery_date = VALUES(confirmed_delivery_date), delivered = VALUES(delivered), invoiced = VALUES(invoiced), cancelled = VALUES(cancelled), customer = VALUES(customer), order_date = VALUES(order_date), internal_ord_no = VALUES(internal_ord_no), notes = VALUES(notes), matrice_misura = VALUES(matrice_misura), cod_art_cli = VALUES(cod_art_cli), tb_modified = VALUES(tb_modified), synced_at = NOW() "); $read = 0; $saved = 0; $maxModified = $state['last_tb_modified'] ?? null; $this->pdo->beginTransaction(); try { if (!$incremental) { // lettura completa: riparto pulito per gli ordini del periodo $this->pdo->prepare(" DELETE d FROM mago_sale_ord_detail d JOIN mago_sale_ord o ON o.sale_ord_id = d.sale_ord_id WHERE o.order_date >= ? ")->execute([$from]); } foreach ($this->mago->iterate('MA_SaleOrdDetails', ['filters' => $filters]) as $r) { $read++; $id = (int)($r['SaleOrdId'] ?? 0); $mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true); if ($mod && (!$maxModified || $mod > $maxModified)) $maxModified = $mod; if (!isset($localIds[$id])) continue; $s = function ($k, $len) use ($r) { return mb_substr(trim((string)($r[$k] ?? '')), 0, $len); }; $d = function ($k, $dec = 2) use ($r) { return round(MagoClient::toDecimal($r[$k] ?? 0), $dec); }; $stmt->execute([ 'sale_ord_id' => $id, 'sub_id' => (int)($r['SubId'] ?? $r['Line'] ?? 0), 'line' => (int)($r['Line'] ?? 0), 'position' => (int)($r['Position'] ?? 0), 'line_type' => (int)($r['LineType'] ?? 0), 'item' => $s('Item', 30), 'description' => $s('Description', 255), 'uom' => $s('UoM', 10), 'qty' => $d('Qty', 3), 'delivered_qty' => $d('DeliveredQty', 3), 'invoiced_qty' => $d('InvoicedQty', 3), 'unit_value' => $d('UnitValue', 5), 'discount_amount' => $d('DiscountAmount'), 'taxable_amount' => $d('TaxableAmount'), 'total_amount' => $d('TotalAmount'), 'expected' => MagoClient::toMysqlDate($r['ExpectedDeliveryDate'] ?? null), 'confirmed' => MagoClient::toMysqlDate($r['ConfirmedDeliveryDate'] ?? null), 'delivered' => MagoClient::toBool($r['Delivered'] ?? false), 'invoiced' => MagoClient::toBool($r['Invoiced'] ?? false), 'cancelled' => MagoClient::toBool($r['Cancelled'] ?? false), 'customer' => $s('Customer', 20), 'order_date' => MagoClient::toMysqlDate($r['OrderDate'] ?? null), 'internal_ord_no' => $s('InternalOrdNo', 30), 'matrice_misura' => $s('Department', 30), // colonna "Matrice/Misura" in Mago (es. M00786S, D 3,3) 'cod_art_cli' => $s('ReferenceDocNo', 60), // colonna "Cod.Art.Cli" in Mago 'notes' => trim((string)($r['Notes'] ?? '')) ?: null, 'tb_modified' => $mod, ]); $saved++; if ($read % 200 === 0) { $this->pdo->commit(); $this->pdo->beginTransaction(); $this->log(" righe lette: $read"); } } $this->refreshLineDates($from); $this->pdo->commit(); } catch (Throwable $e) { if ($this->pdo->inTransaction()) $this->pdo->rollBack(); $this->saveState(self::ENTITY_DETAILS, null, 'errore', $e->getMessage(), $read, $t0, false); if (!$incremental) { // lettura completa interrotta: al prossimo giro si riparte da capo $this->pdo->prepare("UPDATE mago_sync_state SET last_tb_modified = NULL WHERE entity = ?") ->execute([self::ENTITY_DETAILS]); } throw $e; } $this->saveState(self::ENTITY_DETAILS, $testMode ? null : $maxModified, 'ok', "Lette $read righe, salvate $saved", $read, $t0, !$testMode); $this->log("Righe: lette $read, salvate $saved"); return ['records' => $read, 'salvate' => $saved, 'incrementale' => $incremental, 'ms' => $this->ms($t0)]; } /** * Riporta sulla testata la prima data di consegna delle righe ancora da consegnare * (se tutte consegnate: la prima data tra tutte le righe). * La data confermata vale solo se successiva alla data ordine. */ private function refreshLineDates($from) { $this->pdo->prepare(" UPDATE mago_sale_ord o LEFT JOIN ( SELECT sale_ord_id, COALESCE( MIN(CASE WHEN delivered = 0 AND cancelled = 0 THEN expected_delivery_date END), MIN(expected_delivery_date) ) AS exp_date, COALESCE( MIN(CASE WHEN delivered = 0 AND cancelled = 0 AND confirmed_delivery_date > order_date THEN confirmed_delivery_date END), MIN(CASE WHEN confirmed_delivery_date > order_date THEN confirmed_delivery_date END) ) AS conf_date, COUNT(*) AS n FROM mago_sale_ord_detail WHERE item <> '' OR qty <> 0 GROUP BY sale_ord_id ) d ON d.sale_ord_id = o.sale_ord_id SET o.line_expected_date = d.exp_date, o.line_confirmed_date = d.conf_date, o.lines_count = COALESCE(d.n, 0) WHERE o.order_date >= ? ")->execute([$from]); } /* ========================================================= * 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)]; } /* ========================================================= * DOCUMENTI DI VENDITA (MA_SaleDoc → mago_sale_doc) per il fatturato * ========================================================= */ /** * Testate documenti (tutti i tipi: fatture, note di credito, DDT...), filtrate per DocumentDate. * Lettura completa: alla fine cancella in locale i documenti del periodo non più presenti in Mago. */ public function syncSaleDocs($from, $limit = null, $full = false) { $t0 = microtime(true); $state = $this->getState(self::ENTITY_DOCS); $filters = [['key' => 'DocumentDate', 'value' => $from . 'T00:00:00', 'operator' => '>=']]; $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' => '>=', ]; } // solo i campi che servono (la testata ha ~200 campi: così è molto più veloce) $fields = [ 'SaleDocId', 'DocumentType', 'DocNo', 'DocumentDate', 'PostingDate', 'CustSupp', 'InvoicingCustomer', 'Salesperson', 'Area', 'Currency', 'TaxJournal', 'InvRsn', 'EIDocumentType', 'Issued', 'Cancelled', 'PostedToAccounting', 'TbCreated', 'TbModified' ]; $this->log('Documenti: ' . ($incremental ? 'incrementale da ' . $state['last_tb_modified'] : 'completa dal ' . $from) . ($limit ? " (prova: max $limit)" : '')); $stmt = $this->pdo->prepare(" INSERT INTO mago_sale_doc ( sale_doc_id, document_type, doc_no, document_date, posting_date, customer, invoicing_customer, salesperson, area, currency, tax_journal, inv_rsn, ei_document_type, issued, cancelled, posted_to_accounting, tb_created, tb_modified, synced_at ) VALUES ( :id, :type, :doc_no, :doc_date, :posting_date, :customer, :inv_customer, :salesperson, :area, :currency, :tax_journal, :inv_rsn, :ei_type, :issued, :cancelled, :posted, :tb_created, :tb_modified, NOW() ) ON DUPLICATE KEY UPDATE document_type = VALUES(document_type), doc_no = VALUES(doc_no), document_date = VALUES(document_date), posting_date = VALUES(posting_date), customer = VALUES(customer), invoicing_customer = VALUES(invoicing_customer), salesperson = VALUES(salesperson), area = VALUES(area), currency = VALUES(currency), tax_journal = VALUES(tax_journal), inv_rsn = VALUES(inv_rsn), ei_document_type = VALUES(ei_document_type), issued = VALUES(issued), cancelled = VALUES(cancelled), posted_to_accounting = VALUES(posted_to_accounting), tb_created = VALUES(tb_created), tb_modified = VALUES(tb_modified), synced_at = NOW() "); $count = 0; $seen = []; $maxModified = $state['last_tb_modified'] ?? null; $this->pdo->beginTransaction(); try { foreach ($this->mago->iterate('MA_SaleDoc', ['filters' => $filters, 'fields' => $fields]) as $r) { $s = function ($k, $len) use ($r) { return mb_substr(trim((string)($r[$k] ?? '')), 0, $len); }; $id = (int)($r['SaleDocId'] ?? 0); $mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true); $stmt->execute([ 'id' => $id, 'type' => (int)($r['DocumentType'] ?? 0), 'doc_no' => $s('DocNo', 30), 'doc_date' => MagoClient::toMysqlDate($r['DocumentDate'] ?? null), 'posting_date' => MagoClient::toMysqlDate($r['PostingDate'] ?? null), 'customer' => $s('CustSupp', 20), 'inv_customer' => $s('InvoicingCustomer', 20), 'salesperson' => $s('Salesperson', 20), 'area' => $s('Area', 20), 'currency' => $s('Currency', 8), 'tax_journal' => $s('TaxJournal', 20), 'inv_rsn' => $s('InvRsn', 20), 'ei_type' => (int)($r['EIDocumentType'] ?? 0), 'issued' => MagoClient::toBool($r['Issued'] ?? false), 'cancelled' => MagoClient::toBool($r['Cancelled'] ?? false), 'posted' => MagoClient::toBool($r['PostedToAccounting'] ?? false), 'tb_created' => MagoClient::toMysqlDate($r['TbCreated'] ?? null, true), 'tb_modified' => $mod, ]); $count++; $seen[$id] = true; if ($mod && (!$maxModified || $mod > $maxModified)) $maxModified = $mod; if ($count % 200 === 0) { $this->pdo->commit(); $this->pdo->beginTransaction(); $this->log(" documenti salvati: $count"); } if ($limit && $count >= $limit) break; } // lettura completa riuscita: tolgo i documenti del periodo cancellati in Mago $deleted = 0; if (!$incremental && !$limit) { $st = $this->pdo->prepare("SELECT sale_doc_id FROM mago_sale_doc WHERE document_date >= ?"); $st->execute([$from]); $del = $this->pdo->prepare("DELETE FROM mago_sale_doc WHERE sale_doc_id = ?"); foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $id) { if (!isset($seen[(int)$id])) { $del->execute([$id]); $deleted++; } } } $this->pdo->commit(); } catch (Throwable $e) { if ($this->pdo->inTransaction()) $this->pdo->rollBack(); $this->saveState(self::ENTITY_DOCS, null, 'errore', $e->getMessage(), $count, $t0, false); throw $e; } $msg = ($limit ? "Prova: $count documenti" : ($incremental ? "Incrementale: $count documenti" : "Completa: $count documenti")) . ($deleted ? ", $deleted cancellati" : ''); $this->saveState(self::ENTITY_DOCS, $limit ? null : $maxModified, 'ok', $msg, $count, $t0, !$limit); $this->log("Documenti sincronizzati: $count" . ($deleted ? " ($deleted non più presenti in Mago, cancellati)" : '')); return ['records' => $count, 'cancellati' => $deleted, 'incrementale' => $incremental, 'ms' => $this->ms($t0)]; } /** * Importi dei documenti (MA_SaleDocSummary). Come per gli ordini si filtra per TbModified * partendo dalla creazione del primo documento senza importo (o di tutti, in lettura completa). */ public function syncSaleDocSummaries($from, $full = false, $testMode = false) { $t0 = microtime(true); $state = $this->getState(self::ENTITY_DOC_SUMM); $info = $this->pdo->query( " SELECT MIN(COALESCE(tb_created, document_date)) AS first_created, MIN(CASE WHEN summary_tb_modified IS NULL THEN COALESCE(tb_created, document_date) END) AS first_missing, COUNT(*) AS n FROM mago_sale_doc WHERE document_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: dal punto di ripresa, ma se ci sono documenti nuovi senza importo parto dalla loro creazione $incremental = !$full && !$testMode && !empty($state['last_tb_modified']); if ($incremental) { $since = $state['last_tb_modified']; if (!empty($info['first_missing'])) { $miss = date('Y-m-d 00:00:00', strtotime($info['first_missing'] . ' -1 day')); if ($miss < $since) $since = $miss; } } else { $since = date('Y-m-d 00:00:00', strtotime($info['first_created'] . ' -1 day')); } $this->log('Importi documenti: ' . ($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_doc SET taxable_amount = :taxable, tax_amount = :tax, total_amount = :total, summary_tb_modified = :mod WHERE sale_doc_id = :id "); $read = 0; $updated = 0; $maxModified = $state['last_tb_modified'] ?? null; $this->pdo->beginTransaction(); try { foreach ( $this->mago->iterate('MA_SaleDocSummary', [ 'filters' => $filters, 'fields' => ['SaleDocId', 'TaxableAmount', 'TaxAmount', 'TotalAmount', 'TbModified'], ]) as $r ) { $read++; $mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true); $stmt->execute([ 'taxable' => round(MagoClient::toDecimal($r['TaxableAmount'] ?? 0), 2), 'tax' => round(MagoClient::toDecimal($r['TaxAmount'] ?? 0), 2), 'total' => round(MagoClient::toDecimal($r['TotalAmount'] ?? 0), 2), 'mod' => $mod ?: date('Y-m-d H:i:s'), 'id' => (int)($r['SaleDocId'] ?? 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 documenti letti: $read"); } } $this->pdo->commit(); } catch (Throwable $e) { if ($this->pdo->inTransaction()) $this->pdo->rollBack(); $this->saveState(self::ENTITY_DOC_SUMM, null, 'errore', $e->getMessage(), $read, $t0, false); throw $e; } $this->saveState(self::ENTITY_DOC_SUMM, $testMode ? null : $maxModified, 'ok', "Letti $read totali, $updated documenti aggiornati", $read, $t0, !$testMode); $this->log("Importi documenti: letti $read, documenti aggiornati $updated"); return ['records' => $read, 'aggiornati' => $updated, 'incrementale' => $incremental, 'ms' => $this->ms($t0)]; } private function tableExists($name) { $st = $this->pdo->prepare("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?"); $st->execute([$name]); return (int)$st->fetchColumn() > 0; } /* ========================================================= * 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); } } }