From d83bacefc45ed16e4dd6494db34733be5049a054 Mon Sep 17 00:00:00 2001 From: Victor Fraile Garcia Date: Fri, 17 Jul 2026 11:20:39 +0200 Subject: [PATCH] feat(ucepsa): add multi-source production context shadow v0.3 --- ...R-ucepsa-production-context-multisource.md | 29 + ...lligence_production_context_shadow_v0.3.md | 80 ++ ...alidate_production_context_shadow_v0.3.sql | 128 ++++ ...gence_production_context_foundation_v3.sql | 193 +++++ ...100_ucepsa_production_context_views_v3.sql | 705 ++++++++++++++++++ .../edge-oee-demo/tools/li_context_admin.py | 439 +++++++++++ 6 files changed, 1574 insertions(+) create mode 100644 ucepsa/edge-oee-demo/docs/adr/ADR-ucepsa-production-context-multisource.md create mode 100644 ucepsa/edge-oee-demo/docs/runbooks/094_lossintelligence_production_context_shadow_v0.3.md create mode 100644 ucepsa/edge-oee-demo/ops/validate_production_context_shadow_v0.3.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/099_lossintelligence_production_context_foundation_v3.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/100_ucepsa_production_context_views_v3.sql create mode 100644 ucepsa/edge-oee-demo/tools/li_context_admin.py diff --git a/ucepsa/edge-oee-demo/docs/adr/ADR-ucepsa-production-context-multisource.md b/ucepsa/edge-oee-demo/docs/adr/ADR-ucepsa-production-context-multisource.md new file mode 100644 index 0000000..7df0216 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/adr/ADR-ucepsa-production-context-multisource.md @@ -0,0 +1,29 @@ +# ADR — Contexto productivo multifuente durante migraciones ERP + +## Estado + +Aceptado para UCEPSA en modo SHADOW. + +## Problema + +Una máquina puede producir legítimamente sin orden Odoo porque la planta mantiene +temporalmente actividad en el ERP anterior. + +Tratar todo `RUNNING_WITHOUT_VALID_ORDER` como error o pérdida produciría falsos +positivos y dañaría la implantación. + +## Decisión + +MESAVAULT separa: + +- verdad física: WISE/RevPi; +- contexto productivo: Odoo, legacy, manual, mantenimiento o prueba; +- pérdida: solo tras intersectar actividad, contexto confirmado y política económica. + +## Consecuencias + +- Odoo sigue siendo la fuente preferida. +- Legacy se acepta como excepción explícita. +- Sin contexto confirmado se genera una tarea de revisión, no un coste. +- Las balizas no cambian de semántica. +- El porcentaje Odoo/legacy/sin contexto se convierte en indicador de migración. diff --git a/ucepsa/edge-oee-demo/docs/runbooks/094_lossintelligence_production_context_shadow_v0.3.md b/ucepsa/edge-oee-demo/docs/runbooks/094_lossintelligence_production_context_shadow_v0.3.md new file mode 100644 index 0000000..6ed1f30 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/094_lossintelligence_production_context_shadow_v0.3.md @@ -0,0 +1,80 @@ +# UCEPSA — LossIntelligence Production Context SHADOW v0.3 + +## Decisión de arquitectura + +Durante la migración conviven dos vías válidas: + +1. Odoo Shop Floor, estándar preferido. +2. Producción legacy o manual autorizada. + +`máquina en marcha + sin orden Odoo` no se convierte automáticamente en pérdida. + +Primero se clasifica el contexto: + +- ODOO_CONTEXT; +- LEGACY_AUTHORIZED; +- MANUAL_AUTHORIZED; +- MAINTENANCE; +- TEST_SETUP; +- UNCONFIRMED_CONTEXT; +- CONTEXT_CONFLICT. + +## Protección de las balizas + +Esta versión no modifica: + +- `beacon_controller.py`; +- el publicador `order_state`; +- DO0 azul; +- DO1 rojo. + +Azul fijo continúa significando exclusivamente orden Odoo válida. + +Una producción legacy puede seguir provocando azul intermitente porque está fuera del +flujo Odoo, aunque MESAVAULT la reconozca como legacy autorizada. + +## Flujo preventivo recomendado + +Antes de producir por el ERP anterior, producción abre un contexto: + +```bash +python tools/li_context_admin.py open \ + --machine CORT-02 \ + --source LEGACY_ERP \ + --external-order-ref LEGACY-1234 \ + --product-name "Referencia legacy" \ + --authorized-by "Juan Pablo" \ + --notes "Producción autorizada fuera de Odoo durante migración" +``` + +Al finalizar: + +```bash +python tools/li_context_admin.py close \ + --session-id 1 \ + --closed-by "Juan Pablo" +``` + +## Flujo retrospectivo + +Si no se abrió previamente, la vista `li_context_review_queue_v1` muestra un único +bloque físico. Tras confirmar el intervalo se crea una sesión retrospectiva. + +## Seguridad SHADOW + +Mientras `deployment_config.mode = SHADOW`, la herramienta fuerza: + +```text +official_eligible = false +``` + +Por tanto, ningún contexto manual puede crear por sí solo una pérdida económica oficial. + +## Qué no resuelve todavía + +- La identidad activa de la operaria en el flujo personalizado de Shop Floor. +- El cierre real con declaración de producción. +- La extensión de los paros v2 para usar contexto legacy. + +La siguiente subfase será `v0.3.1`: cruzar los paros con los contextos validados tras +comprobar que la apertura/cierre legacy funciona correctamente. diff --git a/ucepsa/edge-oee-demo/ops/validate_production_context_shadow_v0.3.sql b/ucepsa/edge-oee-demo/ops/validate_production_context_shadow_v0.3.sql new file mode 100644 index 0000000..632ade8 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_production_context_shadow_v0.3.sql @@ -0,0 +1,128 @@ +\pset pager off + +\echo '=== 1. Política de contexto ===' +SELECT + policy_code, + activity_gap_s, + activity_tail_s, + stale_open_context_s, + allow_legacy_context, + require_legacy_authorization, + allow_inferred_for_official +FROM mv_loss_intelligence.production_context_policies +WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' +ORDER BY id DESC; + +\echo '=== 2. Seguridad SHADOW ===' +SELECT + mode, + actual_go_live_at +FROM mv_loss_intelligence.deployment_config +WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND scope_code = 'CUTTERS'; + +SELECT count(*) AS official_context_sessions_in_shadow +FROM mv_loss_intelligence.production_context_sessions s +JOIN mv_loss_intelligence.deployment_config d + ON d.tenant = s.tenant + AND d.site = s.site + AND d.scope_code = 'CUTTERS' +WHERE d.mode = 'SHADOW' + AND s.official_eligible = true; + +\echo '=== 3. Estado actual de migración ===' +SELECT + machine_id, + operational_state, + migration_context_state, + migration_context_label, + telemetry_fresh, + machine_running, + order_present, + order_coherent, + production_order, + explicit_context_source, + explicit_order_ref, + explicit_context_started_at +FROM mv_reports_ucepsa_prod.machine_migration_context_state_v1 +ORDER BY machine_id; + +\echo '=== 4. Contextos explícitos ===' +SELECT + context_session_id, + machine_id, + source_type, + source_ref, + external_order_ref, + product_name, + operator_employee_id, + operator_name, + started_at, + ended_at, + status, + authorization_status, + confidence, + official_eligible, + authorized_by, + closed_by +FROM mv_loss_intelligence.production_context_sessions +WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' +ORDER BY context_session_id DESC +LIMIT 30; + +\echo '=== 5. Invariantes de actividad: deben devolver 0 ===' +SELECT count(*) AS invalid_activity_rows +FROM mv_reports_ucepsa_prod.li_machine_running_activity_v1 +WHERE activity_to <= activity_from + OR activity_duration_s <= 0; + +SELECT count(*) AS invalid_context_segment_rows +FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1 +WHERE segment_to <= segment_from + OR segment_duration_s <= 0; + +\echo '=== 6. Los segmentos no superan la actividad: debe devolver 0 ===' +WITH totals AS ( + SELECT + machine_id, + activity_island_id, + max(activity_duration_s) AS activity_duration_s, + sum(segment_duration_s) AS segmented_duration_s + FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1 + GROUP BY machine_id, activity_island_id +) +SELECT count(*) AS segment_overflow_rows +FROM totals +WHERE segmented_duration_s > activity_duration_s + 0.01; + +\echo '=== 7. Cobertura de contexto últimos 30 días ===' +SELECT * +FROM mv_reports_ucepsa_prod.li_production_context_coverage_v1 +ORDER BY machine_id, hours DESC; + +\echo '=== 8. Cola de revisión de contexto ===' +SELECT + review_rank, + context_review_key, + machine_id, + started_at, + ended_at, + duration_min, + context_status, + allowed_decisions +FROM mv_reports_ucepsa_prod.li_context_review_queue_v1 +ORDER BY review_rank +LIMIT 30; + +\echo '=== 9. Calidad de dato v3 ===' +SELECT * +FROM mv_reports_ucepsa_prod.li_data_quality_v3 +ORDER BY check_code; + +\echo '=== 10. Las balizas no forman parte de esta migración ===' +SELECT + 'INFO' AS status, + '099/100 no modifican beacon_controller, order_state publisher ni lógica DO0/DO1.' AS description; diff --git a/ucepsa/edge-oee-demo/sql/versions/099_lossintelligence_production_context_foundation_v3.sql b/ucepsa/edge-oee-demo/sql/versions/099_lossintelligence_production_context_foundation_v3.sql new file mode 100644 index 0000000..89f6f4d --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/099_lossintelligence_production_context_foundation_v3.sql @@ -0,0 +1,193 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS mv_loss_intelligence.production_context_policies ( + id BIGSERIAL PRIMARY KEY, + tenant TEXT NOT NULL, + site TEXT NOT NULL, + policy_code TEXT NOT NULL, + activity_gap_s NUMERIC(12,2) NOT NULL DEFAULT 90 + CHECK (activity_gap_s > 0), + activity_tail_s NUMERIC(12,2) NOT NULL DEFAULT 30 + CHECK (activity_tail_s >= 0), + stale_open_context_s NUMERIC(14,2) NOT NULL DEFAULT 43200 + CHECK (stale_open_context_s > 0), + allow_legacy_context BOOLEAN NOT NULL DEFAULT true, + require_legacy_authorization BOOLEAN NOT NULL DEFAULT true, + allow_inferred_for_official BOOLEAN NOT NULL DEFAULT false, + 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) +); + +COMMENT ON TABLE mv_loss_intelligence.production_context_policies IS +'Políticas reutilizables para segmentar actividad física y reconocer contextos productivos Odoo, legacy, mantenimiento o prueba.'; + +CREATE TABLE IF NOT EXISTS mv_loss_intelligence.production_context_sessions ( + context_session_id BIGSERIAL PRIMARY KEY, + tenant TEXT NOT NULL, + site TEXT NOT NULL, + machine_id TEXT NOT NULL, + source_type TEXT NOT NULL + CHECK (source_type IN ( + 'ODOO_SHOPFLOOR', + 'LEGACY_ERP', + 'MANUAL_AUTHORIZED', + 'MAINTENANCE', + 'TEST_SETUP', + 'INFERRED_SHADOW' + )), + source_ref TEXT, + external_order_ref TEXT, + odoo_production_id BIGINT, + odoo_workorder_id BIGINT, + product_id BIGINT, + product_default_code TEXT, + product_name TEXT, + operator_employee_id BIGINT, + operator_name TEXT, + started_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'OPEN' + CHECK (status IN ('OPEN', 'CLOSED', 'CANCELLED', 'SUPERSEDED')), + authorization_status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (authorization_status IN ( + 'PENDING', + 'AUTHORIZED', + 'AUTO_CONFIRMED', + 'REJECTED' + )), + confidence TEXT NOT NULL DEFAULT 'LOW' + CHECK (confidence IN ('HIGH', 'MEDIUM', 'LOW')), + official_eligible BOOLEAN NOT NULL DEFAULT false, + authorized_by TEXT, + authorized_at TIMESTAMPTZ, + closed_by TEXT, + notes TEXT, + evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (ended_at IS NULL OR ended_at > started_at), + CHECK ( + authorization_status NOT IN ('AUTHORIZED', 'AUTO_CONFIRMED') + OR authorized_at IS NOT NULL + ), + CHECK ( + status <> 'CLOSED' + OR ended_at IS NOT NULL + ) +); + +COMMENT ON TABLE mv_loss_intelligence.production_context_sessions IS +'Contextos productivos explícitos. Durante la migración permite registrar producción legacy autorizada sin fingir que existe una orden Odoo.'; + +CREATE INDEX IF NOT EXISTS idx_li_context_sessions_machine_time +ON mv_loss_intelligence.production_context_sessions +(tenant, site, machine_id, started_at DESC, ended_at); + +CREATE INDEX IF NOT EXISTS idx_li_context_sessions_status +ON mv_loss_intelligence.production_context_sessions +(tenant, site, status, authorization_status, source_type); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_li_context_sessions_one_open_explicit +ON mv_loss_intelligence.production_context_sessions +(tenant, site, machine_id) +WHERE status = 'OPEN' + AND source_type IN ( + 'LEGACY_ERP', + 'MANUAL_AUTHORIZED', + 'MAINTENANCE', + 'TEST_SETUP' + ); + +CREATE TABLE IF NOT EXISTS mv_loss_intelligence.production_context_reviews ( + review_id BIGSERIAL PRIMARY KEY, + tenant TEXT NOT NULL, + site TEXT NOT NULL, + activity_source_key TEXT NOT NULL, + machine_id TEXT NOT NULL, + activity_started_at TIMESTAMPTZ NOT NULL, + activity_ended_at TIMESTAMPTZ NOT NULL, + decision_type TEXT NOT NULL + CHECK (decision_type IN ( + 'CONFIRM_LEGACY', + 'CONFIRM_MANUAL', + 'CONFIRM_MAINTENANCE', + 'CONFIRM_TEST_SETUP', + 'ODOO_PROCESS_GAP', + 'UNKNOWN_CONTEXT', + 'IGNORE' + )), + created_context_session_id BIGINT + REFERENCES mv_loss_intelligence.production_context_sessions(context_session_id), + reviewed_by TEXT NOT NULL, + reviewed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + notes TEXT, + evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb, + UNIQUE (tenant, site, activity_source_key) +); + +COMMENT ON TABLE mv_loss_intelligence.production_context_reviews IS +'Decisiones retrospectivas sobre bloques de actividad física que inicialmente no tenían contexto Odoo ni legacy registrado.'; + +ALTER TABLE mv_loss_intelligence.loss_ledger + ADD COLUMN IF NOT EXISTS production_context_session_id BIGINT + REFERENCES mv_loss_intelligence.production_context_sessions(context_session_id), + ADD COLUMN IF NOT EXISTS production_context_source TEXT, + ADD COLUMN IF NOT EXISTS production_context_confidence TEXT; + +INSERT INTO mv_loss_intelligence.production_context_policies ( + tenant, + site, + policy_code, + activity_gap_s, + activity_tail_s, + stale_open_context_s, + allow_legacy_context, + require_legacy_authorization, + allow_inferred_for_official, + is_active, + notes +) +VALUES ( + 'ucepsa', + 'ucepsa_onpremise', + 'CUTTER_CONTEXT_V1', + 90, + 30, + 43200, + true, + true, + false, + true, + 'La actividad física se separa con huecos >90 s y se prolonga 30 s tras la última muestra activa. Legacy es válido solo si está autorizado. La inferencia nunca alimenta el Ledger oficial.' +) +ON CONFLICT (tenant, site, policy_code) +DO UPDATE SET + activity_gap_s = EXCLUDED.activity_gap_s, + activity_tail_s = EXCLUDED.activity_tail_s, + stale_open_context_s = EXCLUDED.stale_open_context_s, + allow_legacy_context = EXCLUDED.allow_legacy_context, + require_legacy_authorization = EXCLUDED.require_legacy_authorization, + allow_inferred_for_official = EXCLUDED.allow_inferred_for_official, + is_active = true, + notes = EXCLUDED.notes, + updated_at = now(); + +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 mv_loss_intelligence.production_context_policies + TO grafana_ucepsa_ro; + GRANT SELECT ON mv_loss_intelligence.production_context_sessions + TO grafana_ucepsa_ro; + GRANT SELECT ON mv_loss_intelligence.production_context_reviews + TO grafana_ucepsa_ro; + END IF; +END $$; + +COMMIT; diff --git a/ucepsa/edge-oee-demo/sql/versions/100_ucepsa_production_context_views_v3.sql b/ucepsa/edge-oee-demo/sql/versions/100_ucepsa_production_context_views_v3.sql new file mode 100644 index 0000000..d64e949 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/100_ucepsa_production_context_views_v3.sql @@ -0,0 +1,705 @@ +BEGIN; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_machine_running_activity_v1 AS +WITH policy AS ( + SELECT + activity_gap_s::double precision AS activity_gap_s, + activity_tail_s::double precision AS activity_tail_s + FROM mv_loss_intelligence.production_context_policies + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = 'CUTTER_CONTEXT_V1' + AND is_active = true + ORDER BY id DESC + LIMIT 1 +), active_samples AS ( + SELECT + r.tenant, + r.site, + r.machine_id, + r.asset, + r.ingest_ts, + CASE + WHEN lower(COALESCE( + r.payload_json ->> 'machine_running', + 'false' + )) IN ('true', 't', '1', 'yes') + THEN true + ELSE false + END AS machine_running, + COALESCE( + NULLIF(r.payload_json ->> 'cycle_rate_ppm', '')::numeric, + 0 + ) AS cycle_rate_ppm, + p.activity_gap_s, + p.activity_tail_s, + lag(r.ingest_ts) OVER ( + PARTITION BY r.tenant, r.site, r.machine_id + ORDER BY r.ingest_ts + ) AS previous_active_ts + FROM mv_hot.edge_oee_eastron_readings r + CROSS JOIN policy p + WHERE r.tenant = 'ucepsa' + AND r.site = 'ucepsa_onpremise' + AND r.machine_id IS NOT NULL + AND r.comm_ok = true + AND lower(COALESCE( + r.payload_json ->> 'sample_valid', + 'true' + )) NOT IN ('false', 'f', '0', 'no') + AND ( + lower(COALESCE( + r.payload_json ->> 'machine_running', + 'false' + )) IN ('true', 't', '1', 'yes') + OR COALESCE( + NULLIF(r.payload_json ->> 'cycle_rate_ppm', '')::numeric, + 0 + ) > 0 + ) +), marked AS ( + SELECT + s.*, + CASE + WHEN previous_active_ts IS NULL THEN 1 + WHEN ingest_ts - previous_active_ts + > make_interval(secs => activity_gap_s) THEN 1 + ELSE 0 + END AS starts_new_activity + FROM active_samples s +), islanded AS ( + SELECT + m.*, + sum(starts_new_activity) OVER ( + PARTITION BY tenant, site, machine_id + ORDER BY ingest_ts + ROWS UNBOUNDED PRECEDING + ) AS activity_island_id + FROM marked m +) +SELECT + tenant, + site, + machine_id, + max(asset) AS asset, + activity_island_id, + 'ACTIVITY:' || machine_id || ':' || activity_island_id::text + AS activity_source_key, + min(ingest_ts) AS activity_from, + max(ingest_ts) + + make_interval(secs => max(activity_tail_s)) AS activity_to, + count(*) AS sample_count, + round( + EXTRACT(EPOCH FROM ( + max(ingest_ts) + + make_interval(secs => max(activity_tail_s)) + - min(ingest_ts) + ))::numeric, + 2 + ) AS activity_duration_s, + max(activity_gap_s)::numeric(12,2) AS activity_gap_s, + max(activity_tail_s)::numeric(12,2) AS activity_tail_s +FROM islanded +GROUP BY tenant, site, machine_id, activity_island_id; + +COMMENT ON VIEW mv_reports_ucepsa_prod.li_machine_running_activity_v1 IS +'Bloques de actividad física real: marcha o ciclos, separados por huecos superiores a 90 segundos. No presupone que exista Odoo.'; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_production_context_intervals_v1 AS +WITH explicit_contexts AS ( + SELECT + s.tenant, + s.site, + s.machine_id, + 'SESSION:' || s.context_session_id::text AS context_key, + s.context_session_id, + s.source_type AS context_source, + CASE s.source_type + WHEN 'ODOO_SHOPFLOOR' THEN 5 + WHEN 'LEGACY_ERP' THEN 20 + WHEN 'MANUAL_AUTHORIZED' THEN 30 + WHEN 'MAINTENANCE' THEN 40 + WHEN 'TEST_SETUP' THEN 50 + ELSE 90 + END AS context_priority, + s.started_at AS context_from, + COALESCE(s.ended_at, now()) AS context_to, + s.external_order_ref AS order_ref, + s.odoo_production_id, + s.odoo_workorder_id, + s.product_id, + s.product_default_code, + s.product_name, + s.operator_employee_id, + s.operator_name, + s.confidence, + true AS context_confirmed, + s.official_eligible AS official_loss_eligible, + s.authorization_status, + s.status, + s.notes, + s.evidence_json + FROM mv_loss_intelligence.production_context_sessions s + WHERE s.tenant = 'ucepsa' + AND s.site = 'ucepsa_onpremise' + AND s.status IN ('OPEN', 'CLOSED') + AND s.authorization_status IN ('AUTHORIZED', 'AUTO_CONFIRMED') + AND COALESCE(s.ended_at, now()) > s.started_at +), odoo_contexts AS ( + SELECT + o.tenant, + o.site, + o.machine_id, + 'ODOO:' || o.machine_id || ':' || o.id::text AS context_key, + NULL::bigint AS context_session_id, + 'ODOO_ORDER_CONTEXT'::text AS context_source, + 10 AS context_priority, + o.effective_from AS context_from, + COALESCE(o.effective_to, now()) AS context_to, + o.production_order AS order_ref, + o.production_id AS odoo_production_id, + NULLIF(o.workorder_id, '')::bigint AS odoo_workorder_id, + o.product_id, + NULL::text AS product_default_code, + o.product AS product_name, + NULL::bigint AS operator_employee_id, + NULL::text AS operator_name, + 'HIGH'::text AS confidence, + true AS context_confirmed, + false AS official_loss_eligible, + 'AUTO_CONFIRMED'::text AS authorization_status, + CASE + WHEN o.effective_to IS NULL THEN 'OPEN' + ELSE 'CLOSED' + END AS status, + 'Contexto de orden Odoo válido. Aún no equivale a sesión oficial de operaria.'::text + AS notes, + jsonb_build_object( + 'order_state_event_id', o.id, + 'odoo_state', o.odoo_state, + 'workorder_state', o.workorder_state, + 'qty_production', o.qty_production + ) AS evidence_json + FROM mv_reports_ucepsa_prod.li_order_state_intervals_v1 o + WHERE o.tenant = 'ucepsa' + AND o.site = 'ucepsa_onpremise' + AND o.order_present = true + AND o.order_coherent = true + AND COALESCE(o.effective_to, now()) > o.effective_from +) +SELECT * FROM explicit_contexts +UNION ALL +SELECT * FROM odoo_contexts; + +COMMENT ON VIEW mv_reports_ucepsa_prod.li_production_context_intervals_v1 IS +'Contextos reconocidos para la migración: orden Odoo válida y sesiones explícitas legacy/manual/mantenimiento/prueba. Odoo order context todavía no se considera sesión oficial de operaria.'; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_running_activity_context_segments_v1 AS +WITH activities AS ( + SELECT * + FROM mv_reports_ucepsa_prod.li_machine_running_activity_v1 +), overlaps AS ( + SELECT + a.tenant, + a.site, + a.machine_id, + a.asset, + a.activity_island_id, + a.activity_source_key, + a.activity_from, + a.activity_to, + a.activity_duration_s, + c.context_key, + c.context_session_id, + c.context_source, + c.context_priority, + c.context_from, + c.context_to, + c.order_ref, + c.odoo_production_id, + c.odoo_workorder_id, + c.product_id, + c.product_default_code, + c.product_name, + c.operator_employee_id, + c.operator_name, + c.confidence, + c.context_confirmed, + c.official_loss_eligible, + c.authorization_status, + c.status, + c.notes, + c.evidence_json, + greatest(a.activity_from, c.context_from) AS overlap_from, + least(a.activity_to, c.context_to) AS overlap_to + FROM activities a + JOIN mv_reports_ucepsa_prod.li_production_context_intervals_v1 c + ON c.tenant = a.tenant + AND c.site = a.site + AND c.machine_id = a.machine_id + AND a.activity_to > c.context_from + AND a.activity_from < c.context_to + WHERE least(a.activity_to, c.context_to) + > greatest(a.activity_from, c.context_from) +), boundaries AS ( + SELECT + a.tenant, + a.site, + a.machine_id, + a.asset, + a.activity_island_id, + a.activity_source_key, + a.activity_from, + a.activity_to, + a.activity_duration_s, + a.activity_from AS boundary_at + FROM activities a + UNION + SELECT + a.tenant, + a.site, + a.machine_id, + a.asset, + a.activity_island_id, + a.activity_source_key, + a.activity_from, + a.activity_to, + a.activity_duration_s, + a.activity_to AS boundary_at + FROM activities a + UNION + SELECT + o.tenant, + o.site, + o.machine_id, + o.asset, + o.activity_island_id, + o.activity_source_key, + o.activity_from, + o.activity_to, + o.activity_duration_s, + o.overlap_from AS boundary_at + FROM overlaps o + UNION + SELECT + o.tenant, + o.site, + o.machine_id, + o.asset, + o.activity_island_id, + o.activity_source_key, + o.activity_from, + o.activity_to, + o.activity_duration_s, + o.overlap_to AS boundary_at + FROM overlaps o +), distinct_boundaries AS ( + SELECT DISTINCT + tenant, + site, + machine_id, + asset, + activity_island_id, + activity_source_key, + activity_from, + activity_to, + activity_duration_s, + boundary_at + FROM boundaries +), raw_segments AS ( + SELECT + b.*, + lead(boundary_at) OVER ( + PARTITION BY + tenant, + site, + machine_id, + activity_island_id + ORDER BY boundary_at + ) AS next_boundary_at + FROM distinct_boundaries b +), segments AS ( + SELECT + tenant, + site, + machine_id, + asset, + activity_island_id, + activity_source_key, + activity_from, + activity_to, + activity_duration_s, + boundary_at AS segment_from, + next_boundary_at AS segment_to + FROM raw_segments + WHERE next_boundary_at IS NOT NULL + AND next_boundary_at > boundary_at +), segment_context_rows AS ( + SELECT + s.*, + o.context_key, + o.context_session_id, + o.context_source, + o.context_priority, + o.order_ref, + o.odoo_production_id, + o.odoo_workorder_id, + o.product_id, + o.product_default_code, + o.product_name, + o.operator_employee_id, + o.operator_name, + o.confidence, + o.context_confirmed, + o.official_loss_eligible, + o.authorization_status, + o.status, + o.notes, + o.evidence_json, + row_number() OVER ( + PARTITION BY + s.tenant, + s.site, + s.machine_id, + s.activity_island_id, + s.segment_from, + s.segment_to + ORDER BY + o.context_priority NULLS LAST, + o.context_key NULLS LAST + ) AS context_rank + FROM segments s + LEFT JOIN overlaps o + ON o.tenant = s.tenant + AND o.site = s.site + AND o.machine_id = s.machine_id + AND o.activity_island_id = s.activity_island_id + AND s.segment_to > o.overlap_from + AND s.segment_from < o.overlap_to +), aggregated AS ( + SELECT + tenant, + site, + machine_id, + max(asset) AS asset, + activity_island_id, + max(activity_source_key) AS activity_source_key, + max(activity_from) AS activity_from, + max(activity_to) AS activity_to, + max(activity_duration_s) AS activity_duration_s, + segment_from, + segment_to, + count(DISTINCT context_key) AS context_count, + max(context_key) FILTER (WHERE context_rank = 1) AS primary_context_key, + max(context_session_id) FILTER (WHERE context_rank = 1) + AS primary_context_session_id, + max(context_source) FILTER (WHERE context_rank = 1) + AS primary_context_source, + max(order_ref) FILTER (WHERE context_rank = 1) AS order_ref, + max(odoo_production_id) FILTER (WHERE context_rank = 1) + AS odoo_production_id, + max(odoo_workorder_id) FILTER (WHERE context_rank = 1) + AS odoo_workorder_id, + max(product_id) FILTER (WHERE context_rank = 1) AS product_id, + max(product_default_code) FILTER (WHERE context_rank = 1) + AS product_default_code, + max(product_name) FILTER (WHERE context_rank = 1) AS product_name, + max(operator_employee_id) FILTER (WHERE context_rank = 1) + AS operator_employee_id, + max(operator_name) FILTER (WHERE context_rank = 1) AS operator_name, + max(confidence) FILTER (WHERE context_rank = 1) AS confidence, + bool_or(context_confirmed) FILTER (WHERE context_key IS NOT NULL) + AS any_context_confirmed, + bool_or(official_loss_eligible) FILTER (WHERE context_key IS NOT NULL) + AS any_official_loss_eligible, + array_remove( + array_agg(context_source ORDER BY context_priority, context_key), + NULL + ) AS context_sources + FROM segment_context_rows + GROUP BY + tenant, + site, + machine_id, + activity_island_id, + segment_from, + segment_to +) +SELECT + tenant, + site, + machine_id, + asset, + activity_island_id, + activity_source_key, + activity_from, + activity_to, + activity_duration_s, + segment_from, + segment_to, + EXTRACT(EPOCH FROM (segment_to - segment_from))::numeric + AS segment_duration_s, + context_count, + primary_context_key, + primary_context_session_id, + primary_context_source, + order_ref, + odoo_production_id, + odoo_workorder_id, + product_id, + product_default_code, + product_name, + operator_employee_id, + operator_name, + confidence, + COALESCE(any_context_confirmed, false) AS context_confirmed, + CASE + WHEN context_count = 1 + THEN COALESCE(any_official_loss_eligible, false) + ELSE false + END AS official_loss_eligible, + context_sources, + CASE + WHEN context_count = 0 THEN 'UNCONFIRMED_CONTEXT' + WHEN context_count > 1 THEN 'CONTEXT_CONFLICT' + WHEN primary_context_source = 'ODOO_ORDER_CONTEXT' + THEN 'ODOO_CONTEXT' + WHEN primary_context_source = 'LEGACY_ERP' + THEN 'LEGACY_AUTHORIZED' + WHEN primary_context_source = 'MANUAL_AUTHORIZED' + THEN 'MANUAL_AUTHORIZED' + WHEN primary_context_source = 'MAINTENANCE' + THEN 'MAINTENANCE' + WHEN primary_context_source = 'TEST_SETUP' + THEN 'TEST_SETUP' + WHEN primary_context_source = 'ODOO_SHOPFLOOR' + THEN 'ODOO_SHOPFLOOR' + ELSE 'INFERRED_SHADOW' + END AS context_status, + CASE + WHEN context_count = 0 + THEN 'Confirmar si era legacy autorizada, prueba, mantenimiento o hueco de proceso Odoo.' + WHEN context_count > 1 + THEN 'Hay más de un contexto simultáneo. Revisar antes de usar económicamente.' + WHEN primary_context_source = 'ODOO_ORDER_CONTEXT' + THEN 'Orden Odoo válida; falta todavía la sesión oficial de operaria.' + ELSE 'Contexto explícito reconocido.' + END AS context_reason +FROM aggregated; + +COMMENT ON VIEW mv_reports_ucepsa_prod.li_running_activity_context_segments_v1 IS +'Segmenta toda actividad física por contexto. Sin contexto queda como bloque revisable, no como pérdida económica.'; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_context_review_queue_v1 AS +SELECT + row_number() OVER ( + ORDER BY + segment_duration_s DESC, + segment_from DESC + ) AS review_rank, + activity_source_key + || ':' + || to_char( + segment_from AT TIME ZONE 'Europe/Madrid', + 'YYYYMMDDHH24MISS' + ) AS context_review_key, + machine_id, + segment_from AS started_at, + segment_to AS ended_at, + round((segment_duration_s / 60.0)::numeric, 2) AS duration_min, + context_status, + context_reason, + ARRAY[ + 'LEGACY_AUTHORIZED', + 'TEST_SETUP', + 'MAINTENANCE', + 'ODOO_PROCESS_GAP', + 'UNKNOWN_CONTEXT' + ]::text[] AS allowed_decisions, + false AS official_loss_eligible +FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1 +WHERE context_status IN ('UNCONFIRMED_CONTEXT', 'CONTEXT_CONFLICT') + AND segment_duration_s >= 60 +ORDER BY review_rank; + +COMMENT ON VIEW mv_reports_ucepsa_prod.li_context_review_queue_v1 IS +'Una sola fila por bloque físico sin contexto suficiente. Se revisa en producción sin clasificar ciclos o paros individualmente.'; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_production_context_coverage_v1 AS +WITH recent AS ( + SELECT * + FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1 + WHERE segment_to >= now() - interval '30 days' +) +SELECT + machine_id, + context_status, + count(*) AS segment_count, + round(sum(segment_duration_s / 60.0)::numeric, 2) AS minutes, + round(sum(segment_duration_s / 3600.0)::numeric, 2) AS hours, + round(( + 100.0 + * sum(segment_duration_s) + / NULLIF( + sum(sum(segment_duration_s)) OVER ( + PARTITION BY machine_id + ), + 0 + ) + )::numeric, 2) AS machine_activity_pct +FROM recent +GROUP BY machine_id, context_status +ORDER BY machine_id, hours DESC; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.machine_migration_context_state_v1 AS +WITH current_manual AS ( + SELECT DISTINCT ON (s.machine_id) + s.machine_id, + s.context_session_id, + s.source_type, + s.external_order_ref, + s.product_name, + s.operator_employee_id, + s.operator_name, + s.started_at, + s.confidence, + s.official_eligible + FROM mv_loss_intelligence.production_context_sessions s + WHERE s.tenant = 'ucepsa' + AND s.site = 'ucepsa_onpremise' + AND s.status = 'OPEN' + AND s.authorization_status IN ('AUTHORIZED', 'AUTO_CONFIRMED') + AND now() >= s.started_at + ORDER BY + s.machine_id, + CASE s.source_type + WHEN 'LEGACY_ERP' THEN 1 + WHEN 'MANUAL_AUTHORIZED' THEN 2 + WHEN 'MAINTENANCE' THEN 3 + WHEN 'TEST_SETUP' THEN 4 + ELSE 9 + END, + s.started_at DESC +) +SELECT + m.*, + c.context_session_id, + c.source_type AS explicit_context_source, + c.external_order_ref AS explicit_order_ref, + c.product_name AS explicit_product_name, + c.operator_employee_id AS explicit_operator_employee_id, + c.operator_name AS explicit_operator_name, + c.started_at AS explicit_context_started_at, + CASE + WHEN m.operational_state = 'RUNNING_WITH_ORDER' + THEN 'RUNNING_ODOO_CONTEXT' + WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER' + AND c.source_type = 'LEGACY_ERP' + THEN 'RUNNING_LEGACY_AUTHORIZED' + WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER' + AND c.source_type = 'MANUAL_AUTHORIZED' + THEN 'RUNNING_MANUAL_AUTHORIZED' + WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER' + AND c.source_type = 'MAINTENANCE' + THEN 'RUNNING_MAINTENANCE' + WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER' + AND c.source_type = 'TEST_SETUP' + THEN 'RUNNING_TEST_SETUP' + WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER' + THEN 'RUNNING_UNCONFIRMED_CONTEXT' + ELSE m.operational_state + END AS migration_context_state, + CASE + WHEN m.operational_state = 'RUNNING_WITH_ORDER' + THEN 'ODOO' + WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER' + AND c.source_type = 'LEGACY_ERP' + THEN 'LEGACY AUTORIZADA' + WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER' + AND c.source_type = 'MANUAL_AUTHORIZED' + THEN 'PRODUCCIÓN AUTORIZADA' + WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER' + AND c.source_type = 'MAINTENANCE' + THEN 'MANTENIMIENTO' + WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER' + AND c.source_type = 'TEST_SETUP' + THEN 'PRUEBA / AJUSTE' + WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER' + THEN 'CONTEXTO PENDIENTE' + ELSE m.machine_state_label + END AS migration_context_label +FROM mv_reports_ucepsa_prod.machine_operational_state_v1 m +LEFT JOIN current_manual c ON c.machine_id = m.machine_id; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_data_quality_v3 AS +WITH base AS ( + SELECT * FROM mv_reports_ucepsa_prod.li_data_quality_v2 +), unconfirmed AS ( + SELECT count(*) AS n + FROM mv_reports_ucepsa_prod.li_context_review_queue_v1 + WHERE ended_at >= now() - interval '24 hours' +), conflicts AS ( + SELECT count(*) AS n + FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1 + WHERE context_status = 'CONTEXT_CONFLICT' + AND segment_to >= now() - interval '24 hours' +), stale_manual AS ( + SELECT count(*) AS n + FROM mv_loss_intelligence.production_context_sessions s + JOIN mv_loss_intelligence.production_context_policies p + ON p.tenant = s.tenant + AND p.site = s.site + AND p.policy_code = 'CUTTER_CONTEXT_V1' + AND p.is_active = true + WHERE s.tenant = 'ucepsa' + AND s.site = 'ucepsa_onpremise' + AND s.status = 'OPEN' + AND now() - s.started_at + > make_interval(secs => p.stale_open_context_s::double precision) +) +SELECT * FROM base +UNION ALL +SELECT + 'UNCONFIRMED_PRODUCTION_CONTEXT', + CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END, + n, + 'Bloques de actividad de las últimas 24 h sin Odoo ni contexto legacy/manual confirmado.' +FROM unconfirmed +UNION ALL +SELECT + 'CONTEXT_CONFLICT', + CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END, + n, + 'Segmentos de las últimas 24 h cubiertos por más de un contexto simultáneo.' +FROM conflicts +UNION ALL +SELECT + 'STALE_OPEN_PRODUCTION_CONTEXT', + CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END, + n, + 'Contextos manuales/legacy abiertos más tiempo que el umbral permitido.' +FROM stale_manual; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro' + ) THEN + GRANT SELECT ON mv_reports_ucepsa_prod.li_machine_running_activity_v1 + TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_production_context_intervals_v1 + TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_running_activity_context_segments_v1 + TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_context_review_queue_v1 + TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_production_context_coverage_v1 + TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.machine_migration_context_state_v1 + TO grafana_ucepsa_ro; + GRANT SELECT ON mv_reports_ucepsa_prod.li_data_quality_v3 + TO grafana_ucepsa_ro; + END IF; +END $$; + +COMMIT; diff --git a/ucepsa/edge-oee-demo/tools/li_context_admin.py b/ucepsa/edge-oee-demo/tools/li_context_admin.py new file mode 100644 index 0000000..9d8f1f6 --- /dev/null +++ b/ucepsa/edge-oee-demo/tools/li_context_admin.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +""" +MESAVAULT LossIntelligence — Production Context Admin v0.3 + +Herramienta controlada para abrir, cerrar, crear retrospectivamente y cancelar +contextos explícitos de producción. + +Seguridad: +- en modo SHADOW fuerza official_eligible=false; +- no modifica Odoo, WISE, RevPi ni balizas; +- no almacena PINs; +- usa la conexión PostgreSQL ya disponible en el entorno UCEPSA. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +import psycopg2 +import psycopg2.extras + + +ALLOWED_SOURCES = ( + "LEGACY_ERP", + "MANUAL_AUTHORIZED", + "MAINTENANCE", + "TEST_SETUP", +) + +MACHINES = ("CORT-00", "CORT-01", "CORT-02") + + +def env(name: str, default: Optional[str] = None) -> str: + value = os.getenv(name, default) + if value is None or value == "": + raise RuntimeError(f"Falta variable de entorno {name}") + return value + + +def connect(): + return psycopg2.connect( + host=env("PGHOST", "mv_ucepsa_postgres_hot"), + port=int(env("PGPORT", "5432")), + dbname=env("PGDATABASE"), + user=env("PGUSER"), + password=env("PGPASSWORD"), + ) + + +def parse_ts(value: Optional[str]) -> Optional[datetime]: + if value is None: + return None + if value.lower() == "now": + return datetime.now(timezone.utc) + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None: + raise ValueError( + "La fecha debe incluir zona horaria, por ejemplo 2026-07-17T10:30:00+02:00" + ) + return parsed + + +def deployment_mode(cur) -> str: + cur.execute( + """ + SELECT mode + FROM mv_loss_intelligence.deployment_config + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND scope_code = 'CUTTERS' + ORDER BY id DESC + LIMIT 1 + """ + ) + row = cur.fetchone() + if not row: + raise RuntimeError("No existe deployment_config CUTTERS") + return str(row[0]) + + +def print_json(payload: Any) -> None: + print(json.dumps(payload, indent=2, ensure_ascii=False, default=str)) + + +def open_context(args) -> None: + started_at = parse_ts(args.started_at) or datetime.now(timezone.utc) + with connect() as conn: + with conn.cursor( + cursor_factory=psycopg2.extras.RealDictCursor + ) as cur: + mode = deployment_mode(cur) + official = bool(args.official_eligible and mode == "ACTIVE") + if args.official_eligible and mode != "ACTIVE": + print( + "[WARN] El despliegue está en SHADOW: official_eligible se fuerza a false.", + file=sys.stderr, + ) + + cur.execute( + """ + INSERT INTO mv_loss_intelligence.production_context_sessions ( + tenant, + site, + machine_id, + source_type, + source_ref, + external_order_ref, + product_id, + product_default_code, + product_name, + operator_employee_id, + operator_name, + started_at, + status, + authorization_status, + confidence, + official_eligible, + authorized_by, + authorized_at, + notes, + evidence_json + ) + VALUES ( + 'ucepsa', + 'ucepsa_onpremise', + %(machine_id)s, + %(source_type)s, + %(source_ref)s, + %(external_order_ref)s, + %(product_id)s, + %(product_default_code)s, + %(product_name)s, + %(operator_employee_id)s, + %(operator_name)s, + %(started_at)s, + 'OPEN', + 'AUTHORIZED', + %(confidence)s, + %(official_eligible)s, + %(authorized_by)s, + now(), + %(notes)s, + %(evidence_json)s + ) + RETURNING * + """, + { + "machine_id": args.machine, + "source_type": args.source, + "source_ref": args.source_ref, + "external_order_ref": args.external_order_ref, + "product_id": args.product_id, + "product_default_code": args.product_code, + "product_name": args.product_name, + "operator_employee_id": args.operator_employee_id, + "operator_name": args.operator_name, + "started_at": started_at, + "confidence": args.confidence, + "official_eligible": official, + "authorized_by": args.authorized_by, + "notes": args.notes, + "evidence_json": psycopg2.extras.Json({ + "created_with": "li_context_admin.py", + "deployment_mode": mode, + }), + }, + ) + print_json(cur.fetchone()) + + +def close_context(args) -> None: + ended_at = parse_ts(args.ended_at) or datetime.now(timezone.utc) + with connect() as conn: + with conn.cursor( + cursor_factory=psycopg2.extras.RealDictCursor + ) as cur: + cur.execute( + """ + UPDATE mv_loss_intelligence.production_context_sessions + SET + ended_at = %(ended_at)s, + status = 'CLOSED', + closed_by = %(closed_by)s, + notes = concat_ws( + E'\n', + notes, + %(notes)s + ), + updated_at = now() + WHERE context_session_id = %(context_session_id)s + AND status = 'OPEN' + AND ended_at IS NULL + AND %(ended_at)s > started_at + RETURNING * + """, + { + "context_session_id": args.session_id, + "ended_at": ended_at, + "closed_by": args.closed_by, + "notes": args.notes, + }, + ) + row = cur.fetchone() + if not row: + raise RuntimeError( + "No se ha cerrado ninguna sesión. Comprueba ID, estado y fechas." + ) + print_json(row) + + +def create_retrospective(args) -> None: + started_at = parse_ts(args.started_at) + ended_at = parse_ts(args.ended_at) + if not started_at or not ended_at or ended_at <= started_at: + raise ValueError("El intervalo retrospectivo debe tener inicio y fin válidos.") + + with connect() as conn: + with conn.cursor( + cursor_factory=psycopg2.extras.RealDictCursor + ) as cur: + mode = deployment_mode(cur) + official = bool(args.official_eligible and mode == "ACTIVE") + if args.official_eligible and mode != "ACTIVE": + print( + "[WARN] El despliegue está en SHADOW: official_eligible se fuerza a false.", + file=sys.stderr, + ) + + cur.execute( + """ + INSERT INTO mv_loss_intelligence.production_context_sessions ( + tenant, + site, + machine_id, + source_type, + source_ref, + external_order_ref, + product_id, + product_default_code, + product_name, + operator_employee_id, + operator_name, + started_at, + ended_at, + status, + authorization_status, + confidence, + official_eligible, + authorized_by, + authorized_at, + closed_by, + notes, + evidence_json + ) + VALUES ( + 'ucepsa', + 'ucepsa_onpremise', + %(machine_id)s, + %(source_type)s, + %(source_ref)s, + %(external_order_ref)s, + %(product_id)s, + %(product_default_code)s, + %(product_name)s, + %(operator_employee_id)s, + %(operator_name)s, + %(started_at)s, + %(ended_at)s, + 'CLOSED', + 'AUTHORIZED', + %(confidence)s, + %(official_eligible)s, + %(authorized_by)s, + now(), + %(closed_by)s, + %(notes)s, + %(evidence_json)s + ) + RETURNING * + """, + { + "machine_id": args.machine, + "source_type": args.source, + "source_ref": args.source_ref, + "external_order_ref": args.external_order_ref, + "product_id": args.product_id, + "product_default_code": args.product_code, + "product_name": args.product_name, + "operator_employee_id": args.operator_employee_id, + "operator_name": args.operator_name, + "started_at": started_at, + "ended_at": ended_at, + "confidence": args.confidence, + "official_eligible": official, + "authorized_by": args.authorized_by, + "closed_by": args.closed_by, + "notes": args.notes, + "evidence_json": psycopg2.extras.Json({ + "created_with": "li_context_admin.py", + "deployment_mode": mode, + "retrospective": True, + }), + }, + ) + print_json(cur.fetchone()) + + +def cancel_context(args) -> None: + with connect() as conn: + with conn.cursor( + cursor_factory=psycopg2.extras.RealDictCursor + ) as cur: + cur.execute( + """ + UPDATE mv_loss_intelligence.production_context_sessions + SET + status = 'CANCELLED', + ended_at = COALESCE(ended_at, now()), + closed_by = %(closed_by)s, + notes = concat_ws(E'\n', notes, %(notes)s), + updated_at = now() + WHERE context_session_id = %(context_session_id)s + AND status <> 'CANCELLED' + RETURNING * + """, + { + "context_session_id": args.session_id, + "closed_by": args.closed_by, + "notes": args.notes, + }, + ) + row = cur.fetchone() + if not row: + raise RuntimeError("No se ha cancelado ninguna sesión.") + print_json(row) + + +def list_contexts(args) -> None: + with connect() as conn: + with conn.cursor( + cursor_factory=psycopg2.extras.RealDictCursor + ) as cur: + where = [ + "tenant = 'ucepsa'", + "site = 'ucepsa_onpremise'", + ] + params: Dict[str, Any] = {"limit": args.limit} + if args.machine: + where.append("machine_id = %(machine_id)s") + params["machine_id"] = args.machine + if args.open_only: + where.append("status = 'OPEN'") + + cur.execute( + f""" + SELECT * + FROM mv_loss_intelligence.production_context_sessions + WHERE {' AND '.join(where)} + ORDER BY context_session_id DESC + LIMIT %(limit)s + """, + params, + ) + print_json(cur.fetchall()) + + +def add_common_context_args(parser) -> None: + parser.add_argument("--machine", required=True, choices=MACHINES) + parser.add_argument("--source", required=True, choices=ALLOWED_SOURCES) + parser.add_argument("--source-ref") + parser.add_argument("--external-order-ref") + parser.add_argument("--product-id", type=int) + parser.add_argument("--product-code") + parser.add_argument("--product-name") + parser.add_argument("--operator-employee-id", type=int) + parser.add_argument("--operator-name") + parser.add_argument( + "--confidence", + choices=("HIGH", "MEDIUM", "LOW"), + default="MEDIUM", + ) + parser.add_argument("--authorized-by", required=True) + parser.add_argument("--notes") + parser.add_argument("--official-eligible", action="store_true") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Administración controlada de contextos productivos" + ) + sub = parser.add_subparsers(dest="command", required=True) + + p_open = sub.add_parser("open") + add_common_context_args(p_open) + p_open.add_argument("--started-at", default="now") + p_open.set_defaults(func=open_context) + + p_close = sub.add_parser("close") + p_close.add_argument("--session-id", type=int, required=True) + p_close.add_argument("--ended-at", default="now") + p_close.add_argument("--closed-by", required=True) + p_close.add_argument("--notes") + p_close.set_defaults(func=close_context) + + p_create = sub.add_parser("create-retrospective") + add_common_context_args(p_create) + p_create.add_argument("--started-at", required=True) + p_create.add_argument("--ended-at", required=True) + p_create.add_argument("--closed-by", required=True) + p_create.set_defaults(func=create_retrospective) + + p_cancel = sub.add_parser("cancel") + p_cancel.add_argument("--session-id", type=int, required=True) + p_cancel.add_argument("--closed-by", required=True) + p_cancel.add_argument("--notes") + p_cancel.set_defaults(func=cancel_context) + + p_list = sub.add_parser("list") + p_list.add_argument("--machine", choices=MACHINES) + p_list.add_argument("--open-only", action="store_true") + p_list.add_argument("--limit", type=int, default=30) + p_list.set_defaults(func=list_contexts) + + args = parser.parse_args() + args.func(args) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + raise SystemExit(1)