Add Margin Intelligence and Cost Readiness Cockpit
This commit is contained in:
parent
bff3fef40d
commit
206adbc8bb
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
|
||||
<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
Loading…
x
Reference in New Issue
Block a user