diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 17:14:58 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 17:14:58 +0700 |
| commit | 1ca3b3df3421961caec3b747a364071c80f5c7da (patch) | |
| tree | 6778a1f0f3f9b4c6e26d6d87ccde16e24da6c9d6 /hr_payroll_community/models | |
| parent | b57188be371d36d96caac4b8d65a40745c0e972c (diff) | |
initial commit
Diffstat (limited to 'hr_payroll_community/models')
| -rw-r--r-- | hr_payroll_community/models/__init__.py | 7 | ||||
| -rw-r--r-- | hr_payroll_community/models/hr_contract.py | 69 | ||||
| -rw-r--r-- | hr_payroll_community/models/hr_employee.py | 21 | ||||
| -rw-r--r-- | hr_payroll_community/models/hr_payslip.py | 668 | ||||
| -rw-r--r-- | hr_payroll_community/models/hr_salary_rule.py | 249 | ||||
| -rw-r--r-- | hr_payroll_community/models/res_config_settings.py | 12 |
6 files changed, 1026 insertions, 0 deletions
diff --git a/hr_payroll_community/models/__init__.py b/hr_payroll_community/models/__init__.py new file mode 100644 index 0000000..5c85ef0 --- /dev/null +++ b/hr_payroll_community/models/__init__.py @@ -0,0 +1,7 @@ +#-*- coding:utf-8 -*- + +from . import hr_contract +from . import hr_employee +from . import res_config_settings +from . import hr_salary_rule +from . import hr_payslip diff --git a/hr_payroll_community/models/hr_contract.py b/hr_payroll_community/models/hr_contract.py new file mode 100644 index 0000000..b0bc43f --- /dev/null +++ b/hr_payroll_community/models/hr_contract.py @@ -0,0 +1,69 @@ +# -*- coding:utf-8 -*- + +from odoo import api, fields, models + + +class HrContract(models.Model): + """ + Employee contract based on the visa, work permits + allows to configure different Salary structure + """ + _inherit = 'hr.contract' + _description = 'Employee Contract' + + struct_id = fields.Many2one('hr.payroll.structure', string='Salary Structure') + schedule_pay = fields.Selection([ + ('monthly', 'Monthly'), + ('quarterly', 'Quarterly'), + ('semi-annually', 'Semi-annually'), + ('annually', 'Annually'), + ('weekly', 'Weekly'), + ('bi-weekly', 'Bi-weekly'), + ('bi-monthly', 'Bi-monthly'), + ], string='Scheduled Pay', index=True, default='monthly', + help="Defines the frequency of the wage payment.") + resource_calendar_id = fields.Many2one(required=True, help="Employee's working schedule.") + hra = fields.Monetary(string='HRA', tracking=True, help="House rent allowance.") + travel_allowance = fields.Monetary(string="Travel Allowance", help="Travel allowance") + da = fields.Monetary(string="DA", help="Dearness allowance") + meal_allowance = fields.Monetary(string="Meal Allowance", help="Meal allowance") + medical_allowance = fields.Monetary(string="Medical Allowance", help="Medical allowance") + other_allowance = fields.Monetary(string="Other Allowance", help="Other allowances") + + def get_all_structures(self): + + """ + @return: the structures linked to the given contracts, ordered by hierachy (parent=False first, + then first level children and so on) and without duplicata + """ + structures = self.mapped('struct_id') + if not structures: + return [] + # YTI TODO return browse records + return list(set(structures._get_parent_structure().ids)) + + def get_attribute(self, code, attribute): + + return self.env['hr.contract.advantage.template'].search([('code', '=', code)], limit=1)[attribute] + + def set_attribute_value(self, code, active): + for contract in self: + + if active: + + value = self.env['hr.contract.advantage.template'].search([('code', '=', code)], limit=1).default_value + contract[code] = value + else: + + contract[code] = 0.0 + + +class HrContractAdvandageTemplate(models.Model): + _name = 'hr.contract.advantage.template' + _description = "Employee's Advantage on Contract" + + name = fields.Char('Name', required=True) + code = fields.Char('Code', required=True) + lower_bound = fields.Float('Lower Bound', help="Lower bound authorized by the employer for this advantage") + upper_bound = fields.Float('Upper Bound', help="Upper bound authorized by the employer for this advantage") + default_value = fields.Float('Default value for this advantage') diff --git a/hr_payroll_community/models/hr_employee.py b/hr_payroll_community/models/hr_employee.py new file mode 100644 index 0000000..6d33a8b --- /dev/null +++ b/hr_payroll_community/models/hr_employee.py @@ -0,0 +1,21 @@ +# -*- coding:utf-8 -*- + +from odoo import api, fields, models + + +class HrEmployee(models.Model): + _inherit = 'hr.employee' + _description = 'Employee' + + slip_ids = fields.One2many('hr.payslip', 'employee_id', string='Payslips', readonly=True, help="payslip") + payslip_count = fields.Integer(compute='_compute_payslip_count', string='Payslip Count') + + def _compute_payslip_count(self): + + payslip_data = self.env['hr.payslip'].sudo().read_group([('employee_id', 'in', self.ids)], + ['employee_id'], ['employee_id']) + result = dict((data['employee_id'][0], data['employee_id_count']) for data in payslip_data) + for employee in self: + employee.payslip_count = result.get(employee.id, 0) + + diff --git a/hr_payroll_community/models/hr_payslip.py b/hr_payroll_community/models/hr_payslip.py new file mode 100644 index 0000000..83b313e --- /dev/null +++ b/hr_payroll_community/models/hr_payslip.py @@ -0,0 +1,668 @@ +# -*- coding:utf-8 -*- + +import babel +from collections import defaultdict +from datetime import date, datetime, time +from datetime import timedelta +from dateutil.relativedelta import relativedelta +from pytz import timezone +from pytz import utc + +from odoo import api, fields, models, tools, _ +from odoo.addons import decimal_precision as dp +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_utils + +# This will generate 16th of days +ROUNDING_FACTOR = 16 + + +class HrPayslip(models.Model): + _name = 'hr.payslip' + _description = 'Pay Slip' + + struct_id = fields.Many2one('hr.payroll.structure', string='Structure', + readonly=True, states={'draft': [('readonly', False)]}, + help='Defines the rules that have to be applied to this payslip, accordingly ' + 'to the contract chosen. If you let empty the field contract, this field isn\'t ' + 'mandatory anymore and thus the rules applied will be all the rules set on the ' + 'structure of all contracts of the employee valid for the chosen period') + name = fields.Char(string='Payslip Name', readonly=True, + states={'draft': [('readonly', False)]}) + number = fields.Char(string='Reference', readonly=True, copy=False, help="References", + states={'draft': [('readonly', False)]}) + employee_id = fields.Many2one('hr.employee', string='Employee', required=True, readonly=True, help="Employee", + states={'draft': [('readonly', False)]}) + date_from = fields.Date(string='Date From', readonly=True, required=True, help="Start date", + default=lambda self: fields.Date.to_string(date.today().replace(day=1)), + states={'draft': [('readonly', False)]}) + date_to = fields.Date(string='Date To', readonly=True, required=True, help="End date", + default=lambda self: fields.Date.to_string( + (datetime.now() + relativedelta(months=+1, day=1, days=-1)).date()), + states={'draft': [('readonly', False)]}) + # this is chaos: 4 states are defined, 3 are used ('verify' isn't) and 5 exist ('confirm' seems to have existed) + state = fields.Selection([ + ('draft', 'Draft'), + ('verify', 'Waiting'), + ('done', 'Done'), + ('cancel', 'Rejected'), + ], string='Status', index=True, readonly=True, copy=False, default='draft', + help="""* When the payslip is created the status is \'Draft\' + \n* If the payslip is under verification, the status is \'Waiting\'. + \n* If the payslip is confirmed then status is set to \'Done\'. + \n* When user cancel payslip the status is \'Rejected\'.""") + line_ids = fields.One2many('hr.payslip.line', 'slip_id', string='Payslip Lines', readonly=True, + states={'draft': [('readonly', False)]}) + company_id = fields.Many2one('res.company', string='Company', readonly=True, copy=False, help="Company", + default=lambda self: self.env['res.company']._company_default_get(), + states={'draft': [('readonly', False)]}) + worked_days_line_ids = fields.One2many('hr.payslip.worked_days', 'payslip_id', + string='Payslip Worked Days', copy=True, readonly=True, + help="Payslip worked days", + states={'draft': [('readonly', False)]}) + input_line_ids = fields.One2many('hr.payslip.input', 'payslip_id', string='Payslip Inputs', + readonly=True, states={'draft': [('readonly', False)]}) + paid = fields.Boolean(string='Made Payment Order ? ', readonly=True, copy=False, + states={'draft': [('readonly', False)]}) + note = fields.Text(string='Internal Note', readonly=True, states={'draft': [('readonly', False)]}) + contract_id = fields.Many2one('hr.contract', string='Contract', readonly=True, help="Contract", + states={'draft': [('readonly', False)]}) + details_by_salary_rule_category = fields.One2many('hr.payslip.line', + compute='_compute_details_by_salary_rule_category', + string='Details by Salary Rule Category', help="Details from the salary rule category") + credit_note = fields.Boolean(string='Credit Note', readonly=True, + states={'draft': [('readonly', False)]}, + help="Indicates this payslip has a refund of another") + payslip_run_id = fields.Many2one('hr.payslip.run', string='Payslip Batches', readonly=True, + copy=False, states={'draft': [('readonly', False)]}) + payslip_count = fields.Integer(compute='_compute_payslip_count', string="Payslip Computation Details") + + def _compute_details_by_salary_rule_category(self): + for payslip in self: + payslip.details_by_salary_rule_category = payslip.mapped('line_ids').filtered(lambda line: line.category_id) + + def _compute_payslip_count(self): + for payslip in self: + payslip.payslip_count = len(payslip.line_ids) + + @api.constrains('date_from', 'date_to') + def _check_dates(self): + + if any(self.filtered(lambda payslip: payslip.date_from > payslip.date_to)): + raise ValidationError(_("Payslip 'Date From' must be earlier 'Date To'.")) + + def action_payslip_draft(self): + + return self.write({'state': 'draft'}) + + def action_payslip_done(self): + + self.compute_sheet() + return self.write({'state': 'done'}) + + def action_payslip_cancel(self): + + if self.filtered(lambda slip: slip.state == 'done'): + raise UserError(_("Cannot cancel a payslip that is done.")) + return self.write({'state': 'cancel'}) + + def refund_sheet(self): + for payslip in self: + + copied_payslip = payslip.copy({'credit_note': True, 'name': _('Refund: ') + payslip.name}) + copied_payslip.compute_sheet() + copied_payslip.action_payslip_done() + formview_ref = self.env.ref('hr_payroll_community.view_hr_payslip_form', False) + treeview_ref = self.env.ref('hr_payroll_community.view_hr_payslip_tree', False) + return { + 'name': ("Refund Payslip"), + 'view_mode': 'tree, form', + 'view_id': False, + 'res_model': 'hr.payslip', + 'type': 'ir.actions.act_window', + 'target': 'current', + 'domain': "[('id', 'in', %s)]" % copied_payslip.ids, + 'views': [(treeview_ref and treeview_ref.id or False, 'tree'), + (formview_ref and formview_ref.id or False, 'form')], + 'context': {} + } + + def check_done(self): + + return True + + def unlink(self): + + if any(self.filtered(lambda payslip: payslip.state not in ('draft', 'cancel'))): + raise UserError(_('You cannot delete a payslip which is not draft or cancelled!')) + return super(HrPayslip, self).unlink() + + # TODO move this function into hr_contract module, on hr.employee object + @api.model + def get_contract(self, employee, date_from, date_to): + + """ + @param employee: recordset of employee + @param date_from: date field + @param date_to: date field + @return: returns the ids of all the contracts for the given employee that need to be considered for the given dates + """ + # a contract is valid if it ends between the given dates + clause_1 = ['&', ('date_end', '<=', date_to), ('date_end', '>=', date_from)] + # OR if it starts between the given dates + clause_2 = ['&', ('date_start', '<=', date_to), ('date_start', '>=', date_from)] + # OR if it starts before the date_from and finish after the date_end (or never finish) + clause_3 = ['&', ('date_start', '<=', date_from), '|', ('date_end', '=', False), ('date_end', '>=', date_to)] + clause_final = [('employee_id', '=', employee.id), ('state', '=', 'open'), '|', + '|'] + clause_1 + clause_2 + clause_3 + return self.env['hr.contract'].search(clause_final).ids + + def compute_sheet(self): + + for payslip in self: + number = payslip.number or self.env['ir.sequence'].next_by_code('salary.slip') + # delete old payslip lines + payslip.line_ids.unlink() + # set the list of contract for which the rules have to be applied + # if we don't give the contract, then the rules to apply should be for all current contracts of the employee + contract_ids = payslip.contract_id.ids or \ + self.get_contract(payslip.employee_id, payslip.date_from, payslip.date_to) + lines = [(0, 0, line) for line in self._get_payslip_lines(contract_ids, payslip.id)] + payslip.write({'line_ids': lines, 'number': number}) + return True + + @api.model + def get_worked_day_lines(self, contracts, date_from, date_to): + + """ + @param contract: Browse record of contracts + @return: returns a list of dict containing the input that should be applied for the given contract between date_from and date_to + """ + res = [] + # fill only if the contract as a working schedule linked + for contract in contracts.filtered(lambda contract: contract.resource_calendar_id): + day_from = datetime.combine(fields.Date.from_string(date_from), time.min) + day_to = datetime.combine(fields.Date.from_string(date_to), time.max) + + # compute leave days + leaves = {} + calendar = contract.resource_calendar_id + tz = timezone(calendar.tz) + day_leave_intervals = contract.employee_id.list_leaves(day_from, day_to, + calendar=contract.resource_calendar_id) + for day, hours, leave in day_leave_intervals: + holiday = leave.holiday_id + current_leave_struct = leaves.setdefault(holiday.holiday_status_id, { + 'name': holiday.holiday_status_id.name or _('Global Leaves'), + 'sequence': 5, + 'code': holiday.holiday_status_id.code or 'GLOBAL', + 'number_of_days': 0.0, + 'number_of_hours': 0.0, + 'contract_id': contract.id, + }) + current_leave_struct['number_of_hours'] += hours + work_hours = calendar.get_work_hours_count( + tz.localize(datetime.combine(day, time.min)), + tz.localize(datetime.combine(day, time.max)), + compute_leaves=False, + ) + if work_hours: + current_leave_struct['number_of_days'] += hours / work_hours + + # compute worked days + work_data = contract.employee_id.get_work_days_data(day_from, day_to, + calendar=contract.resource_calendar_id) + attendances = { + 'name': _("Normal Working Days paid at 100%"), + 'sequence': 1, + 'code': 'WORK100', + 'number_of_days': work_data['days'], + 'number_of_hours': work_data['hours'], + 'contract_id': contract.id, + } + + res.append(attendances) + res.extend(leaves.values()) + return res + + @api.model + def get_inputs(self, contracts, date_from, date_to): + + res = [] + + structure_ids = contracts.get_all_structures() + rule_ids = self.env['hr.payroll.structure'].browse(structure_ids).get_all_rules() + sorted_rule_ids = [id for id, sequence in sorted(rule_ids, key=lambda x: x[1])] + inputs = self.env['hr.salary.rule'].browse(sorted_rule_ids).mapped('input_ids') + + for contract in contracts: + for input in inputs: + input_data = { + 'name': input.name, + 'code': input.code, + 'contract_id': contract.id, + } + res += [input_data] + return res + + @api.model + def _get_payslip_lines(self, contract_ids, payslip_id): + + def _sum_salary_rule_category(localdict, category, amount): + if category.parent_id: + localdict = _sum_salary_rule_category(localdict, category.parent_id, amount) + localdict['categories'].dict[category.code] = category.code in localdict['categories'].dict and \ + localdict['categories'].dict[category.code] + amount or amount + return localdict + + class BrowsableObject(object): + def __init__(self, employee_id, dict, env): + self.employee_id = employee_id + self.dict = dict + self.env = env + + def __getattr__(self, attr): + return attr in self.dict and self.dict.__getitem__(attr) or 0.0 + + class InputLine(BrowsableObject): + """a class that will be used into the python code, mainly for usability purposes""" + + def sum(self, code, from_date, to_date=None): + if to_date is None: + to_date = fields.Date.today() + self.env.cr.execute(""" + SELECT sum(amount) as sum + FROM hr_payslip as hp, hr_payslip_input as pi + WHERE hp.employee_id = %s AND hp.state = 'done' + AND hp.date_from >= %s AND hp.date_to <= %s AND hp.id = pi.payslip_id AND pi.code = %s""", + (self.employee_id, from_date, to_date, code)) + return self.env.cr.fetchone()[0] or 0.0 + + class WorkedDays(BrowsableObject): + """a class that will be used into the python code, mainly for usability purposes""" + + def _sum(self, code, from_date, to_date=None): + if to_date is None: + to_date = fields.Date.today() + self.env.cr.execute(""" + SELECT sum(number_of_days) as number_of_days, sum(number_of_hours) as number_of_hours + FROM hr_payslip as hp, hr_payslip_worked_days as pi + WHERE hp.employee_id = %s AND hp.state = 'done' + AND hp.date_from >= %s AND hp.date_to <= %s AND hp.id = pi.payslip_id AND pi.code = %s""", + (self.employee_id, from_date, to_date, code)) + return self.env.cr.fetchone() + + def sum(self, code, from_date, to_date=None): + res = self._sum(code, from_date, to_date) + return res and res[0] or 0.0 + + def sum_hours(self, code, from_date, to_date=None): + res = self._sum(code, from_date, to_date) + return res and res[1] or 0.0 + + class Payslips(BrowsableObject): + """a class that will be used into the python code, mainly for usability purposes""" + + def sum(self, code, from_date, to_date=None): + if to_date is None: + to_date = fields.Date.today() + self.env.cr.execute("""SELECT sum(case when hp.credit_note = False then (pl.total) else (-pl.total) end) + FROM hr_payslip as hp, hr_payslip_line as pl + WHERE hp.employee_id = %s AND hp.state = 'done' + AND hp.date_from >= %s AND hp.date_to <= %s AND hp.id = pl.slip_id AND pl.code = %s""", + (self.employee_id, from_date, to_date, code)) + res = self.env.cr.fetchone() + return res and res[0] or 0.0 + + # we keep a dict with the result because a value can be overwritten by another rule with the same code + result_dict = {} + rules_dict = {} + worked_days_dict = {} + inputs_dict = {} + blacklist = [] + payslip = self.env['hr.payslip'].browse(payslip_id) + for worked_days_line in payslip.worked_days_line_ids: + worked_days_dict[worked_days_line.code] = worked_days_line + for input_line in payslip.input_line_ids: + inputs_dict[input_line.code] = input_line + + categories = BrowsableObject(payslip.employee_id.id, {}, self.env) + inputs = InputLine(payslip.employee_id.id, inputs_dict, self.env) + worked_days = WorkedDays(payslip.employee_id.id, worked_days_dict, self.env) + payslips = Payslips(payslip.employee_id.id, payslip, self.env) + rules = BrowsableObject(payslip.employee_id.id, rules_dict, self.env) + + baselocaldict = {'categories': categories, 'rules': rules, 'payslip': payslips, 'worked_days': worked_days, + 'inputs': inputs} + # get the ids of the structures on the contracts and their parent id as well + contracts = self.env['hr.contract'].browse(contract_ids) + if len(contracts) == 1 and payslip.struct_id: + structure_ids = list(set(payslip.struct_id._get_parent_structure().ids)) + else: + structure_ids = contracts.get_all_structures() + # get the rules of the structure and thier children + rule_ids = self.env['hr.payroll.structure'].browse(structure_ids).get_all_rules() + # run the rules by sequence + sorted_rule_ids = [id for id, sequence in sorted(rule_ids, key=lambda x: x[1])] + sorted_rules = self.env['hr.salary.rule'].browse(sorted_rule_ids) + + for contract in contracts: + employee = contract.employee_id + localdict = dict(baselocaldict, employee=employee, contract=contract) + for rule in sorted_rules: + key = rule.code + '-' + str(contract.id) + localdict['result'] = None + localdict['result_qty'] = 1.0 + localdict['result_rate'] = 100 + # check if the rule can be applied + if rule._satisfy_condition(localdict) and rule.id not in blacklist: + # compute the amount of the rule + amount, qty, rate = rule._compute_rule(localdict) + # check if there is already a rule computed with that code + previous_amount = rule.code in localdict and localdict[rule.code] or 0.0 + # set/overwrite the amount computed for this rule in the localdict + tot_rule = amount * qty * rate / 100.0 + localdict[rule.code] = tot_rule + rules_dict[rule.code] = rule + # sum the amount for its salary category + localdict = _sum_salary_rule_category(localdict, rule.category_id, tot_rule - previous_amount) + # create/overwrite the rule in the temporary results + result_dict[key] = { + 'salary_rule_id': rule.id, + 'contract_id': contract.id, + 'name': rule.name, + 'code': rule.code, + 'category_id': rule.category_id.id, + 'sequence': rule.sequence, + 'appears_on_payslip': rule.appears_on_payslip, + 'condition_select': rule.condition_select, + 'condition_python': rule.condition_python, + 'condition_range': rule.condition_range, + 'condition_range_min': rule.condition_range_min, + 'condition_range_max': rule.condition_range_max, + 'amount_select': rule.amount_select, + 'amount_fix': rule.amount_fix, + 'amount_python_compute': rule.amount_python_compute, + 'amount_percentage': rule.amount_percentage, + 'amount_percentage_base': rule.amount_percentage_base, + 'register_id': rule.register_id.id, + 'amount': amount, + 'employee_id': contract.employee_id.id, + 'quantity': qty, + 'rate': rate, + } + else: + # blacklist this rule and its children + blacklist += [id for id, seq in rule._recursive_search_of_rules()] + + return list(result_dict.values()) + + # YTI TODO To rename. This method is not really an onchange, as it is not in any view + # employee_id and contract_id could be browse records + def onchange_employee_id(self, date_from, date_to, employee_id=False, contract_id=False): + + # defaults + res = { + 'value': { + 'line_ids': [], + # delete old input lines + 'input_line_ids': [(2, x,) for x in self.input_line_ids.ids], + # delete old worked days lines + 'worked_days_line_ids': [(2, x,) for x in self.worked_days_line_ids.ids], + # 'details_by_salary_head':[], TODO put me back + 'name': '', + 'contract_id': False, + 'struct_id': False, + } + } + if (not employee_id) or (not date_from) or (not date_to): + return res + ttyme = datetime.combine(fields.Date.from_string(date_from), time.min) + employee = self.env['hr.employee'].browse(employee_id) + locale = self.env.context.get('lang') or 'en_US' + res['value'].update({ + 'name': _('Salary Slip of %s for %s') % ( + employee.name, tools.ustr(babel.dates.format_date(date=ttyme, format='MMMM-y', locale=locale))), + 'company_id': employee.company_id.id, + }) + + if not self.env.context.get('contract'): + # fill with the first contract of the employee + contract_ids = self.get_contract(employee, date_from, date_to) + else: + if contract_id: + # set the list of contract for which the input have to be filled + contract_ids = [contract_id] + else: + # if we don't give the contract, then the input to fill should be for all current contracts of the employee + contract_ids = self.get_contract(employee, date_from, date_to) + + if not contract_ids: + return res + contract = self.env['hr.contract'].browse(contract_ids[0]) + res['value'].update({ + 'contract_id': contract.id + }) + struct = contract.struct_id + if not struct: + return res + res['value'].update({ + 'struct_id': struct.id, + }) + # computation of the salary input + contracts = self.env['hr.contract'].browse(contract_ids) + worked_days_line_ids = self.get_worked_day_lines(contracts, date_from, date_to) + input_line_ids = self.get_inputs(contracts, date_from, date_to) + res['value'].update({ + 'worked_days_line_ids': worked_days_line_ids, + 'input_line_ids': input_line_ids, + }) + return res + + @api.onchange('employee_id', 'date_from', 'date_to') + def onchange_employee(self): + + + if (not self.employee_id) or (not self.date_from) or (not self.date_to): + return + + employee = self.employee_id + date_from = self.date_from + date_to = self.date_to + contract_ids = [] + + ttyme = datetime.combine(fields.Date.from_string(date_from), time.min) + locale = self.env.context.get('lang') or 'en_US' + self.name = _('Salary Slip of %s for %s') % ( + employee.name, tools.ustr(babel.dates.format_date(date=ttyme, format='MMMM-y', locale=locale))) + self.company_id = employee.company_id + + if not self.env.context.get('contract') or not self.contract_id: + contract_ids = self.get_contract(employee, date_from, date_to) + if not contract_ids: + return + self.contract_id = self.env['hr.contract'].browse(contract_ids[0]) + + if not self.contract_id.struct_id: + return + self.struct_id = self.contract_id.struct_id + if self.contract_id: + contract_ids = self.contract_id.ids + # computation of the salary input + contracts = self.env['hr.contract'].browse(contract_ids) + worked_days_line_ids = self.get_worked_day_lines(contracts, date_from, date_to) + worked_days_lines = self.worked_days_line_ids.browse([]) + for r in worked_days_line_ids: + worked_days_lines += worked_days_lines.new(r) + self.worked_days_line_ids = worked_days_lines + + input_line_ids = self.get_inputs(contracts, date_from, date_to) + input_lines = self.input_line_ids.browse([]) + for r in input_line_ids: + input_lines += input_lines.new(r) + self.input_line_ids = input_lines + return + + @api.onchange('contract_id') + def onchange_contract(self): + + if not self.contract_id: + self.struct_id = False + self.with_context(contract=True).onchange_employee() + return + + def get_salary_line_total(self, code): + + self.ensure_one() + line = self.line_ids.filtered(lambda line: line.code == code) + if line: + return line[0].total + else: + return 0.0 + + +class HrPayslipLine(models.Model): + _name = 'hr.payslip.line' + _inherit = 'hr.salary.rule' + _description = 'Payslip Line' + _order = 'contract_id, sequence' + + slip_id = fields.Many2one('hr.payslip', string='Pay Slip', required=True, ondelete='cascade', help="Payslip") + salary_rule_id = fields.Many2one('hr.salary.rule', string='Rule', required=True, help="salary rule") + employee_id = fields.Many2one('hr.employee', string='Employee', required=True, help="Employee") + contract_id = fields.Many2one('hr.contract', string='Contract', required=True, index=True, help="Contract") + rate = fields.Float(string='Rate (%)', digits=dp.get_precision('Payroll Rate'), default=100.0) + amount = fields.Float(digits=dp.get_precision('Payroll')) + quantity = fields.Float(digits=dp.get_precision('Payroll'), default=1.0) + total = fields.Float(compute='_compute_total', string='Total', help="Total", digits=dp.get_precision('Payroll'), store=True) + + @api.depends('quantity', 'amount', 'rate') + def _compute_total(self): + + for line in self: + line.total = float(line.quantity) * line.amount * line.rate / 100 + + @api.model_create_multi + def create(self, vals_list): + + for values in vals_list: + if 'employee_id' not in values or 'contract_id' not in values: + payslip = self.env['hr.payslip'].browse(values.get('slip_id')) + values['employee_id'] = values.get('employee_id') or payslip.employee_id.id + values['contract_id'] = values.get('contract_id') or payslip.contract_id and payslip.contract_id.id + if not values['contract_id']: + raise UserError(_('You must set a contract to create a payslip line.')) + return super(HrPayslipLine, self).create(vals_list) + + +class HrPayslipWorkedDays(models.Model): + _name = 'hr.payslip.worked_days' + _description = 'Payslip Worked Days' + _order = 'payslip_id, sequence' + + name = fields.Char(string='Description', required=True) + payslip_id = fields.Many2one('hr.payslip', string='Pay Slip', required=True, ondelete='cascade', index=True, help="Payslip") + sequence = fields.Integer(required=True, index=True, default=10, help="Sequence") + code = fields.Char(required=True, help="The code that can be used in the salary rules") + number_of_days = fields.Float(string='Number of Days', help="Number of days worked") + number_of_hours = fields.Float(string='Number of Hours', help="Number of hours worked") + contract_id = fields.Many2one('hr.contract', string='Contract', required=True, + help="The contract for which applied this input") + + +class HrPayslipInput(models.Model): + _name = 'hr.payslip.input' + _description = 'Payslip Input' + _order = 'payslip_id, sequence' + + name = fields.Char(string='Description', required=True) + payslip_id = fields.Many2one('hr.payslip', string='Pay Slip', required=True, ondelete='cascade', help="Payslip", index=True) + sequence = fields.Integer(required=True, index=True, default=10, help="Sequence") + code = fields.Char(required=True, help="The code that can be used in the salary rules") + amount = fields.Float(help="It is used in computation. For e.g. A rule for sales having " + "1% commission of basic salary for per product can defined in expression " + "like result = inputs.SALEURO.amount * contract.wage*0.01.") + contract_id = fields.Many2one('hr.contract', string='Contract', required=True, + help="The contract for which applied this input") + + +class HrPayslipRun(models.Model): + _name = 'hr.payslip.run' + _description = 'Payslip Batches' + + name = fields.Char(required=True, readonly=True, states={'draft': [('readonly', False)]}) + slip_ids = fields.One2many('hr.payslip', 'payslip_run_id', string='Payslips', readonly=True, + states={'draft': [('readonly', False)]}) + state = fields.Selection([ + ('draft', 'Draft'), + ('close', 'Close'), + ], string='Status', index=True, readonly=True, copy=False, default='draft') + date_start = fields.Date(string='Date From', required=True, readonly=True, help="start date", + states={'draft': [('readonly', False)]}, + default=lambda self: fields.Date.to_string(date.today().replace(day=1))) + date_end = fields.Date(string='Date To', required=True, readonly=True, help="End date", + states={'draft': [('readonly', False)]}, + default=lambda self: fields.Date.to_string( + (datetime.now() + relativedelta(months=+1, day=1, days=-1)).date())) + credit_note = fields.Boolean(string='Credit Note', readonly=True, + states={'draft': [('readonly', False)]}, + help="If its checked, indicates that all payslips generated from here are refund " + "payslips.") + + def draft_payslip_run(self): + return self.write({'state': 'draft'}) + + def close_payslip_run(self): + return self.write({'state': 'close'}) + + +class ResourceMixin(models.AbstractModel): + _inherit = "resource.mixin" + + def get_work_days_data(self, from_datetime, to_datetime, compute_leaves=True, calendar=None, domain=None): + """ + By default the resource calendar is used, but it can be + changed using the `calendar` argument. + + `domain` is used in order to recognise the leaves to take, + None means default value ('time_type', '=', 'leave') + + Returns a dict {'days': n, 'hours': h} containing the + quantity of working time expressed as days and as hours. + """ + resource = self.resource_id + calendar = calendar or self.resource_calendar_id + + # naive datetimes are made explicit in UTC + if not from_datetime.tzinfo: + from_datetime = from_datetime.replace(tzinfo=utc) + if not to_datetime.tzinfo: + to_datetime = to_datetime.replace(tzinfo=utc) + + # total hours per day: retrieve attendances with one extra day margin, + # in order to compute the total hours on the first and last days + from_full = from_datetime - timedelta(days=1) + to_full = to_datetime + timedelta(days=1) + intervals = calendar._attendance_intervals(from_full, to_full, resource) + day_total = defaultdict(float) + for start, stop, meta in intervals: + day_total[start.date()] += (stop - start).total_seconds() / 3600 + + # actual hours per day + if compute_leaves: + intervals = calendar._work_intervals(from_datetime, to_datetime, resource, domain) + else: + intervals = calendar._attendance_intervals(from_datetime, to_datetime, resource) + day_hours = defaultdict(float) + for start, stop, meta in intervals: + day_hours[start.date()] += (stop - start).total_seconds() / 3600 + + # compute number of days as quarters + days = sum( + float_utils.round(ROUNDING_FACTOR * day_hours[day] / day_total[day]) / ROUNDING_FACTOR + for day in day_hours + ) + return { + 'days': days, + 'hours': sum(day_hours.values()), + } diff --git a/hr_payroll_community/models/hr_salary_rule.py b/hr_payroll_community/models/hr_salary_rule.py new file mode 100644 index 0000000..ee8205c --- /dev/null +++ b/hr_payroll_community/models/hr_salary_rule.py @@ -0,0 +1,249 @@ +# -*- coding:utf-8 -*- + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools.safe_eval import safe_eval + +from odoo.addons import decimal_precision as dp + +class HrPayrollStructure(models.Model): + """ + Salary structure used to defined + - Basic + - Allowances + - Deductions + """ + _name = 'hr.payroll.structure' + _description = 'Salary Structure' + + @api.model + def _get_parent(self): + return self.env.ref('hr_payroll_community.structure_base', False) + + name = fields.Char(required=True) + code = fields.Char(string='Reference', required=True) + company_id = fields.Many2one('res.company', string='Company', required=True, + copy=False, default=lambda self: self.env['res.company']._company_default_get()) + note = fields.Text(string='Description') + parent_id = fields.Many2one('hr.payroll.structure', string='Parent', default=_get_parent) + children_ids = fields.One2many('hr.payroll.structure', 'parent_id', string='Children', copy=True) + rule_ids = fields.Many2many('hr.salary.rule', 'hr_structure_salary_rule_rel', 'struct_id', 'rule_id', string='Salary Rules') + + @api.constrains('parent_id') + def _check_parent_id(self): + + if not self._check_recursion(): + raise ValidationError(_('You cannot create a recursive salary structure.')) + + @api.returns('self', lambda value: value.id) + def copy(self, default=None): + + self.ensure_one() + default = dict(default or {}, code=_("%s (copy)") % (self.code)) + return super(HrPayrollStructure, self).copy(default) + + def get_all_rules(self): + + """ + @return: returns a list of tuple (id, sequence) of rules that are maybe to apply + """ + all_rules = [] + for struct in self: + all_rules += struct.rule_ids._recursive_search_of_rules() + return all_rules + + def _get_parent_structure(self): + + parent = self.mapped('parent_id') + if parent: + parent = parent._get_parent_structure() + return parent + self + + +class HrContributionRegister(models.Model): + _name = 'hr.contribution.register' + _description = 'Contribution Register' + + company_id = fields.Many2one('res.company', string='Company', + default=lambda self: self.env['res.company']._company_default_get()) + partner_id = fields.Many2one('res.partner', string='Partner') + name = fields.Char(required=True) + register_line_ids = fields.One2many('hr.payslip.line', 'register_id', + string='Register Line', readonly=True) + note = fields.Text(string='Description') + + +class HrSalaryRuleCategory(models.Model): + _name = 'hr.salary.rule.category' + _description = 'Salary Rule Category' + + name = fields.Char(required=True, translate=True) + code = fields.Char(required=True) + parent_id = fields.Many2one('hr.salary.rule.category', string='Parent', + help="Linking a salary category to its parent is used only for the reporting purpose.") + children_ids = fields.One2many('hr.salary.rule.category', 'parent_id', string='Children') + note = fields.Text(string='Description') + company_id = fields.Many2one('res.company', string='Company', + default=lambda self: self.env['res.company']._company_default_get()) + + @api.constrains('parent_id') + def _check_parent_id(self): + + if not self._check_recursion(): + raise ValidationError(_('Error! You cannot create recursive hierarchy of Salary Rule Category.')) + + +class HrSalaryRule(models.Model): + _name = 'hr.salary.rule' + _order = 'sequence, id' + _description = 'Salary Rule' + + name = fields.Char(required=True, translate=True) + code = fields.Char(required=True, + help="The code of salary rules can be used as reference in computation of other rules. " + "In that case, it is case sensitive.") + sequence = fields.Integer(required=True, index=True, default=5, + help='Use to arrange calculation sequence') + quantity = fields.Char(default='1.0', + help="It is used in computation for percentage and fixed amount. " + "For e.g. A rule for Meal Voucher having fixed amount of " + u"1€ per worked day can have its quantity defined in expression " + "like worked_days.WORK100.number_of_days.") + category_id = fields.Many2one('hr.salary.rule.category', string='Category', required=True) + active = fields.Boolean(default=True, + help="If the active field is set to false, it will allow you to hide the salary rule without removing it.") + appears_on_payslip = fields.Boolean(string='Appears on Payslip', default=True, + help="Used to display the salary rule on payslip.") + parent_rule_id = fields.Many2one('hr.salary.rule', string='Parent Salary Rule', index=True) + company_id = fields.Many2one('res.company', string='Company', + default=lambda self: self.env['res.company']._company_default_get()) + condition_select = fields.Selection([ + ('none', 'Always True'), + ('range', 'Range'), + ('python', 'Python Expression') + ], string="Condition Based on", default='none', required=True) + condition_range = fields.Char(string='Range Based on', default='contract.wage', + help='This will be used to compute the % fields values; in general it is on basic, ' + 'but you can also use categories code fields in lowercase as a variable names ' + '(hra, ma, lta, etc.) and the variable basic.') + condition_python = fields.Text(string='Python Condition', required=True, + default=''' + # Available variables: + #---------------------- + # payslip: object containing the payslips + # employee: hr.employee object + # contract: hr.contract object + # rules: object containing the rules code (previously computed) + # categories: object containing the computed salary rule categories (sum of amount of all rules belonging to that category). + # worked_days: object containing the computed worked days + # inputs: object containing the computed inputs + + # Note: returned value have to be set in the variable 'result' + + result = rules.NET > categories.NET * 0.10''', + help='Applied this rule for calculation if condition is true. You can specify condition like basic > 1000.') + condition_range_min = fields.Float(string='Minimum Range', help="The minimum amount, applied for this rule.") + condition_range_max = fields.Float(string='Maximum Range', help="The maximum amount, applied for this rule.") + amount_select = fields.Selection([ + ('percentage', 'Percentage (%)'), + ('fix', 'Fixed Amount'), + ('code', 'Python Code'), + ], string='Amount Type', index=True, required=True, default='fix', help="The computation method for the rule amount.") + amount_fix = fields.Float(string='Fixed Amount', digits=dp.get_precision('Payroll')) + amount_percentage = fields.Float(string='Percentage (%)', digits=dp.get_precision('Payroll Rate'), + help='For example, enter 50.0 to apply a percentage of 50%') + amount_python_compute = fields.Text(string='Python Code', + default=''' + # Available variables: + #---------------------- + # payslip: object containing the payslips + # employee: hr.employee object + # contract: hr.contract object + # rules: object containing the rules code (previously computed) + # categories: object containing the computed salary rule categories (sum of amount of all rules belonging to that category). + # worked_days: object containing the computed worked days. + # inputs: object containing the computed inputs. + + # Note: returned value have to be set in the variable 'result' + + result = contract.wage * 0.10''') + amount_percentage_base = fields.Char(string='Percentage based on', help='result will be affected to a variable') + child_ids = fields.One2many('hr.salary.rule', 'parent_rule_id', string='Child Salary Rule', copy=True) + register_id = fields.Many2one('hr.contribution.register', string='Contribution Register', + help="Eventual third party involved in the salary payment of the employees.") + input_ids = fields.One2many('hr.rule.input', 'input_id', string='Inputs', copy=True) + note = fields.Text(string='Description') + + @api.constrains('parent_rule_id') + def _check_parent_rule_id(self): + if not self._check_recursion(parent='parent_rule_id'): + raise ValidationError(_('Error! You cannot create recursive hierarchy of Salary Rules.')) + + def _recursive_search_of_rules(self): + """ + @return: returns a list of tuple (id, sequence) which are all the children of the passed rule_ids + """ + children_rules = [] + for rule in self.filtered(lambda rule: rule.child_ids): + children_rules += rule.child_ids._recursive_search_of_rules() + return [(rule.id, rule.sequence) for rule in self] + children_rules + + #TODO should add some checks on the type of result (should be float) + def _compute_rule(self, localdict): + + """ + :param localdict: dictionary containing the environement in which to compute the rule + :return: returns a tuple build as the base/amount computed, the quantity and the rate + :rtype: (float, float, float) + """ + self.ensure_one() + if self.amount_select == 'fix': + try: + return self.amount_fix, float(safe_eval(self.quantity, localdict)), 100.0 + except: + raise UserError(_('Wrong quantity defined for salary rule %s (%s).') % (self.name, self.code)) + elif self.amount_select == 'percentage': + try: + return (float(safe_eval(self.amount_percentage_base, localdict)), + float(safe_eval(self.quantity, localdict)), + self.amount_percentage) + except: + raise UserError(_('Wrong percentage base or quantity defined for salary rule %s (%s).') % (self.name, self.code)) + else: + try: + safe_eval(self.amount_python_compute, localdict, mode='exec', nocopy=True) + return float(localdict['result']), 'result_qty' in localdict and localdict['result_qty'] or 1.0, 'result_rate' in localdict and localdict['result_rate'] or 100.0 + except: + raise UserError(_('Wrong python code defined for salary rule %s (%s).') % (self.name, self.code)) + + def _satisfy_condition(self, localdict): + + """ + @param contract_id: id of hr.contract to be tested + @return: returns True if the given rule match the condition for the given contract. Return False otherwise. + """ + self.ensure_one() + + if self.condition_select == 'none': + return True + elif self.condition_select == 'range': + try: + result = safe_eval(self.condition_range, localdict) + return self.condition_range_min <= result and result <= self.condition_range_max or False + except: + raise UserError(_('Wrong range condition defined for salary rule %s (%s).') % (self.name, self.code)) + else: # python code + try: + safe_eval(self.condition_python, localdict, mode='exec', nocopy=True) + return 'result' in localdict and localdict['result'] or False + except: + raise UserError(_('Wrong python condition defined for salary rule %s (%s).') % (self.name, self.code)) + + +class HrRuleInput(models.Model): + _name = 'hr.rule.input' + _description = 'Salary Rule Input' + + name = fields.Char(string='Description', required=True) + code = fields.Char(required=True, help="The code that can be used in the salary rules") + input_id = fields.Many2one('hr.salary.rule', string='Salary Rule Input', required=True) diff --git a/hr_payroll_community/models/res_config_settings.py b/hr_payroll_community/models/res_config_settings.py new file mode 100644 index 0000000..db3c420 --- /dev/null +++ b/hr_payroll_community/models/res_config_settings.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + module_account_accountant = fields.Boolean(string='Account Accountant') + module_l10n_fr_hr_payroll = fields.Boolean(string='French Payroll') + module_l10n_be_hr_payroll = fields.Boolean(string='Belgium Payroll') + module_l10n_in_hr_payroll = fields.Boolean(string='Indian Payroll') |
