From e042a156c8dca0110c0cf0ad51e566f26247967a Mon Sep 17 00:00:00 2001 From: solocla Date: Tue, 27 Oct 2026 12:07:55 +0100 Subject: [PATCH] matrici sales update --- public/userarea/class/MagoMatriceStats.php | 116 ++ public/userarea/class/MagoMixPurchases.php | 21 +- public/userarea/sales_help.js | 22 +- public/userarea/sales_matrici.php | 2159 +++++++++++--------- 4 files changed, 1335 insertions(+), 983 deletions(-) diff --git a/public/userarea/class/MagoMatriceStats.php b/public/userarea/class/MagoMatriceStats.php index b19a7eb2..b93a55f3 100644 --- a/public/userarea/class/MagoMatriceStats.php +++ b/public/userarea/class/MagoMatriceStats.php @@ -7,6 +7,7 @@ * Fonte: righe ordine Mago (mago_sale_ord_detail), solo lettura. Prezzo = imponibile riga ÷ quantità. */ require_once __DIR__ . '/MagoSalesStats.php'; +require_once __DIR__ . '/MagoMixPurchases.php'; class MagoMatriceStats { @@ -292,6 +293,75 @@ class MagoMatriceStats return ['status' => ($info['gm_total'] > 0 && $info['gm_missing']) ? 'gm_part' : 'ok', 'kg' => $kg]; } + /* ============================================================ + * COSTO GREZZO (solo mescola) + * ============================================================ */ + + /** Prezzi €/kg delle mescole (vuoto se gli acquisti non sono ancora sincronizzati) */ + public static function mixPrices(PDO $pdo): array + { + try { + return MagoMixPurchases::prices($pdo); + } catch (Throwable $e) { + return []; + } + } + + /** + * Costo della mescola per unità venduta. + * - Metri: €/m = somma di g/m × €/kg ÷ 1000 per ogni mescola della scheda di lavoro. + * - Chili: €/kg = media dei €/kg delle mescole pesata sui g/m (in parti uguali se mancano i g/m). + * - Pezzi: non calcolabile (manca la conversione pezzi → metri). + * $ym: costo in un mese passato (prezzi di quel mese, altrimenti l'ultimo noto prima). + * status: ok | partial (manca g/m o prezzo di qualche mescola: costo sottostimato) | price (nessun prezzo) | gm | none | pz | altro + */ + public static function costOf(?array $mi, string $kind, array $prices, ?string $ym = null): array + { + $out = ['status' => 'none', 'cost' => null, 'parts' => [], 'missing' => [], 'check' => []]; + if ($kind === 'pz' || $kind === 'altro') return ['status' => $kind] + $out; + if (!$mi || !$mi['mix']) return $out; + if ($kind === 'm' && $mi['gm_total'] <= 0) return ['status' => 'gm'] + $out; + + $n = count($mi['mix']); + $cost = 0.0; + $priced = 0; + $gmMissing = false; + foreach ($mi['mix'] as $x) { + $p = $prices[$x['id']] ?? null; + $eur = $p ? ($ym ? MagoMixPurchases::priceAt($p, $ym) : $p['price']) : null; + $gm = $x['gm'] !== null && $x['gm'] > 0 ? (float)$x['gm'] : null; + if ($gm === null) $gmMissing = true; + if ($kind === 'm') { + $part = ($gm !== null && $eur !== null) ? $gm / 1000 * $eur : null; + } else { + $share = $mi['gm_total'] > 0 ? ($gm ?? 0) / $mi['gm_total'] : 1 / $n; + $part = $eur !== null ? $share * $eur : null; + } + if ($eur === null) $out['missing'][] = $x['name']; + if ($part !== null) { + $cost += $part; + $priced++; + } + if ($p && $p['check']) $out['check'][$p['check']] = $p['check']; + $out['parts'][] = [ + 'id' => $x['id'], + 'name' => $x['name'], + 'gm' => $gm, + 'eur_kg' => $eur !== null ? round($eur, 4) : null, + 'part' => $part !== null ? round($part, 4) : null, + 'old' => $p ? $p['old'] : null, + 'last_date' => $p['last_date'] ?? null, + 'supplier' => $p['last_supplier'] ?? null, + 'check' => $p['check'] ?? null, + ]; + } + $out['check'] = array_values($out['check']); + if ($priced === 0) return ['status' => 'price'] + $out; + $out['cost'] = round($cost, 4); + $out['status'] = ($out['missing'] || ($kind === 'm' && $gmMissing) || ($kind === 'kg' && $mi['gm_total'] > 0 && $gmMissing)) ? 'partial' : 'ok'; + return $out; + } + /* ============================================================ * DATI * ============================================================ */ @@ -367,6 +437,7 @@ class MagoMatriceStats } $mix = self::mixInfo($pdo, $ids); $ix = self::index($pdo); + $prices = self::mixPrices($pdo); $spark = []; for ($i = 11; $i >= 0; $i--) $spark[] = date('Y-m', strtotime("first day of -$i months")); @@ -398,6 +469,11 @@ class MagoMatriceStats 'gm_missing_lines' => 0, 'gm_missing_value' => 0, 'matrici' => 0, + 'cost_value' => 0, + 'cost_cover_value' => 0, + 'cost_rows' => 0, + 'cost_partial_rows' => 0, + 'check_rows' => 0, ]; $linkedIds = []; @@ -553,6 +629,18 @@ class MagoMatriceStats $pp = $a['qp'] > 0 ? $a['vp'] / $a['qp'] : null; $sp = []; for ($i = 0; $i < 12; $i++) $sp[] = $a['sq'][$i] > 0 ? round($a['sv'][$i] / $a['sq'][$i], 4) : null; + $c = self::costOf($a['id'] ? ($mix[$a['id']] ?? null) : null, $a['kind'], $prices); + $margin = ($c['cost'] !== null && $price) ? $price - $c['cost'] : null; + if ($c['cost'] !== null && $price && $a['n'] > 0) { + if ($c['status'] === 'ok') { + $kpi['cost_value'] += $a['pq'] * $c['cost']; + $kpi['cost_cover_value'] += $a['value']; + $kpi['cost_rows']++; + } else { + $kpi['cost_partial_rows']++; + } + if ($c['check']) $kpi['check_rows']++; + } $rows[] = [ 'key' => $a['key'], 'id' => $a['id'], @@ -586,6 +674,12 @@ class MagoMatriceStats 'mix_status' => $a['mix_status'], 'mix_n' => $a['mix_n'], 'gm_total' => $a['gm_total'], + 'cost' => $c['cost'], + 'cost_status' => $c['status'], + 'cost_missing' => $c['missing'], + 'cost_check' => $c['check'], + 'margin' => $margin !== null ? round($margin, 4) : null, + 'margin_pct' => $margin !== null ? round($margin / $price * 100, 1) : null, ]; } usort($rows, fn($x, $y) => $y['value'] <=> $x['value']); @@ -618,6 +712,8 @@ class MagoMatriceStats 'mescole' => $mescole, 'types' => self::TYPE_LABELS, 'cats' => self::CAT_LABELS, + 'price_months' => MagoMixPurchases::PRICE_MONTHS, + 'has_prices' => !empty($prices), ]; } @@ -648,6 +744,8 @@ class MagoMatriceStats $lines = self::fetchLines($pdo, $from, $customers, $raws, $uom); $ix = self::index($pdo); $mi = $first['id'] ? (self::mixInfo($pdo, [$first['id']])[$first['id']] ?? null) : null; + $prices = self::mixPrices($pdo); + $costNow = self::costOf($mi, $kind, $prices); $prodLines = []; if ($first['id']) { @@ -778,6 +876,8 @@ class MagoMatriceStats 'last_price' => $x['last_price'] !== null ? round($x['last_price'], 4) : null, 'var' => ($p12 !== null && $pp) ? round(($p12 - $pp) / $pp * 100, 1) : null, 'open_qty' => round($x['open_qty'], 3), + 'margin_pct' => ($p && $costNow['cost'] !== null) ? round(($p - $costNow['cost']) / $p * 100, 1) : null, + 'last_margin_pct' => ($x['last_price'] && $costNow['cost'] !== null) ? round(($x['last_price'] - $costNow['cost']) / $x['last_price'] * 100, 1) : null, ]; } usort($clients, fn($a, $b) => $b['value'] <=> $a['value']); @@ -786,6 +886,17 @@ class MagoMatriceStats $mrows = []; foreach ($months as $ym => $m) $mrows[] = ['ym' => $ym, 'price' => $m['q'] > 0 ? round($m['v'] / $m['q'], 4) : null, 'qty' => round($m['q'], 3)]; + // costo mese per mese (prezzi mescole di quel mese) dal primo mese con vendite a oggi + $costMonths = []; + if ($costNow['cost'] !== null) { + $start = $mrows ? $mrows[0]['ym'] : substr($from, 0, 7); + for ($t = strtotime($start . '-01'); date('Y-m', $t) <= date('Y-m'); $t = strtotime('+1 month', $t)) { + $ym = date('Y-m', $t); + $c = self::costOf($mi, $kind, $prices, $ym); + if ($c['cost'] !== null) $costMonths[] = ['ym' => $ym, 'cost' => $c['cost']]; + } + } + $mixRows = []; if ($mi) { foreach ($mi['mix'] as $x) { @@ -824,6 +935,11 @@ class MagoMatriceStats 'gm_total' => $mi['gm_total'] ?? 0, 'mix_status' => $status, ], + 'cost' => $costNow + [ + 'months' => $costMonths, + 'price_months' => MagoMixPurchases::PRICE_MONTHS, + 'margin_pct' => ($avg && $costNow['cost'] !== null) ? round(($avg - $costNow['cost']) / $avg * 100, 1) : null + ], 'tot' => [ 'n' => $tot['n'], 'qty' => round($tot['qty'], 3), diff --git a/public/userarea/class/MagoMixPurchases.php b/public/userarea/class/MagoMixPurchases.php index ae3f6dce..5abb13af 100644 --- a/public/userarea/class/MagoMixPurchases.php +++ b/public/userarea/class/MagoMixPurchases.php @@ -21,6 +21,9 @@ class MagoMixPurchases /** Sync incrementale: rilegge le fatture degli ultimi N giorni */ const SYNC_DAYS = 90; + /** Fornitori il cui prezzo va verificato (es. conto lavoro: il prezzo non comprende le materie prime) */ + const CHECK_SUPPLIERS = ['D022' => 'Der-Gom']; + /** Righe che non sono una mescola da prezzare */ const EXCLUDE_RE = '/RECUPER|RITAGLI|\bMIX\b|\bREC\b|DA FILTRARE|RILAVORAR|RESO NON LAVORATO|NON STANDARD|SCADENZA|VARIE|ACCELERANTI|\bPROVA\b/i'; @@ -149,7 +152,8 @@ class MagoMixPurchases /** * €/kg per mescola. - * @return array [idmescola => price, last_price, last_date, kg_window, kg_total, old (bool), months [ym => €/kg], suppliers [nome => €/kg]] + * @return array [idmescola => price, last_price, last_date, kg_window, kg_total, old (bool), months [ym => €/kg], suppliers [nome => €/kg], + * last_supplier, check (nome fornitore da verificare o null)] */ public static function prices(PDO $pdo, int $months = self::PRICE_MONTHS, ?array $lines = null): array { @@ -172,6 +176,8 @@ class MagoMixPurchases if (($a['last_date'] ?? '') <= $l['doc_date']) { $a['last_date'] = $l['doc_date']; $a['last_price'] = $l['value'] / $l['qty']; + $a['last_sc'] = (string)$l['supplier_code']; + $a['last_sn'] = (string)($l['supplier_name'] ?: $l['supplier_code']); } $ym = substr((string)$l['doc_date'], 0, 7); $a['m'][$ym]['kg'] = ($a['m'][$ym]['kg'] ?? 0) + $l['qty']; @@ -195,11 +201,24 @@ class MagoMixPurchases 'old' => !$win, 'months' => $ms, 'suppliers' => $sup, + 'last_supplier' => $a['last_sn'], + 'check' => self::CHECK_SUPPLIERS[$a['last_sc']] ?? null, ]; } return $out; } + /** Prezzo di una mescola in un mese: quello del mese, altrimenti l'ultimo dei mesi precedenti */ + public static function priceAt(array $p, string $ym): ?float + { + $best = null; + foreach ($p['months'] as $m => $v) { + if ($m > $ym) break; + $best = $v; + } + return $best; + } + /** Descrizioni d'acquisto non abbinate (o ambigue), raggruppate per parole, ordinate per valore */ public static function unmatched(PDO $pdo, ?array $lines = null): array { diff --git a/public/userarea/sales_help.js b/public/userarea/sales_help.js index cccfd51d..40e1d9b8 100644 --- a/public/userarea/sales_help.js +++ b/public/userarea/sales_help.js @@ -467,6 +467,23 @@ b: `

Quanta parte del venduto è collegata a una matrice dell'anagrafica (verde), quanta è tubi, rettangoli, diametri e altri profili (viola), quanta resta da sistemare (arancio).

Cosa fare: clic sul box per la lista "Da sistemare" e completare l'anagrafica matrici o le corrispondenze.

`, }, + "mx.k.margin": { + t: "Margine sul grezzo", + b: `

Quanto resta del prezzo di vendita dopo il costo della sola mescola, sulle matrici dove il costo è completo.

+ +

Non comprende scarto, lavorazione, energia e trasporto: è il margine "lordo di mescola".

`, + }, + "mx.margin": { + t: "Margine sul costo mescola", + b: `

Margine più basso: le matrici dove il prezzo copre meno il costo della mescola. Sono le prime da rivedere nei listini.

+

Più margine in euro: le matrici che portano più margine nel periodo (margine unitario × quantità).

+

🟢 da 60% · 🟡 da 40 a 60% · 🔴 sotto 40%.

+

Contano solo le matrici con costo completo. Clic su una matrice per il dettaglio con la linea del costo nel grafico.

`, + }, "mx.map": { t: "Mappa dei prezzi", b: `

Ogni bolla è una matrice venduta nel periodo (le prime 150 per valore).

@@ -488,6 +505,8 @@
  • Prezzo medio: imponibile ÷ quantità del periodo.
  • Forbice: prezzo minimo e massimo praticato. Barra verde fino al 10% di differenza, gialla fino al 25%, rossa oltre.
  • 12 mesi: prezzo mese per mese e variazione sui 12 mesi precedenti.
  • +
  • Costo mescola: €/m o €/kg della sola mescola. parziale = manca il prezzo o il g/m di qualche mescola (costo sottostimato).
  • +
  • Margine: (prezzo − costo mescola) ÷ prezzo.
  • Mescola: kg stimati, oppure il motivo per cui mancano (a pezzi, g/m mancante).
  • La stessa matrice venduta a metri e a chili compare su due righe.

    `, @@ -512,7 +531,8 @@ t: "Prezzo nel tempo", b: `

    Ogni punto è una riga d'ordine: altezza = prezzo, colore = cliente (i primi 8 per valore, gli altri insieme).

    La linea scura è il prezzo medio del mese. Punti sotto la linea = clienti che pagano meno della media.

    -

    Nella tabella Clienti, "Rispetto alla media" mostra di quanto ognuno paga più o meno.

    `, +

    La linea rossa tratteggiata è il costo della mescola mese per mese (prezzi pagati in quel mese): la distanza dalla linea dei prezzi è il margine.

    +

    Nella tabella Clienti, "Rispetto alla media" mostra di quanto ognuno paga più o meno, "Margine" quanto resta dopo la mescola.

    `, }, }; diff --git a/public/userarea/sales_matrici.php b/public/userarea/sales_matrici.php index 83c80e89..4d53a120 100644 --- a/public/userarea/sales_matrici.php +++ b/public/userarea/sales_matrici.php @@ -268,10 +268,65 @@ if ($action === 'list' || $action === 'detail') { @media (min-width: 1400px) { .mxk-grid { - grid-template-columns: repeat(6, 1fr); + grid-template-columns: repeat(7, 1fr); } } + .mg-pill { + display: inline-block; + font-weight: 800; + font-size: .8rem; + border-radius: 999px; + padding: 2px 9px; + white-space: nowrap; + } + + .mg-hi { + background: #dcfce7; + color: #166534; + } + + .mg-mid { + background: #fef9c3; + color: #854d0e; + } + + .mg-lo { + background: #fee2e2; + color: #991b1b; + } + + .tb-check { + background: #ffedd5; + color: #9a3412; + } + + .mg-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 12px; + cursor: pointer; + border: 1px solid transparent; + } + + .mg-item:hover { + background: #f8fafc; + border-color: var(--c-line); + } + + .mg-item .nm { + font-weight: 700; + color: var(--c-ink); + line-height: 1.2; + } + + .mg-item .pr { + font-size: .8rem; + color: var(--c-muted); + } + .mxk { background: color-mix(in srgb, var(--k) 11%, #fff); border: 1px solid color-mix(in srgb, var(--k) 26%, #fff); @@ -881,13 +936,19 @@ if ($action === 'list' || $action === 'detail') { -
    -
    -
    - -
    -
    Margine per matrice e per cliente · in arrivo
    -
    Quando avremo il costo delle mescole (€/kg), qui vedrai costo grezzo al metro, prezzo di vendita e margine per ogni matrice e cliente. I kg di mescola sono già calcolati.
    +
    +
    +
    Margine sul costo mescola
    +
    Prezzo di vendita meno il costo della sola mescola (g/m della scheda di lavoro × €/kg pagato). Clic su una matrice per il dettaglio.
    + +
    +
    +
    Margine più basso
    +
    +
    +
    +
    Più margine in euro
    +
    @@ -926,682 +987,751 @@ if ($action === 'list' || $action === 'detail') { - +