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>`,
},
};
+218 -21
View File
@@ -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') {
</div>
</div>
<div class="col-12" data-widget="margin" data-title="Margine per matrice">
<div class="margin-card">
<div class="d-flex align-items-center gap-3 flex-wrap">
<i class="fa-solid fa-scale-balanced fa-2x"></i>
<div>
<div class="fw-bold">Margine per matrice e per cliente · in arrivo</div>
<div class="small">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.</div>
<div class="col-12" data-widget="margin" data-title="Margine sul costo mescola">
<div class="chart-card">
<div class="chart-title" data-help="mx.margin">Margine sul costo mescola</div>
<div class="chart-hint mb-2">Prezzo di vendita meno il costo della sola mescola (g/m della scheda di lavoro × €/kg pagato). Clic su una matrice per il dettaglio.</div>
<div id="mgEmpty" class="margin-card" style="display:none;"></div>
<div class="row g-3" id="mgLists">
<div class="col-lg-6">
<h6 class="fw-bold text-danger mb-1"><i class="fa-solid fa-triangle-exclamation me-1"></i> Margine più basso</h6>
<div id="mgLow"></div>
</div>
<div class="col-lg-6">
<h6 class="fw-bold text-success mb-1"><i class="fa-solid fa-trophy me-1"></i> Più margine in euro</h6>
<div id="mgTop"></div>
</div>
</div>
</div>
@@ -1220,6 +1281,7 @@ if ($action === 'list' || $action === 'detail') {
renderMap();
renderMoves();
renderTable();
renderMargin();
}
function renderBanner() {
@@ -1253,12 +1315,70 @@ if ($action === 'list' || $action === 'detail') {
`ancora da produrre: <b>${fmtShort(k.kg_open, 'kg')}</b><br>per ${fmtEurShort(k.open_value)} di ordini aperti`, '', 'mx.k.mix'),
card('#ec4899', 'fa-cubes', 'Venduto a pezzi', fmtNum(k.pz_lines) + ' righe',
`${fmtEurShort(k.pz_value)} · <span class="tb tb-pz">mescole in attesa</span><br>serve la conversione pezzi → metri`, 'data-goto-u="pz"', 'mx.k.pz'),
marginCard(card, k),
card('#64748b', 'fa-link', 'Copertura anagrafica', fmtNum(pl, 0) + '%',
`del venduto è collegato a una matrice<div class="mxbar"><i style="width:${pl}%;background:#22c55e"></i><i style="width:${pp}%;background:#a78bfa"></i><i style="width:${pu}%;background:#f97316"></i></div>
<span style="color:#7c3aed">tubi/rett./diam. ${fmtNum(pp, 0)}%</span> · <span style="color:#ea580c">da sistemare ${fmtNum(pu, 0)}%</span>${k.gm_missing_lines ? `<br><span class="tb tb-gm">${fmtNum(k.gm_missing_lines)} righe senza g/m</span>` : ''}`, 'data-goto-t="todo"', 'mx.k.cover')
].join(''));
}
function marginCard(card, k) {
if (!data.has_prices) return card('#16a34a', 'fa-scale-balanced', 'Margine sul grezzo', '—', 'Prezzi mescole non ancora disponibili:<br>serve il sync acquisti', '', 'mx.k.margin');
const pct = k.cost_cover_value > 0 ? (k.cost_cover_value - k.cost_value) / k.cost_cover_value * 100 : null;
const cov = k.value > 0 ? k.cost_cover_value / k.value * 100 : 0;
return card(pct !== null && pct < 50 ? '#dc2626' : '#16a34a', 'fa-scale-balanced', 'Margine sul grezzo', pct === null ? '—' : fmtNum(pct, 0) + '%',
`costo mescola <b>${fmtEurShort(k.cost_value)}</b><br>calcolato sul <b>${fmtNum(cov, 0)}%</b> del venduto${k.check_rows ? `<br><span class="tb tb-check">${fmtNum(k.check_rows)} con prezzo da verificare</span>` : ''}`, '', 'mx.k.margin');
}
function marginSpan(pct) {
if (pct === null || pct === undefined) return '<span class="text-muted">—</span>';
const c = pct >= 60 ? 'mg-hi' : pct >= 40 ? 'mg-mid' : 'mg-lo';
return `<span class="mg-pill ${c}">${fmtNum(pct, 0)}%</span>`;
}
const COST_ST = {
pz: 'a pezzi: in attesa',
gm: 'g/m mancante',
none: 'senza mescole',
price: 'prezzo mescola mancante',
altro: 'UM non gestita'
};
function costCell(r) {
if (r.cost === null || r.cost === undefined) {
if (!r.id) return '<span class="text-muted">—</span>';
return `<span class="tb ${r.cost_status === 'pz' ? 'tb-pz' : 'tb-gm'}">${COST_ST[r.cost_status] || '—'}</span>`;
}
const ul = UOM_LABEL[r.kind] || r.uom;
let b = '';
if (r.cost_status === 'partial') b += ` <span class="tb tb-gm" title="${esc((r.cost_missing || []).length ? 'Senza prezzo: ' + r.cost_missing.join(', ') : 'Manca il g/m di qualche mescola')}">parziale</span>`;
if ((r.cost_check || []).length) b += ` <span class="tb tb-check" title="Prezzo ${esc(r.cost_check.join(', '))} da verificare (conto lavoro?)">da verificare</span>`;
return `<b>${fmtPrice(r.cost)}</b><span class="small text-muted">/${ul}</span>${b}`;
}
function renderMargin() {
const rows = rowsFiltered().filter(r => r.margin_pct !== null && r.n > 0 && r.cost_status === 'ok');
if (!data.has_prices || !rows.length) {
$('#mgLists').hide();
$('#mgEmpty').show().html(!data.has_prices ?
'<b>Prezzi mescole non ancora disponibili.</b> Si attivano con il sync acquisti (mago_sync_mix_cli.php).' :
'<b>Nessuna matrice con costo completo</b> nei filtri scelti: servono scheda di lavoro con g/m e prezzo di tutte le mescole.');
return;
}
$('#mgEmpty').hide();
$('#mgLists').show();
const item = (r, right) => `<div class="mg-item" data-k="${esc(r.key)}">${thumb(r.photo, 36)}
<div style="min-width:0;flex:1;"><div class="nm text-truncate">${esc(r.name)} <span class="uom">${esc(r.uom)}</span></div>
<div class="pr">vendita ${fmtPrice(r.price)} · costo ${fmtPrice(r.cost)} /${UOM_LABEL[r.kind]}${(r.cost_check || []).length ? ' · <span class="tb tb-check">da verificare</span>' : ''}</div></div>
<div class="text-end">${right}</div></div>`;
const low = rows.slice().sort((a, b) => a.margin_pct - b.margin_pct).slice(0, 8);
const top = rows.map(r => Object.assign({}, r, {
mval: r.margin * (r.value / r.price)
})).sort((a, b) => b.mval - a.mval).slice(0, 8);
$('#mgLow').html(low.map(r => item(r, marginSpan(r.margin_pct))).join(''));
$('#mgTop').html(top.map(r => item(r, `<b>${fmtEurShort(r.mval)}</b><div>${marginSpan(r.margin_pct)}</div>`)).join(''));
}
/* ---------- MAPPA ---------- */
let mapRows = [];
@@ -1433,6 +1553,16 @@ if ($action === 'list' || $action === 'detail') {
if (t !== 'display') return v === null ? -1e9 : +v;
return `<span title="${sparkTitle}">${spark(r.spark)}</span> ${pctSpan(v)}`;
}
}, {
title: 'Costo mescola',
data: 'cost',
className: 'text-end text-nowrap',
render: (v, t, r) => t === 'display' ? costCell(r) : (v === null ? (t === 'export' ? (COST_ST[r.cost_status] || '') : -1) : +v)
}, {
title: 'Margine',
data: 'margin_pct',
className: 'text-end',
render: (v, t) => t === 'display' ? marginSpan(v) : (v === null ? (t === 'export' ? '' : -1e9) : (t === 'export' ? v / 100 : +v))
}, {
title: 'Mescola',
data: 'kg',
@@ -1615,10 +1745,11 @@ if ($action === 'list' || $action === 'detail') {
${k('#f59e0b', 'Prezzo medio', fmtPrice(t.price) + '<small class="text-muted fs-6">/' + ul + '</small>', '')}
${k(sp > 25 ? '#ef4444' : '#64748b', 'Forbice', t.pmin === null ? '—' : `${fmtPrice(t.pmin)} – ${fmtPrice(t.pmax)}`, sp !== null ? `differenza ${fmtNum(sp, 1)}% tra min e max` : '')}
${k('#10b981', 'Mescola (stima)', fmtShort(t.kg, 'kg'), t.open_kg ? `da produrre: <b>${fmtShort(t.open_kg, 'kg')}</b>` : '')}
${costKpis(d, k, ul)}
</div>
<div class="chart-card mb-3">
<div class="chart-title" data-help="mx.dprice">Prezzo nel tempo, cliente per cliente</div>
<div class="chart-hint">Ogni punto è una riga d'ordine; la linea scura è la media del mese.</div>
<div class="chart-hint">Ogni punto è una riga d'ordine; la linea scura è la media del mese, la linea rossa tratteggiata il costo della mescola. La distanza tra le due è il margine.</div>
<div id="chDetail" style="min-height:340px;"></div>
</div>
<div class="chart-card">
@@ -1637,18 +1768,62 @@ if ($action === 'list' || $action === 'detail') {
showDmTab(dmTab);
}
function costKpis(d, k, ul) {
const c = d.cost || {};
if (c.cost === null || c.cost === undefined) {
return k('#64748b', 'Costo mescola', '—', COST_ST[c.status] || 'non calcolabile');
}
const warn = [];
if (c.status === 'partial') warn.push(`<span class="tb tb-gm">parziale</span>`);
(c.check || []).forEach(x => warn.push(`<span class="tb tb-check">${esc(x)} da verificare</span>`));
const m = c.margin_pct;
return k('#dc2626', 'Costo mescola', fmtPrice(c.cost) + '<small class="text-muted fs-6">/' + ul + '</small>', `media ${c.price_months} mesi ${warn.join(' ')}`) +
k(m !== null && m < 40 ? '#dc2626' : '#16a34a', 'Margine sul grezzo', m === null ? '—' : fmtNum(m, 0) + '%',
m === null ? '' : `${fmtPrice(d.tot.price - c.cost)}/${ul} sul prezzo medio`);
}
function mixPane(d) {
const h = d.head;
if (h.kind === 'pz') return '<div class="alert alert-warning mb-0"><span class="tb tb-pz">A pezzi</span> Le righe vendute a pezzi aspettano la conversione pezzi → metri: le mescole non sono ancora stimate.</div>';
if (!d.mix.length) return `<div class="alert alert-light mb-0">${h.id ? 'Nessuna mescola collegata: completa la scheda di lavoro della matrice.' : 'Non collegata a una matrice dell\'anagrafica.'}</div>`;
const src = h.mix_source === 'scheda' ? 'dall\'ultima scheda di lavoro' : 'dall\'anagrafica matrice (senza g/m)';
return `<div class="chart-hint mb-2">Mescole ${src}. ${h.kind === 'm' ? 'Kg = metri × g/m ÷ 1000.' : 'I kg venduti si dividono in base alla quota.'}</div>
<div class="table-responsive"><table class="table table-striped align-middle mx-table mb-0"><thead><tr><th>Mescola</th><th class="text-end">g/m</th><th class="text-end">Quota</th><th class="text-end">Kg periodo</th><th class="text-end">Kg da produrre</th></tr></thead><tbody>
${d.mix.map(m => `<tr style="cursor:default;"><td><b>${esc(m.name)}</b>${m.out ? `<div class="small text-muted">${esc(m.out)}</div>` : ''}</td>
<div class="table-responsive"><table class="table table-striped align-middle mx-table mb-0"><thead><tr><th>Mescola</th><th class="text-end">g/m</th><th class="text-end">Quota</th><th class="text-end">€/kg</th><th class="text-end">Costo /${UOM_LABEL[h.kind] || esc(h.uom)}</th><th class="text-end">Kg periodo</th><th class="text-end">Kg da produrre</th></tr></thead><tbody>
${d.mix.map(m => {
const pt = ((d.cost || {}).parts || []).find(x => x.id === m.id) || {};
const eur = pt.eur_kg !== null && pt.eur_kg !== undefined ?
`
$ {
fmtNum(pt.eur_kg, 3)
}
$ {
pt.old ? ' <span class="tb tb-gm" title="Ultimo acquisto ' + fmtDate(pt.last_date) + '">vecchio</span>' : ''
}
$ {
pt.check ? ' <span class="tb tb-check">da verificare</span>' : ''
} < div class = "small text-muted" > $ {
esc(pt.supplier || '')
} < /div>` :
'<span class="tb tb-gm" title="Nessun acquisto abbinato: aggiungi il nome d\'acquisto nella pagina mescole">senza prezzo</span>';
return `<tr style="cursor:default;"><td><b>${esc(m.name)}</b>${m.out ? `<div class="small text-muted">${esc(m.out)}</div>` : ''}</td>
<td class="text-end">${m.gm ? fmtNum(m.gm, 1) : '<span class="tb tb-gm">mancante</span>'}</td>
<td class="text-end">${fmtNum(m.share, 1)}%</td><td class="text-end amount">${fmtNum(m.kg)} kg</td>
<td class="text-end amount">${m.kg_open ? '<b style="color:#047857">' + fmtNum(m.kg_open) + ' kg</b>' : '—'}</td></tr>`).join('')}
</tbody></table></div>`;
<td class="text-end">${fmtNum(m.share, 1)}%</td>
<td class="text-end">${eur}</td>
<td class="text-end amount">${pt.part !== null && pt.part !== undefined ? '<b>' + fmtPrice(pt.part) + '</b>' : '—'}</td>
<td class="text-end amount">${fmtNum(m.kg)} kg</td>
<td class="text-end amount">${m.kg_open ? '<b style="color:#047857">' + fmtNum(m.kg_open) + ' kg</b>' : '—'}</td></tr>`;
}).join('')
}
$ {
(d.cost || {}).cost !== null && (d.cost || {}).cost !== undefined ? `<tr style="cursor:default;" class="table-light"><td colspan="4" class="text-end fw-bold">Costo mescola totale</td><td class="text-end amount"><b>${fmtPrice(d.cost.cost)}</b></td><td colspan="2"></td></tr>` : ''
} <
/tbody></table > < /div> <
div class = "chart-hint mt-2" > Costo = $ {
h.kind === 'm' ? 'g/m × €/kg ÷ 1000' : 'media dei €/kg pesata sulla quota'
}, prezzi medi degli ultimi $ {
(d.cost || {}).price_months || 3
}
mesi pagati in Mago.Solo mescola: senza scarto, lavorazione, energia. < /div>`;
}
function renderDetailChart(d) {
@@ -1678,6 +1853,7 @@ if ($action === 'list' || $action === 'detail') {
type: 'scatter',
data: by._
});
const nPts = series.length;
series.push({
name: 'Media mese',
type: 'line',
@@ -1686,7 +1862,18 @@ if ($action === 'list' || $action === 'detail') {
y: m.price
}))
});
const n = series.length;
const cm = ((d.cost || {}).months || []);
if (cm.length) series.push({
name: 'Costo mescola',
type: 'line',
data: cm.map(m => ({
x: new Date(m.ym + '-15T12:00:00').getTime(),
y: m.cost,
c: 1
}))
});
const n = nPts + 1; // indice+1 della linea "Media mese"
const isLine = i => i >= nPts;
upsert('detail', 'chDetail', {
chart: {
type: 'line',
@@ -1703,13 +1890,14 @@ if ($action === 'list' || $action === 'detail') {
}
},
series: series,
colors: series.map((s, i) => i === n - 1 ? '#1f2d3d' : PALETTE[i % PALETTE.length]),
colors: series.map((s, i) => i === n - 1 ? '#1f2d3d' : i === n ? '#dc2626' : PALETTE[i % PALETTE.length]),
stroke: {
width: series.map((s, i) => i === n - 1 ? 2.5 : 0),
curve: 'straight'
width: series.map((s, i) => isLine(i) ? 2.5 : 0),
curve: series.map((s, i) => i === n ? 'stepline' : 'straight'),
dashArray: series.map((s, i) => i === n ? 6 : 0)
},
markers: {
size: series.map((s, i) => i === n - 1 ? 3 : 7),
size: series.map((s, i) => isLine(i) ? (i === n ? 0 : 3) : 7),
strokeWidth: 1,
strokeColors: '#fff',
hover: {
@@ -1717,7 +1905,7 @@ if ($action === 'list' || $action === 'detail') {
}
},
fill: {
opacity: series.map((s, i) => i === n - 1 ? 1 : .8)
opacity: series.map((s, i) => isLine(i) ? 1 : .8)
},
xaxis: {
type: 'datetime',
@@ -1752,6 +1940,7 @@ if ($action === 'list' || $action === 'detail') {
const p = w.config.series[seriesIndex].data[dataPointIndex];
if (!p) return '';
const dt = new Date(p.x);
if (seriesIndex === n) return `<div style="padding:8px 12px;"><div style="font-weight:700;">Costo mescola ${MONTHS[dt.getMonth()]} ${dt.getFullYear()}</div><div><b>${fmtPrice(p.y)}/${ul}</b></div></div>`;
const lbl = seriesIndex === n - 1 ? `Media ${MONTHS[dt.getMonth()]} ${dt.getFullYear()}` : `${esc(w.config.series[seriesIndex].name)}<br><span style="color:#64748b">${fmtDate(iso(dt))} · ordine ${esc(p.o)}</span>`;
return `<div style="padding:8px 12px;"><div style="font-weight:700;">${lbl}</div><div><b>${fmtPrice(p.y)}/${ul}</b>${p.q ? ' · ' + qtyFmt(p.q) + ' ' + esc(d.head.uom) : ''}</div></div>`;
}
@@ -1808,6 +1997,11 @@ if ($action === 'list' || $action === 'detail') {
data: 'var',
className: 'text-end',
render: (v, t) => t === 'display' ? pctSpan(v) : (v === null ? (t === 'export' ? '' : -1e9) : (t === 'export' ? v / 100 : +v))
}, {
title: 'Margine',
data: 'margin_pct',
className: 'text-end',
render: (v, t, r) => t === 'display' ? (v === null ? '<span class="text-muted">—</span>' : `${marginSpan(v)}<div class="small text-muted" title="Margine sull'ultimo prezzo">ultimo ${r.last_margin_pct === null ? '—' : fmtNum(r.last_margin_pct, 0) + '%'}</div>`) : (v === null ? (t === 'export' ? '' : -1e9) : (t === 'export' ? v / 100 : +v))
}],
order: [
[3, 'desc']
@@ -1957,6 +2151,9 @@ if ($action === 'list' || $action === 'detail') {
$('#tMat').on('click', 'tbody tr[data-k]', function() {
openDetail($(this).attr('data-k'));
});
$('#mgLow, #mgTop').on('click', '.mg-item', function() {
openDetail($(this).data('k'));
});
$('#mvUp, #mvDown').on('click', '.mv-item', function() {
openDetail($(this).data('k'));
});
@@ -1980,7 +2177,7 @@ if ($action === 'list' || $action === 'detail') {
});
})();
</script>
<script src="sales_help.js?v=4"></script>
<script src="sales_help.js?v=5"></script>
<script src="ui_layout.js?v=1"></script>
</body>