299 lines
13 KiB
Python
299 lines
13 KiB
Python
# -*- coding:utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools.date_utils import start_of
|
|
from odoo.tools.misc import formatLang
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
from math import ceil
|
|
|
|
|
|
class HrSalaryAttachment(models.Model):
|
|
_name = 'hr.salary.attachment'
|
|
_description = 'Salary Attachment'
|
|
_inherit = ['mail.thread']
|
|
_rec_name = 'description'
|
|
|
|
_sql_constraints = [
|
|
(
|
|
'check_monthly_amount', 'CHECK (monthly_amount > 0)',
|
|
'Payslip amount must be strictly positive.'
|
|
),
|
|
(
|
|
'check_total_amount',
|
|
'CHECK ((total_amount > 0 AND total_amount >= monthly_amount) OR no_end_date = True)',
|
|
'Total amount must be strictly positive and greater than or equal to the payslip amount.'
|
|
),
|
|
('check_remaining_amount', 'CHECK (remaining_amount >= 0)', 'Remaining amount must be positive.'),
|
|
('check_dates', 'CHECK (date_start <= date_end)', 'End date may not be before the starting date.'),
|
|
]
|
|
|
|
employee_ids = fields.Many2many('hr.employee', string='Employees', required=True,
|
|
domain=lambda self: [('company_id', 'in', self.env.companies.ids)])
|
|
employee_count = fields.Integer(compute='_compute_employee_count')
|
|
company_id = fields.Many2one(
|
|
'res.company', string='Company', required=True,
|
|
default=lambda self: self.env.company)
|
|
currency_id = fields.Many2one('res.currency', related='company_id.currency_id')
|
|
description = fields.Char(required=True)
|
|
other_input_type_id = fields.Many2one(
|
|
'hr.payslip.input.type',
|
|
string="Type",
|
|
required=True,
|
|
tracking=True,
|
|
domain=[('available_in_attachments', '=', True)]
|
|
)
|
|
no_end_date = fields.Boolean(compute='_compute_no_end_date', store=True, readonly=False)
|
|
monthly_amount = fields.Monetary('Payslip Amount', required=True, tracking=True, help='Amount to pay each payslip.')
|
|
occurrences = fields.Integer(
|
|
compute='_compute_occurrences',
|
|
help='Number of times the salary attachment will appear on the payslip.',
|
|
)
|
|
active_amount = fields.Monetary(
|
|
'Active Amount', compute='_compute_active_amount',
|
|
help='Amount to pay for this payslip, Payslip Amount or less depending on the Remaining Amount.',
|
|
)
|
|
total_amount = fields.Monetary(
|
|
'Total Amount',
|
|
tracking=True,
|
|
help='Total amount to be paid.',
|
|
)
|
|
has_total_amount = fields.Boolean('Has Total Amount', compute='_compute_has_total_amount')
|
|
paid_amount = fields.Monetary('Paid Amount', tracking=True)
|
|
remaining_amount = fields.Monetary(
|
|
'Remaining Amount', compute='_compute_remaining_amount', store=True,
|
|
help='Remaining amount to be paid.',
|
|
)
|
|
is_quantity = fields.Boolean(related='other_input_type_id.is_quantity')
|
|
is_refund = fields.Boolean(
|
|
string='Negative Value',
|
|
help='Check if the value of the salary attachment must be taken into account as negative (-X)')
|
|
date_start = fields.Date('Start Date', required=True, default=lambda r: start_of(fields.Date.today(), 'month'), tracking=True)
|
|
date_estimated_end = fields.Date(
|
|
'Estimated End Date', compute='_compute_estimated_end',
|
|
help='Approximated end date.',
|
|
)
|
|
date_end = fields.Date(
|
|
'End Date', default=False, tracking=True,
|
|
help='Date at which this assignment has been set as completed or cancelled.',
|
|
)
|
|
state = fields.Selection(
|
|
selection=[
|
|
('open', 'Running'),
|
|
('close', 'Completed'),
|
|
('cancel', 'Cancelled'),
|
|
],
|
|
string='Status',
|
|
default='open',
|
|
required=True,
|
|
tracking=True,
|
|
copy=False,
|
|
)
|
|
payslip_ids = fields.Many2many('hr.payslip', relation='hr_payslip_hr_salary_attachment_rel', string='Payslips', copy=False)
|
|
payslip_count = fields.Integer('# Payslips', compute='_compute_payslip_count')
|
|
has_done_payslip = fields.Boolean(compute="_compute_has_done_payslip")
|
|
|
|
attachment = fields.Binary('Document', copy=False)
|
|
attachment_name = fields.Char()
|
|
|
|
has_similar_attachment = fields.Boolean(compute='_compute_has_similar_attachment')
|
|
has_similar_attachment_warning = fields.Char(compute='_compute_has_similar_attachment')
|
|
|
|
@api.depends('other_input_type_id')
|
|
def _compute_no_end_date(self):
|
|
for attachment in self:
|
|
attachment.no_end_date = attachment.other_input_type_id.default_no_end_date
|
|
|
|
@api.depends('employee_ids')
|
|
def _compute_employee_count(self):
|
|
for attachment in self:
|
|
attachment.employee_count = len(attachment.employee_ids)
|
|
|
|
@api.depends('monthly_amount', 'date_start', 'date_end')
|
|
def _compute_total_amount(self):
|
|
for record in self:
|
|
if record.has_total_amount:
|
|
date_start = record.date_start if record.date_start else fields.Date.today()
|
|
date_end = record.date_end if record.date_end else fields.Date.today()
|
|
month_difference = (date_end.year - date_start.year) * 12 + (date_end.month - date_start.month)
|
|
record.total_amount = max(0, month_difference + 1) * record.monthly_amount
|
|
else:
|
|
record.total_amount = record.paid_amount
|
|
|
|
@api.depends('monthly_amount', 'total_amount')
|
|
def _compute_occurrences(self):
|
|
self.occurrences = 0
|
|
for attachment in self:
|
|
if not attachment.total_amount or not attachment.monthly_amount:
|
|
continue
|
|
attachment.occurrences = ceil(attachment.total_amount / attachment.monthly_amount)
|
|
|
|
@api.depends('no_end_date', 'date_end')
|
|
def _compute_has_total_amount(self):
|
|
for record in self:
|
|
if record.no_end_date and not record.date_end:
|
|
record.has_total_amount = False
|
|
else:
|
|
record.has_total_amount = True
|
|
|
|
@api.depends('total_amount', 'paid_amount', 'monthly_amount')
|
|
def _compute_remaining_amount(self):
|
|
for record in self:
|
|
if record.has_total_amount:
|
|
record.remaining_amount = max(0, record.total_amount - record.paid_amount)
|
|
else:
|
|
record.remaining_amount = record.monthly_amount
|
|
|
|
@api.depends('state', 'total_amount', 'monthly_amount', 'date_start')
|
|
def _compute_estimated_end(self):
|
|
for record in self:
|
|
if record.state not in ['close', 'cancel'] and record.has_total_amount and record.monthly_amount:
|
|
record.date_estimated_end = start_of(record.date_start + relativedelta(months=ceil(record.remaining_amount / record.monthly_amount)), 'month')
|
|
else:
|
|
record.date_estimated_end = False
|
|
|
|
@api.depends('payslip_ids')
|
|
def _compute_payslip_count(self):
|
|
for record in self:
|
|
record.payslip_count = len(record.payslip_ids)
|
|
|
|
@api.depends('total_amount', 'paid_amount', 'monthly_amount')
|
|
def _compute_active_amount(self):
|
|
for record in self:
|
|
record.active_amount = min(record.monthly_amount, record.remaining_amount)
|
|
|
|
@api.depends('employee_ids', 'description', 'monthly_amount', 'date_start')
|
|
def _compute_has_similar_attachment(self):
|
|
date_min = min(self.mapped('date_start'))
|
|
possible_matches = self.search([
|
|
('state', '=', 'open'),
|
|
('employee_ids', 'in', self.employee_ids.ids),
|
|
('monthly_amount', 'in', self.mapped('monthly_amount')),
|
|
('date_start', '<=', date_min),
|
|
])
|
|
for record in self:
|
|
similar = []
|
|
if record.employee_count == 1 and record.date_start and record.state == 'open':
|
|
similar = possible_matches.filtered_domain([
|
|
('id', '!=', record.id),
|
|
('employee_ids', 'in', record.employee_ids.ids),
|
|
('monthly_amount', '=', record.monthly_amount),
|
|
('date_start', '<=', record.date_start),
|
|
('other_input_type_id', '=', record.other_input_type_id.id),
|
|
])
|
|
similar = similar.filtered(lambda s: s.employee_count == 1)
|
|
record.has_similar_attachment = similar if record.state == 'open' else False
|
|
record.has_similar_attachment_warning = similar and _('Warning, a similar attachment has been found.')
|
|
|
|
@api.depends("payslip_ids.state")
|
|
def _compute_has_done_payslip(self):
|
|
for record in self:
|
|
record.has_done_payslip = any(payslip.state in ['done', 'paid'] for payslip in record.payslip_ids)
|
|
|
|
def action_done(self):
|
|
self.ensure_one()
|
|
if self.employee_count > 1:
|
|
description = self.description
|
|
self.env['hr.salary.attachment'].create([{
|
|
'employee_ids': [(4, employee.id)],
|
|
'company_id': self.company_id.id,
|
|
'description': self.description,
|
|
'other_input_type_id': self.other_input_type_id.id,
|
|
'monthly_amount': self.monthly_amount,
|
|
'total_amount': self.total_amount,
|
|
'paid_amount': self.paid_amount,
|
|
'date_start': self.date_start,
|
|
'date_end': self.date_end,
|
|
'state': 'open',
|
|
'attachment': self.attachment,
|
|
'attachment_name': self.attachment_name,
|
|
} for employee in self.employee_ids])
|
|
self.write({'state': 'close'})
|
|
self.unlink()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Salary Attachments'),
|
|
'res_model': 'hr.salary.attachment',
|
|
'view_mode': 'list,form',
|
|
'context': {'search_default_description': description},
|
|
}
|
|
self.write({
|
|
'state': 'close',
|
|
'date_end': fields.Date.today(),
|
|
})
|
|
|
|
def action_open(self):
|
|
self.ensure_one()
|
|
self.write({
|
|
'state': 'open',
|
|
'date_end': False,
|
|
})
|
|
|
|
def action_cancel(self):
|
|
self.ensure_one()
|
|
self.write({
|
|
'state': 'cancel',
|
|
'date_end': fields.Date.today(),
|
|
})
|
|
|
|
def action_open_payslips(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Payslips'),
|
|
'res_model': 'hr.payslip',
|
|
'view_mode': 'list,form',
|
|
'domain': [('id', 'in', self.payslip_ids.ids)],
|
|
}
|
|
|
|
@api.ondelete(at_uninstall=False)
|
|
def _unlink_if_not_running(self):
|
|
if any(assignment.state == 'open' for assignment in self):
|
|
raise UserError(_('You cannot delete a running salary attachment!'))
|
|
|
|
def record_payment(self, total_amount):
|
|
''' Record a new payment for this attachment, if the total has been reached the attachment will be closed.
|
|
|
|
:param amount: amount to register for this payment
|
|
computed using the payslip_amount and the total if not given
|
|
|
|
Note that paid_amount can never be higher than total_amount
|
|
'''
|
|
def _record_payment(attachment, amount):
|
|
attachment.message_post(
|
|
body=_('Recorded a new payment of %s.', formatLang(self.env, amount, currency_obj=attachment.currency_id))
|
|
)
|
|
attachment.paid_amount += amount
|
|
if attachment.remaining_amount == 0:
|
|
attachment.action_done()
|
|
|
|
if any(len(a.employee_ids) > 1 for a in self):
|
|
raise UserError(_('You cannot record a payment on multi employees attachments.'))
|
|
|
|
remaining = total_amount
|
|
# It is necessary to sort attachments to pay monthly payments (child_support) first
|
|
attachments_sorted = self.sorted(key=lambda a: a.has_total_amount)
|
|
# For all types of attachments, we must pay the payslip_amount without exceeding the total amount
|
|
for attachment in attachments_sorted:
|
|
amount = min(attachment.monthly_amount, attachment.remaining_amount, remaining)
|
|
if not amount:
|
|
continue
|
|
remaining -= amount
|
|
_record_payment(attachment, amount)
|
|
# If we still have remaining, balance the attachments (running) that have a total amount
|
|
# in the chronology of estimated end dates.
|
|
if remaining:
|
|
fixed_total_attachments = self.filtered(lambda a: a.state == 'open' and a.has_total_amount)
|
|
fixed_total_attachments_sorted = fixed_total_attachments.sorted(lambda a: a.date_estimated_end)
|
|
for attachment in fixed_total_attachments_sorted:
|
|
amount = min(attachment.remaining_amount, attachment.remaining_amount, remaining)
|
|
if not amount:
|
|
continue
|
|
remaining -= amount
|
|
_record_payment(attachment, amount)
|
|
|
|
def _get_active_amount(self):
|
|
return sum(a.active_amount * (-1 if a.is_refund else 1) for a in self)
|