diff --git a/milad/.env.example b/milad/.env.example
new file mode 100644
index 0000000..d8c9c2f
--- /dev/null
+++ b/milad/.env.example
@@ -0,0 +1,10 @@
+ODOO_URL=http://localhost:8069
+ODOO_DB=exam_db
+ODOO_USERNAME=admin
+ODOO_PASSWORD=admin
+
+APP_DB_HOST=localhost
+APP_DB_PORT=5433
+APP_DB_NAME=sync_backend
+APP_DB_USER=sync_user
+APP_DB_PASSWORD=sync_password
\ No newline at end of file
diff --git a/milad/.gitignore b/milad/.gitignore
new file mode 100644
index 0000000..212b160
--- /dev/null
+++ b/milad/.gitignore
@@ -0,0 +1,11 @@
+.venv/
+__pycache__/
+*.pyc
+*.egg-info/
+.env
+.pytest_cache/
+.coverage
+htmlcov/
+*.log
+.vscode/
+.idea/
\ No newline at end of file
diff --git a/milad/Dockerfile b/milad/Dockerfile
new file mode 100644
index 0000000..6d88390
--- /dev/null
+++ b/milad/Dockerfile
@@ -0,0 +1,13 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY pyproject.toml ./
+COPY src ./src
+
+RUN pip install --no-cache-dir -e .
+
+COPY migrations ./migrations
+COPY alembic.ini ./alembic.ini
+
+CMD ["sh", "-c", "alembic upgrade head && python -m backend.cli"]
\ No newline at end of file
diff --git a/milad/README.md b/milad/README.md
new file mode 100644
index 0000000..4f9c319
--- /dev/null
+++ b/milad/README.md
@@ -0,0 +1,27 @@
+# Odoo → PostgreSQL Sync Backend
+
+This is my solution for the technical exam. It pulls contacts, products, and sale orders out of Odoo and stores them in my own PostgreSQL database, without creating duplicates if you run it more than once.
+
+## What's in here
+
+- An Odoo instance running in Docker (I set this up myself, no server was given to me for the exam)
+- A small script to create some test data in Odoo (contacts, products, sale orders)
+- A Python backend that connects to Odoo, pulls the data, and saves it to Postgres
+- Tests for the important parts
+- Docs: `USER.md` (how to run everything) and `TECHNICAL.md` (how it's built and why)
+
+## Quick start
+
+```powershell
+docker compose up -d odoo-db odoo app-db
+```
+
+Then open `http://localhost:8069`, log in / create the database, install the Sales app, and run the seed script. Full steps are in `USER.md`.
+
+## Why it's built this way
+
+I split the code into layers: one part just talks to Odoo, one part maps Odoo's data into my own format, one part handles saving to the database, and one part ties it all together and keeps track of what happened (what got created, what got updated, what failed). This way if I ever needed to swap Odoo for a different system, or Postgres for something else, I wouldn't have to rewrite everything — just the one piece that changed.
+
+## Status
+
+Still learning parts of this as I go — some of the design decisions I made because they're generally good practice, and I'll be able to explain them properly once I've had time to sit with the whole project and understand it end to end.
diff --git a/milad/Technical Documentation.md b/milad/Technical Documentation.md
new file mode 100644
index 0000000..75183d7
--- /dev/null
+++ b/milad/Technical Documentation.md
@@ -0,0 +1,116 @@
+# یادداشتهای فنی
+
+این فایل بیشتر برای توضیح ساختار پروژه و چند تصمیمی نوشته شده که شاید از روی کد مشخص نباشن.
+
+## روند Sync
+
+```text
+Odoo (XML-RPC)
+ ↓
+Adapter
+ ↓
+Mapper
+ ↓
+Sync Service
+ ↓
+Repository
+ ↓
+PostgreSQL
+```
+
+هر لایه فقط مسئول کار خودش است:
+
+
+ - Adapter: فقط با Odoo ارتباط برقرار میکند و دادهی خام برمیگرداند.
+ - Mapper: دادهی Odoo را به مدلهای داخلی تبدیل میکند.
+ - Sync Service: ترتیب کارها رو مدیریت میکنه، خطاها رو هندل میکنه.
+ - Repository: فقط عملیات مربوط به دیتابیس را انجام میدهد.
+
+
+به همین دلیل منطق Sync وابسته به Odoo یا PostgreSQL نیست و هر بخش را میتوان جداگانه تغییر داد.
+
+## جلوگیری از رکوردهای تکراری
+
+هر جدول یک ستون یکتای `odoo_id` دارد.
+
+هنگام Sync ابتدا رکورد با همان `odoo_id` جستجو میشود. اگر وجود داشته باشد، اطلاعاتش بهروزرسانی میشود و در غیر این صورت یک رکورد جدید ایجاد میشود.
+
+به همین خاطر اجرای چندبارهی Sync همیشه نتیجهی یکسانی دارد و دادهی تکراری ایجاد نمیشود.
+
+## مدیریت خطا
+
+هر رکورد بهصورت مستقل پردازش میشود.
+
+اگر پردازش یک رکورد با خطا روبهرو شود، خطا داخل `sync_logs` ثبت میشود و Sync ادامه پیدا میکند.
+
+اما اگر خطایی در سطح زیرساخت رخ بدهد، مثل قطع شدن اتصال دیتابیس، اجرای Sync متوقف میشود و تراکنش Rollback خواهد شد.
+
+## Retry
+
+در صورت بروز خطاهای ارتباطی با Odoo، عملیات دریافت اطلاعات تا سه بار دوباره امتحان میشود.
+
+خطاهای مربوط به اعتبارسنجی یا تبدیل داده Retry نمیشوند، چون اجرای دوباره نتیجهی متفاوتی نخواهد داشت.
+
+## Batch Processing
+
+اطلاعات بهصورت Batchهای ۱۰۰تایی از Odoo دریافت میشوند تا مصرف حافظه ثابت بماند و پروژه برای حجم دادهی بیشتر هم قابل استفاده باشد.
+
+## Incremental Sync
+
+در حال حاضر امکان Sync افزایشی هم در نظر گرفته شده است. با استفاده از زمان آخرین اجرای موفق، فقط رکوردهایی که بعد از آن تغییر کردهاند قابل دریافت هستند. اگر اجرای موفقی وجود نداشته باشد، یک Sync کامل انجام میشود.
+
+## Logging
+
+لاگگیری پروژه در دو سطح انجام میشود:
+
+**سطح اول: لاگ متنی روی ترمینال (Standard Output)**
+
+با استفاده از ماژول استاندارد `logging` پایتون، هر پیام (`info`, `warning`, `error`) به همراه زمان، سطح و نام ماژول روی خروجی استاندارد چاپ میشود. این همان چیزی است که هنگام اجرای `python -m backend.cli` روی ترمینال دیده میشود، مثل:
+
+```text
+2026-07-24 10:33:44 INFO backend.cli: Sync finished: fetched=99 created=0 updated=99 errors=0
+```
+
+**سطح دوم: لاگ ساختاریافته در دیتابیس**
+
+هر بار که پردازش یک رکورد با خطا مواجه شود، علاوه بر چاپ در ترمینال، یک رکورد در جدول `sync_logs` هم ذخیره میشود. این رکورد شامل موارد زیر است:
+
+* `sync_run_id` — به کدام اجرای Sync (از جدول `sync_runs`) مربوط است
+* `level` — سطح لاگ (مثلاً `error`)
+* `message` — متن کامل خطا
+* `record_odoo_id` — شناسهی رکورد مشکلدار در Odoo (در صورت وجود)
+* `created_at` — زمان ثبت لاگ
+
+به این ترتیب لاگهای ترمینال گذرا هستند اما لاگهای داخل دیتابیس دائمیاند و بعداً هم قابل بررسیاند.
+
+**اطلاعات هر اجرای Sync در جدول `sync_runs`**
+
+جدول `sync_runs` هم یک رکورد خلاصه از هر اجرا نگه میدارد، شامل:
+
+* زمان شروع و پایان اجرا (`started_at`, `finished_at`)
+* تعداد رکوردهای دریافتشده (`fetched_count`)
+* تعداد رکوردهای جدید ایجادشده (`created_count`)
+* تعداد رکوردهای بهروزرسانیشده (`updated_count`)
+* تعداد خطاها (`error_count`)
+
+این جدول همیشه پر میشود، حتی اگر خود اجرای Sync با خطای کلی متوقف شود، چون بخش ثبت آمار در بلوک `finally` قرار دارد و مستقل از موفقیت یا شکست کل فرآیند اجرا میشود.
+
+میتوان این اطلاعات را مستقیم با کوئری روی دیتابیس هم دید، مثلاً:
+
+```sql
+SELECT id, operation, started_at, fetched_count, created_count, updated_count, error_count
+FROM sync_runs
+ORDER BY id DESC
+LIMIT 5;
+```
+
+## ساختار دیتابیس
+
+* `contacts`
+* `products`
+* `sale_orders`
+* `sale_order_lines`
+* `sync_runs`
+* `sync_logs`
+
+تغییرات ساختار دیتابیس با **Alembic** مدیریت میشود.
\ No newline at end of file
diff --git a/milad/User Documentation.md b/milad/User Documentation.md
new file mode 100644
index 0000000..65ef714
--- /dev/null
+++ b/milad/User Documentation.md
@@ -0,0 +1,94 @@
+# odoo-sync
+
+این پروژه اطلاعات مخاطبین، محصولات و سفارشهای فروش را از **Odoo** دریافت میکند و داخل **PostgreSQL** ذخیره میکند.
+
+فرآیند **Sync** بهصورت **Idempotent** پیادهسازی شده؛ یعنی فرقی نمیکند یک بار اجرا شود یا چندین بار، رکورد تکراری ایجاد نمیشود و فقط اطلاعات موجود بهروزرسانی خواهند شد.
+
+## پیشنیازها
+
+* Docker Desktop
+* Python 3.12 یا بالاتر
+
+## راهاندازی
+
+ابتدا **Odoo**، دیتابیس آن و دیتابیس برنامه را اجرا کنید:
+
+```powershell
+docker compose up -d odoo-db odoo app-db
+```
+
+چند ثانیه صبر کنید تا سرویسها بالا بیایند، سپس وضعیت آنها را بررسی کنید:
+
+```powershell
+docker compose ps
+```
+
+### فقط برای اولین اجرا
+
+مرورگر را باز کنید و وارد آدرس زیر شوید:
+
+```text
+http://localhost:8069
+```
+
+اگه لازم بود دیتابیس جدیدی بسازید، از تنظیمات زیر استفاده کنید:
+
+* Database: `exam_db`
+* Email: `admin`
+* Password: `admin`
+* Demo Data: `Skip`
+
+اگر صفحه ورود نمایش داده شد، یعنی دیتابیس از قبل ساخته شده است با نام کاربری و رمز `admin` وارد شوید.
+
+بعد از ورود، ماژول **Sales** را نصب کنید. این پروژه برای دریافت مخاطبین، محصولات و سفارشهای فروش به این ماژول نیاز دارد.
+
+
+### ایجاد دادههای نمونه
+
+```powershell
+python scripts\seed_odoo.py
+```
+
+این اسکریپت چند مخاطب، محصول و سفارش فروش نمونه ایجاد میکند و در صورت اجرای دوباره هم مشکلی ایجاد نمیکند.
+
+### ساخت جداول دیتابیس
+
+```powershell
+alembic upgrade head
+```
+
+## اجرای Sync
+
+```powershell
+python -m backend.cli
+```
+
+خروجی نمونه:
+
+```text
+Sync finished: fetched=99 created=99 updated=0 errors=0
+```
+
+اگر دوباره همین دستور را اجرا کنید، مقدار `created` دیگر افزایش پیدا نمیکند و بهجای آن مقدار `updated` بیشتر میشه.
+
+## اجرای پروژه با Docker
+
+```powershell
+docker compose up --build
+```
+
+این دستور Image برنامه را میسازد، منتظر آماده شدن **Odoo** و **PostgreSQL** میماند، Migrationها را اجرا میکند و در نهایت فرآیند **Sync** را شروع میکند.
+
+## اجرای تستها
+
+```powershell
+docker compose up -d test-db
+pytest -v
+```
+
+برای مشاهده **Coverage**:
+
+```powershell
+pytest --cov=backend --cov-report=term-missing
+```
+
diff --git a/milad/alembic.ini b/milad/alembic.ini
new file mode 100644
index 0000000..08f1d9f
--- /dev/null
+++ b/milad/alembic.ini
@@ -0,0 +1,149 @@
+# A generic, single database configuration.
+
+[alembic]
+# path to migration scripts.
+# this is typically a path given in POSIX (e.g. forward slashes)
+# format, relative to the token %(here)s which refers to the location of this
+# ini file
+script_location = %(here)s/migrations
+
+# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
+# Uncomment the line below if you want the files to be prepended with date and time
+# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
+# for all available tokens
+# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+# Or organize into date-based subdirectories (requires recursive_version_locations = true)
+# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
+
+# sys.path path, will be prepended to sys.path if present.
+# defaults to the current working directory. for multiple paths, the path separator
+# is defined by "path_separator" below.
+prepend_sys_path = .
+
+
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the tzdata library which can be installed by adding
+# `alembic[tz]` to the pip requirements.
+# string value is passed to ZoneInfo()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the "slug" field
+# truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; This defaults
+# to /versions. When using multiple version
+# directories, initial revisions must be specified with --version-path.
+# The path separator used here should be the separator specified by "path_separator"
+# below.
+# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
+
+# path_separator; This indicates what character is used to split lists of file
+# paths, including version_locations and prepend_sys_path within configparser
+# files such as alembic.ini.
+# The default rendered in new alembic.ini files is "os", which uses os.pathsep
+# to provide os-dependent path splitting.
+#
+# Note that in order to support legacy alembic.ini files, this default does NOT
+# take place if path_separator is not present in alembic.ini. If this
+# option is omitted entirely, fallback logic is as follows:
+#
+# 1. Parsing of the version_locations option falls back to using the legacy
+# "version_path_separator" key, which if absent then falls back to the legacy
+# behavior of splitting on spaces and/or commas.
+# 2. Parsing of the prepend_sys_path option falls back to the legacy
+# behavior of splitting on spaces, commas, or colons.
+#
+# Valid values for path_separator are:
+#
+# path_separator = :
+# path_separator = ;
+# path_separator = space
+# path_separator = newline
+#
+# Use os.pathsep. Default configuration used for new projects.
+path_separator = os
+
+# set to 'true' to search source files recursively
+# in each "version_locations" directory
+# new in Alembic version 1.10
+# recursive_version_locations = false
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = utf-8
+
+# database URL. This is consumed by the user-maintained env.py script only.
+# other means of configuring database URLs may be customized within the env.py
+# file.
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+
+[post_write_hooks]
+# post_write_hooks defines scripts or Python functions that are run
+# on newly generated revision scripts. See the documentation for further
+# detail and examples
+
+# format using "black" - use the console_scripts runner, against the "black" entrypoint
+# hooks = black
+# black.type = console_scripts
+# black.entrypoint = black
+# black.options = -l 79 REVISION_SCRIPT_FILENAME
+
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# hooks = ruff
+# ruff.type = module
+# ruff.module = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Alternatively, use the exec runner to execute a binary found on your PATH
+# hooks = ruff
+# ruff.type = exec
+# ruff.executable = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Logging configuration. This is also consumed by the user-maintained
+# env.py script only.
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARNING
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARNING
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/milad/docker-compose.yml b/milad/docker-compose.yml
new file mode 100644
index 0000000..9ac1af8
--- /dev/null
+++ b/milad/docker-compose.yml
@@ -0,0 +1,92 @@
+services:
+ odoo-db:
+ image: postgres:15-alpine
+ environment:
+ POSTGRES_USER: odoo
+ POSTGRES_PASSWORD: odoo
+ POSTGRES_DB: postgres
+ volumes:
+ - odoo_db_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U odoo -d postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ odoo:
+ image: odoo:17.0
+ depends_on:
+ odoo-db:
+ condition: service_healthy
+ ports:
+ - "8069:8069"
+ environment:
+ HOST: odoo-db
+ USER: odoo
+ PASSWORD: odoo
+ volumes:
+ - odoo_data:/var/lib/odoo
+ - ./odoo/config/odoo.conf:/etc/odoo/odoo.conf
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8069/web/login"]
+ interval: 10s
+ timeout: 5s
+ retries: 15
+ start_period: 30s
+
+ app-db:
+ image: postgres:15-alpine
+ environment:
+ POSTGRES_USER: sync_user
+ POSTGRES_PASSWORD: sync_password
+ POSTGRES_DB: sync_backend
+ ports:
+ - "5433:5432"
+ volumes:
+ - app_db_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U sync_user -d sync_backend"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+
+ test-db:
+ image: postgres:15-alpine
+ environment:
+ POSTGRES_USER: test_user
+ POSTGRES_PASSWORD: test_password
+ POSTGRES_DB: test_backend
+ ports:
+ - "5434:5432"
+ volumes:
+ - test_db_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U test_user -d test_backend"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ backend:
+ build: .
+ depends_on:
+ odoo:
+ condition: service_healthy
+ app-db:
+ condition: service_healthy
+ environment:
+ ODOO_URL: http://odoo:8069
+ ODOO_DB: exam_db
+ ODOO_USERNAME: admin
+ ODOO_PASSWORD: admin
+ APP_DB_HOST: app-db
+ APP_DB_PORT: 5432
+ APP_DB_NAME: sync_backend
+ APP_DB_USER: sync_user
+ APP_DB_PASSWORD: sync_password
+ restart: "no"
+
+volumes:
+ odoo_db_data:
+ odoo_data:
+ app_db_data:
+ test_db_data:
\ No newline at end of file
diff --git a/milad/migrations/README b/milad/migrations/README
new file mode 100644
index 0000000..98e4f9c
--- /dev/null
+++ b/milad/migrations/README
@@ -0,0 +1 @@
+Generic single-database configuration.
\ No newline at end of file
diff --git a/milad/migrations/env.py b/milad/migrations/env.py
new file mode 100644
index 0000000..361d267
--- /dev/null
+++ b/milad/migrations/env.py
@@ -0,0 +1,47 @@
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from backend.infrastructure.config import get_settings
+from backend.infrastructure.db_models import Base
+
+config = context.config
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+settings = get_settings()
+config.set_main_option("sqlalchemy.url", settings.app_db_url)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
\ No newline at end of file
diff --git a/milad/migrations/script.py.mako b/milad/migrations/script.py.mako
new file mode 100644
index 0000000..1101630
--- /dev/null
+++ b/milad/migrations/script.py.mako
@@ -0,0 +1,28 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ ${downgrades if downgrades else "pass"}
diff --git a/milad/migrations/versions/ff9e734e0a1c_initial_schema.py b/milad/migrations/versions/ff9e734e0a1c_initial_schema.py
new file mode 100644
index 0000000..09e91c9
--- /dev/null
+++ b/milad/migrations/versions/ff9e734e0a1c_initial_schema.py
@@ -0,0 +1,112 @@
+"""initial schema
+
+Revision ID: ff9e734e0a1c
+Revises:
+Create Date: 2026-07-24 09:53:48.856974
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'ff9e734e0a1c'
+down_revision: Union[str, Sequence[str], None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('contacts',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('odoo_id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('email', sa.String(length=255), nullable=True),
+ sa.Column('phone', sa.String(length=50), nullable=True),
+ sa.Column('mobile', sa.String(length=50), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_contacts_odoo_id'), 'contacts', ['odoo_id'], unique=True)
+ op.create_table('products',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('odoo_id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('internal_reference', sa.String(length=100), nullable=True),
+ sa.Column('sale_price', sa.Numeric(precision=12, scale=2), nullable=False),
+ sa.Column('product_type', sa.String(length=50), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_products_odoo_id'), 'products', ['odoo_id'], unique=True)
+ op.create_table('sync_runs',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('operation', sa.String(length=100), nullable=False),
+ sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('fetched_count', sa.Integer(), nullable=False),
+ sa.Column('created_count', sa.Integer(), nullable=False),
+ sa.Column('updated_count', sa.Integer(), nullable=False),
+ sa.Column('error_count', sa.Integer(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('sale_orders',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('odoo_id', sa.Integer(), nullable=False),
+ sa.Column('order_number', sa.String(length=100), nullable=False),
+ sa.Column('customer_id', sa.Integer(), nullable=False),
+ sa.Column('order_date', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('state', sa.String(length=50), nullable=False),
+ sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['customer_id'], ['contacts.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_sale_orders_odoo_id'), 'sale_orders', ['odoo_id'], unique=True)
+ op.create_table('sync_logs',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('sync_run_id', sa.Integer(), nullable=False),
+ sa.Column('level', sa.String(length=20), nullable=False),
+ sa.Column('message', sa.String(), nullable=False),
+ sa.Column('record_odoo_id', sa.Integer(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['sync_run_id'], ['sync_runs.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('sale_order_lines',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('odoo_id', sa.Integer(), nullable=False),
+ sa.Column('sale_order_id', sa.Integer(), nullable=False),
+ sa.Column('product_id', sa.Integer(), nullable=False),
+ sa.Column('quantity', sa.Numeric(precision=12, scale=2), nullable=False),
+ sa.Column('unit_price', sa.Numeric(precision=12, scale=2), nullable=False),
+ sa.Column('subtotal', sa.Numeric(precision=12, scale=2), nullable=False),
+ sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
+ sa.ForeignKeyConstraint(['sale_order_id'], ['sale_orders.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_sale_order_lines_odoo_id'), 'sale_order_lines', ['odoo_id'], unique=True)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_sale_order_lines_odoo_id'), table_name='sale_order_lines')
+ op.drop_table('sale_order_lines')
+ op.drop_table('sync_logs')
+ op.drop_index(op.f('ix_sale_orders_odoo_id'), table_name='sale_orders')
+ op.drop_table('sale_orders')
+ op.drop_table('sync_runs')
+ op.drop_index(op.f('ix_products_odoo_id'), table_name='products')
+ op.drop_table('products')
+ op.drop_index(op.f('ix_contacts_odoo_id'), table_name='contacts')
+ op.drop_table('contacts')
+ # ### end Alembic commands ###
diff --git a/milad/odoo/config/odoo.conf b/milad/odoo/config/odoo.conf
new file mode 100644
index 0000000..55adf23
--- /dev/null
+++ b/milad/odoo/config/odoo.conf
@@ -0,0 +1,9 @@
+[options]
+admin_passwd = $pbkdf2-sha512$600000$WItxTum9V2qt9b53LuU8Rw$sbCpzhAV0WU01NsdoX0KulLZl5MpN4lE/ZWUIz6KfUsgCobuc/aCd8gASBJ4SdZrIKg4buthry83KhPf8s3ylg
+db_host = odoo-db
+db_port = 5432
+db_user = odoo
+db_password = odoo
+db_name = exam_db
+list_db = True
+
diff --git a/milad/pyproject.toml b/milad/pyproject.toml
new file mode 100644
index 0000000..dba746b
--- /dev/null
+++ b/milad/pyproject.toml
@@ -0,0 +1,28 @@
+[project]
+name = "odoo-sync-backend"
+version = "0.1.0"
+description = "Backend service syncing Odoo data into PostgreSQL"
+requires-python = ">=3.12"
+dependencies = [
+ "sqlalchemy>=2.0",
+ "alembic>=1.13",
+ "psycopg2-binary>=2.9",
+ "python-dotenv>=1.0",
+ "pydantic>=2.6",
+ "pydantic-settings>=2.2",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.0",
+ "pytest-cov>=5.0",
+ "black>=24.0",
+ "ruff>=0.4",
+]
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[build-system]
+requires = ["setuptools>=68.0"]
+build-backend = "setuptools.build_meta"
diff --git a/milad/scripts/seed_odoo.py b/milad/scripts/seed_odoo.py
new file mode 100644
index 0000000..c0179d2
--- /dev/null
+++ b/milad/scripts/seed_odoo.py
@@ -0,0 +1,84 @@
+import xmlrpc.client
+
+ODOO_URL = "http://localhost:8069"
+ODOO_DB = "exam_db"
+ODOO_USER = "admin"
+ODOO_PASSWORD = "admin"
+
+common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common")
+uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASSWORD, {})
+
+if not uid:
+ raise SystemExit("Authentication failed, check ODOO_USER / ODOO_PASSWORD / ODOO_DB")
+
+models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object")
+
+
+def call(model, method, *args):
+ return models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, model, method, list(args))
+
+
+contacts_data = [
+ {"name": "Ali Rezaei", "email": "ali.rezaei@example.com", "phone": "02112345678", "mobile": "09121234567"},
+ {"name": "Sara Ahmadi", "email": "sara.ahmadi@example.com", "phone": "02187654321", "mobile": "09129876543"},
+ {"name": "Reza Karimi", "email": "reza.karimi@example.com", "phone": "02155667788", "mobile": "09123344556"},
+]
+
+contact_ids = []
+for c in contacts_data:
+ existing = call("res.partner", "search", [["email", "=", c["email"]]])
+ if existing:
+ contact_ids.append(existing[0])
+ continue
+ new_id = call("res.partner", "create", c)
+ contact_ids.append(new_id)
+
+print(f"Contacts ready: {contact_ids}")
+
+products_data = [
+ {"name": "Wireless Mouse", "default_code": "PRD-001", "list_price": 15.5, "type": "consu"},
+ {"name": "Mechanical Keyboard", "default_code": "PRD-002", "list_price": 45.0, "type": "consu"},
+ {"name": "USB-C Hub", "default_code": "PRD-003", "list_price": 22.75, "type": "consu"},
+]
+
+product_ids = []
+for p in products_data:
+ existing = call("product.product", "search", [["default_code", "=", p["default_code"]]])
+ if existing:
+ product_ids.append(existing[0])
+ continue
+ new_id = call("product.product", "create", p)
+ product_ids.append(new_id)
+
+print(f"Products ready: {product_ids}")
+
+orders_data = [
+ {
+ "partner_id": contact_ids[0],
+ "order_line": [
+ (0, 0, {"product_id": product_ids[0], "product_uom_qty": 2, "price_unit": 15.5}),
+ (0, 0, {"product_id": product_ids[1], "product_uom_qty": 1, "price_unit": 45.0}),
+ ],
+ },
+ {
+ "partner_id": contact_ids[1],
+ "order_line": [
+ (0, 0, {"product_id": product_ids[2], "product_uom_qty": 3, "price_unit": 22.75}),
+ ],
+ },
+ {
+ "partner_id": contact_ids[2],
+ "order_line": [
+ (0, 0, {"product_id": product_ids[0], "product_uom_qty": 5, "price_unit": 15.5}),
+ (0, 0, {"product_id": product_ids[2], "product_uom_qty": 2, "price_unit": 22.75}),
+ ],
+ },
+]
+
+order_ids = []
+for o in orders_data:
+ new_id = call("sale.order", "create", o)
+ order_ids.append(new_id)
+ call("sale.order", "action_confirm", [new_id])
+
+print(f"Sale orders created and confirmed: {order_ids}")
\ No newline at end of file
diff --git a/milad/src/backend/__init__.py b/milad/src/backend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milad/src/backend/adapters/__init__.py b/milad/src/backend/adapters/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milad/src/backend/adapters/base.py b/milad/src/backend/adapters/base.py
new file mode 100644
index 0000000..5bfe865
--- /dev/null
+++ b/milad/src/backend/adapters/base.py
@@ -0,0 +1,19 @@
+from abc import ABC, abstractmethod
+
+
+class ErpAdapter(ABC):
+ @abstractmethod
+ def fetch_contacts(self, offset: int = 0, limit: int = 100, since: str | None = None) -> list[dict]:
+ ...
+
+ @abstractmethod
+ def fetch_products(self, offset: int = 0, limit: int = 100, since: str | None = None) -> list[dict]:
+ ...
+
+ @abstractmethod
+ def fetch_sale_orders(self, offset: int = 0, limit: int = 100, since: str | None = None) -> list[dict]:
+ ...
+
+ @abstractmethod
+ def fetch_sale_order_lines(self, order_ids: list[int]) -> list[dict]:
+ ...
\ No newline at end of file
diff --git a/milad/src/backend/adapters/odoo_adapter.py b/milad/src/backend/adapters/odoo_adapter.py
new file mode 100644
index 0000000..f99ccd8
--- /dev/null
+++ b/milad/src/backend/adapters/odoo_adapter.py
@@ -0,0 +1,96 @@
+import xmlrpc.client
+from typing import Any
+
+from backend.adapters.base import ErpAdapter
+
+
+class OdooConnectionError(Exception):
+ pass
+
+
+class OdooAdapter(ErpAdapter):
+ def __init__(self, url: str, db: str, username: str, password: str) -> None:
+ self._url = url
+ self._db = db
+ self._username = username
+ self._password = password
+ self._uid: int | None = None
+ self._models: xmlrpc.client.ServerProxy | None = None
+
+ def connect(self) -> None:
+ common = xmlrpc.client.ServerProxy(f"{self._url}/xmlrpc/2/common")
+ try:
+ uid = common.authenticate(self._db, self._username, self._password, {})
+ except (xmlrpc.client.Fault, OSError) as exc:
+ raise OdooConnectionError(f"Failed to authenticate with Odoo: {exc}") from exc
+
+ if not uid:
+ raise OdooConnectionError("Odoo authentication rejected, check credentials")
+
+ self._uid = uid
+ self._models = xmlrpc.client.ServerProxy(f"{self._url}/xmlrpc/2/object")
+
+ def _execute(self, model: str, method: str, *args: Any, **kwargs: Any) -> Any:
+ if self._uid is None or self._models is None:
+ self.connect()
+
+ try:
+ return self._models.execute_kw(
+ self._db, self._uid, self._password, model, method, list(args), kwargs
+ )
+ except (xmlrpc.client.Fault, OSError) as exc:
+ raise OdooConnectionError(f"Odoo call failed ({model}.{method}): {exc}") from exc
+
+ def fetch_contacts(self, offset: int = 0, limit: int = 100, since: str | None = None) -> list[dict]:
+ domain = [["write_date", ">=", since]] if since else []
+ ids = self._execute(
+ "res.partner", "search", domain, offset=offset, limit=limit
+ )
+ if not ids:
+ return []
+ return self._execute(
+ "res.partner", "read", ids, ["id", "name", "email", "phone", "mobile"]
+ )
+
+ def fetch_products(self, offset: int = 0, limit: int = 100, since: str | None = None) -> list[dict]:
+ domain = [["write_date", ">=", since]] if since else []
+ ids = self._execute(
+ "product.product", "search", domain, offset=offset, limit=limit
+ )
+ if not ids:
+ return []
+ return self._execute(
+ "product.product",
+ "read",
+ ids,
+ ["id", "name", "default_code", "list_price", "type"],
+ )
+
+ def fetch_sale_orders(self, offset: int = 0, limit: int = 100, since: str | None = None) -> list[dict]:
+ domain = [["write_date", ">=", since]] if since else []
+ ids = self._execute(
+ "sale.order", "search", domain, offset=offset, limit=limit
+ )
+ if not ids:
+ return []
+ return self._execute(
+ "sale.order",
+ "read",
+ ids,
+ ["id", "name", "partner_id", "date_order", "state", "amount_total"],
+ )
+
+ def fetch_sale_order_lines(self, order_ids: list[int]) -> list[dict]:
+ if not order_ids:
+ return []
+ ids = self._execute(
+ "sale.order.line", "search", [["order_id", "in", order_ids]]
+ )
+ if not ids:
+ return []
+ return self._execute(
+ "sale.order.line",
+ "read",
+ ids,
+ ["id", "order_id", "product_id", "product_uom_qty", "price_unit", "price_subtotal"],
+ )
\ No newline at end of file
diff --git a/milad/src/backend/cli.py b/milad/src/backend/cli.py
new file mode 100644
index 0000000..2e669f1
--- /dev/null
+++ b/milad/src/backend/cli.py
@@ -0,0 +1,67 @@
+import logging
+import signal
+import sys
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from backend.adapters.odoo_adapter import OdooAdapter
+from backend.infrastructure.config import get_settings
+from backend.services.sync_service import SyncService
+
+logger = logging.getLogger("backend.cli")
+_shutdown_requested = False
+
+
+def configure_logging() -> None:
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+ stream=sys.stdout,
+ )
+
+
+def _handle_shutdown_signal(signum: int, _frame) -> None:
+ global _shutdown_requested
+ _shutdown_requested = True
+ logger.warning("Received signal %s, will stop after current record finishes", signum)
+
+
+def run_sync() -> int:
+ configure_logging()
+ signal.signal(signal.SIGTERM, _handle_shutdown_signal)
+ signal.signal(signal.SIGINT, _handle_shutdown_signal)
+
+ settings = get_settings()
+ engine = create_engine(settings.app_db_url)
+ session_factory = sessionmaker(bind=engine)
+
+ odoo = OdooAdapter(
+ url=settings.odoo_url,
+ db=settings.odoo_db,
+ username=settings.odoo_username,
+ password=settings.odoo_password,
+ )
+
+ session = session_factory()
+ try:
+ service = SyncService(session, odoo)
+ run = service.run_full_sync()
+ logger.info(
+ "Sync finished: fetched=%s created=%s updated=%s errors=%s",
+ run.fetched_count,
+ run.created_count,
+ run.updated_count,
+ run.error_count,
+ )
+ return 0 if run.error_count == 0 else 1
+ except Exception:
+ logger.exception("Sync failed with an unhandled error")
+ return 2
+ finally:
+ session.close()
+ logger.info("Database session closed")
+
+
+if __name__ == "__main__":
+ sys.exit(run_sync())
\ No newline at end of file
diff --git a/milad/src/backend/domain/__init__.py b/milad/src/backend/domain/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milad/src/backend/domain/entities.py b/milad/src/backend/domain/entities.py
new file mode 100644
index 0000000..b96b398
--- /dev/null
+++ b/milad/src/backend/domain/entities.py
@@ -0,0 +1,41 @@
+from dataclasses import dataclass, field
+from datetime import datetime
+from decimal import Decimal
+
+
+@dataclass
+class Contact:
+ odoo_id: int
+ name: str
+ email: str | None
+ phone: str | None
+ mobile: str | None
+
+
+@dataclass
+class Product:
+ odoo_id: int
+ name: str
+ internal_reference: str | None
+ sale_price: Decimal
+ product_type: str
+
+
+@dataclass
+class SaleOrderLine:
+ odoo_id: int
+ product_odoo_id: int
+ quantity: Decimal
+ unit_price: Decimal
+ subtotal: Decimal
+
+
+@dataclass
+class SaleOrder:
+ odoo_id: int
+ order_number: str
+ customer_odoo_id: int
+ order_date: datetime
+ state: str
+ total_amount: Decimal
+ lines: list[SaleOrderLine] = field(default_factory=list)
\ No newline at end of file
diff --git a/milad/src/backend/domain/mappers.py b/milad/src/backend/domain/mappers.py
new file mode 100644
index 0000000..393ef4c
--- /dev/null
+++ b/milad/src/backend/domain/mappers.py
@@ -0,0 +1,58 @@
+from datetime import datetime
+from decimal import Decimal
+
+from backend.domain.entities import Contact, Product, SaleOrder, SaleOrderLine
+
+
+def map_contact(raw: dict) -> Contact:
+ return Contact(
+ odoo_id=raw["id"],
+ name=raw["name"],
+ email=raw.get("email") or None,
+ phone=raw.get("phone") or None,
+ mobile=raw.get("mobile") or None,
+ )
+
+
+def map_product(raw: dict) -> Product:
+ return Product(
+ odoo_id=raw["id"],
+ name=raw["name"],
+ internal_reference=raw.get("default_code") or None,
+ sale_price=Decimal(str(raw.get("list_price", 0))),
+ product_type=raw.get("type", "consu"),
+ )
+
+
+def map_sale_order(raw: dict, lines: list[SaleOrderLine]) -> SaleOrder:
+ partner = raw.get("partner_id")
+ customer_odoo_id = partner[0] if isinstance(partner, (list, tuple)) else partner
+
+ return SaleOrder(
+ odoo_id=raw["id"],
+ order_number=raw["name"],
+ customer_odoo_id=customer_odoo_id,
+ order_date=_parse_odoo_datetime(raw.get("date_order")),
+ state=raw["state"],
+ total_amount=Decimal(str(raw.get("amount_total", 0))),
+ lines=lines,
+ )
+
+
+def map_sale_order_line(raw: dict) -> SaleOrderLine:
+ product = raw.get("product_id")
+ product_odoo_id = product[0] if isinstance(product, (list, tuple)) else product
+
+ return SaleOrderLine(
+ odoo_id=raw["id"],
+ product_odoo_id=product_odoo_id,
+ quantity=Decimal(str(raw.get("product_uom_qty", 0))),
+ unit_price=Decimal(str(raw.get("price_unit", 0))),
+ subtotal=Decimal(str(raw.get("price_subtotal", 0))),
+ )
+
+
+def _parse_odoo_datetime(value: str | None) -> datetime:
+ if not value:
+ return datetime.min
+ return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
\ No newline at end of file
diff --git a/milad/src/backend/infrastructure/__init__.py b/milad/src/backend/infrastructure/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milad/src/backend/infrastructure/config.py b/milad/src/backend/infrastructure/config.py
new file mode 100644
index 0000000..4c04804
--- /dev/null
+++ b/milad/src/backend/infrastructure/config.py
@@ -0,0 +1,30 @@
+from functools import lru_cache
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+
+ odoo_url: str = "http://localhost:8069"
+ odoo_db: str = "exam_db"
+ odoo_username: str = "admin"
+ odoo_password: str = "admin"
+
+ app_db_host: str = "localhost"
+ app_db_port: int = 5433
+ app_db_name: str = "sync_backend"
+ app_db_user: str = "sync_user"
+ app_db_password: str = "sync_password"
+
+ @property
+ def app_db_url(self) -> str:
+ return (
+ f"postgresql+psycopg2://{self.app_db_user}:{self.app_db_password}"
+ f"@{self.app_db_host}:{self.app_db_port}/{self.app_db_name}"
+ )
+
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
\ No newline at end of file
diff --git a/milad/src/backend/infrastructure/db_models.py b/milad/src/backend/infrastructure/db_models.py
new file mode 100644
index 0000000..fa653f6
--- /dev/null
+++ b/milad/src/backend/infrastructure/db_models.py
@@ -0,0 +1,107 @@
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import ForeignKey, Numeric, String, DateTime, Integer, func
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+class ContactModel(Base):
+ __tablename__ = "contacts"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ odoo_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
+ name: Mapped[str] = mapped_column(String(255))
+ email: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+ sale_orders: Mapped[list["SaleOrderModel"]] = relationship(back_populates="customer")
+
+
+class ProductModel(Base):
+ __tablename__ = "products"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ odoo_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
+ name: Mapped[str] = mapped_column(String(255))
+ internal_reference: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ sale_price: Mapped[Decimal] = mapped_column(Numeric(12, 2))
+ product_type: Mapped[str] = mapped_column(String(50))
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+ order_lines: Mapped[list["SaleOrderLineModel"]] = relationship(back_populates="product")
+
+
+class SaleOrderModel(Base):
+ __tablename__ = "sale_orders"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ odoo_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
+ order_number: Mapped[str] = mapped_column(String(100))
+ customer_id: Mapped[int] = mapped_column(ForeignKey("contacts.id"))
+ order_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+ state: Mapped[str] = mapped_column(String(50))
+ total_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+ customer: Mapped["ContactModel"] = relationship(back_populates="sale_orders")
+ lines: Mapped[list["SaleOrderLineModel"]] = relationship(
+ back_populates="order", cascade="all, delete-orphan"
+ )
+
+
+class SaleOrderLineModel(Base):
+ __tablename__ = "sale_order_lines"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ odoo_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
+ sale_order_id: Mapped[int] = mapped_column(ForeignKey("sale_orders.id"))
+ product_id: Mapped[int] = mapped_column(ForeignKey("products.id"))
+ quantity: Mapped[Decimal] = mapped_column(Numeric(12, 2))
+ unit_price: Mapped[Decimal] = mapped_column(Numeric(12, 2))
+ subtotal: Mapped[Decimal] = mapped_column(Numeric(12, 2))
+
+ order: Mapped["SaleOrderModel"] = relationship(back_populates="lines")
+ product: Mapped["ProductModel"] = relationship(back_populates="order_lines")
+
+
+class SyncRunModel(Base):
+ __tablename__ = "sync_runs"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ operation: Mapped[str] = mapped_column(String(100))
+ started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ fetched_count: Mapped[int] = mapped_column(Integer, default=0)
+ created_count: Mapped[int] = mapped_column(Integer, default=0)
+ updated_count: Mapped[int] = mapped_column(Integer, default=0)
+ error_count: Mapped[int] = mapped_column(Integer, default=0)
+
+ logs: Mapped[list["SyncLogModel"]] = relationship(back_populates="run", cascade="all, delete-orphan")
+
+
+class SyncLogModel(Base):
+ __tablename__ = "sync_logs"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ sync_run_id: Mapped[int] = mapped_column(ForeignKey("sync_runs.id"))
+ level: Mapped[str] = mapped_column(String(20))
+ message: Mapped[str] = mapped_column(String)
+ record_odoo_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+
+ run: Mapped["SyncRunModel"] = relationship(back_populates="logs")
\ No newline at end of file
diff --git a/milad/src/backend/repositories/__init__.py b/milad/src/backend/repositories/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milad/src/backend/repositories/repositories.py b/milad/src/backend/repositories/repositories.py
new file mode 100644
index 0000000..a9ba8ab
--- /dev/null
+++ b/milad/src/backend/repositories/repositories.py
@@ -0,0 +1,146 @@
+from sqlalchemy.orm import Session
+
+from backend.domain.entities import Contact, Product, SaleOrder, SaleOrderLine
+from backend.infrastructure.db_models import (
+ ContactModel,
+ ProductModel,
+ SaleOrderLineModel,
+ SaleOrderModel,
+)
+
+
+class ContactRepository:
+ def __init__(self, session: Session) -> None:
+ self._session = session
+
+ def upsert(self, contact: Contact) -> tuple[ContactModel, bool]:
+ existing = (
+ self._session.query(ContactModel)
+ .filter_by(odoo_id=contact.odoo_id)
+ .one_or_none()
+ )
+
+ if existing is None:
+ model = ContactModel(
+ odoo_id=contact.odoo_id,
+ name=contact.name,
+ email=contact.email,
+ phone=contact.phone,
+ mobile=contact.mobile,
+ )
+ self._session.add(model)
+ self._session.flush()
+ return model, True
+
+ existing.name = contact.name
+ existing.email = contact.email
+ existing.phone = contact.phone
+ existing.mobile = contact.mobile
+ self._session.flush()
+ return existing, False
+
+ def get_by_odoo_id(self, odoo_id: int) -> ContactModel | None:
+ return self._session.query(ContactModel).filter_by(odoo_id=odoo_id).one_or_none()
+
+
+class ProductRepository:
+ def __init__(self, session: Session) -> None:
+ self._session = session
+
+ def upsert(self, product: Product) -> tuple[ProductModel, bool]:
+ existing = (
+ self._session.query(ProductModel)
+ .filter_by(odoo_id=product.odoo_id)
+ .one_or_none()
+ )
+
+ if existing is None:
+ model = ProductModel(
+ odoo_id=product.odoo_id,
+ name=product.name,
+ internal_reference=product.internal_reference,
+ sale_price=product.sale_price,
+ product_type=product.product_type,
+ )
+ self._session.add(model)
+ self._session.flush()
+ return model, True
+
+ existing.name = product.name
+ existing.internal_reference = product.internal_reference
+ existing.sale_price = product.sale_price
+ existing.product_type = product.product_type
+ self._session.flush()
+ return existing, False
+
+ def get_by_odoo_id(self, odoo_id: int) -> ProductModel | None:
+ return self._session.query(ProductModel).filter_by(odoo_id=odoo_id).one_or_none()
+
+
+class SaleOrderRepository:
+ def __init__(self, session: Session) -> None:
+ self._session = session
+
+ def upsert(
+ self, order: SaleOrder, customer: ContactModel, product_lookup: dict[int, ProductModel]
+ ) -> tuple[SaleOrderModel, bool]:
+ existing = (
+ self._session.query(SaleOrderModel)
+ .filter_by(odoo_id=order.odoo_id)
+ .one_or_none()
+ )
+
+ if existing is None:
+ model = SaleOrderModel(
+ odoo_id=order.odoo_id,
+ order_number=order.order_number,
+ customer=customer,
+ order_date=order.order_date,
+ state=order.state,
+ total_amount=order.total_amount,
+ )
+ self._session.add(model)
+ self._session.flush()
+ is_new = True
+ else:
+ existing.order_number = order.order_number
+ existing.customer = customer
+ existing.order_date = order.order_date
+ existing.state = order.state
+ existing.total_amount = order.total_amount
+ model = existing
+ is_new = False
+
+ self._upsert_lines(model, order.lines, product_lookup)
+ self._session.flush()
+ return model, is_new
+
+ def _upsert_lines(
+ self,
+ order_model: SaleOrderModel,
+ lines: list[SaleOrderLine],
+ product_lookup: dict[int, ProductModel],
+ ) -> None:
+ existing_by_odoo_id = {line.odoo_id: line for line in order_model.lines}
+
+ for line in lines:
+ product = product_lookup[line.product_odoo_id]
+ existing_line = existing_by_odoo_id.get(line.odoo_id)
+
+ if existing_line is None:
+ new_line = SaleOrderLineModel(
+ odoo_id=line.odoo_id,
+ product=product,
+ quantity=line.quantity,
+ unit_price=line.unit_price,
+ subtotal=line.subtotal,
+ )
+ order_model.lines.append(new_line)
+ else:
+ existing_line.product = product
+ existing_line.quantity = line.quantity
+ existing_line.unit_price = line.unit_price
+ existing_line.subtotal = line.subtotal
+
+ def get_by_odoo_id(self, odoo_id: int) -> SaleOrderModel | None:
+ return self._session.query(SaleOrderModel).filter_by(odoo_id=odoo_id).one_or_none()
\ No newline at end of file
diff --git a/milad/src/backend/services/__init__.py b/milad/src/backend/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milad/src/backend/services/sync_service.py b/milad/src/backend/services/sync_service.py
new file mode 100644
index 0000000..91020bd
--- /dev/null
+++ b/milad/src/backend/services/sync_service.py
@@ -0,0 +1,193 @@
+import logging
+from datetime import datetime, timezone
+
+from sqlalchemy.orm import Session
+
+from backend.adapters.base import ErpAdapter
+from backend.adapters.odoo_adapter import OdooConnectionError
+from backend.domain.mappers import map_contact, map_product, map_sale_order, map_sale_order_line
+from backend.infrastructure.db_models import SyncLogModel, SyncRunModel
+from backend.repositories.repositories import (
+ ContactRepository,
+ ProductRepository,
+ SaleOrderRepository,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class SyncResult:
+ def __init__(self) -> None:
+ self.fetched = 0
+ self.created = 0
+ self.updated = 0
+ self.errors = 0
+
+
+class SyncService:
+ def __init__(self, session: Session, odoo: ErpAdapter, batch_size: int = 100) -> None:
+ self._session = session
+ self._odoo = odoo
+ self._batch_size = batch_size
+ self._contacts = ContactRepository(session)
+ self._products = ProductRepository(session)
+ self._orders = SaleOrderRepository(session)
+
+ def run_full_sync(self) -> SyncRunModel:
+ return self._run(operation="full_sync", since=None)
+
+ def run_incremental_sync(self) -> SyncRunModel:
+ since = self._last_successful_sync_timestamp()
+ return self._run(operation="incremental_sync", since=since)
+
+ def _run(self, operation: str, since: str | None) -> SyncRunModel:
+ run = SyncRunModel(operation=operation, started_at=datetime.now(timezone.utc))
+ self._session.add(run)
+ self._session.flush()
+
+ result = SyncResult()
+
+ try:
+ self._sync_contacts(run, result, since)
+ self._sync_products(run, result, since)
+ self._sync_sale_orders(run, result, since)
+ self._session.commit()
+ except Exception as exc:
+ self._session.rollback()
+ self._log(run, "error", f"Sync aborted: {exc}")
+ raise
+ finally:
+ run.finished_at = datetime.now(timezone.utc)
+ run.fetched_count = result.fetched
+ run.created_count = result.created
+ run.updated_count = result.updated
+ run.error_count = result.errors
+ self._session.add(run)
+ self._session.commit()
+
+ return run
+
+ def _last_successful_sync_timestamp(self) -> str | None:
+ last_run = (
+ self._session.query(SyncRunModel)
+ .filter(SyncRunModel.error_count == 0)
+ .filter(SyncRunModel.finished_at.isnot(None))
+ .order_by(SyncRunModel.finished_at.desc())
+ .first()
+ )
+ if last_run is None:
+ return None
+ return last_run.finished_at.strftime("%Y-%m-%d %H:%M:%S")
+
+ def _sync_contacts(self, run: SyncRunModel, result: SyncResult, since: str | None) -> None:
+ offset = 0
+ while True:
+ batch = self._fetch_with_retry(self._odoo.fetch_contacts, offset, self._batch_size, since)
+ if not batch:
+ break
+
+ for raw in batch:
+ result.fetched += 1
+ try:
+ contact = map_contact(raw)
+ _, created = self._contacts.upsert(contact)
+ result.created += int(created)
+ result.updated += int(not created)
+ except Exception as exc:
+ result.errors += 1
+ self._log(run, "error", f"Contact sync failed: {exc}", raw.get("id"))
+
+ offset += self._batch_size
+
+ def _sync_products(self, run: SyncRunModel, result: SyncResult, since: str | None) -> None:
+ offset = 0
+ while True:
+ batch = self._fetch_with_retry(self._odoo.fetch_products, offset, self._batch_size, since)
+ if not batch:
+ break
+
+ for raw in batch:
+ result.fetched += 1
+ try:
+ product = map_product(raw)
+ _, created = self._products.upsert(product)
+ result.created += int(created)
+ result.updated += int(not created)
+ except Exception as exc:
+ result.errors += 1
+ self._log(run, "error", f"Product sync failed: {exc}", raw.get("id"))
+
+ offset += self._batch_size
+
+ def _sync_sale_orders(self, run: SyncRunModel, result: SyncResult, since: str | None) -> None:
+ offset = 0
+ while True:
+ batch = self._fetch_with_retry(self._odoo.fetch_sale_orders, offset, self._batch_size, since)
+ if not batch:
+ break
+
+ order_ids = [raw["id"] for raw in batch]
+ raw_lines_by_order = self._group_lines_by_order(order_ids)
+
+ for raw in batch:
+ result.fetched += 1
+ try:
+ is_new = self._sync_single_order(raw, raw_lines_by_order.get(raw["id"], []))
+ result.created += int(is_new)
+ result.updated += int(not is_new)
+ except Exception as exc:
+ result.errors += 1
+ self._log(run, "error", f"Sale order sync failed: {exc}", raw.get("id"))
+
+ offset += self._batch_size
+
+ def _sync_single_order(self, raw_order: dict, raw_lines: list[dict]) -> bool:
+ partner = raw_order.get("partner_id")
+ customer_odoo_id = partner[0] if isinstance(partner, (list, tuple)) else partner
+ customer = self._contacts.get_by_odoo_id(customer_odoo_id)
+ if customer is None:
+ raise ValueError(f"Customer {customer_odoo_id} not found, sync contacts first")
+
+ lines = [map_sale_order_line(raw_line) for raw_line in raw_lines]
+
+ product_lookup = {}
+ for line in lines:
+ product = self._products.get_by_odoo_id(line.product_odoo_id)
+ if product is None:
+ raise ValueError(f"Product {line.product_odoo_id} not found, sync products first")
+ product_lookup[line.product_odoo_id] = product
+
+ order = map_sale_order(raw_order, lines)
+ _, is_new = self._orders.upsert(order, customer, product_lookup)
+ return is_new
+
+ def _group_lines_by_order(self, order_ids: list[int]) -> dict[int, list[dict]]:
+ raw_lines = self._odoo.fetch_sale_order_lines(order_ids)
+ grouped: dict[int, list[dict]] = {}
+ for raw_line in raw_lines:
+ order_ref = raw_line.get("order_id")
+ order_odoo_id = order_ref[0] if isinstance(order_ref, (list, tuple)) else order_ref
+ grouped.setdefault(order_odoo_id, []).append(raw_line)
+ return grouped
+
+ def _fetch_with_retry(
+ self, fetch_fn, offset: int, limit: int, since: str | None, attempts: int = 3
+ ) -> list[dict]:
+ last_error: Exception | None = None
+ for attempt in range(1, attempts + 1):
+ try:
+ return fetch_fn(offset=offset, limit=limit, since=since)
+ except OdooConnectionError as exc:
+ last_error = exc
+ logger.warning("Odoo fetch attempt %s/%s failed: %s", attempt, attempts, exc)
+ raise last_error
+
+ def _log(self, run: SyncRunModel, level: str, message: str, record_odoo_id: int | None = None) -> None:
+ entry = SyncLogModel(
+ sync_run_id=run.id,
+ level=level,
+ message=message,
+ record_odoo_id=record_odoo_id,
+ )
+ self._session.add(entry)
+ logger.log(logging.ERROR if level == "error" else logging.INFO, message)
\ No newline at end of file
diff --git a/milad/tests/__init__.py b/milad/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milad/tests/conftest.py b/milad/tests/conftest.py
new file mode 100644
index 0000000..5e6df32
--- /dev/null
+++ b/milad/tests/conftest.py
@@ -0,0 +1,27 @@
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from backend.infrastructure.db_models import Base
+
+TEST_DB_URL = "postgresql+psycopg2://test_user:test_password@localhost:5434/test_backend"
+
+
+@pytest.fixture(scope="session")
+def engine():
+ return create_engine(TEST_DB_URL)
+
+
+@pytest.fixture(autouse=True)
+def _reset_schema(engine):
+ Base.metadata.drop_all(engine)
+ Base.metadata.create_all(engine)
+ yield
+
+
+@pytest.fixture
+def session(engine) -> Session:
+ session_factory = sessionmaker(bind=engine)
+ db_session = session_factory()
+ yield db_session
+ db_session.close()
\ No newline at end of file
diff --git a/milad/tests/test_mappers.py b/milad/tests/test_mappers.py
new file mode 100644
index 0000000..b97b900
--- /dev/null
+++ b/milad/tests/test_mappers.py
@@ -0,0 +1,85 @@
+from decimal import Decimal
+
+from backend.domain.mappers import (
+ map_contact,
+ map_product,
+ map_sale_order,
+ map_sale_order_line,
+)
+
+
+def test_map_contact_extracts_fields():
+ raw = {"id": 1, "name": "Ali Rezaei", "email": "ali@example.com", "phone": "123", "mobile": "456"}
+
+ contact = map_contact(raw)
+
+ assert contact.odoo_id == 1
+ assert contact.name == "Ali Rezaei"
+ assert contact.email == "ali@example.com"
+ assert contact.phone == "123"
+ assert contact.mobile == "456"
+
+
+def test_map_contact_handles_falsy_optional_fields():
+ raw = {"id": 2, "name": "Sara", "email": False, "phone": False, "mobile": False}
+
+ contact = map_contact(raw)
+
+ assert contact.email is None
+ assert contact.phone is None
+ assert contact.mobile is None
+
+
+def test_map_product_converts_price_to_decimal():
+ raw = {"id": 10, "name": "Mouse", "default_code": "PRD-001", "list_price": 15.5, "type": "consu"}
+
+ product = map_product(raw)
+
+ assert product.sale_price == Decimal("15.5")
+ assert isinstance(product.sale_price, Decimal)
+
+
+def test_map_sale_order_unwraps_many2one_partner_tuple():
+ raw = {
+ "id": 1000,
+ "name": "S00001",
+ "partner_id": [1, "Ali Rezaei"],
+ "date_order": "2026-01-15 10:30:00",
+ "state": "sale",
+ "amount_total": 31.0,
+ }
+
+ order = map_sale_order(raw, lines=[])
+
+ assert order.customer_odoo_id == 1
+ assert order.order_date.year == 2026
+ assert order.order_date.month == 1
+ assert order.order_date.day == 15
+
+
+def test_map_sale_order_line_unwraps_product_tuple():
+ raw = {
+ "id": 100,
+ "order_id": [1000, "S00001"],
+ "product_id": [10, "Mouse"],
+ "product_uom_qty": 2.0,
+ "price_unit": 15.5,
+ "price_subtotal": 31.0,
+ }
+
+ line = map_sale_order_line(raw)
+
+ assert line.product_odoo_id == 10
+ assert line.quantity == Decimal("2.0")
+ assert line.subtotal == Decimal("31.0")
+
+
+def test_map_sale_order_handles_missing_date():
+ raw = {
+ "id": 1000, "name": "S00001", "partner_id": [1, "Ali"],
+ "date_order": False, "state": "draft", "amount_total": 0,
+ }
+
+ order = map_sale_order(raw, lines=[])
+
+ assert order.order_date is not None
\ No newline at end of file
diff --git a/milad/tests/test_repositories.py b/milad/tests/test_repositories.py
new file mode 100644
index 0000000..8d2301e
--- /dev/null
+++ b/milad/tests/test_repositories.py
@@ -0,0 +1,116 @@
+from decimal import Decimal
+
+from backend.domain.entities import Contact, Product, SaleOrder, SaleOrderLine
+from backend.repositories.repositories import (
+ ContactRepository,
+ ProductRepository,
+ SaleOrderRepository,
+)
+
+
+def test_contact_upsert_creates_new_record(session):
+ repo = ContactRepository(session)
+ contact = Contact(odoo_id=1, name="Ali Rezaei", email="ali@example.com", phone="123", mobile="456")
+
+ model, created = repo.upsert(contact)
+ session.commit()
+
+ assert created is True
+ assert model.odoo_id == 1
+ assert model.name == "Ali Rezaei"
+
+
+def test_contact_upsert_updates_existing_record_without_duplicating(session):
+ repo = ContactRepository(session)
+ original = Contact(odoo_id=1, name="Ali Rezaei", email="ali@example.com", phone="123", mobile="456")
+ repo.upsert(original)
+ session.commit()
+
+ changed = Contact(odoo_id=1, name="Ali R. Updated", email="ali@example.com", phone="999", mobile="456")
+ model, created = repo.upsert(changed)
+ session.commit()
+
+ assert created is False
+ assert model.name == "Ali R. Updated"
+ assert model.phone == "999"
+
+ all_contacts = session.query(type(model)).filter_by(odoo_id=1).all()
+ assert len(all_contacts) == 1
+
+
+def test_product_upsert_is_idempotent(session):
+ repo = ProductRepository(session)
+ product = Product(odoo_id=10, name="Mouse", internal_reference="PRD-001", sale_price=Decimal("15.50"), product_type="consu")
+
+ repo.upsert(product)
+ session.commit()
+ repo.upsert(product)
+ session.commit()
+
+ all_products = session.query(type(repo.upsert(product)[0])).filter_by(odoo_id=10).all()
+ assert len(all_products) == 1
+
+
+def test_sale_order_upsert_creates_lines(session):
+ contact_repo = ContactRepository(session)
+ product_repo = ProductRepository(session)
+ order_repo = SaleOrderRepository(session)
+
+ customer, _ = contact_repo.upsert(
+ Contact(odoo_id=1, name="Ali", email="ali@example.com", phone=None, mobile=None)
+ )
+ product, _ = product_repo.upsert(
+ Product(odoo_id=10, name="Mouse", internal_reference="PRD-001", sale_price=Decimal("15.50"), product_type="consu")
+ )
+ session.commit()
+
+ line = SaleOrderLine(odoo_id=100, product_odoo_id=10, quantity=Decimal("2"), unit_price=Decimal("15.50"), subtotal=Decimal("31.00"))
+ order = SaleOrder(
+ odoo_id=1000,
+ order_number="S00001",
+ customer_odoo_id=1,
+ order_date=customer.created_at,
+ state="sale",
+ total_amount=Decimal("31.00"),
+ lines=[line],
+ )
+
+ model, created = order_repo.upsert(order, customer, {10: product})
+ session.commit()
+
+ assert created is True
+ assert len(model.lines) == 1
+ assert model.lines[0].odoo_id == 100
+
+
+def test_sale_order_upsert_updates_lines_without_duplicating(session):
+ contact_repo = ContactRepository(session)
+ product_repo = ProductRepository(session)
+ order_repo = SaleOrderRepository(session)
+
+ customer, _ = contact_repo.upsert(
+ Contact(odoo_id=1, name="Ali", email="ali@example.com", phone=None, mobile=None)
+ )
+ product, _ = product_repo.upsert(
+ Product(odoo_id=10, name="Mouse", internal_reference="PRD-001", sale_price=Decimal("15.50"), product_type="consu")
+ )
+ session.commit()
+
+ line = SaleOrderLine(odoo_id=100, product_odoo_id=10, quantity=Decimal("2"), unit_price=Decimal("15.50"), subtotal=Decimal("31.00"))
+ order = SaleOrder(
+ odoo_id=1000, order_number="S00001", customer_odoo_id=1,
+ order_date=customer.created_at, state="sale", total_amount=Decimal("31.00"), lines=[line],
+ )
+ order_repo.upsert(order, customer, {10: product})
+ session.commit()
+
+ updated_line = SaleOrderLine(odoo_id=100, product_odoo_id=10, quantity=Decimal("5"), unit_price=Decimal("15.50"), subtotal=Decimal("77.50"))
+ order.lines = [updated_line]
+ order.total_amount = Decimal("77.50")
+
+ model, created = order_repo.upsert(order, customer, {10: product})
+ session.commit()
+
+ assert created is False
+ assert len(model.lines) == 1
+ assert model.lines[0].quantity == Decimal("5.00")
\ No newline at end of file
diff --git a/milad/tests/test_sync_service.py b/milad/tests/test_sync_service.py
new file mode 100644
index 0000000..7e0f35e
--- /dev/null
+++ b/milad/tests/test_sync_service.py
@@ -0,0 +1,106 @@
+from datetime import datetime, timezone
+
+import pytest
+
+from backend.adapters.base import ErpAdapter
+from backend.adapters.odoo_adapter import OdooConnectionError
+from backend.services.sync_service import SyncService
+
+
+class FakeOdooAdapter(ErpAdapter):
+ def __init__(self, contacts=None, products=None, orders=None, order_lines=None, fail_contacts_once=False):
+ self._contacts = contacts or []
+ self._products = products or []
+ self._orders = orders or []
+ self._order_lines = order_lines or []
+ self._fail_contacts_once = fail_contacts_once
+ self._contacts_call_count = 0
+
+ def fetch_contacts(self, offset=0, limit=100, since=None):
+ self._contacts_call_count += 1
+ if self._fail_contacts_once and self._contacts_call_count == 1:
+ raise OdooConnectionError("simulated transient failure")
+ return self._contacts[offset:offset + limit]
+
+ def fetch_products(self, offset=0, limit=100, since=None):
+ return self._products[offset:offset + limit]
+
+ def fetch_sale_orders(self, offset=0, limit=100, since=None):
+ return self._orders[offset:offset + limit]
+
+ def fetch_sale_order_lines(self, order_ids):
+ return [line for line in self._order_lines if line["order_id"][0] in order_ids]
+
+
+@pytest.fixture
+def contact_raw():
+ return {"id": 1, "name": "Ali", "email": "ali@example.com", "phone": None, "mobile": None}
+
+
+@pytest.fixture
+def product_raw():
+ return {"id": 10, "name": "Mouse", "default_code": "PRD-001", "list_price": 15.5, "type": "consu"}
+
+
+@pytest.fixture
+def order_raw():
+ return {
+ "id": 1000, "name": "S00001", "partner_id": [1, "Ali"],
+ "date_order": "2026-01-15 10:00:00", "state": "sale", "amount_total": 31.0,
+ }
+
+
+@pytest.fixture
+def line_raw():
+ return {
+ "id": 100, "order_id": [1000, "S00001"], "product_id": [10, "Mouse"],
+ "product_uom_qty": 2.0, "price_unit": 15.5, "price_subtotal": 31.0,
+ }
+
+
+def test_full_sync_creates_all_records(session, contact_raw, product_raw, order_raw, line_raw):
+ odoo = FakeOdooAdapter(contacts=[contact_raw], products=[product_raw], orders=[order_raw], order_lines=[line_raw])
+ service = SyncService(session, odoo)
+
+ run = service.run_full_sync()
+
+ assert run.fetched_count == 3
+ assert run.created_count == 3
+ assert run.error_count == 0
+
+
+def test_full_sync_is_idempotent_on_second_run(session, contact_raw, product_raw, order_raw, line_raw):
+ odoo = FakeOdooAdapter(contacts=[contact_raw], products=[product_raw], orders=[order_raw], order_lines=[line_raw])
+ service = SyncService(session, odoo)
+
+ service.run_full_sync()
+ second_run = service.run_full_sync()
+
+ assert second_run.created_count == 0
+ assert second_run.updated_count == 3
+ assert second_run.error_count == 0
+
+
+def test_sale_order_with_missing_customer_is_isolated_as_error(session, product_raw, order_raw, line_raw):
+ odoo = FakeOdooAdapter(contacts=[], products=[product_raw], orders=[order_raw], order_lines=[line_raw])
+ service = SyncService(session, odoo)
+
+ run = service.run_full_sync()
+
+ assert run.error_count == 1
+ assert run.fetched_count == 2
+ assert len(run.logs) == 1
+ assert run.logs[0].record_odoo_id == 1000
+
+
+def test_transient_odoo_failure_is_retried_and_recovers(session, contact_raw, product_raw, order_raw, line_raw):
+ odoo = FakeOdooAdapter(
+ contacts=[contact_raw], products=[product_raw], orders=[order_raw],
+ order_lines=[line_raw], fail_contacts_once=True,
+ )
+ service = SyncService(session, odoo)
+
+ run = service.run_full_sync()
+
+ assert run.error_count == 0
+ assert run.created_count == 3
\ No newline at end of file