From 20bc2bbab5839bdbbf6b86972e677e10102e5bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Lef=C3=A8vre?= Date: Wed, 19 Dec 2018 12:32:05 +0000 Subject: [PATCH] [IMP] hr_payroll: add multi contract support Purpose ======= We would like to be able to manage multi contracts payslips and contract transitions. Specification ============= General flow: 1/ Benefits are generated based on contracts's calendar. 2/ When validating additionnal benefits, calendar.attendances are created in the contract's calendar 3/ Create one payslip per contract in state [open|pending|close] 4/ For each payslip, generate worked day lines based on the contract's calendar New Constraints: 1/ An employee cannot have more than one contract at the same time (excluding draft, closed and cancelled contracts). 2/ A leave cannot overlap multiple contracts. 3/ A contract cannot have starting and ending dates such that a leave become in the middle of two contacts. New state for a contract: Incoming. It means it's ready and will be running soon. An incoming contract automatically becomes 'Running' if the start date is passed (cron) Number of hours and days are always correct (according to the contract's calendar) on payslips as the flow starts from the contract's calendar. However, it is not correct in the Time Off app because it's counted based on the employee's calendar. The number of days/hours for hr.leaves should be computed according to the contract at the time of the leave. e.g. If I currently work full time and I request 2 weeks off next month where I'll be working only 3 days/week and 5hours/day (other contract), It should count 6 days and 30 hours. - in hr.benefits form view show the contract (below employee field) TaskID: 1903483 closes odoo/odoo#30181 --- addons/hr_contract/data/hr_contract_demo.xml | 4 +- addons/hr_contract/models/hr_contract.py | 55 +++++-- addons/hr_contract/tests/__init__.py | 1 + addons/hr_contract/tests/test_auto_status.py | 13 +- addons/hr_contract/tests/test_contract.py | 86 ++++++++++ addons/hr_payroll/models/hr_benefit.py | 4 +- addons/hr_payroll/models/hr_contract.py | 14 +- addons/hr_payroll/models/hr_employee.py | 18 ++- addons/hr_payroll/models/hr_leave.py | 112 ++++++++++++- addons/hr_payroll/models/hr_payslip.py | 41 ++--- addons/hr_payroll/models/resource.py | 23 ++- addons/hr_payroll/tests/__init__.py | 2 + addons/hr_payroll/tests/common.py | 61 +++++++ addons/hr_payroll/tests/test_benefit.py | 34 ++-- addons/hr_payroll/tests/test_calendar_sync.py | 52 ++++++ .../hr_payroll/tests/test_multi_contract.py | 149 ++++++++++++++++++ addons/hr_payroll/views/hr_benefit_views.xml | 1 + .../hr_payroll_payslips_by_employees.py | 21 +-- .../models/hr_payroll_account.py | 6 +- .../tests/test_hr_payroll_account.py | 1 - 20 files changed, 612 insertions(+), 86 deletions(-) create mode 100644 addons/hr_contract/tests/test_contract.py create mode 100644 addons/hr_payroll/tests/test_calendar_sync.py create mode 100644 addons/hr_payroll/tests/test_multi_contract.py diff --git a/addons/hr_contract/data/hr_contract_demo.xml b/addons/hr_contract/data/hr_contract_demo.xml index 6e8326a0203..eef59fd9813 100644 --- a/addons/hr_contract/data/hr_contract_demo.xml +++ b/addons/hr_contract/data/hr_contract_demo.xml @@ -11,7 +11,7 @@ - open + draft @@ -64,7 +64,7 @@ - open + draft diff --git a/addons/hr_contract/models/hr_contract.py b/addons/hr_contract/models/hr_contract.py index 21aaa77487d..938ae2d06a8 100644 --- a/addons/hr_contract/models/hr_contract.py +++ b/addons/hr_contract/models/hr_contract.py @@ -16,15 +16,9 @@ class Employee(models.Model): medic_exam = fields.Date(string='Medical Examination Date', groups="hr.group_hr_user") vehicle = fields.Char(string='Company Vehicle', groups="hr.group_hr_user") contract_ids = fields.One2many('hr.contract', 'employee_id', string='Employee Contracts') - contract_id = fields.Many2one('hr.contract', compute='_compute_contract_id', string='Current Contract', help='Latest contract of the employee') + contract_id = fields.Many2one('hr.contract', string='Current Contract', help='Current contract of the employee') contracts_count = fields.Integer(compute='_compute_contracts_count', string='Contract Count') - def _compute_contract_id(self): - """ get the lastest contract """ - Contract = self.env['hr.contract'] - for employee in self: - employee.contract_id = Contract.search([('employee_id', '=', employee.id)], order='date_start desc', limit=1) - def _compute_contracts_count(self): # read_group as sudo, since contract count is displayed on form view contract_data = self.env['hr.contract'].sudo().read_group([('employee_id', 'in', self.ids)], ['employee_id'], ['employee_id']) @@ -32,25 +26,28 @@ class Employee(models.Model): for employee in self: employee.contracts_count = result.get(employee.id, 0) - def _get_contracts(self, date_from, date_to): + def _get_contracts(self, date_from, date_to, states=['open', 'pending']): """ Returns the contracts of the employee between date_from and date_to """ # a contract is valid if it ends between the given dates - if not self: - employees = self.env['hr.employee'].search([]) - else: - employees = self clause_1 = ['&', ('date_end', '<=', date_to), ('date_end', '>=', date_from)] # OR if it starts between the given dates clause_2 = ['&', ('date_start', '<=', date_to), ('date_start', '>=', date_from)] # OR if it starts before the date_from and finish after the date_end (or never finish) clause_3 = ['&', ('date_start', '<=', date_from), '|', ('date_end', '=', False), ('date_end', '>=', date_to)] clause_final = expression.AND([ - [('employee_id', 'in', employees.ids), ('state', '=', 'open')], + [('employee_id', 'in', self.ids), ('state', 'in', states)], expression.OR([clause_1, clause_2, clause_3])]) return self.env['hr.contract'].search(clause_final) + @api.model + def _get_all_contracts(self, date_from, date_to, states=['open', 'pending']): + """ + Returns the contracts of all employees between date_from and date_to + """ + return self.search([])._get_contracts(date_from, date_to, states=states) + class ContractType(models.Model): @@ -88,6 +85,7 @@ class Contract(models.Model): notes = fields.Text('Notes') state = fields.Selection([ ('draft', 'New'), + ('incoming', 'Incoming'), ('open', 'Running'), ('pending', 'To Renew'), ('close', 'Expired'), @@ -112,6 +110,27 @@ class Contract(models.Model): self.department_id = self.employee_id.department_id self.resource_calendar_id = self.employee_id.resource_calendar_id + @api.constrains('employee_id', 'state', 'date_start', 'date_end') + def _check_current_contract(self): + """ Two contracts in state [incoming | pending | open | close] cannot overlap """ + for contract in self.filtered(lambda c: c.state not in ['draft', 'cancel']): + domain = [ + ('id', '!=', contract.id), + ('employee_id', '=', contract.employee_id.id), + ('state', 'in', ['incoming', 'pending', 'open', 'close']), + ] + + if not contract.date_end: + start_domain = [] + end_domain = ['|', ('date_end', '>=', contract.date_start), ('date_end', '=', False)] + else: + start_domain = [('date_start', '<=', contract.date_end)] + end_domain = ['|', ('date_end', '>', contract.date_start), ('date_end', '=', False)] + + domain = expression.AND([domain, start_domain, end_domain]) + if self.search_count(domain): + raise ValidationError(_('An employee can only have one contract at the same time. (Excluding Draft and Cancelled contracts)')) + @api.constrains('date_start', 'date_end') def _check_dates(self): if self.filtered(lambda c: c.date_end and c.date_start > c.date_end): @@ -141,8 +160,18 @@ class Contract(models.Model): 'state': 'close' }) + self.search([('state', '=', 'incoming'), ('date_start', '<=', fields.Date.to_string(date.today())),]).write({ + 'state': 'open' + }) return True + @api.multi + def write(self, vals): + if vals.get('state') == 'open': + for contract in self: + contract.employee_id.contract_id = contract + return super(Contract, self).write(vals) + @api.multi def _track_subtype(self, init_values): self.ensure_one() diff --git a/addons/hr_contract/tests/__init__.py b/addons/hr_contract/tests/__init__.py index 6f1832811c2..ad627a612a4 100644 --- a/addons/hr_contract/tests/__init__.py +++ b/addons/hr_contract/tests/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_auto_status +from . import test_contract diff --git a/addons/hr_contract/tests/test_auto_status.py b/addons/hr_contract/tests/test_auto_status.py index 580a6d17423..20b8e4007fc 100644 --- a/addons/hr_contract/tests/test_auto_status.py +++ b/addons/hr_contract/tests/test_auto_status.py @@ -10,7 +10,12 @@ class TestHrContracts(TransactionCase): def setUp(self): super(TestHrContracts, self).setUp() self.contracts = self.env['hr.contract'].with_context(tracking_disable=True) - self.employee = self.env.ref('hr.employee_admin') + self.employee = self.env['hr.employee'].create({ + 'name': 'Richard', + 'gender': 'male', + 'birthday': '1984-05-01', + 'country_id': self.ref('base.be'), + }) self.test_contract = dict(name='Test', wage=1, employee_id=self.employee.id, state='open') def apply_cron(self): @@ -51,3 +56,9 @@ class TestHrContracts(TransactionCase): self.contract.write(self.test_contract) self.apply_cron() self.assertEquals(self.contract.state, 'close') + + def test_contract_start_date(self): + self.test_contract.update(dict(date_start=datetime.now(), state='incoming')) + self.contract = self.contracts.create(self.test_contract) + self.apply_cron() + self.assertEquals(self.contract.state, 'open') diff --git a/addons/hr_contract/tests/test_contract.py b/addons/hr_contract/tests/test_contract.py new file mode 100644 index 00000000000..39d6743d275 --- /dev/null +++ b/addons/hr_contract/tests/test_contract.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime +from odoo.tests.common import TransactionCase +from odoo.exceptions import ValidationError +from dateutil.relativedelta import relativedelta + + +class TestHrContracts(TransactionCase): + + def setUp(self): + super(TestHrContracts, self).setUp() + self.contracts = self.env['hr.contract'].with_context(tracking_disable=True) + self.employee = self.env['hr.employee'].create({ + 'name': 'Richard', + 'gender': 'male', + 'birthday': '1984-05-01', + 'country_id': self.ref('base.be'), + }) + + def create_contract(self, state, start, end=None): + return self.env['hr.contract'].create({ + 'name': 'Contract', + 'employee_id': self.employee.id, + 'state': state, + 'wage': 1, + 'date_start': start, + 'date_end': end, + }) + + def test_incoming_overlapping_contract(self): + start = datetime.strptime('2015-11-01', '%Y-%m-%d').date() + end = datetime.strptime('2015-11-30', '%Y-%m-%d').date() + self.create_contract('open', start, end) + + # Incoming contract + with self.assertRaises(ValidationError, msg="It should not create two contract in state open or incoming"): + start = datetime.strptime('2015-11-15', '%Y-%m-%d').date() + end = datetime.strptime('2015-12-30', '%Y-%m-%d').date() + self.create_contract('incoming', start, end) + + def test_pending_overlapping_contract(self): + start = datetime.strptime('2015-11-01', '%Y-%m-%d').date() + end = datetime.strptime('2015-11-30', '%Y-%m-%d').date() + self.create_contract('open', start, end) + + # Pending contract + with self.assertRaises(ValidationError, msg="It should not create two contract in state open or pending"): + start = datetime.strptime('2015-11-15', '%Y-%m-%d').date() + end = datetime.strptime('2015-12-30', '%Y-%m-%d').date() + self.create_contract('pending', start, end) + + # Draft contract -> should not raise + start = datetime.strptime('2015-11-15', '%Y-%m-%d').date() + end = datetime.strptime('2015-12-30', '%Y-%m-%d').date() + self.create_contract('draft', start, end) + + def test_draft_overlapping_contract(self): + start = datetime.strptime('2015-11-01', '%Y-%m-%d').date() + end = datetime.strptime('2015-11-30', '%Y-%m-%d').date() + self.create_contract('open', start, end) + + # Draft contract -> should not raise even if overlapping + start = datetime.strptime('2015-11-15', '%Y-%m-%d').date() + end = datetime.strptime('2015-12-30', '%Y-%m-%d').date() + self.create_contract('draft', start, end) + + def test_overlapping_contract_no_end(self): + + # No end date + self.create_contract('open', datetime.strptime('2015-11-01', '%Y-%m-%d').date()) + + with self.assertRaises(ValidationError): + start = datetime.strptime('2015-11-15', '%Y-%m-%d').date() + end = datetime.strptime('2015-12-30', '%Y-%m-%d').date() + self.create_contract('incoming', start, end) + + def test_overlapping_contract_no_end2(self): + + start = datetime.strptime('2015-11-01', '%Y-%m-%d').date() + end = datetime.strptime('2015-12-30', '%Y-%m-%d').date() + self.create_contract('open', start, end) + + with self.assertRaises(ValidationError): + # No end + self.create_contract('incoming', datetime.strptime('2015-01-01', '%Y-%m-%d').date()) diff --git a/addons/hr_payroll/models/hr_benefit.py b/addons/hr_payroll/models/hr_benefit.py index bd99e999a12..c25ebfd8afb 100644 --- a/addons/hr_payroll/models/hr_benefit.py +++ b/addons/hr_payroll/models/hr_benefit.py @@ -21,6 +21,7 @@ class HrBenefit(models.Model): date_start = fields.Datetime(required=True, string='From') date_stop = fields.Datetime(string='To') duration = fields.Float(compute='_compute_duration', inverse='_inverse_duration', store=True, string="Period") + contract_id = fields.Many2one('hr.contract', string="Contract", required=True) benefit_type_id = fields.Many2one('hr.benefit.type') color = fields.Integer(related='benefit_type_id.color', readonly=True) state = fields.Selection([ @@ -190,6 +191,7 @@ class HrBenefit(models.Model): 'name': benefit.name, 'employee_id': benefit.employee_id.id, 'benefit_type_id': benefit.benefit_type_id.id, + 'contract_id': benefit.contract_id.id, } benefit_state = benefit.state benefit.unlink() @@ -256,7 +258,7 @@ class HrBenefit(models.Model): 'date_to': end.date(), 'hour_from':start.hour + start.minute/60, 'hour_to': end.hour + end.minute/60, - 'calendar_id': benefit.employee_id.resource_calendar_id.id, + 'calendar_id': benefit.contract_id.resource_calendar_id.id, 'day_period': 'morning' if end.hour <= 12 else 'afternoon', 'resource_id': benefit.employee_id.resource_id.id, 'benefit_type_id': benefit.benefit_type_id.id, diff --git a/addons/hr_payroll/models/hr_contract.py b/addons/hr_payroll/models/hr_contract.py index 3fb14c8988f..53217a4b4b9 100644 --- a/addons/hr_payroll/models/hr_contract.py +++ b/addons/hr_payroll/models/hr_contract.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models - +from datetime import date class HrContract(models.Model): _inherit = 'hr.contract' @@ -25,6 +25,18 @@ class HrContract(models.Model): full_time_required_hours = fields.Float(related='resource_calendar_id.full_time_required_hours') is_fulltime = fields.Boolean(related='resource_calendar_id.is_fulltime') + @api.constrains('date_start', 'date_end', 'state') + def _check_contracts(self): + self._get_leaves()._check_contracts() + + @api.multi + def _get_leaves(self): + return self.env['hr.leave'].search([ + ('employee_id', 'in', self.mapped('employee_id.id')), + ('date_from', '<=', max([end or date.max for end in self.mapped('date_end')])), + ('date_to', '>=', min(self.mapped('date_start'))), + ]) + @api.multi def get_all_structures(self): """ diff --git a/addons/hr_payroll/models/hr_employee.py b/addons/hr_payroll/models/hr_employee.py index 2ab7a292149..ec3d6df3198 100644 --- a/addons/hr_payroll/models/hr_employee.py +++ b/addons/hr_payroll/models/hr_employee.py @@ -33,8 +33,14 @@ class HrEmployee(models.Model): ('state', 'in', ['draft', 'confirmed']) ])) - - + @api.multi + def write(self, vals): + res = super(HrEmployee, self).write(vals) + if vals.get('contract_id'): + for employee in self: + employee.resource_calendar_id.transfer_leaves_to(employee.contract_id.resource_calendar_id, employee.resource_id) + employee.resource_calendar_id = employee.contract_id.resource_calendar_id + return res @api.model def generate_benefit(self, date_start, date_stop): @@ -47,7 +53,7 @@ class HrEmployee(models.Model): date_start = _format_datetime(date_start) date_stop = _format_datetime(date_stop) - current_contracts = self.env['hr.employee']._get_contracts(date_start, date_stop) + current_contracts = self.env['hr.employee']._get_all_contracts(date_start, date_stop, states=['open', 'pending', 'close']) current_employees = current_contracts.mapped('employee_id') mapped_data = dict.fromkeys(current_employees, self.env['hr.contract']) @@ -55,6 +61,7 @@ class HrEmployee(models.Model): mapped_data[contract.employee_id] |= contract for employee, contracts in mapped_data.items(): + # Approved leaves emp_leaves = employee.resource_calendar_id.leave_ids.filtered( lambda r: @@ -63,9 +70,7 @@ class HrEmployee(models.Model): r.date_to.replace(tzinfo=pytz.utc) >= date_start ) global_leaves = employee.resource_calendar_id.global_leave_ids - for calendar_leave in emp_leaves | global_leaves: - hr_leave = calendar_leave.holiday_id - hr_leave.copy_to_benefits() + (emp_leaves | global_leaves).mapped('holiday_id').copy_to_benefits() new_benefits = self.env['hr.benefit'] for contract in contracts: @@ -85,6 +90,7 @@ class HrEmployee(models.Model): 'date_stop': interval[1].astimezone(pytz.utc), 'benefit_type_id': benefit_type_id.id, 'employee_id': employee.id, + 'contract_id': contract.id, 'state': 'confirmed', }) diff --git a/addons/hr_payroll/models/hr_leave.py b/addons/hr_payroll/models/hr_leave.py index aaa49384762..b659386f985 100644 --- a/addons/hr_payroll/models/hr_leave.py +++ b/addons/hr_payroll/models/hr_leave.py @@ -1,8 +1,12 @@ # -*- coding:utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import math from dateutil.relativedelta import relativedelta +from datetime import datetime, date from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError from odoo.addons.resource.models.resource import Intervals +from odoo.addons.resource.models.resource import HOURS_PER_DAY class HrLeaveType(models.Model): _inherit = 'hr.leave.type' @@ -16,23 +20,87 @@ class HrLeave(models.Model): @api.multi def copy_to_benefits(self): for leave in self: + contract = leave.employee_id._get_contracts(leave.date_from, leave.date_to, states=['open', 'pending', 'close']) + start = max(leave.date_from, datetime.combine(contract.date_start, datetime.min.time())) + end = min(leave.date_to, datetime.combine(contract.date_end or date.max, datetime.max.time())) benefit_type = leave.holiday_status_id.benefit_type_id self.env['hr.benefit'].safe_duplicate_create({ 'name': "%s%s" % (benefit_type.name + ": " if benefit_type else "", leave.employee_id.name), - 'date_start': leave.date_from, - 'date_stop': leave.date_to, + 'date_start': start, + 'date_stop': end, 'benefit_type_id': benefit_type.id, 'employee_id': leave.employee_id.id, - 'leave_id': self.id, + 'leave_id': leave.id, 'state': 'confirmed', + 'contract_id': contract.id, }) + @api.multi + def _create_resource_leave(self): + """ + Add a resource leave in calendars of contracts running at the same period. + This is needed in order to compute the correct number of hours/days of the leave + according to the contract's calender. + """ + super(HrLeave, self)._create_resource_leave() + resource_leave_values = [] + + for leave in self.filtered(lambda l: l.employee_id): + + contract = leave.employee_id._get_contracts(leave.date_from, leave.date_to, states=['open', 'pending', 'close']) + if contract and contract.resource_calendar_id != leave.employee_id.resource_calendar_id: + resource_leave_values += [{ + 'name': leave.name, + 'holiday_id': leave.id, + 'resource_id': leave.employee_id.resource_id.id, + 'benefit_type_id': leave.holiday_status_id.benefit_type_id.id, + 'time_type': leave.holiday_status_id.time_type, + 'date_from': max(leave.date_from, datetime.combine(contract.date_start, datetime.min.time())), + 'date_to': min(leave.date_to, datetime.combine(contract.date_end or date.max, datetime.max.time())), + 'calendar_id': contract.resource_calendar_id.id, + }] + + self.env['resource.calendar.leaves'].create(resource_leave_values) + return True + + @api.constrains('date_from', 'date_to') + def _check_contracts(self): + """ + A leave cannot be set across multiple contracts. + Note: a leave can be across multiple contracts despite this constraint. + It happens if a leave is correctly created (not accross multiple contracts) but + contracts are later modifed/created in the middle of the leave. + """ + for holiday in self: + domain = [ + ('employee_id', '=', holiday.employee_id.id), + ('date_start', '<=', holiday.date_to), + ('state', 'not in', ['draft', 'cancel']), + '|', + ('date_end', '>=', holiday.date_from), + ('date_end', '=', False), + ] + nbr_contracts = self.env['hr.contract'].sudo().search_count(domain) + if nbr_contracts > 1: + raise ValidationError(_('A leave cannot be set across multiple contracts.')) + @api.multi def _cancel_benefit_conflict(self): + """ + Unlink any benefit linked to a leave in self. + Re-create new benefits where the leaves do not cover the full range of the deleted benefits. + Create a leave benefit for each leave in self. + Return True if one or more benefits are unlinked. + e.g.: + |---------------- benefit ----------------| + |------ leave ------| + || + vv + |-benef-|---benefit leave---|----benefit---| + """ benefits = self.env['hr.benefit'].search([('leave_id', 'in', self.ids)]) if benefits: self.copy_to_benefits() - # create new benefits where the leave does not cover the full benefit benefits_intervals = Intervals(intervals=[(b.date_start, b.date_stop, b) for b in benefits]) leave_intervals = Intervals(intervals=[(l.date_from, l.date_to, l) for l in self]) @@ -52,17 +120,18 @@ class HrLeave(models.Model): 'date_start': benefit_start, 'date_stop': benefit_stop, 'benefit_type_id': benefit_type.id, + 'contract_id': benefit.contract_id.id, 'employee_id': employee.id, 'state': 'confirmed', }) benefits.unlink() + return True + return False @api.multi def action_validate(self): super(HrLeave, self).action_validate() - self._cancel_benefit_conflict() - calendar_leaves = self.env['resource.calendar.leaves'].search([('holiday_id', 'in', self.ids)]) - calendar_leaves.write({'benefit_type_id': self.holiday_status_id.benefit_type_id.id}) + self._cancel_benefit_conflict() # delete preexisting conflicting benefits return True @api.multi @@ -71,3 +140,32 @@ class HrLeave(models.Model): benefits = self.env['hr.benefit'].search([('leave_id', 'in', self.ids)]) benefits.write({'display_warning': False, 'active': False}) return True + + def _get_number_of_days(self, date_from, date_to, employee_id): + """ If an employee is currently working full time but requests a leave next month + where he has a new contract working only 3 days/week. This should be taken into + account when computing the number of days for the leave (2 weeks leave = 6 days). + Override this method to get number of days according to the contract's calendar + at the time of the leave. + """ + days = super(HrLeave, self)._get_number_of_days(date_from, date_to, employee_id) + if employee_id: + employee = self.env['hr.employee'].browse(employee_id) + # Use sudo otherwise base users can't compute number of days + contracts = employee.sudo()._get_contracts(date_from, date_to, states=['incoming', 'open', 'pending']) + calendar = contracts[:1].resource_calendar_id if contracts else None # Note: if len(contracts)>1, the leave creation will crash because of unicity constaint + return employee.get_work_days_data(date_from, date_to, calendar=calendar)['days'] + + return days + + @api.multi + @api.depends('number_of_days') + def _compute_number_of_hours_display(self): + """ Override for the same reason as _get_number_of_days()""" + super(HrLeave, self)._compute_number_of_hours_display() + for holiday in self: + if holiday.date_from and holiday.date_to: + contracts = holiday.employee_id.sudo()._get_contracts(holiday.date_from, holiday.date_to, states=['incoming', 'open', 'pending']) + contract_calendar = contracts[:1].resource_calendar_id if contracts else None + calendar = contract_calendar or holiday.employee_id.resource_calendar_id or self.env.user.company_id.resource_calendar_id + holiday.number_of_hours_display = holiday.number_of_days * (calendar.hours_per_day or HOURS_PER_DAY) diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index 110432fcadf..de7ce63761e 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -23,7 +23,7 @@ class HrPayslip(models.Model): 'to the contract chosen. If you let empty the field contract, this field isn\'t ' 'mandatory anymore and thus the rules applied will be all the rules set on the ' 'structure of all contracts of the employee valid for the chosen period') - name = fields.Char(string='Payslip Name', readonly=True, + name = fields.Char(string='Payslip Name', readonly=True, required=True, states={'draft': [('readonly', False)], 'verify': [('readonly', False)]}) number = fields.Char(string='Reference', readonly=True, copy=False, states={'draft': [('readonly', False)], 'verify': [('readonly', False)]}) @@ -143,31 +143,27 @@ class HrPayslip(models.Model): number = payslip.number or self.env['ir.sequence'].next_by_code('salary.slip') # delete old payslip lines payslip.line_ids.unlink() - # set the list of contract for which the rules have to be applied - # if we don't give the contract, then the rules to apply should be for all current contracts of the employee - contracts = payslip.contract_id or \ - payslip.employee_id._get_contracts(payslip.date_from, payslip.date_to) - lines = [(0, 0, line) for line in payslip._get_payslip_lines(contracts)] + lines = [(0, 0, line) for line in payslip._get_payslip_lines(payslip.contract_id)] payslip.write({'line_ids': lines, 'number': number, 'state': 'verify', 'compute_date': fields.Date.today()}) return True @api.model - def get_worked_day_lines(self, contracts, date_from, date_to): + def get_worked_day_lines(self, contract, date_from, date_to): """ @param contract: Browse record of contracts @return: returns a list of dict containing the worked days that should be applied for the given contract between date_from and date_to """ res = [] # fill only if the contract as a working schedule linked - for contract in contracts.filtered(lambda contract: contract.resource_calendar_id): - day_from = datetime.combine(fields.Date.from_string(date_from), time.min) - day_to = datetime.combine(fields.Date.from_string(date_to), time.max) + if contract.resource_calendar_id: + day_from = datetime.combine(max(fields.Date.from_string(date_from), contract.date_start), time.min) + day_to = datetime.combine(min(fields.Date.from_string(date_to), contract.date_end or date.max), time.max) calendar = contract.resource_calendar_id benefit_types = self.env['hr.benefit.type'].search([('code', '!=', False)]) for benefit_type in benefit_types: - hours = contract.employee_id.get_benefit_days_data(benefit_type, day_from, day_to)['hours'] + hours = contract.employee_id.get_benefit_days_data(benefit_type, day_from, day_to, calendar=calendar)['hours'] if hours: line = { 'name': benefit_type.name, @@ -265,7 +261,7 @@ class HrPayslip(models.Model): return list(result_dict.values()) - @api.onchange('employee_id', 'date_from', 'date_to') + @api.onchange('employee_id', 'contract_id', 'date_from', 'date_to') def onchange_employee(self): if (not self.employee_id) or (not self.date_from) or (not self.date_to): @@ -274,22 +270,20 @@ class HrPayslip(models.Model): employee = self.employee_id date_from = self.date_from date_to = self.date_to - + self.company_id = employee.company_id - contracts = self.env['hr.contract'] - if not self.env.context.get('contract') or not self.contract_id: + if not self.contract_id: # Add a default contract if not already defined contracts = employee._get_contracts(date_from, date_to) - if not contracts: + + if not contracts or not contracts[0].struct_id: return self.contract_id = contracts[0] + self.struct_id = contracts[0].struct_id - if not self.contract_id.struct_id: - return - self.struct_id = self.contract_id.struct_id #computation of the salary worked days - worked_days_line_ids = self.get_worked_day_lines(contracts, date_from, date_to) + worked_days_line_ids = self.get_worked_day_lines(self.contract_id, date_from, date_to) worked_days_lines = self.worked_days_line_ids.browse([]) for r in worked_days_line_ids: worked_days_lines += worked_days_lines.new(r) @@ -302,13 +296,6 @@ class HrPayslip(models.Model): payslip_name = self.struct_id.payslip_name or _('Salary Slip') self.name = '%s - %s - %s' % (payslip_name, self.employee_id.name, tools.ustr(babel.dates.format_date(date=ttyme, format='MMMM-y', locale=locale))) - @api.onchange('contract_id') - def onchange_contract(self): - if not self.contract_id: - self.struct_id = False - self.with_context(contract=True).onchange_employee() - return - def get_salary_line_total(self, code): self.ensure_one() line = self.line_ids.filtered(lambda line: line.code == code) diff --git a/addons/hr_payroll/models/resource.py b/addons/hr_payroll/models/resource.py index 72593757d06..4763312c4f5 100644 --- a/addons/hr_payroll/models/resource.py +++ b/addons/hr_payroll/models/resource.py @@ -1,8 +1,10 @@ # -*- coding:utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import models, fields, api -from odoo.osv.expression import AND, OR +from odoo.osv.expression import AND from odoo.tools import float_compare +from datetime import datetime + class ResourceCalendar(models.Model): _inherit = 'resource.calendar' @@ -39,6 +41,25 @@ class ResourceCalendar(models.Model): def _onchange_hours_per_day(self): return super(ResourceCalendar, self)._onchange_hours_per_day() + @api.multi + def transfer_leaves_to(self, other_calendar, resources=None, from_date=None): + """ + Transfer some resource.calendar.leaves from 'self' to another calendar 'other_calendar'. + Transfered leaves linked to `resources` (or all if `resources` is None) and starting + after 'from_date' (or today if None). + """ + from_date = from_date or datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + domain = [ + ('calendar_id', 'in', self.ids), + ('date_from', '>=', from_date), + ] + domain = AND([domain, [('resource_id', 'in', resources.ids)]]) if resources else domain + + self.env['resource.calendar.leaves'].search(domain).write({ + 'calendar_id': other_calendar.id, + }) + + class ResourceCalendarAttendance(models.Model): _inherit = 'resource.calendar.attendance' diff --git a/addons/hr_payroll/tests/__init__.py b/addons/hr_payroll/tests/__init__.py index 1a7d794de5a..98f0f93e77b 100644 --- a/addons/hr_payroll/tests/__init__.py +++ b/addons/hr_payroll/tests/__init__.py @@ -3,3 +3,5 @@ from . import test_payslip_flow from . import test_benefit +from . import test_multi_contract +from . import test_calendar_sync diff --git a/addons/hr_payroll/tests/common.py b/addons/hr_payroll/tests/common.py index 3848011849c..c64419282e5 100644 --- a/addons/hr_payroll/tests/common.py +++ b/addons/hr_payroll/tests/common.py @@ -47,3 +47,64 @@ class TestPayslipBase(TransactionCase): 'employee_id': self.richard_emp.id, 'struct_id': self.developer_pay_structure.id, }) + + self.benefit_type_leave = self.env['hr.benefit.type'].create({ + 'name': 'Leave', + 'is_leave': True, + 'code': 'LEAVE100' + }) + self.leave_type = self.env['hr.leave.type'].create({ + 'name': 'Legal Leaves', + 'time_type': 'leave', + 'allocation_type': 'no', + 'benefit_type_id': self.benefit_type_leave.id + }) + +class TestPayslipContractBase(TestPayslipBase): + + def setUp(self): + super(TestPayslipContractBase, self).setUp() + self.calendar_richard = self.env['resource.calendar'].create({'name': 'Calendar of Richard'}) + self.calendar_40h = self.env['resource.calendar'].create({'name': 'Default calendar'}) + self.calendar_35h = self.env['resource.calendar'].create({ + 'name': '35h calendar', + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Monday Evening', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday Evening', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Evening', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Evening', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Evening', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}) + ] + }) + self.calendar_35h._onchange_hours_per_day() # update hours/day + self.richard_emp.resource_calendar_id = self.calendar_richard + + # This contract ends at the 15th of the month + self.contract_cdd = self.env['hr.contract'].create({ # Fixed term contract + 'date_end': datetime.strptime('2015-11-15', '%Y-%m-%d'), + 'date_start': datetime.strptime('2015-01-01', '%Y-%m-%d'), + 'name': 'First CDD Contract for Richard', + 'resource_calendar_id': self.calendar_40h.id, + 'wage': 5000.0, + 'type_id': self.ref('hr_contract.hr_contract_type_emp'), + 'employee_id': self.richard_emp.id, + 'struct_id': self.developer_pay_structure.id, + 'state': 'close', + }) + + # This contract starts the next day + self.contract_cdi = self.env['hr.contract'].create({ # Fixed term contract + 'date_start': datetime.strptime('2015-11-16', '%Y-%m-%d'), + 'name': 'Contract for Richard', + 'resource_calendar_id': self.calendar_35h.id, + 'wage': 5000.0, + 'type_id': self.ref('hr_contract.hr_contract_type_emp'), + 'employee_id': self.richard_emp.id, + 'struct_id': self.developer_pay_structure.id, + 'state': 'open', + }) diff --git a/addons/hr_payroll/tests/test_benefit.py b/addons/hr_payroll/tests/test_benefit.py index 7e7ea89382c..92a5d1daa6b 100644 --- a/addons/hr_payroll/tests/test_benefit.py +++ b/addons/hr_payroll/tests/test_benefit.py @@ -18,7 +18,7 @@ class TestBenefit(TestPayslipBase): self.start = datetime.strptime('2015-11-01 01:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) self.end = datetime.strptime('2015-11-30 23:59:59', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) self.resource_calendar_id = self.env['resource.calendar'].create({'name': 'Zboub'}) - self.env['hr.contract'].create({ + contract = self.env['hr.contract'].create({ 'date_start': self.start - relativedelta(days=5), 'name': 'dodo', 'resource_calendar_id': self.resource_calendar_id.id, @@ -28,18 +28,7 @@ class TestBenefit(TestPayslipBase): 'state': 'open', }) self.richard_emp.resource_calendar_id = self.resource_calendar_id - - self.benefit_type_leave = self.env['hr.benefit.type'].create({ - 'name': 'Leave', - 'is_leave': True, - 'code': 'LEAVE100' - }) - self.leave_type = self.env['hr.leave.type'].create({ - 'name': 'Paid Time Off', - 'time_type': 'leave', - 'allocation_type': 'no', - 'benefit_type_id': self.benefit_type_leave.id - }) + self.richard_emp.contract_id = contract self.benefit_type = self.env['hr.benefit.type'].create({ 'name': 'Extra attendance', 'is_leave': False, @@ -69,6 +58,7 @@ class TestBenefit(TestPayslipBase): benefit = self.env['hr.benefit'].create({ 'name': '1', 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, 'date_start': start, 'date_stop': end, }) @@ -91,6 +81,7 @@ class TestBenefit(TestPayslipBase): benefit = self.env['hr.benefit'].create({ 'name': '1', 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, 'date_start': start, 'date_stop': end, }) @@ -114,6 +105,7 @@ class TestBenefit(TestPayslipBase): benefit = self.env['hr.benefit'].create({ 'name': '1', 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, 'date_start': start, 'date_stop': end, 'benefit_type_id': self.benefit_type.id, @@ -131,6 +123,7 @@ class TestBenefit(TestPayslipBase): 'name': '1', 'employee_id': self.richard_emp.id, 'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id, + 'contract_id': self.richard_emp.contract_id.id, 'date_start': start, 'date_stop': end, }) @@ -150,6 +143,7 @@ class TestBenefit(TestPayslipBase): 'name': '1', 'employee_id': self.richard_emp.id, 'benefit_type_id': self.benefit_type.id, + 'contract_id': self.richard_emp.contract_id.id, 'date_start': start, 'date_stop': end, }) @@ -186,6 +180,7 @@ class TestBenefit(TestPayslipBase): 'name': 'Richard leave from benef', 'employee_id': self.richard_emp.id, 'benefit_type_id': self.benefit_type_leave.id, + 'contract_id': self.richard_emp.contract_id.id, 'date_start': start, 'date_stop': end, }) @@ -201,6 +196,7 @@ class TestBenefit(TestPayslipBase): 'name': '1', 'employee_id': self.richard_emp.id, 'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id, + 'contract_id': self.richard_emp.contract_id.id, 'date_start': start, 'date_stop': end + relativedelta(hours=5), }) @@ -208,6 +204,7 @@ class TestBenefit(TestPayslipBase): 'name': '2', 'employee_id': self.richard_emp.id, 'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id, + 'contract_id': self.richard_emp.contract_id.id, 'date_start': start + relativedelta(hours=3), 'date_stop': end, }) @@ -220,6 +217,7 @@ class TestBenefit(TestPayslipBase): 'name': '1', 'employee_id': self.richard_emp.id, 'benefit_type_id': self.benefit_type_leave.id, + 'contract_id': self.richard_emp.contract_id.id, 'date_start': self.start, 'date_stop': self.end, }) @@ -238,6 +236,7 @@ class TestBenefit(TestPayslipBase): benef1 = self.env['hr.benefit'].create({ 'name': '1', 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, 'date_start': self.start, 'date_stop': self.end, }) @@ -257,10 +256,11 @@ class TestBenefit(TestPayslipBase): benef = self.env['hr.benefit'].create({ 'name': '1', 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, 'benefit_type_id': self.benefit_type.id, 'date_start': start, 'date_stop': end, - 'leave_id': leave.id + 'leave_id': leave.id, # benefit conflicts with this leave }) leave.action_approve() @@ -295,6 +295,7 @@ class TestBenefit(TestPayslipBase): benef = self.env['hr.benefit'].create({ 'name': '1', 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, 'benefit_type_id': self.benefit_type.id, 'date_start': start, 'date_stop': end, @@ -316,6 +317,7 @@ class TestBenefit(TestPayslipBase): benef = self.env['hr.benefit'].create({ 'name': '1', 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, 'benefit_type_id': self.benefit_type.id, 'date_start': start, 'date_stop': end, @@ -331,6 +333,7 @@ class TestBenefit(TestPayslipBase): leave_benef = self.env['hr.benefit'].create({ 'name': '1leave', 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, 'benefit_type_id': self.benefit_type_leave.id, 'date_start': start, 'date_stop': end, @@ -346,6 +349,7 @@ class TestBenefit(TestPayslipBase): leave_benef = self.env['hr.benefit'].create({ 'name': '1leave', 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, 'benefit_type_id': self.benefit_type_leave.id, 'date_start': start, 'date_stop': end, @@ -361,6 +365,7 @@ class TestBenefit(TestPayslipBase): leave_benef = self.env['hr.benefit'].create({ 'name': '1leave', 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, 'benefit_type_id': self.benefit_type_leave.id, 'date_start': start, 'date_stop': end, @@ -384,6 +389,7 @@ class TestBenefit(TestPayslipBase): benef = self.env['hr.benefit'].create({ 'name': 'Extra', 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, 'benefit_type_id': self.benefit_type.id, 'date_start': start, 'date_stop': end, diff --git a/addons/hr_payroll/tests/test_calendar_sync.py b/addons/hr_payroll/tests/test_calendar_sync.py new file mode 100644 index 00000000000..f108fd90eec --- /dev/null +++ b/addons/hr_payroll/tests/test_calendar_sync.py @@ -0,0 +1,52 @@ + +import os +from datetime import datetime +from odoo.tools import config, test_reports +from odoo.tests.common import tagged +from odoo.addons.hr_payroll.tests.common import TestPayslipContractBase + +class TestPayslipCalendars(TestPayslipContractBase): + + + def test_contract_state_incoming_to_open(self): + # Employee's calendar should change + self.assertEqual(self.richard_emp.resource_calendar_id, self.calendar_richard) + self.contract_cdd.state = 'open' + self.assertEqual(self.richard_emp.resource_calendar_id, self.contract_cdd.resource_calendar_id, "The employee should have the calendar of its contract.") + + def test_contract_transfer_leaves(self): + + def create_calendar_leave(start, end, resource=None): + return self.env['resource.calendar.leaves'].create({ + 'name': 'leave name', + 'date_from': start, + 'date_to': end, + 'resource_id': resource.id if resource else None, + 'calendar_id': self.richard_emp.resource_calendar_id.id, + 'benefit_type_id': self.benefit_type_leave.id, + 'time_type': 'leave', + }) + + start = datetime.strptime('2015-11-17 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-11-20 18:00:00', '%Y-%m-%d %H:%M:%S') + leave1 = create_calendar_leave(start, end, resource=self.richard_emp.resource_id) + + start = datetime.strptime('2015-11-25 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-11-28 18:00:00', '%Y-%m-%d %H:%M:%S') + leave2 = create_calendar_leave(start, end, resource=self.richard_emp.resource_id) + + # global leave + start = datetime.strptime('2015-11-25 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-11-28 18:00:00', '%Y-%m-%d %H:%M:%S') + leave3 = create_calendar_leave(start, end) + + self.calendar_richard.transfer_leaves_to(self.calendar_35h, resources=self.richard_emp.resource_id, from_date=datetime.strptime('2015-11-21', '%Y-%m-%d').date()) + + self.assertEqual(leave1.calendar_id, self.calendar_richard, "It should stay in Richard's calendar") + self.assertEqual(leave3.calendar_id, self.calendar_richard, "Global leave should stay in original calendar") + self.assertEqual(leave2.calendar_id, self.calendar_35h, "It should be transfered to the other calendar") + + # Transfer global leaves + self.calendar_richard.transfer_leaves_to(self.calendar_35h, resources=None, from_date=datetime.strptime('2015-11-21', '%Y-%m-%d').date()) + + self.assertEqual(leave3.calendar_id, self.calendar_35h, "Global leave should be transfered") \ No newline at end of file diff --git a/addons/hr_payroll/tests/test_multi_contract.py b/addons/hr_payroll/tests/test_multi_contract.py new file mode 100644 index 00000000000..75f07871e45 --- /dev/null +++ b/addons/hr_payroll/tests/test_multi_contract.py @@ -0,0 +1,149 @@ + +import os +from datetime import datetime +from odoo.tools import config, test_reports +from odoo.tests.common import tagged +from odoo.exceptions import ValidationError +from odoo.addons.hr_payroll.tests.common import TestPayslipContractBase + +@tagged('payslips_multi_contract') +class TestPayslipMultiContract(TestPayslipContractBase): + + def create_leave(self, start, end): + return self.env['hr.leave'].create({ + 'name': 'Doctor Appointment', + 'employee_id': self.richard_emp.id, + 'holiday_status_id': self.leave_type.id, + 'date_from': start, + 'date_to': end, + }) + + def test_multi_contract(self): + # First contact: 40h, start of the month + payslip = self.env['hr.payslip'].create({ + 'name': 'November 2015', + 'employee_id': self.richard_emp.id, + 'date_from': datetime.strptime('2015-11-01', '%Y-%m-%d'), + 'date_to': datetime.strptime('2015-11-30', '%Y-%m-%d'), + 'contract_id': self.contract_cdd.id, + }) + payslip.onchange_employee() + self.assertEqual(payslip.worked_days_line_ids.number_of_hours, 80, "It should be 80 hours of work this month for this contract") + self.assertEqual(payslip.worked_days_line_ids.number_of_days, 10, "It should be 10 days of work this month for this contract") + + # Second contract: 35h, end of the month + payslip = self.env['hr.payslip'].create({ + 'name': 'November 2015', + 'employee_id': self.richard_emp.id, + 'date_from': datetime.strptime('2015-11-01', '%Y-%m-%d'), + 'date_to': datetime.strptime('2015-11-30', '%Y-%m-%d'), + 'contract_id': self.contract_cdi.id, + }) + payslip.onchange_employee() + self.assertEqual(payslip.worked_days_line_ids.number_of_hours, 77, "It should be 77 hours of work this month for this contract") + self.assertEqual(payslip.worked_days_line_ids.number_of_days, 11, "It should be 11 days of work this month for this contract") + + def test_multi_contract_holiday(self): + # Leave during second contract + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'leave name', + 'date_from': datetime.strptime('2015-11-17 07:00:00', '%Y-%m-%d %H:%M:%S'), + 'date_to': datetime.strptime('2015-11-20 18:00:00', '%Y-%m-%d %H:%M:%S'), + 'resource_id': self.richard_emp.resource_id.id, + 'calendar_id': self.calendar_35h.id, + 'benefit_type_id': self.benefit_type_leave.id, + 'time_type': 'leave', + }) + payslip = self.env['hr.payslip'].create({ + 'name': 'November 2015', + 'employee_id': self.richard_emp.id, + 'date_from': datetime.strptime('2015-11-01', '%Y-%m-%d'), + 'date_to': datetime.strptime('2015-11-30', '%Y-%m-%d'), + 'contract_id': self.contract_cdi.id, + }) + payslip.onchange_employee() + work = payslip.worked_days_line_ids.filtered(lambda line: line.code == 'WORK100') + leave = payslip.worked_days_line_ids.filtered(lambda line: line.code == 'LEAVE100') + self.assertEqual(work.number_of_hours, 49, "It should be 49 hours of work this month for this contract") + self.assertEqual(leave.number_of_hours, 28, "It should be 28 hours of leave this month for this contract") + self.assertEqual(work.number_of_days, 7, "It should be 7 days of work this month for this contract") + self.assertEqual(leave.number_of_days, 4, "It should be 4 days of leave this month for this contract") + + def test_move_contract_in_leave(self): + # test move contract dates such that a leave is accross two contracts + start = datetime.strptime('2015-11-05 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-12-15 18:00:00', '%Y-%m-%d %H:%M:%S') + self.contract_cdi.write({'date_start': datetime.strptime('2015-12-30', '%Y-%m-%d').date()}) + print(self.contract_cdi.date_start, 'test') + # begins during contract, ends after contract + leave = self.create_leave(start, end) + leave.action_approve() + # move contract in the middle of the leave + with self.assertRaises(ValidationError): + self.contract_cdi.date_start = datetime.strptime('2015-11-17', '%Y-%m-%d').date() + + def test_create_contract_in_leave(self): + # test create contract such that a leave is accross two contracts + start = datetime.strptime('2015-11-05 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-12-15 18:00:00', '%Y-%m-%d %H:%M:%S') + self.contract_cdi.date_start = datetime.strptime('2015-12-30', '%Y-%m-%d').date() # remove this contract to be able to create the leave + # begins during contract, ends after contract + leave = self.create_leave(start, end) + leave.action_approve() + # move contract in the middle of the leave + with self.assertRaises(ValidationError): + contract = self.env['hr.contract'].create({ + 'date_start': datetime.strptime('2015-11-30', '%Y-%m-%d').date(), + 'name': 'Contract for Richard', + 'resource_calendar_id': self.calendar_40h.id, + 'wage': 5000.0, + 'type_id': self.ref('hr_contract.hr_contract_type_emp'), + 'employee_id': self.richard_emp.id, + 'struct_id': self.developer_pay_structure.id, + 'state': 'open', + }) + + def test_leave_outside_contract(self): + + # Leave outside contract => should not raise + start = datetime.strptime('2014-10-18 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2014-10-20 09:00:00', '%Y-%m-%d %H:%M:%S') + self.create_leave(start, end) + + # begins before contract, ends during contract => should not raise + start = datetime.strptime('2014-10-25 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-01-15 18:00:00', '%Y-%m-%d %H:%M:%S') + self.create_leave(start, end) + + # begins during contract, ends after contract => should not raise + self.contract_cdi.date_end = datetime.strptime('2015-11-30', '%Y-%m-%d').date() + start = datetime.strptime('2015-11-25 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-12-5 18:00:00', '%Y-%m-%d %H:%M:%S') + self.create_leave(start, end) + + def test_no_leave_overlapping_contracts(self): + + with self.assertRaises(ValidationError): + # Overlap two contracts + start = datetime.strptime('2015-11-12 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-11-17 18:00:00', '%Y-%m-%d %H:%M:%S') + leave = self.create_leave(start, end) + + # Leave inside fixed term contract => should not raise + start = datetime.strptime('2015-11-04 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-11-07 09:00:00', '%Y-%m-%d %H:%M:%S') + leave = self.create_leave(start, end) + + # Leave inside contract (no end) => should not raise + start = datetime.strptime('2015-11-18 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-11-20 09:00:00', '%Y-%m-%d %H:%M:%S') + leave = self.create_leave(start, end) + + def test_leave_request_next_contracts(self): + + start = datetime.strptime('2015-11-23 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-11-24 18:00:00', '%Y-%m-%d %H:%M:%S') + leave = self.create_leave(start, end) + + leave._compute_number_of_hours_display() + self.assertEqual(leave.number_of_hours_display, 14, "It should count hours according to the future contract.") diff --git a/addons/hr_payroll/views/hr_benefit_views.xml b/addons/hr_payroll/views/hr_benefit_views.xml index f3cb80eae0c..5b05cff1f11 100644 --- a/addons/hr_payroll/views/hr_benefit_views.xml +++ b/addons/hr_payroll/views/hr_benefit_views.xml @@ -82,6 +82,7 @@ + diff --git a/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py b/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py index 35647d78265..6cbb6c80d78 100644 --- a/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py +++ b/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py @@ -3,7 +3,7 @@ from odoo import api, fields, models, _ from odoo.exceptions import UserError -from datetime import datetime +from datetime import datetime, date class HrPayslipEmployees(models.TransientModel): @@ -43,17 +43,20 @@ class HrPayslipEmployees(models.TransientModel): values = Payslip.default_get(Payslip.fields_get()) values.update({ 'employee_id': employee.id, - 'date_from': payslip_run.date_start, - 'date_to': payslip_run.date_end, 'credit_note': payslip_run.credit_note, 'payslip_run_id': payslip_run.id, + 'date_from': payslip_run.date_start, + 'date_to': payslip_run.date_end, }) - payslip = self.env['hr.payslip'].new(values) - payslip.onchange_employee() - payslip.onchange_contract() - payslip._onchange_struct_id() - values = payslip._convert_to_write(payslip._cache) - payslips += Payslip.create(values) + for contract in employee._get_contracts(payslip_run.date_start, payslip_run.date_end, states=['open', 'pending', 'close']): + values.update({ + 'contract_id': contract.id, + }) + payslip = self.env['hr.payslip'].new(values) + payslip.onchange_employee() + payslip._onchange_struct_id() + values = payslip._convert_to_write(payslip._cache) + payslips += Payslip.create(values) payslips.compute_sheet() payslip_run.state = 'verify' diff --git a/addons/hr_payroll_account/models/hr_payroll_account.py b/addons/hr_payroll_account/models/hr_payroll_account.py index 82117e38cdd..9263b8f69e3 100644 --- a/addons/hr_payroll_account/models/hr_payroll_account.py +++ b/addons/hr_payroll_account/models/hr_payroll_account.py @@ -39,9 +39,9 @@ class HrPayslip(models.Model): vals['journal_id'] = self.env.context.get('journal_id') return super(HrPayslip, self).create(vals) - @api.onchange('contract_id') - def onchange_contract(self): - super(HrPayslip, self).onchange_contract() + @api.onchange('employee_id', 'contract_id', 'date_from', 'date_to') + def onchange_employee(self): + super(HrPayslip, self).onchange_employee() self.journal_id = self.contract_id.journal_id.id or self.default_get(['journal_id'])['journal_id'] @api.multi diff --git a/addons/hr_payroll_account/tests/test_hr_payroll_account.py b/addons/hr_payroll_account/tests/test_hr_payroll_account.py index 5d86e607440..0813ffd635d 100644 --- a/addons/hr_payroll_account/tests/test_hr_payroll_account.py +++ b/addons/hr_payroll_account/tests/test_hr_payroll_account.py @@ -80,7 +80,6 @@ class TestHrPayrollAccount(common.TransactionCase): self.hr_payslip.date_from = time.strftime('%Y-%m-01') self.hr_payslip.date_to = str(datetime.now() + relativedelta.relativedelta(months=+1, day=1, days=-1))[:10] self.hr_payslip.onchange_employee() - self.hr_payslip.onchange_contract() self.hr_payslip._onchange_struct_id() # I assign the amount to Input data.