Odoo Sales Order as Contract¶
Purpose: Define the Sales Order as a contract envelope with line-level semantics for asset-service binding.
Audience: Developers, operations teams, ABS integration engineers.
Design Status: B2C Validated Model (v2.0)
A. Foundational Principle¶
Odoo as SPOT for Physical Assets and Contractual Bindings¶
Odoo serves as the Single Point of Truth (SPOT) for:
- Physical asset identity
- Physical movement history
- Serial-number lifecycle
- Commercial contracts and amendments
Core Odoo Models¶
stock.lot(serial number) - Canonical representation of a specific, non-fungible assetsale.order- Canonical representation of a commercial contract over timestock.move.line- Record of which serial fulfilled which contractual obligationabs.contract- Computed state of service commitments (likestock.quantfor inventory)
ABS consumes bindings authored and persisted in Odoo; it never infers bindings independently.
B. "Swap Privilege" as Composite Commercial Right¶
"Swap privilege" is not a simple service—it decomposes into three implied bindings:
B1. Asset-Class Compatibility¶
The motorbike model implies: * Battery form factor * Electrical interface * Safety envelope
This is product-model logic, not runtime logic, and belongs in: * Product definition / category rules * Service terms metadata
B2. Serial-Scoped Entitlement¶
A specific motorbike serial number entitles: * The holder * During a defined period * To access a defined battery circulation pool * And a defined swap station network
This service–serial binding must be: * Explicit * Queryable * Time-bounded
B3. Ownership vs Access (Rental Case)¶
In rental scenarios: * No sale occurs * Asset remains on company balance sheet * Physical issuance occurs via contract
In Odoo terms:
* Serial exists independently of sale
* sale.order expresses right of use, not transfer of ownership
* Physical movement may occur without revenue recognition
Principle: Serial numbers are asset identities, not sale artifacts.
C. Multi-Service Binding to One Serial¶
One serial → many services is the norm, not an edge case.
Examples bound to the same serial: * Warranty * Anti-theft / tracking * Swap privilege * Extended maintenance * Insurance-like coverage
Odoo Implementation¶
- Services are
sale.order.lineentries - Binding is per line, per serial
abs.contractrecords are one per (service × serial), not one per order- Multiple contracts of the same type ARE allowed (different validity periods)
D. Sales Order as Time-Persistent Contract¶
D1. Sales Order = Contract Envelope¶
sale.order persists and anchors:
* Commercial intent
* Amendments
* References
It does not natively enforce time or recurrence.
D2. Contract Semantics Are Line-Level¶
Each sale.order.line has its own:
* Effective date
* Duration
* Termination logic
Warranty, subscription, rental, and privilege cannot be safely modeled as order-level attributes.
D3. Selective Use of Contract/Subscription Constructs¶
Use Odoo's contract/subscription constructs only where temporal behavior (recurrence, renewal, suspension) exists.
Do not force all service bindings into the subscription model.
E. Two Types of Sales Orders¶
E1. Bundle SO (Physical Product + Services)¶
Contains:
* ONE physical product line (serial-tracked) at order_line[0]
* Zero or more service product lines
Serial flows from physical line through delivery to service contracts.
E2. Service-Only SO¶
Contains:
* ONLY service product lines
* MUST reference original Bundle SO via source_so_id field
* target_lot_id is computed from source_so_id
Used when customer purchases additional services for an asset they already own.
Key constraint: Service-only SO is BASED on the original Bundle SO, enabling:
- Direct FK to original transaction
- Temporal anchor for eligibility rules (source_so_id.date_order)
- Implicit ownership inheritance (source_so_id.partner_id)
- Audit trail of service purchase lineage
F. Serial Number Flow Architecture¶
F1. Key Principle: Service Products Have NO Serial Field¶
Service products are intangible. They do NOT use stock tracking methods.
IMPORTANT: There is no service_lot_id field on service SO lines.
F2. Serial Flow for Bundle SO¶
sale.order.line (physical)
↓ confirmation + delivery
stock.move
↓
stock.move.line
↓
lot_id (the actual serial)
↓ computed field
sale.order.line.fulfilled_lot_id
↓ contract creation
abs.contract.physical_lot_id
F3. Serial Flow for Service-Only SO¶
sale.order.target_lot_id (pre-existing serial)
↓ validation (ownership check)
↓ contract creation
abs.contract.physical_lot_id
G. SO Line Computed Field: fulfilled_lot_id¶
This computed field retrieves the serial from completed delivery:
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
fulfilled_lot_id = fields.Many2one('stock.lot',
compute='_compute_fulfilled_lot',
store=True,
help='Serial number assigned from delivery (stock.move.line)'
)
@api.depends('move_ids.move_line_ids.lot_id')
def _compute_fulfilled_lot(self):
for line in self:
# Traverse: SO line → stock.move → stock.move.line → lot_id
move_lines = line.move_ids.mapped('move_line_ids')
lots = move_lines.filtered(lambda ml: ml.lot_id).mapped('lot_id')
line.fulfilled_lot_id = lots[0] if lots else False
Why this matters:
- Uses Odoo's native sale.order.line → stock.move → stock.move.line relationships
- Serial becomes available AFTER delivery, not at draft time
- Computed field provides efficient query path
H. Service-Only SO: source_so_id (Primary) and target_lot_id (Computed)¶
For orders containing ONLY service products:
class SaleOrder(models.Model):
_inherit = 'sale.order'
# Service-only SO MUST reference original Bundle SO
source_so_id = fields.Many2one('sale.order',
string='Original Purchase Order',
help='The Bundle SO that originally sold the physical asset. '
'Required for service-only orders.',
domain="[('state', 'in', ['sale', 'done'])]"
)
# Computed from source_so_id
target_lot_id = fields.Many2one('stock.lot',
compute='_compute_target_lot',
store=True,
string='Target Asset Serial'
)
is_service_only = fields.Boolean(
compute='_compute_is_service_only',
store=True
)
@api.depends('order_line.product_id.type')
def _compute_is_service_only(self):
for order in self:
has_physical = any(
line.product_id.type == 'product'
for line in order.order_line
)
order.is_service_only = not has_physical and bool(order.order_line)
@api.depends('source_so_id', 'source_so_id.order_line.fulfilled_lot_id')
def _compute_target_lot(self):
for order in self:
if order.source_so_id:
physical_line = order.source_so_id.order_line.filtered(
lambda l: l.fulfilled_lot_id
)
order.target_lot_id = physical_line[0].fulfilled_lot_id if physical_line else False
else:
order.target_lot_id = False
I. Service Product Configuration¶
I.1. Pricing Integration¶
Requirement: Service products participate in standard Odoo pricelist regime.
Implementation: Service products are standard product.product records with:
- Full product.pricelist.item support
- Currency conversion
- Customer-specific pricing
- Volume/time-based discounts
I.2. Product Category Structure¶
Required hierarchy:
All Products
├── Physical Goods
│ ├── Motorcycles
│ ├── Batteries
│ └── Accessories
└── Service Products
├── Warranties
├── Swap Privileges
├── Maintenance Plans
└── Subscriptions
I.3. Service-Product Compatibility and Eligibility¶
class ProductProduct(models.Model):
_inherit = 'product.product'
compatible_product_ids = fields.Many2many(
'product.product',
'product_service_compatibility_rel',
'service_id', 'product_id',
string='Compatible Products',
help='Physical products this service can be sold with',
domain="[('type', '=', 'product'), ('tracking', '=', 'serial')]"
)
service_transferable = fields.Boolean(
default=False,
help='If True, service follows the asset regardless of owner. '
'If False, only original purchaser can claim service.'
)
# Purchase mode constraint
service_purchase_mode = fields.Selection([
('bundle_only', 'Bundle SO Only'),
('service_only', 'Service-Only SO Only'),
('both', 'Both Modes Allowed')
], default='both',
help='Where can this service be purchased?'
)
# Temporal eligibility
eligible_max_days_after_delivery = fields.Integer(
default=0,
help='Max days after original delivery to purchase. 0 = no limit.'
)
# Prerequisite service
requires_prior_service_id = fields.Many2one(
'product.product',
string='Requires Prior Service',
help='Customer must have active/fulfilled contract of this type first'
)
Example Service Product Configuration¶
| Product | Mode | Max Days | Requires Prior |
|---|---|---|---|
| E3Pro Warranty (New) | bundle_only | - | - |
| E3Pro Extended Warranty | service_only | 30 | E3Pro Warranty (New) |
| E3Pro Swap Service | both | 0 | - |
| E3Pro Swap Renewal | service_only | 0 | E3Pro Swap Service |
J. SO Validation Rules¶
Rule 1: Bundle SO - Single Physical Product¶
@api.constrains('order_line')
def _check_bundle_so_single_physical(self):
"""Bundle SO must have exactly ONE physical serial-tracked product."""
if self.is_service_only:
return # Not a bundle SO
service_lines = self.order_line.filtered(
lambda l: l.product_id.categ_id.complete_name.startswith('Service Products')
)
if not service_lines:
return # No services, no constraint
physical_lines = self.order_line.filtered(
lambda l: l.product_id.type == 'product'
and l.product_id.tracking == 'serial'
)
if len(physical_lines) != 1:
raise ValidationError(
f'Bundle orders with service products must contain exactly '
f'ONE physical product with serial tracking.\n\n'
f'Found: {len(physical_lines)} physical products.\n\n'
f'For multiple assets, create separate orders.'
)
Rule 2: Service-Product Type Compatibility¶
@api.constrains('order_line')
def _check_service_product_compatibility(self):
"""Service products must be compatible with physical product."""
service_lines = self.order_line.filtered(
lambda l: l.product_id.categ_id.complete_name.startswith('Service Products')
)
if not service_lines:
return
# For Bundle SO: get physical product
# For Service-only SO: get product from target_lot_id
if self.is_service_only:
if not self.target_lot_id:
return
physical_product = self.target_lot_id.product_id
else:
physical_line = self.order_line.filtered(
lambda l: l.product_id.type == 'product'
and l.product_id.tracking == 'serial'
)
if not physical_line:
return
physical_product = physical_line[0].product_id
for svc_line in service_lines:
compatible = svc_line.product_id.compatible_product_ids
if compatible and physical_product not in compatible:
raise ValidationError(
f'Service "{svc_line.product_id.name}" is not compatible '
f'with "{physical_product.name}".'
)
Rule 3: Service-Only SO - Ownership & Temporal Validation (B2C)¶
@api.constrains('source_so_id', 'partner_id', 'order_line')
def _check_service_only_so_rules(self):
"""B2C: Validate service-only SO against original purchase."""
if not self.is_service_only:
return
# Rule 3a: Must reference original SO
if not self.source_so_id:
raise ValidationError(
'Service-only orders must reference the Original Purchase Order.\n\n'
'Select the order that originally sold the physical asset.'
)
# Rule 3b: Ownership continuity
if self.partner_id != self.source_so_id.partner_id:
raise ValidationError(
f'Service-only orders must be for the same customer as the '
f'original purchase.\n\n'
f'Original customer: {self.source_so_id.partner_id.name}\n'
f'Current customer: {self.partner_id.name}\n\n'
f'For gifts, use a service voucher instead.'
)
# Rule 3c: Temporal eligibility
original_date = self.source_so_id.date_order.date()
days_elapsed = (fields.Date.today() - original_date).days
for line in self.order_line:
product = line.product_id
# Purchase mode check
if product.service_purchase_mode == 'bundle_only':
raise ValidationError(
f'"{product.name}" can only be purchased with a new product.\n'
f'For existing assets, look for an "Extended" or "Renewal" version.'
)
# Time window check
max_days = product.eligible_max_days_after_delivery or 0
if max_days > 0 and days_elapsed > max_days:
raise ValidationError(
f'"{product.name}" must be purchased within {max_days} days '
f'of original purchase.\n\n'
f'Original purchase: {original_date} ({days_elapsed} days ago)'
)
# Prerequisite service check
if product.requires_prior_service_id:
prior_contract = self.env['abs.contract'].search([
('physical_lot_id', '=', self.target_lot_id.id),
('service_product_id', '=', product.requires_prior_service_id.id),
('state', 'in', ['active', 'fulfilled'])
], limit=1)
if not prior_contract:
raise ValidationError(
f'"{product.name}" requires prior purchase of '
f'"{product.requires_prior_service_id.name}".'
)
Rule 4: Service-Only SO Must Have Source SO¶
@api.constrains('is_service_only', 'source_so_id')
def _check_service_only_requires_source(self):
"""Service-only SO must specify source SO."""
if self.is_service_only and not self.source_so_id:
raise ValidationError(
'Service-only orders must reference the Original Purchase Order.\n\n'
'Which original order sold the physical asset?'
)
K. Contract Creation Trigger¶
K1. Bundle SO: After Delivery¶
class StockPicking(models.Model):
_inherit = 'stock.picking'
def _action_done(self):
res = super()._action_done()
# Check if this picking completes a Bundle SO with services
for picking in self:
sale_order = picking.sale_id
if not sale_order or sale_order.is_service_only:
continue
# Check if all pickings are done
if sale_order.picking_ids.filtered(lambda p: p.state != 'done'):
continue
# Create abs.contract records for service lines
service_lines = sale_order.order_line.filtered(
lambda l: l.product_id.categ_id.complete_name.startswith('Service Products')
)
physical_line = sale_order.order_line.filtered(
lambda l: l.fulfilled_lot_id
)
if physical_line and service_lines:
for svc_line in service_lines:
self.env['abs.contract'].create_from_so_line(
svc_line,
physical_line[0].fulfilled_lot_id
)
return res
K2. Service-Only SO: After Confirmation¶
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
res = super().action_confirm()
for order in self:
if not order.is_service_only:
continue
# Create abs.contract for each service line
for line in order.order_line:
self.env['abs.contract'].create_from_so_line(
line,
order.target_lot_id
)
return res
L. UI Enhancements¶
L1. SO Form View¶
<record id="view_order_form_service_extension" model="ir.ui.view">
<field name="name">sale.order.form.service.ext</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="is_service_only" invisible="1"/>
<field name="source_so_id"
string="Original Purchase Order"
attrs="{'invisible': [('is_service_only', '=', False)],
'required': [('is_service_only', '=', True)]}"
options="{'no_create': True}"/>
<field name="target_lot_id"
string="Target Asset Serial"
readonly="1"
attrs="{'invisible': [('is_service_only', '=', False)]}"/>
</xpath>
</field>
</record>
L2. SO Line Tree View¶
<xpath expr="//field[@name='product_id']" position="after">
<field name="fulfilled_lot_id"
string="Serial"
readonly="1"
attrs="{'invisible': [('product_id.type', '!=', 'product')]}"/>
</xpath>
M. Summary: What Changed from Previous Design¶
| Aspect | OLD (Incorrect) | NEW (Validated) |
|---|---|---|
| Service line serial field | service_lot_id on SO line |
NO field - serial comes from physical line |
| Serial lookup | Direct lot_id on SO line |
Computed fulfilled_lot_id from stock.move.line |
| Service-only SO reference | target_lot_id only |
source_so_id (FK to original SO) |
target_lot_id |
Manual input | Computed from source_so_id |
| Ownership validation | Search for original SO | Direct FK via source_so_id.partner_id |
| Temporal eligibility | Not addressed | source_so_id.date_order as anchor |
| Contract creation trigger | On SO confirmation | Bundle: after delivery; Service-only: on confirmation |
| Ownership model | Not specified | B2C: Only owner can purchase |
Related Documentation¶
- Service Bundles Intent - Bundle architecture overview
- ABS ServicePlan Integration - Odoo-ABS contract
- Odoo Lot/Serial Numbers - Physical asset identity