2026-05-30 20:30:27 +02:00

635 lines
18 KiB
Python

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,),
)
margin = fetch_one(
"""
SELECT *
FROM mv_reports_ucepsa_demo.order_margin_report_v1
WHERE odoo_order_ref = %s
""",
(order_ref,),
)
raw_materials = fetch_all(
"""
SELECT
raw_product_default_code,
raw_product_name,
planned_qty,
consumed_qty,
raw_uom,
material_cost_eur_per_kg,
material_cost_estimated_eur_rounded,
material_cost_status
FROM mv_reports_ucepsa_demo.order_raw_material_costs_v1
WHERE odoo_order_ref = %s
ORDER BY raw_product_default_code, raw_product_name
""",
(order_ref,),
)
return {
"title": REPORT_TITLE,
"report": report,
"stops": stops,
"timeline": timeline,
"margin": margin,
"raw_materials": raw_materials,
}
@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}"'
},
)
@app.get("/api/cost-readiness")
def api_cost_readiness(limit: int = Query(100, ge=1, le=500)):
summary = fetch_one(
"""
SELECT *
FROM mv_reports_ucepsa_demo.cost_readiness_summary_v1
""",
(),
)
orders = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
sale_order_ref,
customer_name,
product_default_code,
product_name,
machine_id,
revenue_net_eur,
raw_total_consumed_qty,
raw_materials_summary,
cost_readiness_score,
cost_readiness_status,
cost_readiness_description,
technical_quality_level,
final_report_status,
margin_status
FROM mv_reports_ucepsa_demo.cost_readiness_orders_v1
ORDER BY cost_readiness_score ASC, odoo_order_ref DESC
LIMIT %s
""",
(limit,),
)
raw_materials = fetch_all(
"""
SELECT
raw_product_default_code,
raw_product_name,
raw_uom,
affected_order_count,
affected_orders,
affected_sale_orders,
affected_customers,
total_consumed_qty,
current_material_cost_eur_per_kg,
raw_material_readiness_status,
recommended_action
FROM mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
ORDER BY affected_order_count DESC, raw_product_default_code
""",
(),
)
return jsonable_encoder(
{
"summary": summary or {},
"orders": orders,
"raw_materials": raw_materials,
}
)
@app.get("/cost-readiness.html", response_class=HTMLResponse)
def cost_readiness_html(request: Request, limit: int = Query(100, ge=1, le=500)):
summary = fetch_one(
"""
SELECT *
FROM mv_reports_ucepsa_demo.cost_readiness_summary_v1
""",
(),
)
orders = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
sale_order_ref,
customer_name,
product_default_code,
product_name,
machine_id,
revenue_net_eur,
raw_total_consumed_qty,
raw_materials_summary,
cost_readiness_score,
cost_readiness_status,
cost_readiness_description,
technical_quality_level,
final_report_status,
margin_status
FROM mv_reports_ucepsa_demo.cost_readiness_orders_v1
ORDER BY cost_readiness_score ASC, odoo_order_ref DESC
LIMIT %s
""",
(limit,),
)
raw_materials = fetch_all(
"""
SELECT
raw_product_default_code,
raw_product_name,
raw_uom,
affected_order_count,
affected_orders,
affected_sale_orders,
affected_customers,
total_consumed_qty,
current_material_cost_eur_per_kg,
raw_material_readiness_status,
recommended_action
FROM mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
ORDER BY affected_order_count DESC, raw_product_default_code
""",
(),
)
return templates.TemplateResponse(
"cost_readiness.html",
{
"request": request,
"title": REPORT_TITLE,
"summary": summary or {},
"orders": orders,
"raw_materials": raw_materials,
},
)
@app.get("/api/orders")
def api_orders(limit: int = Query(50, ge=1, le=200)):
rows = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
product,
machine_id,
start_ts,
end_ts,
window_min,
real_cycle_delta,
kwh_consumed,
stop_count,
stop_min,
technical_quality_level,
final_report_status
FROM mv_reports_ucepsa_demo.order_execution_report_full_v1
ORDER BY start_ts DESC
LIMIT %s
""",
(limit,),
)
return jsonable_encoder({"orders": rows})
@app.get("/orders.html", response_class=HTMLResponse)
def orders_html(request: Request, limit: int = Query(50, ge=1, le=200)):
orders = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
product,
machine_id,
start_ts,
end_ts,
window_min,
real_cycle_delta,
kwh_consumed,
stop_count,
stop_min,
technical_quality_level,
final_report_status
FROM mv_reports_ucepsa_demo.order_execution_report_full_v1
ORDER BY start_ts DESC
LIMIT %s
""",
(limit,),
)
return templates.TemplateResponse(
"orders_index.html",
{
"request": request,
"title": REPORT_TITLE,
"orders": orders,
},
)
@app.get("/api/data-quality-contract")
def api_data_quality_contract(limit: int = Query(100, ge=1, le=500)):
summary = fetch_one(
"""
SELECT
count(*) AS total_orders,
count(*) FILTER (WHERE contract_status = 'CONTRATO_CUMPLIDO') AS contract_ok_orders,
count(*) FILTER (WHERE contract_status = 'VALIDO_CON_OBSERVACIONES') AS contract_warn_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_CRITICO') AS blocked_critical_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_ALTO') AS blocked_high_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_MEDIO') AS blocked_medium_orders,
round(avg(contract_score), 2) AS avg_contract_score,
sum(critical_failures) AS total_critical_failures,
sum(high_failures) AS total_high_failures,
sum(medium_failures) AS total_medium_failures,
sum(total_issue_score) AS total_issue_score
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
""",
(),
)
orders = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
sale_order_ref,
customer_name,
product_default_code,
product_name,
machine_id,
contract_score,
contract_status,
failed_rules,
warn_rules,
critical_failures,
high_failures,
medium_failures,
main_blocker_rule_code,
main_blocker_rule_name,
main_blocker_domain,
main_blocker_severity,
owner_area_to_act,
main_blocker_finding,
main_recommended_action,
contract_summary
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
ORDER BY contract_score ASC, total_issue_score DESC, odoo_order_ref DESC
LIMIT %s
""",
(limit,),
)
owners = fetch_all(
"""
SELECT
owner_area,
failed_checks,
warn_checks,
critical_failed_checks,
high_failed_checks,
affected_orders,
total_issue_score,
affected_rules,
affected_order_refs
FROM mv_reports_ucepsa_demo.data_quality_contract_owner_summary_v1
WHERE failed_checks > 0
OR warn_checks > 0
ORDER BY total_issue_score DESC NULLS LAST, owner_area
""",
(),
)
rules = fetch_all(
"""
SELECT
rule_code,
rule_name,
domain,
severity,
owner_area,
check_status,
count(*) AS order_count,
sum(issue_score) AS total_issue_score,
string_agg(
DISTINCT odoo_order_ref,
', '
ORDER BY odoo_order_ref
) AS affected_orders
FROM mv_reports_ucepsa_demo.data_quality_contract_order_checks_v1
WHERE check_status IN ('FAIL', 'WARN')
GROUP BY
rule_code,
rule_name,
domain,
severity,
owner_area,
check_status
ORDER BY
total_issue_score DESC NULLS LAST,
rule_code,
check_status
""",
(),
)
return jsonable_encoder(
{
"summary": summary or {},
"orders": orders,
"owners": owners,
"rules": rules,
}
)
@app.get("/data-quality-contract.html", response_class=HTMLResponse)
def data_quality_contract_html(request: Request, limit: int = Query(100, ge=1, le=500)):
summary = fetch_one(
"""
SELECT
count(*) AS total_orders,
count(*) FILTER (WHERE contract_status = 'CONTRATO_CUMPLIDO') AS contract_ok_orders,
count(*) FILTER (WHERE contract_status = 'VALIDO_CON_OBSERVACIONES') AS contract_warn_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_CRITICO') AS blocked_critical_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_ALTO') AS blocked_high_orders,
count(*) FILTER (WHERE contract_status = 'BLOQUEADO_MEDIO') AS blocked_medium_orders,
round(avg(contract_score), 2) AS avg_contract_score,
sum(critical_failures) AS total_critical_failures,
sum(high_failures) AS total_high_failures,
sum(medium_failures) AS total_medium_failures,
sum(total_issue_score) AS total_issue_score
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
""",
(),
)
orders = fetch_all(
"""
SELECT
odoo_order_ref,
odoo_state,
sale_order_ref,
customer_name,
product_default_code,
product_name,
machine_id,
contract_score,
contract_status,
failed_rules,
warn_rules,
critical_failures,
high_failures,
medium_failures,
main_blocker_rule_code,
main_blocker_rule_name,
main_blocker_domain,
main_blocker_severity,
owner_area_to_act,
main_blocker_finding,
main_recommended_action,
contract_summary
FROM mv_reports_ucepsa_demo.data_quality_contract_order_summary_v1
ORDER BY contract_score ASC, total_issue_score DESC, odoo_order_ref DESC
LIMIT %s
""",
(limit,),
)
owners = fetch_all(
"""
SELECT
owner_area,
failed_checks,
warn_checks,
critical_failed_checks,
high_failed_checks,
affected_orders,
total_issue_score,
affected_rules,
affected_order_refs
FROM mv_reports_ucepsa_demo.data_quality_contract_owner_summary_v1
WHERE failed_checks > 0
OR warn_checks > 0
ORDER BY total_issue_score DESC NULLS LAST, owner_area
""",
(),
)
rules = fetch_all(
"""
SELECT
rule_code,
rule_name,
domain,
severity,
owner_area,
check_status,
count(*) AS order_count,
sum(issue_score) AS total_issue_score,
string_agg(
DISTINCT odoo_order_ref,
', '
ORDER BY odoo_order_ref
) AS affected_orders
FROM mv_reports_ucepsa_demo.data_quality_contract_order_checks_v1
WHERE check_status IN ('FAIL', 'WARN')
GROUP BY
rule_code,
rule_name,
domain,
severity,
owner_area,
check_status
ORDER BY
total_issue_score DESC NULLS LAST,
rule_code,
check_status
""",
(),
)
return templates.TemplateResponse(
"data_quality_contract.html",
{
"request": request,
"title": REPORT_TITLE,
"summary": summary or {},
"orders": orders,
"owners": owners,
"rules": rules,
},
)