fix(ucepsa): exclude pending Shop Floor planning dates
This commit is contained in:
parent
0ed49a3838
commit
86fc845a5e
@ -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.
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user