'Linee adesso', 'oggi' => 'Oggi e settimana', 'andamento' => 'Andamento 14 giorni', 'fermi' => 'Fermi della settimana', 'coda' => 'Lavoro in arrivo', 'meteo' => 'Meteo e previsioni', 'sicurezza' => 'Giorni senza infortuni', 'obiettivi' => 'Obiettivi', 'comunicazioni' => 'Comunicazioni', 'benvenuti' => 'Benvenuti e ringraziamenti', 'turni' => 'Turni e calendario', 'menu' => 'Menu', ]; const ITEM_TYPES = [ 'comunicazione' => 'Comunicazione', 'benvenuto' => 'Benvenuto / grazie', 'turni' => 'Turni e calendario', 'menu' => 'Menu', 'ticker' => 'Avviso scorrevole', ]; /** Obiettivi: calcolati dai dati (sul periodo dell'obiettivo) o inseriti a mano. */ const GOAL_SOURCES = [ 'manuale' => ['Valore inserito a mano', '', false], 'metri_mese' => ['Metri prodotti', 'm', false], 'kg_mese' => ['Kg prodotti', 'kg', false], 'lavori_mese' => ['Lavori completati', '', false], 'scarto_mese' => ['Scarto %', '%', true], 'fermi_mese' => ['Ore di fermo', 'h', true], ]; const DEFAULTS = [ 'company' => 'Zibo', 'slide_seconds' => '15', 'slide_durations' => '', 'slides' => 'linee,oggi,andamento,fermi,coda,meteo,sicurezza,obiettivi,comunicazioni,benvenuti,turni,menu', 'scarto_target' => '3', 'last_incident' => '', 'record_days' => '', 'safety_note' => '', 'city' => '', 'lat' => '', 'lon' => '', 'token' => '', ]; /* ===================== IMPOSTAZIONI ===================== */ public static function settings(PDO $pdo) { $s = self::DEFAULTS; foreach ($pdo->query("SELECT setting_key, setting_value FROM kiosk_settings") as $r) { $s[$r['setting_key']] = (string)$r['setting_value']; } return $s; } public static function set(PDO $pdo, $key, $value) { $pdo->prepare("INSERT INTO kiosk_settings (setting_key, setting_value, updated_at) VALUES (?, ?, NOW()) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()") ->execute([$key, (string)$value]); } /** Codice segreto del link dello schermo (creato al primo uso). */ public static function token(PDO $pdo, $renew = false) { $t = self::settings($pdo)['token']; if ($t === '' || $renew) { $t = bin2hex(random_bytes(12)); self::set($pdo, 'token', $t); } return $t; } /** Secondi per tipo di slide (vuoto = durata predefinita). */ public static function durations(array $s) { $out = []; foreach ((array)json_decode((string)$s['slide_durations'], true) as $k => $v) { if (isset(self::SLIDES[$k]) && (int)$v > 0) $out[$k] = max(4, min(300, (int)$v)); } return $out; } /* ===================== DATE ===================== */ private static function tz() { return new DateTimeZone(self::TZ); } /** Mezzanotte locale di oggi + $days giorni. */ public static function day($days = 0) { $d = new DateTime('today', self::tz()); if ($days) $d->modify(($days > 0 ? '+' : '') . $days . ' day'); return $d; } public static function monday($weeks = 0) { $d = new DateTime('monday this week', self::tz()); if ($weeks) $d->modify(($weeks > 0 ? '+' : '') . $weeks . ' week'); return $d; } /** DateTime locale → stringa UTC per confrontare con start_time / end_time. */ public static function utc(DateTime $d) { $x = clone $d; $x->setTimezone(new DateTimeZone('UTC')); return $x->format('Y-m-d H:i:s'); } public static function ms($utc) { return $utc ? (new DateTime($utc, new DateTimeZone('UTC')))->getTimestamp() * 1000 : null; } /* ===================== PRODUZIONE ===================== */ /** Lavori conclusi tra due istanti locali. */ public static function produced(PDO $pdo, DateTime $from, DateTime $to) { $st = $pdo->prepare("SELECT COUNT(*) n, COALESCE(SUM(mtprod), 0) mt, COALESCE(SUM(kgprod), 0) kg, COALESCE(SUM(scarto), 0) sc FROM productiondata WHERE end_time >= ? AND end_time < ? AND id_status NOT IN (" . implode(',', self::NOT_FINISHED) . ")"); $st->execute([self::utc($from), self::utc($to)]); $r = $st->fetch(PDO::FETCH_ASSOC); $kg = (float)$r['kg']; $sc = (float)$r['sc']; return [ 'n' => (int)$r['n'], 'mt' => round((float)$r['mt']), 'kg' => round($kg), 'scarto' => ($kg + $sc) > 0 ? round($sc / ($kg + $sc) * 100, 1) : null, ]; } /** Ore di fermo (pause) tra due istanti locali, per motivo. */ public static function stops(PDO $pdo, DateTime $from, DateTime $to) { $f = self::utc($from); $t = self::utc($to); $st = $pdo->prepare(" SELECT COALESCE(pr.name, 'Motivo non indicato') AS name, COALESCE(MAX(pr.is_problem), 0) AS problem, SUM(GREATEST(0, TIMESTAMPDIFF(SECOND, GREATEST(pp.start_pause, ?), LEAST(COALESCE(pp.end_pause, UTC_TIMESTAMP()), ?)))) AS sec FROM production_pauses pp LEFT JOIN pause_reasons pr ON pr.id = pp.reason_id WHERE pp.start_pause < ? AND COALESCE(pp.end_pause, UTC_TIMESTAMP()) > ? GROUP BY COALESCE(pr.name, 'Motivo non indicato') ORDER BY sec DESC"); $st->execute([$f, $t, $t, $f]); $items = []; $tot = 0; foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) { $h = (int)$r['sec'] / 3600; if ($h <= 0) continue; $tot += $h; $items[] = ['name' => $r['name'], 'h' => round($h, 1), 'problem' => (int)$r['problem'] === 1]; } return ['total_h' => round($tot, 1), 'items' => $items]; } private static function lines(PDO $pdo) { $lines = []; foreach ($pdo->query("SELECT * FROM production_lines ORDER BY line_number, id")->fetchAll(PDO::FETCH_ASSOC) as $l) { if (isset($l['status']) && $l['status'] !== '' && $l['status'] !== 'active') continue; $lines[(int)$l['id']] = [ 'id' => (int)$l['id'], 'name' => $l['name'], 'color' => !empty($l['color']) ? $l['color'] : '#64748b', 'job' => null, 'queue_n' => 0, 'queue_h' => 0, 'next' => null, ]; } // in macchina $st = $pdo->query(" SELECT p.id, p.id_linea, p.id_status, p.start_time, p.ore_previste, m.nome AS matrice, (SELECT GROUP_CONCAT(ms.nome ORDER BY ms.nome SEPARATOR ', ') FROM productiondata_mescole pm JOIN mescole ms ON ms.id = pm.id_mescola WHERE pm.id_productiondata = p.id) AS mescole, (SELECT COALESCE(SUM(TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause)), 0) FROM production_pauses pp WHERE pp.id_production = p.id AND pp.end_pause IS NOT NULL) AS pause_sec, (SELECT pp.start_pause FROM production_pauses pp WHERE pp.id_production = p.id AND pp.end_pause IS NULL ORDER BY pp.start_pause DESC LIMIT 1) AS open_pause FROM productiondata p LEFT JOIN matrice m ON m.id = p.idmatrice WHERE p.id_status IN (" . self::ST_PRODUZIONE . "," . self::ST_PAUSA . ") ORDER BY p.start_time"); foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $j) { $lid = (int)$j['id_linea']; if (!isset($lines[$lid]) || $lines[$lid]['job']) continue; $lines[$lid]['job'] = [ 'matrice' => $j['matrice'], 'mescole' => (string)$j['mescole'], 'paused' => (int)$j['id_status'] === self::ST_PAUSA, 'start_ms' => self::ms($j['start_time']), 'pause_sec' => (int)$j['pause_sec'], 'open_pause_ms' => self::ms($j['open_pause']), 'ore' => (float)$j['ore_previste'], ]; } // coda foreach ( $pdo->query("SELECT p.id_linea, m.nome, p.ore_previste FROM productiondata p LEFT JOIN matrice m ON m.id = p.idmatrice WHERE p.id_status = " . self::ST_PROGRAMMATO . " ORDER BY p.priority IS NULL, p.priority, p.id") as $r ) { $lid = (int)$r['id_linea']; if (!isset($lines[$lid])) continue; $lines[$lid]['queue_n']++; $lines[$lid]['queue_h'] += (float)$r['ore_previste']; if ($lines[$lid]['next'] === null) $lines[$lid]['next'] = $r['nome']; } foreach ($lines as &$l) $l['queue_h'] = round($l['queue_h'], 1); unset($l); return array_values($lines); } /** Metri prodotti negli ultimi $days giorni, per giorno e linea. */ private static function trend(PDO $pdo, array $lines, $days = 14) { $from = self::day(- ($days - 1)); $labels = []; $idx = []; $GG = ['dom', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab']; for ($i = 0; $i < $days; $i++) { $d = (clone $from)->modify("+$i day"); $idx[$d->format('Y-m-d')] = $i; $labels[] = $GG[(int)$d->format('w')] . ' ' . $d->format('d/m'); } $series = []; foreach ($lines as $l) $series[$l['id']] = ['name' => $l['name'], 'color' => $l['color'], 'data' => array_fill(0, $days, 0)]; $st = $pdo->prepare("SELECT id_linea, end_time, mtprod FROM productiondata WHERE end_time >= ? AND id_status NOT IN (" . implode(',', self::NOT_FINISHED) . ")"); $st->execute([self::utc($from)]); foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) { $d = (new DateTime($r['end_time'], new DateTimeZone('UTC')))->setTimezone(self::tz())->format('Y-m-d'); $lid = (int)$r['id_linea']; if (isset($idx[$d], $series[$lid])) $series[$lid]['data'][$idx[$d]] += round((float)$r['mtprod']); } return ['labels' => $labels, 'series' => array_values($series)]; } /** Contenuti validi oggi, raggruppati per tipo. */ public static function items(PDO $pdo, $onlyValid = true) { $sql = "SELECT * FROM kiosk_items" . ($onlyValid ? " WHERE active = 1 AND (valid_from IS NULL OR valid_from <= :d) AND (valid_to IS NULL OR valid_to >= :d2)" : "") . " ORDER BY sort, id DESC"; $st = $pdo->prepare($sql); $today = self::day()->format('Y-m-d'); $st->execute($onlyValid ? ['d' => $today, 'd2' => $today] : []); return $st->fetchAll(PDO::FETCH_ASSOC); } public static function goals(PDO $pdo, $onlyActive = true) { $rows = $pdo->query("SELECT * FROM kiosk_goals" . ($onlyActive ? " WHERE active = 1" : "") . " ORDER BY sort, id")->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as &$g) { $from = $g['period_from'] ? new DateTime($g['period_from'], self::tz()) : new DateTime('first day of this month 00:00', self::tz()); $to = $g['period_to'] ? (new DateTime($g['period_to'], self::tz()))->modify('+1 day') : (new DateTime('first day of next month 00:00', self::tz())); $now = new DateTime('now', self::tz()); $end = $to < $now ? $to : $now; $cur = null; switch ($g['source']) { case 'metri_mese': $cur = self::produced($pdo, $from, $end)['mt']; break; case 'kg_mese': $cur = self::produced($pdo, $from, $end)['kg']; break; case 'lavori_mese': $cur = self::produced($pdo, $from, $end)['n']; break; case 'scarto_mese': $cur = self::produced($pdo, $from, $end)['scarto']; break; case 'fermi_mese': $cur = self::stops($pdo, $from, $end)['total_h']; break; default: $cur = $g['current_value'] !== null ? (float)$g['current_value'] : null; } $target = (float)$g['target']; $lower = (int)$g['lower_is_better'] === 1; $pct = null; if ($cur !== null && $target > 0) { $pct = $lower ? ($cur <= $target ? 100 : max(0, round($target / max($cur, 0.0001) * 100))) : min(100, round($cur / $target * 100)); } $g['current'] = $cur; $g['pct'] = $pct; $g['ok'] = $cur !== null && ($lower ? $cur <= $target : $cur >= $target); $g['period_label'] = $from->format('d/m') . ' – ' . (clone $to)->modify('-1 day')->format('d/m'); } unset($g); return $rows; } public static function safety(array $s) { if ($s['last_incident'] === '') return null; $last = new DateTime($s['last_incident'], self::tz()); $days = (int)$last->diff(self::day())->format('%r%a'); $record = $s['record_days'] !== '' ? (int)$s['record_days'] : null; return [ 'days' => max(0, $days), 'record' => $record !== null ? max($record, $days) : null, 'since' => $last->format('d/m/Y'), 'note' => $s['safety_note'], ]; } /* ===================== TUTTO PER LO SCHERMO ===================== */ public static function data(PDO $pdo) { $s = self::settings($pdo); $now = new DateTime('now', self::tz()); $lines = self::lines($pdo); // oggi a quest'ora contro ieri alla stessa ora; settimana in corso contro la scorsa allo stesso punto $today = self::day(); $yest = self::day(-1); $yestSame = (clone $yest)->setTime((int)$now->format('H'), (int)$now->format('i'), (int)$now->format('s')); $mon = self::monday(); $monPrev = self::monday(-1); $samePrev = (clone $monPrev)->modify('+' . ($now->getTimestamp() - $mon->getTimestamp()) . ' seconds'); $items = ['comunicazione' => [], 'benvenuto' => [], 'turni' => [], 'menu' => [], 'ticker' => []]; foreach (self::items($pdo) as $it) { if (!isset($items[$it['item_type']])) continue; $items[$it['item_type']][] = [ 'id' => (int)$it['id'], 'title' => $it['title'], 'body' => (string)$it['body'], 'image' => $it['image'], ]; } $goals = array_map(function ($g) { return [ 'title' => $g['title'], 'unit' => $g['unit'], 'target' => (float)$g['target'], 'current' => $g['current'], 'pct' => $g['pct'], 'ok' => $g['ok'], 'lower' => (int)$g['lower_is_better'] === 1, 'period' => $g['period_label'], ]; }, self::goals($pdo)); return [ 'success' => true, 'now_ms' => time() * 1000, 'company' => $s['company'], 'settings' => [ 'slide_seconds' => max(6, min(120, (int)$s['slide_seconds'] ?: 15)), 'slides' => array_values(array_filter(explode(',', $s['slides']))), 'durations' => (object)self::durations($s), 'scarto_target' => (float)str_replace(',', '.', $s['scarto_target']), 'city' => $s['city'], 'lat' => $s['lat'], 'lon' => $s['lon'], ], 'lines' => $lines, 'today' => ['cur' => self::produced($pdo, $today, $now), 'prev' => self::produced($pdo, $yest, $yestSame)], 'week' => ['cur' => self::produced($pdo, $mon, $now), 'prev' => self::produced($pdo, $monPrev, $samePrev)], 'trend' => self::trend($pdo, $lines), 'stops' => self::stops($pdo, $mon, $now), 'safety' => self::safety($s), 'goals' => $goals, 'items' => $items, ]; } }