diff --git a/hr_payroll_timesheet/__init__.py b/hr_payroll_timesheet/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/hr_payroll_timesheet/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/hr_payroll_timesheet/__manifest__.py b/hr_payroll_timesheet/__manifest__.py new file mode 100755 index 00000000..ebdaba8c --- /dev/null +++ b/hr_payroll_timesheet/__manifest__.py @@ -0,0 +1,19 @@ +{ + 'name': 'Timesheets on Payslips', + 'description': 'Get Timesheet hours onto Employee Payslips.', + '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 new file mode 100644 index 00000000..6683136f --- /dev/null +++ b/hr_payroll_timesheet/models/__init__.py @@ -0,0 +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_contract.py b/hr_payroll_timesheet/models/hr_contract.py new file mode 100644 index 00000000..d0ac4d1d --- /dev/null +++ b/hr_payroll_timesheet/models/hr_contract.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class HrContract(models.Model): + _inherit = 'hr.contract' + + paid_hourly_timesheet = fields.Boolean(string="Paid Hourly Timesheet", default=False) diff --git a/hr_payroll_timesheet/models/hr_payslip.py b/hr_payroll_timesheet/models/hr_payslip.py new file mode 100644 index 00000000..b0f506ed --- /dev/null +++ b/hr_payroll_timesheet/models/hr_payslip.py @@ -0,0 +1,88 @@ +from collections import defaultdict +from odoo import api, fields, models, _ + + +class HrPayslip(models.Model): + _inherit = 'hr.payslip' + + 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') + + @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 + + @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 + + 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 + + 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() + 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 + + def action_open_timesheets(self): + self.ensure_one() + 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/__init__.py b/hr_payroll_timesheet/tests/__init__.py new file mode 100755 index 00000000..1345fb8f --- /dev/null +++ b/hr_payroll_timesheet/tests/__init__.py @@ -0,0 +1 @@ +from . import test_payslip_timesheet diff --git a/hr_payroll_timesheet/tests/test_payslip_timesheet.py b/hr_payroll_timesheet/tests/test_payslip_timesheet.py new file mode 100644 index 00000000..416ab852 --- /dev/null +++ b/hr_payroll_timesheet/tests/test_payslip_timesheet.py @@ -0,0 +1,127 @@ +from odoo.tests import common + + +class TestPayslipTimesheet(common.TransactionCase): + + def setUp(self): + super(TestPayslipTimesheet, self).setUp() + self.test_hourly_wage = 21.5 + self.employee = self.env.ref('hr.employee_hne') + self.contract = self.env['hr.contract'].create({ + 'name': 'Test', + 'employee_id': self.employee.id, + 'structure_type_id': self.env.ref('hr_payroll.structure_type_employee').id, + 'date_start': '2018-01-01', + 'resource_calendar_id': self.employee.resource_calendar_id.id, + 'wage': self.test_hourly_wage, + 'paid_hourly_timesheet': True, + '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', + }) + + def test_payslip_timesheet(self): + self.assertTrue(self.contract.paid_hourly_timesheet) + + # Day 1 + self.env['account.analytic.line'].create({ + 'employee_id': self.employee.id, + 'project_id': self.project.id, + 'date': '2018-01-01', + 'unit_amount': 5.0, + 'name': 'test', + }) + self.env['account.analytic.line'].create({ + 'employee_id': self.employee.id, + 'project_id': self.project.id, + 'date': '2018-01-01', + 'unit_amount': 3.0, + 'name': 'test', + }) + + # Day 2 + self.env['account.analytic.line'].create({ + 'employee_id': self.employee.id, + 'project_id': self.project.id, + 'date': '2018-01-02', + 'unit_amount': 10.0, + 'name': 'test', + }) + + # Make one that should be excluded. + self.env['account.analytic.line'].create({ + 'employee_id': self.employee.id, + 'project_id': self.project.id, + 'date': '2017-01-01', + 'unit_amount': 5.0, + 'name': 'test', + 'payslip_id': self.payslip_dummy.id, + }) + + 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 = 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, 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_contract_view.xml b/hr_payroll_timesheet/views/hr_contract_view.xml new file mode 100755 index 00000000..e3e06705 --- /dev/null +++ b/hr_payroll_timesheet/views/hr_contract_view.xml @@ -0,0 +1,19 @@ + + + + hr.contract.form.inherit + hr.contract + + + + + + + + / pay period + / hour + + + + + 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 + + + + + + + + + + + +