Your Fleet Costs Are Hiding in Spreadsheets Nobody Updates
Most companies with 10+ vehicles manage their fleet in a shared spreadsheet. Lease expiry dates live in someone's Outlook calendar. Fuel receipts sit in a shoebox until the accountant asks for them at quarter-end. Insurance renewals get missed because the reminder was set on an employee's personal phone — and that employee left six months ago.
The result is predictable: lease contracts auto-renew at unfavorable rates, vehicles run 5,000 km past their service interval, fuel costs creep up 15% year-over-year with no visibility into which vehicles or drivers are responsible, and insurance lapses go unnoticed until an accident happens.
Odoo 19's Fleet module replaces this chaos with a structured system: a vehicle registry with full lifecycle tracking, driver assignment with history, contract management for leasing and insurance, odometer logging, service and fuel cost tracking, and built-in reporting that answers "how much does each vehicle actually cost us per kilometer?" This guide walks through a complete fleet setup — from installing the module to building cost-per-km dashboards.
Setting Up the Vehicle Registry: Models, Tags, and Fleet Categories
The vehicle registry is the backbone of Odoo Fleet. Every vehicle gets a record with its make, model, license plate, VIN, acquisition date, and current status. Before adding vehicles, configure the reference data that makes filtering and reporting useful.
Install and Configure the Fleet Module
Navigate to Apps, search for "Fleet," and install. This creates the fleet.vehicle, fleet.vehicle.model, fleet.vehicle.log.fuel, fleet.vehicle.log.services, and fleet.vehicle.log.contract models. Once installed, head to Fleet → Configuration to set up manufacturers and models.
from odoo import models, fields, api
class FleetVehicleExtended(models.Model):
_inherit = 'fleet.vehicle'
department_id = fields.Many2one(
'hr.department',
string='Assigned Department',
tracking=True,
help='Department responsible for this vehicle',
)
gps_tracker_id = fields.Char(
string='GPS Tracker ID',
help='External GPS device identifier for telematics integration',
)
tire_size = fields.Char(string='Tire Size')
next_inspection_date = fields.Date(
string='Next Technical Inspection',
tracking=True,
)
co2_emission = fields.Float(
string='CO2 Emission (g/km)',
help='Used for environmental reporting and tax calculations',
)
cost_per_km = fields.Float(
string='Cost per Km',
compute='_compute_cost_per_km',
store=True,
digits=(10, 4),
)
total_cost = fields.Float(
string='Total Fleet Cost',
compute='_compute_total_cost',
store=True,
)
@api.depends('log_fuel', 'log_fuel.amount',
'log_services', 'log_services.amount',
'log_contracts', 'log_contracts.amount')
def _compute_total_cost(self):
for vehicle in self:
fuel = sum(vehicle.log_fuel.mapped('amount'))
services = sum(vehicle.log_services.mapped('amount'))
contracts = sum(vehicle.log_contracts.mapped('amount'))
vehicle.total_cost = fuel + services + contracts
@api.depends('total_cost', 'odometer')
def _compute_cost_per_km(self):
for vehicle in self:
if vehicle.odometer > 0:
vehicle.cost_per_km = (
vehicle.total_cost / vehicle.odometer
)
else:
vehicle.cost_per_km = 0.0Vehicle States and Lifecycle
Odoo 19 provides default vehicle states: New Request, To Order, Ordered, Registered, Downgraded, Reserve. You can customize these at Fleet → Configuration → Vehicle Status. A well-structured lifecycle typically looks like:
| Status | Meaning | Automation Trigger |
|---|---|---|
| Active | In daily use, assigned to a driver | Default after registration |
| In Maintenance | At the shop, temporarily unavailable | Service log created with type "repair" |
| Reserve / Decommissioned | Available but unassigned / end of life | Driver unassigned / manual write-off |
<odoo>
<record id="fleet_vehicle_view_form_inherit" model="ir.ui.view">
<field name="name">fleet.vehicle.form.inherit.custom</field>
<field name="model">fleet.vehicle</field>
<field name="inherit_id" ref="fleet.fleet_vehicle_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='driver_id']" position="after">
<field name="department_id"/>
<field name="gps_tracker_id"/>
</xpath>
<xpath expr="//page[@name='main_info']" position="inside">
<group string="Environmental & Inspection">
<field name="co2_emission"/>
<field name="next_inspection_date"/>
<field name="tire_size"/>
</group>
<group string="Cost Summary">
<field name="total_cost" widget="monetary"/>
<field name="cost_per_km"/>
</group>
</xpath>
</field>
</record>
</odoo>Vehicle tags in Odoo are free-form labels you can attach to any vehicle. Use them for attributes that don't fit a single field: "EV" for electric vehicles, "Pool Car" for shared-use vehicles, "Client-Facing" for vehicles that visit customer sites. Tags enable quick filtering in list views and are available as groupBy criteria in reports.
Driver Assignment, History Tracking, and Multi-Driver Policies
Every fleet.vehicle record has a driver_id field (linked to res.partner) and a driver_history_ids one2many that logs every assignment change with timestamps. This means you can always answer: "Who was driving vehicle X on date Y?"
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class FleetVehicleDriverPolicy(models.Model):
_inherit = 'fleet.vehicle'
max_drivers = fields.Integer(
string='Max Simultaneous Drivers',
default=1,
help='For pool cars, allow multiple authorized drivers',
)
license_required = fields.Selection([
('B', 'Category B (Car)'),
('C', 'Category C (Truck)'),
('D', 'Category D (Bus)'),
('BE', 'Category BE (Car + Trailer)'),
], string='License Category Required', default='B')
@api.constrains('driver_id')
def _check_driver_license(self):
"""Ensure the assigned driver has a valid license
matching the vehicle requirement."""
for vehicle in self:
if not vehicle.driver_id:
continue
driver = vehicle.driver_id
# Check if driver has an employee record
employee = self.env['hr.employee'].search([
('work_contact_id', '=', driver.id),
], limit=1)
if employee and employee.license_plate:
# Custom field: employee.driving_license_category
pass # Implement license validation logic
def action_return_vehicle(self):
"""Unassign driver and set vehicle to Reserve."""
self.ensure_one()
self.write({
'driver_id': False,
'state_id': self.env.ref(
'fleet.fleet_vehicle_state_reserve'
).id,
})
return True For companies with pool vehicles shared across departments, add an expected_return_date field and create a scheduled action that searches for vehicles past their return date and posts a chatter notification to the fleet manager. This prevents pool cars from being "borrowed" indefinitely by one department.
Contract Management: Leasing, Insurance, and Renewal Automation
Fleet contracts in Odoo 19 cover three cost categories: leasing (the monthly payment for the vehicle itself), insurance (liability, comprehensive, gap coverage), and service contracts (maintenance agreements, roadside assistance). Each contract type lives in fleet.vehicle.log.contract and tracks start date, expiry date, recurring cost, vendor, and alert thresholds.
Configuring Contract Types
Navigate to Fleet → Configuration → Contract Types and create entries for each category your fleet uses. The contract type determines how costs roll up in reports:
| Contract Type | Typical Vendor | Cost Structure | Renewal Alert (Days) |
|---|---|---|---|
| Operating Lease | Leasing company | Fixed monthly + excess km penalty | 90 |
| Finance Lease | Bank / lessor | Fixed monthly + residual buyout | 180 |
| Comprehensive Insurance | Insurance broker | Annual premium | 60 |
| Liability Only | Insurance broker | Annual premium | 60 |
| Maintenance Plan | Dealer / garage | Monthly or per-service | 30 |
from odoo import models, fields, api
from dateutil.relativedelta import relativedelta
class FleetContractAutoRenew(models.Model):
_inherit = 'fleet.vehicle.log.contract'
auto_renew = fields.Boolean(
string='Auto-Renew',
default=False,
help='Automatically create a renewal contract before expiry',
)
renewal_lead_days = fields.Integer(
string='Renewal Lead Time (Days)',
default=60,
)
renewal_contract_id = fields.Many2one(
'fleet.vehicle.log.contract',
string='Renewed From',
readonly=True,
)
def _cron_auto_renew_contracts(self):
"""Find contracts nearing expiry with auto-renew
enabled and create renewal records."""
today = fields.Date.today()
expiring = self.search([
('auto_renew', '=', True),
('expiration_date', '!=', False),
('expiration_date', '<=',
today + relativedelta(days=60)),
('state', '!=', 'closed'),
])
for contract in expiring:
# Check if a renewal already exists
existing = self.search([
('renewal_contract_id', '=', contract.id),
], limit=1)
if existing:
continue
new_start = contract.expiration_date + relativedelta(
days=1
)
new_contract = contract.copy({
'start_date': new_start,
'expiration_date': (
new_start + relativedelta(years=1)
),
'renewal_contract_id': contract.id,
'state': 'open',
})
contract.message_post(
body=(
f"Auto-renewal created: "
f"<a href='/web#id={new_contract.id}"
f"&model=fleet.vehicle.log.contract'>"
f"{new_contract.name}</a>"
),
subject="Contract Auto-Renewed",
)Set lease renewal alerts to 90-180 days because renegotiating a vehicle lease takes weeks of vendor quotes and internal approvals. Insurance renewals need 60 days — enough time to get competing quotes but not so early that insurers won't quote. Maintenance contracts can alert at 30 days since they're typically straightforward renewals.
Service Logs, Fuel Tracking, and Odometer Management
Every cost that isn't a contract goes into service logs (fleet.vehicle.log.services) or fuel logs (fleet.vehicle.log.fuel). Together with odometer readings, these three data streams give you complete visibility into operational costs.
Odometer Tracking
Odometer readings are the foundation of fleet cost analysis. Without accurate mileage, you can't calculate cost-per-km, predict maintenance intervals, or flag lease overage risks. Odoo stores each reading in fleet.vehicle.odometer and automatically updates the vehicle's current odometer.
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class FleetOdometerValidation(models.Model):
_inherit = 'fleet.vehicle.odometer'
@api.constrains('value')
def _check_odometer_value(self):
"""Prevent odometer readings lower than the
previous entry — catches data entry errors."""
for record in self:
previous = self.search([
('vehicle_id', '=', record.vehicle_id.id),
('date', '<=', record.date),
('id', '!=', record.id),
], order='date desc, id desc', limit=1)
if previous and record.value < previous.value:
raise ValidationError(
f"Odometer reading ({record.value} km) "
f"cannot be lower than the previous "
f"reading ({previous.value} km) on "
f"{previous.date}. Check for typos."
)
class FleetVehicleOdometerProjection(models.Model):
_inherit = 'fleet.vehicle'
projected_annual_km = fields.Float(
string='Projected Annual Km',
compute='_compute_projected_annual_km',
)
lease_km_remaining = fields.Float(
string='Lease Km Remaining',
compute='_compute_lease_km_remaining',
)
def _compute_projected_annual_km(self):
from datetime import timedelta
today = fields.Date.today()
for vehicle in self:
readings = self.env[
'fleet.vehicle.odometer'
].search([
('vehicle_id', '=', vehicle.id),
('date', '>=', today - timedelta(days=90)),
], order='date asc')
if len(readings) >= 2:
km = readings[-1].value - readings[0].value
days = (readings[-1].date - readings[0].date).days or 1
vehicle.projected_annual_km = (km / days) * 365
else:
vehicle.projected_annual_km = 0.0
def _compute_lease_km_remaining(self):
for vehicle in self:
lease = self.env[
'fleet.vehicle.log.contract'
].search([
('vehicle_id', '=', vehicle.id),
('cost_subtype_id.name', 'ilike', 'lease'),
('state', '!=', 'closed'),
], limit=1)
if lease and lease.odometer_limit:
vehicle.lease_km_remaining = (
lease.odometer_limit - vehicle.odometer
)
else:
vehicle.lease_km_remaining = 0.0Fuel Log Configuration
Fuel logs capture date, vehicle, driver, odometer at fill-up, liters, price per liter, total cost, and vendor. The key metric derived from fuel logs is liters per 100 km (L/100km) — the universal efficiency indicator for fleet management:
from odoo import models, fields, api
class FleetFuelEfficiency(models.Model):
_inherit = 'fleet.vehicle.log.fuel'
efficiency = fields.Float(
string='L/100km',
compute='_compute_efficiency',
store=True,
digits=(10, 2),
help='Fuel consumption in liters per 100 km',
)
@api.depends('liter', 'odometer_id', 'odometer_id.value')
def _compute_efficiency(self):
for log in self:
if not log.vehicle_id or not log.liter:
log.efficiency = 0.0
continue
# Find the previous fuel log for this vehicle
prev_log = self.search([
('vehicle_id', '=', log.vehicle_id.id),
('date', '<', log.date),
('id', '!=', log.id),
], order='date desc', limit=1)
if prev_log and prev_log.odometer_id:
km_driven = (
log.odometer_id.value
- prev_log.odometer_id.value
)
if km_driven > 0:
log.efficiency = (
log.liter / km_driven
) * 100
else:
log.efficiency = 0.0
else:
log.efficiency = 0.0Service Log Types
Configure service types at Fleet → Configuration → Service Types. A clean taxonomy makes cost breakdowns actionable:
| Service Type | Examples | Typical Interval | Avg. Cost Range |
|---|---|---|---|
| Preventive | Oil change, filter replacement, brake inspection | Every 10,000-15,000 km | $80-$250 |
| Corrective | Engine repair, transmission, electrical | As needed | $300-$5,000+ |
| Tires | Replacement, rotation, alignment | Every 40,000-60,000 km | $400-$1,200 |
| Regulatory | Annual inspection, emissions test, registration | Annual | $50-$200 |
The single most impactful fleet data quality rule: never allow a fuel or service log without an odometer reading. Make the odometer field required via a Python constraint or a view attribute. Without consistent odometer data, every derived metric — cost-per-km, fuel efficiency, maintenance interval prediction, lease overage risk — is unreliable. One missing reading corrupts the entire chain.
Fleet Cost Reporting: TCO Dashboards and Per-Vehicle Analysis
Odoo 19 ships with basic fleet reports, but most fleet managers need deeper analysis. The goal is a Total Cost of Ownership (TCO) view that combines contract costs, fuel, services, and depreciation into a single per-vehicle, per-km metric.
To build a custom TCO report, create a read-only SQL view model. This aggregates contract, fuel, and service costs per vehicle and exposes them as filterable, pivotable fields in Odoo's reporting UI:
from odoo import models, fields, tools
class FleetCostReport(models.Model):
_name = 'fleet.cost.report'
_description = 'Fleet Cost Analysis'
_auto = False # No automatic table creation
_order = 'total_cost desc'
vehicle_id = fields.Many2one(
'fleet.vehicle', string='Vehicle', readonly=True)
vehicle_name = fields.Char(readonly=True)
license_plate = fields.Char(readonly=True)
model_name = fields.Char(
string='Model', readonly=True)
brand_name = fields.Char(
string='Brand', readonly=True)
current_odometer = fields.Float(readonly=True)
contract_cost = fields.Float(readonly=True)
fuel_cost = fields.Float(readonly=True)
total_liters = fields.Float(readonly=True)
service_cost = fields.Float(readonly=True)
total_cost = fields.Float(
string='Total Cost (TCO)', readonly=True)
cost_per_km = fields.Float(readonly=True)
avg_fuel_efficiency = fields.Float(
string='Avg L/100km', 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
v.id,
v.id AS vehicle_id,
v.name AS vehicle_name,
v.license_plate,
vm.name AS model_name,
vb.name AS brand_name,
v.odometer AS current_odometer,
COALESCE(SUM(c.amount), 0)
AS contract_cost,
COALESCE(SUM(f.amount), 0)
AS fuel_cost,
COALESCE(SUM(f.liter), 0)
AS total_liters,
COALESCE(SUM(s.amount), 0)
AS service_cost,
COALESCE(SUM(c.amount), 0)
+ COALESCE(SUM(f.amount), 0)
+ COALESCE(SUM(s.amount), 0)
AS total_cost,
CASE WHEN v.odometer > 0 THEN
(COALESCE(SUM(c.amount), 0)
+ COALESCE(SUM(f.amount), 0)
+ COALESCE(SUM(s.amount), 0)
) / v.odometer
ELSE 0 END AS cost_per_km,
0 AS avg_fuel_efficiency
FROM fleet_vehicle v
LEFT JOIN fleet_vehicle_model vm
ON v.model_id = vm.id
LEFT JOIN fleet_vehicle_model_brand vb
ON vm.brand_id = vb.id
LEFT JOIN fleet_vehicle_log_contract c
ON c.vehicle_id = v.id
LEFT JOIN fleet_vehicle_log_fuel f
ON f.vehicle_id = v.id
LEFT JOIN fleet_vehicle_log_services s
ON s.vehicle_id = v.id
GROUP BY v.id, v.name, v.license_plate,
vm.name, vb.name, v.odometer
)
""") The TCO view above gives you a lifetime snapshot. For monthly trending, add a date dimension to your SQL view by joining on the log dates and grouping by date_trunc('month', log_date). This lets fleet managers spot cost spikes — a vehicle that suddenly doubles its monthly service cost is a candidate for replacement, not more repairs.
4 Fleet Module Mistakes That Inflate Your Total Cost of Ownership
Not Linking Fleet Contracts to Accounting
Odoo Fleet tracks contract costs internally but does not automatically create journal entries. Fleet managers see a $2,400/month lease cost in the Fleet module while the accounting team books it manually from the vendor invoice. When someone forgets to categorize a fleet invoice correctly, the fleet cost report and the P&L diverge — and nobody notices until the quarterly review.
Create an analytic account per vehicle (or per fleet category). Tag every fleet-related vendor bill line with the vehicle's analytic account. This way, the accounting module becomes the single source of truth for costs, and the fleet module reads from it. The numbers always match because they come from the same data.
Ignoring Odometer Gaps That Corrupt Every Derived Metric
A driver fills up without logging the odometer. The next fill-up shows 800 km driven on 40 liters — an impossible 5.0 L/100km for a delivery van that normally runs at 12.0 L/100km. The fuel efficiency report now shows this vehicle as your most efficient, when in reality 600 km of driving went unrecorded. Multiply this across 50 vehicles and 12 months, and your fleet cost data is fiction.
Implement the odometer validation constraint shown earlier. Add a weekly scheduled action that flags vehicles with no odometer update in the past 7 days. For high-value fleets, integrate with GPS/telematics to auto-populate odometer readings — removes the human error entirely.
Lease Km Overage Discovered at Contract End
Operating leases typically include a km allowance (e.g., 20,000 km/year). Every km over that allowance costs $0.10-$0.25 at contract end. We've seen companies hit with $8,000+ overage bills on a single vehicle because nobody tracked cumulative mileage against the lease limit during the contract term. The penalty was discovered when the leasing company sent the final invoice.
Use the lease_km_remaining computed field from Step 02. Create a dashboard alert that flags any vehicle where projected annual km (based on 90-day average) exceeds the lease allowance. At that point, you have options: reassign the vehicle, swap with a lower-mileage vehicle in the fleet, or negotiate the allowance increase mid-term (cheaper than the per-km penalty).
No Depreciation Tracking for Owned Vehicles
Companies that own their vehicles (not leased) often track fuel and maintenance but forget depreciation — the largest single cost component. A $45,000 van depreciating over 5 years costs $750/month before it burns a drop of fuel. Without depreciation in the TCO calculation, owned vehicles appear artificially cheap compared to leased ones, leading to bad lease-vs-buy decisions.
Link each owned vehicle to an asset record in Odoo's Accounting module (account.asset). Set the depreciation method (straight-line or declining balance) and useful life. The monthly depreciation entry flows into the same analytic account as other fleet costs, making TCO comparisons between owned and leased vehicles accurate.
What Structured Fleet Management Saves Your Business
Fleet costs are typically the second-largest operational expense after payroll for companies with 20+ vehicles. The ROI of managing them properly is substantial:
Visibility into per-vehicle TCO exposes the 15-20% of your fleet that costs twice the average. Replacing or reassigning those vehicles has an immediate impact on total fleet spend.
Automated contract alerts eliminate the risk of driving uninsured. A single uninsured accident can cost $50,000+ in liability — the entire fleet module ROI in one avoided incident.
Drivers log fuel and odometer from their phones. Contracts send their own renewal alerts. Reports generate themselves. The fleet manager spends time on decisions, not data entry.
The hidden ROI is lease negotiation leverage. Walking into a renewal with 24 months of accurate per-vehicle cost data lets you negotiate from strength. Leasing companies price uncertainty into quotes — eliminating it typically saves 5-8% on renewal rates.
Optimization Metadata
Complete guide to Odoo 19 Fleet Management. Vehicle registry, driver assignment, contract management for leasing and insurance, fuel tracking, odometer validation, and TCO cost reporting.
1. "Setting Up the Vehicle Registry: Models, Tags, and Fleet Categories"
2. "Contract Management: Leasing, Insurance, and Renewal Automation"
3. "Fleet Cost Reporting: TCO Dashboards and Per-Vehicle Analysis"
4. "4 Fleet Module Mistakes That Inflate Your Total Cost of Ownership"