Sale Order Line Documentation¶
Overview¶
The Sale Order Line module (sale.order.line) represents individual line items within a sales order. Each line contains product details, quantity, pricing, discounts, taxes, and calculated amounts. This is a child model of sale.order and handles all line-level calculations including amounts, discounts, promotions, taxes, profit margins, and delivered/invoiced quantities.
Model Information¶
Model Name: sale.order.line
Display Name: Sale Order Line
Name Field: order_id
Ordering: sequence_no, id
Features¶
- Multi-currency support (amounts calculated in order currency)
- Automatic amount calculation with discounts and promotions
- Tax calculation (tax inclusive and exclusive)
- Profit and margin tracking (estimated and actual)
- Quantity tracking (ordered, delivered, invoiced, produced)
- Stock availability checking
- UoM (Unit of Measure) support with primary and secondary units
- Integration with inventory, invoicing, and production modules
Understanding Sale Order Lines¶
What are Sale Order Lines?¶
Sale order lines are the detail records that make up a sales order. While the sale.order header contains customer information, dates, and totals, the sale.order.line records contain:
- Product Selection - What is being sold
- Quantities - How much is being sold (with UoM support)
- Pricing - Unit price and calculated amounts
- Discounts - Both percentage and fixed amount discounts
- Taxes - Tax rates and calculated tax amounts
- Tracking - Delivered, invoiced, and produced quantities
Relationship with Sale Order¶
Each sale order line:
- Belongs to exactly one sale.order (Many2One relationship)
- Cascades on delete - Lines are automatically deleted when the order is deleted
- Inherits tax type from the parent order (tax_ex or tax_in)
- Shares currency with the parent order for amount calculations
# The parent relationship
"order_id": fields.Many2One("sale.order", "Sales Order",
required=True,
on_delete="cascade",
search=True
)
Key Fields Reference¶
Product Information Fields¶
| Field | Type | Required | Description |
|---|---|---|---|
product_id |
Many2One(product) | No | Product being sold (searchable) |
description |
Text | Yes | Line description (auto-filled from product) |
uom_id |
Many2One(uom) | No | Unit of measure |
uom2_id |
Many2One(uom) | No | Secondary unit of measure |
lot_id |
Many2One(stock.lot) | No | Lot or serial number |
packaging_id |
Many2One(stock.packaging) | No | Packaging type |
Quantity Fields¶
| Field | Type | Description |
|---|---|---|
qty |
Decimal | Primary quantity ordered |
qty2 |
Decimal | Secondary quantity (for dual UoM products) |
qty_stock |
Decimal | Quantity in stock UoM |
qty_delivered |
Decimal | Quantity shipped/delivered (computed) |
qty_invoiced |
Decimal | Quantity invoiced (computed) |
qty_produced |
Decimal | Quantity produced (computed) |
qty_avail |
Decimal | Available quantity in stock (computed) |
Pricing and Amount Fields¶
| Field | Type | Scale | Description |
|---|---|---|---|
unit_price |
Decimal | 6 | Unit price per item |
discount |
Decimal | 2 | Discount percentage (e.g., 10 for 10%) |
discount_amount |
Decimal | 2 | Fixed discount amount |
amount |
Decimal | 2 | Line total after discounts (stored, computed) |
amount_cur |
Decimal | 2 | Amount in base currency (stored, computed) |
amount_discount |
Decimal | 2 | Total discount applied (computed) |
promotion_amount |
Decimal | 2 | Promotion discount applied (computed) |
Tax Fields¶
| Field | Type | Description |
|---|---|---|
tax_id |
Many2One(account.tax.rate) | Tax rate to apply |
amount_tax |
Decimal | Calculated tax amount |
amount_incl_tax |
Decimal | Amount including tax |
amount_excl_tax |
Decimal | Amount excluding tax |
Profit and Cost Fields¶
| Field | Type | Description |
|---|---|---|
cost_price |
Decimal | Unit cost price |
cost_amount |
Decimal | Total cost (cost_price * qty) |
profit_amount |
Decimal | Profit (amount - cost_amount) |
margin_percent |
Decimal | Profit margin percentage |
est_cost_amount |
Float | Estimated cost from quotation |
est_profit_amount |
Float | Estimated profit |
est_margin_percent |
Float | Estimated margin percentage |
act_cost_amount |
Float | Actual cost from tracking |
act_profit_amount |
Float | Actual profit (stored) |
act_margin_percent |
Float | Actual margin percentage |
Warehouse and Logistics Fields¶
| Field | Type | Description |
|---|---|---|
location_id |
Many2One(stock.location) | Warehouse location (internal only) |
reserve_location_id |
Many2One(stock.location) | Reservation location |
ship_method_id |
Many2One(ship.method) | Shipping method |
ship_address_id |
Many2One(address) | Shipping address |
ship_tracking |
Char | Tracking numbers (computed) |
delivery_slot_id |
Many2One(delivery.slot) | Delivery time slot |
due_date |
Date | Line due date |
Organization Fields¶
| Field | Type | Description |
|---|---|---|
sequence_no |
Integer | Line item number (for ordering) |
index |
Integer | Display index (computed from position) |
type |
Selection | Line type: "item" or "group" |
remark |
Char | Short remark |
remarks |
Text | Long remarks/notes |
notes |
Text | Additional notes |
Related Fields (Computed from Order)¶
| Field | Type | Description |
|---|---|---|
contact_id |
Many2One(contact) | Customer (from order) |
date |
Date | Order date (from order) |
user_id |
Many2One(base.user) | Sales owner (from order) |
state |
Selection | Order state (from order) |
Tracking and Analytics Fields¶
| Field | Type | Description |
|---|---|---|
track_id |
Many2One(account.track.categ) | Tracking dimension 1 |
track2_id |
Many2One(account.track.categ) | Tracking dimension 2 |
production_id |
Many2One(production.order) | Linked production order |
supplier_id |
Many2One(contact) | Supplier (for drop shipping) |
Industry-Specific Fields¶
| Field | Type | Description |
|---|---|---|
size |
Text | Product size specification |
roll_carton |
Text | Roll or carton specification |
packing_style_id |
Many2One(packing.style) | Packing style |
papercore_id |
Many2One(papercore) | Papercore specification |
gross_weight |
Decimal(2) | Gross weight per unit |
total_weight |
Decimal(2) | Total weight for line |
addons |
Many2Many(product.addon) | Product addons/accessories |
Aggregation Fields¶
| Field | Type | Description |
|---|---|---|
agg_amount |
Decimal | Sum of all line amounts |
agg_qty |
Decimal | Sum of all order quantities |
agg_act_profit |
Decimal | Sum of actual profit |
API Methods¶
1. Create Record¶
Method: create(vals, context)
Creates a new sale order line. The create method automatically triggers amount calculations and stores computed fields.
Parameters:
vals = {
"order_id": 123, # Required: Parent sale order ID
"product_id": 456, # Optional but recommended: Product ID
"description": "Product Name", # Required: Line description
"qty": 10.0, # Optional: Quantity
"uom_id": 1, # Optional: Unit of measure
"unit_price": 100.00, # Optional: Unit price
"discount": 5.0, # Optional: Discount percentage
"discount_amount": 50.00, # Optional: Fixed discount amount
"tax_id": 7, # Optional: Tax rate ID
"location_id": 8, # Optional: Warehouse location
"sequence_no": 10 # Optional: Line sequence number
}
Returns: int - New line ID
Example:
# Create a simple order line
line_id = get_model("sale.order.line").create({
"order_id": 123,
"product_id": 456,
"description": "Widget Model X",
"qty": 5.0,
"uom_id": 1,
"unit_price": 250.00,
"tax_id": 7
})
# Create line with discount
line_id = get_model("sale.order.line").create({
"order_id": 123,
"product_id": 789,
"description": "Premium Service Package",
"qty": 1.0,
"unit_price": 1000.00,
"discount": 10.0, # 10% discount
"tax_id": 7,
"sequence_no": 20
})
Behavior:
- Automatically calls function_store([id]) to calculate amounts
- Amount calculation considers qty, unit_price, discounts, and promotions
- Currency conversion happens if order currency differs from base currency
2. Write (Update) Record¶
Method: write(ids, vals, context)
Updates existing sale order line(s). Automatically recalculates amounts when pricing fields change.
Parameters:
vals = {
"qty": 15.0, # Update quantity
"unit_price": 95.00, # Update price
"discount": 7.5, # Update discount percentage
"description": "Updated description"
}
Example:
# Update quantity and price
get_model("sale.order.line").write([line_id], {
"qty": 20.0,
"unit_price": 90.00
})
# Update discount
get_model("sale.order.line").write([line_id], {
"discount": 15.0 # Change to 15% discount
})
Behavior:
- Calls function_store(ids) to recalculate all computed fields
- Updates parent order totals (handled by sale.order model)
- Recalculates tax amounts if tax_id or amount changes
3. Amount Calculation¶
Method: get_amount(ids, context)
Calculates line amounts including discounts and promotions. This is a multi-function that returns amount, amount_cur, amount_discount, and promotion_amount.
Calculation Logic:
# Base calculation
amount = qty * unit_price
# Apply percentage discount
if discount:
discount_amt = amount * (discount / 100)
amount -= discount_amt
# Apply fixed discount amount
if discount_amount:
amount -= discount_amount
# Apply promotions from order
if promotions exist:
promotion_amt = calculate_promotion_share()
amount -= promotion_amt
# Convert to base currency
amount_cur = convert_currency(amount, order_currency, base_currency)
Special Handling:
- Products with sale_use_qty2_for_amount flag use qty2 instead of qty
- Promotion amounts are distributed proportionally across matching products
- Promotion percentages can apply to specific products or all products (product_id = None)
Returns: Dictionary with computed values:
{
line_id: {
"amount": 950.00, # Final line amount
"amount_cur": 950.00, # Amount in base currency
"amount_discount": 50.00, # Total discount applied
"promotion_amount": 0.00 # Promotion discount
}
}
4. Quantity Tracking Methods¶
4.1 Get Delivered Quantity¶
Method: get_qty_delivered(ids, context)
Calculates the delivered (shipped) quantity based on stock moves linked to the sales order.
Behavior: - Iterates through all stock moves in state "done" - Excludes expanded picking moves (expand_picking_id) - Matches product and location (reserve_location_id or location_id) - Distributes delivered quantities across lines - Handles cases where delivered qty exceeds ordered qty
Example:
line = get_model("sale.order.line").browse(line_id)
print(f"Ordered: {line.qty}, Delivered: {line.qty_delivered}")
# Output: Ordered: 10.0, Delivered: 8.0
4.2 Get Invoiced Quantity¶
Method: get_qty_invoiced(ids, context)
Calculates the invoiced quantity based on invoice lines linked to the sales order.
Behavior: - Considers invoices in states: draft, waiting_payment, paid - Matches products between order lines and invoice lines - Distributes invoiced quantities proportionally - Handles partial invoicing scenarios
Example:
line = get_model("sale.order.line").browse(line_id)
print(f"Ordered: {line.qty}, Invoiced: {line.qty_invoiced}")
# Output: Ordered: 10.0, Invoiced: 10.0
4.3 Get Produced Quantity¶
Method: get_qty_produced(ids, context)
Calculates quantity produced through linked production orders.
Behavior:
- Sums qty_received from production orders linked to the sale order
- Matches product_id between line and production order
- Useful for make-to-order scenarios
Example:
line = get_model("sale.order.line").browse(line_id)
print(f"Ordered: {line.qty}, Produced: {line.qty_produced}")
# Output: Ordered: 50.0, Produced: 45.0
5. Stock Availability¶
Method: get_qty_avail(ids, context)
Retrieves available stock quantity for the product at the specified location.
Behavior: - Requires both product_id and location_id to be set - Calls stock.location.compute_balance() for real-time stock levels - Returns None if product or location not specified
Example:
# Check stock before confirming order
line = get_model("sale.order.line").browse(line_id)
if line.qty_avail and line.qty_avail < line.qty:
print(f"Warning: Only {line.qty_avail} units available, {line.qty} ordered")
6. Profit and Margin Calculations¶
6.1 Simple Profit Calculation¶
Method: get_profit(ids, context)
Calculates profit based on cost_price field (manual or product cost).
Formula:
cost_amount = cost_price * qty
profit_amount = amount - cost_amount
margin_percent = (profit_amount / amount) * 100 if amount else None
Returns:
6.2 Estimated Profit (from Quotation)¶
Method: get_est_profit(ids, context)
Calculates estimated profit based on cost estimates from quotation stage.
Behavior: - Reads estimated costs from parent order's est_costs lines - Matches costs by sequence number - Converts currency if cost is in different currency - Used during quotation and early order stages
6.3 Actual Profit (from Tracking)¶
Method: get_act_profit(ids, context)
Calculates actual profit based on tracked expenses.
Behavior: - Reads actual costs from account tracking entries - Matches by tracking code: "ORDER_NUMBER / SEQUENCE" - Subtracts tracked amounts from line amount - Stored in database for reporting
Example:
line = get_model("sale.order.line").browse(line_id)
print(f"Amount: {line.amount}")
print(f"Estimated Profit: {line.est_profit_amount} ({line.est_margin_percent}%)")
print(f"Actual Profit: {line.act_profit_amount} ({line.act_margin_percent}%)")
7. Tax Calculation¶
Method: get_tax_amount(ids, context)
Calculates tax amounts based on tax rate and order tax type (tax_ex or tax_in).
Tax Type Handling:
Tax Exclusive (tax_ex):
amount_excl = line.amount
tax_amount = compute_taxes(tax_id, amount_excl)
amount_incl = amount_excl + tax_amount
Tax Inclusive (tax_in):
amount_incl = line.amount
tax_amount = compute_taxes(tax_id, amount_incl)
amount_excl = amount_incl - tax_amount
Returns:
Example:
# Tax exclusive order with 7% VAT
line.amount = 1000.00
line.tax_id = VAT_7_percent
# Result: amount_tax=70.00, amount_incl_tax=1070.00, amount_excl_tax=1000.00
# Tax inclusive order with 7% VAT
line.amount = 1070.00
line.tax_id = VAT_7_percent
# Result: amount_tax=70.00, amount_incl_tax=1070.00, amount_excl_tax=1000.00
8. Helper Methods¶
8.1 View Sale Order¶
Method: view_sale(ids, context)
Navigation helper to view the parent sale order from a line.
Returns:
Usage: Typically called from UI action buttons
8.2 Get Shipping Tracking¶
Method: get_ship_tracking(ids, context)
Retrieves tracking numbers for shipments matching the line's due date.
Behavior: - Matches pickings by due_date - Concatenates multiple tracking numbers with ", " - Returns empty string if no due_date or no matching pickings
8.3 Get Index¶
Method: get_index(ids, context)
Calculates the display position index of each line within its order.
Returns: 1-based index (first line = 1, second line = 2, etc.)
Computed Fields Functions¶
get_amount(ids, context)¶
Multi-function that calculates:
- amount - Net line total after all discounts
- amount_cur - Line total in base currency
- amount_discount - Total discount amount applied
- promotion_amount - Promotion discount amount
Considers: qty, qty2, unit_price, discount (%), discount_amount, and order promotions.
get_qty_delivered(ids, context)¶
Returns quantity delivered/shipped based on completed stock moves. Matches product and location, handles multiple deliveries.
get_qty_invoiced(ids, context)¶
Returns quantity invoiced based on linked invoice lines in valid states (draft, waiting_payment, paid).
get_qty_produced(ids, context)¶
Returns quantity produced through linked production orders. Useful for make-to-order manufacturing.
get_qty_avail(ids, context)¶
Returns available stock quantity at the specified location. Requires both product_id and location_id.
get_profit(ids, context)¶
Multi-function calculating simple profit:
- cost_amount = cost_price * qty
- profit_amount = amount - cost_amount
- margin_percent = (profit / amount) * 100
get_est_profit(ids, context)¶
Multi-function for estimated profit from quotation costs:
- est_cost_amount - Estimated costs
- est_profit_amount - Estimated profit
- est_margin_percent - Estimated margin
get_act_profit(ids, context)¶
Multi-function for actual profit from tracked costs:
- act_cost_amount - Actual tracked costs
- act_profit_amount - Actual profit (stored)
- act_margin_percent - Actual margin
get_tax_amount(ids, context)¶
Multi-function calculating tax amounts:
- amount_tax - Tax amount
- amount_incl_tax - Amount including tax
- amount_excl_tax - Amount excluding tax
Respects order's tax_type (tax_ex or tax_in).
get_ship_tracking(ids, context)¶
Returns shipping tracking numbers from pickings matching the line's due_date.
get_index(ids, context)¶
Returns the 1-based position index of the line within its order.
_get_related(ids, context)¶
Generic function to get related fields from parent order (contact_id, date, user_id, state) or product (categs, categ_id).
Related Models¶
| Model | Relationship | Description |
|---|---|---|
sale.order |
Many2One (parent) | Parent sales order - cascade delete |
product |
Many2One | Product being sold |
uom |
Many2One | Unit of measure for quantity |
account.tax.rate |
Many2One | Tax rate applied to line |
stock.location |
Many2One | Warehouse location for inventory |
contact |
Many2One (related) | Customer from parent order |
stock.move |
Computed | Stock movements for delivery tracking |
invoice.line |
Computed | Invoice lines for billing tracking |
production.order |
Many2One | Linked production order |
product.categ |
Many2Many (related) | Product categories |
account.track.categ |
Many2One | Tracking dimensions for analytics |
stock.lot |
Many2One | Lot/serial number for traceability |
ship.method |
Many2One | Shipping method |
address |
Many2One | Shipping address |
delivery.slot |
Many2One | Delivery time slot |
Common Use Cases¶
Use Case 1: Creating Order Lines with Product Selection¶
# Scenario: Adding products to a sales order with pricing and discounts
# 1. Get or create a sale order
order_id = get_model("sale.order").search([["number", "=", "SO-2026-001"]])
if not order_id:
order_id = get_model("sale.order").create({
"contact_id": 123,
"date": "2026-01-05",
"tax_type": "tax_ex"
})
else:
order_id = order_id[0]
# 2. Add first product line
line1_id = get_model("sale.order.line").create({
"order_id": order_id,
"product_id": 456, # Product: "Laptop Model X"
"description": "Laptop Model X - Core i7, 16GB RAM",
"qty": 5.0,
"uom_id": 1, # UoM: Unit
"unit_price": 1200.00,
"tax_id": 7, # VAT 7%
"location_id": 10, # Main Warehouse
"sequence_no": 10
})
# 3. Add second product line with discount
line2_id = get_model("sale.order.line").create({
"order_id": order_id,
"product_id": 789,
"description": "Wireless Mouse",
"qty": 5.0,
"uom_id": 1,
"unit_price": 50.00,
"discount": 10.0, # 10% discount for bulk purchase
"tax_id": 7,
"location_id": 10,
"sequence_no": 20
})
# 4. Add service line with fixed discount amount
line3_id = get_model("sale.order.line").create({
"order_id": order_id,
"product_id": 101,
"description": "Extended Warranty - 3 Years",
"qty": 1.0,
"unit_price": 500.00,
"discount_amount": 100.00, # $100 off promotion
"tax_id": 7,
"sequence_no": 30
})
# 5. Review line totals
for line_id in [line1_id, line2_id, line3_id]:
line = get_model("sale.order.line").browse(line_id)
print(f"Line {line.sequence_no}: {line.description}")
print(f" Qty: {line.qty}, Price: {line.unit_price}")
print(f" Discount: {line.amount_discount}")
print(f" Amount: {line.amount}")
print(f" Tax: {line.amount_tax}")
print(f" Total Inc Tax: {line.amount_incl_tax}")
Use Case 2: Tracking Order Fulfillment Status¶
# Scenario: Monitor which lines are delivered, invoiced, and what's pending
# Get all lines for an order
order = get_model("sale.order").browse(order_id)
print(f"Order: {order.number}")
print(f"Customer: {order.contact_id.name}\n")
for line in order.lines:
print(f"Product: {line.description}")
print(f" Ordered Qty: {line.qty}")
print(f" Available in Stock: {line.qty_avail}")
print(f" Delivered Qty: {line.qty_delivered}")
print(f" Invoiced Qty: {line.qty_invoiced}")
# Check fulfillment status
if line.qty_delivered >= line.qty:
delivery_status = "Fully Delivered"
elif line.qty_delivered > 0:
delivery_status = f"Partially Delivered ({line.qty_delivered}/{line.qty})"
else:
delivery_status = "Not Delivered"
if line.qty_invoiced >= line.qty:
invoice_status = "Fully Invoiced"
elif line.qty_invoiced > 0:
invoice_status = f"Partially Invoiced ({line.qty_invoiced}/{line.qty})"
else:
invoice_status = "Not Invoiced"
print(f" Delivery Status: {delivery_status}")
print(f" Invoice Status: {invoice_status}")
# Check stock availability
if line.qty_avail is not None and line.qty_avail < (line.qty - line.qty_delivered):
shortage = (line.qty - line.qty_delivered) - line.qty_avail
print(f" WARNING: Stock shortage of {shortage} units!")
print()
Use Case 3: Analyzing Profitability by Order Line¶
# Scenario: Review profit margins and identify most profitable products
# Get order lines with profit data
order = get_model("sale.order").browse(order_id)
print(f"Profitability Analysis: {order.number}\n")
total_revenue = 0
total_cost = 0
total_profit = 0
for line in order.lines:
if not line.product_id:
continue # Skip non-product lines
print(f"Product: {line.description}")
print(f" Quantity: {line.qty}")
print(f" Unit Price: ${line.unit_price}")
print(f" Line Amount: ${line.amount}")
# Show different profit calculations
if line.cost_price:
print(f"\n Simple Profit Calculation:")
print(f" Cost Price: ${line.cost_price}")
print(f" Cost Amount: ${line.cost_amount}")
print(f" Profit: ${line.profit_amount}")
print(f" Margin: {line.margin_percent}%")
if line.est_profit_amount:
print(f"\n Estimated Profit (from quotation):")
print(f" Est. Cost: ${line.est_cost_amount}")
print(f" Est. Profit: ${line.est_profit_amount}")
print(f" Est. Margin: {line.est_margin_percent}%")
if line.act_profit_amount:
print(f"\n Actual Profit (tracked):")
print(f" Act. Cost: ${line.act_cost_amount}")
print(f" Act. Profit: ${line.act_profit_amount}")
print(f" Act. Margin: {line.act_margin_percent}%")
# Accumulate totals
total_revenue += line.amount or 0
if line.cost_amount:
total_cost += line.cost_amount
total_profit += line.profit_amount or 0
print()
# Summary
if total_cost:
overall_margin = (total_profit / total_revenue * 100) if total_revenue else 0
print(f"Overall Summary:")
print(f" Total Revenue: ${total_revenue}")
print(f" Total Cost: ${total_cost}")
print(f" Total Profit: ${total_profit}")
print(f" Average Margin: {overall_margin:.2f}%")
Best Practices¶
1. Always Set Sequence Numbers¶
# Bad: Lines without sequence numbers may display in wrong order
get_model("sale.order.line").create({
"order_id": order_id,
"product_id": 123,
"description": "Product A",
"qty": 1.0,
"unit_price": 100.00
# Missing sequence_no - order unpredictable
})
# Good: Explicitly set sequence numbers
lines = [
{"product_id": 123, "description": "Product A", "qty": 1, "price": 100, "seq": 10},
{"product_id": 456, "description": "Product B", "qty": 2, "price": 200, "seq": 20},
{"product_id": 789, "description": "Product C", "qty": 1, "price": 150, "seq": 30}
]
for line_data in lines:
get_model("sale.order.line").create({
"order_id": order_id,
"product_id": line_data["product_id"],
"description": line_data["description"],
"qty": line_data["qty"],
"unit_price": line_data["price"],
"sequence_no": line_data["seq"]
})
2. Use Appropriate Discount Fields¶
# Scenario: Applying discounts correctly
# Use discount (percentage) for standard discounts
get_model("sale.order.line").create({
"order_id": order_id,
"product_id": 123,
"qty": 10.0,
"unit_price": 100.00,
"discount": 15.0 # 15% off - good for standard discounts
})
# Result: amount = (10 * 100) - (1000 * 0.15) = $850
# Use discount_amount for fixed discounts
get_model("sale.order.line").create({
"order_id": order_id,
"product_id": 456,
"qty": 1.0,
"unit_price": 500.00,
"discount_amount": 50.00 # $50 off - good for promotions
})
# Result: amount = (1 * 500) - 50 = $450
# Both can be combined
get_model("sale.order.line").create({
"order_id": order_id,
"product_id": 789,
"qty": 5.0,
"unit_price": 200.00,
"discount": 10.0, # 10% off
"discount_amount": 25.00 # Plus $25 off
})
# Result: amount = (5 * 200) - (1000 * 0.10) - 25 = $875
# Bad: Using discount for small fixed amounts
get_model("sale.order.line").create({
"order_id": order_id,
"qty": 1.0,
"unit_price": 1000.00,
"discount": 5.0 # Should use discount_amount: 50.00 instead
})
3. Check Stock Availability Before Confirming¶
# Good: Check stock before order confirmation
def validate_stock_availability(order_id):
order = get_model("sale.order").browse(order_id)
issues = []
for line in order.lines:
if not line.product_id:
continue
if not line.location_id:
issues.append(f"Line {line.sequence_no}: No location specified")
continue
if line.qty_avail is None:
continue # Product doesn't track inventory
required_qty = line.qty - (line.qty_delivered or 0)
if line.qty_avail < required_qty:
shortage = required_qty - line.qty_avail
issues.append(
f"Line {line.sequence_no} ({line.description}): "
f"Shortage of {shortage} units "
f"(available: {line.qty_avail}, required: {required_qty})"
)
if issues:
print("Stock Availability Issues:")
for issue in issues:
print(f" - {issue}")
return False
return True
# Use before confirming order
if validate_stock_availability(order_id):
get_model("sale.order").confirm([order_id])
else:
print("Cannot confirm order - resolve stock issues first")
4. Handle Multi-UoM Products Correctly¶
# For products with dual UoM (e.g., sold by box but tracked by piece)
# Get product UoM info
product = get_model("product").browse(product_id)
# Create line with both quantities
line_id = get_model("sale.order.line").create({
"order_id": order_id,
"product_id": product_id,
"description": product.name,
"qty": 10.0, # 10 boxes
"uom_id": box_uom_id, # UoM: Box
"qty2": 120.0, # 120 pieces (10 boxes * 12 pieces/box)
"uom2_id": piece_uom_id, # UoM2: Piece
"unit_price": 100.00 # $100 per box
})
# For products with sale_use_qty2_for_amount flag
if product.sale_use_qty2_for_amount:
# Amount will be calculated as: qty2 * unit_price
# Instead of: qty * unit_price
print(f"Amount based on qty2: {line.qty2} * {line.unit_price} = {line.amount}")
5. Set Cost Price for Profit Tracking¶
# Good: Set cost_price when creating lines for immediate profit visibility
product = get_model("product").browse(product_id)
line_id = get_model("sale.order.line").create({
"order_id": order_id,
"product_id": product_id,
"description": product.name,
"qty": 5.0,
"unit_price": 200.00,
"cost_price": product.cost_price or 120.00, # Get from product or specify
"tax_id": 7
})
# Now profit fields are immediately available
line = get_model("sale.order.line").browse(line_id)
print(f"Revenue: ${line.amount}")
print(f"Cost: ${line.cost_amount}")
print(f"Profit: ${line.profit_amount}")
print(f"Margin: {line.margin_percent}%")
# Bad: Not setting cost_price
line_id = get_model("sale.order.line").create({
"order_id": order_id,
"product_id": product_id,
"qty": 5.0,
"unit_price": 200.00
# cost_price not set - profit_amount will be None
})
6. Use Tracking Dimensions for Analytics¶
# Set tracking dimensions for better reporting and cost allocation
# Example: Track by department and project
line_id = get_model("sale.order.line").create({
"order_id": order_id,
"product_id": product_id,
"description": "Custom Development Services",
"qty": 100.0, # Hours
"unit_price": 150.00, # Per hour
"track_id": sales_dept_track_id, # Track-1: Sales Department
"track2_id": project_alpha_track_id, # Track-2: Project Alpha
})
# Later, costs tracked against this line can be analyzed by dimension
# Reports can show profitability by department and project
Performance Tips¶
1. Batch Operations for Multiple Lines¶
When creating multiple lines, create them individually but avoid unnecessary browse() calls:
# Bad: Browsing after each create
for product in products:
line_id = get_model("sale.order.line").create({...})
line = get_model("sale.order.line").browse(line_id) # Unnecessary
print(line.amount) # Don't need to browse immediately
# Good: Create all lines first, then browse if needed
line_ids = []
for product in products:
line_id = get_model("sale.order.line").create({...})
line_ids.append(line_id)
# Only browse if you need to read the data
if need_to_read_data:
lines = get_model("sale.order.line").browse(line_ids)
for line in lines:
print(line.amount)
2. Use Search with Specific Conditions¶
Be specific when searching to reduce database load:
# Bad: Getting all lines then filtering in Python
all_lines = get_model("sale.order.line").search([])
high_value_lines = [l for l in get_model("sale.order.line").browse(all_lines)
if l.amount > 1000]
# Good: Search with conditions
high_value_line_ids = get_model("sale.order.line").search([
["amount", ">", 1000],
["state", "=", "confirmed"]
])
3. Avoid Redundant Function Store Calls¶
The create() and write() methods already call function_store(), don't call it again:
# Bad: Redundant function_store call
line_id = get_model("sale.order.line").create({...})
get_model("sale.order.line").function_store([line_id]) # Already called in create()
# Good: Trust the built-in behavior
line_id = get_model("sale.order.line").create({...})
# Amounts are already calculated
Troubleshooting¶
"Key error: amount"¶
Cause: Trying to access amount field immediately after create without the function_store completing
Solution: The create method already calls function_store. If accessing in same transaction, refresh the browse:
line_id = get_model("sale.order.line").create({...})
line = get_model("sale.order.line").browse(line_id)
# line.amount is now available
"Negative amount calculated"¶
Cause: Discount percentage or discount_amount exceeds the line subtotal (qty * unit_price) Solution: Validate discounts before creating lines:
subtotal = qty * unit_price
if discount:
discount_amt = subtotal * discount / 100
else:
discount_amt = 0
if discount_amount:
discount_amt += discount_amount
if discount_amt >= subtotal:
raise Exception(f"Total discount ({discount_amt}) exceeds line subtotal ({subtotal})")
"qty_delivered shows incorrect value"¶
Cause: Multiple lines with same product and location, delivery quantities distributed incorrectly Solution: Use reserve_location_id to distinguish lines, or ensure clear product-location mapping:
# Specify reserve_location_id for each line
line1_id = get_model("sale.order.line").create({
"order_id": order_id,
"product_id": 123,
"qty": 10.0,
"location_id": main_warehouse_id,
"reserve_location_id": shelf_a_location_id # Specific reservation
})
"Tax amount not calculating"¶
Cause: Order tax_type is "no_tax" or tax_id not set on line Solution: Ensure parent order has tax_type set to "tax_ex" or "tax_in" and line has tax_id:
# Check order tax type
order = get_model("sale.order").browse(order_id)
if order.tax_type == "no_tax":
# Update order tax type first
get_model("sale.order").write([order_id], {"tax_type": "tax_ex"})
# Set tax_id on line
get_model("sale.order.line").write([line_id], {
"tax_id": default_tax_id
})
"Profit amounts are None"¶
Cause: cost_price not set on the line Solution: Set cost_price explicitly or from product:
product = get_model("product").browse(product_id)
get_model("sale.order.line").write([line_id], {
"cost_price": product.cost_price
})
# Now profit_amount, cost_amount, and margin_percent will calculate
"qty_avail returns None"¶
Cause: Either product_id or location_id not set on the line Solution: Ensure both fields are populated:
line = get_model("sale.order.line").browse(line_id)
if not line.product_id:
print("Product not specified")
if not line.location_id:
print("Location not specified")
# Set location
get_model("sale.order.line").write([line_id], {
"location_id": default_location_id
})
Testing Examples¶
Unit Test: Create Line and Verify Amounts¶
def test_line_amount_calculation():
# Create a test order
order_id = get_model("sale.order").create({
"contact_id": test_customer_id,
"date": "2026-01-05",
"tax_type": "tax_ex"
})
# Create line with discount
line_id = get_model("sale.order.line").create({
"order_id": order_id,
"product_id": test_product_id,
"description": "Test Product",
"qty": 10.0,
"unit_price": 100.00,
"discount": 10.0, # 10% discount
"tax_id": vat_7_percent_id
})
# Verify amount calculation
line = get_model("sale.order.line").browse(line_id)
expected_subtotal = 10.0 * 100.00 # 1000.00
expected_discount = 1000.00 * 0.10 # 100.00
expected_amount = 1000.00 - 100.00 # 900.00
assert line.amount == expected_amount, \
f"Expected amount {expected_amount}, got {line.amount}"
assert line.amount_discount == expected_discount, \
f"Expected discount {expected_discount}, got {line.amount_discount}"
# Verify tax calculation (7% on 900)
expected_tax = 900.00 * 0.07 # 63.00
assert line.amount_tax == expected_tax, \
f"Expected tax {expected_tax}, got {line.amount_tax}"
assert line.amount_incl_tax == 963.00, \
f"Expected total {963.00}, got {line.amount_incl_tax}"
# Cleanup
get_model("sale.order").delete([order_id])
Integration Test: Order Fulfillment Tracking¶
def test_qty_tracking():
# Create order with line
order_id = get_model("sale.order").create({
"contact_id": test_customer_id,
"date": "2026-01-05"
})
line_id = get_model("sale.order.line").create({
"order_id": order_id,
"product_id": test_product_id,
"description": "Test Product",
"qty": 20.0,
"unit_price": 50.00,
"location_id": test_location_id
})
line = get_model("sale.order.line").browse(line_id)
# Initially no delivery
assert line.qty_delivered == 0, "Initial qty_delivered should be 0"
# Confirm order
get_model("sale.order").confirm([order_id])
# Create and validate picking
picking_ids = get_model("stock.picking").search([
["related_id", "=", f"sale.order,{order_id}"]
])
assert len(picking_ids) > 0, "Picking should be created"
# Set picking to done with partial quantity
picking = get_model("stock.picking").browse(picking_ids[0])
for move in picking.lines:
get_model("stock.move").write([move.id], {"qty": 15.0}) # Partial delivery
get_model("stock.picking").set_done(picking_ids)
# Verify qty_delivered updated
line = get_model("sale.order.line").browse(line_id)
assert line.qty_delivered == 15.0, \
f"Expected qty_delivered=15.0, got {line.qty_delivered}"
# Create invoice
invoice_id = get_model("sale.order").make_invoice([order_id])[0]
invoice = get_model("account.invoice").browse(invoice_id)
# Verify qty_invoiced
line = get_model("sale.order.line").browse(line_id)
assert line.qty_invoiced == 20.0, \
f"Expected qty_invoiced=20.0, got {line.qty_invoiced}"
# Cleanup
get_model("account.invoice").delete([invoice_id])
get_model("sale.order").delete([order_id])
Security Considerations¶
Permission Model¶
Sale order lines inherit security from the parent sale.order model:
- sale_readonly - View sale orders and lines
- sale_manager - Full access to create, edit, delete
Data Access¶
- Lines are automatically deleted when parent order is deleted (cascade)
- Users can only access lines for orders they have permission to view
- Company filtering applies through parent order's company_id
- Multi-company environments: Lines visible based on order's company
Best Practices¶
- Always validate product_id exists and user has access
- Check stock availability before confirming to prevent overselling
- Validate pricing and discounts to prevent negative amounts
- Use tracking dimensions for audit trails
- Set appropriate tax_id based on customer location and product type
Configuration Settings¶
Required Settings¶
| Setting | Location | Description |
|---|---|---|
| Currency | settings.currency_id |
Base currency for currency conversions |
| Default Tax Rate | account.tax.rate |
Default tax applied to lines |
| Default Warehouse | stock.location |
Default location for inventory |
Optional Settings¶
| Setting | Default | Description |
|---|---|---|
| Auto Sequence | True | Automatically number lines (10, 20, 30...) |
| Track Delivered Qty | True | Calculate qty_delivered from stock moves |
| Track Invoiced Qty | True | Calculate qty_invoiced from invoices |
| Store Actual Profit | True | Store act_profit_amount for reporting |
Integration Points¶
Stock Management¶
- Stock Moves: qty_delivered calculated from completed stock.move records
- Stock Locations: location_id and reserve_location_id track inventory sources
- Stock Lots: lot_id links line to specific lot/serial numbers
- Stock Packaging: packaging_id specifies packaging requirements
Invoicing¶
- Invoice Lines: qty_invoiced tracks billing progress
- Invoice Creation: Lines copied to invoice.line when creating invoices
- Tax Calculation: Tax amounts flow to invoice lines
Production¶
- Production Orders: production_id links to manufacturing orders
- Produced Quantity: qty_produced tracks manufactured quantities
- Make-to-Order: Lines trigger production orders when confirmed
Accounting¶
- Tracking Categories: track_id and track2_id for dimensional analysis
- Profit Tracking: act_profit_amount stored for financial reporting
- Cost Allocation: Track entries link costs to specific lines
Shipping¶
- Delivery Slots: delivery_slot_id schedules deliveries
- Shipping Methods: ship_method_id determines carrier
- Tracking Numbers: ship_tracking shows courier tracking info
- Due Dates: due_date controls delivery scheduling
Version History¶
Last Updated: 2026-01-05 Model File: sale_order_line.py (415 lines) Framework: Netforce Dependencies: sale.order, product, stock, account modules
Additional Resources¶
- Sale Order Documentation:
sale.order - Product Documentation:
product - Stock Location Documentation:
stock.location - Tax Rate Documentation:
account.tax.rate - Invoice Documentation:
account.invoice - UOM Documentation:
uom
Support & Feedback¶
For issues or questions about sale order lines: 1. Verify parent sale.order exists and is accessible 2. Check product_id, location_id, and tax_id are valid references 3. Review computed fields by calling browse() after create/write 4. Ensure qty, unit_price are numeric and positive 5. Validate discount values don't exceed line subtotal 6. Check order tax_type matches expected tax calculation method 7. Review system logs for detailed error messages
This documentation is generated for developer onboarding and reference purposes.