Skip to content

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:

_key = ["company_id", "type", "number"]

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:

CREATE UNIQUE INDEX stock_picking_key_uniq 
    ON stock_picking (company_id, type, number);

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:

draft → pending → approved → done
                             voided
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

  1. Stock Move - Deep dive into movements
  2. Stock Picking - Document-level operations
  3. Stock Balance - Understanding inventory calculations
  4. Stock Location - Location management
  5. 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

  1. Create a simple receipt:
  2. Follow Pattern 1 above
  3. Verify stock balance updates

  4. Issue stock with lot tracking:

  5. Create lot numbers
  6. Link to stock moves
  7. Verify traceability

  8. Generate a stock report:

  9. Use report.stock.summary
  10. Filter by date range
  11. 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

get_model("stock.picking").set_done([picking_id])

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

  1. Enable SQL logging:

    import logging
    logging.getLogger("netforce.model").setLevel(logging.DEBUG)
    

  2. Check stock move states:

    move = get_model("stock.move").browse(move_id)
    print(f"State: {move.state}")
    print(f"From: {move.location_from_id.name}")
    print(f"To: {move.location_to_id.name}")
    

  3. 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:

  1. Everything is a stock move - Understand movements, you understand stock
  2. Balances are computed - They aggregate from moves, not stored separately
  3. Locations define context - Internal vs external determines inventory ownership
  4. States control flow - Only "done" state affects actual stock
  5. 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! 🚀