feat(ucepsa): add read-only Shop Floor session probe
This commit is contained in:
parent
4586e29afe
commit
36d67d99b0
@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
mv_ucepsa_shopfloor_session_probe:
|
||||||
|
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
|
||||||
|
container_name: mv_ucepsa_shopfloor_session_probe
|
||||||
|
profiles:
|
||||||
|
- diagnostic
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
ODOO_DB_OVERRIDE: ucepsa_prod
|
||||||
|
PYTHONUNBUFFERED: "1"
|
||||||
|
PYTHONDONTWRITEBYTECODE: "1"
|
||||||
|
volumes:
|
||||||
|
- ./:/app:ro
|
||||||
|
- /tmp:/probe-output
|
||||||
|
working_dir: /app
|
||||||
|
command:
|
||||||
|
- python
|
||||||
|
- tools/odoo_shopfloor_session_probe.py
|
||||||
|
- --machine
|
||||||
|
- CORT-00
|
||||||
|
- --watch
|
||||||
|
- --duration-minutes
|
||||||
|
- "30"
|
||||||
|
- --interval
|
||||||
|
- "2"
|
||||||
|
- --output
|
||||||
|
- /probe-output/odoo_shopfloor_session_probe.jsonl
|
||||||
|
networks:
|
||||||
|
- mv_ucepsa_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mv_ucepsa_net:
|
||||||
|
external: true
|
||||||
|
name: mv_ucepsa_net
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
# UCEPSA — Descubrimiento de sesiones Odoo Shop Floor v0.1
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Identificar, sin escribir en Odoo, qué registros y campos cambian cuando una operaria:
|
||||||
|
|
||||||
|
1. se identifica en Shop Floor;
|
||||||
|
2. inicia una orden en una cortadora;
|
||||||
|
3. pausa la orden;
|
||||||
|
4. la reanuda;
|
||||||
|
5. termina o abandona la orden;
|
||||||
|
6. es sustituida por otra operaria.
|
||||||
|
|
||||||
|
El resultado servirá para diseñar el publicador productivo de sesiones Shop Floor de MESAVAULT.
|
||||||
|
|
||||||
|
## Hipótesis técnica que se debe verificar
|
||||||
|
|
||||||
|
La fuente candidata principal es `mrp.workcenter.productivity`.
|
||||||
|
|
||||||
|
Una sesión activa debería aparecer como una fila con:
|
||||||
|
|
||||||
|
- `workorder_id`;
|
||||||
|
- `workcenter_id`;
|
||||||
|
- `date_start`;
|
||||||
|
- `date_end` vacío mientras sigue activa;
|
||||||
|
- `user_id` u otro campo de identidad;
|
||||||
|
- una pérdida/tipo compatible con tiempo productivo.
|
||||||
|
|
||||||
|
No se dará esta hipótesis por válida hasta observarla en UCEPSA PROD.
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
La sonda:
|
||||||
|
|
||||||
|
- solo usa XML-RPC de lectura;
|
||||||
|
- no invoca `create`, `write`, `unlink` ni botones de Odoo;
|
||||||
|
- no publica MQTT;
|
||||||
|
- no modifica PostgreSQL;
|
||||||
|
- no altera balizas;
|
||||||
|
- no modifica Shop Floor.
|
||||||
|
|
||||||
|
## Secuencia recomendada
|
||||||
|
|
||||||
|
### 1. Descubrimiento estático
|
||||||
|
|
||||||
|
Ejecutar `--discover` y conservar la salida.
|
||||||
|
|
||||||
|
### 2. Instantánea inicial
|
||||||
|
|
||||||
|
Con CORT-00 sin una prueba en curso, ejecutar `--snapshot`.
|
||||||
|
|
||||||
|
### 3. Vigilancia
|
||||||
|
|
||||||
|
Iniciar `--watch` durante 30 minutos.
|
||||||
|
|
||||||
|
### 4. Acciones en Shop Floor
|
||||||
|
|
||||||
|
Anotar la hora local exacta de cada acción:
|
||||||
|
|
||||||
|
- T0: operaria se identifica;
|
||||||
|
- T1: abre CORT-00;
|
||||||
|
- T2: inicia la orden;
|
||||||
|
- T3: pausa;
|
||||||
|
- T4: reanuda;
|
||||||
|
- T5: finaliza;
|
||||||
|
- T6: otra operaria inicia o se incorpora, si el flujo lo permite.
|
||||||
|
|
||||||
|
Esperar al menos 20–30 segundos entre acciones para distinguirlas con claridad.
|
||||||
|
|
||||||
|
### 5. Cierre
|
||||||
|
|
||||||
|
Detener la sonda o esperar a que termine y copiar:
|
||||||
|
|
||||||
|
- salida de descubrimiento;
|
||||||
|
- instantánea inicial;
|
||||||
|
- JSONL de vigilancia;
|
||||||
|
- lista manual T0–T6;
|
||||||
|
- orden, producto y máquina utilizados.
|
||||||
|
|
||||||
|
## Criterios de aceptación
|
||||||
|
|
||||||
|
Se considera fuente oficial viable si se puede reconstruir de forma inequívoca:
|
||||||
|
|
||||||
|
- operaria;
|
||||||
|
- máquina;
|
||||||
|
- orden de trabajo;
|
||||||
|
- orden de fabricación;
|
||||||
|
- inicio;
|
||||||
|
- pausa/cierre;
|
||||||
|
- reanudación;
|
||||||
|
- relevo.
|
||||||
|
|
||||||
|
Debe confirmarse además que una sesión abierta tiene un estado detectable y que una pausa o fin deja una marca histórica, no solo un estado actual.
|
||||||
662
ucepsa/edge-oee-demo/tools/odoo_shopfloor_session_probe.py
Normal file
662
ucepsa/edge-oee-demo/tools/odoo_shopfloor_session_probe.py
Normal file
@ -0,0 +1,662 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Read-only probe for Odoo Shop Floor operator/work-order timing.
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Discover the actual Odoo models/fields available in UCEPSA PROD.
|
||||||
|
- Observe which records change when an operator signs in, starts, pauses,
|
||||||
|
resumes, and finishes a work order in Shop Floor.
|
||||||
|
- Produce a JSONL audit trail without writing anything to Odoo.
|
||||||
|
|
||||||
|
This script performs only XML-RPC reads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import xmlrpc.client
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
MACHINE_MAP = {
|
||||||
|
"CORT-00": "Cortadora 0",
|
||||||
|
"CORT-01": "Cortadora 01",
|
||||||
|
"CORT-02": "Cortadora 02",
|
||||||
|
}
|
||||||
|
|
||||||
|
RELEVANT_FIELDS = {
|
||||||
|
"mrp.workcenter.productivity": [
|
||||||
|
"id",
|
||||||
|
"display_name",
|
||||||
|
"description",
|
||||||
|
"workorder_id",
|
||||||
|
"workcenter_id",
|
||||||
|
"date_start",
|
||||||
|
"date_end",
|
||||||
|
"duration",
|
||||||
|
"user_id",
|
||||||
|
"employee_id",
|
||||||
|
"loss_id",
|
||||||
|
"loss_type",
|
||||||
|
"company_id",
|
||||||
|
"create_date",
|
||||||
|
"write_date",
|
||||||
|
],
|
||||||
|
"mrp.workorder": [
|
||||||
|
"id",
|
||||||
|
"display_name",
|
||||||
|
"name",
|
||||||
|
"state",
|
||||||
|
"workcenter_id",
|
||||||
|
"production_id",
|
||||||
|
"product_id",
|
||||||
|
"date_start",
|
||||||
|
"date_finished",
|
||||||
|
"duration",
|
||||||
|
"duration_expected",
|
||||||
|
"working_user_ids",
|
||||||
|
"last_working_user_id",
|
||||||
|
"is_user_working",
|
||||||
|
"time_ids",
|
||||||
|
"create_date",
|
||||||
|
"write_date",
|
||||||
|
],
|
||||||
|
"res.users": [
|
||||||
|
"id",
|
||||||
|
"display_name",
|
||||||
|
"name",
|
||||||
|
"login",
|
||||||
|
"active",
|
||||||
|
"employee_id",
|
||||||
|
"employee_ids",
|
||||||
|
"create_date",
|
||||||
|
"write_date",
|
||||||
|
],
|
||||||
|
"hr.employee": [
|
||||||
|
"id",
|
||||||
|
"display_name",
|
||||||
|
"name",
|
||||||
|
"active",
|
||||||
|
"user_id",
|
||||||
|
"create_date",
|
||||||
|
"write_date",
|
||||||
|
],
|
||||||
|
"mrp.workcenter.productivity.loss": [
|
||||||
|
"id",
|
||||||
|
"display_name",
|
||||||
|
"name",
|
||||||
|
"loss_type",
|
||||||
|
"manual",
|
||||||
|
"sequence",
|
||||||
|
"create_date",
|
||||||
|
"write_date",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_env(path: str = ".env") -> Dict[str, str]:
|
||||||
|
env: Dict[str, str] = {}
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
return env
|
||||||
|
for raw in p.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
env[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def odoo_datetime(dt: datetime) -> str:
|
||||||
|
return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def json_default(value: Any) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def connect_odoo(env: Dict[str, str]) -> Dict[str, Any]:
|
||||||
|
required = ["ODOO_URL", "ODOO_DB", "ODOO_USER", "ODOO_PASSWORD"]
|
||||||
|
missing = [key for key in required if not (os.getenv(key) or env.get(key))]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(f"Faltan variables Odoo: {', '.join(missing)}")
|
||||||
|
|
||||||
|
url = (os.getenv("ODOO_URL") or env["ODOO_URL"]).rstrip("/")
|
||||||
|
db = os.getenv("ODOO_DB_OVERRIDE") or os.getenv("ODOO_DB") or env["ODOO_DB"]
|
||||||
|
user = os.getenv("ODOO_USER") or env["ODOO_USER"]
|
||||||
|
password = os.getenv("ODOO_PASSWORD") or env["ODOO_PASSWORD"]
|
||||||
|
|
||||||
|
common = xmlrpc.client.ServerProxy(
|
||||||
|
f"{url}/xmlrpc/2/common",
|
||||||
|
allow_none=True,
|
||||||
|
)
|
||||||
|
uid = common.authenticate(db, user, password, {})
|
||||||
|
if not uid:
|
||||||
|
raise RuntimeError("Autenticación Odoo fallida")
|
||||||
|
|
||||||
|
models = xmlrpc.client.ServerProxy(
|
||||||
|
f"{url}/xmlrpc/2/object",
|
||||||
|
allow_none=True,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"db": db,
|
||||||
|
"user": user,
|
||||||
|
"password": password,
|
||||||
|
"uid": uid,
|
||||||
|
"models": models,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def execute_kw(
|
||||||
|
odoo: Dict[str, Any],
|
||||||
|
model: str,
|
||||||
|
method: str,
|
||||||
|
args: List[Any],
|
||||||
|
kwargs: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Any:
|
||||||
|
return odoo["models"].execute_kw(
|
||||||
|
odoo["db"],
|
||||||
|
odoo["uid"],
|
||||||
|
odoo["password"],
|
||||||
|
model,
|
||||||
|
method,
|
||||||
|
args,
|
||||||
|
kwargs or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def model_fields(odoo: Dict[str, Any], model: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return execute_kw(
|
||||||
|
odoo,
|
||||||
|
model,
|
||||||
|
"fields_get",
|
||||||
|
[],
|
||||||
|
{"attributes": ["string", "type", "relation", "required", "readonly"]},
|
||||||
|
)
|
||||||
|
except xmlrpc.client.Fault as exc:
|
||||||
|
print(
|
||||||
|
f"[WARN] No se puede consultar fields_get de {model}: {exc.faultString}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def existing_fields(
|
||||||
|
odoo: Dict[str, Any],
|
||||||
|
model: str,
|
||||||
|
wanted: Iterable[str],
|
||||||
|
) -> List[str]:
|
||||||
|
fields = model_fields(odoo, model)
|
||||||
|
return [field for field in wanted if field in fields]
|
||||||
|
|
||||||
|
|
||||||
|
def search_read(
|
||||||
|
odoo: Dict[str, Any],
|
||||||
|
model: str,
|
||||||
|
domain: List[Any],
|
||||||
|
wanted_fields: Iterable[str],
|
||||||
|
*,
|
||||||
|
limit: int = 200,
|
||||||
|
order: str = "id asc",
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
fields = existing_fields(odoo, model, wanted_fields)
|
||||||
|
if not fields:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return execute_kw(
|
||||||
|
odoo,
|
||||||
|
model,
|
||||||
|
"search_read",
|
||||||
|
[domain],
|
||||||
|
{
|
||||||
|
"fields": fields,
|
||||||
|
"limit": limit,
|
||||||
|
"order": order,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except xmlrpc.client.Fault as exc:
|
||||||
|
print(
|
||||||
|
f"[WARN] No se puede leer {model}: {exc.faultString}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def m2o_id(value: Any) -> Optional[int]:
|
||||||
|
if isinstance(value, list) and value:
|
||||||
|
try:
|
||||||
|
return int(value[0])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_workcenters(
|
||||||
|
odoo: Dict[str, Any],
|
||||||
|
machine_filter: Optional[str],
|
||||||
|
) -> Tuple[Dict[str, Dict[str, Any]], List[int]]:
|
||||||
|
selected = (
|
||||||
|
{machine_filter: MACHINE_MAP[machine_filter]}
|
||||||
|
if machine_filter
|
||||||
|
else MACHINE_MAP
|
||||||
|
)
|
||||||
|
result: Dict[str, Dict[str, Any]] = {}
|
||||||
|
ids: List[int] = []
|
||||||
|
|
||||||
|
for machine_id, workcenter_name in selected.items():
|
||||||
|
rows = search_read(
|
||||||
|
odoo,
|
||||||
|
"mrp.workcenter",
|
||||||
|
[("name", "=", workcenter_name)],
|
||||||
|
["id", "name", "display_name", "code", "active"],
|
||||||
|
limit=5,
|
||||||
|
order="id asc",
|
||||||
|
)
|
||||||
|
if len(rows) != 1:
|
||||||
|
result[machine_id] = {
|
||||||
|
"machine_id": machine_id,
|
||||||
|
"expected_name": workcenter_name,
|
||||||
|
"status": "NOT_FOUND" if not rows else "AMBIGUOUS",
|
||||||
|
"matches": rows,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
row = rows[0]
|
||||||
|
row["machine_id"] = machine_id
|
||||||
|
row["status"] = "OK"
|
||||||
|
result[machine_id] = row
|
||||||
|
ids.append(int(row["id"]))
|
||||||
|
|
||||||
|
return result, ids
|
||||||
|
|
||||||
|
|
||||||
|
def discover(
|
||||||
|
odoo: Dict[str, Any],
|
||||||
|
workcenters: Dict[str, Dict[str, Any]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
output: Dict[str, Any] = {
|
||||||
|
"captured_at": utc_now().isoformat(),
|
||||||
|
"odoo": {
|
||||||
|
"url": odoo["url"],
|
||||||
|
"db": odoo["db"],
|
||||||
|
"user": odoo["user"],
|
||||||
|
"uid": odoo["uid"],
|
||||||
|
},
|
||||||
|
"workcenters": workcenters,
|
||||||
|
"models": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for model, wanted in RELEVANT_FIELDS.items():
|
||||||
|
all_fields = model_fields(odoo, model)
|
||||||
|
output["models"][model] = {
|
||||||
|
"accessible": bool(all_fields),
|
||||||
|
"relevant_fields_present": {
|
||||||
|
field: all_fields[field]
|
||||||
|
for field in wanted
|
||||||
|
if field in all_fields
|
||||||
|
},
|
||||||
|
"relevant_fields_missing": [
|
||||||
|
field for field in wanted if field not in all_fields
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def collect_snapshot(
|
||||||
|
odoo: Dict[str, Any],
|
||||||
|
workcenter_ids: List[int],
|
||||||
|
lookback_hours: float,
|
||||||
|
) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
start = odoo_datetime(utc_now() - timedelta(hours=lookback_hours))
|
||||||
|
|
||||||
|
productivity_domain: List[Any] = [
|
||||||
|
("workcenter_id", "in", workcenter_ids),
|
||||||
|
"|",
|
||||||
|
("date_end", "=", False),
|
||||||
|
("date_start", ">=", start),
|
||||||
|
]
|
||||||
|
productivity_rows = search_read(
|
||||||
|
odoo,
|
||||||
|
"mrp.workcenter.productivity",
|
||||||
|
productivity_domain,
|
||||||
|
RELEVANT_FIELDS["mrp.workcenter.productivity"],
|
||||||
|
limit=1000,
|
||||||
|
order="id asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
workorder_domain: List[Any] = [
|
||||||
|
("workcenter_id", "in", workcenter_ids),
|
||||||
|
("state", "in", ["ready", "progress", "done"]),
|
||||||
|
"|",
|
||||||
|
("write_date", ">=", start),
|
||||||
|
("state", "=", "progress"),
|
||||||
|
]
|
||||||
|
workorder_rows = search_read(
|
||||||
|
odoo,
|
||||||
|
"mrp.workorder",
|
||||||
|
workorder_domain,
|
||||||
|
RELEVANT_FIELDS["mrp.workorder"],
|
||||||
|
limit=1000,
|
||||||
|
order="id asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_ids = sorted(
|
||||||
|
{
|
||||||
|
user_id
|
||||||
|
for row in productivity_rows
|
||||||
|
for user_id in [m2o_id(row.get("user_id"))]
|
||||||
|
if user_id
|
||||||
|
}
|
||||||
|
|
|
||||||
|
{
|
||||||
|
int(user_id)
|
||||||
|
for row in workorder_rows
|
||||||
|
for user_id in (row.get("working_user_ids") or [])
|
||||||
|
if isinstance(user_id, int)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
users = (
|
||||||
|
search_read(
|
||||||
|
odoo,
|
||||||
|
"res.users",
|
||||||
|
[("id", "in", user_ids)],
|
||||||
|
RELEVANT_FIELDS["res.users"],
|
||||||
|
limit=1000,
|
||||||
|
order="id asc",
|
||||||
|
)
|
||||||
|
if user_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
employee_ids = sorted(
|
||||||
|
{
|
||||||
|
employee_id
|
||||||
|
for row in users
|
||||||
|
for employee_id in (
|
||||||
|
([m2o_id(row.get("employee_id"))] if row.get("employee_id") else [])
|
||||||
|
+ [
|
||||||
|
int(item)
|
||||||
|
for item in (row.get("employee_ids") or [])
|
||||||
|
if isinstance(item, int)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if employee_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
employees = (
|
||||||
|
search_read(
|
||||||
|
odoo,
|
||||||
|
"hr.employee",
|
||||||
|
[("id", "in", employee_ids)],
|
||||||
|
RELEVANT_FIELDS["hr.employee"],
|
||||||
|
limit=1000,
|
||||||
|
order="id asc",
|
||||||
|
)
|
||||||
|
if employee_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
loss_ids = sorted(
|
||||||
|
{
|
||||||
|
loss_id
|
||||||
|
for row in productivity_rows
|
||||||
|
for loss_id in [m2o_id(row.get("loss_id"))]
|
||||||
|
if loss_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
losses = (
|
||||||
|
search_read(
|
||||||
|
odoo,
|
||||||
|
"mrp.workcenter.productivity.loss",
|
||||||
|
[("id", "in", loss_ids)],
|
||||||
|
RELEVANT_FIELDS["mrp.workcenter.productivity.loss"],
|
||||||
|
limit=1000,
|
||||||
|
order="id asc",
|
||||||
|
)
|
||||||
|
if loss_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mrp.workcenter.productivity": productivity_rows,
|
||||||
|
"mrp.workorder": workorder_rows,
|
||||||
|
"res.users": users,
|
||||||
|
"hr.employee": employees,
|
||||||
|
"mrp.workcenter.productivity.loss": losses,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_record(record: Dict[str, Any]) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
record,
|
||||||
|
sort_keys=True,
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=json_default,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def index_snapshot(
|
||||||
|
snapshot: Dict[str, List[Dict[str, Any]]],
|
||||||
|
) -> Dict[Tuple[str, int], str]:
|
||||||
|
indexed: Dict[Tuple[str, int], str] = {}
|
||||||
|
for model, rows in snapshot.items():
|
||||||
|
for row in rows:
|
||||||
|
row_id = row.get("id")
|
||||||
|
if isinstance(row_id, int):
|
||||||
|
indexed[(model, row_id)] = normalize_record(row)
|
||||||
|
return indexed
|
||||||
|
|
||||||
|
|
||||||
|
def append_jsonl(path: Path, payload: Dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(
|
||||||
|
json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=json_default,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def emit_event(
|
||||||
|
event_type: str,
|
||||||
|
model: str,
|
||||||
|
record: Dict[str, Any],
|
||||||
|
output_path: Path,
|
||||||
|
) -> None:
|
||||||
|
payload = {
|
||||||
|
"captured_at": utc_now().isoformat(),
|
||||||
|
"event_type": event_type,
|
||||||
|
"model": model,
|
||||||
|
"record": record,
|
||||||
|
}
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, default=json_default), flush=True)
|
||||||
|
append_jsonl(output_path, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def watch(
|
||||||
|
odoo: Dict[str, Any],
|
||||||
|
workcenter_ids: List[int],
|
||||||
|
*,
|
||||||
|
lookback_hours: float,
|
||||||
|
interval_s: float,
|
||||||
|
duration_minutes: float,
|
||||||
|
output_path: Path,
|
||||||
|
) -> None:
|
||||||
|
snapshot = collect_snapshot(odoo, workcenter_ids, lookback_hours)
|
||||||
|
previous = index_snapshot(snapshot)
|
||||||
|
|
||||||
|
for model, rows in snapshot.items():
|
||||||
|
for row in rows:
|
||||||
|
emit_event("BASELINE", model, row, output_path)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[INFO] Vigilancia iniciada. interval={interval_s}s "
|
||||||
|
f"duration={duration_minutes}min output={output_path}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
deadline = time.monotonic() + duration_minutes * 60
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
time.sleep(interval_s)
|
||||||
|
current_snapshot = collect_snapshot(odoo, workcenter_ids, lookback_hours)
|
||||||
|
current = index_snapshot(current_snapshot)
|
||||||
|
|
||||||
|
for model, rows in current_snapshot.items():
|
||||||
|
for row in rows:
|
||||||
|
key = (model, int(row["id"]))
|
||||||
|
serialized = normalize_record(row)
|
||||||
|
if key not in previous:
|
||||||
|
emit_event("CREATED", model, row, output_path)
|
||||||
|
elif previous[key] != serialized:
|
||||||
|
emit_event("UPDATED", model, row, output_path)
|
||||||
|
|
||||||
|
removed = set(previous) - set(current)
|
||||||
|
for model, row_id in sorted(removed):
|
||||||
|
emit_event(
|
||||||
|
"NO_LONGER_IN_WINDOW",
|
||||||
|
model,
|
||||||
|
{"id": row_id},
|
||||||
|
output_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
previous = current
|
||||||
|
|
||||||
|
print("[INFO] Vigilancia finalizada.", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Sonda read-only de sesiones Odoo Shop Floor"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--env-file",
|
||||||
|
default=".env",
|
||||||
|
help="Ruta del .env de UCEPSA",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--machine",
|
||||||
|
choices=sorted(MACHINE_MAP),
|
||||||
|
help="Limitar la prueba a una cortadora",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--discover",
|
||||||
|
action="store_true",
|
||||||
|
help="Mostrar modelos y campos disponibles",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--snapshot",
|
||||||
|
action="store_true",
|
||||||
|
help="Mostrar una instantánea de registros",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--watch",
|
||||||
|
action="store_true",
|
||||||
|
help="Vigilar cambios y escribir JSONL",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lookback-hours",
|
||||||
|
type=float,
|
||||||
|
default=24.0,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval",
|
||||||
|
type=float,
|
||||||
|
default=2.0,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--duration-minutes",
|
||||||
|
type=float,
|
||||||
|
default=30.0,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default="/tmp/odoo_shopfloor_session_probe.jsonl",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not (args.discover or args.snapshot or args.watch):
|
||||||
|
parser.error("Indica --discover, --snapshot o --watch")
|
||||||
|
|
||||||
|
env = read_env(args.env_file)
|
||||||
|
odoo = connect_odoo(env)
|
||||||
|
workcenters, workcenter_ids = get_workcenters(odoo, args.machine)
|
||||||
|
|
||||||
|
if not workcenter_ids:
|
||||||
|
print(json.dumps(workcenters, indent=2, ensure_ascii=False))
|
||||||
|
raise RuntimeError("No se ha encontrado ningún centro de trabajo válido")
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[INFO] Odoo conectado: url={odoo['url']} db={odoo['db']} "
|
||||||
|
f"user={odoo['user']} uid={odoo['uid']}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.discover:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
discover(odoo, workcenters),
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=json_default,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.snapshot:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"captured_at": utc_now().isoformat(),
|
||||||
|
"workcenters": workcenters,
|
||||||
|
"snapshot": collect_snapshot(
|
||||||
|
odoo,
|
||||||
|
workcenter_ids,
|
||||||
|
args.lookback_hours,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=json_default,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.watch:
|
||||||
|
watch(
|
||||||
|
odoo,
|
||||||
|
workcenter_ids,
|
||||||
|
lookback_hours=args.lookback_hours,
|
||||||
|
interval_s=args.interval,
|
||||||
|
duration_minutes=args.duration_minutes,
|
||||||
|
output_path=Path(args.output),
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[INFO] Interrumpido por usuario.", file=sys.stderr)
|
||||||
|
raise SystemExit(130)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
Loading…
x
Reference in New Issue
Block a user