Add Margin Intelligence and Cost Readiness Cockpit

This commit is contained in:
Victor Fraile Garcia 2026-05-30 20:05:42 +02:00
parent bff3fef40d
commit 206adbc8bb
4 changed files with 1781 additions and 0 deletions

View File

@ -119,11 +119,40 @@ def build_report_context(order_ref: str) -> dict[str, Any]:
(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,
}
@ -172,3 +201,199 @@ def report_pdf(order_ref: str = Query(..., description="Odoo order reference, e.
"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,
},
)

View File

@ -0,0 +1,250 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{{ title }} - Cost Readiness</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 28px;
color: #1f2933;
background: #f7f9fb;
font-size: 13px;
}
.page {
background: white;
padding: 28px;
border-radius: 12px;
max-width: 1600px;
margin: auto;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
h1 {
margin: 0 0 4px 0;
color: #0f172a;
}
h2 {
margin-top: 26px;
color: #0f172a;
border-bottom: 1px solid #cbd5e1;
padding-bottom: 6px;
}
.muted {
color: #64748b;
}
.grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
margin-top: 18px;
margin-bottom: 20px;
}
.card {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px;
background: #ffffff;
}
.label {
color: #64748b;
font-size: 12px;
margin-bottom: 6px;
}
.value {
color: #0f172a;
font-weight: bold;
font-size: 22px;
line-height: 1.1;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 14px;
font-size: 12px;
}
th {
background: #f1f5f9;
text-align: left;
padding: 8px;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 8px;
border-bottom: 1px solid #e2e8f0;
vertical-align: top;
}
.ok {
color: #166534;
font-weight: bold;
}
.warn {
color: #92400e;
font-weight: bold;
}
.bad {
color: #991b1b;
font-weight: bold;
}
.small {
font-size: 11px;
}
a {
color: #0f766e;
font-weight: bold;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.pill {
display: inline-block;
padding: 3px 7px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: bold;
}
</style>
</head>
<body>
<div class="page">
<h1>Cost Readiness Cockpit</h1>
<p class="muted">{{ title }}</p>
<p>
Esta pantalla muestra qué órdenes están preparadas para cálculo de margen y qué datos lo bloquean.
No calcula margen real si falta coste de materia prima.
</p>
<div class="grid">
<div class="card">
<div class="label">Órdenes totales</div>
<div class="value">{{ summary.total_orders or 0 }}</div>
</div>
<div class="card">
<div class="label">Con ingreso</div>
<div class="value">{{ summary.orders_with_revenue or 0 }}</div>
</div>
<div class="card">
<div class="label">Sin ingreso</div>
<div class="value">{{ summary.orders_without_revenue or 0 }}</div>
</div>
<div class="card">
<div class="label">Sin coste material</div>
<div class="value">{{ summary.orders_missing_raw_material_cost or 0 }}</div>
</div>
<div class="card">
<div class="label">Listas para margen</div>
<div class="value">{{ summary.orders_ready_for_margin or 0 }}</div>
</div>
<div class="card">
<div class="label">Score medio</div>
<div class="value">{{ summary.avg_cost_readiness_score|num(2) if summary.avg_cost_readiness_score is not none else "0.00" }}</div>
</div>
</div>
<h2>1. Estado por orden</h2>
<table>
<tr>
<th>Orden</th>
<th>Pedido venta</th>
<th>Cliente</th>
<th>Producto</th>
<th>Máquina</th>
<th>Ingreso neto</th>
<th>Kg materia prima</th>
<th>Score</th>
<th>Estado</th>
<th>Bloqueo / acción</th>
<th>Informe</th>
</tr>
{% for order in orders %}
<tr>
<td>{{ order.odoo_order_ref }}</td>
<td>{{ order.sale_order_ref or "" }}</td>
<td>{{ order.customer_name or "" }}</td>
<td>
<strong>{{ order.product_default_code or "" }}</strong><br>
<span class="small">{{ order.product_name or "" }}</span>
</td>
<td>{{ order.machine_id }}</td>
<td>{{ order.revenue_net_eur|num(2) if order.revenue_net_eur is not none else "" }} €</td>
<td>{{ order.raw_total_consumed_qty|num(3) if order.raw_total_consumed_qty is not none else "" }}</td>
<td>
{% if order.cost_readiness_score >= 80 %}
<span class="ok">{{ order.cost_readiness_score }}</span>
{% elif order.cost_readiness_score >= 50 %}
<span class="warn">{{ order.cost_readiness_score }}</span>
{% else %}
<span class="bad">{{ order.cost_readiness_score }}</span>
{% endif %}
</td>
<td>
{% if order.cost_readiness_status == 'LISTO_PARA_MARGEN' %}
<span class="ok">Listo para margen</span>
{% elif order.cost_readiness_status == 'BLOQUEADO_FALTA_COSTE_MATERIA_PRIMA' %}
<span class="warn">Falta coste materia prima</span>
{% elif order.cost_readiness_status == 'BLOQUEADO_SIN_INGRESO' %}
<span class="bad">Sin ingreso</span>
{% elif order.cost_readiness_status == 'BLOQUEADO_DATOS_TECNICOS' %}
<span class="bad">Datos técnicos débiles</span>
{% else %}
<span class="pill">{{ order.cost_readiness_status }}</span>
{% endif %}
</td>
<td class="small">{{ order.cost_readiness_description }}</td>
<td>
<a href="/report.html?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">HTML</a>
|
<a href="/report.pdf?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">PDF</a>
</td>
</tr>
{% else %}
<tr><td colspan="11">No hay órdenes disponibles.</td></tr>
{% endfor %}
</table>
<h2>2. Materias primas pendientes de coste</h2>
<table>
<tr>
<th>Código</th>
<th>Materia prima</th>
<th>UdM</th>
<th>Órdenes afectadas</th>
<th>Pedidos venta</th>
<th>Clientes</th>
<th>Cantidad consumida</th>
<th>Coste €/kg actual</th>
<th>Estado</th>
<th>Acción recomendada</th>
</tr>
{% for raw in raw_materials %}
<tr>
<td>{{ raw.raw_product_default_code or "" }}</td>
<td>{{ raw.raw_product_name or "" }}</td>
<td>{{ raw.raw_uom or "" }}</td>
<td>
<strong>{{ raw.affected_order_count }}</strong><br>
<span class="small">{{ raw.affected_orders }}</span>
</td>
<td class="small">{{ raw.affected_sale_orders }}</td>
<td class="small">{{ raw.affected_customers }}</td>
<td>{{ raw.total_consumed_qty|num(3) }}</td>
<td>{{ raw.current_material_cost_eur_per_kg|num(4) if raw.current_material_cost_eur_per_kg is not none else "" }}</td>
<td>
{% if raw.raw_material_readiness_status == 'COSTE_DISPONIBLE' %}
<span class="ok">Coste disponible</span>
{% else %}
<span class="warn">Pendiente coste</span>
{% endif %}
</td>
<td>{{ raw.recommended_action }}</td>
</tr>
{% else %}
<tr><td colspan="10">No hay materias primas detectadas.</td></tr>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@ -0,0 +1,120 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{{ title }} - Informes de orden</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 28px;
color: #1f2933;
background: #f7f9fb;
font-size: 13px;
}
.page {
background: white;
padding: 28px;
border-radius: 12px;
max-width: 1500px;
margin: auto;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
h1 {
margin-top: 0;
color: #0f172a;
}
.muted {
color: #64748b;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 18px;
}
th {
background: #f1f5f9;
text-align: left;
padding: 8px;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 8px;
border-bottom: 1px solid #e2e8f0;
vertical-align: top;
}
a {
color: #0f766e;
font-weight: bold;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.status {
font-weight: bold;
font-size: 12px;
}
.ok { color: #166534; }
.warn { color: #92400e; }
.bad { color: #991b1b; }
.small { font-size: 12px; }
</style>
</head>
<body>
<div class="page">
<h1>Informes de ejecución de orden</h1>
<p class="muted">{{ title }}</p>
<p>
<a href="/cost-readiness.html" target="_blank">Abrir Cost Readiness Cockpit</a>
</p>
<table>
<tr>
<th>Orden</th>
<th>Estado</th>
<th>Producto</th>
<th>Máquina</th>
<th>Inicio</th>
<th>Fin</th>
<th>Duración min</th>
<th>Ciclos</th>
<th>kWh</th>
<th>Paros</th>
<th>Min paro</th>
<th>Calidad</th>
<th>Estado informe</th>
<th>Acciones</th>
</tr>
{% for order in orders %}
<tr>
<td>{{ order.odoo_order_ref }}</td>
<td>{{ order.odoo_state }}</td>
<td>{{ order.product }}</td>
<td>{{ order.machine_id }}</td>
<td>{{ order.start_ts|dt }}</td>
<td>{{ order.end_ts|dt }}</td>
<td>{{ order.window_min|num(2) }}</td>
<td>{{ order.real_cycle_delta }}</td>
<td>{{ order.kwh_consumed|num(3) }}</td>
<td>{{ order.stop_count }}</td>
<td>{{ order.stop_min|num(2) }}</td>
<td class="status {% if order.technical_quality_level == 'FIABLE' %}ok{% elif order.technical_quality_level == 'NO_DEFENDIBLE' %}bad{% else %}warn{% endif %}">
{{ order.technical_quality_level }}
</td>
<td class="small">{{ order.final_report_status }}</td>
<td>
<a href="/report.html?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">HTML</a>
&nbsp;|&nbsp;
<a href="/report.pdf?order_ref={{ order.odoo_order_ref | urlencode }}" target="_blank">PDF</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="14">No hay órdenes disponibles.</td>
</tr>
{% endfor %}
</table>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff