feat(ucepsa): attribute stops to Shop Floor sessions shadow

This commit is contained in:
Victor Fraile Garcia 2026-07-20 11:16:37 +02:00
parent 86fc845a5e
commit 48fb79d452
3 changed files with 1275 additions and 0 deletions

View File

@ -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.

View File

@ -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;

View File

@ -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;