diff --git a/ucepsa/edge-oee-demo/docs/runbooks/099_shopfloor_stop_attribution_shadow_v0.3.4.md b/ucepsa/edge-oee-demo/docs/runbooks/099_shopfloor_stop_attribution_shadow_v0.3.4.md new file mode 100644 index 0000000..e0d4850 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/099_shopfloor_stop_attribution_shadow_v0.3.4.md @@ -0,0 +1,126 @@ +# UCEPSA — Shop Floor Stop Attribution SHADOW v0.3.4 + +## Objetivo + +Cruzar en paralelo: + +```text +paro físico +∩ telemetría válida +∩ sesión Odoo Shop Floor OPEN/CLOSED +``` + +La versión no reemplaza las vistas context-aware v0.3.1 y no alimenta la cola +oficial. + +## Evidencia que permite avanzar + +La sesión real `workorder 340 / WH/MO/00352` fue observada: + +```text +Odoo date_start: 07:36:19 +Primera observación MESAVAULT: 07:36:27 +Operario confirmado después: Esteban Vaquerizo García +``` + +El inicio original se conservó aunque la identidad apareció más tarde. + +CORT-00, por el contrario, registró actividad física sin workorder Odoo en +`progress`; debe continuar bloqueada como ausencia de sesión Shop Floor. + +## Nuevas vistas + +- `li_stop_shopfloor_slices_v1` +- `li_stop_shopfloor_candidates_v1` +- `li_shadow_review_queue_shopfloor_v1` +- `li_shopfloor_session_stop_summary_v1` +- `li_stop_shopfloor_diagnostics_v1` + +## Reglas + +Un tramo solo puede ser candidato cuando: + +1. el paro está cerrado; +2. existe exactamente una sesión Shop Floor; +3. existe telemetría válida; +4. supera el umbral de ruido. + +La atribución temporal y la atribución individual son independientes: + +```text +sesión válida + operario confirmado +→ CONFIRMED_OPERATOR + +sesión válida + persona candidata +→ PENDING_OPERATOR_CANDIDATE + +sesión válida sin persona +→ UNRESOLVED_OPERATOR +``` + +Los paros pueden analizarse por sesión aunque todavía no exista atribución +individual. + +## Alcance económico + +`shadow_capacity_loss_eur` es exposición provisional de capacidad usando el +parámetro de 36 €/h cuando proceda. No es una pérdida aprobada. + +## Garantías + +- `official_ledger_eligible=false` +- no modifica Odoo; +- no modifica sesiones; +- no modifica paros raw; +- no modifica balizas; +- no modifica RevPi ni WISE; +- no sustituye v0.3.1; +- no alimenta `li_operator_stop_queue_v2`. + +## Despliegue + +No es necesario detener servicios. + +```bash +FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/106_ucepsa_shopfloor_stop_attribution_shadow_v034.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$FILE" +``` + +## Validación + +```bash +VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_shopfloor_stop_attribution_shadow_v034.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$VALIDATION" +``` + +Valores obligatorios: + +```text +invalid_slice_intervals = 0 +negative_slice_durations = 0 +duration_overflow_count = 0 +open_stops_marked_eligible = 0 +cort00_attributed_rows = 0 +official_candidate_rows = 0 +official_operator_queue_rows = 0 +``` + +La consulta de la sesión 340 debe devolver paros de CORT-02 atribuidos al +workorder y al operario Esteban, siempre que existan paros cerrados posteriores +a las 07:36:19. + +## Siguiente observación + +Cuando el workorder 340 se cierre: + +- la sesión debe pasar `OPEN → CLOSED`; +- debe conservar el inicio 07:36:19; +- debe obtener un `ended_at` real; +- deben resolverse cantidad y posible continuación; +- los paros posteriores al cierre deben quedar fuera de la sesión. diff --git a/ucepsa/edge-oee-demo/ops/validate_shopfloor_stop_attribution_shadow_v034.sql b/ucepsa/edge-oee-demo/ops/validate_shopfloor_stop_attribution_shadow_v034.sql new file mode 100644 index 0000000..6f9799e --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_shopfloor_stop_attribution_shadow_v034.sql @@ -0,0 +1,157 @@ +\pset pager off + +\echo '=== 1. Objetos v0.3.4 ===' + +SELECT + to_regclass( + 'mv_reports_ucepsa_prod.li_stop_shopfloor_slices_v1' + ) AS slices_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_stop_shopfloor_candidates_v1' + ) AS candidates_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_shadow_review_queue_shopfloor_v1' + ) AS queue_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_shopfloor_session_stop_summary_v1' + ) AS summary_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_stop_shopfloor_diagnostics_v1' + ) AS diagnostics_view; + +\echo '=== 2. Invariantes temporales ===' + +SELECT + count(*) FILTER ( + WHERE segment_to <= segment_from + ) AS invalid_slice_intervals, + count(*) FILTER ( + WHERE segment_duration_s < 0 + ) AS negative_slice_durations +FROM + mv_reports_ucepsa_prod + .li_stop_shopfloor_slices_v1; + +\echo '=== 3. Duración atribuida no supera el paro raw ===' + +WITH totals AS ( + SELECT + source_stop_id, + max(raw_duration_s) AS raw_duration_s, + sum(segment_duration_s) AS sliced_duration_s + FROM + mv_reports_ucepsa_prod + .li_stop_shopfloor_slices_v1 + GROUP BY source_stop_id +) +SELECT count(*) AS duration_overflow_count +FROM totals +WHERE sliced_duration_s + > raw_duration_s + 1::numeric; + +\echo '=== 4. Paros abiertos no elegibles ===' + +SELECT count(*) AS open_stops_marked_eligible +FROM + mv_reports_ucepsa_prod + .li_stop_shopfloor_candidates_v1 +WHERE source_status = 'OPEN' + AND technically_eligible; + +\echo '=== 5. Atribución de la sesión real 340 ===' + +SELECT + machine_id, + shopfloor_session_id, + production_order, + odoo_workorder_id, + session_status, + operator_employee_id, + operator_name, + operator_attribution_status, + count( + DISTINCT source_stop_id + ) AS stop_count, + round( + sum(eligible_duration_s) / 60.0, + 2 + ) AS eligible_stop_minutes, + round( + sum( + coalesce( + shadow_capacity_loss_eur, + 0 + ) + ), + 2 + ) AS shadow_capacity_loss_eur +FROM + mv_reports_ucepsa_prod + .li_stop_shopfloor_candidates_v1 +WHERE odoo_workorder_id = 340 +GROUP BY + machine_id, + shopfloor_session_id, + production_order, + odoo_workorder_id, + session_status, + operator_employee_id, + operator_name, + operator_attribution_status; + +\echo '=== 6. CORT-00 sigue sin sesión Shop Floor hoy ===' + +SELECT + count(*) FILTER ( + WHERE shopfloor_session_id IS NOT NULL + ) AS cort00_attributed_rows, + count(*) FILTER ( + WHERE eligibility_status = + 'BLOCKED_NO_SHOPFLOOR_SESSION' + ) AS cort00_blocked_rows +FROM + mv_reports_ucepsa_prod + .li_stop_shopfloor_candidates_v1 +WHERE machine_id = 'CORT-00' + AND raw_started_at >= + '2026-07-20 00:00:00+02'::timestamptz; + +\echo '=== 7. Resumen por sesión ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_shopfloor_session_stop_summary_v1 +WHERE odoo_workorder_id IN (87, 810, 340) +ORDER BY odoo_workorder_id; + +\echo '=== 8. Cola SHADOW Shop Floor ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_shadow_review_queue_shopfloor_v1 +ORDER BY shadow_rank +LIMIT 30; + +\echo '=== 9. Nada oficial ===' + +SELECT + count(*) FILTER ( + WHERE official_ledger_eligible + ) AS official_candidate_rows +FROM + mv_reports_ucepsa_prod + .li_stop_shopfloor_candidates_v1; + +\echo '=== 10. Cola oficial original sigue vacía ===' + +SELECT count(*) AS official_operator_queue_rows +FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v2; + +\echo '=== 11. Diagnóstico 24 horas ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_stop_shopfloor_diagnostics_v1; diff --git a/ucepsa/edge-oee-demo/sql/versions/106_ucepsa_shopfloor_stop_attribution_shadow_v034.sql b/ucepsa/edge-oee-demo/sql/versions/106_ucepsa_shopfloor_stop_attribution_shadow_v034.sql new file mode 100644 index 0000000..b3c2a35 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/106_ucepsa_shopfloor_stop_attribution_shadow_v034.sql @@ -0,0 +1,992 @@ +BEGIN; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_stop_shopfloor_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 +), +sessions AS ( + SELECT + c.context_session_id AS shopfloor_session_id, + c.tenant, + c.site, + c.machine_id, + c.source_type, + c.source_ref, + c.external_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.operator_candidate_employee_id, + c.operator_candidate_name, + c.effective_from, + COALESCE(c.effective_to, now()) AS effective_to, + c.status, + c.authorization_status, + c.confidence, + c.official_eligible, + c.context_priority, + c.context_time_usable, + c.individual_attribution_eligible, + c.operator_resolution, + c.operator_confirmation_status, + c.quantity_status, + c.quantity_confirmation_status, + c.production_chain_key, + c.evidence_json + FROM + mv_reports_ucepsa_prod + .li_odoo_shopfloor_context_intervals_v1 c + WHERE c.context_time_usable + AND COALESCE(c.effective_to, now()) > c.effective_from +), +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 +), +session_edges AS ( + SELECT + s.source_stop_id, + GREATEST( + s.raw_started_at, + c.effective_from + ) AS boundary_at + FROM raw_stops s + JOIN sessions c + ON c.tenant = s.tenant + AND c.site = s.site + AND c.machine_id = s.machine_id + AND s.raw_effective_end > c.effective_from + AND s.raw_started_at < c.effective_to + + UNION ALL + + SELECT + s.source_stop_id, + LEAST( + s.raw_effective_end, + c.effective_to + ) AS boundary_at + FROM raw_stops s + JOIN sessions c + ON c.tenant = s.tenant + AND c.site = s.site + AND c.machine_id = s.machine_id + AND s.raw_effective_end > c.effective_from + AND s.raw_started_at < c.effective_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 session_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 +), +session_matches AS ( + SELECT + s.source_stop_id, + s.segment_from, + s.segment_to, + c.shopfloor_session_id, + c.source_ref, + c.external_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.operator_candidate_employee_id, + c.operator_candidate_name, + c.effective_from, + c.effective_to, + c.status, + c.authorization_status, + c.confidence, + c.official_eligible, + c.context_priority, + c.individual_attribution_eligible, + c.operator_resolution, + c.operator_confirmation_status, + c.quantity_status, + c.quantity_confirmation_status, + c.production_chain_key, + c.evidence_json + FROM segments s + JOIN raw_stops r + ON r.source_stop_id = s.source_stop_id + JOIN sessions c + ON c.tenant = r.tenant + AND c.site = r.site + AND c.machine_id = r.machine_id + AND s.segment_to > c.effective_from + AND s.segment_from < c.effective_to +), +session_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.shopfloor_session_id + ) AS session_rank + FROM session_matches m +), +session_summary AS ( + SELECT + source_stop_id, + segment_from, + segment_to, + COUNT( + DISTINCT shopfloor_session_id + ) AS session_count + FROM session_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(ss.session_count, 0) + AS shopfloor_session_count, + sr.shopfloor_session_id, + sr.source_ref AS shopfloor_source_ref, + sr.external_order_ref AS production_order, + sr.odoo_production_id, + sr.odoo_workorder_id, + sr.product_id, + sr.product_default_code, + sr.product_name, + sr.operator_employee_id, + sr.operator_name, + sr.operator_candidate_employee_id, + sr.operator_candidate_name, + sr.effective_from AS session_from, + sr.effective_to AS session_to, + sr.status AS session_status, + sr.authorization_status, + sr.confidence AS session_confidence, + sr.official_eligible AS session_official_eligible, + sr.individual_attribution_eligible, + sr.operator_resolution, + sr.operator_confirmation_status, + sr.quantity_status, + sr.quantity_confirmation_status, + sr.production_chain_key, + sr.evidence_json AS session_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(ss.session_count, 0) = 0 + THEN 'NO_SHOPFLOOR_SESSION' + WHEN COALESCE(ss.session_count, 0) = 1 + THEN 'SINGLE_SHOPFLOOR_SESSION' + ELSE 'SHOPFLOOR_SESSION_CONFLICT' + END AS shopfloor_context_status +FROM segments s +JOIN raw_stops r + ON r.source_stop_id = s.source_stop_id +LEFT JOIN session_summary ss + ON ss.source_stop_id = s.source_stop_id + AND ss.segment_from = s.segment_from + AND ss.segment_to = s.segment_to +LEFT JOIN session_ranked sr + ON sr.source_stop_id = s.source_stop_id + AND sr.segment_from = s.segment_from + AND sr.segment_to = s.segment_to + AND sr.session_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_shopfloor_slices_v1 IS +'Vista SHADOW que divide cada paro físico por límites de sesión Odoo Shop Floor y cobertura de telemetría. No modifica la tubería context-aware v0.3.1.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_stop_shopfloor_candidates_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, + s.shopfloor_context_status, + s.shopfloor_session_count, + s.shopfloor_session_id, + s.shopfloor_source_ref, + s.production_order, + 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.operator_candidate_employee_id, + s.operator_candidate_name, + MIN(s.session_from) AS session_from, + MAX(s.session_to) AS session_to, + s.session_status, + s.authorization_status, + s.session_confidence, + BOOL_AND( + COALESCE( + s.session_official_eligible, + false + ) + ) AS session_official_eligible, + BOOL_AND( + COALESCE( + s.individual_attribution_eligible, + false + ) + ) AS individual_attribution_eligible, + s.operator_resolution, + s.operator_confirmation_status, + s.quantity_status, + s.quantity_confirmation_status, + s.production_chain_key, + ( + jsonb_agg( + s.session_evidence_json + ORDER BY s.segment_from + ) FILTER ( + WHERE s.session_evidence_json + IS NOT NULL + ) + )->0 AS session_evidence_json, + MIN(s.segment_from) AS started_at, + MAX(s.segment_to) AS ended_at, + 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_shopfloor_slices_v1 s + GROUP BY + s.source_stop_id, + s.tenant, + s.site, + s.machine_id, + s.shopfloor_context_status, + s.shopfloor_session_count, + s.shopfloor_session_id, + s.shopfloor_source_ref, + s.production_order, + 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.operator_candidate_employee_id, + s.operator_candidate_name, + s.session_status, + s.authorization_status, + s.session_confidence, + s.operator_resolution, + s.operator_confirmation_status, + s.quantity_status, + s.quantity_confirmation_status, + s.production_chain_key +), +with_boundary_count AS ( + SELECT + r.*, + COUNT(*) OVER ( + PARTITION BY r.source_stop_id + ) > 1 AS crosses_shopfloor_boundary + FROM slice_rollup r +), +enriched AS ( + SELECT + r.*, + p.noise_below_s, + p.aggregate_below_s, + p.operator_individual_from_s, + p.aggregation_window_min, + c.cause_name, + c.is_planned, + COALESCE( + ep.parameter_value, + r.value_hour_eur + ) AS applied_capacity_eur_h, + COALESCE( + ep.confidence, + 'LOW' + ) AS capacity_cost_confidence, + CASE + WHEN r.shopfloor_session_count = 1 + 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.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 + .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.started_at >= x.valid_from + AND ( + x.valid_to IS NULL + OR r.started_at < 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.shopfloor_session_count = 0 + THEN 'BLOCKED_NO_SHOPFLOOR_SESSION' + WHEN e.shopfloor_session_count > 1 + THEN 'BLOCKED_SHOPFLOOR_CONFLICT' + 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.shopfloor_session_count = 1 + AND e.eligible_duration_s + >= e.noise_below_s + ) AS technically_eligible, + CASE + WHEN e.shopfloor_session_count <> 1 + THEN 'NOT_APPLICABLE' + WHEN e.individual_attribution_eligible + THEN 'CONFIRMED_OPERATOR' + WHEN e.operator_candidate_employee_id + IS NOT NULL + THEN 'PENDING_OPERATOR_CANDIDATE' + ELSE 'UNRESOLVED_OPERATOR' + END AS operator_attribution_status + FROM enriched e +) +SELECT + c.source_stop_id, + c.tenant, + c.site, + c.vertical, + c.asset, + c.machine_id, + c.source_order_id, + c.raw_started_at, + c.raw_ended_at, + c.raw_effective_end, + c.raw_duration_s, + c.started_at, + c.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, + c.context_segment_duration_s + < (c.raw_duration_s - 1::numeric) + AS session_clipped, + c.eligible_duration_s + < (c.context_segment_duration_s - 1::numeric) + AS telemetry_clipped, + c.crosses_shopfloor_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.shopfloor_context_status, + c.shopfloor_session_count, + c.shopfloor_session_id, + c.shopfloor_source_ref, + c.production_chain_key, + c.production_order, + c.odoo_production_id, + c.odoo_workorder_id, + c.product_id, + c.product_default_code, + c.product_name, + c.session_from, + c.session_to, + c.session_status, + c.authorization_status, + c.session_confidence, + c.operator_employee_id, + c.operator_name, + c.operator_candidate_employee_id, + c.operator_candidate_name, + c.operator_resolution, + c.operator_confirmation_status, + c.individual_attribution_eligible, + c.operator_attribution_status, + c.quantity_status, + c.quantity_confirmation_status, + 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.' + WHEN c.eligibility_status = + 'BLOCKED_NO_SHOPFLOOR_SESSION' + THEN + 'No existe una sesión Odoo Shop Floor OPEN/CLOSED que cubra el tramo.' + WHEN c.eligibility_status = + 'BLOCKED_SHOPFLOOR_CONFLICT' + THEN + 'Más de una sesión Shop Floor cubre el mismo tramo.' + WHEN c.eligibility_status = + 'BLOCKED_NO_TELEMETRY' + THEN + 'No existe cobertura de telemetría válida.' + 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 atribuido a una sesión Shop Floor.' + ELSE + 'Tramo bloqueado por política Shop Floor.' + 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, + 'shopfloor_session_id', + c.shopfloor_session_id, + 'odoo_workorder_id', + c.odoo_workorder_id, + 'production_order', + c.production_order, + 'session_from', + c.session_from, + 'session_to', + c.session_to, + 'operator_attribution_status', + c.operator_attribution_status, + 'operator_employee_id', + c.operator_employee_id, + 'operator_candidate_employee_id', + c.operator_candidate_employee_id, + 'eligible_duration_s', + c.eligible_duration_s, + 'session_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_shopfloor_boundary', + c.crosses_shopfloor_boundary, + 'session_evidence', + c.session_evidence_json + ) AS evidence_json +FROM classified c; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_stop_shopfloor_candidates_v1 IS +'Candidatos de paro atribuidos exclusivamente a sesiones Odoo Shop Floor. Capa paralela SHADOW; no sustituye v0.3.1 ni alimenta la cola oficial.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_shadow_review_queue_shopfloor_v1 +AS +SELECT + ROW_NUMBER() OVER ( + ORDER BY + shadow_capacity_loss_eur DESC NULLS LAST, + eligible_duration_s DESC, + started_at DESC + ) AS shadow_rank, + source_stop_id, + machine_id, + shopfloor_session_id, + production_order, + odoo_workorder_id, + product_name, + operator_employee_id, + operator_name, + operator_candidate_employee_id, + operator_candidate_name, + operator_attribution_status, + cause_code, + cause_name, + started_at, + ended_at, + eligible_duration_min, + shadow_capacity_loss_eur, + eligibility_status, + session_clipped, + telemetry_clipped, + crosses_shopfloor_boundary, + 'SHADOW_SHOPFLOOR_NOT_OFFICIAL'::text + AS queue_status, + official_ledger_eligible +FROM + mv_reports_ucepsa_prod + .li_stop_shopfloor_candidates_v1 +WHERE eligibility_status IN ( + 'CANDIDATE_GROUP', + 'CANDIDATE_INDIVIDUAL' + ) + AND ended_at >= now() - interval '24 hours'; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_shadow_review_queue_shopfloor_v1 IS +'Cola de revisión SHADOW basada en sesiones Shop Floor durante las últimas 24 horas. No alimenta la cola oficial.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_shopfloor_session_stop_summary_v1 +AS +SELECT + c.shopfloor_session_id, + c.machine_id, + c.production_chain_key, + c.production_order, + c.odoo_production_id, + c.odoo_workorder_id, + c.product_id, + c.product_name, + c.session_from, + c.session_to, + c.session_status, + c.operator_employee_id, + c.operator_name, + c.operator_candidate_employee_id, + c.operator_candidate_name, + c.operator_attribution_status, + COUNT( + DISTINCT c.source_stop_id + ) AS physical_stop_count, + COUNT( + DISTINCT c.source_stop_id + ) FILTER ( + WHERE c.technically_eligible + ) AS eligible_stop_count, + ROUND( + SUM(c.eligible_duration_s) / 60.0, + 2 + ) AS eligible_stop_minutes, + ROUND( + SUM( + COALESCE( + c.shadow_capacity_loss_eur, + 0 + ) + ), + 2 + ) AS shadow_capacity_loss_eur, + BOOL_OR(c.session_clipped) + AS any_session_clipped, + BOOL_OR(c.telemetry_clipped) + AS any_telemetry_clipped, + BOOL_OR(c.crosses_shopfloor_boundary) + AS any_crosses_shopfloor_boundary, + false AS official_ledger_eligible +FROM + mv_reports_ucepsa_prod + .li_stop_shopfloor_candidates_v1 c +WHERE c.shopfloor_session_id IS NOT NULL +GROUP BY + c.shopfloor_session_id, + c.machine_id, + c.production_chain_key, + c.production_order, + c.odoo_production_id, + c.odoo_workorder_id, + c.product_id, + c.product_name, + c.session_from, + c.session_to, + c.session_status, + c.operator_employee_id, + c.operator_name, + c.operator_candidate_employee_id, + c.operator_candidate_name, + c.operator_attribution_status; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_shopfloor_session_stop_summary_v1 IS +'Resumen SHADOW de paros físicos por sesión Odoo Shop Floor. Los importes son exposición provisional de capacidad, no pérdida aprobada.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_stop_shopfloor_diagnostics_v1 +AS +SELECT + machine_id, + shopfloor_context_status, + eligibility_status, + operator_attribution_status, + 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_shopfloor_candidates_v1 +WHERE raw_started_at >= now() - interval '24 hours' +GROUP BY + machine_id, + shopfloor_context_status, + eligibility_status, + operator_attribution_status +ORDER BY + machine_id, + eligibility_status, + operator_attribution_status; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_stop_shopfloor_diagnostics_v1 IS +'Diagnóstico de 24 horas del cruce paro físico × sesión Shop Floor × telemetría.'; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'grafana_ucepsa_ro' + ) THEN + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_stop_shopfloor_slices_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_stop_shopfloor_candidates_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_shadow_review_queue_shopfloor_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_shopfloor_session_stop_summary_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_stop_shopfloor_diagnostics_v1 + TO grafana_ucepsa_ro; + END IF; +END +$$; + +COMMIT;