fix(ucepsa): exclude pending Shop Floor planning dates

This commit is contained in:
Victor Fraile Garcia 2026-07-19 10:39:01 +02:00
parent 0ed49a3838
commit 86fc845a5e
4 changed files with 450 additions and 13 deletions

View File

@ -0,0 +1,57 @@
# UCEPSA — Guard READY/PENDING Shop Floor v0.3.3.1
## Hallazgo
Durante la primera sincronización continua apareció:
```text
workorder 434
state = ready
production_state = progress
date_start = 17/07/2026
date_finished = 04/09/2026
```
Odoo puede poblar esas fechas como planificación antes de que exista una sesión
real de operaria. La v0.3.3 las interpretó como intervalo y mostró 1.182 horas
de cobertura ficticia.
## Riesgo
El modo sigue siendo SHADOW y las sesiones aún no alimentan candidatos de paro,
por lo que no hubo impacto oficial. Aun así, un workorder `READY/PENDING` no debe
aportar:
- contexto productivo;
- horas de cobertura;
- atribución individual;
- cola de revisión de sesiones reales.
## Corrección
La v0.3.3.1:
1. conserva las fechas originales de Odoo en
`mv_hot.odoo_shopfloor_workorder_current`;
2. pone `started_at` y `ended_at` de la sesión a `NULL` mientras el estado no sea
`progress` o `done`;
3. exige `OPEN` o `CLOSED` para `context_time_usable`;
4. excluye `PENDING` de la cola de revisión;
5. excluye `PENDING` del cálculo de horas;
6. mantiene `official_eligible=false`.
## Orden de despliegue
1. Detener temporalmente el sincronizador.
2. Aplicar migración 105.
3. Instalar el sincronizador v0.3.3.1.
4. Refrescar explícitamente el workorder 434.
5. Validar.
6. Arrancar de nuevo el sincronizador.
## Observación de producción del lunes
Cuando 434 u otro workorder pase de `ready` a `progress`, el sincronizador
registrará la transición. Después debe comprobarse si Odoo actualiza
`date_start` al inicio real o conserva la fecha planificada. Esa observación
determinará la regla definitiva de inicio para v0.3.4.

View File

@ -0,0 +1,113 @@
\pset pager off
\echo '=== 1. Estado del sincronizador ==='
SELECT
tenant,
site,
sync_name,
last_success_at,
last_odoo_cursor_at,
last_processed_count,
last_error,
updated_at
FROM mv_loss_intelligence.odoo_shopfloor_sync_state
WHERE sync_name = 'odoo_shopfloor_sessions_v033';
\echo '=== 2. Workorder 434 no es sesión productiva ==='
SELECT
odoo_workorder_id,
machine_id,
production_order,
workorder_state,
production_state,
session_status,
started_at,
ended_at,
confidence,
context_time_usable,
individual_attribution_eligible,
official_eligible
FROM mv_reports_ucepsa_prod.li_odoo_shopfloor_sessions_v1
WHERE odoo_workorder_id = 434;
\echo '=== 3. Workorder 434 no aparece como contexto ==='
SELECT count(*) AS pending_context_rows
FROM
mv_reports_ucepsa_prod
.li_odoo_shopfloor_context_intervals_v1
WHERE odoo_workorder_id = 434;
\echo '=== 4. Workorder 434 no aparece en revisión de sesiones ==='
SELECT count(*) AS pending_review_rows
FROM
mv_reports_ucepsa_prod
.li_odoo_shopfloor_session_review_queue_v1
WHERE odoo_workorder_id = 434;
\echo '=== 5. READY conserva fechas fuente en CURRENT ==='
SELECT
odoo_workorder_id,
workorder_state,
workorder_started_at,
workorder_ended_at,
observed_at,
updated_at
FROM mv_hot.odoo_shopfloor_workorder_current
WHERE odoo_workorder_id = 434;
\echo '=== 6. Cobertura CORT-01 sin horas ficticias ==='
SELECT *
FROM
mv_reports_ucepsa_prod
.li_odoo_shopfloor_session_coverage_v1
WHERE machine_id = 'CORT-01';
\echo '=== 7. Sesiones conocidas no cambian ==='
SELECT
odoo_workorder_id,
production_order,
session_status,
started_at,
ended_at,
operator_employee_id,
operator_candidate_employee_id,
declared_quantity,
declared_quantity_candidate,
confidence,
context_time_usable,
official_eligible
FROM mv_reports_ucepsa_prod.li_odoo_shopfloor_sessions_v1
WHERE odoo_workorder_id IN (87, 810)
ORDER BY odoo_workorder_id;
\echo '=== 8. Invariantes ==='
SELECT
count(*) FILTER (
WHERE official_eligible
) AS official_rows,
count(*) FILTER (
WHERE session_status = 'PENDING'
AND (
started_at IS NOT NULL
OR ended_at IS NOT NULL
)
) AS pending_rows_with_session_time,
count(*) FILTER (
WHERE ended_at IS NOT NULL
AND started_at IS NOT NULL
AND ended_at < started_at
) AS invalid_intervals
FROM mv_loss_intelligence.odoo_shopfloor_sessions;
\echo '=== 9. Cola oficial 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,223 @@
BEGIN;
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_odoo_shopfloor_sessions_v1
AS
SELECT
s.shopfloor_session_id,
s.tenant,
s.site,
s.session_key,
s.production_chain_key,
s.machine_id,
s.workcenter_id,
s.workcenter_name,
s.odoo_production_id,
s.production_order,
s.odoo_workorder_id,
s.workorder_name,
s.workorder_state,
s.production_state,
s.origin,
s.product_id,
s.product_name,
s.product_uom_id,
s.product_uom_name,
s.started_at,
s.ended_at,
s.session_status,
s.operator_employee_id,
s.operator_name,
s.operator_candidate_employee_id,
s.operator_candidate_name,
s.operator_resolution,
s.operator_confidence,
s.operator_confirmation_status,
s.planned_quantity,
s.declared_quantity,
s.declared_quantity_candidate,
s.posted_quantity,
s.remaining_planned_quantity,
s.quantity_status,
s.quantity_confirmation_status,
s.confidence,
s.official_eligible,
(
(
s.session_status = 'OPEN'
AND s.started_at IS NOT NULL
AND s.ended_at IS NULL
)
OR (
s.session_status = 'CLOSED'
AND s.started_at IS NOT NULL
AND s.ended_at IS NOT NULL
)
) AS context_time_usable,
(
(
(
s.session_status = 'OPEN'
AND s.started_at IS NOT NULL
AND s.ended_at IS NULL
)
OR (
s.session_status = 'CLOSED'
AND s.started_at IS NOT NULL
AND s.ended_at IS NOT NULL
)
)
AND s.operator_employee_id IS NOT NULL
AND s.operator_confirmation_status IN (
'EVIDENCE_CONFIRMED',
'CONFIRMED_HUMAN'
)
) AS individual_attribution_eligible,
s.evidence_json,
s.reviewed_by,
s.reviewed_at,
s.review_notes,
s.source_observed_at,
s.source_odoo_write_date,
s.created_at,
s.updated_at
FROM mv_loss_intelligence.odoo_shopfloor_sessions s;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_odoo_shopfloor_sessions_v1 IS
'Sesiones canónicas Odoo Shop Floor SHADOW. READY/PENDING no aporta tiempo productivo aunque Odoo traiga fechas planificadas.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_odoo_shopfloor_session_review_queue_v1
AS
SELECT
row_number() OVER (
ORDER BY
CASE
WHEN s.session_status = 'OPEN' THEN 0
ELSE 1
END,
COALESCE(
s.ended_at,
s.started_at,
s.updated_at
) DESC,
s.shopfloor_session_id DESC
) AS review_rank,
s.*
FROM mv_reports_ucepsa_prod.li_odoo_shopfloor_sessions_v1 s
WHERE s.session_status IN ('OPEN', 'CLOSED')
AND (
s.operator_confirmation_status IN (
'PENDING_HUMAN',
'UNRESOLVED',
'REJECTED'
)
OR s.quantity_confirmation_status IN (
'PENDING_HUMAN',
'UNRESOLVED'
)
);
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_odoo_shopfloor_session_review_queue_v1 IS
'Cola SHADOW de sesiones OPEN/CLOSED con operaria o cantidad pendiente. Excluye workorders READY/PENDING.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_odoo_shopfloor_session_coverage_v1
AS
SELECT
s.tenant,
s.site,
s.machine_id,
count(*) AS session_count,
count(*) FILTER (
WHERE s.session_status = 'OPEN'
) AS open_session_count,
count(*) FILTER (
WHERE s.session_status = 'CLOSED'
) AS closed_session_count,
count(*) FILTER (
WHERE s.session_status IN ('OPEN', 'CLOSED')
AND s.operator_confirmation_status IN (
'EVIDENCE_CONFIRMED',
'CONFIRMED_HUMAN'
)
) AS operator_confirmed_count,
count(*) FILTER (
WHERE s.session_status IN ('OPEN', 'CLOSED')
AND s.operator_confirmation_status =
'PENDING_HUMAN'
) AS operator_pending_count,
count(*) FILTER (
WHERE s.session_status IN ('OPEN', 'CLOSED')
AND s.quantity_confirmation_status IN (
'EVIDENCE_CONFIRMED',
'CONFIRMED_HUMAN'
)
) AS quantity_confirmed_count,
count(*) FILTER (
WHERE s.session_status IN ('OPEN', 'CLOSED')
AND s.quantity_confirmation_status =
'PENDING_HUMAN'
) AS quantity_pending_count,
COALESCE(
round(
(
sum(
EXTRACT(
EPOCH FROM (
COALESCE(s.ended_at, now())
- s.started_at
)
)
) FILTER (
WHERE (
s.session_status = 'OPEN'
AND s.started_at IS NOT NULL
AND s.ended_at IS NULL
)
OR (
s.session_status = 'CLOSED'
AND s.started_at IS NOT NULL
AND s.ended_at IS NOT NULL
)
)
)::numeric / 3600,
2
),
0::numeric
) AS covered_hours
FROM mv_loss_intelligence.odoo_shopfloor_sessions s
GROUP BY
s.tenant,
s.site,
s.machine_id;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_odoo_shopfloor_session_coverage_v1 IS
'Cobertura de sesiones OPEN/CLOSED. Las fechas planificadas de READY/PENDING no suman horas.';
UPDATE mv_loss_intelligence.odoo_shopfloor_sessions
SET
started_at = NULL,
ended_at = NULL,
confidence = 'LOW',
evidence_json = evidence_json || jsonb_build_object(
'_mesavault_guard_v0331',
jsonb_build_object(
'reason',
'READY/PENDING dates are planning metadata, not session time',
'guarded_at',
now()
)
),
updated_at = now()
WHERE session_status = 'PENDING'
AND (
started_at IS NOT NULL
OR ended_at IS NOT NULL
OR confidence <> 'LOW'
);
COMMIT;

View File

@ -24,7 +24,7 @@ import psycopg2
from psycopg2.extras import Json, RealDictCursor
VERSION = "0.3.3"
VERSION = "0.3.3.1"
SYNC_NAME = "odoo_shopfloor_sessions_v033"
RUNNING = True
@ -851,21 +851,36 @@ def build_records(
output_moves,
)
started_at = parse_odoo_datetime(
source_workorder_started_at = parse_odoo_datetime(
workorder.get("date_start")
)
ended_at = parse_odoo_datetime(
source_workorder_ended_at = parse_odoo_datetime(
workorder.get("date_finished")
)
workorder_state = str(workorder.get("state") or "")
if workorder_state == "done" and started_at and ended_at:
# En Odoo, los workorders READY pueden traer fechas de
# planificación, incluso una fecha de fin futura. Esas fechas
# no representan una sesión real de operaria.
if (
workorder_state == "done"
and source_workorder_started_at
and source_workorder_ended_at
):
session_status = "CLOSED"
elif workorder_state == "progress" and started_at:
started_at = source_workorder_started_at
ended_at = source_workorder_ended_at
elif (
workorder_state == "progress"
and source_workorder_started_at
):
session_status = "OPEN"
started_at = source_workorder_started_at
ended_at = None
else:
session_status = "PENDING"
started_at = None
ended_at = None
operator_confirmed = (
operator["operator_confirmation_status"]
@ -876,10 +891,15 @@ def build_records(
== "EVIDENCE_CONFIRMED"
)
complete_interval = (
started_at is not None
and (
(
session_status == "OPEN"
or ended_at is not None
and started_at is not None
and ended_at is None
)
or (
session_status == "CLOSED"
and started_at is not None
and ended_at is not None
)
)
@ -920,6 +940,13 @@ def build_records(
"output_moves": output_moves,
"operator_resolution": operator,
"quantity_resolution": quantity,
"source_workorder_datetimes": {
"date_start": source_workorder_started_at,
"date_finished": source_workorder_ended_at,
"semantic_rule": (
"READY dates are planning metadata, not session time"
),
},
"timezone_rule": (
"Odoo XML-RPC datetimes interpreted as UTC"
),
@ -937,6 +964,12 @@ def build_records(
"product_id": product_id,
"started_at": started_at,
"ended_at": ended_at,
"source_workorder_started_at": (
source_workorder_started_at
),
"source_workorder_ended_at": (
source_workorder_ended_at
),
"operator": operator,
"quantity": quantity,
"source_odoo_write_date": parse_odoo_datetime(
@ -980,6 +1013,12 @@ def build_records(
"product_uom_name": uom_name,
"started_at": started_at,
"ended_at": ended_at,
"source_workorder_started_at": (
source_workorder_started_at
),
"source_workorder_ended_at": (
source_workorder_ended_at
),
"session_status": session_status,
**operator,
**quantity,
@ -1059,10 +1098,15 @@ def preserve_human_reviews(
merged["quantity_status"] = "FINAL_POSTED"
interval_complete = (
merged.get("started_at") is not None
and (
(
merged.get("session_status") == "OPEN"
or merged.get("ended_at") is not None
and merged.get("started_at") is not None
and merged.get("ended_at") is None
)
or (
merged.get("session_status") == "CLOSED"
and merged.get("started_at") is not None
and merged.get("ended_at") is not None
)
)
operator_final = merged.get(
@ -1250,8 +1294,8 @@ def upsert_record(
%(planned_quantity)s,
%(qty_producing)s,
%(qty_produced)s,
%(started_at)s,
%(ended_at)s,
%(source_workorder_started_at)s,
%(source_workorder_ended_at)s,
%(production_started_at)s,
%(production_ended_at)s,
%(productivity_employee_ids)s,