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, }, )