438 lines
20 KiB
Python
438 lines
20 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
from ast import literal_eval
|
|
from collections import defaultdict
|
|
from datetime import date, datetime, time
|
|
from dateutil.relativedelta import relativedelta
|
|
import pytz
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.fields import Domain
|
|
from odoo.tools.date_utils import get_month
|
|
|
|
STATUS_COLOR = {
|
|
'01_ready': 4, # info light blue
|
|
'02_close': 10, # success green
|
|
'03_paid': 5, # primary purple
|
|
'04_cancel': 0, # default grey
|
|
False: 0, # default grey -- for studio
|
|
}
|
|
|
|
|
|
class HrPayslipRun(models.Model):
|
|
_name = 'hr.payslip.run'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
_description = 'Pay Run'
|
|
_order = 'date_end desc'
|
|
|
|
active = fields.Boolean(default=True)
|
|
name = fields.Char(required=True)
|
|
slip_ids = fields.One2many('hr.payslip', 'payslip_run_id', string='Payslips')
|
|
state = fields.Selection([
|
|
('01_ready', 'Ready'),
|
|
('02_close', 'Done'),
|
|
('03_paid', 'Paid'),
|
|
('04_cancel', 'Cancelled'),
|
|
],
|
|
string='Status', index=True, readonly=True, copy=False,
|
|
default='01_ready', tracking=True,
|
|
compute='_compute_state', store=True)
|
|
color = fields.Integer(compute='_compute_color', export_string_translation=False)
|
|
date_start = fields.Date(
|
|
string='From', readonly=False, required=True,
|
|
compute="_compute_date_start", store=True, precompute=True,
|
|
default=lambda self: fields.Date.to_string(date.today().replace(day=1)))
|
|
date_end = fields.Date(
|
|
string='To', readonly=False, required=True,
|
|
compute="_compute_date_end", store=True, precompute=True,
|
|
default=lambda self: fields.Date.to_string((datetime.now() + relativedelta(months=+1, day=1, days=-1)).date()))
|
|
structure_id = fields.Many2one('hr.payroll.structure', string='Salary Structure', readonly=False)
|
|
use_worked_day_lines = fields.Boolean(related='structure_id.use_worked_day_lines')
|
|
schedule_pay = fields.Selection(
|
|
selection=lambda self: self.env['hr.payroll.structure.type']._get_selection_schedule_pay(),
|
|
compute='_compute_schedule_pay', default="monthly", readonly=False, store=True, precompute=True, string='Pay Schedule')
|
|
payslip_count = fields.Integer(compute='_compute_payslip_count', store=True)
|
|
payslips_with_issues = fields.Integer(compute='_compute_payslips_with_issues')
|
|
has_error = fields.Boolean(compute='_compute_has_error')
|
|
empty_payslips = fields.Integer(compute='_compute_empty_payslips')
|
|
company_id = fields.Many2one('res.company', string='Company', required=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)
|
|
currency_id = fields.Many2one(related="company_id.currency_id")
|
|
payment_report = fields.Binary(
|
|
string='Payment Report',
|
|
help="Export .csv file related to this pay run",
|
|
readonly=True)
|
|
payment_report_filename = fields.Char(readonly=True)
|
|
payment_report_format = fields.Char(readonly=True)
|
|
payment_report_date = fields.Date(readonly=True)
|
|
total_employer_cost = fields.Monetary(compute='_compute_total_employer_cost', string='Total Employer Cost')
|
|
gross_sum = fields.Monetary(compute="_compute_gross_net_sum", store=True, readonly=True, copy=False)
|
|
net_sum = fields.Monetary(compute="_compute_gross_net_sum", store=True, readonly=True, copy=False)
|
|
|
|
def _get_name_for_period(self, vals=None, cache=None):
|
|
|
|
def normalize_date(val):
|
|
if isinstance(val, date):
|
|
return val
|
|
elif isinstance(val, datetime):
|
|
return val.date()
|
|
elif isinstance(val, str):
|
|
return date.fromisoformat(val)
|
|
return None
|
|
|
|
if vals is None:
|
|
vals = {}
|
|
if cache is None:
|
|
cache = {}
|
|
if not vals.get("date_start") or not vals.get("date_end"):
|
|
raise UserError(self.env._("You must set a start and end date for the Pay Run"))
|
|
date_start = normalize_date(vals["date_start"])
|
|
date_end = normalize_date(vals["date_end"])
|
|
structure_id = vals.get("structure_id")
|
|
name = ""
|
|
if not date_start or not date_end:
|
|
return name
|
|
format_date_cached = self.env["hr.payslip"]._format_date_cached
|
|
if date_end.year == date_start.year:
|
|
if date_end.month - date_start.month == 11:
|
|
name += format_date_cached(cache, date_start, date_format="Y")
|
|
elif date_end.month == date_start.month:
|
|
if (date_start, date_end) == get_month(date_start):
|
|
name += format_date_cached(cache, date_start, date_format="MMM Y")
|
|
elif date_start.day == date_end.day:
|
|
name += format_date_cached(cache, date_start, date_format="d MMM Y")
|
|
else:
|
|
name += format_date_cached(cache, date_start, date_format="d MMM Y") + " - " + format_date_cached(cache, date_end, date_format="d MMM Y")
|
|
else:
|
|
name += format_date_cached(cache, date_start, date_format="MMM Y") + " - " + format_date_cached(cache, date_end, date_format="MMM Y")
|
|
else:
|
|
name += format_date_cached(cache, date_start, date_format="Y") + " - " + format_date_cached(cache, date_end, date_format="Y")
|
|
if structure_id:
|
|
structure_id = self.env["hr.payroll.structure"].browse(structure_id)
|
|
name += " - " + structure_id.name
|
|
return name
|
|
|
|
def _get_valid_version_ids(self, date_start=None, date_end=None, structure_id=None, company_id=None, employee_ids=None, schedule_pay=None):
|
|
date_start = date_start or self.date_start
|
|
date_end = date_end or self.date_end
|
|
structure = self.env["hr.payroll.structure"].browse(structure_id) if structure_id else self.structure_id
|
|
schedule_pay = schedule_pay or self.schedule_pay
|
|
company = company_id or self.company_id.id
|
|
version_domain = Domain([
|
|
('company_id', '=', company),
|
|
('employee_id', '!=', False),
|
|
('active_employee', '=', True),
|
|
('contract_date_start', '<=', date_end),
|
|
'|',
|
|
('contract_date_end', '=', False),
|
|
('contract_date_end', '>=', date_start),
|
|
('date_version', '<=', date_end),
|
|
('structure_type_id', '!=', False),
|
|
])
|
|
if structure:
|
|
version_domain &= Domain([('structure_type_id', '=', structure.type_id.id)])
|
|
if employee_ids:
|
|
version_domain &= Domain([('employee_id', 'in', employee_ids)])
|
|
if schedule_pay:
|
|
version_domain &= Domain([('schedule_pay', '=', schedule_pay)])
|
|
all_versions = self.env['hr.version']._read_group(
|
|
domain=version_domain,
|
|
groupby=['employee_id', 'date_version:day'],
|
|
order="date_version:day DESC",
|
|
aggregates=['id:recordset'],
|
|
)
|
|
all_employee_versions = defaultdict(list)
|
|
for employee, _, version in all_versions:
|
|
all_employee_versions[employee] += [*version]
|
|
valid_versions = self.env["hr.version"]
|
|
for employee_versions in all_employee_versions.values():
|
|
employee_valid_versions = self.env["hr.version"]
|
|
for i in range(len(employee_versions)):
|
|
version = employee_versions[i]
|
|
if version.date_version <= date_start or employee_versions[-1] == version:
|
|
# End case: The first version in contract before the pay run start or the last version of the list
|
|
employee_valid_versions |= version
|
|
break
|
|
if employee_valid_versions:
|
|
# Version already added => new contract?
|
|
if (employee_valid_versions[-1].contract_date_start > version.contract_date_start
|
|
and (version.contract_date_start >= version.date_version
|
|
or version.contract_date_start > employee_versions[i + 1].contract_date_start)):
|
|
# Take only the first version of the new contract founded
|
|
employee_valid_versions |= version
|
|
elif version.contract_date_start >= version.date_version or version.contract_date_start > employee_versions[i + 1].contract_date_start:
|
|
# Take only the first version of the first contract founded
|
|
employee_valid_versions |= version
|
|
valid_versions |= employee_valid_versions
|
|
return valid_versions.ids
|
|
|
|
@api.depends("structure_id")
|
|
def _compute_schedule_pay(self):
|
|
for payslip_run in self:
|
|
payslip_run.schedule_pay = payslip_run.structure_id.type_id.default_schedule_pay
|
|
|
|
@api.depends("slip_ids")
|
|
def _compute_payslip_count(self):
|
|
for payslip_run in self:
|
|
payslip_run.payslip_count = len(payslip_run.slip_ids)
|
|
|
|
@api.depends("slip_ids.state")
|
|
def _compute_state(self):
|
|
for payslip_run in self:
|
|
states = payslip_run.mapped('slip_ids.state')
|
|
if any(state == "draft" for state in states) or not payslip_run.slip_ids:
|
|
payslip_run.state = '01_ready'
|
|
elif any(state == "validated" for state in states):
|
|
payslip_run.state = '02_close'
|
|
elif any(state == "paid" for state in states):
|
|
payslip_run.state = '03_paid'
|
|
elif all(state == "cancel" for state in states):
|
|
payslip_run.state = '04_cancel'
|
|
else:
|
|
payslip_run.state = '01_ready'
|
|
|
|
@api.depends('state')
|
|
def _compute_color(self):
|
|
for payslip_run in self:
|
|
payslip_run.color = STATUS_COLOR[payslip_run.state]
|
|
|
|
@api.depends('schedule_pay')
|
|
def _compute_date_start(self):
|
|
for payslip_run in self:
|
|
if payslip_run.schedule_pay:
|
|
payslip_run.date_start = self.env["hr.payslip"]._schedule_period_start(payslip_run.schedule_pay, date.today(), payslip_run.country_code)
|
|
|
|
@api.depends('date_start')
|
|
def _compute_date_end(self):
|
|
for payslip_run in self:
|
|
if payslip_run.schedule_pay:
|
|
payslip_run.date_end = (payslip_run.date_start and payslip_run.date_start +
|
|
self.env["hr.payslip"]._schedule_timedelta(payslip_run.schedule_pay, payslip_run.date_start, payslip_run.country_code))
|
|
|
|
@api.depends("slip_ids.gross_wage", "slip_ids.net_wage", "slip_ids.state")
|
|
def _compute_gross_net_sum(self):
|
|
for payslip_run in self:
|
|
payslip_run.gross_sum = sum(payslip_run.slip_ids.filtered(lambda p: p.state != "cancel").mapped("gross_wage"))
|
|
payslip_run.net_sum = sum(payslip_run.slip_ids.filtered(lambda p: p.state != "cancel").mapped("net_wage"))
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
formated_date_cache = {}
|
|
for vals in vals_list:
|
|
if not vals.get("name"):
|
|
vals["name"] = self._get_name_for_period(vals, formated_date_cache)
|
|
return super().create(vals_list)
|
|
|
|
@api.depends('slip_ids.error_count', 'slip_ids.warning_count')
|
|
def _compute_payslips_with_issues(self):
|
|
for run in self:
|
|
run.payslips_with_issues = len(run.slip_ids.filtered(lambda ps: ps.error_count or ps.warning_count))
|
|
|
|
@api.depends('slip_ids.error_count')
|
|
def _compute_has_error(self):
|
|
for run in self:
|
|
run.has_error = bool(run.slip_ids.filtered('error_count'))
|
|
|
|
@api.depends('slip_ids.line_ids')
|
|
def _compute_empty_payslips(self):
|
|
for run in self:
|
|
run.empty_payslips = len(run.slip_ids.filtered(
|
|
lambda slip: not slip.line_ids
|
|
))
|
|
|
|
def _search_country_id(self, operator, value):
|
|
return [('company_id.partner_id.country_id', operator, value)]
|
|
|
|
def action_draft(self):
|
|
if self.slip_ids.filtered(lambda s: s.state == 'paid'):
|
|
raise ValidationError(self.env._('You cannot reset a pay run to draft if some of the payslips have already been paid.'))
|
|
self.env['ir.attachment'].sudo().search([
|
|
('res_model', '=', 'hr.payslip.run'),
|
|
('res_id', 'in', self.ids),
|
|
('res_field', '=', 'payment_report')
|
|
]).unlink()
|
|
self.write({
|
|
'payment_report': False,
|
|
'payment_report_filename': False,
|
|
'payment_report_format': False,
|
|
'payment_report_date': False,
|
|
})
|
|
self.slip_ids.write({
|
|
'state': 'draft',
|
|
})
|
|
|
|
def action_payment_report(self, export_format='csv'):
|
|
self.ensure_one()
|
|
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.slip_ids.ids,
|
|
'default_payslip_run_id': self.id,
|
|
'default_export_format': export_format,
|
|
},
|
|
}
|
|
|
|
def action_paid(self):
|
|
self.mapped('slip_ids').action_payslip_paid()
|
|
|
|
def action_unpaid(self):
|
|
self.slip_ids.action_payslip_unpaid()
|
|
|
|
def action_validate(self):
|
|
return self.slip_ids.filtered(
|
|
lambda slip: slip.state != 'cancel' and slip.line_ids
|
|
).action_payslip_done()
|
|
|
|
def action_confirm(self):
|
|
self.slip_ids.filtered(lambda slip: slip.state == 'draft').compute_sheet()
|
|
|
|
def action_open_payslips(self):
|
|
action = self.env['ir.actions.act_window']._for_xml_id('hr_payroll.action_view_hr_payslip_month_form')
|
|
action['context'] = dict(
|
|
literal_eval(action["context"]),
|
|
search_default_payslip_run_id=self.id or False)
|
|
return action
|
|
|
|
def action_payroll_hr_version_list_view_payrun(self, date_start=None, date_end=None, structure_id=None, company_id=None, schedule_pay=None):
|
|
action = self.env['ir.actions.act_window']._for_xml_id('hr_payroll.action_payroll_hr_version_list_view_payrun')
|
|
|
|
valid_version_ids = self._get_valid_version_ids(
|
|
fields.Date.from_string(date_start),
|
|
fields.Date.from_string(date_end),
|
|
structure_id,
|
|
company_id,
|
|
None,
|
|
schedule_pay,
|
|
)
|
|
|
|
payslip_domain = Domain.AND([
|
|
Domain('version_id', 'in', valid_version_ids),
|
|
Domain('date_from', '=', fields.Date.from_string(date_start) if date_start else self.date_start),
|
|
Domain('date_to', '=', fields.Date.from_string(date_end) if date_end else self.date_end),
|
|
Domain('struct_id', '=', structure_id if structure_id else (self.structure_id.id if self.structure_id else False)),
|
|
Domain('state', '!=', 'cancel'),
|
|
Domain('version_id.schedule_pay', '=', schedule_pay if schedule_pay else False)
|
|
])
|
|
existing_version_ids = self.env['hr.payslip'].search(payslip_domain).version_id.ids
|
|
filtered_version_ids = set(valid_version_ids) - set(existing_version_ids)
|
|
action['domain'] = [("id", "in", list(filtered_version_ids))]
|
|
return action
|
|
|
|
def action_review_issues(self):
|
|
self.ensure_one()
|
|
return {
|
|
'name': 'Issue Payslips',
|
|
'type': 'ir.actions.act_window',
|
|
'target': 'current',
|
|
'res_model': 'hr.payslip',
|
|
'view_mode': 'list,form',
|
|
'context': {
|
|
'search_default_payslip_run_id': self.id,
|
|
'search_default_filter_issue': 1,
|
|
}
|
|
}
|
|
|
|
def generate_payslips(self, version_ids=None, employee_ids=None):
|
|
self.ensure_one()
|
|
|
|
if employee_ids and not version_ids:
|
|
version_ids = self._get_valid_version_ids(employee_ids=employee_ids)
|
|
|
|
if not version_ids:
|
|
raise UserError(self.env._("You must select employee(s) version(s) to generate payslip(s)."))
|
|
|
|
valid_versions = self.env["hr.version"].browse(version_ids)
|
|
|
|
Payslip = self.env['hr.payslip']
|
|
|
|
if self.structure_id:
|
|
valid_versions = valid_versions.filtered(lambda c: c.structure_type_id.id == self.structure_id.type_id.id)
|
|
valid_versions.generate_work_entries(self.date_start, self.date_end)
|
|
|
|
all_work_entries = dict(self.env['hr.work.entry']._read_group(
|
|
domain=[
|
|
('employee_id', 'in', valid_versions.employee_id.ids),
|
|
('date', '<=', self.date_end),
|
|
('date', '>=', self.date_start),
|
|
],
|
|
groupby=['version_id'],
|
|
aggregates=['id:recordset'],
|
|
))
|
|
|
|
utc = pytz.utc
|
|
for tz, slips_per_tz in self.slip_ids.grouped(lambda s: s.version_id.tz).items():
|
|
slip_tz = pytz.timezone(tz or utc)
|
|
for slip in slips_per_tz:
|
|
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)
|
|
if version_work_entries := all_work_entries.get(slip.version_id):
|
|
version_work_entries.filtered_domain([
|
|
('date', '<=', date_to),
|
|
('date', '>=', date_from),
|
|
])
|
|
version_work_entries._check_undefined_slots(slip.date_from, slip.date_to)
|
|
|
|
for work_entries in all_work_entries.values():
|
|
work_entries = work_entries.filtered(lambda we: we.state != 'validated')
|
|
if work_entries._check_if_error():
|
|
work_entries = work_entries.filtered(lambda we: we.state == 'conflict')
|
|
conflicts = work_entries._to_intervals()
|
|
time_intervals_str = "".join(
|
|
f"\n - {start} -> {end} ({entry.employee_id.name})" for start, end, entry in conflicts._items)
|
|
raise UserError(self.env._("Some work entries could not be validated. Time intervals to look for:%s", time_intervals_str))
|
|
|
|
default_values = Payslip.default_get(Payslip.fields_get())
|
|
payslips_vals = []
|
|
for version in valid_versions[::-1]:
|
|
values = default_values | {
|
|
'name': self.env._('New Payslip'),
|
|
'employee_id': version.employee_id.id,
|
|
'payslip_run_id': self.id,
|
|
'date_from': self.date_start,
|
|
'date_to': self.date_end,
|
|
'version_id': version.id,
|
|
'company_id': self.company_id.id,
|
|
'struct_id': self.structure_id.id or version.structure_type_id.default_struct_id.id,
|
|
}
|
|
payslips_vals.append(values)
|
|
self.slip_ids |= Payslip.with_context(tracking_disable=True).create(payslips_vals)
|
|
self.slip_ids._compute_name()
|
|
self.slip_ids.compute_sheet()
|
|
self.state = '01_ready'
|
|
|
|
return 1
|
|
|
|
@api.ondelete(at_uninstall=False)
|
|
def _unlink_if_draft_or_cancel(self):
|
|
if any(self.mapped('slip_ids').filtered(lambda payslip: payslip.state not in ('draft', 'cancel'))):
|
|
raise UserError(self.env._("You can't delete a pay run with payslips if they are not draft or cancelled."))
|
|
|
|
def _are_payslips_ready(self):
|
|
return any(slip.state in ['validated', 'cancel'] for slip in self.mapped('slip_ids'))
|
|
|
|
@api.model
|
|
def get_unusual_days(self, date_from, date_to=None):
|
|
return self.env.company.resource_calendar_id._get_unusual_days(
|
|
datetime.combine(fields.Date.from_string(date_from), time.min).replace(tzinfo=pytz.utc),
|
|
datetime.combine(fields.Date.from_string(date_to), time.max).replace(tzinfo=pytz.utc),
|
|
self.company_id,
|
|
)
|
|
|
|
def get_formview_action(self, access_uid=None):
|
|
return self.action_open_payslips()
|
|
|
|
@api.depends('slip_ids.employer_cost')
|
|
def _compute_total_employer_cost(self):
|
|
for payslip_run in self:
|
|
payslip_run.total_employer_cost = sum(payslip_run.slip_ids.mapped('employer_cost'))
|