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..e3603436 --- /dev/null +++ b/hr_payroll_timesheet/__manifest__.py @@ -0,0 +1,16 @@ +{ + 'name': 'Timesheets on Payslips', + 'description': 'Get Timesheet hours onto Employee Payslips.', + 'version': '12.0.1.0.0', + 'website': 'https://hibou.io/', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Human Resources', + 'data': [ + 'views/hr_contract_view.xml', + ], + 'depends': [ + 'hr_payroll', + 'hr_timesheet', + ], +} diff --git a/hr_payroll_timesheet/models/__init__.py b/hr_payroll_timesheet/models/__init__.py new file mode 100644 index 00000000..9199a523 --- /dev/null +++ b/hr_payroll_timesheet/models/__init__.py @@ -0,0 +1,2 @@ +from . import hr_contract +from . import hr_payslip 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..7df4cdff --- /dev/null +++ b/hr_payroll_timesheet/models/hr_payslip.py @@ -0,0 +1,95 @@ +from collections import defaultdict +from odoo import api, 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) + + res = super(HrPayslip, self).get_worked_day_lines(contracts.filtered(lambda c: not c.paid_hourly_timesheet), date_from, date_to) + res.extend(work) + 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, + } + + 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), + ] + + days = set() + for ts in self.env['account.analytic.line'].search(valid_ts): + 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 + + 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. + """ + 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 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..571b6b20 --- /dev/null +++ b/hr_payroll_timesheet/tests/test_payslip_timesheet.py @@ -0,0 +1,91 @@ +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.contract = self.env['hr.contract'].create({ + '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, + 'date_start': '2018-01-01', + 'state': 'open', + 'paid_hourly_timesheet': True, + 'schedule_pay': 'monthly', + }) + self.project = self.env['project.project'].create({ + 'name': 'Timesheets', + }) + + 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({ + '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': 1.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', + }) + + # 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) + + timesheet_line = 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) 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_holidays/__init__.py b/hr_payroll_timesheet_holidays/__init__.py new file mode 100755 index 00000000..5d988fa2 --- /dev/null +++ b/hr_payroll_timesheet_holidays/__init__.py @@ -0,0 +1 @@ +from . import hr_payslip diff --git a/hr_payroll_timesheet_holidays/__manifest__.py b/hr_payroll_timesheet_holidays/__manifest__.py new file mode 100755 index 00000000..36a88163 --- /dev/null +++ b/hr_payroll_timesheet_holidays/__manifest__.py @@ -0,0 +1,15 @@ +{ + 'name': 'Payroll Timesheet Holidays', + 'author': 'Hibou Corp. ', + 'version': '12.0.1.0.0', + 'category': 'Human Resources', + 'sequence': 95, + 'summary': 'Holiday Pay', + 'description': """ +Simplifies getting approved Holiday Leaves onto an employee Payslip. + """, + 'website': 'https://hibou.io/', + 'depends': ['hr_payroll_timesheet', 'hr_holidays'], + 'installable': True, + 'application': False, +} diff --git a/hr_payroll_timesheet_holidays/hr_payslip.py b/hr_payroll_timesheet_holidays/hr_payslip.py new file mode 100755 index 00000000..474811ee --- /dev/null +++ b/hr_payroll_timesheet_holidays/hr_payslip.py @@ -0,0 +1,52 @@ +from odoo import models, api +from odoo.addons.resource.models.resource import HOURS_PER_DAY + + +class HrPayslip(models.Model): + _inherit = 'hr.payslip' + + @api.model + def get_worked_day_lines(self, contracts, date_from, date_to): + leaves = {} + + for contract in contracts.filtered(lambda c: c.paid_hourly_timesheet): + for leave in self._fetch_valid_leaves_timesheet(contract.employee_id.id, date_from, date_to): + leave_code = self._create_leave_code(leave.holiday_status_id.name) + if leave_code in leaves: + leaves[leave_code]['number_of_days'] += leave.number_of_days + leaves[leave_code]['number_of_hours'] += leave.number_of_days * HOURS_PER_DAY + else: + leaves[leave_code] = { + 'name': leave.holiday_status_id.name, + 'sequence': 15, + 'code': leave_code, + 'number_of_days': leave.number_of_days, + 'number_of_hours': leave.number_of_days * HOURS_PER_DAY, + 'contract_id': contract.id, + } + + res = super(HrPayslip, self).get_worked_day_lines(contracts, date_from, date_to) + res.extend(leaves.values()) + return res + + @api.multi + def action_payslip_done(self): + for slip in self.filtered(lambda s: s.contract_id.paid_hourly_timesheet): + leaves = self._fetch_valid_leaves_timesheet(slip.employee_id.id, slip.date_from, slip.date_to) + leaves.write({'payslip_status': True}) + return super(HrPayslip, self).action_payslip_done() + + def _fetch_valid_leaves_timesheet(self, employee_id, date_from, date_to): + valid_leaves = [ + ('employee_id', '=', employee_id), + ('state', '=', 'validate'), + ('date_from', '>=', date_from), + ('date_from', '<=', date_to), + ('payslip_status', '=', False), + ('holiday_status_id.unpaid', '=', False), + ] + + return self.env['hr.leave'].search(valid_leaves) + + def _create_leave_code(self, name): + return 'L_' + name.replace(' ', '_')