From 2d198e5ddb85caf33de578a354fcb2006dc186e8 Mon Sep 17 00:00:00 2001 From: Victor Fraile Garcia Date: Thu, 16 Jul 2026 12:27:29 +0200 Subject: [PATCH] feat(ucepsa): add LossIntelligence shadow foundation --- ...sintelligence_shadow_julio_octubre_2026.md | 120 ++++ .../ops/validate_lossintelligence_shadow.sql | 53 ++ .../0001_order_state_history_context_v2.patch | 93 ++++ ...093_lossintelligence_shadow_foundation.sql | 263 +++++++++ .../094_order_state_history_context_v2.sql | 99 ++++ ...5_lossintelligence_ucepsa_shadow_views.sql | 523 ++++++++++++++++++ ...96_lossintelligence_ucepsa_shadow_seed.sql | 244 ++++++++ .../tools/order_state_pg_sink.py | 37 +- 8 files changed, 1428 insertions(+), 4 deletions(-) create mode 100644 ucepsa/edge-oee-demo/docs/runbooks/090_lossintelligence_shadow_julio_octubre_2026.md create mode 100644 ucepsa/edge-oee-demo/ops/validate_lossintelligence_shadow.sql create mode 100644 ucepsa/edge-oee-demo/patches/0001_order_state_history_context_v2.patch create mode 100644 ucepsa/edge-oee-demo/sql/versions/093_lossintelligence_shadow_foundation.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/094_order_state_history_context_v2.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/095_lossintelligence_ucepsa_shadow_views.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/096_lossintelligence_ucepsa_shadow_seed.sql diff --git a/ucepsa/edge-oee-demo/docs/runbooks/090_lossintelligence_shadow_julio_octubre_2026.md b/ucepsa/edge-oee-demo/docs/runbooks/090_lossintelligence_shadow_julio_octubre_2026.md new file mode 100644 index 0000000..5486b03 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/090_lossintelligence_shadow_julio_octubre_2026.md @@ -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 s–5 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 15–30 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 093–096. +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. diff --git a/ucepsa/edge-oee-demo/ops/validate_lossintelligence_shadow.sql b/ucepsa/edge-oee-demo/ops/validate_lossintelligence_shadow.sql new file mode 100644 index 0000000..044534c --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_lossintelligence_shadow.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/patches/0001_order_state_history_context_v2.patch b/ucepsa/edge-oee-demo/patches/0001_order_state_history_context_v2.patch new file mode 100644 index 0000000..e80da08 --- /dev/null +++ b/ucepsa/edge-oee-demo/patches/0001_order_state_history_context_v2.patch @@ -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 + ) diff --git a/ucepsa/edge-oee-demo/sql/versions/093_lossintelligence_shadow_foundation.sql b/ucepsa/edge-oee-demo/sql/versions/093_lossintelligence_shadow_foundation.sql new file mode 100644 index 0000000..5943ee1 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/093_lossintelligence_shadow_foundation.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/versions/094_order_state_history_context_v2.sql b/ucepsa/edge-oee-demo/sql/versions/094_order_state_history_context_v2.sql new file mode 100644 index 0000000..d0d2504 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/094_order_state_history_context_v2.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/versions/095_lossintelligence_ucepsa_shadow_views.sql b/ucepsa/edge-oee-demo/sql/versions/095_lossintelligence_ucepsa_shadow_views.sql new file mode 100644 index 0000000..4190094 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/095_lossintelligence_ucepsa_shadow_views.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/versions/096_lossintelligence_ucepsa_shadow_seed.sql b/ucepsa/edge-oee-demo/sql/versions/096_lossintelligence_ucepsa_shadow_seed.sql new file mode 100644 index 0000000..efb3cee --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/096_lossintelligence_ucepsa_shadow_seed.sql @@ -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 s–5 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/2026–30/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; diff --git a/ucepsa/edge-oee-demo/tools/order_state_pg_sink.py b/ucepsa/edge-oee-demo/tools/order_state_pg_sink.py index 72684a1..715f26e 100755 --- a/ucepsa/edge-oee-demo/tools/order_state_pg_sink.py +++ b/ucepsa/edge-oee-demo/tools/order_state_pg_sink.py @@ -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 )