Initial commit
This commit is contained in:
7
fusion_payroll/wizards/__init__.py
Normal file
7
fusion_payroll/wizards/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import hr_employee_terminate_wizard
|
||||
from . import run_payroll_wizard
|
||||
from . import hr_employee_sin_wizard
|
||||
from . import payroll_cheque_print_wizard
|
||||
from . import cheque_number_wizard
|
||||
132
fusion_payroll/wizards/cheque_number_wizard.py
Normal file
132
fusion_payroll/wizards/cheque_number_wizard.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Cheque Number Wizard
|
||||
====================
|
||||
Wizard to allow changing cheque number before printing.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ChequeNumberWizard(models.TransientModel):
|
||||
"""Wizard to set cheque number before printing."""
|
||||
_name = 'payroll.cheque.number.wizard'
|
||||
_description = 'Cheque Number Wizard'
|
||||
|
||||
cheque_id = fields.Many2one(
|
||||
'payroll.cheque',
|
||||
string='Cheque',
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
cheque_number = fields.Char(
|
||||
string='Cheque Number',
|
||||
required=True,
|
||||
help='Enter the cheque number to use for this cheque',
|
||||
)
|
||||
employee_name = fields.Char(
|
||||
string='Employee',
|
||||
related='cheque_id.employee_id.name',
|
||||
readonly=True,
|
||||
)
|
||||
amount = fields.Monetary(
|
||||
string='Amount',
|
||||
related='cheque_id.amount',
|
||||
readonly=True,
|
||||
currency_field='currency_id',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
related='cheque_id.currency_id',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Load current cheque number or suggest next available number."""
|
||||
res = super().default_get(fields_list)
|
||||
cheque_id = self.env.context.get('default_cheque_id')
|
||||
if cheque_id:
|
||||
cheque = self.env['payroll.cheque'].browse(cheque_id)
|
||||
if cheque.exists():
|
||||
if cheque.cheque_number:
|
||||
# Use existing cheque number
|
||||
res['cheque_number'] = cheque.cheque_number
|
||||
else:
|
||||
# Suggest next available number
|
||||
res['cheque_number'] = self._get_next_cheque_number(cheque.company_id.id)
|
||||
return res
|
||||
|
||||
def _get_next_cheque_number(self, company_id):
|
||||
"""Get the next available cheque number by checking all sources."""
|
||||
max_num = 0
|
||||
|
||||
# Check payroll cheques
|
||||
payroll_cheques = self.env['payroll.cheque'].search([
|
||||
('cheque_number', '!=', False),
|
||||
('cheque_number', '!=', ''),
|
||||
('company_id', '=', company_id),
|
||||
])
|
||||
for cheque in payroll_cheques:
|
||||
try:
|
||||
num = int(cheque.cheque_number)
|
||||
if num > max_num:
|
||||
max_num = num
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Check vendor payments (account.payment)
|
||||
if 'account.payment' in self.env:
|
||||
payments = self.env['account.payment'].search([
|
||||
('check_number', '!=', False),
|
||||
('check_number', '!=', ''),
|
||||
('company_id', '=', company_id),
|
||||
])
|
||||
for payment in payments:
|
||||
try:
|
||||
num = int(payment.check_number)
|
||||
if num > max_num:
|
||||
max_num = num
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
next_num = max_num + 1
|
||||
return str(next_num).zfill(6)
|
||||
|
||||
def action_confirm(self):
|
||||
"""Save cheque number and print."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.cheque_number:
|
||||
raise UserError(_('Please enter a cheque number.'))
|
||||
|
||||
# Check if cheque number already exists
|
||||
existing = self.env['payroll.cheque'].search([
|
||||
('cheque_number', '=', self.cheque_number),
|
||||
('id', '!=', self.cheque_id.id),
|
||||
('company_id', '=', self.cheque_id.company_id.id),
|
||||
])
|
||||
if existing:
|
||||
raise UserError(_('Cheque number %s is already used by another cheque.') % self.cheque_number)
|
||||
|
||||
# Check account.payment for vendor cheques
|
||||
payment_check = self.env['account.payment'].search([
|
||||
('check_number', '=', self.cheque_number),
|
||||
('company_id', '=', self.cheque_id.company_id.id),
|
||||
], limit=1)
|
||||
if payment_check:
|
||||
raise UserError(_('Cheque number %s is already used by a vendor payment.') % self.cheque_number)
|
||||
|
||||
# Update cheque number
|
||||
self.cheque_id.write({
|
||||
'cheque_number': self.cheque_number,
|
||||
})
|
||||
|
||||
# Mark as printed and print
|
||||
self.cheque_id.write({
|
||||
'state': 'printed',
|
||||
'printed_date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
return self.env.ref('fusion_payroll.action_report_payroll_cheque').report_action(self.cheque_id)
|
||||
81
fusion_payroll/wizards/hr_employee_sin_wizard.py
Normal file
81
fusion_payroll/wizards/hr_employee_sin_wizard.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError, AccessError
|
||||
|
||||
|
||||
class HrEmployeeSinWizard(models.TransientModel):
|
||||
_name = 'hr.employee.sin.wizard'
|
||||
_description = 'View/Edit Employee SIN'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
employee_name = fields.Char(
|
||||
related='employee_id.name',
|
||||
string='Employee Name',
|
||||
readonly=True,
|
||||
)
|
||||
sin_number = fields.Char(
|
||||
string='Social Insurance Number',
|
||||
help='9-digit Social Insurance Number (Format: XXX-XXX-XXX)',
|
||||
)
|
||||
sin_number_formatted = fields.Char(
|
||||
string='SIN (Formatted)',
|
||||
compute='_compute_sin_formatted',
|
||||
)
|
||||
|
||||
@api.depends('sin_number')
|
||||
def _compute_sin_formatted(self):
|
||||
for wizard in self:
|
||||
if wizard.sin_number:
|
||||
sin = wizard.sin_number.replace('-', '').replace(' ', '')
|
||||
if len(sin) == 9:
|
||||
wizard.sin_number_formatted = f"{sin[0:3]}-{sin[3:6]}-{sin[6:9]}"
|
||||
else:
|
||||
wizard.sin_number_formatted = wizard.sin_number
|
||||
else:
|
||||
wizard.sin_number_formatted = ''
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
# Check access rights
|
||||
if not self.env.user.has_group('hr.group_hr_manager') and \
|
||||
not self.env.user.has_group('account.group_account_manager'):
|
||||
raise AccessError('Only HR Managers and Accountants can view or edit SIN numbers.')
|
||||
return res
|
||||
|
||||
def action_save(self):
|
||||
"""Save the SIN number to the employee record"""
|
||||
self.ensure_one()
|
||||
|
||||
# Double-check access rights
|
||||
if not self.env.user.has_group('hr.group_hr_manager') and \
|
||||
not self.env.user.has_group('account.group_account_manager'):
|
||||
raise AccessError('Only HR Managers and Accountants can edit SIN numbers.')
|
||||
|
||||
# Format and validate SIN
|
||||
if self.sin_number:
|
||||
sin = self.sin_number.replace('-', '').replace(' ', '')
|
||||
if not sin.isdigit() or len(sin) != 9:
|
||||
raise UserError('Social Insurance Number must be exactly 9 digits.')
|
||||
# Store formatted
|
||||
formatted_sin = f"{sin[0:3]}-{sin[3:6]}-{sin[6:9]}"
|
||||
self.employee_id.write({'sin_number': formatted_sin})
|
||||
else:
|
||||
self.employee_id.write({'sin_number': False})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'SIN Updated',
|
||||
'message': f'Social Insurance Number has been updated for {self.employee_id.name}',
|
||||
'type': 'success',
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
}
|
||||
}
|
||||
175
fusion_payroll/wizards/hr_employee_terminate_wizard.py
Normal file
175
fusion_payroll/wizards/hr_employee_terminate_wizard.py
Normal file
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class HrEmployeeTerminateWizard(models.TransientModel):
|
||||
_name = 'hr.employee.terminate.wizard'
|
||||
_description = 'Employee Termination Wizard'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
)
|
||||
last_day_of_work = fields.Date(
|
||||
string='Last Day of Work',
|
||||
required=True,
|
||||
default=fields.Date.today,
|
||||
)
|
||||
roe_reason_code = fields.Selection(
|
||||
selection=[
|
||||
# A - Shortage of Work
|
||||
('A00', 'A00 - Shortage of work/End of contract or season'),
|
||||
('A01', 'A01 - Employer bankruptcy or receivership'),
|
||||
# B - Strike/Lockout
|
||||
('B00', 'B00 - Strike or lockout'),
|
||||
# D - Illness
|
||||
('D00', 'D00 - Illness or injury'),
|
||||
# E - Quit
|
||||
('E00', 'E00 - Quit'),
|
||||
('E02', 'E02 - Quit/Follow spouse'),
|
||||
('E03', 'E03 - Quit/Return to school'),
|
||||
('E04', 'E04 - Quit/Health Reasons'),
|
||||
('E05', 'E05 - Quit/Voluntary retirement'),
|
||||
('E06', 'E06 - Quit/Take another job'),
|
||||
('E09', 'E09 - Quit/Employer relocation'),
|
||||
('E10', 'E10 - Quit/Care for a dependent'),
|
||||
('E11', 'E11 - Quit/To become self-employed'),
|
||||
# F - Maternity
|
||||
('F00', 'F00 - Maternity'),
|
||||
# G - Retirement
|
||||
('G00', 'G00 - Mandatory retirement'),
|
||||
('G07', 'G07 - Retirement/Approved workforce reduction'),
|
||||
# H - Work-Sharing
|
||||
('H00', 'H00 - Work-Sharing'),
|
||||
# J - Apprentice
|
||||
('J00', 'J00 - Apprentice training'),
|
||||
# K - Other
|
||||
('K00', 'K00 - Other'),
|
||||
('K12', 'K12 - Other/Change of payroll frequency'),
|
||||
('K13', 'K13 - Other/Change of ownership'),
|
||||
('K14', 'K14 - Other/Requested by Employment Insurance'),
|
||||
('K15', 'K15 - Other/Canadian Forces - Queen\'s Regulations/Orders'),
|
||||
('K16', 'K16 - Other/At the employee\'s request'),
|
||||
('K17', 'K17 - Other/Change of Service Provider'),
|
||||
# M - Dismissal
|
||||
('M00', 'M00 - Dismissal'),
|
||||
('M08', 'M08 - Dismissal/Terminated within probationary period'),
|
||||
# N - Leave
|
||||
('N00', 'N00 - Leave of absence'),
|
||||
# P - Parental
|
||||
('P00', 'P00 - Parental'),
|
||||
# Z - Compassionate Care
|
||||
('Z00', 'Z00 - Compassionate Care/Family Caregiver'),
|
||||
],
|
||||
string='Reason for Termination',
|
||||
required=True,
|
||||
help='Record of Employment (ROE) reason code for Service Canada',
|
||||
)
|
||||
show_in_employee_lists_only = fields.Boolean(
|
||||
string='Keep in Employee Lists',
|
||||
help='If unchecked, employee will be archived after termination',
|
||||
)
|
||||
issue_roe_now = fields.Boolean(
|
||||
string='Issue ROE Now',
|
||||
default=False,
|
||||
help='Mark Record of Employment as issued immediately',
|
||||
)
|
||||
notes = fields.Text(
|
||||
string='Termination Notes',
|
||||
)
|
||||
|
||||
def action_terminate(self):
|
||||
"""Process employee termination"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.employee_id:
|
||||
raise UserError('No employee selected.')
|
||||
|
||||
# Map detailed ROE codes to simple reason codes for hr.roe model
|
||||
roe_code_mapping = {
|
||||
'A00': 'A', 'A01': 'A',
|
||||
'B00': 'B',
|
||||
'D00': 'D',
|
||||
'E00': 'E', 'E02': 'E', 'E03': 'E', 'E04': 'E', 'E05': 'E',
|
||||
'E06': 'E', 'E09': 'E', 'E10': 'E', 'E11': 'E',
|
||||
'F00': 'F',
|
||||
'G00': 'G', 'G07': 'G',
|
||||
'H00': 'H',
|
||||
'J00': 'J',
|
||||
'K00': 'K', 'K12': 'K', 'K13': 'K', 'K14': 'K', 'K15': 'K', 'K16': 'K', 'K17': 'K',
|
||||
'M00': 'M', 'M08': 'M',
|
||||
'N00': 'N',
|
||||
'P00': 'P',
|
||||
'Z00': 'Z',
|
||||
}
|
||||
|
||||
values = {
|
||||
'employment_status': 'terminated',
|
||||
'last_day_of_work': self.last_day_of_work,
|
||||
'roe_reason_code': self.roe_reason_code,
|
||||
'show_in_employee_lists_only': self.show_in_employee_lists_only,
|
||||
}
|
||||
|
||||
# Archive employee if not keeping in lists
|
||||
if not self.show_in_employee_lists_only:
|
||||
values['active'] = False
|
||||
|
||||
self.employee_id.write(values)
|
||||
|
||||
# End any running contracts
|
||||
running_contracts = self.env['hr.contract'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('state', '=', 'open'),
|
||||
])
|
||||
if running_contracts:
|
||||
running_contracts.write({
|
||||
'date_end': self.last_day_of_work,
|
||||
'state': 'close',
|
||||
})
|
||||
|
||||
# Log note if provided
|
||||
if self.notes:
|
||||
self.employee_id.message_post(
|
||||
body=f"<strong>Termination Notes:</strong><br/>{self.notes}",
|
||||
message_type='comment',
|
||||
)
|
||||
|
||||
# Create ROE if requested
|
||||
roe = None
|
||||
if self.issue_roe_now:
|
||||
simple_reason = roe_code_mapping.get(self.roe_reason_code, 'K')
|
||||
roe = self.env['hr.roe'].create({
|
||||
'employee_id': self.employee_id.id,
|
||||
'last_day_paid': self.last_day_of_work,
|
||||
'final_pay_period_end': self.last_day_of_work,
|
||||
'reason_code': simple_reason,
|
||||
'contact_name': self.env.user.name,
|
||||
'communication_language': self.employee_id.communication_language or 'E',
|
||||
})
|
||||
# Calculate earnings automatically
|
||||
roe.action_calculate_earnings()
|
||||
|
||||
# Return action to open ROE if created, otherwise show notification
|
||||
if roe:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Record of Employment',
|
||||
'res_model': 'hr.roe',
|
||||
'res_id': roe.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Employee Terminated',
|
||||
'message': f'{self.employee_id.name} has been terminated. ROE Code: {self.roe_reason_code}',
|
||||
'type': 'warning',
|
||||
'sticky': True,
|
||||
}
|
||||
}
|
||||
213
fusion_payroll/wizards/payroll_cheque_print_wizard.py
Normal file
213
fusion_payroll/wizards/payroll_cheque_print_wizard.py
Normal file
@@ -0,0 +1,213 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Payroll Cheque Print Wizard
|
||||
===========================
|
||||
Wizard that appears after payroll submission when cheques need to be printed.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class PayrollChequePrintWizard(models.TransientModel):
|
||||
"""Wizard to print cheques after payroll submission."""
|
||||
_name = 'payroll.cheque.print.wizard'
|
||||
_description = 'Print Payroll Cheques'
|
||||
|
||||
payslip_run_id = fields.Many2one(
|
||||
'hr.payslip.run',
|
||||
string='Payslip Batch',
|
||||
readonly=True,
|
||||
)
|
||||
cheque_ids = fields.Many2many(
|
||||
'payroll.cheque',
|
||||
string='Cheques to Print',
|
||||
)
|
||||
cheque_count = fields.Integer(string='Number of Cheques')
|
||||
|
||||
# Starting cheque number (optional override)
|
||||
starting_cheque_number = fields.Char(
|
||||
string='Starting Cheque Number',
|
||||
help='Leave blank to use automatic numbering',
|
||||
)
|
||||
|
||||
# Preview of cheques
|
||||
cheque_preview = fields.Html(
|
||||
string='Cheque Preview',
|
||||
compute='_compute_cheque_preview',
|
||||
)
|
||||
|
||||
@api.depends('cheque_ids')
|
||||
def _compute_cheque_preview(self):
|
||||
for wizard in self:
|
||||
if not wizard.cheque_ids:
|
||||
wizard.cheque_preview = '<p>No cheques to print</p>'
|
||||
continue
|
||||
|
||||
html = '<table class="table table-sm"><thead><tr>'
|
||||
html += '<th>Employee</th><th>Amount</th><th>Pay Period</th>'
|
||||
html += '</tr></thead><tbody>'
|
||||
|
||||
for cheque in wizard.cheque_ids:
|
||||
html += f'<tr><td>{cheque.employee_id.name}</td>'
|
||||
html += f'<td>${cheque.amount:,.2f}</td>'
|
||||
html += f'<td>{cheque.pay_period_display or ""}</td></tr>'
|
||||
|
||||
html += '</tbody></table>'
|
||||
wizard.cheque_preview = html
|
||||
|
||||
def action_print_all(self):
|
||||
"""Print all cheques."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.cheque_ids:
|
||||
raise UserError(_("No cheques to print."))
|
||||
|
||||
# Assign numbers if custom starting number provided
|
||||
if self.starting_cheque_number:
|
||||
try:
|
||||
start_num = int(self.starting_cheque_number)
|
||||
for i, cheque in enumerate(self.cheque_ids.sorted(key=lambda c: c.employee_id.name)):
|
||||
cheque.cheque_number = str(start_num + i).zfill(6)
|
||||
except ValueError:
|
||||
raise UserError(_("Starting cheque number must be numeric."))
|
||||
else:
|
||||
# Use sequence
|
||||
for cheque in self.cheque_ids:
|
||||
if not cheque.cheque_number:
|
||||
cheque.action_assign_number()
|
||||
|
||||
# Mark all as printed
|
||||
self.cheque_ids.write({
|
||||
'state': 'printed',
|
||||
'printed_date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
# Generate report
|
||||
return self.env.ref('fusion_payroll.action_report_payroll_cheque').report_action(self.cheque_ids)
|
||||
|
||||
def action_skip_print(self):
|
||||
"""Skip printing and go to payslip view."""
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Payslips'),
|
||||
'res_model': 'hr.payslip.run',
|
||||
'res_id': self.payslip_run_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_cheques(self):
|
||||
"""View cheques list."""
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Cheques'),
|
||||
'res_model': 'payroll.cheque',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', self.cheque_ids.ids)],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
|
||||
class HrPayslipRunCheque(models.Model):
|
||||
"""Extend payslip batch with cheque functionality."""
|
||||
_inherit = 'hr.payslip.run'
|
||||
|
||||
cheque_ids = fields.One2many(
|
||||
'payroll.cheque',
|
||||
'payslip_run_id',
|
||||
string='Cheques',
|
||||
)
|
||||
cheque_count = fields.Integer(
|
||||
string='Cheque Count',
|
||||
compute='_compute_cheque_count',
|
||||
)
|
||||
cheques_to_print = fields.Integer(
|
||||
string='Cheques to Print',
|
||||
compute='_compute_cheque_count',
|
||||
)
|
||||
|
||||
@api.depends('cheque_ids', 'cheque_ids.state')
|
||||
def _compute_cheque_count(self):
|
||||
for batch in self:
|
||||
batch.cheque_count = len(batch.cheque_ids)
|
||||
batch.cheques_to_print = len(batch.cheque_ids.filtered(lambda c: c.state == 'draft'))
|
||||
|
||||
def action_view_cheques(self):
|
||||
"""Open cheques for this batch."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Cheques'),
|
||||
'res_model': 'payroll.cheque',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('payslip_run_id', '=', self.id)],
|
||||
'context': {'default_payslip_run_id': self.id},
|
||||
}
|
||||
|
||||
def action_print_cheques(self):
|
||||
"""Print all draft cheques for this batch."""
|
||||
self.ensure_one()
|
||||
|
||||
cheques = self.cheque_ids.filtered(lambda c: c.state == 'draft')
|
||||
|
||||
if not cheques:
|
||||
# Check if there are employees needing cheques
|
||||
employees_needing_cheques = self.slip_ids.filtered(
|
||||
lambda s: hasattr(s.employee_id, 'payment_method') and
|
||||
s.employee_id.payment_method == 'cheque'
|
||||
).mapped('employee_id')
|
||||
|
||||
if employees_needing_cheques:
|
||||
# Create cheques
|
||||
for slip in self.slip_ids:
|
||||
if hasattr(slip.employee_id, 'payment_method') and \
|
||||
slip.employee_id.payment_method == 'cheque':
|
||||
self.env['payroll.cheque'].create_from_payslip(slip)
|
||||
|
||||
cheques = self.cheque_ids.filtered(lambda c: c.state == 'draft')
|
||||
|
||||
if not cheques:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_("No cheques to print. Make sure employees have payment method set to 'Cheque'."))
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Print Cheques'),
|
||||
'res_model': 'payroll.cheque.print.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_payslip_run_id': self.id,
|
||||
'default_cheque_ids': [(6, 0, cheques.ids)],
|
||||
'default_cheque_count': len(cheques),
|
||||
},
|
||||
}
|
||||
|
||||
def action_create_cheques(self):
|
||||
"""Create cheques for all employees paid by cheque."""
|
||||
self.ensure_one()
|
||||
|
||||
created_count = 0
|
||||
for slip in self.slip_ids:
|
||||
if hasattr(slip.employee_id, 'payment_method') and \
|
||||
slip.employee_id.payment_method == 'cheque':
|
||||
# Check if cheque already exists
|
||||
existing = self.env['payroll.cheque'].search([
|
||||
('payslip_id', '=', slip.id),
|
||||
], limit=1)
|
||||
|
||||
if not existing:
|
||||
self.env['payroll.cheque'].create_from_payslip(slip)
|
||||
created_count += 1
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Cheques Created'),
|
||||
'message': _('%d cheques created.') % created_count,
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
865
fusion_payroll/wizards/run_payroll_wizard.py
Normal file
865
fusion_payroll/wizards/run_payroll_wizard.py
Normal file
@@ -0,0 +1,865 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
class RunPayrollWizard(models.TransientModel):
|
||||
_name = 'run.payroll.wizard'
|
||||
_description = 'Run Payroll'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
display_name = fields.Char(compute='_compute_display_name')
|
||||
|
||||
@api.depends('pay_schedule', 'date_start', 'date_end')
|
||||
def _compute_display_name(self):
|
||||
schedule_labels = {
|
||||
'weekly': 'Weekly',
|
||||
'biweekly': 'Bi-Weekly',
|
||||
'semi_monthly': 'Semi-Monthly',
|
||||
'monthly': 'Monthly',
|
||||
}
|
||||
for wizard in self:
|
||||
schedule = schedule_labels.get(wizard.pay_schedule, 'Bi-Weekly')
|
||||
wizard.display_name = f"Run Payroll: {schedule}"
|
||||
|
||||
# === Wizard State ===
|
||||
state = fields.Selection([
|
||||
('entry', 'Enter Payroll'),
|
||||
('preview', 'Preview Payroll'),
|
||||
], string='State', default='entry')
|
||||
|
||||
# === Company & Schedule ===
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# === Pay Period Selection (QuickBooks-like dropdown) ===
|
||||
pay_period_id = fields.Many2one(
|
||||
'payroll.pay.period',
|
||||
string='Pay Period',
|
||||
domain="[('company_id', '=', company_id)]",
|
||||
)
|
||||
pay_schedule = fields.Selection([
|
||||
('weekly', 'Weekly'),
|
||||
('biweekly', 'Bi-Weekly'),
|
||||
('semi_monthly', 'Semi-Monthly'),
|
||||
('monthly', 'Monthly'),
|
||||
], string='Pay Schedule', required=True, default='biweekly')
|
||||
|
||||
# === Chart of Accounts (for journal entries) ===
|
||||
bank_account_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Funding Account',
|
||||
domain="[('account_type', 'in', ['asset_cash', 'liability_credit_card'])]",
|
||||
help='Bank or credit card account to fund payroll',
|
||||
)
|
||||
|
||||
# === Preview Summary Fields ===
|
||||
total_payroll_cost = fields.Monetary(
|
||||
string='Total Payroll Cost',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_preview_totals',
|
||||
)
|
||||
change_from_last = fields.Float(
|
||||
string='Change from Last (%)',
|
||||
compute='_compute_preview_totals',
|
||||
)
|
||||
|
||||
date_start = fields.Date(
|
||||
string='Period Start',
|
||||
required=True,
|
||||
default=lambda self: self._default_date_start(),
|
||||
)
|
||||
date_end = fields.Date(
|
||||
string='Period End',
|
||||
required=True,
|
||||
default=lambda self: self._default_date_end(),
|
||||
)
|
||||
pay_date = fields.Date(
|
||||
string='Pay Date',
|
||||
required=True,
|
||||
default=lambda self: fields.Date.context_today(self) + relativedelta(days=7),
|
||||
)
|
||||
|
||||
# Period display name for dropdown
|
||||
period_display = fields.Char(
|
||||
string='Period',
|
||||
compute='_compute_period_display',
|
||||
)
|
||||
|
||||
# === Employee Payroll Entries (QuickBooks-like grid) ===
|
||||
entry_ids = fields.One2many(
|
||||
'payroll.entry',
|
||||
'wizard_id',
|
||||
string='Payroll Entries',
|
||||
)
|
||||
|
||||
# Summary totals
|
||||
total_regular_hours = fields.Float(
|
||||
string='Total Regular Hours',
|
||||
compute='_compute_totals',
|
||||
)
|
||||
total_hours = fields.Float(
|
||||
string='Total Hours',
|
||||
compute='_compute_totals',
|
||||
)
|
||||
total_gross_pay = fields.Monetary(
|
||||
string='Total Gross Pay',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_totals',
|
||||
)
|
||||
total_net_pay = fields.Monetary(
|
||||
string='Total Net Pay',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_totals',
|
||||
)
|
||||
total_employee_taxes = fields.Monetary(
|
||||
string='Total Employee Taxes',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_totals',
|
||||
)
|
||||
total_employer_taxes = fields.Monetary(
|
||||
string='Total Employer Taxes',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_totals',
|
||||
)
|
||||
employee_count = fields.Integer(
|
||||
string='Employees',
|
||||
compute='_compute_totals',
|
||||
)
|
||||
selected_count = fields.Integer(
|
||||
string='Selected',
|
||||
compute='_compute_totals',
|
||||
)
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
|
||||
# === Generated Payslips ===
|
||||
payslip_run_id = fields.Many2one(
|
||||
'hr.payslip.run',
|
||||
string='Payslip Batch',
|
||||
readonly=True,
|
||||
)
|
||||
payslip_count = fields.Integer(
|
||||
string='Payslips Generated',
|
||||
compute='_compute_payslip_count',
|
||||
)
|
||||
|
||||
# Available periods for dropdown
|
||||
available_period_ids = fields.Many2many(
|
||||
'payroll.pay.period',
|
||||
string='Available Periods',
|
||||
compute='_compute_available_periods',
|
||||
)
|
||||
|
||||
def _default_date_start(self):
|
||||
"""Default to start of current pay period"""
|
||||
today = fields.Date.context_today(self)
|
||||
# Default to start of current bi-weekly period (Monday)
|
||||
return today - relativedelta(days=today.weekday())
|
||||
|
||||
def _default_date_end(self):
|
||||
"""Default to end of current pay period"""
|
||||
today = fields.Date.context_today(self)
|
||||
start = today - relativedelta(days=today.weekday())
|
||||
return start + relativedelta(days=13)
|
||||
|
||||
@api.depends('date_start', 'date_end')
|
||||
def _compute_period_display(self):
|
||||
for wizard in self:
|
||||
if wizard.date_start and wizard.date_end:
|
||||
wizard.period_display = f"{wizard.date_start.strftime('%m.%d.%Y')} to {wizard.date_end.strftime('%m.%d.%Y')}"
|
||||
else:
|
||||
wizard.period_display = ''
|
||||
|
||||
@api.depends('company_id', 'pay_schedule')
|
||||
def _compute_available_periods(self):
|
||||
today = fields.Date.context_today(self)
|
||||
six_months_ago = today - relativedelta(months=6)
|
||||
|
||||
for wizard in self:
|
||||
# Get periods from last 6 months plus future periods
|
||||
periods = self.env['payroll.pay.period'].search([
|
||||
('company_id', '=', wizard.company_id.id),
|
||||
('schedule_type', '=', wizard.pay_schedule),
|
||||
'|',
|
||||
('date_start', '>=', six_months_ago), # Past 6 months
|
||||
('date_end', '>=', today), # Current and future
|
||||
], order='date_start asc') # Sort ascending so current is near top
|
||||
wizard.available_period_ids = periods
|
||||
|
||||
@api.depends('entry_ids', 'entry_ids.regular_hours', 'entry_ids.total_hours',
|
||||
'entry_ids.gross_pay', 'entry_ids.net_pay',
|
||||
'entry_ids.total_employee_tax', 'entry_ids.total_employer_tax')
|
||||
def _compute_totals(self):
|
||||
for wizard in self:
|
||||
wizard.total_regular_hours = sum(wizard.entry_ids.mapped('regular_hours'))
|
||||
wizard.total_hours = sum(wizard.entry_ids.mapped('total_hours'))
|
||||
wizard.total_gross_pay = sum(wizard.entry_ids.mapped('gross_pay'))
|
||||
wizard.total_net_pay = sum(wizard.entry_ids.mapped('net_pay'))
|
||||
wizard.total_employee_taxes = sum(wizard.entry_ids.mapped('total_employee_tax'))
|
||||
wizard.total_employer_taxes = sum(wizard.entry_ids.mapped('total_employer_tax'))
|
||||
wizard.employee_count = len(wizard.entry_ids)
|
||||
wizard.selected_count = len(wizard.entry_ids.filtered(lambda e: e.regular_hours > 0))
|
||||
|
||||
@api.depends('entry_ids', 'entry_ids.gross_pay', 'entry_ids.total_employer_tax')
|
||||
def _compute_preview_totals(self):
|
||||
for wizard in self:
|
||||
gross = sum(wizard.entry_ids.mapped('gross_pay'))
|
||||
employer_tax = sum(wizard.entry_ids.mapped('total_employer_tax'))
|
||||
wizard.total_payroll_cost = gross + employer_tax
|
||||
|
||||
# Calculate change from last payroll
|
||||
last_payroll = self.env['hr.payslip.run'].search([
|
||||
('company_id', '=', wizard.company_id.id),
|
||||
('state', 'in', ['close', 'paid']),
|
||||
], order='date_end desc', limit=1)
|
||||
|
||||
if last_payroll and last_payroll.slip_ids:
|
||||
last_total = sum(last_payroll.slip_ids.mapped('net_wage'))
|
||||
if last_total > 0:
|
||||
wizard.change_from_last = ((wizard.total_net_pay - last_total) / last_total) * 100
|
||||
else:
|
||||
wizard.change_from_last = 0
|
||||
else:
|
||||
wizard.change_from_last = 0
|
||||
|
||||
@api.depends('payslip_run_id')
|
||||
def _compute_payslip_count(self):
|
||||
for wizard in self:
|
||||
if wizard.payslip_run_id:
|
||||
wizard.payslip_count = len(wizard.payslip_run_id.slip_ids)
|
||||
else:
|
||||
wizard.payslip_count = 0
|
||||
|
||||
@api.onchange('pay_period_id')
|
||||
def _onchange_pay_period_id(self):
|
||||
"""Update dates when period is selected."""
|
||||
if self.pay_period_id:
|
||||
self.date_start = self.pay_period_id.date_start
|
||||
self.date_end = self.pay_period_id.date_end
|
||||
if self.pay_period_id.pay_date:
|
||||
self.pay_date = self.pay_period_id.pay_date
|
||||
|
||||
@api.onchange('pay_schedule', 'date_start')
|
||||
def _onchange_pay_schedule(self):
|
||||
"""Adjust date_end based on pay schedule"""
|
||||
if self.date_start and self.pay_schedule:
|
||||
if self.pay_schedule == 'weekly':
|
||||
self.date_end = self.date_start + relativedelta(days=6)
|
||||
elif self.pay_schedule == 'biweekly':
|
||||
self.date_end = self.date_start + relativedelta(days=13)
|
||||
elif self.pay_schedule == 'semi_monthly':
|
||||
if self.date_start.day <= 15:
|
||||
self.date_end = self.date_start.replace(day=15)
|
||||
else:
|
||||
self.date_end = self.date_start + relativedelta(months=1, day=1) - relativedelta(days=1)
|
||||
elif self.pay_schedule == 'monthly':
|
||||
self.date_end = self.date_start + relativedelta(months=1, day=1) - relativedelta(days=1)
|
||||
|
||||
@api.onchange('company_id', 'pay_schedule')
|
||||
def _onchange_load_employees(self):
|
||||
"""Load employees when company or schedule changes."""
|
||||
self._load_employees()
|
||||
|
||||
def _load_employees(self):
|
||||
"""Load all active employees into entries."""
|
||||
if not self.company_id:
|
||||
return
|
||||
|
||||
# Clear existing entries
|
||||
self.entry_ids = [(5, 0, 0)]
|
||||
|
||||
# Find active employees in the company
|
||||
employees = self.env['hr.employee'].search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
('active', '=', True),
|
||||
])
|
||||
|
||||
entries = []
|
||||
for employee in employees:
|
||||
# Get payment method from Fusion Payroll settings
|
||||
payment_method = getattr(employee, 'payment_method', 'cheque') or 'cheque'
|
||||
|
||||
# Get vacation pay percent from Fusion Payroll settings (vacation_rate field)
|
||||
vacation_percent = getattr(employee, 'vacation_rate', 4.0) or 4.0
|
||||
|
||||
entry_vals = {
|
||||
'employee_id': employee.id,
|
||||
'payment_method': payment_method,
|
||||
'vacation_pay_percent': vacation_percent,
|
||||
'regular_hours': 0,
|
||||
'stat_holiday_hours': 0,
|
||||
}
|
||||
entries.append((0, 0, entry_vals))
|
||||
|
||||
self.entry_ids = entries
|
||||
|
||||
def action_add_employee(self):
|
||||
"""Open dialog to add an employee."""
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Add Employee'),
|
||||
'res_model': 'hr.employee',
|
||||
'view_mode': 'kanban,list,form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_company_id': self.company_id.id,
|
||||
'payroll_wizard_id': self.id,
|
||||
},
|
||||
}
|
||||
|
||||
def action_load_attendance(self):
|
||||
"""Load attendance hours for all employees in the period."""
|
||||
for entry in self.entry_ids:
|
||||
entry.action_load_attendance_hours()
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Attendance Loaded'),
|
||||
'message': _('Loaded attendance hours for %d employees.') % len(self.entry_ids),
|
||||
'type': 'success',
|
||||
},
|
||||
}
|
||||
|
||||
def action_preview_payroll(self):
|
||||
"""Show the preview page with totals."""
|
||||
self.ensure_one()
|
||||
|
||||
# Validate that at least one employee has hours
|
||||
entries_with_pay = self.entry_ids.filtered(
|
||||
lambda e: e.regular_hours > 0 or e.stat_holiday_hours > 0 or e.stat_pay_avg_daily_wage > 0
|
||||
)
|
||||
|
||||
if not entries_with_pay:
|
||||
raise UserError(_("No employees have hours entered. Please enter hours for at least one employee."))
|
||||
|
||||
# Set state to preview (this was incorrectly indented before)
|
||||
self.state = 'preview'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Run Payroll'),
|
||||
'res_model': 'run.payroll.wizard',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'context': {'form_view_ref': 'fusion_payroll.run_payroll_wizard_view_form'},
|
||||
}
|
||||
|
||||
def action_back_to_entry(self):
|
||||
"""Go back to the entry page."""
|
||||
self.ensure_one()
|
||||
self.state = 'entry'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Run Payroll'),
|
||||
'res_model': 'run.payroll.wizard',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'context': {'form_view_ref': 'fusion_payroll.run_payroll_wizard_view_form'},
|
||||
}
|
||||
|
||||
def action_save_for_later(self):
|
||||
"""Save the payroll entries for later without processing."""
|
||||
self.ensure_one()
|
||||
|
||||
# Update pay period status to in_progress if using periods
|
||||
if self.pay_period_id:
|
||||
self.pay_period_id.write({'state': 'in_progress'})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Payroll Saved'),
|
||||
'message': _('Payroll entries have been saved. You can continue later.'),
|
||||
'type': 'success',
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
|
||||
def action_submit_payroll(self):
|
||||
"""Submit the payroll - create payslips and journal entries."""
|
||||
self.ensure_one()
|
||||
return self.action_generate_payslips()
|
||||
|
||||
def action_generate_payslips(self):
|
||||
"""Generate payslips for all entries with hours."""
|
||||
self.ensure_one()
|
||||
|
||||
# Get entries with hours
|
||||
entries_with_pay = self.entry_ids.filtered(
|
||||
lambda e: e.regular_hours > 0 or e.stat_holiday_hours > 0 or e.stat_pay_avg_daily_wage > 0
|
||||
)
|
||||
|
||||
if not entries_with_pay:
|
||||
raise UserError(_("No employees have hours entered. Please enter hours for at least one employee."))
|
||||
|
||||
# Create payslip batch
|
||||
batch_name = f"Payroll {self.date_start.strftime('%Y-%m-%d')} to {self.date_end.strftime('%Y-%m-%d')}"
|
||||
payslip_run = self.env['hr.payslip.run'].create({
|
||||
'name': batch_name,
|
||||
'date_start': self.date_start,
|
||||
'date_end': self.date_end,
|
||||
'company_id': self.company_id.id,
|
||||
})
|
||||
|
||||
# Generate payslips for each entry
|
||||
for entry in entries_with_pay:
|
||||
# Generate payslip name
|
||||
payslip_name = f"Payslip - {entry.employee_id.name} - {self.date_start.strftime('%Y/%m')}"
|
||||
|
||||
# Create payslip
|
||||
payslip_vals = {
|
||||
'name': payslip_name,
|
||||
'employee_id': entry.employee_id.id,
|
||||
'date_from': self.date_start,
|
||||
'date_to': self.date_end,
|
||||
'payslip_run_id': payslip_run.id,
|
||||
'company_id': self.company_id.id,
|
||||
}
|
||||
|
||||
# Add contract_id if employee has one
|
||||
if hasattr(entry.employee_id, 'contract_id') and entry.employee_id.contract_id:
|
||||
payslip_vals['contract_id'] = entry.employee_id.contract_id.id
|
||||
|
||||
# Create the payslip (this was incorrectly indented before)
|
||||
payslip = self.env['hr.payslip'].create(payslip_vals)
|
||||
|
||||
# Add inputs from Fusion Payroll entry
|
||||
self._add_fusion_payroll_inputs(payslip, entry)
|
||||
|
||||
# Compute sheet
|
||||
payslip.compute_sheet()
|
||||
|
||||
# Override computed values with Fusion Payroll calculations
|
||||
self._apply_fusion_payroll_values(payslip, entry)
|
||||
|
||||
self.payslip_run_id = payslip_run
|
||||
|
||||
# Update pay period status if using periods
|
||||
if self.pay_period_id:
|
||||
self.pay_period_id.write({
|
||||
'state': 'in_progress',
|
||||
'payslip_run_id': payslip_run.id,
|
||||
})
|
||||
|
||||
# Create cheques for employees with payment method = 'cheque'
|
||||
cheques_created = self._create_cheques_for_batch(payslip_run)
|
||||
|
||||
# If cheques were created, show print option
|
||||
if cheques_created:
|
||||
return self._action_payroll_complete_with_cheques(payslip_run, cheques_created)
|
||||
|
||||
return self.action_view_payslips()
|
||||
|
||||
def _add_fusion_payroll_inputs(self, payslip, entry):
|
||||
"""Add salary inputs from Fusion Payroll entry to payslip."""
|
||||
# Find or create input types
|
||||
InputType = self.env['hr.payslip.input.type']
|
||||
Input = self.env['hr.payslip.input']
|
||||
|
||||
# Regular Hours input
|
||||
if entry.regular_hours > 0:
|
||||
hours_type = InputType.search([('code', '=', 'HOURS')], limit=1)
|
||||
if not hours_type:
|
||||
hours_type = InputType.search([('code', '=', 'WORKED_HOURS')], limit=1)
|
||||
if not hours_type:
|
||||
hours_type = InputType.create({
|
||||
'name': 'Worked Hours',
|
||||
'code': 'HOURS',
|
||||
})
|
||||
Input.create({
|
||||
'payslip_id': payslip.id,
|
||||
'input_type_id': hours_type.id,
|
||||
'amount': entry.regular_hours,
|
||||
})
|
||||
|
||||
# Regular Pay (calculated amount)
|
||||
if entry.regular_pay > 0:
|
||||
reg_pay_type = InputType.search([('code', '=', 'REGPAY')], limit=1)
|
||||
if not reg_pay_type:
|
||||
reg_pay_type = InputType.create({
|
||||
'name': 'Regular Pay',
|
||||
'code': 'REGPAY',
|
||||
})
|
||||
Input.create({
|
||||
'payslip_id': payslip.id,
|
||||
'input_type_id': reg_pay_type.id,
|
||||
'amount': entry.regular_pay,
|
||||
})
|
||||
|
||||
# Vacation Pay
|
||||
if entry.vacation_pay > 0:
|
||||
vac_type = InputType.search([('code', '=', 'VAC')], limit=1)
|
||||
if not vac_type:
|
||||
vac_type = InputType.create({
|
||||
'name': 'Vacation Pay',
|
||||
'code': 'VAC',
|
||||
})
|
||||
Input.create({
|
||||
'payslip_id': payslip.id,
|
||||
'input_type_id': vac_type.id,
|
||||
'amount': entry.vacation_pay,
|
||||
})
|
||||
|
||||
# Stat Holiday Pay
|
||||
if entry.stat_holiday_pay > 0:
|
||||
stat_type = InputType.search([('code', '=', 'STAT')], limit=1)
|
||||
if not stat_type:
|
||||
stat_type = InputType.create({
|
||||
'name': 'Stat Holiday Pay',
|
||||
'code': 'STAT',
|
||||
})
|
||||
Input.create({
|
||||
'payslip_id': payslip.id,
|
||||
'input_type_id': stat_type.id,
|
||||
'amount': entry.stat_holiday_pay,
|
||||
})
|
||||
|
||||
# Stat Pay (Average Daily Wage)
|
||||
if entry.stat_pay_avg_daily_wage > 0:
|
||||
stat_avg_type = InputType.search([('code', '=', 'STATAVG')], limit=1)
|
||||
if not stat_avg_type:
|
||||
stat_avg_type = InputType.create({
|
||||
'name': 'Stat Pay - Avg Daily Wage',
|
||||
'code': 'STATAVG',
|
||||
})
|
||||
Input.create({
|
||||
'payslip_id': payslip.id,
|
||||
'input_type_id': stat_avg_type.id,
|
||||
'amount': entry.stat_pay_avg_daily_wage,
|
||||
})
|
||||
|
||||
def _apply_fusion_payroll_values(self, payslip, entry):
|
||||
"""Apply Fusion Payroll calculated values to payslip lines."""
|
||||
# Get or create salary rule categories
|
||||
Category = self.env['hr.salary.rule.category']
|
||||
|
||||
# Find categories
|
||||
gross_cat = Category.search([('code', '=', 'GROSS')], limit=1)
|
||||
ded_cat = Category.search([('code', '=', 'DED')], limit=1)
|
||||
net_cat = Category.search([('code', '=', 'NET')], limit=1)
|
||||
|
||||
# Update or create payslip lines with Fusion Payroll values
|
||||
# This directly sets the computed amounts
|
||||
|
||||
# Update basic wage on payslip
|
||||
payslip.write({
|
||||
'basic_wage': entry.gross_pay,
|
||||
'net_wage': entry.net_pay,
|
||||
})
|
||||
|
||||
# Update payslip lines
|
||||
for line in payslip.line_ids:
|
||||
if line.code == 'BASIC' or line.code == 'GROSS':
|
||||
line.write({'amount': entry.gross_pay, 'total': entry.gross_pay})
|
||||
elif line.code == 'NET':
|
||||
line.write({'amount': entry.net_pay, 'total': entry.net_pay})
|
||||
|
||||
# Create additional lines if they don't exist
|
||||
self._ensure_payslip_lines(payslip, entry)
|
||||
|
||||
def _ensure_payslip_lines(self, payslip, entry):
|
||||
"""Ensure all Fusion Payroll calculated values have corresponding payslip lines."""
|
||||
Line = self.env['hr.payslip.line']
|
||||
Rule = self.env['hr.salary.rule']
|
||||
Category = self.env['hr.salary.rule.category']
|
||||
|
||||
# Get categories
|
||||
gross_cat = Category.search([('code', '=', 'GROSS')], limit=1)
|
||||
ded_cat = Category.search([('code', '=', 'DED')], limit=1)
|
||||
alw_cat = Category.search([('code', '=', 'ALW')], limit=1)
|
||||
|
||||
# Helper to find or create rule
|
||||
def get_or_create_rule(code, name, category, sequence=100):
|
||||
rule = Rule.search([('code', '=', code)], limit=1)
|
||||
if not rule and category:
|
||||
struct = payslip.struct_id or self.env['hr.payroll.structure'].search([], limit=1)
|
||||
rule = Rule.create({
|
||||
'name': name,
|
||||
'code': code,
|
||||
'category_id': category.id,
|
||||
'sequence': sequence,
|
||||
'struct_id': struct.id if struct else False,
|
||||
'amount_select': 'fix',
|
||||
'amount_fix': 0,
|
||||
})
|
||||
return rule
|
||||
|
||||
# Check and add lines
|
||||
existing_codes = payslip.line_ids.mapped('code')
|
||||
|
||||
# Regular Pay line
|
||||
if 'REGPAY' not in existing_codes and entry.regular_pay > 0:
|
||||
rule = get_or_create_rule('REGPAY', 'Regular Pay', gross_cat, 10)
|
||||
if rule:
|
||||
Line.create({
|
||||
'slip_id': payslip.id,
|
||||
'salary_rule_id': rule.id,
|
||||
'name': 'Regular Pay',
|
||||
'code': 'REGPAY',
|
||||
'category_id': gross_cat.id if gross_cat else False,
|
||||
'sequence': 10,
|
||||
'quantity': entry.regular_hours,
|
||||
'rate': entry.hourly_rate,
|
||||
'amount': entry.regular_pay,
|
||||
'total': entry.regular_pay,
|
||||
})
|
||||
|
||||
# Vacation Pay line
|
||||
if 'VAC' not in existing_codes and entry.vacation_pay > 0:
|
||||
rule = get_or_create_rule('VAC', 'Vacation Pay', alw_cat or gross_cat, 20)
|
||||
if rule:
|
||||
Line.create({
|
||||
'slip_id': payslip.id,
|
||||
'salary_rule_id': rule.id,
|
||||
'name': 'Vacation Pay',
|
||||
'code': 'VAC',
|
||||
'category_id': (alw_cat or gross_cat).id if (alw_cat or gross_cat) else False,
|
||||
'sequence': 20,
|
||||
'quantity': 1,
|
||||
'rate': entry.vacation_pay_percent,
|
||||
'amount': entry.vacation_pay,
|
||||
'total': entry.vacation_pay,
|
||||
})
|
||||
|
||||
# Stat Holiday Pay line
|
||||
if 'STAT' not in existing_codes and entry.stat_holiday_pay > 0:
|
||||
rule = get_or_create_rule('STAT', 'Stat Holiday Pay', alw_cat or gross_cat, 25)
|
||||
if rule:
|
||||
Line.create({
|
||||
'slip_id': payslip.id,
|
||||
'salary_rule_id': rule.id,
|
||||
'name': 'Stat Holiday Pay',
|
||||
'code': 'STAT',
|
||||
'category_id': (alw_cat or gross_cat).id if (alw_cat or gross_cat) else False,
|
||||
'sequence': 25,
|
||||
'quantity': entry.stat_holiday_hours,
|
||||
'rate': entry.hourly_rate,
|
||||
'amount': entry.stat_holiday_pay,
|
||||
'total': entry.stat_holiday_pay,
|
||||
})
|
||||
|
||||
# Employee Tax lines (negative deductions)
|
||||
if 'FIT' not in existing_codes and 'INCOMETAX' not in existing_codes and entry.income_tax > 0:
|
||||
rule = get_or_create_rule('FIT', 'Federal Income Tax', ded_cat, 100)
|
||||
if rule:
|
||||
Line.create({
|
||||
'slip_id': payslip.id,
|
||||
'salary_rule_id': rule.id,
|
||||
'name': 'Income Tax',
|
||||
'code': 'FIT',
|
||||
'category_id': ded_cat.id if ded_cat else False,
|
||||
'sequence': 100,
|
||||
'quantity': 1,
|
||||
'rate': 100,
|
||||
'amount': -entry.income_tax,
|
||||
'total': -entry.income_tax,
|
||||
})
|
||||
|
||||
if 'EI' not in existing_codes and 'EI_EMP' not in existing_codes and entry.employment_insurance > 0:
|
||||
rule = get_or_create_rule('EI_EMP', 'Employment Insurance', ded_cat, 110)
|
||||
if rule:
|
||||
Line.create({
|
||||
'slip_id': payslip.id,
|
||||
'salary_rule_id': rule.id,
|
||||
'name': 'Employment Insurance',
|
||||
'code': 'EI_EMP',
|
||||
'category_id': ded_cat.id if ded_cat else False,
|
||||
'sequence': 110,
|
||||
'quantity': 1,
|
||||
'rate': 100,
|
||||
'amount': -entry.employment_insurance,
|
||||
'total': -entry.employment_insurance,
|
||||
})
|
||||
|
||||
if 'CPP' not in existing_codes and 'CPP_EMP' not in existing_codes and entry.cpp > 0:
|
||||
rule = get_or_create_rule('CPP_EMP', 'Canada Pension Plan', ded_cat, 120)
|
||||
if rule:
|
||||
Line.create({
|
||||
'slip_id': payslip.id,
|
||||
'salary_rule_id': rule.id,
|
||||
'name': 'Canada Pension Plan',
|
||||
'code': 'CPP_EMP',
|
||||
'category_id': ded_cat.id if ded_cat else False,
|
||||
'sequence': 120,
|
||||
'quantity': 1,
|
||||
'rate': 100,
|
||||
'amount': -entry.cpp,
|
||||
'total': -entry.cpp,
|
||||
})
|
||||
|
||||
if 'CPP2' not in existing_codes and 'CPP2_EMP' not in existing_codes and entry.cpp2 > 0:
|
||||
rule = get_or_create_rule('CPP2_EMP', 'Second CPP', ded_cat, 125)
|
||||
if rule:
|
||||
Line.create({
|
||||
'slip_id': payslip.id,
|
||||
'salary_rule_id': rule.id,
|
||||
'name': 'Second Canada Pension Plan',
|
||||
'code': 'CPP2_EMP',
|
||||
'category_id': ded_cat.id if ded_cat else False,
|
||||
'sequence': 125,
|
||||
'quantity': 1,
|
||||
'rate': 100,
|
||||
'amount': -entry.cpp2,
|
||||
'total': -entry.cpp2,
|
||||
})
|
||||
|
||||
def _create_cheques_for_batch(self, payslip_run):
|
||||
"""Create cheque records for employees paid by cheque."""
|
||||
cheques = self.env['payroll.cheque']
|
||||
|
||||
for payslip in payslip_run.slip_ids:
|
||||
# Check if employee's payment method is cheque
|
||||
if hasattr(payslip.employee_id, 'payment_method') and \
|
||||
payslip.employee_id.payment_method == 'cheque':
|
||||
cheque = self.env['payroll.cheque'].create_from_payslip(payslip)
|
||||
if cheque:
|
||||
cheques |= cheque
|
||||
|
||||
return cheques
|
||||
|
||||
def _action_payroll_complete_with_cheques(self, payslip_run, cheques):
|
||||
"""Show completion wizard with option to print cheques."""
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Payroll Complete'),
|
||||
'res_model': 'payroll.cheque.print.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_payslip_run_id': payslip_run.id,
|
||||
'default_cheque_ids': [(6, 0, cheques.ids)],
|
||||
'default_cheque_count': len(cheques),
|
||||
},
|
||||
}
|
||||
|
||||
def action_print_cheques(self):
|
||||
"""Print all cheques for the current batch."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.payslip_run_id:
|
||||
raise UserError(_("No payslips have been generated yet."))
|
||||
|
||||
cheques = self.env['payroll.cheque'].search([
|
||||
('payslip_run_id', '=', self.payslip_run_id.id),
|
||||
])
|
||||
|
||||
if not cheques:
|
||||
raise UserError(_("No cheques found for this payroll batch."))
|
||||
|
||||
# Assign numbers and mark as printed
|
||||
for cheque in cheques:
|
||||
cheque.action_assign_number()
|
||||
|
||||
return self.env.ref('fusion_payroll.action_report_payroll_cheque').report_action(cheques)
|
||||
|
||||
def action_view_payslips(self):
|
||||
"""View generated payslips."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.payslip_run_id:
|
||||
raise UserError(_("No payslips have been generated yet."))
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Payslip Batch'),
|
||||
'res_model': 'hr.payslip.run',
|
||||
'res_id': self.payslip_run_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_confirm_payslips(self):
|
||||
"""Confirm all generated payslips."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.payslip_run_id:
|
||||
raise UserError(_("No payslips have been generated yet."))
|
||||
|
||||
# Confirm all payslips in the batch
|
||||
for payslip in self.payslip_run_id.slip_ids:
|
||||
if payslip.state == 'draft':
|
||||
payslip.action_payslip_done()
|
||||
|
||||
# Update pay period status
|
||||
if self.pay_period_id:
|
||||
self.pay_period_id.write({'state': 'paid'})
|
||||
|
||||
return self.action_view_payslips()
|
||||
|
||||
@api.model
|
||||
def action_open_run_payroll(self):
|
||||
"""Open the Run Payroll wizard with pre-loaded data."""
|
||||
# Check/create pay period settings
|
||||
settings = self.env['payroll.pay.period.settings'].get_or_create_settings()
|
||||
|
||||
# Auto-generate periods for past 6 months and future 6 months
|
||||
self.env['payroll.pay.period'].auto_generate_periods_if_needed(
|
||||
company_id=self.env.company.id,
|
||||
schedule_type=settings.schedule_type,
|
||||
)
|
||||
|
||||
# Find the current pay period (today falls within it)
|
||||
today = fields.Date.context_today(self)
|
||||
current_period = self.env['payroll.pay.period'].search([
|
||||
('company_id', '=', self.env.company.id),
|
||||
('schedule_type', '=', settings.schedule_type),
|
||||
('date_start', '<=', today),
|
||||
('date_end', '>=', today),
|
||||
], limit=1)
|
||||
|
||||
# If no current period, find the next upcoming one
|
||||
if not current_period:
|
||||
current_period = self.env['payroll.pay.period'].search([
|
||||
('company_id', '=', self.env.company.id),
|
||||
('schedule_type', '=', settings.schedule_type),
|
||||
('date_start', '>', today),
|
||||
], order='date_start asc', limit=1)
|
||||
|
||||
# Create wizard with default period
|
||||
wizard_vals = {
|
||||
'company_id': self.env.company.id,
|
||||
'pay_schedule': settings.schedule_type,
|
||||
}
|
||||
if current_period:
|
||||
wizard_vals['pay_period_id'] = current_period.id
|
||||
wizard_vals['date_start'] = current_period.date_start
|
||||
wizard_vals['date_end'] = current_period.date_end
|
||||
wizard_vals['pay_date'] = current_period.pay_date
|
||||
|
||||
wizard = self.create(wizard_vals)
|
||||
wizard._load_employees()
|
||||
|
||||
schedule_labels = {
|
||||
'weekly': 'Weekly',
|
||||
'biweekly': 'Bi-Weekly',
|
||||
'semi_monthly': 'Semi-Monthly',
|
||||
'monthly': 'Monthly',
|
||||
}
|
||||
schedule = schedule_labels.get(settings.schedule_type, 'Bi-Weekly')
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Run Payroll: %s') % schedule,
|
||||
'res_model': 'run.payroll.wizard',
|
||||
'res_id': wizard.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'context': {'form_view_ref': 'fusion_payroll.run_payroll_wizard_view_form'},
|
||||
}
|
||||
Reference in New Issue
Block a user