feat(ucepsa): persist Odoo order_state for Grafana
This commit is contained in:
parent
cc951b713d
commit
7c5ad7f5a3
@ -0,0 +1,28 @@
|
||||
services:
|
||||
mv_ucepsa_odoo_order_state_pg_sink:
|
||||
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
|
||||
container_name: mv_ucepsa_odoo_order_state_pg_sink
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
TENANT: ucepsa
|
||||
SITE: ucepsa_onpremise
|
||||
MQTT_HOST: mv_ucepsa_mosquitto
|
||||
MQTT_PORT: 1883
|
||||
MQTT_TOPIC: vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/+
|
||||
PGHOST: mv_ucepsa_postgres_hot
|
||||
PGPORT: 5432
|
||||
PGDATABASE: ${POSTGRES_DB}
|
||||
PGUSER: ${POSTGRES_USER}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- ./tools:/app/tools:ro
|
||||
working_dir: /app
|
||||
command: python tools/order_state_pg_sink.py
|
||||
networks:
|
||||
- mv_ucepsa_net
|
||||
|
||||
networks:
|
||||
mv_ucepsa_net:
|
||||
external: true
|
||||
125
ucepsa/edge-oee-demo/sql/081_odoo_order_state_pg.sql
Normal file
125
ucepsa/edge-oee-demo/sql/081_odoo_order_state_pg.sql
Normal file
@ -0,0 +1,125 @@
|
||||
CREATE TABLE IF NOT EXISTS mv_hot.odoo_order_state_current (
|
||||
tenant text NOT NULL,
|
||||
site text NOT NULL,
|
||||
machine_id text NOT NULL,
|
||||
topic text,
|
||||
mqtt_ts timestamptz,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
source text,
|
||||
workcenter_id bigint,
|
||||
workcenter_name text,
|
||||
workcenter_display_name text,
|
||||
|
||||
order_present boolean,
|
||||
order_coherent boolean,
|
||||
odoo_state text,
|
||||
reason text,
|
||||
|
||||
workorder_id text,
|
||||
production_order text,
|
||||
product text,
|
||||
|
||||
waiting_count integer,
|
||||
ready_count integer,
|
||||
progress_count integer,
|
||||
|
||||
payload_hash text,
|
||||
payload_json jsonb NOT NULL,
|
||||
|
||||
PRIMARY KEY (tenant, site, machine_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_hot.odoo_order_state_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
tenant text NOT NULL,
|
||||
site text NOT NULL,
|
||||
machine_id text NOT NULL,
|
||||
topic text,
|
||||
mqtt_ts timestamptz,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
odoo_state text,
|
||||
order_present boolean,
|
||||
order_coherent boolean,
|
||||
production_order text,
|
||||
product text,
|
||||
reason text,
|
||||
|
||||
waiting_count integer,
|
||||
ready_count integer,
|
||||
progress_count integer,
|
||||
|
||||
payload_hash text NOT NULL,
|
||||
payload_json jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_odoo_order_state_events_state_hash
|
||||
ON mv_hot.odoo_order_state_events (tenant, site, machine_id, payload_hash);
|
||||
|
||||
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.odoo_order_state_machine_grafana;
|
||||
|
||||
CREATE VIEW mv_reports_ucepsa_demo.odoo_order_state_machine_grafana AS
|
||||
SELECT
|
||||
COALESCE(m.machine_id, o.machine_id) AS machine_id,
|
||||
|
||||
o.received_at AS odoo_state_received_at,
|
||||
o.mqtt_ts AS odoo_state_ts,
|
||||
EXTRACT(EPOCH FROM (now() - o.received_at))::double precision AS order_age_s,
|
||||
|
||||
o.workcenter_id,
|
||||
o.workcenter_name,
|
||||
o.workcenter_display_name,
|
||||
|
||||
o.production_order AS order_ref,
|
||||
o.product AS product_name,
|
||||
o.odoo_state,
|
||||
o.order_present,
|
||||
o.order_coherent,
|
||||
o.reason,
|
||||
|
||||
o.waiting_count,
|
||||
o.ready_count,
|
||||
o.progress_count,
|
||||
|
||||
m.ingest_ts AS telemetry_ingest_ts,
|
||||
m.age_s AS telemetry_age_s,
|
||||
m.comm_ok,
|
||||
m.sample_valid,
|
||||
m.machine_running,
|
||||
m.cycle_rate_ppm,
|
||||
m.cycle_pulse_count,
|
||||
m.digital_inputs,
|
||||
|
||||
CASE
|
||||
WHEN o.machine_id IS NULL THEN 'SIN_ESTADO_ODOO'
|
||||
WHEN EXTRACT(EPOCH FROM (now() - o.received_at)) > 30 THEN 'ODOO_STATE_ANTIGUO'
|
||||
WHEN o.odoo_state = 'multiple_loaded_orders' THEN 'MULTIPLES_ORDENES_EN_PROGRESS'
|
||||
WHEN o.order_present = true
|
||||
AND o.order_coherent = true
|
||||
AND COALESCE(m.machine_running, false) = true
|
||||
THEN 'ORDEN_CARGADA_MAQUINA_EN_MARCHA'
|
||||
WHEN o.order_present = true
|
||||
AND o.order_coherent = true
|
||||
AND COALESCE(m.machine_running, false) = false
|
||||
THEN 'ORDEN_CARGADA_MAQUINA_PARADA'
|
||||
WHEN COALESCE(o.order_present, false) = false
|
||||
AND COALESCE(m.machine_running, false) = true
|
||||
THEN 'MAQUINA_EN_MARCHA_SIN_ORDEN_ODOO'
|
||||
WHEN COALESCE(o.order_present, false) = false
|
||||
AND COALESCE(m.machine_running, false) = false
|
||||
THEN 'SIN_ORDEN_MAQUINA_PARADA'
|
||||
ELSE 'REVISAR'
|
||||
END AS edge_odoo_status,
|
||||
|
||||
o.payload_json AS odoo_payload_json
|
||||
FROM mv_reports_ucepsa_demo.machine_state_latest_multi m
|
||||
FULL OUTER JOIN mv_hot.odoo_order_state_current o
|
||||
ON o.tenant = 'ucepsa'
|
||||
AND o.site = 'ucepsa_onpremise'
|
||||
AND o.machine_id = m.machine_id
|
||||
WHERE COALESCE(m.site, o.site) = 'ucepsa_onpremise';
|
||||
|
||||
GRANT SELECT ON mv_hot.odoo_order_state_current TO grafana_ucepsa_ro;
|
||||
GRANT SELECT ON mv_hot.odoo_order_state_events TO grafana_ucepsa_ro;
|
||||
GRANT SELECT ON mv_reports_ucepsa_demo.odoo_order_state_machine_grafana TO grafana_ucepsa_ro;
|
||||
247
ucepsa/edge-oee-demo/tools/order_state_pg_sink.py
Executable file
247
ucepsa/edge-oee-demo/tools/order_state_pg_sink.py
Executable file
@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json
|
||||
|
||||
|
||||
RUNNING = True
|
||||
|
||||
|
||||
def stop_handler(signum, frame):
|
||||
global RUNNING
|
||||
RUNNING = False
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, stop_handler)
|
||||
signal.signal(signal.SIGINT, stop_handler)
|
||||
|
||||
|
||||
def env_first(*names, default=None):
|
||||
for name in names:
|
||||
value = os.getenv(name)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def to_bool(value):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).strip().lower() in ("true", "t", "1", "yes", "y", "si", "sí")
|
||||
|
||||
|
||||
def to_int(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def parse_ts(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def state_hash(payload):
|
||||
diagnostics = payload.get("diagnostics") or {}
|
||||
relevant = {
|
||||
"machine_id": payload.get("machine_id"),
|
||||
"workcenter_id": payload.get("workcenter_id"),
|
||||
"order_present": payload.get("order_present"),
|
||||
"order_coherent": payload.get("order_coherent"),
|
||||
"odoo_state": payload.get("odoo_state"),
|
||||
"reason": payload.get("reason"),
|
||||
"workorder_id": payload.get("workorder_id"),
|
||||
"production_order": payload.get("production_order"),
|
||||
"product": payload.get("product"),
|
||||
"waiting_count": diagnostics.get("waiting_count"),
|
||||
"ready_count": diagnostics.get("ready_count"),
|
||||
"progress_count": diagnostics.get("progress_count"),
|
||||
}
|
||||
raw = json.dumps(relevant, sort_keys=True, ensure_ascii=False)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
TENANT = env_first("TENANT", "TENANT_OVERRIDE", default="ucepsa")
|
||||
SITE = env_first("SITE", "SITE_OVERRIDE", default="ucepsa_onpremise")
|
||||
|
||||
MQTT_HOST = env_first("MQTT_HOST", "MQTT_HOST_OVERRIDE", default="mv_ucepsa_mosquitto")
|
||||
MQTT_PORT = int(env_first("MQTT_PORT", default="1883"))
|
||||
MQTT_TOPIC = env_first(
|
||||
"MQTT_TOPIC",
|
||||
"ORDER_STATE_TOPIC",
|
||||
default=f"vertical/{TENANT}/{SITE}/edge_oee/order_state/+",
|
||||
)
|
||||
|
||||
MQTT_USER = env_first("MQTT_USER", "MQTT_USER_REVPI")
|
||||
MQTT_PASSWORD = env_first("MQTT_PASSWORD", "MQTT_PASSWORD_REVPI")
|
||||
|
||||
PGHOST = env_first("PGHOST", default="mv_ucepsa_postgres_hot")
|
||||
PGPORT = int(env_first("PGPORT", default="5432"))
|
||||
PGDATABASE = env_first("PGDATABASE", "POSTGRES_DB")
|
||||
PGUSER = env_first("PGUSER", "POSTGRES_USER")
|
||||
PGPASSWORD = env_first("PGPASSWORD", "POSTGRES_PASSWORD")
|
||||
|
||||
if not PGDATABASE or not PGUSER or not PGPASSWORD:
|
||||
print("ERROR: faltan credenciales PostgreSQL", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=PGHOST,
|
||||
port=PGPORT,
|
||||
dbname=PGDATABASE,
|
||||
user=PGUSER,
|
||||
password=PGPASSWORD,
|
||||
)
|
||||
conn.autocommit = True
|
||||
|
||||
|
||||
def upsert_order_state(topic, payload):
|
||||
machine_id = payload.get("machine_id") or topic.rstrip("/").split("/")[-1]
|
||||
diagnostics = payload.get("diagnostics") or {}
|
||||
|
||||
mqtt_ts = parse_ts(payload.get("ts"))
|
||||
h = state_hash(payload)
|
||||
|
||||
row = {
|
||||
"tenant": TENANT,
|
||||
"site": SITE,
|
||||
"machine_id": machine_id,
|
||||
"topic": topic,
|
||||
"mqtt_ts": mqtt_ts,
|
||||
"source": payload.get("source"),
|
||||
"workcenter_id": to_int(payload.get("workcenter_id")),
|
||||
"workcenter_name": payload.get("workcenter_name"),
|
||||
"workcenter_display_name": payload.get("workcenter_display_name"),
|
||||
"order_present": to_bool(payload.get("order_present")),
|
||||
"order_coherent": to_bool(payload.get("order_coherent")),
|
||||
"odoo_state": payload.get("odoo_state"),
|
||||
"reason": payload.get("reason"),
|
||||
"workorder_id": str(payload.get("workorder_id")) if payload.get("workorder_id") is not None else None,
|
||||
"production_order": payload.get("production_order"),
|
||||
"product": payload.get("product"),
|
||||
"waiting_count": to_int(diagnostics.get("waiting_count")),
|
||||
"ready_count": to_int(diagnostics.get("ready_count")),
|
||||
"progress_count": to_int(diagnostics.get("progress_count")),
|
||||
"payload_hash": h,
|
||||
"payload_json": payload,
|
||||
}
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO mv_hot.odoo_order_state_current (
|
||||
tenant, site, machine_id, topic, mqtt_ts, received_at,
|
||||
source, workcenter_id, workcenter_name, workcenter_display_name,
|
||||
order_present, order_coherent, odoo_state, reason,
|
||||
workorder_id, production_order, product,
|
||||
waiting_count, ready_count, progress_count,
|
||||
payload_hash, payload_json
|
||||
)
|
||||
VALUES (
|
||||
%(tenant)s, %(site)s, %(machine_id)s, %(topic)s, %(mqtt_ts)s, now(),
|
||||
%(source)s, %(workcenter_id)s, %(workcenter_name)s, %(workcenter_display_name)s,
|
||||
%(order_present)s, %(order_coherent)s, %(odoo_state)s, %(reason)s,
|
||||
%(workorder_id)s, %(production_order)s, %(product)s,
|
||||
%(waiting_count)s, %(ready_count)s, %(progress_count)s,
|
||||
%(payload_hash)s, %(payload_json)s
|
||||
)
|
||||
ON CONFLICT (tenant, site, machine_id)
|
||||
DO UPDATE SET
|
||||
topic = EXCLUDED.topic,
|
||||
mqtt_ts = EXCLUDED.mqtt_ts,
|
||||
received_at = now(),
|
||||
source = EXCLUDED.source,
|
||||
workcenter_id = EXCLUDED.workcenter_id,
|
||||
workcenter_name = EXCLUDED.workcenter_name,
|
||||
workcenter_display_name = EXCLUDED.workcenter_display_name,
|
||||
order_present = EXCLUDED.order_present,
|
||||
order_coherent = EXCLUDED.order_coherent,
|
||||
odoo_state = EXCLUDED.odoo_state,
|
||||
reason = EXCLUDED.reason,
|
||||
workorder_id = EXCLUDED.workorder_id,
|
||||
production_order = EXCLUDED.production_order,
|
||||
product = EXCLUDED.product,
|
||||
waiting_count = EXCLUDED.waiting_count,
|
||||
ready_count = EXCLUDED.ready_count,
|
||||
progress_count = EXCLUDED.progress_count,
|
||||
payload_hash = EXCLUDED.payload_hash,
|
||||
payload_json = EXCLUDED.payload_json
|
||||
""",
|
||||
{**row, "payload_json": Json(payload)},
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO mv_hot.odoo_order_state_events (
|
||||
tenant, site, machine_id, topic, mqtt_ts, received_at,
|
||||
odoo_state, order_present, order_coherent,
|
||||
production_order, product, reason,
|
||||
waiting_count, ready_count, progress_count,
|
||||
payload_hash, payload_json
|
||||
)
|
||||
VALUES (
|
||||
%(tenant)s, %(site)s, %(machine_id)s, %(topic)s, %(mqtt_ts)s, now(),
|
||||
%(odoo_state)s, %(order_present)s, %(order_coherent)s,
|
||||
%(production_order)s, %(product)s, %(reason)s,
|
||||
%(waiting_count)s, %(ready_count)s, %(progress_count)s,
|
||||
%(payload_hash)s, %(payload_json)s
|
||||
)
|
||||
ON CONFLICT (tenant, site, machine_id, payload_hash)
|
||||
DO NOTHING
|
||||
""",
|
||||
{**row, "payload_json": Json(payload)},
|
||||
)
|
||||
|
||||
|
||||
def on_connect(client, userdata, flags, rc, properties=None):
|
||||
if rc != 0:
|
||||
print(f"MQTT connect failed rc={rc}", flush=True)
|
||||
return
|
||||
print(f"MQTT connected. Subscribing to {MQTT_TOPIC}", flush=True)
|
||||
client.subscribe(MQTT_TOPIC, qos=1)
|
||||
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
try:
|
||||
payload = json.loads(msg.payload.decode("utf-8"))
|
||||
upsert_order_state(msg.topic, payload)
|
||||
machine_id = payload.get("machine_id") or msg.topic.rstrip("/").split("/")[-1]
|
||||
print(
|
||||
f"ORDER_STATE {machine_id} "
|
||||
f"state={payload.get('odoo_state')} "
|
||||
f"present={payload.get('order_present')} "
|
||||
f"coherent={payload.get('order_coherent')} "
|
||||
f"mo={payload.get('production_order')}",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"ERROR processing message topic={msg.topic}: {exc}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
|
||||
|
||||
if MQTT_USER:
|
||||
client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_message = on_message
|
||||
|
||||
print(f"Starting order_state_pg_sink MQTT={MQTT_HOST}:{MQTT_PORT} topic={MQTT_TOPIC}", flush=True)
|
||||
client.connect(MQTT_HOST, MQTT_PORT, keepalive=30)
|
||||
client.loop_forever()
|
||||
Loading…
x
Reference in New Issue
Block a user