fix(ucepsa): separate eligible and observed stop minutes

This commit is contained in:
Victor Fraile Garcia 2026-07-20 11:21:31 +02:00
parent 48fb79d452
commit d1f1b9a6c8
3 changed files with 370 additions and 0 deletions

View File

@ -0,0 +1,94 @@
# UCEPSA — Semántica del resumen de paros Shop Floor v0.3.4.1
## Hallazgo
La validación de v0.3.4 mostró para el workorder 340:
```text
physical_stop_count = 31
eligible_stop_count = 28
eligible_stop_minutes = 55,25
```
El diagnóstico descomponía esos 55 minutos en:
```text
CANDIDATE_GROUP 32,88 min
CANDIDATE_INDIVIDUAL 17,51 min
EXCLUDED_NOISE 0,11 min
OPEN_MONITORING 4,81 min
```
La vista de resumen sumaba todos los minutos observados en la columna
`eligible_stop_minutes`, aunque el importe económico sí excluía correctamente
el ruido y el paro abierto.
## Corrección
`eligible_stop_minutes` pasa a incluir únicamente filas con:
```text
technically_eligible = true
```
Se añaden columnas separadas:
- `observed_stop_minutes`
- `telemetry_covered_stop_minutes`
- `candidate_group_minutes`
- `candidate_individual_minutes`
- `excluded_noise_minutes`
- `open_monitoring_minutes`
- `blocked_minutes`
No se modifica la vista de candidatos, la cola SHADOW, la cola oficial ni los
datos raw.
## Resultado esperado para la sesión 340
A la hora de la validación inicial, aproximadamente:
```text
observed_stop_minutes 55,3
eligible_stop_minutes 50,4
candidate_group_minutes 32,9
candidate_individual 17,5
excluded_noise 0,1
open_monitoring 4,8
shadow_capacity_loss_eur 30,2
```
El paro abierto puede seguir creciendo hasta que la máquina vuelva a arrancar,
por lo que los valores exactos pueden variar.
## Despliegue
No es necesario detener servicios.
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/107_fix_shopfloor_stop_summary_semantics_v0341.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_summary_semantics_v0341.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
eligible_decomposition_errors = 0
summary_candidate_mismatches = 0
open_or_noise_inflation_errors = 0
official_summary_rows = 0
official_operator_queue_rows = 0
```

View File

@ -0,0 +1,118 @@
\pset pager off
\echo '=== 1. Columnas semánticas v0.3.4.1 ==='
SELECT
column_name,
data_type,
ordinal_position
FROM information_schema.columns
WHERE table_schema = 'mv_reports_ucepsa_prod'
AND table_name =
'li_shopfloor_session_stop_summary_v1'
ORDER BY ordinal_position;
\echo '=== 2. Resumen corregido de la sesión 340 ==='
SELECT
shopfloor_session_id,
machine_id,
production_order,
odoo_workorder_id,
session_status,
operator_name,
physical_stop_count,
eligible_stop_count,
observed_stop_minutes,
telemetry_covered_stop_minutes,
eligible_stop_minutes,
candidate_group_minutes,
candidate_individual_minutes,
excluded_noise_minutes,
open_monitoring_minutes,
blocked_minutes,
shadow_capacity_loss_eur,
official_ledger_eligible
FROM
mv_reports_ucepsa_prod
.li_shopfloor_session_stop_summary_v1
WHERE odoo_workorder_id = 340;
\echo '=== 3. Eligible = grupo + individual ==='
SELECT count(*) AS eligible_decomposition_errors
FROM
mv_reports_ucepsa_prod
.li_shopfloor_session_stop_summary_v1
WHERE abs(
eligible_stop_minutes
- (
candidate_group_minutes
+ candidate_individual_minutes
)
) > 0.02;
\echo '=== 4. Resumen coincide con candidatos técnicamente elegibles ==='
WITH expected AS (
SELECT
shopfloor_session_id,
round(
sum(eligible_duration_s) FILTER (
WHERE technically_eligible
) / 60.0,
2
) AS expected_eligible_minutes,
count(
DISTINCT source_stop_id
) FILTER (
WHERE technically_eligible
) AS expected_eligible_count
FROM
mv_reports_ucepsa_prod
.li_stop_shopfloor_candidates_v1
WHERE shopfloor_session_id IS NOT NULL
GROUP BY shopfloor_session_id
)
SELECT count(*) AS summary_candidate_mismatches
FROM expected e
JOIN
mv_reports_ucepsa_prod
.li_shopfloor_session_stop_summary_v1 s
ON s.shopfloor_session_id =
e.shopfloor_session_id
WHERE abs(
s.eligible_stop_minutes
- coalesce(e.expected_eligible_minutes, 0)
) > 0.02
OR s.eligible_stop_count
<> e.expected_eligible_count;
\echo '=== 5. Abiertos y ruido no inflan eligible_stop_minutes ==='
SELECT count(*) AS open_or_noise_inflation_errors
FROM
mv_reports_ucepsa_prod
.li_shopfloor_session_stop_summary_v1
WHERE eligible_stop_minutes
> observed_stop_minutes + 0.02
OR eligible_stop_minutes
+ excluded_noise_minutes
+ open_monitoring_minutes
+ blocked_minutes
> observed_stop_minutes + 0.05;
\echo '=== 6. Nada oficial ==='
SELECT
count(*) FILTER (
WHERE official_ledger_eligible
) AS official_summary_rows
FROM
mv_reports_ucepsa_prod
.li_shopfloor_session_stop_summary_v1;
\echo '=== 7. Cola oficial original sigue vacía ==='
SELECT count(*) AS official_operator_queue_rows
FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v2;

View File

@ -0,0 +1,158 @@
BEGIN;
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,
COALESCE(
ROUND(
SUM(c.eligible_duration_s) FILTER (
WHERE c.technically_eligible
) / 60.0,
2
),
0::numeric
) 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,
ROUND(
SUM(c.context_segment_duration_s) / 60.0,
2
) AS observed_stop_minutes,
ROUND(
SUM(c.telemetry_covered_duration_s) / 60.0,
2
) AS telemetry_covered_stop_minutes,
COALESCE(
ROUND(
SUM(c.eligible_duration_s) FILTER (
WHERE c.eligibility_status =
'CANDIDATE_GROUP'
) / 60.0,
2
),
0::numeric
) AS candidate_group_minutes,
COALESCE(
ROUND(
SUM(c.eligible_duration_s) FILTER (
WHERE c.eligibility_status =
'CANDIDATE_INDIVIDUAL'
) / 60.0,
2
),
0::numeric
) AS candidate_individual_minutes,
COALESCE(
ROUND(
SUM(c.eligible_duration_s) FILTER (
WHERE c.eligibility_status =
'EXCLUDED_NOISE'
) / 60.0,
2
),
0::numeric
) AS excluded_noise_minutes,
COALESCE(
ROUND(
SUM(c.eligible_duration_s) FILTER (
WHERE c.eligibility_status =
'OPEN_MONITORING'
) / 60.0,
2
),
0::numeric
) AS open_monitoring_minutes,
COALESCE(
ROUND(
SUM(c.context_segment_duration_s) FILTER (
WHERE c.eligibility_status LIKE
'BLOCKED_%'
) / 60.0,
2
),
0::numeric
) AS blocked_minutes
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 por sesión Shop Floor. eligible_stop_minutes incluye solo paros cerrados técnicamente elegibles; ruido, abiertos y bloqueados se muestran por separado.';
COMMIT;