From e7aa13c43379f0e739be29cebb149925873e3c86 Mon Sep 17 00:00:00 2001 From: Victor Fraile Garcia Date: Fri, 17 Jul 2026 12:46:29 +0200 Subject: [PATCH] feat(ucepsa): add context-aware stop candidates shadow v0.3.1 --- ...gence_context_aware_stops_shadow_v0.3.1.md | 58 + .../ops/validate_context_aware_stops_v031.sql | 193 +++ ...ence_context_aware_stop_candidates_v31.sql | 1130 +++++++++++++++++ 3 files changed, 1381 insertions(+) create mode 100644 ucepsa/edge-oee-demo/docs/runbooks/095_lossintelligence_context_aware_stops_shadow_v0.3.1.md create mode 100644 ucepsa/edge-oee-demo/ops/validate_context_aware_stops_v031.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/102_lossintelligence_context_aware_stop_candidates_v31.sql diff --git a/ucepsa/edge-oee-demo/docs/runbooks/095_lossintelligence_context_aware_stops_shadow_v0.3.1.md b/ucepsa/edge-oee-demo/docs/runbooks/095_lossintelligence_context_aware_stops_shadow_v0.3.1.md new file mode 100644 index 0000000..75b8ba4 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/095_lossintelligence_context_aware_stops_shadow_v0.3.1.md @@ -0,0 +1,58 @@ +# UCEPSA — LossIntelligence Context-Aware Stops SHADOW v0.3.1 + +## Objetivo + +Cruzar los paros físicos de WISE/RevPi con: + +- cobertura válida de telemetría; +- contexto Odoo válido; +- producción legacy autorizada; +- producción manual autorizada. + +## Seguridad + +La migración 102 solo crea vistas paralelas. + +No modifica: + +- `li_stop_candidates_v2`; +- `li_stop_groups_v2`; +- `li_shadow_review_queue_v2`; +- `li_operator_stop_queue_v2`; +- `loss_ledger`; +- Odoo; +- RevPi; +- nodos WISE; +- balizas. + +`official_ledger_eligible` queda forzado a `false`. + +## Vistas nuevas + +- `li_stop_context_slices_v1` +- `li_stop_candidates_context_v1` +- `li_stop_groups_context_v1` +- `li_shadow_review_queue_context_v1` +- `li_shadow_review_backlog_context_v1` +- `li_stop_context_diagnostics_v1` + +## Comportamiento esperado durante la prueba + +CORT-00: + +- sesión legacy 1 abierta; +- un paro abierto se muestra como `OPEN_MONITORING`; +- al cerrar físicamente el paro, se convierte en candidato si supera el ruido. + +CORT-02: + +- `WH/MO/00081`; +- paros de 2–5 segundos deben quedar como ruido; +- paros superiores al umbral deben agruparse o mostrarse individualmente. + +## Cola diaria + +La cola diaria context-aware está limitada a las últimas 24 horas para evitar que +los falsos positivos históricos dominen la revisión. + +El backlog histórico sigue disponible en una vista separada. diff --git a/ucepsa/edge-oee-demo/ops/validate_context_aware_stops_v031.sql b/ucepsa/edge-oee-demo/ops/validate_context_aware_stops_v031.sql new file mode 100644 index 0000000..359dddc --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_context_aware_stops_v031.sql @@ -0,0 +1,193 @@ +\pset pager off + +\echo '================================================' +\echo '1. VISTAS CREADAS' +\echo '================================================' + +SELECT + to_regclass( + 'mv_reports_ucepsa_prod.li_stop_context_slices_v1' + ) AS slices_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_stop_candidates_context_v1' + ) AS candidates_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_stop_groups_context_v1' + ) AS groups_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_shadow_review_queue_context_v1' + ) AS shadow_queue_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_stop_context_diagnostics_v1' + ) AS diagnostics_view; + +\echo '================================================' +\echo '2. INVARIANTES: TODAS DEBEN DEVOLVER 0' +\echo '================================================' + +SELECT COUNT(*) AS invalid_slice_intervals +FROM mv_reports_ucepsa_prod.li_stop_context_slices_v1 +WHERE segment_to <= segment_from + OR segment_duration_s <= 0; + +SELECT COUNT(*) AS candidate_duration_overflow +FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1 +WHERE context_segment_duration_s > raw_duration_s + 0.01 + OR eligible_duration_s > context_segment_duration_s + 0.01 + OR eligible_duration_s < 0; + +SELECT COUNT(*) AS open_stops_marked_eligible +FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1 +WHERE source_status = 'OPEN' + AND technically_eligible = true; + +SELECT COUNT(*) AS official_rows_in_parallel_v1 +FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1 +WHERE official_ledger_eligible = true; + +SELECT COUNT(*) AS official_group_rows_in_parallel_v1 +FROM mv_reports_ucepsa_prod.li_stop_groups_context_v1 +WHERE official_ledger_eligible = true; + +\echo '================================================' +\echo '3. SEGURIDAD SHADOW Y COLA OFICIAL ANTERIOR' +\echo '================================================' + +SELECT + mode, + actual_go_live_at +FROM mv_loss_intelligence.deployment_config +WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND scope_code = 'CUTTERS' +ORDER BY id DESC +LIMIT 1; + +SELECT COUNT(*) AS existing_official_operator_queue_rows +FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v2; + +\echo '================================================' +\echo '4. POLÍTICA DE PAROS' +\echo '================================================' + +SELECT + policy_code, + noise_below_s, + aggregate_below_s, + operator_individual_from_s, + aggregation_window_min, + require_valid_order, + require_closed_event, + require_telemetry_coverage, + telemetry_gap_s, + telemetry_tail_s +FROM mv_loss_intelligence.loss_policies +WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = 'CUTTER_STOPS_V1' + AND is_active = true +ORDER BY id DESC +LIMIT 1; + +\echo '================================================' +\echo '5. DIAGNÓSTICO ÚLTIMAS 24 HORAS' +\echo '================================================' + +SELECT * +FROM mv_reports_ucepsa_prod.li_stop_context_diagnostics_v1; + +\echo '================================================' +\echo '6. CORT-00 — SESIÓN LEGACY 1' +\echo '================================================' + +SELECT + source_stop_id, + source_status, + started_at, + ended_at, + ROUND(eligible_duration_s / 60.0, 2) + AS eligible_duration_min, + context_status, + primary_context_source, + primary_context_session_id, + eligibility_status, + technically_eligible, + shadow_capacity_loss_eur, + official_ledger_eligible +FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1 +WHERE machine_id = 'CORT-00' + AND primary_context_session_id = 1 +ORDER BY source_stop_id, started_at; + +\echo '================================================' +\echo '7. CORT-02 — ORDEN WH/MO/00081' +\echo '================================================' + +SELECT + source_stop_id, + source_status, + started_at, + ended_at, + ROUND(eligible_duration_s, 2) AS eligible_duration_s, + context_status, + primary_context_source, + order_ref, + eligibility_status, + technically_eligible, + shadow_capacity_loss_eur, + official_ledger_eligible +FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1 +WHERE machine_id = 'CORT-02' + AND order_ref = 'WH/MO/00081' +ORDER BY source_stop_id, started_at; + +\echo '================================================' +\echo '8. COLA SHADOW CONTEXT-AWARE ÚLTIMAS 24 HORAS' +\echo '================================================' + +SELECT + shadow_rank, + source_key, + machine_id, + context_status, + primary_context_source, + primary_context_session_id, + order_ref, + event_count, + started_at, + ended_at, + duration_min, + capacity_loss_eur, + preliminary_priority, + queue_status, + official_ledger_eligible, + source_stop_ids +FROM mv_reports_ucepsa_prod.li_shadow_review_queue_context_v1 +ORDER BY shadow_rank +LIMIT 30; + +\echo '================================================' +\echo '9. CONFLICTOS RECIENTES' +\echo '================================================' + +SELECT + source_stop_id, + machine_id, + started_at, + ended_at, + context_count, + context_sources, + eligibility_status +FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1 +WHERE raw_started_at >= now() - interval '24 hours' + AND context_count > 1 +ORDER BY machine_id, started_at; + +\echo '================================================' +\echo '10. CONFIRMACIÓN DE NO MODIFICACIÓN' +\echo '================================================' + +SELECT + 'INFO' AS status, + '102 crea vistas paralelas; no reemplaza v2, no escribe en loss_ledger y no modifica Odoo, WISE, RevPi ni balizas.' + AS description; diff --git a/ucepsa/edge-oee-demo/sql/versions/102_lossintelligence_context_aware_stop_candidates_v31.sql b/ucepsa/edge-oee-demo/sql/versions/102_lossintelligence_context_aware_stop_candidates_v31.sql new file mode 100644 index 0000000..527a241 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/102_lossintelligence_context_aware_stop_candidates_v31.sql @@ -0,0 +1,1130 @@ +BEGIN; + +CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_stop_context_slices_v1 AS +WITH raw_stops AS ( + SELECT + s.id AS source_stop_id, + s.tenant, + s.site, + s.vertical, + s.asset, + s.machine_id, + s.order_id AS source_order_id, + s.started_at AS raw_started_at, + s.ended_at AS raw_ended_at, + COALESCE(s.ended_at, now()) AS raw_effective_end, + EXTRACT( + EPOCH FROM COALESCE(s.ended_at, now()) - s.started_at + )::numeric AS raw_duration_s, + s.status AS source_status, + s.classification_status, + s.cause_code, + s.operator_note, + s.value_hour_eur, + s.start_event_name, + s.end_event_name, + s.start_payload_json, + s.end_payload_json + FROM mv_hot.edge_oee_machine_stops s + WHERE s.tenant = 'ucepsa' + AND s.site = 'ucepsa_onpremise' + AND s.machine_id IS NOT NULL + AND COALESCE(s.ended_at, now()) > s.started_at +), +contexts AS ( + SELECT + c.*, + CASE + WHEN c.context_source IN ( + 'ODOO_SHOPFLOOR', + 'ODOO_ORDER_CONTEXT' + ) + THEN + 'ODOO:' + || c.machine_id + || ':' + || COALESCE( + c.odoo_production_id::text, + c.order_ref, + 'UNKNOWN' + ) + ELSE c.context_key + END AS context_family_key + FROM mv_reports_ucepsa_prod.li_production_context_intervals_v1 c + WHERE c.context_confirmed = true +), +telemetry AS ( + SELECT + t.tenant, + t.site, + t.machine_id, + t.telemetry_island_id, + t.coverage_from, + t.coverage_to + FROM mv_reports_ucepsa_prod.li_telemetry_coverage_v1 t +), +context_edges AS ( + SELECT + s.source_stop_id, + GREATEST(s.raw_started_at, c.context_from) AS boundary_at + FROM raw_stops s + JOIN contexts c + ON c.tenant = s.tenant + AND c.site = s.site + AND c.machine_id = s.machine_id + AND s.raw_effective_end > c.context_from + AND s.raw_started_at < c.context_to + + UNION ALL + + SELECT + s.source_stop_id, + LEAST(s.raw_effective_end, c.context_to) AS boundary_at + FROM raw_stops s + JOIN contexts c + ON c.tenant = s.tenant + AND c.site = s.site + AND c.machine_id = s.machine_id + AND s.raw_effective_end > c.context_from + AND s.raw_started_at < c.context_to +), +telemetry_edges AS ( + SELECT + s.source_stop_id, + GREATEST(s.raw_started_at, t.coverage_from) AS boundary_at + FROM raw_stops s + JOIN telemetry t + ON t.tenant = s.tenant + AND t.site = s.site + AND t.machine_id = s.machine_id + AND s.raw_effective_end > t.coverage_from + AND s.raw_started_at < t.coverage_to + + UNION ALL + + SELECT + s.source_stop_id, + LEAST(s.raw_effective_end, t.coverage_to) AS boundary_at + FROM raw_stops s + JOIN telemetry t + ON t.tenant = s.tenant + AND t.site = s.site + AND t.machine_id = s.machine_id + AND s.raw_effective_end > t.coverage_from + AND s.raw_started_at < t.coverage_to +), +boundaries AS ( + SELECT source_stop_id, raw_started_at AS boundary_at + FROM raw_stops + + UNION ALL + + SELECT source_stop_id, raw_effective_end AS boundary_at + FROM raw_stops + + UNION ALL + + SELECT source_stop_id, boundary_at + FROM context_edges + + UNION ALL + + SELECT source_stop_id, boundary_at + FROM telemetry_edges +), +distinct_boundaries AS ( + SELECT DISTINCT + source_stop_id, + boundary_at + FROM boundaries +), +segmented AS ( + SELECT + source_stop_id, + boundary_at AS segment_from, + LEAD(boundary_at) OVER ( + PARTITION BY source_stop_id + ORDER BY boundary_at + ) AS segment_to + FROM distinct_boundaries +), +segments AS ( + SELECT + source_stop_id, + segment_from, + segment_to + FROM segmented + WHERE segment_to IS NOT NULL + AND segment_to > segment_from +), +context_matches AS ( + SELECT + s.source_stop_id, + s.segment_from, + s.segment_to, + c.context_family_key, + 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 AS context_official_eligible, + c.authorization_status, + c.status AS context_record_status, + c.notes AS context_notes, + c.evidence_json AS context_evidence_json + FROM segments s + JOIN raw_stops r + ON r.source_stop_id = s.source_stop_id + JOIN contexts c + ON c.tenant = r.tenant + AND c.site = r.site + AND c.machine_id = r.machine_id + AND s.segment_to > c.context_from + AND s.segment_from < c.context_to +), +context_ranked AS ( + SELECT + m.*, + ROW_NUMBER() OVER ( + PARTITION BY + m.source_stop_id, + m.segment_from, + m.segment_to + ORDER BY + m.context_priority, + m.context_key + ) AS context_rank + FROM context_matches m +), +context_summary AS ( + SELECT + source_stop_id, + segment_from, + segment_to, + COUNT(DISTINCT context_family_key) AS context_count, + ARRAY_AGG( + context_source + ORDER BY context_priority, context_key + ) AS context_sources, + BOOL_AND(context_confirmed) AS all_contexts_confirmed + FROM context_matches + GROUP BY + source_stop_id, + segment_from, + segment_to +), +telemetry_matches AS ( + SELECT + s.source_stop_id, + s.segment_from, + s.segment_to, + t.telemetry_island_id, + t.coverage_from, + t.coverage_to + FROM segments s + JOIN raw_stops r + ON r.source_stop_id = s.source_stop_id + JOIN telemetry t + ON t.tenant = r.tenant + AND t.site = r.site + AND t.machine_id = r.machine_id + AND s.segment_to > t.coverage_from + AND s.segment_from < t.coverage_to +), +telemetry_summary AS ( + SELECT + source_stop_id, + segment_from, + segment_to, + COUNT(DISTINCT telemetry_island_id) AS telemetry_island_count, + MIN(coverage_from) AS first_telemetry_coverage_from, + MAX(coverage_to) AS last_telemetry_coverage_to + FROM telemetry_matches + GROUP BY + source_stop_id, + segment_from, + segment_to +) +SELECT + r.source_stop_id, + r.tenant, + r.site, + r.vertical, + r.asset, + r.machine_id, + r.source_order_id, + r.raw_started_at, + r.raw_ended_at, + r.raw_effective_end, + r.raw_duration_s, + r.source_status, + r.classification_status, + r.cause_code, + r.operator_note, + r.value_hour_eur, + r.start_event_name, + r.end_event_name, + r.start_payload_json, + r.end_payload_json, + s.segment_from, + s.segment_to, + EXTRACT(EPOCH FROM (s.segment_to - s.segment_from))::numeric + AS segment_duration_s, + COALESCE(cs.context_count, 0) AS context_count, + COALESCE(cs.context_sources, ARRAY[]::text[]) AS context_sources, + COALESCE(cs.all_contexts_confirmed, false) + AS all_contexts_confirmed, + cr.context_family_key AS primary_context_family_key, + cr.context_key AS primary_context_key, + cr.context_session_id AS primary_context_session_id, + cr.context_source AS primary_context_source, + cr.context_from AS primary_context_from, + cr.context_to AS primary_context_to, + cr.order_ref, + cr.odoo_production_id, + cr.odoo_workorder_id, + cr.product_id, + cr.product_default_code, + cr.product_name, + cr.operator_employee_id, + cr.operator_name, + cr.confidence AS context_confidence, + cr.context_official_eligible, + cr.authorization_status, + cr.context_record_status, + cr.context_notes, + cr.context_evidence_json, + COALESCE(ts.telemetry_island_count, 0) + AS telemetry_island_count, + COALESCE(ts.telemetry_island_count, 0) > 0 + AS telemetry_covered, + ts.first_telemetry_coverage_from, + ts.last_telemetry_coverage_to, + CASE + WHEN COALESCE(cs.context_count, 0) = 0 + THEN 'UNCONFIRMED_CONTEXT' + WHEN COALESCE(cs.context_count, 0) > 1 + THEN 'CONTEXT_CONFLICT' + WHEN cr.context_source IN ( + 'ODOO_ORDER_CONTEXT', + 'ODOO_SHOPFLOOR' + ) + THEN 'ODOO_CONTEXT' + WHEN cr.context_source = 'LEGACY_ERP' + THEN 'LEGACY_AUTHORIZED' + WHEN cr.context_source = 'MANUAL_AUTHORIZED' + THEN 'MANUAL_AUTHORIZED' + WHEN cr.context_source = 'MAINTENANCE' + THEN 'MAINTENANCE' + WHEN cr.context_source = 'TEST_SETUP' + THEN 'TEST_SETUP' + WHEN cr.context_source = 'INFERRED_SHADOW' + THEN 'INFERRED_SHADOW' + ELSE 'UNSUPPORTED_CONTEXT' + END AS context_status, + ( + COALESCE(cs.context_count, 0) = 1 + AND cr.context_source IN ( + 'ODOO_ORDER_CONTEXT', + 'ODOO_SHOPFLOOR', + 'LEGACY_ERP', + 'MANUAL_AUTHORIZED' + ) + ) AS productive_context +FROM segments s +JOIN raw_stops r + ON r.source_stop_id = s.source_stop_id +LEFT JOIN context_summary cs + ON cs.source_stop_id = s.source_stop_id + AND cs.segment_from = s.segment_from + AND cs.segment_to = s.segment_to +LEFT JOIN context_ranked cr + ON cr.source_stop_id = s.source_stop_id + AND cr.segment_from = s.segment_from + AND cr.segment_to = s.segment_to + AND cr.context_rank = 1 +LEFT JOIN telemetry_summary ts + ON ts.source_stop_id = s.source_stop_id + AND ts.segment_from = s.segment_from + AND ts.segment_to = s.segment_to; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_stop_context_slices_v1 IS +'Segmentos temporales de cada paro físico delimitados por cambios de contexto productivo y cobertura de telemetría. No modifica la tubería v2 existente.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_stop_candidates_context_v1 AS +WITH slice_rollup AS ( + SELECT + s.source_stop_id, + s.tenant, + s.site, + MAX(s.vertical) AS vertical, + MAX(s.asset) AS asset, + s.machine_id, + MAX(s.source_order_id) AS source_order_id, + MAX(s.raw_started_at) AS raw_started_at, + MAX(s.raw_ended_at) AS raw_ended_at, + MAX(s.raw_effective_end) AS raw_effective_end, + MAX(s.raw_duration_s) AS raw_duration_s, + MAX(s.source_status) AS source_status, + MAX(s.classification_status) AS classification_status, + MAX(s.cause_code) AS cause_code, + MAX(s.operator_note) AS operator_note, + MAX(s.value_hour_eur) AS value_hour_eur, + MAX(s.start_event_name) AS start_event_name, + MAX(s.end_event_name) AS end_event_name, + ( + jsonb_agg( + s.start_payload_json + ORDER BY s.segment_from + ) + )->0 AS start_payload_json, + ( + jsonb_agg( + s.end_payload_json + ORDER BY s.segment_to DESC + ) + )->0 AS end_payload_json, + s.context_status, + s.context_count, + s.context_sources, + s.all_contexts_confirmed, + s.primary_context_family_key, + s.primary_context_key, + s.primary_context_session_id, + s.primary_context_source, + MIN(s.primary_context_from) AS primary_context_from, + MAX(s.primary_context_to) AS primary_context_to, + s.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.context_confidence, + BOOL_AND(COALESCE(s.context_official_eligible, false)) + AS context_official_eligible, + s.authorization_status, + s.context_record_status, + MAX(s.context_notes) AS context_notes, + ( + jsonb_agg( + s.context_evidence_json + ORDER BY s.segment_from + ) FILTER ( + WHERE s.context_evidence_json IS NOT NULL + ) + )->0 AS context_evidence_json, + BOOL_AND(s.productive_context) AS productive_context, + MIN(s.segment_from) AS context_segment_from, + MAX(s.segment_to) AS context_segment_to, + SUM(s.segment_duration_s) AS context_segment_duration_s, + SUM( + CASE + WHEN s.telemetry_covered + THEN s.segment_duration_s + ELSE 0::numeric + END + ) AS telemetry_covered_duration_s, + COUNT(*) AS segment_count, + COUNT(*) FILTER (WHERE s.telemetry_covered) + AS telemetry_slice_count, + MIN(s.first_telemetry_coverage_from) + AS first_telemetry_coverage_from, + MAX(s.last_telemetry_coverage_to) + AS last_telemetry_coverage_to + FROM mv_reports_ucepsa_prod.li_stop_context_slices_v1 s + GROUP BY + s.source_stop_id, + s.tenant, + s.site, + s.machine_id, + s.context_status, + s.context_count, + s.context_sources, + s.all_contexts_confirmed, + s.primary_context_family_key, + s.primary_context_key, + s.primary_context_session_id, + s.primary_context_source, + s.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.context_confidence, + s.authorization_status, + s.context_record_status +), +with_boundary_count AS ( + SELECT + r.*, + COUNT(*) OVER ( + PARTITION BY r.source_stop_id + ) > 1 AS crosses_context_boundary + FROM slice_rollup r +), +enriched AS ( + SELECT + r.*, + d.id AS deployment_id, + d.scope_code, + d.mode AS deployment_mode, + d.planned_go_live_date, + d.actual_go_live_at, + CASE + WHEN d.mode = 'ACTIVE' + AND d.actual_go_live_at IS NOT NULL + AND r.context_segment_from >= d.actual_go_live_at + THEN 'ACTIVE' + ELSE 'SHADOW' + END AS ledger_phase, + p.noise_below_s, + p.aggregate_below_s, + p.operator_individual_from_s, + p.aggregation_window_min, + c.cause_name, + c.is_planned, + pc.units_per_cycle, + pc.target_cycle_rate_ppm, + COALESCE(ep.parameter_value, r.value_hour_eur) + AS applied_capacity_eur_h, + COALESCE(ep.confidence, 'LOW') + AS capacity_cost_confidence, + CASE + WHEN r.context_count = 1 + AND r.productive_context + THEN r.telemetry_covered_duration_s + ELSE 0::numeric + END AS eligible_duration_s + FROM with_boundary_count r + LEFT JOIN mv_hot.edge_oee_stop_causes c + ON c.cause_code = r.cause_code + LEFT JOIN LATERAL ( + SELECT x.* + FROM mv_loss_intelligence.deployment_config x + WHERE x.tenant = r.tenant + AND x.site = r.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 = r.tenant + AND x.site = r.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_loss_intelligence.product_cycle_parameters x + WHERE x.tenant = r.tenant + AND x.site = r.site + AND x.is_active = true + AND r.product_id IS NOT NULL + AND x.product_id = r.product_id + AND ( + x.machine_id IS NULL + OR x.machine_id = r.machine_id + ) + AND r.context_segment_from >= x.valid_from + AND ( + x.valid_to IS NULL + OR r.context_segment_from < 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 = r.tenant + AND x.site = r.site + AND x.parameter_code = 'CAPACITY_HOUR_EUR' + AND x.is_active = true + AND ( + x.machine_id IS NULL + OR x.machine_id = r.machine_id + ) + AND ( + x.product_id IS NULL + OR x.product_id = r.product_id + ) + AND r.context_segment_from >= x.valid_from + AND ( + x.valid_to IS NULL + OR r.context_segment_from < x.valid_to + ) + ORDER BY + (x.machine_id IS NOT NULL) DESC, + (x.product_id IS NOT NULL) DESC, + x.valid_from DESC, + x.id DESC + LIMIT 1 + ) ep ON true +), +classified AS ( + SELECT + e.*, + CASE + WHEN e.source_status = 'OPEN' + THEN 'OPEN_MONITORING' + WHEN e.context_count = 0 + THEN 'BLOCKED_NO_CONTEXT' + WHEN e.context_count > 1 + THEN 'BLOCKED_CONTEXT_CONFLICT' + WHEN NOT e.all_contexts_confirmed + THEN 'BLOCKED_UNCONFIRMED_CONTEXT' + WHEN e.primary_context_source IN ( + 'MAINTENANCE', + 'TEST_SETUP' + ) + THEN 'EXCLUDED_NON_PRODUCTION_CONTEXT' + WHEN e.primary_context_source = 'INFERRED_SHADOW' + THEN 'BLOCKED_INFERRED_CONTEXT' + WHEN NOT e.productive_context + THEN 'BLOCKED_UNSUPPORTED_CONTEXT' + WHEN e.eligible_duration_s <= 0 + THEN 'BLOCKED_NO_TELEMETRY' + WHEN e.eligible_duration_s < e.noise_below_s + THEN 'EXCLUDED_NOISE' + WHEN e.eligible_duration_s + < e.operator_individual_from_s + THEN 'CANDIDATE_GROUP' + ELSE 'CANDIDATE_INDIVIDUAL' + END AS eligibility_status, + ( + e.source_status = 'CLOSED' + AND e.context_count = 1 + AND e.all_contexts_confirmed + AND e.productive_context + AND e.eligible_duration_s >= e.noise_below_s + ) AS technically_eligible + FROM enriched e +) +SELECT + c.source_stop_id, + c.tenant, + c.site, + c.vertical, + c.asset, + c.machine_id, + c.source_order_id, + c.deployment_id, + c.scope_code, + c.deployment_mode, + c.planned_go_live_date, + c.actual_go_live_at, + c.ledger_phase, + c.raw_started_at, + c.raw_ended_at, + c.raw_effective_end, + c.raw_duration_s, + c.context_segment_from AS started_at, + c.context_segment_to AS ended_at, + c.context_segment_duration_s, + c.telemetry_covered_duration_s, + c.eligible_duration_s, + ROUND(c.eligible_duration_s / 60.0, 2) + AS eligible_duration_min, + GREATEST( + c.context_segment_duration_s - c.eligible_duration_s, + 0::numeric + ) AS excluded_duration_s, + ROUND( + GREATEST( + c.context_segment_duration_s - c.eligible_duration_s, + 0::numeric + ) / 60.0, + 2 + ) AS excluded_duration_min, + ROUND( + c.context_segment_duration_s + / NULLIF(c.raw_duration_s, 0::numeric), + 6 + ) AS context_coverage_ratio, + ROUND( + c.eligible_duration_s + / NULLIF(c.context_segment_duration_s, 0::numeric), + 6 + ) AS telemetry_context_coverage_ratio, + c.context_segment_duration_s + < (c.raw_duration_s - 1::numeric) + AS context_clipped, + c.eligible_duration_s + < (c.context_segment_duration_s - 1::numeric) + AS telemetry_clipped, + c.crosses_context_boundary, + c.segment_count, + c.telemetry_slice_count, + c.first_telemetry_coverage_from, + c.last_telemetry_coverage_to, + c.source_status, + c.classification_status, + c.cause_code, + c.cause_name, + c.is_planned, + c.operator_note, + c.start_event_name, + c.end_event_name, + c.context_status, + c.context_count, + c.context_sources, + c.all_contexts_confirmed AS context_confirmed, + c.primary_context_family_key, + c.primary_context_key, + c.primary_context_session_id, + c.primary_context_source, + c.primary_context_from, + c.primary_context_to, + c.authorization_status, + c.context_record_status, + c.context_confidence, + 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.units_per_cycle, + c.target_cycle_rate_ppm, + c.applied_capacity_eur_h, + c.capacity_cost_confidence, + CASE + WHEN c.technically_eligible + THEN ROUND( + c.eligible_duration_s + / 3600.0 + * c.applied_capacity_eur_h, + 2 + ) + ELSE NULL::numeric + END AS shadow_capacity_loss_eur, + c.eligibility_status, + c.technically_eligible, + false AS official_ledger_eligible, + CASE + WHEN c.eligibility_status = 'OPEN_MONITORING' + THEN 'El paro continúa abierto; no entra todavía en cola.' + WHEN c.eligibility_status = 'BLOCKED_NO_CONTEXT' + THEN 'El paro no tiene contexto productivo confirmado.' + WHEN c.eligibility_status = 'BLOCKED_CONTEXT_CONFLICT' + THEN 'Más de un contexto productivo cubre el mismo tramo.' + WHEN c.eligibility_status = 'BLOCKED_NO_TELEMETRY' + THEN 'No existe cobertura de telemetría válida para el tramo.' + WHEN c.eligibility_status = 'EXCLUDED_NON_PRODUCTION_CONTEXT' + THEN 'El contexto es mantenimiento o prueba, no producción.' + WHEN c.eligibility_status = 'EXCLUDED_NOISE' + THEN 'Duración inferior al umbral de ruido.' + WHEN c.eligibility_status IN ( + 'CANDIDATE_GROUP', + 'CANDIDATE_INDIVIDUAL' + ) + THEN 'Candidato técnico SHADOW con contexto productivo confirmado.' + ELSE 'Tramo bloqueado por política de contexto.' + END AS blocking_reason, + jsonb_build_object( + 'source_stop_id', c.source_stop_id, + 'raw_started_at', c.raw_started_at, + 'raw_ended_at', c.raw_ended_at, + 'raw_duration_s', c.raw_duration_s, + 'context_segment_from', c.context_segment_from, + 'context_segment_to', c.context_segment_to, + 'context_segment_duration_s', c.context_segment_duration_s, + 'eligible_duration_s', c.eligible_duration_s, + 'context_status', c.context_status, + 'context_count', c.context_count, + 'context_sources', c.context_sources, + 'primary_context_key', c.primary_context_key, + 'primary_context_source', c.primary_context_source, + 'context_clipped', + c.context_segment_duration_s + < (c.raw_duration_s - 1::numeric), + 'telemetry_clipped', + c.eligible_duration_s + < (c.context_segment_duration_s - 1::numeric), + 'crosses_context_boundary', c.crosses_context_boundary, + 'source_start_payload', c.start_payload_json, + 'source_end_payload', c.end_payload_json, + 'context_evidence', c.context_evidence_json + ) AS evidence_json +FROM classified c; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_stop_candidates_context_v1 IS +'Candidatos de paro context-aware en paralelo a v2. Acepta Odoo o legacy autorizado, mantiene la cola oficial desactivada y bloquea ausencia o conflicto de contexto.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_stop_groups_context_v1 AS +WITH candidates AS ( + SELECT + c.*, + COALESCE(p.aggregation_window_min, 15) + AS effective_aggregation_window_min + FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1 c + LEFT JOIN LATERAL ( + SELECT x.* + FROM mv_loss_intelligence.loss_policies x + WHERE x.tenant = c.tenant + AND x.site = c.site + AND x.policy_code = 'CUTTER_STOPS_V1' + AND x.is_active = true + ORDER BY x.id DESC + LIMIT 1 + ) p ON true + WHERE c.eligibility_status IN ( + 'CANDIDATE_GROUP', + 'CANDIDATE_INDIVIDUAL' + ) +), +bucketed AS ( + SELECT + c.*, + date_bin( + make_interval( + mins => c.effective_aggregation_window_min + ), + c.started_at, + '2000-01-01 00:00:00+00'::timestamptz + ) AS bucket_start + FROM candidates c +), +grouped_micro AS ( + SELECT + b.tenant, + b.site, + b.deployment_id, + b.ledger_phase, + b.machine_id, + b.asset, + b.context_status, + b.primary_context_family_key, + MIN(b.primary_context_key) AS primary_context_key, + b.primary_context_session_id, + b.primary_context_source, + b.order_ref, + b.odoo_production_id, + b.odoo_workorder_id, + b.product_id, + b.product_default_code, + b.product_name, + b.operator_employee_id, + b.operator_name, + b.units_per_cycle, + b.target_cycle_rate_ppm, + COALESCE(b.cause_code, 'PENDING') AS cause_code, + COALESCE(b.cause_name, 'Pendiente de clasificar') + AS cause_name, + b.classification_status, + b.bucket_start AS group_window_start, + COUNT(DISTINCT b.source_stop_id)::integer + AS event_count, + MIN(b.started_at) AS started_at, + MAX(b.ended_at) AS ended_at, + SUM(b.eligible_duration_s) AS duration_s, + ROUND(SUM(b.shadow_capacity_loss_eur), 2) + AS capacity_loss_eur, + BOOL_OR(b.context_clipped) AS context_clipped, + BOOL_OR(b.telemetry_clipped) AS telemetry_clipped, + BOOL_OR(b.crosses_context_boundary) + AS crosses_context_boundary, + ARRAY_AGG( + DISTINCT b.source_stop_id + ORDER BY b.source_stop_id + ) AS source_stop_ids, + false AS official_ledger_eligible + FROM bucketed b + WHERE b.eligibility_status = 'CANDIDATE_GROUP' + GROUP BY + b.tenant, + b.site, + b.deployment_id, + b.ledger_phase, + b.machine_id, + b.asset, + b.context_status, + b.primary_context_family_key, + b.primary_context_session_id, + b.primary_context_source, + b.order_ref, + b.odoo_production_id, + b.odoo_workorder_id, + b.product_id, + b.product_default_code, + b.product_name, + b.operator_employee_id, + b.operator_name, + b.units_per_cycle, + b.target_cycle_rate_ppm, + COALESCE(b.cause_code, 'PENDING'), + COALESCE(b.cause_name, 'Pendiente de clasificar'), + b.classification_status, + b.bucket_start +), +individual AS ( + SELECT + b.tenant, + b.site, + b.deployment_id, + b.ledger_phase, + b.machine_id, + b.asset, + b.context_status, + b.primary_context_family_key, + b.primary_context_key, + b.primary_context_session_id, + b.primary_context_source, + b.order_ref, + b.odoo_production_id, + b.odoo_workorder_id, + b.product_id, + b.product_default_code, + b.product_name, + b.operator_employee_id, + b.operator_name, + b.units_per_cycle, + b.target_cycle_rate_ppm, + COALESCE(b.cause_code, 'PENDING') AS cause_code, + COALESCE(b.cause_name, 'Pendiente de clasificar') + AS cause_name, + b.classification_status, + b.started_at AS group_window_start, + 1 AS event_count, + b.started_at, + b.ended_at, + b.eligible_duration_s AS duration_s, + b.shadow_capacity_loss_eur AS capacity_loss_eur, + b.context_clipped, + b.telemetry_clipped, + b.crosses_context_boundary, + ARRAY[b.source_stop_id] AS source_stop_ids, + false AS official_ledger_eligible + FROM bucketed b + WHERE b.eligibility_status = 'CANDIDATE_INDIVIDUAL' +), +combined AS ( + SELECT * FROM grouped_micro + UNION ALL + SELECT * FROM individual +) +SELECT + CASE + WHEN event_count = 1 + THEN + 'CTXSTOPV1:' + || source_stop_ids[1]::text + || ':' + || COALESCE( + primary_context_session_id::text, + odoo_production_id::text, + md5(primary_context_family_key) + ) + ELSE + 'CTXGROUPV1:' + || machine_id + || ':' + || md5(primary_context_family_key) + || ':' + || to_char( + group_window_start AT TIME ZONE 'Europe/Madrid', + 'YYYYMMDDHH24MI' + ) + || ':' + || cause_code + END AS source_key, + tenant, + site, + deployment_id, + ledger_phase, + machine_id, + asset, + context_status, + primary_context_family_key, + 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, + units_per_cycle, + target_cycle_rate_ppm, + cause_code, + cause_name, + classification_status, + group_window_start, + event_count, + started_at, + ended_at, + duration_s, + ROUND(duration_s / 60.0, 2) AS duration_min, + capacity_loss_eur, + context_clipped, + telemetry_clipped, + crosses_context_boundary, + source_stop_ids, + official_ledger_eligible, + CASE + WHEN duration_s >= 900::numeric THEN 'HIGH' + WHEN duration_s >= 300::numeric THEN 'MEDIUM' + ELSE 'LOW' + END AS preliminary_priority +FROM combined; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_stop_groups_context_v1 IS +'Agrupación context-aware de microparos y paros individuales. Separa Odoo, legacy y cada sesión productiva.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_shadow_review_queue_context_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, + context_status, + primary_context_source, + primary_context_session_id, + order_ref, + product_name, + operator_name, + cause_code, + cause_name, + event_count, + started_at, + ended_at, + duration_min, + capacity_loss_eur, + preliminary_priority, + context_clipped, + telemetry_clipped, + crosses_context_boundary, + 'SHADOW_CONTEXT_AWARE_NOT_OFFICIAL'::text + AS queue_status, + official_ledger_eligible, + source_stop_ids +FROM mv_reports_ucepsa_prod.li_stop_groups_context_v1 +WHERE ledger_phase = 'SHADOW' + AND ended_at >= now() - interval '24 hours' +ORDER BY shadow_rank; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_shadow_review_queue_context_v1 IS +'Cola diaria SHADOW context-aware limitada a las últimas 24 horas. No reemplaza ni alimenta la cola oficial.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_shadow_review_backlog_context_v1 AS +SELECT + ROW_NUMBER() OVER ( + ORDER BY + capacity_loss_eur DESC, + duration_s DESC, + started_at DESC + ) AS backlog_rank, + g.* +FROM mv_reports_ucepsa_prod.li_stop_groups_context_v1 g +WHERE g.ledger_phase = 'SHADOW' +ORDER BY backlog_rank; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_shadow_review_backlog_context_v1 IS +'Backlog histórico completo de candidatos context-aware. La reunión diaria debe usar la cola de 24 horas, no este backlog.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_stop_context_diagnostics_v1 AS +SELECT + eligibility_status, + context_status, + primary_context_source, + machine_id, + COUNT(*) AS candidate_row_count, + COUNT(DISTINCT source_stop_id) AS source_stop_count, + ROUND(SUM(context_segment_duration_s) / 60.0, 2) + AS context_minutes, + ROUND(SUM(eligible_duration_s) / 60.0, 2) + AS eligible_minutes +FROM mv_reports_ucepsa_prod.li_stop_candidates_context_v1 +WHERE raw_started_at >= now() - interval '24 hours' +GROUP BY + eligibility_status, + context_status, + primary_context_source, + machine_id +ORDER BY + machine_id, + eligibility_status, + context_status; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_stop_context_diagnostics_v1 IS +'Diagnóstico de 24 horas de paros abiertos, ruido, ausencia de contexto, conflictos, telemetría y candidatos productivos.'; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'grafana_ucepsa_ro' + ) THEN + GRANT SELECT + ON mv_reports_ucepsa_prod.li_stop_context_slices_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON mv_reports_ucepsa_prod.li_stop_candidates_context_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON mv_reports_ucepsa_prod.li_stop_groups_context_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON mv_reports_ucepsa_prod.li_shadow_review_queue_context_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON mv_reports_ucepsa_prod.li_shadow_review_backlog_context_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON mv_reports_ucepsa_prod.li_stop_context_diagnostics_v1 + TO grafana_ucepsa_ro; + END IF; +END $$; + +COMMIT;