# Part of Odoo. See LICENSE file for full copyright and licensing details. import logging import random import math import pytz from collections import defaultdict from datetime import date, datetime, time from dateutil.relativedelta import relativedelta from functools import reduce from itertools import zip_longest from odoo import api, Command, fields, models, _ from odoo.fields import Domain from odoo.exceptions import UserError, ValidationError from odoo.tools import float_round, convert_file, format_amount from odoo.tools.float_utils import float_compare from odoo.tools.misc import format_date from odoo.tools.safe_eval import safe_eval, datetime as safe_eval_datetime, dateutil as safe_eval_dateutil _logger = logging.getLogger(__name__) class DefaultDictPayroll(defaultdict): def get(self, key, default=None): if key not in self and default is not None: self[key] = default return self[key] class HrPayslip(models.Model): _name = 'hr.payslip' _description = 'Pay Slip' _inherit = ['mail.thread.cc', 'mail.thread.main.attachment', 'mail.activity.mixin'] _order = 'date_to desc' struct_id = fields.Many2one( 'hr.payroll.structure', string='Structure', precompute=True, compute='_compute_struct_id', store=True, readonly=False, tracking=True, help='Defines the rules that have to be applied to this payslip, according ' 'to the contract chosen. If the contract is empty, this field isn\'t ' 'mandatory anymore and all the valid rules of the structures ' 'of the employee\'s contracts will be applied.') structure_code = fields.Char(related="struct_id.code") struct_type_id = fields.Many2one('hr.payroll.structure.type', related='struct_id.type_id') wage_type = fields.Selection(related='version_id.wage_type') name = fields.Char( string='Payslip Name', required=True, compute='_compute_name', store=True, readonly=False) employee_id = fields.Many2one( 'hr.employee', string='Employee', required=True, index=True, domain="['|', ('company_id', '=', False), ('company_id', 'child_of', company_id), '|', ('active', '=', True), ('active', '=', False)]") employee_reference = fields.Char(related='employee_id.registration_number') image_128 = fields.Image(related='employee_id.image_128') image_1920 = fields.Image(related='employee_id.image_1920') avatar_128 = fields.Image(related='employee_id.avatar_128') avatar_1920 = fields.Image(related='employee_id.avatar_1920') department_id = fields.Many2one('hr.department', string='Department', related='employee_id.department_id', readonly=True, store=True) job_id = fields.Many2one('hr.job', string='Job Position', related='employee_id.job_id', readonly=True, store=True) date_from = fields.Date( string='From', readonly=False, required=True, tracking=True, default=lambda self: date.today().replace(day=1)) date_to = fields.Date( string='To', readonly=False, required=True, tracking=True, compute="_compute_date_to", store=True, precompute=True) state = fields.Selection([ ('draft', 'Draft'), ('validated', 'Validated'), ('paid', 'Paid'), ('cancel', 'Canceled')], string='State', index=True, readonly=True, copy=False, default='draft', tracking=True, help="""* When the payslip is created the status is \'Draft\' \n* If the payslip is confirmed then status is set to \'Done\'. \n* When the user cancels a payslip, the status is \'Canceled\'.""") state_display = fields.Selection([ ('draft', 'Draft'), ('validated', 'Done'), ('paid', 'Paid'), ('cancel', 'Canceled'), ('warning', 'Warning'), ('error', 'Error'), ], string='Status', compute='_compute_state_display', store=True, readonly=True, ) line_ids = fields.One2many( 'hr.payslip.line', 'slip_id', string='Payslip Lines', compute='_compute_line_ids', store=True, readonly=False, copy=True) company_id = fields.Many2one( 'res.company', string='Company', copy=False, required=True, compute='_compute_company_id', store=True, readonly=True, default=lambda self: self.env.company) country_id = fields.Many2one( 'res.country', string='Country', related='company_id.country_id', readonly=True, search='_search_country_id' ) country_code = fields.Char(related='country_id.code', depends=['country_id'], readonly=True) worked_days_line_ids = fields.One2many( 'hr.payslip.worked_days', 'payslip_id', string='Payslip Worked Days', copy=True, compute='_compute_worked_days_line_ids', store=True, readonly=False) input_line_ids = fields.One2many( 'hr.payslip.input', 'payslip_id', string='Payslip Inputs', compute='_compute_input_line_ids', store=True, readonly=False) paid = fields.Boolean( string='Made Payment Order? ', copy=False) done_date = fields.Datetime(string="payslip confirmation Date") paid_date = fields.Date(string="Payment Date") note = fields.Text(string='Internal Note') version_id = fields.Many2one( 'hr.version', string='Employee Record', precompute=True, tracking=True, compute='_compute_version_id', store=True, readonly=False, index=True, domain="""[ ('date_start', '<=', date_to), '|', ('date_end', '=', False), ('date_end', '>=', date_from) ]""", ) credit_note = fields.Boolean( string='Credit Note', help="Indicates this payslip has a refund of another") payslip_run_id = fields.Many2one( 'hr.payslip.run', string='Pay Run', copy=False, ondelete='cascade', tracking=True, index='btree_not_null', domain="[('company_id', '=', company_id)]") sum_worked_hours = fields.Float(compute='_compute_worked_hours', store=True, help='Total hours of attendance and time off (paid or not)') compute_date = fields.Date('Computed On') basic_wage = fields.Monetary(compute='_compute_basic_net', store=True) gross_wage = fields.Monetary(compute='_compute_basic_net', store=True) net_wage = fields.Monetary(compute='_compute_basic_net', store=True) currency_id = fields.Many2one(related='version_id.currency_id') is_regular = fields.Boolean(compute='_compute_is_regular') is_wrong_version = fields.Boolean(compute='_compute_is_wrong_version', store=True) has_wrong_data = fields.Boolean(compute='_compute_is_wrong_version', store=True) keep_wrong_version = fields.Boolean(default=False) has_negative_net_to_report = fields.Boolean() is_superuser = fields.Boolean(compute="_compute_is_superuser") edited = fields.Boolean() queued_for_pdf = fields.Boolean(default=False) issues = fields.Json(compute='_compute_issues', store=True, readonly=True) warning_count = fields.Integer(compute='_compute_issues', store=True, readonly=True) error_count = fields.Integer(compute='_compute_issues', store=True, readonly=True) is_wrong_duration = fields.Boolean(compute='_compute_is_wrong_duration', compute_sudo=True) negative_net_to_report_message = fields.Char(compute='_compute_negative_net_to_report_display') negative_net_to_report_amount = fields.Float(compute='_compute_negative_net_to_report_display') negative_net_to_report_display = fields.Boolean(compute='_compute_negative_net_to_report_display') salary_attachment_ids = fields.Many2many( 'hr.salary.attachment', relation='hr_payslip_hr_salary_attachment_rel', string='Salary Adjustments', compute='_compute_salary_attachment_ids', store=True, readonly=False, ) salary_attachment_count = fields.Integer('Salary Adjustment count', compute='_compute_salary_attachment_count') use_worked_day_lines = fields.Boolean(related="struct_id.use_worked_day_lines") payment_report = fields.Binary( string='Payment Report', help="Export .csv file related to this payslip", readonly=True) payment_report_filename = fields.Char(readonly=True) payment_report_date = fields.Date(readonly=True) ytd_computation = fields.Boolean(related='struct_id.ytd_computation') employer_cost = fields.Monetary(compute='_compute_basic_net', store=True, string='Employer Cost') is_refund_payslip = fields.Boolean(string="Is Refund payslip", default=False) is_refunded = fields.Boolean(string="Is Refunded", default=False) is_corrected = fields.Boolean(string="Is Corrected", default=False) origin_payslip_id = fields.Many2one('hr.payslip', string='Origin Payslip', index='btree_not_null') related_payslip_ids = fields.One2many('hr.payslip', 'origin_payslip_id', string="Related Payslips") related_payslip_count = fields.Integer("Related payslip count", compute="_compute_related_payslip_count_count") payslip_properties = fields.Properties('Payroll Properties', definition='struct_id.payslip_properties_definition') def _get_salary_advance_balances(self): return defaultdict(float) @api.model def _schedule_period_start(self, schedule, today, country_code=False): week_start = self.env["res.lang"]._get_data(code=self.env.user.lang).week_start if schedule == 'quarterly': current_year_quarter = math.ceil(today.month / 3) date_from = today.replace(day=1, month=(current_year_quarter - 1) * 3 + 1) elif schedule == 'semi-annually': is_second_half = math.floor((today.month - 1) / 6) date_from = today.replace(day=1, month=7) if is_second_half else today.replace(day=1, month=1) elif schedule == 'annually': date_from = today.replace(day=1, month=1) elif schedule == 'weekly': week_day = today.weekday() date_from = today + relativedelta(days=-week_day) elif schedule == 'bi-weekly': week = int(today.strftime("%U") if week_start == '7' else today.strftime("%W")) week_day = today.weekday() is_second_week = week % 2 == 0 date_from = today + relativedelta(days=-week_day - 7 * int(is_second_week)) elif schedule == 'semi-monthly': date_from = today.replace(day=1 if today.day < 16 else 16) elif schedule == 'bi-monthly': current_year_slice = math.ceil(today.month / 2) date_from = today.replace(day=1, month=(current_year_slice - 1) * 2 + 1) elif schedule == 'daily': date_from = today else: # if not handled, put the monthly behaviour date_from = today.replace(day=1) return date_from @api.model_create_multi def create(self, vals_list): payslips = super().create(vals_list) payslips._compute_payslip_properties() return payslips def _compute_payslip_properties(self): properties_definition_per_structure = defaultdict(list) for struct in self.mapped('struct_id'): properties_definition_per_structure[struct] = struct._get_common_payroll_properties() for payslip in self: payslip_properties = dict(payslip.payslip_properties) or {} if payslip.state in ['paid', 'done']: continue version_properties = dict(payslip.version_id.payroll_properties) payslip_properties.update({ key: version_properties.get(key, 0) for key in properties_definition_per_structure[payslip.struct_id] }) payslip.update({ 'payslip_properties': payslip_properties }) @api.depends('error_count', 'warning_count', 'state') def _compute_state_display(self): for payslip in self: if payslip.error_count: payslip.state_display = 'error' elif payslip.warning_count: payslip.state_display = 'warning' else: payslip.state_display = payslip.state @api.model def _schedule_timedelta(self, schedule, date_from, country_code=False): if schedule == 'quarterly': timedelta = relativedelta(months=3, days=-1) elif schedule == 'semi-annually': timedelta = relativedelta(months=6, days=-1) elif schedule == 'annually': timedelta = relativedelta(years=1, days=-1) elif schedule == 'weekly': timedelta = relativedelta(days=6) elif schedule == 'bi-weekly': timedelta = relativedelta(days=13) elif schedule == 'semi-monthly': timedelta = relativedelta(day=15 if date_from.day < 16 else 31) elif schedule == 'bi-monthly': timedelta = relativedelta(months=2, days=-1) elif schedule == 'daily': timedelta = relativedelta(days=0) else: # if not handled, put the monthly behaviour timedelta = relativedelta(months=1, days=-1) return timedelta def _get_schedule_timedelta(self): self.ensure_one() schedule = self.version_id.schedule_pay or self.version_id.structure_type_id.default_schedule_pay return self._schedule_timedelta(schedule, self.date_from, self.country_code) @api.depends('date_from', 'version_id', 'struct_id') def _compute_date_to(self): for payslip in self: if self.env.context.get('default_date_to'): payslip.date_to = self.env.context.get('default_date_to') else: payslip.date_to = payslip.date_from and payslip.date_from + payslip._get_schedule_timedelta() @api.depends('employee_id', 'version_id', 'struct_id', 'date_from', 'date_to', 'struct_id') def _compute_input_line_ids(self): attachment_type_ids = self.env['hr.payslip.input.type'].search([('available_in_attachments', '=', True)]).ids for slip in self: if not slip.employee_id or not slip.employee_id.salary_attachment_ids or not slip.struct_id: lines_to_remove = slip.input_line_ids.filtered(lambda x: x.input_type_id.id in attachment_type_ids) slip.update({'input_line_ids': [Command.unlink(line.id) for line in lines_to_remove]}) if slip.employee_id.salary_attachment_ids and slip.date_to: lines_to_remove = slip.input_line_ids.filtered(lambda x: x.input_type_id.id in attachment_type_ids) input_line_vals = [Command.unlink(line.id) for line in lines_to_remove] valid_attachments = slip.employee_id.salary_attachment_ids.filtered( lambda a: a.state == 'open' and a.date_start <= slip.date_to and (not a.date_end or a.date_end >= slip.date_from) and (not a.other_input_type_id.struct_ids or slip.struct_id in a.other_input_type_id.struct_ids) ) # Only take deduction types present in structure for input_type_id, attachments in valid_attachments.grouped("other_input_type_id").items(): amount = attachments._get_active_amount() name = ', '.join(description for description in attachments.mapped('description') if description) input_line_vals.append(Command.create({ 'name': name, 'amount': amount if not slip.credit_note else -amount, 'input_type_id': input_type_id.id, })) slip.update({'input_line_ids': input_line_vals}) @api.depends('input_line_ids.input_type_id', 'input_line_ids') def _compute_salary_attachment_ids(self): for slip in self: if not slip.input_line_ids and not slip.salary_attachment_ids: continue attachments = self.env['hr.salary.attachment'] if slip.employee_id and slip.input_line_ids and slip.date_to: deduction_types = slip.input_line_ids.input_type_id.filtered("available_in_attachments").ids attachments = slip.employee_id.salary_attachment_ids.filtered( lambda a: ( a.state == 'open' and a.other_input_type_id.id in deduction_types and a.date_start <= slip.date_to ) ) slip.salary_attachment_ids = attachments @api.depends('salary_attachment_ids') def _compute_salary_attachment_count(self): for slip in self: slip.salary_attachment_count = len(slip.salary_attachment_ids) @api.depends('employee_id', 'state') def _compute_negative_net_to_report_display(self): activity_type = self.env.ref( 'hr_payroll.mail_activity_data_hr_payslip_negative_net', raise_if_not_found=False ) or self.env['mail.activity.type'] for payslip in self: if payslip.state == 'draft': payslips_to_report = self.env['hr.payslip'].search([ ('has_negative_net_to_report', '=', True), ('employee_id', 'in', payslip.employee_id.ids), ('credit_note', '=', False), ]) payslip.negative_net_to_report_display = payslips_to_report payslip.negative_net_to_report_amount = payslips_to_report._get_line_values(['NET'], compute_sum=True)['NET']['sum']['total'] payslip.negative_net_to_report_message = _( 'There are previous payslips with a negative amount for a total of %s to report.', round(payslip.negative_net_to_report_amount, 2)) if payslips_to_report and payslip.state == 'draft' and payslip.line_ids and payslip.version_id and ( not payslip.activity_ids.filtered(lambda a: a.activity_type_id == activity_type) ): payslip.activity_schedule( 'hr_payroll.mail_activity_data_hr_payslip_negative_net', summary=_('Previous Negative Payslip to Report'), note=_('At least one previous negative net could be reported on this payslip for %s', payslip.employee_id._get_html_link()), user_id=payslip.version_id.hr_responsible_id.id or self.env.ref('base.user_admin').id) else: payslip.negative_net_to_report_display = False payslip.negative_net_to_report_amount = False payslip.negative_net_to_report_message = False @api.depends('related_payslip_ids') def _compute_related_payslip_count_count(self): for slip in self: slip.related_payslip_count = len(slip.related_payslip_ids) def _get_negative_net_input_type(self): self.ensure_one() return self.env.ref('hr_payroll.input_deduction') def action_report_negative_amount(self): self.ensure_one() deduction_input_type = self._get_negative_net_input_type() deduction_input_line = self.input_line_ids.filtered(lambda l: l.input_type_id == deduction_input_type) if deduction_input_line: deduction_input_line.amount += abs(self.negative_net_to_report_amount) else: self.write({'input_line_ids': [(0, 0, { 'input_type_id': deduction_input_type.id, 'amount': abs(self.negative_net_to_report_amount), })]}) self.compute_sheet() self.env['hr.payslip'].search([ ('has_negative_net_to_report', '=', True), ('employee_id', '=', self.employee_id.id), ('credit_note', '=', False), ]).write({'has_negative_net_to_report': False}) self.activity_feedback(['hr_payroll.mail_activity_data_hr_payslip_negative_net']) def _compute_is_regular(self): for payslip in self: payslip.is_regular = payslip.struct_id.type_id.default_struct_id == payslip.struct_id @api.depends('employee_id.current_version_id', 'version_id.last_modified_date', 'date_from', 'done_date') def _compute_is_wrong_version(self): for payslip in self.filtered(lambda slip: slip.state in ("validated", "paid")): payslip.is_wrong_version = payslip.employee_id and payslip.version_id \ and payslip.version_id != payslip.employee_id._get_version(date=payslip.date_from) payslip.has_wrong_data = payslip.version_id.last_modified_date > payslip.done_date def _is_invalid(self): self.ensure_one() if self.state not in ['validated', 'paid']: return _("This payslip is not validated. This is not a legal document.") return False @api.depends(lambda self: self._get_recomputing_fields()) def _compute_line_ids(self): if not self.env.context.get("payslip_no_recompute"): return payslips = self.filtered(lambda p: p.line_ids and p.state == 'draft') for payslip in payslips: lines_vals = [] if payslip.employee_id and payslip.version_id and payslip.date_from and payslip.date_to and payslip.struct_id: lines_vals = [(0, 0, line_vals) for line_vals in payslip._get_payslip_lines()] payslip.line_ids = [(5, 0, 0)] + lines_vals def _get_recomputing_fields(self): return [ 'employee_id', 'version_id', 'struct_id', 'date_from', 'date_to', 'worked_days_line_ids', 'input_line_ids' ] @api.depends('line_ids.total', 'struct_id.rule_ids.appears_on_employee_cost_dashboard') def _compute_basic_net(self): to_compute_payslips = self.filtered(lambda p: p.state in ('draft', 'validated')).with_prefetch() line_values = (to_compute_payslips._origin)._get_line_values(['BASIC', 'GROSS', 'NET']) employer_cost_codes = set(self.env['hr.salary.rule'].search([ ('appears_on_employee_cost_dashboard', '=', True) ]).mapped('code')) employer_cost_values = {} if employer_cost_codes: employer_cost_values = (to_compute_payslips._origin)._get_line_values(employer_cost_codes) for payslip in to_compute_payslips: employer_cost_total = 0.0 payslip_employer_codes = payslip.struct_id.rule_ids.filtered( 'appears_on_employee_cost_dashboard' ).mapped('code') for code in payslip_employer_codes: employer_cost_total += employer_cost_values[code][payslip._origin.id]['total'] payslip.write({ 'basic_wage': line_values['BASIC'][payslip._origin.id]['total'], 'gross_wage': line_values['GROSS'][payslip._origin.id]['total'], 'net_wage': line_values['NET'][payslip._origin.id]['total'], 'employer_cost': employer_cost_total, }) @api.depends('worked_days_line_ids.number_of_hours', 'worked_days_line_ids.is_paid') def _compute_worked_hours(self): for payslip in self: payslip.sum_worked_hours = sum(line.number_of_hours for line in payslip.worked_days_line_ids) def _get_regular_worked_hours(self): # To be overridden by localization modules. Used for the amount computation for each worked days type. self.ensure_one() return self.sum_worked_hours def _compute_is_superuser(self): self.is_superuser = self.env.user._is_superuser() and self.env.user.has_group('base.group_no_one') @api.constrains('date_from', 'date_to') def _check_dates(self): if any(payslip.date_from > payslip.date_to for payslip in self): raise ValidationError(_("Payslip 'Date From' must be earlier than 'Date To'.")) def _record_attachment_payment(self, attachments, slip_lines): self.ensure_one() sign = -1 if self.credit_note else 1 amount = sum(sl.total for sl in slip_lines) if not all(attachments.other_input_type_id.mapped("is_quantity")) else sum(sl.quantity for sl in slip_lines) attachments.record_payment(sign * abs(amount)) def write(self, vals): res = super().write(vals) if 'state' in vals and vals['state'] == 'paid': # Register payment in Salary Attachments # NOTE: Since we combine multiple attachments on one input line, it's not possible to compute # how much per attachment needs to be taken record_payment will consume monthly payments (child_support) before other attachments for slip in self.filtered(lambda r: r.salary_attachment_ids): for deduction_code, attachments in slip.salary_attachment_ids.grouped(lambda x: x.other_input_type_id.code).items(): # Use the amount from the computed value in the payslip lines not the input salary_lines = slip.line_ids.filtered(lambda r: r.code == deduction_code) if not attachments or not salary_lines: continue slip._record_attachment_payment(attachments, salary_lines) return res def action_draft_linked_entries(self): work_entries = self.env['hr.work.entry'].search( Domain('employee_id', '=', self.employee_id.ids) & Domain('date', '>=', min(self.mapped('date_from'))) & Domain('date', '<=', max(self.mapped('date_to'))) & Domain('state', '=', 'validated') ) linked_entries = self.env['hr.work.entry'] similar_payslips = self._get_similar_payslips() for regular_payslip in self: payslip_start = regular_payslip.date_from payslip_end = regular_payslip.date_to key = (regular_payslip.version_id.id, regular_payslip.struct_id.id, payslip_start, payslip_end) duplicates = similar_payslips[key].filtered(lambda dup: dup.id != regular_payslip.id) if duplicates: continue linked_entries |= work_entries.filtered( lambda entry: entry.employee_id == regular_payslip.employee_id and payslip_start <= entry.date <= payslip_end and not entry.has_payslip) if linked_entries: linked_entries.action_set_to_draft() def action_payslip_draft(self): self.env['ir.attachment'].sudo().search([ ('res_model', '=', 'hr.payslip'), ('res_id', 'in', self.ids), ('res_field', '=', 'payment_report'), ]).unlink() self.write({ 'payment_report': False, 'payment_report_filename': False, 'payment_report_date': False, 'state': 'draft', 'done_date': False, }) self.action_draft_linked_entries() return True def _get_pdf_reports(self): default_report = self.env.ref('hr_payroll.action_report_payslip') result = defaultdict(lambda: self.env['hr.payslip']) for payslip in self: if not payslip.struct_id or not payslip.struct_id.report_id: result[default_report] |= payslip else: result[payslip.struct_id.report_id] |= payslip return result def _get_email_template(self): return self.env.ref( 'hr_payroll.mail_template_new_payslip', raise_if_not_found=False ) def _check_send_payslip_mail(self): self.ensure_one() return True def _generate_pdf(self): mapped_reports = self._get_pdf_reports() attachments_vals_list = [] generic_name = _("Payslip") for report, payslips in mapped_reports.items(): for payslip in payslips: pdf_content, dummy = self.env['ir.actions.report'].sudo().with_context(lang=payslip.employee_id.lang or self.env.lang)._render_qweb_pdf(report, payslip.id) if report.print_report_name: pdf_name = safe_eval(report.print_report_name, {'object': payslip}) else: pdf_name = generic_name attachments_vals_list.append({ 'name': pdf_name, 'type': 'binary', 'raw': pdf_content, 'res_model': payslip._name, 'res_id': payslip.id }) self.env['ir.attachment'].sudo().create(attachments_vals_list) # Send email to employees (after attachment is created to include it in the mail by other bridge module) for payslips in mapped_reports.values(): for payslip in payslips: template = payslip._get_email_template() if template and payslip._check_send_payslip_mail(): template.send_mail(payslip.id, email_layout_xmlid='mail.mail_notification_light') def _filter_not_in_contract_payslips(self): return self.filtered( lambda p: not p.is_refund_payslip and p.version_id and p.date_from and p.date_to and ( not p.version_id.contract_date_start or p.version_id.contract_date_start > p.date_to or (p.version_id.contract_date_end and p.version_id.contract_date_end < p.date_from) )) def _get_payslip_send_trigger(self): """ Return when payslips should be generated and sent ('on_confirmed', 'on_paid', or 'never'). """ # Read the global parameter, fall back to on_confirmed to preserve the historical behaviour. return self.env['ir.config_parameter'].sudo().get_param( 'hr_payroll.payslip_generate_and_send_trigger', default='on_confirmed' ) def _queue_for_send(self): """ Mark payslips as queued for PDF generation and wake the cron. """ self.write({'queued_for_pdf': True}) # Do flush_all so the cron sees the updated queued_for_pdf flags right away. self.env.flush_all() payslip_cron = self.env.ref( 'hr_payroll.ir_cron_generate_payslip_pdfs', raise_if_not_found=False ) if payslip_cron: payslip_cron._trigger() def action_payslip_done(self): if any(slip.state == 'cancel' for slip in self): raise ValidationError(_("You can't confirm cancelled payslips.")) if self.filtered('error_count'): raise ValidationError(self._get_error_message()) self.write({ 'state': 'validated', 'done_date': fields.Datetime.now(), }) line_values = self._get_line_values(['NET']) self.filtered(lambda p: not p.credit_note and line_values['NET'][p.id]['total'] < 0).write({'has_negative_net_to_report': True}) # Validate work entries for regular payslips (exclude end of year bonus, ...) regular_payslips = self.filtered(lambda p: p.struct_id.type_id.default_struct_id == p.struct_id) work_entries_domain = Domain.OR([ [ ('date', '<=', regular_payslip.date_to), ('date', '>=', regular_payslip.date_from), ('employee_id', '=', regular_payslip.employee_id.id) ] for regular_payslip in regular_payslips ]) work_entries = self.env['hr.work.entry'].search(work_entries_domain) if work_entries: work_entries.action_validate() if self.env.context.get('payslip_generate_pdf'): if self.env.context.get('payslip_generate_pdf_direct') or ( len(self) == 1 and self._get_payslip_send_trigger() == 'on_confirmed' ): # Single payslip confirmed: generate immediately so it appears in the chatter right away. self._generate_pdf() elif self._get_payslip_send_trigger() == 'on_confirmed': # Batch confirmation: queue so the cron can spread the work. self._queue_for_send() def action_validate(self): self.filtered(lambda slip: slip.state == 'draft' and not slip.line_ids).compute_sheet() self.filtered(lambda slip: slip.state == 'draft').action_payslip_done() def action_payslip_cancel(self): if not self.env.user.has_group('hr_payroll.group_hr_payroll_manager') \ and self.filtered(lambda slip: slip.state == 'validated'): raise UserError(_("Cannot cancel a payslip that is validated.")) self.write({'state': 'cancel', 'done_date': False}) self.action_draft_linked_entries() def action_payslip_paid(self): if any(slip.state not in ['validated', 'paid'] for slip in self): raise UserError(_('Cannot mark payslip as paid if not confirmed.')) if self.filtered('error_count'): raise ValidationError(self._get_error_message()) self.filtered(lambda p: p.state != 'paid').write({ 'state': 'paid', 'paid_date': fields.Date.today(), }) # Send payslips now if the company chose to deliver them on payment. if self._get_payslip_send_trigger() == 'on_paid': self._queue_for_send() def action_payslip_payment_report(self, export_format='csv'): self.ensure_one() if len(self.payslip_run_id) > 1: raise UserError(_('The selected payslips should be linked to the same batch')) return { 'name': self.env._('Generate a Payment Report'), 'type': 'ir.actions.act_window', 'res_model': 'hr.payroll.payment.report.wizard', 'view_mode': 'form', 'views': [(False, 'form')], 'target': 'new', 'context': { 'default_payslip_ids': self.ids, 'default_payslip_run_id': self.payslip_run_id.id, 'default_export_format': export_format, }, } def action_payslip_unpaid(self): if any(slip.state != 'paid' for slip in self): raise UserError(_('You cannot cancel the payment if the payslip has not been paid.')) self.write({'state': 'validated'}) self.payslip_run_id.write({'state': '02_close'}) def action_open_work_entries(self): self.ensure_one() return self.employee_id.action_open_work_entries(initial_date=self.date_from) def action_open_salary_attachments(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Salary Adjustments'), 'res_model': 'hr.salary.attachment', 'view_mode': 'list,form', 'domain': [('id', 'in', self.salary_attachment_ids.ids)], } def action_keep_wrong_version(self): self.keep_wrong_version = True def action_adjust_payslip(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': self.env._('Payslip Correction'), 'res_model': 'hr.payslip.correction.wizard', 'view_mode': 'form', 'view_id': 'hr_payslip_correction_wizard_form', 'views': [(False, 'form')], 'target': 'new', 'context': { 'default_employee_id': self.employee_id.id, 'default_payslip_id': self.id, }, } def action_open_related_payslips(self): return { 'name': self.env._("Related Payslip"), 'res_model': 'hr.payslip', 'type': 'ir.actions.act_window', 'target': 'current', 'view_mode': 'list, form', 'views': [(False, 'list'), (False, 'form')], 'domain': [('id', 'in', self.related_payslip_ids.ids)], } def _get_payslips_action(self): formview_ref = self.env.ref('hr_payroll.view_hr_payslip_form', False) listview_ref = self.env.ref('hr_payroll.view_hr_payslip_tree', False) return { 'type': 'ir.actions.act_window', 'name': self.env._("Refund Payslip"), 'res_model': 'hr.payslip', 'target': 'current', 'view_mode': 'list,form', 'views': [(listview_ref.id, 'list'), (formview_ref.id, 'form')], 'domain': [('id', 'in', self.ids)], } def _action_refund_payslips(self): reverted_payslips = self.env['hr.payslip'] for payslip in self: reverted_payslip = payslip.copy({ 'credit_note': True, 'name': self.env._('Refund: %(payslip)s', payslip=payslip.name), 'edited': True, 'state': 'draft', 'origin_payslip_id': payslip.id, 'is_refund_payslip': True, }) for wd in reverted_payslip.worked_days_line_ids: wd.number_of_hours = -wd.number_of_hours wd.number_of_days = -wd.number_of_days wd.amount = -wd.amount for line in reverted_payslip.line_ids: line.amount = -line.amount line.total = -line.total reverted_payslips |= reverted_payslip payslip.message_post( body=self.env._('This is a refunded payslip.\nFind the refund under this name: %(payslip)s', payslip=reverted_payslip.name) ) payslip.is_refunded = True return reverted_payslips def _action_correct_payslips(self): corrected_payslips_values = [] for payslip in self: corrected_name = self.env._('Correction: %(payslip)s', payslip=payslip.name) corrected_payslips_values.append({ 'name': corrected_name, 'origin_payslip_id': payslip.id, 'employee_id': payslip.employee_id.id, 'version_id': payslip.employee_id._get_version(date=payslip.date_from).id, 'date_from': payslip.date_from, 'date_to': payslip.date_to, }) payslip.message_post( body=self.env._('This is a corrected payslip.\nFind the correction under this name: %(payslip)s', payslip=corrected_name)) payslip.is_corrected = True corrected_payslips = self.env['hr.payslip'].create(corrected_payslips_values) corrected_payslips.compute_sheet() return corrected_payslips def refund_sheet(self): reverted_payslips = self._action_refund_payslips() return (self | reverted_payslips)._get_payslips_action() def correct_sheet(self): reverted_payslips = self._action_refund_payslips() corrected_payslips = self._action_correct_payslips() return (self | corrected_payslips | reverted_payslips)._get_payslips_action() @api.ondelete(at_uninstall=False) def _unlink_if_draft_or_cancel(self): if any(payslip.state not in ('draft', 'cancel') for payslip in self): raise UserError(_( "Oops! Only draft and cancelled payslips can be deleted without causing any chaos. We can't " "take back our dedicated employees' hard-earned cash!" )) self.action_draft_linked_entries() def compute_sheet(self): """ Compute payslip lines for draft payslips sequentially by employee to ensure accurate cumulative/YTD calculations. When processing a batch, some employees may have multiple payslips in the same period (e.g., due to a mid-month contract version change). To ensure cumulative salary rules evaluate correctly, an employee's first payslip must be fully computed before their second payslip is evaluated. To do so, we group payslips by employee, sort them chronologically, and evaluate them in horizontal "layers": - Layer 1: Computes the 1st payslip for ALL employees simultaneously. - Layer 2: Computes the 2nd payslip for the subset of employees who have one, etc """ payslips = self.filtered(lambda slip: slip.state == 'draft') # delete old payslip lines payslips.line_ids.unlink() # this guarantees consistent results self.env.flush_all() today = fields.Date.today() for payslip in payslips: payslip.write({ 'state': 'draft', 'compute_date': today }) sorted_lists = [ sorted(v, key=lambda p: (p.version_id.date_version, p.date_from)) for v in payslips.grouped('employee_id').values() ] for batch_tuple in zip_longest(*sorted_lists): batch = self.env['hr.payslip'].union(*(p for p in batch_tuple if p)) self.env['hr.payslip.line'].create(batch._get_payslip_lines()) if any(payslips.mapped('ytd_computation')): self._compute_worked_days_ytd() return True def action_refresh_from_work_entries(self): # Refresh the whole payslip in case the HR has modified some work entries # after the payslip generation if any(p.state != 'draft' for p in self): raise UserError(_('The payslips should be in Draft or Waiting state.')) payslips = self.filtered(lambda p: not p.edited) payslips._compute_payslip_properties() payslips.mapped('worked_days_line_ids').unlink() payslips.mapped('line_ids').unlink() payslips._compute_worked_days_line_ids() payslips.compute_sheet() def action_move_to_off_cycle(self): self.payslip_run_id = False def _round_days(self, work_entry_type, days): if work_entry_type.round_days != 'NO': precision_rounding = 0.5 if work_entry_type.round_days == "HALF" else 1 day_rounded = float_round(days, precision_rounding=precision_rounding, rounding_method=work_entry_type.round_days_type) return day_rounded return days @api.model def _get_attachment_types(self): input_types = self.env['hr.payslip.input.type'].search([('available_in_attachments', '=', True)]) return {input_type.code: input_type for input_type in input_types} def _get_worked_day_lines_hours_per_day(self): self.ensure_one() calendar = self.version_id.resource_calendar_id or self.employee_id.resource_calendar_id or self.company_id.resource_calendar_id return calendar.hours_per_day def _get_worked_day_lines_hours_per_week(self): self.ensure_one() calendar = self.version_id.resource_calendar_id or self.employee_id._get_calendars()[self.employee_id.id] return calendar.hours_per_week def _get_out_of_contract_calendar(self): self.ensure_one() return self.version_id.resource_calendar_id or self.employee_id.resource_calendar_id or self.company_id.resource_calendar_id def _get_worked_day_lines_values(self, domain=None): self.ensure_one() res = [] hours_per_day = self._get_worked_day_lines_hours_per_day() work_hours = self.version_id.get_work_hours(self.date_from, self.date_to, domain=domain) work_hours_ordered = sorted(work_hours.items(), key=lambda x: x[1]) biggest_work = work_hours_ordered[-1][0] if work_hours_ordered else 0 add_days_rounding = 0 for work_entry_type_id, hours in work_hours_ordered: work_entry_type = self.env['hr.work.entry.type'].browse(work_entry_type_id) days = round(hours / hours_per_day, 5) if hours_per_day else 0 if work_entry_type_id == biggest_work: days += add_days_rounding day_rounded = self._round_days(work_entry_type, days) add_days_rounding += (days - day_rounded) attendance_line = { 'sequence': work_entry_type.sequence, 'work_entry_type_id': work_entry_type_id, 'number_of_days': day_rounded, 'number_of_hours': hours, } res.append(attendance_line) # Sort by Work Entry Type sequence work_entry_type = self.env['hr.work.entry.type'] return sorted(res, key=lambda d: work_entry_type.browse(d['work_entry_type_id']).sequence) def _get_worked_day_lines(self, domain=None, check_out_of_version=True): """ :returns: a list of dict containing the worked days values that should be applied for the given payslip """ self.ensure_one() version = self.version_id res = self._get_worked_day_lines_values(domain=domain) if not check_out_of_version: return res # If the version doesn't cover the whole month, create # worked_days lines to adapt the wage accordingly out_days, out_hours = 0, 0 reference_calendar = self._get_out_of_contract_calendar() out_periods = [] if not version.contract_date_start: start = fields.Datetime.to_datetime(self.date_from) stop = fields.Datetime.to_datetime(self.date_to) + relativedelta(hour=23, minute=59) out_periods.append((start, stop)) else: if self.date_from < version.date_start: start = fields.Datetime.to_datetime(self.date_from) stop = min(fields.Datetime.to_datetime(version.date_start) + relativedelta(days=-1, hour=23, minute=59), fields.Datetime.to_datetime(self.date_to) + relativedelta(hour=23, minute=59)) out_periods.append((start, stop)) if version.date_end and version.date_end < self.date_to: start = max(fields.Datetime.to_datetime(version.date_end) + relativedelta(days=1), fields.Datetime.to_datetime(self.date_from)) stop = fields.Datetime.to_datetime(self.date_to) + relativedelta(hour=23, minute=59) out_periods.append((start, stop)) for out_period in out_periods: out_time = reference_calendar.get_work_duration_data(out_period[0], out_period[1], compute_leaves=False, domain=['|', ('work_entry_type_id', '=', False), ('work_entry_type_id.is_leave', '=', False)]) out_days += out_time['days'] out_hours += out_time['hours'] work_entry_type = self.env.ref('hr_work_entry.hr_work_entry_type_out_of_contract', raise_if_not_found=False) if work_entry_type and (out_days or out_hours): res.append({ 'sequence': work_entry_type.sequence, 'work_entry_type_id': work_entry_type.id, 'number_of_days': out_days, 'number_of_hours': out_hours, }) return res @property def paid_amount(self): self.ensure_one() return self._get_paid_amount() @property def is_outside_contract(self): self.ensure_one() if self.env.context.get('salary_simulation'): return False return self._is_outside_contract_dates() def _rule_parameter(self, code, reference_date=False): self.ensure_one() if reference_date: return self.env['hr.rule.parameter']._get_parameter_from_code(code, reference_date) else: return self.env['hr.rule.parameter']._get_parameter_from_code(code, self.date_to) 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(pl.total) FROM hr_payslip as hp, hr_payslip_line as pl WHERE hp.employee_id = %s AND hp.state in ('validated', 'paid') AND hp.date_from >= %s AND hp.date_to <= %s AND hp.id = pl.slip_id AND pl.code = %s""", (self.employee_id.id, from_date, to_date, code)) res = self.env.cr.fetchone() return res and res[0] or 0.0 def _sum_category(self, code, from_date, to_date=None): self.ensure_one() if to_date is None: to_date = fields.Date.today() self.env['hr.payslip'].flush_model(['employee_id', 'state', 'date_from', 'date_to']) self.env['hr.payslip.line'].flush_model(['total', 'slip_id', 'salary_rule_id']) self.env['hr.salary.rule.category'].flush_model(['code']) self.env.cr.execute(""" SELECT sum(pl.total) FROM hr_payslip as hp, hr_payslip_line as pl, hr_salary_rule_category as rc, hr_salary_rule as sr WHERE hp.employee_id = %s AND hp.state in ('validated', 'paid') AND hp.date_from >= %s AND hp.date_to <= %s AND hp.id = pl.slip_id AND sr.id = pl.salary_rule_id AND rc.id = sr.category_id AND rc.code = %s""", (self.employee_id.id, from_date, to_date, code)) res = self.env.cr.fetchone() return res and res[0] or 0.0 def _sum_worked_days(self, code, from_date, to_date=None): self.ensure_one() if to_date is None: to_date = fields.Date.today() query = """ SELECT sum(hwd.amount) FROM hr_payslip hp, hr_payslip_worked_days hwd, hr_work_entry_type hwet WHERE hp.state in ('validated', 'paid') AND hp.id = hwd.payslip_id AND hwet.id = hwd.work_entry_type_id AND hp.employee_id = %(employee)s AND hp.date_to <= %(stop)s AND hwet.code = %(code)s AND hp.date_from >= %(start)s""" self.env.cr.execute(query, { 'employee': self.employee_id.id, 'code': code, 'start': from_date, 'stop': to_date}) res = self.env.cr.fetchone() return res[0] if res else 0.0 def _get_base_local_dict(self): return { 'float_round': float_round, 'float_compare': float_compare, 'relativedelta': safe_eval_dateutil.relativedelta.relativedelta, 'ceil': math.ceil, 'floor': math.floor, 'UserError': UserError, 'date': safe_eval_datetime.date, 'datetime': safe_eval_datetime.datetime, 'defaultdict': defaultdict, } def _get_localdict(self): self.ensure_one() # Check for multiple inputs of the same type and keep a copy of # them because otherwise they are lost when building the dict same_type_input_lines = defaultdict(lambda x: self.env['hr.payslip.input']) input_line_ids_by_code = self.input_line_ids.grouped('code') for code, input_lines in input_line_ids_by_code.items(): if len(input_lines) > 1: same_type_input_lines[code] = input_lines employee_properties = dict(self.version_id.payroll_properties) payslip_properties = dict(self.payslip_properties) employee_properties.update(payslip_properties) localdict = { **self._get_base_local_dict(), **{ 'categories': DefaultDictPayroll(lambda: 0), 'rules': DefaultDictPayroll(lambda: dict(total=0, amount=0, quantity=0)), 'payslip': self, 'worked_days': {line.code: line for line in self.worked_days_line_ids if line.code}, 'property_inputs': {self.env['hr.salary.rule'].browse(int(rule_id)).id: float(value) for rule_id, value in employee_properties.items() if rule_id.isdigit()}, 'inputs': {line.code: line for line in self.input_line_ids if line.code}, 'employee': self.employee_id, 'version': self.version_id, 'result_rules': DefaultDictPayroll(lambda: dict(total=0, amount=0, quantity=0, rate=0, ytd=0)), 'same_type_input_lines': same_type_input_lines, } } for code, input_lines in same_type_input_lines.items(): input_line_ids = input_lines.ids localdict['inputs'][code] = self.__get_aggregator_hr_payslip_input_model()( env=self.env, ids=input_line_ids, prefetch_ids=input_line_ids, ) return localdict def _get_payslip_line_total(self, amount, quantity, rate, rule): self.ensure_one() return amount * quantity * rate / 100.0 def _get_last_ytd_payslips(self): if not self: return self earliest_date_to = min(self.mapped('date_to')) earliest_ytd_date_to = min( company.get_last_ytd_reset_date(earliest_date_to) for company in self.company_id ) ytd_payslips_grouped = self.env['hr.payslip']._read_group( domain=[ ('employee_id', 'in', self.employee_id.ids), ('struct_id', 'in', self.struct_id.ids), ('ytd_computation', '=', True), ('date_to', '>=', earliest_ytd_date_to), ('date_to', '<=', max(self.mapped('date_to'))), ('state', 'in', ['validated', 'paid']), ], groupby=['employee_id', 'struct_id'], aggregates=['id:recordset'] ) ytd_payslips_sorted = defaultdict(lambda: self.env['hr.payslip']) for employee_id, struct_id, payslips in ytd_payslips_grouped: ytd_payslips_sorted[(employee_id, struct_id)] = payslips.sorted( key=lambda p: p.date_to, reverse=True ) last_ytd_payslips = defaultdict(lambda: self.env['hr.payslip']) for payslip in self: last_payslips = ytd_payslips_sorted[(payslip.employee_id, payslip.struct_id)].filtered( lambda p: p.date_to <= payslip.date_to ) if last_payslips and last_payslips[0].date_to >=\ payslip.company_id.get_last_ytd_reset_date(payslip.date_to): last_ytd_payslips[payslip] = last_payslips[0] return last_ytd_payslips def _get_payslip_lines(self): def get_rule_name(localdict, rule): if localdict['result_name']: return localdict['result_name'] if rule.amount_select == "input"\ and rule.amount_other_input_id.code in localdict['inputs']\ and (name := localdict['inputs'][rule.amount_other_input_id.code].name): return name return rule.name line_vals = [] if any(self.mapped('ytd_computation')): last_ytd_payslips = self._get_last_ytd_payslips() code_set = set(self.struct_id.rule_ids.mapped('code')) else: last_ytd_payslips = defaultdict(lambda: self.env['hr.payslip']) code_set = set() ytd_payslips = reduce( lambda ytd_payslips, payslip: ytd_payslips | payslip, last_ytd_payslips.values(), self.env['hr.payslip'] ) line_values = ytd_payslips._get_line_values(code_set, ['ytd']) for payslip in self: if lang := payslip.employee_id.lang: # /!\ Don't remove /!\ ensure that the employee will receive their payslip in their language. payslip = payslip.with_context(lang=lang) localdict = self.env.context.get('force_payslip_localdict', payslip._get_localdict()) result_rules_dict = localdict['result_rules'] blacklisted_rule_ids = self.env.context.get('prevent_payslip_computation_line_ids', []) result = {} for rule in sorted(payslip.struct_id.rule_ids, key=lambda x: x.sequence): if rule.id in blacklisted_rule_ids: continue localdict.update({ 'result': None, 'result_qty': 1.0, 'result_rate': 100, 'result_name': False }) if rule._satisfy_condition(localdict): if rule.code in localdict['same_type_input_lines']: for multi_line_rule in localdict['same_type_input_lines'][rule.code]: localdict['inputs'][rule.code] = multi_line_rule amount, qty, rate = rule._compute_rule(localdict) tot_rule = payslip._get_payslip_line_total(amount, qty, rate, rule) ytd = line_values[rule.code][last_ytd_payslips[payslip].id]['ytd'] + tot_rule result_rules_dict[rule.code]['total'] += tot_rule result_rules_dict[rule.code]['amount'] += tot_rule result_rules_dict[rule.code]['quantity'] = 1 result_rules_dict[rule.code]['rate'] = 100 result_rules_dict[rule.code]['ytd'] = ytd localdict = rule.category_id._sum_salary_rule_category( localdict, tot_rule) line_vals.append({ 'sequence': rule.sequence, 'code': rule.code, 'name': get_rule_name(localdict, rule), 'salary_rule_id': rule.id, 'version_id': localdict['version'].id, 'employee_id': localdict['employee'].id, 'amount': amount, 'quantity': qty, 'rate': rate, 'total': tot_rule, 'slip_id': payslip.id, 'ytd': ytd, }) input_line_ids = localdict['same_type_input_lines'][rule.code].ids localdict['inputs'][rule.code] = self.__get_aggregator_hr_payslip_input_model()( env=self.env, ids=input_line_ids, prefetch_ids=input_line_ids, ) else: amount, qty, rate = rule._compute_rule(localdict) #check if there is already a rule computed with that code previous_amount = localdict.get(rule.code, 0.0) #set/overwrite the amount computed for this rule in the localdict tot_rule = payslip._get_payslip_line_total(amount, qty, rate, rule) ytd = line_values[rule.code][last_ytd_payslips[payslip].id]['ytd'] + tot_rule localdict[rule.code] = tot_rule result_rules_dict[rule.code] = { 'total': tot_rule, 'amount': amount, 'quantity': qty, 'rate': rate, 'ytd': ytd } # sum the amount for its salary category localdict = rule.category_id._sum_salary_rule_category(localdict, tot_rule - previous_amount) # create/overwrite the rule in the temporary results result[rule.code] = { 'sequence': rule.sequence, 'code': rule.code, 'name': get_rule_name(localdict, rule), 'salary_rule_id': rule.id, 'version_id': localdict['version'].id, 'employee_id': localdict['employee'].id, 'amount': amount, 'quantity': qty, 'rate': rate, 'total': tot_rule, 'slip_id': payslip.id, 'ytd': ytd, } line_vals += list(result.values()) return line_vals def _compute_worked_days_ytd(self): last_ytd_payslips = self._get_last_ytd_payslips() ytd_payslips = reduce( lambda ytd_payslips, payslip: ytd_payslips | payslip, last_ytd_payslips.values(), self.env['hr.payslip'] ) code_set = set(self.worked_days_line_ids.mapped('code')) worked_days_line_values = ytd_payslips._get_worked_days_line_values(code_set, ['ytd']) for payslip in self: for worked_days in payslip.worked_days_line_ids: worked_days.ytd = worked_days_line_values[worked_days.code][ last_ytd_payslips[payslip].id ]['ytd'] + worked_days.amount @api.depends('employee_id') def _compute_company_id(self): for slip in self.filtered(lambda p: p.employee_id): slip.company_id = slip.employee_id.company_id @api.depends('employee_id', 'date_from') def _compute_version_id(self): for slip in self: slip.version_id = slip.employee_id._get_version(slip.date_from) if slip.employee_id else False @api.depends('version_id') def _compute_struct_id(self): for slip in self.filtered(lambda p: not p.struct_id): slip.struct_id = slip.version_id.structure_type_id.default_struct_id\ or slip.employee_id.version_id.structure_type_id.default_struct_id def _search_country_id(self, operator, value): return [('company_id.partner_id.country_id', operator, value)] def _get_period_name(self, cache): self.ensure_one() period_name = '%s - %s' % ( self._format_date_cached(cache, self.date_from), self._format_date_cached(cache, self.date_to)) if self.is_wrong_duration: return period_name start_date = self.date_from end_date = self.date_to lang = self.employee_id.lang or self.env.user.lang week_start = self.env["res.lang"]._get_data(code=lang).week_start schedule = self.version_id.schedule_pay or self.version_id.structure_type_id.default_schedule_pay if schedule == 'monthly': period_name = self._format_date_cached(cache, start_date, "MMMM Y") elif schedule == 'quarterly': current_year_quarter = math.ceil(start_date.month / 3) period_name = _("Quarter %(quarter)s of %(year)s", quarter=current_year_quarter, year=start_date.year) elif schedule == 'semi-annually': year_half = start_date.replace(day=1, month=6) is_first_half = start_date < year_half period_name = _("1st semester of %s", start_date.year)\ if is_first_half\ else _("2nd semester of %s", start_date.year) elif schedule == 'annually': period_name = start_date.year elif schedule == 'weekly': wk_num = start_date.strftime('%U') if week_start == '7' else start_date.strftime('%W') period_name = _('Week %(week_number)s of %(year)s', week_number=wk_num, year=start_date.year) elif schedule == 'bi-weekly': week = int(start_date.strftime("%U") if week_start == '7' else start_date.strftime("%W")) first_week = week - 1 + week % 2 period_name = _("Weeks %(week)s and %(week1)s of %(year)s", week=first_week, week1=first_week + 1, year=start_date.year) elif schedule == 'bi-monthly': start_date_string = self._format_date_cached(cache, start_date, "MMMM Y") end_date_string = self._format_date_cached(cache, end_date, "MMMM Y") period_name = _("%(start_date_string)s and %(end_date_string)s", start_date_string=start_date_string, end_date_string=end_date_string) return period_name def _format_date_cached(self, cache, date, date_format=False): lang = self.employee_id.lang or self.env.user.lang key = (date, date_format, lang) if key not in cache: cache[key] = format_date(env=self.env, value=date, lang_code=lang, date_format=date_format) return cache[key] @api.depends('employee_id.legal_name', 'employee_id.lang', 'struct_id', 'date_from', 'date_to') def _compute_name(self): formated_date_cache = {} for slip in self.filtered(lambda p: p.employee_id and p.date_from and p.date_to): lang = slip.employee_id.lang or self.env.user.lang context = {'lang': lang} payslip_name = slip.struct_id.payslip_name or _('Salary Slip') del context slip.name = '%(payslip_name)s - %(employee_name)s - %(dates)s' % { 'payslip_name': payslip_name, 'employee_name': slip.employee_id.legal_name, 'dates': slip._get_period_name(formated_date_cache), } @api.model def _issues_dependencies(self): return [ 'state', 'date_to', 'date_from', 'employee_id', 'version_id', 'version_id.contract_date_start', 'version_id.contract_date_end', 'version_id.schedule_pay', 'struct_id', 'version_id.structure_type_id.default_schedule_pay', 'company_id', 'payslip_run_id.company_id', 'employee_id.bank_account_ids', 'employee_id.bank_account_ids.allow_out_payment', ] def _get_errors_by_slip(self): by_state = self.grouped('state') draft_slips = by_state.get('draft', self.env['hr.payslip']) errors_by_slip = {slip: [] for slip in self} for slip in draft_slips._filter_not_in_contract_payslips(): errors_by_slip[slip].append({ 'message': _('No running contract over payslip period'), 'action_text': _("Contract"), 'action': slip.employee_id._get_records_action( name=self.env._("Employee"), target='new', context={**self.env.context, 'version_id': slip.version_id.id} ), 'level': 'danger', }) for slip in draft_slips.filtered(lambda slip: slip.payslip_run_id and slip.company_id != slip.payslip_run_id.company_id): errors_by_slip[slip].append({ 'message': _("The payslip's company doesn't match the batch's"), 'action_text': _('Batch'), 'action': slip.payslip_run_id._get_records_action(), 'level': 'danger', }) return errors_by_slip def _get_warnings_by_slip(self): similar_payslips = self._get_similar_payslips() warnings_by_slip = {slip: [] for slip in self} for slip in self.filtered(lambda s: ( s.state in ['draft', 'validated'] and s.date_from and s.date_to )): warnings = [] if slip.struct_id.use_worked_day_lines \ and (slip.version_id.schedule_pay or slip.version_id.structure_type_id.default_schedule_pay) \ and slip.date_from \ and slip.date_from + slip._get_schedule_timedelta() != slip.date_to: warnings.append({ 'message': _("The duration of the payslip is not accurate according to the structure type."), 'level': 'warning', }) if slip.version_id and slip.struct_id and slip.date_from and slip.date_to: key = (slip.version_id.id, slip.struct_id.id, slip.date_from, slip.date_to) duplicates = similar_payslips[key].filtered(lambda dup: dup.id != slip.id) # Ignore duplicate warning if this slip is a refund of the original if duplicates: related_payslips = self.env['hr.payslip'] if slip.origin_payslip_id: related_payslips |= slip.origin_payslip_id | slip.origin_payslip_id.related_payslip_ids if slip.related_payslip_ids: related_payslips |= slip.related_payslip_ids duplicates -= related_payslips if duplicates: warnings.append({ 'message': _("Similar payslips found"), 'action_text': _('Duplicate(s)'), 'action': duplicates._get_records_action(), 'level': 'warning', }) warnings_by_slip[slip] = warnings # Payment report related errors for employee_banks, slips in self.filtered( lambda ps: ps.state == 'validated' ).grouped( lambda ps: ps.employee_id.bank_account_ids ).items(): if not employee_banks: for slip in slips: warnings_by_slip[slip].append({ 'message': _("Missing bank account on employee"), 'action_text': _('Employee'), 'action': slip.employee_id._get_records_action(), 'level': 'warning', }) elif any(not b.allow_out_payment for b in employee_banks): warning = { 'message': _("Untrusted bank accounts"), 'action_text': _('Bank Accounts'), 'action': employee_banks._get_records_action(), 'level': 'warning', } for slip in slips: warnings_by_slip[slip].append(warning) return warnings_by_slip @api.depends(lambda self: self._issues_dependencies()) def _compute_issues(self): to_compute_payslips = self.filtered(lambda p: p.state in ('draft', 'validated')).with_prefetch() errors_by_slip = to_compute_payslips._get_errors_by_slip() warnings_by_slip = to_compute_payslips._get_warnings_by_slip() for slip in to_compute_payslips: warnings = warnings_by_slip[slip] errors = errors_by_slip[slip] slip.warning_count = len(warnings) slip.error_count = len(errors) if not errors and not warnings: slip.issues = {} else: slip.issues = dict(enumerate(errors + warnings)) def _get_error_message(self): if len(self) == 1: return '\n'.join([ f' • {issue["message"]}' for issue in self.issues.values() if issue['level'] == 'danger' ]) return '\n'.join([ f' • {slip.name}: {issue["message"]}' for slip in self for issue in (slip.issues or {}).values() if issue['level'] == 'danger' ]) @api.depends('date_from', 'date_to', 'struct_id') def _compute_is_wrong_duration(self): for slip in self: slip.is_wrong_duration = slip.date_to and ( slip.version_id.schedule_pay or slip.version_id.structure_type_id.default_schedule_pay ) and ( slip.date_from + slip._get_schedule_timedelta() != slip.date_to ) @api.depends('employee_id', 'version_id', 'struct_id', 'date_from', 'date_to') def _compute_worked_days_line_ids(self): if not self or self.env.context.get('salary_simulation'): return # Reset worked days before filtering valid slips to clear lines on structures without worked days self.update({'worked_days_line_ids': [(5, 0, 0)]}) valid_slips = self.filtered(lambda p: p.employee_id and p.date_from and p.date_to and p.version_id and p.struct_id and p.struct_id.use_worked_day_lines) if not valid_slips: return # Ensure work entries are generated for all contracts generate_from = min(p.date_from for p in valid_slips) + relativedelta(days=-1) generate_to = max(p.date_to for p in valid_slips) + relativedelta(days=1) self.version_id.generate_work_entries(generate_from, generate_to) work_entries = self.env['hr.work.entry'].search([ ('date', '<=', generate_to), ('date', '>=', generate_from), ('version_id', 'in', self.version_id.ids), ]) work_entries_by_version = defaultdict(lambda: self.env['hr.work.entry']) for work_entry in work_entries: work_entries_by_version[work_entry.version_id.id] += work_entry for slip in valid_slips: if not slip.struct_id.use_worked_day_lines: continue # convert slip.date_to to a datetime with max time to compare correctly in filtered_domain. slip_tz = pytz.timezone(slip.version_id.resource_calendar_id.tz or slip.employee_id.tz or slip.company_id.resource_calendar_id.tz or 'UTC') utc = pytz.timezone('UTC') date_from = slip_tz.localize(datetime.combine(slip.date_from, time.min)).astimezone(utc).replace(tzinfo=None) date_to = slip_tz.localize(datetime.combine(slip.date_to, time.max)).astimezone(utc).replace(tzinfo=None) payslip_work_entries = work_entries_by_version[slip.version_id].filtered_domain([ ('date', '<=', date_to), ('date', '>=', date_from), ]) payslip_work_entries._check_undefined_slots(slip.date_from, slip.date_to) # YTI Note: We can't use a batched create here as the payslip may not exist slip.update({'worked_days_line_ids': slip._get_new_worked_days_lines()}) def _get_similar_payslips(self): done_payslips = self.filtered(lambda p: p.version_id and p.struct_id and p.date_from and p.date_to) search_domain = [ ('struct_id', 'in', done_payslips.struct_id.ids), ('date_from', 'in', done_payslips.mapped('date_from')), ('date_to', 'in', done_payslips.mapped('date_to')), ('state', 'in', ['validated', 'paid']), ('version_id', 'in', done_payslips.version_id.ids), ] all_existing_payslips = self.env['hr.payslip'].search(search_domain) # Group existing slips for easy lookup existing_payslip_map = defaultdict(lambda: self.env['hr.payslip']) for slip in all_existing_payslips: key = (slip.version_id.id, slip.struct_id.id, slip.date_from, slip.date_to) existing_payslip_map[key] |= slip return existing_payslip_map def _get_new_worked_days_lines(self): if self.struct_id.use_worked_day_lines: return [(0, 0, vals) for vals in self._get_worked_day_lines()] return [] def _get_salary_line_total(self, code): _logger.warning('The method _get_salary_line_total is deprecated in favor of _get_line_values') lines = self.line_ids.filtered(lambda line: line.code == code) return sum([line.total for line in lines]) def _get_salary_line_quantity(self, code): _logger.warning('The method _get_salary_line_quantity is deprecated in favor of _get_line_values') lines = self.line_ids.filtered(lambda line: line.code == code) return sum([line.quantity for line in lines]) def _get_line_values(self, code_list, vals_list=None, compute_sum=False): if vals_list is None: vals_list = ['total'] valid_values = {'quantity', 'amount', 'total', 'ytd'} if set(vals_list) - valid_values: raise UserError(_('The following values are not valid:\n%s', '\n'.join(list(set(vals_list) - valid_values)))) result = defaultdict(lambda: defaultdict(lambda: dict.fromkeys(vals_list, 0.0))) if not self or not code_list: return result payslip_line_read_group = self.env['hr.payslip.line']._read_group( [ ('slip_id', 'in', self.ids), ('code', 'in', code_list), ], ['slip_id', 'code'], [f'{f_name}:sum' for f_name in vals_list], ) # result = { # 'IP': { # 'sum': {'quantity': 2, 'total': 300}, # 1: {'quantity': 1, 'total': 100}, # 2: {'quantity': 1, 'total': 200}, # }, # 'IP.DED': { # 'sum': {'quantity': 2, 'total': -5}, # 1: {'quantity': 1, 'total': -2}, # 2: {'quantity': 1, 'total': -3}, # }, # } for payslip, code, *sum_vals_list in payslip_line_read_group: for vals, vals_value in zip(vals_list, sum_vals_list): if compute_sum: result[code]['sum'][vals] += vals_value or 0.0 result[code][payslip.id][vals] += vals_value or 0.0 return result def _get_worked_days_line_values_orm(self, code, field_name=None): """ Use this method only if self is not yet created. Otherwise, use '_get_worked_days_line_values' that leads to better performances. """ if field_name is None: field_name = 'amount' valid_values = {'number_of_hours', 'number_of_days', 'amount', 'ytd'} if field_name not in valid_values: raise UserError(_('The field is not valid:%s', field_name)) wds = self.worked_days_line_ids.filtered(lambda wd: wd.code == code) return sum(wd[field_name] for wd in wds) def _get_worked_days_line_values(self, code_list, vals_list=None, compute_sum=False, exclude_codes=False): if vals_list is None: vals_list = ['amount'] valid_values = {'number_of_hours', 'number_of_days', 'amount', 'ytd'} if set(vals_list) - valid_values: raise UserError(_('The following values are not valid:\n%s', '\n'.join(list(set(vals_list) - valid_values)))) result = defaultdict(lambda: defaultdict(lambda: dict.fromkeys(vals_list, 0.0))) if not self or (not code_list and not exclude_codes): return result self.env.flush_all() selected_fields = ','.join('SUM(%s) AS %s' % (vals, vals) for vals in vals_list) operation = 'IN' if not exclude_codes else 'NOT IN' self.env.cr.execute(""" SELECT p.id, wet.code, %s FROM hr_payslip_worked_days wd JOIN hr_work_entry_type wet ON wet.id = wd.work_entry_type_id JOIN hr_payslip p ON p.id IN %s AND wd.payslip_id = p.id AND wet.code %s %s GROUP BY p.id, wet.code """ % (selected_fields, '%s', operation, '%s'), (tuple(self.ids), tuple(code_list))) # self = hr.payslip(1, 2) # request_rows = [ # {'id': 1, 'code': 'WORK100', 'amount': 100, 'number_of_days': 1}, # {'id': 1, 'code': 'LEAVE100', 'amount': 200, 'number_of_days': 1}, # {'id': 2, 'code': 'WORK100', 'amount': -2, 'number_of_days': 1}, # {'id': 2, 'code': 'LEAVE100', 'amount': -3, 'number_of_days': 1} # ] request_rows = self.env.cr.dictfetchall() # result = { # 'IP': { # 'sum': {'number_of_days': 2, 'amount': 300}, # 1: {'number_of_days': 1, 'amount': 100}, # 2: {'number_of_days': 1, 'amount': 200}, # }, # 'LEAVE100': { # 'sum': {'number_of_days': 2, 'amount': -5}, # 1: {'number_of_days': 1, 'amount': -2}, # 2: {'number_of_days': 1, 'amount': -3}, # }, # } for row in request_rows: code = row['code'] payslip_id = row['id'] for vals in vals_list: if compute_sum: result[code]['sum'][vals] += row[vals] or 0.0 result[code][payslip_id][vals] += row[vals] or 0.0 return result # YTI TODO: Convert in a single SQL request + Handle children def _get_category_data(self, category_code): category_data = {'quantity': 0.0, 'total': 0.0} for line in self.line_ids: if line.category_id.code == category_code: category_data['quantity'] += line.quantity category_data['total'] += line.total return category_data def _get_input_line_amount(self, code): lines = self.input_line_ids.filtered(lambda line: line.code == code) return sum([line.amount for line in lines]) @api.model def get_views(self, views, options=None): res = super().get_views(views, options) if options and options.get('toolbar'): for view_type in res['views']: res['views'][view_type]['toolbar'].pop('print', None) return res def action_print_payslip(self): if self.filtered('error_count'): raise ValidationError(self._get_error_message()) return { 'name': 'Payslip', 'type': 'ir.actions.act_url', 'url': '/print/payslips?list_ids=%(list_ids)s' % {'list_ids': ','.join(str(x) for x in self.ids)}, } def action_export_payslip(self): pass def _get_contract_wage(self): self.ensure_one() return self.version_id._get_contract_wage() def _get_paid_amount(self): self.ensure_one() if self.env.context.get('no_paid_amount'): return 0.0 if self.env.context.get('salary_simulation') or not self.struct_id.use_worked_day_lines: return self._get_contract_wage() total_amount = 0 for line in self.worked_days_line_ids: total_amount += line.amount return total_amount def _get_unpaid_amount(self): self.ensure_one() return self._get_contract_wage() - self._get_paid_amount() def _is_outside_contract_dates(self): self.ensure_one() payslip = self contract = self.version_id return contract.date_start > payslip.date_to or (contract.date_end and contract.date_end < payslip.date_from) def _get_data_files_to_update(self): # Note: Use lists as modules/files order should be maintained return [] def _update_payroll_data(self): data_to_update = self._get_data_files_to_update() _logger.info("Update payroll static data") idref = {} for module_name, files_to_update in data_to_update: for file_to_update in files_to_update: convert_file(self.env, module_name, file_to_update, idref) def action_edit_payslip_lines(self): self.ensure_one() if not self.env.user.has_group('hr_payroll.group_hr_payroll_user'): raise UserError(_('This action is restricted to payroll officers only.')) if self.state == 'validated': raise UserError(_('This action is forbidden on validated payslips.')) wizard = self.env['hr.payroll.edit.payslip.lines.wizard'].create({ 'payslip_id': self.id, 'line_ids': [(0, 0, { 'sequence': line.sequence, 'code': line.salary_rule_id.code, 'name': line.name, 'salary_rule_id': line.salary_rule_id.id, 'version_id': line.version_id.id, 'employee_id': line.employee_id.id, 'amount': line.amount, 'quantity': line.quantity, 'rate': line.rate, 'ytd': line.ytd, 'slip_id': self.id}) for line in self.line_ids], 'worked_days_line_ids': [(0, 0, { 'name': line.name, 'sequence': line.sequence, 'code': line.code, 'work_entry_type_id': line.work_entry_type_id.id, 'number_of_days': line.number_of_days, 'number_of_hours': line.number_of_hours, 'amount': line.amount, 'ytd': line.ytd, 'slip_id': self.id}) for line in self.worked_days_line_ids] }) return { 'type': 'ir.actions.act_window', 'name': _('Edit Payslip Lines'), 'res_model': 'hr.payroll.edit.payslip.lines.wizard', 'view_mode': 'form', 'target': 'new', 'binding_model_id': self.env['ir.model.data']._xmlid_to_res_id('hr_payroll.model_hr_payslip'), 'binding_view_types': 'form', 'res_id': wizard.id } @api.model def _cron_generate_pdf(self, batch_size=False): payslips = self.search([ ('state', 'in', ['validated', 'paid']), ('queued_for_pdf', '=', True), ]) if payslips: BATCH_SIZE = batch_size or 30 payslips_batch = payslips[:BATCH_SIZE] payslips_batch._generate_pdf() payslips_batch.write({'queued_for_pdf': False}) # if necessary, retrigger the cron to generate more pdfs if len(payslips) > BATCH_SIZE: self.env.ref('hr_payroll.ir_cron_generate_payslip_pdfs')._trigger() return True lines = self.env['hr.payroll.employee.declaration'].search([('pdf_to_generate', '=', True)]) if lines: BATCH_SIZE = batch_size or 30 lines_batch = lines[:BATCH_SIZE] lines_batch._generate_pdf() lines_batch.write({'pdf_to_generate': False}) # if necessary, retrigger the cron to generate more pdfs if len(lines) > BATCH_SIZE: self.env.ref('hr_payroll.ir_cron_generate_payslip_pdfs')._trigger() return True return False @api.model def __get_aggregator_hr_payslip_input_model(self): """this method return an aggregator version of hr.payslip.input Model. this method is also meant to be overriden in case other modules adds new fields to hr.payslip.input model. In case it can be extended as such: ```py def __get_aggregator_hr_payslip_input_model(self): class ProxyHrPayslipInput(super().__get_aggregator_hr_payslip_input_model()): ... # your modifications return ProxyHrPayslipInput ``` Note: -this method use the normal python class inheritance instead of odoo's -the aggregation assumes that the code are the same for all the inputs Warning: Do not reproduce elsewhere it's quite a specific case here return: Aggregator version of hr.payslip.input model """ class ProxyHrPayslipInput(self.env['hr.payslip.input'].__class__): _name = self.env['hr.payslip.input']._name _register = False # invisible to the ORM # All the fields that are not overridden are considered as equal to # self[0]['field'] def __getitem__(self, key_or_slice): if not self or len(self) == 1: return super().__getitem__(key_or_slice) if key_or_slice == 'name': return self.name if key_or_slice == 'sequence': return self.sequence if key_or_slice == 'amount': return self.amount return super().__getitem__(key_or_slice) @property def name(self): if not self or len(self) == 1: return super().name if len(set(self.mapped('name'))) == 1: return super(ProxyHrPayslipInput, self[0]).name return ', '.join(self.mapped('name')) @property def sequence(self): if not self or len(self) == 1: return super().sequence return min(self.mapped('sequence')) @property def amount(self): if not self or len(self) == 1: return super().amount return sum([payslip_input.amount for payslip_input in self]) return ProxyHrPayslipInput # Payroll Dashboard @api.model def _dashboard_default_action(self, name, res_model, res_ids, additional_context=None): if additional_context is None: additional_context = {} return { 'type': 'ir.actions.act_window', 'name': name, 'res_model': res_model, 'context': {**self.env.context, **additional_context}, 'domain': [('id', 'in', res_ids)], 'views': [[False, 'list'], [False, 'kanban'], [False, 'form']], 'view_mode': 'list,kanban,form', } @api.model def _get_dashboard_warnings_domain(self): return [] @api.model def get_dashboard_warnings(self): # Retrieve the different warnings to display on the actions section (box on the left) result = [] # Retrieves last batches (this month, or last month) batch_limit_date = fields.Date.today() - relativedelta(months=1, day=1) batch_group_read = self.env['hr.payslip.run'].with_context(lang='en_US')._read_group( [('date_start', '>=', fields.Date.today() - relativedelta(months=1, day=1))], groupby=['date_start:month'], order='date_start:month desc') # Keep only the last month batch_group_read = batch_group_read[:1] if batch_group_read: min_date = batch_group_read[-1][0] or batch_limit_date last_batches = self.env['hr.payslip.run'].search([('date_start', '>=', min_date)]) else: last_batches = self.env['hr.payslip.run'] for warning in self.env['hr.payroll.dashboard.warning'].search(self._get_dashboard_warnings_domain()): context = { 'date': safe_eval_datetime.date, 'datetime': safe_eval_datetime.datetime, 'relativedelta': safe_eval_dateutil.relativedelta.relativedelta, 'warning_count': 0, 'warning_records': self.env['base'], 'warning_action': False, 'additional_context': {}, 'self': self.env['hr.payslip'], 'last_batches': last_batches, 'defaultdict': defaultdict, } try: safe_eval(warning.evaluation_code, context, mode='exec') except Exception as e: raise UserError(_("Wrong warning computation code defined for:\n- Warning: %(warning)s\n- Error: %(error)s", warning=warning.name, error=e)) if context['warning_count']: result.append({ 'string': warning.name, 'color': warning.color, 'count': context['warning_count'], 'action': context['warning_action'] or self._dashboard_default_action( warning.name, context['warning_records']._name, context['warning_records'].ids, additional_context=context['additional_context'], ), }) return result def _get_employee_stats_actions(self): result = [] today = fields.Date.today() HrVersion = self.env['hr.version'] new_versions = HrVersion.search([('contract_date_start', '>=', today + relativedelta(months=-3, day=1))]) past_versions_grouped_by_employee_id = { employee.id for [employee] in HrVersion._read_group([ ('employee_id', 'in', new_versions.employee_id.ids), # ('date_end', '<', today), # TODO: filter after ('id', 'not in', new_versions.ids) ], groupby=['employee_id']) } new_versions_without_past_version = HrVersion for new_version in new_versions: if new_version.employee_id.id not in past_versions_grouped_by_employee_id: new_versions_without_past_version |= new_version if new_versions_without_past_version: new_versions_str = _('New Employees') employees_from_new_versions = new_versions_without_past_version.mapped('employee_id') new_employees = { 'string': new_versions_str, 'count': len(employees_from_new_versions), 'action': self._dashboard_default_action(new_versions_str, 'hr.employee', employees_from_new_versions.ids), } new_employees['action']['views'][0] = [self.env.ref('hr_payroll.payroll_hr_employee_view_tree_employee_trends').id, 'list'] result.append(new_employees) gone_employees = self.env['hr.employee'].with_context(active_test=False).search([ ('departure_date', '>=', today + relativedelta(months=-1, day=1)), ('company_id', 'in', self.env.companies.ids), ]) if gone_employees: gone_employees_str = _('Last Departures') result.append({ 'string': gone_employees_str, 'count': len(gone_employees), 'action': self.with_context(active_test=False)._dashboard_default_action( gone_employees_str, 'hr.employee', gone_employees.ids), }) return result @api.model def _get_dashboard_stat_employer_cost_codes(self): costs = self.env['hr.salary.rule'].search_read([ ('appears_on_employee_cost_dashboard', '=', True)], fields=['code', 'name']) cost_codes = {} for cost in costs: cost_codes[cost['code']] = cost['name'] return cost_codes @api.model def _get_dashboard_stats_employer_cost(self): today = fields.Date.context_today(self) date_formats = { 'monthly': 'MMMM y', 'yearly': 'y', } employer_cost = { 'type': 'stacked_bar', 'title': _('Employer Cost'), 'label': _('Employer Cost'), 'id': 'employer_cost', 'is_sample': False, 'actions': [], 'data': { 'monthly': defaultdict(lambda: [{}, {}, {}]), 'yearly': defaultdict(lambda: [{}, {}, {}]), }, } # Retrieve employer costs over the last 3 months last_payslips = self.env['hr.payslip'].search([ ('state', '!=', 'cancel'), ('date_from', '>=', fields.Date.today() + relativedelta(months=-2, day=1)), ('date_to', '<=', fields.Date.today() + relativedelta(day=31)) ]) if not last_payslips: employer_cost['is_sample'] = True cost_codes = self._get_dashboard_stat_employer_cost_codes() line_values = last_payslips._get_line_values(cost_codes.keys()) for slip in last_payslips: for code, code_desc in cost_codes.items(): start = slip.date_from end = today idx = -((end.year - start.year) * 12 + (end.month - start.month) - 2) amount = employer_cost['data']['monthly'][code_desc][idx].get('value', 0.0) amount += line_values[code][slip.id]['total'] employer_cost['data']['monthly'][code_desc][idx]['value'] = amount if not employer_cost['data']['monthly'][code_desc][idx].get('label'): period_str = format_date(self.env, start, date_format=date_formats['monthly']) employer_cost['data']['monthly'][code_desc][idx]['label'] = period_str # Retrieve employer costs over the last 3 years last_payslips = self.env['hr.payslip'].search([ ('state', '!=', 'cancel'), ('date_from', '>=', fields.Date.today() + relativedelta(years=-2, day=1)), ('date_to', '<=', fields.Date.today() + relativedelta(month=12, day=31)) ]) line_values = last_payslips._get_line_values(cost_codes.keys()) for slip in last_payslips: for code, code_desc in cost_codes.items(): start = slip.date_from end = today idx = -(end.year - start.year - 2) amount = employer_cost['data']['yearly'][code_desc][idx].get('value', 0.0) amount += line_values[code][slip.id]['total'] employer_cost['data']['yearly'][code_desc][idx]['value'] = amount if not employer_cost['data']['yearly'][code_desc][idx].get('label'): period_str = format_date(self.env, start, date_format=date_formats['yearly']) employer_cost['data']['yearly'][code_desc][idx]['label'] = period_str # Nullify empty sections for i in range(3): for code_desc in cost_codes.values(): if not employer_cost['data']['monthly'][code_desc][i]: value = 0 if not employer_cost['is_sample'] else random.randint(1000, 1500) employer_cost['data']['monthly'][code_desc][i]['value'] = value if not employer_cost['data']['monthly'][code_desc][i].get('label'): label = format_date(self.env, today + relativedelta(months=i-2), date_format=date_formats['monthly']) employer_cost['data']['monthly'][code_desc][i]['label'] = label if not employer_cost['data']['yearly'][code_desc][i]: value = 0 if not employer_cost['is_sample'] else random.randint(10000, 15000) employer_cost['data']['yearly'][code_desc][i]['value'] = value if not employer_cost['data']['yearly'][code_desc][i].get('label'): label = format_date(self.env, today + relativedelta(years=i-2), date_format=date_formats['yearly']) employer_cost['data']['yearly'][code_desc][i]['label'] = label # Format/Round at the end as the method cost is heavy for data_by_code in employer_cost['data'].values(): for data_by_type in data_by_code.values(): for data_dict in data_by_type: value = round(data_dict['value'], 2) data_dict['value'] = value data_dict['formatted_value'] = format_amount(self.env, value, self.env.company.currency_id) return employer_cost @api.model def _get_dashboard_stat_employee_trends(self): today = fields.Date.context_today(self) employees_trends = { 'type': 'bar', 'title': _('Employee Count'), 'label': _('Employee Count'), 'id': 'employees', 'is_sample': False, 'actions': self._get_employee_stats_actions(), 'data': { 'monthly': [{}, {}, {}], 'yearly': [{}, {}, {}], }, } # These are all the periods for which we need data periods = [ # Last month (today - relativedelta(months=1, day=1), today - relativedelta(day=1, days=1), 'monthly,past'), # This month (today - relativedelta(day=1), today + relativedelta(months=1, day=1, days=-1), 'monthly,present'), # Next month (today + relativedelta(months=1, day=1), today + relativedelta(months=2, day=1, days=-1), 'monthly,future'), # Last year (today - relativedelta(years=1, month=1, day=1), today - relativedelta(years=1, month=12, day=31), 'yearly,past'), # This year (today - relativedelta(month=1, day=1), today + relativedelta(month=12, day=31), 'yearly,present'), # Next year (today + relativedelta(years=1, month=1, day=1), today + relativedelta(years=1, month=12, day=31), 'yearly,future'), ] periods_str = ', '.join( "(DATE '%(date_from)s', DATE '%(date_to)s', '%(date_type)s')" % { 'date_from': p[0].strftime('%Y-%m-%d'), 'date_to': p[1].strftime('%Y-%m-%d'), 'date_type': p[2], } for p in periods) # Fetch our statistics # Contracts are joined by our period using the usual state/date conditions # and aggregates are used to collect data directly from our database # avoiding unnecessary orm overhead # TODO: should filter with date_start, date_end via orm or is it already good ? will this stay at all ? self.env.cr.execute(""" WITH periods AS ( SELECT * FROM (VALUES %s ) x(start, _end, _type) ) -- fetch all contracts matching periods from `periods` SELECT p.start, p._end, p._type, ARRAY_AGG(v.id), COUNT (DISTINCT v.employee_id) as employee_count FROM periods p JOIN hr_version v ON (v.contract_date_end >= p.start OR v.contract_date_end IS NULL) AND v.contract_date_start <= p._end JOIN hr_employee e ON e.id = v.employee_id AND e.company_id IN %%s GROUP BY p.start, p._end, p._type """ % (periods_str), (tuple(self.env.companies.ids),)) period_indexes = { 'past': 0, 'present': 1, 'future': 2, } date_formats = { 'monthly': 'MMMM y', 'yearly': 'y', } # Collect data in our result for res in self.env.cr.dictfetchall(): period_type, _type = res['_type'].split(',') # Ex: yearly,past start = res['start'] period_idx = period_indexes[_type] period_str = format_date(self.env, start, date_format=date_formats[period_type]) # The data is formatted for the chart module employees_trends['data'][period_type][period_idx] = { 'label': period_str, 'value': res['employee_count'], 'name': period_str, } # Generates a point as sample data def make_sample_data(period_str, period_type, chart_type): if chart_type == 'line': return {'x': period_str, 'name': period_str, 'y': random.randint(1000, 1500)} return {'value': random.randint(1000, 1500), 'label': period_str, 'type': period_type} # Generates empty data in case a column is missing def make_null_data(period_str, period_type, chart_type): if chart_type == 'line': return {'x': period_str, 'name': period_str, 'y': 0} return {'value': 0, 'label': period_str, 'type': period_type} make_data = make_null_data period_types = ['monthly', 'yearly'] if all(not data for data in employees_trends['data']['monthly']): employees_trends['is_sample'] = True make_data = make_sample_data # Go through all the data and create null or sample values where necessary for start, _dummy, p_types in periods: _type, _time = p_types.split(',') i = period_indexes[_time] for period in period_types: period_str = format_date(self.env, start, date_format=date_formats[period]) if not employees_trends['data'][_type][i]: employees_trends['data'][_type][i] = make_data( period_str, _type, employees_trends['type']) return employees_trends @api.model def _get_dashboard_stats(self): # Retrieve the different stats to display on the stats sections # This function fills in employees and employer costs statistics # Default data, replaced by sample data if empty after query employees_trends = self._get_dashboard_stat_employee_trends() employer_cost = self._get_dashboard_stats_employer_cost() return [employer_cost, employees_trends] @api.model def _get_dashboard_employee_count(self): admin_employee = self.env.ref('hr.employee_admin', raise_if_not_found=False) domain = [('company_id', 'in', self.env.companies.ids)] if admin_employee: domain += [('id', '!=', admin_employee.id)] return self.env['hr.employee'].search_count(domain, limit=1) @api.model def _get_dashboard_default_sections(self): return ['batches', 'stats', 'employee_count'] @api.model def _get_dashboard_batch_fields(self): return ['id', 'date_start', 'name', 'state', 'payslip_count'] @api.model def get_payroll_dashboard_data(self, sections=None): # Entry point for getting the dashboard data # `sections` defines which part of the data we want to include/exclude if sections is None: sections = self._get_dashboard_default_sections() result = {} if 'batches' in sections: # Batches are loaded for the last 3 months with batches, for example if there are no batches for # the summer and september is loaded, we want to get september, june, may. # Limit to max - 1 year batch_limit_date = fields.Date.today() - relativedelta(years=1, day=1) batch_group_read = self.env['hr.payslip.run'].with_context(lang='en_US')._read_group( [('date_start', '>=', batch_limit_date)], groupby=['date_start:month'], limit=20, order='date_start:month desc') # Keep only the last 3 months batch_group_read = batch_group_read[:3] if batch_group_read: min_date = batch_group_read[-1][0] or fields.Date.today() - relativedelta(months=1, day=1) batches_read_result = self.env['hr.payslip.run'].search_read( [('date_start', '>=', min_date)], fields=self._get_dashboard_batch_fields()) else: batches_read_result = [] translated_states = dict(self.env['hr.payslip.run']._fields['state']._description_selection(self.env)) for batch_read in batches_read_result: batch_read.update({ 'name': f"{batch_read['name']} ({format_date(self.env, batch_read['date_start'], date_format='MM/y')})", 'payslip_count': _('(%s Payslips)', batch_read['payslip_count']), 'state': translated_states.get(batch_read['state'], _('Unknown State')), }) result['batches'] = batches_read_result if 'stats' in sections: result['stats'] = self._get_dashboard_stats() if 'employee_count' in sections: result['employee_count'] = self._get_dashboard_employee_count() return result def action_configure_payslip_inputs(self): self.ensure_one() action = self.struct_id.action_get_structure_inputs() existing_ids = [int(definition['name']) for definition in self.struct_id.payslip_properties_definition if definition.get('name', '').isdigit()] action['domain'].extend([ ('input_usage_payslip', '=', True), ('id', 'not in', existing_ids), '|', ('dependent_input_id', '=', False), ('dependent_input_id', 'in', existing_ids)]) return action def compute_salary_allocations(self, total_amount=None): self.ensure_one() if total_amount is None: remaining = self.currency_id.round(self.net_wage) else: remaining = self.currency_id.round(total_amount) res = {} if remaining == 0 or not self.employee_id.bank_account_ids: return res if not self.employee_id.has_multiple_bank_accounts: res[str(self.employee_id.primary_bank_account_id.id)] = remaining return res fixed_bank_account_ids = self.employee_id.get_accounts_with_fixed_allocations() for ba in fixed_bank_account_ids: amount, _ = self.employee_id.get_bank_account_salary_allocation(str(ba.id)) if amount > 0 and amount <= remaining: amount = self.currency_id.round(amount) res[str(ba.id)] = amount remaining -= amount if remaining < 0: raise ValidationError(self.env._("Allocated amounts surpass the net salary.")) else: raise ValidationError(self.env._("Allocated amounts surpass the net salary.")) percentage_bank_account_ids = self.employee_id.bank_account_ids - fixed_bank_account_ids if not percentage_bank_account_ids and remaining > 0: raise ValidationError(self.env._("Allocated amounts are less than the net salary.")) total_percentage_account_amounts = 0 for i, ba in enumerate(percentage_bank_account_ids): percentage, _ = self.employee_id.get_bank_account_salary_allocation(str(ba.id)) if percentage > 0: if i == len(percentage_bank_account_ids) - 1: amount = self.currency_id.round(remaining - total_percentage_account_amounts) else: amount = self.currency_id.round((percentage / 100.0) * remaining) total_percentage_account_amounts += amount if amount > 0: res[str(ba.id)] = amount else: res[str(ba.id)] = 0 return res def safe_compute_salary_allocations(self, total_amount=None): try: return self.compute_salary_allocations(total_amount) except ValidationError as e: return {"__error__": str(e)}