diff --git a/addons/hr_work_entry/models/hr_work_entry.py b/addons/hr_work_entry/models/hr_work_entry.py
index c956a8d671f..25c2f46c6f9 100644
--- a/addons/hr_work_entry/models/hr_work_entry.py
+++ b/addons/hr_work_entry/models/hr_work_entry.py
@@ -20,7 +20,7 @@ class HrWorkEntry(models.Model):
employee_id = fields.Many2one('hr.employee', required=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", index=True)
date_start = fields.Datetime(required=True, string='From')
date_stop = fields.Datetime(compute='_compute_date_stop', store=True, readonly=False, string='To')
- duration = fields.Float(compute='_compute_duration', store=True, string="Period")
+ duration = fields.Float(compute='_compute_duration', store=True, string="Period", readonly=False)
work_entry_type_id = fields.Many2one('hr.work.entry.type', index=True)
color = fields.Integer(related='work_entry_type_id.color', readonly=True)
state = fields.Selection([
diff --git a/addons/hr_work_entry_contract/__manifest__.py b/addons/hr_work_entry_contract/__manifest__.py
index 459561b11c9..f52f5aa3ff9 100644
--- a/addons/hr_work_entry_contract/__manifest__.py
+++ b/addons/hr_work_entry_contract/__manifest__.py
@@ -17,6 +17,7 @@
'data/hr_work_entry_data.xml',
'data/ir_cron_data.xml',
'views/hr_work_entry_views.xml',
+ 'views/hr_contract_views.xml',
'wizard/hr_work_entry_regeneration_wizard_views.xml',
],
'demo': [
diff --git a/addons/hr_work_entry_contract/models/hr_contract.py b/addons/hr_work_entry_contract/models/hr_contract.py
index aff668a325a..3d7720d4513 100644
--- a/addons/hr_work_entry_contract/models/hr_contract.py
+++ b/addons/hr_work_entry_contract/models/hr_contract.py
@@ -19,9 +19,18 @@ class HrContract(models.Model):
_description = 'Employee Contract'
date_generated_from = fields.Datetime(string='Generated From', readonly=True, required=True,
- default=lambda self: datetime.now().replace(hour=0, minute=0, second=0), copy=False)
+ default=lambda self: datetime.now().replace(hour=0, minute=0, second=0, microsecond=0), copy=False)
date_generated_to = fields.Datetime(string='Generated To', readonly=True, required=True,
- default=lambda self: datetime.now().replace(hour=0, minute=0, second=0), copy=False)
+ default=lambda self: datetime.now().replace(hour=0, minute=0, second=0, microsecond=0), copy=False)
+ last_generation_date = fields.Date(string='Last Generation Date', readonly=True)
+ work_entry_source = fields.Selection([('calendar', 'Working Schedule')], required=True, default='calendar', help='''
+ Defines the source for work entries generation
+
+ Working Schedule: Work entries will be generated from the working hours below.
+ Attendances: Work entries will be generated from the employee's attendances. (requires Attendance app)
+ Planning: Work entries will be generated from the employee's planning. (requires Planning app)
+ '''
+ )
def _get_default_work_entry_type(self):
return self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False)
@@ -32,6 +41,10 @@ class HrContract(models.Model):
def _get_leave_work_entry_type(self, leave):
return leave.work_entry_type_id
+ # Is used to add more values, for example planning_slot_id
+ def _get_more_vals_attendance_interval(self, interval):
+ return []
+
# Is used to add more values, for example leave_id (in hr_work_entry_holidays)
def _get_more_vals_leave_interval(self, interval, leaves):
return []
@@ -62,24 +75,29 @@ class HrContract(models.Model):
('company_id', 'in', [False, self.company_id.id]),
]
+ def _get_attendance_intervals(self, start_dt, end_dt):
+ # {resource: intervals}
+ employees_by_calendar = defaultdict(lambda: self.env['hr.employee'])
+ for contract in self:
+ employees_by_calendar[contract.resource_calendar_id] |= contract.employee_id
+ result = dict()
+ for calendar, employees in employees_by_calendar.items():
+ result.update(calendar._attendance_intervals_batch(
+ start_dt,
+ end_dt,
+ resources=employees.resource_id,
+ tz=pytz.timezone(calendar.tz)
+ ))
+ return result
+
def _get_contract_work_entries_values(self, date_start, date_stop):
start_dt = pytz.utc.localize(date_start) if not date_start.tzinfo else date_start
end_dt = pytz.utc.localize(date_stop) if not date_stop.tzinfo else date_stop
contract_vals = []
bypassing_work_entry_type_codes = self._get_bypassing_work_entry_type_codes()
- # {calendar: employees}
- employees_by_calendar = defaultdict(lambda: self.env['hr.employee'])
- for contract in self:
- employees_by_calendar[contract.resource_calendar_id] |= contract.employee_id
- attendances_by_calendar = {
- calendar: calendar._attendance_intervals_batch(
- start_dt,
- end_dt,
- resources=employees.resource_id,
- tz=pytz.timezone(calendar.tz)
- ) for calendar, employees in employees_by_calendar.items()}
+ attendances_by_resource = self._get_attendance_intervals(start_dt, end_dt)
resource_calendar_leaves = self.env['resource.calendar.leaves'].search(self._get_leave_domain(start_dt, end_dt))
# {resource: resource_calendar_leaves}
@@ -94,7 +112,7 @@ class HrContract(models.Model):
resource = employee.resource_id
tz = pytz.timezone(calendar.tz)
- attendances = attendances_by_calendar[calendar][resource.id]
+ attendances = attendances_by_resource[resource.id]
# Other calendars: In case the employee has declared time off in another calendar
# Example: Take a time off, then a credit time.
@@ -125,7 +143,27 @@ class HrContract(models.Model):
leaves = mapped_leaves[resource.id]
real_attendances = attendances - leaves
- real_leaves = attendances - real_attendances
+ if contract.has_static_work_entries() or not leaves:
+ # Empty leaves means empty real_leaves
+ real_leaves = attendances - real_attendances
+ else:
+ # In the case of attendance based contracts use regular attendances to generate leave intervals
+ static_attendances = calendar._attendance_intervals_batch(
+ start_dt, end_dt, resources=resource, tz=tz)[resource.id]
+ real_leaves = static_attendances & leaves
+
+ if not contract.has_static_work_entries():
+ # An attendance based contract might have an invalid planning, by definition it may not happen with
+ # static work entries.
+ # Creating overlapping slots for example might lead to a single work entry.
+ # In that case we still create both work entries to indicate a problem (conflicting W E).
+ split_attendances = []
+ for attendance in real_attendances:
+ if attendance[2] and len(attendance[2]) > 1:
+ split_attendances += [(attendance[0], attendance[1], a) for a in attendance[2]]
+ else:
+ split_attendances += [attendance]
+ real_attendances = split_attendances
# A leave period can be linked to several resource.calendar.leave
split_leaves = []
@@ -139,18 +177,19 @@ class HrContract(models.Model):
# Attendances
default_work_entry_type = contract._get_default_work_entry_type()
for interval in real_attendances:
- work_entry_type_id = interval[2].mapped('work_entry_type_id')[:1] or default_work_entry_type
+ work_entry_type = 'work_entry_type_id' in interval[2] and interval[2].work_entry_type_id[:1]\
+ or default_work_entry_type
# All benefits generated here are using datetimes converted from the employee's timezone
- contract_vals += [{
- 'name': "%s: %s" % (work_entry_type_id.name, employee.name),
- 'date_start': interval[0].astimezone(pytz.utc).replace(tzinfo=None),
- 'date_stop': interval[1].astimezone(pytz.utc).replace(tzinfo=None),
- 'work_entry_type_id': work_entry_type_id.id,
- 'employee_id': employee.id,
- 'contract_id': contract.id,
- 'company_id': contract.company_id.id,
- 'state': 'draft',
- }]
+ contract_vals += [dict([
+ ('name', "%s: %s" % (work_entry_type.name, employee.name)),
+ ('date_start', interval[0].astimezone(pytz.utc).replace(tzinfo=None)),
+ ('date_stop', interval[1].astimezone(pytz.utc).replace(tzinfo=None)),
+ ('work_entry_type_id', work_entry_type.id),
+ ('employee_id', employee.id),
+ ('contract_id', contract.id),
+ ('company_id', contract.company_id.id),
+ ('state', 'draft'),
+ ] + contract._get_more_vals_attendance_interval(interval))]
for interval in real_leaves:
# Could happen when a leave is configured on the interface on a day for which the
@@ -206,6 +245,12 @@ class HrContract(models.Model):
return contract_vals
+ def has_static_work_entries(self):
+ # Static work entries as in the same are to be generated each month
+ # Useful to differentiate attendance based contracts from regular ones
+ self.ensure_one()
+ return self.work_entry_source == 'calendar'
+
def _generate_work_entries(self, date_start, date_stop, force=False):
self = self.with_context(tracking_disable=True)
canceled_contracts = self.filtered(lambda c: c.state == 'cancel')
@@ -216,6 +261,7 @@ class HrContract(models.Model):
vals_list = []
date_start = fields.Datetime.to_datetime(date_start)
date_stop = datetime.combine(fields.Datetime.to_datetime(date_stop), datetime.max.time())
+ self.write({'last_generation_date': fields.Date.today()})
intervals_to_generate = defaultdict(lambda: self.env['hr.contract'])
# In case the date_generated_from == date_generated_to, move it to the date_start to
@@ -236,14 +282,19 @@ class HrContract(models.Model):
continue
# For each contract, we found each interval we must generate
+ # In some cases we do not want to set the generated dates beforehand, since attendance based work entries
+ # is more dynamic, we want to update the dates within the _get_work_entries_values function
+ is_static_work_entries = contract.has_static_work_entries()
last_generated_from = min(contract.date_generated_from, contract_stop)
if last_generated_from > date_start_work_entries:
- contract.date_generated_from = date_start_work_entries
+ if is_static_work_entries:
+ contract.date_generated_from = date_start_work_entries
intervals_to_generate[(date_start_work_entries, last_generated_from)] |= contract
last_generated_to = max(contract.date_generated_to, contract_start)
if last_generated_to < date_stop_work_entries:
- contract.date_generated_to = date_stop_work_entries
+ if is_static_work_entries:
+ contract.date_generated_to = date_stop_work_entries
intervals_to_generate[(last_generated_to, date_stop_work_entries)] |= contract
for interval, contracts in intervals_to_generate.items():
@@ -306,6 +357,7 @@ class HrContract(models.Model):
date_to = min(self.date_end or date.max, self.date_generated_to.date())
if date_from != date_to:
contract._recompute_work_entries(date_from, date_to)
+ return result
def _recompute_work_entries(self, date_from, date_to):
self.ensure_one()
@@ -318,28 +370,30 @@ class HrContract(models.Model):
def _get_fields_that_recompute_we(self):
# Returns the fields that should recompute the work entries
- return ['resource_calendar_id']
+ return ['resource_calendar_id', 'work_entry_source']
@api.model
def _cron_generate_missing_work_entries(self):
# retrieve contracts for the current month
today = fields.Date.today()
- start = today + relativedelta(day=1)
- stop = today + relativedelta(day=31)
+ start = today + relativedelta(day=1, hour=0)
+ stop = today + relativedelta(day=31, hour=23, minute=59, second=59)
contracts = self.env['hr.employee']._get_all_contracts(
start, stop, states=['open', 'close'])
- # determine contracts to do (the ones without work entries this month)
- work_entry_groups = self.env['hr.work.entry'].read_group([
- ('date_start', '<=', stop),
- ('date_stop', '>=', start),
- ('contract_id', 'in', contracts.ids)
- ], ['contract_id'], ['contract_id'])
- contracts_done = self.browse(group['contract_id'][0] for group in work_entry_groups)
- contracts_todo = contracts - contracts_done
+ # determine contracts to do (the ones whose generated dates have open periods this month)
+ contracts_todo = contracts.filtered(lambda c:\
+ (c.date_generated_from > start or c.date_generated_to < stop) and\
+ (not c.last_generation_date or c.last_generation_date < today))
if not contracts_todo:
return
# generate a batch of work entries
BATCH_SIZE = 100
+ # Since attendance based are more volatile for their work entries generation
+ # it can happen that the date_generated_from and date_generated_to fields are not
+ # pushed to start and stop
+ # It is more interesting for batching to process statically generated work entries first
+ # since we get benefits from having multiple contracts on the same calendar
+ contracts_todo = contracts_todo.sorted(key=lambda c: 1 if c.has_static_work_entries() else 100)
contracts_todo[:BATCH_SIZE]._generate_work_entries(start, stop, False)
# if necessary, retrigger the cron to generate more work entries
if len(contracts_todo) > BATCH_SIZE:
diff --git a/addons/hr_work_entry_contract/models/hr_work_entry.py b/addons/hr_work_entry_contract/models/hr_work_entry.py
index bb54d30e792..d5208a5c256 100644
--- a/addons/hr_work_entry_contract/models/hr_work_entry.py
+++ b/addons/hr_work_entry_contract/models/hr_work_entry.py
@@ -16,6 +16,7 @@ class HrWorkEntry(models.Model):
contract_id = fields.Many2one('hr.contract', string="Contract", required=True)
employee_id = fields.Many2one(domain=[('contract_ids.state', 'in', ('open', 'pending'))])
+ work_entry_source = fields.Selection(related='contract_id.work_entry_source')
def _init_column(self, column_name):
if column_name != 'contract_id':
diff --git a/addons/hr_work_entry_contract/views/hr_contract_views.xml b/addons/hr_work_entry_contract/views/hr_contract_views.xml
new file mode 100644
index 00000000000..97fb055fecc
--- /dev/null
+++ b/addons/hr_work_entry_contract/views/hr_contract_views.xml
@@ -0,0 +1,14 @@
+
+
+
+ hr.contract.view.form.inherit.work.entry
+ hr.contract
+
+
+
+
+
+
+
+
+
diff --git a/addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard.py b/addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard.py
index 4c3e5772ce1..e39df799d34 100644
--- a/addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard.py
+++ b/addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard.py
@@ -81,6 +81,9 @@ class HrWorkEntryRegenerationWizard(models.TransientModel):
user_date_format = self.env['res.lang']._lang_get(self.env.user.lang).date_format
return date.strftime(user_date_format)
+ def _work_entry_fields_to_nullify(self):
+ return ['active']
+
def regenerate_work_entries(self):
self.ensure_one()
if not self.env.context.get('work_entry_skip_validation'):
@@ -98,7 +101,8 @@ class HrWorkEntryRegenerationWizard(models.TransientModel):
('date_start', '<=', date_to),
('state', '!=', 'validated')])
- work_entries.write({'active': False})
+ write_vals = {field: False for field in self._work_entry_fields_to_nullify()}
+ work_entries.write(write_vals)
self.employee_id.generate_work_entries(date_from, date_to, True)
action = self.env["ir.actions.actions"]._for_xml_id('hr_work_entry.hr_work_entry_action')
return action
diff --git a/addons/hr_work_entry_holidays/models/hr_contract.py b/addons/hr_work_entry_holidays/models/hr_contract.py
index 26b12e32f18..2c040cc8f23 100644
--- a/addons/hr_work_entry_holidays/models/hr_contract.py
+++ b/addons/hr_work_entry_holidays/models/hr_contract.py
@@ -42,7 +42,7 @@ class HrContract(models.Model):
# Overriden in hr_work_entry_contract_holiday to select the
# global time off first (eg: Public Holiday > Home Working)
self.ensure_one()
- if interval[2].work_entry_type_id.code in bypassing_codes:
+ if 'work_entry_type_id' in interval[2] and interval[2].work_entry_type_id.code in bypassing_codes:
return interval[2].work_entry_type_id
interval_start = interval[0].astimezone(pytz.utc).replace(tzinfo=None)
diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py
index 5cd598fbc62..c7f5a724b65 100644
--- a/addons/resource/models/resource.py
+++ b/addons/resource/models/resource.py
@@ -476,7 +476,7 @@ class ResourceCalendar(models.Model):
start_dt, end_dt, resources=resource, domain=domain, tz=tz
)[resource.id]
- def _leave_intervals_batch(self, start_dt, end_dt, resources=None, domain=None, tz=None):
+ def _leave_intervals_batch(self, start_dt, end_dt, resources=None, domain=None, tz=None, any_calendar=False):
""" Return the leave intervals in the given datetime range.
The returned intervals are expressed in specified tz or in the calendar's timezone.
"""
@@ -491,9 +491,10 @@ class ResourceCalendar(models.Model):
resource_ids = [r.id for r in resources_list]
if domain is None:
domain = [('time_type', '=', 'leave')]
+ if not any_calendar:
+ domain = domain + [('calendar_id', 'in', [False, self.id])]
# for the computation, express all datetimes in UTC
domain = domain + [
- ('calendar_id', 'in', [False, self.id]),
('resource_id', 'in', resource_ids),
('date_from', '<=', datetime_to_string(end_dt)),
('date_to', '>=', datetime_to_string(start_dt)),