#!/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()