feat(ucepsa): add LossIntelligence shadow foundation

This commit is contained in:
Victor Fraile Garcia 2026-07-16 12:27:29 +02:00
parent 88c84f73f5
commit 2d198e5ddb
8 changed files with 1428 additions and 4 deletions

View File

@ -0,0 +1,120 @@
# UCEPSA — Despliegue LossIntelligence en modo sombra hasta octubre de 2026
## Objetivo
Construir y validar entre julio y septiembre la infraestructura reusable de MESAVAULT LossIntelligence sin convertir todavía los paros técnicos en pérdidas oficiales. En octubre se realizará el kickoff y se activará el Ledger con una fecha efectiva acordada.
## Regla de seguridad funcional
La captura raw no se modifica:
```text
WISE / RevPi / MQTT / PostgreSQL
-> evento físico
-> paro raw
```
LossIntelligence trabaja encima:
```text
paro raw
-> contexto Odoo histórico
-> política de elegibilidad
-> exclusión / agrupación / candidato
-> Loss Ledger
-> acción
-> prueba
-> resultado
```
Un paro raw no equivale automáticamente a pérdida.
## Estados
- `SHADOW`: infraestructura activa, resultados solo para validación interna.
- `ACTIVE`: Ledger oficial desde `actual_go_live_at`.
- `PAUSED`: captura conservada, promoción de nuevas pérdidas detenida.
- `CLOSED`: despliegue cerrado.
Octubre es una fecha prevista, no una activación automática. La activación requiere kickoff y actualización explícita de `actual_go_live_at`.
## Parámetros iniciales
- Coste de capacidad: `36 €/h`, provisional y pendiente de aprobación.
- Coste eléctrico variable: `0,128249 €/kWh`, derivado de la factura 6.1TD de junio de 2026.
- Bolsas por golpe: `1` o `2` según referencia. Debe configurarse por `product_id`.
- Cadencia objetivo: pendiente por referencia o familia. Hasta completarla no se activa pérdida por baja cadencia.
## Política inicial de paros
| Duración | Tratamiento en sombra |
|---|---|
| `<5 s` | Se conserva en raw y se excluye de la cola humana. |
| `5 s5 min` | Se agrupa por máquina, orden, ventana de 15 min y causa. |
| `>=5 min` | Se mantiene como candidato individual. |
| Paro abierto | Solo monitorización; no entra en Ledger. |
| Sin orden Odoo válida y coherente | Excluido. |
| Antes del go-live | Sombra, nunca pérdida oficial. |
Los umbrales deben validarse con producción antes del kickoff.
## Rutina de revisión futura
La reunión diaria de 1530 minutos entre dirección y jefe de producción será el punto de revisión. La cola oficial se limitará a cinco elementos:
1. pérdidas nuevas con mayor prioridad;
2. acciones cuyo plazo vence;
3. pruebas en curso que requieren decisión;
4. bloqueos de calidad de dato;
5. resultados que deben mantenerse, adaptarse, repetirse, descartarse o escalarse.
## Orden de implantación
1. Crear rama de trabajo en Gitea.
2. Tomar backup de PostgreSQL.
3. Aplicar migraciones 093096.
4. Validar que la cola oficial está vacía en modo `SHADOW`.
5. Actualizar el sink de `order_state` para guardar campos tipados y un hash histórico completo.
6. Observar datos durante al menos dos semanas.
7. Completar bolsas por golpe de las referencias activas.
8. Validar política de microparos y causas con producción.
9. Construir dashboard de sombra y panel de calidad de dato.
10. Preparar checklist de kickoff.
11. En octubre, acordar `actual_go_live_at` y cambiar `mode` a `ACTIVE`.
## Kill switch
Para detener la promoción oficial sin tocar la captura:
```sql
UPDATE mv_loss_intelligence.deployment_config
SET mode = 'PAUSED', updated_at = now()
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND scope_code = 'CUTTERS';
```
## Activación en kickoff
Ejemplo; sustituir fecha y hora por las acordadas:
```sql
UPDATE mv_loss_intelligence.deployment_config
SET
mode = 'ACTIVE',
actual_go_live_at = TIMESTAMPTZ '2026-10-05 06:00:00+02',
updated_at = now()
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND scope_code = 'CUTTERS';
```
La activación no debe hacerse antes de confirmar:
- Shop Floor estable;
- órdenes correctamente cargadas por máquina;
- causas y umbrales aceptados;
- coste/hora revisado;
- productos activos con bolsas por golpe;
- responsables y reunión diaria confirmados;
- backup y rollback disponibles.

View File

@ -0,0 +1,53 @@
\pset pager off
\echo '=== 1. Configuración de despliegue ==='
SELECT tenant, site, scope_code, mode, planned_go_live_date, actual_go_live_at,
review_owner_primary, review_owner_secondary, daily_review_limit
FROM mv_loss_intelligence.deployment_config
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise';
\echo '=== 2. Parámetros económicos ==='
SELECT parameter_code, parameter_value, unit, confidence, includes_energy,
valid_from, source_ref, approved_by
FROM mv_loss_intelligence.economic_parameters
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise' AND is_active = true
ORDER BY parameter_code, valid_from DESC;
\echo '=== 3. Calidad de dato ==='
SELECT * FROM mv_reports_ucepsa_prod.li_data_quality_v1 ORDER BY check_code;
\echo '=== 4. Intervalos Odoo más recientes ==='
SELECT machine_id, effective_from, effective_to, odoo_state,
order_present, order_coherent, production_order, product_id, product,
qty_production
FROM mv_reports_ucepsa_prod.li_order_state_intervals_v1
ORDER BY effective_from DESC
LIMIT 20;
\echo '=== 5. Resumen de elegibilidad en sombra ==='
SELECT *
FROM mv_reports_ucepsa_prod.li_shadow_summary_v1
ORDER BY machine_id, eligibility_status;
\echo '=== 6. Primeros candidatos de sombra ==='
SELECT shadow_rank, machine_id, order_ref, product_name, cause_name,
event_count, started_at, duration_min, capacity_loss_eur,
queue_status
FROM mv_reports_ucepsa_prod.li_shadow_review_queue_v1
ORDER BY shadow_rank
LIMIT 20;
\echo '=== 7. Cola oficial: debe estar vacía en SHADOW ==='
SELECT COUNT(*) AS official_operator_queue_rows
FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v1;
\echo '=== 8. Cobertura bolsas/golpe y cadencia ==='
SELECT *
FROM mv_reports_ucepsa_prod.li_product_cycle_coverage_v1
ORDER BY latest_stop_started_at DESC NULLS LAST;
\echo '=== 9. Catálogo de acciones ==='
SELECT action_id, source_cause_code, action_type, action_candidate,
suggested_owner, test_window, success_criteria
FROM mv_loss_intelligence.action_catalog
WHERE catalog_code = 'LI-CUTTER-STOPS' AND is_active = true
ORDER BY source_cause_code, action_id;

View File

@ -0,0 +1,93 @@
--- /mnt/data/ucepsa_repo/40-clients/ucepsa/edge-oee-demo/tools/order_state_pg_sink.py 2026-07-16 09:11:42.000000000 +0000
+++ /mnt/data/ucepsa_lossintelligence_patch/tools/order_state_pg_sink.py 2026-07-16 10:10:45.826794132 +0000
@@ -67,8 +67,15 @@
"odoo_state": payload.get("odoo_state"),
"reason": payload.get("reason"),
"workorder_id": payload.get("workorder_id"),
+ "production_id": payload.get("production_id"),
"production_order": payload.get("production_order"),
+ "product_id": payload.get("product_id"),
"product": payload.get("product"),
+ "qty_production": payload.get("qty_production"),
+ "date_start": payload.get("date_start"),
+ "date_finished": payload.get("date_finished"),
+ "workorder_name": payload.get("workorder_name"),
+ "workorder_state": payload.get("workorder_state"),
"waiting_count": diagnostics.get("waiting_count"),
"ready_count": diagnostics.get("ready_count"),
"progress_count": diagnostics.get("progress_count"),
@@ -133,8 +140,15 @@
"odoo_state": payload.get("odoo_state"),
"reason": payload.get("reason"),
"workorder_id": str(payload.get("workorder_id")) if payload.get("workorder_id") is not None else None,
+ "workorder_name": payload.get("workorder_name"),
+ "workorder_state": payload.get("workorder_state"),
+ "production_id": to_int(payload.get("production_id")),
"production_order": payload.get("production_order"),
+ "product_id": to_int(payload.get("product_id")),
"product": payload.get("product"),
+ "qty_production": payload.get("qty_production"),
+ "date_start": parse_ts(payload.get("date_start")),
+ "date_finished": parse_ts(payload.get("date_finished")),
"waiting_count": to_int(diagnostics.get("waiting_count")),
"ready_count": to_int(diagnostics.get("ready_count")),
"progress_count": to_int(diagnostics.get("progress_count")),
@@ -149,7 +163,9 @@
tenant, site, machine_id, topic, mqtt_ts, received_at,
source, workcenter_id, workcenter_name, workcenter_display_name,
order_present, order_coherent, odoo_state, reason,
- workorder_id, production_order, product,
+ workorder_id, workorder_name, workorder_state,
+ production_id, production_order, product_id, product,
+ qty_production, date_start, date_finished,
waiting_count, ready_count, progress_count,
payload_hash, payload_json
)
@@ -157,7 +173,9 @@
%(tenant)s, %(site)s, %(machine_id)s, %(topic)s, %(mqtt_ts)s, now(),
%(source)s, %(workcenter_id)s, %(workcenter_name)s, %(workcenter_display_name)s,
%(order_present)s, %(order_coherent)s, %(odoo_state)s, %(reason)s,
- %(workorder_id)s, %(production_order)s, %(product)s,
+ %(workorder_id)s, %(workorder_name)s, %(workorder_state)s,
+ %(production_id)s, %(production_order)s, %(product_id)s, %(product)s,
+ %(qty_production)s, %(date_start)s, %(date_finished)s,
%(waiting_count)s, %(ready_count)s, %(progress_count)s,
%(payload_hash)s, %(payload_json)s
)
@@ -175,8 +193,15 @@
odoo_state = EXCLUDED.odoo_state,
reason = EXCLUDED.reason,
workorder_id = EXCLUDED.workorder_id,
+ workorder_name = EXCLUDED.workorder_name,
+ workorder_state = EXCLUDED.workorder_state,
+ production_id = EXCLUDED.production_id,
production_order = EXCLUDED.production_order,
+ product_id = EXCLUDED.product_id,
product = EXCLUDED.product,
+ qty_production = EXCLUDED.qty_production,
+ date_start = EXCLUDED.date_start,
+ date_finished = EXCLUDED.date_finished,
waiting_count = EXCLUDED.waiting_count,
ready_count = EXCLUDED.ready_count,
progress_count = EXCLUDED.progress_count,
@@ -191,14 +216,18 @@
INSERT INTO mv_hot.odoo_order_state_events (
tenant, site, machine_id, topic, mqtt_ts, received_at,
odoo_state, order_present, order_coherent,
- production_order, product, reason,
+ workorder_id, workorder_name, workorder_state,
+ production_id, production_order, product_id, product,
+ qty_production, date_start, date_finished, reason,
waiting_count, ready_count, progress_count,
payload_hash, payload_json
)
VALUES (
%(tenant)s, %(site)s, %(machine_id)s, %(topic)s, %(mqtt_ts)s, now(),
%(odoo_state)s, %(order_present)s, %(order_coherent)s,
- %(production_order)s, %(product)s, %(reason)s,
+ %(workorder_id)s, %(workorder_name)s, %(workorder_state)s,
+ %(production_id)s, %(production_order)s, %(product_id)s, %(product)s,
+ %(qty_production)s, %(date_start)s, %(date_finished)s, %(reason)s,
%(waiting_count)s, %(ready_count)s, %(progress_count)s,
%(payload_hash)s, %(payload_json)s
)

View File

@ -0,0 +1,263 @@
BEGIN;
CREATE SCHEMA IF NOT EXISTS mv_loss_intelligence;
COMMENT ON SCHEMA mv_loss_intelligence IS
'Capa común MESAVAULT LossIntelligence. Conserva configuración, catálogo de acciones, Loss Ledger, pruebas y decisiones. No sustituye las tablas raw de captura.';
CREATE TABLE IF NOT EXISTS mv_loss_intelligence.deployment_config (
id BIGSERIAL PRIMARY KEY,
tenant TEXT NOT NULL,
site TEXT NOT NULL,
scope_code TEXT NOT NULL,
scope_name TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'SHADOW'
CHECK (mode IN ('SHADOW', 'ACTIVE', 'PAUSED', 'CLOSED')),
planned_go_live_date DATE,
actual_go_live_at TIMESTAMPTZ,
timezone TEXT NOT NULL DEFAULT 'Europe/Madrid',
review_owner_primary TEXT,
review_owner_secondary TEXT,
review_cadence TEXT NOT NULL DEFAULT 'DAILY',
daily_review_limit INTEGER NOT NULL DEFAULT 5
CHECK (daily_review_limit BETWEEN 1 AND 20),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant, site, scope_code),
CHECK (actual_go_live_at IS NULL OR planned_go_live_date IS NULL OR actual_go_live_at::date >= planned_go_live_date - 31)
);
CREATE TABLE IF NOT EXISTS mv_loss_intelligence.economic_parameters (
id BIGSERIAL PRIMARY KEY,
tenant TEXT NOT NULL,
site TEXT NOT NULL,
scope_code TEXT NOT NULL DEFAULT 'GLOBAL',
machine_id TEXT,
product_id BIGINT,
parameter_code TEXT NOT NULL,
parameter_value NUMERIC(18,6) NOT NULL,
unit TEXT NOT NULL,
confidence TEXT NOT NULL DEFAULT 'LOW'
CHECK (confidence IN ('HIGH', 'MEDIUM', 'LOW', 'NOT_ACCEPTED')),
includes_energy BOOLEAN,
valid_from TIMESTAMPTZ NOT NULL DEFAULT now(),
valid_to TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT true,
source_ref TEXT,
approved_by TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (valid_to IS NULL OR valid_to > valid_from)
);
CREATE INDEX IF NOT EXISTS idx_li_economic_parameters_lookup
ON mv_loss_intelligence.economic_parameters
(tenant, site, parameter_code, is_active, valid_from DESC);
CREATE TABLE IF NOT EXISTS mv_loss_intelligence.product_cycle_parameters (
id BIGSERIAL PRIMARY KEY,
tenant TEXT NOT NULL,
site TEXT NOT NULL,
machine_id TEXT,
product_id BIGINT,
product_default_code TEXT,
product_name TEXT,
units_per_cycle NUMERIC(10,4) NOT NULL
CHECK (units_per_cycle > 0),
target_cycle_rate_ppm NUMERIC(12,4)
CHECK (target_cycle_rate_ppm IS NULL OR target_cycle_rate_ppm > 0),
target_units_per_min NUMERIC(14,4)
GENERATED ALWAYS AS (
CASE
WHEN target_cycle_rate_ppm IS NULL THEN NULL
ELSE target_cycle_rate_ppm * units_per_cycle
END
) STORED,
confidence TEXT NOT NULL DEFAULT 'LOW'
CHECK (confidence IN ('HIGH', 'MEDIUM', 'LOW')),
source_ref TEXT,
approved_by TEXT,
valid_from TIMESTAMPTZ NOT NULL DEFAULT now(),
valid_to TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (product_id IS NOT NULL OR product_default_code IS NOT NULL),
CHECK (valid_to IS NULL OR valid_to > valid_from)
);
CREATE INDEX IF NOT EXISTS idx_li_product_cycle_parameters_lookup
ON mv_loss_intelligence.product_cycle_parameters
(tenant, site, product_id, product_default_code, machine_id, is_active, valid_from DESC);
CREATE TABLE IF NOT EXISTS mv_loss_intelligence.loss_policies (
id BIGSERIAL PRIMARY KEY,
tenant TEXT NOT NULL,
site TEXT NOT NULL,
policy_code TEXT NOT NULL,
source_type TEXT NOT NULL DEFAULT 'MACHINE_STOP',
noise_below_s NUMERIC(12,2) NOT NULL DEFAULT 5
CHECK (noise_below_s >= 0),
aggregate_below_s NUMERIC(12,2) NOT NULL DEFAULT 300
CHECK (aggregate_below_s >= noise_below_s),
operator_individual_from_s NUMERIC(12,2) NOT NULL DEFAULT 300
CHECK (operator_individual_from_s >= noise_below_s),
aggregation_window_min INTEGER NOT NULL DEFAULT 15
CHECK (aggregation_window_min BETWEEN 1 AND 1440),
require_valid_order BOOLEAN NOT NULL DEFAULT true,
require_closed_event BOOLEAN NOT NULL DEFAULT true,
exclude_planned_by_default BOOLEAN NOT NULL DEFAULT true,
is_active BOOLEAN NOT NULL DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant, site, policy_code)
);
CREATE TABLE IF NOT EXISTS mv_loss_intelligence.action_catalog (
action_id TEXT PRIMARY KEY,
catalog_code TEXT NOT NULL,
catalog_version TEXT NOT NULL,
tenant TEXT,
site TEXT,
loss_type TEXT NOT NULL,
source_cause_code TEXT,
action_type TEXT NOT NULL
CHECK (action_type IN ('TECHNICAL', 'METHOD', 'ORGANIZATIONAL', 'PROCESS_DESIGN', 'DATA_QUALITY', 'INVESTMENT_DECISION')),
action_candidate TEXT NOT NULL,
applies_when JSONB NOT NULL DEFAULT '[]'::jsonb,
does_not_apply_when JSONB NOT NULL DEFAULT '[]'::jsonb,
suggested_owner TEXT,
test_window TEXT NOT NULL,
metric_before TEXT NOT NULL,
metric_after TEXT NOT NULL,
success_criteria TEXT NOT NULL,
risk_level TEXT NOT NULL DEFAULT 'LOW'
CHECK (risk_level IN ('LOW', 'MEDIUM', 'HIGH')),
cost_to_test TEXT NOT NULL DEFAULT 'LOW'
CHECK (cost_to_test IN ('LOW', 'MEDIUM', 'HIGH')),
is_active BOOLEAN NOT NULL DEFAULT true,
source_ref TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_li_action_catalog_lookup
ON mv_loss_intelligence.action_catalog
(loss_type, source_cause_code, is_active);
CREATE TABLE IF NOT EXISTS mv_loss_intelligence.loss_ledger (
loss_id BIGSERIAL PRIMARY KEY,
tenant TEXT NOT NULL,
site TEXT NOT NULL,
deployment_id BIGINT REFERENCES mv_loss_intelligence.deployment_config(id),
ledger_phase TEXT NOT NULL DEFAULT 'SHADOW'
CHECK (ledger_phase IN ('SHADOW', 'ACTIVE')),
source_type TEXT NOT NULL,
source_key TEXT NOT NULL,
machine_id TEXT,
asset TEXT,
order_ref TEXT,
odoo_production_id BIGINT,
odoo_workorder_id BIGINT,
product_id BIGINT,
product_default_code TEXT,
product_name TEXT,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
event_count INTEGER NOT NULL DEFAULT 1 CHECK (event_count > 0),
duration_s NUMERIC(18,2) NOT NULL DEFAULT 0 CHECK (duration_s >= 0),
cycle_count NUMERIC(18,4),
units_per_cycle NUMERIC(10,4),
estimated_units NUMERIC(18,4),
loss_type TEXT NOT NULL,
cause_code TEXT,
classification_status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (classification_status IN ('PENDING', 'CLASSIFIED', 'EXCLUDED', 'NOT_REQUIRED')),
capacity_loss_eur NUMERIC(18,2),
energy_waste_kwh NUMERIC(18,6),
energy_waste_eur NUMERIC(18,2),
technical_confidence TEXT NOT NULL DEFAULT 'MEDIUM'
CHECK (technical_confidence IN ('HIGH', 'MEDIUM', 'LOW')),
economic_confidence TEXT NOT NULL DEFAULT 'LOW'
CHECK (economic_confidence IN ('HIGH', 'MEDIUM', 'LOW', 'NOT_ACCEPTED')),
priority_score NUMERIC(12,2),
priority_level TEXT
CHECK (priority_level IS NULL OR priority_level IN ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL')),
action_catalog_id TEXT REFERENCES mv_loss_intelligence.action_catalog(action_id),
action_candidate TEXT,
action_final TEXT,
review_owner TEXT,
review_due_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'DETECTED'
CHECK (status IN ('DETECTED', 'CANDIDATE', 'IN_REVIEW', 'IN_TRIAL', 'VALIDATED', 'ADAPTED', 'DISCARDED', 'ESCALATED', 'CLOSED')),
learning_reusable TEXT,
evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
detected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
promoted_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant, site, source_type, source_key)
);
CREATE INDEX IF NOT EXISTS idx_li_loss_ledger_review
ON mv_loss_intelligence.loss_ledger
(tenant, site, status, review_due_at, priority_score DESC);
CREATE INDEX IF NOT EXISTS idx_li_loss_ledger_order_machine
ON mv_loss_intelligence.loss_ledger
(tenant, site, machine_id, order_ref, started_at DESC);
CREATE TABLE IF NOT EXISTS mv_loss_intelligence.action_trials (
trial_id BIGSERIAL PRIMARY KEY,
loss_id BIGINT NOT NULL REFERENCES mv_loss_intelligence.loss_ledger(loss_id) ON DELETE CASCADE,
trial_code TEXT NOT NULL,
action_final TEXT NOT NULL,
owner TEXT NOT NULL,
baseline_from TIMESTAMPTZ,
baseline_to TIMESTAMPTZ,
test_from TIMESTAMPTZ,
test_to TIMESTAMPTZ,
metric_code TEXT NOT NULL,
metric_unit TEXT,
metric_before NUMERIC(18,6),
metric_after NUMERIC(18,6),
change_pct NUMERIC(12,4),
success_criteria TEXT NOT NULL,
result TEXT
CHECK (result IS NULL OR result IN ('PENDING', 'SUCCESS', 'PARTIAL', 'NO_CHANGE', 'WORSE', 'INVALID_TEST')),
decision TEXT
CHECK (decision IS NULL OR decision IN ('KEEP', 'ADAPT', 'REPEAT', 'DISCARD', 'SCALE')),
learning TEXT,
evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (loss_id, trial_code)
);
CREATE TABLE IF NOT EXISTS mv_loss_intelligence.review_decisions (
decision_id BIGSERIAL PRIMARY KEY,
loss_id BIGINT REFERENCES mv_loss_intelligence.loss_ledger(loss_id) ON DELETE CASCADE,
trial_id BIGINT REFERENCES mv_loss_intelligence.action_trials(trial_id) ON DELETE CASCADE,
decision_type TEXT NOT NULL
CHECK (decision_type IN ('PROMOTE', 'CLASSIFY', 'ASSIGN_ACTION', 'ADAPT_ACTION', 'DEFER', 'EXCLUDE', 'CLOSE', 'ESCALATE')),
decision_text TEXT NOT NULL,
decided_by TEXT NOT NULL,
decided_at TIMESTAMPTZ NOT NULL DEFAULT now(),
next_review_at TIMESTAMPTZ,
evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
CHECK (loss_id IS NOT NULL OR trial_id IS NOT NULL)
);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro') THEN
GRANT USAGE ON SCHEMA mv_loss_intelligence TO grafana_ucepsa_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA mv_loss_intelligence TO grafana_ucepsa_ro;
END IF;
END $$;
COMMIT;

View File

@ -0,0 +1,99 @@
BEGIN;
ALTER TABLE mv_hot.odoo_order_state_current
ADD COLUMN IF NOT EXISTS workorder_name TEXT,
ADD COLUMN IF NOT EXISTS workorder_state TEXT,
ADD COLUMN IF NOT EXISTS production_id BIGINT,
ADD COLUMN IF NOT EXISTS product_id BIGINT,
ADD COLUMN IF NOT EXISTS qty_production NUMERIC(18,4),
ADD COLUMN IF NOT EXISTS date_start TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS date_finished TIMESTAMPTZ;
ALTER TABLE mv_hot.odoo_order_state_events
ADD COLUMN IF NOT EXISTS workorder_id TEXT,
ADD COLUMN IF NOT EXISTS workorder_name TEXT,
ADD COLUMN IF NOT EXISTS workorder_state TEXT,
ADD COLUMN IF NOT EXISTS production_id BIGINT,
ADD COLUMN IF NOT EXISTS product_id BIGINT,
ADD COLUMN IF NOT EXISTS qty_production NUMERIC(18,4),
ADD COLUMN IF NOT EXISTS date_start TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS date_finished TIMESTAMPTZ;
UPDATE mv_hot.odoo_order_state_current
SET
workorder_name = COALESCE(workorder_name, NULLIF(payload_json ->> 'workorder_name', '')),
workorder_state = COALESCE(workorder_state, NULLIF(payload_json ->> 'workorder_state', '')),
production_id = COALESCE(production_id, NULLIF(payload_json ->> 'production_id', '')::bigint),
product_id = COALESCE(product_id, NULLIF(payload_json ->> 'product_id', '')::bigint),
qty_production = COALESCE(qty_production, NULLIF(payload_json ->> 'qty_production', '')::numeric),
date_start = COALESCE(date_start, NULLIF(payload_json ->> 'date_start', '')::timestamptz),
date_finished = COALESCE(date_finished, NULLIF(payload_json ->> 'date_finished', '')::timestamptz)
WHERE payload_json IS NOT NULL;
UPDATE mv_hot.odoo_order_state_events
SET
workorder_id = COALESCE(workorder_id, NULLIF(payload_json ->> 'workorder_id', '')),
workorder_name = COALESCE(workorder_name, NULLIF(payload_json ->> 'workorder_name', '')),
workorder_state = COALESCE(workorder_state, NULLIF(payload_json ->> 'workorder_state', '')),
production_id = COALESCE(production_id, NULLIF(payload_json ->> 'production_id', '')::bigint),
product_id = COALESCE(product_id, NULLIF(payload_json ->> 'product_id', '')::bigint),
qty_production = COALESCE(qty_production, NULLIF(payload_json ->> 'qty_production', '')::numeric),
date_start = COALESCE(date_start, NULLIF(payload_json ->> 'date_start', '')::timestamptz),
date_finished = COALESCE(date_finished, NULLIF(payload_json ->> 'date_finished', '')::timestamptz)
WHERE payload_json IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_odoo_order_state_events_machine_effective
ON mv_hot.odoo_order_state_events
(tenant, site, machine_id, COALESCE(mqtt_ts, received_at));
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_order_state_intervals_v1 AS
WITH ordered AS (
SELECT
e.id,
e.tenant,
e.site,
e.machine_id,
e.topic,
e.mqtt_ts,
e.received_at,
COALESCE(e.mqtt_ts, e.received_at) AS effective_from,
LEAD(COALESCE(e.mqtt_ts, e.received_at)) OVER (
PARTITION BY e.tenant, e.site, e.machine_id
ORDER BY COALESCE(e.mqtt_ts, e.received_at), e.id
) AS effective_to,
e.odoo_state,
e.order_present,
e.order_coherent,
e.workorder_id,
e.workorder_name,
e.workorder_state,
e.production_id,
e.production_order,
e.product_id,
e.product,
e.qty_production,
e.date_start,
e.date_finished,
e.reason,
e.waiting_count,
e.ready_count,
e.progress_count,
e.payload_hash,
e.payload_json
FROM mv_hot.odoo_order_state_events e
WHERE e.site = 'ucepsa_onpremise'
)
SELECT *
FROM ordered;
COMMENT ON VIEW mv_reports_ucepsa_prod.li_order_state_intervals_v1 IS
'Intervalos históricos de estado Odoo por máquina. Permite fijar qué orden estaba activa en el instante de un evento físico.';
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro') THEN
GRANT SELECT ON mv_reports_ucepsa_prod.li_order_state_intervals_v1 TO grafana_ucepsa_ro;
END IF;
END $$;
COMMIT;

View File

@ -0,0 +1,523 @@
BEGIN;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_stop_candidates_v1 AS
WITH stop_base AS (
SELECT
s.*,
COALESCE(
s.duration_s,
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))::numeric
) AS effective_duration_s,
COALESCE(
s.cost_eur,
ROUND((
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))
/ 3600.0 * s.value_hour_eur
)::numeric, 2)
) AS effective_capacity_cost_eur
FROM mv_hot.edge_oee_machine_stops s
WHERE s.tenant = 'ucepsa'
AND s.site = 'ucepsa_onpremise'
), enriched AS (
SELECT
s.*,
d.id AS deployment_id,
d.scope_code,
d.mode AS deployment_mode,
d.planned_go_live_date,
d.actual_go_live_at,
p.noise_below_s,
p.aggregate_below_s,
p.operator_individual_from_s,
p.aggregation_window_min,
p.require_valid_order,
p.require_closed_event,
o.id AS order_state_event_id,
o.effective_from AS order_context_from,
o.effective_to AS order_context_to,
o.odoo_state,
o.order_present,
o.order_coherent,
o.workorder_id AS odoo_workorder_id,
o.workorder_name,
o.workorder_state,
o.production_id AS odoo_production_id,
o.production_order AS order_ref,
o.product_id,
o.product AS product_name,
o.qty_production,
o.date_start AS odoo_date_start,
o.date_finished AS odoo_date_finished,
pc.units_per_cycle,
pc.target_cycle_rate_ppm,
ep.parameter_value AS configured_capacity_eur_h,
ep.confidence AS capacity_cost_confidence
FROM stop_base s
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_loss_intelligence.deployment_config x
WHERE x.tenant = s.tenant
AND x.site = s.site
AND x.scope_code = 'CUTTERS'
ORDER BY x.id DESC
LIMIT 1
) d ON true
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_loss_intelligence.loss_policies x
WHERE x.tenant = s.tenant
AND x.site = s.site
AND x.policy_code = 'CUTTER_STOPS_V1'
AND x.is_active = true
ORDER BY x.id DESC
LIMIT 1
) p ON true
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_reports_ucepsa_prod.li_order_state_intervals_v1 x
WHERE x.tenant = s.tenant
AND x.site = s.site
AND x.machine_id = s.machine_id
AND s.started_at >= x.effective_from
AND (x.effective_to IS NULL OR s.started_at < x.effective_to)
ORDER BY x.effective_from DESC, x.id DESC
LIMIT 1
) o ON true
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_loss_intelligence.product_cycle_parameters x
WHERE x.tenant = s.tenant
AND x.site = s.site
AND x.is_active = true
AND x.product_id = o.product_id
AND (x.machine_id IS NULL OR x.machine_id = s.machine_id)
AND s.started_at >= x.valid_from
AND (x.valid_to IS NULL OR s.started_at < x.valid_to)
ORDER BY (x.machine_id IS NOT NULL) DESC, x.valid_from DESC, x.id DESC
LIMIT 1
) pc ON true
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_loss_intelligence.economic_parameters x
WHERE x.tenant = s.tenant
AND x.site = s.site
AND x.parameter_code = 'CAPACITY_HOUR_EUR'
AND x.is_active = true
AND (x.machine_id IS NULL OR x.machine_id = s.machine_id)
AND s.started_at >= x.valid_from
AND (x.valid_to IS NULL OR s.started_at < x.valid_to)
ORDER BY (x.machine_id IS NOT NULL) DESC, x.valid_from DESC, x.id DESC
LIMIT 1
) ep ON true
)
SELECT
e.id AS source_stop_id,
e.tenant,
e.site,
e.deployment_id,
e.scope_code,
e.deployment_mode,
e.planned_go_live_date,
e.actual_go_live_at,
CASE
WHEN e.deployment_mode = 'ACTIVE'
AND e.actual_go_live_at IS NOT NULL
AND e.started_at >= e.actual_go_live_at
THEN 'ACTIVE'
ELSE 'SHADOW'
END AS ledger_phase,
e.machine_id,
e.asset,
e.started_at,
e.ended_at,
e.status AS source_status,
e.classification_status,
e.cause_code,
c.cause_name,
c.is_planned,
e.operator_note,
e.start_event_name,
e.end_event_name,
e.effective_duration_s,
ROUND((e.effective_duration_s / 60.0)::numeric, 2) AS effective_duration_min,
e.value_hour_eur AS source_value_hour_eur,
e.configured_capacity_eur_h,
COALESCE(e.configured_capacity_eur_h, e.value_hour_eur) AS applied_capacity_eur_h,
ROUND((
e.effective_duration_s / 3600.0
* COALESCE(e.configured_capacity_eur_h, e.value_hour_eur)
)::numeric, 2) AS effective_capacity_cost_eur,
e.capacity_cost_confidence,
e.order_state_event_id,
e.order_context_from,
e.order_context_to,
e.odoo_state,
e.order_present,
e.order_coherent,
e.odoo_workorder_id,
e.workorder_name,
e.workorder_state,
e.odoo_production_id,
e.order_ref,
e.product_id,
e.product_name,
e.qty_production,
e.odoo_date_start,
e.odoo_date_finished,
e.units_per_cycle,
e.target_cycle_rate_ppm,
CASE
WHEN e.deployment_id IS NULL THEN 'CONFIG_MISSING'
WHEN e.noise_below_s IS NULL THEN 'POLICY_MISSING'
WHEN e.status = 'OPEN' THEN 'OPEN_MONITORING'
WHEN e.require_closed_event AND e.status <> 'CLOSED' THEN 'EXCLUDED_NOT_CLOSED'
WHEN e.order_state_event_id IS NULL THEN 'EXCLUDED_NO_ORDER_CONTEXT'
WHEN e.require_valid_order AND COALESCE(e.order_present, false) = false THEN 'EXCLUDED_NO_VALID_ORDER'
WHEN e.require_valid_order AND COALESCE(e.order_coherent, false) = false THEN 'EXCLUDED_ORDER_INCOHERENT'
WHEN e.effective_duration_s < e.noise_below_s THEN 'EXCLUDED_NOISE'
WHEN e.effective_duration_s < e.operator_individual_from_s THEN 'CANDIDATE_GROUP'
ELSE 'CANDIDATE_INDIVIDUAL'
END AS eligibility_status,
CASE
WHEN e.status = 'CLOSED'
AND e.order_state_event_id IS NOT NULL
AND (NOT e.require_valid_order OR (e.order_present = true AND e.order_coherent = true))
AND e.effective_duration_s >= e.noise_below_s
THEN true
ELSE false
END AS technically_eligible,
CASE
WHEN e.deployment_mode = 'ACTIVE'
AND e.actual_go_live_at IS NOT NULL
AND e.started_at >= e.actual_go_live_at
AND e.status = 'CLOSED'
AND e.order_state_event_id IS NOT NULL
AND (NOT e.require_valid_order OR (e.order_present = true AND e.order_coherent = true))
AND e.effective_duration_s >= e.noise_below_s
THEN true
ELSE false
END AS official_ledger_eligible,
jsonb_build_object(
'source_stop_id', e.id,
'source_start_payload', e.start_payload_json,
'source_end_payload', e.end_payload_json,
'order_state_event_id', e.order_state_event_id,
'deployment_mode', e.deployment_mode,
'policy', jsonb_build_object(
'noise_below_s', e.noise_below_s,
'aggregate_below_s', e.aggregate_below_s,
'operator_individual_from_s', e.operator_individual_from_s,
'aggregation_window_min', e.aggregation_window_min
)
) AS evidence_json
FROM enriched e
LEFT JOIN mv_hot.edge_oee_stop_causes c
ON c.cause_code = e.cause_code;
COMMENT ON VIEW mv_reports_ucepsa_prod.li_stop_candidates_v1 IS
'Clasifica cada paro raw como excluido, agrupable, individual u abierto. Mantiene separados el modo SHADOW y la elegibilidad oficial.';
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_stop_groups_v1 AS
WITH candidates AS (
SELECT
c.*,
COALESCE((c.evidence_json #>> '{policy,aggregation_window_min}')::int, 15) AS aggregation_window_min
FROM mv_reports_ucepsa_prod.li_stop_candidates_v1 c
WHERE c.eligibility_status IN ('CANDIDATE_GROUP', 'CANDIDATE_INDIVIDUAL')
), bucketed AS (
SELECT
c.*,
date_bin(
make_interval(mins => c.aggregation_window_min),
c.started_at,
TIMESTAMPTZ '2000-01-01 00:00:00+00'
) AS bucket_start
FROM candidates c
), grouped_micro AS (
SELECT
tenant,
site,
deployment_id,
ledger_phase,
machine_id,
asset,
order_ref,
odoo_production_id,
NULLIF(odoo_workorder_id, '')::bigint AS odoo_workorder_id,
product_id,
product_name,
units_per_cycle,
target_cycle_rate_ppm,
COALESCE(cause_code, 'PENDING') AS cause_code,
COALESCE(cause_name, 'Pendiente de clasificar') AS cause_name,
classification_status,
bucket_start AS group_window_start,
COUNT(*)::int AS event_count,
MIN(started_at) AS started_at,
MAX(ended_at) AS ended_at,
SUM(effective_duration_s)::numeric AS duration_s,
ROUND(SUM(effective_capacity_cost_eur)::numeric, 2) AS capacity_loss_eur,
array_agg(source_stop_id ORDER BY started_at) AS source_stop_ids,
bool_and(official_ledger_eligible) AS official_ledger_eligible
FROM bucketed
WHERE eligibility_status = 'CANDIDATE_GROUP'
GROUP BY
tenant, site, deployment_id, ledger_phase, machine_id, asset,
order_ref, odoo_production_id, odoo_workorder_id, product_id,
product_name, units_per_cycle, target_cycle_rate_ppm,
COALESCE(cause_code, 'PENDING'),
COALESCE(cause_name, 'Pendiente de clasificar'),
classification_status, bucket_start
), individual AS (
SELECT
tenant,
site,
deployment_id,
ledger_phase,
machine_id,
asset,
order_ref,
odoo_production_id,
NULLIF(odoo_workorder_id, '')::bigint AS odoo_workorder_id,
product_id,
product_name,
units_per_cycle,
target_cycle_rate_ppm,
COALESCE(cause_code, 'PENDING') AS cause_code,
COALESCE(cause_name, 'Pendiente de clasificar') AS cause_name,
classification_status,
started_at AS group_window_start,
1::int AS event_count,
started_at,
ended_at,
effective_duration_s::numeric AS duration_s,
effective_capacity_cost_eur::numeric AS capacity_loss_eur,
ARRAY[source_stop_id]::bigint[] AS source_stop_ids,
official_ledger_eligible
FROM bucketed
WHERE eligibility_status = 'CANDIDATE_INDIVIDUAL'
), combined AS (
SELECT * FROM grouped_micro
UNION ALL
SELECT * FROM individual
)
SELECT
CASE
WHEN event_count = 1 THEN 'STOP:' || source_stop_ids[1]::text
ELSE 'STOPGROUP:' || machine_id || ':'
|| COALESCE(odoo_production_id::text, COALESCE(order_ref, 'NO_ORDER')) || ':'
|| to_char(group_window_start AT TIME ZONE 'Europe/Madrid', 'YYYYMMDDHH24MI') || ':'
|| cause_code
END AS source_key,
*,
ROUND((duration_s / 60.0)::numeric, 2) AS duration_min,
CASE
WHEN duration_s >= 900 THEN 'HIGH'
WHEN duration_s >= 300 THEN 'MEDIUM'
ELSE 'LOW'
END AS preliminary_priority
FROM combined;
COMMENT ON VIEW mv_reports_ucepsa_prod.li_stop_groups_v1 IS
'Agrupa microparos por máquina, orden, ventana y causa. Los paros largos permanecen individuales.';
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_shadow_review_queue_v1 AS
SELECT
row_number() OVER (
ORDER BY capacity_loss_eur DESC, duration_s DESC, started_at DESC
) AS shadow_rank,
source_key,
machine_id,
order_ref,
product_name,
cause_code,
cause_name,
event_count,
started_at,
ended_at,
duration_min,
capacity_loss_eur,
preliminary_priority,
'SHADOW_ONLY_NOT_OFFICIAL'::text AS queue_status,
official_ledger_eligible,
source_stop_ids
FROM mv_reports_ucepsa_prod.li_stop_groups_v1
WHERE ledger_phase = 'SHADOW'
ORDER BY shadow_rank;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_operator_stop_queue_v1 AS
SELECT
source_key,
machine_id,
order_ref,
product_name,
cause_code,
cause_name,
classification_status,
event_count,
started_at,
ended_at,
duration_min,
capacity_loss_eur,
source_stop_ids
FROM mv_reports_ucepsa_prod.li_stop_groups_v1
WHERE ledger_phase = 'ACTIVE'
AND official_ledger_eligible = true
AND classification_status = 'PENDING'
ORDER BY capacity_loss_eur DESC, duration_s DESC, started_at DESC;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_daily_review_queue_v1 AS
SELECT
row_number() OVER (
ORDER BY
COALESCE(l.priority_score, 0) DESC,
l.review_due_at NULLS LAST,
l.detected_at
) AS review_rank,
l.loss_id,
l.machine_id,
l.order_ref,
l.product_name,
l.loss_type,
l.cause_code,
ROUND((l.duration_s / 60.0)::numeric, 2) AS duration_min,
l.capacity_loss_eur,
l.energy_waste_eur,
l.priority_level,
l.action_candidate,
l.action_final,
l.review_owner,
l.review_due_at,
l.status,
t.trial_id,
t.trial_code,
t.result AS trial_result,
t.decision AS trial_decision
FROM mv_loss_intelligence.loss_ledger l
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_loss_intelligence.action_trials x
WHERE x.loss_id = l.loss_id
ORDER BY x.created_at DESC, x.trial_id DESC
LIMIT 1
) t ON true
WHERE l.tenant = 'ucepsa'
AND l.site = 'ucepsa_onpremise'
AND l.ledger_phase = 'ACTIVE'
AND l.status NOT IN ('CLOSED', 'DISCARDED')
ORDER BY review_rank
LIMIT 5;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_shadow_summary_v1 AS
SELECT
ledger_phase,
eligibility_status,
machine_id,
COUNT(*) AS stop_count,
ROUND(SUM(effective_duration_s / 60.0)::numeric, 2) AS total_minutes,
ROUND(SUM(effective_capacity_cost_eur)::numeric, 2) AS capacity_cost_eur
FROM mv_reports_ucepsa_prod.li_stop_candidates_v1
GROUP BY ledger_phase, eligibility_status, machine_id;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_product_cycle_coverage_v1 AS
SELECT
c.machine_id,
c.odoo_production_id,
c.order_ref,
c.product_id,
c.product_name,
c.qty_production,
c.units_per_cycle,
c.target_cycle_rate_ppm,
CASE
WHEN c.product_id IS NULL THEN 'MISSING_PRODUCT_ID'
WHEN c.units_per_cycle IS NULL THEN 'MISSING_UNITS_PER_CYCLE'
WHEN c.target_cycle_rate_ppm IS NULL THEN 'MISSING_TARGET_CYCLE_RATE'
ELSE 'READY'
END AS cycle_parameter_status,
MAX(c.started_at) AS latest_stop_started_at,
COUNT(*) AS related_stop_count
FROM mv_reports_ucepsa_prod.li_stop_candidates_v1 c
WHERE c.order_present = true
AND c.order_coherent = true
GROUP BY
c.machine_id, c.odoo_production_id, c.order_ref, c.product_id,
c.product_name, c.qty_production, c.units_per_cycle, c.target_cycle_rate_ppm;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_data_quality_v1 AS
WITH deployment AS (
SELECT COUNT(*) AS n
FROM mv_loss_intelligence.deployment_config
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise' AND scope_code = 'CUTTERS'
), capacity_cost AS (
SELECT COUNT(*) AS n
FROM mv_loss_intelligence.economic_parameters
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise'
AND parameter_code = 'CAPACITY_HOUR_EUR' AND is_active = true
), energy_cost AS (
SELECT COUNT(*) AS n
FROM mv_loss_intelligence.economic_parameters
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise'
AND parameter_code = 'ENERGY_VARIABLE_EUR_KWH' AND is_active = true
), current_loaded AS (
SELECT COUNT(*) AS n
FROM mv_hot.odoo_order_state_current
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise'
AND order_present = true AND order_coherent = true
), cycle_missing AS (
SELECT COUNT(*) AS n
FROM mv_hot.odoo_order_state_current o
WHERE o.tenant = 'ucepsa' AND o.site = 'ucepsa_onpremise'
AND o.order_present = true AND o.order_coherent = true
AND NOT EXISTS (
SELECT 1
FROM mv_loss_intelligence.product_cycle_parameters p
WHERE p.tenant = o.tenant
AND p.site = o.site
AND p.product_id = o.product_id
AND p.is_active = true
AND (p.machine_id IS NULL OR p.machine_id = o.machine_id)
)
), stale_open AS (
SELECT COUNT(*) AS n
FROM mv_hot.edge_oee_machine_stops
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise'
AND status = 'OPEN'
AND started_at < now() - interval '12 hours'
)
SELECT 'DEPLOYMENT_CONFIG'::text AS check_code,
CASE WHEN n = 1 THEN 'OK' ELSE 'BLOCKER' END AS status,
n AS affected_count,
'Debe existir una única configuración CUTTERS.'::text AS description
FROM deployment
UNION ALL
SELECT 'CAPACITY_COST', CASE WHEN n >= 1 THEN 'OK' ELSE 'BLOCKER' END, n,
'Debe existir coste de capacidad €/h activo.' FROM capacity_cost
UNION ALL
SELECT 'ENERGY_COST', CASE WHEN n >= 1 THEN 'OK' ELSE 'BLOCKER' END, n,
'Debe existir coste eléctrico variable €/kWh activo.' FROM energy_cost
UNION ALL
SELECT 'CURRENT_VALID_ORDERS', CASE WHEN n >= 1 THEN 'OK' ELSE 'INFO' END, n,
'Órdenes actualmente cargadas y coherentes.' FROM current_loaded
UNION ALL
SELECT 'MISSING_CYCLE_PARAMETERS', CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END, n,
'Productos activos sin bolsas por golpe o cadencia configurada.' FROM cycle_missing
UNION ALL
SELECT 'STALE_OPEN_STOPS', CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END, n,
'Paros abiertos desde hace más de 12 horas; no equivalen automáticamente a pérdida.' FROM stale_open;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro') THEN
GRANT SELECT ON mv_reports_ucepsa_prod.li_stop_candidates_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_stop_groups_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_shadow_review_queue_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_operator_stop_queue_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_daily_review_queue_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_shadow_summary_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_product_cycle_coverage_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_data_quality_v1 TO grafana_ucepsa_ro;
END IF;
END $$;
COMMIT;

View File

@ -0,0 +1,244 @@
BEGIN;
INSERT INTO mv_loss_intelligence.deployment_config (
tenant, site, scope_code, scope_name, mode,
planned_go_live_date, actual_go_live_at, timezone,
review_owner_primary, review_owner_secondary,
review_cadence, daily_review_limit, notes
)
VALUES (
'ucepsa', 'ucepsa_onpremise', 'CUTTERS', 'Cortadoras UCEPSA', 'SHADOW',
DATE '2026-10-01', NULL, 'Europe/Madrid',
'Jefe de producción', 'Gerencia / dirección',
'DAILY', 5,
'Infraestructura preparada entre julio y septiembre. El Ledger oficial se activará en kickoff; octubre es una previsión, no una activación automática.'
)
ON CONFLICT (tenant, site, scope_code)
DO UPDATE SET
scope_name = EXCLUDED.scope_name,
mode = 'SHADOW',
planned_go_live_date = EXCLUDED.planned_go_live_date,
timezone = EXCLUDED.timezone,
review_owner_primary = EXCLUDED.review_owner_primary,
review_owner_secondary = EXCLUDED.review_owner_secondary,
review_cadence = EXCLUDED.review_cadence,
daily_review_limit = EXCLUDED.daily_review_limit,
notes = EXCLUDED.notes,
updated_at = now();
INSERT INTO mv_loss_intelligence.loss_policies (
tenant, site, policy_code, source_type,
noise_below_s, aggregate_below_s, operator_individual_from_s,
aggregation_window_min, require_valid_order, require_closed_event,
exclude_planned_by_default, is_active, notes
)
VALUES (
'ucepsa', 'ucepsa_onpremise', 'CUTTER_STOPS_V1', 'MACHINE_STOP',
5, 300, 300,
15, true, true,
true, true,
'Política inicial de modo sombra. <5 s se conserva solo en raw; 5 s5 min se agrupa; >=5 min se trata individualmente. Validar con producción antes del kickoff.'
)
ON CONFLICT (tenant, site, policy_code)
DO UPDATE SET
noise_below_s = EXCLUDED.noise_below_s,
aggregate_below_s = EXCLUDED.aggregate_below_s,
operator_individual_from_s = EXCLUDED.operator_individual_from_s,
aggregation_window_min = EXCLUDED.aggregation_window_min,
require_valid_order = EXCLUDED.require_valid_order,
require_closed_event = EXCLUDED.require_closed_event,
exclude_planned_by_default = EXCLUDED.exclude_planned_by_default,
is_active = true,
notes = EXCLUDED.notes,
updated_at = now();
INSERT INTO mv_loss_intelligence.economic_parameters (
tenant, site, scope_code, machine_id, product_id,
parameter_code, parameter_value, unit, confidence,
includes_energy, valid_from, is_active, source_ref, approved_by, notes
)
SELECT
'ucepsa', 'ucepsa_onpremise', 'CUTTERS', NULL, NULL,
'CAPACITY_HOUR_EUR', 36.000000, 'EUR/h', 'LOW',
NULL, TIMESTAMPTZ '2026-06-18 00:00:00+02', true,
'Valor demostrativo usado en Edge-OEE', NULL,
'Pendiente de validación por gerencia. No se ha confirmado si incorpora energía.'
WHERE NOT EXISTS (
SELECT 1
FROM mv_loss_intelligence.economic_parameters
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise'
AND parameter_code = 'CAPACITY_HOUR_EUR' AND is_active = true
);
INSERT INTO mv_loss_intelligence.economic_parameters (
tenant, site, scope_code, machine_id, product_id,
parameter_code, parameter_value, unit, confidence,
includes_energy, valid_from, is_active, source_ref, approved_by, notes
)
SELECT
'ucepsa', 'ucepsa_onpremise', 'CUTTERS', NULL, NULL,
'ENERGY_VARIABLE_EUR_KWH', 0.128249, 'EUR/kWh', 'MEDIUM',
false, TIMESTAMPTZ '2026-06-10 00:00:00+02', true,
'Factura UCEPSA 6.1TD 10/06/202630/06/2026', NULL,
'Media ponderada de energía, red variable e impuesto eléctrico. No incluye reparto automático del término fijo de potencia.'
WHERE NOT EXISTS (
SELECT 1
FROM mv_loss_intelligence.economic_parameters
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise'
AND parameter_code = 'ENERGY_VARIABLE_EUR_KWH' AND is_active = true
);
INSERT INTO mv_edge_oee_ucepsa_prod.margin_cost_parameters (
tenant, site, scope_level, machine_id, product_default_code,
energy_cost_eur_per_kwh, material_cost_eur_per_kg,
machine_run_cost_eur_per_h, labor_cost_eur_per_h, overhead_cost_eur_per_h,
valid_from, valid_to, is_active, notes, created_at
)
SELECT
'ucepsa', 'ucepsa_onpremise', 'GLOBAL', NULL, NULL,
0.128249, NULL,
36.0000, NULL, NULL,
TIMESTAMPTZ '2026-06-10 00:00:00+02', NULL, true,
'Parámetros provisionales para Cost Readiness: 36 €/h y 0,128249 €/kWh. Mantener costes separados hasta confirmar si el coste/hora incluye energía.',
now()
WHERE NOT EXISTS (
SELECT 1
FROM mv_edge_oee_ucepsa_prod.margin_cost_parameters
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND scope_level = 'GLOBAL'
AND is_active = true
);
INSERT INTO mv_loss_intelligence.action_catalog (
action_id, catalog_code, catalog_version, tenant, site,
loss_type, source_cause_code, action_type, action_candidate,
applies_when, does_not_apply_when, suggested_owner,
test_window, metric_before, metric_after, success_criteria,
risk_level, cost_to_test, source_ref, notes
)
VALUES
(
'CUTTER-BOBBIN-001', 'LI-CUTTER-STOPS', '0.1', 'ucepsa', 'ucepsa_onpremise',
'MACHINE_STOP', 'cambio_bobina', 'METHOD',
'Preparar la siguiente bobina y sus elementos auxiliares antes de que termine la bobina en curso.',
'["Paros repetidos por cambio de bobina", "Orden y referencia conocidas"]'::jsonb,
'["Cambio imprevisto por defecto de material", "Falta de espacio seguro para staging"]'::jsonb,
'Producción', '10 cambios de bobina comparables',
'Minutos de cambio de bobina por evento', 'Minutos de cambio de bobina por evento',
'Reducción >= 20% sin aumentar defectos ni incidencias de seguridad',
'LOW', 'LOW', 'MESAVAULT Action Catalog Cutter v0.1',
'La acción final debe adaptarse al espacio y método real de UCEPSA.'
),
(
'CUTTER-SPLICE-001', 'LI-CUTTER-STOPS', '0.1', 'ucepsa', 'ucepsa_onpremise',
'MACHINE_STOP', 'fallo_empalme', 'METHOD',
'Separar el fallo de empalme por tipo de defecto y probar un estándar único de preparación durante 10 órdenes.',
'["Fallo de empalme repetido", "La causa puede observarse y clasificarse"]'::jsonb,
'["Avería mecánica ajena al empalme"]'::jsonb,
'Producción / métodos', '10 órdenes o 20 empalmes',
'Eventos y minutos perdidos por empalme', 'Eventos y minutos perdidos por empalme',
'Reducción >= 25% de minutos perdidos',
'LOW', 'LOW', 'MESAVAULT Action Catalog Cutter v0.1', NULL
),
(
'CUTTER-SEAL-001', 'LI-CUTTER-STOPS', '0.1', 'ucepsa', 'ucepsa_onpremise',
'MACHINE_STOP', 'ajuste_sellado', 'METHOD',
'Crear y probar una ficha de parámetros de sellado por familia de referencia antes del arranque.',
'["Ajustes repetidos de mordazas, temperatura, presión o teflón", "Referencias repetitivas"]'::jsonb,
'["Avería mecánica", "Materia prima fuera de especificación no confirmada"]'::jsonb,
'Producción / calidad', '10 órdenes de la misma familia',
'Minutos de ajuste de sellado por orden', 'Minutos de ajuste de sellado por orden',
'Reducción >= 25% manteniendo calidad',
'LOW', 'LOW', 'MESAVAULT Action Catalog Cutter v0.1', NULL
),
(
'CUTTER-MATERIAL-001', 'LI-CUTTER-STOPS', '0.1', 'ucepsa', 'ucepsa_onpremise',
'MACHINE_STOP', 'falta_material', 'ORGANIZATIONAL',
'No iniciar la orden hasta que bobina y material auxiliar estén físicamente preparados junto a la cortadora.',
'["Espera de material con orden cargada", "Material identificable antes del arranque"]'::jsonb,
'["Rotura o rechazo de material durante producción"]'::jsonb,
'Producción / logística', '10 órdenes comparables',
'Minutos de espera de material por orden', 'Minutos de espera de material por orden',
'Reducción >= 50% de la espera de material',
'LOW', 'LOW', 'MESAVAULT Action Catalog Cutter v0.1', NULL
),
(
'CUTTER-PHOTO-001', 'LI-CUTTER-STOPS', '0.1', 'ucepsa', 'ucepsa_onpremise',
'MACHINE_STOP', 'limpieza_fotocelula', 'TECHNICAL',
'Aplicar una comprobación y limpieza breve de fotocélula antes del turno en las referencias con recurrencia.',
'["Limpieza de fotocélula repetida", "Patrón concentrado en máquina o producto"]'::jsonb,
'["Fallo eléctrico o sustitución necesaria del sensor"]'::jsonb,
'Producción / mantenimiento', '2 semanas',
'Eventos y minutos por limpieza de fotocélula', 'Eventos y minutos por limpieza de fotocélula',
'Reducción >= 30% de eventos sin aumentar tiempo de arranque',
'LOW', 'LOW', 'MESAVAULT Action Catalog Cutter v0.1', NULL
),
(
'CUTTER-FORMAT-001', 'LI-CUTTER-STOPS', '0.1', 'ucepsa', 'ucepsa_onpremise',
'MACHINE_STOP', 'cambio_formato', 'METHOD',
'Separar preparación externa e interna del cambio de formato y preparar fuera de máquina todo lo posible.',
'["Cambios de formato repetidos", "Se pueden comparar referencias o familias similares"]'::jsonb,
'["Cambio excepcional no repetible"]'::jsonb,
'Producción / métodos', '10 cambios comparables',
'Minutos de cambio de formato', 'Minutos de cambio de formato',
'Reducción >= 20% del tiempo mediano',
'LOW', 'MEDIUM', 'MESAVAULT Action Catalog Cutter v0.1', NULL
),
(
'CUTTER-MECH-001', 'LI-CUTTER-STOPS', '0.1', 'ucepsa', 'ucepsa_onpremise',
'MACHINE_STOP', 'averia_mecanica', 'TECHNICAL',
'Seleccionar la avería mecánica repetida con mayor coste, intervenir sobre el componente y comprobar recurrencia.',
'["Misma avería o síntoma repetido", "Existe responsable de mantenimiento"]'::jsonb,
'["Evento único sin diagnóstico", "Paro no confirmado como avería"]'::jsonb,
'Mantenimiento', '30 días o siguiente ciclo de recurrencia',
'Eventos, minutos y coste de la avería seleccionada', 'Eventos, minutos y coste tras la intervención',
'Cero recurrencias o reducción >= 50% del tiempo perdido',
'MEDIUM', 'MEDIUM', 'MESAVAULT Action Catalog Cutter v0.1', NULL
),
(
'CUTTER-INSTR-001', 'LI-CUTTER-STOPS', '0.1', 'ucepsa', 'ucepsa_onpremise',
'MACHINE_STOP', 'espera_instrucciones', 'ORGANIZATIONAL',
'Definir antes del turno qué órdenes, referencias y decisiones requieren autorización para evitar esperas con máquina ocupada.',
'["Espera de instrucciones repetida", "Decisión previsible antes del arranque"]'::jsonb,
'["Incidencia de seguridad o calidad que exige autorización específica"]'::jsonb,
'Jefe de producción', '2 semanas',
'Minutos de espera de instrucciones por orden', 'Minutos de espera de instrucciones por orden',
'Reducción >= 50% de la espera',
'LOW', 'LOW', 'MESAVAULT Action Catalog Cutter v0.1', NULL
),
(
'CUTTER-OTHER-001', 'LI-CUTTER-STOPS', '0.1', 'ucepsa', 'ucepsa_onpremise',
'MACHINE_STOP', 'otro', 'DATA_QUALITY',
'Revisar semanalmente los paros clasificados como Otro y crear una causa nueva si un patrón se repite.',
'["Uso de Otro superior al 10% de minutos o eventos"]'::jsonb,
'["Evento aislado sin patrón"]'::jsonb,
'Producción / MESAVAULT', '4 semanas',
'Porcentaje de eventos y minutos en Otro', 'Porcentaje de eventos y minutos en Otro',
'Otro < 10% de eventos y minutos',
'LOW', 'LOW', 'MESAVAULT Action Catalog Cutter v0.1',
'Es una acción de calidad de dato, no una mejora de proceso por sí misma.'
)
ON CONFLICT (action_id)
DO UPDATE SET
catalog_code = EXCLUDED.catalog_code,
catalog_version = EXCLUDED.catalog_version,
loss_type = EXCLUDED.loss_type,
source_cause_code = EXCLUDED.source_cause_code,
action_type = EXCLUDED.action_type,
action_candidate = EXCLUDED.action_candidate,
applies_when = EXCLUDED.applies_when,
does_not_apply_when = EXCLUDED.does_not_apply_when,
suggested_owner = EXCLUDED.suggested_owner,
test_window = EXCLUDED.test_window,
metric_before = EXCLUDED.metric_before,
metric_after = EXCLUDED.metric_after,
success_criteria = EXCLUDED.success_criteria,
risk_level = EXCLUDED.risk_level,
cost_to_test = EXCLUDED.cost_to_test,
source_ref = EXCLUDED.source_ref,
notes = EXCLUDED.notes,
is_active = true,
updated_at = now();
COMMIT;

View File

@ -67,8 +67,15 @@ def state_hash(payload):
"odoo_state": payload.get("odoo_state"),
"reason": payload.get("reason"),
"workorder_id": payload.get("workorder_id"),
"production_id": payload.get("production_id"),
"production_order": payload.get("production_order"),
"product_id": payload.get("product_id"),
"product": payload.get("product"),
"qty_production": payload.get("qty_production"),
"date_start": payload.get("date_start"),
"date_finished": payload.get("date_finished"),
"workorder_name": payload.get("workorder_name"),
"workorder_state": payload.get("workorder_state"),
"waiting_count": diagnostics.get("waiting_count"),
"ready_count": diagnostics.get("ready_count"),
"progress_count": diagnostics.get("progress_count"),
@ -133,8 +140,15 @@ def upsert_order_state(topic, payload):
"odoo_state": payload.get("odoo_state"),
"reason": payload.get("reason"),
"workorder_id": str(payload.get("workorder_id")) if payload.get("workorder_id") is not None else None,
"workorder_name": payload.get("workorder_name"),
"workorder_state": payload.get("workorder_state"),
"production_id": to_int(payload.get("production_id")),
"production_order": payload.get("production_order"),
"product_id": to_int(payload.get("product_id")),
"product": payload.get("product"),
"qty_production": payload.get("qty_production"),
"date_start": parse_ts(payload.get("date_start")),
"date_finished": parse_ts(payload.get("date_finished")),
"waiting_count": to_int(diagnostics.get("waiting_count")),
"ready_count": to_int(diagnostics.get("ready_count")),
"progress_count": to_int(diagnostics.get("progress_count")),
@ -149,7 +163,9 @@ def upsert_order_state(topic, payload):
tenant, site, machine_id, topic, mqtt_ts, received_at,
source, workcenter_id, workcenter_name, workcenter_display_name,
order_present, order_coherent, odoo_state, reason,
workorder_id, production_order, product,
workorder_id, workorder_name, workorder_state,
production_id, production_order, product_id, product,
qty_production, date_start, date_finished,
waiting_count, ready_count, progress_count,
payload_hash, payload_json
)
@ -157,7 +173,9 @@ def upsert_order_state(topic, payload):
%(tenant)s, %(site)s, %(machine_id)s, %(topic)s, %(mqtt_ts)s, now(),
%(source)s, %(workcenter_id)s, %(workcenter_name)s, %(workcenter_display_name)s,
%(order_present)s, %(order_coherent)s, %(odoo_state)s, %(reason)s,
%(workorder_id)s, %(production_order)s, %(product)s,
%(workorder_id)s, %(workorder_name)s, %(workorder_state)s,
%(production_id)s, %(production_order)s, %(product_id)s, %(product)s,
%(qty_production)s, %(date_start)s, %(date_finished)s,
%(waiting_count)s, %(ready_count)s, %(progress_count)s,
%(payload_hash)s, %(payload_json)s
)
@ -175,8 +193,15 @@ def upsert_order_state(topic, payload):
odoo_state = EXCLUDED.odoo_state,
reason = EXCLUDED.reason,
workorder_id = EXCLUDED.workorder_id,
workorder_name = EXCLUDED.workorder_name,
workorder_state = EXCLUDED.workorder_state,
production_id = EXCLUDED.production_id,
production_order = EXCLUDED.production_order,
product_id = EXCLUDED.product_id,
product = EXCLUDED.product,
qty_production = EXCLUDED.qty_production,
date_start = EXCLUDED.date_start,
date_finished = EXCLUDED.date_finished,
waiting_count = EXCLUDED.waiting_count,
ready_count = EXCLUDED.ready_count,
progress_count = EXCLUDED.progress_count,
@ -191,14 +216,18 @@ def upsert_order_state(topic, payload):
INSERT INTO mv_hot.odoo_order_state_events (
tenant, site, machine_id, topic, mqtt_ts, received_at,
odoo_state, order_present, order_coherent,
production_order, product, reason,
workorder_id, workorder_name, workorder_state,
production_id, production_order, product_id, product,
qty_production, date_start, date_finished, reason,
waiting_count, ready_count, progress_count,
payload_hash, payload_json
)
VALUES (
%(tenant)s, %(site)s, %(machine_id)s, %(topic)s, %(mqtt_ts)s, now(),
%(odoo_state)s, %(order_present)s, %(order_coherent)s,
%(production_order)s, %(product)s, %(reason)s,
%(workorder_id)s, %(workorder_name)s, %(workorder_state)s,
%(production_id)s, %(production_order)s, %(product_id)s, %(product)s,
%(qty_production)s, %(date_start)s, %(date_finished)s, %(reason)s,
%(waiting_count)s, %(ready_count)s, %(progress_count)s,
%(payload_hash)s, %(payload_json)s
)