Sales Forecast Line Documentation¶
Overview¶
The Sales Forecast Line module (sale.forecast.line) represents individual line items within a sales forecast. It enables granular forecasting at the product and customer level, tracks actual sales performance against planned quantities, and provides variance analysis capabilities for accurate demand planning and performance measurement.
Model Information¶
Model Name: sale.forecast.line
Display Name: Sales Forecast Line
Key Fields: None (relies on parent forecast uniqueness)
Features¶
- ❌ Audit logging enabled (
_audit_log) - ❌ Multi-company support (
company_id) - ❌ Full-text content search (
_content_search) - ✅ Cascade deletion when parent forecast is deleted
Forecast Line Segmentation¶
Forecast lines provide detailed breakdown capabilities:
| Segmentation | Description |
|---|---|
| Product-Level | Forecast specific products or SKUs |
| Customer-Level | Forecast demand by customer or customer group |
| Location-Level | Forecast by warehouse or distribution center |
| Shelf Life | Track shelf life requirements for perishable goods |
| Variance Tracking | Compare planned vs actual quantities |
| Historical Comparison | Compare with previous period forecasts |
Key Fields Reference¶
Header Fields¶
| Field | Type | Required | Description |
|---|---|---|---|
forecast_id |
Many2One | ✅ | Parent sales forecast (sale.forecast) |
product_id |
Many2One | ✅ | Product being forecasted |
plan_qty |
Decimal | ✅ | Planned/forecasted sales quantity |
sequence |
Integer | ❌ | Display order within forecast |
Segmentation Fields¶
| Field | Type | Description |
|---|---|---|
customer_id |
Many2One | Specific customer for this forecast line (contact model) |
location_id |
Many2One | Warehouse location for this line (stock.location) |
min_shelf_life |
Selection | Minimum shelf life requirement: "50" (50%) or "75" (75%) |
uom_id |
Many2One | Unit of measure (deprecated - use product UoM) |
Computed Fields¶
| Field | Type | Description |
|---|---|---|
actual_qty |
Decimal | Actual sales quantity from confirmed orders (computed) |
plan_out_qty |
Decimal | Planned issue quantity from stock movements (computed) |
plan_remain_qty |
Decimal | Remaining stock quantity at end of period (computed) |
prev_line_id |
Many2One | Previous period's forecast line for same product (computed) |
prev_plan_qty |
Decimal | Previous period's planned quantity (computed) |
prev_diff_percent |
Decimal | Percentage change from previous period (computed) |
Relationship Fields¶
| Field | Type | Description |
|---|---|---|
comments |
One2Many | User comments and notes (message model) |
API Methods¶
1. Create Record¶
Method: create(vals, context)
Creates a new forecast line item within a sales forecast.
Parameters:
vals = {
"forecast_id": 123, # Required: Parent forecast ID
"product_id": 10, # Required: Product to forecast
"plan_qty": 1000, # Required: Planned quantity
"customer_id": 5, # Optional: Specific customer
"location_id": 1, # Optional: Warehouse location
"min_shelf_life": "75", # Optional: Shelf life requirement
"sequence": 10 # Optional: Display order
}
context = {
"company_id": 1 # Current company context
}
Returns: int - New line ID
Example:
# Add product forecast line for specific customer
line_id = get_model("sale.forecast.line").create({
"forecast_id": forecast_id,
"product_id": 10,
"customer_id": 5,
"plan_qty": 1000,
"min_shelf_life": "75"
})
2. Get Actual Quantity¶
Method: get_actual_qty(ids, context)
Computed field function that calculates actual sales from confirmed orders.
Behavior: - Searches sale.order.line for orders in confirmed/done state - Filters by forecast period dates (date_from to date_to) - Filters by product_id - Optionally filters by customer_id if specified - Converts quantities to product's base UoM - Returns total actual sales quantity
Example:
# Automatically calculated when browsing line
line = get_model("sale.forecast.line").browse(line_id)
print(f"Planned: {line.plan_qty}, Actual: {line.actual_qty}")
variance = line.plan_qty - line.actual_qty
3. Get Planned Issue Quantity¶
Method: get_plan_out_qty(ids, context)
Computed field function that calculates planned stock issues from pending movements.
Behavior: - Searches stock.move for movements in pending/approved/done states - Filters by forecast period dates - Filters by product_id and location_from_id - Optionally filters by customer contact_id - Converts quantities to line's UoM - Returns total planned outbound quantity
Example:
# Check planned stock issues
line = get_model("sale.forecast.line").browse(line_id)
print(f"Planned issues: {line.plan_out_qty}")
4. Get Planned Remaining Quantity¶
Method: get_plan_remain_qty(ids, context)
Computed field function that calculates remaining stock at end of forecast period.
Behavior: - Queries stock.move for all movements up to period end date - Calculates inbound quantities (location_to_id = line location) - Calculates outbound quantities (location_from_id = line location) - Returns balance: inbound - outbound - Uses direct database queries for performance
Example:
# Check projected remaining stock
line = get_model("sale.forecast.line").browse(line_id)
print(f"Remaining stock at period end: {line.plan_remain_qty}")
5. Get Previous Line¶
Method: get_prev_line(ids, context)
Computed field function that finds the previous period's forecast line.
Behavior: - Searches for forecast line with same product_id and min_shelf_life - Filters for forecasts with date_to before current forecast - Orders by date_to descending (most recent first) - Returns most recent previous line or None
Example:
# Compare with previous period
line = get_model("sale.forecast.line").browse(line_id)
if line.prev_line_id:
print(f"Previous period: {line.prev_plan_qty}")
print(f"Change: {line.prev_diff_percent}%")
6. Get Previous Period Data¶
Method: get_prev(ids, context)
Computed field function that calculates previous period comparison metrics.
Behavior: - Uses prev_line_id to get previous period's line - Calculates prev_plan_qty from previous line - Calculates prev_diff_percent: (current - previous) / previous * 100 - Returns None if no previous period exists
Returns: dict - Multi-field computed values:
UI Events (onchange methods)¶
onchange_product¶
Triggered when product is selected. Updates:
- uom_id - Sets to product's default unit of measure
Usage:
data = {
"product_id": 10,
"plan_qty": 100
}
result = get_model("sale.forecast.line").onchange_product(
context={"data": data}
)
# Returns updated data with uom_id set
onchange_customer¶
Triggered when customer is selected. Updates:
- min_shelf_life - Sets to customer's minimum shelf life requirement
Usage:
data = {
"forecast_id": 123,
"product_id": 10,
"customer_id": 5
}
result = get_model("sale.forecast.line").onchange_customer(
context={"data": data}
)
# Returns updated data with min_shelf_life set from customer
Search Functions¶
Search by Forecast¶
Search by Product¶
Search by Customer¶
Search by Period and Product¶
# Find forecast lines for product in date range
condition = [
["product_id", "=", 10],
["forecast_id.date_from", ">=", "2026-01-01"],
["forecast_id.date_to", "<=", "2026-03-31"]
]
Search by Shelf Life Requirement¶
Computed Fields Functions¶
get_actual_qty(ids, context)¶
Calculates actual sales quantity from confirmed sale orders during the forecast period, filtered by product and optionally by customer.
get_plan_out_qty(ids, context)¶
Calculates planned stock issues from pending/approved stock movements during the forecast period.
get_plan_remain_qty(ids, context)¶
Calculates projected remaining stock quantity at the end of the forecast period based on all planned movements.
get_prev_line(ids, context)¶
Finds the forecast line from the previous period for the same product and shelf life requirement.
get_prev(ids, context)¶
Calculates comparison metrics (quantity and percentage change) versus the previous period's forecast.
Best Practices¶
1. Product-Customer Segmentation¶
# Bad: Single aggregate line
line = {
"product_id": 10,
"plan_qty": 5000 # Total for all customers
}
# Good: Segment by major customers
lines = [
{"product_id": 10, "customer_id": 5, "plan_qty": 2000}, # Customer A
{"product_id": 10, "customer_id": 8, "plan_qty": 1500}, # Customer B
{"product_id": 10, "plan_qty": 1500} # Others
]
Why: Customer-level segmentation enables better variance analysis and account management.
2. Shelf Life Management¶
For perishable products, always specify shelf life requirements:
# Good: Explicit shelf life requirements
line = {
"product_id": 10,
"customer_id": 5,
"plan_qty": 1000,
"min_shelf_life": "75" # Customer requires 75% remaining shelf life
}
Why: Ensures proper inventory rotation and customer satisfaction for perishable goods.
3. Regular Variance Review¶
Monitor actual vs planned quantities throughout the forecast period:
# Review forecast accuracy mid-period
forecast = get_model("sale.forecast").browse(forecast_id)
for line in forecast.lines:
if line.actual_qty > 0: # Has sales data
variance_pct = ((line.actual_qty - line.plan_qty) / line.plan_qty) * 100
if abs(variance_pct) > 20:
print(f"ALERT: {line.product_id.name}")
print(f" Customer: {line.customer_id.name if line.customer_id else 'All'}")
print(f" Variance: {variance_pct:.1f}%")
print(f" Planned: {line.plan_qty}, Actual: {line.actual_qty}")
Why: Early detection of variances enables proactive adjustments to inventory and production.
4. Sequence Management¶
Use sequence field to organize lines logically:
# Organize by product category, then customer importance
lines = [
# Category A products
{"product_id": 10, "customer_id": 5, "plan_qty": 1000, "sequence": 10},
{"product_id": 10, "customer_id": 8, "plan_qty": 500, "sequence": 20},
# Category B products
{"product_id": 20, "customer_id": 5, "plan_qty": 750, "sequence": 30},
{"product_id": 20, "customer_id": 8, "plan_qty": 250, "sequence": 40}
]
Why: Logical organization improves readability and makes forecast review more efficient.
5. Historical Trend Analysis¶
Leverage previous period comparison for better forecasting:
# Analyze trends and adjust forecasts
lines = get_model("sale.forecast.line").search_browse([
["forecast_id", "=", current_forecast_id]
])
for line in lines:
if line.prev_line_id:
# Check if previous forecast was accurate
prev_variance = line.prev_line_id.actual_qty - line.prev_line_id.plan_qty
prev_variance_pct = (prev_variance / line.prev_line_id.plan_qty) * 100
# Adjust current forecast based on historical accuracy
if abs(prev_variance_pct) > 10:
print(f"Consider adjusting {line.product_id.name}:")
print(f" Previous variance: {prev_variance_pct:.1f}%")
print(f" Current vs previous: {line.prev_diff_percent:.1f}%")
Why: Historical accuracy data improves future forecast precision.
Database Constraints¶
Cascade Delete Constraint¶
"forecast_id": fields.Many2One("sale.forecast", "Sales Forecast",
required=True, on_delete="cascade")
When a forecast is deleted, all its lines are automatically deleted to maintain referential integrity.
Related Models¶
| Model | Relationship | Description |
|---|---|---|
sale.forecast |
Many2One | Parent sales forecast header |
product |
Many2One | Product being forecasted |
contact |
Many2One | Customer for segmented forecasting |
stock.location |
Many2One | Warehouse location for the forecast line |
uom |
Many2One | Unit of measure (deprecated) |
sale.order.line |
Computed | Source of actual sales quantities |
stock.move |
Computed | Source of planned stock movements |
message |
One2Many | Comments and notes |
Common Use Cases¶
Use Case 1: Create Product-Customer Forecast Matrix¶
# Create detailed forecast with product-customer breakdown
# 1. Define forecast matrix
forecast_matrix = [
{"product": 10, "customer": 5, "qty": 1000},
{"product": 10, "customer": 8, "qty": 500},
{"product": 15, "customer": 5, "qty": 750},
{"product": 15, "customer": 8, "qty": 250},
]
# 2. Create forecast header
forecast_id = get_model("sale.forecast").create({
"date_from": "2026-01-01",
"date_to": "2026-03-31",
"location_id": 1
})
# 3. Create forecast lines
for item in forecast_matrix:
get_model("sale.forecast.line").create({
"forecast_id": forecast_id,
"product_id": item["product"],
"customer_id": item["customer"],
"plan_qty": item["qty"]
})
# 4. Generate stock movements
forecast = get_model("sale.forecast").browse(forecast_id)
forecast.update_stock()
Use Case 2: Variance Analysis Dashboard¶
# Build variance analysis report for management
# 1. Get current period forecast
forecast = get_model("sale.forecast").browse(forecast_id)
# 2. Calculate variances
variances = []
for line in forecast.lines:
if line.plan_qty > 0:
variance_qty = line.actual_qty - line.plan_qty
variance_pct = (variance_qty / line.plan_qty) * 100
accuracy = 100 - abs(variance_pct)
variances.append({
"product": line.product_id.name,
"product_code": line.product_id.code,
"customer": line.customer_id.name if line.customer_id else "All Customers",
"planned": float(line.plan_qty),
"actual": float(line.actual_qty),
"variance_qty": float(variance_qty),
"variance_pct": float(variance_pct),
"accuracy": float(accuracy)
})
# 3. Sort by absolute variance
variances.sort(key=lambda x: abs(x["variance_pct"]), reverse=True)
# 4. Generate report
print("FORECAST VARIANCE ANALYSIS")
print(f"Period: {forecast.date_from} to {forecast.date_to}")
print("\nTop Variances:")
for v in variances[:10]:
print(f"\n{v['product']} ({v['product_code']}) - {v['customer']}")
print(f" Planned: {v['planned']:.0f}, Actual: {v['actual']:.0f}")
print(f" Variance: {v['variance_pct']:.1f}% (Accuracy: {v['accuracy']:.1f}%)")
# 5. Summary statistics
total_planned = sum(v["planned"] for v in variances)
total_actual = sum(v["actual"] for v in variances)
overall_accuracy = (1 - abs(total_actual - total_planned) / total_planned) * 100
print(f"\nOverall Accuracy: {overall_accuracy:.1f}%")
Use Case 3: Copy and Adjust from Previous Period¶
# Create new forecast based on previous period with adjustments
# 1. Get previous period lines
prev_forecast = get_model("sale.forecast").search_browse([
["date_to", "=", "2025-12-31"]
])[0]
# 2. Create new forecast header
new_forecast_id = get_model("sale.forecast").create({
"date_from": "2026-01-01",
"date_to": "2026-03-31",
"location_id": prev_forecast.location_id.id
})
# 3. Copy lines with growth adjustment
for prev_line in prev_forecast.lines:
# Calculate growth based on previous variance
if prev_line.actual_qty > 0:
growth_factor = prev_line.actual_qty / prev_line.plan_qty
else:
growth_factor = 1.0
# Apply growth and add 5% optimistic increase
new_qty = prev_line.plan_qty * growth_factor * 1.05
get_model("sale.forecast.line").create({
"forecast_id": new_forecast_id,
"product_id": prev_line.product_id.id,
"customer_id": prev_line.customer_id.id if prev_line.customer_id else None,
"plan_qty": new_qty,
"min_shelf_life": prev_line.min_shelf_life
})
print(f"Created new forecast with {len(prev_forecast.lines)} lines")
Use Case 4: Shelf Life Requirement Tracking¶
# Manage products with shelf life requirements
# 1. Find all forecast lines for perishable products
perishable_lines = get_model("sale.forecast.line").search_browse([
["forecast_id.state", "=", "open"],
["min_shelf_life", "!=", None]
])
# 2. Group by shelf life requirement
shelf_life_75 = [l for l in perishable_lines if l.min_shelf_life == "75"]
shelf_life_50 = [l for l in perishable_lines if l.min_shelf_life == "50"]
# 3. Report requirements
print("SHELF LIFE REQUIREMENTS")
print(f"\n75% Minimum Shelf Life ({len(shelf_life_75)} lines):")
for line in shelf_life_75:
print(f" {line.product_id.name}: {line.plan_qty} units")
print(f" Customer: {line.customer_id.name if line.customer_id else 'Various'}")
print(f"\n50% Minimum Shelf Life ({len(shelf_life_50)} lines):")
for line in shelf_life_50:
print(f" {line.product_id.name}: {line.plan_qty} units")
print(f" Customer: {line.customer_id.name if line.customer_id else 'Various'}")
# 4. Check stock expiration planning
for line in perishable_lines:
# This would integrate with lot tracking
print(f"\nAlert: Check expiration dates for {line.product_id.name}")
print(f" Required shelf life: {line.min_shelf_life}%")
print(f" Forecasted demand: {line.plan_qty}")
Use Case 5: Multi-Location Forecasting¶
# Forecast same product across multiple locations
# 1. Define product across locations
product_id = 10
locations = [
{"location_id": 1, "name": "Main Warehouse", "qty": 2000},
{"location_id": 2, "name": "Regional DC", "qty": 1000},
{"location_id": 3, "name": "Retail Store", "qty": 500}
]
# 2. Create forecast
forecast_id = get_model("sale.forecast").create({
"date_from": "2026-01-01",
"date_to": "2026-03-31"
})
# 3. Create lines per location
for loc in locations:
get_model("sale.forecast.line").create({
"forecast_id": forecast_id,
"product_id": product_id,
"location_id": loc["location_id"],
"plan_qty": loc["qty"]
})
# 4. Analyze location performance
forecast = get_model("sale.forecast").browse(forecast_id)
for line in forecast.lines:
location_name = line.location_id.name if line.location_id else "N/A"
variance = line.actual_qty - line.plan_qty
print(f"{location_name}: Planned {line.plan_qty}, Actual {line.actual_qty}, Variance {variance}")
Performance Tips¶
1. Minimize Computed Field Calls¶
# Bad: Multiple database queries
for line in forecast.lines:
print(line.actual_qty) # Query
print(line.plan_out_qty) # Query
print(line.plan_remain_qty) # Query
# Good: Access computed fields once
for line in forecast.lines:
actual = line.actual_qty # Single query per field type
planned = line.plan_out_qty
remain = line.plan_remain_qty
print(f"Actual: {actual}, Planned: {planned}, Remain: {remain}")
2. Use Bulk Operations¶
# Bad: Create lines one by one
for item in items:
get_model("sale.forecast.line").create({
"forecast_id": forecast_id,
"product_id": item["product_id"],
"plan_qty": item["qty"]
})
# Good: Create during forecast creation
get_model("sale.forecast").create({
"date_from": "2026-01-01",
"date_to": "2026-03-31",
"lines": [
("create", {"product_id": item["product_id"], "plan_qty": item["qty"]})
for item in items
]
})
3. Efficient Searches¶
The model orders by sequence,id by default:
# Efficient: Uses indexed fields
lines = get_model("sale.forecast.line").search_browse([
["forecast_id", "=", 123],
["product_id", "=", 10]
])
Troubleshooting¶
"Forecast line showing zero actual_qty despite sales"¶
Cause: Sales orders may not be in confirmed/done state, or dates don't match forecast period Solution: Verify order states and dates:
# Check sales orders in period
orders = get_model("sale.order.line").search_browse([
["product_id", "=", line.product_id.id],
["order_id.due_date", ">=", line.forecast_id.date_from],
["order_id.due_date", "<=", line.forecast_id.date_to]
])
for order in orders:
print(f"Order {order.order_id.number}: State={order.order_id.state}, Qty={order.qty}")
"prev_line_id is None but previous forecasts exist"¶
Cause: Product ID or min_shelf_life doesn't match previous periods Solution: Check exact product and shelf life matching:
# Find potential previous lines
prev_lines = get_model("sale.forecast.line").search_browse([
["product_id", "=", line.product_id.id],
["forecast_id.date_to", "<", line.forecast_id.date_to]
], order="forecast_id.date_to desc")
for prev in prev_lines:
print(f"Previous: Date={prev.forecast_id.date_to}, Shelf Life={prev.min_shelf_life}")
"UoM conversion errors in actual_qty"¶
Cause: Sale order lines using different UoM than product Solution: Ensure UoM conversion is properly configured in uom model
Testing Examples¶
Unit Test: Variance Calculation¶
def test_line_variance():
# Create forecast with line
forecast_id = get_model("sale.forecast").create({
"date_from": "2025-12-01",
"date_to": "2025-12-31",
"lines": [
("create", {
"product_id": 10,
"plan_qty": 1000
})
]
})
# Get line
line = get_model("sale.forecast.line").search_browse([
["forecast_id", "=", forecast_id]
])[0]
# Verify computed fields
assert hasattr(line, "actual_qty")
assert hasattr(line, "plan_out_qty")
# Calculate variance
variance = line.actual_qty - line.plan_qty
assert isinstance(variance, (int, float, Decimal))
Security Considerations¶
Permission Model¶
- Inherits permissions from parent sale.forecast model
sale.forecast.line.create- Create new linessale.forecast.line.write- Edit existing linessale.forecast.line.delete- Delete linessale.forecast.line.read- View lines
Data Access¶
- Lines are accessible if parent forecast is accessible
- No separate company-level isolation
- Cascade delete ensures referential integrity
Configuration Settings¶
Required Settings¶
| Setting | Location | Description |
|---|---|---|
| Parent Forecast | sale.forecast | Must exist before creating lines |
| Product | product | Valid product with UoM configuration |
Integration Points¶
Internal Modules¶
- Sales Forecast: Parent forecast header
- Sales Orders: Source of actual sales data for variance analysis
- Stock Management: Source of planned stock movements and remaining quantities
- Product Management: Links to products, UoMs, and specifications
- Customer Management: Links to customers for segmented forecasting
Version History¶
Last Updated: 2026-01-05 Model Version: sale_forecast_line.py (134 lines) Framework: Netforce
Additional Resources¶
- Sales Forecast Documentation:
sale.forecast - Sales Target Documentation:
sale.target - Sales Order Line Documentation:
sale.order.line - Stock Movement Documentation:
stock.move - Product Documentation:
product
Support & Feedback¶
For issues or questions about this module: 1. Check parent sale.forecast documentation 2. Verify product and customer configurations 3. Review computed field calculations for variance analysis 4. Ensure date ranges align with sales order due dates 5. Test in development environment first
This documentation is generated for developer onboarding and reference purposes.