feat(ucepsa): add Odoo Shop Floor sessions shadow
This commit is contained in:
parent
43795f8038
commit
73544c0a85
@ -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.
|
||||
132
ucepsa/edge-oee-demo/ops/manage_odoo_shopfloor_sync_v033.sh
Normal file
132
ucepsa/edge-oee-demo/ops/manage_odoo_shopfloor_sync_v033.sh
Normal 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
|
||||
@ -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;
|
||||
@ -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;
|
||||
529
ucepsa/edge-oee-demo/tools/odoo_shopfloor_session_admin.py
Normal file
529
ucepsa/edge-oee-demo/tools/odoo_shopfloor_session_admin.py
Normal 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())
|
||||
1866
ucepsa/edge-oee-demo/tools/odoo_shopfloor_session_sync.py
Normal file
1866
ucepsa/edge-oee-demo/tools/odoo_shopfloor_session_sync.py
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user