Initial commit
This commit is contained in:
528
fusion_payroll/models/hr_roe.py
Normal file
528
fusion_payroll/models/hr_roe.py
Normal file
@@ -0,0 +1,528 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import base64
|
||||
from datetime import date, timedelta
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class HrROE(models.Model):
|
||||
_name = 'hr.roe'
|
||||
_description = 'Record of Employment'
|
||||
_order = 'create_date desc'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
# === ROE Reason Codes (Service Canada) ===
|
||||
ROE_REASON_CODES = [
|
||||
('A', 'A - Shortage of work'),
|
||||
('B', 'B - Strike or lockout'),
|
||||
('D', 'D - Illness or injury'),
|
||||
('E', 'E - Quit'),
|
||||
('F', 'F - Maternity'),
|
||||
('G', 'G - Retirement'),
|
||||
('H', 'H - Work-Sharing'),
|
||||
('J', 'J - Apprentice training'),
|
||||
('K', 'K - Other'),
|
||||
('M', 'M - Dismissal'),
|
||||
('N', 'N - Leave of absence'),
|
||||
('P', 'P - Parental'),
|
||||
('Z', 'Z - Compassionate Care/Family Caregiver'),
|
||||
]
|
||||
|
||||
PAY_PERIOD_TYPES = [
|
||||
('W', 'Weekly'),
|
||||
('B', 'Bi-Weekly'),
|
||||
('S', 'Semi-Monthly'),
|
||||
('M', 'Monthly'),
|
||||
]
|
||||
|
||||
STATE_SELECTION = [
|
||||
('draft', 'Draft'),
|
||||
('ready', 'Ready to Submit'),
|
||||
('submitted', 'Submitted'),
|
||||
('archived', 'Archived'),
|
||||
]
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
default=lambda self: self.env['ir.sequence'].next_by_code('hr.roe') or 'New',
|
||||
)
|
||||
state = fields.Selection(
|
||||
selection=STATE_SELECTION,
|
||||
string='Status',
|
||||
default='draft',
|
||||
tracking=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# === Box 5: CRA Business Number ===
|
||||
cra_business_number = fields.Char(
|
||||
string='CRA Business Number (BN)',
|
||||
compute='_compute_cra_business_number',
|
||||
readonly=True,
|
||||
help='15-character format: 123456789RP0001',
|
||||
)
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_cra_business_number(self):
|
||||
"""Get CRA business number from payroll settings."""
|
||||
for roe in self:
|
||||
if roe.company_id:
|
||||
settings = self.env['payroll.config.settings'].get_settings(roe.company_id.id)
|
||||
roe.cra_business_number = settings.get_cra_payroll_account_number() or roe.company_id.vat or ''
|
||||
else:
|
||||
roe.cra_business_number = ''
|
||||
|
||||
# === Box 6: Pay Period Type ===
|
||||
pay_period_type = fields.Selection(
|
||||
selection=PAY_PERIOD_TYPES,
|
||||
string='Pay Period Type',
|
||||
compute='_compute_pay_period_type',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# === Box 8: Social Insurance Number ===
|
||||
sin_number = fields.Char(
|
||||
string='Social Insurance Number',
|
||||
related='employee_id.sin_number',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# === Box 10: First Day Worked ===
|
||||
first_day_worked = fields.Date(
|
||||
string='First Day Worked',
|
||||
related='employee_id.hire_date',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# === Box 11: Last Day for Which Paid ===
|
||||
last_day_paid = fields.Date(
|
||||
string='Last Day for Which Paid',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# === Box 12: Final Pay Period Ending Date ===
|
||||
final_pay_period_end = fields.Date(
|
||||
string='Final Pay Period Ending Date',
|
||||
required=True,
|
||||
)
|
||||
|
||||
# === Box 13: Occupation ===
|
||||
occupation = fields.Char(
|
||||
string='Occupation',
|
||||
related='employee_id.job_title',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# === Box 14: Expected Date of Recall ===
|
||||
expected_recall_date = fields.Date(
|
||||
string='Expected Date of Recall',
|
||||
help='If temporary layoff, when employee is expected to return',
|
||||
)
|
||||
|
||||
# === Box 15A: Total Insurable Hours ===
|
||||
total_insurable_hours = fields.Float(
|
||||
string='Total Insurable Hours',
|
||||
digits=(10, 2),
|
||||
help='Total hours worked during the insurable period',
|
||||
)
|
||||
|
||||
# === Box 15B: Total Insurable Earnings ===
|
||||
total_insurable_earnings = fields.Float(
|
||||
string='Total Insurable Earnings',
|
||||
digits=(10, 2),
|
||||
help='Total earnings during the insurable period',
|
||||
)
|
||||
|
||||
# === Box 15C: Insurable Earnings by Pay Period ===
|
||||
pay_period_earnings_ids = fields.One2many(
|
||||
'hr.roe.pay.period',
|
||||
'roe_id',
|
||||
string='Pay Period Earnings',
|
||||
)
|
||||
|
||||
# === Box 16: Reason for Issuing ROE ===
|
||||
reason_code = fields.Selection(
|
||||
selection=ROE_REASON_CODES,
|
||||
string='Reason for Issuing ROE',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# === Box 17: Other Payments ===
|
||||
other_payments = fields.Text(
|
||||
string='Other Payments/Benefits',
|
||||
help='Other than regular pay, paid or payable at a later date',
|
||||
)
|
||||
|
||||
# === Box 18: Comments ===
|
||||
comments = fields.Text(
|
||||
string='Comments',
|
||||
)
|
||||
|
||||
# === Box 20: Communication Preference ===
|
||||
communication_language = fields.Selection([
|
||||
('E', 'English'),
|
||||
('F', 'French'),
|
||||
], string='Communication Preference', default='E')
|
||||
|
||||
# === Contact Information ===
|
||||
contact_name = fields.Char(
|
||||
string='Contact Person',
|
||||
default=lambda self: self.env.user.name,
|
||||
)
|
||||
contact_phone = fields.Char(
|
||||
string='Contact Phone',
|
||||
)
|
||||
|
||||
# === File Attachments ===
|
||||
blk_file = fields.Binary(
|
||||
string='BLK File',
|
||||
attachment=True,
|
||||
)
|
||||
blk_filename = fields.Char(
|
||||
string='BLK Filename',
|
||||
)
|
||||
pdf_file = fields.Binary(
|
||||
string='PDF File',
|
||||
attachment=True,
|
||||
)
|
||||
pdf_filename = fields.Char(
|
||||
string='PDF Filename',
|
||||
)
|
||||
|
||||
# === Submission Tracking ===
|
||||
submission_date = fields.Date(
|
||||
string='Submission Date',
|
||||
tracking=True,
|
||||
)
|
||||
submission_deadline = fields.Date(
|
||||
string='Submission Deadline',
|
||||
compute='_compute_submission_deadline',
|
||||
store=True,
|
||||
)
|
||||
service_canada_serial = fields.Char(
|
||||
string='Service Canada Serial Number',
|
||||
help='Serial number assigned after submission',
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'employee_id.pay_schedule')
|
||||
def _compute_pay_period_type(self):
|
||||
mapping = {
|
||||
'weekly': 'W',
|
||||
'biweekly': 'B',
|
||||
'semi_monthly': 'S',
|
||||
'monthly': 'M',
|
||||
}
|
||||
for roe in self:
|
||||
schedule = roe.employee_id.pay_schedule if roe.employee_id else 'biweekly'
|
||||
roe.pay_period_type = mapping.get(schedule, 'B')
|
||||
|
||||
@api.depends('last_day_paid')
|
||||
def _compute_submission_deadline(self):
|
||||
for roe in self:
|
||||
if roe.last_day_paid:
|
||||
# ROE must be submitted within 5 calendar days
|
||||
roe.submission_deadline = roe.last_day_paid + timedelta(days=5)
|
||||
else:
|
||||
roe.submission_deadline = False
|
||||
|
||||
def action_calculate_earnings(self):
|
||||
"""Calculate insurable earnings from payslips with proper period allocation"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.employee_id:
|
||||
raise UserError('Please select an employee first.')
|
||||
|
||||
# Find all payslips for this employee in the last year
|
||||
year_ago = self.last_day_paid - timedelta(days=365) if self.last_day_paid else date.today() - timedelta(days=365)
|
||||
|
||||
payslips = self.env['hr.payslip'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('state', '=', 'done'),
|
||||
('date_from', '>=', year_ago),
|
||||
('date_to', '<=', self.last_day_paid or date.today()),
|
||||
], order='date_from asc', limit=53) # Max 53 pay periods, order ascending for period allocation
|
||||
|
||||
if not payslips:
|
||||
raise UserError('No payslips found for this employee in the specified period.')
|
||||
|
||||
Payslip = self.env['hr.payslip']
|
||||
|
||||
# Track earnings by period
|
||||
# Key: period index (0-based), Value: total insurable earnings for that period
|
||||
period_earnings = {}
|
||||
total_hours = 0
|
||||
total_earnings = 0
|
||||
|
||||
# Process each payslip
|
||||
for idx, payslip in enumerate(payslips):
|
||||
# Get worked hours for this payslip
|
||||
worked_days = payslip.worked_days_line_ids
|
||||
hours = sum(wd.number_of_hours for wd in worked_days) if worked_days else 0
|
||||
total_hours += hours
|
||||
|
||||
# Break down earnings by pay type
|
||||
period_earnings_for_which = 0 # Earnings for this period (work period)
|
||||
period_earnings_in_which = 0 # Earnings for next period (pay date)
|
||||
|
||||
for line in payslip.line_ids:
|
||||
code = line.code or ''
|
||||
category_code = line.category_id.code if line.category_id else None
|
||||
amount = abs(line.total or 0)
|
||||
|
||||
# Use pay type helpers
|
||||
pay_type = Payslip._get_pay_type_from_code(code, category_code)
|
||||
is_reimbursement = Payslip._is_reimbursement(code, category_code)
|
||||
|
||||
# Skip reimbursements - they are non-insurable
|
||||
if is_reimbursement:
|
||||
continue
|
||||
|
||||
# Skip union dues - they are deductions, not earnings
|
||||
if pay_type == 'union_dues':
|
||||
continue
|
||||
|
||||
# "For which period" allocation (work period)
|
||||
# Salary, Hourly, Overtime, Stat Holiday, Commission
|
||||
if pay_type in ['salary', 'hourly', 'overtime', 'stat_holiday', 'commission', 'other']:
|
||||
period_earnings_for_which += amount
|
||||
|
||||
# "In which period" allocation (pay date)
|
||||
# Bonus, Allowance, Vacation (paid as %)
|
||||
elif pay_type in ['bonus', 'allowance']:
|
||||
period_earnings_in_which += amount
|
||||
|
||||
# Allocate earnings to periods
|
||||
# "For which" earnings go to current period (idx)
|
||||
if idx not in period_earnings:
|
||||
period_earnings[idx] = 0
|
||||
period_earnings[idx] += period_earnings_for_which
|
||||
|
||||
# "In which" earnings go to next period (idx + 1)
|
||||
# If it's the last payslip, allocate to current period
|
||||
next_period_idx = idx + 1 if idx < len(payslips) - 1 else idx
|
||||
if next_period_idx not in period_earnings:
|
||||
period_earnings[next_period_idx] = 0
|
||||
period_earnings[next_period_idx] += period_earnings_in_which
|
||||
|
||||
# Add to total (both types are insurable)
|
||||
total_earnings += period_earnings_for_which + period_earnings_in_which
|
||||
|
||||
# Clear existing pay period lines
|
||||
self.pay_period_earnings_ids.unlink()
|
||||
|
||||
# Create pay period lines (ROE uses reverse order - most recent first)
|
||||
pay_period_data = []
|
||||
for period_idx in sorted(period_earnings.keys(), reverse=True):
|
||||
if period_idx < len(payslips):
|
||||
payslip = payslips[period_idx]
|
||||
earnings = period_earnings[period_idx]
|
||||
|
||||
# ROE sequence numbers start from 1, most recent period is sequence 1
|
||||
sequence = len(payslips) - period_idx
|
||||
|
||||
pay_period_data.append({
|
||||
'roe_id': self.id,
|
||||
'sequence': sequence,
|
||||
'amount': earnings,
|
||||
'payslip_id': payslip.id,
|
||||
})
|
||||
|
||||
# Create new pay period lines
|
||||
if pay_period_data:
|
||||
self.env['hr.roe.pay.period'].create(pay_period_data)
|
||||
|
||||
self.write({
|
||||
'total_insurable_hours': total_hours,
|
||||
'total_insurable_earnings': total_earnings,
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Earnings Calculated',
|
||||
'message': f'Found {len(payslips)} pay periods. Total: ${total_earnings:,.2f}',
|
||||
'type': 'success',
|
||||
}
|
||||
}
|
||||
|
||||
def action_generate_blk(self):
|
||||
"""Generate BLK file for ROE Web submission"""
|
||||
self.ensure_one()
|
||||
|
||||
blk_content = self._generate_blk_xml()
|
||||
|
||||
# Encode to base64
|
||||
blk_data = base64.b64encode(blk_content.encode('utf-8'))
|
||||
|
||||
# Generate filename
|
||||
employee_name = self.employee_id.name.replace(' ', '_')
|
||||
today = date.today().strftime('%Y-%m-%d')
|
||||
filename = f'ROEForm_{employee_name}_{today}.blk'
|
||||
|
||||
self.write({
|
||||
'blk_file': blk_data,
|
||||
'blk_filename': filename,
|
||||
'state': 'ready',
|
||||
})
|
||||
|
||||
# Post the file to chatter as attachment
|
||||
attachment = self.env['ir.attachment'].create({
|
||||
'name': filename,
|
||||
'type': 'binary',
|
||||
'datas': blk_data,
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'mimetype': 'application/xml',
|
||||
})
|
||||
|
||||
# Post message with attachment
|
||||
self.message_post(
|
||||
body=f'BLK file generated: <strong>{filename}</strong><br/>Ready for submission to Service Canada ROE Web.',
|
||||
attachment_ids=[attachment.id],
|
||||
message_type='notification',
|
||||
)
|
||||
|
||||
# Return download action
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'/web/content/{attachment.id}?download=true',
|
||||
'target': 'self',
|
||||
}
|
||||
|
||||
def _generate_blk_xml(self):
|
||||
"""Generate the XML content for BLK file in CRA-compliant format"""
|
||||
self.ensure_one()
|
||||
|
||||
# Format SIN (remove dashes/spaces)
|
||||
sin = (self.sin_number or '').replace('-', '').replace(' ', '')
|
||||
|
||||
# Employee address
|
||||
emp = self.employee_id
|
||||
|
||||
# Build pay period earnings XML with proper indentation
|
||||
pp_lines = []
|
||||
for pp in self.pay_period_earnings_ids:
|
||||
pp_lines.append(f''' <PP nbr="{pp.sequence}">
|
||||
<AMT>{pp.amount:.2f}</AMT>
|
||||
</PP>''')
|
||||
pp_xml = '\n'.join(pp_lines)
|
||||
|
||||
# Contact phone parts
|
||||
phone = (self.contact_phone or '').replace('-', '').replace(' ', '').replace('(', '').replace(')', '')
|
||||
area_code = phone[:3] if len(phone) >= 10 else ''
|
||||
phone_number = phone[3:10] if len(phone) >= 10 else phone
|
||||
|
||||
# Get first name and last name
|
||||
name_parts = (emp.name or '').split() if emp.name else ['', '']
|
||||
first_name = name_parts[0] if name_parts else ''
|
||||
last_name = ' '.join(name_parts[1:]) if len(name_parts) > 1 else ''
|
||||
|
||||
# Contact name parts
|
||||
contact_parts = (self.contact_name or '').split() if self.contact_name else ['', '']
|
||||
contact_first = contact_parts[0] if contact_parts else ''
|
||||
contact_last = ' '.join(contact_parts[1:]) if len(contact_parts) > 1 else ''
|
||||
|
||||
# Build XML with proper CRA formatting
|
||||
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ROEHEADER FileVersion="W-2.0" ProductName="Fusion Payroll"
|
||||
SoftwareVendor="Nexa Systems Inc.">
|
||||
<ROE Issue="D" PrintingLanguage="{self.communication_language}">
|
||||
<B5>{self.cra_business_number or ''}</B5>
|
||||
<B6>{self.pay_period_type}</B6>
|
||||
<B8>{sin}</B8>
|
||||
<B9>
|
||||
<FN>{first_name}</FN>
|
||||
<LN>{last_name}</LN>
|
||||
<A1>{emp.home_street or ''}</A1>
|
||||
<A2>{emp.home_city or ''}</A2>
|
||||
<A3>{emp.home_province or 'ON'}, CA</A3>
|
||||
<PC>{(emp.home_postal_code or '').replace(' ', '')}</PC>
|
||||
</B9>
|
||||
<B10>{self.first_day_worked.strftime('%Y-%m-%d') if self.first_day_worked else ''}</B10>
|
||||
<B11>{self.last_day_paid.strftime('%Y-%m-%d') if self.last_day_paid else ''}</B11>
|
||||
<B12>{self.final_pay_period_end.strftime('%Y-%m-%d') if self.final_pay_period_end else ''}</B12>
|
||||
<B14>
|
||||
<CD>{'R' if self.expected_recall_date else 'U'}</CD>
|
||||
</B14>
|
||||
<B15A>{self.total_insurable_hours:.0f}</B15A>
|
||||
<B15C>
|
||||
{pp_xml}
|
||||
</B15C>
|
||||
<B16>
|
||||
<CD>{self.reason_code}</CD>
|
||||
<FN>{contact_first}</FN>
|
||||
<LN>{contact_last}</LN>
|
||||
<AC>{area_code}</AC>
|
||||
<TEL>{phone_number}</TEL>
|
||||
</B16>
|
||||
<B20>{self.communication_language}</B20>
|
||||
</ROE>
|
||||
</ROEHEADER>'''
|
||||
|
||||
return xml
|
||||
|
||||
def action_print_roe(self):
|
||||
"""Print ROE as PDF"""
|
||||
self.ensure_one()
|
||||
return self.env.ref('fusion_payroll.action_report_roe').report_action(self)
|
||||
|
||||
def action_mark_submitted(self):
|
||||
"""Mark ROE as submitted to Service Canada"""
|
||||
self.ensure_one()
|
||||
self.write({
|
||||
'state': 'submitted',
|
||||
'submission_date': date.today(),
|
||||
})
|
||||
|
||||
# Update employee ROE tracking
|
||||
self.employee_id.write({
|
||||
'roe_issued': True,
|
||||
'roe_issued_date': date.today(),
|
||||
})
|
||||
|
||||
def action_archive(self):
|
||||
"""Archive the ROE"""
|
||||
self.ensure_one()
|
||||
self.write({'state': 'archived'})
|
||||
|
||||
|
||||
class HrROEPayPeriod(models.Model):
|
||||
_name = 'hr.roe.pay.period'
|
||||
_description = 'ROE Pay Period Earnings'
|
||||
_order = 'sequence'
|
||||
|
||||
roe_id = fields.Many2one(
|
||||
'hr.roe',
|
||||
string='ROE',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Pay Period #',
|
||||
required=True,
|
||||
)
|
||||
amount = fields.Float(
|
||||
string='Insurable Earnings',
|
||||
digits=(10, 2),
|
||||
)
|
||||
payslip_id = fields.Many2one(
|
||||
'hr.payslip',
|
||||
string='Payslip',
|
||||
)
|
||||
Reference in New Issue
Block a user