Add UCEPSA order execution report HTML and PDF service
This commit is contained in:
parent
350e9a29c8
commit
90a0da730b
28
ucepsa/edge-oee-demo/services/order-report-api/Dockerfile
Normal file
28
ucepsa/edge-oee-demo/services/order-report-api/Dockerfile
Normal 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"]
|
||||
174
ucepsa/edge-oee-demo/services/order-report-api/app/main.py
Normal file
174
ucepsa/edge-oee-demo/services/order-report-api/app/main.py
Normal 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}"'
|
||||
},
|
||||
)
|
||||
@ -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 > 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>
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user