453 lines
12 KiB
Python

#!/usr/bin/env python3
import os
import sys
import time
import xmlrpc.client
from datetime import datetime, timezone
import psycopg2
import psycopg2.extras
def env(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
print(f"[ERROR] Missing required env var: {name}", file=sys.stderr, flush=True)
sys.exit(2)
return value
ODOO_URL = env("ODOO_URL", required=True).rstrip("/")
ODOO_DB = env("ODOO_DB", required=True)
ODOO_USER = env("ODOO_USER", required=True)
ODOO_PASSWORD = env("ODOO_PASSWORD", required=True)
ODOO_WORKCENTER_CODE = env("ODOO_WORKCENTER_CODE", "CORT-01")
ODOO_INSTANCE = env("ODOO_INSTANCE", "odoo_demo")
ODOO_SYNC_INTERVAL_SECONDS = int(env("ODOO_SYNC_INTERVAL_SECONDS", "30"))
ODOO_WORKORDER_LIMIT = int(env("ODOO_WORKORDER_LIMIT", "50"))
ODOO_WORKORDER_STATES = [
s.strip()
for s in env("ODOO_WORKORDER_STATES", "waiting,ready,progress,done").split(",")
if s.strip()
]
TENANT = env("TENANT", "ucepsa")
SITE = env("SITE", "demo_edge_oee")
MACHINE_ID = env("MACHINE_ID", "CORT-01")
ASSET = env("ASSET", "revpi_oee_node_01")
TARGET_CYCLE_RATE_PPM = float(env("TARGET_CYCLE_RATE_PPM", "10"))
PG_CONFIG = {
"host": env("PGHOST", required=True),
"port": int(env("PGPORT", "5432")),
"dbname": env("PGDATABASE", required=True),
"user": env("PGUSER", required=True),
"password": env("PGPASSWORD", required=True),
"connect_timeout": 5,
}
def parse_odoo_datetime(value):
if not value:
return None
# Odoo devuelve fechas en UTC como string naive: YYYY-MM-DD HH:MM:SS
if isinstance(value, str):
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
return None
def odoo_connect():
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common")
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object")
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASSWORD, {})
if not uid:
raise RuntimeError("Odoo authentication failed")
return uid, models
def existing_fields(models, uid, model, wanted):
fields = models.execute_kw(
ODOO_DB,
uid,
ODOO_PASSWORD,
model,
"fields_get",
[],
{"attributes": ["string", "type"]},
)
return [f for f in wanted if f in fields]
def search_read(models, uid, model, domain, wanted_fields, limit=20, order="id desc"):
fields = existing_fields(models, uid, model, wanted_fields)
return models.execute_kw(
ODOO_DB,
uid,
ODOO_PASSWORD,
model,
"search_read",
[domain],
{
"fields": fields,
"limit": limit,
"order": order,
},
)
def read_records(models, uid, model, ids, wanted_fields):
if not ids:
return []
fields = existing_fields(models, uid, model, wanted_fields)
return models.execute_kw(
ODOO_DB,
uid,
ODOO_PASSWORD,
model,
"read",
[ids],
{"fields": fields},
)
def get_workcenter(models, uid):
wc_fields = existing_fields(models, uid, "mrp.workcenter", ["id", "name", "code", "tag_ids"])
if "code" in wc_fields:
domain = ["|", ("code", "=", ODOO_WORKCENTER_CODE), ("name", "ilike", ODOO_WORKCENTER_CODE)]
else:
domain = [("name", "ilike", ODOO_WORKCENTER_CODE)]
workcenters = search_read(
models,
uid,
"mrp.workcenter",
domain,
["id", "name", "code", "tag_ids"],
limit=10,
order="id asc",
)
if not workcenters:
raise RuntimeError(f"No workcenter found for code/name {ODOO_WORKCENTER_CODE}")
# Preferencia exacta por code
for wc in workcenters:
if wc.get("code") == ODOO_WORKCENTER_CODE:
return wc
return workcenters[0]
def normalize_many2one(value):
if isinstance(value, list) and len(value) >= 2:
return value[0], value[1]
return None, None
def upsert_workcenter_map(conn, wc):
wc_id = wc.get("id")
wc_name = wc.get("name") or ODOO_WORKCENTER_CODE
wc_code = wc.get("code") or ODOO_WORKCENTER_CODE
sql = """
INSERT INTO mv_edge_oee_ucepsa_demo.odoo_workcenter_map (
tenant,
site,
odoo_instance,
odoo_workcenter_id,
odoo_workcenter_code,
odoo_workcenter_name,
machine_id,
asset,
is_active
)
VALUES (
%(tenant)s,
%(site)s,
%(odoo_instance)s,
%(odoo_workcenter_id)s,
%(odoo_workcenter_code)s,
%(odoo_workcenter_name)s,
%(machine_id)s,
%(asset)s,
true
)
ON CONFLICT (tenant, site, odoo_instance, odoo_workcenter_code)
DO UPDATE SET
odoo_workcenter_id = EXCLUDED.odoo_workcenter_id,
odoo_workcenter_name = EXCLUDED.odoo_workcenter_name,
machine_id = EXCLUDED.machine_id,
asset = EXCLUDED.asset,
is_active = true;
"""
params = {
"tenant": TENANT,
"site": SITE,
"odoo_instance": ODOO_INSTANCE,
"odoo_workcenter_id": wc_id,
"odoo_workcenter_code": wc_code,
"odoo_workcenter_name": wc_name,
"machine_id": MACHINE_ID,
"asset": ASSET,
}
with conn.cursor() as cur:
cur.execute(sql, params)
def fetch_workorders(models, uid, wc):
wc_id = wc["id"]
domain = [
("workcenter_id", "=", wc_id),
("state", "in", ODOO_WORKORDER_STATES),
]
workorders = search_read(
models,
uid,
"mrp.workorder",
domain,
[
"id",
"name",
"state",
"workcenter_id",
"production_id",
"product_id",
"date_start",
"date_finished",
"duration_expected",
"duration",
"qty_production",
],
limit=ODOO_WORKORDER_LIMIT,
order="id desc",
)
production_ids = []
for wo in workorders:
prod_id, _ = normalize_many2one(wo.get("production_id"))
if prod_id:
production_ids.append(prod_id)
productions = read_records(
models,
uid,
"mrp.production",
production_ids,
[
"id",
"name",
"state",
"product_id",
"product_qty",
"qty_producing",
"date_start",
"date_finished",
"workorder_ids",
],
)
productions_by_id = {p["id"]: p for p in productions}
return workorders, productions_by_id
def upsert_workorder(conn, wc, wo, productions_by_id):
wo_id = wo.get("id")
wo_name = wo.get("name")
wo_state = wo.get("state")
wc_id, wc_name = normalize_many2one(wo.get("workcenter_id"))
prod_id, prod_ref = normalize_many2one(wo.get("production_id"))
product_id, product_name = normalize_many2one(wo.get("product_id"))
production = productions_by_id.get(prod_id, {})
prod_state = production.get("state")
prod_product_id, prod_product_name = normalize_many2one(production.get("product_id"))
product = product_name or prod_product_name or "Producto sin nombre"
order_ref = prod_ref or production.get("name") or f"WO-{wo_id}"
qty_planned = wo.get("qty_production")
if qty_planned is None:
qty_planned = production.get("product_qty")
qty_done = production.get("qty_producing")
real_start = parse_odoo_datetime(wo.get("date_start"))
real_end = parse_odoo_datetime(wo.get("date_finished"))
if real_end is None and wo_state in ("done", "cancel"):
real_end = parse_odoo_datetime(production.get("date_finished"))
has_real_start = real_start is not None
# Si Odoo no tiene date_start, usamos now() solo en el primer insert.
# En updates posteriores, si sigue sin date_start real, conservamos el odoo_start anterior.
insert_start = real_start or datetime.now(timezone.utc)
wc_code = wc.get("code") or ODOO_WORKCENTER_CODE
wc_display_name = wc.get("name") or wc_name or ODOO_WORKCENTER_CODE
sql = """
INSERT INTO mv_edge_oee_ucepsa_demo.odoo_workorders_demo (
tenant,
site,
odoo_instance,
odoo_workorder_id,
odoo_production_id,
odoo_order_ref,
odoo_state,
odoo_workcenter_code,
odoo_workcenter_name,
machine_id,
asset,
product,
qty_planned,
qty_done,
uom,
planned_cycles,
target_cycle_rate_ppm,
odoo_start,
odoo_end,
notes,
snapshot_at
)
VALUES (
%(tenant)s,
%(site)s,
%(odoo_instance)s,
%(odoo_workorder_id)s,
%(odoo_production_id)s,
%(odoo_order_ref)s,
%(odoo_state)s,
%(odoo_workcenter_code)s,
%(odoo_workcenter_name)s,
%(machine_id)s,
%(asset)s,
%(product)s,
%(qty_planned)s,
%(qty_done)s,
%(uom)s,
%(planned_cycles)s,
%(target_cycle_rate_ppm)s,
%(odoo_start)s,
%(odoo_end)s,
%(notes)s,
now()
)
ON CONFLICT (tenant, site, odoo_instance, odoo_workorder_id)
WHERE odoo_workorder_id IS NOT NULL
DO UPDATE SET
odoo_production_id = EXCLUDED.odoo_production_id,
odoo_order_ref = EXCLUDED.odoo_order_ref,
odoo_state = EXCLUDED.odoo_state,
odoo_workcenter_code = EXCLUDED.odoo_workcenter_code,
odoo_workcenter_name = EXCLUDED.odoo_workcenter_name,
machine_id = EXCLUDED.machine_id,
asset = EXCLUDED.asset,
product = EXCLUDED.product,
qty_planned = EXCLUDED.qty_planned,
qty_done = EXCLUDED.qty_done,
uom = EXCLUDED.uom,
planned_cycles = EXCLUDED.planned_cycles,
target_cycle_rate_ppm = EXCLUDED.target_cycle_rate_ppm,
odoo_start = CASE
WHEN %(has_real_start)s THEN EXCLUDED.odoo_start
ELSE mv_edge_oee_ucepsa_demo.odoo_workorders_demo.odoo_start
END,
odoo_end = EXCLUDED.odoo_end,
notes = EXCLUDED.notes,
snapshot_at = now();
"""
params = {
"tenant": TENANT,
"site": SITE,
"odoo_instance": ODOO_INSTANCE,
"odoo_workorder_id": wo_id,
"odoo_production_id": prod_id,
"odoo_order_ref": order_ref,
"odoo_state": wo_state,
"odoo_workcenter_code": wc_code,
"odoo_workcenter_name": wc_display_name,
"machine_id": MACHINE_ID,
"asset": ASSET,
"product": product,
"qty_planned": qty_planned,
"qty_done": qty_done,
"uom": "unidades",
"planned_cycles": qty_planned,
"target_cycle_rate_ppm": TARGET_CYCLE_RATE_PPM,
"odoo_start": insert_start,
"odoo_end": real_end,
"notes": f"Sincronizado desde Odoo workorder {wo_id} / production {prod_id}. Production state: {prod_state}. Workorder name: {wo_name}.",
"has_real_start": has_real_start,
}
with conn.cursor() as cur:
cur.execute(sql, params)
def sync_once():
uid, models = odoo_connect()
wc = get_workcenter(models, uid)
with psycopg2.connect(**PG_CONFIG) as conn:
conn.autocommit = False
upsert_workcenter_map(conn, wc)
workorders, productions_by_id = fetch_workorders(models, uid, wc)
for wo in workorders:
upsert_workorder(conn, wc, wo, productions_by_id)
conn.commit()
print(
f"[OK] sync complete workcenter={wc.get('name')} code={wc.get('code')} "
f"workorders={len(workorders)}",
flush=True,
)
def main():
print("[START] odoo-workorders-sync", flush=True)
print(f"[CONFIG] ODOO_URL={ODOO_URL}", flush=True)
print(f"[CONFIG] ODOO_DB={ODOO_DB}", flush=True)
print(f"[CONFIG] ODOO_WORKCENTER_CODE={ODOO_WORKCENTER_CODE}", flush=True)
print(f"[CONFIG] INTERVAL={ODOO_SYNC_INTERVAL_SECONDS}s", flush=True)
run_once = env("RUN_ONCE", "0") == "1"
while True:
try:
sync_once()
except Exception as exc:
print(f"[ERROR] {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
if run_once:
break
time.sleep(ODOO_SYNC_INTERVAL_SECONDS)
if __name__ == "__main__":
main()