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

+ + + + + + + + + + + +
CampoValor
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étricaValor
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 }}

+ + + + + + + + + + + + + {% for stop in stops %} + + + + + + + + + + + {% else %} + + {% endfor %} +
IDInicioFinDuración minCausaNotaCoste €Estado
{{ 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 }}
Sin paros imputados a la orden.
+ +

5. Calidad del dato

+ + + + + + + + + + + +
MétricaValor
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

+ + + + + + + + + + + + {% for event in timeline %} + + + + + + + + + + + {% endfor %} +
HoraTipoEventoNivelCausaDuraciónCosteDescripción
{{ 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 }}
+ +

+ 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