'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]; } /* ============================================================ * 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 * ============================================================ */ /** 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); $prices = self::mixPrices($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, 'cost_value' => 0, 'cost_cover_value' => 0, 'cost_rows' => 0, 'cost_partial_rows' => 0, 'check_rows' => 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; $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'], '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'], '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']); $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, 'price_months' => MagoMixPurchases::PRICE_MONTHS, 'has_prices' => !empty($prices), ]; } /* ============================================================ * 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; $prices = self::mixPrices($pdo); $costNow = self::costOf($mi, $kind, $prices); $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), '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']); 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)]; // 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) { $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, ], '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), '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), ]; } }