Skip to content

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_paymentpaid 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_*_assignment association model (full history) - 🟢 V2 — governed by x_sa_id stamp 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 404 if 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 }
  ]
}

lines is 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.move has 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: draftpostedin_paymentpaid (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.payment has 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.