Your Annual Physical Inventory Is Costing You More Than You Think
Every year, thousands of warehouses shut their doors for a weekend—sometimes a full week—to count every item on every shelf. Forklifts idle. Orders queue. Customers wait. When the count finishes, the team discovers discrepancies that have been accumulating for 12 months, and by then the root causes are impossible to trace.
Cycle counting replaces that annual shutdown with a continuous, rolling audit. Instead of counting everything once a year, you count a small subset of items every day or every week. Discrepancies surface within days of occurring—while the root cause is still fresh enough to investigate and fix. Your warehouse never stops shipping.
Odoo 19's Inventory module supports cycle counting natively through inventory adjustments, barcode scanning, and scheduled actions. But the default setup requires deliberate configuration to work as a true perpetual audit system. This guide walks you through every step: how inventory adjustments differ from cycle counts, which scheduling strategy fits your warehouse, how to configure automated counts, how to use barcode scanning for speed, how to investigate discrepancies, and how the accounting entries flow.
Inventory Adjustment vs Cycle Count: Understanding the Difference in Odoo 19
Odoo uses the term "Inventory Adjustment" for both full physical inventories and cycle counts. This creates confusion. The mechanism is the same—a stock.quant record gets its inventory_quantity updated—but the scope and frequency are fundamentally different.
| Dimension | Full Physical Inventory | Cycle Count |
|---|---|---|
| Scope | Every product in every location | A targeted subset (one category, one aisle, one ABC class) |
| Frequency | Once or twice per year | Daily, weekly, or monthly—rotating through all stock |
| Warehouse impact | Full or partial shutdown required | Zero downtime—counting happens during normal operations |
| Discrepancy detection | Months after the error occurred | Days or weeks after the error occurred |
| Root cause traceability | Nearly impossible after 12 months | High—recent moves, receipts, and picks are still in context |
| Odoo mechanism | stock.quant → Apply All | stock.quant filtered by product/location/category → Apply |
In Odoo 19, there is no separate "Cycle Count" model. Cycle counting is a workflow discipline applied on top of the standard inventory adjustment features. The power comes from how you filter, schedule, and automate those adjustments—not from a different menu item.
Cycle Count Scheduling Strategies: ABC Analysis, Random, and Location-Based
The scheduling strategy determines which items get counted and when. Choose the wrong strategy and you'll either over-count low-value items or miss high-risk discrepancies entirely. Here are the three proven approaches.
ABC Analysis (Value-Based Frequency)
ABC analysis classifies products by their inventory value contribution. The principle: count high-value items more frequently because discrepancies in those items have a larger financial impact.
| Class | % of SKUs | % of Inventory Value | Count Frequency |
|---|---|---|---|
| A | ~20% | ~80% | Monthly (or even weekly for critical items) |
| B | ~30% | ~15% | Quarterly |
| C | ~50% | ~5% | Semi-annually or annually |
In Odoo, you implement ABC classification using product categories or a custom field on product.template. Create three categories (ABC-A, ABC-B, ABC-C) and assign products based on their value contribution. Then schedule inventory adjustments filtered by category.
from odoo import api, models
class ProductTemplate(models.Model):
_inherit = "product.template"
def _cron_update_abc_classification(self):
"""Recalculate ABC class based on last 12 months of sales."""
products = self.search([("type", "=", "product")])
# Sum delivered qty * cost for each product
ranked = []
for p in products:
moves = self.env["stock.move"].search([
("product_id.product_tmpl_id", "=", p.id),
("state", "=", "done"),
("picking_code", "=", "outgoing"),
("date", ">=", fields.Date.today() - relativedelta(months=12)),
])
total_value = sum(m.quantity * m.product_id.standard_price for m in moves)
ranked.append((p, total_value))
ranked.sort(key=lambda x: x[1], reverse=True)
cumulative = 0
grand_total = sum(v for _, v in ranked) or 1
abc_a = self.env.ref("my_module.product_categ_abc_a")
abc_b = self.env.ref("my_module.product_categ_abc_b")
abc_c = self.env.ref("my_module.product_categ_abc_c")
for product, value in ranked:
cumulative += value
pct = cumulative / grand_total
if pct <= 0.80:
product.categ_id = abc_a
elif pct <= 0.95:
product.categ_id = abc_b
else:
product.categ_id = abc_cRandom Sampling
Random sampling selects a fixed number of products each count period regardless of value. This approach is simpler to implement and removes bias, but it doesn't prioritize high-value or high-risk items. Best for warehouses where all items have roughly similar values or where regulatory requirements mandate unbiased sampling.
import random
from odoo import models
class StockQuant(models.Model):
_inherit = "stock.quant"
def _cron_random_cycle_count(self):
"""Select 50 random quants for today's cycle count."""
quants = self.search([
("location_id.usage", "=", "internal"),
("quantity", ">", 0),
])
sample_size = min(50, len(quants))
selected = random.sample(quants.ids, sample_size)
selected_quants = self.browse(selected)
# Set inventory_quantity_set to trigger count
for quant in selected_quants:
quant.inventory_quantity_set = TrueLocation-Based (Zone Counting)
Location-based counting rotates through warehouse zones on a fixed schedule. Monday you count Zone A (receiving area), Tuesday you count Zone B (fast-pick shelves), Wednesday you count Zone C (bulk storage), and so on. This approach works well for large warehouses with distinct zones because it keeps the counting team moving through a logical path without backtracking.
In Odoo, implement this by filtering inventory adjustments by location_id. Create child locations under your main warehouse that represent physical zones, then schedule counts per zone.
Most mature warehouses use a hybrid strategy: ABC classification determines the frequency, and location-based routing determines the daily execution path. A-class items in Zone B get counted on Tuesday (when Zone B is scheduled), but they'll come up again next month. C-class items in Zone B also get counted on Tuesday—but they won't reappear for six months.
Configuring Scheduled Cycle Counts in Odoo 19
Odoo 19 provides two mechanisms for scheduling cycle counts: the built-in inventory adjustment frequency on stock locations and custom scheduled actions (cron jobs) for more complex logic. Here's how to configure both.
Method 1: Location-Level Cycle Count Frequency
Every stock.location in Odoo has a cyclic_inventory_frequency field. This integer specifies how many days should pass between counts for that location. When the interval elapses, Odoo flags the location's quants as needing a count.
<odoo>
<!-- Fast-pick zone: count every 7 days -->
<record id="location_zone_fastpick" model="stock.location">
<field name="name">Zone A - Fast Pick</field>
<field name="location_id" ref="stock.stock_location_stock"/>
<field name="cyclic_inventory_frequency">7</field>
</record>
<!-- Bulk storage: count every 30 days -->
<record id="location_zone_bulk" model="stock.location">
<field name="name">Zone B - Bulk Storage</field>
<field name="location_id" ref="stock.stock_location_stock"/>
<field name="cyclic_inventory_frequency">30</field>
</record>
<!-- High-value cage: count every 3 days -->
<record id="location_zone_highvalue" model="stock.location">
<field name="name">Zone C - High Value</field>
<field name="location_id" ref="stock.stock_location_stock"/>
<field name="cyclic_inventory_frequency">3</field>
</record>
</odoo>Method 2: Custom Scheduled Action for ABC-Driven Counts
For ABC-based scheduling, you need a cron job that selects products based on their classification and generates inventory adjustment records:
<odoo>
<record id="ir_cron_daily_cycle_count" model="ir.cron">
<field name="name">Inventory: Generate Daily Cycle Counts</field>
<field name="model_id" ref="stock.model_stock_quant"/>
<field name="state">code</field>
<field name="code">model._cron_generate_cycle_counts()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
</odoo>from odoo import fields, models
from dateutil.relativedelta import relativedelta
class StockQuant(models.Model):
_inherit = "stock.quant"
def _cron_generate_cycle_counts(self):
"""Generate daily cycle count list based on ABC schedule."""
today = fields.Date.today()
abc_schedule = {
"ABC-A": 30, # A-class: every 30 days
"ABC-B": 90, # B-class: every 90 days
"ABC-C": 180, # C-class: every 180 days
}
quants_to_count = self.env["stock.quant"]
for categ_name, interval_days in abc_schedule.items():
cutoff = today - relativedelta(days=interval_days)
quants = self.search([
("location_id.usage", "=", "internal"),
("product_id.categ_id.name", "=", categ_name),
("quantity", ">", 0),
"|",
("inventory_date", "=", False),
("inventory_date", "<=", cutoff),
])
quants_to_count |= quants
# Flag them for counting
quants_to_count.write({
"inventory_quantity_set": True,
}) The cyclic_inventory_frequency field only appears if Storage Locations is enabled in Inventory → Configuration → Settings. Without this setting, you can't configure per-location count frequencies and the cycle counting workflow loses most of its value.
Barcode-Assisted Cycle Counting: Speed and Accuracy on the Warehouse Floor
Manual data entry is the number one source of counting errors. A warehouse associate typing "47" instead of "74" creates a phantom discrepancy that triggers an investigation, wastes supervisor time, and ultimately gets written off as a counting mistake. Barcode scanning eliminates keystroke errors and cuts counting time by 60-70%.
Setting Up Barcode Scanning for Inventory Adjustments
Odoo 19's Barcode module integrates directly with inventory adjustments. Here's the configuration checklist:
- Enable the Barcode module — Install
stock_barcodefrom the Apps menu. This adds the barcode scanning interface to inventory operations. - Assign barcodes to products — Every product that will be cycle-counted needs a barcode on the
product.productrecord. Use EAN-13, UPC-A, or internal barcodes—Odoo supports all three. - Assign barcodes to locations — Print barcode labels for each warehouse location. When the counter scans a location barcode, Odoo filters the adjustment to that location only.
- Configure the scanning hardware — USB barcode scanners work out of the box (they emulate keyboard input). For mobile devices, use Odoo's mobile app or a Bluetooth scanner paired to a tablet.
The Barcode Counting Workflow
1. Open Inventory > Operations > Physical Inventory
2. Scan the LOCATION barcode → Odoo filters to that zone
3. Scan a PRODUCT barcode → Odoo finds the quant, sets counted qty to 1
4. Scan the SAME product again → Increments counted qty by 1
5. (Or manually enter qty for bulk items)
6. Repeat steps 3-5 for all products in the location
7. Tap "Apply All" to confirm the count
Odoo automatically:
- Compares counted qty vs on-hand qty
- Highlights discrepancies in red
- Creates stock moves for adjustments
- Posts journal entries if perpetual valuation is enabledIf a product exists in a location but the counter doesn't scan it, Odoo does not automatically zero it out. This is by design—the counter might have simply missed it. To zero out uncounted products, the counter must explicitly set the quantity to 0. Train your team on this behavior, or you'll end up with "ghost" inventory that never gets corrected.
Investigating Discrepancies: Tracing the Root Cause Before Adjusting
A cycle count reveals a discrepancy: the system says 120 units, the shelf has 114. Before clicking "Apply" and writing off 6 units, you need to investigate. Adjusting without investigating is just hiding errors—the same discrepancy will reappear next cycle.
The Investigation Checklist
1. CHECK PENDING TRANSFERS
Inventory > Operations > Transfers
Filter: product = [item], state = "assigned" or "waiting"
{{ Are there picks/deliveries in progress that
removed stock from the shelf but aren't yet validated? }}
2. CHECK RECENT STOCK MOVES
Inventory > Reporting > Stock Moves
Filter: product = [item], last 7 days
{{ Look for unusual quantities, unexpected
source/destination locations, or cancelled moves }}
3. CHECK LOT/SERIAL NUMBERS (if applicable)
Inventory > Products > Lots/Serial Numbers
{{ Verify that lot-tracked items haven't been
received under a different lot number }}
4. CHECK OTHER LOCATIONS
Inventory > Reporting > Inventory Report
Group by: Location
{{ The missing 6 units might be in a different
bin, on a packing table, or in quality hold }}
5. CHECK FOR SCRAP/DAMAGE
Inventory > Operations > Scrap
{{ Were units scrapped but the scrap record
wasn't created until after the count? }}Adding Investigation Notes to Adjustments
Odoo 19 doesn't provide a built-in "reason" field on stock.quant inventory adjustments. Add one with a simple model extension so every adjustment includes a documented root cause:
from odoo import fields, models
class StockQuant(models.Model):
_inherit = "stock.quant"
x_adjustment_reason = fields.Selection(
selection=[
("count_error", "Previous Count Error"),
("receiving_error", "Receiving Discrepancy"),
("pick_error", "Picking Error"),
("damage", "Damaged / Scrapped"),
("theft", "Suspected Theft"),
("location_error", "Wrong Location"),
("unknown", "Under Investigation"),
],
string="Adjustment Reason",
help="Root cause classification for inventory discrepancies.",
)
x_adjustment_notes = fields.Text(
string="Investigation Notes",
help="Details of the discrepancy investigation.",
)Require a reason selection before allowing adjustment approval. Use a server action or Python constraint to enforce this. Warehouses that track adjustment reasons see a 35-40% reduction in recurring discrepancies within six months because the investigation step forces teams to fix process gaps instead of just correcting numbers.
Accounting Impact: How Cycle Count Adjustments Flow to the General Ledger
Every inventory adjustment in Odoo creates stock moves. When your inventory valuation is set to Automated (perpetual), those stock moves generate journal entries. Understanding this accounting flow is critical because cycle count adjustments directly impact your balance sheet and income statement.
The Journal Entry Flow
| Adjustment Type | Debit Account | Credit Account | Effect |
|---|---|---|---|
| Positive (found extra stock) | Stock Valuation (asset) | Inventory Adjustment (expense) | Increases asset value, reduces expense |
| Negative (stock missing) | Inventory Adjustment (expense) | Stock Valuation (asset) | Reduces asset value, increases expense |
# Account configuration path in Odoo 19:
# Inventory > Configuration > Product Categories > [Category]
# Account Properties tab:
# - Stock Valuation Account: 1400 - Inventory (Asset)
# - Stock Input Account: 1401 - Stock Interim (Received)
# - Stock Output Account: 1402 - Stock Interim (Delivered)
# The adjustment account is set at:
# Inventory > Configuration > Settings > Inventory Valuation
# - Inventory Adjustment Account: 5100 - Inventory Adjustments (Expense)
# When a cycle count adjusts -6 units of a product
# costing $25.00/unit, Odoo generates:
#
# Debit: 5100 Inventory Adjustments $150.00
# Credit: 1400 Inventory Asset $150.00Valuation Method Considerations
The valuation method on your product category determines how the cost per unit is calculated for the journal entry:
- Standard Price — uses the fixed cost on the product. Simple and predictable. Adjustments always use the same unit cost regardless of when the item was received.
- Average Cost (AVCO) — uses the current weighted average cost. A negative adjustment reduces both quantity and total value proportionally. Watch for edge cases where adjusting down to zero resets the average cost.
- FIFO — uses the cost of the oldest layer. Negative adjustments consume the oldest cost layers first. This can create unexpected cost differences if old layers have significantly different costs than recent purchases.
If your company uses perpetual valuation and undergoes external audits, keep the Inventory Adjustment account (5100) separate from your standard COGS account. Auditors want to see inventory write-offs isolated so they can assess whether shrinkage levels are within acceptable thresholds. Mixing adjustment entries with normal COGS obscures the true shrinkage rate.
3 Cycle Counting Mistakes That Undermine Your Inventory Accuracy
Counting While Picks Are in Progress
A counter walks to a shelf and counts 48 units. Meanwhile, a picker pulls 5 units from the same shelf for an order that hasn't been validated yet. The counter records 48, the picker validates the transfer, and the system now shows 43 on-hand but the count says 48. The adjustment adds 5 phantom units to your inventory.
Schedule cycle counts during low-activity windows—early morning before the first pick wave, or during shift changes. If that's not possible, use Odoo's reservation system: check for reserved quantities on the quant before counting. The reserved_quantity field on stock.quant shows how many units are allocated to pending transfers. Count available stock only: on_hand - reserved = countable.
Not Counting Zero-Quantity Locations
Your cycle count logic filters for quantity > 0 because "why count empty locations?" But the system says the location is empty while 5 units are actually sitting there—received but not scanned in, returned but not processed, or simply misplaced. By excluding zero-quantity locations, you'll never find stock that the system doesn't know about.
Include a random sample of "empty" locations in each count cycle. If your warehouse has 200 locations, add 10-15 randomly selected zero-quantity locations to each day's count list. When the counter finds stock in a "zero" location, they create a positive adjustment. This catches receiving errors, undocumented returns, and misplaced inventory.
Applying Adjustments Without Supervisor Review for High-Value Items
A counter enters a quantity of 2 instead of 200—a simple typo. The adjustment is applied immediately, writing off 198 units of a $50 product. That's a $9,900 inventory write-down from a keystroke error. Without a review step, these errors go straight to the general ledger.
Implement a threshold-based approval workflow. Adjustments below a configurable value threshold (e.g., $500) are auto-approved. Adjustments above the threshold require supervisor validation before they post. In Odoo, achieve this with a server action that checks the adjustment value and sets a custom x_state field to "pending_review" for high-value changes. Restrict the "Apply" button visibility based on this field and user group.
What Cycle Counting Saves Your Warehouse Operations
Cycle counting isn't just about accurate numbers. It's an operational upgrade that impacts revenue, costs, and customer satisfaction:
Warehouses that implement cycle counting consistently reach 95-99% inventory accuracy within 6 months, compared to 60-75% with annual counts only.
Eliminate the annual warehouse shutdown. For a warehouse shipping $200K/day, each shutdown day is a direct revenue loss plus overtime costs for the count team.
Accurate inventory means accurate reorder points. When the system says you have 30 units, you actually have 30 units—not 18 with 12 phantom units masking a stockout.
For a mid-size warehouse carrying $5M in inventory with a 3% shrinkage rate, cycle counting typically reduces shrinkage to under 1%. That's a $100,000+ annual savings from reduced write-offs alone—before you count the gains from fewer stockouts, eliminated shutdown costs, and faster order fulfillment.
Optimization Metadata
Complete guide to cycle counting in Odoo 19. Configure perpetual inventory audits with ABC analysis, barcode scanning, discrepancy investigation, and automated scheduling without warehouse shutdowns.
1. "Inventory Adjustment vs Cycle Count: Understanding the Difference in Odoo 19"
2. "Cycle Count Scheduling Strategies: ABC Analysis, Random, and Location-Based"
3. "Barcode-Assisted Cycle Counting: Speed and Accuracy on the Warehouse Floor"
4. "Accounting Impact: How Cycle Count Adjustments Flow to the General Ledger"