"""Config-driven Odoo demo data builder (template — adapt per engagement). Reads demo_config.json (see demo_config.example.json) and creates: currency, products (with weight), customers (with credit limits), vehicle categories + vehicles (with capacity), a B2B pricelist, and pre-staged receivables (posted unpaid invoices) so heroes fire live. Idempotent: get-or-create by name. Run order matters (currency before invoices). Requires odoo_connect.py in the same folder + creds (env or creds.json). """ import json, os from odoo_connect import x, get_or_create, get_or_create_many, OdooError, URL, DB CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "demo_config.json") cfg = json.load(open(CFG, encoding="utf-8")) # Every key below is read with cfg.get(), so anything this script does not know about is # silently skipped. That is how a vertical config (take-offs, progress statements, ...) fed # to the stock builder prints DONE over an empty database. Fail loudly instead: this script # is a per-engagement TEMPLATE, and an unknown key means it has not been adapted yet. KNOWN_KEYS = { "client", "currency", "use_credit_limit", "scope_modules", "products", "customers", "vehicles", "pricelist", "prestage_receivables", "heroes", } unknown = sorted(k for k in cfg if k not in KNOWN_KEYS and not k.startswith("_")) if unknown: print("!! demo_config.json has keys this builder does not implement:") for k in unknown: print(f" {k}") print("!! They will NOT be created. Either extend this script to handle them, or") print("!! remove them so the config reflects what actually gets built.") if os.environ.get("DEMO_ALLOW_UNKNOWN_KEYS") != "1": raise SystemExit("aborting: unimplemented config keys (set DEMO_ALLOW_UNKNOWN_KEYS=1 to override)") # 1. currency (must run before invoices) cur = cfg.get("currency") if cur: ids = x("res.currency", "search", domain=[["name", "=", cur]], context={"active_test": False}) if not ids: ids = [x("res.currency", "create", vals_list={"name": cur, "rounding": 0.01})] x("res.currency", "write", ids, vals={"active": True}) comp = x("res.company", "search", domain=[], limit=1)[0] try: x("res.company", "write", comp, vals={"currency_id": ids[0]}) except Exception as e: print(" ! currency switch blocked (invoices exist?):", str(e)[:120]) print("currency:", cur) # 2. settings s = x("res.config.settings", "create", vals_list={ "account_use_credit_limit": bool(cfg.get("use_credit_limit", True)), "group_product_pricelist": True, }) x("res.config.settings", "execute", s) # 3. products (batched: one search_read + one create for the whole catalogue) rows = [{"name": p["name"], "list_price": p.get("price", 0.0), "weight": p.get("weight", 0.0), "type": "consu", "is_storable": True} for p in cfg.get("products", [])] try: prod = get_or_create_many("product.template", "name", rows) except OdooError: for r in rows: # older builds have no is_storable — retry the batch without it r.pop("is_storable", None) prod = get_or_create_many("product.template", "name", rows) print("products:", len(prod)) # 4. vehicle categories + vehicles — batched one level at a time (each level needs the ids above it) veh = cfg.get("vehicles", []) if veh: brand = get_or_create("fleet.vehicle.model.brand", [["name", "=", "Demo Fleet"]], {"name": "Demo Fleet"}) cats = get_or_create_many("fleet.vehicle.model.category", "name", [{"name": v["category"], "weight_capacity": v.get("capacity_kg", 0.0)} for v in veh]) models = get_or_create_many("fleet.vehicle.model", "name", [{"name": v["model"], "brand_id": brand, "category_id": cats[v["category"]]} for v in veh]) get_or_create_many("fleet.vehicle", "license_plate", [{"license_plate": v["plate"], "model_id": models[v["model"]], "category_id": cats[v["category"]]} for v in veh]) print("vehicles:", len(veh)) # 5. customers with credit limits (batched) cust = get_or_create_many("res.partner", "name", [{"name": c["name"], "is_company": True, "customer_rank": 1, "use_partner_credit_limit": True, "credit_limit": c.get("credit_limit", 0.0)} for c in cfg.get("customers", [])]) print("customers:", len(cust)) # 6. B2B pricelist (optional global % discount) pl_cfg = cfg.get("pricelist") if pl_cfg: pl = get_or_create("product.pricelist", [["name", "=", pl_cfg["name"]]], {"name": pl_cfg["name"]}) if pl_cfg.get("percent") and not x("product.pricelist.item", "search", domain=[["pricelist_id", "=", pl]]): x("product.pricelist.item", "create", vals_list={"pricelist_id": pl, "applied_on": "3_global", "compute_price": "percentage", "percent_price": pl_cfg["percent"]}) assign = [cust[c] for c in pl_cfg.get("assign_to", []) if c in cust] if assign: # same vals for all of them -> one write x("res.partner", "write", assign, vals={"property_product_pricelist": pl}) # 7. pre-stage receivables (post unpaid invoices) def first_variant(tmpl_id): return x("product.product", "search", domain=[["product_tmpl_id", "=", tmpl_id]], limit=1)[0] any_prod = first_variant(next(iter(prod.values()))) if prod else None stages = [st for st in cfg.get("prestage_receivables", []) if cust.get(st["customer"])] if stages: mvs = x("account.move", "create", vals_list=[{ "move_type": "out_invoice", "partner_id": cust[st["customer"]], "invoice_line_ids": [(0, 0, {"product_id": any_prod, "quantity": 1, "price_unit": st["amount"], "tax_ids": [(6, 0, [])]})]} for st in stages]) try: x("account.move", "action_post", mvs) # one button call over every invoice except OdooError as e: print(" ! posting invoices:", str(e)[:160]) # 8. verify credit headroom print("\n-- credit headroom --") for r in x("res.partner", "read", list(cust.values()), fields=["name", "credit", "credit_limit"]): flag = "OVER" if r["credit"] > r["credit_limit"] else "ok" print(f" {r['name']:30} owes={r['credit']:>12,.0f} limit={r['credit_limit']:>12,.0f} {flag}") print("\nDONE. Instance:", URL, "DB:", DB)