From 78128760c553dbb728a22ab9f10fc5b403d2ba13 Mon Sep 17 00:00:00 2001 From: lul-odoo Date: Mon, 29 Oct 2018 16:14:53 +0100 Subject: [PATCH] [IMP] hr_payroll: Add new models `hr.benefit`, `hr.benefit.type` Purpose ======= For payroll purpose, we need to manage attendances. For instance, someone is working only mondays, tuesdays and fridays, but works exceptionally an other day. It must be recorded. Specification ============= The new models are used to represent attendances, extra hours and leaves (as `hr.benefit`s) in a new window called "Benefits". The schedule of employees for the full month is visible in that view (calendar view). First the benefits needs to be generated (via a button) based on the calendar attendances and leaves. The manager can then check benefits, delete or add some. Only the benefit name and type can be edited (not start and end dates). Once benefits are correct, they can be validated. Validation will fail if they are some leaves to approve or if some benefits overlap on the same day. Benefits cannot be deleted or edited once validated. When benefits are all validated, a button appears to generate payslips. Closes #28261 --- addons/hr_payroll/__manifest__.py | 6 + addons/hr_payroll/data/hr_payroll_data.xml | 14 + addons/hr_payroll/data/hr_payroll_demo.xml | 68 ++++ addons/hr_payroll/models/__init__.py | 3 + addons/hr_payroll/models/hr_benefit.py | 306 ++++++++++++++++ addons/hr_payroll/models/hr_employee.py | 48 ++- addons/hr_payroll/models/hr_leave.py | 71 ++++ addons/hr_payroll/models/hr_payslip.py | 48 +-- addons/hr_payroll/models/resource.py | 62 ++++ .../security/hr_payroll_security.xml | 11 + .../hr_payroll/security/ir.model.access.csv | 2 + addons/hr_payroll/static/src/js/benefit.js | 128 +++++++ addons/hr_payroll/tests/__init__.py | 1 + addons/hr_payroll/tests/test_benefit.py | 337 ++++++++++++++++++ .../hr_payroll/views/hr_benefit_template.xml | 9 + addons/hr_payroll/views/hr_benefit_views.xml | 165 +++++++++ addons/hr_payroll/views/hr_leave_views.xml | 16 + addons/hr_payroll/views/hr_payslip_views.xml | 9 +- addons/hr_payroll/views/resource_views.xml | 31 ++ addons/hr_payroll/wizard/__init__.py | 1 + .../hr_payroll/wizard/hr_benefit_employee.py | 26 ++ .../wizard/hr_benefit_employee_views.xml | 23 ++ .../hr_payroll_payslips_by_employees.py | 37 +- 23 files changed, 1379 insertions(+), 43 deletions(-) create mode 100644 addons/hr_payroll/models/hr_benefit.py create mode 100644 addons/hr_payroll/models/hr_leave.py create mode 100644 addons/hr_payroll/models/resource.py create mode 100644 addons/hr_payroll/static/src/js/benefit.js create mode 100644 addons/hr_payroll/tests/test_benefit.py create mode 100644 addons/hr_payroll/views/hr_benefit_template.xml create mode 100644 addons/hr_payroll/views/hr_benefit_views.xml create mode 100644 addons/hr_payroll/views/hr_leave_views.xml create mode 100644 addons/hr_payroll/views/resource_views.xml create mode 100644 addons/hr_payroll/wizard/hr_benefit_employee.py create mode 100644 addons/hr_payroll/wizard/hr_benefit_employee_views.xml diff --git a/addons/hr_payroll/__manifest__.py b/addons/hr_payroll/__manifest__.py index 73bb35e4a66..e26ea532ab9 100644 --- a/addons/hr_payroll/__manifest__.py +++ b/addons/hr_payroll/__manifest__.py @@ -28,6 +28,12 @@ 'views/report_contributionregister_templates.xml', 'views/report_payslip_templates.xml', 'views/report_payslipdetails_templates.xml', + 'views/hr_benefit_views.xml', + 'views/hr_benefit_views.xml', + 'views/hr_leave_views.xml', + 'views/resource_views.xml', + 'views/hr_benefit_template.xml', + 'wizard/hr_benefit_employee_views.xml', ], 'demo': ['data/hr_payroll_demo.xml'], } diff --git a/addons/hr_payroll/data/hr_payroll_data.xml b/addons/hr_payroll/data/hr_payroll_data.xml index 2eede6d92bb..87d00a5c6fc 100644 --- a/addons/hr_payroll/data/hr_payroll_data.xml +++ b/addons/hr_payroll/data/hr_payroll_data.xml @@ -89,6 +89,20 @@ 4 + + + + Attendance + 0 + WORK100 + + + + Generic Leave + LEAVE100 + 3 + True + diff --git a/addons/hr_payroll/data/hr_payroll_demo.xml b/addons/hr_payroll/data/hr_payroll_demo.xml index 21e1b150abe..34a9d091fe6 100644 --- a/addons/hr_payroll/data/hr_payroll_demo.xml +++ b/addons/hr_payroll/data/hr_payroll_demo.xml @@ -146,6 +146,7 @@ Default contract for marketing executives + open @@ -159,4 +160,71 @@ + + Contract For Mitchell Admin + + + + + + This is Mitchell Admin's contract + + open + + + + + Home Working + WORK200 + 2 + + + + Extra hours + WORK300 + 2 + + + + Long Term Leave + LEAVE200 + True + 4 + + + + Unpaid Leave + LEAVE300 + True + 5 + + + + Sick Leave + LEAVE400 + True + 5 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/hr_payroll/models/__init__.py b/addons/hr_payroll/models/__init__.py index 47e89d21c90..9088be882e9 100644 --- a/addons/hr_payroll/models/__init__.py +++ b/addons/hr_payroll/models/__init__.py @@ -7,3 +7,6 @@ from . import hr_employee from . import res_config_settings from . import hr_salary_rule from . import hr_payslip +from . import hr_benefit +from . import hr_leave +from . import resource diff --git a/addons/hr_payroll/models/hr_benefit.py b/addons/hr_payroll/models/hr_benefit.py new file mode 100644 index 00000000000..ec27e044d59 --- /dev/null +++ b/addons/hr_payroll/models/hr_benefit.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import pytz +import itertools +from psycopg2 import IntegrityError +from dateutil.relativedelta import relativedelta +from odoo import api, fields, models, exceptions, _ +from odoo.tools import mute_logger +from odoo.exceptions import UserError, ValidationError +from odoo.addons.resource.models.resource import Intervals + +class HrBenefit(models.Model): + _name = 'hr.benefit' + _description = 'hr.benefit' + + name = fields.Char(required=True) + active = fields.Boolean(default=True) + employee_id = fields.Many2one('hr.employee', required=True, + domain=lambda self: [('contract_ids.state', 'in', ('open', 'pending')), ('company_id', '=', self.env.user.company_id.id)]) + date_start = fields.Datetime(required=True, string='Start') + date_stop = fields.Datetime(string='End') + duration = fields.Float(compute='_compute_duration', inverse='_inverse_duration', store=True, string="Hours") + benefit_type_id = fields.Many2one('hr.benefit.type') + color = fields.Integer(related='benefit_type_id.color', readonly=True) + state = fields.Selection([ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('validated', 'Validated'), + ('cancelled', 'Cancelled') + ], default='draft') + display_warning = fields.Boolean(string="Error") + leave_id = fields.Many2one('hr.leave', string='Leave') + + _sql_constraints = [ + ('_unique', 'unique (employee_id, date_start, date_stop, benefit_type_id)', "Benefit already exists for this attendence"), + ] + + @api.model + def create(self, vals): + vals['state'] = 'confirmed' + return super(HrBenefit, self).create(vals) + + + @api.constrains('date_stop', 'duration') + def _check_validity_benefit_ends(self): + """ verifies if benefit has an end. """ + for benefit in self: + if not (benefit.date_stop or benefit.duration): + raise exceptions.ValidationError(_('Benefit must end. Please define an end date or a duration.')) + + @api.constrains('date_start', 'date_stop') + def _check_validity_start_before_end(self): + """ verifies if benefit has an end. """ + for benefit in self: + if benefit.date_stop < benefit.date_start: + raise exceptions.ValidationError(_('Starting time should be before end time.')) + + + @api.onchange('duration') + def _onchange_duration(self): + self._inverse_duration() + + @api.depends('date_stop', 'date_start') + def _compute_duration(self): + for benefit in self: + if benefit.date_start and benefit.date_stop: + dt = benefit.date_stop - benefit.date_start + benefit.duration = dt.days * 24 + dt.seconds / 3600 # Number of hours + + def _inverse_duration(self): + for benefit in self: + if benefit.date_start and benefit.duration: + benefit.date_stop = benefit.date_start + relativedelta(hours=benefit.duration) + + @api.multi + def check_if_error(self): + if not self: + return False + undefined_type = self.filtered(lambda b: not b.benefit_type_id) + undefined_type.write({'display_warning': True}) + conflict = self._compute_schedule_conflicts() + conflict_with_leaves = self.compute_conflicts_leaves_to_approve() + return undefined_type or conflict or conflict_with_leaves + + @api.multi + def _compute_schedule_conflicts(self): + conflict = False + date_start_benefits = min(self.mapped('date_start')) + date_stop_benefits = max(self.mapped('date_stop')) + domain = [ + ('date_start', '<', date_stop_benefits), + ('date_stop', '>', date_start_benefits), + ] + + benefs = self.search(domain) + benefits_by_employee = itertools.groupby(benefs, lambda b: b.employee_id) + for employee, benefs in benefits_by_employee: + intervals = Intervals(intervals=((b.date_start, b.date_stop, b) for b in benefs)) + for interval in intervals: + if len(interval[2]) > 1: + interval[2].write({'display_warning': True}) + conflict = True + return conflict + + @api.multi + def compute_conflicts_leaves_to_approve(self): + + if not self: + return False + + query = """ + SELECT + b.id AS benefit_id, + l.id AS leave_id + FROM hr_benefit b + INNER JOIN hr_leave l ON b.employee_id = l.employee_id + WHERE + b.id IN %s AND + l.date_from <= b.date_stop AND + l.date_to >= b.date_start AND + l.state IN ('confirm', 'validate1'); + """ + self.env.cr.execute(query, [tuple(self.ids)]) + conflicts = self.env.cr.dictfetchall() + for res in conflicts: + self.browse(res.get('benefit_id')).write({ + 'display_warning': True, + 'leave_id': res.get('leave_id') + }) + return bool(conflicts) + + def safe_duplicate_create(self, vals): + """ + Create benefit but silently abord if it already exists in the database (according to the _unique constraints). + @return: new record if it didn't exist, empty recordset otherwise. + """ + try: + with mute_logger('odoo.sql_db'), self.env.cr.savepoint(): + # Any database call will be run inside a postgresql savepoint which is + # rolledback if an exception occurs allowing to make subsequent calls in the transaction. + # (Otherwise InternalError is raised by any subsequent database operations) + return self.create(vals) + except IntegrityError as err: + if 'hr_benefit__unique' not in err.pgerror: + raise err + return self.env['hr.benefit'] + + def action_leave(self): + leave = self.leave_id + return { + 'type':'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_id': leave.id, + 'res_model': 'hr.leave', + 'views': [[False, 'form']], + } + + @api.multi + def action_draft(self): + for benefit in self: + if benefit.state not in ['confirmed', 'cancelled']: + raise UserError(_('Benefit state must be "Confirmed" or "Cancelled" in order to be reset to draft.')) + benefit.write({ + 'state': 'draft', + 'active': True, + }) + return True + + @api.multi + def action_cancel(self): + for benefit in self: + if benefit.state not in ['confirmed']: + raise UserError(_('Benefit state must be "Confirmed" in order to be cancelled.')) + benefit.write({ + 'state': 'cancelled', + 'active': False, + }) + return True + + def split_by_day(self): + """ + Split the benefit by days and unlink the original benefit. + @return recordset + """ + def _split_range_by_day(start, end): + days = [] + current_start = start + current_end = start.replace(hour=23, minute=59, second=59) + while current_end < end: + days.append((current_start, current_end)) + current_start = current_end + relativedelta(seconds=1) + current_end = current_end + relativedelta(days=1) + + days.append((current_start, end)) + + # filter to avoid dummy intervals starting and ending at the same time + return [(start, end) for start, end in days if start != end] + + new_benefits = self.env['hr.benefit'] + for benefit in self: + if benefit.date_start.date() == benefit.date_stop.date(): + new_benefits |= benefit + else: + tz = pytz.timezone(benefit.employee_id.tz) + benefit_start, benefit_stop = tz.localize(benefit.date_start), tz.localize(benefit.date_stop) + values = { + 'name': benefit.name, + 'employee_id': benefit.employee_id.id, + 'benefit_type_id': benefit.benefit_type_id.id, + } + benefit_state = benefit.state + benefit.unlink() + for start, stop in _split_range_by_day(benefit_start, benefit_stop): + values['date_start'] = start.astimezone(pytz.utc) + values['date_stop'] = stop.astimezone(pytz.utc) + new_benefit = self.create(values) + # Write the state after the creation due to the ir.rule on benefit state + # LUL TODO: Write a test for this use case + new_benefit.state = benefit_state + new_benefits |= new_benefit + + return new_benefits + + @api.multi + def _duplicate_to_calendar(self): + """ + Duplicate data to keep the complexity in benefit and not mess up payroll, etc. + """ + attendance_benefits = self.filtered(lambda b: + not b.benefit_type_id.is_leave and + # Normal benefit are global to all employees -> avoid duplicating it + not b.benefit_type_id == self.env.ref('hr_payroll.benefit_type_attendance')) + leave_benefits = self.filtered(lambda b: b.benefit_type_id.is_leave) + + for benefit in attendance_benefits: + benefit = benefit.split_by_day() + benefit._duplicate_to_calendar_attendance() + leave_benefits._duplicate_to_calendar_leave() + + @api.multi + def _duplicate_to_calendar_leave(self): + + for benefit in self: + if not benefit.leave_id: + tz = pytz.timezone(benefit.employee_id.tz) + self.env['resource.calendar.leaves'].create({ + 'name': benefit.name, + 'date_from': benefit.date_start, + 'date_to': benefit.date_stop, + 'calendar_id': benefit.employee_id.resource_calendar_id.id, + 'resource_id': benefit.employee_id.resource_id.id, + 'benefit_type_id': benefit.benefit_type_id.id, + }) + + @api.multi + def _duplicate_to_calendar_attendance(self): + mapped_data = { + benefit: [ + pytz.utc.localize(benefit.date_start).astimezone(pytz.timezone(benefit.employee_id.tz)), # Start date + pytz.utc.localize(benefit.date_stop).astimezone(pytz.timezone(benefit.employee_id.tz)) # End date + ] for benefit in self + } + + if any(data[0].date() != data[1].date() for data in mapped_data.values()): + raise ValidationError(_("You can't validate a benefit that covers several days.")) + + for benefit in self: + start, end = mapped_data.get(benefit) + + self.env['resource.calendar.attendance'].create({ + 'name': benefit.name, + 'dayofweek': str(start.weekday()), + 'date_from': start.date(), + '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, + '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, + }) + + @api.model + def action_validate(self, ids): + benefits = self.env['hr.benefit'].search([('id', 'in', ids), ('state', '!=', 'validated')]) + benefits.write({'display_warning': False}) + if not benefits.check_if_error(): + benefits.write({'state': 'validated'}) + benefits._duplicate_to_calendar() + return True + return False + +class HrBenefitType(models.Model): + _name = 'hr.benefit.type' + _description = 'hr.benefit.type' + + name = fields.Char(required=True) + code = fields.Char() + color = fields.Integer(default=1) # Will be used with the new calendar/kanban view + sequence = fields.Integer(default=25) + active = fields.Boolean('Active', default=True, + help="If the active field is set to false, it will allow you to hide the benefit type without removing it.") + is_leave = fields.Boolean(default=False, string="Leave") + diff --git a/addons/hr_payroll/models/hr_employee.py b/addons/hr_payroll/models/hr_employee.py index 0d5a88d2ccd..161fd5414aa 100644 --- a/addons/hr_payroll/models/hr_employee.py +++ b/addons/hr_payroll/models/hr_employee.py @@ -1,7 +1,10 @@ # -*- coding:utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import api, fields, models, _ +from odoo.addons.resource.models.resource import Intervals +from datetime import datetime, timedelta +import pytz class HrEmployee(models.Model): @@ -15,3 +18,46 @@ class HrEmployee(models.Model): def _compute_payslip_count(self): for employee in self: employee.payslip_count = len(employee.slip_ids) + + + @api.multi + def generate_benefit(self, date_start, date_stop): + + date_start = date_start.replace(tzinfo=pytz.utc) + date_stop = date_stop.replace(tzinfo=pytz.utc) + + for employee in self: + + # Approved leaves + emp_leaves = employee.resource_calendar_id.leave_ids.filtered( + lambda r: + r.resource_id == employee.resource_id and + r.date_from.replace(tzinfo=pytz.utc) <= date_stop and + 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() + + new_benefits = self.env['hr.benefit'] + for contract in employee._get_contracts(date_start, date_stop): + + date_start_benefits = max(date_start, datetime.combine(contract.date_start, datetime.min.time()).replace(tzinfo=pytz.utc)) + date_stop_benefits = min(date_stop, datetime.combine(contract.date_end or datetime.max.date(), datetime.max.time()).replace(tzinfo=pytz.utc)) + + calendar = contract.resource_calendar_id + resource = employee.resource_id + attendances = calendar._work_intervals(date_start_benefits, date_stop_benefits, resource=resource) + # Attendances + for interval in attendances: + benefit_type_id = interval[2].mapped('benefit_type_id')[:1] or self.env.ref('hr_payroll.benefit_type_attendance') + new_benefits |= self.env['hr.benefit'].safe_duplicate_create({ + 'name': "%s: %s" % (benefit_type_id.name, employee.name), + 'date_start': interval[0].astimezone(pytz.utc), + 'date_stop': interval[1].astimezone(pytz.utc), + 'benefit_type_id': benefit_type_id.id, + 'employee_id': employee.id, + }) + + new_benefits.compute_conflicts_leaves_to_approve() diff --git a/addons/hr_payroll/models/hr_leave.py b/addons/hr_payroll/models/hr_leave.py new file mode 100644 index 00000000000..4a7c4cdc252 --- /dev/null +++ b/addons/hr_payroll/models/hr_leave.py @@ -0,0 +1,71 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from dateutil.relativedelta import relativedelta +from odoo import api, fields, models, _ +from odoo.addons.resource.models.resource import Intervals + +class HrLeaveType(models.Model): + _inherit = 'hr.leave.type' + + benefit_type_id = fields.Many2one('hr.benefit.type', string='Benefit Type') + + +class HrLeave(models.Model): + _inherit = 'hr.leave' + + @api.multi + def copy_to_benefits(self): + for leave in self: + 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, + 'benefit_type_id': benefit_type.id, + 'employee_id': leave.employee_id.id, + 'leave_id': self.id, + }) + + @api.multi + def _cancel_benefit_conflict(self): + 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]) + remaining_benefits = benefits_intervals - leave_intervals + + for interval in remaining_benefits: + benefit = interval[2] + leave = benefit.leave_id + benefit_type = benefit.benefit_type_id + employee = benefit.employee_id + + benefit_start = interval[0] + relativedelta(seconds=1) if leave.date_to == interval[0] else interval[0] + benefit_stop = interval[1] - relativedelta(seconds=1) if leave.date_from == interval[1] else interval[1] + + self.env['hr.benefit'].safe_duplicate_create({ + 'name': "%s: %s" % (benefit_type.name, employee.name), + 'date_start': benefit_start, + 'date_stop': benefit_stop, + 'benefit_type_id': benefit_type.id, + 'employee_id': employee.id, + }) + benefits.unlink() + + @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}) + return True + + @api.multi + def action_refuse(self): + super(HrLeave, self).action_refuse() + benefits = self.env['hr.benefit'].search([('leave_id', 'in', self.ids)]) + benefits.write({'display_warning': False, 'leave_id': None}) + return True diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index 609fa316907..51f84c60b86 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -151,45 +151,25 @@ class HrPayslip(models.Model): day_from = datetime.combine(fields.Date.from_string(date_from), time.min) day_to = datetime.combine(fields.Date.from_string(date_to), time.max) - # compute leave days - leaves = {} calendar = contract.resource_calendar_id - tz = timezone(calendar.tz) - day_leave_intervals = contract.employee_id.list_leaves(day_from, day_to, calendar=contract.resource_calendar_id) - for day, hours, leave in day_leave_intervals: - holiday = leave.holiday_id - current_leave_struct = leaves.setdefault(holiday.holiday_status_id, { - 'name': holiday.holiday_status_id.name, - 'sequence': 5, - 'code': holiday.holiday_status_id.code or holiday.holiday_status_id.name, - 'number_of_days': 0.0, - 'number_of_hours': 0.0, - 'contract_id': contract.id, - }) - current_leave_struct['number_of_hours'] += hours - work_hours = calendar.get_work_hours_count( - tz.localize(datetime.combine(day, time.min)), - tz.localize(datetime.combine(day, time.max)), - compute_leaves=False, - ) - if work_hours: - current_leave_struct['number_of_days'] += hours / work_hours - # compute worked days - work_data = contract.employee_id.get_work_days_data(day_from, day_to, calendar=contract.resource_calendar_id) - attendances = { - 'name': _("Normal Working Days paid at 100%"), - 'sequence': 1, - 'code': 'WORK100', - 'number_of_days': work_data['days'], - 'number_of_hours': work_data['hours'], - 'contract_id': contract.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'] + if hours: + line = { + 'name': benefit_type.name, + 'sequence': benefit_type.sequence, + 'code': benefit_type.code, + 'number_of_days': hours / calendar.hours_per_day, # n_days returned by benefit_days_data doesn't make sense for extra work + 'number_of_hours': hours, + 'contract_id': contract.id, + } + res.append(line) - res.append(attendances) - res.extend(leaves.values()) return res + @api.model def get_inputs(self, contracts, date_from, date_to): res = [] diff --git a/addons/hr_payroll/models/resource.py b/addons/hr_payroll/models/resource.py new file mode 100644 index 00000000000..440820f2017 --- /dev/null +++ b/addons/hr_payroll/models/resource.py @@ -0,0 +1,62 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import models, fields +from odoo.osv.expression import AND, OR + + +class ResourceCalendar(models.Model): + _inherit = 'resource.calendar' + + # UI fields + normal_attendance_ids = fields.One2many( + 'resource.calendar.attendance', 'calendar_id', 'Normal working Time', + domain=[('resource_id', '=', False)]) + + extra_attendance_ids = fields.One2many( + 'resource.calendar.attendance', 'calendar_id', 'Employees working Time', + domain=[('resource_id', '!=', False)]) + + +class ResourceCalendarAttendance(models.Model): + _inherit = 'resource.calendar.attendance' + + def _default_benefit_type_id(self): + return self.env.ref('hr_payroll.benefit_type_attendance', raise_if_not_found=False) + + benefit_type_id = fields.Many2one('hr.benefit.type', 'Benefit Type', default=_default_benefit_type_id) + +class ResourceCalendarLeave(models.Model): + _inherit = 'resource.calendar.leaves' + benefit_type_id = fields.Many2one('hr.benefit.type', 'Benefit Type') + + +class ResourceMixin(models.AbstractModel): + _inherit = "resource.mixin" + + def get_benefit_days_data(self, benefit_type, from_datetime, to_datetime, calendar=None): + """ + By default the resource calendar is used, but it can be + changed using the `calendar` argument. + + Returns a dict {'days': n, 'hours': h} containing the number of leaves + expressed as days and as hours. + """ + resource = self.resource_id + calendar = calendar or self.resource_calendar_id + benefit_type_ids = benefit_type.ids + if benefit_type == self.env.ref('hr_payroll.benefit_type_attendance'): # special case for global attendances + benefit_type_ids += [False]# no benefit type = normal/global attendance + domain = [('benefit_type_id', 'in', benefit_type_ids)] + + # naive datetimes are made explicit in UTC + from_datetime = self._timezone_datetime(from_datetime) + to_datetime = self._timezone_datetime(to_datetime) + + day_total = self._get_day_total(from_datetime, to_datetime, calendar, resource) + # actual hours per day + if benefit_type.is_leave: + intervals = calendar._attendance_intervals(from_datetime, to_datetime, resource) & calendar._leave_intervals(from_datetime, to_datetime, resource, domain) # use domain to only retrieve leaves of this type + else: + intervals = calendar._attendance_intervals(from_datetime, to_datetime, resource, domain) - calendar._leave_intervals(from_datetime, to_datetime, resource) + + return self._get_days_data(intervals, day_total) diff --git a/addons/hr_payroll/security/hr_payroll_security.xml b/addons/hr_payroll/security/hr_payroll_security.xml index 93bc89ead02..423cfa4dd13 100644 --- a/addons/hr_payroll/security/hr_payroll_security.xml +++ b/addons/hr_payroll/security/hr_payroll_security.xml @@ -33,5 +33,16 @@ + + hr.benefit: only non validated benefits updated + + [('state', '!=', 'validated')] + + + + + + + diff --git a/addons/hr_payroll/security/ir.model.access.csv b/addons/hr_payroll/security/ir.model.access.csv index 65def2231fa..853f5ef67f5 100644 --- a/addons/hr_payroll/security/ir.model.access.csv +++ b/addons/hr_payroll/security/ir.model.access.csv @@ -12,3 +12,5 @@ access_hr_rule_input_officer,hr.rule.input.office,model_hr_rule_input,hr_payroll access_hr_salary_rule_user,hr.salary.rule.user,model_hr_salary_rule,hr_payroll.group_hr_payroll_user,1,1,1,1 access_hr_contract_advantage_template,hr.contract.advantage.template.user,model_hr_contract_advantage_template,hr_payroll.group_hr_payroll_user,1,1,1,1 access_hr_contract_advantage_template_hr_user,hr.contract.advantage.template.hr.user,model_hr_contract_advantage_template,hr.group_hr_user,1,0,0,0 +access_hr_benefit,access_hr_benefit,model_hr_benefit,group_hr_payroll_user,1,1,1,1 +access_hr_benefit_type,access_hr_benefit_type,model_hr_benefit_type,group_hr_payroll_user,1,1,1,1 \ No newline at end of file diff --git a/addons/hr_payroll/static/src/js/benefit.js b/addons/hr_payroll/static/src/js/benefit.js new file mode 100644 index 00000000000..63f4799ddd7 --- /dev/null +++ b/addons/hr_payroll/static/src/js/benefit.js @@ -0,0 +1,128 @@ +odoo.define('hr_payroll.benefit.view_custo', function(require) { + 'use strict'; + + var core = require('web.core'); + var CalendarController = require("web.CalendarController"); + + var _t = core._t; + CalendarController.include({ + + update: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self._renderBenefitButtons(); + }); + }, + + _renderGenerateButton: function(date_from, date_to, employee_ids, secondary) { + var self = this; + var primary = !secondary ? 'btn-primary' : 'btn-secondary'; + var txt = _t("Generate Benefits"); + this.$buttons.find('.o_calendar_button_month').after( + $('') + .off('click') + .on('click', function (e) { + e.preventDefault(); + e.stopImmediatePropagation(); + var date_fmt = 'YYYY-MM-DD HH:mm:ss'; + var options = { + on_close: function () { + self.reload(); + }, + }; + self.do_action({ + type: 'ir.actions.act_window', + name: txt, + res_model: 'hr.benefit.employees', + view_type: 'form', + views: [[false,'form']], + target: 'new', + context: { + 'start_benefits': date_from.format(date_fmt), + 'stop_benefits':date_to.format(date_fmt), + }, + }, options); + }) + ); + }, + + _renderBenefitButtons: function () { + if (this.modelName !== "hr.benefit") { + return; + } + + var firstDay = this.model.data.target_date.clone().startOf('month'); + var lastDay = this.model.data.target_date.clone().endOf('month'); + var events = this._checkDataInRange(firstDay, lastDay, this.model.data.data); + var is_validated = this._checkValidation(events); + this.$buttons.find('.btn-benefit').remove(); + var employee_ids = _.map(events, function (event) { return event.record.employee_id[0]; }); + employee_ids = _.uniq(employee_ids); + var self = this; + if (this.model.data.domain.length !== 0) { // select by default the employee in the domain + var employee_search_id = (this.model.data.domain[0][0] === 'employee_id' && this.model.data.domain[0][1] === '=')? [this.model.data.domain[0][2]]: null; + } + if (events.length === 0) { // Generate button + this._renderGenerateButton(firstDay, lastDay, employee_search_id); + } else { + this._renderGenerateButton(firstDay, lastDay, employee_ids, true); + } + if (is_validated && events.length !== 0) { // Generate Payslip button + this.$buttons.find('.o_calendar_button_month').after( + $('') + .off('click') + // action_hr_payslip_by_employees + .on('click', function (e) { + e.preventDefault(); + e.stopImmediatePropagation(); + var date_fmt = 'YYYY-MM-DD'; + self.do_action('hr_payroll.action_hr_payslip_by_employees', { + additional_context: { + default_employee_ids: employee_ids || [], + default_date_start: firstDay.format(date_fmt), + default_date_end: lastDay.format(date_fmt), + }, + }); + }) + ); + } + else if (!is_validated) { // Validate button + this.$buttons.find('.o_calendar_button_month').after( + $('') + .off('click') + .on('click', function (e) { + e.preventDefault(); + e.stopImmediatePropagation(); + self._rpc({ + model: 'hr.benefit', + method: 'action_validate', + args: [_.map(events, function (event) { return event.record.id; })], + }).then(function () { + return self.reload(); + }); + }) + ); + } + }, + + _checkDataInRange: function (firstDay, lastDay, events) { + var res = _.filter(events, function (event) { + // Filter records that are not inside the current month + // (because in calendar view some days of prev. and next month are visible) + return event.record.date_start.isBefore(lastDay) && event.record.date_stop.isAfter(firstDay); + }); + return res; + }, + + _checkValidation: function (records) { + return _.every(records, function (record) { + return record.record.state === 'validated'; + }); + }, + + renderButtons: function () { + this._super.apply(this, arguments); + this._renderBenefitButtons(); + }, + }); +}); \ No newline at end of file diff --git a/addons/hr_payroll/tests/__init__.py b/addons/hr_payroll/tests/__init__.py index 530540f8158..1a7d794de5a 100644 --- a/addons/hr_payroll/tests/__init__.py +++ b/addons/hr_payroll/tests/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_payslip_flow +from . import test_benefit diff --git a/addons/hr_payroll/tests/test_benefit.py b/addons/hr_payroll/tests/test_benefit.py new file mode 100644 index 00000000000..1624a5b32d1 --- /dev/null +++ b/addons/hr_payroll/tests/test_benefit.py @@ -0,0 +1,337 @@ +# # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +import pytz +from dateutil.relativedelta import relativedelta +from odoo import exceptions +from odoo.tests.common import tagged +from odoo.addons.hr_payroll.tests.common import TestPayslipBase + + +@tagged('benefit') +class TestBenefit(TestPayslipBase): + + def setUp(self): + super(TestBenefit, self).setUp() + self.tz = pytz.timezone(self.richard_emp.tz) + 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({ + 'date_start': self.start - relativedelta(days=5), + 'name': 'dodo', + 'resource_calendar_id': self.resource_calendar_id.id, + 'wage': 1000, + 'employee_id': self.richard_emp.id, + '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': 'Legal Leaves', + 'time_type': 'leave', + 'allocation_type': 'no', + 'benefit_type_id': self.benefit_type_leave.id + }) + self.benefit_type = self.env['hr.benefit.type'].create({ + 'name': 'attendance', + 'is_leave': False, + 'code': 'LEAVE100' + }) + + def test_no_duplicate(self): + self.richard_emp.generate_benefit(self.start, self.end) + pou1 = self.env['hr.benefit'].search_count([]) + self.richard_emp.generate_benefit(self.start, self.end) + pou2 = self.env['hr.benefit'].search_count([]) + self.assertEqual(pou1, pou2, "Benefits should not be duplicated") + + def test_benefit(self): + + self.richard_emp.generate_benefit(self.start, self.end) + + attendance_nb = len(self.resource_calendar_id._attendance_intervals(self.start, self.end)) + benefit_nb = self.env['hr.benefit'].search_count([('employee_id', '=', self.richard_emp.id)]) + self.assertEqual(attendance_nb, benefit_nb, "One benefit should be generated for each calendar attendance") + + def test_split_benefit_by_day(self): + start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + end = datetime.strptime('2015-11-03 18:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + + # Benefit of type attendance should be split in three + benefit = self.env['hr.benefit'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'date_start': start, + 'date_stop': end, + }) + + benefits = benefit.split_by_day() + self.assertEqual(len(benefits), 3, "Benefit should be split in three") + self.assertEqual(pytz.utc.localize(benefits[0].date_start), self.tz.localize(datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S'))) + self.assertEqual(pytz.utc.localize(benefits[0].date_stop), self.tz.localize(datetime.strptime('2015-11-01 23:59:59', '%Y-%m-%d %H:%M:%S'))) + + self.assertEqual(pytz.utc.localize(benefits[1].date_start), self.tz.localize(datetime.strptime('2015-11-02 00:00:00', '%Y-%m-%d %H:%M:%S'))) + self.assertEqual(pytz.utc.localize(benefits[1].date_stop), self.tz.localize(datetime.strptime('2015-11-02 23:59:59', '%Y-%m-%d %H:%M:%S'))) + + self.assertEqual(pytz.utc.localize(benefits[2].date_start), self.tz.localize(datetime.strptime('2015-11-03 00:00:00', '%Y-%m-%d %H:%M:%S'))) + self.assertEqual(pytz.utc.localize(benefits[2].date_stop), self.tz.localize(datetime.strptime('2015-11-03 18:00:00', '%Y-%m-%d %H:%M:%S'))) + + # Test with end at mid-night -> should not create benefit starting and ending at the same time (at 00:00) + start = datetime.strptime('2013-11-01 00:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + end = datetime.strptime('2013-11-04 00:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + + benefit = self.env['hr.benefit'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'date_start': start, + 'date_stop': end, + }) + benefits = benefit.split_by_day() + self.assertEqual(len(benefits), 3, "Benefit should be split in three") + self.assertEqual(pytz.utc.localize(benefits[0].date_start), self.tz.localize(datetime.strptime('2013-11-01 00:00:00', '%Y-%m-%d %H:%M:%S'))) + self.assertEqual(pytz.utc.localize(benefits[0].date_stop), self.tz.localize(datetime.strptime('2013-11-01 23:59:59', '%Y-%m-%d %H:%M:%S'))) + + self.assertEqual(pytz.utc.localize(benefits[1].date_start), self.tz.localize(datetime.strptime('2013-11-02 00:00:00', '%Y-%m-%d %H:%M:%S'))) + self.assertEqual(pytz.utc.localize(benefits[1].date_stop), self.tz.localize(datetime.strptime('2013-11-02 23:59:59', '%Y-%m-%d %H:%M:%S'))) + + self.assertEqual(pytz.utc.localize(benefits[2].date_start), self.tz.localize(datetime.strptime('2013-11-03 00:00:00', '%Y-%m-%d %H:%M:%S'))) + self.assertEqual(pytz.utc.localize(benefits[2].date_stop), self.tz.localize(datetime.strptime('2013-11-03 23:59:59', '%Y-%m-%d %H:%M:%S'))) + + + def test_duplicate_global_benefit_to_attendance(self): + start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + end = datetime.strptime('2015-11-03 18:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + + benef = self.env['hr.benefit'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id, + 'date_start': start, + 'date_stop': end, + }) + benef._duplicate_to_calendar() + attendance_nb = self.env['resource.calendar.attendance'].search_count([ + ('date_from', '>=', start.date()), + ('date_to', '<=', end.date()) + ]) + self.assertEqual(attendance_nb, 0, "It should not duplicate the 'normal/global' benefit type") + + def test_duplicate_benefit_to_attendance(self): + start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + end = datetime.strptime('2015-11-03 18:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + + # Benefit (not leave) should be split in three attendance + benef = self.env['hr.benefit'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'benefit_type_id': self.benefit_type.id, + 'date_start': start, + 'date_stop': end, + }) + benef._duplicate_to_calendar() + attendance_nb = self.env['resource.calendar.attendance'].search_count([ + ('date_from', '>=', start.date()), + ('date_to', '<=', end.date()) + ]) + self.assertEqual(attendance_nb, 3, "It should create one calendar attendance per day") + self.assertTrue(self.env['resource.calendar.attendance'].search([ + ('date_from', '=', datetime.strptime('2015-11-01', '%Y-%m-%d').date()), + ('date_to', '=', datetime.strptime('2015-11-01', '%Y-%m-%d').date()), + ('hour_from', '=', 9.0), + ('hour_to', '>=', 23.9) + ])) + self.assertTrue(self.env['resource.calendar.attendance'].search([ + ('date_from', '=', datetime.strptime('2015-11-02', '%Y-%m-%d').date()), + ('date_to', '=', datetime.strptime('2015-11-02', '%Y-%m-%d').date()), + ('hour_from', '=', 0.0), + ('hour_to', '>=', 23.9) + ])) + self.assertTrue(self.env['resource.calendar.attendance'].search([ + ('date_from', '=', datetime.strptime('2015-11-03', '%Y-%m-%d').date()), + ('date_to', '=', datetime.strptime('2015-11-03', '%Y-%m-%d').date()), + ('hour_from', '=', 0.0), + ('hour_to', '=', 18.0) + ])) + + def test_create_benefit_leave(self): + start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + end = datetime.strptime('2015-11-03 18:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + + benef = self.env['hr.benefit'].create({ + 'name': 'Richard leave from benef', + 'employee_id': self.richard_emp.id, + 'benefit_type_id': self.benefit_type_leave.id, + 'date_start': start, + 'date_stop': end, + }) + benef.action_validate(benef.ids) + calendar_leave = self.env['resource.calendar.leaves'].search([('name', '=', 'Richard leave from benef')]) + self.assertTrue(calendar_leave, "It should have created a leave in the calendar") + self.assertEqual(calendar_leave.benefit_type_id, benef.benefit_type_id, "It should have the same benefit type") + + def test_validate_conflict_benefit(self): + start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + end = datetime.strptime('2015-11-01 13:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + benef1 = self.env['hr.benefit'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id, + 'date_start': start, + 'date_stop': end + relativedelta(hours=5), + }) + benef2 = self.env['hr.benefit'].create({ + 'name': '2', + 'employee_id': self.richard_emp.id, + 'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id, + 'date_start': start + relativedelta(hours=3), + 'date_stop': end, + }) + self.assertFalse(benef1.action_validate(benef1.ids), "It should not validate benefits conflicting with others") + self.assertTrue(benef1.display_warning) + self.assertNotEqual(benef1.state, 'validated') + + def test_validate_non_approved_leave_benefit(self): + benef1 = self.env['hr.benefit'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'benefit_type_id': self.benefit_type_leave.id, + 'date_start': self.start, + 'date_stop': self.end, + }) + leave_1 = self.env['hr.leave'].create({ + 'name': 'Doctor Appointment', + 'employee_id': self.richard_emp.id, + 'holiday_status_id': self.leave_type.id, + 'date_from': self.start - relativedelta(days=1), + 'date_to': self.start + relativedelta(days=1), + 'number_of_days': 2, + }) + self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits conflicting with non approved leaves") + self.assertTrue(benef1.display_warning) + + def test_validate_undefined_benefit(self): + benef1 = self.env['hr.benefit'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'date_start': self.start, + 'date_stop': self.end, + }) + self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits without a type") + + def test_approve_leave_benefit(self): + start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + end = datetime.strptime('2015-11-03 13:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + leave = 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': start + relativedelta(days=1), + 'number_of_days': 2, + }) + benef = self.env['hr.benefit'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'benefit_type_id': self.benefit_type.id, + 'date_start': start, + 'date_stop': end, + 'leave_id': leave.id + }) + leave.action_approve() + + new_leave_benef = self.env['hr.benefit'].search([ + ('date_start', '=', datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S')), + ('date_stop', '=', datetime.strptime('2015-11-02 09:00:00', '%Y-%m-%d %H:%M:%S')), + ('benefit_type_id.is_leave', '=', True) + ]) + + new_benef = self.env['hr.benefit'].search([ + ('date_start', '=', datetime.strptime('2015-11-02 09:00:01', '%Y-%m-%d %H:%M:%S')), + ('date_stop', '=', end), + ('benefit_type_id.is_leave', '=', False) + ]) + + self.assertTrue(new_benef, "It should have created a benefit for the last two days") + self.assertTrue(new_leave_benef, "It should have created a leave benefit for the first day") + + self.assertTrue(benef.action_validate((new_benef|new_leave_benef).ids), "It should be able to validate the benefits") + + def test_refuse_leave_benefit(self): + start = datetime.strptime('2015-11-01 09:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + end = datetime.strptime('2015-11-03 13:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + leave = 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': start + relativedelta(days=1), + 'number_of_days': 2, + }) + benef = self.env['hr.benefit'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'benefit_type_id': self.benefit_type.id, + 'date_start': start, + 'date_stop': end, + 'leave_id': leave.id + }) + benef.action_validate(benef.ids) + self.assertTrue(benef.display_warning, "It should have an error (conflicting leave to approve") + leave.action_refuse() + self.assertFalse(benef.display_warning, "It should not have an error") + + def test_time_normal_benefit(self): + # Normal attendances (global to all employees) + data = self.richard_emp.get_benefit_days_data(self.env.ref('hr_payroll.benefit_type_attendance'), self.start, self.end) + self.assertEqual(data['hours'], 168.0) + + def test_time_extra_benefit(self): + start = datetime.strptime('2015-11-01 10:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + end = datetime.strptime('2015-11-01 17:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=self.tz) + benef = self.env['hr.benefit'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'benefit_type_id': self.benefit_type.id, + 'date_start': start, + 'date_stop': end, + }) + benef.action_validate(benef.ids) + data = self.richard_emp.get_benefit_days_data(self.benefit_type, self.start, self.end) + self.assertEqual(data['hours'], 7.0) + + def test_time_week_leave_benefit(self): + # /!\ this is a week day => it exists an calendar attendance at this time + start = datetime.strptime('2015-11-02 10:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=pytz.utc) # UTC + end = datetime.strptime('2015-11-02 17:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=pytz.utc) + leave_benef = self.env['hr.benefit'].create({ + 'name': '1leave', + 'employee_id': self.richard_emp.id, + 'benefit_type_id': self.benefit_type_leave.id, + 'date_start': start, + 'date_stop': end, + }) + leave_benef.action_validate(leave_benef.ids) + data = self.richard_emp.get_benefit_days_data(self.benefit_type_leave, self.start, self.end) + self.assertEqual(data['hours'], 5.0, "It should equal the number of hours richard should have worked") + + def test_time_weekend_leave_benefit(self): + # /!\ this is in the weekend => no calendar attendance at this time + start = datetime.strptime('2015-11-01 10:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=pytz.utc) # UTC + end = datetime.strptime('2015-11-01 17:00:00', '%Y-%m-%d %H:%M:%S').replace(tzinfo=pytz.utc) + leave_benef = self.env['hr.benefit'].create({ + 'name': '1leave', + 'employee_id': self.richard_emp.id, + 'benefit_type_id': self.benefit_type_leave.id, + 'date_start': start, + 'date_stop': end, + }) + leave_benef.action_validate(leave_benef.ids) + data = self.richard_emp.get_benefit_days_data(self.benefit_type_leave, self.start, self.end) + self.assertEqual(data['hours'], 0.0, "It should equal the number of hours richard should have worked") diff --git a/addons/hr_payroll/views/hr_benefit_template.xml b/addons/hr_payroll/views/hr_benefit_template.xml new file mode 100644 index 00000000000..1db60d24ac0 --- /dev/null +++ b/addons/hr_payroll/views/hr_benefit_template.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/addons/hr_payroll/views/hr_benefit_views.xml b/addons/hr_payroll/views/hr_benefit_views.xml new file mode 100644 index 00000000000..d187ebded06 --- /dev/null +++ b/addons/hr_payroll/views/hr_benefit_views.xml @@ -0,0 +1,165 @@ + + + + + Generate Benefit + + + code + +if records: + records.generate_benefit() + + + + + Benefit + hr.benefit + form + calendar,tree,form + + + + hr.benefit.calendar + hr.benefit + + + + + + + + + +
+ +
+
+ + + + + + + + + + +
+
+ + + +
+ + + + hr.benefit.form + hr.benefit + +
+
+ + +
+ +
+ +
+ + +
+
+
+

+ +

+
+ + + + + + + + + + + + +
+
+
+
+ + + hr.benefit.filter + hr.benefit + + + + + + + + + + + + hr.benefit.type.form + hr.benefit.type + +
+ +
+

+ +

+
+ + + + + + + + + +
+
+
+
+ + + + + Benefit Types + hr.benefit.type + tree,kanban,form + + + + \ No newline at end of file diff --git a/addons/hr_payroll/views/hr_leave_views.xml b/addons/hr_payroll/views/hr_leave_views.xml new file mode 100644 index 00000000000..ecdd983bc3f --- /dev/null +++ b/addons/hr_payroll/views/hr_leave_views.xml @@ -0,0 +1,16 @@ + + + + + benefit.type.leave.form.inherit + hr.leave.type + + + + + + + + + + diff --git a/addons/hr_payroll/views/hr_payslip_views.xml b/addons/hr_payroll/views/hr_payslip_views.xml index 4970b637716..8dbc78f0274 100644 --- a/addons/hr_payroll/views/hr_payslip_views.xml +++ b/addons/hr_payroll/views/hr_payslip_views.xml @@ -385,7 +385,14 @@ - + + + + + + + + diff --git a/addons/hr_payroll/views/resource_views.xml b/addons/hr_payroll/views/resource_views.xml new file mode 100644 index 00000000000..5fe67b67128 --- /dev/null +++ b/addons/hr_payroll/views/resource_views.xml @@ -0,0 +1,31 @@ + + + + + + payroll.resource.calendar.view.form + resource.calendar + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/hr_payroll/wizard/__init__.py b/addons/hr_payroll/wizard/__init__.py index f433d2bbf05..86fd3512f4d 100644 --- a/addons/hr_payroll/wizard/__init__.py +++ b/addons/hr_payroll/wizard/__init__.py @@ -3,3 +3,4 @@ from . import hr_payroll_payslips_by_employees from . import hr_payroll_contribution_register_report +from . import hr_benefit_employee diff --git a/addons/hr_payroll/wizard/hr_benefit_employee.py b/addons/hr_payroll/wizard/hr_benefit_employee.py new file mode 100644 index 00000000000..cdbf8b6345a --- /dev/null +++ b/addons/hr_payroll/wizard/hr_benefit_employee.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class HrPayslipEmployees(models.TransientModel): + _name = 'hr.benefit.employees' + _description = 'Generate benefits for all selected employees' + + def _get_domain(self): + return [('contract_ids.state', 'in', ('open', 'pending')), ('company_id', '=', self.env.user.company_id.id)] + + employee_ids = fields.Many2many('hr.employee', string='Employees', domain=lambda self: self._get_domain()) + + + def generate_benefit(self): + if not self.employee_ids: + raise UserError(_("You must select employee(s) to generate benefits.")) + date_start = self.env.context.get('start_benefits') + date_stop = self.env.context.get('stop_benefits') + + date_start = fields.Datetime.from_string(date_start) + date_stop = fields.Datetime.from_string(date_stop) + self.employee_ids.generate_benefit(date_start, date_stop) diff --git a/addons/hr_payroll/wizard/hr_benefit_employee_views.xml b/addons/hr_payroll/wizard/hr_benefit_employee_views.xml new file mode 100644 index 00000000000..7db54da6264 --- /dev/null +++ b/addons/hr_payroll/wizard/hr_benefit_employee_views.xml @@ -0,0 +1,23 @@ + + + + hr_benefit_employees + hr.benefit.employees + +
+
+
+ + This wizard will generate benefits for all selected employee(s) based on attendances. + + + + + + + +
+
+
+
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 d2097da5306..8a986015c8b 100644 --- a/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py +++ b/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py @@ -3,17 +3,22 @@ from odoo import api, fields, models, _ from odoo.exceptions import UserError +from datetime import datetime class HrPayslipEmployees(models.TransientModel): _name = 'hr.payslip.employees' _description = 'Generate payslips for all selected employees' - employee_ids = fields.Many2many('hr.employee', 'hr_employee_group_rel', 'payslip_id', 'employee_id', 'Employees', - domain=lambda self: self._get_available_contracts_domain()) - def _get_available_contracts_domain(self): - return [('contract_ids.state', 'in', ('open', 'pending'))] + return [('contract_ids.state', 'in', ('open', 'pending')), ('company_id', '=', self.env.user.company_id.id)] + + def _get_employees(self): + return self.env['hr.employee'].search(self._get_available_contracts_domain()) + + employee_ids = fields.Many2many('hr.employee', 'hr_employee_group_rel', 'payslip_id', 'employee_id', 'Employees', + domain=lambda self: self._get_available_contracts_domain(), + default=lambda self: self._get_employees()) @api.multi @@ -23,8 +28,20 @@ class HrPayslipEmployees(models.TransientModel): active_id = self.env.context.get('active_id') if active_id: [run_data] = self.env['hr.payslip.run'].browse(active_id).read(['date_start', 'date_end', 'credit_note']) - from_date = run_data.get('date_start') - to_date = run_data.get('date_end') + from_date = run_data.get('date_start') + to_date = run_data.get('date_end') + else: + from_date = datetime.strptime(self.env.context.get('default_date_start'), '%Y-%m-%d') + to_date = datetime.strptime(self.env.context.get('default_date_end'), '%Y-%m-%d') + payslip_run = self.env['hr.payslip.run'].create({ + 'name': from_date.strftime('%B %Y'), + 'date_start': from_date, + 'date_end': to_date, + }) + + active_id = payslip_run.id + [run_data] = payslip_run.read(['date_start', 'date_end', 'credit_note']) + if not data['employee_ids']: raise UserError(_("You must select employee(s) to generate payslip(s).")) for employee in self.env['hr.employee'].browse(data['employee_ids']): @@ -44,4 +61,10 @@ class HrPayslipEmployees(models.TransientModel): } payslips += self.env['hr.payslip'].create(res) payslips.compute_sheet() - return {'type': 'ir.actions.act_window_close'} + + return {'type': 'ir.actions.act_window', + 'res_model': 'hr.payslip.run', + 'view_type': 'form', + 'views': [[False, 'form']], + 'res_id': active_id, + }