Stock Module Overview for New Developers¶
Introduction¶
Welcome to the Netforce Stock Management Module! This guide is designed for developers who are new to the Netforce framework and need to understand how inventory management works in the system. Whether you're implementing new features, fixing bugs, or integrating with external systems, this overview will help you navigate the stock module effectively.
What is the Stock Module?¶
The Stock Module is the core inventory management system in Netforce. It handles:
- Physical inventory tracking - Monitor stock quantities across multiple locations
- Inventory movements - Track all stock transfers (receipts, issues, transfers)
- Lot/Serial number tracking - Full traceability for products
- Inventory costing - FIFO, Average, and Standard costing methods
- Warehouse operations - Multi-warehouse and multi-location support
- Forecasting & planning - Predict stock needs and automate reordering
- Quality control - Inspection and validation workflows
- Reporting - Comprehensive inventory analytics
Core Concepts¶
1. Stock Movements: The Foundation¶
Everything in the stock module revolves around stock movements (stock.move). Think of a stock move as a single line in a ledger:
Product X moved from Location A to Location B
Quantity: 100 units
Date: 2024-10-15
Cost: $50.00 per unit
Key Insight: Stock balances are NOT stored as static numbers. Instead, they are calculated from stock moves. The system aggregates all movements to determine how much stock exists at any location.
2. Stock Pickings: The Container¶
Stock moves are grouped into stock pickings (stock.picking). A picking represents a complete transaction:
- Goods Receipt (in): Receiving stock from suppliers
- Goods Issue (out): Shipping stock to customers
- Internal Transfer (internal): Moving stock between locations
Think of a picking as a document that contains multiple line items (stock moves).
3. Locations: Where Stock Lives¶
Stock locations (stock.location) define physical or logical places where inventory exists:
| Location Type | Examples | Purpose |
|---|---|---|
| Internal | Main Warehouse, Retail Store | Physical storage |
| Customer | Customer Site A, Shipping Address | Customer delivery points |
| Supplier | Supplier ABC, Vendor Location | Supplier sources |
| Virtual | Production, Waste, Adjustment | Logical locations for transformations |
Important: Stock can only exist in internal locations. Moving stock to a customer location means it's no longer in your inventory.
4. Lots & Serial Numbers: Traceability¶
Lots (stock.lot) enable detailed tracking:
- Batch Tracking: Group products by manufacturing batch (e.g., LOT-2024-001)
- Serial Numbers: Track individual units (e.g., SN-12345)
- Expiry Management: Track expiration dates for perishable goods
- Quality Control: Link lots to inspection results
5. Stock Balance: Real-Time Inventory¶
The stock balance (stock.balance) table provides quick access to current inventory levels:
{
"product_id": 100,
"location_id": 5,
"lot_id": 25,
"qty_phys": 150.0, # Physical quantity (completed moves)
"qty_virt": 180.0, # Virtual quantity (physical + planned)
"qty_in": 50.0, # Planned incoming
"qty_out": 20.0, # Planned outgoing
"amount": 7500.0 # Inventory value
}
Physical vs Virtual Quantity: - Physical (qty_phys): What you have RIGHT NOW (completed moves) - Virtual (qty_virt): What you WILL have (physical + pending receipts - pending shipments)
Use virtual quantity for order promising and planning!
Understanding Key Fields in Models¶
What are "Key Fields"?¶
In Netforce, key fields are a combination of fields that must be unique together. They create a composite business key for data integrity.
Example from stock.picking:
This means: - Company A + type "in" + number "GR-001" ✅ Valid - Company A + type "out" + number "GR-001" ✅ Valid (different type) - Company A + type "in" + number "GR-001" ❌ ERROR! Duplicate key
Database Implementation:
Why It Matters: - Prevents duplicate document numbers - Ensures data integrity at the database level - Allows the same number across different contexts (companies, types)
Stock Module Architecture¶
Model Hierarchy¶
┌─────────────────────────────────────┐
│ CORE MODELS │
├─────────────────────────────────────┤
│ stock.picking (Document Level) │
│ └─ stock.move (Line Level) │
│ ├─ product │
│ ├─ stock.location (from/to) │
│ └─ stock.lot (optional) │
└─────────────────────────────────────┘
↓ movements recorded
┌─────────────────────────────────────┐
│ AGGREGATION │
├─────────────────────────────────────┤
│ stock.balance │
│ (Computed from stock.move) │
└─────────────────────────────────────┘
Data Flow: How Inventory Moves¶
Scenario: Receive 100 units from supplier
1. CREATE Picking
┌──────────────────────────┐
│ stock.picking │
│ - type: "in" │
│ - contact: Supplier ABC │
│ - date: 2024-10-15 │
└──────────────────────────┘
↓
2. ADD Movement Lines
┌──────────────────────────┐
│ stock.move │
│ - product_id: 100 │
│ - location_from: Supplier│
│ - location_to: Warehouse │
│ - qty: 100 │
│ - cost_price: 50.00 │
│ - state: draft │
└──────────────────────────┘
↓
3. VALIDATE Picking (set_done)
┌──────────────────────────┐
│ stock.move │
│ - state: done ✓ │
└──────────────────────────┘
↓
4. UPDATE Stock Balance (automatic)
┌──────────────────────────┐
│ stock.balance │
│ - product_id: 100 │
│ - location_id: Warehouse │
│ - qty_phys: +100 │
│ - amount: +5000.00 │
└──────────────────────────┘
↓
5. POST to Accounting (if perpetual costing enabled)
┌──────────────────────────┐
│ account.move │
│ - Debit: Inventory │
│ - Credit: AP/GRN │
└──────────────────────────┘
State Workflow¶
Stock pickings and moves follow a state machine:
| State | Editable | Stock Updated | Accounting Posted |
|---|---|---|---|
draft |
✅ Yes | ❌ No | ❌ No |
pending |
⚠️ Limited | ❌ No | ❌ No |
approved |
⚠️ Limited | ❌ No | ❌ No |
done |
❌ No | ✅ Yes | ✅ Yes (if enabled) |
voided |
❌ No | ❌ No | ❌ No |
Important: Only done state affects stock balances!
Common Development Patterns¶
Pattern 1: Create and Validate a Stock Receipt¶
# 1. Create goods receipt picking
picking_id = get_model("stock.picking").create({
"type": "in",
"journal_id": 1, # Goods receipt journal
"contact_id": supplier_id,
"date": "2025-10-15 10:00:00",
"lines": [
("create", {
"product_id": 100,
"qty": 50,
"uom_id": 1,
"location_from_id": 5, # Supplier location
"location_to_id": 10, # Warehouse location
"cost_price_cur": 25.50
})
]
}, context={"pick_type": "in"})
# 2. Move through workflow
get_model("stock.picking").pending([picking_id])
get_model("stock.picking").approve([picking_id])
get_model("stock.picking").set_done([picking_id])
# Now stock is updated!
Pattern 2: Check Stock Availability¶
# Get current stock at location
balances = get_model("stock.balance").search_browse([
["product_id", "=", product_id],
["location_id", "=", warehouse_id],
["qty_phys", ">", 0] # Only non-zero balances
])
total_physical = sum(b.qty_phys for b in balances)
total_virtual = sum(b.qty_virt for b in balances)
if total_virtual >= order_qty:
print("Can fulfill order (including pending receipts)")
elif total_physical >= order_qty:
print("Can fulfill now, but may conflict with other orders")
else:
print(f"Insufficient stock. Need {order_qty - total_virtual} more units")
Pattern 3: Lot-Specific Operations¶
# Find lots for product with sufficient quantity
lots = get_model("stock.balance").search_browse([
["product_id", "=", product_id],
["location_id", "=", warehouse_id],
["lot_id", "!=", None],
["qty_phys", ">=", required_qty]
])
# Sort by FIFO (first in, first out)
lots_sorted = sorted(lots, key=lambda x: x.lot_id.received_date or "")
# Or sort by FEFO (first expired, first out)
lots_fefo = sorted(
[l for l in lots if l.lot_id.expiry_date],
key=lambda x: x.lot_id.expiry_date
)
# Use oldest/soonest expiring lot
selected_lot = lots_fefo[0] if lots_fefo else lots_sorted[0]
Pattern 4: Update Stock Balances Manually¶
# Stock balances update automatically, but you can force refresh
get_model("stock.balance").do_update_balances()
# Update specific locations only (more efficient)
get_model("stock.balance").do_update_balances(
context={"update_loc_ids": [warehouse_id]}
)
# Full recalculation (expensive!)
get_model("stock.balance").do_update_balances(
context={"update_all": True}
)
Critical Concepts for Developers¶
1. Stock Balance Updates are Asynchronous¶
When you create stock moves, balances don't update immediately in code:
# Create movement
move_id = get_model("stock.move").create({...})
# Balance NOT yet updated!
balance = get_model("stock.balance").search([...]) # May be old
# Set to done (validates movement)
get_model("stock.move").set_done([move_id])
# Triggers balance update via stock.balance.update table
# Balance will be updated by next balance calculation
Trigger Mechanism:
# When stock move is created/updated
INSERT INTO stock_balance_update (product_id, lot_id)
VALUES (100, 50);
# Scheduled job processes these updates
get_model("stock.balance").do_update_balances()
2. Perpetual Inventory Costing¶
If settings.stock_cost_mode == "perpetual", stock movements create accounting entries:
# Goods receipt
Debit: Inventory Asset Account $5,000
Credit: Goods Received Not Invoiced $5,000
# Goods issue
Debit: Cost of Goods Sold $5,000
Credit: Inventory Asset Account $5,000
Configuration Required:
- Location must have account_id set
- Product category must have cost accounts configured
- Stock journal must have accounting settings
3. Costing Methods¶
Products can use different costing methods:
| Method | Description | Use Case |
|---|---|---|
| FIFO | First In, First Out | Default for most products |
| Average | Weighted average cost | Commodities, bulk items |
| Standard | Fixed standard cost | Manufacturing, budgeting |
FIFO Example:
Receipt 1: 100 units @ $10 = $1,000
Receipt 2: 100 units @ $12 = $1,200
Issue: 150 units
Cost calculation (FIFO):
- 100 units @ $10 = $1,000
- 50 units @ $12 = $600
Total cost: $1,600
Average cost per unit issued: $10.67
4. Multi-Location Inventory¶
Stock can exist in multiple locations simultaneously:
# Check stock across all locations
all_balances = get_model("stock.balance").search_browse([
["product_id", "=", product_id],
["location_id.type", "=", "internal"], # Only internal
["qty_phys", ">", 0]
])
for balance in all_balances:
print(f"{balance.location_id.name}: {balance.qty_phys} units")
# Total across all locations
total = sum(b.qty_phys for b in all_balances)
5. Virtual vs Physical Transactions¶
Some locations are "virtual" (don't hold physical stock):
- Production: Components consumed, finished goods produced
- Waste: Damaged/expired goods disposal
- Inventory: Adjustments (losses, corrections)
Moving stock TO these locations removes it from inventory.
Essential Models Reference¶
Core Transaction Models¶
| Model | Purpose | Key Fields |
|---|---|---|
stock.picking |
Transaction document | type, number, date, state |
stock.move |
Transaction line | product_id, qty, location_from_id, location_to_id |
stock.journal |
Movement type definition | type, sequence_id, location defaults |
Master Data Models¶
| Model | Purpose | Key Fields |
|---|---|---|
stock.location |
Storage locations | name, type, account_id |
stock.lot |
Lot/serial numbers | number, expiry_date, mfg_date |
product |
Products | name, code, cost_method |
uom |
Units of measure | name, type |
Aggregation Models¶
| Model | Purpose | Update Method |
|---|---|---|
stock.balance |
Current stock levels | Computed from stock.move |
Configuration Models¶
| Model | Purpose | Key Fields |
|---|---|---|
stock.orderpoint |
Min/max reorder rules | product_id, location_id, min_qty, max_qty |
stock.forecast |
Forecasted movements | Auto-generated by system |
Advanced Models¶
| Model | Purpose | Use Case |
|---|---|---|
landed.cost |
Import cost allocation | Freight, duties, customs |
stock.consign |
Consignment tracking | Vendor-managed inventory |
central.order |
Multi-channel orders | E-commerce fulfillment |
stock.container |
Container tracking | Pallet/box management |
Common Pitfalls & Solutions¶
Pitfall 1: Editing Done Movements¶
Problem:
# This will FAIL!
move = get_model("stock.move").browse(move_id)
if move.state == "done":
move.write({"qty": 150}) # ERROR: Cannot edit done move
Solution:
# Revert to draft first
get_model("stock.move").to_draft([move_id])
# Now you can edit
get_model("stock.move").write([move_id], {"qty": 150})
# Re-validate
get_model("stock.move").set_done([move_id])
Pitfall 2: Negative Stock¶
Problem:
# Trying to issue more than available
get_model("stock.move").create({
"product_id": 100,
"location_from_id": warehouse_id,
"location_to_id": customer_id,
"qty": 1000 # But only 50 available!
})
Solution:
# Check stock first
balance = get_model("stock.balance").compute_balance(
ids=[warehouse_id],
product_id=100
)
available = balance["bal_qty"]
if available >= required_qty:
# Proceed with movement
pass
else:
raise Exception(f"Insufficient stock. Available: {available}, Required: {required_qty}")
Pitfall 3: Forgetting Context¶
Problem:
# Number not auto-generated!
picking_id = get_model("stock.picking").create({
"type": "in",
"journal_id": 1
})
Solution:
# Always provide pick_type in context
picking_id = get_model("stock.picking").create({
"type": "in",
"journal_id": 1
}, context={"pick_type": "in"}) # Enables auto-numbering
Pitfall 4: Not Using Fast Methods¶
Problem:
# Slow: Processing one by one
for move_id in move_ids:
get_model("stock.move").set_done([move_id]) # Many DB calls
Solution:
# Fast: Batch processing
get_model("stock.move").set_done_fast(move_ids) # One operation
# For very large batches, use async
get_model("stock.move").set_done_fast_async(move_ids)
Pitfall 5: Ignoring Location Types¶
Problem:
# Using customer location as source!
get_model("stock.move").create({
"product_id": 100,
"location_from_id": customer_location_id, # Wrong!
"location_to_id": warehouse_id,
"qty": 50
})
Solution:
# Always check location types
location = get_model("stock.location").browse(location_id)
if location.type == "customer":
# This is a return, use return journal
journal_id = return_journal_id
elif location.type == "supplier":
# This is a receipt, use receipt journal
journal_id = receipt_journal_id
Testing Your Stock Code¶
Unit Test Template¶
def test_stock_receipt():
# Setup
product_id = create_test_product()
supplier_loc = get_supplier_location()
warehouse_loc = get_warehouse_location()
journal_id = get_receipt_journal()
# Create picking
picking_id = get_model("stock.picking").create({
"type": "in",
"journal_id": journal_id,
"lines": [
("create", {
"product_id": product_id,
"qty": 100,
"uom_id": 1,
"location_from_id": supplier_loc,
"location_to_id": warehouse_loc,
"cost_price": 50.00
})
]
}, context={"pick_type": "in"})
# Verify draft state
picking = get_model("stock.picking").browse(picking_id)
assert picking.state == "draft"
# Validate
get_model("stock.picking").set_done([picking_id])
# Verify completed
picking = picking.browse()[0] # Refresh
assert picking.state == "done"
# Verify stock balance updated
balance = get_model("stock.balance").search_browse([
["product_id", "=", product_id],
["location_id", "=", warehouse_loc]
])
assert len(balance) > 0
assert balance[0].qty_phys == 100.0
print("✓ Test passed")
Performance Best Practices¶
1. Use Specific Filters¶
# BAD: Fetch everything
all_balances = get_model("stock.balance").search([])
# GOOD: Filter early
warehouse_balances = get_model("stock.balance").search([
["location_id", "=", warehouse_id],
["qty_phys", ">", 0],
["product_id.categ_id", "=", category_id]
])
2. Batch Operations¶
# BAD: Loop with individual operations
for product_id in product_ids:
balance = get_model("stock.balance").search([
["product_id", "=", product_id]
])
# GOOD: Single query with "in" operator
balances = get_model("stock.balance").search([
["product_id", "in", product_ids]
])
3. Use search_browse Instead of browse¶
# BAD: Fetch IDs then browse
ids = get_model("stock.balance").search([...])
for id in ids:
obj = get_model("stock.balance").browse(id) # Many queries
# GOOD: search_browse in one go
for obj in get_model("stock.balance").search_browse([...]):
# Process obj
4. Limit Result Sets¶
# Get top 100 results only
balances = get_model("stock.balance").search(
[["qty_phys", ">", 0]],
limit=100,
order="qty_phys desc"
)
Integration Points¶
Purchase Orders → Stock¶
# 1. Purchase order created
po = get_model("purchase.order").browse(po_id)
# 2. Create goods receipt from PO
vals = {
"type": "in",
"journal_id": gr_journal_id,
"contact_id": po.supplier_id.id,
"related_id": f"purchase.order,{po.id}",
"lines": []
}
for line in po.lines:
vals["lines"].append(("create", {
"product_id": line.product_id.id,
"qty": line.qty,
"uom_id": line.uom_id.id,
"cost_price_cur": line.unit_price
}))
picking_id = get_model("stock.picking").create(vals)
# 3. Validate receipt
get_model("stock.picking").set_done([picking_id])
# 4. Link to PO
po.write({"stock_moves": [("add", picking_id)]})
Sales Orders → Stock¶
# 1. Sales order confirmed
so = get_model("sale.order").browse(so_id)
# 2. Create goods issue
vals = {
"type": "out",
"journal_id": gi_journal_id,
"contact_id": so.customer_id.id,
"ship_address_id": so.ship_address_id.id,
"related_id": f"sale.order,{so.id}",
"lines": []
}
for line in so.lines:
vals["lines"].append(("create", {
"product_id": line.product_id.id,
"qty": line.qty,
"uom_id": line.uom_id.id,
"sale_price": line.unit_price
}))
picking_id = get_model("stock.picking").create(vals)
# 3. Auto-assign lots (FIFO/FEFO)
get_model("stock.picking").assign_lots([picking_id])
# 4. Validate shipment
get_model("stock.picking").set_done([picking_id])
Accounting Integration¶
# If perpetual costing enabled
settings = get_model("settings").browse(1)
if settings.stock_cost_mode == "perpetual":
# Stock movements create journal entries automatically
# Access journal entry from movement
move = get_model("stock.move").browse(move_id)
if move.move_id: # Accounting entry
journal_entry = get_model("account.move").browse(move.move_id)
for line in journal_entry.lines:
print(f"{line.account_id.name}: {line.debit or line.credit}")
Where to Go Next¶
Read These Docs Next¶
- Stock Move - Deep dive into movements
- Stock Picking - Document-level operations
- Stock Balance - Understanding inventory calculations
- Stock Location - Location management
- Stock Lot - Traceability and expiry
Key Documentation Files¶
- DOCUMENTATION-SUMMARY.md - Complete model list
- INDEX-COSTS-FORECASTING-ORDERS.md - Navigation guide
- appendix-stock-reports.md - All reporting models
Try These Examples¶
- Create a simple receipt:
- Follow Pattern 1 above
-
Verify stock balance updates
-
Issue stock with lot tracking:
- Create lot numbers
- Link to stock moves
-
Verify traceability
-
Generate a stock report:
- Use
report.stock.summary - Filter by date range
- Export to Excel
Quick Reference Cheat Sheet¶
Create Goods Receipt¶
get_model("stock.picking").create({
"type": "in",
"journal_id": 1,
"lines": [("create", {...})]
}, context={"pick_type": "in"})
Check Stock¶
balance = get_model("stock.balance").compute_balance(
ids=[location_id],
product_id=product_id
)
available = balance["bal_qty"]
Validate Movement¶
Find Low Stock¶
low = get_model("stock.balance").search([
["below_min", "=", True],
["location_id.type", "=", "internal"]
])
Get Movement History¶
moves = get_model("stock.move").search_browse([
["product_id", "=", product_id],
["date", ">=", "2024-01-01"],
["state", "=", "done"]
], order="date")
Getting Help¶
Common Error Messages¶
| Error | Cause | Solution |
|---|---|---|
| "Before stock lock date" | Backdating movements | Check settings.stock_lock_date |
| "Product is out of stock" | Negative stock check | Verify stock availability first |
| "Missing QC results" | QC required but not done | Create qc.result records |
| "User does not have permission" | Missing approval permission | Grant approve_pick_in/out/internal |
Debugging Tips¶
-
Enable SQL logging:
-
Check stock move states:
-
Verify balance calculation:
# Manual calculation moves_in = get_model("stock.move").search([ ["product_id", "=", product_id], ["location_to_id", "=", location_id], ["state", "=", "done"] ]) qty_in = sum(m.qty for m in moves_in) # Compare with balance balance = get_model("stock.balance").search_browse([ ["product_id", "=", product_id], ["location_id", "=", location_id] ]) assert abs(balance[0].qty_phys - (qty_in - qty_out)) < 0.01
Conclusion¶
The Netforce Stock Module is powerful but follows consistent patterns. Remember these key principles:
- Everything is a stock move - Understand movements, you understand stock
- Balances are computed - They aggregate from moves, not stored separately
- Locations define context - Internal vs external determines inventory ownership
- States control flow - Only "done" state affects actual stock
- Batching is faster - Use fast methods for bulk operations
Start with simple receipts and issues, then gradually explore advanced features like lot tracking, forecasting, and landed costs. The documentation is comprehensive - don't hesitate to dive deep into specific models as you need them.
Happy coding! 🚀