From 13e0005a8bef686f0daeed8613ebf0651a13f66f Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Mon, 30 Apr 2018 08:15:31 -0700 Subject: [PATCH 01/10] Adding all from 11.0 https://github.com/hibou-io/odoo-hr-payroll hr_payroll_holidays hr_payroll_input_name_report hr_payroll_input_report hr_payroll_payment hr_payroll_timesheet hr_payslip_line_date l10n_us_fl_hr_payroll l10n_us_hr_payroll l10n_us_mo_hr_payroll l10n_us_oh_hr_payroll l10n_us_va_hr_payroll --- hr_payroll_timesheet/__init__.py | 3 ++ hr_payroll_timesheet/__manifest__.py | 18 ++++++++ hr_payroll_timesheet/hr_contract.py | 8 ++++ hr_payroll_timesheet/hr_contract_view.xml | 18 ++++++++ hr_payroll_timesheet/hr_payslip.py | 50 +++++++++++++++++++++++ 5 files changed, 97 insertions(+) create mode 100755 hr_payroll_timesheet/__init__.py create mode 100755 hr_payroll_timesheet/__manifest__.py create mode 100755 hr_payroll_timesheet/hr_contract.py create mode 100755 hr_payroll_timesheet/hr_contract_view.xml create mode 100755 hr_payroll_timesheet/hr_payslip.py diff --git a/hr_payroll_timesheet/__init__.py b/hr_payroll_timesheet/__init__.py new file mode 100755 index 00000000..4d15389f --- /dev/null +++ b/hr_payroll_timesheet/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import hr_payslip +from . import hr_contract diff --git a/hr_payroll_timesheet/__manifest__.py b/hr_payroll_timesheet/__manifest__.py new file mode 100755 index 00000000..c3a9eae3 --- /dev/null +++ b/hr_payroll_timesheet/__manifest__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +{ + 'name': 'Timesheets on Payslips', + 'description': 'Get Timesheet and Attendence numbers onto Employee Payslips.', + 'version': '11.0.0.0.0', + 'website': 'https://hibou.io/', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Human Resources', + 'data': [ + 'hr_contract_view.xml', + ], + 'depends': [ + 'hr_payroll', + 'hr_timesheet_attendance', + ], +} diff --git a/hr_payroll_timesheet/hr_contract.py b/hr_payroll_timesheet/hr_contract.py new file mode 100755 index 00000000..f2be76d4 --- /dev/null +++ b/hr_payroll_timesheet/hr_contract.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields + + +class HrContract(models.Model): + _inherit = 'hr.contract' + + paid_hourly = fields.Boolean(string="Paid Hourly", default=False) diff --git a/hr_payroll_timesheet/hr_contract_view.xml b/hr_payroll_timesheet/hr_contract_view.xml new file mode 100755 index 00000000..abd831f5 --- /dev/null +++ b/hr_payroll_timesheet/hr_contract_view.xml @@ -0,0 +1,18 @@ + + + + + hr.contract.form.inherit + hr.contract + 20 + + + + + + + + + + + diff --git a/hr_payroll_timesheet/hr_payslip.py b/hr_payroll_timesheet/hr_payslip.py new file mode 100755 index 00000000..5d09c267 --- /dev/null +++ b/hr_payroll_timesheet/hr_payslip.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +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): + def create_empty_worked_lines(employee_id, contract_id, date_from, date_to): + attendance = { + 'name': 'Timesheet Attendance', + 'sequence': 10, + 'code': 'ATTN', + 'number_of_days': 0.0, + 'number_of_hours': 0.0, + 'contract_id': contract_id, + } + + valid_days = [ + ('sheet_id.employee_id', '=', employee_id), + ('sheet_id.state', '=', 'done'), + ('sheet_id.date_from', '>=', date_from), + ('sheet_id.date_to', '<=', date_to), + ] + return attendance, valid_days + + attendances = [] + + for contract in contracts: + attendance, valid_days = create_empty_worked_lines( + contract.employee_id.id, + contract.id, + date_from, + date_to + ) + + for day in self.env['hr_timesheet_sheet.sheet.day'].search(valid_days): + if day.total_attendance >= 0.0: + attendance['number_of_days'] += 1 + attendance['number_of_hours'] += day.total_attendance + + # needed so that the shown hours matches any calculations you use them for + attendance['number_of_hours'] = round(attendance['number_of_hours'], 2) + attendances.append(attendance) + + res = super(HrPayslip, self).get_worked_day_lines(contracts, date_from, date_to) + res.extend(attendances) + return res + From c00fc654740721d28ea55e512b467c47b9cbb583 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Wed, 9 May 2018 16:01:49 -0700 Subject: [PATCH 02/10] Proper migration to 11.0, and implementation of 'overtime rules' breakdown of hours/weeks. --- hr_payroll_timesheet/hr_contract_view.xml | 2 +- hr_payroll_timesheet/hr_payslip.py | 91 ++++++++++++++++------- 2 files changed, 66 insertions(+), 27 deletions(-) diff --git a/hr_payroll_timesheet/hr_contract_view.xml b/hr_payroll_timesheet/hr_contract_view.xml index abd831f5..6b8edde4 100755 --- a/hr_payroll_timesheet/hr_contract_view.xml +++ b/hr_payroll_timesheet/hr_contract_view.xml @@ -8,7 +8,7 @@ - + diff --git a/hr_payroll_timesheet/hr_payslip.py b/hr_payroll_timesheet/hr_payslip.py index 5d09c267..b320495b 100755 --- a/hr_payroll_timesheet/hr_payslip.py +++ b/hr_payroll_timesheet/hr_payslip.py @@ -1,5 +1,8 @@ -# -*- coding: utf-8 -*- +from datetime import datetime +from collections import defaultdict from odoo import api, models +from odoo.exceptions import ValidationError +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT class HrPayslip(models.Model): @@ -7,44 +10,80 @@ class HrPayslip(models.Model): @api.model def get_worked_day_lines(self, contracts, date_from, date_to): - def create_empty_worked_lines(employee_id, contract_id, date_from, date_to): - attendance = { - 'name': 'Timesheet Attendance', + def create_empty_worked_lines(employee, contract, date_from, date_to): + attn = { + 'name': 'Attendance', 'sequence': 10, 'code': 'ATTN', 'number_of_days': 0.0, 'number_of_hours': 0.0, - 'contract_id': contract_id, + 'contract_id': contract.id, } - valid_days = [ - ('sheet_id.employee_id', '=', employee_id), - ('sheet_id.state', '=', 'done'), - ('sheet_id.date_from', '>=', date_from), - ('sheet_id.date_to', '<=', date_to), + valid_attn = [ + ('employee_id', '=', employee.id), + ('check_in', '>=', date_from), + ('check_in', '<=', date_to), ] - return attendance, valid_days - - attendances = [] + return attn, valid_attn + work = [] for contract in contracts: - attendance, valid_days = create_empty_worked_lines( - contract.employee_id.id, - contract.id, + worked_attn, valid_attn = create_empty_worked_lines( + contract.employee_id, + contract, date_from, date_to ) - - for day in self.env['hr_timesheet_sheet.sheet.day'].search(valid_days): - if day.total_attendance >= 0.0: - attendance['number_of_days'] += 1 - attendance['number_of_hours'] += day.total_attendance - - # needed so that the shown hours matches any calculations you use them for - attendance['number_of_hours'] = round(attendance['number_of_hours'], 2) - attendances.append(attendance) + days = set() + for attn in self.env['hr.attendance'].search(valid_attn): + if not attn.check_out: + raise ValidationError('This pay period must not have any open attendances.') + if attn.worked_hours: + # Avoid in/outs + attn_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT) + attn_iso = attn_start_time.isocalendar() + if not attn_iso in days: + worked_attn['number_of_days'] += 1 + days.add(attn_iso) + worked_attn['number_of_hours'] += attn.worked_hours + worked_attn['number_of_hours'] = round(worked_attn['number_of_hours'], 2) + work.append(worked_attn) res = super(HrPayslip, self).get_worked_day_lines(contracts, date_from, date_to) - res.extend(attendances) + res.extend(work) return res + @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 == 'ATTN': + attns = self.env['hr.attendance'].search([ + ('employee_id', '=', self.employee_id.id), + ('check_in', '>=', self.date_from), + ('check_in', '<=', self.date_to), + ]) + day_values = defaultdict(float) + for attn in attns: + if not attn.check_out: + raise ValidationError('This pay period must not have any open attendances.') + if attn.worked_hours: + # Avoid in/outs + attn_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT) + attn_iso = attn_start_time.isocalendar() + day_values[attn_iso] += attn.worked_hours + return day_values + elif hasattr(super(HrPayslip, self), 'hours'): + return super(HrPayslip, self).hours(code) + + @api.multi + def hours_break_down_week(self, code): + days = self.hour_break_down(code) + weeks = defaultdict(float) + for isoday, hours in days.items(): + weeks[isoday[1]] += hours + return weeks From b9808524b6ad5b8bdfdff7c6c0ba3c09f37f98ea Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Wed, 31 Oct 2018 09:58:09 -0700 Subject: [PATCH 03/10] Initial commit of *new* `hr_payroll_timesheet` and `hr_payroll_timesheet_old` for 11.0 --- hr_payroll_timesheet/__init__.py | 4 +- hr_payroll_timesheet/__manifest__.py | 10 +- hr_payroll_timesheet/hr_contract.py | 8 -- hr_payroll_timesheet/hr_contract_view.xml | 18 ---- hr_payroll_timesheet/hr_payslip.py | 89 ----------------- hr_payroll_timesheet/models/__init__.py | 2 + hr_payroll_timesheet/models/hr_contract.py | 7 ++ hr_payroll_timesheet/models/hr_payslip.py | 99 +++++++++++++++++++ hr_payroll_timesheet/tests/__init__.py | 1 + .../tests/test_payslip_timesheet.py | 87 ++++++++++++++++ .../views/hr_contract_view.xml | 19 ++++ 11 files changed, 220 insertions(+), 124 deletions(-) mode change 100755 => 100644 hr_payroll_timesheet/__init__.py delete mode 100755 hr_payroll_timesheet/hr_contract.py delete mode 100755 hr_payroll_timesheet/hr_contract_view.xml delete mode 100755 hr_payroll_timesheet/hr_payslip.py create mode 100644 hr_payroll_timesheet/models/__init__.py create mode 100644 hr_payroll_timesheet/models/hr_contract.py create mode 100644 hr_payroll_timesheet/models/hr_payslip.py create mode 100755 hr_payroll_timesheet/tests/__init__.py create mode 100644 hr_payroll_timesheet/tests/test_payslip_timesheet.py create mode 100755 hr_payroll_timesheet/views/hr_contract_view.xml diff --git a/hr_payroll_timesheet/__init__.py b/hr_payroll_timesheet/__init__.py old mode 100755 new mode 100644 index 4d15389f..0650744f --- a/hr_payroll_timesheet/__init__.py +++ b/hr_payroll_timesheet/__init__.py @@ -1,3 +1 @@ -# -*- coding: utf-8 -*- -from . import hr_payslip -from . import hr_contract +from . import models diff --git a/hr_payroll_timesheet/__manifest__.py b/hr_payroll_timesheet/__manifest__.py index c3a9eae3..7c842873 100755 --- a/hr_payroll_timesheet/__manifest__.py +++ b/hr_payroll_timesheet/__manifest__.py @@ -1,18 +1,16 @@ -# -*- coding: utf-8 -*- - { 'name': 'Timesheets on Payslips', - 'description': 'Get Timesheet and Attendence numbers onto Employee Payslips.', - 'version': '11.0.0.0.0', + 'description': 'Get Timesheet hours onto Employee Payslips.', + 'version': '11.0.1.0.0', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', 'license': 'AGPL-3', 'category': 'Human Resources', 'data': [ - 'hr_contract_view.xml', + 'views/hr_contract_view.xml', ], 'depends': [ 'hr_payroll', - 'hr_timesheet_attendance', + 'hr_timesheet', ], } diff --git a/hr_payroll_timesheet/hr_contract.py b/hr_payroll_timesheet/hr_contract.py deleted file mode 100755 index f2be76d4..00000000 --- a/hr_payroll_timesheet/hr_contract.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -from odoo import models, fields - - -class HrContract(models.Model): - _inherit = 'hr.contract' - - paid_hourly = fields.Boolean(string="Paid Hourly", default=False) diff --git a/hr_payroll_timesheet/hr_contract_view.xml b/hr_payroll_timesheet/hr_contract_view.xml deleted file mode 100755 index 6b8edde4..00000000 --- a/hr_payroll_timesheet/hr_contract_view.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - hr.contract.form.inherit - hr.contract - 20 - - - - - - - - - - - diff --git a/hr_payroll_timesheet/hr_payslip.py b/hr_payroll_timesheet/hr_payslip.py deleted file mode 100755 index b320495b..00000000 --- a/hr_payroll_timesheet/hr_payslip.py +++ /dev/null @@ -1,89 +0,0 @@ -from datetime import datetime -from collections import defaultdict -from odoo import api, models -from odoo.exceptions import ValidationError -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT - - -class HrPayslip(models.Model): - _inherit = 'hr.payslip' - - @api.model - def get_worked_day_lines(self, contracts, date_from, date_to): - def create_empty_worked_lines(employee, contract, date_from, date_to): - attn = { - 'name': 'Attendance', - 'sequence': 10, - 'code': 'ATTN', - 'number_of_days': 0.0, - 'number_of_hours': 0.0, - 'contract_id': contract.id, - } - - valid_attn = [ - ('employee_id', '=', employee.id), - ('check_in', '>=', date_from), - ('check_in', '<=', date_to), - ] - return attn, valid_attn - - work = [] - for contract in contracts: - worked_attn, valid_attn = create_empty_worked_lines( - contract.employee_id, - contract, - date_from, - date_to - ) - days = set() - for attn in self.env['hr.attendance'].search(valid_attn): - if not attn.check_out: - raise ValidationError('This pay period must not have any open attendances.') - if attn.worked_hours: - # Avoid in/outs - attn_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT) - attn_iso = attn_start_time.isocalendar() - if not attn_iso in days: - worked_attn['number_of_days'] += 1 - days.add(attn_iso) - worked_attn['number_of_hours'] += attn.worked_hours - worked_attn['number_of_hours'] = round(worked_attn['number_of_hours'], 2) - work.append(worked_attn) - - res = super(HrPayslip, self).get_worked_day_lines(contracts, date_from, date_to) - res.extend(work) - return res - - @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 == 'ATTN': - attns = self.env['hr.attendance'].search([ - ('employee_id', '=', self.employee_id.id), - ('check_in', '>=', self.date_from), - ('check_in', '<=', self.date_to), - ]) - day_values = defaultdict(float) - for attn in attns: - if not attn.check_out: - raise ValidationError('This pay period must not have any open attendances.') - if attn.worked_hours: - # Avoid in/outs - attn_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT) - attn_iso = attn_start_time.isocalendar() - day_values[attn_iso] += attn.worked_hours - return day_values - elif hasattr(super(HrPayslip, self), 'hours'): - return super(HrPayslip, self).hours(code) - - @api.multi - def hours_break_down_week(self, code): - 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/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..cd6299ba --- /dev/null +++ b/hr_payroll_timesheet/models/hr_payslip.py @@ -0,0 +1,99 @@ +from datetime import datetime +from collections import defaultdict +from odoo import api, models +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT + + +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_date = datetime.strptime(ts.date, DEFAULT_SERVER_DATE_FORMAT) + 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_date = datetime.strptime(ts.date, DEFAULT_SERVER_DATE_FORMAT) + 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..f19e7c3d --- /dev/null +++ b/hr_payroll_timesheet/tests/test_payslip_timesheet.py @@ -0,0 +1,87 @@ +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, + }) + self.env['account.analytic.line'].create({ + 'employee_id': self.employee.id, + 'project_id': self.project.id, + 'date': '2018-01-01', + 'unit_amount': 3.0, + }) + + # 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, + }) + + # 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, + }) + + # 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 + + + + + From 80613814ce9082e59dd8ca7f811bb3a48d1d43fd Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 12 Mar 2019 11:51:07 -0700 Subject: [PATCH 04/10] MIG `hr_payroll_timesheet` to 12.0 --- hr_payroll_timesheet/__manifest__.py | 2 +- hr_payroll_timesheet/models/hr_payslip.py | 8 ++------ hr_payroll_timesheet/tests/test_payslip_timesheet.py | 4 ++++ 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/hr_payroll_timesheet/__manifest__.py b/hr_payroll_timesheet/__manifest__.py index 7c842873..e3603436 100755 --- a/hr_payroll_timesheet/__manifest__.py +++ b/hr_payroll_timesheet/__manifest__.py @@ -1,7 +1,7 @@ { 'name': 'Timesheets on Payslips', 'description': 'Get Timesheet hours onto Employee Payslips.', - 'version': '11.0.1.0.0', + 'version': '12.0.1.0.0', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', 'license': 'AGPL-3', diff --git a/hr_payroll_timesheet/models/hr_payslip.py b/hr_payroll_timesheet/models/hr_payslip.py index cd6299ba..7df4cdff 100644 --- a/hr_payroll_timesheet/models/hr_payslip.py +++ b/hr_payroll_timesheet/models/hr_payslip.py @@ -1,7 +1,5 @@ -from datetime import datetime from collections import defaultdict from odoo import api, models -from odoo.tools import DEFAULT_SERVER_DATE_FORMAT class HrPayslip(models.Model): @@ -50,8 +48,7 @@ class HrPayslip(models.Model): days = set() for ts in self.env['account.analytic.line'].search(valid_ts): if ts.unit_amount: - ts_date = datetime.strptime(ts.date, DEFAULT_SERVER_DATE_FORMAT) - ts_iso = ts_date.isocalendar() + ts_iso = ts.date.isocalendar() if ts_iso not in days: values['number_of_days'] += 1 days.add(ts_iso) @@ -79,8 +76,7 @@ class HrPayslip(models.Model): day_values = defaultdict(float) for ts in timesheets: if ts.unit_amount: - ts_date = datetime.strptime(ts.date, DEFAULT_SERVER_DATE_FORMAT) - ts_iso = ts_date.isocalendar() + ts_iso = ts.date.isocalendar() day_values[ts_iso] += ts.unit_amount return day_values elif hasattr(super(HrPayslip, self), 'hour_break_down'): diff --git a/hr_payroll_timesheet/tests/test_payslip_timesheet.py b/hr_payroll_timesheet/tests/test_payslip_timesheet.py index f19e7c3d..571b6b20 100644 --- a/hr_payroll_timesheet/tests/test_payslip_timesheet.py +++ b/hr_payroll_timesheet/tests/test_payslip_timesheet.py @@ -40,12 +40,14 @@ class TestPayslipTimesheet(common.TransactionCase): '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 @@ -54,6 +56,7 @@ class TestPayslipTimesheet(common.TransactionCase): 'project_id': self.project.id, 'date': '2018-01-02', 'unit_amount': 1.0, + 'name': 'test', }) # Make one that should be excluded. @@ -62,6 +65,7 @@ class TestPayslipTimesheet(common.TransactionCase): 'project_id': self.project.id, 'date': '2017-01-01', 'unit_amount': 5.0, + 'name': 'test', }) # Create slip like a batch run. From f2d68a6c00f76540c8abf8197c4a96620ce9260e Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 7 Jul 2020 14:59:02 -0700 Subject: [PATCH 05/10] [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 + + + + + + + + + + + + From ae11a0e595536bd3f110370964dfc8a772fd341f Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Mon, 13 Jul 2020 16:04:48 -0700 Subject: [PATCH 06/10] [FIX] hr_payroll_timesheet: Unlink behavior on timesheet entries and remove "Work Calendar" work lines. --- hr_payroll_timesheet/models/account.py | 8 +- hr_payroll_timesheet/models/hr_payslip.py | 74 +++++++++---------- .../views/hr_payslip_views.xml | 2 +- 3 files changed, 45 insertions(+), 39 deletions(-) diff --git a/hr_payroll_timesheet/models/account.py b/hr_payroll_timesheet/models/account.py index 8e73681f..0b6721e3 100644 --- a/hr_payroll_timesheet/models/account.py +++ b/hr_payroll_timesheet/models/account.py @@ -4,4 +4,10 @@ from odoo import fields, models class AnalyticLine(models.Model): _inherit = 'account.analytic.line' - payslip_id = fields.Many2one('hr.payslip', string="Payslip", readonly=True) + payslip_id = fields.Many2one('hr.payslip', string="Payslip", readonly=True, ondelete='set null') + + def unlink(self): + ts_with_payslip = self.filtered(lambda ts: ts.payslip_id) + ts_with_payslip.write({'payslip_id': False}) + return super(AnalyticLine, self - ts_with_payslip).unlink() + diff --git a/hr_payroll_timesheet/models/hr_payslip.py b/hr_payroll_timesheet/models/hr_payslip.py index b0f506ed..c14d01bc 100644 --- a/hr_payroll_timesheet/models/hr_payslip.py +++ b/hr_payroll_timesheet/models/hr_payslip.py @@ -6,7 +6,7 @@ class HrPayslip(models.Model): _inherit = 'hr.payslip' timesheet_ids = fields.One2many('account.analytic.line', 'payslip_id', string='Timesheets', - help='Timesheets represented by payslip.', + help='Timesheets represented by payslip.', readonly=True, states={'draft': [('readonly', False)], 'verify': [('readonly', False)]}) timesheet_count = fields.Integer(compute='_compute_timesheet_count') @@ -15,53 +15,53 @@ class HrPayslip(models.Model): 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}) + def _get_worked_day_lines(self): + # Called at the end of _onchange_employee() + worked_day_lines = super()._get_worked_day_lines() + return self._timesheet_get_worked_day_lines(worked_day_lines) - @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 _timesheet_get_worked_day_lines(self, worked_day_lines): + """ + Filters out basic "Attendance"/"Work Calendar" entries as they would add to salary. + Note that this is during an onchange (probably). + :returns: a list of dict containing the worked days values that should be applied for the given payslip + """ + if not self.contract_id.paid_hourly_timesheet: + return worked_day_lines + if not self.state == 'draft': + return worked_day_lines + + timesheet_to_keep = self.timesheet_ids.filtered(lambda ts: ts.employee_id == self.employee_id + and ts.date <= self.date_to) + timesheet_to_keep += self.env['account.analytic.line'].search([ + ('employee_id', '=', self.employee_id.id), + ('date', '<=', self.date_to), + ('payslip_id', '=', False), + ]) + self.update({'timesheet_ids': [(6, 0, timesheet_to_keep.ids)]}) - @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 + # return early, include the "work calendar lines" + return worked_day_lines 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 + # filter out "work calendar lines" + worked_day_lines = [w for w in worked_day_lines if w['work_entry_type_id'] != original_work_type.id] 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}) + worked_day_lines += [{ + '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()] + + return worked_day_lines def _wage_for_work_type(self, work_type): # Override if you pay differently for different work types diff --git a/hr_payroll_timesheet/views/hr_payslip_views.xml b/hr_payroll_timesheet/views/hr_payslip_views.xml index e9730827..8b193a26 100644 --- a/hr_payroll_timesheet/views/hr_payslip_views.xml +++ b/hr_payroll_timesheet/views/hr_payslip_views.xml @@ -12,7 +12,7 @@ - + From e91aff862bd47b45d3c02fa2227a7e2f740571bc Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 16 Jul 2020 13:27:35 -0700 Subject: [PATCH 07/10] [FIX] hr_payroll_timesheet: repeated onchange in a single change will duplicate timesheet in a set until saved --- hr_payroll_timesheet/models/hr_payslip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hr_payroll_timesheet/models/hr_payslip.py b/hr_payroll_timesheet/models/hr_payslip.py index c14d01bc..a263a168 100644 --- a/hr_payroll_timesheet/models/hr_payslip.py +++ b/hr_payroll_timesheet/models/hr_payslip.py @@ -33,7 +33,7 @@ class HrPayslip(models.Model): timesheet_to_keep = self.timesheet_ids.filtered(lambda ts: ts.employee_id == self.employee_id and ts.date <= self.date_to) - timesheet_to_keep += self.env['account.analytic.line'].search([ + timesheet_to_keep |= self.env['account.analytic.line'].search([ ('employee_id', '=', self.employee_id.id), ('date', '<=', self.date_to), ('payslip_id', '=', False), From eb0ca4387d41e77cc4a3abd65f18073991d22b16 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 3 Sep 2020 12:54:18 -0700 Subject: [PATCH 08/10] [IMP] hr_payroll_timesheet: Migration script for 13 --- hr_payroll_timesheet/__init__.py | 18 ++++++++++++++++++ hr_payroll_timesheet/__manifest__.py | 1 + .../migrations/13.0.0.0.1/pre-migration.py | 6 ++++++ 3 files changed, 25 insertions(+) create mode 100644 hr_payroll_timesheet/migrations/13.0.0.0.1/pre-migration.py diff --git a/hr_payroll_timesheet/__init__.py b/hr_payroll_timesheet/__init__.py index 0650744f..a16151af 100644 --- a/hr_payroll_timesheet/__init__.py +++ b/hr_payroll_timesheet/__init__.py @@ -1 +1,19 @@ from . import models + + +def ts_payroll_pre_init_hook(cr): + """ + This module installs a Work Entry Type with code "TS" + If you have undergone a migration (either for this module + or even your own Payslip Work Entry lines with code "TS") + then the uniqueness constraint will prevent this module + from installing. + """ + cr.execute("UPDATE hr_work_entry_type " + "SET code = 'TS-PRE-INSTALL' " + "WHERE code = 'TS';" + ) + cr.execute("UPDATE hr_work_entry_type " + "SET code = 'TS_OT-PRE-INSTALL' " + "WHERE code = 'TS_OT';" + ) diff --git a/hr_payroll_timesheet/__manifest__.py b/hr_payroll_timesheet/__manifest__.py index ebdaba8c..cb8baa6f 100755 --- a/hr_payroll_timesheet/__manifest__.py +++ b/hr_payroll_timesheet/__manifest__.py @@ -16,4 +16,5 @@ 'hr_timesheet', 'hr_payroll_overtime', ], + 'pre_init_hook': 'ts_payroll_pre_init_hook', } diff --git a/hr_payroll_timesheet/migrations/13.0.0.0.1/pre-migration.py b/hr_payroll_timesheet/migrations/13.0.0.0.1/pre-migration.py new file mode 100644 index 00000000..30a761b3 --- /dev/null +++ b/hr_payroll_timesheet/migrations/13.0.0.0.1/pre-migration.py @@ -0,0 +1,6 @@ + +def migrate(cr, version): + # pre_init_hook script only runs on install, + # if you're coming from 12.0 we need the same change + from odoo.addons.hr_payroll_timesheet import ts_payroll_pre_init_hook + ts_payroll_pre_init_hook(cr) From fd32e01a0ea3bd59e69677d753a7d803b45a99f9 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 6 Oct 2020 11:53:45 -0700 Subject: [PATCH 09/10] [ADD] hr_timesheet_work_entry: for Odoo 13 --- hr_timesheet_work_entry/__init__.py | 15 ++++++++++ hr_timesheet_work_entry/__manifest__.py | 22 ++++++++++++++ .../data/hr_timesheet_work_entry_data.xml | 10 +++++++ .../data/hr_timesheet_work_entry_demo.xml | 10 +++++++ hr_timesheet_work_entry/models/__init__.py | 2 ++ hr_timesheet_work_entry/models/timesheet.py | 9 ++++++ hr_timesheet_work_entry/models/work_entry.py | 7 +++++ hr_timesheet_work_entry/tests/__init__.py | 1 + .../tests/test_timesheet_work_type.py | 19 ++++++++++++ .../views/timesheet_views.xml | 30 +++++++++++++++++++ .../views/work_entry_views.xml | 23 ++++++++++++++ 11 files changed, 148 insertions(+) create mode 100644 hr_timesheet_work_entry/__init__.py create mode 100755 hr_timesheet_work_entry/__manifest__.py create mode 100644 hr_timesheet_work_entry/data/hr_timesheet_work_entry_data.xml create mode 100644 hr_timesheet_work_entry/data/hr_timesheet_work_entry_demo.xml create mode 100644 hr_timesheet_work_entry/models/__init__.py create mode 100644 hr_timesheet_work_entry/models/timesheet.py create mode 100644 hr_timesheet_work_entry/models/work_entry.py create mode 100644 hr_timesheet_work_entry/tests/__init__.py create mode 100644 hr_timesheet_work_entry/tests/test_timesheet_work_type.py create mode 100644 hr_timesheet_work_entry/views/timesheet_views.xml create mode 100644 hr_timesheet_work_entry/views/work_entry_views.xml diff --git a/hr_timesheet_work_entry/__init__.py b/hr_timesheet_work_entry/__init__.py new file mode 100644 index 00000000..6e8e0aba --- /dev/null +++ b/hr_timesheet_work_entry/__init__.py @@ -0,0 +1,15 @@ +from . import models + + +def ts_work_type_pre_init_hook(cr): + """ + This module installs a Work Entry Type with code "TS" + If you have undergone a migration (either for this module + or even your own Payslip Work Entry lines with code "TS") + then the uniqueness constraint will prevent this module + from installing. + """ + cr.execute("UPDATE hr_work_entry_type " + "SET code = 'TS-PRE-INSTALL' " + "WHERE code = 'TS';" + ) diff --git a/hr_timesheet_work_entry/__manifest__.py b/hr_timesheet_work_entry/__manifest__.py new file mode 100755 index 00000000..142814bd --- /dev/null +++ b/hr_timesheet_work_entry/__manifest__.py @@ -0,0 +1,22 @@ +{ + 'name': 'Timesheet Work Entry Type', + 'description': 'Set work types on timesheet records.', + 'version': '13.0.1.0.0', + 'website': 'https://hibou.io/', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Human Resources', + 'depends': [ + 'hr_timesheet', + 'hr_work_entry', + ], + 'data': [ + 'data/hr_timesheet_work_entry_data.xml', + 'views/timesheet_views.xml', + 'views/work_entry_views.xml', + ], + 'demo': [ + 'data/hr_timesheet_work_entry_demo.xml', + ], + 'pre_init_hook': 'ts_work_type_pre_init_hook', +} diff --git a/hr_timesheet_work_entry/data/hr_timesheet_work_entry_data.xml b/hr_timesheet_work_entry/data/hr_timesheet_work_entry_data.xml new file mode 100644 index 00000000..cdcd2609 --- /dev/null +++ b/hr_timesheet_work_entry/data/hr_timesheet_work_entry_data.xml @@ -0,0 +1,10 @@ + + + + + Timesheet + TS + + + + \ No newline at end of file diff --git a/hr_timesheet_work_entry/data/hr_timesheet_work_entry_demo.xml b/hr_timesheet_work_entry/data/hr_timesheet_work_entry_demo.xml new file mode 100644 index 00000000..996dee41 --- /dev/null +++ b/hr_timesheet_work_entry/data/hr_timesheet_work_entry_demo.xml @@ -0,0 +1,10 @@ + + + + + Internal + TS_INTERNAL + + + + \ No newline at end of file diff --git a/hr_timesheet_work_entry/models/__init__.py b/hr_timesheet_work_entry/models/__init__.py new file mode 100644 index 00000000..1ea5c2f4 --- /dev/null +++ b/hr_timesheet_work_entry/models/__init__.py @@ -0,0 +1,2 @@ +from . import timesheet +from . import work_entry diff --git a/hr_timesheet_work_entry/models/timesheet.py b/hr_timesheet_work_entry/models/timesheet.py new file mode 100644 index 00000000..f7f3bb9d --- /dev/null +++ b/hr_timesheet_work_entry/models/timesheet.py @@ -0,0 +1,9 @@ +from odoo import api, fields, models + + +class AccountAnalyticLine(models.Model): + _inherit = 'account.analytic.line' + + work_type_id = fields.Many2one('hr.work.entry.type', string='Work Type', + default=lambda self: self.env.ref('hr_timesheet_work_entry.work_input_timesheet', + raise_if_not_found=False)) diff --git a/hr_timesheet_work_entry/models/work_entry.py b/hr_timesheet_work_entry/models/work_entry.py new file mode 100644 index 00000000..eb68353c --- /dev/null +++ b/hr_timesheet_work_entry/models/work_entry.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class HrWorkEntryType(models.Model): + _inherit = 'hr.work.entry.type' + + allow_timesheet = fields.Boolean(string='Allow on Timesheet') diff --git a/hr_timesheet_work_entry/tests/__init__.py b/hr_timesheet_work_entry/tests/__init__.py new file mode 100644 index 00000000..962f2f44 --- /dev/null +++ b/hr_timesheet_work_entry/tests/__init__.py @@ -0,0 +1 @@ +from . import test_timesheet_work_type diff --git a/hr_timesheet_work_entry/tests/test_timesheet_work_type.py b/hr_timesheet_work_entry/tests/test_timesheet_work_type.py new file mode 100644 index 00000000..f104e3c7 --- /dev/null +++ b/hr_timesheet_work_entry/tests/test_timesheet_work_type.py @@ -0,0 +1,19 @@ +from odoo.tests import common + + +class TestTimesheetWorkType(common.TransactionCase): + def setUp(self): + super().setUp() + self.employee = self.env.ref('hr.employee_hne') + self.project = self.env.ref('project.project_project_2') + self.default_work_type = self.env.ref('hr_timesheet_work_entry.work_input_timesheet') + + def test_01_work_type(self): + timesheet = self.env['account.analytic.line'].create({ + 'name': '/', + 'employee_id': self.employee.id, + 'unit_amount': 1.0, + 'project_id': self.project.id, + }) + self.assertTrue(timesheet.work_type_id) + self.assertEqual(timesheet.work_type_id, self.default_work_type) diff --git a/hr_timesheet_work_entry/views/timesheet_views.xml b/hr_timesheet_work_entry/views/timesheet_views.xml new file mode 100644 index 00000000..c6380521 --- /dev/null +++ b/hr_timesheet_work_entry/views/timesheet_views.xml @@ -0,0 +1,30 @@ + + + + + account.analytic.line.tree.hr_timesheet.inherit + account.analytic.line + + + + + + + + + + account.analytic.line.form.inherit + account.analytic.line + + + + + + + + + diff --git a/hr_timesheet_work_entry/views/work_entry_views.xml b/hr_timesheet_work_entry/views/work_entry_views.xml new file mode 100644 index 00000000..84f76a7a --- /dev/null +++ b/hr_timesheet_work_entry/views/work_entry_views.xml @@ -0,0 +1,23 @@ + + + + + hr.work.type.view.form.inherit + hr.work.entry.type + + + + + + + + + + + + + \ No newline at end of file From 6ec7c3f24c1ec7c47e2706ce473a5ec111ad183c Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 6 Oct 2020 12:14:49 -0700 Subject: [PATCH 10/10] [IMP] hr_payroll_timesheet: require `hr_timesheet_work_type` and use the timesheet's specififed work entry type --- hr_payroll_timesheet/__init__.py | 8 +-- hr_payroll_timesheet/__manifest__.py | 5 +- .../data/hr_payroll_timesheet_data.xml | 5 +- .../data/hr_payroll_timesheet_demo.xml | 10 ++++ hr_payroll_timesheet/models/hr_payslip.py | 20 ++++--- .../tests/test_payslip_timesheet.py | 56 +++++++++++++++++++ 6 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 hr_payroll_timesheet/data/hr_payroll_timesheet_demo.xml diff --git a/hr_payroll_timesheet/__init__.py b/hr_payroll_timesheet/__init__.py index a16151af..90502662 100644 --- a/hr_payroll_timesheet/__init__.py +++ b/hr_payroll_timesheet/__init__.py @@ -3,16 +3,12 @@ from . import models def ts_payroll_pre_init_hook(cr): """ - This module installs a Work Entry Type with code "TS" + This module installs a Work Entry Type with code "TS_OT" If you have undergone a migration (either for this module - or even your own Payslip Work Entry lines with code "TS") + or even your own Payslip Work Entry lines with code "TS_OT") then the uniqueness constraint will prevent this module from installing. """ - cr.execute("UPDATE hr_work_entry_type " - "SET code = 'TS-PRE-INSTALL' " - "WHERE code = 'TS';" - ) cr.execute("UPDATE hr_work_entry_type " "SET code = 'TS_OT-PRE-INSTALL' " "WHERE code = 'TS_OT';" diff --git a/hr_payroll_timesheet/__manifest__.py b/hr_payroll_timesheet/__manifest__.py index cb8baa6f..947bf38d 100755 --- a/hr_payroll_timesheet/__manifest__.py +++ b/hr_payroll_timesheet/__manifest__.py @@ -11,9 +11,12 @@ 'views/hr_contract_view.xml', 'views/hr_payslip_views.xml', ], + 'demo': [ + 'data/hr_payroll_timesheet_demo.xml', + ], 'depends': [ 'hr_payroll', - 'hr_timesheet', + 'hr_timesheet_work_entry', 'hr_payroll_overtime', ], 'pre_init_hook': 'ts_payroll_pre_init_hook', diff --git a/hr_payroll_timesheet/data/hr_payroll_timesheet_data.xml b/hr_payroll_timesheet/data/hr_payroll_timesheet_data.xml index eb684a08..69ae14c0 100644 --- a/hr_payroll_timesheet/data/hr_payroll_timesheet_data.xml +++ b/hr_payroll_timesheet/data/hr_payroll_timesheet_data.xml @@ -6,9 +6,8 @@ Timesheet Overtime TS_OT - - Timesheet - TS + + diff --git a/hr_payroll_timesheet/data/hr_payroll_timesheet_demo.xml b/hr_payroll_timesheet/data/hr_payroll_timesheet_demo.xml new file mode 100644 index 00000000..af096c6b --- /dev/null +++ b/hr_payroll_timesheet/data/hr_payroll_timesheet_demo.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/hr_payroll_timesheet/models/hr_payslip.py b/hr_payroll_timesheet/models/hr_payslip.py index a263a168..218d6e8a 100644 --- a/hr_payroll_timesheet/models/hr_payslip.py +++ b/hr_payroll_timesheet/models/hr_payslip.py @@ -28,7 +28,7 @@ class HrPayslip(models.Model): """ if not self.contract_id.paid_hourly_timesheet: return worked_day_lines - if not self.state == 'draft': + if not self.state in ('draft', 'verify'): return worked_day_lines timesheet_to_keep = self.timesheet_ids.filtered(lambda ts: ts.employee_id == self.employee_id @@ -40,17 +40,20 @@ class HrPayslip(models.Model): ]) self.update({'timesheet_ids': [(6, 0, timesheet_to_keep.ids)]}) - timesheet_type = self.env.ref('hr_payroll_timesheet.work_input_timesheet', raise_if_not_found=False) + timesheet_type = self.env.ref('hr_tiemsheet_work_entry.work_input_timesheet', raise_if_not_found=False) if not timesheet_type: - # return early, include the "work calendar lines" - return worked_day_lines + # different default type + timesheet_type = self.struct_id.type_id.default_work_entry_type_id + if not timesheet_type: + # return early, include the "work calendar lines" + return worked_day_lines original_work_type = self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False) if original_work_type: # filter out "work calendar lines" worked_day_lines = [w for w in worked_day_lines if w['work_entry_type_id'] != original_work_type.id] - work_data = self._pre_aggregate_timesheet_data() + work_data = self._pre_aggregate_timesheet_data(timesheet_type) processed_data = self.aggregate_overtime(work_data) worked_day_lines += [{ @@ -67,12 +70,15 @@ class HrPayslip(models.Model): # 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) + def _pre_aggregate_timesheet_data(self, default_workentrytype): worked_ts = defaultdict(list) for ts in self.timesheet_ids.sorted('id'): if ts.unit_amount: ts_iso = ts.date.isocalendar() + timesheet_type = ts.work_type_id or default_workentrytype + if timesheet_type in self.struct_id.unpaid_work_entry_type_ids: + # this is unpaid, so we have to skip it from aggregation + continue 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 diff --git a/hr_payroll_timesheet/tests/test_payslip_timesheet.py b/hr_payroll_timesheet/tests/test_payslip_timesheet.py index 416ab852..8e86f75a 100644 --- a/hr_payroll_timesheet/tests/test_payslip_timesheet.py +++ b/hr_payroll_timesheet/tests/test_payslip_timesheet.py @@ -125,3 +125,59 @@ class TestPayslipTimesheet(common.TransactionCase): 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) + + def test_payslip_timesheet_specific_work_entry_type(self): + self.assertTrue(self.contract.paid_hourly_timesheet) + worktype = self.env.ref('hr_timesheet_work_entry.work_input_timesheet_internal') + + # 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', + 'work_type_id': worktype.id, + }) + + # 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, 1.0) + self.assertEqual(timesheet_line.number_of_hours, 8.0) + + worktype_line = self.payslip.worked_days_line_ids.filtered(lambda l: l.code == worktype.code) + self.assertTrue(worktype_line) + self.assertEqual(worktype_line.number_of_days, 1.0) + self.assertEqual(worktype_line.number_of_hours, 10.0)