[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