From ca0bf33ee4a7fdb86ecebf55f2604ad4894c5d98 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Mon, 30 Apr 2018 08:15:31 -0700 Subject: [PATCH 01/13] 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 f59359c0b6a305b4cd3918cedc629154b2724cdf Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Wed, 9 May 2018 16:01:49 -0700 Subject: [PATCH 02/13] 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 d1a849688f6f3a6dce608b8cef59317f6552a535 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Wed, 31 Oct 2018 09:58:09 -0700 Subject: [PATCH 03/13] 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 e3371efb3e6a3ed931075e6c8e6679b00df0709a Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 12 Mar 2019 11:51:07 -0700 Subject: [PATCH 04/13] 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 b290fdb9b58ba636c2004b736cf04c788d44f6a8 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 7 Jul 2020 14:59:02 -0700 Subject: [PATCH 05/13] [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 c2b8442de839e6e37d5809cfa2624396d0b6d759 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Mon, 13 Jul 2020 16:04:48 -0700 Subject: [PATCH 06/13] [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 addfd391e6c1be1e0a2c0fbfb19a7ba125668078 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 16 Jul 2020 13:27:35 -0700 Subject: [PATCH 07/13] [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 54747eefff393463bb8ea85331e05c6a1b790dc5 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 3 Sep 2020 12:54:18 -0700 Subject: [PATCH 08/13] [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 1289bbc5b0833cf8625fb994fe49255b8877eb10 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 6 Oct 2020 12:14:49 -0700 Subject: [PATCH 09/13] [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) From 2ff56a9050d518dc5fcaaa3e3335f395f5a4eebe Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 26 Nov 2020 04:01:28 -0800 Subject: [PATCH 10/13] [IMP] hr_payroll_timesheet: normalize 'amount' on leave lines, require `hibou_professional` --- hr_payroll_timesheet/__manifest__.py | 7 +++- .../migrations/13.0.0.0.1/pre-migration.py | 1 + hr_payroll_timesheet/models/account.py | 2 + hr_payroll_timesheet/models/hr_contract.py | 2 + hr_payroll_timesheet/models/hr_payslip.py | 15 +++++++ .../tests/test_payslip_timesheet.py | 41 +++++++++++++++++++ 6 files changed, 66 insertions(+), 2 deletions(-) diff --git a/hr_payroll_timesheet/__manifest__.py b/hr_payroll_timesheet/__manifest__.py index 947bf38d..8adbd81b 100755 --- a/hr_payroll_timesheet/__manifest__.py +++ b/hr_payroll_timesheet/__manifest__.py @@ -1,10 +1,12 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + { 'name': 'Timesheets on Payslips', 'description': 'Get Timesheet hours onto Employee Payslips.', - 'version': '13.0.1.0.0', + 'version': '13.0.1.0.1', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', - 'license': 'AGPL-3', + 'license': 'OPL-1', 'category': 'Human Resources', 'data': [ 'data/hr_payroll_timesheet_data.xml', @@ -18,6 +20,7 @@ 'hr_payroll', 'hr_timesheet_work_entry', 'hr_payroll_overtime', + 'hibou_professional', ], '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 index 30a761b3..85e132be 100644 --- 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 @@ -1,3 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. def migrate(cr, version): # pre_init_hook script only runs on install, diff --git a/hr_payroll_timesheet/models/account.py b/hr_payroll_timesheet/models/account.py index 0b6721e3..249cdba3 100644 --- a/hr_payroll_timesheet/models/account.py +++ b/hr_payroll_timesheet/models/account.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from odoo import fields, models diff --git a/hr_payroll_timesheet/models/hr_contract.py b/hr_payroll_timesheet/models/hr_contract.py index d0ac4d1d..423b04ac 100644 --- a/hr_payroll_timesheet/models/hr_contract.py +++ b/hr_payroll_timesheet/models/hr_contract.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from odoo import fields, models diff --git a/hr_payroll_timesheet/models/hr_payslip.py b/hr_payroll_timesheet/models/hr_payslip.py index 218d6e8a..8568cd43 100644 --- a/hr_payroll_timesheet/models/hr_payslip.py +++ b/hr_payroll_timesheet/models/hr_payslip.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from collections import defaultdict from odoo import api, fields, models, _ @@ -53,6 +55,9 @@ class HrPayslip(models.Model): # 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] + # normalize leaves + self._timesheet_normalize_other_work_lines(worked_day_lines) + work_data = self._pre_aggregate_timesheet_data(timesheet_type) processed_data = self.aggregate_overtime(work_data) @@ -66,6 +71,16 @@ class HrPayslip(models.Model): return worked_day_lines + def _timesheet_normalize_other_work_lines(self, worked_day_line_values): + # Modifies the values based on 'wage' + unpaid_work_entry_types = self.struct_id.unpaid_work_entry_type_ids + for line_vals in worked_day_line_values: + work_type = self.env['hr.work.entry.type'].browse(line_vals['work_entry_type_id']) + if work_type not in unpaid_work_entry_types: + line_vals['amount'] = line_vals.get('number_of_hours', 0.0) * self._wage_for_work_type(work_type) + else: + line_vals['amount'] = 0.0 + def _wage_for_work_type(self, work_type): # Override if you pay differently for different work types return self.contract_id.wage diff --git a/hr_payroll_timesheet/tests/test_payslip_timesheet.py b/hr_payroll_timesheet/tests/test_payslip_timesheet.py index 8e86f75a..862f1a7a 100644 --- a/hr_payroll_timesheet/tests/test_payslip_timesheet.py +++ b/hr_payroll_timesheet/tests/test_payslip_timesheet.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from odoo.tests import common @@ -32,6 +34,19 @@ class TestPayslipTimesheet(common.TransactionCase): self.project = self.env['project.project'].create({ 'name': 'Timesheets', }) + self.work_entry_type_leave = self.env['hr.work.entry.type'].create({ + 'name': 'Test PTO', + 'code': 'TESTPTO', + 'is_leave': True, + }) + self.leave_type = self.env['hr.leave.type'].create({ + 'name': 'Test Paid Time Off', + 'time_type': 'leave', + 'allocation_type': 'no', + 'validity_start': False, + 'work_entry_type_id': self.work_entry_type_leave.id, + }) + def test_payslip_timesheet(self): self.assertTrue(self.contract.paid_hourly_timesheet) @@ -81,6 +96,7 @@ class TestPayslipTimesheet(common.TransactionCase): self.assertTrue(timesheet_line) self.assertEqual(timesheet_line.number_of_days, 2.0) self.assertEqual(timesheet_line.number_of_hours, 18.0) + self.assertEqual(timesheet_line.amount, 18.0 * wage) # Day 3 self.env['account.analytic.line'].create({ @@ -181,3 +197,28 @@ class TestPayslipTimesheet(common.TransactionCase): self.assertTrue(worktype_line) self.assertEqual(worktype_line.number_of_days, 1.0) self.assertEqual(worktype_line.number_of_hours, 10.0) + + + def test_with_leave(self): + date_from = '2018-01-10' + date_to = '2018-01-11' + leave = self.env['hr.leave'].create({ + 'name': 'Test Leave', + 'employee_id': self.employee.id, + 'holiday_status_id': self.leave_type.id, + 'date_to': date_to, + 'date_from': date_from, + 'number_of_days': 1, + }) + leave.action_validate() + self.assertEqual(leave.state, 'validate') + self.payslip._onchange_employee() + self.assertTrue(self.payslip.contract_id, 'No auto-discovered contract!') + self.payslip.compute_sheet() + self.assertTrue(self.payslip.worked_days_line_ids) + + leave_line = self.payslip.worked_days_line_ids.filtered(lambda l: l.code == 'TESTPTO') + self.assertTrue(leave_line) + self.assertEqual(leave_line.number_of_days, 1.0) + self.assertEqual(leave_line.number_of_hours, 8.0) + self.assertEqual(leave_line.amount, 8.0 * self.test_hourly_wage) From 11b9ffe5ef395c38ff77097e1051dd27df18500f Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Fri, 27 Nov 2020 14:25:10 -0800 Subject: [PATCH 11/13] [MIG] hr_payroll_timesheet: for Odoo Enterprise 14.0 --- hr_payroll_timesheet/__manifest__.py | 5 +- .../migrations/13.0.0.0.1/pre-migration.py | 7 - hr_payroll_timesheet/models/account.py | 23 +++- hr_payroll_timesheet/models/hr_contract.py | 9 +- hr_payroll_timesheet/models/hr_payslip.py | 104 +++++---------- .../tests/test_payslip_timesheet.py | 124 +++++++++++------- .../views/hr_contract_view.xml | 12 +- .../views/timesheet_views.xml | 30 +++++ 8 files changed, 172 insertions(+), 142 deletions(-) delete mode 100644 hr_payroll_timesheet/migrations/13.0.0.0.1/pre-migration.py create mode 100644 hr_payroll_timesheet/views/timesheet_views.xml diff --git a/hr_payroll_timesheet/__manifest__.py b/hr_payroll_timesheet/__manifest__.py index 8adbd81b..ccd1c658 100755 --- a/hr_payroll_timesheet/__manifest__.py +++ b/hr_payroll_timesheet/__manifest__.py @@ -3,7 +3,7 @@ { 'name': 'Timesheets on Payslips', 'description': 'Get Timesheet hours onto Employee Payslips.', - 'version': '13.0.1.0.1', + 'version': '14.0.1.0.0', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', 'license': 'OPL-1', @@ -12,12 +12,13 @@ 'data/hr_payroll_timesheet_data.xml', 'views/hr_contract_view.xml', 'views/hr_payslip_views.xml', + 'views/timesheet_views.xml', ], 'demo': [ 'data/hr_payroll_timesheet_demo.xml', ], 'depends': [ - 'hr_payroll', + 'hr_payroll_hibou', 'hr_timesheet_work_entry', 'hr_payroll_overtime', 'hibou_professional', 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 deleted file mode 100644 index 85e132be..00000000 --- a/hr_payroll_timesheet/migrations/13.0.0.0.1/pre-migration.py +++ /dev/null @@ -1,7 +0,0 @@ -# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. - -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) diff --git a/hr_payroll_timesheet/models/account.py b/hr_payroll_timesheet/models/account.py index 249cdba3..246e76a5 100644 --- a/hr_payroll_timesheet/models/account.py +++ b/hr_payroll_timesheet/models/account.py @@ -1,12 +1,31 @@ # Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. -from odoo import fields, models +from odoo import api, fields, models +from odoo.exceptions import ValidationError class AnalyticLine(models.Model): _inherit = 'account.analytic.line' - payslip_id = fields.Many2one('hr.payslip', string="Payslip", readonly=True, ondelete='set null') + payslip_id = fields.Many2one('hr.payslip', string="Payslip", ondelete='set null') + + @api.model_create_multi + def create(self, vals_list): + if isinstance(vals_list, dict): + vals_list = [vals_list] + + payslips = self.env['hr.payslip'].sudo().browse([d.get('payslip_id', 0) for d in vals_list]) + if any(p.state not in ('draft', 'verify') for p in payslips.exists()): + raise ValidationError('Cannot create attendance linked to payslip that is not draft.') + return super().create(vals_list) + + def write(self, values): + payslip_id = values.get('payslip_id') + if payslip_id: + payslip = self.env['hr.payslip'].sudo().browse(payslip_id) + if payslip.exists().state not in ('draft', 'verify'): + raise ValidationError('Cannot modify attendance linked to payslip that is not draft.') + return super().write(values) def unlink(self): ts_with_payslip = self.filtered(lambda ts: ts.payslip_id) diff --git a/hr_payroll_timesheet/models/hr_contract.py b/hr_payroll_timesheet/models/hr_contract.py index 423b04ac..f9d7501d 100644 --- a/hr_payroll_timesheet/models/hr_contract.py +++ b/hr_payroll_timesheet/models/hr_contract.py @@ -1,9 +1,16 @@ # Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. -from odoo import fields, models +from odoo import api, fields, models class HrContract(models.Model): _inherit = 'hr.contract' paid_hourly_timesheet = fields.Boolean(string="Paid Hourly Timesheet", default=False) + + @api.onchange('paid_hourly_timesheet') + def _onchange_paid_hourly_timesheet(self): + for contract in self: + if contract.paid_hourly_timesheet: + # only allow switch, not automatic switch 'back' + contract.wage_type = 'hourly' diff --git a/hr_payroll_timesheet/models/hr_payslip.py b/hr_payroll_timesheet/models/hr_payslip.py index 8568cd43..c3b34b42 100644 --- a/hr_payroll_timesheet/models/hr_payslip.py +++ b/hr_payroll_timesheet/models/hr_payslip.py @@ -1,6 +1,5 @@ # Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. -from collections import defaultdict from odoo import api, fields, models, _ @@ -17,76 +16,38 @@ class HrPayslip(models.Model): for payslip in self: payslip.timesheet_count = len(payslip.timesheet_ids) - 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) + def _filter_worked_day_lines_values(self, worked_day_lines_values): + worked_day_lines_values = super()._filter_worked_day_lines_values(worked_day_lines_values) + if self.contract_id.paid_hourly_timesheet: + 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" + return [w for w in worked_day_lines_values if w['work_entry_type_id'] != original_work_type.id] + return worked_day_lines_values - 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 in ('draft', 'verify'): - return worked_day_lines + def _pre_aggregate_work_data(self): + work_data = super()._pre_aggregate_work_data() + if self.contract_id.paid_hourly_timesheet: + 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)]}) - 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)]}) - - timesheet_type = self.env.ref('hr_tiemsheet_work_entry.work_input_timesheet', raise_if_not_found=False) - if not timesheet_type: - # different default type - timesheet_type = self.struct_id.type_id.default_work_entry_type_id + timesheet_type = self.env.ref('hr_timesheet_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 work_data + work_data = self._pre_aggregate_timesheet_data(work_data, timesheet_type) + return work_data - 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] - - # normalize leaves - self._timesheet_normalize_other_work_lines(worked_day_lines) - - work_data = self._pre_aggregate_timesheet_data(timesheet_type) - processed_data = self.aggregate_overtime(work_data) - - 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 _timesheet_normalize_other_work_lines(self, worked_day_line_values): - # Modifies the values based on 'wage' - unpaid_work_entry_types = self.struct_id.unpaid_work_entry_type_ids - for line_vals in worked_day_line_values: - work_type = self.env['hr.work.entry.type'].browse(line_vals['work_entry_type_id']) - if work_type not in unpaid_work_entry_types: - line_vals['amount'] = line_vals.get('number_of_hours', 0.0) * self._wage_for_work_type(work_type) - else: - line_vals['amount'] = 0.0 - - 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, default_workentrytype): - worked_ts = defaultdict(list) + def _pre_aggregate_timesheet_data(self, work_data, default_workentrytype): for ts in self.timesheet_ids.sorted('id'): if ts.unit_amount: ts_iso = ts.date.isocalendar() @@ -94,9 +55,8 @@ class HrPayslip(models.Model): 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 + work_data[ts_iso].append((timesheet_type, ts.unit_amount, ts)) + return work_data def action_open_timesheets(self): self.ensure_one() @@ -105,5 +65,9 @@ class HrPayslip(models.Model): 'name': _('Paid Timesheets'), 'res_model': 'account.analytic.line', 'view_mode': 'tree,form', + 'context': { + 'default_employee_id': self.employee_id.id, + 'default_payslip_id': self.id, + }, '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 862f1a7a..19287258 100644 --- a/hr_payroll_timesheet/tests/test_payslip_timesheet.py +++ b/hr_payroll_timesheet/tests/test_payslip_timesheet.py @@ -1,52 +1,76 @@ # Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. -from odoo.tests import common +from odoo.addons.hr_payroll_hibou.tests import common +from odoo.exceptions import ValidationError -class TestPayslipTimesheet(common.TransactionCase): +class TestPayslipTimesheet(common.TestPayslip): def setUp(self): super(TestPayslipTimesheet, self).setUp() + + self.work_type = self.env.ref('hr_timesheet_work_entry.work_input_timesheet') + self.overtime_rules = self.work_type.overtime_type_id + self.overtime_rules.hours_per_day = 0.0 + self.overtime_rules.multiplier = 1.5 + 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', - }) + self.employee = self._createEmployee() + self.contract = self._createContract(self.employee, + wage=self.test_hourly_wage, + hourly_wage=self.test_hourly_wage, + wage_type='hourly', + paid_hourly_timesheet=True) + self.work_entry_type_leave = self.env['hr.work.entry.type'].create({ 'name': 'Test PTO', 'code': 'TESTPTO', 'is_leave': True, }) - self.leave_type = self.env['hr.leave.type'].create({ - 'name': 'Test Paid Time Off', - 'time_type': 'leave', - 'allocation_type': 'no', - 'validity_start': False, - 'work_entry_type_id': self.work_entry_type_leave.id, + self.project = self.env['project.project'].create({ + 'name': 'Timesheets', }) + # 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', + # }) + # self.work_entry_type_leave = self.env['hr.work.entry.type'].create({ + # 'name': 'Test PTO', + # 'code': 'TESTPTO', + # 'is_leave': True, + # }) + # self.leave_type = self.env['hr.leave.type'].create({ + # 'name': 'Test Paid Time Off', + # 'time_type': 'leave', + # 'allocation_type': 'no', + # 'validity_start': False, + # 'work_entry_type_id': self.work_entry_type_leave.id, + # }) + def test_payslip_timesheet(self): self.assertTrue(self.contract.paid_hourly_timesheet) @@ -76,6 +100,7 @@ class TestPayslipTimesheet(common.TransactionCase): 'name': 'test', }) + self.payslip_dummy = self._createPayslip(self.employee, '2017-01-01', '2017-01-31') # Make one that should be excluded. self.env['account.analytic.line'].create({ 'employee_id': self.employee.id, @@ -86,10 +111,9 @@ class TestPayslipTimesheet(common.TransactionCase): 'payslip_id': self.payslip_dummy.id, }) - self.payslip._onchange_employee() + self.payslip = self._createPayslip(self.employee, '2018-01-01', '2018-01-31') 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') @@ -172,6 +196,7 @@ class TestPayslipTimesheet(common.TransactionCase): 'work_type_id': worktype.id, }) + self.payslip_dummy = self._createPayslip(self.employee, '2017-01-01', '2017-01-31') # Make one that should be excluded. self.env['account.analytic.line'].create({ 'employee_id': self.employee.id, @@ -182,10 +207,9 @@ class TestPayslipTimesheet(common.TransactionCase): 'payslip_id': self.payslip_dummy.id, }) - self.payslip._onchange_employee() + self.payslip = self._createPayslip(self.employee, '2018-01-01', '2018-01-31') 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') @@ -200,21 +224,19 @@ class TestPayslipTimesheet(common.TransactionCase): def test_with_leave(self): - date_from = '2018-01-10' - date_to = '2018-01-11' - leave = self.env['hr.leave'].create({ - 'name': 'Test Leave', - 'employee_id': self.employee.id, - 'holiday_status_id': self.leave_type.id, - 'date_to': date_to, + date_from = '2020-01-10' + date_to = '2020-01-11' + self.env['resource.calendar.leaves'].create({ + 'name': 'Doctor Appointment', 'date_from': date_from, - 'number_of_days': 1, + 'date_to': date_to, + 'resource_id': self.employee.resource_id.id, + 'calendar_id': self.employee.resource_calendar_id.id, + 'work_entry_type_id': self.work_entry_type_leave.id, + 'time_type': 'leave', }) - leave.action_validate() - self.assertEqual(leave.state, 'validate') - self.payslip._onchange_employee() - self.assertTrue(self.payslip.contract_id, 'No auto-discovered contract!') - self.payslip.compute_sheet() + + self.payslip = self._createPayslip(self.employee, '2020-01-06', '2020-01-19') self.assertTrue(self.payslip.worked_days_line_ids) leave_line = self.payslip.worked_days_line_ids.filtered(lambda l: l.code == 'TESTPTO') diff --git a/hr_payroll_timesheet/views/hr_contract_view.xml b/hr_payroll_timesheet/views/hr_contract_view.xml index e3e06705..48c41e75 100755 --- a/hr_payroll_timesheet/views/hr_contract_view.xml +++ b/hr_payroll_timesheet/views/hr_contract_view.xml @@ -5,15 +5,9 @@ hr.contract - - - - - - / pay period - / hour - - + + + diff --git a/hr_payroll_timesheet/views/timesheet_views.xml b/hr_payroll_timesheet/views/timesheet_views.xml new file mode 100644 index 00000000..c7eeb3f4 --- /dev/null +++ b/hr_payroll_timesheet/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 + + + + + + + + + + From 3617188ff8e5a7bf870975d3b655b9a24efdc580 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Sun, 21 Mar 2021 13:35:09 -0700 Subject: [PATCH 12/13] [FIX] hr_payroll_timesheet: Fixes for a database that has gone through multiple Odoo Upgrades Init will run on install, even if the module existed in the prior version. Because these init scripts are intended to clear (and maybe even re-name back on actual init of data), records we should be more generic with the intended name. Additionally, if your database already had `hr_payroll_timesheet` based 'TS' work entry codes, then we cannot delete it if it is used on records like payslips (as a work type). Delete the link to simply stop using the old record, but leave it behind to keep it working. --- hr_payroll_timesheet/__init__.py | 2 +- .../migrations/14.0.0.0.1/pre-migration.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 hr_payroll_timesheet/migrations/14.0.0.0.1/pre-migration.py diff --git a/hr_payroll_timesheet/__init__.py b/hr_payroll_timesheet/__init__.py index 90502662..73e9c28d 100644 --- a/hr_payroll_timesheet/__init__.py +++ b/hr_payroll_timesheet/__init__.py @@ -10,6 +10,6 @@ def ts_payroll_pre_init_hook(cr): from installing. """ cr.execute("UPDATE hr_work_entry_type " - "SET code = 'TS_OT-PRE-INSTALL' " + "SET code = 'TS_OT-PRE-INSTALL-14' " "WHERE code = 'TS_OT';" ) diff --git a/hr_payroll_timesheet/migrations/14.0.0.0.1/pre-migration.py b/hr_payroll_timesheet/migrations/14.0.0.0.1/pre-migration.py new file mode 100644 index 00000000..b4d0ae4a --- /dev/null +++ b/hr_payroll_timesheet/migrations/14.0.0.0.1/pre-migration.py @@ -0,0 +1,19 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +import odoo + + +def migrate(cr, version): + """ + In 13.0, we had our own work type: + hr_payroll_timesheet.work_input_timesheet + + This was moved to `hr_timesheet_work_entry` + We will unlink the XML ref so that the record will be kept. + """ + env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {}) + xml_refs = env['ir.model.data'].search([ + ('module', '=', 'hr_payroll_timesheet'), + ('name', '=', 'work_input_timesheet'), + ]) + xml_refs.unlink() From 14ce451d2ec6bccdcef6477624f37ae75bdf9de5 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 7 Oct 2021 09:48:54 -0700 Subject: [PATCH 13/13] [MIG] hr_payroll_timesheet: to Odoo 15.0 --- hr_payroll_timesheet/__init__.py | 2 +- hr_payroll_timesheet/__manifest__.py | 2 +- .../migrations/14.0.0.0.1/pre-migration.py | 19 ------------------- .../tests/test_payslip_timesheet.py | 2 +- 4 files changed, 3 insertions(+), 22 deletions(-) delete mode 100644 hr_payroll_timesheet/migrations/14.0.0.0.1/pre-migration.py diff --git a/hr_payroll_timesheet/__init__.py b/hr_payroll_timesheet/__init__.py index 73e9c28d..dc1ae41b 100644 --- a/hr_payroll_timesheet/__init__.py +++ b/hr_payroll_timesheet/__init__.py @@ -10,6 +10,6 @@ def ts_payroll_pre_init_hook(cr): from installing. """ cr.execute("UPDATE hr_work_entry_type " - "SET code = 'TS_OT-PRE-INSTALL-14' " + "SET code = 'TS_OT-PRE-INSTALL-15' " "WHERE code = 'TS_OT';" ) diff --git a/hr_payroll_timesheet/__manifest__.py b/hr_payroll_timesheet/__manifest__.py index ccd1c658..95c40294 100755 --- a/hr_payroll_timesheet/__manifest__.py +++ b/hr_payroll_timesheet/__manifest__.py @@ -3,7 +3,7 @@ { 'name': 'Timesheets on Payslips', 'description': 'Get Timesheet hours onto Employee Payslips.', - 'version': '14.0.1.0.0', + 'version': '15.0.1.0.0', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', 'license': 'OPL-1', diff --git a/hr_payroll_timesheet/migrations/14.0.0.0.1/pre-migration.py b/hr_payroll_timesheet/migrations/14.0.0.0.1/pre-migration.py deleted file mode 100644 index b4d0ae4a..00000000 --- a/hr_payroll_timesheet/migrations/14.0.0.0.1/pre-migration.py +++ /dev/null @@ -1,19 +0,0 @@ -# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. - -import odoo - - -def migrate(cr, version): - """ - In 13.0, we had our own work type: - hr_payroll_timesheet.work_input_timesheet - - This was moved to `hr_timesheet_work_entry` - We will unlink the XML ref so that the record will be kept. - """ - env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {}) - xml_refs = env['ir.model.data'].search([ - ('module', '=', 'hr_payroll_timesheet'), - ('name', '=', 'work_input_timesheet'), - ]) - xml_refs.unlink() diff --git a/hr_payroll_timesheet/tests/test_payslip_timesheet.py b/hr_payroll_timesheet/tests/test_payslip_timesheet.py index 19287258..c1c348b5 100644 --- a/hr_payroll_timesheet/tests/test_payslip_timesheet.py +++ b/hr_payroll_timesheet/tests/test_payslip_timesheet.py @@ -156,7 +156,7 @@ class TestPayslipTimesheet(common.TestPayslip): }) self.payslip.state = 'draft' - self.payslip._onchange_employee() + self.payslip.action_refresh_from_work_entries() 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)