odoo-demo-architect: discovery -> live Odoo demo + sales docs, AI-built

Public skill for Claude Code. Mines a discovery call, researches native-vs-custom,
stands up a working Odoo demo over XML-RPC (modules + data + tested hero features),
and generates run-of-show, one-pager, and a 1-page demo script.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBytQq5dcASi4ETm7n6THb
This commit is contained in:
SMEtools
2026-06-28 20:13:22 +03:00
co-authored by Claude Opus 4.8
commit 618bdcb444
11 changed files with 571 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
"""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, URL, DB
CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "demo_config.json")
cfg = json.load(open(CFG, encoding="utf-8"))
# 1. currency (must run before invoices)
cur = cfg.get("currency")
if cur:
ids = x("res.currency", "search", [["name", "=", cur]], context={"active_test": False})
if not ids:
ids = [x("res.currency", "create", {"name": cur, "rounding": 0.01})]
x("res.currency", "write", ids, {"active": True})
comp = x("res.company", "search", [], limit=1)[0]
try:
x("res.company", "write", [comp], {"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", {
"account_use_credit_limit": bool(cfg.get("use_credit_limit", True)),
"group_product_pricelist": True,
})
x("res.config.settings", "execute", [s])
# 3. products
prod = {}
for p in cfg.get("products", []):
vals = {"name": p["name"], "list_price": p.get("price", 0.0),
"weight": p.get("weight", 0.0), "type": "consu", "is_storable": True}
try:
prod[p["name"]] = get_or_create("product.template", [["name", "=", p["name"]]], vals)
except Exception:
vals.pop("is_storable", None)
prod[p["name"]] = get_or_create("product.template", [["name", "=", p["name"]]], vals)
print("products:", len(prod))
# 4. vehicle categories + vehicles
brand = None
if cfg.get("vehicles"):
brand = get_or_create("fleet.vehicle.model.brand", [["name", "=", "Demo Fleet"]], {"name": "Demo Fleet"})
for v in cfg.get("vehicles", []):
cat = get_or_create("fleet.vehicle.model.category", [["name", "=", v["category"]]],
{"name": v["category"], "weight_capacity": v.get("capacity_kg", 0.0)})
model = get_or_create("fleet.vehicle.model", [["name", "=", v["model"]]],
{"name": v["model"], "brand_id": brand, "category_id": cat})
get_or_create("fleet.vehicle", [["license_plate", "=", v["plate"]]],
{"model_id": model, "license_plate": v["plate"], "category_id": cat})
print("vehicles:", len(cfg.get("vehicles", [])))
# 5. customers with credit limits
cust = {}
for c in cfg.get("customers", []):
cust[c["name"]] = get_or_create("res.partner", [["name", "=", c["name"]]],
{"name": c["name"], "is_company": True, "customer_rank": 1,
"use_partner_credit_limit": True, "credit_limit": c.get("credit_limit", 0.0)})
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", [["pricelist_id", "=", pl]]):
x("product.pricelist.item", "create", {"pricelist_id": pl, "applied_on": "3_global",
"compute_price": "percentage", "percent_price": pl_cfg["percent"]})
for cname in pl_cfg.get("assign_to", []):
if cname in cust:
x("res.partner", "write", [cust[cname]], {"property_product_pricelist": pl})
# 7. pre-stage receivables (post unpaid invoices)
def first_variant(tmpl_id):
return x("product.product", "search", [["product_tmpl_id", "=", tmpl_id]], limit=1)[0]
any_prod = first_variant(next(iter(prod.values()))) if prod else None
for stage in cfg.get("prestage_receivables", []):
cid = cust.get(stage["customer"])
if not cid:
continue
mv = x("account.move", "create", {
"move_type": "out_invoice", "partner_id": cid,
"invoice_line_ids": [(0, 0, {"product_id": any_prod, "quantity": 1,
"price_unit": stage["amount"], "tax_ids": [(6, 0, [])]})]})
try:
x("account.move", "action_post", [mv])
except Exception as e:
print(" ! post for", stage["customer"], str(e)[:100])
# 8. verify credit headroom
print("\n-- credit headroom --")
for c in cfg.get("customers", []):
cid = cust[c["name"]]
r = x("res.partner", "read", [cid], ["credit", "credit_limit"])[0]
flag = "OVER" if r["credit"] > r["credit_limit"] else "ok"
print(f" {c['name']:30} owes={r['credit']:>12,.0f} limit={r['credit_limit']:>12,.0f} {flag}")
print("\nDONE. Instance:", URL, "DB:", DB)
+76
View File
@@ -0,0 +1,76 @@
"""Generic Odoo XML-RPC connector for odoo-demo-architect.
Reads credentials from environment variables (or a creds.json next to this file):
ODOO_URL e.g. https://my-instance.odoo.com (no trailing slash)
ODOO_DB database name
ODOO_LOGIN user login (often an email; "admin" on Odoo.sh trials)
ODOO_KEY an API key (Settings > My Profile > Account Security > New API Key)
Usage:
from odoo_connect import x, common, UID, URL, DB
UID # authenticated user id
common.version() # confirm server_version BEFORE building (field names vary by version)
x("res.partner", "search_read", [["customer_rank", ">", 0]], fields=["name"])
The x() helper passes keyword args straight into execute_kw's kwargs, so pass
Odoo kwargs natively: x(model, "search", domain, limit=5, context={"active_test": False})
Do NOT pass a context dict positionally — that becomes `offset` and Postgres errors.
"""
import os, json, ssl, xmlrpc.client
_here = os.path.dirname(os.path.abspath(__file__))
def _cfg(key, default=None):
v = os.environ.get(key)
if v:
return v
path = os.path.join(_here, "creds.json")
if os.path.exists(path):
with open(path) as f:
return json.load(f).get(key, default)
return default
URL = (_cfg("ODOO_URL") or "").rstrip("/")
DB = _cfg("ODOO_DB")
LOGIN = _cfg("ODOO_LOGIN") or "admin"
KEY = _cfg("ODOO_KEY")
if not (URL and DB and KEY):
raise SystemExit("Set ODOO_URL, ODOO_DB, ODOO_KEY (and optionally ODOO_LOGIN) "
"as env vars or in creds.json next to this script.")
_ctx = ssl.create_default_context()
common = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/common", context=_ctx, allow_none=True)
UID = common.authenticate(DB, LOGIN, KEY, {})
if not UID:
raise SystemExit("Authentication failed — check DB / LOGIN / API key.")
models = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/object", context=_ctx, allow_none=True)
def x(model, method, *args, **kwargs):
"""execute_kw wrapper. Positional args -> Odoo positional params;
keyword args (limit, fields, context, ...) -> Odoo kwargs."""
return models.execute_kw(DB, UID, KEY, model, method, list(args), kwargs)
def get_or_create(model, domain, vals, label=None):
"""Idempotent create: update if a record matches `domain`, else create."""
ids = x(model, "search", domain, limit=1)
if ids:
x(model, "write", ids, vals)
return ids[0]
return x(model, "create", vals)
def fields_named(model, *needles):
"""Return field technical names on `model` whose name/label contains any needle.
NOTE: on some Odoo 19 builds fields_get attribute VALUES return null over XML-RPC,
so match on the technical KEYS, which are always reliable."""
keys = list(x(model, "fields_get", [], {}).keys())
nl = [n.lower() for n in needles]
return sorted(k for k in keys if any(n in k.lower() for n in nl))
if __name__ == "__main__":
print("server_version:", common.version().get("server_version"))
print("uid:", UID, "url:", URL, "db:", DB)