added quote

This commit is contained in:
2026-10-27 12:23:09 +01:00
parent ccfe34637d
commit 12b7de0ac1
5 changed files with 4010 additions and 0 deletions
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Preventivi per matrice:
* - permesso sales.quote.view (pagina sales_preventivo.php: mostra costi e margini)
* - tabella sales_quotes: preventivi salvati con costo, ricarico, prezzo e commento
*/
final class AddSalesQuotes extends AbstractMigration
{
public function up(): void
{
if (!$this->fetchRow("SELECT id FROM auth_permissions WHERE name = 'sales.quote.view'")) {
$this->table('auth_permissions')->insert([
'name' => 'sales.quote.view',
'display_name' => 'Preventivi matrici',
'description' => 'Calcolare preventivi: costo mescola, ricarico, prezzo suggerito',
'removable' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
])->saveData();
}
if (!$this->hasTable('sales_quotes')) {
$this->table('sales_quotes', ['collation' => 'utf8mb4_general_ci'])
->addColumn('created_at', 'datetime')
->addColumn('user_id', 'integer', ['null' => true])
->addColumn('idmatrice', 'integer')
->addColumn('matrice_name', 'string', ['limit' => 150])
->addColumn('customer_code', 'string', ['limit' => 20, 'null' => true])
->addColumn('customer_name', 'string', ['limit' => 200, 'null' => true])
->addColumn('uom', 'string', ['limit' => 4])
->addColumn('cost_mix', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('scrap_pct', 'decimal', ['precision' => 6, 'scale' => 2, 'default' => 0])
->addColumn('other_costs', 'decimal', ['precision' => 12, 'scale' => 4, 'default' => 0])
->addColumn('cost_total', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('markup_pct', 'decimal', ['precision' => 7, 'scale' => 2, 'null' => true])
->addColumn('price_suggested', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('price_offered', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('ref_last_price', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('ref_avg_price', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('mix_detail', 'text', ['null' => true])
->addColumn('comment', 'text', ['null' => true])
->addIndex(['idmatrice'])
->addIndex(['customer_code'])
->addIndex(['created_at'])
->create();
}
}
public function down(): void
{
if ($this->hasTable('sales_quotes')) $this->table('sales_quotes')->drop()->save();
$this->execute("DELETE pr FROM auth_permission_role pr JOIN auth_permissions p ON p.id = pr.permission_id WHERE p.name = 'sales.quote.view'");
$this->execute("DELETE FROM auth_permissions WHERE name = 'sales.quote.view'");
}
}
+301
View File
@@ -0,0 +1,301 @@
<?php
/**
* Preventivo per matrice: costo della mescola, storico prezzi di vendita, preventivi salvati.
* Usa MagoMatriceStats (riconoscimento matrici, schede di lavoro, costo) e MagoMixPurchases (€/kg pagati).
*/
require_once __DIR__ . '/MagoMatriceStats.php';
class MagoQuote
{
/** Mesi di storico per prezzi e costo nel grafico */
const HISTORY_MONTHS = 24;
/** Ricerca matrici per la tendina (nome, con foto e indicazione della scheda di lavoro) */
public static function searchMatrici(PDO $pdo, string $q, int $limit = 30): array
{
$q = trim($q);
$p = [];
$w = '';
if ($q !== '') {
// cerco anche senza la M iniziale: "M00786S" trova "00786S"
$q2 = preg_replace('/^M(?=\d)/i', '', $q);
$w = "WHERE m.nome LIKE :q OR m.nome LIKE :q2";
$p['q'] = '%' . $q . '%';
$p['q2'] = '%' . $q2 . '%';
}
$st = $pdo->prepare("SELECT m.id, m.nome, m.photo,
(SELECT COUNT(*) FROM work_sheets ws JOIN work_sheet_mescole wm ON wm.worksheet_id = ws.id
WHERE ws.idmatrice = m.id AND wm.mix_weight_g_m > 0) AS n_gm
FROM matrice m $w
ORDER BY (m.nome LIKE :start) DESC, m.nome
LIMIT " . (int)$limit);
$p['start'] = ($q !== '' ? preg_replace('/^M(?=\d)/i', '', $q) : '') . '%';
$st->execute($p);
$out = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$out[] = ['id' => (int)$r['id'], 'text' => $r['nome'], 'photo' => MagoMatriceStats::photoUrl($r['photo']), 'has_gm' => (int)$r['n_gm'] > 0];
}
return $out;
}
/** Valori Mago del campo Matrice/Misura che portano a questa matrice */
public static function rawsFor(PDO $pdo, int $id): array
{
$raws = [];
foreach ($pdo->query("SELECT DISTINCT matrice_misura FROM mago_sale_ord_detail WHERE matrice_misura <> ''")->fetchAll(PDO::FETCH_COLUMN) as $raw) {
if (MagoMatriceStats::resolve($pdo, $raw)['id'] === $id) $raws[] = $raw;
}
return $raws;
}
/**
* Tutti i dati per il preventivo.
* @param string $kind m | kg
*/
public static function data(PDO $pdo, int $id, string $kind, ?string $customer): array
{
$kind = $kind === 'kg' ? 'kg' : 'm';
$st = $pdo->prepare("SELECT id, nome, photo FROM matrice WHERE id = ?");
$st->execute([$id]);
$mat = $st->fetch(PDO::FETCH_ASSOC);
if (!$mat) return ['success' => false, 'message' => 'Matrice non trovata'];
$mi = MagoMatriceStats::mixInfo($pdo, [$id])[$id] ?? null;
$prices = MagoMatriceStats::mixPrices($pdo);
$cost = MagoMatriceStats::costOf($mi, $kind, $prices);
// mescole con tutti i dati per il calcolo modificabile nella pagina
$mix = [];
foreach ($mi['mix'] ?? [] as $x) {
$p = $prices[$x['id']] ?? null;
$mix[] = [
'id' => $x['id'],
'name' => $x['name'],
'out' => $x['out'],
'gm' => $x['gm'] !== null && $x['gm'] > 0 ? (float)$x['gm'] : null,
'eur_kg' => $p ? $p['price'] : null,
'last_price' => $p['last_price'] ?? null,
'last_date' => $p['last_date'] ?? null,
'old' => $p ? $p['old'] : null,
'supplier' => $p['last_supplier'] ?? null,
'check' => $p['check'] ?? null,
];
}
// costo mese per mese
$months = [];
for ($i = self::HISTORY_MONTHS - 1; $i >= 0; $i--) $months[] = date('Y-m', strtotime("first day of -$i months"));
$costMonths = [];
if ($cost['cost'] !== null) {
foreach ($months as $ym) {
$c = MagoMatriceStats::costOf($mi, $kind, $prices, $ym);
if ($c['cost'] !== null) $costMonths[$ym] = $c['cost'];
}
}
// vendite della matrice nell'unità scelta
$raws = self::rawsFor($pdo, $id);
$since = $months[0] . '-01';
$d12 = date('Y-m-d', strtotime('-365 days'));
$lines = [];
foreach ($raws ? MagoMatriceStats::fetchLines($pdo, $since, [], $raws) : [] as $l) {
if ($l['order_date'] < $since) continue;
if (MagoMatriceStats::uomKind($l['uom']) !== $kind) continue;
if ((float)$l['value'] <= 0 || (float)$l['qty'] <= 0) continue;
$lines[] = $l;
}
$names = MagoMatriceStats::customerNames($pdo, array_column($lines, 'customer'));
$all = ['m' => [], 'v12' => 0, 'q12' => 0, 'min' => null, 'max' => null, 'clients' => []];
$cus = ['m' => [], 'v12' => 0, 'q12' => 0, 'last_price' => null, 'last_date' => null, 'lines' => []];
foreach ($lines as $l) {
$q = (float)$l['qty'];
$v = (float)$l['value'];
$pr = $v / $q;
$ym = substr($l['order_date'], 0, 7);
$all['m'][$ym]['v'] = ($all['m'][$ym]['v'] ?? 0) + $v;
$all['m'][$ym]['q'] = ($all['m'][$ym]['q'] ?? 0) + $q;
if ($l['order_date'] > $d12) {
$all['v12'] += $v;
$all['q12'] += $q;
$all['min'] = $all['min'] === null ? $pr : min($all['min'], $pr);
$all['max'] = $all['max'] === null ? $pr : max($all['max'], $pr);
$c = &$all['clients'][$l['customer']];
$c['v'] = ($c['v'] ?? 0) + $v;
$c['q'] = ($c['q'] ?? 0) + $q;
unset($c);
}
if ($customer !== null && $l['customer'] === $customer) {
$cus['m'][$ym]['v'] = ($cus['m'][$ym]['v'] ?? 0) + $v;
$cus['m'][$ym]['q'] = ($cus['m'][$ym]['q'] ?? 0) + $q;
if ($l['order_date'] > $d12) {
$cus['v12'] += $v;
$cus['q12'] += $q;
}
$cus['last_price'] = $pr;
$cus['last_date'] = $l['order_date'];
$cus['lines'][] = ['date' => $l['order_date'], 'ord' => $l['internal_ord_no'], 'qty' => round($q, 3), 'price' => round($pr, 4)];
}
}
$avgM = function (array $m) {
$o = [];
ksort($m);
foreach ($m as $ym => $x) $o[$ym] = round($x['v'] / $x['q'], 4);
return $o;
};
$clients = [];
foreach ($all['clients'] as $code => $c) $clients[] = ['code' => $code, 'name' => $names[$code] ?? $code, 'price' => round($c['v'] / $c['q'], 4), 'qty' => round($c['q'], 1)];
usort($clients, fn($a, $b) => $b['qty'] <=> $a['qty']);
$avg12 = $all['q12'] > 0 ? $all['v12'] / $all['q12'] : null;
return [
'success' => true,
'matrice' => [
'id' => (int)$mat['id'],
'name' => $mat['nome'],
'photo' => MagoMatriceStats::photoUrl($mat['photo']),
'raws' => $raws,
'ws' => $mi['ws'] ?? null,
'mix_source' => $mi['source'] ?? 'nessuna'
],
'kind' => $kind,
'uom' => $kind === 'kg' ? 'KG' : 'MT',
'price_months' => MagoMixPurchases::PRICE_MONTHS,
'cost' => ['cost' => $cost['cost'], 'status' => $cost['status'], 'missing' => $cost['missing'], 'check' => $cost['check']],
'mix' => $mix,
'months' => $months,
'cost_months' => $costMonths,
'all' => [
'months' => $avgM($all['m']),
'avg12' => $avg12 !== null ? round($avg12, 4) : null,
'min12' => $all['min'] !== null ? round($all['min'], 4) : null,
'max12' => $all['max'] !== null ? round($all['max'], 4) : null,
'clients' => array_slice($clients, 0, 12),
'n_clients' => count($clients),
'markup' => ($avg12 && $cost['cost'] && $cost['status'] === 'ok') ? round(($avg12 / $cost['cost'] - 1) * 100, 0) : null,
],
'customer' => $customer === null ? null : [
'code' => $customer,
'name' => $names[$customer] ?? self::customerName($pdo, $customer),
'months' => $avgM($cus['m']),
'avg12' => $cus['q12'] > 0 ? round($cus['v12'] / $cus['q12'], 4) : null,
'last_price' => $cus['last_price'] !== null ? round($cus['last_price'], 4) : null,
'last_date' => $cus['last_date'],
'lines' => array_slice(array_reverse($cus['lines']), 0, 12),
],
'quotes' => self::quotes($pdo, $id, null),
];
}
public static function customerName(PDO $pdo, string $code): string
{
return MagoMatriceStats::customerNames($pdo, [$code])[$code] ?? $code;
}
/** Preventivi salvati di una matrice (tutti i clienti), più recenti prima */
public static function quotes(PDO $pdo, ?int $idmatrice, ?string $customer, int $limit = 50): array
{
$w = [];
$p = [];
if ($idmatrice) {
$w[] = 'q.idmatrice = :m';
$p['m'] = $idmatrice;
}
if ($customer) {
$w[] = 'q.customer_code = :c';
$p['c'] = $customer;
}
$user = "TRIM(CONCAT(IFNULL(u.first_name, ''), ' ', IFNULL(u.last_name, '')))";
try {
$st = $pdo->prepare("SELECT q.*, NULLIF($user, '') AS user_name, u.username
FROM sales_quotes q LEFT JOIN auth_users u ON u.id = q.user_id
" . ($w ? 'WHERE ' . implode(' AND ', $w) : '') . "
ORDER BY q.created_at DESC, q.id DESC LIMIT " . (int)$limit);
$st->execute($p);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
return [];
}
}
/** Salva un preventivo. I numeri arrivano dalla pagina; il costo è ricalcolato dalle mescole inviate. */
public static function save(PDO $pdo, array $in, ?int $userId): array
{
$id = (int)($in['idmatrice'] ?? 0);
$st = $pdo->prepare("SELECT nome FROM matrice WHERE id = ?");
$st->execute([$id]);
$name = $st->fetchColumn();
if (!$name) return ['success' => false, 'message' => 'Matrice non trovata'];
$kind = ($in['kind'] ?? 'm') === 'kg' ? 'kg' : 'm';
$num = function ($v) {
if ($v === null || $v === '') return null;
return (float)str_replace(',', '.', (string)$v);
};
// mescole inviate dalla pagina (g/m e €/kg eventualmente corretti a mano)
$mix = json_decode((string)($in['mix'] ?? '[]'), true) ?: [];
$cost = 0.0;
$ok = 0;
$gmTot = 0.0;
foreach ($mix as $x) $gmTot += max(0, (float)($x['gm'] ?? 0));
foreach ($mix as &$x) {
$gm = $num($x['gm'] ?? null);
$eur = $num($x['eur_kg'] ?? null);
$x = ['id' => (int)($x['id'] ?? 0), 'name' => (string)($x['name'] ?? ''), 'gm' => $gm, 'eur_kg' => $eur, 'edited' => !empty($x['edited'])];
if ($eur === null) continue;
if ($kind === 'm') {
if ($gm) {
$cost += $gm / 1000 * $eur;
$ok++;
}
} else {
$share = $gmTot > 0 ? max(0, (float)$gm) / $gmTot : 1 / max(1, count($mix));
$cost += $share * $eur;
$ok++;
}
}
unset($x);
$costMix = $ok ? $cost : null;
$scrap = $num($in['scrap_pct'] ?? 0) ?? 0;
$other = $num($in['other_costs'] ?? 0) ?? 0;
$markup = $num($in['markup_pct'] ?? null);
$costTotal = $costMix !== null ? $costMix * (1 + $scrap / 100) + $other : ($other ?: null);
$suggested = ($costTotal !== null && $markup !== null) ? $costTotal * (1 + $markup / 100) : null;
$offered = $num($in['price_offered'] ?? null) ?? $suggested;
if ($offered === null) return ['success' => false, 'message' => 'Manca il prezzo: indica il ricarico o il prezzo proposto'];
$cust = trim((string)($in['customer'] ?? ''));
$st = $pdo->prepare("INSERT INTO sales_quotes
(created_at, user_id, idmatrice, matrice_name, customer_code, customer_name, uom, cost_mix, scrap_pct, other_costs, cost_total,
markup_pct, price_suggested, price_offered, ref_last_price, ref_avg_price, mix_detail, comment)
VALUES (NOW(), :u, :m, :mn, :cc, :cn, :uom, :cm, :sp, :oc, :ct, :mk, :ps, :po, :rl, :ra, :md, :cmt)");
$st->execute([
'u' => $userId ?: null,
'm' => $id,
'mn' => $name,
'cc' => $cust !== '' ? $cust : null,
'cn' => $cust !== '' ? self::customerName($pdo, $cust) : null,
'uom' => $kind === 'kg' ? 'KG' : 'MT',
'cm' => $costMix,
'sp' => $scrap,
'oc' => $other,
'ct' => $costTotal,
'mk' => $markup,
'ps' => $suggested,
'po' => $offered,
'rl' => $num($in['ref_last_price'] ?? null),
'ra' => $num($in['ref_avg_price'] ?? null),
'md' => json_encode($mix, JSON_UNESCAPED_UNICODE),
'cmt' => trim((string)($in['comment'] ?? '')) ?: null,
]);
return ['success' => true, 'id' => (int)$pdo->lastInsertId()];
}
public static function delete(PDO $pdo, int $id): bool
{
$st = $pdo->prepare("DELETE FROM sales_quotes WHERE id = ?");
$st->execute([$id]);
return $st->rowCount() > 0;
}
}
+51
View File
@@ -484,6 +484,57 @@
<p>🟢 da 60% · 🟡 da 40 a 60% · 🔴 sotto 40%.</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>`, <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>`,
}, },
/* ===================== PREVENTIVO ===================== */
"page.quote": {
t: "Preventivo: come si usa",
b: `<ol class="ps-3">
<li>Scegli la <b>matrice</b>, il <b>cliente</b> (facoltativo) e se vendi <b>al metro o al kg</b>.</li>
<li><b>Costo</b>: le mescole della scheda di lavoro con g/m ed €/kg pagati in Mago. Puoi correggere i numeri (diventano gialli) e aggiungere scarto e altri costi.</li>
<li><b>Prezzo</b>: scegli il ricarico sul costo. Il prezzo suggerito si calcola da solo; il <b>prezzo proposto</b> si può arrotondare a mano.</li>
<li>Confronta con <b>ultimo prezzo al cliente</b>, media di mercato e forbice, poi scrivi un commento e <b>Salva</b>.</li>
</ol>
<p class="sh-note">I preventivi salvati restano in fondo alla pagina e si riaprono con un clic. Stampa per avere un foglio pulito.</p>`,
},
"pv.cost": {
t: "Costo",
b: `<ul>
<li><b>€/metro</b> = somma di g/m × €/kg ÷ 1000 di ogni mescola della scheda di lavoro.</li>
<li><b>€/kg</b> (vendita a chili) = media dei €/kg pesata sui g/m.</li>
<li>€/kg = media pagata in Mago negli ultimi 3 mesi. <span class="sh-chip sh-amber">prezzo vecchio</span> = nessun acquisto recente, si usa l'ultimo. <span class="sh-chip sh-orange">da verificare</span> = fornitore in conto lavoro.</li>
</ul>
<p class="sh-note">Le correzioni valgono solo per questo preventivo e vengono salvate con lui.</p>`,
},
"pv.scrap": {
t: "Scarto %",
b: `<p>Percentuale di mescola persa in produzione (avvii, sfridi, scarti). Il costo della mescola viene aumentato di questa percentuale.</p>`,
},
"pv.other": {
t: "Altri costi",
b: `<p>Costi per metro o per kg da aggiungere alla mescola: lavorazioni esterne (biadesivo, giunzione, raspatura), imballo, trasporto, quota di lavorazione.</p>`,
},
"pv.price": {
t: "Prezzo suggerito",
b: `<p><b>Prezzo suggerito</b> = costo totale × (1 + ricarico %). Es. costo 0,50 €/m con ricarico 100% → 1,00 €/m.</p>
<p>Il pulsante <b>come mercato</b> usa il ricarico che oggi si ottiene con il prezzo medio di vendita della matrice.</p>
<p>🟢 margine da 60% · 🟡 da 40 a 60% · 🔴 sotto 40% (margine = (prezzo − costo) ÷ prezzo).</p>`,
},
"pv.offered": {
t: "Prezzo proposto",
b: `<p>È il prezzo che finisce nel preventivo: parte dal suggerito e si può arrotondare a mano. Accanto, il margine che ne risulta.</p>`,
},
"pv.refs": {
t: "Confronto",
b: `<ul>
<li><b>Ultimo prezzo al cliente</b> e <b>media cliente</b>: cosa ha pagato finora.</li>
<li><b>Media mercato</b>: prezzo medio di tutti i clienti negli ultimi 12 mesi.</li>
<li>La percentuale verde/rossa dice quanto il prezzo proposto è sopra o sotto.</li>
</ul>`,
},
"pv.chart": {
t: "Costo e prezzi nel tempo",
b: `<p>Mese per mese: <b>costo della mescola</b> (con i prezzi pagati in quel mese), <b>prezzo medio</b> di tutti i clienti e del cliente scelto. Le righe orizzontali sono il costo totale e il prezzo proposto.</p>
<p class="sh-note">Se la linea del costo sale e quella dei prezzi no, il margine si sta riducendo.</p>`,
},
"mx.map": { "mx.map": {
t: "Mappa dei prezzi", t: "Mappa dei prezzi",
b: `<p>Ogni bolla è una matrice venduta nel periodo (le prime 150 per valore).</p> b: `<p>Ogni bolla è una matrice venduta nel periodo (le prime 150 per valore).</p>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff