From 07bec7db879aee92316570770316857417636207 Mon Sep 17 00:00:00 2001 From: lul-odoo Date: Wed, 21 Nov 2018 08:59:06 +0100 Subject: [PATCH 01/12] [FIX] hr_payroll_account: Remove journal_id: False in context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the wizard to generate payslip was launched without an ´active_id´ in the context. A ´journal_id´ entry was set to False in the context later leading to a crash at payslip creation. The crash happens because, at creation time, the journal_id of a ´hr.payslip´ is set to the journal_id in the context if its present. Since this context entry is False and the journal_id field is required, creation crashes. --- .../wizard/hr_payroll_payslips_by_employees.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/hr_payroll_account/wizard/hr_payroll_payslips_by_employees.py b/addons/hr_payroll_account/wizard/hr_payroll_payslips_by_employees.py index 296d7b0810a..84798a68476 100644 --- a/addons/hr_payroll_account/wizard/hr_payroll_payslips_by_employees.py +++ b/addons/hr_payroll_account/wizard/hr_payroll_payslips_by_employees.py @@ -8,7 +8,7 @@ class HrPayslipEmployees(models.TransientModel): @api.multi def compute_sheet(self): - journal_id = False if self.env.context.get('active_id'): journal_id = self.env['hr.payslip.run'].browse(self.env.context.get('active_id')).journal_id.id - return super(HrPayslipEmployees, self.with_context(journal_id=journal_id)).compute_sheet() + return super(HrPayslipEmployees, self.with_context(journal_id=journal_id)).compute_sheet() + return super(HrPayslipEmployees, self).compute_sheet() From 5a9b8a41b692061548af2cb543171c503e802ade Mon Sep 17 00:00:00 2001 From: lul-odoo Date: Wed, 14 Nov 2018 13:55:01 +0100 Subject: [PATCH 02/12] [IMP] resource: Add `domain` parameter to compute attendace intervals Purpose ======= More flexibility is useful when using the _attendance_intervals method (resource.calendar) in other modules when inheriting resource.calendar Specification ============= The commit adds a `domain` parameter to the _attendance_intervals method in resource.calendar, allowing to better control attendances from which to compute intervals. + small refactoring: extraction of duplicated code in method --- addons/resource/models/resource.py | 12 ++- addons/resource/models/resource_mixin.py | 98 ++++++++++++------------ 2 files changed, 60 insertions(+), 50 deletions(-) diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index 08d6b9e73e9..481cb9b57df 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -197,13 +197,20 @@ class ResourceCalendar(models.Model): # -------------------------------------------------- # Computation API # -------------------------------------------------- - def _attendance_intervals(self, start_dt, end_dt, resource=None): + def _attendance_intervals(self, start_dt, end_dt, resource=None, domain=None): """ Return the attendance intervals in the given datetime range. The returned intervals are expressed in the resource's timezone. """ assert start_dt.tzinfo and end_dt.tzinfo combine = datetime.combine + resource_ids = [resource.id, False] if resource else [False] + domain = domain if domain is not None else [] + domain = domain + [ + ('calendar_id', '=', self.id), + ('resource_id', 'in', resource_ids), + ] + # express all dates and times in the resource's timezone tz = timezone((resource or self).tz) start_dt = start_dt.astimezone(tz) @@ -211,7 +218,7 @@ class ResourceCalendar(models.Model): # for each attendance spec, generate the intervals in the date range result = [] - for attendance in self.attendance_ids: + for attendance in self.env['resource.calendar.attendance'].search(domain): start = start_dt.date() if attendance.date_from: start = max(start, attendance.date_from) @@ -404,6 +411,7 @@ class ResourceCalendarAttendance(models.Model): hour_to = fields.Float(string='Work to', required=True) calendar_id = fields.Many2one("resource.calendar", string="Resource's Calendar", required=True, ondelete='cascade') day_period = fields.Selection([('morning', 'Morning'), ('afternoon', 'Afternoon')], required=True, default='morning') + resource_id = fields.Many2one('resource.resource', 'Resource') @api.onchange('hour_from', 'hour_to') def _onchange_hours(self): diff --git a/addons/resource/models/resource_mixin.py b/addons/resource/models/resource_mixin.py index a55911398af..4108146149d 100644 --- a/addons/resource/models/resource_mixin.py +++ b/addons/resource/models/resource_mixin.py @@ -53,6 +53,47 @@ class ResourceMixin(models.AbstractModel): default['resource_calendar_id'] = resource.calendar_id.id return super(ResourceMixin, self).copy_data(default) + def _get_days_data(self, intervals, day_total): + """ + helper function to compute duration of `intervals` + expressed in days and hours. + `day_total` is a dict {date: n_hours} with the number of hours for each day. + """ + day_hours = defaultdict(float) + for start, stop, meta in intervals: + day_hours[start.date()] += (stop - start).total_seconds() / 3600 + + # compute number of days as quarters + days = sum( + float_utils.round(ROUNDING_FACTOR * day_hours[day] / day_total[day]) / ROUNDING_FACTOR + for day in day_hours + ) + return { + 'days': days, + 'hours': sum(day_hours.values()), + } + + def _get_day_total(self, from_datetime, to_datetime, calendar, resource): + """ + @return dict with hours of attendance in each day between `from_datetime` and `to_datetime` + """ + + # total hours per day: retrieve attendances with one extra day margin, + # in order to compute the total hours on the first and last days + from_full = from_datetime - timedelta(days=1) + to_full = to_datetime + timedelta(days=1) + intervals = calendar._attendance_intervals(from_full, to_full, resource) + day_total = defaultdict(float) + for start, stop, meta in intervals: + day_total[start.date()] += (stop - start).total_seconds() / 3600 + return day_total + + def _timezone_datetime(self, time): + if not time.tzinfo: + time = time.replace(tzinfo=utc) + return time + + def get_work_days_data(self, from_datetime, to_datetime, compute_leaves=True, calendar=None, domain=None): """ By default the resource calendar is used, but it can be @@ -68,38 +109,18 @@ class ResourceMixin(models.AbstractModel): calendar = calendar or self.resource_calendar_id # naive datetimes are made explicit in UTC - if not from_datetime.tzinfo: - from_datetime = from_datetime.replace(tzinfo=utc) - if not to_datetime.tzinfo: - to_datetime = to_datetime.replace(tzinfo=utc) + from_datetime = self._timezone_datetime(from_datetime) + to_datetime = self._timezone_datetime(to_datetime) - # total hours per day: retrieve attendances with one extra day margin, - # in order to compute the total hours on the first and last days - from_full = from_datetime - timedelta(days=1) - to_full = to_datetime + timedelta(days=1) - intervals = calendar._attendance_intervals(from_full, to_full, resource) - day_total = defaultdict(float) - for start, stop, meta in intervals: - day_total[start.date()] += (stop - start).total_seconds() / 3600 + day_total = self._get_day_total(from_datetime, to_datetime, calendar, resource) # actual hours per day if compute_leaves: intervals = calendar._work_intervals(from_datetime, to_datetime, resource, domain) else: intervals = calendar._attendance_intervals(from_datetime, to_datetime, resource) - day_hours = defaultdict(float) - for start, stop, meta in intervals: - day_hours[start.date()] += (stop - start).total_seconds() / 3600 - # compute number of days as quarters - days = sum( - float_utils.round(ROUNDING_FACTOR * day_hours[day] / day_total[day]) / ROUNDING_FACTOR - for day in day_hours - ) - return { - 'days': days, - 'hours': sum(day_hours.values()), - } + return self._get_days_data(intervals, day_total) def get_leave_days_data(self, from_datetime, to_datetime, calendar=None, domain=None): """ @@ -116,36 +137,17 @@ class ResourceMixin(models.AbstractModel): calendar = calendar or self.resource_calendar_id # naive datetimes are made explicit in UTC - if not from_datetime.tzinfo: - from_datetime = from_datetime.replace(tzinfo=utc) - if not to_datetime.tzinfo: - to_datetime = to_datetime.replace(tzinfo=utc) + from_datetime = self._timezone_datetime(from_datetime) + to_datetime = self._timezone_datetime(to_datetime) - # total hours per day: retrieve attendances with one extra day margin, - # in order to compute the total hours on the first and last days - from_full = from_datetime - timedelta(days=1) - to_full = to_datetime + timedelta(days=1) - intervals = calendar._attendance_intervals(from_full, to_full, resource) - day_total = defaultdict(float) - for start, stop, meta in intervals: - day_total[start.date()] += (stop - start).total_seconds() / 3600 + day_total = self._get_day_total(from_datetime, to_datetime, calendar, resource) # compute actual hours per day attendances = calendar._attendance_intervals(from_datetime, to_datetime, resource) leaves = calendar._leave_intervals(from_datetime, to_datetime, resource, domain) - day_hours = defaultdict(float) - for start, stop, meta in (attendances & leaves): - day_hours[start.date()] += (stop - start).total_seconds() / 3600 - # compute number of days as quarters - days = sum( - float_utils.round(ROUNDING_FACTOR * day_hours[day] / day_total[day]) / ROUNDING_FACTOR - for day in day_hours - ) - return { - 'days': days, - 'hours': sum(day_hours.values()), - } + return self._get_days_data(attendances & leaves, day_total) + def list_work_time_per_day(self, from_datetime, to_datetime, calendar=None, domain=None): """ From 78128760c553dbb728a22ab9f10fc5b403d2ba13 Mon Sep 17 00:00:00 2001 From: lul-odoo Date: Mon, 29 Oct 2018 16:14:53 +0100 Subject: [PATCH 03/12] [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, + } From c7bbbd41c74d9c07488269f2d77c88833da5cbb1 Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Thu, 29 Nov 2018 11:52:34 +0100 Subject: [PATCH 04/12] [IMP] hr_payroll: Remove hr.rule.input model There are currently 2 models: - hr.rule.input to link some inputs to an hr.payslip.rule - hr.payslip.input to add some additional inputs on a payslip. The first model is actually there to automatically add inputs on the additional inputs on the payslip. This is problematic because these rules are always added (For example a salary reduction due to a police fine). As these inputs are always added on the payslip, the the related rules are always computed with an amount of 0. We decided to remove this mechanism for the following reasons: - If the rule is important and common, then define the needed values on the contract, and compute the rules from that. - If the rule is not so important, it should be added manually when needed. --- addons/hr_payroll/data/hr_payroll_demo.xml | 23 ------- addons/hr_payroll/models/hr_payslip.py | 44 +++---------- addons/hr_payroll/models/hr_salary_rule.py | 11 +--- .../hr_payroll/security/ir.model.access.csv | 1 - addons/hr_payroll/tests/common.py | 3 +- .../hr_payroll/views/hr_salary_rule_views.xml | 8 --- .../tests/test_hr_payroll_account.py | 1 - .../data/l10n_in_hr_payroll_data.xml | 61 ------------------- 8 files changed, 10 insertions(+), 142 deletions(-) diff --git a/addons/hr_payroll/data/hr_payroll_demo.xml b/addons/hr_payroll/data/hr_payroll_demo.xml index 34a9d091fe6..43c4bbaf6a9 100644 --- a/addons/hr_payroll/data/hr_payroll_demo.xml +++ b/addons/hr_payroll/data/hr_payroll_demo.xml @@ -82,29 +82,6 @@ - - code - SALE - - Get 1% of sales - - result = ((inputs.SALEURO and inputs.SALEURO.amount) + (inputs.SALASIA and inputs.SALASIA.amount)) * 0.01 - - - - - - SALEURO - Sales to Europe - - - - - SALASIA - Sales to Asia - - - diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index 51f84c60b86..d42782c3202 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -143,7 +143,7 @@ class HrPayslip(models.Model): def get_worked_day_lines(self, contracts, date_from, date_to): """ @param contract: Browse record of contracts - @return: returns a list of dict containing the input that should be applied for the given contract between date_from and date_to + @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 @@ -170,25 +170,6 @@ class HrPayslip(models.Model): return res - @api.model - def get_inputs(self, contracts, date_from, date_to): - res = [] - - structures = contracts.get_all_structures() - rule_ids = structures.get_all_rules() - sorted_rule_ids = [id for id, sequence in sorted(rule_ids, key=lambda x:x[1])] - inputs = self.env['hr.salary.rule'].browse(sorted_rule_ids).mapped('input_ids') - - for contract in contracts: - for input in inputs: - input_data = { - 'name': input.name, - 'code': input.code, - 'contract_id': contract.id, - } - res += [input_data] - return res - # YTI TODO: pass recordset as argument @api.model def _get_payslip_lines(self, contracts): @@ -303,10 +284,10 @@ class HrPayslip(models.Model): contracts = employee._get_contracts(date_from, date_to) else: if contract_id: - #set the list of contract for which the input have to be filled + #set the list of contract for which the worked days have to be filled contracts = self.env['hr.contract'].browse(contract_id) else: - #if we don't give the contract, then the input to fill should be for all current contracts of the employee + #if we don't give the contract, then the worked days to fill should be for all current contracts of the employee contracts = employee._get_contracts(date_from, date_to) if not contracts: @@ -321,13 +302,9 @@ class HrPayslip(models.Model): res['value'].update({ 'struct_id': struct.id, }) - #computation of the salary input + #computation of the salary worked days worked_days_line_ids = self.get_worked_day_lines(contracts, date_from, date_to) - input_line_ids = self.get_inputs(contracts, date_from, date_to) - res['value'].update({ - 'worked_days_line_ids': worked_days_line_ids, - 'input_line_ids': input_line_ids, - }) + res['value'].update({'worked_days_line_ids': worked_days_line_ids}) return res @api.onchange('employee_id', 'date_from', 'date_to') @@ -356,18 +333,13 @@ class HrPayslip(models.Model): return self.struct_id = self.contract_id.struct_id - #computation of the salary input + #computation of the salary worked days worked_days_line_ids = self.get_worked_day_lines(contracts, 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) self.worked_days_line_ids = worked_days_lines - input_line_ids = self.get_inputs(contracts, date_from, date_to) - input_lines = self.input_line_ids.browse([]) - for r in input_line_ids: - input_lines += input_lines.new(r) - self.input_line_ids = input_lines return @api.onchange('contract_id') @@ -411,7 +383,7 @@ class HrPayslipLine(models.Model): amount_fix = fields.Float(related='salary_rule_id.amount_fix', readonly=True) amount_percentage = fields.Float(related='salary_rule_id.amount_percentage', readonly=True) appears_on_payslip = fields.Boolean(related='salary_rule_id.appears_on_payslip', readonly=True) - category_id = fields.Many2one(related='salary_rule_id.category_id', readonly=True) + category_id = fields.Many2one(related='salary_rule_id.category_id', readonly=True, store=True) register_id = fields.Many2one(related='salary_rule_id.register_id', readonly=True, store=True) @api.depends('quantity', 'amount', 'rate') @@ -443,7 +415,7 @@ class HrPayslipWorkedDays(models.Model): number_of_days = fields.Float(string='Number of Days') number_of_hours = fields.Float(string='Number of Hours') contract_id = fields.Many2one('hr.contract', string='Contract', required=True, - help="The contract for which applied this input") + help="The contract for which applied this worked days") class HrPayslipInput(models.Model): diff --git a/addons/hr_payroll/models/hr_salary_rule.py b/addons/hr_payroll/models/hr_salary_rule.py index 75a899f9eca..9a6ccecf90c 100644 --- a/addons/hr_payroll/models/hr_salary_rule.py +++ b/addons/hr_payroll/models/hr_salary_rule.py @@ -135,7 +135,7 @@ class HrSalaryRule(models.Model): # rules: object containing the rules code (previously computed) # categories: object containing the computed salary rule categories (sum of amount of all rules belonging to that category). # worked_days: object containing the computed worked days - # inputs: object containing the computed inputs + # inputs: object containing the computed inputs. # Note: returned value have to be set in the variable 'result' @@ -170,7 +170,6 @@ class HrSalaryRule(models.Model): child_ids = fields.One2many('hr.salary.rule', 'parent_rule_id', string='Child Salary Rule', copy=True) register_id = fields.Many2one('hr.contribution.register', string='Contribution Register', help="Eventual third party involved in the salary payment of the employees.") - input_ids = fields.One2many('hr.rule.input', 'input_id', string='Inputs', copy=True) note = fields.Text(string='Description') @api.constrains('parent_rule_id') @@ -239,11 +238,3 @@ class HrSalaryRule(models.Model): except: raise UserError(_('Wrong python condition defined for salary rule %s (%s).') % (self.name, self.code)) - -class HrRuleInput(models.Model): - _name = 'hr.rule.input' - _description = 'Salary Rule Input' - - name = fields.Char(string='Description', required=True) - code = fields.Char(required=True, help="The code that can be used in the salary rules") - input_id = fields.Many2one('hr.salary.rule', string='Salary Rule Input', required=True) diff --git a/addons/hr_payroll/security/ir.model.access.csv b/addons/hr_payroll/security/ir.model.access.csv index 853f5ef67f5..484551f5cc4 100644 --- a/addons/hr_payroll/security/ir.model.access.csv +++ b/addons/hr_payroll/security/ir.model.access.csv @@ -8,7 +8,6 @@ access_hr_payslip_line,hr.payslip.line,model_hr_payslip_line,hr_payroll.group_hr access_hr_payslip_input_user,hr.payslip.input.user,model_hr_payslip_input,hr_payroll.group_hr_payroll_user,1,1,1,1 access_hr_payslip_worked_days_officer,hr.payslip.worked_days.officer,model_hr_payslip_worked_days,hr_payroll.group_hr_payroll_user,1,1,1,1 access_hr_payslip_run,hr.payslip.run,model_hr_payslip_run,hr_payroll.group_hr_payroll_manager,1,1,1,1 -access_hr_rule_input_officer,hr.rule.input.office,model_hr_rule_input,hr_payroll.group_hr_payroll_user,1,1,1,1 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 diff --git a/addons/hr_payroll/tests/common.py b/addons/hr_payroll/tests/common.py index 29e5faa31e3..8d50dec7621 100644 --- a/addons/hr_payroll/tests/common.py +++ b/addons/hr_payroll/tests/common.py @@ -18,7 +18,6 @@ class TestPayslipBase(TransactionCase): self.prof_tax_rule_id = self.ref('hr_payroll.hr_salary_rule_professionaltax1') self.pf_rule_id = self.ref('hr_payroll.hr_salary_rule_providentfund1') self.mv_rule_id = self.ref('hr_payroll.hr_salary_rule_meal_voucher') - self.comm_rule_id = self.ref('hr_payroll.hr_salary_rule_sales_commission') # I create a new employee "Richard" self.richard_emp = self.env['hr.employee'].create({ @@ -36,7 +35,7 @@ class TestPayslipBase(TransactionCase): 'company_id': self.ref('base.main_company'), 'rule_ids': [(4, self.hra_rule_id), (4, self.conv_rule_id), (4, self.prof_tax_rule_id), (4, self.pf_rule_id), - (4, self.mv_rule_id), (4, self.comm_rule_id)], + (4, self.mv_rule_id)], }) # I create a contract for "Richard" diff --git a/addons/hr_payroll/views/hr_salary_rule_views.xml b/addons/hr_payroll/views/hr_salary_rule_views.xml index 31f11045fac..37f1c91e2d3 100644 --- a/addons/hr_payroll/views/hr_salary_rule_views.xml +++ b/addons/hr_payroll/views/hr_salary_rule_views.xml @@ -346,14 +346,6 @@ - - - - - - - - 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 f0b01a54198..202a42db0fc 100644 --- a/addons/hr_payroll_account/tests/test_hr_payroll_account.py +++ b/addons/hr_payroll_account/tests/test_hr_payroll_account.py @@ -56,7 +56,6 @@ class TestHrPayrollAccount(common.TransactionCase): self.ref('hr_payroll.hr_salary_rule_professionaltax1'), self.ref('hr_payroll.hr_salary_rule_providentfund1'), self.ref('hr_payroll.hr_salary_rule_meal_voucher'), - self.ref('hr_payroll.hr_salary_rule_sales_commission') ])], }) diff --git a/addons/l10n_in_hr_payroll/data/l10n_in_hr_payroll_data.xml b/addons/l10n_in_hr_payroll/data/l10n_in_hr_payroll_data.xml index 3860c170f5b..ee5b4367e7d 100644 --- a/addons/l10n_in_hr_payroll/data/l10n_in_hr_payroll_data.xml +++ b/addons/l10n_in_hr_payroll/data/l10n_in_hr_payroll_data.xml @@ -518,65 +518,4 @@ or - (contract.wage * 0.12)) Both the employees and employer contribute to the fund at the rate of 12% of the basic wages, dearness allowance and retaining allowance, if any, payable to employees per month. - - - - - PERF - Performance of Employee - - - - - BNS - Bonus of Employee - - - - - ARS - Arrears - - - - - LE - Leave Encashment - - - - - PJ - Periodical Journals - - - - - TR - Telephone Reimbursement - - - - - CEI - Car Expenses - - - - - IE - Internet Expence - - - - - LAI - Leave Availed - - - - MR - Medical Reimbursement - - From 9cd7dbe22d6edf7a057c08f1103d7cc3451d1a97 Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Tue, 27 Nov 2018 13:33:23 +0100 Subject: [PATCH 05/12] [IMP] hr_payroll: Remove 'Details By Salary Rules Category' This field brings nothing more than the payslip lines. Btw the term is badly chosen, it doesn't display the lines by salary rules category, it displays the payslip lines that have a category. The printed report is not very useful too. The only interesting part is the contribution register at the end of the report, and there is already a dedicated report for that. --- addons/hr_payroll/__manifest__.py | 1 - addons/hr_payroll/models/hr_payslip.py | 7 -- addons/hr_payroll/report/__init__.py | 1 - .../report/report_payslip_details.py | 99 ----------------- addons/hr_payroll/tests/test_payslip_flow.py | 3 - addons/hr_payroll/views/hr_payroll_report.xml | 9 -- addons/hr_payroll/views/hr_payslip_views.xml | 14 +-- .../views/report_payslipdetails_templates.xml | 101 ------------------ .../models/hr_payroll_account.py | 2 +- addons/l10n_in_hr_payroll/report/__init__.py | 1 - .../report/report_payslip_details.py | 20 ---- 11 files changed, 3 insertions(+), 255 deletions(-) delete mode 100644 addons/hr_payroll/report/report_payslip_details.py delete mode 100644 addons/hr_payroll/views/report_payslipdetails_templates.xml delete mode 100644 addons/l10n_in_hr_payroll/report/report_payslip_details.py diff --git a/addons/hr_payroll/__manifest__.py b/addons/hr_payroll/__manifest__.py index e26ea532ab9..5483d60de8c 100644 --- a/addons/hr_payroll/__manifest__.py +++ b/addons/hr_payroll/__manifest__.py @@ -27,7 +27,6 @@ 'views/res_config_settings_views.xml', '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', diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index d42782c3202..f6f6bf39521 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -59,8 +59,6 @@ class HrPayslip(models.Model): note = fields.Text(string='Internal Note', readonly=True, states={'draft': [('readonly', False)]}) contract_id = fields.Many2one('hr.contract', string='Contract', readonly=True, states={'draft': [('readonly', False)]}) - details_by_salary_rule_category = fields.One2many('hr.payslip.line', - compute='_compute_details_by_salary_rule_category', string='Details by Salary Rule Category') credit_note = fields.Boolean(string='Credit Note', readonly=True, states={'draft': [('readonly', False)]}, help="Indicates this payslip has a refund of another") @@ -68,11 +66,6 @@ class HrPayslip(models.Model): copy=False, states={'draft': [('readonly', False)]}) payslip_count = fields.Integer(compute='_compute_payslip_count', string="Payslip Computation Details") - @api.multi - def _compute_details_by_salary_rule_category(self): - for payslip in self: - payslip.details_by_salary_rule_category = payslip.mapped('line_ids').filtered(lambda line: line.category_id) - @api.multi def _compute_payslip_count(self): for payslip in self: diff --git a/addons/hr_payroll/report/__init__.py b/addons/hr_payroll/report/__init__.py index 308f6ee3cf1..f05ddd9597b 100644 --- a/addons/hr_payroll/report/__init__.py +++ b/addons/hr_payroll/report/__init__.py @@ -2,5 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import report_payslip_details from . import report_contribution_register diff --git a/addons/hr_payroll/report/report_payslip_details.py b/addons/hr_payroll/report/report_payslip_details.py deleted file mode 100644 index db8e7717c1b..00000000000 --- a/addons/hr_payroll/report/report_payslip_details.py +++ /dev/null @@ -1,99 +0,0 @@ -#-*- coding:utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, models - -class PayslipDetailsReport(models.AbstractModel): - _name = 'report.hr_payroll.report_payslipdetails' - _description = 'Payslip Details Report' - - def get_details_by_rule_category(self, payslip_lines): - PayslipLine = self.env['hr.payslip.line'] - RuleCateg = self.env['hr.salary.rule.category'] - - def get_recursive_parent(current_rule_category, rule_categories=None): - if rule_categories: - rule_categories = current_rule_category | rule_categories - else: - rule_categories = current_rule_category - - if current_rule_category.parent_id: - return get_recursive_parent(current_rule_category.parent_id, rule_categories) - else: - return rule_categories - - res = {} - result = {} - - if payslip_lines: - self.env.cr.execute(""" - SELECT pl.id, pl.category_id, pl.slip_id FROM hr_payslip_line as pl - LEFT JOIN hr_salary_rule_category AS rc on (pl.category_id = rc.id) - WHERE pl.id in %s - GROUP BY rc.parent_id, pl.sequence, pl.id, pl.category_id - ORDER BY pl.sequence, rc.parent_id""", - (tuple(payslip_lines.ids),)) - for x in self.env.cr.fetchall(): - result.setdefault(x[2], {}) - result[x[2]].setdefault(x[1], []) - result[x[2]][x[1]].append(x[0]) - for payslip_id, lines_dict in result.items(): - res.setdefault(payslip_id, []) - for rule_categ_id, line_ids in lines_dict.items(): - rule_categories = RuleCateg.browse(rule_categ_id) - lines = PayslipLine.browse(line_ids) - level = 0 - for parent in get_recursive_parent(rule_categories): - res[payslip_id].append({ - 'rule_category': parent.name, - 'name': parent.name, - 'code': parent.code, - 'level': level, - 'total': sum(lines.mapped('total')), - }) - level += 1 - for line in lines: - res[payslip_id].append({ - 'rule_category': line.name, - 'name': line.name, - 'code': line.code, - 'total': line.total, - 'level': level - }) - return res - - def get_lines_by_contribution_register(self, payslip_lines): - result = {} - res = {} - for line in payslip_lines.filtered('register_id'): - result.setdefault(line.slip_id.id, {}) - result[line.slip_id.id].setdefault(line.register_id, line) - result[line.slip_id.id][line.register_id] |= line - for payslip_id, lines_dict in result.items(): - res.setdefault(payslip_id, []) - for register, lines in lines_dict.items(): - res[payslip_id].append({ - 'register_name': register.name, - 'total': sum(lines.mapped('total')), - }) - for line in lines: - res[payslip_id].append({ - 'name': line.name, - 'code': line.code, - 'quantity': line.quantity, - 'amount': line.amount, - 'total': line.total, - }) - return res - - @api.model - def _get_report_values(self, docids, data=None): - payslips = self.env['hr.payslip'].browse(docids) - return { - 'doc_ids': docids, - 'doc_model': 'hr.payslip', - 'docs': payslips, - 'data': data, - 'get_details_by_rule_category': self.get_details_by_rule_category(payslips.mapped('details_by_salary_rule_category').filtered(lambda r: r.appears_on_payslip)), - 'get_lines_by_contribution_register': self.get_lines_by_contribution_register(payslips.mapped('line_ids').filtered(lambda r: r.appears_on_payslip)), - } diff --git a/addons/hr_payroll/tests/test_payslip_flow.py b/addons/hr_payroll/tests/test_payslip_flow.py index 3245148d93c..a1b2e301cbe 100644 --- a/addons/hr_payroll/tests/test_payslip_flow.py +++ b/addons/hr_payroll/tests/test_payslip_flow.py @@ -71,9 +71,6 @@ class TestPayslipFlow(TestPayslipBase): # I print the payslip report data, data_format = self.env.ref('hr_payroll.action_report_payslip').render(richard_payslip.ids) - # I print the payslip details report - data, data_format = self.env.ref('hr_payroll.payslip_details_report').render(richard_payslip.ids) - # I print the contribution register report context = {'model': 'hr.contribution.register', 'active_ids': [self.ref('hr_payroll.hr_houserent_register')]} test_reports.try_report_action(self.env.cr, self.env.uid, 'action_payslip_lines_contribution_register', context=context, our_module='hr_payroll') diff --git a/addons/hr_payroll/views/hr_payroll_report.xml b/addons/hr_payroll/views/hr_payroll_report.xml index e9c4ac11ccf..f37c1462f3e 100644 --- a/addons/hr_payroll/views/hr_payroll_report.xml +++ b/addons/hr_payroll/views/hr_payroll_report.xml @@ -19,13 +19,4 @@ print_report_name="'Payslip - %s' % (object.employee_id.name)" /> - diff --git a/addons/hr_payroll/views/hr_payslip_views.xml b/addons/hr_payroll/views/hr_payslip_views.xml index 8dbc78f0274..6cb0c62a4ec 100644 --- a/addons/hr_payroll/views/hr_payslip_views.xml +++ b/addons/hr_payroll/views/hr_payslip_views.xml @@ -230,17 +230,7 @@ - - - - - - - - - - - + @@ -253,7 +243,7 @@
-
+
diff --git a/addons/hr_payroll/views/report_payslipdetails_templates.xml b/addons/hr_payroll/views/report_payslipdetails_templates.xml deleted file mode 100644 index ee6a99af5dd..00000000000 --- a/addons/hr_payroll/views/report_payslipdetails_templates.xml +++ /dev/null @@ -1,101 +0,0 @@ - - - - diff --git a/addons/hr_payroll_account/models/hr_payroll_account.py b/addons/hr_payroll_account/models/hr_payroll_account.py index 989d5f260f4..1e21ae1a83a 100644 --- a/addons/hr_payroll_account/models/hr_payroll_account.py +++ b/addons/hr_payroll_account/models/hr_payroll_account.py @@ -69,7 +69,7 @@ class HrPayslip(models.Model): 'journal_id': slip.journal_id.id, 'date': date, } - for line in slip.details_by_salary_rule_category: + for line in slip.line_ids.filtered(lambda line: line.category_id): amount = slip.credit_note and -line.total or line.total if float_is_zero(amount, precision_digits=precision): continue diff --git a/addons/l10n_in_hr_payroll/report/__init__.py b/addons/l10n_in_hr_payroll/report/__init__.py index 9c4069ac8b5..464af19911a 100644 --- a/addons/l10n_in_hr_payroll/report/__init__.py +++ b/addons/l10n_in_hr_payroll/report/__init__.py @@ -1,7 +1,6 @@ # -*- coding:utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import report_payslip_details from . import report_payroll_advice from . import report_hr_salary_employee_bymonth from . import payment_advice_report diff --git a/addons/l10n_in_hr_payroll/report/report_payslip_details.py b/addons/l10n_in_hr_payroll/report/report_payslip_details.py deleted file mode 100644 index 373ddd89ebb..00000000000 --- a/addons/l10n_in_hr_payroll/report/report_payslip_details.py +++ /dev/null @@ -1,20 +0,0 @@ -#-*- coding:utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, models - - -class PayslipDetailsReportIN(models.AbstractModel): - _name = 'report.l10n_in_hr_payroll.report_payslipdetails' - _inherit = 'report.hr_payroll.report_payslipdetails' - - @api.model - def _get_report_values(self, docids, data=None): - payslips = self.env['hr.payslip'].browse(docids) - return { - 'doc_ids': docids, - 'doc_model': 'hr.payslip', - 'docs': payslips, - 'data': data, - 'get_details_by_rule_category': self.get_details_by_rule_category(payslips.mapped('details_by_salary_rule_category')) - } From fbfd98a77971c85ae13057edb0369b40124d73d9 Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Tue, 27 Nov 2018 13:41:13 +0100 Subject: [PATCH 06/12] [IMP] hr_payroll: Group payslips by batch by default --- addons/hr_payroll/views/hr_payslip_views.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/hr_payroll/views/hr_payslip_views.xml b/addons/hr_payroll/views/hr_payslip_views.xml index 6cb0c62a4ec..3ebab026c52 100644 --- a/addons/hr_payroll/views/hr_payslip_views.xml +++ b/addons/hr_payroll/views/hr_payslip_views.xml @@ -263,7 +263,7 @@ - + @@ -276,6 +276,7 @@ form tree,kanban,form + {'search_default_group_by_batch': 1} From 3cc823a69043a50e5ef626286283699edd473ba8 Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Thu, 29 Nov 2018 12:26:40 +0100 Subject: [PATCH 07/12] [IMP] hr_payroll: Make 'contract_id' related on worked_days/input lines No need to display this information on every worked days/input lines It makes no sense to have a different contract on different payslip lines too. --- addons/hr_payroll/models/hr_payslip.py | 5 ++--- addons/hr_payroll/views/hr_payslip_views.xml | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index f6f6bf39521..e8f549a6412 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -156,7 +156,6 @@ class HrPayslip(models.Model): '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) @@ -407,7 +406,7 @@ class HrPayslipWorkedDays(models.Model): code = fields.Char(required=True, help="The code that can be used in the salary rules") number_of_days = fields.Float(string='Number of Days') number_of_hours = fields.Float(string='Number of Hours') - contract_id = fields.Many2one('hr.contract', string='Contract', required=True, + contract_id = fields.Many2one(related='payslip_id.contract_id', string='Contract', required=True, help="The contract for which applied this worked days") @@ -423,7 +422,7 @@ class HrPayslipInput(models.Model): amount = fields.Float(help="It is used in computation. For e.g. A rule for sales having " "1% commission of basic salary for per product can defined in expression " "like result = inputs.SALEURO.amount * contract.wage*0.01.") - contract_id = fields.Many2one('hr.contract', string='Contract', required=True, + contract_id = fields.Many2one(related='payslip_id.contract_id', string='Contract', required=True, help="The contract for which applied this input") diff --git a/addons/hr_payroll/views/hr_payslip_views.xml b/addons/hr_payroll/views/hr_payslip_views.xml index 3ebab026c52..780f1af5fee 100644 --- a/addons/hr_payroll/views/hr_payslip_views.xml +++ b/addons/hr_payroll/views/hr_payslip_views.xml @@ -168,7 +168,7 @@ - +
@@ -188,7 +188,7 @@ - + From 9bab1ed2afb92bad5bcc26762507275dec5bea9b Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Thu, 29 Nov 2018 12:27:50 +0100 Subject: [PATCH 08/12] [IMP] hr_payroll: Improve payslip creation/validation flow Avoid to display the buttons on each stage (compute sheet, cancel, ...). Now the next action is clear on the payslip. --- addons/hr_payroll/models/hr_payslip.py | 2 +- addons/hr_payroll/views/hr_payslip_views.xml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index e8f549a6412..af1750a19c9 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -129,7 +129,7 @@ class HrPayslip(models.Model): 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)] - payslip.write({'line_ids': lines, 'number': number}) + payslip.write({'line_ids': lines, 'number': number, 'state': 'verify'}) return True @api.model diff --git a/addons/hr_payroll/views/hr_payslip_views.xml b/addons/hr_payroll/views/hr_payslip_views.xml index 780f1af5fee..4ae92d55b42 100644 --- a/addons/hr_payroll/views/hr_payslip_views.xml +++ b/addons/hr_payroll/views/hr_payslip_views.xml @@ -128,12 +128,12 @@
-
From f29d9dfe2e6f5bb835574097f6be7f613eea3fe6 Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Thu, 29 Nov 2018 12:39:13 +0100 Subject: [PATCH 09/12] [IMP] hr_payroll: Put single payslips in a batch. When creating a payslip, if no batch is defined for it, put it automatically in a new batch. --- addons/hr_payroll/models/hr_payslip.py | 13 +++++++++++++ .../tests/test_hr_payroll_account.py | 1 + 2 files changed, 14 insertions(+) diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index af1750a19c9..bc088388cf6 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -112,6 +112,19 @@ class HrPayslip(models.Model): 'context': {} } + @api.model + def create(self, vals): + res = super(HrPayslip, self).create(vals) + if not res.payslip_run_id: + self.env['hr.payslip.run'].create({ + 'name': res.name, + 'date_start': res.date_from, + 'date_end': res.date_to, + 'slip_ids': [(4, res.id)], + 'state': 'close', + }) + return res + @api.multi def unlink(self): if any(self.filtered(lambda payslip: payslip.state not in ('draft', 'cancel'))): 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 202a42db0fc..2d4be4829ba 100644 --- a/addons/hr_payroll_account/tests/test_hr_payroll_account.py +++ b/addons/hr_payroll_account/tests/test_hr_payroll_account.py @@ -74,6 +74,7 @@ class TestHrPayrollAccount(common.TransactionCase): self.hr_payslip = self.env['hr.payslip'].create({ 'employee_id': self.hr_employee_john.id, 'journal_id': self.ref('hr_payroll_account.expenses_journal'), + 'name': 'Test Payslip', }) def test_00_hr_payslip(self): From 023e4e27fd94f11dfd94fa857b90ac5d2b292da9 Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Thu, 29 Nov 2018 12:39:40 +0100 Subject: [PATCH 10/12] [IMP] hr_payroll: Hide credit_note on payslip views This field should be set by making a refund, it should be available for an end user. --- addons/hr_payroll/views/hr_payslip_views.xml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/addons/hr_payroll/views/hr_payslip_views.xml b/addons/hr_payroll/views/hr_payslip_views.xml index 4ae92d55b42..40920592369 100644 --- a/addons/hr_payroll/views/hr_payslip_views.xml +++ b/addons/hr_payroll/views/hr_payslip_views.xml @@ -157,7 +157,7 @@ - + @@ -255,12 +255,14 @@ hr.payslip + + - - + + From 28a1b546e9ec27ccd395a10dde193d36c9b078e8 Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Thu, 29 Nov 2018 13:14:09 +0100 Subject: [PATCH 11/12] [IMP] hr_payroll: Improve payslip batch generation flow Same than for payslips. Improve the flow to make it clear what is the next action instead of displaying all the buttons. And refactor a little the code to be more efficient, and reuse existing method instead of using this horrible 'onchange_employee_id' method. --- addons/hr_payroll/models/hr_payslip.py | 75 +++++-------------- addons/hr_payroll/views/hr_payslip_views.xml | 21 +++--- .../hr_payroll_payslips_by_employees.py | 65 ++++++++-------- ...hr_payroll_payslips_by_employees_views.xml | 7 +- .../tests/test_hr_payroll_account.py | 16 +--- .../tests/test_payment_advice_batch.py | 2 +- 6 files changed, 66 insertions(+), 120 deletions(-) diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index bc088388cf6..5a990a4f99b 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -255,63 +255,6 @@ class HrPayslip(models.Model): return list(result_dict.values()) - # YTI TODO To rename. This method is not really an onchange, as it is not in any view - # employee_id and contract_id could be browse records. Contract_id should be a record - @api.multi - def onchange_employee_id(self, date_from, date_to, employee_id=False, contract_id=False): - #defaults - res = { - 'value': { - 'line_ids': [], - #delete old input lines - 'input_line_ids': [(2, x,) for x in self.input_line_ids.ids], - #delete old worked days lines - 'worked_days_line_ids': [(2, x,) for x in self.worked_days_line_ids.ids], - #'details_by_salary_head':[], TODO put me back - 'name': '', - 'contract_id': False, - 'struct_id': False, - } - } - if (not employee_id) or (not date_from) or (not date_to): - return res - ttyme = datetime.combine(fields.Date.from_string(date_from), time.min) - employee = self.env['hr.employee'].browse(employee_id) - locale = self.env.context.get('lang') or 'en_US' - res['value'].update({ - 'name': _('Salary Slip of %s for %s') % (employee.name, tools.ustr(babel.dates.format_date(date=ttyme, format='MMMM-y', locale=locale))), - 'company_id': employee.company_id.id, - }) - - contracts = self.env['hr.contract'] - if not self.env.context.get('contract'): - #fill with the first contract of the employee - contracts = employee._get_contracts(date_from, date_to) - else: - if contract_id: - #set the list of contract for which the worked days have to be filled - contracts = self.env['hr.contract'].browse(contract_id) - else: - #if we don't give the contract, then the worked days to fill should be for all current contracts of the employee - contracts = employee._get_contracts(date_from, date_to) - - if not contracts: - return res - contract = contracts[0] - res['value'].update({ - 'contract_id': contract.id - }) - struct = contract.struct_id - if not struct: - return res - res['value'].update({ - 'struct_id': struct.id, - }) - #computation of the salary worked days - worked_days_line_ids = self.get_worked_day_lines(contracts, date_from, date_to) - res['value'].update({'worked_days_line_ids': worked_days_line_ids}) - return res - @api.onchange('employee_id', 'date_from', 'date_to') def onchange_employee(self): @@ -448,7 +391,8 @@ class HrPayslipRun(models.Model): states={'draft': [('readonly', False)]}) state = fields.Selection([ ('draft', 'Draft'), - ('close', 'Close'), + ('verify', 'Verify'), + ('close', 'Done'), ], string='Status', index=True, readonly=True, copy=False, default='draft') date_start = fields.Date(string='Date From', required=True, readonly=True, states={'draft': [('readonly', False)]}, default=lambda self: fields.Date.to_string(date.today().replace(day=1))) @@ -458,6 +402,11 @@ class HrPayslipRun(models.Model): credit_note = fields.Boolean(string='Credit Note', readonly=True, states={'draft': [('readonly', False)]}, help="If its checked, indicates that all payslips generated from here are refund payslips.") + payslip_count = fields.Integer(compute='_compute_payslip_count') + + def _compute_payslip_count(self): + for payslip_run in self: + payslip_run.payslip_count = len(self.slip_ids) @api.multi def draft_payslip_run(self): @@ -467,3 +416,13 @@ class HrPayslipRun(models.Model): def close_payslip_run(self): self.mapped('slip_ids').filtered(lambda payslip: payslip.state != 'done').action_payslip_done() return self.write({'state': 'close'}) + + def action_open_payslips(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": "hr.payslip", + "views": [[False, "tree"], [False, "form"]], + "domain": [['id', 'in', self.slip_ids.ids]], + "name": "Payslips", + } diff --git a/addons/hr_payroll/views/hr_payslip_views.xml b/addons/hr_payroll/views/hr_payslip_views.xml index 40920592369..7fe8164662f 100644 --- a/addons/hr_payroll/views/hr_payslip_views.xml +++ b/addons/hr_payroll/views/hr_payslip_views.xml @@ -360,12 +360,20 @@
-
+
+ +
- - - - - - - - -
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 8a986015c8b..63f89d746fa 100644 --- a/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py +++ b/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py @@ -18,53 +18,48 @@ class HrPayslipEmployees(models.TransientModel): 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()) - + default=lambda self: self._get_employees(), required=True) @api.multi def compute_sheet(self): - payslips = self.env['hr.payslip'] - [data] = self.read() - 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') - else: + self.ensure_one() + if not self.env.context.get('active_id'): 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') + end_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, + 'date_end': end_date, }) + else: + payslip_run = self.env['hr.payslip.run'].browse(self.env.context.get('active_id')) - active_id = payslip_run.id - [run_data] = payslip_run.read(['date_start', 'date_end', 'credit_note']) - - if not data['employee_ids']: + if not self.employee_ids: raise UserError(_("You must select employee(s) to generate payslip(s).")) - for employee in self.env['hr.employee'].browse(data['employee_ids']): - slip_data = self.env['hr.payslip'].onchange_employee_id(from_date, to_date, employee.id, contract_id=False) - res = { - 'employee_id': employee.id, - 'name': slip_data['value'].get('name'), - 'struct_id': slip_data['value'].get('struct_id'), - 'contract_id': slip_data['value'].get('contract_id'), - 'payslip_run_id': active_id, - 'input_line_ids': [(0, 0, x) for x in slip_data['value'].get('input_line_ids')], - 'worked_days_line_ids': [(0, 0, x) for x in slip_data['value'].get('worked_days_line_ids')], - 'date_from': from_date, - 'date_to': to_date, - 'credit_note': run_data.get('credit_note'), - 'company_id': employee.company_id.id, - } - payslips += self.env['hr.payslip'].create(res) - payslips.compute_sheet() - return {'type': 'ir.actions.act_window', + payslips = self.env['hr.payslip'] + Payslip = self.env['hr.payslip'] + for employee in self.employee_ids: + 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, + }) + payslip = self.env['hr.payslip'].new(values) + payslip.onchange_employee() + payslip.onchange_contract() + values = payslip._convert_to_write(payslip._cache) + payslips += Payslip.create(values) + payslips.compute_sheet() + payslip_run.state = 'verify' + + return { + 'type': 'ir.actions.act_window', 'res_model': 'hr.payslip.run', 'view_type': 'form', 'views': [[False, 'form']], - 'res_id': active_id, + 'res_id': payslip_run.id, } diff --git a/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees_views.xml b/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees_views.xml index 64417d67a50..3f8c8e075c4 100644 --- a/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees_views.xml +++ b/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees_views.xml @@ -6,9 +6,6 @@ hr.payslip.employees
-
-
This wizard will generate payslips for all selected employee(s) based on the dates and credit note specified on Payslips Run. @@ -17,6 +14,10 @@ +
+
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 2d4be4829ba..ea1a08e6f04 100644 --- a/addons/hr_payroll_account/tests/test_hr_payroll_account.py +++ b/addons/hr_payroll_account/tests/test_hr_payroll_account.py @@ -80,18 +80,10 @@ class TestHrPayrollAccount(common.TransactionCase): def test_00_hr_payslip(self): """ checking the process of payslip. """ - date_from = time.strftime('%Y-%m-01') - date_to = str(datetime.now() + relativedelta.relativedelta(months=+1, day=1, days=-1))[:10] - res = self.hr_payslip.onchange_employee_id(date_from, date_to, self.hr_employee_john.id) - vals = { - 'struct_id': res['value']['struct_id'], - 'contract_id': res['value']['contract_id'], - 'name': res['value']['name'], - } - vals['worked_days_line_ids'] = [(0, 0, i) for i in res['value']['worked_days_line_ids']] - vals['input_line_ids'] = [(0, 0, i) for i in res['value']['input_line_ids']] - vals.update({'contract_id': self.hr_contract_john.id}) - self.hr_payslip.write(vals) + 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() # I assign the amount to Input data. payslip_input = self.env['hr.payslip.input'].search([('payslip_id', '=', self.hr_payslip.id)]) diff --git a/addons/l10n_in_hr_payroll/tests/test_payment_advice_batch.py b/addons/l10n_in_hr_payroll/tests/test_payment_advice_batch.py index a833ac67631..43703dadf53 100644 --- a/addons/l10n_in_hr_payroll/tests/test_payment_advice_batch.py +++ b/addons/l10n_in_hr_payroll/tests/test_payment_advice_batch.py @@ -21,7 +21,7 @@ class TestPaymentAdviceBatch(TestPaymentAdviceBase): payslip_employee.with_context(active_id=payslip_run.id).compute_sheet() # I check that the Payslip Batch is in "Draft" - self.assertEqual(payslip_run.state, 'draft') + self.assertEqual(payslip_run.state, 'verify') # Now I close Payslip Batch payslip_run.write({'state': 'close'}) From d63b5599d9919b41834750f33e7645eced7b469f Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Fri, 30 Nov 2018 10:22:44 +0100 Subject: [PATCH 12/12] [IMP] hr_payroll: Make benefit state editable on status bar --- addons/hr_payroll/models/hr_benefit.py | 36 +++++--------------- addons/hr_payroll/models/hr_employee.py | 1 + addons/hr_payroll/models/hr_leave.py | 2 ++ addons/hr_payroll/views/hr_benefit_views.xml | 4 +-- 4 files changed, 12 insertions(+), 31 deletions(-) diff --git a/addons/hr_payroll/models/hr_benefit.py b/addons/hr_payroll/models/hr_benefit.py index ec27e044d59..89b2731f6be 100644 --- a/addons/hr_payroll/models/hr_benefit.py +++ b/addons/hr_payroll/models/hr_benefit.py @@ -36,12 +36,6 @@ class HrBenefit(models.Model): ('_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. """ @@ -73,6 +67,14 @@ class HrBenefit(models.Model): if benefit.date_start and benefit.duration: benefit.date_stop = benefit.date_start + relativedelta(hours=benefit.duration) + def write(self, vals): + if 'state' in vals: + if vals['state'] == 'draft': + vals['active'] = True + if vals['state'] == 'cancelled': + vals['active'] = False + return super(HrBenefit, self).write(vals) + @api.multi def check_if_error(self): if not self: @@ -157,28 +159,6 @@ class HrBenefit(models.Model): '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. diff --git a/addons/hr_payroll/models/hr_employee.py b/addons/hr_payroll/models/hr_employee.py index 161fd5414aa..4570063ad91 100644 --- a/addons/hr_payroll/models/hr_employee.py +++ b/addons/hr_payroll/models/hr_employee.py @@ -58,6 +58,7 @@ class HrEmployee(models.Model): 'date_stop': interval[1].astimezone(pytz.utc), 'benefit_type_id': benefit_type_id.id, 'employee_id': employee.id, + 'state': 'confirmed', }) 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 index 4a7c4cdc252..6440b0d1ee5 100644 --- a/addons/hr_payroll/models/hr_leave.py +++ b/addons/hr_payroll/models/hr_leave.py @@ -24,6 +24,7 @@ class HrLeave(models.Model): 'benefit_type_id': benefit_type.id, 'employee_id': leave.employee_id.id, 'leave_id': self.id, + 'state': 'confirmed', }) @api.multi @@ -52,6 +53,7 @@ class HrLeave(models.Model): 'date_stop': benefit_stop, 'benefit_type_id': benefit_type.id, 'employee_id': employee.id, + 'state': 'confirmed', }) benefits.unlink() diff --git a/addons/hr_payroll/views/hr_benefit_views.xml b/addons/hr_payroll/views/hr_benefit_views.xml index d187ebded06..a6f30e416f7 100644 --- a/addons/hr_payroll/views/hr_benefit_views.xml +++ b/addons/hr_payroll/views/hr_benefit_views.xml @@ -67,10 +67,8 @@ if records:
-