# -*- 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)