Files
2026-02-22 01:22:18 -05:00

529 lines
17 KiB
Python

# -*- 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',
)