diff --git a/public/userarea/class/MagoMatriceStats.php b/public/userarea/class/MagoMatriceStats.php new file mode 100644 index 00000000..b19a7eb2 --- /dev/null +++ b/public/userarea/class/MagoMatriceStats.php @@ -0,0 +1,845 @@ + 'Matrice', + 'revisione' => 'Altra revisione', + 'mappa' => 'Abbinata a mano', + 'da_creare' => 'Non in anagrafica', + 'profilo' => 'Profilo', + 'altro' => 'Non riconosciuta', + ]; + + /** Tipo dalla lettera iniziale di Matrice/Misura (campo cat) */ + const CAT_LABELS = [ + 'M' => 'Matrice', + 'T' => 'Tubo', + 'R' => 'Rettangolo', + 'D' => 'Diametro', + 'Q' => 'Quadrato', + 'S' => 'Profilo S', + 'X' => 'Non riconosciuta', + ]; + + private static ?array $idx = null; + private static array $cache = []; + + /* ============================================================ + * UTILITY + * ============================================================ */ + + public static function norm($s): string + { + return strtoupper(preg_replace('/\s+/u', ' ', trim((string)$s))); + } + + /** m = metri, kg = chili, pz = pezzi, altro */ + public static function uomKind($u): string + { + $u = self::norm($u); + if (in_array($u, self::UOM_METERS, true)) return 'm'; + if (in_array($u, self::UOM_KG, true)) return 'kg'; + if (in_array($u, self::UOM_PIECES, true)) return 'pz'; + return 'altro'; + } + + public static function photoUrl($p): ?string + { + $p = trim((string)$p); + if ($p === '') return null; + if (preg_match('~^(https?:)?//~i', $p) || strpos($p, '/') !== false) return $p; + return 'photos/matrici/' . $p; + } + + private static function inList(string $prefix, array $values, array &$params): string + { + $ph = []; + foreach (array_values($values) as $i => $v) { + $ph[] = ":{$prefix}{$i}"; + $params["{$prefix}{$i}"] = $v; + } + return implode(',', $ph); + } + + /* ============================================================ + * RICONOSCIMENTO MATRICE (stesse regole della coda produzione: + * mappa manuale → codice senza M, con e senza revisione → non in anagrafica / profilo T-R-D-Q-S / altro) + * ============================================================ */ + + /** + * Codice matrice normalizzato: senza la M iniziale e separato dalla revisione. + * "M00407P-1" → ['00407P', '1'] · "00786S" → ['00786S', ''] + */ + public static function splitCode(string $s): array + { + $s = strtoupper(trim($s)); + $s = preg_replace('/^M\s?(?=\d)/', '', $s); + $s = preg_replace('/\s+/', '', $s); + if (preg_match('/^(.+?)-(\d+)$/', $s, $m)) return [$m[1], $m[2]]; + return [$s, '']; + } + + public static function index(PDO $pdo): array + { + if (self::$idx !== null) return self::$idx; + $base = []; // codice senza M e senza revisione => [revisione => id] + $info = []; + foreach ($pdo->query("SELECT id, nome, photo FROM matrice ORDER BY id") as $r) { + $id = (int)$r['id']; + $info[$id] = ['name' => (string)$r['nome'], 'photo' => self::photoUrl($r['photo'])]; + $parts = preg_split('/\s+/u', trim((string)$r['nome'])); + $first = $parts[0] ?? ''; + if ($first === '') continue; + [$code, $rev] = self::splitCode($first); + if ($code === '') continue; + $base[$code][$rev] = $id; + } + $map = []; + try { + foreach ($pdo->query("SELECT codice_mago, idmatrice FROM matrice_mago_map WHERE idmatrice IS NOT NULL") as $r) { + $map[self::norm($r['codice_mago'])] = (int)$r['idmatrice']; + } + } catch (Throwable $e) { + } + return self::$idx = ['base' => $base, 'info' => $info, 'map' => $map]; + } + + /** + * Matrice dell'anagrafica per codice (senza M) e revisione, provando con e senza revisione. + * - stessa revisione, oppure revisione assente da una delle due parti → esatta + * - revisioni diverse da entrambe le parti → revisione (avviso) + * @return array|null [id, type] + */ + private static function findMatrix(array $ix, string $code, string $rev): ?array + { + $revs = $ix['base'][$code] ?? null; + if (!$revs) return null; + if (isset($revs[$rev])) return [$revs[$rev], 'esatta']; + if ($rev !== '' && isset($revs[''])) return [$revs[''], 'esatta']; + $keys = array_keys($revs); + usort($keys, fn($a, $b) => (int)$b <=> (int)$a); // revisione più alta + return [$revs[$keys[0]], $rev === '' ? 'esatta' : 'revisione']; + } + + /** + * @return array key, id, cat (M/T/R/D/Q/S/X), type (esito), label, code + * key: m{id} (matrice in anagrafica) | c:{codice} (non in anagrafica) | p:{valore} (profilo) | x:{valore} + */ + public static function resolve(PDO $pdo, $raw): array + { + $n = self::norm($raw); + if (isset(self::$cache[$n])) return self::$cache[$n]; + $ix = self::index($pdo); + $linked = function (int $id, string $type, string $code) use ($ix, $n) { + return ['key' => 'm' . $id, 'id' => $id, 'cat' => 'M', 'type' => $type, 'label' => $ix['info'][$id]['name'] ?? $n, 'code' => $code]; + }; + + if (isset($ix['map'][$n]) && isset($ix['info'][$ix['map'][$n]])) { + $res = $linked($ix['map'][$n], 'mappa', $n); + } elseif (preg_match('/(? 'c:' . $code, 'id' => null, 'cat' => 'M', 'type' => 'da_creare', 'label' => $label, 'code' => $label]; + } + } elseif (preg_match('/^([DRTQS])\s?\d/', $n, $m)) { + $res = ['key' => 'p:' . $n, 'id' => null, 'cat' => $m[1], 'type' => 'profilo', 'label' => $n, 'code' => $n]; + } else { + $res = ['key' => 'x:' . $n, 'id' => null, 'cat' => 'X', 'type' => 'altro', 'label' => $n, 'code' => $n]; + } + return self::$cache[$n] = $res; + } + + /* ============================================================ + * MESCOLE + * ============================================================ */ + + /** + * Per ogni matrice: ultima scheda di lavoro (prima le attive, poi la più recente) + * con le mescole e i loro g/m. Senza scheda: mescole dell'anagrafica matrice (senza g/m). + */ + public static function mixInfo(PDO $pdo, array $ids): array + { + $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); + $out = []; + foreach (array_chunk($ids, 500) as $chunk) { + $in = implode(',', $chunk); + $ws = []; + $rows = $pdo->query("SELECT id, idmatrice, worksheet_number, worksheet_date, revision_code, worksheet_status + FROM work_sheets WHERE idmatrice IN ($in) + ORDER BY idmatrice, (worksheet_status = 'active') DESC, worksheet_date DESC, id DESC")->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $r) { + $m = (int)$r['idmatrice']; + if (!isset($ws[$m])) $ws[$m] = $r; + } + + $mix = []; + if ($ws) { + $wsIds = implode(',', array_map(fn($r) => (int)$r['id'], $ws)); + foreach ( + $pdo->query("SELECT w.worksheet_id, w.idmescola, w.mix_position, w.mix_weight_g_m, m.nome, m.nomeuscita + FROM work_sheet_mescole w JOIN mescole m ON m.id = w.idmescola + WHERE w.worksheet_id IN ($wsIds) + ORDER BY w.worksheet_id, w.mix_position") as $r + ) { + $mix[(int)$r['worksheet_id']][] = [ + 'id' => (int)$r['idmescola'], + 'name' => (string)$r['nome'], + 'out' => (string)($r['nomeuscita'] ?? ''), + 'pos' => (int)$r['mix_position'], + 'gm' => $r['mix_weight_g_m'] !== null ? (float)$r['mix_weight_g_m'] : null, + ]; + } + } + + $ana = []; + foreach ( + $pdo->query("SELECT mm.idmatrice, m.id, m.nome, m.nomeuscita + FROM matrici_mescole mm JOIN mescole m ON m.id = mm.idmescola + WHERE mm.idmatrice IN ($in) ORDER BY m.nome") as $r + ) { + $ana[(int)$r['idmatrice']][] = ['id' => (int)$r['id'], 'name' => (string)$r['nome'], 'out' => (string)($r['nomeuscita'] ?? ''), 'pos' => 0, 'gm' => null]; + } + + foreach ($chunk as $id) { + $w = $ws[$id] ?? null; + $list = $w ? ($mix[(int)$w['id']] ?? []) : []; + $src = $list ? 'scheda' : 'nessuna'; + if (!$list && !empty($ana[$id])) { + $list = $ana[$id]; + $src = 'anagrafica'; + } + $gmTot = 0.0; + $missing = 0; + foreach ($list as $x) { + if ($x['gm'] !== null && $x['gm'] > 0) $gmTot += $x['gm']; + else $missing++; + } + $out[$id] = [ + 'ws' => $w ? [ + 'id' => (int)$w['id'], + 'number' => $w['worksheet_number'], + 'date' => $w['worksheet_date'], + 'rev' => $w['revision_code'], + 'active' => $w['worksheet_status'] === 'active', + ] : null, + 'mix' => $list, + 'source' => $src, + 'gm_total' => $gmTot, + 'gm_missing' => $missing, + ]; + } + } + return $out; + } + + /** + * Kg di mescola per una quantità venduta. + * - Metri: kg = metri × g/m ÷ 1000, mescola per mescola. + * - Chili: i kg si dividono tra le mescole in proporzione ai g/m; senza g/m in parti uguali (2 → 50/50, 3 → 33%). + * - Pezzi: in attesa della conversione pezzi → metri. + * status: ok | gm_part (g/m mancante su qualche mescola) | gm (nessun g/m) | none (nessuna mescola) | pz | altro + */ + public static function mixKg(?array $info, string $kind, float $qty): array + { + if ($kind === 'pz') return ['status' => 'pz', 'kg' => []]; + if ($kind === 'altro') return ['status' => 'altro', 'kg' => []]; + if (!$info || !$info['mix']) return ['status' => 'none', 'kg' => []]; + + $kg = []; + if ($kind === 'm') { + if ($info['gm_total'] <= 0) return ['status' => 'gm', 'kg' => []]; + foreach ($info['mix'] as $x) { + if ($x['gm'] > 0) $kg[$x['id']] = ($kg[$x['id']] ?? 0) + $qty * $x['gm'] / 1000; + } + return ['status' => $info['gm_missing'] ? 'gm_part' : 'ok', 'kg' => $kg]; + } + + $n = count($info['mix']); + foreach ($info['mix'] as $x) { + $share = $info['gm_total'] > 0 ? max(0.0, (float)$x['gm']) / $info['gm_total'] : 1 / $n; + $kg[$x['id']] = ($kg[$x['id']] ?? 0) + $qty * $share; + } + return ['status' => ($info['gm_total'] > 0 && $info['gm_missing']) ? 'gm_part' : 'ok', 'kg' => $kg]; + } + + /* ============================================================ + * DATI + * ============================================================ */ + + /** Righe ordine con Matrice/Misura: dal giorno $lo oppure ancora da consegnare */ + public static function fetchLines(PDO $pdo, string $lo, array $customers = [], ?array $raws = null, ?string $uom = null): array + { + if ($raws !== null && !$raws) return []; + $from = MagoSalesStats::baseFrom(); + $p = ['lo' => $lo]; + $w = [ + "x.status <> 'annullato'", + "d.cancelled = 0", + "d.matrice_misura <> ''", + "d.qty > " . (float)self::MIN_QTY, + "(x.order_date >= :lo OR (d.delivered = 0 AND x.status <> 'evaso'))", + ]; + foreach (self::EXCLUDED_ITEM_PREFIXES as $i => $pre) { + $w[] = "d.item NOT LIKE :ex$i"; + $p["ex$i"] = $pre . '%'; + } + if ($customers) $w[] = "x.customer IN (" . self::inList('cu', $customers, $p) . ")"; + if ($raws !== null) $w[] = "d.matrice_misura IN (" . self::inList('mm', $raws, $p) . ")"; + if ($uom !== null) { + $w[] = "UPPER(TRIM(d.uom)) = :uom"; + $p['uom'] = $uom; + } + + $sql = "SELECT d.sale_ord_id, d.sub_id, d.matrice_misura, d.uom, d.item, d.description, d.qty, d.delivered_qty, d.delivered, + CASE WHEN d.taxable_amount <> 0 THEN d.taxable_amount ELSE d.qty * d.unit_value - d.discount_amount END AS value, + x.order_date, x.customer, x.internal_ord_no, x.status + FROM mago_sale_ord_detail d + JOIN $from ON x.sale_ord_id = d.sale_ord_id + WHERE " . implode(' AND ', $w) . " + ORDER BY x.order_date, d.sale_ord_id, d.sub_id"; + return MagoSalesStats::fetchAllQ($pdo, $sql, $p); + } + + public static function customerNames(PDO $pdo, array $codes): array + { + $codes = array_values(array_unique(array_filter($codes, fn($c) => $c !== ''))); + $out = []; + foreach (array_chunk($codes, 500) as $chunk) { + $p = []; + $in = self::inList('c', $chunk, $p); + $rows = MagoSalesStats::fetchAllQ($pdo, "SELECT cust_supp AS code, company_name AS name FROM mago_cust_supp + WHERE cust_supp_type = " . MagoSalesStats::CUSTOMER_TYPE . " AND cust_supp IN ($in)", $p); + foreach ($rows as $r) $out[$r['code']] = $r['name'] ?: $r['code']; + } + return $out; + } + + private static function lineIsOpen(array $l): float + { + if ((int)$l['delivered'] !== 0 || $l['status'] === 'evaso') return 0.0; + return max(0.0, (float)$l['qty'] - (float)$l['delivered_qty']); + } + + /* ============================================================ + * CLASSIFICA MATRICI + KPI + MESCOLE + * ============================================================ */ + + public static function buildList(PDO $pdo, string $from, string $to, array $customers = []): array + { + $d12 = date('Y-m-d', strtotime('-365 days')); + $d24 = date('Y-m-d', strtotime('-730 days')); + $lines = self::fetchLines($pdo, min($from, $d24), $customers); + + $ids = []; + foreach ($lines as $i => $l) { + $lines[$i]['_r'] = self::resolve($pdo, $l['matrice_misura']); + if ($lines[$i]['_r']['id']) $ids[] = $lines[$i]['_r']['id']; + } + $mix = self::mixInfo($pdo, $ids); + $ix = self::index($pdo); + + $spark = []; + for ($i = 11; $i >= 0; $i--) $spark[] = date('Y-m', strtotime("first day of -$i months")); + $sparkPos = array_flip($spark); + + $agg = []; + $mixTot = []; + $mixMonth = []; + $mixNames = []; + $kpiClients = []; + $kpi = [ + 'value' => 0, + 'lines' => 0, + 'qty_m' => 0, + 'val_m' => 0, + 'pq_m' => 0, + 'qty_kg' => 0, + 'val_kg' => 0, + 'pq_kg' => 0, + 'pz_lines' => 0, + 'pz_value' => 0, + 'pz_qty' => 0, + 'kg_mix' => 0, + 'kg_open' => 0, + 'open_value' => 0, + 'linked_value' => 0, + 'profile_value' => 0, + 'unlinked_value' => 0, + 'gm_missing_lines' => 0, + 'gm_missing_value' => 0, + 'matrici' => 0, + ]; + $linkedIds = []; + + foreach ($lines as $l) { + $r = $l['_r']; + $uom = self::norm($l['uom']); + $kind = self::uomKind($uom); + $key = $r['key'] . '|' . $uom; + $qty = (float)$l['qty']; + $val = (float)$l['value']; + $date = (string)$l['order_date']; + $ym = substr($date, 0, 7); + $inP = $date >= $from && $date <= $to; + $res = self::lineIsOpen($l); + $mi = $r['id'] ? ($mix[$r['id']] ?? null) : null; + + if (!isset($agg[$key])) { + $agg[$key] = [ + 'key' => $key, + 'rkey' => $r['key'], + 'id' => $r['id'], + 'cat' => $r['cat'], + 'type' => $r['type'], + 'name' => $r['label'], + 'code' => $r['code'], + 'photo' => $r['id'] ? ($ix['info'][$r['id']]['photo'] ?? null) : null, + 'uom' => $uom, + 'kind' => $kind, + 'raws' => [], + 'n' => 0, + 'qty' => 0, + 'value' => 0, + 'pq' => 0, + 'orders' => [], + 'clients' => [], + 'pmin' => null, + 'pmax' => null, + 'last_date' => '', + 'last_price' => null, + 'v12' => 0, + 'q12' => 0, + 'vp' => 0, + 'qp' => 0, + 'sv' => array_fill(0, 12, 0), + 'sq' => array_fill(0, 12, 0), + 'kg' => 0, + 'open_qty' => 0, + 'open_value' => 0, + 'open_kg' => 0, + 'mix_status' => self::mixKg($mi, $kind, 1)['status'], + 'mix_n' => $mi ? count($mi['mix']) : 0, + 'gm_total' => $mi ? $mi['gm_total'] : 0, + ]; + } + $a = &$agg[$key]; + $a['raws'][self::norm($l['matrice_misura'])] = 1; + + if ($inP) { + $a['n']++; + $a['qty'] += $qty; + $a['value'] += $val; + $a['orders'][$l['sale_ord_id']] = 1; + $a['clients'][$l['customer']] = 1; + $kpiClients[$l['customer']] = 1; + if ($val > 0) { + $p = $val / $qty; + $a['pq'] += $qty; + $a['pmin'] = $a['pmin'] === null ? $p : min($a['pmin'], $p); + $a['pmax'] = $a['pmax'] === null ? $p : max($a['pmax'], $p); + if ($date >= $a['last_date']) { + $a['last_date'] = $date; + $a['last_price'] = $p; + } + } + $mk = self::mixKg($mi, $kind, $qty); + foreach ($mk['kg'] as $mid => $kg) { + $a['kg'] += $kg; + $kpi['kg_mix'] += $kg; + $mixTot[$mid]['kg'] = ($mixTot[$mid]['kg'] ?? 0) + $kg; + $mixTot[$mid]['mat'][$r['id']] = 1; + $mixMonth[$mid][$ym] = ($mixMonth[$mid][$ym] ?? 0) + $kg; + } + + $kpi['value'] += $val; + $kpi['lines']++; + if (in_array($r['type'], self::LINKED_TYPES, true)) { + $kpi['linked_value'] += $val; + $linkedIds[$r['id']] = 1; + } elseif ($r['type'] === 'profilo') { + $kpi['profile_value'] += $val; + } else { + $kpi['unlinked_value'] += $val; + } + if ($kind === 'm') { + $kpi['qty_m'] += $qty; + if ($val > 0) { + $kpi['val_m'] += $val; + $kpi['pq_m'] += $qty; + } + } elseif ($kind === 'kg') { + $kpi['qty_kg'] += $qty; + if ($val > 0) { + $kpi['val_kg'] += $val; + $kpi['pq_kg'] += $qty; + } + } elseif ($kind === 'pz') { + $kpi['pz_lines']++; + $kpi['pz_value'] += $val; + $kpi['pz_qty'] += $qty; + } + if (in_array($mk['status'], ['gm', 'gm_part', 'none'], true) && $r['id']) { + $kpi['gm_missing_lines']++; + $kpi['gm_missing_value'] += $val; + } + } + + if ($val > 0) { + if ($date > $d12) { + $a['v12'] += $val; + $a['q12'] += $qty; + } elseif ($date > $d24) { + $a['vp'] += $val; + $a['qp'] += $qty; + } + if (isset($sparkPos[$ym])) { + $a['sv'][$sparkPos[$ym]] += $val; + $a['sq'][$sparkPos[$ym]] += $qty; + } + } + + if ($res > 0) { + $ov = $qty > 0 ? $val * $res / $qty : 0; + $a['open_qty'] += $res; + $a['open_value'] += $ov; + $kpi['open_value'] += $ov; + foreach (self::mixKg($mi, $kind, $res)['kg'] as $mid => $kg) { + $a['open_kg'] += $kg; + $kpi['kg_open'] += $kg; + $mixTot[$mid]['kg_open'] = ($mixTot[$mid]['kg_open'] ?? 0) + $kg; + $mixTot[$mid]['mat'][$r['id']] = 1; + } + } + unset($a); + + if ($mi) foreach ($mi['mix'] as $x) $mixNames[$x['id']] = $x['name']; + } + + $rows = []; + foreach ($agg as $a) { + if ($a['n'] === 0 && $a['open_qty'] <= 0) continue; + $price = $a['pq'] > 0 ? $a['value'] / $a['pq'] : null; + $p12 = $a['q12'] > 0 ? $a['v12'] / $a['q12'] : null; + $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; + $rows[] = [ + 'key' => $a['key'], + 'id' => $a['id'], + 'cat' => $a['cat'], + 'type' => $a['type'], + 'name' => $a['name'], + 'code' => $a['code'], + 'photo' => $a['photo'], + 'uom' => $a['uom'], + 'kind' => $a['kind'], + 'raws' => array_keys($a['raws']), + 'n' => $a['n'], + 'orders' => count($a['orders']), + 'clients' => count($a['clients']), + 'qty' => round($a['qty'], 3), + 'value' => round($a['value'], 2), + 'price' => $price !== null ? round($price, 4) : null, + 'pmin' => $a['pmin'] !== null ? round($a['pmin'], 4) : null, + 'pmax' => $a['pmax'] !== null ? round($a['pmax'], 4) : null, + 'spread' => ($a['pmin'] > 0 && $a['pmax'] !== null) ? round(($a['pmax'] - $a['pmin']) / $a['pmin'] * 100, 1) : null, + 'last_date' => $a['last_date'] ?: null, + 'last_price' => $a['last_price'] !== null ? round($a['last_price'], 4) : null, + 'price12' => $p12 !== null ? round($p12, 4) : null, + 'pricep' => $pp !== null ? round($pp, 4) : null, + 'var' => ($p12 !== null && $pp) ? round(($p12 - $pp) / $pp * 100, 1) : null, + 'spark' => $sp, + 'kg' => round($a['kg'], 1), + 'open_qty' => round($a['open_qty'], 3), + 'open_value' => round($a['open_value'], 2), + 'open_kg' => round($a['open_kg'], 1), + 'mix_status' => $a['mix_status'], + 'mix_n' => $a['mix_n'], + 'gm_total' => $a['gm_total'], + ]; + } + usort($rows, fn($x, $y) => $y['value'] <=> $x['value']); + + $mescole = []; + foreach ($mixTot as $mid => $m) { + $mescole[] = [ + 'id' => $mid, + 'name' => $mixNames[$mid] ?? ('#' . $mid), + 'kg' => round($m['kg'] ?? 0, 1), + 'kg_open' => round($m['kg_open'] ?? 0, 1), + 'matrici' => count($m['mat'] ?? []), + 'months' => array_map(fn($v) => round($v, 1), $mixMonth[$mid] ?? []), + ]; + } + usort($mescole, fn($x, $y) => $y['kg'] <=> $x['kg']); + + $kpi['matrici'] = count($linkedIds); + $kpi['clients'] = count($kpiClients); + foreach ($kpi as $k => $v) if (is_float($v)) $kpi[$k] = round($v, 2); + + return [ + 'success' => true, + 'from' => $from, + 'to' => $to, + 'today' => date('Y-m-d'), + 'spark_months' => $spark, + 'kpi' => $kpi, + 'rows' => $rows, + 'mescole' => $mescole, + 'types' => self::TYPE_LABELS, + 'cats' => self::CAT_LABELS, + ]; + } + + /* ============================================================ + * DETTAGLIO DI UNA MATRICE (in una unità di misura) + * ============================================================ */ + + public static function buildDetail(PDO $pdo, string $key, string $from, string $to, array $customers = []): array + { + $pos = strrpos($key, '|'); + if ($pos === false) return ['success' => false, 'message' => 'Matrice non indicata']; + $rkey = substr($key, 0, $pos); + $uom = substr($key, $pos + 1); + + // valori Mago che portano a questa matrice + $raws = []; + $first = null; + foreach ($pdo->query("SELECT DISTINCT matrice_misura FROM mago_sale_ord_detail WHERE matrice_misura <> ''")->fetchAll(PDO::FETCH_COLUMN) as $raw) { + $r = self::resolve($pdo, $raw); + if ($r['key'] === $rkey) { + $raws[] = $raw; + if (!$first) $first = $r; + } + } + if (!$raws) return ['success' => false, 'message' => 'Nessuna riga d\'ordine per questa matrice']; + + $kind = self::uomKind($uom); + $lines = self::fetchLines($pdo, $from, $customers, $raws, $uom); + $ix = self::index($pdo); + $mi = $first['id'] ? (self::mixInfo($pdo, [$first['id']])[$first['id']] ?? null) : null; + + $prodLines = []; + if ($first['id']) { + $st = $pdo->prepare("SELECT pl.name, pl.color FROM matrici_lines ml JOIN production_lines pl ON pl.id = ml.idlinea WHERE ml.idmatrice = ? ORDER BY pl.line_number"); + $st->execute([$first['id']]); + $prodLines = $st->fetchAll(PDO::FETCH_ASSOC); + } + + $names = self::customerNames($pdo, array_column($lines, 'customer')); + $cl = []; + $months = []; + $out = []; + $mixKgP = []; + $mixKgO = []; + $tot = ['n' => 0, 'qty' => 0, 'value' => 0, 'pq' => 0, 'kg' => 0, 'open_qty' => 0, 'open_kg' => 0, 'pmin' => null, 'pmax' => null]; + $d12 = date('Y-m-d', strtotime('-365 days')); + $d24 = date('Y-m-d', strtotime('-730 days')); + + foreach ($lines as $l) { + $qty = (float)$l['qty']; + $val = (float)$l['value']; + $date = (string)$l['order_date']; + $inP = $date >= $from && $date <= $to; + $res = self::lineIsOpen($l); + $price = ($val > 0 && $qty > 0) ? $val / $qty : null; + $c = $l['customer']; + + if ($res > 0) { + $tot['open_qty'] += $res; + foreach (self::mixKg($mi, $kind, $res)['kg'] as $mid => $kg) { + $mixKgO[$mid] = ($mixKgO[$mid] ?? 0) + $kg; + $tot['open_kg'] += $kg; + } + } + if (!$inP) continue; + + $kgLine = 0; + foreach (self::mixKg($mi, $kind, $qty)['kg'] as $mid => $kg) { + $mixKgP[$mid] = ($mixKgP[$mid] ?? 0) + $kg; + $kgLine += $kg; + } + $tot['n']++; + $tot['qty'] += $qty; + $tot['value'] += $val; + $tot['kg'] += $kgLine; + + if (!isset($cl[$c])) { + $cl[$c] = [ + 'code' => $c, + 'name' => $names[$c] ?? $c, + 'n' => 0, + 'qty' => 0, + 'value' => 0, + 'pq' => 0, + 'first_date' => null, + 'first_price' => null, + 'last_date' => null, + 'last_price' => null, + 'v12' => 0, + 'q12' => 0, + 'vp' => 0, + 'qp' => 0, + 'open_qty' => 0 + ]; + } + $x = &$cl[$c]; + $x['n']++; + $x['qty'] += $qty; + $x['value'] += $val; + $x['open_qty'] += $res; + if ($price !== null) { + $tot['pq'] += $qty; + $tot['pmin'] = $tot['pmin'] === null ? $price : min($tot['pmin'], $price); + $tot['pmax'] = $tot['pmax'] === null ? $price : max($tot['pmax'], $price); + $x['pq'] += $qty; + if ($x['first_date'] === null) { + $x['first_date'] = $date; + $x['first_price'] = $price; + } + $x['last_date'] = $date; + $x['last_price'] = $price; + if ($date > $d12) { + $x['v12'] += $val; + $x['q12'] += $qty; + } elseif ($date > $d24) { + $x['vp'] += $val; + $x['qp'] += $qty; + } + $ym = substr($date, 0, 7); + $months[$ym]['v'] = ($months[$ym]['v'] ?? 0) + $val; + $months[$ym]['q'] = ($months[$ym]['q'] ?? 0) + $qty; + } + unset($x); + + $out[] = [ + 'date' => $date, + 'ord' => $l['internal_ord_no'], + 'customer' => $c, + 'cname' => $names[$c] ?? $c, + 'item' => $l['item'], + 'description' => $l['description'], + 'raw' => $l['matrice_misura'], + 'qty' => round($qty, 3), + 'value' => round($val, 2), + 'price' => $price !== null ? round($price, 4) : null, + 'open' => round($res, 3), + 'kg' => round($kgLine, 1), + ]; + } + + $avg = $tot['pq'] > 0 ? $tot['value'] / $tot['pq'] : null; + $clients = []; + foreach ($cl as $x) { + $p = $x['pq'] > 0 ? $x['value'] / $x['pq'] : null; + $p12 = $x['q12'] > 0 ? $x['v12'] / $x['q12'] : null; + $pp = $x['qp'] > 0 ? $x['vp'] / $x['qp'] : null; + $clients[] = [ + 'code' => $x['code'], + 'name' => $x['name'], + 'n' => $x['n'], + 'qty' => round($x['qty'], 3), + 'value' => round($x['value'], 2), + 'price' => $p !== null ? round($p, 4) : null, + 'dev' => ($p !== null && $avg) ? round(($p - $avg) / $avg * 100, 1) : null, + 'first_date' => $x['first_date'], + 'first_price' => $x['first_price'] !== null ? round($x['first_price'], 4) : null, + 'last_date' => $x['last_date'], + '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), + ]; + } + usort($clients, fn($a, $b) => $b['value'] <=> $a['value']); + + ksort($months); + $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)]; + + $mixRows = []; + if ($mi) { + foreach ($mi['mix'] as $x) { + $share = $mi['gm_total'] > 0 ? ($x['gm'] > 0 ? $x['gm'] / $mi['gm_total'] : 0) : (count($mi['mix']) ? 1 / count($mi['mix']) : 0); + $mixRows[] = [ + 'id' => $x['id'], + 'name' => $x['name'], + 'out' => $x['out'], + 'gm' => $x['gm'], + 'share' => round($share * 100, 1), + 'kg' => round($mixKgP[$x['id']] ?? 0, 1), + 'kg_open' => round($mixKgO[$x['id']] ?? 0, 1), + ]; + } + } + + $status = self::mixKg($mi, $kind, 1)['status']; + return [ + 'success' => true, + 'key' => $key, + 'head' => [ + 'id' => $first['id'], + 'cat' => $first['cat'], + 'cat_label' => self::CAT_LABELS[$first['cat']] ?? $first['cat'], + 'type' => $first['type'], + 'type_label' => self::TYPE_LABELS[$first['type']] ?? $first['type'], + 'name' => $first['label'], + 'code' => $first['code'], + 'photo' => $first['id'] ? ($ix['info'][$first['id']]['photo'] ?? null) : null, + 'uom' => $uom, + 'kind' => $kind, + 'raws' => array_values(array_unique(array_map([self::class, 'norm'], $raws))), + 'lines' => $prodLines, + 'ws' => $mi['ws'] ?? null, + 'mix_source' => $mi['source'] ?? 'nessuna', + 'gm_total' => $mi['gm_total'] ?? 0, + 'mix_status' => $status, + ], + 'tot' => [ + 'n' => $tot['n'], + 'qty' => round($tot['qty'], 3), + 'value' => round($tot['value'], 2), + 'price' => $avg !== null ? round($avg, 4) : null, + 'pmin' => $tot['pmin'] !== null ? round($tot['pmin'], 4) : null, + 'pmax' => $tot['pmax'] !== null ? round($tot['pmax'], 4) : null, + 'kg' => round($tot['kg'], 1), + 'open_qty' => round($tot['open_qty'], 3), + 'open_kg' => round($tot['open_kg'], 1), + 'clients' => count($clients), + ], + 'clients' => $clients, + 'months' => $mrows, + 'mix' => $mixRows, + 'lines' => array_reverse($out), + ]; + } +} diff --git a/public/userarea/sales_help.js b/public/userarea/sales_help.js index 50cdc1c3..cccfd51d 100644 --- a/public/userarea/sales_help.js +++ b/public/userarea/sales_help.js @@ -413,10 +413,107 @@
  • Da consegnare: righe aperte con quantità mancante e date (ZIBO e cliente). Righe molto vecchie spesso sono da chiudere in Mago.
  • Ordini: gli ordini del cliente; clic sul numero per aprirlo.
  • Articoli: cosa compra, quanto, e il prezzo medio degli ultimi 12 mesi rispetto ai 12 precedenti: utile per verificare gli aumenti di listino.
  • +
  • Matrici: le matrici che ordina, con il prezzo per metro o per kg e la variazione sull'anno prima. Clic sul nome per confrontarlo con gli altri clienti.
  • Fatture: fatture, acconti e note di credito degli ultimi 3 anni.
  • Ogni tabella ha il suo pulsante Excel.

    `, }, + + /* ===================== MATRICI ===================== */ + "page.matrici": { + t: "Matrici: come si legge", + b: `

    A che prezzo vendiamo ogni matrice, nell'unità in cui la vendiamo: €/metro se a metri, €/kg se a chili. E quanta mescola consuma.

    + +

    Le mescole sono una stima dall'ultima scheda di lavoro, senza scarto. Le righe a pezzi aspettano la conversione pezzi → metri.

    `, + }, + "mx.types": { + t: "Tipo", + b: `

    Il tipo viene dalla lettera iniziale del campo Matrice/Misura di Mago:

    + +

    Avvisi accanto alle matrici: non in anagrafica codice che non esiste in anagrafica · altra revisione revisione diversa da quella in anagrafica · abbinata a mano da "Ricorda corrispondenza".

    +

    Da sistemare raccoglie matrici non in anagrafica, altre revisioni e valori non riconosciuti.

    `, + }, + "mx.k.value": { + t: "Venduto con matrice", + b: `

    Imponibile delle righe d'ordine del periodo che hanno il campo Matrice/Misura compilato, con il numero di matrici dell'anagrafica e di clienti.

    `, + }, + "mx.k.qty": { + t: "Venduto a metri / a chili", + b: `

    Quantità venduta nel periodo e prezzo medio (imponibile ÷ quantità) su tutte le righe in quell'unità.

    +

    Clic sul box per vedere solo le matrici vendute in quell'unità.

    `, + }, + "mx.k.mix": { + t: "Mescola consumata", + b: `

    Kg di mescola stimati per le righe vendute nel periodo.

    +

    Ancora da produrre: kg necessari per le righe ancora da consegnare (qualsiasi data). È la base per gli acquisti di mescola.

    `, + }, + "mx.k.pz": { + t: "Venduto a pezzi", + b: `

    Righe vendute a pezzi (UM N.). Il prezzo al pezzo c'è, ma la mescola non si può stimare finché non c'è la conversione pezzi → metri.

    `, + }, + "mx.k.cover": { + t: "Copertura anagrafica", + 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.map": { + t: "Mappa dei prezzi", + b: `

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

    + +

    Le bolle in basso a destra sono matrici molto vendute a prezzo basso: le prime da guardare per un aumento.

    `, + }, + "mx.moves": { + t: "Prezzi in movimento", + b: `

    Prezzo medio degli ultimi 12 mesi rispetto ai 12 mesi precedenti, per le matrici vendute in entrambi i periodi.

    +

    Serve a verificare che gli aumenti di listino siano stati applicati e a scoprire cali non voluti.

    `, + }, + "mx.table": { + t: "Classifica matrici", + b: ` +

    La stessa matrice venduta a metri e a chili compare su due righe.

    `, + }, + "mx.mix": { + t: "Mescole consumate per mese", + b: `

    Kg di mescola stimati dalle vendite, mese per mese.

    + +

    Per ora senza scarto.

    `, + }, + "mx.mixneed": { + t: "Fabbisogno mescole", + b: `

    Venduto: kg di mescola delle righe ordinate nel periodo.

    +

    Da produrre: kg che servono per le righe ancora da consegnare, qualsiasi sia la data dell'ordine.

    +

    Quando avremo il costo delle mescole, da qui si calcolerà anche il costo grezzo.

    `, + }, + "mx.dprice": { + 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.

    `, + }, }; /* ===================== STILE ===================== */ diff --git a/public/userarea/sales_matrici.php b/public/userarea/sales_matrici.php new file mode 100644 index 00000000..421080d3 --- /dev/null +++ b/public/userarea/sales_matrici.php @@ -0,0 +1,1979 @@ +getConnection(); +$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +if (!userCan('sales.stats.view')) { + if (isset($_GET['action'])) { + MagoSalesStats::jsonOut(['success' => 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 + * ============================================================ */ +function mx_date($v, $def) +{ + $v = (string)$v; + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : $def; +} +function mx_customers() +{ + $c = trim((string)($_GET['c'] ?? '')); + return $c === '' ? [] : array_slice(array_values(array_filter(array_map('trim', explode(',', $c)))), 0, 200); +} + +$action = $_GET['action'] ?? ''; + +if ($action === 'filters') { + MagoSalesStats::jsonOut(MagoSalesStats::filterOptions($pdo)); +} + +if ($action === 'list' || $action === 'detail') { + try { + $from = mx_date($_GET['from'] ?? '', date('Y-m-d', strtotime('-365 days'))); + $to = mx_date($_GET['to'] ?? '', date('Y-m-d')); + if ($from < MagoMatriceStats::SINCE) $from = MagoMatriceStats::SINCE; + if ($to < $from) $to = $from; + if ($action === 'list') { + MagoSalesStats::jsonOut(MagoMatriceStats::buildList($pdo, $from, $to, mx_customers())); + } + MagoSalesStats::jsonOut(MagoMatriceStats::buildDetail($pdo, (string)($_GET['k'] ?? ''), $from, $to, mx_customers())); + } catch (Throwable $e) { + MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]); + } +} +?> + + + + + + + + + Matrici: prezzi e mescole - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?> + + + + + + + + + + + + + + + + + + + +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    Matrici: prezzi e mescole
    + Dati Mago4 +
    +
    + +
    +
    + +
    + + + +
    +
    +
    + +
    + + + + + + + + +
    +
    +
    + +
    +
    + +
    +
    +
    + +
    + + + + + + + +
    +
    +
    + +
    + + + + +
    +
    +
    +
    + + + +
    +
    +
    Carico le righe d'ordine…
    +
    + + +
    +
    +
    +
    + +
    + + + + + + + + + + + + \ No newline at end of file