#!/usr/bin/env python3 import argparse import json import os import sys import xmlrpc.client from datetime import datetime, timezone try: import psycopg2 import psycopg2.extras except Exception as exc: print(f"ERROR: psycopg2 no disponible en esta imagen Python: {exc}", file=sys.stderr) sys.exit(2) TENANT = os.getenv("TENANT_OVERRIDE", "ucepsa") SITE = os.getenv("SITE_OVERRIDE", "ucepsa_onpremise") ODOO_INSTANCE = os.getenv("ODOO_INSTANCE_OVERRIDE", "odoo_ucepsa_prod") ODOO_DB = os.getenv("ODOO_DB_OVERRIDE") or os.getenv("ODOO_DB") ODOO_URL = (os.getenv("ODOO_URL") or "").rstrip("/") ODOO_USER = os.getenv("ODOO_USER") ODOO_PASSWORD = os.getenv("ODOO_PASSWORD") PGHOST = os.getenv("POSTGRES_HOST") or os.getenv("PGHOST") or "mv_ucepsa_postgres_hot" PGPORT = int(os.getenv("POSTGRES_PORT") or os.getenv("PGPORT") or "5432") PGDATABASE = os.getenv("POSTGRES_DB") or os.getenv("PGDATABASE") PGUSER = os.getenv("POSTGRES_USER") or os.getenv("PGUSER") PGPASSWORD = os.getenv("POSTGRES_PASSWORD") or os.getenv("PGPASSWORD") def require_env(): missing = [] for name, value in { "ODOO_URL": ODOO_URL, "ODOO_DB/ODOO_DB_OVERRIDE": ODOO_DB, "ODOO_USER": ODOO_USER, "ODOO_PASSWORD": ODOO_PASSWORD, "POSTGRES_DB": PGDATABASE, "POSTGRES_USER": PGUSER, "POSTGRES_PASSWORD": PGPASSWORD, }.items(): if not value: missing.append(name) if missing: raise RuntimeError("Faltan variables de entorno: " + ", ".join(missing)) def odoo_connect(): common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common", allow_none=True) uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASSWORD, {}) if not uid: raise RuntimeError("No se pudo autenticar contra Odoo") models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object", allow_none=True) return uid, models def odoo_call(models, uid, model, method, *args, **kwargs): return models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, model, method, list(args), kwargs or {}) def pg_connect(): return psycopg2.connect( host=PGHOST, port=PGPORT, dbname=PGDATABASE, user=PGUSER, password=PGPASSWORD, ) def as_id(value): if isinstance(value, list) and value: return value[0] if isinstance(value, tuple) and value: return value[0] if isinstance(value, int): return value return None def as_name(value): if isinstance(value, list) and len(value) > 1: return value[1] if isinstance(value, tuple) and len(value) > 1: return value[1] return None def read_map_by_ids(models, uid, model, ids, fields): ids = [int(x) for x in ids if x] if not ids: return {} rows = odoo_call(models, uid, model, "read", ids, fields=fields) return {r["id"]: r for r in rows} def extract_default_code(display_name): if not display_name: return None if display_name.startswith("[") and "]" in display_name: return display_name[1:display_name.index("]")] return None def build_row(prod, product, sale_line, sale_order, raw_moves, finished_moves): product_name = as_name(prod.get("product_id")) or (product or {}).get("display_name") product_default_code = (product or {}).get("default_code") or extract_default_code(product_name) product_standard_price = product.get("standard_price") if product else None product_list_price = product.get("list_price") if product else None product_uom = as_name((product or {}).get("uom_id")) or as_name(prod.get("product_uom_id")) sale_order_ref = None sale_order_id = None customer_id = None customer_name = None sale_state = None sale_date_order = None currency = None sale_line_qty = None sale_line_qty_delivered = None sale_price_unit = None sale_discount_pct = None revenue_net_eur = None revenue_total_eur = None sale_line_id = None if sale_line: sale_line_id = sale_line.get("id") sale_line_qty = sale_line.get("product_uom_qty") sale_line_qty_delivered = sale_line.get("qty_delivered") sale_price_unit = sale_line.get("price_unit") sale_discount_pct = sale_line.get("discount") revenue_net_eur = sale_line.get("price_subtotal") revenue_total_eur = sale_line.get("price_total") if sale_order: sale_order_id = sale_order.get("id") sale_order_ref = sale_order.get("name") sale_state = sale_order.get("state") sale_date_order = sale_order.get("date_order") customer_id = as_id(sale_order.get("partner_id")) customer_name = as_name(sale_order.get("partner_id")) currency = as_name(sale_order.get("currency_id")) raw_material_value_eur = sum([float(m.get("value") or 0.0) for m in raw_moves]) finished_value_eur = sum([float(m.get("value") or 0.0) for m in finished_moves]) if raw_material_value_eur and raw_material_value_eur != 0: material_cost_status = "FROM_ODOO_STOCK_MOVE_VALUE" else: material_cost_status = "UNAVAILABLE_OR_ZERO" raw_json = { "production": prod, "product": product, "sale_line": sale_line, "sale_order": sale_order, } return { "tenant": TENANT, "site": SITE, "odoo_instance": ODOO_INSTANCE, "odoo_production_id": prod.get("id"), "odoo_order_ref": prod.get("name"), "odoo_origin": prod.get("origin"), "odoo_state": prod.get("state"), "product_id": as_id(prod.get("product_id")), "product_default_code": product_default_code, "product_name": product_name, "product_uom": product_uom, "product_standard_price": product_standard_price, "product_list_price": product_list_price, "production_qty": prod.get("product_qty"), "qty_producing": prod.get("qty_producing"), "production_uom": as_name(prod.get("product_uom_id")), "sale_line_id": sale_line_id, "sale_order_id": sale_order_id, "sale_order_ref": sale_order_ref, "customer_id": customer_id, "customer_name": customer_name, "sale_state": sale_state, "sale_date_order": sale_date_order, "currency": currency, "sale_line_qty": sale_line_qty, "sale_line_qty_delivered": sale_line_qty_delivered, "sale_price_unit": sale_price_unit, "sale_discount_pct": sale_discount_pct, "revenue_net_eur": revenue_net_eur, "revenue_total_eur": revenue_total_eur, "raw_material_value_eur": raw_material_value_eur, "finished_value_eur": finished_value_eur, "material_cost_status": material_cost_status, "stock_raw_moves_json": raw_moves, "stock_finished_moves_json": finished_moves, "raw_json": raw_json, } def upsert_rows(conn, rows): if not rows: return 0 sql = """ INSERT INTO mv_edge_oee_ucepsa_prod.odoo_order_commercials ( tenant, site, odoo_instance, odoo_production_id, odoo_order_ref, odoo_origin, odoo_state, product_id, product_default_code, product_name, product_uom, product_standard_price, product_list_price, production_qty, qty_producing, production_uom, sale_line_id, sale_order_id, sale_order_ref, customer_id, customer_name, sale_state, sale_date_order, currency, sale_line_qty, sale_line_qty_delivered, sale_price_unit, sale_discount_pct, revenue_net_eur, revenue_total_eur, raw_material_value_eur, finished_value_eur, material_cost_status, stock_raw_moves_json, stock_finished_moves_json, raw_json, snapshot_at ) VALUES ( %(tenant)s, %(site)s, %(odoo_instance)s, %(odoo_production_id)s, %(odoo_order_ref)s, %(odoo_origin)s, %(odoo_state)s, %(product_id)s, %(product_default_code)s, %(product_name)s, %(product_uom)s, %(product_standard_price)s, %(product_list_price)s, %(production_qty)s, %(qty_producing)s, %(production_uom)s, %(sale_line_id)s, %(sale_order_id)s, %(sale_order_ref)s, %(customer_id)s, %(customer_name)s, %(sale_state)s, %(sale_date_order)s, %(currency)s, %(sale_line_qty)s, %(sale_line_qty_delivered)s, %(sale_price_unit)s, %(sale_discount_pct)s, %(revenue_net_eur)s, %(revenue_total_eur)s, %(raw_material_value_eur)s, %(finished_value_eur)s, %(material_cost_status)s, %(stock_raw_moves_json)s, %(stock_finished_moves_json)s, %(raw_json)s, now() ) ON CONFLICT (odoo_instance, odoo_production_id) DO UPDATE SET tenant = EXCLUDED.tenant, site = EXCLUDED.site, odoo_order_ref = EXCLUDED.odoo_order_ref, odoo_origin = EXCLUDED.odoo_origin, odoo_state = EXCLUDED.odoo_state, product_id = EXCLUDED.product_id, product_default_code = EXCLUDED.product_default_code, product_name = EXCLUDED.product_name, product_uom = EXCLUDED.product_uom, product_standard_price = EXCLUDED.product_standard_price, product_list_price = EXCLUDED.product_list_price, production_qty = EXCLUDED.production_qty, qty_producing = EXCLUDED.qty_producing, production_uom = EXCLUDED.production_uom, sale_line_id = EXCLUDED.sale_line_id, sale_order_id = EXCLUDED.sale_order_id, sale_order_ref = EXCLUDED.sale_order_ref, customer_id = EXCLUDED.customer_id, customer_name = EXCLUDED.customer_name, sale_state = EXCLUDED.sale_state, sale_date_order = EXCLUDED.sale_date_order, currency = EXCLUDED.currency, sale_line_qty = EXCLUDED.sale_line_qty, sale_line_qty_delivered = EXCLUDED.sale_line_qty_delivered, sale_price_unit = EXCLUDED.sale_price_unit, sale_discount_pct = EXCLUDED.sale_discount_pct, revenue_net_eur = EXCLUDED.revenue_net_eur, revenue_total_eur = EXCLUDED.revenue_total_eur, raw_material_value_eur = EXCLUDED.raw_material_value_eur, finished_value_eur = EXCLUDED.finished_value_eur, material_cost_status = EXCLUDED.material_cost_status, stock_raw_moves_json = EXCLUDED.stock_raw_moves_json, stock_finished_moves_json = EXCLUDED.stock_finished_moves_json, raw_json = EXCLUDED.raw_json, snapshot_at = now() """ prepared = [] for row in rows: r = dict(row) r["stock_raw_moves_json"] = psycopg2.extras.Json(r["stock_raw_moves_json"]) r["stock_finished_moves_json"] = psycopg2.extras.Json(r["stock_finished_moves_json"]) r["raw_json"] = psycopg2.extras.Json(r["raw_json"]) prepared.append(r) with conn.cursor() as cur: psycopg2.extras.execute_batch(cur, sql, prepared, page_size=50) return len(rows) def main(): parser = argparse.ArgumentParser() parser.add_argument("--production-id", type=int, action="append", help="ID Odoo mrp.production concreto") parser.add_argument("--order-ref", action="append", help="Referencia WH/MO/xxxxx concreta") parser.add_argument("--limit", type=int, default=50) parser.add_argument("--states", default="confirmed,progress,to_close,done", help="Estados mrp.production separados por coma") parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() require_env() uid, models = odoo_connect() print(f"Conectado a Odoo: url={ODOO_URL} db={ODOO_DB} user={ODOO_USER} uid={uid}", flush=True) domain = [] if args.production_id: domain.append(("id", "in", args.production_id)) elif args.order_ref: domain.append(("name", "in", args.order_ref)) else: states = [s.strip() for s in args.states.split(",") if s.strip()] domain.append(("state", "in", states)) prod_fields = [ "id", "name", "state", "origin", "product_id", "product_qty", "qty_producing", "product_uom_id", "bom_id", "move_raw_ids", "move_finished_ids", "sale_line_id", "date_start", "date_finished", "company_id", ] production_ids = odoo_call( models, uid, "mrp.production", "search", domain, limit=args.limit, order="id desc", ) print(f"Producciones encontradas: {len(production_ids)} -> {production_ids}", flush=True) if not production_ids: return 0 productions = odoo_call(models, uid, "mrp.production", "read", production_ids, fields=prod_fields) product_ids = [as_id(p.get("product_id")) for p in productions if as_id(p.get("product_id"))] sale_line_ids = [as_id(p.get("sale_line_id")) for p in productions if as_id(p.get("sale_line_id"))] raw_move_ids = [] finished_move_ids = [] for p in productions: raw_move_ids.extend(p.get("move_raw_ids") or []) finished_move_ids.extend(p.get("move_finished_ids") or []) products = read_map_by_ids( models, uid, "product.product", product_ids, [ "id", "display_name", "default_code", "standard_price", "list_price", "uom_id", "categ_id", "barcode", "product_tmpl_id", ], ) sale_lines = read_map_by_ids( models, uid, "sale.order.line", sale_line_ids, [ "id", "name", "order_id", "product_id", "product_uom_qty", "qty_delivered", "price_unit", "discount", "price_subtotal", "price_total", ], ) sale_order_ids = [as_id(sl.get("order_id")) for sl in sale_lines.values() if as_id(sl.get("order_id"))] sale_orders = read_map_by_ids( models, uid, "sale.order", sale_order_ids, [ "id", "name", "state", "origin", "date_order", "partner_id", "currency_id", "amount_total", "amount_untaxed", "client_order_ref", "order_line", ], ) move_fields = [ "id", "state", "value", "quantity", "price_unit", "product_id", "product_uom", "production_id", "product_uom_qty", "raw_material_production_id", ] raw_moves_by_id = read_map_by_ids(models, uid, "stock.move", raw_move_ids, move_fields) finished_moves_by_id = read_map_by_ids(models, uid, "stock.move", finished_move_ids, move_fields) rows = [] for p in productions: product = products.get(as_id(p.get("product_id"))) sale_line = sale_lines.get(as_id(p.get("sale_line_id"))) sale_order = sale_orders.get(as_id(sale_line.get("order_id"))) if sale_line else None raw_moves = [raw_moves_by_id[mid] for mid in (p.get("move_raw_ids") or []) if mid in raw_moves_by_id] finished_moves = [finished_moves_by_id[mid] for mid in (p.get("move_finished_ids") or []) if mid in finished_moves_by_id] row = build_row(p, product, sale_line, sale_order, raw_moves, finished_moves) rows.append(row) print(json.dumps( [ { "odoo_order_ref": r["odoo_order_ref"], "odoo_production_id": r["odoo_production_id"], "product": r["product_name"], "sale_order_ref": r["sale_order_ref"], "customer_name": r["customer_name"], "revenue_net_eur": r["revenue_net_eur"], "raw_moves": len(r["stock_raw_moves_json"]), "finished_moves": len(r["stock_finished_moves_json"]), "material_cost_status": r["material_cost_status"], } for r in rows ], indent=2, ensure_ascii=False, default=str, ), flush=True) if args.dry_run: print("DRY RUN: no se escribe en PostgreSQL", flush=True) return 0 with pg_connect() as conn: count = upsert_rows(conn, rows) conn.commit() print(f"Filas insertadas/actualizadas en mv_edge_oee_ucepsa_prod.odoo_order_commercials: {count}", flush=True) return 0 if __name__ == "__main__": raise SystemExit(main())