Skip to content

Vendease Odoo Service

Overview

The Vendease Odoo service is a Python application that synchronises procurement and marketplace data into the Odoo ERP. The repository describes itself in README.md as the Procurement Odoo Sync project: a framework for "synchronizing procurement data — such as invoices and products — with Odoo, utilizing Python." It is distinct from the Odoo addons workspace; this service runs outside Odoo and talks to it through Odoo's external XML-RPC interface.

The service has three concurrent jobs running inside a single process:

  1. expose a small FastAPI HTTP surface for product and vehicle operations;
  2. listen to Postgres LISTEN / NOTIFY channels on the in-house E-Procurement and Marketplace databases and translate those events into Odoo writes; and
  3. run a periodic cron task that backfills any invoices that the listeners missed during downtime windows.

A persistent Redis instance is used both for caching the Odoo authenticated UID and for holding job-deduplication keys. The requirements.txt confirms the runtime stack: FastAPI, Uvicorn, psycopg2, Redis, the Slack SDK, the Google API Python client, python-magic and pytest. The README lists Python 3.12 as the target, while the shipped Dockerfile uses python:3.11-slim.

Architecture & Technology

The runtime is composed of three concurrent units owned by a single process:

  • HTTP serverapp.py builds the FastAPI app, attaches a GZipMiddleware with a 1 KB minimum size, registers a global validate_client_id dependency for client authentication, and mounts versioned routers under /api/v1 for the product and vehicles capability areas. The uvicorn server is started inside run_http_server() on the port pulled from Config.PORT.
  • Database listenersstart_listeners() instantiates an EProcurementListener and a MarketplaceListener and runs them concurrently with asyncio.gather. Each listener uses psycopg2 against the source-system database, sets the connection to autocommit, and issues LISTEN against five channels: new_invoice_record_update, new_payment_request_record_update, product_record_event, new_branch_record_update and dispatch_record_event (the marketplace listener also listens on delete_invoice_record).
  • Cron schedulercron/migrate_invoice.py defines CronScheduler with a 60-second interval_ms. The _migrate_invoice task pulls the last seven days of invoices from both source databases and re-runs the hydrate flow, using a Redis-cached marker key to avoid duplicate work in the same window. app.run_tasks wires run_hydration, start_listeners and CronScheduler.start into a single asyncio.gather call.

The two units are launched on separate threads in main(): one thread runs the asyncio event loop containing the listeners, hydration and cron, and the other runs the synchronous Uvicorn server. Both threads are join()ed so the process stays alive as long as either is running.

The Odoo client is implemented once in connectors/odoo_connector.py. It constructs xmlrpc.client.ServerProxy instances against /xmlrpc/2/common and /xmlrpc/2/object using the configured Odoo URL, database, user and password from config.config.Config. The authenticated UID is cached in Redis to avoid re-authentication on every call. The connector exposes the standard CRUD verbs plus an action helper, and is consumed by every model wrapper.

The model layer (models/) holds typed wrappers around the Odoo entities the service has to read or write: invoice, invoice_item, partner, expense, product, product_variant, product_template_value, product_attribute, product_attribute_line, product_packaging, purchase_order_line, sales_order, sale_order_line, stock_move_picking and unit_of_measure. The wrappers stay focused on the field mapping; business rules live in services/.

Service classes (services/) carry the actual hydrate / sync logic and are named after their Odoo target: contact_service.py, dispatch_service.py, expense_service.py, invoice_service.py, product_service.py, product_variant_service.py, sales_order_service.py, vehicle_service.py. Each is the orchestration point that the listeners and cron call into.

Source-system adapters live under apps/:

  • apps/eprocurement/ ships company_db.py, dispatch.py, invoice_db.py, product_db.py and product_service.py — Postgres-backed reads against the E-Procurement schema.
  • apps/marketplace/ mirrors the structure for the Marketplace database (company_db.py, dispatch.py, invoice_db.py).
  • apps/base_db.py factors out the common Postgres wiring.

Auxiliary subsystems include notifications/ (Slack notifications, plus a sale_order.py notifier and send_notification.py dispatcher), vis/ and vtp/ (additional pricing / availability adapters used by the product routes), purchase_order_inherit/ (custom purchase-order behaviours), utils/redis_client.py (the singleton Redis client), common/, types_r/, and infra/. The tests/ directory and connectors/tests/, services/tests/, models/tests/ carry pytest suites; the project ships a coverage.xml and a .coverage file from the last run.

The combination of FastAPI for the synchronous surface, Postgres LISTEN/NOTIFY for change capture, Redis for cross-cycle deduplication, threads for HTTP-versus-async-loop separation, and Odoo XML-RPC for the write side defines the architecture.

How It Fits With Other Systems

Inputs:

  • E-Procurement Postgres database — primary source. The EProcurementListener reacts to invoice, payment request, product, branch and dispatch events; apps/eprocurement exposes paginated reads for hydration and cron backfills.
  • Marketplace Postgres database — the MarketplaceListener mirrors the same channels (with an extra delete_invoice_record channel); the invoice migration cron also pulls Marketplace invoices.
  • HTTP clients — internal services hit /api/v1/product and /api/v1/vehicles. The validate_client_id dependency in config/client.py gates every endpoint by client ID.
  • VTP / VIS — the vtp/ module exposes price-and-availability lookups driven by city, product name and unit of measure; vis/ plays an analogous role for additional inventory data.

Outputs:

  • Odoo ERP — the dominant target. Sales orders, purchase orders, partners, products, product variants, packagings, attributes, stock-move pickings, invoices, invoice items, expenses and units of measure are all written through OdooConnector.
  • Slack — the notifications/ package and the slack_sdk dependency in requirements.txt cover operational alerting.
  • Google APIsgoogle-api-python-client and protobuf are present for Sheets / Drive integrations used in the auxiliary flows.

The service is a one-way pipeline from the in-house Postgres systems into Odoo, with a synchronous HTTP path used for ad-hoc product and vehicle operations and a cron safety-net to recover from any missed events.

Repository Layout

Top-level files:

  • app.py — FastAPI bootstrap, listener / cron orchestration, threaded entry point.
  • requirements.txt — pinned Python dependencies.
  • Dockerfilepython:3.11-slim base, installs libpq-dev, gcc, build-essential, postgresql-client and file, copies the source, exposes port 8000, runs as the unprivileged appuser, and starts the app with python app.py.
  • docker-compose.yml — local dev stack with web (FastAPI), db (postgres:15-alpine) and redis (redis:7-alpine) services.
  • env-prod, env-staging, .env, .env-sample — environment files for the three runtime targets. They are referenced by Docker Compose / the runtime loader; their contents are not reproduced here.
  • README.md — short project description and quickstart.
  • coverage.xml, .coverage — last test-run coverage artefacts.

Application packages:

  • apps/ — source-system adapters split into eprocurement/ and marketplace/, with a shared base_db.py.
  • connectors/ — the single odoo_connector.py XML-RPC client and its tests.
  • listeners/eprocurement.py and marketplace.py, each owning the Postgres LISTEN/NOTIFY loop and dispatching to services.
  • cron/migrate_invoice.py with the CronScheduler periodic backfill.
  • services/ — domain services, one per Odoo target area.
  • models/ — Odoo entity wrappers used by the services.
  • routes/ — FastAPI routers (product_routes.py, vehicle_routes.py).
  • config/Config loader (config/config.py), client.py for client-ID validation, pagination.py helpers.
  • notifications/ — Slack and sale-order notifiers.
  • purchase_order_inherit/ — custom purchase-order behaviour bundled as its own package.
  • vis/, vtp/ — auxiliary product/availability lookups consumed by the product routes.
  • utils/ — shared helpers including redis_client.py.
  • common/, types_r/ — shared types and helpers.
  • infra/ — deployment manifests.
  • tests/, services/tests/, models/tests/, connectors/tests/ — pytest suites.
  • log/ — local log directory.
  • venv/ — a checked-in virtual environment directory, present in the working copy.

Running, Building & Deploying

The README documents the local workflow:

  • Create a Python 3.12 environment.
  • Install dependencies: pip3 install -r requirements.txt.
  • Set PYTHONPATH=$(pwd) before running tests.
  • Run tests with pytest-watch.

Application startup is python app.py, which is what both the README's implicit flow and the Dockerfile's CMD do. app.main() spawns one thread for the HTTP server (uvicorn.run(app, host="0.0.0.0", port=int(Config.PORT))) and one thread for the asyncio loop that owns hydration, listeners and the cron scheduler.

Containerised execution:

  • docker-compose up starts the full local stack: a web container running uvicorn main:app --host 0.0.0.0 --port 8000 --reload, a Postgres 15 database (vis_dev), and a Redis 7 cache. The compose file mounts the working directory into /app so live edits are picked up.
  • The Dockerfile builds a slimmer image for non-dev environments and runs the application as appuser.

Configuration is environment-driven. config/config.py reads Config.ODOO_URL, Config.ODOO_DB, Config.ODOO_USER, Config.ODOO_PASSWORD, the E-Procurement Postgres credentials (EPROC_DB_NAME, EPROC_DB_USER, EPROC_DB_PASSWORD, EPROC_DB_HOST, EPROC_DB_PORT), the equivalent Marketplace ("PIB") credentials, the Redis connection details, the Slack token and Config.PORT. The env-prod and env-staging files supply those values for production and staging respectively; both are present in the working tree but are not read in the course of producing this document. The .env-sample file is the template to use when populating a new environment.

Database migrations are not part of this service: the source-system schemas are owned by E-Procurement and Marketplace, and the Odoo schema is managed by Odoo itself.

Operational Notes & Risks

  • Single Odoo gateway — every Odoo write goes through one OdooConnector per model. If Odoo authentication fails at boot, the process logs the error but the listeners still start; calls then fail at use-time. Re-authentication caches the UID in Redis.
  • Threaded HTTP + asyncio model — the HTTP server and the asyncio loop run on separate threads with no shared cancellation. If one thread dies the other continues, which can leave the service half-functional until it is restarted by the supervisor.
  • print of credentials in the connectorconnectors/odoo_connector.py contains print statements that emit Config.ODOO_DB, Config.ODOO_URL, Config.ODOO_USER and Config.ODOO_PASSWORD on every instantiation. This exposes the Odoo password in container stdout and any aggregated log pipeline; it should be removed.
  • Event-driven plus cron safety net — the listeners are the primary signal, but the 60-second _migrate_invoice cron and the Redis-keyed deduplication is what catches anything that was missed during a listener outage. The Redis cache key is dated and tagged, so flushing Redis can cause the cron to redo a window of work; that work is expected to be idempotent on the Odoo side.
  • Hydration toggles are commented outapp.run_hydration() ships with most of its body commented (product_variant_service.clean_up_old_product, product fetch / hydrate, invoice fetch / hydrate). The function still runs, but at present it only logs intent and returns. The active backfill path is the cron, not the boot-time hydration.
  • Postgres LISTEN/NOTIFY reconnect — both listeners attempt to re-establish the connection on OperationalError; production reliability depends on this loop continuing to retry. Long-lived NOTIFY connections can also be dropped silently by network middleboxes.
  • Multiple environments share the same code path — runtime selection is purely environment-driven through env-prod and env-staging. Any misconfiguration of which env file is loaded would point the service at the wrong source database or the wrong Odoo instance.
  • Local artefacts in repovenv/, coverage.xml, .coverage, log/ and __pycache__/ directories are committed alongside the source. They should be excluded from container images to keep the image small and to avoid leaking historical state.
  • README and Dockerfile Python versions disagree — the README calls out Python 3.12 while the Dockerfile uses python:3.11-slim. Tested behaviour in production is whatever 3.11 produces; the discrepancy should be reconciled.
  • Port mismatch in compose vs. applicationdocker-compose.yml launches uvicorn main:app on port 8000, but the application entry point is app.py (not main.py) and binds to Config.PORT. The documented compose command works only if main.py exists and Config.PORT matches the published port; otherwise the container won't serve. The Dockerfile's CMD ["python", "app.py"] is the authoritative way to run the service.
  • Sensitive env files presentenv-prod and env-staging carry runtime configuration including credentials. They should not leave the controlled environment, and they were intentionally not opened or reproduced when preparing this document.
  • Listener channel surface is fixed in code — the five Postgres channels the listeners subscribe to (new_invoice_record_update, new_payment_request_record_update, product_record_event, new_branch_record_update, dispatch_record_event, plus the marketplace-only delete_invoice_record) are hard-coded constants in listeners/eprocurement.py and listeners/marketplace.py. Adding a new event type requires both a NOTIFY emitter on the source side and a corresponding constant + dispatch branch on this side; missing one side silently drops the events.
  • Hydration is gated by date mathget_default_created_at() in app.py returns "today minus four months" as the start cursor for the invoice fetch, and _migrate_invoice uses a fixed seven-day window. Restoring data older than those windows requires either a manual run with explicit dates or a temporary code change.
  • Reliance on Redis for deduplication — the cron uses Redis-keyed deduplication (cache_key = f"invoice-migration-job-...") to avoid re-running the same window. If Redis is unavailable the cron will re-do work; if Redis is shared with other services, key collisions could mistakenly skip a window. Keys are namespaced loosely in code and would benefit from a tighter prefix.
  • Per-call connection cost — both the listeners and the cron instantiate fresh OdooConnector instances per call. The XML-RPC proxies are cheap to construct, but each call still incurs network round-trips for authentication when the cached UID is missing. Production stability therefore depends on the Redis UID cache staying warm.
  • Idle / disabled hydration code paths — several body lines in app.run_hydration are commented out (product variants cleanup, product fetch + hydrate, invoice hydrate). Anyone re-enabling these needs to consider the load they will place on Odoo and on the source databases, and to confirm whether the underlying service methods are still idempotent against the current schema.