469 lines
16 KiB
Python
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 '',
|
|
},
|
|
}
|