635 lines
18 KiB
Python
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,
|
|
},
|
|
)
|
|
|