import json import os from datetime import datetime import psycopg2 import psycopg2.extras from flask import Flask, Response, 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 UCEPSA") DEFAULT_MACHINE_ID = getenv("DEFAULT_MACHINE_ID", "CORT-01") DEFAULT_ASSET = getenv("DEFAULT_ASSET", "revpi_oee_node_01") AUTO_REFRESH_SECONDS = int(getenv("AUTO_REFRESH_SECONDS", "5")) ALLOWED_MACHINES = [ item.strip() for item in getenv("ALLOWED_MACHINES", DEFAULT_MACHINE_ID).split(",") if item.strip() ] def resolve_machine_id(raw_value): machine_id = (raw_value or DEFAULT_MACHINE_ID).strip() if machine_id not in ALLOWED_MACHINES: return DEFAULT_MACHINE_ID return machine_id app = Flask(__name__) def get_conn(): return psycopg2.connect(**PG_CONFIG) def fetch_pending_stops(machine_id): 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' AND machine_id = %(machine_id)s 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, {"machine_id": machine_id}) 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, machine_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 machine_id = %(machine_id)s AND classification_status = 'PENDING'; """ with get_conn() as conn: with conn.cursor() as cur: cur.execute( sql, { "stop_id": stop_id, "machine_id": machine_id, "cause_code": cause_code, "operator_note": operator_note, }, ) return cur.rowcount def fmt_dt(value): if not value: return "" if isinstance(value, datetime): return value.strftime("%Y-%m-%d %H:%M:%S") return str(value) def fmt_num(value, decimals=2): if value is None: return "0" try: return f"{float(value):.{decimals}f}" except Exception: return str(value) HTML = """ {{ app_title }}

{{ app_title }}

Clasificación manual de paros detectados por Edge-OEE · Máquina seleccionada: {{ selected_machine }} · Última carga: {{ loaded_at }}
{% for machine in allowed_machines %} {{ machine }} {% endfor %}
Paros pendientes
{{ pending_count }}
Máquina monitorizada
{{ selected_machine }}
{{ default_asset }}
{% if stops %} {% for stop in stops %} {% endfor %}
ID Máquina Inicio Fin Estado Duración Coste Causa Nota Guardar
#{{ stop.id }}
{{ stop.machine_id }}
{{ stop.asset }}
{{ fmt_dt(stop.started_at) }} {% if stop.ended_at %} {{ fmt_dt(stop.ended_at) }} {% else %} Abierto {% endif %} {% if stop.status == "OPEN" %} OPEN {% else %} {{ stop.status }} {% endif %}
{{ stop.classification_status }}
{{ fmt_num(stop.duration_min, 2) }} min {{ fmt_num(stop.cost_eur, 2) }} €
{% else %}
No hay paros pendientes para {{ selected_machine }}.
{% endif %}
""" @app.route("/", methods=["GET"]) def index(): selected_machine = resolve_machine_id(request.args.get("machine_id")) stops = fetch_pending_stops(selected_machine) causes = fetch_causes() return render_template_string( HTML, app_title=APP_TITLE, stops=stops, causes=causes, pending_count=len(stops), selected_machine=selected_machine, allowed_machines=ALLOWED_MACHINES, default_machine=selected_machine, default_asset=DEFAULT_ASSET, auto_refresh_seconds=AUTO_REFRESH_SECONDS, loaded_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), fmt_dt=fmt_dt, fmt_num=fmt_num, ) @app.route("/classify/", methods=["POST"]) def classify(stop_id): selected_machine = resolve_machine_id( request.form.get("machine_id") or request.args.get("machine_id") ) cause_code = (request.form.get("cause_code") or "").strip() operator_note = (request.form.get("operator_note") or "").strip() if cause_code: classify_stop( stop_id=stop_id, machine_id=selected_machine, cause_code=cause_code, operator_note=operator_note, ) return redirect(url_for("index", machine_id=selected_machine)) @app.route("/health", methods=["GET"]) def health(): try: with get_conn() as conn: with conn.cursor() as cur: cur.execute("SELECT 1;") cur.fetchone() return {"status": "ok"}, 200 except Exception as exc: return {"status": "error", "error": str(exc)}, 500 @app.route("/manifest.webmanifest", methods=["GET"]) def manifest(): selected_machine = resolve_machine_id(request.args.get("machine_id")) payload = { "name": f"MESAVAULT Edge-OEE {selected_machine}", "short_name": selected_machine, "description": "Consola de paros MESAVAULT Edge-OEE para UCEPSA", "start_url": url_for("index", machine_id=selected_machine), "scope": "/", "display": "standalone", "orientation": "landscape", "background_color": "#0f172a", "theme_color": "#0f172a", "icons": [ { "src": url_for("pwa_icon"), "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable", } ], } return Response( json.dumps(payload, ensure_ascii=False), mimetype="application/manifest+json", ) @app.route("/sw.js", methods=["GET"]) def service_worker(): js = """ self.addEventListener("install", function (event) { self.skipWaiting(); }); self.addEventListener("activate", function (event) { event.waitUntil(self.clients.claim()); }); self.addEventListener("fetch", function (event) { event.respondWith(fetch(event.request)); }); """ return Response( js, mimetype="application/javascript", headers={"Cache-Control": "no-cache"}, ) @app.route("/pwa-icon.svg", methods=["GET"]) def pwa_icon(): svg = """ OEE """ return Response(svg, mimetype="image/svg+xml") if __name__ == "__main__": port = int(getenv("APP_PORT", "8080")) app.run(host="0.0.0.0", port=port)