[ADD] contracts: add Odoo sync API contract and note mapping
Define the integration contract between erplibre_mobile and ERPLibre platform: JSON-RPC 2.0 call specs, Note→project.task field mapping, GeoMultiPoint format for geolocation entries, conflict resolution strategy, re-auth flow, and version compatibility matrix. Establishes the shared source of truth before implementation begins. Generated by Claude Code 2.1.87 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
This commit is contained in:
parent
c4bb115bd6
commit
b689c4ddf1
3 changed files with 385 additions and 0 deletions
40
contracts/compatibility.json
Normal file
40
contracts/compatibility.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "Version compatibility matrix — ERPLibre platform ↔ erplibre_mobile app",
|
||||
"updated": "2026-03-29",
|
||||
"matrix": [
|
||||
{
|
||||
"mobile": "2026.03.29.01",
|
||||
"erplibre": "1.6.0",
|
||||
"odoo": "18.0",
|
||||
"sync_api_version": "1.0",
|
||||
"modules_required": [
|
||||
"project",
|
||||
"project_todo",
|
||||
"base_geoengine",
|
||||
"erplibre_mobile_todo"
|
||||
],
|
||||
"breaking": false,
|
||||
"notes": "Initial sync support — push/pull project.task (todos), media attachments, geolocation"
|
||||
}
|
||||
],
|
||||
"modules": {
|
||||
"project_todo": {
|
||||
"description": "Odoo built-in To-Do — project.task with project_id=False",
|
||||
"auto_installed": true,
|
||||
"source": "odoo/addons/project_todo"
|
||||
},
|
||||
"base_geoengine": {
|
||||
"description": "OCA geospatial fields — GeoMultiPoint for geolocation entries",
|
||||
"source": "odoo18.0/addons/OCA_geospatial/base_geoengine",
|
||||
"version": "18.0.1.2.0",
|
||||
"python_deps": ["shapely", "geojson"]
|
||||
},
|
||||
"erplibre_mobile_todo": {
|
||||
"description": "ERPLibre companion module — adds geo_task_point (GeoMultiPoint) to project.task",
|
||||
"source": "odoo18.0/addons/ERPLibre_erplibre_addons/erplibre_mobile_todo",
|
||||
"depends": ["project_todo", "base_geoengine"],
|
||||
"custom_fields_on_project_task": ["geo_task_point"]
|
||||
}
|
||||
}
|
||||
}
|
||||
85
contracts/note-mapping.md
Normal file
85
contracts/note-mapping.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Note mobile → project.task mapping
|
||||
|
||||
## Field mapping
|
||||
|
||||
| Note mobile field | project.task field | Type | Notes |
|
||||
|------------------------|-----------------------|------------------|-------|
|
||||
| *(no mobile id sent)* | `id` | Integer (server) | Odoo ID stored in mobile SQLite as `odoo_id` after first push |
|
||||
| `title` | `name` | Char | Required |
|
||||
| `done` | `state` | Selection | `'done'` ↔ done=true ; `'01_in_progress'` ↔ done=false |
|
||||
| `archived` | `active` | Boolean | false = archived |
|
||||
| `pinned` | `priority` | Selection | `'1'` = pinned, `'0'` = normal |
|
||||
| `tags` | `tag_ids` | Many2many | Matched by tag name; created if missing |
|
||||
| entry `text` | `description` | Html | Each entry → `<p>text content</p>` |
|
||||
| entry `date` | `date_deadline` | Datetime | First date entry only |
|
||||
| entry `audio` | `attachment_ids` | ir.attachment | + `<p>🎙️ Enregistrement audio — ISO_DATE</p>` in description |
|
||||
| entry `photo` | `attachment_ids` | ir.attachment | + `<p>📷 Photo — ISO_DATE</p>` in description |
|
||||
| entry `video` | `attachment_ids` | ir.attachment | + `<p>🎥 Vidéo — ISO_DATE</p>` in description |
|
||||
| entry `geolocation` | `geo_task_point` | GeoMultiPoint | All lat/lon → MultiPoint GeoJSON ; `<p>📍 text — lat,lon — ISO_DATE</p>` in description |
|
||||
|
||||
## Description HTML structure
|
||||
|
||||
Each note's `description` field is built by concatenating all entries in order:
|
||||
|
||||
```html
|
||||
<!-- entry type=text -->
|
||||
<p>Content of the text entry.</p>
|
||||
|
||||
<!-- entry type=text (another one) -->
|
||||
<p>Another paragraph of notes.</p>
|
||||
|
||||
<!-- entry type=date -->
|
||||
<p>📅 Date : 2026-03-29T14:30:00</p>
|
||||
|
||||
<!-- entry type=geolocation -->
|
||||
<p>📍 Géolocalisation : 45.5017, -73.5673 — Bureau principal — 2026-03-29T14:35:00</p>
|
||||
|
||||
<!-- entry type=audio -->
|
||||
<p>🎙️ Enregistrement audio — 2026-03-29T14:40:00</p>
|
||||
|
||||
<!-- entry type=photo -->
|
||||
<p>📷 Photo — 2026-03-29T14:45:00</p>
|
||||
|
||||
<!-- entry type=video -->
|
||||
<p>🎥 Vidéo — 2026-03-29T14:50:00</p>
|
||||
```
|
||||
|
||||
## GeoMultiPoint format (geo_task_point)
|
||||
|
||||
All geolocation entries of a note are stored as a single GeoJSON MultiPoint:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "MultiPoint",
|
||||
"coordinates": [
|
||||
[-73.5673, 45.5017],
|
||||
[-73.5789, 45.4972]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Order matches the order of geolocation entries. Coordinates are `[longitude, latitude]` per GeoJSON spec.
|
||||
Timestamps and text descriptions are preserved in the HTML description `<p>` lines.
|
||||
|
||||
## Sync ID strategy
|
||||
|
||||
- Mobile does **not** send its internal UUID to Odoo.
|
||||
- On first push: `project.task.create()` returns the Odoo `id` (integer).
|
||||
- Mobile stores it as `odoo_id` in its local SQLite `notes` table.
|
||||
- Subsequent pushes use `project.task.write([[odoo_id], {...}])`.
|
||||
- Multiple mobile clients: each independently stores the same `odoo_id` — fully supported.
|
||||
|
||||
## Conflict resolution
|
||||
|
||||
| Condition | Resolution |
|
||||
|-----------|------------|
|
||||
| `write_date` (Odoo) > `last_synced_at` (mobile) | Odoo wins — pull overwrites mobile |
|
||||
| Mobile modified since `last_synced_at`, Odoo not changed | Mobile wins — push |
|
||||
| Both modified since last sync | Odoo wins (last-write-wins, v1) |
|
||||
|
||||
## Tag resolution
|
||||
|
||||
1. Mobile sends tag names as strings.
|
||||
2. SyncService calls `project.tags` `search_read` to find existing tags by name.
|
||||
3. Missing tags: `project.tags.create()` before task create/write.
|
||||
4. `tag_ids` in task payload uses integer IDs from step 2-3.
|
||||
260
contracts/odoo-api.json
Normal file
260
contracts/odoo-api.json
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "API contract — erplibre_mobile ↔ Odoo JSON-RPC 2.0",
|
||||
"version": "1.0",
|
||||
"updated": "2026-03-29",
|
||||
"base_url": "{odoo_url}",
|
||||
"protocol": "JSON-RPC 2.0",
|
||||
"content_type": "application/json",
|
||||
"authentication": {
|
||||
"method": "session_cookie",
|
||||
"notes": "Standard Odoo session — no custom auth module required"
|
||||
},
|
||||
"endpoints": {
|
||||
"auth.login": {
|
||||
"description": "Authenticate and open a session",
|
||||
"method": "POST",
|
||||
"path": "/web/session/authenticate",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"db": "{database_name}",
|
||||
"login": "{username}",
|
||||
"password": "{password}"
|
||||
}
|
||||
},
|
||||
"response_fields": {
|
||||
"uid": "integer — user ID, null if auth failed",
|
||||
"session_id": "string — session cookie value"
|
||||
},
|
||||
"errors": {
|
||||
"uid=false": "Wrong credentials"
|
||||
},
|
||||
"side_effects": "Sets session cookie — store in SecureStorage"
|
||||
},
|
||||
|
||||
"auth.check": {
|
||||
"description": "Verify session is still valid (ping)",
|
||||
"method": "POST",
|
||||
"path": "/web/dataset/call_kw",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"model": "res.lang",
|
||||
"method": "search_read",
|
||||
"args": [],
|
||||
"kwargs": { "domain": [], "fields": ["name"], "limit": 1 }
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"session_expired": "HTTP 200 with error code 100 — trigger re-auth"
|
||||
}
|
||||
},
|
||||
|
||||
"tags.resolve": {
|
||||
"description": "Find or prepare project tags by name",
|
||||
"method": "POST",
|
||||
"path": "/web/dataset/call_kw",
|
||||
"request": {
|
||||
"params": {
|
||||
"model": "project.tags",
|
||||
"method": "search_read",
|
||||
"args": [],
|
||||
"kwargs": {
|
||||
"domain": [["name", "in", ["tag1", "tag2"]]],
|
||||
"fields": ["id", "name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"notes": "Compare returned names against mobile tags — create missing ones via tags.create"
|
||||
},
|
||||
|
||||
"tags.create": {
|
||||
"description": "Create a missing tag",
|
||||
"method": "POST",
|
||||
"path": "/web/dataset/call_kw",
|
||||
"request": {
|
||||
"params": {
|
||||
"model": "project.tags",
|
||||
"method": "create",
|
||||
"args": [{ "name": "{tag_name}" }],
|
||||
"kwargs": {}
|
||||
}
|
||||
},
|
||||
"response": "integer — new tag ID"
|
||||
},
|
||||
|
||||
"notes.pull": {
|
||||
"description": "Pull todos modified since last sync",
|
||||
"method": "POST",
|
||||
"path": "/web/dataset/call_kw",
|
||||
"request": {
|
||||
"params": {
|
||||
"model": "project.task",
|
||||
"method": "search_read",
|
||||
"args": [],
|
||||
"kwargs": {
|
||||
"domain": [
|
||||
["project_id", "=", false],
|
||||
["write_date", ">", "{last_synced_at_iso}"]
|
||||
],
|
||||
"fields": [
|
||||
"id", "name", "description", "priority", "active",
|
||||
"state", "tag_ids", "date_deadline",
|
||||
"geo_task_point", "write_date", "attachment_ids"
|
||||
],
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"order": "write_date asc"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notes": "project_id=False = project_todo personal tasks. geo_task_point requires erplibre_mobile_todo installed."
|
||||
},
|
||||
|
||||
"notes.poll": {
|
||||
"description": "Lightweight poll — fetch only IDs and write_date to detect changes",
|
||||
"method": "POST",
|
||||
"path": "/web/dataset/call_kw",
|
||||
"request": {
|
||||
"params": {
|
||||
"model": "project.task",
|
||||
"method": "search_read",
|
||||
"args": [],
|
||||
"kwargs": {
|
||||
"domain": [
|
||||
["project_id", "=", false],
|
||||
["write_date", ">", "{last_synced_at_iso}"]
|
||||
],
|
||||
"fields": ["id", "write_date"],
|
||||
"limit": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"notes.create": {
|
||||
"description": "Push a new note to Odoo — returns the Odoo ID to store locally",
|
||||
"method": "POST",
|
||||
"path": "/web/dataset/call_kw",
|
||||
"request": {
|
||||
"params": {
|
||||
"model": "project.task",
|
||||
"method": "create",
|
||||
"args": [
|
||||
{
|
||||
"name": "{note.title}",
|
||||
"description": "{note.html_description}",
|
||||
"project_id": false,
|
||||
"priority": "1 if pinned else 0",
|
||||
"active": "not note.archived",
|
||||
"state": "done if note.done else 01_in_progress",
|
||||
"tag_ids": [[6, 0, ["{tag_id_1}", "{tag_id_2}"]]],
|
||||
"date_deadline": "{first_date_entry_iso or null}",
|
||||
"geo_task_point": "{geojson_multipoint or null}"
|
||||
}
|
||||
],
|
||||
"kwargs": {}
|
||||
}
|
||||
},
|
||||
"response": "integer — Odoo task ID → store as odoo_id in mobile SQLite",
|
||||
"notes": "tag_ids uses Odoo ORM command 6 (replace all). geo_task_point is GeoJSON string."
|
||||
},
|
||||
|
||||
"notes.update": {
|
||||
"description": "Push changes to an existing note",
|
||||
"method": "POST",
|
||||
"path": "/web/dataset/call_kw",
|
||||
"request": {
|
||||
"params": {
|
||||
"model": "project.task",
|
||||
"method": "write",
|
||||
"args": [
|
||||
["{note.odoo_id}"],
|
||||
{
|
||||
"name": "{note.title}",
|
||||
"description": "{note.html_description}",
|
||||
"priority": "1 if pinned else 0",
|
||||
"active": "not note.archived",
|
||||
"state": "done if note.done else 01_in_progress",
|
||||
"tag_ids": [[6, 0, ["{tag_id_1}"]]],
|
||||
"date_deadline": "{first_date_entry_iso or null}",
|
||||
"geo_task_point": "{geojson_multipoint or null}"
|
||||
}
|
||||
],
|
||||
"kwargs": {}
|
||||
}
|
||||
},
|
||||
"response": "true on success"
|
||||
},
|
||||
|
||||
"attachments.list": {
|
||||
"description": "List attachments for a set of tasks",
|
||||
"method": "POST",
|
||||
"path": "/web/dataset/call_kw",
|
||||
"request": {
|
||||
"params": {
|
||||
"model": "ir.attachment",
|
||||
"method": "search_read",
|
||||
"args": [],
|
||||
"kwargs": {
|
||||
"domain": [
|
||||
["res_model", "=", "project.task"],
|
||||
["res_id", "in", ["{odoo_id_1}", "{odoo_id_2}"]]
|
||||
],
|
||||
"fields": ["id", "name", "mimetype", "file_size", "create_date", "res_id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"attachments.upload": {
|
||||
"description": "Upload a media file (audio, photo, video) as task attachment",
|
||||
"method": "POST",
|
||||
"path": "/web/binary/upload_attachment",
|
||||
"content_type": "multipart/form-data",
|
||||
"request_fields": {
|
||||
"model": "project.task",
|
||||
"id": "{note.odoo_id}",
|
||||
"ufile": "<binary file content>"
|
||||
},
|
||||
"response_fields": {
|
||||
"id": "integer — attachment ID",
|
||||
"name": "string — filename"
|
||||
},
|
||||
"notes": "Upload only on WiFi option recommended for video files. Requires valid session cookie."
|
||||
}
|
||||
},
|
||||
|
||||
"re_auth_strategy": {
|
||||
"trigger": "JSON-RPC error code 100 (session expired) or HTTP 401",
|
||||
"steps": [
|
||||
"1. Retrieve credentials from SecureStorage key 'odoo_sync_credentials_{app_url}'",
|
||||
"2. Call auth.login with retrieved credentials",
|
||||
"3. Store new session cookie in SecureStorage key 'odoo_sync_session_{app_url}'",
|
||||
"4. Retry original request once",
|
||||
"5. If still failing — set sync_status='error', notify user"
|
||||
]
|
||||
},
|
||||
|
||||
"sync_status_values": {
|
||||
"local": "Note exists only on device — never pushed",
|
||||
"pending": "Modified since last sync — push needed",
|
||||
"synced": "In sync with Odoo — write_date matches",
|
||||
"conflict": "Both mobile and Odoo modified since last sync (v1: Odoo wins)",
|
||||
"error": "Last sync attempt failed — show error to user"
|
||||
},
|
||||
|
||||
"sqlite_columns_added": {
|
||||
"table": "notes",
|
||||
"migration": "2026032901",
|
||||
"columns": {
|
||||
"odoo_id": "INTEGER — Odoo project.task id, null if never pushed",
|
||||
"odoo_url": "TEXT — Base URL of the Odoo instance used for sync",
|
||||
"sync_status": "TEXT DEFAULT 'local' — see sync_status_values",
|
||||
"last_synced_at": "TEXT — ISO 8601 datetime of last successful sync"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue