Versions and contracts

Employee data changes over time: a promotion changes the job and the wage, a move changes the private address, a part-time arrangement changes the working schedule. Payroll, time off or planning computations of last March must use the data of last March, not today’s.

In hr, the data that can change over time lives on hr.version (Employee Record in the user interface), not on hr.employee. An employee has one or several versions, each one effective from a date (date_version). The contract is not a separate model either: it is carried by the contract dates of the versions. There is no hr.contract model.

This page is the one to read first: almost every HR, payroll or time computation depends on it.

How an employee delegates to its versions

hr.employee delegates to hr.version with _inherits = {'hr.version': 'version_id'}. As with any delegation inheritance, every field of hr.version that is not redefined on the employee is available on the employee as an inherited field, i.e. a related field version_id.<field>: employee.wage reads employee.version_id.wage, employee.job_id reads employee.version_id.job_id, and so on.

The particularity is that version_id is not stored. It is computed by _compute_version_id():

  • by default, it is the current version of the employee (current_version_id, stored);

  • if the context key version_id holds the id of a version of the employee, it is that version. This is how the employee form displays and edits a past or future version (hr.version.get_record_default_action and action_open_version put the key in the context). Other employees of the recordset are not affected.

Consequences for code:

  • Reading an inherited field returns the value of the current version (or of the version of the context).

  • Writing an inherited field on the employee (employee.write({'wage': 3600})) writes it on that version, for its whole period. It does not create a new version. To change data from a given date on, create a version with create_version().

  • Creating an employee creates its first version: the values of version fields (date_version, contract_date_start, wage, job_id, resource_calendar_id, …) given to hr.employee.create go to that version.

Example

from datetime import date

employee = env['hr.employee'].create({
    'name': 'Jane Doe',
    'date_version': date(2026, 1, 1),
    'contract_date_start': date(2026, 1, 1),
    'wage': 3000,
    'resource_calendar_id': env.ref('resource.resource_calendar_std').id,
    'tz': 'Europe/Brussels',
})
v1 = employee.version_id
len(employee.version_ids), v1 == employee.current_version_id  # (1, True)
employee.wage, v1.wage  # (3000.0, 3000.0)

# a raise from June 1st: a new version
v2 = employee.create_version({'date_version': date(2026, 6, 1), 'wage': 3500})
[(v.date_version, v.date_start, v.date_end, v.wage) for v in employee.version_ids]
# [(date(2026, 1, 1), date(2026, 1, 1), date(2026, 5, 31), 3000.0),
#  (date(2026, 6, 1), date(2026, 6, 1), False, 3500.0)]
employee.current_version_id == v2, employee.wage  # (True, 3500.0)  (today: 2026-10-23)

# read and write another version through the context
employee.with_context(version_id=v1.id).wage  # 3000.0

employee.write({'wage': 3600})  # writes the current version only
v1.wage, v2.wage  # (3000.0, 3600.0)
employee.with_context(version_id=v1.id).write({'wage': 3100})
v1.wage, v2.wage  # (3100.0, 3600.0)

Nota

Some version fields are redefined on the employee with related='version_id.<field>', inherited=True (contract dates, structure_type_id, tz, resource_calendar_id, departure fields, …), mainly because resource.mixin defines fields with the same names or because other attributes are needed. They behave as the automatically inherited fields.

Where fields live

On hr.version (time-dependent)

date_version (Effective Date, required), personal data that may change (country_id, identification_id, passport_id, sex, private address private_*, marital, spouse_*, children, km_home_work, …), work data (department_id, job_id, job_title, address_id, work_location_id, resource_calendar_id, tz), contract data (contract_date_start, contract_date_end, fixed_term, trial_date_end, contract_template_id, structure_type_id, wage, employee_type_id, hr_responsible_id) and the departure (departure_id and its related fields). Applications add their own (e.g. hr_payroll adds the wage type, the schedule pay, …; see Payroll data of versions).

On hr.employee (not time-dependent)

The identity and links of the person: name, resource_id, user_id, work_contact_id, company_id, active, work and private contact data (work_email, work_phone, mobile_phone, private_email, private_phone), birthday, bank accounts, permits, parent_id (manager), coach_id, category_ids, barcode, pin, the weekly work locations, hourly_cost, first_contract_date, and the versions themselves (version_ids, current_version_id, version_id).

Contract templates are versions too: see Contract templates.

Version dates

A version has an effective date (date_version, stored) and an effective period (date_start and date_end, computed by _compute_dates(), both inclusive):

  • date_start is date_version, or contract_date_start if it is later;

  • date_end is the day before the next active version of the employee, capped by contract_date_end; it is False for the last version of an open-ended contract (or of an employee without contract).

date_start and date_end are not stored but can be used in domains. Versions are ordered by date_version, so employee.version_ids[0] is the oldest active version (version_ids only contains active versions), and an employee cannot have two active versions with the same date_version (unique index). An employee always keeps at least one active version: archiving, reassigning or deleting all of them is refused.

The flags is_current, is_past and is_future compare this period with today.

Contracts

A version is in contract when contract_date_start is set. A contract is the set of active versions of an employee sharing the same contract_date_start (and contract_date_end). An employee may have several successive contracts, but they cannot overlap (_check_dates), and a contract end date requires a start date.

The timeline below is the one of the examples of this page: two versions in a first contract, a gap in January 2027, then a second contract.

date_version  2026-01-01         2026-06-01                     2027-02-01
              |                  |                              |
versions      [v1: 3100 ........][v2: 3600 ....................]  [v3: 3600 .........>
contracts     [contract 2026-01-01 -> 2026-12-31 ..............]  [contract 2027-02-01 -> open >
                                                   out of contract ^ (January 2027)

v1: date_start 2026-01-01, date_end 2026-05-31
v2: date_start 2026-06-01, date_end 2026-12-31 (capped by the contract end)
v3: date_start 2027-02-01, date_end False (open-ended)

The contract dates are kept consistent by write(): writing contract_date_start or contract_date_end on a version of a contract writes them on all the versions of that contract. Writing them on versions of different contracts at once raises a ValidationError. Pass sync_contract_dates=True in the context to write the values as is, without propagation.

Example

Following the previous example:

employee._get_contract_dates(date(2026, 3, 1))  # (date(2026, 1, 1), False)

# end the contract: written on v2, propagated to v1
v2.write({'contract_date_end': date(2026, 12, 31)})
[(v.date_version, v.contract_date_end) for v in employee.version_ids]
# [(date(2026, 1, 1), date(2026, 12, 31)), (date(2026, 6, 1), date(2026, 12, 31))]

# start a new contract
employee.check_no_existing_contract(date(2027, 2, 1))  # raises if already in contract
v3 = employee.create_contract(date(2027, 2, 1))
v3.date_version, v3.contract_date_start, v3.contract_date_end, v3.wage
# (date(2027, 2, 1), date(2027, 2, 1), False, 3600.0)

employee._get_all_contract_dates()
# [(date(2026, 1, 1), date(2026, 12, 31)), (date(2027, 2, 1), False)]

Two meanings of “in contract”

Two methods have the same name and different meanings:

hr.employee._is_in_contract(date)

Whether a contract of the employee covers the date, whatever the version in effect.

hr.version._is_in_contract(date)

Whether this version has a contract and is in effect on the date (date_start <= date <= date_end). An older version of a still running contract answers False. The computed field is_in_contract uses it with today’s date, and employee.is_in_contract is the one of version_id.

# July 2026, in the first contract, v2 in effect
employee._is_in_contract(date(2026, 7, 1))  # True
v1._is_in_contract(date(2026, 7, 1))  # False: v1 ended on May 31
v2._is_in_contract(date(2026, 7, 1))  # True

The current version

current_version_id (stored) is computed by _compute_current_version_id(): it is the active version with the latest date_version on or before today (in the user’s timezone); if all versions are in the future, the first one. _get_version() applies the same rule to any date.

Two things to keep in mind:

  • Contract dates are ignored: after the end of a contract, the current version is still the last version of that contract, out of contract. Check _is_in_contract when it matters.

  • The value depends on today’s date, which the ORM cannot track: a future version only becomes current when the daily cron HR Employee: Update Current Version (_cron_update_current_version_id()) runs.

The resource.resource of the employee mirrors the calendar and timezone of the current version: see Resource, user and work contact.

Working with versions

The methods to use, all on hr.employee unless stated otherwise:

create_version()

Creates a version effective from values['date_version'] (mandatory): a copy of the version in effect on that date, on which values are written. The contract dates default to those of the contract running on that date. If a version already exists on that exact date, it is returned unchanged: values are not written.

create_contract()

Starts a contract on a date and returns the version carrying it (an existing version on that date, or a new one created with create_version). The contract ends the day before the next future contract, or is open-ended. Call check_no_existing_contract() before, as the user interface does.

_get_version()

The version in effect on a date (today by default), ignoring contracts.

_get_contract_dates()

The (start, end) of the contract covering a date, (False, False) if none.

_get_all_contract_dates()

The (start, end) of all the contracts of the employee.

_get_contract_versions()

Batch: the versions of the contracts overlapping a period, {employee_id: {contract_date_start: hr.version}}. On an empty recordset, it returns the versions of all employees.

_get_contracts()

Batch: one version per contract overlapping a period, {employee_id: hr.version}; the one in effect at the end of the period (use_latest_version=True, default) or at its start.

_get_versions_with_contract_overlap_with_period()

Batch: the versions in contract and in effect at least one day of the period. This is the method behind the contract-aware calendar methods. Its model-level variant _get_all_versions_with_contract_overlap_with_period() searches all the accessible employees.

_get_first_contract_version_at()

{employee_id: version} for the employees in contract on a date.

_get_version_periods()

Batch: splits a period into the periods covered by each version, {employee record: [(date_from, date_to, value), ...]}, where value is the version, a field value or a tuple of field values. check_contract=True drops the periods out of contract.

first_contract_date (stored) is the start of the last uninterrupted block of contracts: a gap with working days between two contracts resets it (see _get_first_contract_date()).

Example

With the employee of the timeline above:

{d: versions.mapped('date_version')
 for d, versions in employee._get_contract_versions()[employee.id].items()}
# {date(2026, 1, 1): [date(2026, 1, 1), date(2026, 6, 1)],
#  date(2027, 2, 1): [date(2027, 2, 1)]}
contracts = employee._get_contracts(date(2026, 1, 1), date(2027, 12, 31))
contracts[employee.id].mapped('date_version')
# [date(2026, 6, 1), date(2027, 2, 1)]

employee._get_version_periods(date(2025, 12, 1), date(2027, 3, 31), 'wage')
# {hr.employee(...): [(date(2026, 1, 1), date(2026, 5, 31), 3100.0),
#                     (date(2026, 6, 1), date(2026, 12, 31), 3600.0),
#                     (date(2027, 2, 1), date(2027, 3, 31), 3600.0)]}
employee._get_versions_with_contract_overlap_with_period(
    date(2026, 7, 1), date(2026, 7, 31)).mapped('date_version')  # [date(2026, 6, 1)]

employee.first_contract_date  # date(2027, 2, 1): January 2027 has working days

# create_version on an existing date returns the existing version unchanged
employee.create_version({'date_version': date(2026, 6, 1), 'wage': 9999}) == v2  # True
v2.wage  # 3600.0

Ending a contract is done by writing contract_date_end on one of its versions, or with a departure (see Departure).

Contract templates

A contract template is an hr.version without employee_id, with a name. Selecting it in contract_template_id copies the fields listed by _get_whitelist_fields_from_template() (job, department, employee type, structure type, wage, working schedule, HR responsible, …) onto the version:

  • hr.version.create and hr.employee.create fill the missing values from the contract_template_id given in the values (explicit values win);

  • create_version applies the contract_template_id given in the values on the new version;

  • in the employee form, an onchange applies it.

get_values_from_contract_template() returns the values to copy.

Example

job = env['hr.job'].create({'name': 'Developer'})
template = env['hr.version'].create({
    'name': 'Developer (full time)',
    'job_id': job.id,
    'wage': 4000,
})
v4 = employee.create_version({
    'date_version': date(2027, 6, 1),
    'contract_template_id': template.id,
})
v4.job_id.name, v4.wage, v4.contract_date_start  # ('Developer', 4000.0, date(2027, 2, 1))

Adding a field

Data that changes over time

Define the field on hr.version, with groups (see Public employees and access rights) and tracking=True if relevant. Through _inherits, it is automatically available on hr.employee with the same groups: it can be put in the employee form view, and writing it from the employee writes the version displayed. Add it to _get_whitelist_fields_from_template() if contract templates should fill it.

class HrVersion(models.Model):
    _inherit = 'hr.version'

    attendance_based = fields.Boolean(
        string="Attendance Based",
        groups="hr.group_hr_user",
        default=lambda self: self.env.company.sudo().attendance_based,
    )

    @api.model
    def _get_whitelist_fields_from_template(self):
        return super()._get_whitelist_fields_from_template() + ['attendance_based']

Redeclare it on the employee only if it needs other attributes there (another group, readonly=False on a computed field, …), and keep inherited=True so that the value given to hr.employee.create still goes into the version:

class HrEmployee(models.Model):
    _inherit = 'hr.employee'

    attendance_based = fields.Boolean(
        related='version_id.attendance_based', inherited=True, readonly=False,
        groups="hr.group_hr_user",
    )
Data that does not change over time

Define the field on hr.employee, with groups="hr.group_hr_user" (or stricter) unless it is also declared on hr.employee.public. See Public employees and access rights.

Other hooks of hr.version meant to be overridden: _get_contract_wage_field (the field holding the contractual wage, e.g. the hourly wage in hr_payroll), _get_salary_costs_factor (wage to yearly cost) and _is_continuous_with (used by _get_field_block_start_date(), e.g. to compute a seniority in a salary scale).

Pitfalls

  • employee.write({...}) on version fields modifies the version displayed (the current one by default) for its whole period, not “from today”. Use create_version.

  • create_version with an existing date_version returns the existing version and ignores the values.

  • employee._is_in_contract (contract) and version._is_in_contract (version period) differ.

  • current_version_id ignores contract dates and only moves to a future version when the daily cron runs.

  • _get_contract_versions on an empty recordset returns all the employees’ versions.

  • The returned dictionaries are keyed by employee record for _get_version_periods and _get_calendar_periods, and by employee id for _get_contracts, _get_contract_versions, _get_first_contract_version_at, _get_calendars, …

  • Version helpers take date objects; the calendar methods take timezone-aware datetimes (see Working time and calendars).

  • version_ids only contains active versions; use with_context(active_test=False) to see archived ones.

Reference

Original definition from hr

model odoo.addons.hr.models.hr_version.HrVersion[código fonte]
_name = hr.version
employee_id: int
Name

Employee

Comodel

hr.employee

date_version: datetime.date
Required

Name

Effective Date

date_start: datetime.date
Name

Date Start

Readonly

this field is not supposed to/cannot be set manually

Store

this field is there only for technical reasons

date_end: datetime.date
Name

Date End

Readonly

this field is not supposed to/cannot be set manually

Store

this field is there only for technical reasons

contract_date_start: datetime.date
Name

Contract Start Date

contract_date_end: datetime.date
Name

Contract End Date

End date of the contract (if it’s a fixed-term contract).

contract_template_id: int
Name

Contract Template

Comodel

hr.version

Select a contract template to auto-fill the contract form with predefined values. You can still edit the fields as needed after applying the template.

resource_calendar_id: int
Required

Name

Working Hours

Comodel

resource.calendar

tz: str
Required

Name

Timezone

department_id: int
Name

Department

Comodel

hr.department

job_id: int
Name

Job

Comodel

hr.job

wage: float
Name

Wage

Employee’s monthly gross wage.

structure_type_id: int
Name

Salary Structure Type

Comodel

hr.payroll.structure.type

employee_type_id: int
Name

Employee Type

Comodel

hr.employee.type

is_in_contract: bool
Name

Is In Contract

Readonly

this field is not supposed to/cannot be set manually

Store

this field is there only for technical reasons

departure_id: int
Name

Departure

Comodel

hr.employee.departure

create(vals_list)[código fonte]

Create versions, pre-filling them from contract_template_id when given.

Template values (see get_values_from_contract_template()) only fill the keys absent from vals. To add a version to an existing employee, prefer hr.employee.create_version, which copies the version in effect and keeps the contract dates consistent.

write(vals)[código fonte]

Write on versions, keeping the contract dates consistent across the versions of a contract.

All versions of a contract share the same contract_date_start and contract_date_end: writing one of these fields on a version writes them on every active version of the same contract (writing a start date on the only version of an employee also moves its date_version; on versions not yet in contract, only the written versions are affected). Writing them on versions of different contracts of the same employee at once is refused. Pass sync_contract_dates=True in the context to bypass the propagation and write the values as is.

Also notifies the employee when hr_responsible_id changes.

Levanta

ValidationError – when unassigning or archiving all the versions of an employee, or writing contract dates on versions of different contracts at once

_compute_dates()[código fonte]

Compute date_start and date_end, the period during which the version is in effect.

  • date_start: max(date_version, contract_date_start), or date_version without contract;

  • date_end: the day before the next active version of the employee, capped by contract_date_end; False if it is the last version of an open-ended (or no) contract.

Both dates are inclusive. These fields are not stored but searchable.

_is_in_contract(date=None)[código fonte]

Return whether this version has a contract and is in effect on date.

Checks date_start <= date <= date_end of the version, not of the whole contract: an older version of a still running contract answers False. Use hr.employee._is_in_contract to check the contract itself. Single version only.

Parâmetros

date (datetime.date or None) – the date to check, today (user’s timezone) by default

Tipo de retorno

bool

_is_overlapping_period(date_from, date_to)[código fonte]

Return whether this version is in contract and in effect at least one day of the period.

Compares the version’s date_start/date_end (not the whole contract) with the period. Returns False if the version has no contract or if a bound is missing.

Parâmetros
Tipo de retorno

bool

check_contract_finished()[código fonte]

Raise if the version has an open-ended contract (start date but no end date).

Levanta

ValidationError – if contract_date_start is set and contract_date_end is not

_get_current_versions_domain()[código fonte]

Return a domain matching the employee versions whose contract covers today.

All versions of a running contract match (not only the version in effect today); contract templates (no employee) are excluded.

Tipo de retorno

list

_get_whitelist_fields_from_template()[código fonte]

Return the names of the fields copied from a contract template.

Extend it to have a new hr.version field filled by contract templates (related fields are never copied). The fields should be tracked so that the change is visible.

Tipo de retorno

list[str]

get_values_from_contract_template(contract_template_id)[código fonte]

Return the values to copy from a contract template onto a version.

Contract templates are hr.version records without employee. Only the whitelisted fields (_get_whitelist_fields_from_template(), evaluated in the template’s company) are returned.

Parâmetros

contract_template_id (hr.version) – the template (may be empty)

Retorna

values in copy_data format, {} without template

Tipo de retorno

dict

get_record_default_action(access_uid=None)[código fonte]

Return the action opening the version: the employee form for employee versions.

  • Version of an employee: opens the employee (hr.employee, or hr.employee.public for non HR officers) with version_id in the context, so that the form shows this version.

  • Contract template (no employee): opens the version with the contract template form view.

Parâmetros

access_uid (int) – the user to check instead of the current user

Tipo de retorno

dict

_get_contract_wage()[código fonte]

Return the contractual wage, read from the field given by _get_contract_wage_field().

Retorna

the wage, 0 for an empty recordset

Tipo de retorno

float

_get_contract_wage_field()[código fonte]

Return the name of the field holding the contractual wage ('wage').

Overridden e.g. by hr_payroll (hourly wage) and localizations.

Tipo de retorno

str

_get_normalized_wage()[código fonte]

Return the hourly wage, assuming a monthly wage (wage * 12 / 52 / hours_per_week).

Without calendar, the wage is returned as is (supposed hourly); with a calendar without hours, 0. Overridden in hr_payroll, which knows the pay frequency.

Tipo de retorno

float

_get_salary_costs_factor()[código fonte]

Return the factor turning the wage into a yearly cost (12, i.e. a monthly wage).

Overridden in hr_payroll (based on the pay schedule) and by localizations (e.g. 13th month, employer contributions).

Tipo de retorno

float

_get_reference_calendar(date=None)[código fonte]

Return the reference calendar (full-time equivalent) of the version’s working schedule.

That is the calendar’s reference_calendar_id, or the company’s default calendar. With date, the employee’s version in effect on that date is used instead of self.

Parâmetros

date (datetime.date or None) – reference date

Tipo de retorno

resource.calendar

_get_tz()[código fonte]

Return the timezone name of the version.

Falls back on the employee’s user partner timezone, the employee’s company timezone, then 'UTC'.

Tipo de retorno

str

_get_resources_per_tz()[código fonte]

Group the resources of the versions’ employees by the versions’ timezone.

Retorna

{ZoneInfo: resource.resource}

Tipo de retorno

dict

_get_field_block_start_date(field_name)[código fonte]

Return since when field_name continuously has its current value.

Walks back the employee’s versions from self as long as the previous version has the same value and is continuous with the next one (_is_continuous_with()), and returns the date_start of the earliest one. Useful for seniority in a job, salary scale, …

Parâmetros

field_name (str) – the hr.version field to track

Retorna

the start date, or None if the field is empty on self

Tipo de retorno

datetime.date or None

_has_work_hours_between_versions(version_to)[código fonte]

Return whether there are working hours between the end of self and the start of version_to.

Uses self’s calendar, without leaves. Back-to-back versions never have work hours in between; without calendar, False. Used to decide whether two versions are consecutive (see hr.employee._get_last_consecutive_versions).

Parâmetros

version_to (hr.version) – the next version

Tipo de retorno

bool

_is_continuous_with(next_version)[código fonte]

Return whether next_version continues self without breaking continuity.

Used by _get_field_block_start_date(). By default any gap between versions is tolerated (always True); localizations may override it (e.g. l10n_be_hr_payroll).

Parâmetros

next_version (hr.version) – the version following self

Tipo de retorno

bool