diff --git a/ucepsa/edge-oee-demo/docs/runbooks/098_odoo_shopfloor_pending_guard_v0.3.3.1.md b/ucepsa/edge-oee-demo/docs/runbooks/098_odoo_shopfloor_pending_guard_v0.3.3.1.md new file mode 100644 index 0000000..d09d0fa --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/098_odoo_shopfloor_pending_guard_v0.3.3.1.md @@ -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. diff --git a/ucepsa/edge-oee-demo/ops/validate_odoo_shopfloor_pending_guard_v0331.sql b/ucepsa/edge-oee-demo/ops/validate_odoo_shopfloor_pending_guard_v0331.sql new file mode 100644 index 0000000..e7a16ca --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_odoo_shopfloor_pending_guard_v0331.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/sql/versions/105_guard_pending_shopfloor_sessions_v0331.sql b/ucepsa/edge-oee-demo/sql/versions/105_guard_pending_shopfloor_sessions_v0331.sql new file mode 100644 index 0000000..600746d --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/105_guard_pending_shopfloor_sessions_v0331.sql @@ -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; diff --git a/ucepsa/edge-oee-demo/tools/odoo_shopfloor_session_sync.py b/ucepsa/edge-oee-demo/tools/odoo_shopfloor_session_sync.py index d8644e8..9964f87 100644 --- a/ucepsa/edge-oee-demo/tools/odoo_shopfloor_session_sync.py +++ b/ucepsa/edge-oee-demo/tools/odoo_shopfloor_session_sync.py @@ -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,