Customer Lifecycle — End-to-End Documentation¶
Module version: 18.0.1.1.1
Governance layers: V1 (foundation) · V2 (stamps) · V3 (association models)
Last updated: 2026-04-23
Overview¶
A customer (res.partner) in this system is not just an Odoo contact. It is a governed entity — owned by a Service Account (SA), assigned to an agent, traceable through its full assignment history. This document describes the complete lifecycle from system bootstrap through customer creation, assignment, reassignment, agent revocation, and archival.
SA Hierarchy — The Container Structure¶
Before any customer can exist under governance, the SA hierarchy must be in place.
[OV Global Root SA] is_global_root=True parent_id=NULL
├── [Company A Seed SA] is_seed=True source_company_id=Company A
│ └── [Branch SA] normal SA parent_id=Seed SA
└── [Company B Seed SA] is_seed=True source_company_id=Company B
└── [Branch SA]
| SA Type | parent_id |
source_company_id |
is_global_root |
is_seed |
Created by |
|---|---|---|---|---|---|
| Global Root | NULL | NULL | True | False | Migration 18.0.1.1.1 |
| Company Seed SA | Global Root | SET | False | True | Migration 18.0.1.0.2 |
| Branch SA | Any SA | NULL | False | False | API / admin |
Key Rules¶
- There is exactly one global root SA in the entire system.
- There is exactly one seed SA per
res.company. - Every branch SA must have a parent and a
sa_managerat creation. sa_manageris exempt for the global root and seed SAs.- Branch containment: a child SA's
company_idmust match its parent's — except seed SAs whose parent is the global root (cross-company is intentional at that level).
Stage 0 — SA and Agent Setup¶
0a. Global Root SA (auto-created)¶
Created by migration 18.0.1.1.1. No API call needed. One per Odoo installation.
ov.serviced_account
name = "OV Global Root"
is_global_root = True
parent_id = NULL
source_company_id = NULL
company_id = main company (id=1)
state = active
0b. Company Seed SA (auto-created)¶
Created by migration 18.0.1.0.2, one per res.company. Re-parented under global root by migration 18.0.1.1.1.
ov.serviced_account
name = <company name>
is_seed = True
parent_id = global root SA
source_company_id = <res.company id>
company_id = <res.company id>
state = active
0c. Branch SA (API-created)¶
POST /api/service-accounts
X-API-KEY: <system key>
{
"name": "Togo Field Operations",
"parent_id": <seed_sa_id>,
"partner_id": <org_partner_id>,
"initial_admin_partner_id": <manager_partner_id>
}
Creates:
ov.serviced_account(state='active',account_class='EXTC')ov.membershipfor the manager (role_code='staff',manager_member_id=NULL)SA.sa_manager→ that membership
0d. Agent Enrollment¶
POST /api/service-accounts/<sa_id>/members/enroll
Authorization: Bearer <manager_jwt>
X-SA-ID: <sa_id>
{
"name": "Jean Kofi",
"email": "jean@example.com",
"role_code": "agent"
}
Creates:
res.partnerfor Jean (if not existing)abs.employee(free-seat, no Odoo user seat)ov.membership(role_code='agent',membership_state='active')
Resulting state:
ov.serviced_account id=42 "Togo Field Operations"
sa_manager → ov.membership Alice staff
membership → ov.membership Jean agent
Stage 1 — Customer Creation¶
POST /api/contacts
Authorization: Bearer <jean_jwt>
X-SA-ID: 42
{
"name": "Marie Dupont",
"email": "marie@client.com",
"phone": "+228 90 000 001"
}
What happens inside¶
- JWT is validated → Jean's
abs.employeeresolved X-SA-ID: 42validated → Jean is an active member of SA 42contact_valsstamped (V2 compat):x_sa_id = 42x_actor_id = Jean.partner_idres.partnercreated with all contact fields- Portal
res.usersauto-created for Marie (login = email, random password) - Invitation email sent to marie@client.com
- V3 dual-write —
ov.sa_customer_assignmentcreated:
account_id = 42
partner_id = Marie.id
actor_id = Jean.partner_id
state = active
date_from = now
assigned_by = Jean.partner_id
customer.createdMQTT event published
Data state after creation¶
res.partner id=101 "Marie Dupont"
x_sa_id = 42 ← V2 compat (kept)
x_actor_id = Jean ← V2 compat (kept)
assignment_ids → [id=1] ← V3 inverse relation
ov.sa_customer_assignment id=1
account_id = 42
partner_id = 101
actor_id = Jean.partner_id
state = active
date_from = 2026-04-23 10:00
assigned_by = Jean.partner_id
Stage 2 — Customer Visibility¶
List all customers (agent view)¶
GET /api/contacts
Authorization: Bearer <jean_jwt>
X-SA-ID: 42
V3 visibility resolution:
resolve_partner_sa_visibility_domain(portal_user)called- Jean's role =
agent→ policy =assigned_plus_unassigned build_partner_sa_visibility_domain()queries assignment table:
SELECT partner_id
FROM ov_sa_customer_assignment
WHERE account_id = 42
AND state = 'active'
AND (actor_id = Jean.partner_id OR actor_id IS NULL)
- Returns
[('id', 'in', [101, ...])] - Domain applied to
res.partner.search_read()
| Role | Policy | Sees |
|---|---|---|
agent |
assigned_plus_unassigned |
Own customers + unassigned |
staff |
sa_wide |
All customers in SA 42 |
| Any | assigned_only (override) |
Only own customers |
Fetch single customer¶
GET /api/contacts/101
Authorization: Bearer <jean_jwt>
X-SA-ID: 42
Same V3 domain applied as [('id', '=', 101)] + vis_domain. If 101 is not in Jean's allowed set → 404 Contact not found.
Stage 3 — Customer Update¶
PUT /api/contacts/101
Authorization: Bearer <jean_jwt>
X-SA-ID: 42
{
"phone": "+228 90 000 002",
"city": "Lomé"
}
V3 visibility guard runs first — confirms Jean can see contact 101 — then applies the field updates.
Stage 4 — Actor Reassignment¶
Reassign Marie from Jean to Kwame (employee-level operational reassignment):
POST /api/contacts/101/assign
Authorization: Bearer <alice_jwt>
X-SA-ID: 42
{
"employee_id": <kwame_employee_id>
}
What happens:
- V3 visibility confirms Alice (staff, sa_wide) can see contact 101
contact.x_assigned_employee_id = Kwame- Native salesperson (
user_id) synced if Kwame has an Odoo user
Note
Full V3 actor reassignment (updating ov.sa_customer_assignment.actor_id) is a Phase 2 item. The current endpoint handles operational employee assignment.
Stage 5 — Agent Membership Revocation¶
Jean leaves. Alice revokes his membership.
DELETE /api/service-accounts/42/members/<jean_membership_id>
Authorization: Bearer <alice_jwt>
X-SA-ID: 42
OvMembership.write({'membership_state': 'revoked'}) triggers two side-effects:
V2 side-effect (legacy compat)¶
# Clears x_actor_id on all legacy-stamped records Jean owned in SA 42
res.partner.search([x_sa_id=42, x_actor_id=Jean]).write({x_actor_id: False})
sale.order.search([x_sa_id=42, x_actor_id=Jean]).write({x_actor_id: False})
crm.lead.search([...]).write({x_actor_id: False})
# ... all _GOVERNED_MODELS
V3 side-effect (new)¶
# Clears actor_id on Jean's active assignment records in SA 42
ov.sa_customer_assignment.search([
account_id=42, actor_id=Jean, state='active'
]).clear_actor()
# → actor_id = NULL, state stays 'active'
# Customer stays in SA 42 as unassigned, not lost
Data state after revocation:
res.partner id=101 "Marie Dupont"
x_sa_id = 42 ← still stamped (customer stays in SA)
x_actor_id = NULL ← cleared
ov.sa_customer_assignment id=1
account_id = 42
partner_id = 101
actor_id = NULL ← cleared, now unassigned
state = active ← still active, customer not lost
Marie now appears in any agent's assigned_plus_unassigned list until reassigned.
Stage 6 — Customer Archival¶
DELETE /api/contacts/101
Authorization: Bearer <alice_jwt>
X-SA-ID: 42
What happens:
- V3 visibility guard confirms Alice can see contact 101
- Linked portal
res.usersarchived (active=False) first res.partner.write({'active': False})ov.sa_customer_assignmentrecord preserved (state='active') — audit history intact
Marie no longer appears in GET /api/contacts (filtered out by active=True domain) but the assignment record remains for historical audit.
Backfill — Migrating Legacy Customers¶
For customers created before V3 (stamps but no assignment records):
Via Odoo module upgrade¶
Migration 18.0.1.1.0 runs automatically on odoo -u abs_connector:
INSERT INTO ov_sa_customer_assignment
(account_id, partner_id, actor_id, state, date_from, create_date, write_date)
SELECT
rp.x_sa_id,
rp.id,
rp.x_actor_id,
'active',
COALESCE(rp.create_date, NOW()),
NOW(),
NOW()
FROM res_partner rp
WHERE rp.x_sa_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM ov_sa_customer_assignment a
WHERE a.partner_id = rp.id AND a.account_id = rp.x_sa_id
)
ON CONFLICT (partner_id, account_id) DO NOTHING
Via API (dev/test)¶
POST /api/migration/backfill-customer-assignments
X-API-KEY: <key>
{
"company_id": 1,
"dry_run": true
}
Returns a preview of what will be created before committing.
Data Model Reference¶
ov.sa_customer_assignment¶
| Field | Type | Description |
|---|---|---|
account_id |
Many2one ov.serviced_account |
The SA that owns this customer |
partner_id |
Many2one res.partner |
The customer |
actor_id |
Many2one res.partner |
The assigned agent (nullable = unassigned) |
state |
Selection active/expired |
Lifecycle state |
date_from |
Datetime | When assignment became effective |
date_to |
Datetime | When assignment expired (null if active) |
assigned_by_id |
Many2one res.partner |
Who created/changed this assignment |
SQL constraint: UNIQUE(partner_id, account_id) — one assignment per customer per SA.
Compatibility stamps on res.partner (V2, transitional)¶
| Field | Description | Status |
|---|---|---|
x_sa_id |
SA context stamp | Kept as compatibility layer |
x_actor_id |
Actor stamp | Kept as compatibility layer |
assignment_ids |
V3 inverse One2many | V3 canonical read path |
API Surface Summary¶
| Method | Endpoint | Auth | Governance Layer | Description |
|---|---|---|---|---|
POST |
/api/service-accounts |
API key | V1 | Create branch SA |
POST |
/api/service-accounts/<id>/members/enroll |
JWT | V1 | Enroll agent |
GET |
/api/me/service-accounts |
JWT | V1 | My SAs + role |
POST |
/api/contacts |
JWT | V2 stamp + V3 dual-write | Create customer |
GET |
/api/contacts |
JWT | V3 | List customers (SA-scoped) |
GET |
/api/contacts/<id> |
JWT | V3 | Get single customer |
PUT |
/api/contacts/<id> |
JWT | V3 | Update customer |
DELETE |
/api/contacts/<id> |
JWT | V3 | Archive customer |
POST |
/api/contacts/<id>/assign |
JWT / API key | V2 | Reassign employee |
DELETE |
/api/service-accounts/<id>/members/<mid> |
JWT | V1 + V2 clear + V3 clear | Revoke membership |
POST |
/api/migration/backfill-customer-assignments |
API key | V3 | Backfill assignments |
Visibility Policy Reference¶
Policies apply to any SA-scoped list endpoint. Resolved from ov.membership.scope_policy (override) or role default.
| Policy | SQL equivalent | Who gets it by default |
|---|---|---|
sa_wide |
All active assignments in SA | staff role |
assigned_plus_unassigned |
actor_id = me OR actor_id IS NULL |
agent role |
assigned_only |
actor_id = me |
Manual override only |
V1 / V2 / V3 Coexistence¶
Every request:
JWT → resolve_sa_context() → ov.membership lookup (V1 always)
GET /api/contacts:
→ resolve_partner_sa_visibility_domain() ← V3 (reads ov.sa_customer_assignment)
GET /api/orders:
→ build_sa_visibility_domain() ← V2 (reads x_sa_id stamp, not yet V3)
POST /api/contacts:
→ stamp x_sa_id / x_actor_id ← V2 compat write (kept)
→ create ov.sa_customer_assignment ← V3 canonical write
Membership revoked:
→ clear x_actor_id on stamped records ← V2 compat clear
→ clear actor_id on assignment records ← V3 clear
Roadmap — Next V3 Phases¶
| Phase | Object | New model | Controller |
|---|---|---|---|
| Phase 1 ✅ | res.partner |
ov.sa_customer_assignment |
contacts.py |
| Phase 2 | sale.order |
ov.sa_sale_order_assignment |
order_controller.py, session_controller.py |
| Phase 3 | crm.lead |
ov.sa_lead_assignment |
crm.py |
| Phase 4 | ov.asset |
ov.sa_asset_assignment |
asset_api.py |
| Phase 5 | account.move |
ov.sa_invoice_assignment |
New (V3-first, no stamp) |
| Phase 6 | helpdesk.ticket |
ov.sa_ticket_assignment |
New (V3-first, no stamp) |
| Future | Retire stamps | Remove x_sa_id/x_actor_id from res.partner |
After all consumers migrate |