431 lines
9.7 KiB
Python
431 lines
9.7 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", "CORTADORA 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="label">Máquina demo</div>
|
|
<div class="value">{{DEFAULT_MACHINE_ID}}</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,
|
|
)
|
|
|
|
|
|
@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
|