From abd09769f267509a0ae86d03ec584781f59512fc Mon Sep 17 00:00:00 2001 From: solocla Date: Thu, 15 Oct 2026 10:20:41 +0200 Subject: [PATCH] added scheda cliente --- public/userarea/class/MagoSalesStats.php | 75 +- public/userarea/include/navbar.php | 5 + public/userarea/sales_customer.php | 1928 +++++++++++++++++++ public/userarea/sales_orders_deliveries.php | 14 +- public/userarea/sales_orders_stats.php | 16 +- public/userarea/sales_orders_years.php | 16 +- 6 files changed, 2023 insertions(+), 31 deletions(-) create mode 100644 public/userarea/sales_customer.php diff --git a/public/userarea/class/MagoSalesStats.php b/public/userarea/class/MagoSalesStats.php index b1a047ea..3ac57efe 100644 --- a/public/userarea/class/MagoSalesStats.php +++ b/public/userarea/class/MagoSalesStats.php @@ -95,32 +95,16 @@ class MagoSalesStats $by[$c]['orders'][] = $r; } - $today = new DateTime('today'); $out = []; foreach ($by as $c) { - $dates = array_keys($c['dates']); - sort($dates); - $n = count($dates); - if ($n < self::DORMANT_MIN_ORDERS) continue; - - $gaps = []; - for ($k = 1; $k < $n; $k++) { - $gaps[] = (int)(new DateTime($dates[$k - 1]))->diff(new DateTime($dates[$k]))->days; - } - sort($gaps); - $m = count($gaps); - $median = $m % 2 ? $gaps[intdiv($m, 2)] : ($gaps[$m / 2 - 1] + $gaps[$m / 2]) / 2; - $rhythm = max(7, (float)$median); - - $last = end($dates); - $since = (int)(new DateTime($last))->diff($today)->days; - if ($since < self::DORMANT_MIN_DAYS) continue; - - $ratio = $since / $rhythm; - if ($ratio >= self::DORMANT_LOST || $since >= 365) $level = 'lost'; - elseif ($ratio >= self::DORMANT_SLEEP) $level = 'dormant'; - elseif ($ratio >= self::DORMANT_WATCH) $level = 'watch'; - else continue; + $info = self::rhythmInfo(array_keys($c['dates'])); + if (!$info || !in_array($info['level'], ['watch', 'dormant', 'lost'], true)) continue; + $last = $info['last_date']; + $since = $info['days_since']; + $rhythm = $info['rhythm']; + $ratio = $info['ratio']; + $level = $info['level']; + $n = $info['orders']; // ordinato nei 12 mesi fino all'ultimo ordine = quanto vale il cliente "a regime" $from12 = (new DateTime($last))->modify('-365 days')->format('Y-m-d'); @@ -142,8 +126,8 @@ class MagoSalesStats 'last_date' => $last, 'last_no' => $lastOrder['ord_no'], 'days_since' => $since, - 'rhythm' => (int)round($rhythm), - 'ratio' => round($ratio, 1), + 'rhythm' => $rhythm, + 'ratio' => $ratio, 'orders' => $n, 'value12' => round($v12, 2), 'level' => $level, @@ -155,6 +139,45 @@ class MagoSalesStats return $out; } + /** + * Ritmo d'ordine di un cliente a partire dalle date d'ordine (Y-m-d) degli ultimi DORMANT_WINDOW_YEARS anni. + * level: 'ok' | 'watch' | 'dormant' | 'lost' | 'few' (troppo pochi ordini per stimare un ritmo) + */ + public static function rhythmInfo(array $dates) + { + $dates = array_values(array_unique($dates)); + sort($dates); + $n = count($dates); + if (!$n) return null; + + $today = new DateTime('today'); + $last = end($dates); + $since = (int)(new DateTime($last))->diff($today)->days; + $out = ['orders' => $n, 'last_date' => $last, 'days_since' => $since, 'rhythm' => null, 'ratio' => null, 'level' => 'few']; + if ($n < self::DORMANT_MIN_ORDERS) return $out; + + $gaps = []; + for ($k = 1; $k < $n; $k++) { + $gaps[] = (int)(new DateTime($dates[$k - 1]))->diff(new DateTime($dates[$k]))->days; + } + sort($gaps); + $m = count($gaps); + $median = $m % 2 ? $gaps[intdiv($m, 2)] : ($gaps[$m / 2 - 1] + $gaps[$m / 2]) / 2; + $rhythm = max(7, (float)$median); + $ratio = $since / $rhythm; + + if ($since < self::DORMANT_MIN_DAYS) $level = 'ok'; + elseif ($ratio >= self::DORMANT_LOST || $since >= 365) $level = 'lost'; + elseif ($ratio >= self::DORMANT_SLEEP) $level = 'dormant'; + elseif ($ratio >= self::DORMANT_WATCH) $level = 'watch'; + else $level = 'ok'; + + $out['rhythm'] = (int)round($rhythm); + $out['ratio'] = round($ratio, 1); + $out['level'] = $level; + return $out; + } + /** * Previsione di fine anno: valore dell'anno in corso ad oggi diviso la quota che, negli anni * precedenti (max 3), si era raggiunta alla stessa data. $rows: [y, full, ytd]. diff --git a/public/userarea/include/navbar.php b/public/userarea/include/navbar.php index 0c8920a5..8a0e082a 100644 --- a/public/userarea/include/navbar.php +++ b/public/userarea/include/navbar.php @@ -235,6 +235,11 @@ Confronto anni +
  • + + Scheda cliente + +
  • Consegne diff --git a/public/userarea/sales_customer.php b/public/userarea/sales_customer.php new file mode 100644 index 00000000..7eb608d8 --- /dev/null +++ b/public/userarea/sales_customer.php @@ -0,0 +1,1928 @@ +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; +} +$canDeliveries = userCan('sales.deliveries.view'); + +/* ============================================================ + * AJAX + * ============================================================ */ + +$action = $_GET['action'] ?? ''; + +if ($action === 'filters') { + MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo)); +} + +if ($action === 'data') { + try { + $c = trim((string)($_GET['c'] ?? '')); + if ($c === '') MagoSalesStats::jsonOut(['success' => false, 'message' => 'Cliente non indicato']); + $p = ['c' => $c]; + $curY = (int)date('Y'); + $from = MagoSalesStats::baseFrom(); + $val = "CASE WHEN x.status <> 'annullato' THEN x.taxable_amount ELSE 0 END"; + + // --- anagrafica --- + $st = $pdo->prepare("SELECT cust_supp AS code, company_name AS name, city, county, region, country + FROM mago_cust_supp WHERE cust_supp_type = " . MagoSalesStats::CUSTOMER_TYPE . " AND cust_supp = ?"); + $st->execute([$c]); + $info = $st->fetch(PDO::FETCH_ASSOC) ?: ['code' => $c, 'name' => $c, 'city' => '', 'county' => '', 'region' => '', 'country' => '']; + + // --- ordini del cliente (dal 2020, "listino" esclusi) --- + $orders = MagoSalesStats::fetchAllQ($pdo, " + SELECT x.sale_ord_id, x.internal_ord_no, x.external_ord_no, x.your_reference, x.order_date, + x.salesperson, x.area, x.status, x.invoiced, x.taxable_amount, x.is_late, + x.open_lines, x.late_lines, x.zibo_date, x.client_date, (x.summary_tb_modified IS NULL) AS no_amount + FROM $from + WHERE x.customer = :c + ORDER BY x.order_date DESC, x.sale_ord_id DESC + ", $p); + + if (!$orders) { + $hasInv = false; + try { + $q = $pdo->prepare("SELECT 1 FROM mago_sale_doc WHERE customer = ? LIMIT 1"); + $q->execute([$c]); + $hasInv = (bool)$q->fetchColumn(); + } catch (Throwable $e) { + } + if (!$hasInv) MagoSalesStats::jsonOut(['success' => false, 'message' => 'Nessun dato per questo cliente']); + } + + $last = $orders[0] ?? null; + $firstDate = $orders ? end($orders)['order_date'] : null; + + // ritmo d'ordine (ultimi N anni, annullati esclusi) + $limit = date('Y-m-d', strtotime('-' . MagoSalesStats::DORMANT_WINDOW_YEARS . ' years')); + $dates = []; + foreach ($orders as $o) { + if ($o['status'] !== 'annullato' && $o['order_date'] >= $limit) $dates[] = $o['order_date']; + } + $rhythm = MagoSalesStats::rhythmInfo($dates); + + // --- ordinato per anno (totale e a pari data) --- + $ordYears = MagoSalesStats::fetchAllQ($pdo, " + SELECT YEAR(x.order_date) AS y, SUM($val) AS full, SUM(x.status <> 'annullato') AS n, + SUM(CASE WHEN DATE_FORMAT(x.order_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d') THEN $val ELSE 0 END) AS ytd + FROM $from WHERE x.customer = :c GROUP BY y ORDER BY y + ", $p); + + $since24 = date('Y-m-01', strtotime('first day of -23 months')); + $ordMonths = MagoSalesStats::fetchAllQ($pdo, " + SELECT DATE_FORMAT(x.order_date, '%Y-%m') AS ym, SUM($val) AS v + FROM $from WHERE x.customer = :c AND x.order_date >= '$since24' + GROUP BY ym ORDER BY ym + ", $p); + + // --- fatturato (se le fatture sono sincronizzate) --- + $inv = ['available' => false, 'years' => [], 'months' => [], 'docs' => [], 'v12' => 0, 'cn12' => 0, 'cn12_n' => 0]; + try { + $iFrom = MagoSalesStats::invoiceFrom(); + $inv['available'] = (bool)$pdo->query("SELECT 1 FROM mago_sale_doc LIMIT 1")->fetchColumn(); + if ($inv['available']) { + $inv['years'] = MagoSalesStats::fetchAllQ($pdo, " + SELECT YEAR(i.document_date) AS y, SUM(i.revenue) AS full, + SUM(CASE WHEN DATE_FORMAT(i.document_date, '%m-%d') <= DATE_FORMAT(CURDATE(), '%m-%d') THEN i.revenue ELSE 0 END) AS ytd + FROM $iFrom WHERE i.customer = :c GROUP BY y ORDER BY y + ", $p); + $inv['months'] = MagoSalesStats::fetchAllQ($pdo, " + SELECT DATE_FORMAT(i.document_date, '%Y-%m') AS ym, SUM(i.revenue) AS v + FROM $iFrom WHERE i.customer = :c AND i.document_date >= '$since24' + GROUP BY ym ORDER BY ym + ", $p); + $inv['docs'] = MagoSalesStats::fetchAllQ($pdo, " + SELECT i.document_date, i.doc_no, i.document_type, i.is_credit_note, i.revenue, i.no_amount + FROM $iFrom WHERE i.customer = :c AND i.document_date >= CURDATE() - INTERVAL 3 YEAR + ORDER BY i.document_date DESC, i.doc_no DESC + ", $p); + $r12 = MagoSalesStats::fetchAllQ($pdo, " + SELECT COALESCE(SUM(CASE WHEN i.is_credit_note = 0 THEN i.revenue END), 0) AS v, + COALESCE(SUM(CASE WHEN i.is_credit_note = 1 THEN -i.revenue END), 0) AS cn, + SUM(i.is_credit_note = 1) AS cn_n + FROM $iFrom WHERE i.customer = :c AND i.document_date > CURDATE() - INTERVAL 365 DAY + ", $p)[0]; + $inv['v12'] = (float)$r12['v']; + $inv['cn12'] = (float)$r12['cn']; + $inv['cn12_n'] = (int)$r12['cn_n']; + } + } catch (Throwable $e) { + $inv['available'] = false; + } + + // --- righe da consegnare --- + $open = MagoSalesStats::fetchAllQ($pdo, " + SELECT r.sale_ord_id, r.internal_ord_no, r.order_date, 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.status, r.your_reference, + CASE WHEN r.due_date < CURDATE() THEN DATEDIFF(CURDATE(), r.due_date) ELSE 0 END AS late_days + FROM " . MagoSalesStats::openLinesFrom() . " + WHERE r.customer = :c + ORDER BY r.due_date IS NULL, r.due_date, r.internal_ord_no + ", $p); + + // --- articoli (righe ordine non annullate) --- + $items = MagoSalesStats::fetchAllQ($pdo, " + SELECT d.item, MAX(d.description) AS description, MAX(d.uom) AS uom, + COUNT(DISTINCT d.sale_ord_id) AS n_orders, + SUM(d.qty) AS qty, SUM(d.taxable_amount) AS value, + MAX(x.order_date) AS last_date, + SUM(CASE WHEN x.order_date > CURDATE() - INTERVAL 365 DAY THEN d.taxable_amount END) AS v12, + SUM(CASE WHEN x.order_date > CURDATE() - INTERVAL 365 DAY THEN d.qty END) AS q12, + SUM(CASE WHEN x.order_date <= CURDATE() - INTERVAL 365 DAY AND x.order_date > CURDATE() - INTERVAL 730 DAY THEN d.taxable_amount END) AS vp, + SUM(CASE WHEN x.order_date <= CURDATE() - INTERVAL 365 DAY AND x.order_date > CURDATE() - INTERVAL 730 DAY THEN d.qty END) AS qp + FROM mago_sale_ord_detail d + JOIN $from ON x.sale_ord_id = d.sale_ord_id + WHERE x.customer = :c AND x.status <> 'annullato' AND d.cancelled = 0 AND d.item <> '' + GROUP BY d.item + ORDER BY value DESC + LIMIT 500 + ", $p); + foreach ($items as &$it) { + $it['price12'] = (float)$it['q12'] > 0 ? round((float)$it['v12'] / (float)$it['q12'], 4) : null; + $it['pricep'] = (float)$it['qp'] > 0 ? round((float)$it['vp'] / (float)$it['qp'], 4) : null; + } + unset($it); + + MagoSalesStats::jsonOut([ + 'success' => true, + 'today' => date('Y-m-d'), + 'info' => $info, + 'last' => $last ? ['salesperson' => $last['salesperson'], 'area' => $last['area'], 'date' => $last['order_date'], 'no' => $last['internal_ord_no']] : null, + 'first' => $firstDate, + 'rhythm' => $rhythm, + 'rhythm_cfg' => ['years' => MagoSalesStats::DORMANT_WINDOW_YEARS, 'min_orders' => MagoSalesStats::DORMANT_MIN_ORDERS], + 'ordYears' => $ordYears, + 'ordMonths' => $ordMonths, + 'since' => $since24, + 'inv' => $inv, + 'types' => MagoSalesStats::INVOICE_TYPE_LABELS, + 'orders' => array_slice($orders, 0, 300), + 'open' => $open, + 'items' => $items, + ]); + } catch (Throwable $e) { + MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]); + } +} + +?> + + + + + + + + + + Scheda cliente - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?> + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +
    +
    +
    + + +
    +
    +
    +
    +
    +
    Statistiche Ordini Cliente
    + Dati Mago4 +
    +
    + +
    +
    + +
    + + + + +
    + + +
    +
    + +
    + +
    Scegli un cliente
    +
    Trovi tutto su di lui in una pagina: ordinato e fatturato, ordini aperti, articoli, fatture.
    +
    + + + +
    +
    +
    +
    + + +
    + + + + + + + \ No newline at end of file diff --git a/public/userarea/sales_orders_deliveries.php b/public/userarea/sales_orders_deliveries.php index 7297890e..e6b11d67 100644 --- a/public/userarea/sales_orders_deliveries.php +++ b/public/userarea/sales_orders_deliveries.php @@ -589,6 +589,17 @@ if ($action === 'data') { .order-link:hover { text-decoration: underline; } + + .sc-link { + color: #198754; + margin-left: 6px; + opacity: .75; + text-decoration: none; + } + + .sc-link:hover { + opacity: 1; + } @@ -620,6 +631,7 @@ if ($action === 'data') { Andamento ordini Confronto anni + Scheda cliente Consegne @@ -1154,7 +1166,7 @@ if ($action === 'data') { (CAN_STATS ? `${esc(r.internal_ord_no)}` : `${esc(r.internal_ord_no)}`) + `
    ${fmtDate(r.order_date)}
    `, - `
    ${esc(r.company_name)}
    ${esc(r.customer)}
    `, + `
    ${esc(r.company_name)}
    ${esc(r.customer)}${CAN_STATS ? `` : ''}
    `, `${esc(r.item)}
    ${esc(r.description)}
    `, `${fmtQty(r.residual_qty)} ${esc(r.uom)}
    di ${fmtQty(r.qty)}
    `, `${dateCell(r.zibo_date, r.client_date)}`, diff --git a/public/userarea/sales_orders_stats.php b/public/userarea/sales_orders_stats.php index d4a49799..2cd2eb33 100644 --- a/public/userarea/sales_orders_stats.php +++ b/public/userarea/sales_orders_stats.php @@ -875,6 +875,17 @@ if ($action === 'order') { grid-template-columns: 1fr; } } + + .sc-link { + color: #198754; + margin-left: 6px; + opacity: .75; + text-decoration: none; + } + + .sc-link:hover { + opacity: 1; + } @@ -923,6 +934,7 @@ if ($action === 'order') {
    Andamento ordini Confronto anni + Scheda cliente Consegne @@ -2006,7 +2018,7 @@ if ($action === 'order') { 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.company_name)}
    ${esc(r.customer)}
    `, esc(r.your_reference), `${dateCell(r.zibo_date, r.client_date, { open: r.status === 'aperto' || r.status === 'bloccato', @@ -2119,7 +2131,7 @@ if ($action === 'order') {

    Ordine ${esc(o.internal_ord_no)}

    -
    ${esc(o.company_name)} (${esc(o.customer)})
    +
    ${STATUS_LABEL[o.status] || esc(o.status)} diff --git a/public/userarea/sales_orders_years.php b/public/userarea/sales_orders_years.php index 0581f6b0..ff198368 100644 --- a/public/userarea/sales_orders_years.php +++ b/public/userarea/sales_orders_years.php @@ -933,6 +933,17 @@ if ($action === 'data') { padding: 60px 20px; color: var(--c-muted); } + + .sc-link { + color: #198754; + margin-left: 6px; + opacity: .75; + text-decoration: none; + } + + .sc-link:hover { + opacity: 1; + } @@ -964,6 +975,7 @@ if ($action === 'data') {
    Andamento ordini Confronto anni + Scheda cliente Consegne @@ -1774,7 +1786,7 @@ if ($action === 'data') { title: 'Cliente', data: null, render: (c, type) => type === 'display' ? - `
    ${esc(c.name)}
    ${esc(c.code)}
    ` : (type === 'export' ? `${c.name} (${c.code})` : `${c.name} ${c.code}`) + `
    ${esc(c.name)}
    ${esc(c.code)}
    ` : (type === 'export' ? `${c.name} (${c.code})` : `${c.name} ${c.code}`) }, { title: 'Agente', data: 'salesperson' @@ -2178,7 +2190,7 @@ if ($action === 'data') { title: 'Cliente', data: null, render: (c, type) => type === 'display' ? - `
    ${esc(c.name)}
    ${esc(c.code)}
    ` : (type === 'export' ? `${c.name} (${c.code})` : `${c.name} ${c.code}`) + `
    ${esc(c.name)}
    ${esc(c.code)}
    ` : (type === 'export' ? `${c.name} (${c.code})` : `${c.name} ${c.code}`) }]; years.slice().reverse().forEach(y => columns.push({ title: `● ${y}`,