Skip to content

Sale Quotation Line Documentation

Overview

The Quotation Line module (sale.quot.line) represents individual line items within a sales quotation. Each line contains product information, quantities, pricing, discounts, and tax details. This model is simpler than sales order lines as it focuses on quotation-specific requirements without inventory or shipping concerns. Lines support both regular items and group headers for organizing quotations into sections.


Model Information

Model Name: sale.quot.line Display Name: Quotation Line Parent Model: sale.quot (belongs to quotation via quot_id)

Features

  • ❌ Audit logging (inherits from parent quotation)
  • ❌ Multi-company support (inherits from parent quotation)
  • ❌ Content search (inherits from parent quotation)
  • ✅ Cascade delete when parent quotation is deleted

Line Types

Type Code Description
Item item Regular product line with quantity and pricing
Group group Section header for organizing lines (no pricing)

Key Fields Reference

Core Fields

Field Type Required Description
quot_id Many2One Parent quotation (cascade delete on parent removal)
product_id Many2One Product being quoted
description Text Line item description
qty Decimal Quantity being quoted
uom_id Many2One Unit of measure
unit_price Decimal Price per unit (scale: 6 decimal places)
type Selection Line type: "item" or "group"

Discount Fields

Field Type Description
discount Decimal Discount percentage (e.g., 10 for 10%)
discount_fixed Decimal Fixed discount amount

Tax & Amount Fields

Field Type Description
tax_id Many2One Tax rate to apply
amount Decimal Line total (computed: qty × unit_price - discounts)
amount_discount Decimal Total discount amount (computed)
amount_tax Decimal Tax amount (computed based on quotation tax_type)
amount_incl_tax Decimal Amount including tax (computed)
amount_excl_tax Decimal Amount excluding tax (computed)

Cost & Profit Fields

Field Type Description
cost_price Decimal Estimated cost per unit
cost_amount Decimal Total cost (computed: cost_price × qty)
profit_amount Decimal Estimated profit (computed: amount - cost_amount)
margin_percent Decimal Profit margin percentage (computed)

Sequencing Fields

Field Type Description
sequence_no Integer Numeric sequence for ordering lines
sequence Char String sequence (deprecated, use sequence_no)
index Integer Position within quotation (computed)
Field Type Description
contact_id Many2One Customer (from quot_id.contact_id)
date Date Quotation date (from quot_id.date)
user_id Many2One Quotation owner (from quot_id.user_id)
state Selection Quotation state (from quot_id.state)
product_categs Many2Many Product categories (from product_id.categs)

Reporting & Aggregation Fields

Field Type Description
agg_amount Decimal Sum aggregation of amounts
agg_qty Decimal Sum aggregation of quantities
notes Text Additional line notes

API Methods

1. Create Quotation Line

Method: create(vals, context)

Creates a new quotation line and updates parent quotation amounts.

Parameters:

vals = {
    "quot_id": 123,                   # Required: Parent quotation
    "product_id": 100,                # Optional: Product
    "description": "Product Name",    # Required: Description
    "qty": 5,                         # Optional: Quantity
    "uom_id": 1,                      # Optional: Unit of measure
    "unit_price": 150.00,             # Optional: Unit price
    "discount": 10,                   # Optional: Discount %
    "discount_fixed": 50.00,          # Optional: Fixed discount
    "tax_id": 1,                      # Optional: Tax rate
    "cost_price": 100.00,             # Optional: Cost price
    "type": "item",                   # Optional: Line type
    "sequence_no": 10,                # Optional: Sequence
    "notes": "Special requirements"   # Optional: Notes
}

Returns: int - New line ID

Example:

# Create quotation line for a product
line_id = get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "product_id": 100,
    "description": "Premium Laptop",
    "qty": 5,
    "uom_id": 1,
    "unit_price": 1500.00,
    "tax_id": 1,
    "cost_price": 1000.00
})

Behavior: - Automatically calls function_store to compute amount fields - Parent quotation amounts are recalculated via write trigger - Line ordered by sequence_no and id


2. Update Quotation Line

Method: write(ids, vals, context)

Updates quotation line(s) and recalculates amounts.

Parameters: - ids (list): Line IDs to update - vals (dict): Fields to update

Example:

# Update quantity and price
get_model("sale.quot.line").write([line_id], {
    "qty": 10,
    "unit_price": 1400.00,
    "discount": 5
})

Behavior: - Calls function_store to recompute amounts - Parent quotation amounts automatically updated - Changes tracked in parent quotation audit log


Computed Fields Functions

get_amount(ids, context)

Calculates the line total amount after discounts.

Formula:

amount = qty × unit_price
if discount:
    amount = amount × (1 - discount/100)
if discount_fixed:
    amount = amount - discount_fixed

Returns: Decimal - Line amount or None if qty or unit_price not set

Example:

# qty=10, unit_price=100, discount=10%, discount_fixed=50
# amount = (10 × 100) × (1 - 10/100) - 50
# amount = 1000 × 0.9 - 50 = 900 - 50 = 850


get_amount_discount(ids, context)

Calculates total discount amount applied to the line.

Formula:

discount_amt = 0
if discount and qty and unit_price:
    discount_amt = qty × unit_price × (discount/100)
if discount_fixed:
    discount_amt += discount_fixed

Returns: Decimal - Total discount amount or None


get_profit(ids, context)

Calculates cost, profit, and margin for the line.

Formula:

cost_amount = cost_price × qty
profit_amount = amount - cost_amount
margin_percent = (profit_amount / amount) × 100

Returns: Dictionary with: - cost_amount - Total cost - profit_amount - Profit amount - margin_percent - Margin percentage

Example:

# amount=1000, cost_price=600, qty=2
# cost_amount = 600 × 2 = 1200
# profit_amount = 1000 - 1200 = -200 (loss!)
# margin_percent = (-200 / 1000) × 100 = -20%


get_tax_amount(ids, context)

Calculates tax amounts based on quotation's tax_type.

Behavior: - Computes base amount for tax calculation - Applies tax rate using account.tax.rate.compute_taxes() - Splits amount into excl_tax and incl_tax based on tax_type

Returns: Dictionary with: - amount_tax - Tax amount - amount_incl_tax - Amount including tax - amount_excl_tax - Amount excluding tax

Tax Type Handling:

if tax_type == "tax_ex":
    # Tax exclusive
    amount_excl_tax = amount
    amount_incl_tax = amount + tax

elif tax_type == "tax_in":
    # Tax inclusive
    amount_incl_tax = amount
    amount_excl_tax = amount - tax

elif tax_type == "no_tax":
    # No tax
    amount_tax = 0


get_index(ids, context)

Calculates the line's position within its quotation.

Returns: Integer - 1-based index position

Example:

# Quotation has 5 lines
# Line 1: index=1
# Line 2: index=2
# Line 3: index=3


get_sequence_old(ids, context)

Deprecated function that converts sequence_no to string.

Returns: String version of sequence_no


Search Functions

Search by Product

# Find all quotation lines for a specific product
line_ids = get_model("sale.quot.line").search([
    ["product_id", "=", product_id]
])

Search by Customer

# Find all quotation lines for a customer
line_ids = get_model("sale.quot.line").search([
    ["contact_id", "=", contact_id]
])

Search by State

# Find all approved quotation lines
line_ids = get_model("sale.quot.line").search([
    ["state", "=", "approved"]
])

Search by Product Category

# Find lines for products in a category
line_ids = get_model("sale.quot.line").search([
    ["product_categs", "=", categ_id]
])

Search by Date Range

# Find lines in date range
line_ids = get_model("sale.quot.line").search([
    ["date", ">=", "2024-01-01"],
    ["date", "<=", "2024-12-31"]
])

Model Relationship Description
sale.quot Many2One (quot_id) Parent quotation (cascade delete)
product Many2One (product_id) Product being quoted
uom Many2One (uom_id) Unit of measure
account.tax.rate Many2One (tax_id) Tax rate
contact Many2One (computed) Customer from parent quotation
base.user Many2One (computed) Owner from parent quotation
product.categ Many2Many (computed) Product categories

Common Use Cases

Use Case 1: Create Standard Product Line

# Add a product line to quotation

# 1. Create line with product
line_id = get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "product_id": 100,
    "description": "Laptop Computer",
    "qty": 5,
    "uom_id": 1,
    "unit_price": 1500.00,
    "tax_id": 1,
    "cost_price": 1000.00,
    "sequence_no": 10
})

# Line amount automatically calculated: 5 × 1500 = 7500
# Parent quotation totals updated automatically

Use Case 2: Create Line with Discounts

# Add line with percentage and fixed discount

line_id = get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "product_id": 101,
    "description": "Desktop Computer",
    "qty": 10,
    "uom_id": 1,
    "unit_price": 1000.00,
    "discount": 10,              # 10% discount
    "discount_fixed": 200,       # Additional $200 off
    "tax_id": 1,
    "sequence_no": 20
})

# Calculation:
# Base: 10 × 1000 = 10,000
# After 10%: 10,000 × 0.9 = 9,000
# After fixed: 9,000 - 200 = 8,800

Use Case 3: Create Group Header

# Create section header to organize lines

# 1. Create group header
group_id = get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "type": "group",
    "description": "HARDWARE COMPONENTS",
    "sequence_no": 100
})

# 2. Add items under the group
line1_id = get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "product_id": 200,
    "description": "CPU",
    "qty": 10,
    "unit_price": 300.00,
    "sequence_no": 110
})

line2_id = get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "product_id": 201,
    "description": "RAM",
    "qty": 20,
    "unit_price": 100.00,
    "sequence_no": 120
})

# Group header appears before items
# Group has no amount (display only)

Use Case 4: Update Line Quantities and Prices

# Customer requests quantity change

# 1. Get current line
line = get_model("sale.quot.line").browse(line_id)
print(f"Current: {line.qty} × {line.unit_price} = {line.amount}")

# 2. Update quantity
get_model("sale.quot.line").write([line_id], {
    "qty": 15,  # Changed from 10
    "unit_price": 1400.00  # Negotiated price
})

# 3. Check new amount
line = line.browse()
print(f"Updated: {line.qty} × {line.unit_price} = {line.amount}")
# Output: Updated: 15 × 1400.00 = 21000.00

# Parent quotation totals automatically updated

Use Case 5: Analyze Line Profitability

# Check profit margins on quotation lines

# 1. Get all lines with profit data
quot = get_model("sale.quot").browse(quot_id)
for line in quot.lines:
    if line.type == "item" and line.amount:
        print(f"\nProduct: {line.description}")
        print(f"  Sale Amount: {line.amount}")
        print(f"  Cost Amount: {line.cost_amount}")
        print(f"  Profit: {line.profit_amount}")
        print(f"  Margin: {line.margin_percent}%")

        # Flag low margin items
        if line.margin_percent and line.margin_percent < 20:
            print(f"  ⚠ WARNING: Low margin!")

# Example output:
# Product: Laptop Computer
#   Sale Amount: 7500.00
#   Cost Amount: 5000.00
#   Profit: 2500.00
#   Margin: 33.33%
#
# Product: Desktop Computer
#   Sale Amount: 8800.00
#   Cost Amount: 8500.00
#   Profit: 300.00
#   Margin: 3.41%
#   ⚠ WARNING: Low margin!

Use Case 6: Bulk Line Creation

# Add multiple products at once

products = [
    {"product_id": 100, "qty": 5, "price": 1500},
    {"product_id": 101, "qty": 10, "price": 1000},
    {"product_id": 102, "qty": 2, "price": 2500},
]

for i, item in enumerate(products):
    prod = get_model("product").browse(item["product_id"])

    get_model("sale.quot.line").create({
        "quot_id": quot_id,
        "product_id": item["product_id"],
        "description": prod.name,
        "qty": item["qty"],
        "uom_id": prod.uom_id.id,
        "unit_price": item["price"],
        "tax_id": prod.sale_tax_id.id,
        "cost_price": prod.cost_price,
        "sequence_no": (i + 1) * 10
    })

# All lines created with sequential numbering
# Quotation totals updated after all lines added

Use Case 7: Tax Calculation Examples

# Different tax scenarios

# Tax Exclusive (tax added on top)
line_id = get_model("sale.quot.line").create({
    "quot_id": quot_id,  # quot has tax_type="tax_ex"
    "product_id": 100,
    "description": "Product A",
    "qty": 10,
    "unit_price": 100,
    "tax_id": vat_7_percent_id
})

line = get_model("sale.quot.line").browse(line_id)
# amount = 1000
# amount_excl_tax = 1000
# amount_tax = 70 (7% of 1000)
# amount_incl_tax = 1070

# Tax Inclusive (tax included in price)
line_id = get_model("sale.quot.line").create({
    "quot_id": quot_id2,  # quot has tax_type="tax_in"
    "product_id": 100,
    "description": "Product A",
    "qty": 10,
    "unit_price": 100,
    "tax_id": vat_7_percent_id
})

line = get_model("sale.quot.line").browse(line_id)
# amount = 1000
# amount_incl_tax = 1000
# amount_tax = 65.42 (calculated from inclusive)
# amount_excl_tax = 934.58

Best Practices

1. Always Set Sequence Numbers

# Bad: No sequence control
get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "description": "Item"
    # No sequence_no - unpredictable order
})

# Good: Control line order
get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "description": "Item",
    "sequence_no": 10  # Explicit ordering
})

2. Use Group Headers for Organization

# Create well-organized quotations with sections

# Section 1: Hardware
get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "type": "group",
    "description": "HARDWARE",
    "sequence_no": 100
})
# ... add hardware items at 110, 120, 130...

# Section 2: Software
get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "type": "group",
    "description": "SOFTWARE",
    "sequence_no": 200
})
# ... add software items at 210, 220, 230...

# Section 3: Services
get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "type": "group",
    "description": "SERVICES",
    "sequence_no": 300
})
# ... add service items at 310, 320, 330...

3. Track Costs for Profitability

# Always set cost_price for profit analysis

# Bad: No cost tracking
get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "product_id": 100,
    "unit_price": 1500
    # No cost_price - can't analyze profit
})

# Good: Track costs
prod = get_model("product").browse(100)
get_model("sale.quot.line").create({
    "quot_id": quot_id,
    "product_id": 100,
    "unit_price": 1500,
    "cost_price": prod.cost_price  # Enable profit analysis
})

4. Consistent Discount Application

# Choose either percentage OR fixed discount, not random mix

# Consistent: Use percentage discounts
for line in lines:
    line.write({"discount": 10})  # 10% on all

# Or consistent: Use fixed discounts based on tier
for line in lines:
    if line.amount > 10000:
        line.write({"discount_fixed": 500})
    elif line.amount > 5000:
        line.write({"discount_fixed": 200})

Performance Tips

1. Batch Line Operations

# Bad: Creating lines one by one with individual commits
for product in products:
    line_id = get_model("sale.quot.line").create({...})
    # Each create triggers quotation recalculation!

# Good: Prepare all lines then create
line_vals_list = []
for product in products:
    line_vals_list.append({
        "quot_id": quot_id,
        "product_id": product.id,
        ...
    })

# Create all at once
for vals in line_vals_list:
    get_model("sale.quot.line").create(vals)
# Quotation recalculated once after all lines added

2. Minimize Field Updates

# Bad: Multiple updates
get_model("sale.quot.line").write([line_id], {"qty": 10})
get_model("sale.quot.line").write([line_id], {"unit_price": 100})
get_model("sale.quot.line").write([line_id], {"discount": 5})
# Triggers 3 recalculations!

# Good: Single update
get_model("sale.quot.line").write([line_id], {
    "qty": 10,
    "unit_price": 100,
    "discount": 5
})
# Triggers 1 recalculation

Troubleshooting

Line amounts not calculating

Cause: Missing qty or unit_price field. Solution: Ensure both qty and unit_price are set. Amount is None if either is missing.

Tax amounts incorrect

Cause: Tax calculation depends on parent quotation's tax_type. Solution: Check quotation.tax_type setting. Verify tax_id is set on line.

Lines appearing in wrong order

Cause: sequence_no not set or not sequential. Solution: Set explicit sequence_no values with gaps (10, 20, 30) to allow insertions.

Parent quotation totals not updating

Cause: Lines created/updated without triggering parent update. Solution: The write() method should automatically trigger parent update. If not working, call quotation.function_store() manually.

Profit calculations showing None

Cause: cost_price not set on line. Solution: Set cost_price field when creating line, typically from product.cost_price.


Database Constraints

Cascade Delete

FOREIGN KEY (quot_id) REFERENCES sale_quot(id) ON DELETE CASCADE

When a quotation is deleted, all its lines are automatically deleted.


Required Fields

  • quot_id - Must reference valid quotation
  • description - Cannot be empty

Testing Examples

Unit Test: Line Amount Calculation

def test_line_amount_calculation():
    # Create quotation
    quot_id = get_model("sale.quot").create({
        "contact_id": 1,
        "currency_id": 1,
        "tax_type": "tax_ex"
    })

    # Create line with discount
    line_id = get_model("sale.quot.line").create({
        "quot_id": quot_id,
        "description": "Test Product",
        "qty": 10,
        "unit_price": 100,
        "discount": 10,
        "discount_fixed": 50
    })

    # Verify calculation
    line = get_model("sale.quot.line").browse(line_id)
    # Expected: (10 × 100) × 0.9 - 50 = 1000 × 0.9 - 50 = 850
    assert line.amount == 850

    # Verify discount amount
    # Expected: (10 × 100 × 0.1) + 50 = 100 + 50 = 150
    assert line.amount_discount == 150

Unit Test: Profit Calculation

def test_profit_calculation():
    # Create quotation line with cost
    line_id = get_model("sale.quot.line").create({
        "quot_id": quot_id,
        "description": "Test Product",
        "qty": 5,
        "unit_price": 200,
        "cost_price": 120
    })

    line = get_model("sale.quot.line").browse(line_id)

    # Verify calculations
    # amount = 5 × 200 = 1000
    assert line.amount == 1000

    # cost_amount = 5 × 120 = 600
    assert line.cost_amount == 600

    # profit = 1000 - 600 = 400
    assert line.profit_amount == 400

    # margin = 400 / 1000 × 100 = 40%
    assert line.margin_percent == 40

Security Considerations

Data Access

  • Line access controlled through parent quotation
  • Users can only access lines of quotations they can view
  • No direct line-level permissions (inherits from quotation)

Audit Trail

  • Line changes tracked through parent quotation audit log
  • Use quotation audit log to track line modifications
  • Cost and profit fields should be restricted from customer views

Version History

Last Updated: 2024-01-05 Model Version: sale_quot_line.py (157 lines) Framework: Netforce


Additional Resources

  • Parent Quotation Documentation: sale.quot
  • Product Documentation: product
  • Tax Rate Documentation: account.tax.rate
  • Unit of Measure Documentation: uom
  • See also: sale.order.line for sales order line comparison

Support & Feedback

For issues or questions about quotation lines: 1. Check parent quotation documentation (sale.quot) 2. Verify product and tax rate setup 3. Review amount calculation formulas 4. Check sequence_no for ordering issues 5. Ensure cost_price is set for profit analysis 6. Test discount combinations in development environment


This documentation is generated for developer onboarding and reference purposes.