Add UCEPSA order execution report HTML and PDF service

This commit is contained in:
Victor Fraile Garcia 2026-05-28 22:24:21 +02:00
parent 350e9a29c8
commit 90a0da730b
4 changed files with 521 additions and 0 deletions

View File

@ -0,0 +1,28 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
fonts-dejavu-core \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libharfbuzz-subset0 \
libffi8 \
libjpeg62-turbo \
libopenjp2-7 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8090
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090"]

View File

@ -0,0 +1,174 @@
import os
import re
from datetime import datetime
from decimal import Decimal
from typing import Any
import psycopg
from psycopg.rows import dict_row
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse, Response
from fastapi.encoders import jsonable_encoder
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
from weasyprint import HTML
DATABASE_URL = os.getenv("DATABASE_URL")
REPORT_TITLE = os.getenv("REPORT_TITLE", "MESAVAULT Edge-OEE UCEPSA")
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL environment variable is required")
app = FastAPI(title="MESAVAULT Order Report API")
templates = Jinja2Templates(directory="/app/app/templates")
def safe_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")
return cleaned or "order-report"
def fmt_dt(value: Any) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.strftime("%d/%m/%Y %H:%M:%S")
text = str(value)
text = re.sub(r"\.\d+", "", text)
text = text.replace("+02:00", "").replace("+01:00", "")
return text
def fmt_num(value: Any, decimals: int = 2) -> str:
if value is None:
return ""
try:
number = float(value)
return f"{number:.{decimals}f}"
except (ValueError, TypeError):
return str(value)
templates.env.filters["dt"] = fmt_dt
templates.env.filters["num"] = fmt_num
def fetch_one(query: str, params: tuple[Any, ...]) -> dict[str, Any] | None:
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
with conn.cursor() as cur:
cur.execute(query, params)
return cur.fetchone()
def fetch_all(query: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
with conn.cursor() as cur:
cur.execute(query, params)
return list(cur.fetchall())
def build_report_context(order_ref: str) -> dict[str, Any]:
report = fetch_one(
"""
SELECT *
FROM mv_reports_ucepsa_demo.order_execution_report_full_v1
WHERE odoo_order_ref = %s
""",
(order_ref,),
)
if report is None:
raise HTTPException(status_code=404, detail=f"Order not found: {order_ref}")
stops = fetch_all(
"""
SELECT
stop_id,
stop_started_at,
stop_ended_at,
attributed_duration_min,
cause_code,
operator_note,
classification_status,
attributed_cost_eur,
stop_quality_status
FROM mv_reports_ucepsa_demo.order_execution_stops_v1
WHERE odoo_order_ref = %s
ORDER BY attributed_started_at
""",
(order_ref,),
)
timeline = fetch_all(
"""
SELECT
event_ts,
event_type,
event_label,
severity,
cause_code,
duration_min,
cost_eur,
description
FROM mv_reports_ucepsa_demo.order_execution_timeline_v1
WHERE odoo_order_ref = %s
ORDER BY event_ts, event_type
""",
(order_ref,),
)
return {
"title": REPORT_TITLE,
"report": report,
"stops": stops,
"timeline": timeline,
}
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/report")
def api_report(order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025")):
return jsonable_encoder(build_report_context(order_ref))
@app.get("/report.html", response_class=HTMLResponse)
def report_html(
request: Request,
order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025"),
):
context = build_report_context(order_ref)
context["request"] = request
return templates.TemplateResponse(
"order_report.html",
context,
)
@app.get("/report.pdf")
def report_pdf(order_ref: str = Query(..., description="Odoo order reference, e.g. WH/MO/00025")):
context = build_report_context(order_ref)
template = templates.env.get_template("order_report.html")
html_content = template.render(**context)
pdf_bytes = HTML(
string=html_content,
base_url="/app/app",
).write_pdf()
filename = f"mesavault-order-report-{safe_filename(order_ref)}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{filename}"'
},
)

View File

@ -0,0 +1,313 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{{ title }} - {{ report.odoo_order_ref }}</title>
<style>
@page {
size: A4 landscape;
margin: 12mm;
}
* {
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
margin: 0;
color: #1f2933;
background: #ffffff;
font-size: 11px;
}
.page {
background: white;
width: 100%;
}
h1 {
color: #0f172a;
font-size: 24px;
margin: 0 0 4px 0;
}
h2 {
color: #0f172a;
font-size: 15px;
margin: 18px 0 8px 0;
border-bottom: 1px solid #cbd5e1;
padding-bottom: 4px;
}
.muted {
color: #64748b;
}
.summary {
padding: 10px 12px;
background: #eef6f7;
border-left: 4px solid #0f766e;
margin: 12px 0 14px 0;
font-size: 12px;
line-height: 1.35;
}
.grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.card {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px;
background: #ffffff;
min-height: 52px;
}
.label {
font-size: 9px;
color: #64748b;
margin-bottom: 3px;
}
.value {
font-size: 15px;
font-weight: bold;
color: #0f172a;
line-height: 1.1;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 12px;
font-size: 10px;
table-layout: fixed;
}
th {
background: #f1f5f9;
text-align: left;
padding: 5px;
border-bottom: 1px solid #cbd5e1;
font-weight: bold;
}
td {
padding: 5px;
border-bottom: 1px solid #e2e8f0;
vertical-align: top;
word-break: break-word;
}
tr {
break-inside: avoid;
page-break-inside: avoid;
}
.kv-table th:first-child,
.kv-table td:first-child {
width: 24%;
}
.stop-table th:nth-child(1),
.stop-table td:nth-child(1) {
width: 5%;
}
.stop-table th:nth-child(2),
.stop-table td:nth-child(2),
.stop-table th:nth-child(3),
.stop-table td:nth-child(3) {
width: 15%;
white-space: nowrap;
}
.stop-table th:nth-child(4),
.stop-table td:nth-child(4) {
width: 9%;
}
.stop-table th:nth-child(5),
.stop-table td:nth-child(5) {
width: 14%;
}
.timeline-table th:nth-child(1),
.timeline-table td:nth-child(1) {
width: 15%;
white-space: nowrap;
}
.timeline-table th:nth-child(2),
.timeline-table td:nth-child(2) {
width: 13%;
}
.timeline-table th:nth-child(3),
.timeline-table td:nth-child(3) {
width: 13%;
}
.timeline-table th:nth-child(4),
.timeline-table td:nth-child(4) {
width: 7%;
}
.timeline-table th:nth-child(5),
.timeline-table td:nth-child(5) {
width: 11%;
}
.timeline-table th:nth-child(6),
.timeline-table td:nth-child(6),
.timeline-table th:nth-child(7),
.timeline-table td:nth-child(7) {
width: 7%;
}
.timeline-table th:nth-child(8),
.timeline-table td:nth-child(8) {
width: 27%;
}
.small {
font-size: 9px;
}
.section-break {
break-before: auto;
}
</style>
</head>
<body>
<div class="page">
<h1>Informe de ejecución de orden</h1>
<p class="muted">{{ title }}</p>
<div class="summary">
{{ report.executive_summary }}
</div>
<h2>1. Identificación</h2>
<table class="kv-table">
<tr><th>Campo</th><th>Valor</th></tr>
<tr><td>Orden</td><td>{{ report.odoo_order_ref }}</td></tr>
<tr><td>Workorder Odoo</td><td>{{ report.odoo_workorder_id }}</td></tr>
<tr><td>Production Odoo</td><td>{{ report.odoo_production_id }}</td></tr>
<tr><td>Estado Odoo</td><td>{{ report.odoo_state }}</td></tr>
<tr><td>Producto</td><td>{{ report.product }}</td></tr>
<tr><td>Máquina</td><td>{{ report.machine_id }}</td></tr>
<tr><td>Centro de trabajo</td><td>{{ report.odoo_workcenter_name }}</td></tr>
<tr><td>Inicio</td><td>{{ report.start_ts|dt }}</td></tr>
<tr><td>Fin</td><td>{{ report.end_ts|dt }}</td></tr>
</table>
<h2>2. Indicadores principales</h2>
<div class="grid">
<div class="card"><div class="label">Duración total</div><div class="value">{{ report.window_min|num(2) }} min</div></div>
<div class="card"><div class="label">Tiempo marcha</div><div class="value">{{ report.run_min|num(2) }} min</div></div>
<div class="card"><div class="label">Tiempo parado</div><div class="value">{{ report.stop_min|num(2) }} min</div></div>
<div class="card"><div class="label">Disponibilidad</div><div class="value">{{ report.availability_pct|num(2) }} %</div></div>
<div class="card"><div class="label">Ciclos reales</div><div class="value">{{ report.real_cycle_delta }}</div></div>
<div class="card"><div class="label">Ciclos/min marcha</div><div class="value">{{ report.cycles_per_run_min|num(2) }}</div></div>
<div class="card"><div class="label">Energía</div><div class="value">{{ report.kwh_consumed|num(3) }} kWh</div></div>
<div class="card"><div class="label">Coste paros</div><div class="value">{{ report.stop_cost_eur|num(2) }} €</div></div>
</div>
<h2>3. Energía y ciclos</h2>
<table class="kv-table">
<tr><th>Métrica</th><th>Valor</th></tr>
<tr><td>Muestras energía</td><td>{{ report.energy_samples }}</td></tr>
<tr><td>Primera muestra</td><td>{{ report.energy_first_ts|dt }}</td></tr>
<tr><td>Última muestra</td><td>{{ report.energy_last_ts|dt }}</td></tr>
<tr><td>kWh inicial</td><td>{{ report.start_import_kwh|num(3) }}</td></tr>
<tr><td>kWh final</td><td>{{ report.end_import_kwh|num(3) }}</td></tr>
<tr><td>Consumo</td><td>{{ report.kwh_consumed|num(3) }} kWh</td></tr>
<tr><td>Potencia media</td><td>{{ report.avg_kw|num(3) }} kW</td></tr>
<tr><td>Potencia máxima</td><td>{{ report.max_kw|num(3) }} kW</td></tr>
<tr><td>kWh/ciclo</td><td>{{ report.kwh_per_cycle|num(6) }}</td></tr>
</table>
<h2>4. Paros</h2>
<p><strong>Resumen por causa:</strong> {{ report.stop_causes_summary }}</p>
<table class="stop-table">
<tr>
<th>ID</th>
<th>Inicio</th>
<th>Fin</th>
<th>Duración min</th>
<th>Causa</th>
<th>Nota</th>
<th>Coste €</th>
<th>Estado</th>
</tr>
{% for stop in stops %}
<tr>
<td>{{ stop.stop_id }}</td>
<td>{{ stop.stop_started_at|dt }}</td>
<td>{{ stop.stop_ended_at|dt }}</td>
<td>{{ stop.attributed_duration_min|num(2) }}</td>
<td>{{ stop.cause_code }}</td>
<td>{{ stop.operator_note or "" }}</td>
<td>{{ stop.attributed_cost_eur|num(2) }}</td>
<td>{{ stop.stop_quality_status }}</td>
</tr>
{% else %}
<tr><td colspan="8">Sin paros imputados a la orden.</td></tr>
{% endfor %}
</table>
<h2>5. Calidad del dato</h2>
<table class="kv-table">
<tr><th>Métrica</th><th>Valor</th></tr>
<tr><td>Estado informe</td><td>{{ report.final_report_status }}</td></tr>
<tr><td>Score calidad técnica</td><td>{{ report.technical_quality_score }}</td></tr>
<tr><td>Nivel calidad técnica</td><td>{{ report.technical_quality_level }}</td></tr>
<tr><td>Cobertura telemetría</td><td>{{ report.telemetry_sample_coverage_pct|num(2) }} %</td></tr>
<tr><td>Gaps &gt; 30 s</td><td>{{ report.telemetry_gap_count_30s }}</td></tr>
<tr><td>Máximo gap</td><td>{{ report.max_sample_gap_s|num(2) }} s</td></tr>
<tr><td>Muestras no OK</td><td>{{ report.non_ok_samples }}</td></tr>
<tr><td>Conversión producción</td><td>{{ report.production_conversion_status }}</td></tr>
<tr><td>Acción recomendada</td><td>{{ report.recommended_action }}</td></tr>
</table>
<h2>6. Timeline</h2>
<table class="timeline-table">
<tr>
<th>Hora</th>
<th>Tipo</th>
<th>Evento</th>
<th>Nivel</th>
<th>Causa</th>
<th>Duración</th>
<th>Coste</th>
<th>Descripción</th>
</tr>
{% for event in timeline %}
<tr>
<td>{{ event.event_ts|dt }}</td>
<td>{{ event.event_type }}</td>
<td>{{ event.event_label }}</td>
<td>{{ event.severity }}</td>
<td>{{ event.cause_code or "" }}</td>
<td>{{ event.duration_min|num(2) if event.duration_min is not none else "" }}</td>
<td>{{ event.cost_eur|num(2) if event.cost_eur is not none else "" }}</td>
<td>{{ event.description }}</td>
</tr>
{% endfor %}
</table>
<p class="muted small">
Informe generado desde MESAVAULT Edge-OEE usando vistas SQL de PostgreSQL.
</p>
</div>
</body>
</html>

View File

@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
psycopg[binary]==3.2.3
jinja2==3.1.5
python-dotenv==1.0.1
weasyprint==63.1