2302 lines
107 KiB
Python
2302 lines
107 KiB
Python
# 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)}
|