matrici sales update

This commit is contained in:
2026-10-27 12:07:55 +01:00
parent 8502295342
commit e042a156c8
4 changed files with 1335 additions and 983 deletions
+116
View File
@@ -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),
+20 -1
View File
@@ -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
{
+21 -1
View File
@@ -467,6 +467,23 @@
b: `<p>Quanta parte del venduto è collegata a una <b>matrice dell'anagrafica</b> (verde), quanta è tubi, rettangoli, diametri e altri profili (viola), quanta resta da sistemare (arancio).</p>
<p class="sh-note">Cosa fare: clic sul box per la lista "Da sistemare" e completare l'anagrafica matrici o le corrispondenze.</p>`,
},
"mx.k.margin": {
t: "Margine sul grezzo",
b: `<p>Quanto resta del prezzo di vendita dopo il <b>costo della sola mescola</b>, sulle matrici dove il costo è completo.</p>
<ul>
<li>Costo mescola = g/m della scheda di lavoro × €/kg pagato in Mago (media degli ultimi 3 mesi).</li>
<li><b>Calcolato sul</b>: quota del venduto che ha un costo completo (scheda con g/m e prezzo di tutte le mescole).</li>
<li><span class="sh-chip sh-orange">da verificare</span> mescole il cui prezzo potrebbe non comprendere le materie prime (es. Der-Gom).</li>
</ul>
<p class="sh-note">Non comprende scarto, lavorazione, energia e trasporto: è il margine "lordo di mescola".</p>`,
},
"mx.margin": {
t: "Margine sul costo mescola",
b: `<p><b>Margine più basso</b>: le matrici dove il prezzo copre meno il costo della mescola. Sono le prime da rivedere nei listini.</p>
<p><b>Più margine in euro</b>: le matrici che portano più margine nel periodo (margine unitario × quantità).</p>
<p>🟢 da 60% · 🟡 da 40 a 60% · 🔴 sotto 40%.</p>
<p class="sh-note">Contano solo le matrici con costo completo. Clic su una matrice per il dettaglio con la linea del costo nel grafico.</p>`,
},
"mx.map": {
t: "Mappa dei prezzi",
b: `<p>Ogni bolla è una matrice venduta nel periodo (le prime 150 per valore).</p>
@@ -488,6 +505,8 @@
<li><b>Prezzo medio</b>: imponibile ÷ quantità del periodo.</li>
<li><b>Forbice</b>: prezzo minimo e massimo praticato. Barra <span class="sh-chip sh-green">verde</span> fino al 10% di differenza, <span class="sh-chip sh-amber">gialla</span> fino al 25%, <span class="sh-chip sh-red">rossa</span> oltre.</li>
<li><b>12 mesi</b>: prezzo mese per mese e variazione sui 12 mesi precedenti.</li>
<li><b>Costo mescola</b>: €/m o €/kg della sola mescola. <span class="sh-chip sh-red">parziale</span> = manca il prezzo o il g/m di qualche mescola (costo sottostimato).</li>
<li><b>Margine</b>: (prezzo − costo mescola) ÷ prezzo.</li>
<li><b>Mescola</b>: kg stimati, oppure il motivo per cui mancano (<span class="sh-chip sh-amber">a pezzi</span>, <span class="sh-chip sh-red">g/m mancante</span>).</li>
</ul>
<p class="sh-note">La stessa matrice venduta a metri e a chili compare su due righe.</p>`,
@@ -512,7 +531,8 @@
t: "Prezzo nel tempo",
b: `<p>Ogni punto è una <b>riga d'ordine</b>: altezza = prezzo, colore = cliente (i primi 8 per valore, gli altri insieme).</p>
<p>La linea scura è il <b>prezzo medio del mese</b>. Punti sotto la linea = clienti che pagano meno della media.</p>
<p class="sh-note">Nella tabella Clienti, "Rispetto alla media" mostra di quanto ognuno paga più o meno.</p>`,
<p>La <b>linea rossa tratteggiata</b> è il costo della mescola mese per mese (prezzi pagati in quel mese): la distanza dalla linea dei prezzi è il margine.</p>
<p class="sh-note">Nella tabella Clienti, "Rispetto alla media" mostra di quanto ognuno paga più o meno, "Margine" quanto resta dopo la mescola.</p>`,
},
};
File diff suppressed because it is too large Load Diff