added fatturato

This commit is contained in:
2026-10-14 17:59:06 +02:00
parent b2a2f56383
commit 5e2ec11dbd
6 changed files with 655 additions and 12 deletions
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Documenti di vendita da MA_SaleDoc + importi da MA_SaleDocSummary (Mago4).
* Servono per il FATTURATO per data documento (fatture, fatture d'acconto, note di credito).
* Si salvano tutti i tipi (anche DDT): quali contano come fatturato si decide in MagoSalesStats.
*/
final class CreateMagoSaleDoc extends AbstractMigration
{
public function change(): void
{
$this->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();
}
}
+33
View File
@@ -59,6 +59,39 @@ class MagoSalesStats
return "DATE_ADD($z, INTERVAL (" . (int)self::ZIBO_WEEK_END_DAYS . " - WEEKDAY($z)) DAY)"; 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). */ /** Scadenza di testata (usata solo per ordini senza righe). */
public static function headerDue($a) public static function headerDue($a)
{ {
+244
View File
@@ -21,6 +21,8 @@ class MagoSync
const ENTITY_CUSTOMER = 'cust_supp'; const ENTITY_CUSTOMER = 'cust_supp';
const ENTITY_SUMMARY = 'sale_ord_summary'; const ENTITY_SUMMARY = 'sale_ord_summary';
const ENTITY_DETAILS = 'sale_ord_details'; const ENTITY_DETAILS = 'sale_ord_details';
const ENTITY_DOCS = 'sale_doc';
const ENTITY_DOC_SUMM = 'sale_doc_summary';
/** @var PDO */ /** @var PDO */
private $pdo; private $pdo;
@@ -65,6 +67,15 @@ class MagoSync
$report['ordini'] = $this->syncSaleOrders($from, $limit, $full); $report['ordini'] = $this->syncSaleOrders($from, $limit, $full);
$report['righe'] = $this->syncSaleOrderDetails($from, $full, (bool)$limit); $report['righe'] = $this->syncSaleOrderDetails($from, $full, (bool)$limit);
$report['importi'] = $this->syncSaleOrderSummaries($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) { } catch (Throwable $e) {
$report['ok'] = false; $report['ok'] = false;
$report['message'] = $e->getMessage(); $report['message'] = $e->getMessage();
@@ -525,6 +536,239 @@ class MagoSync
return ['records' => $read, 'aggiornati' => $updated, 'incrementale' => $incremental, 'ms' => $this->ms($t0)]; 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) * ANAGRAFICA CLIENTI (per decodificare i codici cliente)
* ========================================================= */ * ========================================================= */
+141
View File
@@ -0,0 +1,141 @@
<?php
/**
* mago_probe_saledoc2_cli.php - SOLO LETTURA. Riepilogo dei documenti di vendita PER TIPO
* (n. documenti, imponibile totale, registro IVA, causale...) per capire quali sono fatture
* e quali note di credito. Non scrive nulla nel database.
*
* Uso: php mago_probe_saledoc2_cli.php --from=2026-01-01 > 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;
}
@@ -0,0 +1,73 @@
<?php
/**
* mago_probe_saledoc_cli.php - SOLO LETTURA. Esplora i documenti di vendita (fatture) in Mago
* per preparare i grafici del fatturato. Non scrive nulla nel database.
*
* Uso: php mago_probe_saledoc_cli.php
* php mago_probe_saledoc_cli.php --from=2025-01-01
*/
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';
$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;
}
+115 -12
View File
@@ -73,6 +73,8 @@ if ($action === 'data') {
$monthly = []; $monthly = [];
$customers = []; $customers = [];
$yearIn = '1=0';
$yp = $params;
if ($years) { if ($years) {
$ph = []; $ph = [];
@@ -110,8 +112,41 @@ if ($action === 'data') {
$customers = array_values($customers); $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([ MagoSalesStats::jsonOut([
'success' => true, 'success' => true,
'invoiced' => $invoiced,
'metric' => $metric, 'metric' => $metric,
'ytd' => $ytd, 'ytd' => $ytd,
'today' => date('Y-m-d'), 'today' => date('Y-m-d'),
@@ -444,6 +479,26 @@ if ($action === 'data') {
text-transform: uppercase; 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 { .year-card .deltas {
margin-top: 10px; margin-top: 10px;
font-size: .88rem; font-size: .88rem;
@@ -659,6 +714,25 @@ if ($action === 'data') {
</div> </div>
</div> </div>
<!-- FATTURATO (per data fattura) -->
<div class="row g-3 mb-3" id="invRow">
<div class="col-xl-6">
<div class="chart-card">
<div class="chart-title">Fatturato per mese</div>
<div class="chart-hint">Imponibile fatture + acconti − note di credito, per data fattura</div>
<div id="chInvMonthly" style="min-height:340px;"></div>
</div>
</div>
<div class="col-xl-6">
<div class="chart-card">
<div class="chart-title">Fatturato progressivo dell'anno</div>
<div class="chart-hint">Somma da gennaio del fatturato, anno su anno</div>
<div id="chInvCumul" style="min-height:340px;"></div>
</div>
</div>
</div>
<div class="warn-strip mb-3" id="invMissing" style="display:none;"></div>
<!-- CLIENTI --> <!-- CLIENTI -->
<div class="chart-card"> <div class="chart-card">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
@@ -871,6 +945,7 @@ if ($action === 'data') {
renderCards(d); renderCards(d);
renderMonthly(d); renderMonthly(d);
renderCumul(d); renderCumul(d);
renderInvoiced(d);
renderCustomers(d); renderCustomers(d);
}).catch(e => { }).catch(e => {
$('#content').css('opacity', 1); $('#content').css('opacity', 1);
@@ -899,6 +974,19 @@ if ($action === 'data') {
$('#missingAmounts').toggle(missing > 0).html(missing > 0 ? $('#missingAmounts').toggle(missing > 0).html(missing > 0 ?
`<i class="fa-solid fa-circle-exclamation me-1"></i> ${fmtNum(missing)} ordini negli anni scelti non hanno ancora gli importi: lancia una sincronizzazione.` : ''); `<i class="fa-solid fa-circle-exclamation me-1"></i> ${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 `<div class="inv"${cn}><span>Fatturato</span><b>${fmtEurShort(v)}</b>${dl}</div>`;
};
$('#yearCards').html(d.years.slice().reverse().map(y => { $('#yearCards').html(d.years.slice().reverse().map(y => {
const r = byYear[y] || { const r = byYear[y] || {
orders: 0, orders: 0,
@@ -924,16 +1012,17 @@ if ($action === 'data') {
<div><span>Medio</span><b>${fmtEurShort(avg)}</b></div> <div><span>Medio</span><b>${fmtEurShort(avg)}</b></div>
<div><span>Clienti</span><b>${fmtNum(r.customers)}</b></div> <div><span>Clienti</span><b>${fmtNum(r.customers)}</b></div>
</div> </div>
${invLine(y)}
<div class="deltas">${deltas}</div> <div class="deltas">${deltas}</div>
</div>`; </div>`;
}).join('')); }).join(''));
} }
/* ---------------- GRAFICI ---------------- */ /* ---------------- GRAFICI ---------------- */
function seriesByYear(d) { function seriesByYear(d, rows) {
const map = {}; const map = {};
d.years.forEach(y => map[y] = Array(12).fill(0)); 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; 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" // 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) { function renderMonthly(d, key, el, rows, fmtV, fmtA) {
const map = seriesByYear(d); const map = seriesByYear(d, rows);
upsert('monthly', 'chMonthly', { upsert(key || 'monthly', el || 'chMonthly', {
chart: { chart: {
...base, ...base,
type: 'line', type: 'line',
@@ -989,14 +1078,14 @@ if ($action === 'data') {
}, },
yaxis: { yaxis: {
labels: { labels: {
formatter: fmtAxis formatter: fmtA || fmtAxis
} }
}, },
tooltip: { tooltip: {
shared: true, shared: true,
intersect: false, intersect: false,
y: { y: {
formatter: fmtMetric formatter: fmtV || fmtMetric
} }
}, },
legend: { legend: {
@@ -1009,14 +1098,14 @@ if ($action === 'data') {
}); });
} }
function renderCumul(d) { function renderCumul(d, key, el, rows, fmtV, fmtA) {
const map = seriesByYear(d); const map = seriesByYear(d, rows);
const cum = {}; const cum = {};
d.years.forEach(y => { d.years.forEach(y => {
let s = 0; let s = 0;
cum[y] = map[y].map(v => v === null ? null : (s += v)); cum[y] = map[y].map(v => v === null ? null : (s += v));
}); });
upsert('cumul', 'chCumul', { upsert(key || 'cumul', el || 'chCumul', {
chart: { chart: {
...base, ...base,
type: 'area', type: 'area',
@@ -1046,14 +1135,14 @@ if ($action === 'data') {
}, },
yaxis: { yaxis: {
labels: { labels: {
formatter: fmtAxis formatter: fmtA || fmtAxis
} }
}, },
tooltip: { tooltip: {
shared: true, shared: true,
intersect: false, intersect: false,
y: { y: {
formatter: fmtMetric formatter: fmtV || fmtMetric
} }
}, },
legend: { 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 ?
'<i class="fa-solid fa-circle-info me-1"></i> Fatturato non ancora disponibile: eseguire la migrazione <b>mago_sale_doc</b> e una sincronizzazione.' :
`<i class="fa-solid fa-circle-exclamation me-1"></i> ${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 ---------------- */ /* ---------------- CLIENTI ---------------- */
function classify(c, last, prev) { function classify(c, last, prev) {
const a = c.v[last] || 0, const a = c.v[last] || 0,