Files
Odoo-Modules/fusion_payroll/models/hr_employee.py
2026-02-22 01:22:18 -05:00

469 lines
16 KiB
Python

# -*- coding: utf-8 -*-
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class HrEmployee(models.Model):
_inherit = 'hr.employee'
# === Canadian Provinces/Territories ===
PROVINCES = [
('AB', 'Alberta'),
('BC', 'British Columbia'),
('MB', 'Manitoba'),
('NB', 'New Brunswick'),
('NL', 'Newfoundland and Labrador'),
('NS', 'Nova Scotia'),
('NT', 'Northwest Territories'),
('NU', 'Nunavut'),
('ON', 'Ontario'),
('PE', 'Prince Edward Island'),
('QC', 'Quebec'),
('SK', 'Saskatchewan'),
('YT', 'Yukon'),
]
# === Work Locations ===
work_location_ids = fields.Many2many(
'payroll.work.location',
'payroll_work_location_employee_rel',
'employee_id',
'location_id',
string='Work Locations',
help='Work locations where this employee works',
)
# === ROE (Record of Employment) Reason Codes ===
# These are official Service Canada codes for EI claims
ROE_REASON_CODES = [
# 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'),
]
EMPLOYEE_STATUS = [
('active', 'Active'),
('on_leave', 'On Leave'),
('terminated', 'Terminated'),
]
PAY_SCHEDULE = [
('weekly', 'Weekly'),
('biweekly', 'Bi-Weekly'),
('semi_monthly', 'Semi-Monthly'),
('monthly', 'Monthly'),
]
# === Employment Status Fields ===
employment_status = fields.Selection(
selection=EMPLOYEE_STATUS,
string='Employment Status',
default='active',
tracking=True,
)
hire_date = fields.Date(
string='Hire Date',
tracking=True,
)
last_day_of_work = fields.Date(
string='Last Day of Work',
help='Required when employee status is Terminated',
tracking=True,
)
roe_reason_code = fields.Selection(
selection=ROE_REASON_CODES,
string='Reason for Status Change',
help='Record of Employment (ROE) reason code for Service Canada',
tracking=True,
)
show_in_employee_lists_only = fields.Boolean(
string='Show in Employee Lists Only',
help='If checked, terminated employee will still appear in employee lists',
)
# === Pay Schedule ===
pay_schedule = fields.Selection(
selection=PAY_SCHEDULE,
string='Pay Schedule',
default='biweekly',
)
# === Employee Identification ===
employee_number = fields.Char(
string='Employee ID',
copy=False,
)
sin_number = fields.Char(
string='Social Insurance Number',
groups='hr.group_hr_user,account.group_account_manager',
copy=False,
help='9-digit Social Insurance Number (SIN)',
)
sin_number_display = fields.Char(
string='SIN (Masked)',
compute='_compute_sin_display',
help='Masked SIN showing only last 3 digits',
)
# === Base Pay ===
pay_type = fields.Selection([
('hourly', 'Hourly'),
('salary', 'Salary'),
('commission', 'Commission only'),
], string='Pay Type', default='hourly')
hourly_rate = fields.Monetary(
string='Rate per Hour',
currency_field='currency_id',
)
salary_amount = fields.Monetary(
string='Salary Amount',
currency_field='currency_id',
help='Monthly or annual salary amount',
)
default_hours_per_day = fields.Float(
string='Hours per Day',
default=8.0,
)
default_days_per_week = fields.Float(
string='Days per Week',
default=5.0,
)
default_hours_per_week = fields.Float(
string='Default Hours/Week',
compute='_compute_default_hours_week',
store=True,
)
base_pay_effective_date = fields.Date(
string='Base Pay Effective Date',
help='Date when current base pay became effective',
)
currency_id = fields.Many2one(
'res.currency',
string='Currency',
default=lambda self: self.env.company.currency_id,
)
# === Vacation Policy ===
vacation_policy = fields.Selection([
('percent_4', '4.00% Paid out each pay period'),
('percent_6', '6.00% Paid out each pay period'),
('hours_accrued', '0.04 hours/hour worked'),
('annual_80', '80 hours/year (accrued each pay period)'),
('no_track', "Don't track vacation"),
], string='Vacation Policy', default='percent_4')
vacation_rate = fields.Float(
string='Vacation Rate %',
default=4.0,
help='Percentage of vacationable earnings',
)
# === Personal Address ===
home_street = fields.Char(
string='Street Address',
)
home_street2 = fields.Char(
string='Address Line 2',
)
home_city = fields.Char(
string='City',
)
home_province = fields.Selection(
selection=PROVINCES,
string='Province',
default='ON',
)
home_postal_code = fields.Char(
string='Postal Code',
help='Format: A1A 1A1',
)
home_country = fields.Char(
string='Country',
default='Canada',
)
mailing_same_as_home = fields.Boolean(
string='Mailing Address Same as Home',
default=True,
)
# === Mailing Address (if different) ===
mailing_street = fields.Char(string='Mailing Street')
mailing_street2 = fields.Char(string='Mailing Address Line 2')
mailing_city = fields.Char(string='Mailing City')
mailing_province = fields.Selection(selection=PROVINCES, string='Mailing Province')
mailing_postal_code = fields.Char(string='Mailing Postal Code')
# === Personal Info ===
preferred_name = fields.Char(
string='Preferred First Name',
help='Name the employee prefers to be called',
)
gender_identity = fields.Selection([
('male', 'Male'),
('female', 'Female'),
('other', 'Other'),
('prefer_not', 'Prefer not to say'),
], string='Gender')
# === Communication Preference (for ROE) ===
communication_language = fields.Selection([
('E', 'English'),
('F', 'French'),
], string='Preferred Language', default='E',
help='Communication preference for government forms')
# === Billing/Rate ===
billing_rate = fields.Float(
string='Billing Rate (per hour)',
help='Hourly billing rate for this employee',
)
billable_by_default = fields.Boolean(
string='Billable by Default',
)
# === Emergency Contact ===
emergency_first_name = fields.Char(string='Emergency Contact First Name')
emergency_last_name = fields.Char(string='Emergency Contact Last Name')
emergency_relationship = fields.Char(string='Relationship')
emergency_phone = fields.Char(string='Emergency Phone')
emergency_email = fields.Char(string='Emergency Email')
# === Payment Method ===
payment_method = fields.Selection([
('cheque', 'Cheque'),
('direct_deposit', 'Direct Deposit'),
], string='Payment Method', default='direct_deposit',
help='How employee receives their pay')
# === Tax Withholdings ===
federal_td1_amount = fields.Float(
string='Federal TD1 Amount',
default=16129.00, # 2025 Basic Personal Amount
help='Federal personal tax credit claim amount from TD1 form',
)
federal_additional_tax = fields.Float(
string='Additional Federal Tax',
help='Additional income tax to be deducted from each pay',
)
provincial_claim_amount = fields.Float(
string='Provincial Claim Amount',
default=12399.00, # 2025 Ontario BPA
help='Provincial personal tax credit claim amount from TD1 form',
)
# === Tax Exemptions ===
exempt_cpp = fields.Boolean(
string='Exempt from CPP',
help='Check if employee is exempt from Canada Pension Plan contributions',
)
exempt_ei = fields.Boolean(
string='Exempt from EI',
help='Check if employee is exempt from Employment Insurance premiums',
)
exempt_federal_tax = fields.Boolean(
string='Exempt from Federal Tax',
help='Check if employee is exempt from Federal income tax',
)
# === T4 Dental Benefits Code (CRA requirement 2023+) ===
t4_dental_code = fields.Selection([
('1', 'Code 1 - No dental benefits'),
('2', 'Code 2 - Payee only'),
('3', 'Code 3 - Payee, spouse, and dependents'),
('4', 'Code 4 - Payee and spouse'),
('5', 'Code 5 - Payee and dependents'),
], string='T4 Dental Benefits Code',
help='Required for T4 reporting - indicates access to employer dental benefits',
)
# === ROE Tracking Fields ===
roe_issued = fields.Boolean(
string='ROE Issued',
help='Check when Record of Employment has been issued',
)
roe_issued_date = fields.Date(
string='ROE Issue Date',
)
roe_serial_number = fields.Char(
string='ROE Serial Number',
)
@api.depends('sin_number')
def _compute_sin_display(self):
"""Show only last 3 digits of SIN for non-privileged users"""
for employee in self:
if employee.sin_number:
sin = employee.sin_number.replace('-', '').replace(' ', '')
if len(sin) >= 3:
employee.sin_number_display = f"XXX-XXX-{sin[-3:]}"
else:
employee.sin_number_display = "XXX-XXX-XXX"
else:
employee.sin_number_display = ""
@api.depends('default_hours_per_day', 'default_days_per_week')
def _compute_default_hours_week(self):
for employee in self:
employee.default_hours_per_week = employee.default_hours_per_day * employee.default_days_per_week
@api.depends('home_street', 'home_city', 'home_province', 'home_postal_code')
def _compute_full_address(self):
for employee in self:
parts = []
if employee.home_street:
parts.append(employee.home_street)
if employee.home_street2:
parts.append(employee.home_street2)
city_prov = []
if employee.home_city:
city_prov.append(employee.home_city)
if employee.home_province:
city_prov.append(employee.home_province)
if city_prov:
parts.append(', '.join(city_prov))
if employee.home_postal_code:
parts.append(employee.home_postal_code)
employee.full_home_address = '\n'.join(parts) if parts else ''
full_home_address = fields.Text(
string='Full Address',
compute='_compute_full_address',
store=False,
)
@api.constrains('sin_number')
def _check_sin_number(self):
"""Validate SIN format (9 digits)"""
for employee in self:
if employee.sin_number:
# Remove any formatting (dashes, spaces)
sin = employee.sin_number.replace('-', '').replace(' ', '')
if not sin.isdigit() or len(sin) != 9:
raise ValidationError(
'Social Insurance Number must be exactly 9 digits.'
)
@api.constrains('home_postal_code')
def _check_postal_code(self):
"""Validate Canadian postal code format"""
import re
for employee in self:
if employee.home_postal_code:
# Canadian postal code: A1A 1A1 or A1A1A1
pattern = r'^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$'
if not re.match(pattern, employee.home_postal_code):
raise ValidationError(
'Invalid postal code format. Use format: A1A 1A1'
)
@api.constrains('employment_status', 'last_day_of_work', 'roe_reason_code')
def _check_termination_fields(self):
for employee in self:
if employee.employment_status == 'terminated':
if not employee.last_day_of_work:
raise ValidationError(
'Last Day of Work is required when terminating an employee.'
)
if not employee.roe_reason_code:
raise ValidationError(
'Reason for Status Change (ROE Code) is required when terminating an employee.'
)
@api.onchange('employment_status')
def _onchange_employment_status(self):
"""Clear termination fields when status changes back to active"""
if self.employment_status == 'active':
self.last_day_of_work = False
self.roe_reason_code = False
self.show_in_employee_lists_only = False
elif self.employment_status == 'terminated':
# Set archive flag based on preference
if not self.show_in_employee_lists_only:
self.active = False
def action_terminate_employee(self):
"""Open wizard to terminate employee with proper ROE handling"""
return {
'name': 'Terminate Employee',
'type': 'ir.actions.act_window',
'res_model': 'hr.employee.terminate.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_employee_id': self.id,
'default_last_day_of_work': fields.Date.today(),
},
}
def action_issue_roe(self):
"""Mark ROE as issued and record date"""
self.ensure_one()
self.write({
'roe_issued': True,
'roe_issued_date': fields.Date.today(),
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'ROE Issued',
'message': f'Record of Employment marked as issued for {self.name}',
'type': 'success',
}
}
def action_view_sin(self):
"""Open wizard to view/edit SIN - restricted to admin/accountant"""
self.ensure_one()
return {
'name': 'View/Edit Social Insurance Number',
'type': 'ir.actions.act_window',
'res_model': 'hr.employee.sin.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_employee_id': self.id,
'default_sin_number': self.sin_number or '',
},
}