2026-03-05 17:08:54 +05:30

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',
}