Skip to content

Sales Forecast Documentation

Overview

The Sales Forecast module (sale.forecast) provides comprehensive sales forecasting capabilities for period-based planning. It enables businesses to predict future sales volumes, plan inventory requirements, allocate resources, and coordinate production and procurement activities based on anticipated customer demand.


Model Information

Model Name: sale.forecast Display Name: Sales Forecast Key Fields: number

Features

  • ❌ Audit logging enabled (_audit_log)
  • ❌ Multi-company support (company_id)
  • ❌ Full-text content search (_content_search)
  • ✅ Unique key constraint per forecast number

Understanding Key Fields

What are Key Fields?

In Netforce models, Key Fields are a combination of fields that together create a composite unique identifier for a record. Think of them as a business key that ensures data integrity across the system.

For the sale.forecast model, the key field is:

_key = ["number"]

This means the forecast number must be unique: - number - Auto-generated forecast identifier (e.g., FC0001, FC0002)

Why Key Fields Matter

Uniqueness Guarantee - Key fields prevent duplicate records by ensuring unique combinations:

# Examples of valid combinations:
number = "FC0001"   Valid
number = "FC0002"   Valid
number = "FC0003"   Valid

# This would fail - duplicate key:
number = "FC0001"   ERROR: Key already exists!

Database Implementation

The key fields are enforced at the database level using a unique constraint:

_key = ["number"]

This translates to:

CREATE UNIQUE INDEX sale_forecast_number
    ON sale_forecast (number);

Forecast Period Planning

Sales forecasts are organized by time periods, allowing for systematic planning:

Aspect Description
Period-Based Define date ranges (monthly, quarterly, annually)
Line-Level Detail Break down by products, customers, locations
Stock Integration Generate stock movements for pending forecasts
Production Planning Convert forecasts to production plans
Purchase Planning Generate purchase orders for raw materials

State Workflow

open → closed
  ↓       ↑
  └───────┘
   reopen
State Description
open Forecast is active and can be modified. Stock movements are generated based on forecast lines.
closed Forecast is finalized and locked. Stock movements are deleted. Used for completed periods.

Key Fields Reference

Header Fields

Field Type Required Description
number Char Auto-generated unique forecast number (e.g., FC0001)
date_from Date Start date of the forecast period
date_to Date End date of the forecast period
location_id Many2One Warehouse/location for stock planning (stock.location)
state Selection Current status: open or closed

Detail Fields

Field Type Description
description Text Detailed description or notes about the forecast
num_lines Integer Count of forecast line items (computed)

Relationship Fields

Field Type Description
lines One2Many Forecast line items (sale.forecast.line) with product/customer details
comments One2Many User comments and notes (message model)
stock_moves One2Many Pending stock movements generated from forecast

API Methods

1. Create Record

Method: create(vals, context)

Creates a new sales forecast record with line items.

Parameters:

vals = {
    "number": "FC0001",                # Auto-generated if not provided
    "date_from": "2026-01-01",         # Required: Forecast start date
    "date_to": "2026-03-31",           # Required: Forecast end date
    "location_id": 1,                  # Optional: Warehouse location
    "description": "Q1 2026 Forecast", # Optional: Description
    "lines": [                         # Forecast line items
        ("create", {
            "product_id": 10,
            "customer_id": 5,
            "plan_qty": 1000,
            "min_shelf_life": "75"
        }),
        ("create", {
            "product_id": 15,
            "plan_qty": 500
        })
    ]
}

context = {
    "company_id": 1                    # Current company context
}

Returns: int - New forecast ID

Example:

# Create Q1 2026 sales forecast
forecast_id = get_model("sale.forecast").create({
    "date_from": "2026-01-01",
    "date_to": "2026-03-31",
    "location_id": 1,
    "description": "Q1 2026 Sales Forecast",
    "lines": [
        ("create", {
            "product_id": 10,
            "customer_id": 5,
            "plan_qty": 1000,
            "min_shelf_life": "75"
        })
    ]
})


2. Update Stock

Method: update_stock(ids, context)

Generates pending stock movements based on forecast lines. Creates stock movements from warehouse to customer location for the difference between planned and actual quantities.

Parameters: - ids (list): Forecast IDs to generate stock movements for

Behavior: - Validates that location_id is set on the forecast - Deletes existing stock movements linked to this forecast - For each forecast line, calculates: diff_qty = plan_qty - actual_qty - Creates pending stock move if diff_qty > 0 - Stock moves are dated at the start of the forecast period - Uses pick-out journal from settings

Returns: None

Example:

# Generate stock movements for forecast
forecast = get_model("sale.forecast").browse(123)
forecast.update_stock()

# This creates stock.move records with state="pending"
# Location: From warehouse → To customer location
# Quantity: Planned minus actual sales

Validation: - Raises exception if location_id is not set: "Missing location in sales forecast {number}"


3. State Transition Methods

3.1 Close Forecast

Method: close(ids, context)

Closes the forecast period and removes pending stock movements.

Parameters: - ids (list): Record IDs to close

Behavior: - Deletes all stock movements related to the forecast - Sets state to "closed" - Used when forecast period is completed

Example:

get_model("sale.forecast").close([forecast_id])


3.2 Reopen Forecast

Method: reopen(ids, context)

Reopens a closed forecast and regenerates stock movements.

Parameters: - ids (list): Record IDs to reopen

Behavior: - Sets state back to "open" - Calls update_stock() to regenerate movements - Useful for adjusting past forecasts

Example:

get_model("sale.forecast").reopen([forecast_id])


4. Helper Methods

4.1 Copy Forecast

Method: copy(ids, context)

Creates a duplicate of an existing forecast with all line items.

Behavior: - Copies header fields: date_from, date_to, description - Copies all forecast lines with: product_id, customer_id, min_shelf_life, plan_qty - Generates new forecast number - Returns navigation to the new forecast form

Example:

result = get_model("sale.forecast").copy([forecast_id])
# Returns: {"next": {"name": "sale_forecast", "mode": "form", "active_id": new_id}}


4.2 Copy to Production Plan

Method: copy_to_production_plan(ids, context)

Converts forecast lines into production plans for manufacturing.

Behavior: - For each forecast line, calculates manufacturing date based on product lead time - Finds the Bill of Materials (BoM) for the product - Creates production plan with: product_id, dates, plan_qty, uom_id, bom_id - Returns count of production plans created

Example:

result = get_model("sale.forecast").copy_to_production_plan([forecast_id])
# Returns: {"flash": "N production plans created from sales forecast"}

Validation: - Raises exception if BoM not found for product


4.3 Copy to Raw Material Purchase

Method: copy_to_rm_purchase(ids, context)

Generates purchase orders for raw materials based on forecast requirements.

Behavior: - Calculates current stock quantity vs. forecast planned quantity - If shortage exists, determines order quantity - Checks product suppliers (raises error if missing) - For products with BoM, calculates raw material requirements - Groups purchase lines by supplier - Creates purchase orders with reference to forecast number

Example:

result = get_model("sale.forecast").copy_to_rm_purchase([forecast_id])
# Returns: {"next": {"name": "purchase", "search_condition": [["ref", "=", "FC0001"]]}}

Validation: - Raises exception if no suppliers defined for products - Raises exception if no purchase orders needed


4.4 Months to Quantity

Method: months_to_qty(product_id, months, min_shelf_life=None)

Calculates forecasted quantity for a product over a specified number of months.

Parameters: - product_id (int): Product to calculate forecast for - months (int/float): Number of months to forecast - min_shelf_life (str, optional): Filter by minimum shelf life requirement

Behavior: - Searches forecast lines for the product within the date range - Calculates proportional quantities based on period overlap - Extrapolates if forecast coverage is incomplete - Returns total quantity as integer

Example:

# Get 3-month forecast for product
qty = get_model("sale.forecast").months_to_qty(
    product_id=10,
    months=3,
    min_shelf_life="75"
)


4.5 Get Number of Lines

Method: get_num_lines(ids, context)

Computed field function that counts forecast line items.

Example:

# Automatically calculated when browsing forecast
forecast = get_model("sale.forecast").browse(123)
print(forecast.num_lines)  # Returns count of lines


Search Functions

Search by Date Range

# Find forecasts overlapping a specific date
condition = [
    ["date_from", "<=", "2026-03-31"],
    ["date_to", ">=", "2026-01-01"]
]

Search by State

# Find all open forecasts
condition = [["state", "=", "open"]]

Search by Number

# Find specific forecast
condition = [["number", "=", "FC0001"]]

Search by Location

# Find forecasts for specific warehouse
condition = [["location_id", "=", 1]]

Computed Fields Functions

get_num_lines(ids, context)

Returns the count of forecast line items for each forecast record.


Best Practices

1. Period Definition

# Bad example: Overlapping forecast periods
forecast1 = {"date_from": "2026-01-01", "date_to": "2026-03-31"}
forecast2 = {"date_from": "2026-02-01", "date_to": "2026-04-30"}  # Overlaps!

# Good example: Sequential non-overlapping periods
forecast_q1 = {"date_from": "2026-01-01", "date_to": "2026-03-31"}
forecast_q2 = {"date_from": "2026-04-01", "date_to": "2026-06-30"}

Why: Non-overlapping periods prevent double-counting and confusion in variance analysis.


2. Location Assignment

Always set the warehouse location when creating forecasts:

# Bad: Missing location
forecast = {
    "date_from": "2026-01-01",
    "date_to": "2026-03-31"
    # Missing location_id - will fail when generating stock movements
}

# Good: Location specified
forecast = {
    "date_from": "2026-01-01",
    "date_to": "2026-03-31",
    "location_id": 1  # Warehouse location set
}

Why: Location is required for generating stock movements and purchase planning.


3. Regular Updates

Update forecasts regularly based on actual sales:

# Review forecast monthly
forecast = get_model("sale.forecast").browse(forecast_id)

# Check variance between planned and actual
for line in forecast.lines:
    variance = line.plan_qty - line.actual_qty
    if abs(variance) > line.plan_qty * 0.2:  # More than 20% variance
        print(f"Significant variance for {line.product_id.name}: {variance}")
        # Consider adjusting future forecasts

Why: Regular reviews improve forecast accuracy and planning effectiveness.


4. Bottom-Up vs Top-Down Forecasting

Bottom-Up Approach:

# Create detailed forecasts by product and customer
forecast_id = get_model("sale.forecast").create({
    "date_from": "2026-01-01",
    "date_to": "2026-03-31",
    "lines": [
        ("create", {"product_id": 10, "customer_id": 5, "plan_qty": 1000}),
        ("create", {"product_id": 10, "customer_id": 8, "plan_qty": 500}),
        ("create", {"product_id": 15, "customer_id": 5, "plan_qty": 750}),
    ]
})

Top-Down Approach:

# Start with total forecast, then break down
forecast_id = get_model("sale.forecast").create({
    "date_from": "2026-01-01",
    "date_to": "2026-03-31",
    "lines": [
        ("create", {"product_id": 10, "plan_qty": 1500}),  # Total for product
        ("create", {"product_id": 15, "plan_qty": 750}),
    ]
})

Best Practice: Use bottom-up for better accuracy, top-down for speed. Combine both approaches for optimal results.


Database Constraints

Unique Number Constraint

CREATE UNIQUE INDEX sale_forecast_number
    ON sale_forecast (number);

Ensures each forecast has a unique identifier, preventing duplicate forecast numbers in the system.


Model Relationship Description
sale.forecast.line One2Many Detailed forecast lines with product/customer breakdown
stock.location Many2One Warehouse location for stock planning
stock.move One2Many Pending stock movements generated from forecast
message One2Many Comments and discussion threads
sequence System Auto-generates forecast numbers
production.plan Integration Production plans created from forecast
purchase.order Integration Purchase orders generated from forecast requirements

Common Use Cases

Use Case 1: Create Quarterly Sales Forecast

# Step-by-step quarterly forecast creation

# 1. Create forecast header for Q1 2026
forecast_id = get_model("sale.forecast").create({
    "date_from": "2026-01-01",
    "date_to": "2026-03-31",
    "location_id": 1,
    "description": "Q1 2026 Sales Forecast - Based on historical trends and new contracts"
})

# 2. Add forecast lines by product and customer
forecast = get_model("sale.forecast").browse(forecast_id)
get_model("sale.forecast.line").create({
    "forecast_id": forecast_id,
    "product_id": 10,
    "customer_id": 5,
    "plan_qty": 1000,
    "min_shelf_life": "75"
})

# 3. Generate stock movements
forecast.update_stock()

# 4. Review and approve forecast
# (Review actual_qty as period progresses)

Use Case 2: Copy Previous Period Forecast

# Quickly create next period forecast based on previous period

# 1. Find previous quarter forecast
prev_forecast = get_model("sale.forecast").search_browse([
    ["date_from", "=", "2025-10-01"],
    ["date_to", "=", "2025-12-31"]
])

# 2. Copy the forecast
result = get_model("sale.forecast").copy([prev_forecast[0].id])
new_forecast_id = result["next"]["active_id"]

# 3. Update dates for new period
get_model("sale.forecast").write([new_forecast_id], {
    "date_from": "2026-01-01",
    "date_to": "2026-03-31"
})

# 4. Adjust quantities based on growth expectations
new_forecast = get_model("sale.forecast").browse(new_forecast_id)
for line in new_forecast.lines:
    # Increase by 10% for growth
    new_qty = line.plan_qty * 1.10
    line.write({"plan_qty": new_qty})

Use Case 3: Generate Purchase Orders from Forecast

# Create purchase orders for forecasted demand

# 1. Create or select forecast
forecast_id = 123

# 2. Generate raw material purchase orders
result = get_model("sale.forecast").copy_to_rm_purchase([forecast_id])

# This automatically:
# - Calculates current stock levels
# - Determines shortage quantities
# - Checks BoM for raw material requirements
# - Groups by supplier
# - Creates purchase orders

# 3. Review generated purchase orders
po_condition = [["ref", "=", forecast.number]]
purchase_orders = get_model("purchase.order").search_browse(po_condition)

for po in purchase_orders:
    print(f"PO {po.number} for {po.contact_id.name}: {po.amount_total}")

Use Case 4: Variance Analysis and Tracking

# Monitor forecast accuracy over time

# 1. Get completed forecast
forecast = get_model("sale.forecast").browse(forecast_id)

# 2. Calculate variance for each line
variances = []
for line in forecast.lines:
    variance_qty = line.actual_qty - line.plan_qty
    variance_pct = (variance_qty / line.plan_qty * 100) if line.plan_qty else 0

    variances.append({
        "product": line.product_id.name,
        "customer": line.customer_id.name if line.customer_id else "All",
        "planned": line.plan_qty,
        "actual": line.actual_qty,
        "variance_qty": variance_qty,
        "variance_pct": variance_pct
    })

# 3. Identify significant variances
for v in variances:
    if abs(v["variance_pct"]) > 20:
        print(f"WARNING: {v['product']} variance: {v['variance_pct']:.1f}%")

# 4. Close accurate forecasts
if all(abs(v["variance_pct"]) < 10 for v in variances):
    forecast.close()

Use Case 5: Production Planning from Forecast

# Convert forecast to production schedule

# 1. Create forecast with manufactured products
forecast_id = get_model("sale.forecast").create({
    "date_from": "2026-03-01",
    "date_to": "2026-03-31",
    "location_id": 1,
    "lines": [
        ("create", {
            "product_id": 100,  # Manufactured product
            "plan_qty": 5000
        })
    ]
})

# 2. Generate production plans
result = get_model("sale.forecast").copy_to_production_plan([forecast_id])
# Automatically creates production.plan records
# Manufacturing date = forecast start date - product lead time

# 3. Review production plans
prod_plans = get_model("production.plan").search_browse([
    ["product_id", "=", 100],
    ["date_from", ">=", "2026-02-01"],
    ["date_to", "<=", "2026-03-31"]
])

for plan in prod_plans:
    print(f"Produce {plan.plan_qty} units by {plan.date_to}")

Performance Tips

1. Batch Operations

  • When creating forecasts with many lines, use nested creates rather than separate calls
  • Use search_browse() instead of search() + browse() to reduce database queries

2. Optimize Stock Movement Generation

# Bad: Generate stock movements frequently
forecast.update_stock()  # Called after every line change
forecast.update_stock()
forecast.update_stock()

# Good: Generate once after all changes
# Make all forecast line changes first
for line in lines_data:
    get_model("sale.forecast.line").create(line)

# Then generate stock movements once
forecast.update_stock()

3. Use Date Indexing

The model orders by date_from desc by default, which is optimized for date-based searches:

# Efficient: Uses indexed field
forecasts = get_model("sale.forecast").search_browse([
    ["date_from", ">=", "2026-01-01"],
    ["date_to", "<=", "2026-12-31"]
])

Troubleshooting

"Missing location in sales forecast {number}"

Cause: Attempting to generate stock movements without setting location_id Solution: Set the warehouse location before calling update_stock():

forecast.write({"location_id": 1})
forecast.update_stock()

"Customer location not found"

Cause: No stock location with type="customer" exists in the system Solution: Create a customer location in stock.location:

get_model("stock.location").create({
    "name": "Customers",
    "type": "customer"
})

"BoM not found for product {code}"

Cause: Attempting to create production plan for product without Bill of Materials Solution: Create BoM for the product or remove it from production planning:

# Create BoM first
bom_id = get_model("bom").create({
    "product_id": product_id,
    "qty": 1,
    "lines": [...]
})
# Then create production plan

"Missing supplier for product {name}"

Cause: Generating purchase orders for product without supplier configured Solution: Add supplier to product:

product.write({
    "suppliers": [("create", {
        "supplier_id": supplier_contact_id,
        "price": 10.00
    })]
})


Testing Examples

Unit Test: Create and Close Forecast

def test_forecast_lifecycle():
    # Create forecast
    forecast_id = get_model("sale.forecast").create({
        "date_from": "2026-01-01",
        "date_to": "2026-01-31",
        "location_id": 1,
        "lines": [
            ("create", {
                "product_id": 10,
                "plan_qty": 100
            })
        ]
    })

    # Verify created
    forecast = get_model("sale.forecast").browse(forecast_id)
    assert forecast.state == "open"
    assert forecast.num_lines == 1

    # Generate stock movements
    forecast.update_stock()
    assert len(forecast.stock_moves) > 0

    # Close forecast
    forecast.close()
    assert forecast.state == "closed"
    assert len(forecast.stock_moves) == 0

Unit Test: Variance Calculation

def test_forecast_variance():
    # Create forecast with known quantities
    forecast_id = get_model("sale.forecast").create({
        "date_from": "2025-12-01",
        "date_to": "2025-12-31",
        "location_id": 1,
        "lines": [
            ("create", {
                "product_id": 10,
                "customer_id": 5,
                "plan_qty": 1000
            })
        ]
    })

    # Check actual sales (should be calculated from sale.order.line)
    forecast = get_model("sale.forecast").browse(forecast_id)
    line = forecast.lines[0]

    # Verify actual_qty is computed
    assert hasattr(line, "actual_qty")

    # Calculate variance
    variance = line.actual_qty - line.plan_qty
    accuracy = (1 - abs(variance) / line.plan_qty) * 100 if line.plan_qty else 0

    print(f"Forecast accuracy: {accuracy:.1f}%")

Security Considerations

Permission Model

  • sale.forecast.create - Create new forecasts
  • sale.forecast.write - Edit existing forecasts
  • sale.forecast.delete - Delete forecasts
  • sale.forecast.read - View forecasts

Data Access

  • Forecasts are visible to all users with read permission
  • No company-level isolation (no company_id field)
  • Stock movements generated with admin user permissions
  • Consider implementing approval workflow for large forecasts

Configuration Settings

Required Settings

Setting Location Description
pick_out_journal_id settings (ID=1) Stock journal for outbound movements
sale_forecast sequence sequence model Auto-numbering for forecasts

Optional Settings

Setting Default Description
Location None Default warehouse for new forecasts

Integration Points

Internal Modules

  • Stock Management: Generates pending stock movements for forecast demand
  • Production Planning: Creates production plans based on forecasted quantities
  • Purchase Management: Generates purchase orders for raw materials
  • Sales Orders: Compares forecast vs actual sales for variance analysis
  • Product Management: Links to products and their BoMs, suppliers, lead times

Version History

Last Updated: 2026-01-05 Model Version: sale_forecast.py (245 lines) Framework: Netforce


Additional Resources

  • Sales Forecast Line Documentation: sale.forecast.line
  • Sales Target Documentation: sale.target
  • Stock Movement Documentation: stock.move
  • Production Planning Documentation: production.plan
  • Purchase Order Documentation: purchase.order

Support & Feedback

For issues or questions about this module: 1. Check related model documentation (sale.forecast.line, stock.move) 2. Review system logs for detailed error messages 3. Verify location and sequence configuration 4. Ensure products have suppliers and BoMs where required 5. Test in development environment first


This documentation is generated for developer onboarding and reference purposes.