diff --git a/addons/hr_payroll/__manifest__.py b/addons/hr_payroll/__manifest__.py index 73bb35e4a66..5483d60de8c 100644 --- a/addons/hr_payroll/__manifest__.py +++ b/addons/hr_payroll/__manifest__.py @@ -27,7 +27,12 @@ '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', + '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..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 - - - @@ -146,6 +123,7 @@ Default contract for marketing executives + open @@ -159,4 +137,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..89b2731f6be --- /dev/null +++ b/addons/hr_payroll/models/hr_benefit.py @@ -0,0 +1,286 @@ +# -*- 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.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) + + 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: + 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']], + } + + 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..4570063ad91 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,47 @@ 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, + '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 new file mode 100644 index 00000000000..6440b0d1ee5 --- /dev/null +++ b/addons/hr_payroll/models/hr_leave.py @@ -0,0 +1,73 @@ +# -*- 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, + 'state': 'confirmed', + }) + + @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, + 'state': 'confirmed', + }) + 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..5a990a4f99b 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: @@ -119,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'))): @@ -136,14 +142,14 @@ 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 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 @@ -151,63 +157,23 @@ 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, + } + res.append(line) - res.append(attendances) - res.extend(leaves.values()) 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 @@ -289,67 +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 input 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 - 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 input - 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, - }) - return res - @api.onchange('employee_id', 'date_from', 'date_to') def onchange_employee(self): @@ -376,18 +281,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') @@ -431,7 +331,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') @@ -462,8 +362,8 @@ 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, - help="The contract for which applied this input") + contract_id = fields.Many2one(related='payslip_id.contract_id', string='Contract', required=True, + help="The contract for which applied this worked days") class HrPayslipInput(models.Model): @@ -478,7 +378,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") @@ -491,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))) @@ -501,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): @@ -510,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/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/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/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/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..484551f5cc4 100644 --- a/addons/hr_payroll/security/ir.model.access.csv +++ b/addons/hr_payroll/security/ir.model.access.csv @@ -8,7 +8,8 @@ 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 +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/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/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/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_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..a6f30e416f7 --- /dev/null +++ b/addons/hr_payroll/views/hr_benefit_views.xml @@ -0,0 +1,163 @@ + + + + + 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_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 4970b637716..7fe8164662f 100644 --- a/addons/hr_payroll/views/hr_payslip_views.xml +++ b/addons/hr_payroll/views/hr_payslip_views.xml @@ -128,12 +128,12 @@
-
@@ -157,7 +157,7 @@ - + @@ -168,7 +168,7 @@ - + @@ -188,7 +188,7 @@ - + @@ -230,17 +230,7 @@ - - - - - - - - - - - + @@ -253,7 +243,7 @@
-
+
@@ -265,15 +255,17 @@ hr.payslip + + - - + + - + @@ -286,6 +278,7 @@ form tree,kanban,form + {'search_default_group_by_batch': 1} @@ -367,12 +360,20 @@
-
+
+ +
- -
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/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/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..63f89d746fa 100644 --- a/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py +++ b/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py @@ -3,45 +3,63 @@ 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(), 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') - if not data['employee_ids']: + 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') + 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': end_date, + }) + else: + payslip_run = self.env['hr.payslip.run'].browse(self.env.context.get('active_id')) + + 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 = { + + 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, - '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) + '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() - return {'type': 'ir.actions.act_window_close'} + payslip_run.state = 'verify' + + return { + 'type': 'ir.actions.act_window', + 'res_model': 'hr.payslip.run', + 'view_type': 'form', + 'views': [[False, 'form']], + '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/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/hr_payroll_account/tests/test_hr_payroll_account.py b/addons/hr_payroll_account/tests/test_hr_payroll_account.py index f0b01a54198..ea1a08e6f04 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') ])], }) @@ -75,23 +74,16 @@ 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): """ 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/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() 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 - - 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')) - } 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'}) 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): """