From c96717b0515264cfbb0c913f1f4afc706ec13507 Mon Sep 17 00:00:00 2001 From: solocla Date: Tue, 13 Oct 2026 16:30:47 +0200 Subject: [PATCH] fixed stats --- ...1013135023_create_mago_sale_ord_detail.php | 59 + public/userarea/class/MagoSalesStats.php | 107 +- public/userarea/class/MagoSync.php | 188 +++ public/userarea/include/navbar.php | 5 + public/userarea/sales_orders_deliveries.php | 1245 +++++++++++++++++ public/userarea/sales_orders_stats.php | 309 +++- public/userarea/sales_orders_years.php | 1 + 7 files changed, 1894 insertions(+), 20 deletions(-) create mode 100644 db/migrations/20261013135023_create_mago_sale_ord_detail.php create mode 100644 public/userarea/sales_orders_deliveries.php diff --git a/db/migrations/20261013135023_create_mago_sale_ord_detail.php b/db/migrations/20261013135023_create_mago_sale_ord_detail.php new file mode 100644 index 00000000..e25b97e7 --- /dev/null +++ b/db/migrations/20261013135023_create_mago_sale_ord_detail.php @@ -0,0 +1,59 @@ +table('mago_sale_ord_detail', [ + 'id' => false, + 'primary_key' => ['sale_ord_id', 'sub_id'], + 'comment' => 'Copia locale MA_SaleOrdDetails (Mago4) - sola lettura', + ]) + ->addColumn('sale_ord_id', 'integer', ['signed' => false]) + ->addColumn('sub_id', 'integer') + ->addColumn('line', 'integer', ['default' => 0]) + ->addColumn('position', 'integer', ['default' => 0]) + ->addColumn('line_type', 'integer', ['default' => 0]) + ->addColumn('item', 'string', ['limit' => 30, 'default' => '']) + ->addColumn('description', 'string', ['limit' => 255, 'default' => '']) + ->addColumn('uom', 'string', ['limit' => 10, 'default' => '']) + ->addColumn('qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0]) + ->addColumn('delivered_qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0]) + ->addColumn('invoiced_qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0]) + ->addColumn('unit_value', 'decimal', ['precision' => 15, 'scale' => 5, 'default' => 0]) + ->addColumn('discount_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0]) + ->addColumn('taxable_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0]) + ->addColumn('total_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0]) + ->addColumn('expected_delivery_date', 'date', ['null' => true]) + ->addColumn('confirmed_delivery_date', 'date', ['null' => true]) + ->addColumn('delivered', 'boolean', ['default' => false]) + ->addColumn('invoiced', 'boolean', ['default' => false]) + ->addColumn('cancelled', 'boolean', ['default' => false]) + ->addColumn('customer', 'string', ['limit' => 20, 'default' => '']) + ->addColumn('order_date', 'date', ['null' => true]) + ->addColumn('internal_ord_no', 'string', ['limit' => 30, 'default' => '']) + ->addColumn('notes', 'text', ['null' => true]) + ->addColumn('tb_modified', 'datetime', ['null' => true]) + ->addColumn('synced_at', 'datetime', ['null' => true]) + ->addIndex(['item']) + ->addIndex(['order_date']) + ->addIndex(['expected_delivery_date']) + ->addIndex(['customer']) + ->create(); + + // Date di consegna ricavate dalle righe (la data in testata spesso resta = data ordine) + $this->table('mago_sale_ord') + ->addColumn('line_expected_date', 'date', ['null' => true, 'after' => 'compulsory_delivery_date', 'comment' => 'Prima data prevista tra le righe aperte']) + ->addColumn('line_confirmed_date', 'date', ['null' => true, 'after' => 'line_expected_date', 'comment' => 'Prima data confermata tra le righe aperte']) + ->addColumn('lines_count', 'integer', ['default' => 0, 'after' => 'line_confirmed_date']) + ->update(); + } +} diff --git a/public/userarea/class/MagoSalesStats.php b/public/userarea/class/MagoSalesStats.php index 76f50669..f761940c 100644 --- a/public/userarea/class/MagoSalesStats.php +++ b/public/userarea/class/MagoSalesStats.php @@ -9,6 +9,60 @@ class MagoSalesStats { const CUSTOMER_TYPE = 3211264; + /* + * MAPPATURA DATE DI CONSEGNA (righe e testata usano gli stessi nomi colonna) + * + * Data ZIBO = data comunicata da Zibo al cliente → è la SCADENZA che conta (ritardo in rosso) + * Data cliente = data desiderata dal cliente → riferimento (ritardo in arancione) + * + * Per invertirle basta scambiare i due valori qui sotto. + */ + const ZIBO_DATE_COL = 'confirmed_delivery_date'; // Mago: ConfirmedDeliveryDate + const CLIENT_DATE_COL = 'expected_delivery_date'; // Mago: ExpectedDeliveryDate + + /** Data ZIBO "valida" di una riga/testata: in Mago, se non compilata, resta uguale alla data ordine. */ + public static function ziboDate($a) + { + $c = $a . '.' . self::ZIBO_DATE_COL; + return "CASE WHEN $c > $a.order_date THEN $c END"; + } + + public static function clientDate($a) + { + $c = $a . '.' . self::CLIENT_DATE_COL; + return "CASE WHEN $c > $a.order_date THEN $c END"; + } + + /** Scadenza effettiva di una riga: data ZIBO, se manca la data cliente. */ + public static function lineDue($a) + { + return "COALESCE(" . self::ziboDate($a) . ", " . self::clientDate($a) . ")"; + } + + /** Aggregati per ordine calcolati dalle righe (alias l). */ + public static function lineAggregates() + { + $open = "(d.delivered = 0 AND d.cancelled = 0)"; + $due = self::lineDue('d'); + return "( + SELECT d.sale_ord_id, + COUNT(*) AS lines_total, + SUM($open) AS open_lines, + MIN(CASE WHEN $open THEN " . self::ziboDate('d') . " END) AS next_zibo, + MIN(CASE WHEN $open THEN " . self::clientDate('d') . " END) AS next_client, + MIN(" . self::ziboDate('d') . ") AS first_zibo, + MIN(" . self::clientDate('d') . ") AS first_client, + MIN(CASE WHEN $open THEN $due END) AS next_due, + MIN($due) AS first_due, + COUNT(DISTINCT CASE WHEN $open THEN $due END) AS open_dates, + SUM($open AND $due < CURDATE()) AS late_lines, + SUM($open AND " . self::clientDate('d') . " < CURDATE()) AS late_lines_client + FROM mago_sale_ord_detail d + WHERE d.item <> '' OR d.qty <> 0 + GROUP BY d.sale_ord_id + ) l"; + } + /** Risposta JSON e fine script (pulisce l'output di headscript). */ public static function jsonOut(array $data) { @@ -36,8 +90,13 @@ class MagoSalesStats } /** - * Vista base: ordini + nome cliente + stato calcolato + data consegna di riferimento. - * Le statistiche filtrano su questa vista (alias x). + * Vista base: ordini + nome cliente + stato + date di consegna (alias x). + * + * zibo_date = prima data ZIBO tra le righe ancora da consegnare (se tutte consegnate: la prima in assoluto); + * senza righe: data ZIBO di testata + * client_date = stessa logica con la data cliente + * due_date = scadenza dell'ordine: la più vicina tra le righe aperte (riga: data ZIBO, se manca data cliente) + * late_lines / open_lines = righe aperte in ritardo / righe aperte (per il "ritardo parziale") */ public static function baseFrom() { @@ -45,13 +104,19 @@ class MagoSalesStats SELECT o.*, COALESCE(NULLIF(c.company_name, ''), o.customer) AS company_name, - -- Data consegna di riferimento: prevista; la 'confermata' solo se successiva all'ordine - -- (in Mago spesso coincide con la data ordine e farebbe risultare tutto in ritardo) + COALESCE(l.next_zibo, l.first_zibo, " . self::ziboDate('o') . ") AS zibo_date, + COALESCE(l.next_client, l.first_client, " . self::clientDate('o') . ") AS client_date, + -- scadenza = la più vicina tra le righe aperte, dove ogni riga scade alla sua data ZIBO + -- (o alla data cliente se la data ZIBO non è compilata) COALESCE( - o.expected_delivery_date, - CASE WHEN o.confirmed_delivery_date > o.order_date THEN o.confirmed_delivery_date END, - o.compulsory_delivery_date + l.next_due, l.first_due, + " . self::ziboDate('o') . ", " . self::clientDate('o') . ", o.compulsory_delivery_date ) AS due_date, + COALESCE(l.lines_total, 0) AS lines_total, + COALESCE(l.open_lines, 0) AS open_lines, + COALESCE(l.open_dates, 0) AS open_dates, + COALESCE(l.late_lines, 0) AS late_lines, + COALESCE(l.late_lines_client, 0) AS late_lines_client, CASE WHEN o.cancelled = 1 THEN 'annullato' WHEN o.delivered = 1 THEN 'evaso' @@ -61,9 +126,36 @@ class MagoSalesStats FROM mago_sale_ord o LEFT JOIN mago_cust_supp c ON c.cust_supp_type = " . self::CUSTOMER_TYPE . " AND c.cust_supp = o.customer + LEFT JOIN " . self::lineAggregates() . " ON l.sale_ord_id = o.sale_ord_id ) x"; } + /** + * Vista righe aperte (alias r) per la pagina Consegne: riga + ordine + date effettive. + */ + public static function openLinesFrom() + { + return "( + SELECT + d.sale_ord_id, d.sub_id, d.line, d.position, d.item, d.description, d.uom, + d.qty, d.delivered_qty, + GREATEST(d.qty - d.delivered_qty, 0) AS residual_qty, + CASE WHEN d.qty > 0 THEN ROUND(d.taxable_amount * GREATEST(d.qty - d.delivered_qty, 0) / d.qty, 2) + ELSE d.taxable_amount END AS residual_value, + d.taxable_amount, + " . self::ziboDate('d') . " AS zibo_date, + " . self::clientDate('d') . " AS client_date, + " . self::lineDue('d') . " AS due_date, + x.internal_ord_no, x.order_date, x.customer, x.company_name, + x.salesperson, x.area, x.status, x.your_reference, x.external_ord_no + FROM mago_sale_ord_detail d + JOIN " . self::baseFrom() . " ON x.sale_ord_id = d.sale_ord_id + WHERE d.delivered = 0 AND d.cancelled = 0 + AND x.status IN ('aperto','bloccato') + AND (d.item <> '' OR d.qty <> 0) + ) r"; + } + /** * WHERE + parametri dai filtri in querystring. * $override sostituisce from/to (stringa vuota = nessun filtro data). @@ -108,6 +200,7 @@ class MagoSalesStats 'annullato' => "x.status = 'annullato'", 'bloccato' => "x.status = 'bloccato'", 'ritardo' => "(x.status IN ('aperto','bloccato') AND x.due_date < CURDATE())", + 'ritardo_cliente' => "(x.status IN ('aperto','bloccato') AND x.client_date < CURDATE())", 'da_fatturare' => "(x.delivered = 1 AND x.invoiced = 0 AND x.cancelled = 0)", 'fatturato' => "x.invoiced = 1", ]; diff --git a/public/userarea/class/MagoSync.php b/public/userarea/class/MagoSync.php index 56f87304..2435f569 100644 --- a/public/userarea/class/MagoSync.php +++ b/public/userarea/class/MagoSync.php @@ -20,6 +20,7 @@ class MagoSync const ENTITY_ORDERS = 'sale_ord'; const ENTITY_CUSTOMER = 'cust_supp'; const ENTITY_SUMMARY = 'sale_ord_summary'; + const ENTITY_DETAILS = 'sale_ord_details'; /** @var PDO */ private $pdo; @@ -62,6 +63,7 @@ class MagoSync $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); } catch (Throwable $e) { $report['ok'] = false; @@ -240,6 +242,192 @@ class MagoSync ]; } + /* ========================================================= + * 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, 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, :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), + 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), + '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) * ========================================================= */ diff --git a/public/userarea/include/navbar.php b/public/userarea/include/navbar.php index 6cbc2a62..0c8920a5 100644 --- a/public/userarea/include/navbar.php +++ b/public/userarea/include/navbar.php @@ -235,6 +235,11 @@ Confronto anni +
  • + + Consegne + +
  • diff --git a/public/userarea/sales_orders_deliveries.php b/public/userarea/sales_orders_deliveries.php new file mode 100644 index 00000000..f284bcfb --- /dev/null +++ b/public/userarea/sales_orders_deliveries.php @@ -0,0 +1,1245 @@ +getConnection(); +$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +// --- Permessi: pagina visibile solo con 'sales.stats.view' --- +if (!userCan('sales.stats.view')) { + if (isset($_GET['action']) || isset($_POST['action'])) { + MagoSalesStats::jsonOut(['success' => false, 'ok' => false, 'message' => 'Non hai i permessi per questa pagina']); + } + while (ob_get_level()) ob_end_clean(); + http_response_code(403); + echo '

    Accesso non consentito

    Non hai i permessi per visualizzare questa pagina.

    Torna alla Dashboard
    '; + exit; +} + +/* ============================================================ + * AJAX + * ============================================================ */ + +/** Filtri della pagina Consegne applicati alla vista righe aperte (alias r). */ +function deliveriesWhere() +{ + // cliente / agente / area dalla classe comune (scritti su x.) → riportati su r. + [$w, $p] = MagoSalesStats::buildWhere(['from' => '', 'to' => '']); + $w = preg_replace('/\bx\./', 'r.', $w); + + $items = MagoSalesStats::arrParam('items'); + if ($items) { + $ph = []; + foreach ($items as $i => $v) { + $ph[] = ':it' . $i; + $p['it' . $i] = $v; + } + $w .= ' AND r.item IN (' . implode(',', $ph) . ')'; + } + + $horizons = [ + 'late_zibo' => 'r.due_date < CURDATE()', + 'late_client' => 'r.client_date < CURDATE()', + 'week' => 'r.due_date >= CURDATE() AND YEARWEEK(r.due_date, 3) = YEARWEEK(CURDATE(), 3)', + 'next_week' => 'YEARWEEK(r.due_date, 3) = YEARWEEK(CURDATE() + INTERVAL 7 DAY, 3)', + '30d' => 'r.due_date >= CURDATE() AND r.due_date <= CURDATE() + INTERVAL 30 DAY', + 'nodate' => 'r.due_date IS NULL', + ]; + $h = $_GET['horizon'] ?? ''; + if (isset($horizons[$h])) $w .= ' AND ' . $horizons[$h]; + + $q = trim((string)($_GET['search'] ?? '')); + if ($q !== '') { + $w .= ' AND (r.internal_ord_no LIKE :s1 OR r.company_name LIKE :s2 OR r.customer LIKE :s3 OR r.item LIKE :s4 OR r.description LIKE :s5 OR r.your_reference LIKE :s6)'; + $like = '%' . $q . '%'; + foreach (['s1', 's2', 's3', 's4', 's5', 's6'] as $k) $p[$k] = $like; + } + + return [$w, $p]; +} + +$action = $_GET['action'] ?? ''; + +if ($action === 'filters') { + $opts = MagoSalesStats::filterOptions($pdo); + try { + $opts['items'] = $pdo->query(" + SELECT item AS code, MAX(description) AS name, COUNT(*) AS n + FROM mago_sale_ord_detail + WHERE delivered = 0 AND cancelled = 0 AND item <> '' + GROUP BY item + ORDER BY item + ")->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + $opts['items'] = []; + } + MagoSalesStats::jsonOut($opts); +} + +if ($action === 'data') { + try { + $from = MagoSalesStats::openLinesFrom(); + [$where, $params] = deliveriesWhere(); + + $kpi = MagoSalesStats::fetchAllQ($pdo, " + SELECT COUNT(*) AS n_lines, + COUNT(DISTINCT r.sale_ord_id) AS orders, + COALESCE(SUM(r.residual_value), 0) AS value, + SUM(r.due_date < CURDATE()) AS late_lines, + COALESCE(SUM(CASE WHEN r.due_date < CURDATE() THEN r.residual_value END), 0) AS late_value, + SUM(r.client_date < CURDATE()) AS late_client_lines, + COALESCE(SUM(CASE WHEN r.client_date < CURDATE() THEN r.residual_value END), 0) AS late_client_value, + SUM(r.due_date >= CURDATE() AND r.due_date <= CURDATE() + INTERVAL 7 DAY) AS due7_lines, + COALESCE(SUM(CASE WHEN r.due_date >= CURDATE() AND r.due_date <= CURDATE() + INTERVAL 7 DAY THEN r.residual_value END), 0) AS due7_value, + SUM(r.due_date IS NULL) AS nodate_lines, + AVG(CASE WHEN r.due_date < CURDATE() THEN DATEDIFF(CURDATE(), r.due_date) END) AS avg_late_days, + SUM(r.zibo_date IS NULL) AS no_zibo_lines + FROM $from + WHERE $where + ", $params)[0]; + + // Carico per settimana (data ZIBO, se manca data cliente): scaduto + prossime 12 settimane + oltre + senza data + $weekly = MagoSalesStats::fetchAllQ($pdo, " + SELECT CASE + WHEN r.due_date IS NULL THEN 'nd' + WHEN r.due_date < CURDATE() THEN 'late' + WHEN r.due_date >= (CURDATE() - INTERVAL WEEKDAY(CURDATE()) DAY) + INTERVAL 12 WEEK THEN 'over' + ELSE DATE_FORMAT(r.due_date - INTERVAL WEEKDAY(r.due_date) DAY, '%Y-%m-%d') + END AS wk, + COUNT(*) AS n, + COALESCE(SUM(r.residual_value), 0) AS v + FROM $from + WHERE $where + GROUP BY wk + ", $params); + + // Clienti con più ritardo (data ZIBO) + $lateCustomers = MagoSalesStats::fetchAllQ($pdo, " + SELECT r.customer AS code, r.company_name AS name, + COUNT(*) AS n, COALESCE(SUM(r.residual_value), 0) AS v + FROM $from + WHERE $where AND r.due_date < CURDATE() + GROUP BY r.customer, r.company_name + ORDER BY v DESC + LIMIT 10 + ", $params); + + $rows = MagoSalesStats::fetchAllQ($pdo, " + SELECT r.sale_ord_id, r.internal_ord_no, r.order_date, r.customer, r.company_name, + r.item, r.description, r.uom, r.qty, r.delivered_qty, r.residual_qty, r.residual_value, + r.zibo_date, r.client_date, r.due_date, r.salesperson, r.status, + CASE WHEN r.due_date < CURDATE() THEN DATEDIFF(CURDATE(), r.due_date) ELSE 0 END AS late_days, + CASE WHEN r.due_date >= CURDATE() THEN DATEDIFF(r.due_date, CURDATE()) END AS days_left + FROM $from + WHERE $where + ORDER BY (r.due_date IS NULL), r.due_date, r.internal_ord_no, r.line + LIMIT 5000 + ", $params); + + MagoSalesStats::jsonOut([ + 'success' => true, + 'today' => date('Y-m-d'), + 'kpi' => $kpi, + 'weekly' => $weekly, + 'late_customers' => $lateCustomers, + 'rows' => $rows, + ]); + } catch (Throwable $e) { + MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]); + } +} + +?> + + + + + + + + + Consegne - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?> + + + + + + + + + + + + + + + + + + + + + + +
    + + + +
    +
    +
    + +
    +
    +
    +
    +
    +
    Statistiche Ordini Cliente
    + Dati Mago4 +
    +
    + +
    +
    + +
    + + + + +
    +
    +
    + Scadenza +
    + + + + + + + +
    +
    +
    + Data ZIBO = scadenza comunicata (rossa se superata) + Data cliente = desiderata (arancione se superata) +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +
    + + + +
    +
    +
    +
    Da consegnare
    +
    —
    +
     
    +
    +
    +
    +
    Scadute (data ZIBO)
    +
    —
    +
     
    +
    +
    +
    +
    Oltre data cliente
    +
    —
    +
     
    +
    +
    +
    +
    Prossimi 7 giorni
    +
    —
    +
     
    +
    +
    +
    +
    Senza data
    +
    —
    +
    righe senza data di consegna
    +
    +
    + + +
    +
    +
    +
    +
    +
    Carico consegne per settimana
    +
    Per data ZIBO (se manca: data cliente) · clicca una barra per filtrare
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    Clienti con più ritardo
    +
    Valore residuo scaduto (data ZIBO) · clicca per filtrare
    +
    +
    +
    +
    + + +
    +
    +
    Righe da consegnare
    +
    Ordinate per scadenza · clicca il numero ordine per aprirlo
    +
    +
    + + + + + + + + + + + + + + +
    OrdineClienteArticoloResiduoConsegna (ZIBO / cliente)RitardoValore residuoAgente
    +
    +
    +
    + +
    +
    +
    +
    + + +
    + + + + + + + \ No newline at end of file diff --git a/public/userarea/sales_orders_stats.php b/public/userarea/sales_orders_stats.php index 3ca72e46..a48b8a0c 100644 --- a/public/userarea/sales_orders_stats.php +++ b/public/userarea/sales_orders_stats.php @@ -73,6 +73,8 @@ if ($action === 'data') { SUM(x.status = 'annullato') AS cancelled, SUM(x.status = 'bloccato') AS blocked, SUM(x.status IN ('aperto','bloccato') AND x.due_date < CURDATE()) AS late, + SUM(x.status IN ('aperto','bloccato') AND x.client_date < CURDATE()) AS late_client, + SUM(x.status IN ('aperto','bloccato') AND x.late_lines > 0 AND x.late_lines < x.open_lines) AS late_partial, SUM(x.delivered = 1 AND x.invoiced = 0 AND x.cancelled = 0) AS to_invoice, COUNT(DISTINCT x.customer) AS customers, AVG(CASE WHEN x.status <> 'annullato' AND x.due_date IS NOT NULL AND x.due_date >= x.order_date @@ -160,12 +162,30 @@ if ($action === 'data') { GROUP BY bucket ORDER BY bucket ", $params); + // --- Top articoli (dalle righe, annullati esclusi) --- + $topItems = MagoSalesStats::fetchAllQ($pdo, " + SELECT d.item AS code, + MAX(d.description) AS name, + MAX(d.uom) AS uom, + SUM(d.qty) AS qty, + SUM(d.taxable_amount) AS value, + COUNT(DISTINCT d.sale_ord_id) AS orders + FROM mago_sale_ord_detail d + JOIN $from ON x.sale_ord_id = d.sale_ord_id + WHERE $where AND d.item <> '' AND d.cancelled = 0 AND x.status <> 'annullato' + GROUP BY d.item + ORDER BY " . ($metric === 'value' ? 'value' : 'orders') . " DESC + LIMIT 10 + ", $params); + // --- Elenco ordini --- $rows = MagoSalesStats::fetchAllQ($pdo, " SELECT x.sale_ord_id, x.internal_ord_no, x.external_ord_no, x.order_date, x.due_date, x.customer, x.company_name, x.salesperson, x.area, x.status, x.invoiced, x.your_reference, x.taxable_amount, x.total_amount, + x.zibo_date, x.client_date, x.open_lines, x.late_lines, x.late_lines_client, x.open_dates, + (x.status IN ('aperto','bloccato') AND x.client_date < CURDATE()) AS is_late_client, (x.summary_tb_modified IS NULL) AS no_amount, (x.status IN ('aperto','bloccato') AND x.due_date < CURDATE()) AS is_late FROM $from @@ -183,6 +203,7 @@ if ($action === 'data') { 'by_area' => $byArea, 'by_salesperson' => $bySalesperson, 'due_buckets' => $dueBuckets, + 'top_items' => $topItems, 'rows' => $rows, ]); } catch (Throwable $e) { @@ -201,8 +222,22 @@ if ($action === 'order') { $raw = json_decode((string)$o['raw_json'], true) ?: []; unset($o['raw_json']); + $stL = $pdo->prepare(" + SELECT d.line, d.item, d.description, d.uom, d.qty, d.delivered_qty, d.invoiced_qty, d.unit_value, + GREATEST(d.qty - d.delivered_qty, 0) AS residual_qty, + d.taxable_amount, d.delivered, d.invoiced, d.cancelled, d.notes, + " . MagoSalesStats::ziboDate('d') . " AS zibo_date, + " . MagoSalesStats::clientDate('d') . " AS client_date + FROM mago_sale_ord_detail d + WHERE d.sale_ord_id = ? + ORDER BY d.position, d.line, d.sub_id + "); + $stL->execute([$id]); + $lines = $stL->fetchAll(PDO::FETCH_ASSOC); + MagoSalesStats::jsonOut([ 'success' => true, + 'lines' => $lines, 'order' => $o, 'extra' => [ 'Cliente fatturazione' => $raw['InvoicingCustomer'] ?? '', @@ -654,6 +689,83 @@ if ($action === 'order') { margin-left: 6px; } + /* date di consegna: ZIBO (scadenza) rossa se superata, cliente (desiderata) arancione */ + .dt-zibo { + font-weight: 700; + color: var(--c-ink); + white-space: nowrap; + } + + .dt-zibo.late { + color: #dc3545; + } + + .dt-client { + font-size: .82rem; + color: #64748b; + white-space: nowrap; + } + + .dt-client.late { + color: #ea580c; + font-weight: 700; + } + + .dt-tag { + display: inline-block; + font-size: .68rem; + font-weight: 800; + letter-spacing: .3px; + text-transform: uppercase; + margin-right: 4px; + opacity: .75; + } + + .dt-fallback { + font-size: .72rem; + color: #94a3b8; + font-style: italic; + } + + .more-dates { + display: inline-block; + background: #eef2ff; + color: #4338ca; + font-size: .7rem; + font-weight: 800; + border-radius: 999px; + padding: 1px 7px; + margin-left: 4px; + } + + .partial-badge { + background: #fff1e6; + color: #c2410c; + font-weight: 800; + font-size: .75rem; + padding: 3px 8px; + border-radius: 999px; + margin-left: 6px; + } + + .late-badge, + .partial-badge, + .late-client-badge { + white-space: nowrap; + display: inline-block; + margin-top: 2px; + } + + .late-client-badge { + background: #fff7ed; + color: #ea580c; + font-weight: 800; + font-size: .72rem; + padding: 3px 8px; + border-radius: 999px; + margin-left: 6px; + } + .inv-badge { background: #efe9fb; color: var(--c-invoice); @@ -771,6 +883,7 @@ if ($action === 'order') {
    Andamento ordini Confronto anni + Consegne
    @@ -816,7 +929,8 @@ if ($action === 'order') {
    - + + @@ -876,7 +990,7 @@ if ($action === 'order') {
    In ritardo
    —
    -
    consegna prevista superata
    +
    data ZIBO superata
    @@ -947,6 +1061,16 @@ if ($action === 'order') {
    +
    +
    +
    +
    Top 10 articoli per numero ordini
    +
    Dalle righe ordine · passa sopra una barra per quantità e valore
    +
    +
    +
    +
    +
    @@ -976,7 +1100,7 @@ if ($action === 'order') { Data Cliente Rif. cliente - Consegna prev. + Consegna (ZIBO / cliente) Imponibile Stato Fatt. @@ -1198,7 +1322,11 @@ if ($action === 'order') { if (f.statuses.length === 1) $(`.kpi[data-status="${f.statuses[0]}"]`).addClass('selected'); } + // aperta da link (?q=numero ordine): non sovrascrivo i filtri salvati dell'utente + const URL_Q = new URLSearchParams(location.search).get('q'); + function saveFilters(f) { + if (URL_Q) return; try { localStorage.setItem(STORE_KEY, JSON.stringify(Object.assign({}, f, { metric: metric @@ -1319,7 +1447,13 @@ if ($action === 'order') { renderDue(d.due_buckets); renderSimpleBar('chArea', d.by_area, '#0ea5e9', 'areas'); renderSimpleBar('chSalesperson', d.by_salesperson, '#6f42c1', 'salespeople'); + renderItems(d.top_items || []); renderTable(d.rows); + if (window.__autoOpen) { + const hit = d.rows.find(r => r.internal_ord_no === window.__autoOpen) || (d.rows.length === 1 ? d.rows[0] : null); + window.__autoOpen = null; + if (hit) openOrder(hit.sale_ord_id); + } }) .catch(err => { $('#statsContent').css('opacity', 1); @@ -1336,6 +1470,7 @@ if ($action === 'order') { $('#ttlDue').text(v ? 'Scadenze ordini aperti (valore)' : 'Scadenze ordini aperti'); $('#ttlArea').text(v ? 'Valore per area' : 'Ordini per area'); $('#ttlSalesperson').text(v ? 'Valore per agente' : 'Ordini per agente'); + $('#ttlItems').text(v ? 'Top 10 articoli per valore ordinato' : 'Top 10 articoli per numero ordini'); } function renderKpi(k) { @@ -1343,6 +1478,9 @@ if ($action === 'order') { $('#kOrders').text(fmtNum(orders)); $('#kOpen').text(fmtNum(k.open_orders)); $('#kLate').text(fmtNum(k.late)); + $('#kLateSub').html(`data ZIBO superata` + + (+k.late_partial ? ` · ${fmtNum(k.late_partial)} parziali` : '') + + `
    ${fmtNum(k.late_client)} oltre data cliente`); $('#kDelivered').text(fmtNum(k.delivered)); $('#kToInvoice').text(fmtNum(k.to_invoice)); $('#kCustomers').text(fmtNum(k.customers)); @@ -1695,16 +1833,113 @@ if ($action === 'order') { }); } + function renderItems(rows) { + const v = metric === 'value'; + const label = r => { + const n = (r.name || '').trim(); + const t = n ? `${r.code} · ${n}` : r.code; + return t.length > 60 ? t.substring(0, 58) + '…' : t; + }; + upsertChart('items', 'chItems', { + chart: { + ...baseChart, + type: 'bar', + height: 380 + }, + series: [{ + name: v ? 'Valore' : 'Ordini', + data: rows.map(r => v ? +r.value : +r.orders) + }], + colors: ['#0f766e'], + xaxis: { + categories: rows.map(label), + labels: { + formatter: fmtMetricAxis + } + }, + yaxis: { + labels: { + maxWidth: 380 + } + }, + plotOptions: { + bar: { + horizontal: true, + borderRadius: 4, + barHeight: '65%' + } + }, + dataLabels: { + enabled: false + }, + grid: { + borderColor: '#eef2f7' + }, + tooltip: { + custom: ({ + dataPointIndex + }) => { + const r = rows[dataPointIndex]; + if (!r) return ''; + return `
    +
    ${esc(r.code)}
    +
    ${esc(r.name)}
    +
    Ordini: ${fmtNum(r.orders)}
    +
    Quantità: ${fmtNum(Math.round(r.qty))} ${esc(r.uom)}
    +
    Valore: ${fmtEur(r.value)}
    +
    `; + } + }, + noData: { + text: 'Nessuna riga articolo nel periodo' + } + }); + } + + // cella consegna: data ZIBO (rossa se superata) + data cliente (arancione se superata) + function dateCell(zibo, client, opts) { + opts = opts || {}; + const today = iso(new Date()); + const open = opts.open !== false; + const zLate = open && zibo && zibo < today; + const cLate = open && client && client < today; + let html = ''; + if (zibo) { + html += `
    ZIBO${fmtDate(zibo)}${opts.more ? `+${opts.more} date` : ''}
    `; + } else { + html += `
    data ZIBO non indicata
    `; + } + if (client) { + html += `
    Cliente${fmtDate(client)}
    `; + } + return html || '—'; + } + + function lateBadges(r) { + if (r.status !== 'aperto' && r.status !== 'bloccato') return ''; + const lateL = +r.late_lines, + openL = +r.open_lines; + if (+r.is_late) { + return lateL > 0 && openL > lateL ? + `PARZIALE ${lateL}/${openL} righe` : + 'IN RITARDO'; + } + return +r.is_late_client ? 'OLTRE DATA CLIENTE' : ''; + } + function renderTable(rows) { const data = rows.map(r => [ `${esc(r.internal_ord_no)}${r.external_ord_no ? `
    ${esc(r.external_ord_no)}
    ` : ''}`, `${fmtDate(r.order_date)}`, `
    ${esc(r.company_name)}
    ${esc(r.customer)}
    `, esc(r.your_reference), - `${fmtDate(r.due_date) || '—'}`, + `${dateCell(r.zibo_date, r.client_date, { + open: r.status === 'aperto' || r.status === 'bloccato', + more: +r.open_dates > 1 ? (+r.open_dates - 1) : 0 + })}`, +r.no_amount ? 'n.d.' : `${fmtEur(r.taxable_amount, 2)}`, - `${STATUS_LABEL[r.status] || esc(r.status)}${+r.is_late ? 'IN RITARDO' : ''}`, + `${STATUS_LABEL[r.status] || esc(r.status)}` + lateBadges(r), +r.invoiced ? 'Sì' : '—', esc(r.salesperson), esc(r.area), @@ -1756,7 +1991,7 @@ if ($action === 'order') { columns: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], format: { // importo esportato come numero, il resto come testo - body: (d, row, col) => col === 5 ? (+$(d).data('order') >= 0 ? +$(d).data('order') : '') : $('
    ').html(String(d).replace(/<(div|span)/g, ' <$1')).text().replace(/\s+/g, ' ').trim() + body: (d, row, col) => col === 5 ? (+$(d).data('order') >= 0 ? +$(d).data('order') : '') : $('
    ').html(String(d).replace(/<(div|span)/g, ' <$1').replace(/<\/span>/g, ' ')).text().replace(/\s+/g, ' ').trim() } } }, @@ -1769,7 +2004,7 @@ if ($action === 'order') { columns: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], format: { // importo esportato come numero, il resto come testo - body: (d, row, col) => col === 5 ? (+$(d).data('order') >= 0 ? +$(d).data('order') : '') : $('
    ').html(String(d).replace(/<(div|span)/g, ' <$1')).text().replace(/\s+/g, ' ').trim() + body: (d, row, col) => col === 5 ? (+$(d).data('order') >= 0 ? +$(d).data('order') : '') : $('
    ').html(String(d).replace(/<(div|span)/g, ' <$1').replace(/<\/span>/g, ' ')).text().replace(/\s+/g, ' ').trim() } } } @@ -1825,19 +2060,58 @@ if ($action === 'order') {
    ` : '
    Importi non ancora letti da Mago.
    '}
    Data ordine
    ${fmtDate(o.order_date) || '—'}
    -
    Consegna prevista
    ${fmtDate(o.expected_delivery_date) || '—'}
    -
    Consegna confermata
    ${fmtDate(o.confirmed_delivery_date) || '—'}
    +
    Consegna
    ${dateCell(o.zibo_date, o.client_date, {open: o.status === 'aperto' || o.status === 'bloccato'})}${+o.open_lines ? ` prima riga da consegnare · ${fmtNum(o.open_lines)} righe aperte${+o.late_lines ? `, ${fmtNum(o.late_lines)} in ritardo` : ''}` : ''}
    N° ordine cliente
    ${esc(o.external_ord_no) || '—'}
    Agente
    ${esc(o.salesperson) || '—'}
    Area
    ${esc(o.area) || '—'}
    ${extra}
    - ${o.notes ? `
    Note:
    ${esc(o.notes)}
    ` : ''} -
    Le righe articolo non sono disponibili: la tabella righe ordine non è esposta dalle API di Mago.
    + ${renderLines(d.lines || [])} + ${o.notes ? `
    Note:
    ${esc(o.notes)}
    ` : ''} + `); }); } + function renderLines(lines) { + if (!lines.length) { + return '
    Righe non ancora sincronizzate: premi Aggiorna da Mago.
    '; + } + const today = iso(new Date()); + const rows = lines.map(l => { + const open = !+l.delivered && !+l.cancelled; + const due = l.zibo_date || l.client_date; + const late = open && due && due < today; + const days = late ? Math.round((new Date(today) - new Date(due)) / 86400000) : 0; + const st = +l.cancelled ? 'Annullata' : + (+l.delivered ? 'Consegnata' : + 'Aperta' + (late ? `+${days} gg` : '')); + const qtyTxt = n => fmtNum(Math.round(+n * 100) / 100); + return ` + ${esc(l.item)}
    ${esc(l.description)}
    + ${qtyTxt(l.qty)} ${esc(l.uom)} + ${qtyTxt(l.delivered_qty)} + ${open ? '' + qtyTxt(l.residual_qty) + '' : '—'} + ${dateCell(l.zibo_date, l.client_date, {open: open})} + ${fmtEur(l.taxable_amount, 2)} + ${st} + `; + }).join(''); + return ` +
    Righe ordine (${lines.length})
    +
    + + + + + + + + ${rows} +
    ArticoloQtàConsegnataResiduoConsegna (ZIBO / cliente)ImponibileStato
    +
    `; + } + /* ---------------- SINCRONIZZAZIONE ---------------- */ function runSync(mode) { const labels = { @@ -1875,6 +2149,7 @@ if ($action === 'order') { icon: 'success', title: 'Dati aggiornati', html: `Ordini letti: ${fmtNum(o.records)}` + + (d.righe ? `
    Righe lette: ${fmtNum(d.righe.records)}` : '') + (d.importi ? `
    Totali letti: ${fmtNum(d.importi.records)}` : '') + (c.records !== undefined ? `
    Clienti aggiornati: ${fmtNum(c.records)}` : '') + (d.logoff !== 'OK' ? `
    Logoff Mago: ${esc(d.logoff)}
    ` : ''), @@ -1958,8 +2233,16 @@ if ($action === 'order') { loadFilterOptions(false).then(d => { window.__totalOrders = d && d.sync ? d.sync.total_orders : 0; - const saved = restoreFilters(); + const saved = URL_Q ? null : restoreFilters(); if (saved && saved.metric === 'value') metric = 'value'; + if (URL_Q) { + // tutto lo storico, solo l'ordine cercato; se è uno solo apro il dettaglio + $('#fFrom').val((d && d.range && d.range.min_date) || '2020-01-01'); + $('#fTo').val(iso(new Date())); + $('#periodChips .chip').removeClass('active'); + $('#fSearch').val(URL_Q); + window.__autoOpen = URL_Q; + } if (saved && saved.from) { $('#fFrom').val(saved.from); $('#fTo').val(saved.to); @@ -1968,7 +2251,7 @@ if ($action === 'order') { $('#fAreas').val(saved.areas || []).trigger('change.select2'); (saved.statuses || []).forEach(s => $(`#statusChips .chip[data-status="${s}"]`).addClass('active')); $('#fSearch').val(saved.q || ''); - } else { + } else if (!URL_Q) { setPeriod('y' + new Date().getFullYear(), true); } loadData(); diff --git a/public/userarea/sales_orders_years.php b/public/userarea/sales_orders_years.php index 6d91f07b..bc90a47e 100644 --- a/public/userarea/sales_orders_years.php +++ b/public/userarea/sales_orders_years.php @@ -584,6 +584,7 @@ if ($action === 'data') {