2026-05-27 14:07:02 +02:00

438 lines
9.9 KiB
Python

import os
from datetime import datetime
import psycopg2
import psycopg2.extras
from flask import Flask, redirect, render_template_string, request, url_for
def getenv(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
PG_CONFIG = {
"host": getenv("PGHOST", required=True),
"port": int(getenv("PGPORT", "5432")),
"dbname": getenv("PGDATABASE", required=True),
"user": getenv("PGUSER", required=True),
"password": getenv("PGPASSWORD", required=True),
"connect_timeout": 5,
}
APP_TITLE = getenv("APP_TITLE", "MESAVAULT Edge-OEE - Consola de paros")
DEFAULT_MACHINE_ID = getenv("DEFAULT_MACHINE_ID", "CORT-01")
DEFAULT_ASSET = getenv("DEFAULT_ASSET", "revpi_oee_node_01")
app = Flask(__name__)
def get_conn():
return psycopg2.connect(**PG_CONFIG)
def fetch_pending_stops():
sql = """
SELECT
id,
tenant,
site,
vertical,
asset,
machine_id,
order_id,
started_at,
ended_at,
status,
classification_status,
cause_code,
cause_name,
operator_note,
duration_min,
cost_eur
FROM mv_hot.edge_oee_stops_recent
WHERE classification_status = 'PENDING'
ORDER BY started_at DESC
LIMIT 50;
"""
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql)
return cur.fetchall()
def fetch_causes():
sql = """
SELECT
cause_code,
cause_name,
description,
is_planned
FROM mv_hot.edge_oee_stop_cause_options
ORDER BY sort_order, cause_name;
"""
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql)
return cur.fetchall()
def classify_stop(stop_id, cause_code, operator_note):
sql = """
UPDATE mv_hot.edge_oee_machine_stops
SET
cause_code = %(cause_code)s,
operator_note = %(operator_note)s,
classification_status = 'CLASSIFIED',
updated_at = now()
WHERE id = %(stop_id)s
AND classification_status = 'PENDING';
"""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
sql,
{
"stop_id": stop_id,
"cause_code": cause_code,
"operator_note": operator_note,
},
)
PAGE = """
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{{ app_title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="15">
<style>
:root {
--bg: #0f172a;
--card: #111827;
--card2: #1f2937;
--text: #e5e7eb;
--muted: #9ca3af;
--line: #374151;
--accent: #f97316;
--danger: #ef4444;
--ok: #22c55e;
--button: #2563eb;
--button-hover: #1d4ed8;
}
body {
margin: 0;
font-family: Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
padding: 18px 24px;
border-bottom: 1px solid var(--line);
background: #020617;
}
h1 {
margin: 0;
font-size: 24px;
}
.subtitle {
color: var(--muted);
margin-top: 6px;
font-size: 14px;
}
main {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.summary {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.summary-card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
padding: 14px 18px;
min-width: 180px;
}
.summary-card .label {
color: var(--muted);
font-size: 13px;
margin-bottom: 8px;
}
.summary-card .value {
font-size: 26px;
font-weight: bold;
}
table {
width: 100%;
border-collapse: collapse;
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
}
th, td {
padding: 12px;
border-bottom: 1px solid var(--line);
vertical-align: top;
text-align: left;
font-size: 14px;
}
th {
background: var(--card2);
color: #f9fafb;
font-weight: bold;
}
tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: bold;
}
.badge-open {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.4);
}
.badge-closed {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
border: 1px solid rgba(34, 197, 94, 0.4);
}
.badge-pending {
background: rgba(249, 115, 22, 0.15);
color: #fdba74;
border: 1px solid rgba(249, 115, 22, 0.4);
}
select, input[type="text"] {
width: 100%;
box-sizing: border-box;
background: #020617;
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
padding: 9px;
font-size: 14px;
}
button {
background: var(--button);
color: white;
border: none;
border-radius: 6px;
padding: 10px 14px;
cursor: pointer;
font-weight: bold;
width: 100%;
}
button:hover {
background: var(--button-hover);
}
.empty {
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
padding: 32px;
text-align: center;
color: var(--muted);
}
.small {
font-size: 12px;
color: var(--muted);
}
.nowrap {
white-space: nowrap;
}
</style>
</head>
<body>
<header>
<h1>{{ app_title }}</h1>
<div class="subtitle">
Clasificación manual de paros detectados automáticamente por Edge-OEE.
Última carga: {{ now }}
</div>
</header>
<main>
<div class="summary">
<div class="summary-card">
<div class="label">Paros pendientes</div>
<div class="value">{{ stops|length }}</div>
</div>
<div class="summary-card">
<div class="card-title">Máquina monitorizada</div>
<div class="card-value">{{ default_machine }}</div>
<div class="card-subtitle">{{ default_asset }}</div>
</div>
</div>
{% if stops|length == 0 %}
<div class="empty">
No hay paros pendientes de clasificar.
</div>
{% else %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Máquina</th>
<th>Inicio</th>
<th>Fin</th>
<th>Estado</th>
<th>Duración</th>
<th>Coste</th>
<th>Causa</th>
<th>Nota</th>
<th>Guardar</th>
</tr>
</thead>
<tbody>
{% for stop in stops %}
<tr>
<form method="post" action="{{ url_for('classify') }}">
<input type="hidden" name="stop_id" value="{{ stop.id }}">
<td class="nowrap">#{{ stop.id }}</td>
<td>
<strong>{{ stop.machine_id or "-" }}</strong>
<div class="small">{{ stop.asset }}</div>
</td>
<td class="nowrap">
{{ stop.started_at }}
</td>
<td class="nowrap">
{{ stop.ended_at or "Abierto" }}
</td>
<td>
{% if stop.status == 'OPEN' %}
<span class="badge badge-open">OPEN</span>
{% else %}
<span class="badge badge-closed">CLOSED</span>
{% endif %}
<br>
<span class="badge badge-pending">{{ stop.classification_status }}</span>
</td>
<td class="nowrap">
{{ "%.2f"|format(stop.duration_min or 0) }} min
</td>
<td class="nowrap">
{{ "%.2f"|format(stop.cost_eur or 0) }} €
</td>
<td>
<select name="cause_code" required>
<option value="">Seleccionar causa...</option>
{% for cause in causes %}
<option value="{{ cause.cause_code }}">
{{ cause.cause_name }}
</option>
{% endfor %}
</select>
</td>
<td>
<input type="text" name="operator_note" placeholder="Nota opcional">
</td>
<td>
<button type="submit">Guardar</button>
</td>
</form>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</main>
</body>
</html>
"""
@app.route("/", methods=["GET"])
def index():
stops = fetch_pending_stops()
causes = fetch_causes()
return render_template_string(
PAGE,
app_title=APP_TITLE,
stops=stops,
causes=causes,
now=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
default_machine=DEFAULT_MACHINE_ID,
default_asset=DEFAULT_ASSET,
)
@app.route("/classify", methods=["POST"])
def classify():
stop_id = request.form.get("stop_id")
cause_code = request.form.get("cause_code")
operator_note = request.form.get("operator_note", "").strip() or None
if stop_id and cause_code:
classify_stop(stop_id, cause_code, operator_note)
return redirect(url_for("index"))
@app.route("/health", methods=["GET"])
def health():
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1;")
return {"status": "ok"}, 200
except Exception as exc:
return {"status": "error", "error": str(exc)}, 500