398 lines
17 KiB
PHP
398 lines
17 KiB
PHP
<?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',
|
||
'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,
|
||
];
|
||
}
|
||
}
|