feat(ucepsa): add Odoo Shop Floor sessions shadow

This commit is contained in:
Victor Fraile Garcia 2026-07-19 10:10:54 +02:00
parent 43795f8038
commit 73544c0a85
6 changed files with 3591 additions and 0 deletions

View File

@ -0,0 +1,244 @@
# UCEPSA — Odoo Shop Floor Sessions SHADOW v0.3.3
## Objetivo
Construir sesiones canónicas de producción a partir del flujo real de Odoo
Shop Floor sin pedir un segundo login a la operaria y sin alterar Odoo, balizas,
RevPi, WISE, la cola oficial ni el Loss Ledger.
## Evidencia base
### Workorder 87
```text
WH/MO/00081-001
CORT-02
11:48:31 → 13:48:58 hora local
operaria 43 / M. Angeles Martinez
85 kg contabilizados
```
La operaria está confirmada por dos filas de
`mrp.workcenter.productivity`.
### Workorder 810
```text
WH/MO/00081-002
CORT-02
14:01:56 → 15:50:59 hora local
candidata 20 / MARIA CIBÚ
76 kg candidatos
```
La persona y la cantidad quedan `PENDING_HUMAN`. El intervalo de sesión,
máquina, orden y workorder sí están confirmados.
## Alcance de v0.3.3
Crea:
- snapshot actual por workorder;
- eventos secuenciales por transición;
- sesión canónica por workorder;
- resolución de operaria con confianza;
- separación entre cantidad candidata y contabilizada;
- cola de revisión SHADOW;
- contexto paralelo `ODOO_SHOPFLOOR`;
- herramienta posterior de confirmación humana.
No cambia:
- `production_context_sessions`;
- vistas de candidatos de paro v0.3.1;
- cola oficial;
- Odoo;
- MQTT de balizas;
- RevPi;
- WISE;
- balizas.
La asociación de paros a las sesiones nuevas se hará en una versión posterior,
cuando la captura haya sido validada en operación continua.
## Reglas de operaria
1. Una única persona real en productividad:
- operaria confirmada;
- confianza alta.
2. Sin productividad y una única persona real asignada, excluyendo perfiles
técnicos:
- candidata;
- confianza media;
- confirmación humana pendiente.
3. Más de una persona real o ninguna evidencia:
- sin resolver;
- confianza baja.
`employee_id=35 / CORTADORAS` se excluye porque es el perfil técnico compartido
de la tablet.
## Reglas de cantidad
- Producción `done` y cantidad contabilizada:
`FINAL_POSTED`.
- Producción `to_close` con `qty_producing`:
`PROVISIONAL_TO_CLOSE`.
- La cantidad candidata no se transforma en cantidad confirmada sin evidencia
de stock final o confirmación humana.
## Tiempo
Los datetimes XML-RPC de Odoo se interpretan como UTC.
La sesión usa:
```text
mrp.workorder.date_start
mrp.workorder.date_finished
```
No usa `duration` como reloj de presencia.
## Cadena productiva
La clave se obtiene quitando el sufijo de backorder:
```text
WH/MO/00081-001 → ODOO:WH/MO/00081
WH/MO/00081-002 → ODOO:WH/MO/00081
```
`parent_production_id` y `child_production_ids` no se usan para los relevos,
porque en este caso representan una relación con la fabricación del tubo.
## Despliegue
### 1. Aplicar migración
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/104_ucepsa_odoo_shopfloor_sessions_shadow_v033.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
No es necesario detener servicios.
### 2. Instalar herramientas en runtime
```bash
REPO=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
RUNTIME=/srv/mesavault/edge-oee-ucepsa
install -m 0755 \
"$REPO/tools/odoo_shopfloor_session_sync.py" \
"$RUNTIME/tools/odoo_shopfloor_session_sync.py"
install -m 0755 \
"$REPO/tools/odoo_shopfloor_session_admin.py" \
"$RUNTIME/tools/odoo_shopfloor_session_admin.py"
install -m 0755 \
"$REPO/ops/manage_odoo_shopfloor_sync_v033.sh" \
"$RUNTIME/ops/manage_odoo_shopfloor_sync_v033.sh"
```
### 3. Ensayo de los dos workorders conocidos
```bash
cd /srv/mesavault/edge-oee-ucepsa
ops/manage_odoo_shopfloor_sync_v033.sh dry-run-known
```
Debe mostrar:
```text
87 → operaria 43 / HIGH / 85 kg FINAL_POSTED
810 → candidata 20 / MEDIUM / 76 kg PROVISIONAL_TO_CLOSE
```
### 4. Backfill
```bash
ops/manage_odoo_shopfloor_sync_v033.sh backfill-known
```
### 5. Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_odoo_shopfloor_sessions_shadow_v033.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$VALIDATION"
```
Resultados obligatorios:
```text
official_rows = 0
invalid_session_intervals = 0
known_backfill_ok = true
consecutive_duplicate_event_count = 0
generated_production_context_rows = 0
official_operator_queue_rows = 0
```
### 6. Activar sincronización continua
Solo después de validar el backfill:
```bash
cd /srv/mesavault/edge-oee-ucepsa
ops/manage_odoo_shopfloor_sync_v033.sh start
ops/manage_odoo_shopfloor_sync_v033.sh status
docker logs --tail 100 mv_ucepsa_odoo_shopfloor_session_sync
```
## Confirmación posterior de Juan Pablo
No ejecutar hasta recibir confirmación.
### Confirmar operaria candidata
```bash
docker exec -i mv_ucepsa_odoo_order_state_pg_sink \
python tools/odoo_shopfloor_session_admin.py \
confirm-operator \
--workorder-id 810 \
--use-candidate \
--confirmed-by "Juan Pablo" \
--notes "Confirmado el segundo tramo de CORT-02."
```
### Confirmar cantidad
```bash
docker exec -i mv_ucepsa_odoo_order_state_pg_sink \
python tools/odoo_shopfloor_session_admin.py \
confirm-quantity \
--workorder-id 810 \
--quantity 76 \
--confirmed-by "Juan Pablo" \
--notes "Confirmados 76 kg declarados en el segundo tramo."
```
La sincronización posterior conserva las confirmaciones humanas.
## Parada y rollback operativo
Parar el sincronizador:
```bash
/srv/mesavault/edge-oee-ucepsa/ops/manage_odoo_shopfloor_sync_v033.sh stop
```
La parada no afecta Odoo, captura de máquina, MQTT ni balizas.
Las tablas nuevas pueden mantenerse para diagnóstico. No se recomienda borrarlas
durante el piloto.

View File

@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-status}"
ROOT="${SHOPFLOOR_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
CONTAINER="${SHOPFLOOR_SYNC_CONTAINER:-mv_ucepsa_odoo_shopfloor_session_sync}"
NETWORK="${SHOPFLOOR_DOCKER_NETWORK:-mv_ucepsa_net}"
INTERVAL="${SHOPFLOOR_SYNC_INTERVAL_S:-15}"
LOOKBACK="${SHOPFLOOR_SYNC_LOOKBACK_HOURS:-168}"
OUTPUT="${SHOPFLOOR_SYNC_OUTPUT:-/tmp/ucepsa_odoo_shopfloor_session_sync.jsonl}"
cd "$ROOT"
image() {
docker inspect \
mv_ucepsa_odoo_order_state_pg_sink \
--format '{{.Config.Image}}'
}
start_sync() {
local sync_image
sync_image="$(image)"
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
docker run -d \
--name "$CONTAINER" \
--restart unless-stopped \
--network "$NETWORK" \
--env-file .env \
-e ODOO_DB_OVERRIDE=ucepsa_prod \
-v "$ROOT:/app:ro" \
-v /tmp:/probe-output \
-w /app \
--entrypoint python \
"$sync_image" \
tools/odoo_shopfloor_session_sync.py \
--watch \
--interval "$INTERVAL" \
--lookback-hours "$LOOKBACK" \
--output "/probe-output/$(basename "$OUTPUT")"
docker ps --format 'table {{.Names}}\t{{.Status}}' \
| grep -E "$CONTAINER|NAMES"
}
one_shot() {
local sync_image
sync_image="$(image)"
docker run --rm -i \
--network "$NETWORK" \
--env-file .env \
-e ODOO_DB_OVERRIDE=ucepsa_prod \
-v "$ROOT:/app:ro" \
-w /app \
--entrypoint python \
"$sync_image" \
tools/odoo_shopfloor_session_sync.py \
--once \
--lookback-hours "$LOOKBACK"
}
dry_run_known() {
local sync_image
sync_image="$(image)"
docker run --rm -i \
--network "$NETWORK" \
--env-file .env \
-e ODOO_DB_OVERRIDE=ucepsa_prod \
-v "$ROOT:/app:ro" \
-w /app \
--entrypoint python \
"$sync_image" \
tools/odoo_shopfloor_session_sync.py \
--once \
--dry-run \
--workorder-id 87 \
--workorder-id 810
}
backfill_known() {
local sync_image
sync_image="$(image)"
docker run --rm -i \
--network "$NETWORK" \
--env-file .env \
-e ODOO_DB_OVERRIDE=ucepsa_prod \
-v "$ROOT:/app:ro" \
-w /app \
--entrypoint python \
"$sync_image" \
tools/odoo_shopfloor_session_sync.py \
--once \
--workorder-id 87 \
--workorder-id 810
}
case "$ACTION" in
start)
start_sync
;;
stop)
docker stop "$CONTAINER"
;;
restart)
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
start_sync
;;
status)
docker ps -a --format 'table {{.Names}}\t{{.Status}}' \
| grep -E "$CONTAINER|NAMES"
;;
logs)
docker logs --tail 200 -f "$CONTAINER"
;;
once)
one_shot
;;
dry-run-known)
dry_run_known
;;
backfill-known)
backfill_known
;;
*)
echo "Uso: $0 {start|stop|restart|status|logs|once|dry-run-known|backfill-known}" >&2
exit 2
;;
esac

View File

@ -0,0 +1,207 @@
\pset pager off
\echo '=== 1. Objetos v0.3.3 ==='
SELECT
to_regclass(
'mv_hot.odoo_shopfloor_workorder_current'
) AS current_table,
to_regclass(
'mv_hot.odoo_shopfloor_workorder_events'
) AS events_table,
to_regclass(
'mv_loss_intelligence.odoo_shopfloor_sessions'
) AS sessions_table,
to_regclass(
'mv_reports_ucepsa_prod.li_odoo_shopfloor_sessions_v1'
) AS sessions_view,
to_regclass(
'mv_reports_ucepsa_prod.li_odoo_shopfloor_context_intervals_v1'
) AS context_view,
to_regclass(
'mv_reports_ucepsa_prod.li_odoo_shopfloor_session_review_queue_v1'
) AS review_queue_view;
\echo '=== 2. SHADOW obligatorio ==='
SELECT
count(*) FILTER (
WHERE official_eligible
) AS official_rows,
count(*) AS total_sessions
FROM mv_loss_intelligence.odoo_shopfloor_sessions;
\echo '=== 3. Intervalos inválidos ==='
SELECT count(*) AS invalid_session_intervals
FROM mv_loss_intelligence.odoo_shopfloor_sessions
WHERE ended_at IS NOT NULL
AND started_at IS NOT NULL
AND ended_at < started_at;
\echo '=== 4. Sesiones conocidas 87 y 810 ==='
SELECT
odoo_workorder_id,
machine_id,
production_order,
production_chain_key,
started_at,
ended_at,
session_status,
operator_employee_id,
operator_name,
operator_candidate_employee_id,
operator_candidate_name,
operator_resolution,
operator_confidence,
operator_confirmation_status,
planned_quantity,
declared_quantity,
declared_quantity_candidate,
posted_quantity,
remaining_planned_quantity,
quantity_status,
quantity_confirmation_status,
confidence,
official_eligible
FROM mv_loss_intelligence.odoo_shopfloor_sessions
WHERE odoo_workorder_id IN (87, 810)
ORDER BY odoo_workorder_id;
\echo '=== 5. Validación esperada de los dos tramos ==='
WITH expected AS (
SELECT
count(*) FILTER (
WHERE odoo_workorder_id = 87
AND machine_id = 'CORT-02'
AND operator_employee_id = 43
AND operator_confirmation_status =
'EVIDENCE_CONFIRMED'
AND declared_quantity = 85
AND posted_quantity = 85
AND quantity_status = 'FINAL_POSTED'
AND official_eligible = false
) AS workorder_87_ok,
count(*) FILTER (
WHERE odoo_workorder_id = 810
AND machine_id = 'CORT-02'
AND operator_employee_id IS NULL
AND operator_candidate_employee_id = 20
AND operator_confirmation_status =
'PENDING_HUMAN'
AND declared_quantity IS NULL
AND declared_quantity_candidate = 76
AND posted_quantity = 0
AND quantity_status =
'PROVISIONAL_TO_CLOSE'
AND official_eligible = false
) AS workorder_810_ok
FROM mv_loss_intelligence.odoo_shopfloor_sessions
WHERE odoo_workorder_id IN (87, 810)
)
SELECT
workorder_87_ok,
workorder_810_ok,
(
workorder_87_ok = 1
AND workorder_810_ok = 1
) AS known_backfill_ok
FROM expected;
\echo '=== 6. Contextos paralelos ==='
SELECT
context_session_id,
machine_id,
source_type,
source_ref,
external_order_ref,
odoo_workorder_id,
operator_employee_id,
operator_candidate_employee_id,
effective_from,
effective_to,
status,
confidence,
context_priority,
context_time_usable,
individual_attribution_eligible,
official_eligible
FROM
mv_reports_ucepsa_prod
.li_odoo_shopfloor_context_intervals_v1
WHERE odoo_workorder_id IN (87, 810)
ORDER BY odoo_workorder_id;
\echo '=== 7. Cola de revisión SHADOW ==='
SELECT
review_rank,
machine_id,
production_order,
odoo_workorder_id,
operator_candidate_employee_id,
operator_candidate_name,
operator_confirmation_status,
declared_quantity_candidate,
quantity_confirmation_status,
confidence
FROM
mv_reports_ucepsa_prod
.li_odoo_shopfloor_session_review_queue_v1
ORDER BY review_rank
LIMIT 50;
\echo '=== 8. Eventos consecutivos duplicados ==='
WITH ordered AS (
SELECT
event_id,
odoo_workorder_id,
state_hash,
lag(state_hash) OVER (
PARTITION BY tenant, site, odoo_workorder_id
ORDER BY event_id
) AS previous_hash
FROM mv_hot.odoo_shopfloor_workorder_events
)
SELECT count(*) AS consecutive_duplicate_event_count
FROM ordered
WHERE state_hash = previous_hash;
\echo '=== 9. No se han insertado contextos oficiales ==='
SELECT count(*) AS generated_production_context_rows
FROM mv_loss_intelligence.production_context_sessions
WHERE source_type = 'ODOO_SHOPFLOOR'
AND evidence_json ->> 'source' =
'odoo_shopfloor_session_sync';
\echo '=== 10. Cola oficial sigue vacía ==='
SELECT count(*) AS official_operator_queue_rows
FROM mv_reports_ucepsa_prod.li_operator_stop_queue_v2;
\echo '=== 11. Cobertura ==='
SELECT *
FROM
mv_reports_ucepsa_prod
.li_odoo_shopfloor_session_coverage_v1
ORDER BY machine_id;
\echo '=== 12. Sincronización ==='
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
ORDER BY tenant, site, sync_name;

View File

@ -0,0 +1,613 @@
BEGIN;
CREATE SCHEMA IF NOT EXISTS mv_hot;
CREATE SCHEMA IF NOT EXISTS mv_loss_intelligence;
CREATE SCHEMA IF NOT EXISTS mv_reports_ucepsa_prod;
CREATE TABLE IF NOT EXISTS
mv_hot.odoo_shopfloor_workorder_current (
tenant text NOT NULL,
site text NOT NULL,
machine_id text NOT NULL,
workcenter_id bigint NOT NULL,
workcenter_name text,
odoo_workorder_id bigint NOT NULL,
workorder_name text,
workorder_state text,
odoo_production_id bigint,
production_order text,
production_chain_key text,
production_state text,
origin text,
product_id bigint,
product_name text,
product_uom_id bigint,
product_uom_name text,
planned_quantity numeric(18, 4),
qty_producing numeric(18, 4),
qty_produced numeric(18, 4),
workorder_started_at timestamptz,
workorder_ended_at timestamptz,
production_started_at timestamptz,
production_ended_at timestamptz,
productivity_employee_ids bigint[] NOT NULL
DEFAULT '{}'::bigint[],
assigned_employee_ids bigint[] NOT NULL
DEFAULT '{}'::bigint[],
technical_employee_ids bigint[] NOT NULL
DEFAULT '{}'::bigint[],
output_move_ids bigint[] NOT NULL
DEFAULT '{}'::bigint[],
output_done_quantity numeric(18, 4),
state_hash text NOT NULL,
payload_json jsonb NOT NULL,
odoo_write_date timestamptz,
observed_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (
tenant,
site,
odoo_workorder_id
)
);
CREATE INDEX IF NOT EXISTS
idx_odoo_shopfloor_current_machine_state
ON mv_hot.odoo_shopfloor_workorder_current (
tenant,
site,
machine_id,
workorder_state
);
CREATE INDEX IF NOT EXISTS
idx_odoo_shopfloor_current_write_date
ON mv_hot.odoo_shopfloor_workorder_current (
tenant,
site,
odoo_write_date
);
CREATE TABLE IF NOT EXISTS
mv_hot.odoo_shopfloor_workorder_events (
event_id bigserial PRIMARY KEY,
tenant text NOT NULL,
site text NOT NULL,
machine_id text NOT NULL,
workcenter_id bigint NOT NULL,
odoo_workorder_id bigint NOT NULL,
odoo_production_id bigint,
production_order text,
production_chain_key text,
event_kind text NOT NULL,
effective_event_at timestamptz,
observed_at timestamptz NOT NULL DEFAULT now(),
state_hash text NOT NULL,
payload_json jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS
idx_odoo_shopfloor_events_workorder_last
ON mv_hot.odoo_shopfloor_workorder_events (
tenant,
site,
odoo_workorder_id,
event_id DESC
);
CREATE INDEX IF NOT EXISTS
idx_odoo_shopfloor_events_machine_effective
ON mv_hot.odoo_shopfloor_workorder_events (
tenant,
site,
machine_id,
effective_event_at
);
CREATE TABLE IF NOT EXISTS
mv_loss_intelligence.odoo_shopfloor_technical_employees (
tenant text NOT NULL,
site text NOT NULL,
employee_id bigint NOT NULL,
employee_name text,
reason text NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (
tenant,
site,
employee_id
)
);
INSERT INTO
mv_loss_intelligence.odoo_shopfloor_technical_employees (
tenant,
site,
employee_id,
employee_name,
reason,
active
)
VALUES (
'ucepsa',
'ucepsa_onpremise',
35,
'CORTADORAS',
'Perfil técnico compartido de la tablet; no representa a una operaria.',
true
)
ON CONFLICT (
tenant,
site,
employee_id
)
DO UPDATE SET
employee_name = EXCLUDED.employee_name,
reason = EXCLUDED.reason,
active = EXCLUDED.active,
updated_at = now();
CREATE TABLE IF NOT EXISTS
mv_loss_intelligence.odoo_shopfloor_sessions (
shopfloor_session_id bigserial PRIMARY KEY,
tenant text NOT NULL,
site text NOT NULL,
session_key text NOT NULL,
production_chain_key text,
machine_id text NOT NULL,
workcenter_id bigint NOT NULL,
workcenter_name text,
odoo_production_id bigint,
production_order text,
odoo_workorder_id bigint NOT NULL,
workorder_name text,
workorder_state text,
production_state text,
origin text,
product_id bigint,
product_name text,
product_uom_id bigint,
product_uom_name text,
started_at timestamptz,
ended_at timestamptz,
session_status text NOT NULL,
operator_employee_id bigint,
operator_name text,
operator_candidate_employee_id bigint,
operator_candidate_name text,
operator_resolution text NOT NULL,
operator_confidence text NOT NULL,
operator_confirmation_status text NOT NULL,
planned_quantity numeric(18, 4),
declared_quantity numeric(18, 4),
declared_quantity_candidate numeric(18, 4),
posted_quantity numeric(18, 4),
remaining_planned_quantity numeric(18, 4),
quantity_status text NOT NULL,
quantity_confirmation_status text NOT NULL,
confidence text NOT NULL,
official_eligible boolean NOT NULL DEFAULT false,
evidence_json jsonb NOT NULL,
reviewed_by text,
reviewed_at timestamptz,
review_notes text,
source_observed_at timestamptz,
source_odoo_write_date timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ux_odoo_shopfloor_sessions_workorder
UNIQUE (
tenant,
site,
odoo_workorder_id
),
CONSTRAINT ux_odoo_shopfloor_sessions_key
UNIQUE (
tenant,
site,
session_key
),
CONSTRAINT ck_odoo_shopfloor_session_interval
CHECK (
ended_at IS NULL
OR started_at IS NULL
OR ended_at >= started_at
),
CONSTRAINT ck_odoo_shopfloor_session_status
CHECK (
session_status IN (
'OPEN',
'CLOSED',
'PENDING'
)
),
CONSTRAINT ck_odoo_shopfloor_operator_confidence
CHECK (
operator_confidence IN (
'HIGH',
'MEDIUM',
'LOW'
)
),
CONSTRAINT ck_odoo_shopfloor_operator_confirmation
CHECK (
operator_confirmation_status IN (
'EVIDENCE_CONFIRMED',
'PENDING_HUMAN',
'CONFIRMED_HUMAN',
'UNRESOLVED',
'REJECTED'
)
),
CONSTRAINT ck_odoo_shopfloor_quantity_status
CHECK (
quantity_status IN (
'FINAL_POSTED',
'POSTED_PARTIAL',
'PROVISIONAL_TO_CLOSE',
'PENDING',
'NOT_APPLICABLE'
)
),
CONSTRAINT ck_odoo_shopfloor_quantity_confirmation
CHECK (
quantity_confirmation_status IN (
'EVIDENCE_CONFIRMED',
'PENDING_HUMAN',
'CONFIRMED_HUMAN',
'UNRESOLVED'
)
),
CONSTRAINT ck_odoo_shopfloor_confidence
CHECK (
confidence IN (
'HIGH',
'MEDIUM',
'LOW'
)
),
CONSTRAINT ck_odoo_shopfloor_shadow_only
CHECK (official_eligible = false)
);
CREATE INDEX IF NOT EXISTS
idx_odoo_shopfloor_sessions_machine_time
ON mv_loss_intelligence.odoo_shopfloor_sessions (
tenant,
site,
machine_id,
started_at,
ended_at
);
CREATE INDEX IF NOT EXISTS
idx_odoo_shopfloor_sessions_review
ON mv_loss_intelligence.odoo_shopfloor_sessions (
tenant,
site,
operator_confirmation_status,
quantity_confirmation_status
);
CREATE TABLE IF NOT EXISTS
mv_loss_intelligence.odoo_shopfloor_session_reviews (
review_id bigserial PRIMARY KEY,
shopfloor_session_id bigint NOT NULL
REFERENCES
mv_loss_intelligence.odoo_shopfloor_sessions (
shopfloor_session_id
)
ON DELETE CASCADE,
review_type text NOT NULL,
previous_values jsonb NOT NULL,
new_values jsonb NOT NULL,
reviewed_by text NOT NULL,
reviewed_at timestamptz NOT NULL DEFAULT now(),
notes text
);
CREATE INDEX IF NOT EXISTS
idx_odoo_shopfloor_reviews_session
ON mv_loss_intelligence.odoo_shopfloor_session_reviews (
shopfloor_session_id,
reviewed_at DESC
);
CREATE TABLE IF NOT EXISTS
mv_loss_intelligence.odoo_shopfloor_sync_state (
tenant text NOT NULL,
site text NOT NULL,
sync_name text NOT NULL,
last_success_at timestamptz,
last_odoo_cursor_at timestamptz,
last_processed_count integer,
last_error text,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (
tenant,
site,
sync_name
)
);
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.started_at IS NOT NULL
AND (
s.session_status = 'OPEN'
OR s.ended_at IS NOT NULL
)
) AS context_time_usable,
(
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 en SHADOW. Tiempo de sesión y atribución individual se gobiernan por separado.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_odoo_shopfloor_context_intervals_v1
AS
SELECT
s.shopfloor_session_id AS context_session_id,
s.tenant,
s.site,
s.machine_id,
'ODOO_SHOPFLOOR'::text AS source_type,
s.session_key AS source_ref,
s.production_order AS external_order_ref,
s.odoo_production_id,
s.odoo_workorder_id,
s.product_id,
NULL::text AS product_default_code,
s.product_name,
s.operator_employee_id,
s.operator_name,
s.operator_candidate_employee_id,
s.operator_candidate_name,
s.started_at AS effective_from,
s.ended_at AS effective_to,
s.session_status AS status,
'AUTHORIZED'::text AS authorization_status,
s.confidence,
false AS official_eligible,
100::integer AS context_priority,
s.context_time_usable,
s.individual_attribution_eligible,
s.operator_resolution,
s.operator_confirmation_status,
s.quantity_status,
s.quantity_confirmation_status,
s.production_chain_key,
s.evidence_json
FROM mv_reports_ucepsa_prod.li_odoo_shopfloor_sessions_v1 s
WHERE s.context_time_usable;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_odoo_shopfloor_context_intervals_v1 IS
'Contextos paralelos ODOO_SHOPFLOOR. No sustituyen todavía las vistas v1/v0.3.1 ni alimentan la cola oficial.';
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.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 con operaria o cantidad pendiente. No es una cola para operarias ni modifica Odoo.';
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.operator_confirmation_status IN (
'EVIDENCE_CONFIRMED',
'CONFIRMED_HUMAN'
)
) AS operator_confirmed_count,
count(*) FILTER (
WHERE s.operator_confirmation_status =
'PENDING_HUMAN'
) AS operator_pending_count,
count(*) FILTER (
WHERE s.quantity_confirmation_status IN (
'EVIDENCE_CONFIRMED',
'CONFIRMED_HUMAN'
)
) AS quantity_confirmed_count,
count(*) FILTER (
WHERE s.quantity_confirmation_status =
'PENDING_HUMAN'
) AS quantity_pending_count,
round(
sum(
EXTRACT(
EPOCH FROM (
COALESCE(s.ended_at, now())
- s.started_at
)
)
) FILTER (
WHERE s.started_at IS NOT NULL
)::numeric / 3600,
2
) 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 Odoo Shop Floor por máquina y estado de resolución.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_odoo_shopfloor_vs_order_context_v1
AS
SELECT
s.shopfloor_session_id,
s.machine_id,
s.production_order,
s.odoo_workorder_id,
s.started_at,
s.ended_at,
s.session_status,
s.operator_confirmation_status,
s.quantity_confirmation_status,
count(o.id) AS overlapping_order_interval_count,
round(
COALESCE(
sum(
EXTRACT(
EPOCH FROM (
LEAST(
COALESCE(s.ended_at, now()),
COALESCE(o.effective_to, now())
)
- GREATEST(
s.started_at,
o.effective_from
)
)
)
) FILTER (
WHERE o.id IS NOT NULL
AND LEAST(
COALESCE(s.ended_at, now()),
COALESCE(o.effective_to, now())
) > GREATEST(
s.started_at,
o.effective_from
)
),
0
)::numeric,
2
) AS overlapping_order_seconds,
count(*) FILTER (
WHERE o.id IS NOT NULL
AND o.workorder_id IS DISTINCT FROM
s.odoo_workorder_id::text
) AS mismatching_workorder_intervals
FROM mv_reports_ucepsa_prod.li_odoo_shopfloor_sessions_v1 s
LEFT JOIN
mv_reports_ucepsa_prod.li_order_state_intervals_v1 o
ON o.tenant = s.tenant
AND o.site = s.site
AND o.machine_id = s.machine_id
AND o.effective_from <
COALESCE(s.ended_at, now())
AND COALESCE(o.effective_to, now()) >
s.started_at
WHERE s.started_at IS NOT NULL
GROUP BY
s.shopfloor_session_id,
s.machine_id,
s.production_order,
s.odoo_workorder_id,
s.started_at,
s.ended_at,
s.session_status,
s.operator_confirmation_status,
s.quantity_confirmation_status;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_odoo_shopfloor_vs_order_context_v1 IS
'Diagnóstico paralelo entre sesiones Shop Floor y el histórico order_state. No altera la atribución de paros.';
COMMIT;

View File

@ -0,0 +1,529 @@
#!/usr/bin/env python3
"""Revisión humana de sesiones Odoo Shop Floor SHADOW."""
from __future__ import annotations
import argparse
import json
import os
import sys
from decimal import Decimal
from typing import Any
import psycopg2
from psycopg2.extras import Json, RealDictCursor
def env_first(*names: str, default: str | None = None) -> str | None:
for name in names:
value = os.getenv(name)
if value not in (None, ""):
return value
return default
def connect_pg():
database = env_first("PGDATABASE", "POSTGRES_DB")
user = env_first("PGUSER", "POSTGRES_USER")
password = env_first("PGPASSWORD", "POSTGRES_PASSWORD")
if not database or not user or not password:
raise RuntimeError("Faltan credenciales PostgreSQL")
connection = psycopg2.connect(
host=env_first(
"PGHOST",
default="mv_ucepsa_postgres_hot",
),
port=int(env_first("PGPORT", default="5432") or "5432"),
dbname=database,
user=user,
password=password,
)
connection.autocommit = False
return connection
def tenant_site() -> tuple[str, str]:
return (
env_first("TENANT", "TENANT_OVERRIDE", default="ucepsa")
or "ucepsa",
env_first(
"SITE",
"SITE_OVERRIDE",
default="ucepsa_onpremise",
)
or "ucepsa_onpremise",
)
def load_session(cur, workorder_id: int) -> dict[str, Any]:
tenant, site = tenant_site()
cur.execute(
"""
SELECT *
FROM mv_loss_intelligence.odoo_shopfloor_sessions
WHERE tenant = %s
AND site = %s
AND odoo_workorder_id = %s
FOR UPDATE
""",
(tenant, site, workorder_id),
)
row = cur.fetchone()
if not row:
raise RuntimeError(
f"No existe sesión para workorder {workorder_id}"
)
return dict(row)
def recalculate_confidence(row: dict[str, Any]) -> str:
interval_ok = (
row.get("started_at") is not None
and (
row.get("session_status") == "OPEN"
or row.get("ended_at") is not None
)
)
operator_ok = row.get(
"operator_confirmation_status"
) in {
"EVIDENCE_CONFIRMED",
"CONFIRMED_HUMAN",
}
quantity_ok = row.get(
"quantity_confirmation_status"
) in {
"EVIDENCE_CONFIRMED",
"CONFIRMED_HUMAN",
}
if interval_ok and operator_ok and quantity_ok:
return "HIGH"
if interval_ok:
return "MEDIUM"
return "LOW"
def audit(
cur,
session_id: int,
review_type: str,
before: dict[str, Any],
after: dict[str, Any],
reviewed_by: str,
notes: str | None,
) -> None:
cur.execute(
"""
INSERT INTO
mv_loss_intelligence.odoo_shopfloor_session_reviews (
shopfloor_session_id,
review_type,
previous_values,
new_values,
reviewed_by,
notes
)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
session_id,
review_type,
Json(before),
Json(after),
reviewed_by,
notes,
),
)
def command_list(connection, args: argparse.Namespace) -> None:
tenant, site = tenant_site()
where = ""
params: list[Any] = [tenant, site]
if args.pending:
where = """
AND (
operator_confirmation_status IN (
'PENDING_HUMAN',
'UNRESOLVED',
'REJECTED'
)
OR quantity_confirmation_status IN (
'PENDING_HUMAN',
'UNRESOLVED'
)
)
"""
with connection.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT
shopfloor_session_id,
machine_id,
production_order,
odoo_workorder_id,
started_at,
ended_at,
operator_employee_id,
operator_name,
operator_candidate_employee_id,
operator_candidate_name,
operator_resolution,
operator_confirmation_status,
declared_quantity,
declared_quantity_candidate,
posted_quantity,
quantity_status,
quantity_confirmation_status,
confidence,
official_eligible
FROM
mv_loss_intelligence.odoo_shopfloor_sessions
WHERE tenant = %s
AND site = %s
{where}
ORDER BY
COALESCE(ended_at, started_at, updated_at) DESC,
shopfloor_session_id DESC
LIMIT %s
""",
params + [args.limit],
)
rows = cur.fetchall()
print(
json.dumps(
rows,
ensure_ascii=False,
indent=2,
default=str,
)
)
def command_confirm_operator(
connection,
args: argparse.Namespace,
) -> None:
with connection.cursor(cursor_factory=RealDictCursor) as cur:
row = load_session(cur, args.workorder_id)
before = {
key: row.get(key)
for key in (
"operator_employee_id",
"operator_name",
"operator_candidate_employee_id",
"operator_candidate_name",
"operator_resolution",
"operator_confidence",
"operator_confirmation_status",
"confidence",
)
}
employee_id = args.employee_id
employee_name = args.employee_name
if args.use_candidate:
employee_id = row.get(
"operator_candidate_employee_id"
)
employee_name = row.get(
"operator_candidate_name"
)
if employee_id is None:
raise RuntimeError(
"No existe empleado candidato y no se indicó "
"--employee-id."
)
row.update(
{
"operator_employee_id": int(employee_id),
"operator_name": employee_name,
"operator_candidate_employee_id": None,
"operator_candidate_name": None,
"operator_resolution": "HUMAN_CONFIRMED",
"operator_confidence": "HIGH",
"operator_confirmation_status": "CONFIRMED_HUMAN",
}
)
row["confidence"] = recalculate_confidence(row)
cur.execute(
"""
UPDATE
mv_loss_intelligence.odoo_shopfloor_sessions
SET
operator_employee_id = %s,
operator_name = %s,
operator_candidate_employee_id = NULL,
operator_candidate_name = NULL,
operator_resolution = 'HUMAN_CONFIRMED',
operator_confidence = 'HIGH',
operator_confirmation_status =
'CONFIRMED_HUMAN',
confidence = %s,
reviewed_by = %s,
reviewed_at = now(),
review_notes = %s,
updated_at = now()
WHERE shopfloor_session_id = %s
""",
(
int(employee_id),
employee_name,
row["confidence"],
args.confirmed_by,
args.notes,
row["shopfloor_session_id"],
),
)
after = {
key: row.get(key)
for key in before
}
audit(
cur,
row["shopfloor_session_id"],
"CONFIRM_OPERATOR",
before,
after,
args.confirmed_by,
args.notes,
)
connection.commit()
print(json.dumps(after, ensure_ascii=False, indent=2, default=str))
def command_confirm_quantity(
connection,
args: argparse.Namespace,
) -> None:
with connection.cursor(cursor_factory=RealDictCursor) as cur:
row = load_session(cur, args.workorder_id)
before = {
key: row.get(key)
for key in (
"declared_quantity",
"declared_quantity_candidate",
"quantity_status",
"quantity_confirmation_status",
"remaining_planned_quantity",
"confidence",
)
}
quantity = (
Decimal(str(args.quantity))
if args.quantity is not None
else row.get("declared_quantity_candidate")
)
if quantity is None:
raise RuntimeError(
"No existe cantidad candidata y no se indicó "
"--quantity."
)
planned = row.get("planned_quantity")
remaining = None
if planned is not None:
remaining = max(
Decimal(str(planned)) - Decimal(str(quantity)),
Decimal("0"),
)
row.update(
{
"declared_quantity": quantity,
"declared_quantity_candidate": None,
"quantity_status": "FINAL_POSTED",
"quantity_confirmation_status": "CONFIRMED_HUMAN",
"remaining_planned_quantity": remaining,
}
)
row["confidence"] = recalculate_confidence(row)
cur.execute(
"""
UPDATE
mv_loss_intelligence.odoo_shopfloor_sessions
SET
declared_quantity = %s,
declared_quantity_candidate = NULL,
quantity_status = 'FINAL_POSTED',
quantity_confirmation_status =
'CONFIRMED_HUMAN',
remaining_planned_quantity = %s,
confidence = %s,
reviewed_by = %s,
reviewed_at = now(),
review_notes = %s,
updated_at = now()
WHERE shopfloor_session_id = %s
""",
(
quantity,
remaining,
row["confidence"],
args.confirmed_by,
args.notes,
row["shopfloor_session_id"],
),
)
after = {
key: row.get(key)
for key in before
}
audit(
cur,
row["shopfloor_session_id"],
"CONFIRM_QUANTITY",
before,
after,
args.confirmed_by,
args.notes,
)
connection.commit()
print(json.dumps(after, ensure_ascii=False, indent=2, default=str))
def command_reject_operator(
connection,
args: argparse.Namespace,
) -> None:
with connection.cursor(cursor_factory=RealDictCursor) as cur:
row = load_session(cur, args.workorder_id)
before = {
key: row.get(key)
for key in (
"operator_employee_id",
"operator_name",
"operator_candidate_employee_id",
"operator_candidate_name",
"operator_resolution",
"operator_confidence",
"operator_confirmation_status",
"confidence",
)
}
row.update(
{
"operator_employee_id": None,
"operator_name": None,
"operator_candidate_employee_id": None,
"operator_candidate_name": None,
"operator_resolution": "HUMAN_REJECTED",
"operator_confidence": "LOW",
"operator_confirmation_status": "REJECTED",
}
)
row["confidence"] = recalculate_confidence(row)
cur.execute(
"""
UPDATE
mv_loss_intelligence.odoo_shopfloor_sessions
SET
operator_employee_id = NULL,
operator_name = NULL,
operator_candidate_employee_id = NULL,
operator_candidate_name = NULL,
operator_resolution = 'HUMAN_REJECTED',
operator_confidence = 'LOW',
operator_confirmation_status = 'REJECTED',
confidence = %s,
reviewed_by = %s,
reviewed_at = now(),
review_notes = %s,
updated_at = now()
WHERE shopfloor_session_id = %s
""",
(
row["confidence"],
args.confirmed_by,
args.notes,
row["shopfloor_session_id"],
),
)
after = {
key: row.get(key)
for key in before
}
audit(
cur,
row["shopfloor_session_id"],
"REJECT_OPERATOR_CANDIDATE",
before,
after,
args.confirmed_by,
args.notes,
)
connection.commit()
print(json.dumps(after, ensure_ascii=False, indent=2, default=str))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Administra revisiones humanas de sesiones "
"Odoo Shop Floor SHADOW."
)
)
sub = parser.add_subparsers(dest="command", required=True)
list_parser = sub.add_parser("list")
list_parser.add_argument("--pending", action="store_true")
list_parser.add_argument("--limit", type=int, default=100)
operator = sub.add_parser("confirm-operator")
operator.add_argument("--workorder-id", type=int, required=True)
operator.add_argument("--employee-id", type=int)
operator.add_argument("--employee-name")
operator.add_argument("--use-candidate", action="store_true")
operator.add_argument("--confirmed-by", required=True)
operator.add_argument("--notes")
quantity = sub.add_parser("confirm-quantity")
quantity.add_argument("--workorder-id", type=int, required=True)
quantity.add_argument("--quantity", type=Decimal)
quantity.add_argument("--confirmed-by", required=True)
quantity.add_argument("--notes")
reject = sub.add_parser("reject-operator-candidate")
reject.add_argument("--workorder-id", type=int, required=True)
reject.add_argument("--confirmed-by", required=True)
reject.add_argument("--notes")
return parser
def main() -> int:
args = build_parser().parse_args()
connection = connect_pg()
try:
if args.command == "list":
command_list(connection, args)
elif args.command == "confirm-operator":
command_confirm_operator(connection, args)
elif args.command == "confirm-quantity":
command_confirm_quantity(connection, args)
elif args.command == "reject-operator-candidate":
command_reject_operator(connection, args)
else:
raise RuntimeError(f"Comando no soportado: {args.command}")
return 0
except Exception as exc:
connection.rollback()
print(
f"ERROR {type(exc).__name__}: {exc}",
file=sys.stderr,
)
return 1
finally:
connection.close()
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff