300 lines
12 KiB
Python
300 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import UserError, ValidationError
|
|
|
|
|
|
class Payslip(models.Model):
|
|
_name = 'c2c.payslip'
|
|
_description = 'Employee Payslip'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
_order = 'date_from desc, id desc'
|
|
_rec_name = 'display_name'
|
|
|
|
# ------------------------------------------------------------------
|
|
# Core fields
|
|
# ------------------------------------------------------------------
|
|
employee_id = fields.Many2one(
|
|
'hr.employee', string='Employee', required=True,
|
|
tracking=True,
|
|
)
|
|
contract_id = fields.Many2one(
|
|
'hr.contract', string='Contract', required=True,
|
|
tracking=True,
|
|
domain="[('employee_id', '=', employee_id), ('state', '=', 'open')]",
|
|
)
|
|
date_from = fields.Date(
|
|
string='Period From', required=True,
|
|
)
|
|
date_to = fields.Date(
|
|
string='Period To', required=True,
|
|
)
|
|
company_id = fields.Many2one(
|
|
'res.company', string='Company', required=True,
|
|
default=lambda self: self.env.company,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Earnings (computed & stored)
|
|
# ------------------------------------------------------------------
|
|
gross_salary = fields.Float(
|
|
string='Gross Salary', compute='_compute_salary', store=True,
|
|
)
|
|
basic = fields.Float(
|
|
string='Basic', compute='_compute_salary', store=True,
|
|
)
|
|
hra = fields.Float(
|
|
string='HRA', compute='_compute_salary', store=True,
|
|
)
|
|
allowances = fields.Float(
|
|
string='Allowances', compute='_compute_salary', store=True,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Deductions (computed & stored)
|
|
# ------------------------------------------------------------------
|
|
pf_deduction = fields.Float(
|
|
string='PF Deduction', compute='_compute_salary', store=True,
|
|
)
|
|
esi_deduction = fields.Float(
|
|
string='ESI Deduction', compute='_compute_salary', store=True,
|
|
)
|
|
professional_tax = fields.Float(
|
|
string='Professional Tax', compute='_compute_salary', store=True,
|
|
)
|
|
total_deductions = fields.Float(
|
|
string='Total Deductions', compute='_compute_salary', store=True,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Net
|
|
# ------------------------------------------------------------------
|
|
net_salary = fields.Float(
|
|
string='Net Salary', compute='_compute_salary', store=True,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# State & Accounting
|
|
# ------------------------------------------------------------------
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('confirmed', 'Confirmed'),
|
|
('paid', 'Paid'),
|
|
], string='Status', default='draft', tracking=True, copy=False)
|
|
|
|
journal_entry_id = fields.Many2one(
|
|
'account.move', string='Journal Entry', readonly=True, copy=False,
|
|
)
|
|
journal_entry_count = fields.Integer(
|
|
compute='_compute_journal_entry_count',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Display name
|
|
# ------------------------------------------------------------------
|
|
display_name = fields.Char(compute='_compute_display_name', store=True)
|
|
|
|
@api.depends('employee_id', 'date_from', 'date_to')
|
|
def _compute_display_name(self):
|
|
for rec in self:
|
|
emp_name = rec.employee_id.name or 'New'
|
|
date_from = rec.date_from or ''
|
|
date_to = rec.date_to or ''
|
|
rec.display_name = '%s (%s - %s)' % (emp_name, date_from, date_to)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Computed salary
|
|
# ------------------------------------------------------------------
|
|
@api.depends(
|
|
'contract_id',
|
|
'contract_id.gross_salary',
|
|
'contract_id.salary_structure_id',
|
|
'contract_id.salary_structure_id.basic_percentage',
|
|
'contract_id.salary_structure_id.hra_percentage',
|
|
'contract_id.salary_structure_id.allowance_percentage',
|
|
'contract_id.salary_structure_id.pf_percentage',
|
|
'contract_id.salary_structure_id.esi_percentage',
|
|
'contract_id.salary_structure_id.professional_tax_fixed',
|
|
'contract_id.pf_applicable',
|
|
'contract_id.esi_applicable',
|
|
)
|
|
def _compute_salary(self):
|
|
for rec in self:
|
|
contract = rec.contract_id
|
|
structure = contract.salary_structure_id if contract else False
|
|
|
|
if not contract or not structure:
|
|
rec.gross_salary = 0.0
|
|
rec.basic = 0.0
|
|
rec.hra = 0.0
|
|
rec.allowances = 0.0
|
|
rec.pf_deduction = 0.0
|
|
rec.esi_deduction = 0.0
|
|
rec.professional_tax = 0.0
|
|
rec.total_deductions = 0.0
|
|
rec.net_salary = 0.0
|
|
continue
|
|
|
|
gross = contract.gross_salary
|
|
rec.gross_salary = gross
|
|
|
|
# Earnings
|
|
rec.basic = gross * structure.basic_percentage / 100.0
|
|
rec.hra = gross * structure.hra_percentage / 100.0
|
|
rec.allowances = gross * structure.allowance_percentage / 100.0
|
|
|
|
# Deductions
|
|
pf = (rec.basic * structure.pf_percentage / 100.0) if contract.pf_applicable else 0.0
|
|
esi = (gross * structure.esi_percentage / 100.0) if contract.esi_applicable else 0.0
|
|
pt = structure.professional_tax_fixed
|
|
|
|
rec.pf_deduction = pf
|
|
rec.esi_deduction = esi
|
|
rec.professional_tax = pt
|
|
rec.total_deductions = pf + esi + pt
|
|
rec.net_salary = gross - rec.total_deductions
|
|
|
|
# ------------------------------------------------------------------
|
|
# Journal entry count
|
|
# ------------------------------------------------------------------
|
|
@api.depends('journal_entry_id')
|
|
def _compute_journal_entry_count(self):
|
|
for rec in self:
|
|
rec.journal_entry_count = 1 if rec.journal_entry_id else 0
|
|
|
|
# ------------------------------------------------------------------
|
|
# Onchange helpers
|
|
# ------------------------------------------------------------------
|
|
@api.onchange('employee_id')
|
|
def _onchange_employee_id(self):
|
|
"""Auto-fill contract when employee changes."""
|
|
if self.employee_id:
|
|
contract = self.env['hr.contract'].search([
|
|
('employee_id', '=', self.employee_id.id),
|
|
('state', '=', 'open'),
|
|
('company_id', '=', self.env.company.id),
|
|
], limit=1)
|
|
self.contract_id = contract
|
|
else:
|
|
self.contract_id = False
|
|
|
|
# ------------------------------------------------------------------
|
|
# Constraints
|
|
# ------------------------------------------------------------------
|
|
@api.constrains('date_from', 'date_to')
|
|
def _check_dates(self):
|
|
for rec in self:
|
|
if rec.date_from and rec.date_to and rec.date_from > rec.date_to:
|
|
raise ValidationError('Period From cannot be after Period To.')
|
|
|
|
# ------------------------------------------------------------------
|
|
# Actions
|
|
# ------------------------------------------------------------------
|
|
def action_confirm(self):
|
|
"""Confirm the payslip and create an accounting journal entry."""
|
|
for rec in self:
|
|
if rec.state != 'draft':
|
|
raise UserError('Only draft payslips can be confirmed.')
|
|
if not rec.contract_id or not rec.contract_id.salary_structure_id:
|
|
raise UserError(
|
|
'Please set a salary structure on the contract before confirming.'
|
|
)
|
|
if rec.net_salary <= 0:
|
|
raise UserError('Net salary must be greater than zero to confirm.')
|
|
|
|
# Get accounts from system parameters (company-specific)
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
expense_account_id = int(
|
|
ICP.get_param('c2c_payroll.salary_expense_account_id', default=0)
|
|
)
|
|
payable_account_id = int(
|
|
ICP.get_param('c2c_payroll.salary_payable_account_id', default=0)
|
|
)
|
|
|
|
if not expense_account_id or not payable_account_id:
|
|
raise UserError(
|
|
'Please configure Salary Expense and Payable accounts in '
|
|
'Settings → Technical → Parameters → System Parameters.\n\n'
|
|
'Keys:\n'
|
|
' • c2c_payroll.salary_expense_account_id\n'
|
|
' • c2c_payroll.salary_payable_account_id'
|
|
)
|
|
|
|
# Validate accounts exist
|
|
expense_account = self.env['account.account'].browse(expense_account_id)
|
|
payable_account = self.env['account.account'].browse(payable_account_id)
|
|
if not expense_account.exists() or not payable_account.exists():
|
|
raise UserError(
|
|
'Configured salary accounts do not exist. '
|
|
'Please verify the system parameter values.'
|
|
)
|
|
|
|
journal = self.env['account.journal'].search([
|
|
('type', '=', 'general'),
|
|
('company_id', '=', rec.company_id.id),
|
|
], limit=1)
|
|
if not journal:
|
|
raise UserError(
|
|
'No general journal found for company %s.' % rec.company_id.name
|
|
)
|
|
|
|
move_vals = {
|
|
'journal_id': journal.id,
|
|
'date': rec.date_to,
|
|
'ref': 'Payslip: %s' % rec.display_name,
|
|
'company_id': rec.company_id.id,
|
|
'line_ids': [
|
|
(0, 0, {
|
|
'name': 'Salary Expense - %s' % rec.employee_id.name,
|
|
'account_id': expense_account_id,
|
|
'debit': rec.net_salary,
|
|
'credit': 0.0,
|
|
}),
|
|
(0, 0, {
|
|
'name': 'Employee Payable - %s' % rec.employee_id.name,
|
|
'account_id': payable_account_id,
|
|
'debit': 0.0,
|
|
'credit': rec.net_salary,
|
|
}),
|
|
],
|
|
}
|
|
move = self.env['account.move'].create(move_vals)
|
|
rec.write({
|
|
'state': 'confirmed',
|
|
'journal_entry_id': move.id,
|
|
})
|
|
|
|
def action_mark_paid(self):
|
|
"""Mark confirming payslip as paid."""
|
|
for rec in self:
|
|
if rec.state != 'confirmed':
|
|
raise UserError('Only confirmed payslips can be marked as paid.')
|
|
rec.write({'state': 'paid'})
|
|
|
|
def action_reset_to_draft(self):
|
|
"""Reset to draft (deletes journal entry if unposted)."""
|
|
for rec in self:
|
|
if rec.state == 'paid':
|
|
raise UserError('Paid payslips cannot be reset to draft.')
|
|
if rec.journal_entry_id:
|
|
if rec.journal_entry_id.state == 'posted':
|
|
raise UserError(
|
|
'Cannot reset: the journal entry is already posted. '
|
|
'Please cancel it first.'
|
|
)
|
|
rec.journal_entry_id.unlink()
|
|
rec.write({'state': 'draft', 'journal_entry_id': False})
|
|
|
|
def action_open_journal_entry(self):
|
|
"""Smart button to open the linked journal entry."""
|
|
self.ensure_one()
|
|
if not self.journal_entry_id:
|
|
raise UserError('No journal entry linked to this payslip.')
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Journal Entry',
|
|
'res_model': 'account.move',
|
|
'res_id': self.journal_entry_id.id,
|
|
'view_mode': 'form',
|
|
'target': 'current',
|
|
}
|