Sales Workflow — End-to-End Documentation¶
Module version: 18.0.1.1.3
Governance layers: V1 (foundation) · V2 (stamps) · V3 (association models)
Last updated: 2026-04-23
Companion doc: CUSTOMER_LIFECYCLE.md — customer creation and assignment detail
Overview¶
This document covers every workflow from the moment a customer exists through to a fully paid invoice and governed asset on the ground. CRM (leads, pipeline) is excluded by design. The scope is the commercial and operational execution layer: quotation, approval, confirmation, delivery, invoicing, payment, and asset governance.
The Complete Flow¶
WORKFLOW 0 ──────────────────────────────────────────────────────
Warehouse & Inventory Management
stock.warehouse · stock.location · stock.quant
stock.picking (Receipt) · stock.lot · stock.rule
│ stock levels exist before any sale can happen
▼
[Customer]
│
▼
WORKFLOW 1 ──────────────────────────────────────────────────────
Product Catalogue Setup
product.template · product.product · product.pricelist
ov.bop.sheet · ov.bop.line · account.tax
│
▼
WORKFLOW 2 ──────────────────────────────────────────────────────
Quotation Creation
sale.order (Draft) · sale.order.line · ov.outlet
│
▼
WORKFLOW 3 ──────────────────────────────────────────────────────
Approval Workflow
sale.order (Waiting Approval → Approved)
│
▼
WORKFLOW 4 ──────────────────────────────────────────────────────
Order Confirmation
sale.order (Confirmed)
│
├──────────────────────────────────────────────┐
▼ ▼
WORKFLOW 5 WORKFLOW 6
Stock & Delivery Invoice Creation
stock.picking · stock.move account.move (Draft)
stock.move.line · stock.lot │
stock.quant decremented ▼
│ WORKFLOW 7
▼ Invoice Confirmation
WORKFLOW 8 account.move (Posted)
Asset Governance │
ov.asset · stock.lot ▼
WORKFLOW 9
Payment Registration
account.payment
│
▼
WORKFLOW 10
Subscription (Recurring)
abs.subscription
Workflow 0 — Warehouse & Inventory Management¶
Owner: Warehouse Manager / Admin Frequency: Ongoing — before any sale can be fulfilled Governance layer: ⚙️ No SA stamp today — V3 Phase 2 target
0a. The Core Principle — Location-Based Ledger¶
Odoo does not track "how many units exist". It tracks where every unit is at all times.
Stock is always a movement between two locations. The live ledger is stock.quant.
You never edit stock.quant directly — it is updated automatically when a stock.picking is validated.
[Supplier]
│
▼ stock.picking (type=receipt — goods received)
[WH/Input] → [WH/Quality Check (optional)] → [WH/Stock]
│
▼ stock.picking (type=delivery — outgoing)
[Partners/Customers] ← virtual location
0b. Product Types — Does a Product Have Stock?¶
This single field on product.template controls whether Odoo tracks inventory at all.
product.type |
Odoo Label | Stock Tracked | Delivery on Sale | Invoice When |
|---|---|---|---|---|
product (storable) |
Storable Product | ✅ Yes, full tracking | ✅ Yes | On delivery or order |
consu |
Consumable | ❌ No | ✅ Yes (no stock check) | On order confirm |
service |
Service | ❌ No | ❌ No picking | On order confirm |
Rule for this system:
- Solar kits, batteries, accessories → storable (you need serial numbers + stock tracking)
- Installation fee, warranty → service (no physical unit, billed immediately)
- Spare parts, consumables → consu (dispatched but not serialised)
0c. Warehouse Structure¶
stock.warehouse "Main Warehouse" short_name="WH"
│
├── stock.location WH/Stock ← main shelf (source of all deliveries)
│ ├── WH/Stock/Solar Kits
│ └── WH/Stock/Accessories
│
├── stock.location WH/Input ← goods received here (2/3-step receiving)
├── stock.location WH/Quality ← optional inspection step
├── stock.location WH/Output ← staging area (2/3-step delivery)
│
└── Virtual locations (not physical)
├── Partners/Customers ← every delivered unit "lives" here
├── Partners/Suppliers ← source of every purchase receipt
└── Virtual/Inventory ← used for manual stock adjustments
A single Odoo instance can have multiple warehouses (per country, per depot).
Each warehouse has its own stock.picking.type records: Receipts, Deliveries, Internal Transfers.
0d. Receiving Goods (Stock In)¶
When the company buys products from a supplier, Odoo creates a stock.picking of type Receipt.
Validating it increments stock.quant at the warehouse location.
Scenario: 50 Solar Kits arrive at the warehouse
stock.picking (Receipt)
picking_type_id = Receipts
partner_id = Supplier XYZ
state = ready
stock.move:
product_id = Solar Kit 200W
product_qty = 50
location_id = Partners/Suppliers (virtual source)
location_dest = WH/Stock (physical destination)
→ Validate picking
→ stock.move.line: lot_name = "SN-001" ... "SN-050" (serial numbers assigned)
→ stock.quant at WH/Stock, Solar Kit 200W: qty_done += 50
Proposed API endpoint (does not exist yet):
POST /api/warehouse/receive
Authorization: Bearer <manager_jwt>
Body:
{
"warehouse_id": 1,
"supplier_id": 55,
"scheduled_date": "2026-04-25",
"lines": [
{
"product_id": 22,
"quantity": 50,
"lots": ["SN-2026-001", "SN-2026-002", ..., "SN-2026-050"]
}
]
}
→ stock.picking (Receipt) created and validated
→ stock.quant incremented
→ stock.lot records created for each serial number
0e. Checking Stock Levels¶
The live inventory is stored in stock.quant. Each row represents a product at a specific location with a quantity.
stock.quant
product_id = Solar Kit 200W
location_id = WH/Stock
quantity = 50 ← physical on-hand
reserved_qty = 5 ← locked to confirmed sale orders
available_qty = 45 ← quantity - reserved_qty (what you can still sell)
qty_available on product.product is the computed total across all stock locations. This is what Odoo's UI shows as "On Hand".
virtual_available (= Forecasted) = qty_available + incoming - outgoing demand.
The current GET /api/products endpoint does not return stock quantities. This is a gap.
Proposed API endpoint:
GET /api/stock/levels?company_id=1&warehouse_id=1
Authorization: Bearer <manager_jwt>
Response:
{
"warehouse": { "id": 1, "name": "Main Warehouse" },
"stock": [
{
"product_id": 22,
"product_name": "Solar Kit 200W",
"on_hand": 50,
"reserved": 5,
"available": 45,
"forecasted": 95,
"lots": [
{ "lot_id": 11, "name": "SN-2026-001", "location": "WH/Stock" },
...
]
}
]
}
GET /api/stock/levels/<product_id> ← single product
GET /api/stock/lots?product_id=22 ← all serial numbers for a product
GET /api/stock/lots/<lot_name> ← locate a specific serial number
0f. Does a Sale Reduce Stock?¶
Not immediately. There are two separate moments:
STEP 1 — Order Confirmed (POST /api/orders/<id>/confirm)
│
└── stock.picking (Delivery) created
state = waiting / ready
stock.quant.reserved_qty += ordered_qty ← RESERVED only
stock.quant.quantity unchanged ← on-hand NOT changed yet
STEP 2 — Delivery Validated (POST /api/orders/<id>/approve)
│
└── stock.picking validated
stock.quant.quantity -= delivered_qty ← DECREMENTED here
stock.quant.reserved_qty -= ordered_qty
stock.move.line.lot_id = "SN-2026-001" ← serial number locked
→ stock.lot now "at" Partners/Customers
Key rule: A serial number (stock.lot) can only be in one location at a time. Once delivered, it cannot be delivered again until returned.
0g. Serial Numbers — Full Lifecycle¶
1. ARRIVAL (Receipt)
stock.lot created: name="SN-2026-001", product_id=Solar Kit
stock.quant: (WH/Stock, SN-2026-001) qty = 1
2. SALE CONFIRMED
stock.quant: (WH/Stock, SN-2026-001) reserved_qty = 1
3. DELIVERY VALIDATED
stock.quant: (WH/Stock, SN-2026-001) qty = 0
stock.quant: (Partners/Customers, SN-2026-001) qty = 1
→ ov.asset created and linked to lot_id + partner_id + x_sa_id
4. RETURN / REPAIR
stock.picking (Return) created
stock.quant: (WH/Stock, SN-2026-001) qty = 1 (back in warehouse)
→ ov.asset state → 'transferred' or 'inactive'
5. SCRAP / END OF LIFE
stock.scrap validated
stock.quant: (Virtual/Scrap, SN-2026-001) qty = 1
→ ov.asset state → decommissioned
0h. Inventory Adjustment (Manual Stock Correction)¶
When a physical count does not match the system count (damaged units, miscounts), an inventory adjustment is done. This writes directly against Virtual/Inventory location.
Scenario: 3 units found damaged during a physical count
stock.picking (Inventory Adjustment)
location_id = Virtual/Inventory
location_dest = Virtual/Scrap
→ stock.quant at WH/Stock decremented by 3
Proposed API endpoint:
POST /api/stock/adjust
Authorization: Bearer <manager_jwt>
Body:
{
"warehouse_id": 1,
"reason": "Damaged units found during physical count",
"lines": [
{ "product_id": 22, "lot_id": 45, "quantity_counted": 0 }
]
}
→ Inventory adjustment validated
→ stock.quant updated to match counted quantity
0i. Reorder Rules (Automatic Replenishment)¶
stock.warehouse.orderpoint defines minimum stock levels. When qty_available drops below the minimum, Odoo automatically creates a Purchase Order or Manufacturing Order.
stock.warehouse.orderpoint
product_id = Solar Kit 200W
warehouse_id = WH
product_min_qty = 10 ← trigger replenishment when stock falls below this
product_max_qty = 100 ← replenish up to this quantity
route_id = Buy / Make to Order
This runs automatically via a daily ir.cron job. No API action required.
0j. Internal Transfers¶
Moving stock between warehouses, between shelves, or between companies (inter-company).
stock.picking (Internal Transfer)
picking_type_id = Internal Transfers
location_id = WH/Stock/Solar Kits
location_dest = WH2/Stock ← different warehouse
→ stock.quant decremented at source, incremented at destination
Proposed API endpoint:
POST /api/stock/transfer
Authorization: Bearer <manager_jwt>
Body:
{
"from_location_id": 12,
"to_location_id": 25,
"lines": [
{ "product_id": 22, "lot_id": 45, "quantity": 1 }
]
}
0k. Delivery Tracking (Outbound Pickings from Sales)¶
After an order is confirmed, a delivery picking is created automatically. The warehouse team must validate it.
Proposed API endpoints:
GET /api/stock/deliveries?state=ready&warehouse_id=1
Authorization: Bearer <manager_jwt>
Response — list of pending deliveries:
{
"deliveries": [
{
"id": 12,
"name": "WH/OUT/00012",
"state": "ready",
"scheduled_date": "2026-04-25",
"sale_order_id": 55,
"sale_order_name": "SO055",
"partner_id": 101,
"partner_name": "Marie Dupont",
"lines": [
{
"product_id": 22,
"product_name": "Solar Kit 200W",
"demand": 1,
"done": 0,
"lot_id": null ← serial not yet assigned
}
]
}
]
}
GET /api/stock/deliveries/<id> ← single delivery detail
POST /api/stock/deliveries/<id>/validate ← validate delivery (dispatch goods)
Body: { "lines": [{ "move_line_id": 77, "lot_name": "SN-2026-001", "qty_done": 1 }] }
Summary — Warehouse API Surface (Proposed)¶
All endpoints below need to be built. None currently exist.
| Method | Endpoint | Action | Auth |
|---|---|---|---|
GET |
/api/warehouses |
List warehouses + locations | JWT |
GET |
/api/stock/levels |
Live on-hand per product | JWT |
GET |
/api/stock/levels/<product_id> |
Single product stock + lots | JWT |
GET |
/api/stock/lots |
All serial numbers (filterable) | JWT |
GET |
/api/stock/lots/<lot_name> |
Locate a serial number | JWT |
POST |
/api/warehouse/receive |
Receive goods from supplier | JWT (manager) |
POST |
/api/stock/adjust |
Manual inventory adjustment | JWT (manager) |
POST |
/api/stock/transfer |
Internal transfer between locations | JWT (manager) |
GET |
/api/stock/deliveries |
List pending outbound deliveries | JWT |
GET |
/api/stock/deliveries/<id> |
Single delivery detail | JWT |
POST |
/api/stock/deliveries/<id>/validate |
Validate (dispatch) a delivery | JWT |
Prerequisites Before Any Workflow Starts¶
The SA infrastructure must be in place. See CUSTOMER_LIFECYCLE.md for full detail.
ov.serviced_account ← the SA owning all commercial activity
ov.membership ← agent's authority in that SA
abs.employee ← agent's identity (free-seat, no Odoo user seat)
res.partner ← customer with ov.sa_customer_assignment
Workflow 1 — Product Catalogue¶
Owner: Admin / Product Manager
Frequency: One-time setup, updated as product range changes
Governance layer: V2 (product.template stamped)
1a. The Two Taxonomies — Odoo Native vs Wangvision PU¶
Every product in this system has two classification systems layered on top of each other. Both matter for the sales workflow.
Layer 1 — Odoo Native Product Type (product.type)¶
This is Odoo's built-in classification. It controls stock, delivery, and invoicing behaviour. This is the most important field on any product.
product.type |
Odoo Label | Stock Tracked | Delivery Created | Invoice When |
|---|---|---|---|---|
product |
Storable Product | ✅ Yes | ✅ Yes | After delivery validated (invoice_policy=delivery) |
consu |
Consumable | ❌ No | ✅ Yes | On order confirm (invoice_policy=order) |
service |
Service | ❌ No | ❌ No | On order confirm (invoice_policy=order) |
Combined with tracking:
tracking |
Meaning | Used for |
|---|---|---|
serial |
One serial number per unit | Solar kits, batteries, meters — every unit individually identified |
lot |
Batch tracking | Accessories shipped in groups |
none |
No tracking | Consumables, service fees |
And invoice_policy:
invoice_policy |
Invoice created when | Typical product |
|---|---|---|
delivery |
Only after stock.picking is validated |
Physical kits — must be confirmed delivered |
order |
At order confirmation | Service fees, installation, contracts |
Layer 2 — Wangvision Product-Unit (PU) Taxonomy (x_pu_category)¶
This is a custom classification layer added on top of Odoo. It describes the commercial meaning of the product for the frontend, reporting, and subscription logic — independent of how Odoo internally handles the stock/invoicing.
x_pu_category ← top-level PU category
│
├── physical → tangible hardware asset (kit, battery, cable)
├── service → operational value delivered over time or events
│ ├── x_service_type = access (time-bounded infrastructure access)
│ └── x_service_type = gage (usage-based / per-event)
├── contract → entitlement or obligation (document-backed)
│ ├── x_contract_type = privilege
│ ├── x_contract_type = warranty
│ ├── x_contract_type = rental
│ ├── x_contract_type = maintenance
│ └── x_contract_type = asset_assignment ← PAYG / lease-to-own
└── digital → software access, data, IoT (future)
And how products are measured:
x_pu_metric |
Meaning | Example |
|---|---|---|
piece |
Per physical unit | Solar kit, battery |
duration |
Per time period | Monthly maintenance fee |
count |
Per event/occurrence | Number of service calls |
energy |
Per kWh consumed | Pay-as-you-go energy |
distance |
Per km | Delivery/logistics fee |
1b. Product Categories — The Four Real-World Groups¶
These are the four top-level Odoo product.category roots used by default in this system (configured via abs_connector.unscoped_catalog_category_ids):
| Category | Contains | PU Category |
|---|---|---|
| Solar Kits | Complete solar home systems by wattage | physical |
| E-mobility | Electric bikes, scooters, charging accessories | physical |
| Cross-Grid | Grid-tied / hybrid systems and components | physical |
| Off-Grid | Off-grid batteries, inverters, controllers | physical |
Sub-categories within these roots include accessories, spare parts, and services specific to each product line.
1c. Complete Product Type Matrix — How Each Behaves in the Sales Workflow¶
This is the most important reference for developers and operations.
| PU Category | Odoo Type | Tracking | Invoice Policy | Delivery | Stock | Subscription | Typical Example |
|---|---|---|---|---|---|---|---|
physical |
storable |
serial |
delivery |
✅ Created | ✅ Decremented | ❌ | Solar Kit 200W, Battery 100Ah |
physical |
storable |
lot |
delivery |
✅ Created | ✅ Decremented | ❌ | Cable bundles, spare parts kits |
physical |
consu |
none |
order |
✅ Created | ❌ | ❌ | Installation consumables |
service (access) |
service |
none |
order |
❌ | ❌ | ❌ | Time-bounded connectivity plan |
service (gage) |
service |
none |
order |
❌ | ❌ | ❌ | Pay-per-use energy metering |
contract (warranty) |
service |
none |
order |
❌ | ❌ | ✅ | 2-year warranty contract |
contract (maintenance) |
service |
none |
order |
❌ | ❌ | ✅ | Monthly maintenance subscription |
contract (rental) |
service |
none |
order |
❌ | ❌ | ✅ | Equipment rental agreement |
contract (asset_assignment) |
service |
none |
order |
❌ | ❌ | ✅ | PAYG / lease-to-own agreement |
digital |
service |
none |
order |
❌ | ❌ | ✅ | IoT data plan (future) |
Key rules enforced by the model:
- x_service_type can only be set when x_pu_category = 'service'
- x_contract_type can only be set when x_pu_category = 'contract'
- Recurring billing (recurring_invoice=True) applies to contract and digital PU categories
1d. How Product Type Changes the Workflow Path¶
When an agent creates a quotation with mixed products, Odoo handles each line differently at confirmation:
sale.order (mixed lines)
│
├── Line 1: Solar Kit 200W (storable, serial, invoice_policy=delivery)
│ → stock.picking CREATED at confirmation
│ → stock.quant RESERVED
│ → invoice BLOCKED until picking validated
│ → serial number assigned at delivery
│ → ov.asset created post-delivery
│
├── Line 2: Installation Fee (service, invoice_policy=order)
│ → NO stock.picking
│ → invoice CREATED immediately at confirmation
│
└── Line 3: Warranty Contract (service, recurring_invoice=True)
→ NO stock.picking
→ invoice CREATED immediately
→ abs.subscription created for recurring billing
→ ir.cron generates monthly invoices going forward
Practical implication for operations:
A single order with a kit + installation fee + warranty will produce:
1. One stock.picking (for the kit only)
2. One immediate draft invoice covering the installation fee and warranty
3. One recurring subscription for the warranty monthly billing
4. After delivery validation: a second invoice for the kit itself
1e. Bill of Products (BOP) — Custom Package Definition¶
ov.bop.sheet is a custom model that bundles multiple products into a named package. When an agent selects a "Solar Home System 200W Package", the BOP expands into individual product lines on the quotation.
ov.bop.sheet "Solar Home System 200W"
ov.bop.line → Solar Panel 200W qty=1 product_id=22
ov.bop.line → Battery 100Ah qty=1 product_id=18
ov.bop.line → Charge Controller qty=1 product_id=31
ov.bop.line → Cable Set qty=2 product_id=44
ov.bop.line → Installation Service qty=1 product_id=55
ov.bop.property → "Panel Colour" options: [Black, Silver]
Each BOP line inherits the Odoo type and PU category of its underlying product, so the workflow split above applies to each line individually.
1f. Models Involved¶
| Model | Role | Governance |
|---|---|---|
product.template |
Product master — defines type, tracking, invoice_policy | ✅ Stamped (x_sa_id optional) |
product.product |
Specific variant (200W Black vs 200W Silver) | ⚙️ Inherits from template |
product.category |
Odoo grouping (Solar Kits, E-mobility, etc.) | ⚙️ Config |
product.pricelist |
Price rules per SA / territory / volume | ⚙️ Config |
account.tax |
VAT / withholding applied per line | ⚙️ Config |
uom.uom |
Unit of measure (pieces, kWh, months) | ⚙️ Config |
ov.bop.sheet |
Bill of Products — named package | ✅ Custom |
ov.bop.line |
Individual product within a BOP | ✅ Custom |
ov.bop.property |
Configurable option within a BOP (colour, size) | ✅ Custom |
1g. API Endpoints¶
GET /api/products ← SA-scoped product list (agent JWT)
GET /api/products?pu_category=physical ← filter by PU category
GET /api/products?pu_category=contract ← contract products only
GET /api/products?pu_category=service ← service products only
GET /api/products?recurring_invoice=true ← subscription products only
GET /api/products/categories ← full category tree
GET /api/products/categories?view=products&category_id=115 ← products in category
GET /api/products/<ref>/bop ← Bill of Products for a product
POST /api/products ← create product (API key, admin)
PUT /api/products/<id> ← update product
1h. Gap¶
product.template is V2 stamped (x_sa_id) but not yet V3-migrated. SA-scoped product catalogues are currently driven by the stamp domain. V3 ov.sa_product_assignment is a future consideration (lower priority than invoices and deliveries).
Workflow 2 — Quotation Creation¶
Owner: Field Agent
Governance layer: V2 (stamped) — V3 Phase 2 target
State entering: Customer exists, products exist
State leaving: sale.order in draft
Models involved¶
| Model | Role | Governance |
|---|---|---|
sale.order |
The quotation document | ✅ Stamped (x_sa_id, x_actor_id) |
sale.order.line |
Each product in the order | ⚙️ Inherits SA context from parent |
product.pricelist |
Pricing applied at line level | ⚙️ Config |
account.payment.term |
When payment falls due | ⚙️ Config |
ov.outlet |
Sales outlet context | ✅ Custom (x_outlet_id on order) |
res.partner |
Customer on the order | ✅ V3 |
State machine¶
Draft (Quotation)
→ Sent (emailed to customer)
→ Sale (Confirmed)
→ Done
→ Cancelled
API endpoints¶
POST /api/quotations
Authorization: Bearer <agent_jwt>
X-SA-ID: <sa_id>
Body:
{
"customer_id": 101,
"company_id": 1,
"outlet_id": 5,
"channel_partner_id": 10,
"client_order_ref": "CUST-REF-001",
"products": [
{ "product_id": 22, "quantity": 1, "price_unit": 450.00 },
{ "product_id": 18, "quantity": 2, "price_unit": 25.00 }
]
}
→ sale.order created in Draft
→ x_sa_id + x_actor_id stamped automatically
POST /api/orders/<id>/add-lines ← add more products to draft order
PUT /api/orders/<id> ← update header fields (date, terms, ref)
GET /api/orders/<id> ← fetch full order with lines
GET /api/orders ← list orders (SA-scoped via x_sa_id)
POST /api/orders/<id>/send ← email quotation PDF to customer
GET /api/orders/<id>/proforma-pdf ← download proforma PDF
Data state after creation¶
sale.order id=55
state = draft
partner_id = Marie Dupont (101)
x_sa_id = 42 ← SA stamp
x_actor_id = Jean ← agent stamp
x_outlet_id = 5
amount_total = 500.00
invoice_ids = []
picking_ids = []
Gap¶
sale.order has x_sa_id / x_actor_id stamps (V2). V3 migration (ov.sa_sale_order_assignment) is Phase 2. Until then, GET /api/orders visibility reads from the stamp domain, not an association table.
Workflow 3 — Approval Workflow¶
Owner: SA Manager (Staff role)
Governance layer: V1 membership authority + custom order fields
State entering: sale.order in draft or sent
State leaving: sale.order approved, ready to confirm
When approval is required¶
Approval is triggered when an order exceeds a value threshold defined at the SA level, or when a product category requires explicit authorisation. Orders below threshold can skip directly to confirmation.
Models involved¶
| Model | Role | Governance |
|---|---|---|
sale.order |
Carries approval state custom fields | ✅ Stamped |
ov.membership |
Approver's role must be staff in the SA |
⚙️ V1 |
State machine¶
Draft
→ Waiting Approval (agent submits)
→ Approved (manager approves) → ready for confirm
→ Rejected (manager rejects) → back to Draft with reason
API endpoints¶
POST /api/orders/<id>/request-approval
Authorization: Bearer <agent_jwt>
X-SA-ID: <sa_id>
→ order marked as pending approval
POST /api/orders/<id>/approve
Authorization: Bearer <manager_jwt> ← must have staff role in SA
X-SA-ID: <sa_id>
Body: { "note": "Approved for Q2 deployment" }
→ order approved, ready to confirm
POST /api/orders/<id>/reject
Authorization: Bearer <manager_jwt>
X-SA-ID: <sa_id>
Body: { "reason": "Price too high, renegotiate" }
→ order rejected, agent must revise
Constraint¶
Only an ov.membership with role_code='staff' in the order's SA can approve. An agent (role_code='agent') cannot approve their own orders.
Workflow 4 — Order Confirmation¶
Owner: Agent or Manager
Governance layer: V2 stamps, Odoo native triggers
State entering: sale.order in draft (approved if approval required)
State leaving: sale.order in sale (confirmed), delivery and/or invoice created
API endpoint¶
POST /api/orders/<id>/confirm
Authorization: Bearer <agent_jwt>
X-SA-ID: <sa_id>
What Odoo triggers automatically on confirmation¶
This is the most critical moment in the workflow. One call produces multiple downstream objects.
sale.order.action_confirm()
│
├── IF product.type = 'storable':
│ stock.picking ← DELIVERY ORDER created automatically
│ └── stock.move (one per order line)
│ └── stock.move.line (serial/lot detail)
│
└── IF invoice_policy = 'order':
account.move (Draft invoice) ← INVOICE created automatically
State after confirmation¶
sale.order id=55
state = sale
picking_ids = [picking_id=12] ← delivery created
invoice_ids = [move_id=33] ← invoice created (if policy=order)
delivery_status = pending
stock.picking id=12
state = waiting / ready
sale_id = 55
partner_id = Marie Dupont
picking_type_id = Delivery Orders
scheduled_date = <from order>
account.move id=33 (if invoice_policy=order)
state = draft
move_type = out_invoice
invoice_origin = SO055
amount_total = 500.00
Important: invoice_policy behaviour¶
invoice_policy |
Invoice created at confirm? | Invoice created at delivery? |
|---|---|---|
order |
Yes, immediately in Draft | No |
delivery |
No | Yes, after picking validated |
If products have mixed policies on the same order, Odoo creates one invoice per policy group.
Workflow 5 — Stock Movement and Delivery¶
Owner: Warehouse / Agent
Governance layer: V2 gap — stock.picking NOT yet stamped
State entering: stock.picking in waiting or ready
State leaving: stock.picking in done, stock decremented, serial numbers assigned
Models involved — complete chain¶
| Model | Role | Governance |
|---|---|---|
stock.warehouse |
Warehouse owning the stock | ⚙️ Config |
stock.location |
Source (shelf) and dest (customer) locations | ⚙️ Config |
stock.picking |
The delivery order | 🟡 NOT STAMPED |
stock.move |
One product line in the delivery | ⚙️ Inherits picking |
stock.move.line |
Serial/lot level detail | ⚙️ Inherits move |
stock.lot |
The serial number record | 🟡 NOT STAMPED |
stock.quant |
On-hand inventory ledger | ⚙️ System-maintained |
Delivery state machine¶
Waiting Availability (stock not yet reserved)
→ Ready (stock reserved — picking_availability confirmed)
→ Done (validated — goods physically dispatched)
→ Backorder (partial delivery — creates new picking for remainder)
→ Cancelled
What happens inside stock.picking validation¶
picking.button_validate()
│
├── stock.move → state = done
├── stock.move.line → lot_id assigned (serial number locked to this delivery)
├── stock.quant at source location: quantity decremented
├── stock.quant at customer location: quantity added (virtual)
├── sale.order.delivery_status → fully_delivered (if all lines done)
│
└── IF invoice_policy = 'delivery':
account.move (Draft) now unlocked and auto-created
Serial number assignment during validation¶
stock.lot name="SN-2026-001" product_id=Solar Kit 200W
← assigned to stock.move.line during validation
← location changes from Warehouse → Customer Location (virtual)
← this is the moment the physical unit leaves the warehouse
Gap — Critical¶
stock.picking and stock.lot have no x_sa_id stamp. This means:
- You cannot query "all deliveries made by SA 42 this quarter"
- You cannot trace "which SA deployed serial number SN-2026-001"
- Delivery performance per SA is invisible to the governance layer
Fix: V3 ov.sa_delivery_assignment — Phase 2 target.
CURRENT: sale.order (x_sa_id=42) → stock.picking (NO SA)
FUTURE: sale.order (x_sa_id=42) → stock.picking → ov.sa_delivery_assignment (account_id=42)
Backorder handling¶
If an agent can only deliver part of an order:
stock.picking (partial validation)
→ Done picking covers what was delivered
→ Backorder picking auto-created for remainder
→ sale.order delivery_status → partially delivered
→ invoice_policy=delivery invoices only the delivered quantity
Workflow 6 — Invoice Creation¶
Owner: Agent or Finance
Governance layer: V2 gap — account.move NOT yet stamped
State entering: sale.order confirmed + (delivery done if policy=delivery)
State leaving: account.move in draft
Models involved¶
| Model | Role | Governance |
|---|---|---|
account.move |
The invoice | 🔴 NOT STAMPED — CRITICAL GAP |
account.move.line |
Invoice lines (product + tax) | ⚙️ Inherits move |
account.tax |
Tax computed per line | ⚙️ Config |
account.journal |
Sales Journal (groups the entries) | ⚙️ Config |
account.payment.term |
Due date calculation | ⚙️ Config |
account.account |
Revenue + receivable accounts | ⚙️ Config |
API endpoint¶
POST /api/orders/<id>/invoice
Authorization: Bearer <agent_jwt>
→ account.move created from sale.order lines
→ state = draft
→ If draft invoice already exists: returned without creating duplicate
GET /api/orders/<id>/invoices ← list all invoices for an order
GET /api/customers/<id>/invoices ← all invoices for a customer
GET /api/customers/<id>/invoices/pending ← unpaid invoices only
GET /api/customers/<id>/next-payment ← next instalment due date + amount
What the invoice contains¶
account.move id=33 type=out_invoice
partner_id = Marie Dupont (101)
invoice_origin = SO055
invoice_date = today
invoice_date_due = today + payment_terms
amount_untaxed = 434.78
amount_tax = 65.22
amount_total = 500.00
payment_state = not_paid
state = draft
line_ids:
├── Solar Kit 200W qty=1 price=450.00 tax=VAT15% → Revenue Acct
├── Cable Set qty=2 price=25.00 tax=VAT15% → Revenue Acct
└── VAT 15% total=65.22 → VAT Payable Acct
Gap — Most Critical¶
account.move has no x_sa_id stamp. This is the single biggest governance gap in the entire system. Without it:
- Revenue per SA cannot be queried natively
- Outstanding receivables per SA are invisible
- Finance cannot slice P&L by service account
- PA-facing invoice lists cannot be SA-scoped
Fix: V3 ov.sa_invoice_assignment — Phase 5 target. V3-first (no stamp migration needed, create association model directly).
FUTURE:
ov.sa_invoice_assignment
account_id = 42 (the SA)
move_id = 33 (the invoice)
actor_id = Jean (agent who created the order)
state = active
date_from = invoice_date
Workflow 7 — Invoice Confirmation (Posting)¶
Owner: Finance / Manager
Governance layer: Odoo native accounting
State entering: account.move in draft
State leaving: account.move in posted, journal entries written
API endpoint¶
POST /api/orders/<id>/invoices/<invoice_id>/confirm
Authorization: Bearer <agent_jwt>
→ account.move.action_post()
→ state = posted
→ journal entries written to chart of accounts
→ due date locked
→ customer receivable balance updated
What posting does to the chart of accounts¶
DEBIT Accounts Receivable (partner=Marie) 500.00
CREDIT Revenue Account 434.78
CREDIT VAT Payable 65.22
These journal entries are permanent and legally binding once posted. Reversing requires a credit note.
State machine¶
Draft → Posted → In Payment → Paid
→ Cancelled (only from Draft)
→ Reset to Draft (only from Posted, before any payment)
Payment state (separate from document state)¶
payment_state |
Meaning |
|---|---|
not_paid |
No payment registered yet |
in_payment |
Payment registered, bank not yet reconciled |
paid |
Fully settled |
partial |
Partially paid |
reversed |
Credit note applied |
The API normalises in_payment → paid in responses since from an operational standpoint the invoice is settled.
Workflow 8 — Asset Governance¶
Owner: Agent (field)
Governance layer: V2 (ov.asset stamped)
State entering: Delivery validated, serial number (stock.lot) exists
State leaving: ov.asset created and linked to SA, agent, and customer
The gap this workflow closes¶
After delivery, a physical unit (solar kit) exists at a customer site. stock.lot records the serial number but has no SA governance. ov.asset is the governance wrapper that makes the physical unit a governed object.
Models involved¶
| Model | Role | Governance |
|---|---|---|
ov.asset |
The governed physical asset | ✅ Stamped (x_sa_id, x_actor_id) |
stock.lot |
The serial number identity | 🟡 Not stamped (linked via ov.asset) |
res.partner |
Customer who holds the asset | ✅ V3 |
ov.serviced_account |
SA governing the asset | ⚙️ V1 |
API endpoints¶
POST /api/assets
Authorization: Bearer <agent_jwt>
X-SA-ID: <sa_id>
Body:
{
"name": "Solar Kit 200W",
"asset_code": "SN-2026-001",
"product_id": 22,
"partner_id": 101,
"installation_date": "2026-04-23",
"lot_id": <stock_lot_id> ← links serial number to asset
}
→ ov.asset created
→ x_sa_id + x_actor_id stamped
→ linked to customer (partner_id) and serial number (lot_id)
GET /api/assets ← list assets (SA-scoped via x_sa_id)
GET /api/assets/<id> ← fetch single asset
PUT /api/assets/<id> ← update asset (swap, repair note)
Data state after asset creation¶
ov.asset id=7
name = "Solar Kit 200W"
asset_code = "SN-2026-001"
x_sa_id = 42 ← SA stamp
x_actor_id = Jean ← agent stamp
partner_id = Marie (101) ← customer holding the asset
lot_id = SN-2026-001 ← serial number link
installation_date= 2026-04-23
state = active
stock.lot name="SN-2026-001"
product_id = Solar Kit 200W
(no x_sa_id) ← gap: serial number not directly governed
Gap¶
stock.lot does not carry x_sa_id. The serial number's SA context is only knowable by traversing stock.lot → ov.asset → x_sa_id. This is an indirect join. V3 ov.sa_asset_assignment will make this direct.
Workflow 9 — Payment Registration¶
Owner: Agent or Finance
Governance layer: V2 gap — account.payment NOT yet stamped
State entering: account.move in posted
State leaving: Invoice payment_state = paid
Models involved¶
| Model | Role | Governance |
|---|---|---|
account.payment |
The payment record | 🟡 NOT STAMPED |
account.journal |
Bank / Cash / Mobile Money journal | ⚙️ Config |
account.move |
Reconciliation journal entry auto-created | ⚙️ Auto |
abs.payment.attempt |
Custom mobile money attempt tracker | ✅ Stamped |
payment.request |
Custom payment request object | 🔴 NOT GOVERNED |
Payment state machine¶
Draft → Posted → Reconciled with invoice → Invoice state → Paid
API endpoint¶
POST /api/orders/<id>/register-payment
Authorization: Bearer <agent_jwt>
Body:
{
"amount": 500.00,
"journal_id": 6, ← Bank, Cash, or Mobile Money journal
"payment_source": "mobile_money", ← hint for auto-selecting payment method
"payment_date": "2026-04-23",
"memo": "Customer cash payment",
"invoice_id": 33 ← optional: target specific invoice
}
→ account.payment created and posted
→ auto-reconciled with account.move
→ invoice.amount_residual decremented
→ invoice.payment_state → in_payment (normalised → paid in API response)
Smart payment auto-resolution¶
The endpoint handles these scenarios automatically:
| Scenario | Behaviour |
|---|---|
| No posted invoice exists | Auto-creates and posts invoice, then registers payment |
| Unvalidated delivery exists | Blocks with error — call /approve first |
| Order already fully paid | Returns 409 with current invoice state |
| Multiple invoices on order | Uses invoice_id param to target specific invoice |
Gap¶
account.payment has no x_sa_id. You cannot query "total cash collected by SA 42 this month" or "all payments made by agent Jean". This is paired with the account.move gap — both must be fixed together for complete financial SA visibility.
GET /api/customers/<id>/payments ← payment history per customer
GET /api/customers/<id>/dashboard ← summary: outstanding, paid, overdue
Workflow 10 — Subscription (Recurring Billing)¶
Owner: Finance / System
Governance layer: V2 (abs.subscription stamped)
State entering: Initial sale confirmed and paid (or agreed at contract time)
State leaving: Recurring invoices generated automatically per schedule
Use case¶
After a customer pays a deposit for a solar kit, they may enter a service or financing agreement requiring monthly payments. This is the subscription workflow.
Models involved¶
| Model | Role | Governance |
|---|---|---|
abs.subscription |
Custom subscription record | ✅ Stamped |
account.move |
Recurring invoices generated | 🔴 NOT STAMPED |
res.partner |
Customer on the subscription | ✅ V3 |
sale.order |
Origin order (optional link) | ✅ Stamped |
ir.cron |
Scheduled job that generates invoices | ⚙️ System |
API endpoints¶
GET /api/subscriptions ← list subscriptions (SA-scoped)
GET /api/subscriptions/<id> ← fetch single subscription
GET /api/customer/subscriptions ← customer portal: my subscriptions
GET /api/customer/subscriptions/<id> ← single subscription detail
POST /api/customer/subscriptions/<id>/pause ← pause recurring billing
POST /api/customer/subscriptions/<id>/resume ← resume billing
POST /api/customer/subscriptions/<id>/cancel ← cancel subscription
Subscription state machine¶
Draft → Active → Paused → Active (resumed)
→ Cancelled
→ Expired (end date reached)
Recurring invoice generation¶
ir.cron (daily/weekly/monthly)
→ abs.subscription records where next_invoice_date <= today AND state = active
→ account.move (Invoice) auto-created per subscription
→ next_invoice_date advanced
→ customer notified
Gap¶
Recurring invoices generated from subscriptions are account.move records with no x_sa_id. The abs.subscription is stamped but the invoices it generates are not. Full financial SA visibility requires both the subscription AND its generated invoices to be governed.
Complete Governance Coverage Matrix¶
Workflow Object Status V3 Phase Blocker if unresolved
─────────────────────────────────────────────────────────────────────────────
0 stock.warehouse ⚙️ Config n/a Config, no stamp needed
0 stock.location ⚙️ Config n/a Config, no stamp needed
0 stock.quant ⚙️ System n/a System-managed ledger
0 stock.picking (rcpt) 🟡 NONE Phase 2 Receipts per SA invisible
0 stock.lot (received) 🟡 NONE Phase 2 Serial origin SA unknown
0 stock.rule ⚙️ Config n/a Reorder rules, no stamp
1 product.template ✅ V2 Phase 2 SA-scoped catalogue gaps
1 ov.bop.sheet ✅ Custom n/a None
2 sale.order ✅ V2 Phase 2 Order list SA-scoped via stamp
3 sale.order (approval) ✅ Custom n/a None
4 sale.order (confirm) ✅ V2 Phase 2 Same as workflow 2
5 stock.picking (deliv.) 🟡 NONE Phase 2 Delivery reporting invisible
5 stock.lot (delivered) 🟡 NONE Phase 2 Serial tracking invisible
5 stock.move ⚙️ n/a n/a Inherits from picking
6 account.move 🔴 NONE Phase 5 Revenue per SA invisible ← MOST CRITICAL
7 account.move (posted) 🔴 NONE Phase 5 Same as workflow 6
8 ov.asset ✅ V2 Phase 4 Asset list SA-scoped via stamp
8 stock.lot 🟡 NONE Phase 2 Indirect SA join only
9 account.payment 🟡 NONE Phase 5 Collections per SA invisible
9 abs.payment.attempt ✅ V2 future Mobile money tracking OK
9 payment.request 🔴 NONE Phase 5 Custom object ungoverned
10 abs.subscription ✅ V2 future Subscription list SA-scoped
10 account.move (recur.) 🔴 NONE Phase 5 Recurring revenue invisible
The Three Gaps That Break Coherent Reporting¶
Gap 1 — account.move (Invoices) 🔴¶
Every workflow generates an invoice. None of them are SA-governed.
Without this: - Cannot answer "total revenue for SA Togo this quarter" - Cannot answer "outstanding receivables per SA" - Cannot produce per-SA P&L - Finance reporting and PA-facing invoice lists are impossible
Fix: ov.sa_invoice_assignment — V3-first (no existing stamps to migrate). Create the association model, dual-write on invoice creation, move invoice list reads to the association table.
Gap 2 — stock.picking (Deliveries) 🟡¶
Every physical product sale generates a delivery. None are SA-governed.
Without this: - Cannot answer "how many kits did SA Togo deploy this month" - Cannot answer "which delivery brought serial number SN-2026-001 to the field" - Operational delivery performance per SA is invisible
Fix: ov.sa_delivery_assignment — copy the V3 pattern from ov.sa_customer_assignment.
Gap 3 — account.payment (Payments) 🟡¶
Every payment event is ungoverned.
Without this: - Cannot answer "total cash collected by SA Togo this week" - Cannot answer "all payments made by agent Jean" - Mobile money reconciliation per SA is impossible without manual joins
Fix: ov.sa_payment_assignment — paired with the invoice fix in Phase 5.
Achieving a Coherent Odoo Workflow¶
For all 10 workflows to be coherent — meaning SA-scoped at every step — the following association models must exist:
V3 Phase 1 ✅ DONE (abs_connector 18.0.1.1.2)
ov.sa_customer_assignment res.partner → contacts.py
V3 Phase 2 ✅ DONE (abs_connector 18.0.1.1.3)
ov.sa_sale_order_assignment sale.order → order_controller.py dual-write on POST /api/quotations
ov.sa_delivery_assignment stock.picking → stock_api.py dual-write on POST /api/stock/deliveries/<id>/validate
V3 Phase 3 ⬜ SKIPPED (CRM excluded from scope)
ov.sa_lead_assignment crm.lead → crm.py
V3 Phase 4 ✅ DONE (abs_connector 18.0.1.1.3)
ov.sa_asset_assignment ov.asset → asset_api.py dual-write on POST /api/assets
V3 Phase 5 ✅ DONE (abs_connector 18.0.1.1.3)
ov.sa_invoice_assignment account.move → invoice_api.py dual-write on confirm; V3-primary list domain
ov.sa_payment_assignment account.payment → order_controller.py dual-write on POST /api/orders/<id>/register-payment
V3 Phase 6 ✅ DONE (abs_connector 18.0.1.1.3)
ov.sa_ticket_assignment helpdesk.ticket → tickets.py dual-write on POST /api/tickets
Once all six phases are complete, every object in the sale-to-cash flow can be queried, reported, and governed exclusively within its SA context — without relying on indirect joins through x_sa_id stamps on native Odoo objects.
API Surface — Full Sales Workflow Reference¶
For frontend developers. Every endpoint below is implemented and live. Auth column shows what token is required. SA Governed shows whether the response is automatically filtered to the calling agent's Service Accounts.
Auth tokens: -
JWT— employee Bearer token (Authorization: Bearer <token>) -API Key— internal header (X-API-KEY: <key>) -JWT | API Key— either is accepted -Customer JWT— customer-facing Bearer token (different issuer)SA Governed legend: - ✅ V3 — governed by
ov.sa_*_assignmentassociation model (full history) - 🟢 V2 — governed byx_sa_idstamp on the Odoo record - 🟡 Partial — scoped via indirect join (e.g.sale_id.x_sa_id) - 🔴 None — no SA filter applied; all records visible to authenticated user
Warehouse & Stock (stock_api.py)¶
GET /api/warehouses¶
List all warehouses with their named stock locations.
Auth: JWT | API Key
SA Governed: 🔴 None (warehouses are company-level)
Response:
{
"success": true,
"total": 2,
"warehouses": [
{
"id": 1,
"name": "Main Warehouse",
"short_name": "WH",
"company": { "id": 1, "name": "Omnivoltaic" },
"locations": {
"input": { "id": 8, "name": "WH/Input" },
"stock": { "id": 9, "name": "WH/Stock" },
"pack": { "id": 10, "name": "WH/Packing Zone" },
"output": { "id": 11, "name": "WH/Output" }
}
}
]
}
GET /api/stock/levels¶
Live on-hand stock for all products, broken down by product + location.
Auth: JWT | API Key
SA Governed: 🔴 None
Query params:
| Param | Type | Description |
|---|---|---|
product_id |
int | Filter to a single product |
warehouse_id |
int | Filter to one warehouse |
page |
int | Default 1 |
limit |
int | Default 50, max 200 |
Response:
{
"success": true,
"total": 45,
"levels": [
{
"product": { "id": 12, "name": "Solar Kit 100W" },
"location": { "id": 9, "name": "WH/Stock" },
"qty_on_hand": 20.0,
"qty_reserved": 5.0,
"qty_available": 15.0
}
]
}
GET /api/stock/levels/<product_id>¶
Full stock detail for a single product.product, broken down by location, plus forecasted quantity.
Auth: JWT | API Key
SA Governed: 🔴 None
Query params: warehouse_id (optional)
Response:
{
"success": true,
"product": {
"id": 12,
"name": "Solar Kit 100W",
"default_code": "SK-100W",
"tracking": "serial"
},
"by_location": [
{
"location": { "id": 9, "name": "WH/Stock" },
"qty_on_hand": 20.0,
"qty_reserved": 5.0,
"qty_available": 15.0
}
],
"totals": {
"qty_on_hand": 20.0,
"qty_reserved": 5.0,
"qty_available": 15.0,
"qty_forecasted": 18.0
}
}
qty_forecasted= on_hand + confirmed incoming − pending outgoing orders. Useful for "can I promise this product to a customer?"
GET /api/stock/lots¶
List all serial numbers / lot numbers, filterable.
Auth: JWT | API Key
SA Governed: 🔴 None
Query params:
| Param | Type | Description |
|---|---|---|
product_id |
int | Filter by product |
serial |
string | Exact match on serial name |
q |
string | Partial name search |
page |
int | Default 1 |
limit |
int | Default 20, max 100 |
Response:
{
"success": true,
"total": 312,
"lots": [
{
"id": 55,
"serial": "SN-2025-00123",
"ref": null,
"product": { "id": 12, "name": "Solar Kit 100W" },
"qty_on_hand": 1.0
}
]
}
GET /api/stock/lots/<lot_name>¶
Locate a specific serial number by exact name. Returns the lot plus every internal location it currently has stock in.
Auth: JWT | API Key
SA Governed: 🔴 None
Example: GET /api/stock/lots/SN-2025-00123
Response:
{
"success": true,
"lot": {
"id": 55,
"serial": "SN-2025-00123",
"ref": null,
"product": { "id": 12, "name": "Solar Kit 100W" },
"qty_on_hand": 1.0
},
"locations": [
{
"location": { "id": 9, "name": "WH/Stock" },
"qty_on_hand": 1.0,
"qty_reserved": 0.0,
"qty_available": 1.0
}
]
}
Returns
404if the serial does not exist.
GET /api/stock/deliveries¶
List outbound delivery pickings, SA-scoped to the calling employee's service accounts.
Auth: JWT (required — SA scope enforced)
SA Governed: 🟡 Partial (via sale_id.x_sa_id)
Query params:
| Param | Type | Description |
|---|---|---|
state |
string | draft | waiting | confirmed | assigned | done | cancel. Default: active (not done/cancelled) |
warehouse_id |
int | Filter by warehouse |
sale_order_id |
int | Filter by sale order |
page |
int | Default 1 |
limit |
int | Default 20, max 100 |
Response:
{
"success": true,
"total": 14,
"deliveries": [
{
"id": 201,
"name": "WH/OUT/00201",
"state": "assigned",
"scheduled_date": "2025-06-15 08:00:00",
"date_done": null,
"origin": "S00042",
"partner": { "id": 88, "name": "John Doe" },
"sale_order": { "id": 42, "name": "S00042" },
"location_from": { "id": 9, "name": "WH/Stock" },
"location_to": { "id": 14, "name": "Customers" },
"move_count": 3
}
]
}
GET /api/stock/deliveries/<id>¶
Single delivery with full move lines and assigned serials.
Auth: JWT | API Key
Response:
{
"success": true,
"delivery": {
"id": 201,
"name": "WH/OUT/00201",
"state": "assigned",
"note": null,
"lines": [
{
"id": 501,
"product": { "id": 12, "name": "Solar Kit 100W" },
"qty_ordered": 2.0,
"qty_done": 0.0,
"uom": { "id": 1, "name": "Units" },
"state": "confirmed",
"lot_ids": [55, 56]
}
]
}
}
POST /api/stock/deliveries/<id>/validate¶
Validate a delivery — confirms physical dispatch. Automatically enables invoice creation for invoice_policy = delivery products.
Auth: JWT (required)
Content-Type: application/json
Request body:
{
"force_backorder": false,
"lines": [
{ "move_id": 501, "qty_done": 2.0, "lot_id": 55 }
]
}
linesis optional. If omitted, all moves are set to their ordered quantity and validated immediately.
force_backorder: true— allows partial validation; creates a backorder for remaining quantity.
Response:
{
"success": true,
"message": "Delivery validated",
"delivery": { "id": 201, "name": "WH/OUT/00201", "state": "done", "date_done": "2025-06-15 10:23:00" }
}
GET /api/stock/receipts¶
List inbound goods receipt pickings.
Auth: JWT | API Key
SA Governed: 🔴 None
Query params:
| Param | Type | Description |
|---|---|---|
state |
string | Same states as deliveries. Default: active |
warehouse_id |
int | Filter by warehouse |
partner_id |
int | Filter by supplier |
page |
int | Default 1 |
limit |
int | Default 20, max 100 |
Response:
{
"success": true,
"total": 8,
"receipts": [
{
"id": 301,
"name": "WH/IN/00301",
"state": "assigned",
"scheduled_date": "2025-06-10 09:00:00",
"date_done": null,
"origin": "PO00012",
"supplier": { "id": 5, "name": "Supplier Co." },
"location_from": { "id": 6, "name": "Vendors" },
"location_to": { "id": 9, "name": "WH/Stock" },
"move_count": 5
}
]
}
GET /api/stock/receipts/<id>¶
Single receipt with full move lines and lot/serial assignments.
Auth: JWT | API Key
Response:
{
"success": true,
"receipt": {
"id": 301,
"name": "WH/IN/00301",
"state": "done",
"date_done": "2025-06-10 14:05:00",
"origin": "PO00012",
"supplier": { "id": 5, "name": "Supplier Co." },
"lines": [
{
"id": 601,
"product": { "id": 12, "name": "Solar Kit 100W" },
"qty_ordered": 10.0,
"qty_done": 10.0,
"uom": { "id": 1, "name": "Units" },
"state": "done",
"lot_ids": [55, 56, 57, 58, 59, 60, 61, 62, 63, 64]
}
]
}
}
GET /api/stock/movements¶
Complete stock movement history for a product, serial, or location.
Auth: JWT | API Key
SA Governed: 🔴 None
Query params:
| Param | Type | Description |
|---|---|---|
product_id |
int | Filter by product |
lot_id |
int | Filter by serial/lot ID |
location_id |
int | Filter by source or destination location |
picking_type |
string | outgoing | incoming | internal |
date_from |
string | ISO date e.g. 2025-01-01 |
date_to |
string | ISO date e.g. 2025-12-31 |
page |
int | Default 1 |
limit |
int | Default 20, max 100 |
Response:
{
"success": true,
"total": 47,
"movements": [
{
"id": 701,
"date": "2025-06-15 10:23:00",
"reference": "WH/OUT/00201",
"type": "outgoing",
"product": { "id": 12, "name": "Solar Kit 100W" },
"qty_ordered": 2.0,
"qty_done": 2.0,
"uom": { "id": 1, "name": "Units" },
"from_location": { "id": 9, "name": "WH/Stock" },
"to_location": { "id": 14, "name": "Customers" },
"picking": { "id": 201, "name": "WH/OUT/00201" },
"lot_ids": [55, 56]
}
]
}
POST /api/stock/receive¶
Create a goods receipt (inbound picking) from a supplier, optionally validate it immediately.
Auth: JWT
Content-Type: application/json
Request body:
{
"warehouse_id": 1,
"partner_id": 5,
"validate": true,
"lines": [
{ "product_id": 12, "qty": 10, "lot_name": "SN-2025-00123" },
{ "product_id": 12, "qty": 1, "lot_name": "SN-2025-00124" }
]
}
validate: true— confirms the receipt immediately and adds stock to inventory.
lot_name— creates a new serial/lot if it does not already exist.
Response: 201 Created
{
"success": true,
"message": "Goods receipt created and validated",
"picking": { "id": 302, "name": "WH/IN/00302", "state": "done" }
}
POST /api/stock/adjustment¶
Manual inventory count correction (Odoo 17+ approach via stock.quant).
Auth: JWT
Content-Type: application/json
Request body:
{
"warehouse_id": 1,
"reason": "Physical count June 2025",
"lines": [
{ "product_id": 12, "lot_id": 55, "quantity_counted": 1.0 },
{ "product_id": 13, "quantity_counted": 45.0 }
]
}
POST /api/stock/transfer¶
Create an internal stock transfer between two locations.
Auth: JWT
Content-Type: application/json
Request body:
{
"from_location_id": 9,
"to_location_id": 22,
"validate": true,
"lines": [
{ "product_id": 12, "qty": 5, "lot_id": 55 }
]
}
Products (product_management.py)¶
| Method | Endpoint | Description | Auth | SA |
|---|---|---|---|---|
GET |
/api/products |
List products. Filter: x_pu_category, product_type, q (search), page, limit |
JWT\|API Key |
🟢 V2 |
POST |
/api/products |
Create a product | JWT\|API Key |
🟢 V2 |
GET |
/api/products/<id> |
Single product detail | JWT\|API Key |
🟢 V2 |
PUT |
/api/products/<id> |
Update a product | JWT\|API Key |
🟢 V2 |
DELETE |
/api/products/<id> |
Archive a product | JWT\|API Key |
🟢 V2 |
GET |
/api/products/categories |
List product categories | JWT\|API Key |
🟢 V2 |
GET |
/api/products/<ref>/bom |
Get Bill of Products for a product | JWT\|API Key |
🟢 V2 |
POST |
/api/products/<ref>/bom |
Add a BOM line | JWT\|API Key |
🟢 V2 |
PUT |
/api/products/<ref>/bom/<line_id> |
Update a BOM line | JWT\|API Key |
🟢 V2 |
DELETE |
/api/products/<ref>/bom/<line_id> |
Remove a BOM line | JWT\|API Key |
🟢 V2 |
Sales Orders (order_controller.py)¶
| Method | Endpoint | Description | Auth | SA |
|---|---|---|---|---|
POST |
/api/quotations |
Create a new quotation (draft sale order) | JWT |
🟢 V2 |
GET |
/api/orders |
List orders. Filter: state, partner_id, page, limit |
JWT |
🟢 V2 |
GET |
/api/orders/<id> |
Single order detail with lines and session | JWT |
🟢 V2 |
PUT |
/api/orders/<id> |
Update quotation lines or partner | JWT |
🟢 V2 |
POST |
/api/orders/<id>/add-lines |
Add product lines to a draft order | JWT |
🟢 V2 |
POST |
/api/orders/<id>/send |
Send quotation email to customer | JWT |
🟢 V2 |
GET |
/api/orders/<id>/proforma-pdf |
Download pro-forma PDF | JWT |
🟢 V2 |
POST |
/api/orders/<id>/request-approval |
Submit order for manager approval | JWT |
V1 |
POST |
/api/orders/<id>/approve |
Manager approves the order | JWT (staff) |
V1 |
POST |
/api/orders/<id>/reject |
Manager rejects the order | JWT (staff) |
V1 |
POST |
/api/orders/<id>/confirm |
Confirm order → triggers delivery creation | JWT |
🟢 V2 |
POST |
/api/orders/<id>/finalize |
Finalize a confirmed order | JWT |
🟢 V2 |
Invoicing (order_controller.py + invoice_api.py)¶
⚠️
account.movehas no SA stamp. All invoice endpoints return invoices visible to the authenticated user regardless of SA. SA scoping is a Phase 5 governance target.
| Method | Endpoint | Description | Auth | SA |
|---|---|---|---|---|
POST |
/api/orders/<id>/invoice |
Create invoice from order | JWT |
🔴 None |
GET |
/api/orders/<id>/invoices |
List invoices linked to an order | JWT |
🔴 None |
POST |
/api/orders/<id>/invoices/<inv_id>/confirm |
Post (confirm) a draft invoice | JWT |
🔴 None |
POST |
/api/orders/<id>/credit-note |
Reverse a posted invoice (credit note) | JWT |
🔴 None |
GET |
/api/invoices |
List all customer invoices. Filter: state, partner_id, date_from, date_to, page, limit |
JWT |
🔴 None |
GET |
/api/invoices/<id> |
Single invoice with all lines | JWT |
🔴 None |
POST |
/api/invoices/<id>/send |
Send invoice by email to customer | JWT |
🔴 None |
GET |
/api/invoices/<id>/pdf |
Download invoice PDF (base64 encoded) | JWT |
🔴 None |
GET |
/api/customers/<id>/invoices |
All invoices for a specific customer | JWT |
🔴 None |
Invoice states: draft → posted → in_payment → paid (or cancel)
Credit note flow:
POST /api/orders/<id>/credit-note
Body: { "invoice_id": 99, "reason": "Customer returned goods", "date": "2025-06-20" }
Payments (payments.py)¶
⚠️
account.paymenthas no SA stamp. SA-scoped payment reporting is a Phase 5 target.
| Method | Endpoint | Description | Auth | SA |
|---|---|---|---|---|
POST |
/api/orders/<id>/register-payment |
Register payment against a confirmed invoice | JWT |
🟡 Partial |
GET |
/api/payments |
List payments | JWT |
🟡 Partial |
GET |
/api/customers/<id>/payments |
All payments for a customer | JWT |
🟡 Partial |
GET |
/api/customers/<id>/dashboard |
Customer summary: orders, invoices, payments, assets | JWT |
Mixed |
Assets (asset_api.py)¶
| Method | Endpoint | Description | Auth | SA |
|---|---|---|---|---|
POST |
/api/assets |
Register a deployed asset (solar kit installed at site) | JWT |
🟢 V2 |
GET |
/api/assets |
List assets. Filter: partner_id, sa_id, state, page, limit |
JWT |
🟢 V2 |
Subscriptions (subscription_api.py)¶
| Method | Endpoint | Description | Auth | SA |
|---|---|---|---|---|
GET |
/api/subscriptions |
List subscriptions. Filter: partner_id, state, page, limit |
JWT |
🟢 V2 |
POST |
/api/subscription/purchase |
Create a new subscription | JWT |
🟢 V2 |
GET |
/api/subscription/status/<code> |
Get status of a subscription by code | JWT |
🟢 V2 |
POST |
/api/customer/subscriptions/<id>/pause |
Pause a subscription | Customer JWT |
🟢 V2 |
POST |
/api/customer/subscriptions/<id>/cancel |
Cancel a subscription | Customer JWT |
🟢 V2 |
Helpdesk Tickets (tickets.py)¶
| Method | Endpoint | Description | Auth | SA |
|---|---|---|---|---|
POST |
/api/tickets |
Create a new helpdesk ticket | JWT |
V1 |
GET |
/api/tickets |
List tickets. Filter: partner_id, stage_id, page, limit |
JWT |
V1 |
GET |
/api/tickets/<id> |
Single ticket detail | JWT |
V1 |
PUT |
/api/tickets/<id> |
Update ticket subject, description, priority, or stage | JWT |
V1 |
POST |
/api/tickets/<id>/close |
Close a ticket with optional resolution note | JWT |
V1 |
Common Response Conventions¶
All endpoints return JSON. Errors always include "success": false and an "error" string.
Pagination (all list endpoints):
{ "success": true, "total": 312, "page": 1, "limit": 20, "items": [...] }
HTTP status codes:
| Code | Meaning |
|---|---|
200 |
OK |
201 |
Created (POST that creates a resource) |
400 |
Bad request — missing field, invalid state transition |
401 |
Missing or invalid token |
404 |
Record not found (or not visible to the calling agent's SA) |
500 |
Unexpected server error — check Odoo logs |
CORS: All endpoints support OPTIONS preflight. Headers Authorization, X-API-KEY, and X-SA-ID are allowed.