From b290fdb9b58ba636c2004b736cf04c788d44f6a8 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 7 Jul 2020 14:59:02 -0700 Subject: [PATCH] [MIG] hr_payroll_timesheet: to Odoo 13.0 + [ADD] Overtime calculations --- hr_payroll_timesheet/__manifest__.py | 5 +- .../data/hr_payroll_timesheet_data.xml | 16 ++ hr_payroll_timesheet/models/__init__.py | 1 + hr_payroll_timesheet/models/account.py | 7 + hr_payroll_timesheet/models/hr_payslip.py | 153 +++++++++--------- .../tests/test_payslip_timesheet.py | 108 ++++++++----- .../views/hr_payslip_views.xml | 20 +++ 7 files changed, 193 insertions(+), 117 deletions(-) create mode 100644 hr_payroll_timesheet/data/hr_payroll_timesheet_data.xml create mode 100644 hr_payroll_timesheet/models/account.py create mode 100644 hr_payroll_timesheet/views/hr_payslip_views.xml diff --git a/hr_payroll_timesheet/__manifest__.py b/hr_payroll_timesheet/__manifest__.py index e3603436..ebdaba8c 100755 --- a/hr_payroll_timesheet/__manifest__.py +++ b/hr_payroll_timesheet/__manifest__.py @@ -1,16 +1,19 @@ { 'name': 'Timesheets on Payslips', 'description': 'Get Timesheet hours onto Employee Payslips.', - 'version': '12.0.1.0.0', + 'version': '13.0.1.0.0', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', 'license': 'AGPL-3', 'category': 'Human Resources', 'data': [ + 'data/hr_payroll_timesheet_data.xml', 'views/hr_contract_view.xml', + 'views/hr_payslip_views.xml', ], 'depends': [ 'hr_payroll', 'hr_timesheet', + 'hr_payroll_overtime', ], } diff --git a/hr_payroll_timesheet/data/hr_payroll_timesheet_data.xml b/hr_payroll_timesheet/data/hr_payroll_timesheet_data.xml new file mode 100644 index 00000000..eb684a08 --- /dev/null +++ b/hr_payroll_timesheet/data/hr_payroll_timesheet_data.xml @@ -0,0 +1,16 @@ + + + + + + Timesheet Overtime + TS_OT + + + Timesheet + TS + + + + + diff --git a/hr_payroll_timesheet/models/__init__.py b/hr_payroll_timesheet/models/__init__.py index 9199a523..6683136f 100644 --- a/hr_payroll_timesheet/models/__init__.py +++ b/hr_payroll_timesheet/models/__init__.py @@ -1,2 +1,3 @@ +from . import account from . import hr_contract from . import hr_payslip diff --git a/hr_payroll_timesheet/models/account.py b/hr_payroll_timesheet/models/account.py new file mode 100644 index 00000000..8e73681f --- /dev/null +++ b/hr_payroll_timesheet/models/account.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class AnalyticLine(models.Model): + _inherit = 'account.analytic.line' + + payslip_id = fields.Many2one('hr.payslip', string="Payslip", readonly=True) diff --git a/hr_payroll_timesheet/models/hr_payslip.py b/hr_payroll_timesheet/models/hr_payslip.py index 7df4cdff..b0f506ed 100644 --- a/hr_payroll_timesheet/models/hr_payslip.py +++ b/hr_payroll_timesheet/models/hr_payslip.py @@ -1,95 +1,88 @@ from collections import defaultdict -from odoo import api, models +from odoo import api, fields, models, _ class HrPayslip(models.Model): _inherit = 'hr.payslip' - @api.model - def get_worked_day_lines(self, contracts, date_from, date_to): - work = [] - for contract in contracts.filtered(lambda c: c.paid_hourly_timesheet): - # Only run on 'paid hourly timesheet' contracts. - res = self._get_worked_day_lines_hourly_timesheet(contract, date_from, date_to) - if res: - work.append(res) + timesheet_ids = fields.One2many('account.analytic.line', 'payslip_id', string='Timesheets', + help='Timesheets represented by payslip.', + states={'draft': [('readonly', False)], 'verify': [('readonly', False)]}) + timesheet_count = fields.Integer(compute='_compute_timesheet_count') - res = super(HrPayslip, self).get_worked_day_lines(contracts.filtered(lambda c: not c.paid_hourly_timesheet), date_from, date_to) - res.extend(work) + @api.depends('timesheet_ids', 'timesheet_ids.payslip_id') + def _compute_timesheet_count(self): + for payslip in self: + payslip.timesheet_count = len(payslip.timesheet_ids) + + @api.onchange('worked_days_line_ids') + def _onchange_worked_days_line_ids(self): + # super()._onchange_worked_days_line_ids() + timesheet_type = self.env.ref('hr_payroll_timesheet.work_input_timesheet', raise_if_not_found=False) + if not self.worked_days_line_ids.filtered(lambda line: line.work_entry_type_id == timesheet_type): + self.timesheet_ids.write({'payslip_id': False}) + + @api.onchange('employee_id', 'struct_id', 'contract_id', 'date_from', 'date_to') + def _onchange_employee(self): + res = super()._onchange_employee() + if self.state == 'draft' and self.contract_id.paid_hourly_timesheet: + self.timesheet_ids = self.env['account.analytic.line'].search([ + ('employee_id', '=', self.employee_id.id), + ('date', '<=', self.date_to), + '|', ('payslip_id', '=', False), + ('payslip_id', '=', self.id), + ]) + self._onchange_timesheet_ids() return res - def _get_worked_day_lines_hourly_timesheet(self, contract, date_from, date_to): - """ - This would be a common hook to extend or break out more functionality, like pay rate based on project. - Note that you will likely need to aggregate similarly in hour_break_down() and hour_break_down_week() - :param contract: `hr.contract` - :param date_from: str - :param date_to: str - :return: dict of values for `hr.payslip.worked_days` - """ - values = { - 'name': 'Timesheet', - 'sequence': 15, - 'code': 'TS', - 'number_of_days': 0.0, - 'number_of_hours': 0.0, - 'contract_id': contract.id, - } + @api.onchange('timesheet_ids') + def _onchange_timesheet_ids(self): + timesheet_type = self.env.ref('hr_payroll_timesheet.work_input_timesheet', raise_if_not_found=False) + if not timesheet_type: + return - valid_ts = [ - # ('is_timesheet', '=', True), - # 'is_timesheet' is computed if there is a project_id associated with the entry - ('project_id', '!=', False), - ('employee_id', '=', contract.employee_id.id), - ('date', '>=', date_from), - ('date', '<=', date_to), - ] + original_work_type = self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False) + if original_work_type: + types_to_remove = original_work_type + timesheet_type + else: + types_to_remove = timesheet_type - days = set() - for ts in self.env['account.analytic.line'].search(valid_ts): + work_data = self._pre_aggregate_timesheet_data() + processed_data = self.aggregate_overtime(work_data) + + lines_to_keep = self.worked_days_line_ids.filtered(lambda x: x.work_entry_type_id not in types_to_remove) + # Note that [(5, 0, 0)] + [(4, 999, 0)], will not work + work_lines_vals = [(3, line.id, False) for line in (self.worked_days_line_ids - lines_to_keep)] + work_lines_vals += [(4, line.id, False) for line in lines_to_keep] + work_lines_vals += [(0, 0, { + 'number_of_days': data[0], + 'number_of_hours': data[1], + 'amount': data[1] * data[2] * self._wage_for_work_type(work_type), + 'contract_id': self.contract_id.id, + 'work_entry_type_id': work_type.id, + }) for work_type, data in processed_data.items()] + self.update({'worked_days_line_ids': work_lines_vals}) + + def _wage_for_work_type(self, work_type): + # Override if you pay differently for different work types + return self.contract_id.wage + + def _pre_aggregate_timesheet_data(self): + timesheet_type = self.env.ref('hr_payroll_timesheet.work_input_timesheet', raise_if_not_found=False) + worked_ts = defaultdict(list) + for ts in self.timesheet_ids.sorted('id'): if ts.unit_amount: ts_iso = ts.date.isocalendar() - if ts_iso not in days: - values['number_of_days'] += 1 - days.add(ts_iso) - values['number_of_hours'] += ts.unit_amount + worked_ts[ts_iso].append((timesheet_type, ts.unit_amount, ts)) + res = [(k, worked_ts[k]) for k in sorted(worked_ts.keys())] + return res - values['number_of_hours'] = round(values['number_of_hours'], 2) - return values - - @api.multi - def hour_break_down(self, code): - """ - :param code: what kind of worked days you need aggregated - :return: dict: keys are isocalendar tuples, values are hours. - """ + def action_open_timesheets(self): self.ensure_one() - if code == 'TS': - timesheets = self.env['account.analytic.line'].search([ - # ('is_timesheet', '=', True), - # 'is_timesheet' is computed if there is a project_id associated with the entry - ('project_id', '!=', False), - ('employee_id', '=', self.employee_id.id), - ('date', '>=', self.date_from), - ('date', '<=', self.date_to), - ]) - day_values = defaultdict(float) - for ts in timesheets: - if ts.unit_amount: - ts_iso = ts.date.isocalendar() - day_values[ts_iso] += ts.unit_amount - return day_values - elif hasattr(super(HrPayslip, self), 'hour_break_down'): - return super(HrPayslip, self).hour_break_down(code) - - @api.multi - def hours_break_down_week(self, code): - """ - :param code: hat kind of worked days you need aggregated - :return: dict: keys are isocalendar weeks, values are hours. - """ - days = self.hour_break_down(code) - weeks = defaultdict(float) - for isoday, hours in days.items(): - weeks[isoday[1]] += hours - return weeks + return { + 'type': 'ir.actions.act_window', + 'name': _('Paid Timesheets'), + 'res_model': 'account.analytic.line', + 'view_mode': 'tree,form', + 'domain': [('id', 'in', self.timesheet_ids.ids)], + } diff --git a/hr_payroll_timesheet/tests/test_payslip_timesheet.py b/hr_payroll_timesheet/tests/test_payslip_timesheet.py index 571b6b20..416ab852 100644 --- a/hr_payroll_timesheet/tests/test_payslip_timesheet.py +++ b/hr_payroll_timesheet/tests/test_payslip_timesheet.py @@ -1,29 +1,33 @@ from odoo.tests import common -from odoo import fields class TestPayslipTimesheet(common.TransactionCase): def setUp(self): super(TestPayslipTimesheet, self).setUp() - self.employee = self.env['hr.employee'].create({ - 'birthday': '1985-03-14', - 'country_id': self.ref('base.us'), - 'department_id': self.ref('hr.dep_rd'), - 'gender': 'male', - 'name': 'Jared' - }) + self.test_hourly_wage = 21.5 + self.employee = self.env.ref('hr.employee_hne') self.contract = self.env['hr.contract'].create({ - 'name': 'test', + 'name': 'Test', 'employee_id': self.employee.id, - 'type_id': self.ref('hr_contract.hr_contract_type_emp'), - 'struct_id': self.ref('hr_payroll.structure_base'), - 'resource_calendar_id': self.ref('resource.resource_calendar_std'), - 'wage': 21.50, + 'structure_type_id': self.env.ref('hr_payroll.structure_type_employee').id, 'date_start': '2018-01-01', - 'state': 'open', + 'resource_calendar_id': self.employee.resource_calendar_id.id, + 'wage': self.test_hourly_wage, 'paid_hourly_timesheet': True, - 'schedule_pay': 'monthly', + 'state': 'open', + }) + self.payslip_dummy = self.env['hr.payslip'].create({ + 'name': 'test slip dummy', + 'employee_id': self.employee.id, + 'date_from': '2017-01-01', + 'date_to': '2017-01-31', + }) + self.payslip = self.env['hr.payslip'].create({ + 'name': 'test slip', + 'employee_id': self.employee.id, + 'date_from': '2018-01-01', + 'date_to': '2018-01-31', }) self.project = self.env['project.project'].create({ 'name': 'Timesheets', @@ -31,8 +35,6 @@ class TestPayslipTimesheet(common.TransactionCase): def test_payslip_timesheet(self): self.assertTrue(self.contract.paid_hourly_timesheet) - from_date = '2018-01-01' - to_date = '2018-01-31' # Day 1 self.env['account.analytic.line'].create({ @@ -55,7 +57,7 @@ class TestPayslipTimesheet(common.TransactionCase): 'employee_id': self.employee.id, 'project_id': self.project.id, 'date': '2018-01-02', - 'unit_amount': 1.0, + 'unit_amount': 10.0, 'name': 'test', }) @@ -66,26 +68,60 @@ class TestPayslipTimesheet(common.TransactionCase): 'date': '2017-01-01', 'unit_amount': 5.0, 'name': 'test', + 'payslip_id': self.payslip_dummy.id, }) - # Create slip like a batch run. - slip_data = self.env['hr.payslip'].onchange_employee_id(from_date, to_date, self.employee.id, contract_id=False) - res = { - 'employee_id': self.employee.id, - 'name': slip_data['value'].get('name'), - 'struct_id': slip_data['value'].get('struct_id'), - 'contract_id': slip_data['value'].get('contract_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, - 'company_id': self.employee.company_id.id, - } - payslip = self.env['hr.payslip'].create(res) - payslip.compute_sheet() - self.assertTrue(payslip.worked_days_line_ids) + self.payslip._onchange_employee() + self.assertTrue(self.payslip.contract_id, 'No auto-discovered contract!') + wage = self.test_hourly_wage + self.payslip.compute_sheet() + self.assertTrue(self.payslip.worked_days_line_ids) - timesheet_line = payslip.worked_days_line_ids.filtered(lambda l: l.code == 'TS') + timesheet_line = self.payslip.worked_days_line_ids.filtered(lambda l: l.code == 'TS') self.assertTrue(timesheet_line) self.assertEqual(timesheet_line.number_of_days, 2.0) - self.assertEqual(timesheet_line.number_of_hours, 9.0) + self.assertEqual(timesheet_line.number_of_hours, 18.0) + + # Day 3 + self.env['account.analytic.line'].create({ + 'employee_id': self.employee.id, + 'project_id': self.project.id, + 'date': '2018-01-03', + 'unit_amount': 10.0, + 'name': 'test', + }) + # Day 4 + self.env['account.analytic.line'].create({ + 'employee_id': self.employee.id, + 'project_id': self.project.id, + 'date': '2018-01-04', + 'unit_amount': 10.0, + 'name': 'test', + }) + # Day 5 + self.env['account.analytic.line'].create({ + 'employee_id': self.employee.id, + 'project_id': self.project.id, + 'date': '2018-01-05', + 'unit_amount': 10.0, + 'name': 'test', + }) + # Day 6 + self.env['account.analytic.line'].create({ + 'employee_id': self.employee.id, + 'project_id': self.project.id, + 'date': '2018-01-06', + 'unit_amount': 4.0, + 'name': 'test', + }) + + self.payslip.state = 'draft' + self.payslip._onchange_employee() + timesheet_line = self.payslip.worked_days_line_ids.filtered(lambda l: l.code == 'TS') + timesheet_overtime_line = self.payslip.worked_days_line_ids.filtered(lambda l: l.code == 'TS_OT') + self.assertTrue(timesheet_line) + self.assertEqual(timesheet_line.number_of_days, 5.0) + self.assertEqual(timesheet_line.number_of_hours, 40.0) + self.assertTrue(timesheet_overtime_line) + self.assertEqual(timesheet_overtime_line.number_of_days, 1.0) + self.assertEqual(timesheet_overtime_line.number_of_hours, 12.0) diff --git a/hr_payroll_timesheet/views/hr_payslip_views.xml b/hr_payroll_timesheet/views/hr_payslip_views.xml new file mode 100644 index 00000000..e9730827 --- /dev/null +++ b/hr_payroll_timesheet/views/hr_payslip_views.xml @@ -0,0 +1,20 @@ + + + + + hr.payslip.view.form.inherit + hr.payslip + + + + + + + + + + + +