Your Work Centers Are Running at Half Capacity and Nobody Knows
We see it in every manufacturing audit: Odoo is installed, bills of materials exist, manufacturing orders get confirmed — but the work centers are either missing entirely or configured with default infinite capacity. The production manager looks at the Gantt chart and sees a clean schedule. The shop floor tells a different story: three jobs queued at the CNC mill, the paint booth sitting idle, and an operator walking between stations wondering which work order takes priority.
Work centers are the physical machines and stations where production happens. Routings define the sequence of operations that transform raw materials into finished goods. Together, they are the bridge between what Odoo plans and what actually happens on the floor. Without them, MRP is doing math without constraints — every job finishes on time in theory, and nothing finishes on time in practice.
This guide covers the complete work center and routing setup in Odoo 19: creating and configuring work centers with realistic capacities, defining operations and multi-step routings, tracking Overall Equipment Effectiveness (OEE), grouping alternative work centers for load balancing, and implementing finite-capacity scheduling. Every example is production-tested on discrete manufacturing Odoo deployments.
Creating and Configuring Work Centers in Odoo 19
A work center in Odoo represents any resource that performs production operations: a CNC machine, a welding station, an assembly bench, or even a human operator pool. Navigate to Manufacturing → Configuration → Work Centers to create them. The critical fields most people skip are capacity, time efficiency, and working hours.
Step 1: Define Work Center Core Properties
Each work center needs a name, a code (for shop floor displays), and a working hours calendar. The calendar tells Odoo when the work center is available. If your CNC runs two shifts (6 AM - 10 PM) but the paint booth only runs days (8 AM - 5 PM), they need separate calendars.
<odoo>
<data noupdate="0">
<!-- Two-shift calendar: 06:00-22:00, Mon-Fri -->
<record id="calendar_two_shift" model="resource.calendar">
<field name="name">Two-Shift (06-22)</field>
<field name="tz">America/New_York</field>
<field name="attendance_ids" eval="[
(0, 0, {{'name': 'Mon AM', 'dayofweek': '0', 'hour_from': 6, 'hour_to': 14}}),
(0, 0, {{'name': 'Mon PM', 'dayofweek': '0', 'hour_from': 14, 'hour_to': 22}}),
(0, 0, {{'name': 'Tue AM', 'dayofweek': '1', 'hour_from': 6, 'hour_to': 14}}),
(0, 0, {{'name': 'Tue PM', 'dayofweek': '1', 'hour_from': 14, 'hour_to': 22}}),
(0, 0, {{'name': 'Wed AM', 'dayofweek': '2', 'hour_from': 6, 'hour_to': 14}}),
(0, 0, {{'name': 'Wed PM', 'dayofweek': '2', 'hour_from': 14, 'hour_to': 22}}),
(0, 0, {{'name': 'Thu AM', 'dayofweek': '3', 'hour_from': 6, 'hour_to': 14}}),
(0, 0, {{'name': 'Thu PM', 'dayofweek': '3', 'hour_from': 14, 'hour_to': 22}}),
(0, 0, {{'name': 'Fri AM', 'dayofweek': '4', 'hour_from': 6, 'hour_to': 14}}),
(0, 0, {{'name': 'Fri PM', 'dayofweek': '4', 'hour_from': 14, 'hour_to': 22}}),
]"/>
</record>
<!-- CNC Milling Center -->
<record id="wc_cnc_mill" model="mrp.workcenter">
<field name="name">CNC Milling Center</field>
<field name="code">CNC-01</field>
<field name="resource_calendar_id" ref="calendar_two_shift"/>
<field name="capacity">1</field>
<field name="time_efficiency">90</field>
<field name="oee_target">85</field>
<field name="time_start">15</field>
<field name="time_stop">10</field>
<field name="costs_hour">75.0</field>
</record>
<!-- Assembly Station -->
<record id="wc_assembly" model="mrp.workcenter">
<field name="name">Assembly Station A</field>
<field name="code">ASM-01</field>
<field name="resource_calendar_id" ref="calendar_two_shift"/>
<field name="capacity">3</field>
<field name="time_efficiency">100</field>
<field name="oee_target">90</field>
<field name="time_start">5</field>
<field name="time_stop">5</field>
<field name="costs_hour">45.0</field>
</record>
</data>
</odoo>Step 2: Understand the Time Fields
Odoo work centers have four time fields that control scheduling. Misconfiguring any of them throws off every delivery date your system calculates.
| Field | What It Represents | Example | Scheduling Impact |
|---|---|---|---|
Setup Time (time_start) | Time to prepare the machine before production begins | 15 min for tool change | Added once per work order, before the cycle starts |
Cleanup Time (time_stop) | Time to clean or reset after production ends | 10 min for chip removal | Added once per work order, after the last cycle |
| Capacity | Units the work center can process simultaneously | 3 operators at assembly bench | Divides cycle time by capacity |
| Time Efficiency | Percentage of theoretical speed the machine achieves | 90% = machine runs at 90% of rated speed | Multiplies cycle time by 100/efficiency |
The costs_hour field drives manufacturing cost accounting. Include machine depreciation, energy, and labor. A CNC mill might cost $75/hr ($25 depreciation + $15 energy + $35 operator). If this field is zero, your manufactured product costs will only reflect raw material — making every production run look more profitable than it actually is.
Step 3: Configure Work Center Programmatically
For bulk work center creation during implementation, use a Python script or data migration. This is especially useful when migrating from a legacy MES that exports machine data as CSV.
from odoo import models, fields, api
class MrpWorkcenter(models.Model):
_inherit = 'mrp.workcenter'
# Custom fields for advanced capacity planning
max_weight_kg = fields.Float(
string='Max Weight (kg)',
help='Maximum part weight this work center can handle.',
)
coolant_type = fields.Selection([
('none', 'None'),
('flood', 'Flood Coolant'),
('mist', 'Mist Coolant'),
('dry', 'Dry Machining'),
], string='Coolant Type', default='none')
maintenance_interval_hours = fields.Float(
string='PM Interval (hours)',
default=500,
help='Preventive maintenance every N operating hours.',
)
cumulative_hours = fields.Float(
string='Hours Since Last PM',
compute='_compute_cumulative_hours',
store=True,
)
@api.depends('order_ids.duration')
def _compute_cumulative_hours(self):
for wc in self:
total = sum(
wo.duration
for wo in wc.order_ids
if wo.state == 'done'
and wo.date_finished
)
wc.cumulative_hours = total / 60.0 # minutes to hours
def action_reset_pm_counter(self):
"""Called after preventive maintenance is completed."""
self.ensure_one()
self.cumulative_hours = 0.0Defining Operations and Multi-Step Routings
A routing is a sequence of operations that a product passes through during manufacturing. Each operation is linked to a work center and defines how long the step takes. In Odoo 19, routings are embedded directly into the Bill of Materials under the Operations tab — there is no separate routing object. This simplifies configuration but means you must enable Work Orders in Manufacturing settings first.
Step 1: Enable Work Orders
Navigate to Manufacturing → Configuration → Settings and enable Work Orders. Without this setting, Odoo treats every manufacturing order as a single operation with no routing. You will not see the Operations tab on BOMs until this is enabled.
Step 2: Define Operations on the BOM
Open a Bill of Materials and click the Operations tab. Add each production step in sequence. For a machined metal bracket, the routing might be: Cut → Mill → Deburr → Inspect → Pack. Each operation specifies the work center, expected duration, and optional worksheet instructions.
<odoo>
<data noupdate="0">
<!-- BOM for Steel Bracket (product must exist) -->
<record id="bom_steel_bracket" model="mrp.bom">
<field name="product_tmpl_id" ref="product_steel_bracket_tmpl"/>
<field name="product_qty">1</field>
<field name="type">normal</field>
</record>
<!-- Operation 1: CNC Cutting -->
<record id="op_cutting" model="mrp.routing.workcenter">
<field name="bom_id" ref="bom_steel_bracket"/>
<field name="name">CNC Cutting</field>
<field name="workcenter_id" ref="wc_cnc_mill"/>
<field name="sequence">10</field>
<field name="time_mode">manual</field>
<field name="time_cycle_manual">12</field>
</record>
<!-- Operation 2: Milling -->
<record id="op_milling" model="mrp.routing.workcenter">
<field name="bom_id" ref="bom_steel_bracket"/>
<field name="name">Precision Milling</field>
<field name="workcenter_id" ref="wc_cnc_mill"/>
<field name="sequence">20</field>
<field name="time_mode">manual</field>
<field name="time_cycle_manual">25</field>
</record>
<!-- Operation 3: Deburring -->
<record id="op_deburr" model="mrp.routing.workcenter">
<field name="bom_id" ref="bom_steel_bracket"/>
<field name="name">Deburr & Finish</field>
<field name="workcenter_id" ref="wc_deburr"/>
<field name="sequence">30</field>
<field name="time_mode">manual</field>
<field name="time_cycle_manual">8</field>
</record>
<!-- Operation 4: Quality Inspection -->
<record id="op_inspect" model="mrp.routing.workcenter">
<field name="bom_id" ref="bom_steel_bracket"/>
<field name="name">Quality Inspection</field>
<field name="workcenter_id" ref="wc_qc_station"/>
<field name="sequence">40</field>
<field name="time_mode">manual</field>
<field name="time_cycle_manual">5</field>
</record>
<!-- Operation 5: Packing -->
<record id="op_pack" model="mrp.routing.workcenter">
<field name="bom_id" ref="bom_steel_bracket"/>
<field name="name">Pack & Label</field>
<field name="workcenter_id" ref="wc_assembly"/>
<field name="sequence">50</field>
<field name="time_mode">manual</field>
<field name="time_cycle_manual">3</field>
</record>
</data>
</odoo>Step 3: Time Modes — Manual vs. Computed
Each operation has a time_mode field that determines how Odoo calculates cycle time:
| Time Mode | How It Works | When to Use |
|---|---|---|
| Manual | You enter a fixed cycle time in minutes | New products, no historical data |
| Computed (last 10) | Averages the last 10 actual durations | Mature products with stable processes |
| Computed (all) | Averages all historical durations | High-volume, very stable operations |
When you first set up routing, use manual time mode with conservative estimates (add 20% buffer). After 20-30 production runs, switch to computed (last 10) so that Odoo self-adjusts based on actual shop floor performance. This prevents the chicken-and-egg problem of needing history before you have any.
Step 4: Assign Components to Specific Operations
In Odoo 19, each BOM line can be consumed at a specific operation rather than all at once when the MO starts. This is critical for multi-step routings where different materials are needed at different stages. On the BOM Components tab, set the Consumed in Operation field for each component.
# Programmatically assign BOM lines to operations
bom = env.ref('my_module.bom_steel_bracket')
op_cutting = env.ref('my_module.op_cutting')
op_pack = env.ref('my_module.op_pack')
for line in bom.bom_line_ids:
if line.product_id.categ_id.name == 'Raw Steel':
# Raw steel consumed at cutting step
line.operation_id = op_cutting.id
elif line.product_id.categ_id.name == 'Packaging':
# Packaging material consumed at packing step
line.operation_id = op_pack.idCapacity Planning: Making Your Schedule Respect Reality
Out of the box, Odoo uses infinite capacity scheduling — it calculates start and end dates based on operation durations and lead times, but never checks whether the work center is actually available during that window. The result is a beautiful Gantt chart that collapses on contact with reality. Finite capacity planning requires configuring three things: work center availability, capacity constraints, and scheduling logic.
Step 1: Verify Calendar Accuracy
Every work center must have a resource calendar that reflects actual operating hours. This is not the same as the company default calendar. If your paint booth runs one shift and your CNC runs two, they need separate calendars. Holidays and planned shutdowns must be entered as calendar leaves.
from odoo import models, fields
from datetime import datetime
class MrpWorkcenterDowntime(models.Model):
_inherit = 'mrp.workcenter'
def action_schedule_maintenance_window(self, date_from, date_to, reason):
"""Block a work center for planned maintenance.
Creates a calendar leave so the scheduler avoids this window."""
self.ensure_one()
self.env['resource.calendar.leaves'].create({{
'name': f'PM: {{reason}}',
'calendar_id': self.resource_calendar_id.id,
'resource_id': self.resource_id.id,
'date_from': date_from,
'date_to': date_to,
}})
# Usage:
# wc_cnc.action_schedule_maintenance_window(
# datetime(2026, 3, 20, 6, 0),
# datetime(2026, 3, 20, 14, 0),
# 'Spindle bearing replacement'
# )Step 2: Compute Available Capacity Per Period
To plan effectively, you need to know how many hours each work center has available per week and how many are already booked. This capacity analysis report is not built into Odoo by default — here is how to build one.
from odoo import models, fields, api
from datetime import timedelta
class MrpCapacityReport(models.TransientModel):
_name = 'mrp.capacity.report'
_description = 'Work Center Capacity Report'
workcenter_id = fields.Many2one('mrp.workcenter', required=True)
date_from = fields.Date(required=True, default=fields.Date.today)
date_to = fields.Date(required=True)
line_ids = fields.One2many('mrp.capacity.report.line', 'report_id')
def action_compute(self):
self.ensure_one()
self.line_ids.unlink()
wc = self.workcenter_id
calendar = wc.resource_calendar_id
current = self.date_from
lines = []
while current <= self.date_to:
week_end = current + timedelta(days=6)
# Available hours from calendar (minus leaves)
available = calendar.get_work_hours_count(
datetime.combine(current, datetime.min.time()),
datetime.combine(week_end, datetime.max.time()),
resource=wc.resource_id,
)
# Booked hours from scheduled work orders
work_orders = self.env['mrp.workorder'].search([
('workcenter_id', '=', wc.id),
('state', 'not in', ['done', 'cancel']),
('date_start', '>=', current),
('date_start', '<=', week_end),
])
booked = sum(wo.duration_expected for wo in work_orders) / 60
utilization = (booked / available * 100) if available else 0
lines.append((0, 0, {{
'week_start': current,
'available_hours': available,
'booked_hours': booked,
'utilization_pct': utilization,
}}))
current = week_end + timedelta(days=1)
self.line_ids = linesStep 3: Implement Finite Capacity Slot Finding
The real power comes from finding the next available slot on a work center when scheduling a new work order. Instead of blindly assigning the requested date, this logic checks existing bookings and slides the work order to the first open window.
from odoo import models, api
from datetime import datetime, timedelta
class MrpWorkorder(models.Model):
_inherit = 'mrp.workorder'
def _find_available_slot(self, desired_start, duration_minutes):
"""Find the next open slot on this work order's work center.
Args:
desired_start: datetime, earliest acceptable start
duration_minutes: float, total operation time needed
Returns:
(slot_start, slot_end) as datetime tuple
"""
self.ensure_one()
wc = self.workcenter_id
calendar = wc.resource_calendar_id
candidate = desired_start
max_attempts = 60 # Look up to 60 days ahead
for _ in range(max_attempts):
# Get working intervals for the day
intervals = calendar.plan_hours(
duration_minutes / 60.0,
candidate,
compute_leaves=True,
resource=wc.resource_id,
)
if not intervals:
candidate += timedelta(days=1)
candidate = candidate.replace(hour=0, minute=0)
continue
slot_start = candidate
slot_end = slot_start + timedelta(minutes=duration_minutes)
# Check for conflicts with existing work orders
conflicts = self.search_count([
('workcenter_id', '=', wc.id),
('id', '!=', self.id),
('state', 'not in', ['done', 'cancel']),
('date_start', '<', slot_end),
('date_finished', '>', slot_start),
])
if conflicts == 0:
return (slot_start, slot_end)
# Move past the conflicting order
conflict = self.search([
('workcenter_id', '=', wc.id),
('state', 'not in', ['done', 'cancel']),
('date_start', '<', slot_end),
('date_finished', '>', slot_start),
], order='date_finished desc', limit=1)
candidate = conflict.date_finished
return (desired_start, desired_start + timedelta(minutes=duration_minutes)) A work center with capacity=2 can process two units simultaneously, but it is still one resource on the calendar. If you have two physical CNC machines that can run independently, create two work centers (CNC-01 and CNC-02) and group them — do not set capacity to 2 on a single work center. Capacity is for parallel processing on the same machine (e.g., a multi-spindle lathe), not for multiple machines.
OEE Tracking: Measure What Matters on the Shop Floor
Overall Equipment Effectiveness (OEE) is the gold standard for measuring manufacturing productivity. It combines three factors: Availability (is the machine running?), Performance (is it running at full speed?), and Quality (are the parts good?). OEE = Availability x Performance x Quality. World-class OEE is 85%. Most plants run 40-60% and don't know it.
Step 1: Enable OEE Tracking
Odoo 19 tracks OEE automatically when you use the Shop Floor module (tablet view). Operators start and stop work orders, log downtime reasons, and record scrap. Odoo calculates OEE from this data. Set the oee_target field on each work center so you can compare actual vs. target.
Step 2: Configure Loss Categories
OEE losses fall into categories. Odoo uses mrp.workcenter.productivity.loss records to classify downtime. Configure these before go-live so operators pick from a standardized list instead of typing free-text reasons.
<odoo>
<data noupdate="0">
<!-- Availability Losses -->
<record id="loss_breakdown" model="mrp.workcenter.productivity.loss">
<field name="name">Machine Breakdown</field>
<field name="loss_type">availability</field>
</record>
<record id="loss_changeover" model="mrp.workcenter.productivity.loss">
<field name="name">Changeover / Setup</field>
<field name="loss_type">availability</field>
</record>
<record id="loss_material_shortage" model="mrp.workcenter.productivity.loss">
<field name="name">Material Shortage</field>
<field name="loss_type">availability</field>
</record>
<!-- Performance Losses -->
<record id="loss_reduced_speed" model="mrp.workcenter.productivity.loss">
<field name="name">Reduced Speed</field>
<field name="loss_type">performance</field>
</record>
<record id="loss_minor_stop" model="mrp.workcenter.productivity.loss">
<field name="name">Minor Stoppage (< 5 min)</field>
<field name="loss_type">performance</field>
</record>
<!-- Quality Losses -->
<record id="loss_scrap" model="mrp.workcenter.productivity.loss">
<field name="name">Scrap / Rework</field>
<field name="loss_type">quality</field>
</record>
<record id="loss_startup_rejects" model="mrp.workcenter.productivity.loss">
<field name="name">Startup Rejects</field>
<field name="loss_type">quality</field>
</record>
</data>
</odoo>Step 3: Build a Custom OEE Dashboard
Odoo's built-in OEE report is basic. For manufacturing managers who need trend analysis and cross-work-center comparisons, extend the reporting with a custom computed model.
from odoo import models, fields, api, tools
class MrpOeeAnalysis(models.Model):
_name = 'mrp.oee.analysis'
_description = 'OEE Analysis Report'
_auto = False
_order = 'date desc'
date = fields.Date(readonly=True)
workcenter_id = fields.Many2one('mrp.workcenter', readonly=True)
availability = fields.Float(readonly=True, group_operator='avg')
performance = fields.Float(readonly=True, group_operator='avg')
quality = fields.Float(readonly=True, group_operator='avg')
oee = fields.Float(string='OEE %', readonly=True, group_operator='avg')
target_oee = fields.Float(readonly=True)
def init(self):
tools.drop_view_if_exists(self.env.cr, self._table)
self.env.cr.execute(f"""
CREATE OR REPLACE VIEW {{self._table}} AS (
SELECT
row_number() OVER () AS id,
wo.date_start::date AS date,
wo.workcenter_id,
wc.oee_target AS target_oee,
-- Availability: actual run / planned run
CASE WHEN SUM(wo.duration_expected) > 0
THEN LEAST(
SUM(wo.duration) / SUM(wo.duration_expected) * 100,
100
) ELSE 0 END AS availability,
-- Performance: ideal cycle / actual cycle
CASE WHEN SUM(wo.duration) > 0
THEN LEAST(
SUM(wo.duration_expected) / SUM(wo.duration) * 100,
100
) ELSE 0 END AS performance,
-- Quality: good units / total units
CASE WHEN SUM(wo.qty_produced) > 0
THEN (SUM(wo.qty_produced) - COALESCE(SUM(sm.product_uom_qty), 0))
/ SUM(wo.qty_produced) * 100
ELSE 100 END AS quality,
-- OEE = A * P * Q / 10000
0 AS oee
FROM mrp_workorder wo
JOIN mrp_workcenter wc ON wc.id = wo.workcenter_id
LEFT JOIN stock_move sm ON sm.production_id = wo.production_id
AND sm.scrapped = true
WHERE wo.state = 'done'
GROUP BY wo.date_start::date, wo.workcenter_id, wc.oee_target
)
""")
OEE only works if operators accurately log start times, stop times, and downtime reasons on the Shop Floor tablet. If they batch-record at the end of the shift, the data is useless. Roll out OEE tracking with training, explain why the data matters, and never use it as a punishment tool. Operators who fear the metric will game it — and you will be making decisions on fiction.
Work Center Groups: Alternative Machines and Load Balancing
When you have multiple machines that can perform the same operation — three CNC mills, two paint booths, four assembly benches — you should not hardcode each routing to a single work center. Odoo 19's alternative work centers feature lets you define a group of interchangeable machines. The scheduler (or the operator on the shop floor) can then pick the one with the shortest queue.
Step 1: Define Alternative Work Centers
On each work center form, use the Alternative Work Centers field to list machines that can substitute. When a work order is assigned to CNC-01 but CNC-01 is fully booked, the planner can reassign it to CNC-02 or CNC-03 with one click.
<odoo>
<data noupdate="0">
<record id="wc_cnc_02" model="mrp.workcenter">
<field name="name">CNC Milling Center 02</field>
<field name="code">CNC-02</field>
<field name="resource_calendar_id" ref="calendar_two_shift"/>
<field name="capacity">1</field>
<field name="time_efficiency">85</field>
<field name="oee_target">80</field>
<field name="costs_hour">70.0</field>
</record>
<record id="wc_cnc_03" model="mrp.workcenter">
<field name="name">CNC Milling Center 03</field>
<field name="code">CNC-03</field>
<field name="resource_calendar_id" ref="calendar_two_shift"/>
<field name="capacity">1</field>
<field name="time_efficiency">95</field>
<field name="oee_target">85</field>
<field name="costs_hour">80.0</field>
</record>
<!-- Link alternatives: CNC-01 can use CNC-02 or CNC-03 -->
<record id="wc_cnc_mill" model="mrp.workcenter">
<field name="alternative_workcenter_ids"
eval="[(6, 0, [ref('wc_cnc_02'), ref('wc_cnc_03')])]"/>
</record>
</data>
</odoo>Step 2: Auto-Select the Least Loaded Work Center
Out of the box, Odoo assigns work orders to the work center defined in the BOM routing. To automatically pick the least-loaded alternative, extend the work order creation logic.
from odoo import models, api
from datetime import timedelta
class MrpProduction(models.Model):
_inherit = 'mrp.production'
def _get_least_loaded_workcenter(self, default_wc, date_start):
"""Among the default work center and its alternatives,
return the one with the fewest booked hours in the next 5 days."""
candidates = default_wc | default_wc.alternative_workcenter_ids
date_end = date_start + timedelta(days=5)
best_wc = default_wc
min_load = float('inf')
for wc in candidates:
booked = sum(
wo.duration_expected
for wo in self.env['mrp.workorder'].search([
('workcenter_id', '=', wc.id),
('state', 'not in', ['done', 'cancel']),
('date_start', '>=', date_start),
('date_start', '<=', date_end),
])
)
if booked < min_load:
min_load = booked
best_wc = wc
return best_wc
@api.model_create_multi
def create(self, vals_list):
productions = super().create(vals_list)
for production in productions:
for wo in production.workorder_ids:
best = production._get_least_loaded_workcenter(
wo.workcenter_id,
production.date_start or fields.Datetime.now(),
)
if best != wo.workcenter_id:
wo.workcenter_id = best
return productionsCNC-01 might run at 95% efficiency while CNC-03 (the older machine) runs at 85%. Set time efficiency differently on each work center. Odoo adjusts the scheduled duration accordingly: a 10-minute operation on CNC-01 becomes 11.8 minutes on CNC-03. This keeps your delivery dates honest when work spills over to slower machines.
Scheduling Work Orders: Gantt Views, Priorities, and Sequencing
With work centers configured and routings defined, the final piece is scheduling — deciding when each operation runs. Odoo 19 provides a Gantt view under Manufacturing → Planning → Planning by Workcenter that shows all work orders on a timeline. But the default scheduling is first-come, first-served. For real manufacturing, you need priority rules, sequencing logic, and the ability to reschedule on the fly.
Step 1: Set Manufacturing Order Priorities
Every manufacturing order has a priority field (0 = Normal, 1 = Urgent). Extend this with more granularity so planners can differentiate between "customer deadline is Friday" and "the line is down waiting for this part."
from odoo import models, fields
class MrpProduction(models.Model):
_inherit = 'mrp.production'
priority = fields.Selection(
selection=[
('0', 'Normal'),
('1', 'High'),
('2', 'Urgent'),
('3', 'Emergency — Line Down'),
],
default='0',
string='Priority',
)
scheduling_note = fields.Text(
string='Scheduling Note',
help='Visible to shop floor operators on the tablet view.',
)Step 2: Forward Scheduling from the MO Confirmation
When an MO is confirmed, Odoo calculates work order dates using forward scheduling — it starts from the MO start date and schedules each operation sequentially, accounting for setup time, cycle time, and cleanup time. The total lead time is the sum of all operations plus any wait time between them.
# Conceptual walkthrough of Odoo's scheduling calculation
# for a 3-operation routing producing 10 units of Steel Bracket
# Operation 1: CNC Cutting on CNC-01
# Setup: 15 min
# Cycle: 12 min/unit x 10 units = 120 min
# Efficiency: 90% => 120 / 0.9 = 133.3 min
# Cleanup: 10 min
# Total Op1: 15 + 133.3 + 10 = 158.3 min
# Operation 2: Milling on CNC-01
# Setup: 15 min
# Cycle: 25 min/unit x 10 units = 250 min
# Efficiency: 90% => 250 / 0.9 = 277.8 min
# Cleanup: 10 min
# Total Op2: 15 + 277.8 + 10 = 302.8 min
# Operation 3: Deburring on DEBURR-01
# Setup: 5 min
# Cycle: 8 min/unit x 10 units = 80 min
# Efficiency: 100% => 80 min
# Cleanup: 5 min
# Total Op3: 5 + 80 + 5 = 90 min
# Total production time: 158.3 + 302.8 + 90 = 551.1 min
# = ~9.2 hours = ~1.15 working days (8hr shift)
# With 2-shift calendar (16 hr/day):
# Start: Monday 06:00
# End: Monday 15:11 (same day!)Step 3: Drag-and-Drop Rescheduling on the Gantt Chart
The Planning by Workcenter Gantt view in Odoo 19 supports drag-and-drop. Planners can move work orders between time slots and between work centers (if alternatives are configured). When a work order is moved, Odoo automatically recalculates downstream operations. This is the closest Odoo gets to a visual finite scheduler without third-party add-ons.
Odoo's default is forward scheduling (start date → calculate end date). For make-to-order with firm delivery dates, you want backward scheduling (delivery date → calculate latest start date). Odoo 19 supports this via the date_deadline field on the MO. Set the deadline, and the scheduler works backward through the routing to determine when each operation must start to meet the commitment.
Step 4: Shop Floor Tablet View for Operators
The Shop Floor module provides a tablet-optimized interface where operators see their assigned work orders, start/pause/finish operations, log downtime, and scan components. This is where the scheduling rubber meets the road — operators execute the plan that work centers and routings define.
<odoo>
<!-- Add scheduling note to the shop floor work order card -->
<record id="view_shop_floor_wo_inherit" model="ir.ui.view">
<field name="name">mrp.workorder.shop.floor.inherit</field>
<field name="model">mrp.workorder</field>
<field name="inherit_id" ref="mrp_workorder.mrp_workorder_view_form_tablet"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('o_workorder_icon')]" position="after">
<div class="alert alert-warning"
invisible="not production_id.scheduling_note">
<strong>Scheduling Note:</strong>
<field name="production_id" invisible="1"/>
<span>
<field name="scheduling_note"
related="production_id.scheduling_note"/>
</span>
</div>
</xpath>
</field>
</record>
</odoo>5 Work Center Configuration Mistakes That Kill Your Schedule
Leaving Setup and Cleanup Times at Zero
Default setup and cleanup times are zero minutes. This means Odoo schedules back-to-back operations with no transition time. On the floor, every job change requires a tool swap, a fixture change, or a cleaning cycle. Ten jobs per day with a real 15-minute changeover each equals 2.5 hours of unplanned downtime that the schedule ignores.
Time three actual changeovers with a stopwatch. Use the median as your setup time. For machines with variable changeover (e.g., a paint booth changing colors), use the worst-case time and add a protip in the operator instructions for short-change sequences.
Using Company Default Calendar for All Work Centers
The company default calendar (usually 8 AM - 5 PM, Mon-Fri) gets assigned to every new work center automatically. If your CNC runs two shifts and the company calendar says one shift, Odoo thinks the CNC has 8 available hours per day when it actually has 16. Your capacity calculations are off by 100%, and every MO scheduled on that work center finishes a day later than the Gantt shows.
Create a dedicated resource calendar for each unique shift pattern. Assign it explicitly to each work center. Audit with a quick report: SELECT name, resource_calendar_id FROM mrp_workcenter; — any NULL or wrong calendar ID is a bug.
Confusing Capacity with Parallel Machines
Setting capacity=3 on a single work center does not mean you have three machines. It means the work center can process three units simultaneously — like a triple-head drill press or an oven that fits three batches. If you have three separate drill presses, create three work centers and link them as alternatives. The distinction matters because capacity divides cycle time while alternatives add scheduling flexibility.
Ask this question: "Can an operator run one job across all three simultaneously, or are they three independent jobs?" If independent, use alternative work centers. If simultaneous (batch oven, multi-cavity mold), use capacity.
Not Linking BOM Components to Operations
If you leave the "Consumed in Operation" field blank on BOM lines, all materials are reserved when the MO starts. For multi-step routings, this means packaging materials are reserved at the cutting step — locking up inventory for hours or days before they are actually needed. This inflates WIP, creates phantom shortages on other MOs, and confuses warehouse staff who see reserved quantities that nobody is using yet.
Map every BOM line to the operation where it is physically consumed. Raw materials go to the first operation; packaging to the last. Fasteners and adhesives go to the assembly step. This reduces WIP and makes component availability more accurate.
Ignoring Time Efficiency on Older Machines
Time efficiency defaults to 100%. Your 15-year-old milling machine does not run at 100% of its rated speed. If the rated cycle time is 10 minutes but the machine actually takes 12 minutes, your efficiency is 83%. Every job scheduled on that machine finishes 20% late. Multiply by 20 jobs per week and you have a permanent scheduling gap that compounds every Monday.
Measure actual cycle times for 10 jobs on each machine. Divide rated time by actual time and multiply by 100. That is your time efficiency. Update it quarterly — machines degrade, and new tooling sometimes improves things.
What Proper Work Center Configuration Saves Your Operation
Work center and routing configuration is a one-time setup that pays dividends on every manufacturing order. Here is what changes when your shop floor schedule reflects reality:
Accurate setup times and work center grouping eliminate gaps between jobs. Machines run when they should, and operators know exactly what is next in the queue.
Finite capacity scheduling prevents bottleneck overloading. Work flows smoothly through the shop instead of piling up at the CNC while the assembly bench sits empty.
Schedules that account for real capacity, efficiency, and changeover times produce delivery promises customers can trust. Fewer expedite charges, fewer lost customers.
The hidden ROI is in OEE visibility. Most plants cannot answer "what is our biggest source of downtime?" Without data, improvement projects are guesswork. With OEE tracking configured on every work center, your continuous improvement team can identify the top three loss categories in minutes and focus investment where it matters. A 5-point OEE improvement on a bottleneck machine can increase total plant output by 10-15% with zero capital expenditure.
Optimization Metadata
Complete guide to work centers and routing in Odoo 19. Configure work center capacity, multi-step routings, OEE tracking, work center groups, and finite-capacity scheduling.
1. "Creating and Configuring Work Centers in Odoo 19"
2. "Defining Operations and Multi-Step Routings"
3. "Capacity Planning: Making Your Schedule Respect Reality"
4. "OEE Tracking: Measure What Matters on the Shop Floor"
5. "Work Center Groups: Alternative Machines and Load Balancing"
6. "Scheduling Work Orders: Gantt Views, Priorities, and Sequencing"