From 5e2ec11dbd1e3660e03003fce4686e0ba43ddf30 Mon Sep 17 00:00:00 2001 From: solocla Date: Wed, 14 Oct 2026 17:59:06 +0200 Subject: [PATCH] added fatturato --- .../20261014155524_create_mago_sale_doc.php | 49 ++++ public/userarea/class/MagoSalesStats.php | 33 +++ public/userarea/class/MagoSync.php | 244 ++++++++++++++++++ public/userarea/mago_probe_saledoc2_cli.php | 141 ++++++++++ public/userarea/mago_probe_saledoc_cli.php | 73 ++++++ public/userarea/sales_orders_years.php | 127 ++++++++- 6 files changed, 655 insertions(+), 12 deletions(-) create mode 100644 db/migrations/20261014155524_create_mago_sale_doc.php create mode 100644 public/userarea/mago_probe_saledoc2_cli.php create mode 100644 public/userarea/mago_probe_saledoc_cli.php diff --git a/db/migrations/20261014155524_create_mago_sale_doc.php b/db/migrations/20261014155524_create_mago_sale_doc.php new file mode 100644 index 00000000..3e4995bf --- /dev/null +++ b/db/migrations/20261014155524_create_mago_sale_doc.php @@ -0,0 +1,49 @@ +table('mago_sale_doc', [ + 'id' => false, + 'primary_key' => ['sale_doc_id'], + 'comment' => 'Copia locale MA_SaleDoc + MA_SaleDocSummary (Mago4) - sola lettura', + ]) + ->addColumn('sale_doc_id', 'integer', ['signed' => false]) + ->addColumn('document_type', 'integer', ['default' => 0, 'comment' => '3407874 fattura, 3407883 acconto, 3407876 nota credito, 3407873 DDT...']) + ->addColumn('doc_no', 'string', ['limit' => 30, 'default' => '']) + ->addColumn('document_date', 'date', ['null' => true]) + ->addColumn('posting_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', 'string', ['limit' => 20, 'default' => '']) + ->addColumn('currency', 'string', ['limit' => 8, 'default' => '']) + ->addColumn('tax_journal', 'string', ['limit' => 20, 'default' => '', 'comment' => 'Registro IVA: vuoto per DDT/proforma']) + ->addColumn('inv_rsn', 'string', ['limit' => 20, 'default' => '']) + ->addColumn('ei_document_type', 'integer', ['default' => 0]) + ->addColumn('issued', 'boolean', ['default' => false]) + ->addColumn('cancelled', 'boolean', ['default' => false]) + ->addColumn('posted_to_accounting', 'boolean', ['default' => false]) + ->addColumn('taxable_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'null' => true]) + ->addColumn('tax_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'null' => true]) + ->addColumn('total_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'null' => true]) + ->addColumn('summary_tb_modified', 'datetime', ['null' => true]) + ->addColumn('tb_created', 'datetime', ['null' => true]) + ->addColumn('tb_modified', 'datetime', ['null' => true]) + ->addColumn('synced_at', 'datetime', ['null' => true]) + ->addIndex(['document_date']) + ->addIndex(['document_type', 'document_date']) + ->addIndex(['customer']) + ->create(); + } +} diff --git a/public/userarea/class/MagoSalesStats.php b/public/userarea/class/MagoSalesStats.php index f1b149d4..b4788334 100644 --- a/public/userarea/class/MagoSalesStats.php +++ b/public/userarea/class/MagoSalesStats.php @@ -59,6 +59,39 @@ class MagoSalesStats return "DATE_ADD($z, INTERVAL (" . (int)self::ZIBO_WEEK_END_DAYS . " - WEEKDAY($z)) DAY)"; } + /** + * FATTURATO: tipi di documento di vendita che contano (verificati sui dati reali il 14/10/2026) + * e segno con cui contano. Esclusi: DDT 3407873, DDT non valorizzati 3407881, + * proforma 3407888, movimenti deposito 3407890. + */ + const INVOICE_TYPES = [ + 3407874 => 1, // Fattura (immediata / differita) serie A ... + 3407883 => 1, // Fattura d'acconto (TD02) serie B + 3407876 => -1, // Nota di credito (TD04) → in negativo serie D ... + ]; + + /** Vista fatture/note di credito (alias i) con imponibile già col segno giusto (revenue). */ + public static function invoiceFrom() + { + $types = implode(',', array_map('intval', array_keys(self::INVOICE_TYPES))); + $sign = 'CASE d.document_type'; + foreach (self::INVOICE_TYPES as $t => $sg) $sign .= ' WHEN ' . (int)$t . ' THEN ' . ((int)$sg < 0 ? -1 : 1); + $sign .= ' ELSE 0 END'; + + return "( + SELECT d.sale_doc_id, d.document_type, d.doc_no, d.document_date, + d.customer, d.salesperson, d.area, + COALESCE(NULLIF(c.company_name, ''), d.customer) AS company_name, + ($sign) * COALESCE(d.taxable_amount, 0) AS revenue, + ($sign) < 0 AS is_credit_note, + d.summary_tb_modified IS NULL AS no_amount + FROM mago_sale_doc d + LEFT JOIN mago_cust_supp c + ON c.cust_supp_type = " . self::CUSTOMER_TYPE . " AND c.cust_supp = d.customer + WHERE d.document_type IN ($types) AND d.cancelled = 0 + ) i"; + } + /** Scadenza di testata (usata solo per ordini senza righe). */ public static function headerDue($a) { diff --git a/public/userarea/class/MagoSync.php b/public/userarea/class/MagoSync.php index 2435f569..d2f814a8 100644 --- a/public/userarea/class/MagoSync.php +++ b/public/userarea/class/MagoSync.php @@ -21,6 +21,8 @@ class MagoSync 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; @@ -65,6 +67,15 @@ class MagoSync $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)'); + } } catch (Throwable $e) { $report['ok'] = false; $report['message'] = $e->getMessage(); @@ -525,6 +536,239 @@ class MagoSync 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) * ========================================================= */ diff --git a/public/userarea/mago_probe_saledoc2_cli.php b/public/userarea/mago_probe_saledoc2_cli.php new file mode 100644 index 00000000..eb9f7fa2 --- /dev/null +++ b/public/userarea/mago_probe_saledoc2_cli.php @@ -0,0 +1,141 @@ + probe_saledoc2.txt 2>&1 + */ + +if (PHP_SAPI !== 'cli') { + http_response_code(403); + exit('Solo da riga di comando'); +} + +set_time_limit(0); + +$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; + } + } +} +require_once __DIR__ . '/class/MagoClient.php'; + +$opts = getopt('', ['from::']); +$from = $opts['from'] ?? date('Y') . '-01-01'; + +$mago = MagoClient::fromEnv(); +try { + $mago->login(); + + // 1) tutte le testate del periodo, solo i campi utili + $fields = [ + 'SaleDocId', + 'DocumentType', + 'DocNo', + 'DocumentDate', + 'CustSupp', + 'TaxJournal', + 'InvoicingTaxJournal', + 'InvRsn', + 'ShippingReason', + 'EIDocumentType', + 'InvoiceFollows', + 'Issued', + 'Cancelled', + 'PostedToAccounting', + 'CorrectionDocument', + 'StubBook' + ]; + $docs = []; + $minId = PHP_INT_MAX; + foreach ( + $mago->iterate('MA_SaleDoc', [ + 'filters' => [['key' => 'DocumentDate', 'value' => $from, 'operator' => '>=']], + 'fields' => $fields, + ]) as $r + ) { + $id = (int)$r['SaleDocId']; + $docs[$id] = $r; + $minId = min($minId, $id); + } + echo "Documenti dal $from: " . count($docs) . PHP_EOL; + + // 2) importi: leggo i totali con SaleDocId >= il più piccolo del periodo + $tax = []; + if ($docs) { + foreach ( + $mago->iterate('MA_SaleDocSummary', [ + 'filters' => [['key' => 'SaleDocId', 'value' => (string)$minId, 'operator' => '>=']], + 'fields' => ['SaleDocId', 'TaxableAmount', 'TotalAmount'], + ]) as $r + ) { + $tax[(int)$r['SaleDocId']] = (float)$r['TaxableAmount']; + } + } + echo "Totali letti: " . count($tax) . PHP_EOL; + + // 3) riepilogo per tipo + $sum = []; + $add = function (&$arr, $v) { + $v = (string)$v; + if ($v === '') $v = '(vuoto)'; + $arr[$v] = ($arr[$v] ?? 0) + 1; + }; + foreach ($docs as $id => $r) { + $t = (string)$r['DocumentType']; + if (!isset($sum[$t])) $sum[$t] = [ + 'documenti' => 0, + 'imponibile' => 0.0, + 'senza_importo' => 0, + 'annullati' => 0, + 'in_contabilita' => 0, + 'TaxJournal' => [], + 'InvRsn' => [], + 'ShippingReason' => [], + 'EIDocumentType' => [], + 'StubBook' => [], + 'InvoiceFollows' => [], + 'esempi' => [] + ]; + $s = &$sum[$t]; + $s['documenti']++; + if (isset($tax[$id])) $s['imponibile'] += $tax[$id]; + else $s['senza_importo']++; + if (!empty($r['Cancelled'])) $s['annullati']++; + if (!empty($r['PostedToAccounting'])) $s['in_contabilita']++; + $add($s['TaxJournal'], $r['TaxJournal'] ?? ''); + $add($s['InvRsn'], $r['InvRsn'] ?? ''); + $add($s['ShippingReason'], $r['ShippingReason'] ?? ''); + $add($s['EIDocumentType'], $r['EIDocumentType'] ?? ''); + $add($s['StubBook'], $r['StubBook'] ?? ''); + $add($s['InvoiceFollows'], !empty($r['InvoiceFollows']) ? 'si' : 'no'); + if (count($s['esempi']) < 4) $s['esempi'][] = $r['DocNo'] . ' del ' . substr((string)$r['DocumentDate'], 0, 10) . ' cliente ' . $r['CustSupp'] . ' imponibile ' . ($tax[$id] ?? '?'); + unset($s); + } + ksort($sum); + foreach ($sum as &$s) { + $s['imponibile'] = round($s['imponibile'], 2); + foreach (['TaxJournal', 'InvRsn', 'ShippingReason', 'EIDocumentType', 'StubBook', 'InvoiceFollows'] as $k) arsort($s[$k]); + } + unset($s); + + echo PHP_EOL . '=== Riepilogo per DocumentType ===' . PHP_EOL; + echo json_encode($sum, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +} catch (Throwable $e) { + fwrite(STDERR, PHP_EOL . 'ERRORE: ' . $e->getMessage() . PHP_EOL); +} finally { + $mago->logoff(); + echo PHP_EOL . 'Logoff eseguito.' . PHP_EOL; +} diff --git a/public/userarea/mago_probe_saledoc_cli.php b/public/userarea/mago_probe_saledoc_cli.php new file mode 100644 index 00000000..d976d0d9 --- /dev/null +++ b/public/userarea/mago_probe_saledoc_cli.php @@ -0,0 +1,73 @@ +safeLoad(); + break; + } + } +} +require_once __DIR__ . '/class/MagoClient.php'; + +$opts = getopt('', ['from::']); +$from = $opts['from'] ?? date('Y') . '-01-01'; + +$out = function ($title, $data = null) { + echo PHP_EOL . '=== ' . $title . ' ===' . PHP_EOL; + if ($data !== null) echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; +}; + +$mago = MagoClient::fromEnv(); +try { + $mago->login(); + $f = [['key' => 'DocumentDate', 'value' => $from, 'operator' => '>=']]; + + // 1) testata documento: 2 record completi (tutti i campi) + $r = $mago->getDataTable('MA_SaleDoc', ['filters' => $f, 'recordsPerPage' => 2]); + $out("MA_SaleDoc dal $from: totale documenti = " . $r['total'] . ' (2 esempi completi)', $r['records']); + + // 2) quanti documenti per TIPO (fattura, DDT, nota di credito...): mi serve per capire i codici + $types = []; + $examples = []; + foreach ($mago->iterate('MA_SaleDoc', ['filters' => $f, 'fields' => ['SaleDocId', 'DocumentType', 'DocNo', 'DocumentDate']]) as $rec) { + $t = (string)($rec['DocumentType'] ?? '?'); + $types[$t] = ($types[$t] ?? 0) + 1; + if (!isset($examples[$t])) $examples[$t] = ($rec['DocNo'] ?? '') . ' del ' . substr((string)($rec['DocumentDate'] ?? ''), 0, 10) . ' (SaleDocId ' . ($rec['SaleDocId'] ?? '') . ')'; + } + ksort($types); + $rows = []; + foreach ($types as $t => $n) $rows[] = ['DocumentType' => $t, 'documenti' => $n, 'esempio' => $examples[$t]]; + $out('Documenti per DocumentType', $rows); + + // 3) totali: 2 record completi + $r = $mago->getDataTable('MA_SaleDocSummary', ['recordsPerPage' => 2]); + $out('MA_SaleDocSummary (2 esempi completi)', $r['records']); +} catch (Throwable $e) { + fwrite(STDERR, PHP_EOL . 'ERRORE: ' . $e->getMessage() . PHP_EOL); +} finally { + $mago->logoff(); + echo PHP_EOL . 'Logoff eseguito.' . PHP_EOL; +} diff --git a/public/userarea/sales_orders_years.php b/public/userarea/sales_orders_years.php index 244ba18e..ef19c96d 100644 --- a/public/userarea/sales_orders_years.php +++ b/public/userarea/sales_orders_years.php @@ -73,6 +73,8 @@ if ($action === 'data') { $monthly = []; $customers = []; + $yearIn = '1=0'; + $yp = $params; if ($years) { $ph = []; @@ -110,8 +112,41 @@ if ($action === 'data') { $customers = array_values($customers); } + // --- FATTURATO per data documento (fatture + acconti - note di credito) --- + $invoiced = ['available' => false, 'summary' => [], 'monthly' => []]; + try { + $iFrom = MagoSalesStats::invoiceFrom(); + $iWhere = preg_replace('/\bx\./', 'i.', $where); // stessi filtri cliente/agente/area + $iYtd = $ytd ? " AND DATE_FORMAT(i.document_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d')" : ''; + + $invoiced['summary'] = MagoSalesStats::fetchAllQ($pdo, " + SELECT YEAR(i.document_date) AS y, + COALESCE(SUM(i.revenue), 0) AS value, + SUM(i.is_credit_note = 0) AS docs, + SUM(i.is_credit_note = 1) AS credit_notes, + COALESCE(SUM(CASE WHEN i.is_credit_note = 1 THEN -i.revenue END), 0) AS credit_value, + SUM(i.no_amount) AS missing_amounts + FROM $iFrom + WHERE $iWhere AND i.document_date IS NOT NULL $iYtd + GROUP BY y ORDER BY y + ", $params); + + if ($years) { + $invoiced['monthly'] = MagoSalesStats::fetchAllQ($pdo, " + SELECT YEAR(i.document_date) AS y, MONTH(i.document_date) AS m, SUM(i.revenue) AS v + FROM $iFrom + WHERE $iWhere AND " . str_replace('x.order_date', 'i.document_date', $yearIn) . " $iYtd + GROUP BY y, m ORDER BY y, m + ", $yp); + } + $invoiced['available'] = (bool)$pdo->query("SELECT 1 FROM mago_sale_doc LIMIT 1")->fetchColumn(); + } catch (Throwable $e) { + $invoiced['error'] = 'Fatturato non disponibile: eseguire la migrazione e la sincronizzazione'; + } + MagoSalesStats::jsonOut([ 'success' => true, + 'invoiced' => $invoiced, 'metric' => $metric, 'ytd' => $ytd, 'today' => date('Y-m-d'), @@ -444,6 +479,26 @@ if ($action === 'data') { text-transform: uppercase; } + .year-card .inv { + margin-top: 8px; + background: #fff; + border-radius: 10px; + padding: 6px 10px; + font-size: .85rem; + color: var(--c-muted); + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; + } + + .year-card .inv b { + color: var(--c-ink); + font-size: 1rem; + font-variant-numeric: tabular-nums; + } + .year-card .deltas { margin-top: 10px; font-size: .88rem; @@ -659,6 +714,25 @@ if ($action === 'data') { + +
+
+
+
Fatturato per mese
+
Imponibile fatture + acconti − note di credito, per data fattura
+
+
+
+
+
+
Fatturato progressivo dell'anno
+
Somma da gennaio del fatturato, anno su anno
+
+
+
+
+ +
@@ -871,6 +945,7 @@ if ($action === 'data') { renderCards(d); renderMonthly(d); renderCumul(d); + renderInvoiced(d); renderCustomers(d); }).catch(e => { $('#content').css('opacity', 1); @@ -899,6 +974,19 @@ if ($action === 'data') { $('#missingAmounts').toggle(missing > 0).html(missing > 0 ? ` ${fmtNum(missing)} ordini negli anni scelti non hanno ancora gli importi: lancia una sincronizzazione.` : ''); + // riga fatturato nella carta anno (se i documenti sono sincronizzati) + const inv = {}; + ((d.invoiced && d.invoiced.summary) || []).forEach(r => inv[+r.y] = r); + const invLine = y => { + if (!d.invoiced || !d.invoiced.available) return ''; + const r = inv[y], + p = inv[y - 1]; + const v = r ? +r.value : 0; + const dl = p ? deltaSpan(pct(v, +p.value), `vs ${y - 1}`) : ''; + const cn = r && +r.credit_notes ? ` title="${fmtNum(r.docs)} fatture, ${fmtNum(r.credit_notes)} note di credito (−${esc(fmtEur(r.credit_value, 2))})"` : (r ? ` title="${fmtNum(r.docs)} fatture"` : ''); + return `
Fatturato${fmtEurShort(v)}${dl}
`; + }; + $('#yearCards').html(d.years.slice().reverse().map(y => { const r = byYear[y] || { orders: 0, @@ -924,16 +1012,17 @@ if ($action === 'data') {
Medio${fmtEurShort(avg)}
Clienti${fmtNum(r.customers)}
+ ${invLine(y)}
${deltas}
`; }).join('')); } /* ---------------- GRAFICI ---------------- */ - function seriesByYear(d) { + function seriesByYear(d, rows) { const map = {}; d.years.forEach(y => map[y] = Array(12).fill(0)); - d.monthly.forEach(r => { + (rows || d.monthly).forEach(r => { if (map[+r.y]) map[+r.y][+r.m - 1] = +r.v; }); // mesi futuri: vuoti (non zero) per l'anno in corso, e per tutti se "a pari data" @@ -964,9 +1053,9 @@ if ($action === 'data') { } }; - function renderMonthly(d) { - const map = seriesByYear(d); - upsert('monthly', 'chMonthly', { + function renderMonthly(d, key, el, rows, fmtV, fmtA) { + const map = seriesByYear(d, rows); + upsert(key || 'monthly', el || 'chMonthly', { chart: { ...base, type: 'line', @@ -989,14 +1078,14 @@ if ($action === 'data') { }, yaxis: { labels: { - formatter: fmtAxis + formatter: fmtA || fmtAxis } }, tooltip: { shared: true, intersect: false, y: { - formatter: fmtMetric + formatter: fmtV || fmtMetric } }, legend: { @@ -1009,14 +1098,14 @@ if ($action === 'data') { }); } - function renderCumul(d) { - const map = seriesByYear(d); + function renderCumul(d, key, el, rows, fmtV, fmtA) { + const map = seriesByYear(d, rows); const cum = {}; d.years.forEach(y => { let s = 0; cum[y] = map[y].map(v => v === null ? null : (s += v)); }); - upsert('cumul', 'chCumul', { + upsert(key || 'cumul', el || 'chCumul', { chart: { ...base, type: 'area', @@ -1046,14 +1135,14 @@ if ($action === 'data') { }, yaxis: { labels: { - formatter: fmtAxis + formatter: fmtA || fmtAxis } }, tooltip: { shared: true, intersect: false, y: { - formatter: fmtMetric + formatter: fmtV || fmtMetric } }, legend: { @@ -1066,6 +1155,20 @@ if ($action === 'data') { }); } + // fatturato: sempre in € (indipendente dal selettore N° ordini / Valore) + function renderInvoiced(d) { + const inv = d.invoiced || {}; + $('#invRow').toggle(!!inv.available); + const miss = (inv.summary || []).filter(r => d.years.includes(+r.y)).reduce((a, r) => a + (+r.missing_amounts || 0), 0); + $('#invMissing').toggle(!inv.available || miss > 0).html(!inv.available ? + ' Fatturato non ancora disponibile: eseguire la migrazione mago_sale_doc e una sincronizzazione.' : + ` ${fmtNum(miss)} fatture negli anni scelti non hanno ancora l'importo: lancia una sincronizzazione.`); + if (!inv.available) return; + const fv = v => v === null || v === undefined ? '—' : fmtEur(v); + renderMonthly(d, 'invMonthly', 'chInvMonthly', inv.monthly || [], fv, fmtEurShort); + renderCumul(d, 'invCumul', 'chInvCumul', inv.monthly || [], fv, fmtEurShort); + } + /* ---------------- CLIENTI ---------------- */ function classify(c, last, prev) { const a = c.v[last] || 0,