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_idholds 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_actionandaction_open_versionput 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 withcreate_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 tohr.employee.creatego 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 addressprivate_*,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_idand its related fields). Applications add their own (e.g.hr_payrolladds 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_startisdate_version, orcontract_date_startif it is later;date_endis the day before the next active version of the employee, capped bycontract_date_end; it isFalsefor 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 answersFalse. The computed fieldis_in_contractuses it with today’s date, andemployee.is_in_contractis the one ofversion_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_contractwhen 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 whichvaluesare 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:valuesare 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. Callcheck_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), ...]}, wherevalueis the version, a field value or a tuple of field values.check_contract=Truedrops 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.createandhr.employee.createfill the missing values from thecontract_template_idgiven in the values (explicit values win);create_versionapplies thecontract_template_idgiven 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, withgroups(see Public employees and access rights) andtracking=Trueif relevant. Through_inherits, it is automatically available onhr.employeewith the samegroups: 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=Falseon a computed field, …), and keepinherited=Trueso that the value given tohr.employee.createstill 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, withgroups="hr.group_hr_user"(or stricter) unless it is also declared onhr.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”. Usecreate_version.create_versionwith an existingdate_versionreturns the existing version and ignores the values.employee._is_in_contract(contract) andversion._is_in_contract(version period) differ.current_version_idignores contract dates and only moves to a future version when the daily cron runs._get_contract_versionson an empty recordset returns all the employees’ versions.The returned dictionaries are keyed by employee record for
_get_version_periodsand_get_calendar_periods, and by employee id for_get_contracts,_get_contract_versions,_get_first_contract_version_at,_get_calendars, …Version helpers take
dateobjects; the calendar methods take timezone-aware datetimes (see Working time and calendars).version_idsonly contains active versions; usewith_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¶
- 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
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.
- 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
- create(vals_list)[código fonte]¶
Create versions, pre-filling them from
contract_template_idwhen given.Template values (see
get_values_from_contract_template()) only fill the keys absent fromvals. To add a version to an existing employee, preferhr.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_startandcontract_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 itsdate_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. Passsync_contract_dates=Truein the context to bypass the propagation and write the values as is.Also notifies the employee when
hr_responsible_idchanges.- 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_startanddate_end, the period during which the version is in effect.date_start:max(date_version, contract_date_start), ordate_versionwithout contract;date_end: the day before the next active version of the employee, capped bycontract_date_end;Falseif 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_endof the version, not of the whole contract: an older version of a still running contract answersFalse. Usehr.employee._is_in_contractto 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
- _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. ReturnsFalseif the version has no contract or if a bound is missing.- Parâmetros
date_from (datetime.date) – start of the period (inclusive)
date_to (datetime.date) – end of the period (inclusive)
- Tipo de retorno
- 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_startis set andcontract_date_endis 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
- _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.versionfield filled by contract templates (related fields are never copied). The fields should be tracked so that the change is visible.
- 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.versionrecords 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_dataformat,{}without template- Tipo de retorno
- 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, orhr.employee.publicfor non HR officers) withversion_idin the context, so that the form shows this version.Contract template (no employee): opens the version with the contract template form view.
- _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
- _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
- _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
- _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
- _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. Withdate, the employee’s version in effect on that date is used instead ofself.- 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
- _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
- _get_field_block_start_date(field_name)[código fonte]¶
Return since when
field_namecontinuously has its current value.Walks back the employee’s versions from
selfas long as the previous version has the same value and is continuous with the next one (_is_continuous_with()), and returns thedate_startof the earliest one. Useful for seniority in a job, salary scale, …- Parâmetros
field_name (str) – the
hr.versionfield to track- Retorna
the start date, or
Noneif the field is empty onself- Tipo de retorno
- _has_work_hours_between_versions(version_to)[código fonte]¶
Return whether there are working hours between the end of
selfand the start ofversion_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 (seehr.employee._get_last_consecutive_versions).- Parâmetros
version_to (hr.version) – the next version
- Tipo de retorno
- _is_continuous_with(next_version)[código fonte]¶
Return whether
next_versioncontinuesselfwithout breaking continuity.Used by
_get_field_block_start_date(). By default any gap between versions is tolerated (alwaysTrue); localizations may override it (e.g.l10n_be_hr_payroll).- Parâmetros
next_version (hr.version) – the version following
self- Tipo de retorno