diff --git a/ucepsa/edge-oee-demo/services/order-report-api/Dockerfile b/ucepsa/edge-oee-demo/services/order-report-api/Dockerfile
new file mode 100644
index 0000000..9d7ec5e
--- /dev/null
+++ b/ucepsa/edge-oee-demo/services/order-report-api/Dockerfile
@@ -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"]
diff --git a/ucepsa/edge-oee-demo/services/order-report-api/app/main.py b/ucepsa/edge-oee-demo/services/order-report-api/app/main.py
new file mode 100644
index 0000000..78b5365
--- /dev/null
+++ b/ucepsa/edge-oee-demo/services/order-report-api/app/main.py
@@ -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}"'
+ },
+ )
diff --git a/ucepsa/edge-oee-demo/services/order-report-api/app/templates/order_report.html b/ucepsa/edge-oee-demo/services/order-report-api/app/templates/order_report.html
new file mode 100644
index 0000000..2d6ba8d
--- /dev/null
+++ b/ucepsa/edge-oee-demo/services/order-report-api/app/templates/order_report.html
@@ -0,0 +1,313 @@
+
+
+
+
+ {{ title }} - {{ report.odoo_order_ref }}
+
+
+
+
+
+
Informe de ejecución de orden
+
{{ title }}
+
+
+ {{ report.executive_summary }}
+
+
+
1. Identificación
+
+ | Campo | Valor |
+ | Orden | {{ report.odoo_order_ref }} |
+ | Workorder Odoo | {{ report.odoo_workorder_id }} |
+ | Production Odoo | {{ report.odoo_production_id }} |
+ | Estado Odoo | {{ report.odoo_state }} |
+ | Producto | {{ report.product }} |
+ | Máquina | {{ report.machine_id }} |
+ | Centro de trabajo | {{ report.odoo_workcenter_name }} |
+ | Inicio | {{ report.start_ts|dt }} |
+ | Fin | {{ report.end_ts|dt }} |
+
+
+
2. Indicadores principales
+
+
Duración total
{{ report.window_min|num(2) }} min
+
Tiempo marcha
{{ report.run_min|num(2) }} min
+
Tiempo parado
{{ report.stop_min|num(2) }} min
+
Disponibilidad
{{ report.availability_pct|num(2) }} %
+
Ciclos reales
{{ report.real_cycle_delta }}
+
Ciclos/min marcha
{{ report.cycles_per_run_min|num(2) }}
+
Energía
{{ report.kwh_consumed|num(3) }} kWh
+
Coste paros
{{ report.stop_cost_eur|num(2) }} €
+
+
+
3. Energía y ciclos
+
+ | Métrica | Valor |
+ | Muestras energía | {{ report.energy_samples }} |
+ | Primera muestra | {{ report.energy_first_ts|dt }} |
+ | Última muestra | {{ report.energy_last_ts|dt }} |
+ | kWh inicial | {{ report.start_import_kwh|num(3) }} |
+ | kWh final | {{ report.end_import_kwh|num(3) }} |
+ | Consumo | {{ report.kwh_consumed|num(3) }} kWh |
+ | Potencia media | {{ report.avg_kw|num(3) }} kW |
+ | Potencia máxima | {{ report.max_kw|num(3) }} kW |
+ | kWh/ciclo | {{ report.kwh_per_cycle|num(6) }} |
+
+
+
4. Paros
+
Resumen por causa: {{ report.stop_causes_summary }}
+
+
+
+ | ID |
+ Inicio |
+ Fin |
+ Duración min |
+ Causa |
+ Nota |
+ Coste € |
+ Estado |
+
+ {% for stop in stops %}
+
+ | {{ stop.stop_id }} |
+ {{ stop.stop_started_at|dt }} |
+ {{ stop.stop_ended_at|dt }} |
+ {{ stop.attributed_duration_min|num(2) }} |
+ {{ stop.cause_code }} |
+ {{ stop.operator_note or "" }} |
+ {{ stop.attributed_cost_eur|num(2) }} |
+ {{ stop.stop_quality_status }} |
+
+ {% else %}
+ | Sin paros imputados a la orden. |
+ {% endfor %}
+
+
+
5. Calidad del dato
+
+ | Métrica | Valor |
+ | Estado informe | {{ report.final_report_status }} |
+ | Score calidad técnica | {{ report.technical_quality_score }} |
+ | Nivel calidad técnica | {{ report.technical_quality_level }} |
+ | Cobertura telemetría | {{ report.telemetry_sample_coverage_pct|num(2) }} % |
+ | Gaps > 30 s | {{ report.telemetry_gap_count_30s }} |
+ | Máximo gap | {{ report.max_sample_gap_s|num(2) }} s |
+ | Muestras no OK | {{ report.non_ok_samples }} |
+ | Conversión producción | {{ report.production_conversion_status }} |
+ | Acción recomendada | {{ report.recommended_action }} |
+
+
+
6. Timeline
+
+
+ | Hora |
+ Tipo |
+ Evento |
+ Nivel |
+ Causa |
+ Duración |
+ Coste |
+ Descripción |
+
+ {% for event in timeline %}
+
+ | {{ event.event_ts|dt }} |
+ {{ event.event_type }} |
+ {{ event.event_label }} |
+ {{ event.severity }} |
+ {{ event.cause_code or "" }} |
+ {{ event.duration_min|num(2) if event.duration_min is not none else "" }} |
+ {{ event.cost_eur|num(2) if event.cost_eur is not none else "" }} |
+ {{ event.description }} |
+
+ {% endfor %}
+
+
+
+ Informe generado desde MESAVAULT Edge-OEE usando vistas SQL de PostgreSQL.
+
+
+
+
+
diff --git a/ucepsa/edge-oee-demo/services/order-report-api/requirements.txt b/ucepsa/edge-oee-demo/services/order-report-api/requirements.txt
new file mode 100644
index 0000000..6015309
--- /dev/null
+++ b/ucepsa/edge-oee-demo/services/order-report-api/requirements.txt
@@ -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