453 lines
12 KiB
Python
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()
|