Files
zibo-dashboard/public/userarea/class/Kiosk.php
T
2026-10-21 10:03:59 +02:00

385 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Kiosk - dati dello schermo di reparto (sala pausa).
*
* Mostra solo dati di produzione e comunicazioni: niente fatturato, prezzi o clienti.
* I tempi di produzione sono salvati in UTC (UTC_TIMESTAMP): i giorni si calcolano in ora italiana.
*/
class Kiosk
{
const TZ = 'Europe/Rome';
/** Stati non conclusi: tutto il resto con end_time è un lavoro finito. */
const NOT_FINISHED = [1, 6, 2, 7];
const ST_PROGRAMMATO = 6;
const ST_PRODUZIONE = 2;
const ST_PAUSA = 7;
const SLIDES = [
'linee' => 'Linee adesso',
'oggi' => 'Oggi e settimana',
'andamento' => 'Andamento 14 giorni',
'fermi' => 'Fermi della settimana',
'coda' => 'Lavoro in arrivo',
'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',
'slides' => 'linee,oggi,andamento,fermi,coda,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;
}
/* ===================== 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']))),
'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,
];
}
}